Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cd6a1d36ff | |||
| 2342f54179 | |||
| 246b554dab | |||
| 7ee79292fa | |||
| 600e8eec6a | |||
| 3ec1978176 | |||
| 12ea853170 | |||
| 7e41c81d53 | |||
| 2d6c751fb2 | |||
| e0b8d5641e | |||
| faa96d1964 |
@@ -287,6 +287,11 @@ function UploadChip({ job }: { job: UploadJob }) {
|
|||||||
{isArmed("stop") && !stopping && (
|
{isArmed("stop") && !stopping && (
|
||||||
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
<p className="text-[11px] text-status-danger">{t("uploads.stopConfirm")}</p>
|
||||||
)}
|
)}
|
||||||
|
{job.waiting && !stopping && (
|
||||||
|
<p className="text-[11px] text-status-warning" role="status" data-testid="upload-waiting">
|
||||||
|
{t("uploads.waiting")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
<p className="truncate text-[11px] text-fg-muted" title={job.folder}>
|
||||||
{job.folder}
|
{job.folder}
|
||||||
{job.name ? ` · ${job.name}` : ""}
|
{job.name ? ` · ${job.name}` : ""}
|
||||||
|
|||||||
@@ -91,6 +91,7 @@ import {
|
|||||||
type FsPlace,
|
type FsPlace,
|
||||||
type GitSource,
|
type GitSource,
|
||||||
isHomePlace,
|
isHomePlace,
|
||||||
|
newUploadId,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { Lane } from "../../lib/types";
|
import type { Lane } from "../../lib/types";
|
||||||
import { useDialog } from "../../hooks/useDialog";
|
import { useDialog } from "../../hooks/useDialog";
|
||||||
@@ -529,7 +530,13 @@ export function FileBrowser({
|
|||||||
await fn();
|
await fn();
|
||||||
if (done) toast.success(done);
|
if (done) toast.success(done);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e));
|
toast.error(
|
||||||
|
errCode(e) === "EUPGRADE"
|
||||||
|
? t("fx.needsUpgrade")
|
||||||
|
: errCode(e) === "ESECRET"
|
||||||
|
? t("fx.secret")
|
||||||
|
: errText(e)
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
changed();
|
changed();
|
||||||
@@ -682,13 +689,21 @@ export function FileBrowser({
|
|||||||
// false once the server says a batch cannot be written here (an old helper).
|
// false once the server says a batch cannot be written here (an old helper).
|
||||||
let batchable = true;
|
let batchable = true;
|
||||||
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
const show = (name: string, extra = 0) => job.update({ name, done, sent: sent + extra });
|
||||||
|
const onWait = (waiting: boolean) => job.update({ waiting });
|
||||||
/** One file; false = the user cancelled. */
|
/** One file; false = the user cancelled. */
|
||||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||||
if (signal.aborted) return false;
|
if (signal.aborted) return false;
|
||||||
const dest = join(dir, rel);
|
const dest = join(dir, rel);
|
||||||
show(rel);
|
show(rel);
|
||||||
|
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
|
||||||
|
const upload = newUploadId();
|
||||||
const send = (c: FsConflict) =>
|
const send = (c: FsConflict) =>
|
||||||
api.files.upload(place, dest, file, c, (n) => show(rel, n), signal);
|
api.files.uploadStaged(place, dest, file, c, {
|
||||||
|
upload,
|
||||||
|
onProgress: (n) => show(rel, n),
|
||||||
|
onWait,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||||
if (errCode(e) !== "EEXISTS") throw e;
|
if (errCode(e) !== "EEXISTS") throw e;
|
||||||
@@ -701,6 +716,9 @@ export function FileBrowser({
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||||
|
} finally {
|
||||||
|
onWait(false);
|
||||||
|
void api.files.dropUpload(place, upload);
|
||||||
}
|
}
|
||||||
sent += file.size;
|
sent += file.size;
|
||||||
done++;
|
done++;
|
||||||
@@ -720,6 +738,7 @@ export function FileBrowser({
|
|||||||
return a.choice;
|
return a.choice;
|
||||||
};
|
};
|
||||||
if (signal.aborted) return "cancel";
|
if (signal.aborted) return "cancel";
|
||||||
|
const upload = newUploadId();
|
||||||
try {
|
try {
|
||||||
const c = await api.files.uploadCheck(
|
const c = await api.files.uploadCheck(
|
||||||
place,
|
place,
|
||||||
@@ -733,7 +752,12 @@ export function FileBrowser({
|
|||||||
let policy = await choose(c.taken);
|
let policy = await choose(c.taken);
|
||||||
if (!policy) return "cancel";
|
if (!policy) return "cancel";
|
||||||
const send = (p: FsConflict) =>
|
const send = (p: FsConflict) =>
|
||||||
api.files.uploadBatch(place, dir, b, p, (n) => show(label, n), signal);
|
api.files.uploadBatchStaged(place, dir, b, p, {
|
||||||
|
upload,
|
||||||
|
onProgress: (n) => show(label, n),
|
||||||
|
onWait,
|
||||||
|
signal,
|
||||||
|
});
|
||||||
const r = await send(policy).catch(async (e) => {
|
const r = await send(policy).catch(async (e) => {
|
||||||
// Taken meanwhile: ask about those, then once more.
|
// Taken meanwhile: ask about those, then once more.
|
||||||
if (errCode(e) !== "EEXISTS") throw e;
|
if (errCode(e) !== "EEXISTS") throw e;
|
||||||
@@ -752,6 +776,9 @@ export function FileBrowser({
|
|||||||
return "fallback";
|
return "fallback";
|
||||||
}
|
}
|
||||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||||
|
} finally {
|
||||||
|
onWait(false);
|
||||||
|
void api.files.dropUpload(place, upload);
|
||||||
}
|
}
|
||||||
sent += size;
|
sent += size;
|
||||||
done += b.length;
|
done += b.length;
|
||||||
@@ -1693,7 +1720,6 @@ export function FileBrowser({
|
|||||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ctx.menu && (
|
{ctx.menu && (
|
||||||
|
|||||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||||
source ? { lane, source } : session ? { lane, session } : lane,
|
source ? { lane, source } : session ? { lane, session } : lane,
|
||||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||||
|
newUploadId: (() => {
|
||||||
|
let n = 0;
|
||||||
|
return () => `upload-id-${String(++n).padStart(8, "0")}`;
|
||||||
|
})(),
|
||||||
api: {
|
api: {
|
||||||
lanes: {
|
lanes: {
|
||||||
list: vi.fn(),
|
list: vi.fn(),
|
||||||
@@ -52,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
allTrash: vi.fn(),
|
allTrash: vi.fn(),
|
||||||
restore: vi.fn(),
|
restore: vi.fn(),
|
||||||
purge: vi.fn(),
|
purge: vi.fn(),
|
||||||
upload: vi.fn(),
|
uploadStaged: vi.fn(),
|
||||||
uploadBatch: vi.fn(),
|
uploadBatchStaged: vi.fn(),
|
||||||
|
dropUpload: vi.fn(async () => null),
|
||||||
uploadCheck: vi.fn(),
|
uploadCheck: vi.fn(),
|
||||||
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
zipUrl: (t: number, ps: string[]) => `/api/lanes/${t}/fs/zip?p=${ps.join("&p=")}`,
|
||||||
},
|
},
|
||||||
@@ -236,7 +241,7 @@ describe("FileExplorer", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("asks about a taken name on upload, and sends the choice", async () => {
|
it("asks about a taken name on upload, and sends the choice", async () => {
|
||||||
vi.mocked(api.files.upload)
|
vi.mocked(api.files.uploadStaged)
|
||||||
.mockRejectedValueOnce(
|
.mockRejectedValueOnce(
|
||||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||||
)
|
)
|
||||||
@@ -249,20 +254,24 @@ describe("FileExplorer", () => {
|
|||||||
const ask = await screen.findByTestId("fx-conflict");
|
const ask = await screen.findByTestId("fx-conflict");
|
||||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
|
||||||
1,
|
1,
|
||||||
"a.txt",
|
"a.txt",
|
||||||
file,
|
file,
|
||||||
"keep",
|
"keep",
|
||||||
expect.any(Function),
|
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||||
expect.any(AbortSignal)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
// The retry names the SAME upload, so the staged bytes are not sent again,
|
||||||
|
// and the staged upload is dropped once answered.
|
||||||
|
const [first, second] = vi.mocked(api.files.uploadStaged).mock.calls.map((c) => c[4].upload);
|
||||||
|
expect(second).toBe(first);
|
||||||
|
await waitFor(() => expect(api.files.dropUpload).toHaveBeenCalledWith(1, first));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("sends many small files as one batch, asking once about every taken name", async () => {
|
it("sends many small files as one batch, asking once about every taken name", async () => {
|
||||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: ["a.txt", "b.txt"], batch: true });
|
||||||
vi.mocked(api.files.uploadBatch).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
vi.mocked(api.files.uploadBatchStaged).mockResolvedValue({ saved: 3, skipped: 0, paths: [] });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
@@ -271,33 +280,32 @@ describe("FileExplorer", () => {
|
|||||||
const ask = await screen.findByTestId("fx-conflict");
|
const ask = await screen.findByTestId("fx-conflict");
|
||||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
|
||||||
1,
|
1,
|
||||||
"",
|
"",
|
||||||
files.map((file) => ({ file, rel: file.name })),
|
files.map((file) => ({ file, rel: file.name })),
|
||||||
"replace",
|
"replace",
|
||||||
expect.any(Function),
|
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||||
expect.any(AbortSignal)
|
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
expect(api.files.uploadCheck).toHaveBeenCalledWith(1, "", ["a.txt", "b.txt", "c.txt"]);
|
||||||
expect(api.files.upload).not.toHaveBeenCalled();
|
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to one request per file when the server cannot take a batch", async () => {
|
it("falls back to one request per file when the server cannot take a batch", async () => {
|
||||||
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
vi.mocked(api.files.uploadCheck).mockResolvedValue({ taken: [], batch: false });
|
||||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
|
||||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
it("leaves a picked repo's .git out of a folder upload, with one notice", async () => {
|
||||||
vi.mocked(api.files.upload).mockResolvedValue({ path: "x" });
|
vi.mocked(api.files.uploadStaged).mockResolvedValue({ path: "x" });
|
||||||
open();
|
open();
|
||||||
await screen.findByText("a.txt");
|
await screen.findByText("a.txt");
|
||||||
const mk = (rel: string) => {
|
const mk = (rel: string) => {
|
||||||
@@ -307,8 +315,8 @@ describe("FileExplorer", () => {
|
|||||||
};
|
};
|
||||||
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
const files = ["repo/.git/HEAD", "repo/.git/objects/d4/ab", "repo/src/a.js"].map(mk);
|
||||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
|
||||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||||
expect(toast.error).not.toHaveBeenCalled();
|
expect(toast.error).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -320,7 +328,7 @@ describe("FileExplorer", () => {
|
|||||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||||
expect(api.files.upload).not.toHaveBeenCalled();
|
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("restores from the Trash", async () => {
|
it("restores from the Trash", async () => {
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ import {
|
|||||||
sameFilesHere,
|
sameFilesHere,
|
||||||
} from "../../lib/promptAttach";
|
} from "../../lib/promptAttach";
|
||||||
import { attachImeInput } from "../../lib/imeInput";
|
import { attachImeInput } from "../../lib/imeInput";
|
||||||
|
import { wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||||
import { usePermissions } from "../AuthGate";
|
import { usePermissions } from "../AuthGate";
|
||||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||||
@@ -524,9 +525,32 @@ export const TerminalView = memo(function TerminalView({
|
|||||||
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
// wheel report itself — psmux scrolls, or hands it to the pane's app.
|
||||||
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
// ponytail: at 1;1, which is all psmux reports to the app anyway.
|
||||||
let ownWheel = false;
|
let ownWheel = false;
|
||||||
|
// Tracked: the reports are this pane's own too — one per
|
||||||
|
// LINES_PER_REPORT rows of travel (lib/wheelReports.ts), where xterm
|
||||||
|
// sends one per event and damps a touchpad ×0.3. SGR, as tmux always
|
||||||
|
// asks for it; at the pointer's cell, with xterm's modifier bits.
|
||||||
|
const wheelState: WheelState = { partial: 0 };
|
||||||
|
const sendWheel = (ev: WheelEvent) => {
|
||||||
|
const screen = term.element?.querySelector(".xterm-screen");
|
||||||
|
const box = screen?.getBoundingClientRect();
|
||||||
|
if (!box || !box.height || !box.width) return;
|
||||||
|
const n = wheelReports(wheelState, ev, box.height / term.rows, term.rows);
|
||||||
|
const ws = wsRef.current;
|
||||||
|
if (!n || !ws || ws.readyState !== WebSocket.OPEN) return;
|
||||||
|
const clamp = (v: number, max: number) => Math.min(max, Math.max(1, v));
|
||||||
|
const col = clamp(Math.floor(((ev.clientX - box.left) / box.width) * term.cols) + 1, term.cols);
|
||||||
|
const row = clamp(Math.floor(((ev.clientY - box.top) / box.height) * term.rows) + 1, term.rows);
|
||||||
|
const button = (n < 0 ? 64 : 65) | (ev.altKey ? 8 : 0) | (ev.ctrlKey ? 16 : 0);
|
||||||
|
ws.send(`\x1b[<${button};${col};${row}M`.repeat(Math.abs(n)));
|
||||||
|
};
|
||||||
term.attachCustomWheelEventHandler((ev) => {
|
term.attachCustomWheelEventHandler((ev) => {
|
||||||
const mode = term.modes.mouseTrackingMode;
|
const mode = term.modes.mouseTrackingMode;
|
||||||
const tracked = mode !== "none" && mode !== "x10";
|
const tracked = mode !== "none" && mode !== "x10";
|
||||||
|
// Shift+wheel stays xterm's (it reports nothing for it).
|
||||||
|
if (tracked && !ev.shiftKey) {
|
||||||
|
sendWheel(ev);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (!tracked && ownWheel) {
|
if (!tracked && ownWheel) {
|
||||||
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
if (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
screen,
|
screen,
|
||||||
waitFor,
|
waitFor,
|
||||||
} from "@testing-library/react";
|
} from "@testing-library/react";
|
||||||
|
import { Terminal } from "@xterm/xterm";
|
||||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||||
@@ -326,13 +327,33 @@ describe("TerminalView", () => {
|
|||||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets the wheel through once the pane's program tracks the mouse", () => {
|
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||||
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
|
const screenBox = () => {
|
||||||
// report, so returning false here would kill scrolling entirely.
|
const term = vi.mocked(Terminal).mock.results[vi.mocked(Terminal).mock.results.length - 1]!.value as { element: HTMLElement };
|
||||||
|
const screen = document.createElement("div");
|
||||||
|
screen.className = "xterm-screen";
|
||||||
|
screen.getBoundingClientRect = () =>
|
||||||
|
({ left: 0, top: 0, width: termState.cols * 10, height: termState.rows * 10 }) as DOMRect;
|
||||||
|
term.element.appendChild(screen);
|
||||||
|
};
|
||||||
|
|
||||||
|
it("sends a tracked wheel itself, one report per LINES_PER_REPORT rows, at the pointer", () => {
|
||||||
|
// tmux with `mouse on` sets this. xterm would send ONE report per event and
|
||||||
|
// damp a touchpad ×0.3 — a swipe crawled — so the pane sends its own.
|
||||||
termState.modes.mouseTrackingMode = "any";
|
termState.modes.mouseTrackingMode = "any";
|
||||||
termState.buffer.active.type = "alternate";
|
termState.buffer.active.type = "alternate";
|
||||||
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
render(<TerminalView runId="agenthub-lane-1" wsBaseUrl="ws://localhost:4820" />);
|
||||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(true);
|
screenBox();
|
||||||
|
const ws = MockWebSocket.instances[MockWebSocket.instances.length - 1]!;
|
||||||
|
ws.sent.length = 0;
|
||||||
|
const wheel = wheelHandlerMock.mock.calls[0]![0]!;
|
||||||
|
// A notch of 6 rows: one report, at the cell under the pointer.
|
||||||
|
expect(wheel(new WheelEvent("wheel", { deltaY: -60, clientX: 45, clientY: 72 }))).toBe(false);
|
||||||
|
expect(ws.sent).toEqual(["\x1b[<64;5;8M"]);
|
||||||
|
// A touchpad: 4 steps of 25px = 10 rows = two reports, sent as they add up.
|
||||||
|
ws.sent.length = 0;
|
||||||
|
for (let i = 0; i < 4; i++) wheel(new WheelEvent("wheel", { deltaY: 25, clientX: 5, clientY: 5 }));
|
||||||
|
expect(ws.sent.join("")).toBe("\x1b[<65;1;1M".repeat(2));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
it("lets the wheel through on a normal buffer, where xterm scrolls scrollback", () => {
|
||||||
@@ -361,10 +382,11 @@ describe("TerminalView", () => {
|
|||||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||||
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
expect(wheel(new WheelEvent("wheel", { deltaY: 100 }))).toBe(false);
|
||||||
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
expect(ws.sent).toEqual(["\x1b[<64;1;1M", "\x1b[<65;1;1M"]);
|
||||||
// Tracked again (a tmux that does report): xterm sends it, never twice.
|
// Tracked again (a tmux that does report): sent once, by the tracked path.
|
||||||
termState.modes.mouseTrackingMode = "any";
|
termState.modes.mouseTrackingMode = "any";
|
||||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
screenBox();
|
||||||
expect(ws.sent).toHaveLength(2);
|
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(false);
|
||||||
|
expect(ws.sent).toHaveLength(3);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
it("keeps Shift+Tab in the terminal instead of letting focus escape", () => {
|
||||||
|
|||||||
@@ -19,15 +19,33 @@
|
|||||||
* the next one below) — and, for
|
* the next one below) — and, for
|
||||||
* a user with their own Linux account (S3), their own HTTPS
|
* a user with their own Linux account (S3), their own HTTPS
|
||||||
* logins. The host account's HTTPS logins are the shared Git credentials
|
* logins. The host account's HTTPS logins are the shared Git credentials
|
||||||
* table above it, so this lists them only for a member. The private key and
|
* table above it, so this lists them only for a member. **Commit signing**
|
||||||
|
* (under the author) is PER WORKSPACE: one picker per workspace of the user's
|
||||||
|
* (`PUT /api/me/git/signing/lane`, "Don't sign" by default), the keys that can
|
||||||
|
* sign with Delete, a way out of the old account-wide setting and of a
|
||||||
|
* signing setup the user made themselves, and **Import a key you have** takes
|
||||||
|
* a private key file or paste (`POST /api/me/git/ssh-key/import`). The private key and
|
||||||
* every token are write-only: the server never returns them. A host identity
|
* every token are write-only: the server never returns them. A host identity
|
||||||
* seen by a non-admin is read-only.
|
* seen by a non-admin is read-only.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Cloud, Copy, GitBranch, KeyRound, Plus, RefreshCw, Server, Trash2 } from "lucide-react";
|
import {
|
||||||
|
Cloud,
|
||||||
|
Copy,
|
||||||
|
FileKey,
|
||||||
|
GitBranch,
|
||||||
|
KeyRound,
|
||||||
|
PenLine,
|
||||||
|
Plus,
|
||||||
|
RefreshCw,
|
||||||
|
Server,
|
||||||
|
Trash2,
|
||||||
|
Upload,
|
||||||
|
} from "lucide-react";
|
||||||
import { api, type GitIdentity } from "../../lib/api";
|
import { api, type GitIdentity } from "../../lib/api";
|
||||||
|
import type { Lane } from "../../lib/types";
|
||||||
import { confirmDialog } from "../../lib/confirm";
|
import { confirmDialog } from "../../lib/confirm";
|
||||||
import { copyText } from "../../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { toast } from "../../lib/toast";
|
import { toast } from "../../lib/toast";
|
||||||
@@ -216,6 +234,339 @@ function GitHostForm({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Commit signing, PER WORKSPACE: one picker per workspace of the user's
|
||||||
|
* (`PUT /api/me/git/signing/lane` — "Don't sign" by default, so only the
|
||||||
|
* workspaces that need it sign), the keys that can sign, each with Delete,
|
||||||
|
* and Import a key the user already has. The old account-wide setting
|
||||||
|
* (every repo signed) only shows when it is on, with a button that turns it
|
||||||
|
* off; a signing setup the user made themselves in `~/.gitconfig` can be
|
||||||
|
* taken out (their key file stays).
|
||||||
|
*/
|
||||||
|
function SigningCard({
|
||||||
|
id,
|
||||||
|
keys,
|
||||||
|
keyLabel,
|
||||||
|
busy,
|
||||||
|
run,
|
||||||
|
}: {
|
||||||
|
id: GitIdentity;
|
||||||
|
keys: Key[];
|
||||||
|
keyLabel: (keyId: string | null | undefined) => string;
|
||||||
|
busy: boolean;
|
||||||
|
run: (fn: () => Promise<GitIdentity>, done?: string) => Promise<boolean>;
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation("settings");
|
||||||
|
const auth = useAuth().state;
|
||||||
|
const me = auth.mode === "server" ? auth.user : null;
|
||||||
|
const signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||||
|
const laneSigning = id.laneSigning ?? [];
|
||||||
|
const [form, setForm] = useState<{ text: string; name: string } | null>(null);
|
||||||
|
const [lanes, setLanes] = useState<Lane[] | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
api.lanes
|
||||||
|
.list()
|
||||||
|
.then((r) => live && setLanes(r.lanes))
|
||||||
|
.catch(() => live && setLanes([]));
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
// The user's OWN workspaces: an admin sees everyone's, but signs only theirs.
|
||||||
|
const mine = (lanes ?? []).filter((l) => !me || l.owner_id == null || l.owner_id === me.id);
|
||||||
|
const laneName = (l: Lane) => l.title || l.cwd;
|
||||||
|
// Still signing, but its workspace is gone from the list (removed): the
|
||||||
|
// folder keeps signing until it is turned off here.
|
||||||
|
const orphans =
|
||||||
|
lanes === null
|
||||||
|
? []
|
||||||
|
: laneSigning
|
||||||
|
.filter((x) => !lanes.some((l) => l.id === x.laneId))
|
||||||
|
.map(
|
||||||
|
(x) =>
|
||||||
|
({
|
||||||
|
id: x.laneId,
|
||||||
|
title: t("gitIdentity.signLaneGone", { id: x.laneId }),
|
||||||
|
cwd: "",
|
||||||
|
}) as Lane
|
||||||
|
);
|
||||||
|
const signedWith = (laneId: number) => laneSigning.find((x) => x.laneId === laneId);
|
||||||
|
|
||||||
|
const chooseLane = async (lane: Lane, keyId: string | null) =>
|
||||||
|
run(
|
||||||
|
() => api.gitIdentity.setLaneSigning(lane.id, keyId),
|
||||||
|
keyId
|
||||||
|
? t("gitIdentity.signLaneSaved", { name: laneName(lane), key: keyLabel(keyId) })
|
||||||
|
: t("gitIdentity.signLaneStopped", { name: laneName(lane) })
|
||||||
|
);
|
||||||
|
|
||||||
|
const clearForeign = async () => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.signForeignConfirm", { path: signing.path }),
|
||||||
|
confirmLabel: t("gitIdentity.signForeignRemove"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.setSigning(null, false, true),
|
||||||
|
t("gitIdentity.signForeignRemoved")
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteKey = async (k: Key) => {
|
||||||
|
if (
|
||||||
|
!(await confirmDialog({
|
||||||
|
title: t("gitIdentity.signKeyDeleteConfirm", { key: keyLabel(k.id) }),
|
||||||
|
confirmLabel: t("gitIdentity.remove"),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
await run(
|
||||||
|
() => api.gitIdentity.removeKeyById(k.id!),
|
||||||
|
t("gitIdentity.signKeyDeleted", { key: keyLabel(k.id) })
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const doImport = async () => {
|
||||||
|
if (!form) return;
|
||||||
|
const ok = await run(
|
||||||
|
() =>
|
||||||
|
api.gitIdentity.importKey({
|
||||||
|
privateKey: form.text,
|
||||||
|
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||||
|
}),
|
||||||
|
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||||
|
);
|
||||||
|
if (ok) setForm(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const readFile = async (file: File | undefined) => {
|
||||||
|
if (!file || !form) return;
|
||||||
|
// A key file is a few KB; anything far larger is not one.
|
||||||
|
if (file.size > 32 * 1024) return toast.error(t("gitIdentity.importPaste"));
|
||||||
|
const text = await file.text();
|
||||||
|
setForm({ ...form, text });
|
||||||
|
};
|
||||||
|
|
||||||
|
const usable = keys.filter((k) => k.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className="space-y-2 rounded-lg border border-border p-3"
|
||||||
|
aria-label={t("gitIdentity.signTitle")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<PenLine className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
|
<h3 className="flex-1 text-xs font-semibold text-fg-primary">
|
||||||
|
{t("gitIdentity.signTitle")}
|
||||||
|
</h3>
|
||||||
|
{id.writable && !form && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setForm({ text: "", name: "" })}
|
||||||
|
>
|
||||||
|
<Upload className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.importKey")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||||
|
|
||||||
|
{signing.state === "on" && (
|
||||||
|
<div
|
||||||
|
data-testid="git-signing-legacy"
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded border border-status-warning/40 bg-status-warning/10 px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-[11px] text-fg-primary">
|
||||||
|
{t("gitIdentity.signLegacy", { key: keyLabel(signing.key) })}
|
||||||
|
</span>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() =>
|
||||||
|
void run(() => api.gitIdentity.setSigning(null), t("gitIdentity.signLegacyStopped"))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("gitIdentity.signLegacyOff")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{signing.state === "foreign" && (
|
||||||
|
<div
|
||||||
|
data-testid="git-signing-foreign"
|
||||||
|
className="flex flex-wrap items-center gap-2 rounded border border-border px-2.5 py-1.5"
|
||||||
|
>
|
||||||
|
<span className="min-w-0 flex-1 text-[11px] text-fg-secondary [overflow-wrap:anywhere]">
|
||||||
|
{t("gitIdentity.signForeign", { path: signing.path })}
|
||||||
|
</span>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => void clearForeign()}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.signForeignRemove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1" data-testid="git-signing-lanes">
|
||||||
|
<div className="text-[11px] font-medium text-fg-secondary">
|
||||||
|
{t("gitIdentity.signLanes")}
|
||||||
|
</div>
|
||||||
|
{usable.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signNeedKey")}</p>
|
||||||
|
) : lanes === null ? null : mine.length + orphans.length === 0 ? (
|
||||||
|
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signLanesEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
[...mine, ...orphans].map((l) => {
|
||||||
|
const on = signedWith(l.id);
|
||||||
|
return (
|
||||||
|
<div key={l.id} className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`min-w-0 flex-1 truncate text-xs ${
|
||||||
|
on ? "text-status-success" : "text-fg-primary"
|
||||||
|
}`}
|
||||||
|
title={l.cwd || undefined}
|
||||||
|
>
|
||||||
|
{laneName(l)}
|
||||||
|
</span>
|
||||||
|
<select
|
||||||
|
aria-label={t("gitIdentity.signLaneKey", { name: laneName(l) })}
|
||||||
|
value={on?.key ?? ""}
|
||||||
|
disabled={busy || !id.writable}
|
||||||
|
onChange={(e) => void chooseLane(l, e.target.value || null)}
|
||||||
|
className="select !w-48 !py-1 !text-xs"
|
||||||
|
>
|
||||||
|
<option value="">{t("gitIdentity.signNone")}</option>
|
||||||
|
{usable.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{keyLabel(k.id)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{usable.length > 0 && (
|
||||||
|
<div className="space-y-1" data-testid="git-signing-keys">
|
||||||
|
<div className="text-[11px] font-medium text-fg-secondary">
|
||||||
|
{t("gitIdentity.signKeys")}
|
||||||
|
</div>
|
||||||
|
{usable.map((k) => {
|
||||||
|
const used = laneSigning.filter((x) => x.key === k.id).length;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={k.id}
|
||||||
|
className="flex items-center gap-2 rounded border border-border px-2 py-1"
|
||||||
|
>
|
||||||
|
<FileKey className="h-3.5 w-3.5 shrink-0 text-fg-muted" aria-hidden />
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-xs text-fg-primary">{keyLabel(k.id)}</div>
|
||||||
|
<div className="truncate font-mono text-[10px] text-fg-muted">
|
||||||
|
{k.fingerprint}
|
||||||
|
{used > 0 ? ` · ${t("gitIdentity.signKeyUsed", { count: used })}` : ""}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={BTN}
|
||||||
|
onClick={() => void copyText(k.publicKey)}
|
||||||
|
aria-label={t("gitIdentity.signKeyCopy", { key: keyLabel(k.id) })}
|
||||||
|
title={t("gitIdentity.signHostStep")}
|
||||||
|
>
|
||||||
|
<Copy className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
{id.writable && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${BTN} hover:text-status-danger`}
|
||||||
|
disabled={busy}
|
||||||
|
aria-label={t("gitIdentity.removeKeyNamed", { name: keyLabel(k.id) })}
|
||||||
|
onClick={() => void deleteKey(k)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.remove")}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="text-[10px] text-fg-muted">{t("gitIdentity.signHostStep")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form && (
|
||||||
|
<form
|
||||||
|
className="space-y-2 rounded border border-border p-2.5"
|
||||||
|
aria-label={t("gitIdentity.importTitle")}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void doImport();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-xs font-medium text-fg-primary">{t("gitIdentity.importTitle")}</div>
|
||||||
|
<label className={`${BTN} cursor-pointer`}>
|
||||||
|
<FileKey className="h-3.5 w-3.5" />
|
||||||
|
{t("gitIdentity.importFile")}
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
className="sr-only"
|
||||||
|
onChange={(e) => void readFile(e.target.files?.[0])}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
aria-label={t("gitIdentity.importPaste")}
|
||||||
|
placeholder={t("gitIdentity.importPaste")}
|
||||||
|
value={form.text}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
rows={4}
|
||||||
|
onChange={(e) => setForm({ ...form, text: e.target.value })}
|
||||||
|
className={`${INPUT} resize-y font-mono text-[10px]`}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
aria-label={t("gitIdentity.importName")}
|
||||||
|
placeholder={t("gitIdentity.importName")}
|
||||||
|
value={form.name}
|
||||||
|
autoComplete="off"
|
||||||
|
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
|
||||||
|
className={`${INPUT} !w-40 font-mono`}
|
||||||
|
/>
|
||||||
|
<p className="text-[10px] text-fg-muted">{t("gitIdentity.importNote")}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || !form.text.trim()}
|
||||||
|
className="rounded border border-accent/40 bg-accent/15 px-3 py-1 text-xs text-accent hover:bg-accent/25 disabled:cursor-not-allowed disabled:opacity-40"
|
||||||
|
>
|
||||||
|
{t("gitIdentity.importSubmit")}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setForm(null)} className={BTN}>
|
||||||
|
{t("gitCreds.cancel")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function GitIdentitySettings() {
|
export function GitIdentitySettings() {
|
||||||
const { t } = useTranslation("settings");
|
const { t } = useTranslation("settings");
|
||||||
const [id, setId] = useState<GitIdentity | null>(null);
|
const [id, setId] = useState<GitIdentity | null>(null);
|
||||||
@@ -572,6 +923,10 @@ export function GitIdentitySettings() {
|
|||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
{id.signing !== undefined && (
|
||||||
|
<SigningCard id={id} keys={keys} keyLabel={keyLabel} busy={busy} run={run} />
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-3 rounded-lg border border-border p-3">
|
<div className="space-y-3 rounded-lg border border-border p-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||||
@@ -616,6 +971,11 @@ export function GitIdentitySettings() {
|
|||||||
onRemove={() => removeNamed(k.id!)}
|
onRemove={() => removeNamed(k.id!)}
|
||||||
>
|
>
|
||||||
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
<Step n={2}>{t("gitIdentity.gitHubStepAdd")}</Step>
|
||||||
|
{id.signing?.state === "on" && id.signing.key === k.id && (
|
||||||
|
<div className="text-[10px] text-status-success">
|
||||||
|
{t("gitIdentity.signingBadge")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Step n={3}>
|
<Step n={3}>
|
||||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||||
|
|||||||
@@ -7,7 +7,11 @@
|
|||||||
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
* other host under "Other SSH hosts" — each kind of host in its own tab, so
|
||||||
* the sections never mix; "Add another key" makes a named key as one more
|
* the sections never mix; "Add another key" makes a named key as one more
|
||||||
* card in its own tab, a second AWS account is saved from that card under
|
* card in its own tab, a second AWS account is saved from that card under
|
||||||
* the key's name with that key, and a second GitHub key is tied to its host.
|
* the key's name with that key, and a second GitHub key is tied to its host;
|
||||||
|
* commit signing PER WORKSPACE: an imported key signs nothing by itself, one
|
||||||
|
* workspace's picker signs that workspace only, a signing key is deleted from
|
||||||
|
* the card after a confirm, and the old account-wide setting and a setup the
|
||||||
|
* user made themselves each have a way out.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
@@ -31,6 +35,18 @@ vi.mock("../../../lib/api", () => ({
|
|||||||
saveHttps: vi.fn(),
|
saveHttps: vi.fn(),
|
||||||
saveAuthor: vi.fn(),
|
saveAuthor: vi.fn(),
|
||||||
removeHttps: vi.fn(),
|
removeHttps: vi.fn(),
|
||||||
|
importKey: vi.fn(),
|
||||||
|
setSigning: vi.fn(),
|
||||||
|
setLaneSigning: vi.fn(),
|
||||||
|
},
|
||||||
|
lanes: {
|
||||||
|
list: vi.fn(async () => ({
|
||||||
|
lanes: [
|
||||||
|
{ id: 1, title: "shop-api", cwd: "/w/shop-api" },
|
||||||
|
{ id: 2, title: "", cwd: "/w/docs" },
|
||||||
|
],
|
||||||
|
counts: {},
|
||||||
|
})),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
@@ -38,6 +54,7 @@ vi.mock("../../../lib/toast", () => ({ toast: { success: vi.fn(), error: vi.fn()
|
|||||||
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
vi.mock("../../../lib/confirm", () => ({ confirmDialog: vi.fn(() => Promise.resolve(true)) }));
|
||||||
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
vi.mock("../../../lib/clipboard", () => ({ copyText: vi.fn(() => Promise.resolve(true)) }));
|
||||||
|
|
||||||
|
const OFF = { state: "off" as const, key: null, path: null };
|
||||||
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
const KEY = { publicKey: "ssh-ed25519 AAAAC3Nz an@agenthub", fingerprint: "SHA256:abc" };
|
||||||
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
const view = (over: Partial<GitIdentity> = {}): GitIdentity => ({
|
||||||
account: "member",
|
account: "member",
|
||||||
@@ -335,4 +352,130 @@ describe("GitIdentitySettings", () => {
|
|||||||
});
|
});
|
||||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("imports a key without signing anything by itself", async () => {
|
||||||
|
const signingKey = {
|
||||||
|
...KEY,
|
||||||
|
id: "id_ed25519_signing",
|
||||||
|
type: "ed25519" as const,
|
||||||
|
name: "signing",
|
||||||
|
};
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||||
|
...view({ sshKeys: [signingKey], signing: OFF, laneSigning: [] }),
|
||||||
|
imported: signingKey,
|
||||||
|
signError: null,
|
||||||
|
});
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
expect(await screen.findByText(/Import a key you have, or make one/)).toBeInTheDocument();
|
||||||
|
await userEvent.click(await screen.findByRole("button", { name: "Import a key you have" }));
|
||||||
|
const form = screen.getByRole("form", { name: "Import an existing private key" });
|
||||||
|
await userEvent.type(
|
||||||
|
within(form).getByLabelText(/paste the private key/),
|
||||||
|
"-----BEGIN OPENSSH PRIVATE KEY-----x"
|
||||||
|
);
|
||||||
|
await userEvent.click(within(form).getByRole("button", { name: "Import" }));
|
||||||
|
expect(api.gitIdentity.importKey).toHaveBeenCalledWith({
|
||||||
|
privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----x",
|
||||||
|
});
|
||||||
|
// Now there is a key: each workspace gets its own picker, all "Don't sign".
|
||||||
|
expect(
|
||||||
|
await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 })
|
||||||
|
).toHaveValue("");
|
||||||
|
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||||
|
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("signs one workspace only, and stops it", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: { state: "off", key: null, path: null }, laneSigning: [] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setLaneSigning)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 1, key: k.id }] })
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce(view({ sshKeys: [k], signing: OFF, laneSigning: [] }));
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const pick = await screen.findByLabelText("Signing key for shop-api", {}, { timeout: 5000 });
|
||||||
|
await userEvent.selectOptions(pick, k.id);
|
||||||
|
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(1, k.id);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByLabelText("Signing key for shop-api")).toHaveValue(k.id)
|
||||||
|
);
|
||||||
|
expect(screen.getByLabelText("Signing key for /w/docs")).toHaveValue("");
|
||||||
|
expect(
|
||||||
|
within(screen.getByTestId("git-signing-keys")).getByText(/signs 1 workspace/)
|
||||||
|
).toBeTruthy();
|
||||||
|
await userEvent.selectOptions(screen.getByLabelText("Signing key for shop-api"), "");
|
||||||
|
expect(api.gitIdentity.setLaneSigning).toHaveBeenLastCalledWith(1, null);
|
||||||
|
expect(api.gitIdentity.setSigning).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists a removed workspace that still signs, so it can be turned off", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: OFF, laneSigning: [{ laneId: 9, key: k.id }] })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setLaneSigning).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: OFF, laneSigning: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const pick = await screen.findByLabelText(
|
||||||
|
"Signing key for Workspace #9 (removed)",
|
||||||
|
{},
|
||||||
|
{ timeout: 5000 }
|
||||||
|
);
|
||||||
|
expect(pick).toHaveValue(k.id);
|
||||||
|
await userEvent.selectOptions(pick, "");
|
||||||
|
expect(api.gitIdentity.setLaneSigning).toHaveBeenCalledWith(9, null);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.queryByLabelText("Signing key for Workspace #9 (removed)")).toBeNull()
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deletes a signing key from the signing card, after asking", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519_signing", type: "ed25519" as const, name: "signing" };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({
|
||||||
|
sshKeys: [k],
|
||||||
|
signing: { state: "off", key: null, path: null },
|
||||||
|
laneSigning: [{ laneId: 1, key: k.id }],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.removeKeyById).mockResolvedValue(
|
||||||
|
view({ sshKeys: [], signing: OFF, laneSigning: [] })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const keys = await screen.findByTestId("git-signing-keys", {}, { timeout: 5000 });
|
||||||
|
await userEvent.click(within(keys).getByRole("button", { name: "Delete key signing" }));
|
||||||
|
expect(confirmDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ title: expect.stringMatching(/every workspace signing with it/) })
|
||||||
|
);
|
||||||
|
expect(api.gitIdentity.removeKeyById).toHaveBeenCalledWith(k.id);
|
||||||
|
await waitFor(() => expect(screen.queryByTestId("git-signing-keys")).toBeNull());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("turns off the old account-wide signing and removes a setup the user made", async () => {
|
||||||
|
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||||
|
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||||
|
);
|
||||||
|
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||||
|
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/mine" } })
|
||||||
|
);
|
||||||
|
render(<GitIdentitySettings />);
|
||||||
|
const legacy = await screen.findByTestId("git-signing-legacy");
|
||||||
|
await userEvent.click(within(legacy).getByRole("button", { name: "Sign per workspace only" }));
|
||||||
|
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith(null);
|
||||||
|
const foreign = await screen.findByTestId("git-signing-foreign");
|
||||||
|
expect(foreign).toHaveTextContent("~/.ssh/mine");
|
||||||
|
await userEvent.click(within(foreign).getByRole("button", { name: "Remove" }));
|
||||||
|
expect(confirmDialog).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ title: expect.stringMatching(/key file itself is not deleted/) })
|
||||||
|
);
|
||||||
|
expect(api.gitIdentity.setSigning).toHaveBeenLastCalledWith(null, false, true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* @file UserAvatar.tsx
|
* @file UserAvatar.tsx
|
||||||
* @description An account's round picture: the signed-in account's own
|
* @description An account's round picture: the signed-in account's own
|
||||||
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
|
||||||
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
|
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
|
||||||
* or when the image fails to load — the first letters of its name. Used by the
|
* or when the image fails to load — the first letters of its name. Used by the
|
||||||
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
* sidebar's account row, its workspace owner groups, Settings → Account, the
|
||||||
* Users page and its editor, the pool board and member picker, and System
|
* Users page and its editor, the pool board and member picker, and System
|
||||||
@@ -23,7 +23,7 @@ export function UserAvatar({
|
|||||||
}: {
|
}: {
|
||||||
name: string;
|
name: string;
|
||||||
avatarAt?: string | null;
|
avatarAt?: string | null;
|
||||||
/** Another account's (staff only); omitted = the signed-in account's own. */
|
/** Another account's; omitted = the signed-in account's own. */
|
||||||
userId?: number;
|
userId?: number;
|
||||||
/** How many letters of the name stand in for a missing picture. */
|
/** How many letters of the name stand in for a missing picture. */
|
||||||
letters?: number;
|
letters?: number;
|
||||||
|
|||||||
@@ -262,6 +262,7 @@
|
|||||||
"jobs.expand": "Show background tasks",
|
"jobs.expand": "Show background tasks",
|
||||||
"uploads.stop": "Stop upload",
|
"uploads.stop": "Stop upload",
|
||||||
"uploads.stopConfirm": "Click again to stop",
|
"uploads.stopConfirm": "Click again to stop",
|
||||||
|
"uploads.waiting": "The dashboard is restarting — the upload resumes when it is back.",
|
||||||
"uploads.stopping": "Stopping…",
|
"uploads.stopping": "Stopping…",
|
||||||
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
||||||
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
||||||
@@ -645,6 +646,7 @@
|
|||||||
"fx.pasted": "Pasted.",
|
"fx.pasted": "Pasted.",
|
||||||
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
"fx.crossOnlyWorkspace": "These items can only be pasted where they came from (a session worktree, an extra folder, or between two homes).",
|
||||||
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
"fx.needsUpgrade": "This needs the updated server helper — ask an admin to run `agenthub server install-system` once.",
|
||||||
|
"fx.secret": "Hidden folders at the top of a home (.ssh, .config…) are not editable here — use a terminal. For an SSH key to clone or sign commits: Settings → Git.",
|
||||||
"fx.conflictTitle": "Already there",
|
"fx.conflictTitle": "Already there",
|
||||||
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
"fx.conflictBody": "This name is taken in the destination: {{names}}",
|
||||||
"fx.conflictAll": "Do this for every remaining file",
|
"fx.conflictAll": "Do this for every remaining file",
|
||||||
|
|||||||
@@ -883,6 +883,40 @@
|
|||||||
"authorName": "Name",
|
"authorName": "Name",
|
||||||
"authorEmail": "Email",
|
"authorEmail": "Email",
|
||||||
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
"authorUnset": "Not set yet: commits from the dashboard and your agents fail until a name and email are saved. Filled in from your account — check and save.",
|
||||||
|
"signTitle": "Commit signing",
|
||||||
|
"signIntro": "Choose which workspaces sign their commits — from your terminal, the Changes panel or an agent — with one of your SSH keys. Every other repository stays unsigned.",
|
||||||
|
"signLanes": "Workspaces",
|
||||||
|
"signLanesEmpty": "You have no workspace yet.",
|
||||||
|
"signNeedKey": "Import a key you have, or make one below, to sign with.",
|
||||||
|
"signLaneGone": "Workspace #{{id}} (removed)",
|
||||||
|
"signLaneKey": "Signing key for {{name}}",
|
||||||
|
"signLaneSaved": "{{name}} now signs its commits with {{key}}.",
|
||||||
|
"signLaneStopped": "{{name}} no longer signs its commits.",
|
||||||
|
"signLegacy": "Every commit of your account is signed with {{key}} (the old account-wide setting).",
|
||||||
|
"signLegacyOff": "Sign per workspace only",
|
||||||
|
"signLegacyStopped": "Account-wide signing is off — each workspace keeps its own choice.",
|
||||||
|
"signForeign": "Your ~/.gitconfig signs every commit with a key you set up yourself: {{path}}.",
|
||||||
|
"signForeignRemove": "Remove",
|
||||||
|
"signForeignConfirm": "Take {{path}} out of your ~/.gitconfig? Commits stop being signed with it; the key file itself is not deleted.",
|
||||||
|
"signForeignRemoved": "The old signing setup was taken out of ~/.gitconfig.",
|
||||||
|
"signKeys": "Keys you can sign with",
|
||||||
|
"signKeyUsed_one": "signs {{count}} workspace",
|
||||||
|
"signKeyUsed_other": "signs {{count}} workspaces",
|
||||||
|
"signKeyCopy": "Copy the public key of {{key}}",
|
||||||
|
"signKeyDeleteConfirm": "Delete key {{key}}? Its private file is deleted, every workspace signing with it stops signing, and clones that use it can no longer fetch or push.",
|
||||||
|
"signKeyDeleted": "Key {{key}} deleted.",
|
||||||
|
"signKey": "Signing key",
|
||||||
|
"signNone": "Don't sign",
|
||||||
|
"signHostStep": "Add this public key to your account on the host as a SIGNING key (Gitea/GitHub: SSH / GPG Keys — on Gitea press Verify), and keep your commit email above the same as the host account's.",
|
||||||
|
"importKey": "Import a key you have",
|
||||||
|
"importTitle": "Import an existing private key",
|
||||||
|
"importFile": "Choose the key file",
|
||||||
|
"importPaste": "…or paste the private key (-----BEGIN … PRIVATE KEY-----)",
|
||||||
|
"importName": "Name (optional)",
|
||||||
|
"importNote": "The key must have no passphrase (a dashboard commit has nowhere to type one). It is stored in your own account; only its public half is ever shown again.",
|
||||||
|
"importSubmit": "Import",
|
||||||
|
"imported": "Key {{name}} imported.",
|
||||||
|
"signingBadge": "Signs commits",
|
||||||
"authorSaved": "Commit author saved.",
|
"authorSaved": "Commit author saved.",
|
||||||
"title": "Your git identity",
|
"title": "Your git identity",
|
||||||
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
"descriptionMember": "Your own SSH key and HTTPS logins, kept in your own home. Add workspace → Clone uses them, and the clone keeps them for its later fetch, pull and push.",
|
||||||
@@ -904,9 +938,9 @@
|
|||||||
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
"terminalHint": "A clone, fetch or push you type yourself (in a pane or over SSH) uses this key, then your own ~/.ssh keys. Sets core.sshCommand in your ~/.gitconfig.",
|
||||||
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
"terminalForeign": "Your ~/.gitconfig already sets its own core.sshCommand, so AgentHub leaves it alone. Remove it to use this key in the terminal.",
|
||||||
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
"sshIntro": "Use the section for where your code lives. Each kind of host takes its own key; you need only the ones you use.",
|
||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||||
"gitHubSub": "ED25519 key — the git@… SSH address",
|
"gitHubSub": "ED25519 key — the git@… SSH address",
|
||||||
"gitHubStepAdd": "Add it to your account on the host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Paste the whole line.",
|
"gitHubStepAdd": "Add it to your account on the host and paste the whole line: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Personal settings → Register SSH key.",
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||||
"stepCopy": "Copy the public key.",
|
"stepCopy": "Copy the public key.",
|
||||||
@@ -932,10 +966,10 @@
|
|||||||
"aliasPlaceholder": "name (optional)",
|
"aliasPlaceholder": "name (optional)",
|
||||||
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
"aliasHint": "Leave empty for the default account of this host. Give a name (e.g. aws-prod, lowercase, no dot) for one more account: clone it as ssh://<name>/…",
|
||||||
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
"awsMany": "Several AWS accounts? Add another key for each — it is made at once — upload it to that account, then save its SSH key ID in that key’s step 3. Clone with the URL AWS gives you as usual: AgentHub tries each saved account until one can open the repository.",
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||||
"tabOther": "Other hosts",
|
"tabOther": "Other hosts",
|
||||||
"addKey": "Add another key",
|
"addKey": "Add another key",
|
||||||
"namedGitSub": "ED25519 · for one more GitHub/GitLab account.",
|
"namedGitSub": "ED25519 · for one more account on a git host.",
|
||||||
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
"namedGitStepHost": "Tie it to its host, so a clone through ssh://{{name}}/… offers only this key (git@host would log in with the default key’s account):",
|
||||||
"namedAwsSub": "RSA · for one more AWS account.",
|
"namedAwsSub": "RSA · for one more AWS account.",
|
||||||
"removeKeyNamed": "Delete key {{name}}"
|
"removeKeyNamed": "Delete key {{name}}"
|
||||||
|
|||||||
@@ -262,6 +262,7 @@
|
|||||||
"jobs.expand": "Xem các việc chạy nền",
|
"jobs.expand": "Xem các việc chạy nền",
|
||||||
"uploads.stop": "Ngưng tải lên",
|
"uploads.stop": "Ngưng tải lên",
|
||||||
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
"uploads.stopConfirm": "Bấm lần nữa để ngưng",
|
||||||
|
"uploads.waiting": "Dashboard đang khởi động lại — tải lên sẽ tiếp tục khi có kết nối.",
|
||||||
"uploads.stopping": "Đang ngưng…",
|
"uploads.stopping": "Đang ngưng…",
|
||||||
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
"uploads.stopped_one": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||||
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
"uploads.stopped_other": "Đã ngưng tải lên — đã gửi {{count}}/{{total}} tệp.",
|
||||||
@@ -645,6 +646,7 @@
|
|||||||
"fx.pasted": "Đã dán.",
|
"fx.pasted": "Đã dán.",
|
||||||
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
"fx.crossOnlyWorkspace": "Các mục này chỉ dán được ở nơi chúng được lấy ra (worktree của session, thư mục phụ, hoặc giữa hai home).",
|
||||||
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
"fx.needsUpgrade": "Cần cập nhật helper của server — nhờ admin chạy `agenthub server install-system` một lần.",
|
||||||
|
"fx.secret": "Thư mục ẩn ở cấp đầu home (.ssh, .config…) không chỉnh được ở đây — hãy dùng terminal. Cần SSH key để clone hay ký commit: Settings → Git.",
|
||||||
"fx.conflictTitle": "Đã có sẵn",
|
"fx.conflictTitle": "Đã có sẵn",
|
||||||
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
"fx.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||||
|
|||||||
@@ -871,6 +871,40 @@
|
|||||||
"authorName": "Tên",
|
"authorName": "Tên",
|
||||||
"authorEmail": "Email",
|
"authorEmail": "Email",
|
||||||
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
"authorUnset": "Chưa đặt: commit từ dashboard và từ agent của bạn sẽ lỗi cho tới khi lưu tên và email. Đã điền sẵn từ tài khoản — kiểm tra rồi bấm Lưu.",
|
||||||
|
"signTitle": "Ký commit",
|
||||||
|
"signIntro": "Chọn workspace nào ký commit — từ terminal, bảng Changes hay agent — bằng một SSH key của bạn. Các repo khác không ký.",
|
||||||
|
"signLanes": "Workspace",
|
||||||
|
"signLanesEmpty": "Bạn chưa có workspace nào.",
|
||||||
|
"signNeedKey": "Hãy nhập key bạn có, hoặc tạo key ở phần dưới, để dùng ký.",
|
||||||
|
"signLaneGone": "Workspace #{{id}} (đã xóa)",
|
||||||
|
"signLaneKey": "Key ký cho {{name}}",
|
||||||
|
"signLaneSaved": "{{name}} giờ ký commit bằng {{key}}.",
|
||||||
|
"signLaneStopped": "{{name}} không còn ký commit.",
|
||||||
|
"signLegacy": "Mọi commit của tài khoản đang được ký bằng {{key}} (cài đặt cũ cho cả tài khoản).",
|
||||||
|
"signLegacyOff": "Chỉ ký theo workspace",
|
||||||
|
"signLegacyStopped": "Đã tắt ký cho cả tài khoản — mỗi workspace giữ lựa chọn riêng.",
|
||||||
|
"signForeign": "~/.gitconfig của bạn đang ký mọi commit bằng key bạn tự cài: {{path}}.",
|
||||||
|
"signForeignRemove": "Gỡ",
|
||||||
|
"signForeignConfirm": "Gỡ {{path}} khỏi ~/.gitconfig? Commit sẽ không còn ký bằng key này; file key không bị xóa.",
|
||||||
|
"signForeignRemoved": "Đã gỡ cấu hình ký cũ khỏi ~/.gitconfig.",
|
||||||
|
"signKeys": "Key dùng để ký",
|
||||||
|
"signKeyUsed_one": "ký {{count}} workspace",
|
||||||
|
"signKeyUsed_other": "ký {{count}} workspace",
|
||||||
|
"signKeyCopy": "Sao chép public key của {{key}}",
|
||||||
|
"signKeyDeleteConfirm": "Xóa key {{key}}? File khóa riêng sẽ bị xóa, các workspace đang ký bằng key này sẽ ngừng ký, và các bản clone dùng key này không fetch/push được nữa.",
|
||||||
|
"signKeyDeleted": "Đã xóa key {{key}}.",
|
||||||
|
"signKey": "Key dùng để ký",
|
||||||
|
"signNone": "Không ký",
|
||||||
|
"signHostStep": "Thêm public key này vào tài khoản trên host dưới dạng key KÝ (Gitea/GitHub: SSH / GPG Keys — trên Gitea bấm Verify), và giữ email commit ở trên trùng với email tài khoản trên host.",
|
||||||
|
"importKey": "Nhập key có sẵn",
|
||||||
|
"importTitle": "Nhập private key có sẵn",
|
||||||
|
"importFile": "Chọn file key",
|
||||||
|
"importPaste": "…hoặc dán private key (-----BEGIN … PRIVATE KEY-----)",
|
||||||
|
"importName": "Tên (không bắt buộc)",
|
||||||
|
"importNote": "Key không được có passphrase (commit từ dashboard không có chỗ nhập). Key được lưu trong tài khoản của chính bạn; sau khi nhập chỉ public key được hiển thị.",
|
||||||
|
"importSubmit": "Nhập",
|
||||||
|
"imported": "Đã nhập key {{name}}.",
|
||||||
|
"signingBadge": "Ký commit",
|
||||||
"authorSaved": "Đã lưu tác giả commit.",
|
"authorSaved": "Đã lưu tác giả commit.",
|
||||||
"title": "Danh tính git của bạn",
|
"title": "Danh tính git của bạn",
|
||||||
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
"descriptionMember": "SSH key và đăng nhập HTTPS riêng của bạn, lưu trong home của chính bạn. Add workspace → Clone dùng chúng, và bản clone giữ lại cho các lần fetch, pull, push sau.",
|
||||||
@@ -892,9 +926,9 @@
|
|||||||
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
"terminalHint": "Lệnh clone, fetch hay push bạn tự gõ (trong pane hoặc qua SSH) sẽ dùng key này, sau đó tới các key trong ~/.ssh của bạn. Ghi core.sshCommand vào ~/.gitconfig của bạn.",
|
||||||
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
"terminalForeign": "~/.gitconfig của bạn đã tự đặt core.sshCommand nên AgentHub không động vào. Xoá nó đi nếu muốn dùng key này trong terminal.",
|
||||||
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
"sshIntro": "Dùng đúng mục theo nơi chứa code của bạn. Mỗi loại host dùng một key riêng; chỉ cần tạo key cho nơi bạn dùng.",
|
||||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||||
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
"gitHubSub": "Key ED25519 — địa chỉ SSH dạng git@…",
|
||||||
"gitHubStepAdd": "Thêm vào tài khoản trên host: GitHub → Settings → SSH and GPG keys → New SSH key. GitLab: Preferences → SSH Keys. Dán nguyên dòng.",
|
"gitHubStepAdd": "Thêm vào tài khoản trên host, dán nguyên dòng: GitHub → Settings → SSH and GPG keys. GitLab → Preferences → SSH Keys. Gitea → Settings → SSH / GPG Keys. Backlog → Cài đặt cá nhân → Đăng ký SSH key.",
|
||||||
"awsTitle": "AWS CodeCommit",
|
"awsTitle": "AWS CodeCommit",
|
||||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||||
"stepCopy": "Copy public key.",
|
"stepCopy": "Copy public key.",
|
||||||
@@ -920,10 +954,10 @@
|
|||||||
"aliasPlaceholder": "tên (tuỳ chọn)",
|
"aliasPlaceholder": "tên (tuỳ chọn)",
|
||||||
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
"aliasHint": "Để trống cho tài khoản mặc định của host này. Đặt tên (vd aws-prod, chữ thường, không dấu chấm) cho thêm một tài khoản: clone bằng ssh://<tên>/…",
|
||||||
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
"awsMany": "Nhiều tài khoản AWS? Bấm Thêm key khác cho mỗi tài khoản — key được tạo ngay — upload lên tài khoản đó, rồi lưu SSH key ID ở bước 3 của key đó. Clone bằng URL AWS đưa như bình thường: AgentHub tự thử từng tài khoản đã lưu đến khi mở được repository.",
|
||||||
"tabGit": "GitHub · GitLab",
|
"tabGit": "Git (GitHub, GitLab, Gitea, Backlog…)",
|
||||||
"tabOther": "Host khác",
|
"tabOther": "Host khác",
|
||||||
"addKey": "Thêm key khác",
|
"addKey": "Thêm key khác",
|
||||||
"namedGitSub": "ED25519 · cho thêm một tài khoản GitHub/GitLab.",
|
"namedGitSub": "ED25519 · cho thêm một tài khoản trên git host.",
|
||||||
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
"namedGitStepHost": "Gắn key với host của nó, để clone qua ssh://{{name}}/… chỉ dùng đúng key này (git@host sẽ đăng nhập bằng tài khoản của key mặc định):",
|
||||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||||
"removeKeyNamed": "Xoá key {{name}}"
|
"removeKeyNamed": "Xoá key {{name}}"
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* @file uploadStaged.test.ts
|
||||||
|
* @description The resumable File Explorer upload (`api.files.uploadStaged`):
|
||||||
|
* a file goes in parts to `/fs/upload-part` and is committed with
|
||||||
|
* `?staged=<id>`; a dashboard that goes away mid-way (network error, a
|
||||||
|
* proxy's bare 502) is waited out and the upload resumes from the byte the
|
||||||
|
* server reports; a refusal with our own error code is not retried.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api, newUploadId } from "../api";
|
||||||
|
|
||||||
|
type Answer = { status: number; body?: unknown } | "neterr";
|
||||||
|
|
||||||
|
/** A fake XHR that answers each send with the next scripted answer. */
|
||||||
|
class FakeXhr {
|
||||||
|
static answers: ((url: string, size: number) => Answer)[] = [];
|
||||||
|
static sent: { url: string; size: number }[] = [];
|
||||||
|
status = 0;
|
||||||
|
responseText = "";
|
||||||
|
upload: { onprogress: ((e: { loaded: number }) => void) | null } = { onprogress: null };
|
||||||
|
onload: (() => void) | null = null;
|
||||||
|
onerror: (() => void) | null = null;
|
||||||
|
onabort: (() => void) | null = null;
|
||||||
|
private url = "";
|
||||||
|
open(_m: string, url: string) {
|
||||||
|
this.url = url;
|
||||||
|
}
|
||||||
|
setRequestHeader() {}
|
||||||
|
abort() {
|
||||||
|
this.onabort?.();
|
||||||
|
}
|
||||||
|
send(body: Blob) {
|
||||||
|
FakeXhr.sent.push({ url: this.url, size: body.size });
|
||||||
|
const next = FakeXhr.answers.shift();
|
||||||
|
const a = next ? next(this.url, body.size) : { status: 500, body: { error: { code: "EX" } } };
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (a === "neterr") return this.onerror?.();
|
||||||
|
this.status = a.status;
|
||||||
|
this.responseText = a.body === undefined ? "" : JSON.stringify(a.body);
|
||||||
|
this.onload?.();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
const q = (url: string) => new URL(url, "http://x").searchParams;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
FakeXhr.answers = [];
|
||||||
|
FakeXhr.sent = [];
|
||||||
|
vi.stubGlobal("XMLHttpRequest", FakeXhr);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("api.files.uploadStaged", () => {
|
||||||
|
it("sends parts, waits out a restart, resumes from the server's count, then commits", async () => {
|
||||||
|
const file = new Blob([new Uint8Array(20 * MB)]);
|
||||||
|
const upload = newUploadId();
|
||||||
|
expect(upload).toMatch(/^[0-9a-f]{32}$/);
|
||||||
|
// part 1 ok; part 2 dies with the server; part 2 again after the restart;
|
||||||
|
// part 3; the commit's answer is lost once; then the stored answer.
|
||||||
|
FakeXhr.answers = [
|
||||||
|
() => ({ status: 200, body: { received: 8 * MB } }),
|
||||||
|
() => "neterr",
|
||||||
|
(url) => ({ status: 200, body: { received: Number(q(url).get("offset")) + 8 * MB } }),
|
||||||
|
() => ({ status: 200, body: { received: 20 * MB } }),
|
||||||
|
() => ({ status: 502 }),
|
||||||
|
() => ({ status: 201, body: { path: "big.bin" } }),
|
||||||
|
];
|
||||||
|
// While away: the status read says 10 MB arrived before the server died.
|
||||||
|
const fetchMock = vi.fn(
|
||||||
|
async () =>
|
||||||
|
new Response(JSON.stringify({ received: 10 * MB, total: 20 * MB, done: false }), {
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const waits: boolean[] = [];
|
||||||
|
const done = api.files.uploadStaged(7, "big.bin", file, "fail", {
|
||||||
|
upload,
|
||||||
|
onWait: (w) => waits.push(w),
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await expect(done).resolves.toEqual({ path: "big.bin" });
|
||||||
|
const parts = FakeXhr.sent.filter((s) => s.url.includes("/upload-part"));
|
||||||
|
expect(parts.map((p) => [q(p.url).get("offset"), p.size])).toEqual([
|
||||||
|
["0", 8 * MB],
|
||||||
|
[String(8 * MB), 8 * MB],
|
||||||
|
[String(10 * MB), 8 * MB],
|
||||||
|
[String(18 * MB), 2 * MB],
|
||||||
|
]);
|
||||||
|
for (const p of parts) expect(q(p.url).get("upload")).toBe(upload);
|
||||||
|
const commits = FakeXhr.sent.filter((s) => s.url.includes("/fs/upload?"));
|
||||||
|
expect(commits).toHaveLength(2);
|
||||||
|
expect(q(commits[0]!.url).get("staged")).toBe(upload);
|
||||||
|
expect(q(commits[0]!.url).get("path")).toBe("big.bin");
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
expect(waits).toEqual([true, false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("goes on from the server's count on EOFFSET, and never retries our own refusal", async () => {
|
||||||
|
const file = new Blob([new Uint8Array(10)]);
|
||||||
|
FakeXhr.answers = [
|
||||||
|
() => ({ status: 409, body: { error: { code: "EOFFSET", message: "x", received: 10 } } }),
|
||||||
|
() => ({ status: 409, body: { error: { code: "EEXISTS", message: "taken", names: ["a"] } } }),
|
||||||
|
];
|
||||||
|
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
|
||||||
|
const settled = expect(done).rejects.toMatchObject({ code: "EEXISTS", names: ["a"] });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await settled;
|
||||||
|
expect(FakeXhr.sent).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives up once the dashboard has been away too long", async () => {
|
||||||
|
const file = new Blob([new Uint8Array(4)]);
|
||||||
|
FakeXhr.answers = Array.from({ length: 200 }, () => () => "neterr" as const);
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async () => {
|
||||||
|
throw new TypeError("Failed to fetch");
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const done = api.files.uploadStaged(7, "a", file, "fail", { upload: newUploadId() });
|
||||||
|
const settled = expect(done).rejects.toMatchObject({ code: "ENET" });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
await settled;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* @file wheelReports.test.ts
|
||||||
|
* @description A touchpad scrolls 1:1 with the finger (one report per
|
||||||
|
* LINES_PER_REPORT rows, remainder carried), a wheel notch always sends one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { LINES_PER_REPORT, wheelReports, type WheelState } from "../wheelReports";
|
||||||
|
|
||||||
|
const ROW = 20;
|
||||||
|
const px = (deltaY: number) => ({ deltaY, deltaMode: 0 });
|
||||||
|
|
||||||
|
describe("wheelReports", () => {
|
||||||
|
it("adds small touchpad steps up to one report per LINES_PER_REPORT rows", () => {
|
||||||
|
const s: WheelState = { partial: 0 };
|
||||||
|
let sent = 0;
|
||||||
|
// 30 steps of 10px = 300px = 15 rows.
|
||||||
|
for (let i = 0; i < 30; i++) sent += wheelReports(s, px(10), ROW, 40);
|
||||||
|
expect(sent).toBe(15 / LINES_PER_REPORT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("drops the carried remainder when the swipe turns around", () => {
|
||||||
|
const s: WheelState = { partial: 0 };
|
||||||
|
wheelReports(s, px(40), ROW, 40); // 2 rows down, nothing sent yet
|
||||||
|
let sent = 0;
|
||||||
|
for (let i = 0; i < 5; i++) sent += wheelReports(s, px(-20), ROW, 40); // 5 rows up
|
||||||
|
expect(sent).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends at least one report for a wheel notch, in either delta mode", () => {
|
||||||
|
expect(wheelReports({ partial: 0 }, px(100), ROW, 40)).toBe(1);
|
||||||
|
expect(wheelReports({ partial: 0 }, px(-100), ROW, 40)).toBe(-1);
|
||||||
|
expect(wheelReports({ partial: 0 }, { deltaY: 3, deltaMode: 1 }, ROW, 40)).toBe(1);
|
||||||
|
expect(wheelReports({ partial: 0 }, { deltaY: 1, deltaMode: 2 }, ROW, 40)).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends nothing for a horizontal-only event or an unmeasured terminal", () => {
|
||||||
|
expect(wheelReports({ partial: 0 }, px(0), ROW, 40)).toBe(0);
|
||||||
|
expect(wheelReports({ partial: 0 }, px(10), 0, 40)).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
+234
-25
@@ -1188,7 +1188,9 @@ const sendRaw = <T>(
|
|||||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||||
xhr.onload = () => {
|
xhr.onload = () => {
|
||||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
let body: {
|
||||||
|
error?: { code?: string; message?: string; names?: string[]; received?: number };
|
||||||
|
} = {};
|
||||||
try {
|
try {
|
||||||
body = JSON.parse(xhr.responseText);
|
body = JSON.parse(xhr.responseText);
|
||||||
} catch {
|
} catch {
|
||||||
@@ -1206,12 +1208,148 @@ const sendRaw = <T>(
|
|||||||
code: body.error?.code,
|
code: body.error?.code,
|
||||||
status: xhr.status,
|
status: xhr.status,
|
||||||
names: body.error?.names,
|
names: body.error?.names,
|
||||||
|
received: body.error?.received,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
xhr.send(payload);
|
xhr.send(payload);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A batch body (`/fs/upload-batch`): a 4-byte length, the JSON file list,
|
||||||
|
* then each file's bytes. The Blob is only composed, never read into memory.
|
||||||
|
*/
|
||||||
|
function batchBody(files: { file: Blob; rel: string }[]): Blob {
|
||||||
|
const head = new TextEncoder().encode(
|
||||||
|
JSON.stringify({
|
||||||
|
files: files.map((f) => ({
|
||||||
|
path: f.rel,
|
||||||
|
size: f.file.size,
|
||||||
|
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
const len = new Uint8Array(4);
|
||||||
|
new DataView(len.buffer).setUint32(0, head.length);
|
||||||
|
return new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One part of a resumable upload (the server takes at most 16 MB). */
|
||||||
|
const UPLOAD_PART_BYTES = 8 * 1024 * 1024;
|
||||||
|
/** How long an upload waits for the dashboard to come back (a restart, an update). */
|
||||||
|
const UPLOAD_WAIT_MS = 3 * 60 * 1000;
|
||||||
|
|
||||||
|
/** A fresh id for one resumable upload (also outside a secure context). */
|
||||||
|
export function newUploadId(): string {
|
||||||
|
const b = new Uint8Array(16);
|
||||||
|
crypto.getRandomValues(b);
|
||||||
|
return Array.from(b, (x) => x.toString(16).padStart(2, "0")).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
type SendErr = Error & { code?: string; status?: number; received?: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dashboard is away (restarting) rather than refusing: worth waiting for.
|
||||||
|
* A 5xx with none of our error codes is a proxy (nginx, Vite) answering for it.
|
||||||
|
*/
|
||||||
|
const isAway = (e: SendErr) =>
|
||||||
|
e.code === "ENET" || e.code === "EBUSY" || ((e.status ?? 0) >= 500 && !e.code);
|
||||||
|
|
||||||
|
const pause = (ms: number, signal?: AbortSignal) =>
|
||||||
|
new Promise<void>((resolve, reject) => {
|
||||||
|
const timer = setTimeout(resolve, ms);
|
||||||
|
signal?.addEventListener(
|
||||||
|
"abort",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
reject(Object.assign(new Error("aborted"), { code: "EABORT" }));
|
||||||
|
},
|
||||||
|
{ once: true }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Callbacks of a resumable upload. */
|
||||||
|
export interface StagedSendOpts {
|
||||||
|
/** The upload's id: pass the SAME one to retry a commit (another `conflict`) without resending. */
|
||||||
|
upload: string;
|
||||||
|
onProgress?: (sent: number) => void;
|
||||||
|
/** true while the dashboard is away and the upload waits to resume. */
|
||||||
|
onWait?: (waiting: boolean) => void;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send `payload` in parts to `place`'s staging (`/fs/upload-part`), then
|
||||||
|
* POST `commitUrl(id)` to write it. A dropped connection or a restarting
|
||||||
|
* dashboard (network error, 502/503/504) is waited out for up to
|
||||||
|
* UPLOAD_WAIT_MS, and the upload resumes from the byte the server has.
|
||||||
|
*/
|
||||||
|
async function sendStaged<T>(
|
||||||
|
place: FsPlace,
|
||||||
|
commitUrl: (id: string) => string,
|
||||||
|
payload: Blob,
|
||||||
|
{ upload, onProgress, onWait, signal }: StagedSendOpts
|
||||||
|
): Promise<T> {
|
||||||
|
const total = payload.size;
|
||||||
|
let received = 0;
|
||||||
|
let awaySince = 0;
|
||||||
|
let delay = 500;
|
||||||
|
const waitOut = async (e: SendErr) => {
|
||||||
|
if (!isAway(e)) throw e;
|
||||||
|
if (!awaySince) awaySince = Date.now();
|
||||||
|
if (Date.now() - awaySince > UPLOAD_WAIT_MS) throw e;
|
||||||
|
onWait?.(true);
|
||||||
|
await pause(delay, signal);
|
||||||
|
delay = Math.min(delay * 2, 5000);
|
||||||
|
};
|
||||||
|
const back = () => {
|
||||||
|
if (awaySince) onWait?.(false);
|
||||||
|
awaySince = 0;
|
||||||
|
delay = 500;
|
||||||
|
};
|
||||||
|
const partUrl = (q: Record<string, string>) =>
|
||||||
|
fsUrl(place, "/upload-part", new URLSearchParams({ upload, ...q }));
|
||||||
|
// An empty file is still one (empty) part: it makes the upload known.
|
||||||
|
do {
|
||||||
|
try {
|
||||||
|
const r = await sendRaw<{ received: number }>(
|
||||||
|
partUrl({ offset: String(received), total: String(total) }),
|
||||||
|
payload.slice(received, received + UPLOAD_PART_BYTES),
|
||||||
|
(n) => onProgress?.(received + n),
|
||||||
|
signal
|
||||||
|
);
|
||||||
|
received = r.received;
|
||||||
|
back();
|
||||||
|
} catch (err) {
|
||||||
|
const e = err as SendErr;
|
||||||
|
if (e.code === "EOFFSET" && typeof e.received === "number") {
|
||||||
|
// The server has more (or less) than we thought: go on from there.
|
||||||
|
received = e.received;
|
||||||
|
back();
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await waitOut(e);
|
||||||
|
try {
|
||||||
|
const s = await request<{ received: number; done: boolean }>(partUrl({}));
|
||||||
|
received = s.received;
|
||||||
|
} catch {
|
||||||
|
/* still away: the next part asks again */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onProgress?.(received);
|
||||||
|
} while (received < total);
|
||||||
|
for (;;) {
|
||||||
|
try {
|
||||||
|
const r = await sendRaw<T>(commitUrl(upload), new Blob([]), undefined, signal);
|
||||||
|
back();
|
||||||
|
return r;
|
||||||
|
} catch (err) {
|
||||||
|
// A commit whose answer was lost is answered again, never written twice.
|
||||||
|
await waitOut(err as SendErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
/** A home the File Explorer can open (`GET /api/homes`): `me`, or another user's for an admin. */
|
||||||
export interface FsHome {
|
export interface FsHome {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1430,7 +1568,7 @@ export const api = {
|
|||||||
},
|
},
|
||||||
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
|
||||||
users: {
|
users: {
|
||||||
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
|
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
|
||||||
avatarUrl: (id: number, avatarAt?: string | null) =>
|
avatarUrl: (id: number, avatarAt?: string | null) =>
|
||||||
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
|
||||||
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
/** GET /api/users — every account with its recent usage (admin/analyst). */
|
||||||
@@ -3300,6 +3438,28 @@ export const api = {
|
|||||||
onProgress,
|
onProgress,
|
||||||
signal
|
signal
|
||||||
),
|
),
|
||||||
|
/**
|
||||||
|
* {@link files.upload}, resumable: sent in parts and written once whole,
|
||||||
|
* so a dashboard restart mid-way only pauses it ({@link sendStaged}).
|
||||||
|
*/
|
||||||
|
uploadStaged: (
|
||||||
|
t: FsPlace,
|
||||||
|
path: string,
|
||||||
|
file: Blob,
|
||||||
|
conflict: FsConflict,
|
||||||
|
opts: StagedSendOpts
|
||||||
|
) =>
|
||||||
|
sendStaged<{ path: string; skipped?: boolean }>(
|
||||||
|
t,
|
||||||
|
(staged) => fsUrl(t, "/upload", new URLSearchParams({ path, conflict, staged })),
|
||||||
|
file,
|
||||||
|
opts
|
||||||
|
),
|
||||||
|
/** Forget a resumable upload's staged bytes (stopped, or given up). Never throws. */
|
||||||
|
dropUpload: (t: FsPlace, upload: string) =>
|
||||||
|
request<{ dropped: boolean }>(fsUrl(t, "/upload-part", new URLSearchParams({ upload })), {
|
||||||
|
method: "DELETE",
|
||||||
|
}).catch(() => null),
|
||||||
/**
|
/**
|
||||||
* Many small files in ONE request, under the folder `dir` (each `rel`
|
* Many small files in ONE request, under the folder `dir` (each `rel`
|
||||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||||
@@ -3312,25 +3472,27 @@ export const api = {
|
|||||||
conflict: FsConflict = "fail",
|
conflict: FsConflict = "fail",
|
||||||
onProgress?: (sent: number) => void,
|
onProgress?: (sent: number) => void,
|
||||||
signal?: AbortSignal
|
signal?: AbortSignal
|
||||||
) => {
|
) =>
|
||||||
const head = new TextEncoder().encode(
|
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||||
JSON.stringify({
|
|
||||||
files: files.map((f) => ({
|
|
||||||
path: f.rel,
|
|
||||||
size: f.file.size,
|
|
||||||
mtime: f.file instanceof File ? f.file.lastModified : undefined,
|
|
||||||
})),
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const len = new Uint8Array(4);
|
|
||||||
new DataView(len.buffer).setUint32(0, head.length);
|
|
||||||
return sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
|
||||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
batchBody(files),
|
||||||
onProgress,
|
onProgress,
|
||||||
signal
|
signal
|
||||||
);
|
),
|
||||||
},
|
/** {@link files.uploadBatch}, resumable like {@link files.uploadStaged}. */
|
||||||
|
uploadBatchStaged: (
|
||||||
|
t: FsPlace,
|
||||||
|
dir: string,
|
||||||
|
files: { file: Blob; rel: string }[],
|
||||||
|
conflict: FsConflict,
|
||||||
|
opts: StagedSendOpts
|
||||||
|
) =>
|
||||||
|
sendStaged<{ saved: number; skipped: number; paths: string[] }>(
|
||||||
|
t,
|
||||||
|
(staged) => fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict, staged })),
|
||||||
|
batchBody(files),
|
||||||
|
opts
|
||||||
|
),
|
||||||
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
/** Before a batch: which of `paths` (under `dir`) are taken, and whether a batch can go there. */
|
||||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||||
@@ -3356,6 +3518,32 @@ export const api = {
|
|||||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||||
removeKeyById: (id: string) =>
|
removeKeyById: (id: string) =>
|
||||||
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
request<GitIdentity>(`/me/git/ssh-key?id=${encodeURIComponent(id)}`, { method: "DELETE" }),
|
||||||
|
/**
|
||||||
|
* POST /api/me/git/ssh-key/import — a private key the user already has
|
||||||
|
* (the file's text); `sign` signs commits with it at once, `replace`
|
||||||
|
* over a signing key they set themselves. Only its public half returns.
|
||||||
|
*/
|
||||||
|
importKey: (body: { privateKey: string; name?: string; sign?: boolean; replace?: boolean }) =>
|
||||||
|
request<GitIdentity & { imported: GitSshKey | null; signError: ApiErrorBody | null }>(
|
||||||
|
"/me/git/ssh-key/import",
|
||||||
|
{ method: "POST", body: JSON.stringify(body) }
|
||||||
|
),
|
||||||
|
/**
|
||||||
|
* PUT /api/me/git/signing — the account-wide setting: sign every commit
|
||||||
|
* with key `key` (its id), or stop (null); `clearForeign` also takes out a
|
||||||
|
* signing setup the user made themselves (their key file stays).
|
||||||
|
*/
|
||||||
|
setSigning: (key: string | null, replace = false, clearForeign = false) =>
|
||||||
|
request<GitIdentity>("/me/git/signing", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify(clearForeign ? { key, replace, clearForeign } : { key, replace }),
|
||||||
|
}),
|
||||||
|
/** PUT /api/me/git/signing/lane — sign ONE workspace's commits with `key`, or stop (null). */
|
||||||
|
setLaneSigning: (laneId: number, key: string | null) =>
|
||||||
|
request<GitIdentity>("/me/git/signing/lane", {
|
||||||
|
method: "PUT",
|
||||||
|
body: JSON.stringify({ laneId, key }),
|
||||||
|
}),
|
||||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||||
setTerminalSsh: (on: boolean) =>
|
setTerminalSsh: (on: boolean) =>
|
||||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||||
@@ -5232,6 +5420,21 @@ export interface GitCredential {
|
|||||||
hasToken: true;
|
hasToken: true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One SSH key of `GET /api/me/git` — its public half only. */
|
||||||
|
export interface GitSshKey {
|
||||||
|
id?: string;
|
||||||
|
type?: "ed25519" | "rsa";
|
||||||
|
name?: string | null;
|
||||||
|
publicKey: string;
|
||||||
|
fingerprint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An API error's `{code, message}`. */
|
||||||
|
export interface ApiErrorBody {
|
||||||
|
code: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
/** `GET /api/me/git`: the user's own SSH key (public half only) and HTTPS logins. */
|
||||||
export interface GitIdentity {
|
export interface GitIdentity {
|
||||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||||
@@ -5249,13 +5452,19 @@ export interface GitIdentity {
|
|||||||
* Every key the account holds: the default ED25519 and RSA first, then the
|
* Every key the account holds: the default ED25519 and RSA first, then the
|
||||||
* named ones (`name`; `id` = its file name); absent from an older server.
|
* named ones (`name`; `id` = its file name); absent from an older server.
|
||||||
*/
|
*/
|
||||||
sshKeys?: {
|
sshKeys?: GitSshKey[];
|
||||||
id?: string;
|
/**
|
||||||
type?: "ed25519" | "rsa";
|
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
|
||||||
name?: string | null;
|
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
|
||||||
publicKey: string;
|
* or `foreign` — a signing key the user set themselves (`path`). Absent
|
||||||
fingerprint: string;
|
* from an older server.
|
||||||
}[];
|
*/
|
||||||
|
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
|
||||||
|
/**
|
||||||
|
* The workspaces whose commits are signed (an `includeIf "gitdir:…"` each):
|
||||||
|
* `key` = the key's id, null when its file is gone. Absent from an older server.
|
||||||
|
*/
|
||||||
|
laneSigning?: { laneId: number; key: string | null }[];
|
||||||
/** The terminal switch (shared by both keys); null without a key. */
|
/** The terminal switch (shared by both keys); null without a key. */
|
||||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -9,6 +9,8 @@
|
|||||||
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
* kept in localStorage (refreshed every ~2 s), and the next page load shows
|
||||||
* a record that stopped being refreshed as an interrupted upload until it is
|
* a record that stopped being refreshed as an interrupted upload until it is
|
||||||
* dismissed. A fresh record belongs to another open tab and is left alone.
|
* dismissed. A fresh record belongs to another open tab and is left alone.
|
||||||
|
* A dashboard RESTART is not an interruption: the upload is sent in parts
|
||||||
|
* (`api.files.uploadStaged`) and the job shows `waiting` until it resumes.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -25,6 +27,8 @@ export interface UploadJob {
|
|||||||
sent: number;
|
sent: number;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
state: "running" | "stopping" | "interrupted";
|
state: "running" | "stopping" | "interrupted";
|
||||||
|
/** The dashboard is away (a restart): the upload waits and then resumes. */
|
||||||
|
waiting?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Stored = UploadJob & { beat: number };
|
type Stored = UploadJob & { beat: number };
|
||||||
@@ -115,7 +119,8 @@ export function beginUpload(init: { folder: string; total: number; bytes: number
|
|||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent">>) => set(id, patch),
|
update: (patch: Partial<Pick<UploadJob, "name" | "done" | "sent" | "waiting">>) =>
|
||||||
|
set(id, patch),
|
||||||
end: () => {
|
end: () => {
|
||||||
controllers.delete(id);
|
controllers.delete(id);
|
||||||
uploads = uploads.filter((u) => u.id !== id);
|
uploads = uploads.filter((u) => u.id !== id);
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* @file wheelReports.ts — turns a browser wheel event into the number of SGR
|
||||||
|
* wheel reports a terminal pane gets, in proportion to how far the finger or
|
||||||
|
* the wheel moved, the way a native terminal does.
|
||||||
|
*
|
||||||
|
* xterm.js sends ONE report per wheel event whatever its size, and damps a
|
||||||
|
* touchpad's pixel deltas ×0.3 before it counts even that one — a touchpad
|
||||||
|
* swipe crawled. In our panes every report moves a fixed stretch (Claude's
|
||||||
|
* `CLAUDE_CODE_SCROLL_SPEED`, tmux's copy-mode `WHEEL_LINES`), so a report is
|
||||||
|
* sent per `LINES_PER_REPORT` rows of travel: a touchpad scrolls 1:1 with the
|
||||||
|
* finger, and a wheel notch always sends at least one.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Rows one report scrolls in a Claude pane (server/lib/pty-run.js CLAUDE_SCROLL_SPEED). */
|
||||||
|
export const LINES_PER_REPORT = 5;
|
||||||
|
|
||||||
|
/** A pixel delta this large or larger is a wheel notch, not a touchpad step (xterm.js's own cut). */
|
||||||
|
const NOTCH_PX = 50;
|
||||||
|
|
||||||
|
const DOM_DELTA_PIXEL = 0;
|
||||||
|
const DOM_DELTA_LINE = 1;
|
||||||
|
const DOM_DELTA_PAGE = 2;
|
||||||
|
|
||||||
|
export interface WheelState {
|
||||||
|
/** Touchpad travel not yet sent, in rows (signed). */
|
||||||
|
partial: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed report count for `ev` — negative scrolls up. `rowPx` is one row's
|
||||||
|
* height on screen (CSS scale included), `rows` the terminal's height.
|
||||||
|
*/
|
||||||
|
export function wheelReports(
|
||||||
|
state: WheelState,
|
||||||
|
ev: Pick<WheelEvent, "deltaY" | "deltaMode">,
|
||||||
|
rowPx: number,
|
||||||
|
rows: number
|
||||||
|
): number {
|
||||||
|
if (!ev.deltaY || !(rowPx > 0)) return 0;
|
||||||
|
const sign = ev.deltaY < 0 ? -1 : 1;
|
||||||
|
if (ev.deltaMode === DOM_DELTA_PIXEL && Math.abs(ev.deltaY) < NOTCH_PX) {
|
||||||
|
// Touchpad: carry the remainder, so slow swipes still add up.
|
||||||
|
if (Math.sign(state.partial) === -sign) state.partial = 0; // turned around
|
||||||
|
state.partial += ev.deltaY / rowPx;
|
||||||
|
const n = Math.trunc(state.partial / LINES_PER_REPORT);
|
||||||
|
state.partial -= n * LINES_PER_REPORT;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
// A notch (or a fast flick): at least one report, more for a long one.
|
||||||
|
state.partial = 0;
|
||||||
|
const lines =
|
||||||
|
ev.deltaMode === DOM_DELTA_LINE
|
||||||
|
? ev.deltaY
|
||||||
|
: ev.deltaMode === DOM_DELTA_PAGE
|
||||||
|
? ev.deltaY * rows
|
||||||
|
: ev.deltaY / rowPx;
|
||||||
|
return sign * Math.max(1, Math.round(Math.abs(lines) / LINES_PER_REPORT));
|
||||||
|
}
|
||||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
|||||||
<span
|
<span
|
||||||
class="text-status-warning/90"
|
class="text-status-warning/90"
|
||||||
>
|
>
|
||||||
UI build v2.8.59 (rebuild client to match)
|
UI build v2.8.63 (rebuild client to match)
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+38
-2
File diff suppressed because one or more lines are too long
+1
-1
@@ -886,7 +886,7 @@ What runs as the member, and how:
|
|||||||
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
- In a member's folder, git and `gh` run as `sudo -n -u ah-<name> git …`, so git's own ownership check passes and every file stays the member's.
|
||||||
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
- The editor and `.gitignore` saves go through `/opt/agenthub/libexec/write-as`.
|
||||||
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
- A file uploaded in the Files tab (`POST /api/lanes/:id/git/upload`) is written the same way, AS the member through `write-as` (content on stdin), and only as a NEW file in an existing folder of the workspace — never over an existing name, never into `.git`, never a home dot folder, at most 25 MB. With **Download** on a file (`GET /api/lanes/:id/git/download`, read like any member file below, up to 512 MB) this is how a member with no SSH or `scp` to the host moves files in and out of their own folder; both work in a folder that is not a git repo.
|
||||||
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making the key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. The SSH keys are shown one TAB per kind of host, each with only its own steps: **GitHub · GitLab** (ED25519, for Gitea and Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
- AgentHub's stored git logins are never handed to a member's git, and a password typed in the git bar goes into the member's OWN store (below) for that one call — through `write-as`'s stdin, never sudo's argv or env — and out again unless "remember" was ticked; a store that cannot be read is never rewritten (`409 ESTOREUNREADABLE`). The member keeps their OWN identity in Settings → Git → **Your git identity** (`/api/me/git`): a commit author (`user.name`/`user.email` in their `~/.gitconfig` — commits fail without one; a new member has none), SSH keys AgentHub makes for them (an ED25519 and/or an RSA one — AWS CodeCommit takes only RSA) and HTTPS logins, both under `~/.config/agenthub/` in their home, written as them by `write-as` (0600). Add workspace → **Clone** (`POST /api/lanes/clone`) clones as them with it and writes it into the clone's own config (`core.sshCommand` / `credential.helper=store --file=…`), so the git bar and their agents' pushes in that repo use it too. A plain `git` they type themselves in a pane reads none of that, so making (or importing) a key also sets `core.sshCommand` in their `~/.gitconfig` (the key first, then `~/.ssh/id_*`; a switch under the key, issue #82) — unless they already set their own, which is left alone. **Commit signing** sits under the author: one picker signs every commit (terminal, Changes panel, agents) with one of their keys — `gpg.format ssh`, `user.signingkey` = that key's private file, `commit.gpgsign true` in their `~/.gitconfig`; a signing key they set up themselves is replaced only after a confirm — and **Import a key you have** takes a key file or paste (OpenSSH or PEM, ed25519 or RSA ≥ 2048, no passphrase: a dashboard commit has nowhere to type one) into the same `~/.config/agenthub/ssh/` as a named key (`signing`, `signing-2`…), written as them, and by default signs with it at once; only its public half is shown again. The SSH keys are shown one TAB per kind of host, each with only its own steps: **Git (GitHub, GitLab, Gitea, Backlog…)** (ED25519, for Bitbucket too) and **AWS CodeCommit** (RSA: copy → upload in IAM → paste the SSH key ID `APKA…` — saved for every region at once, no region to pick), then **Other hosts** (advanced); a tab's **Add another key** makes one more key of that kind at once (named `aws-2`, `aws-3`… / `git-2`…) as its own card below, with the same steps (an AWS one's step 3 saves its key ID under the key's name, tied to that key — a clone of the plain AWS URL tries it; a GitHub one's ties the key to its host — clone `ssh://<name>/<org>/<repo>.git`); it opens on the AWS tab when only an RSA key exists. The terminal switch sits under the tabs. The key ID (or another host's user) is saved as `url."ssh://<user>@<host>/".insteadOf "ssh://<host>/"` in the same `~/.gitconfig`, so the plain `git clone ssh://git-codecommit.<region>.amazonaws.com/v1/repos/<repo>` works in a pane and in Clone — and Clone tries every CodeCommit login saved (the region's own first, then each other account's key — an IAM key ID works in every region of its AWS account), keeping the one that logs in in the clone's `origin`. A login typed in the Clone dialog goes to their store through `write-as`'s stdin, never sudo's argv or env, and is removed again after the clone unless "remember" was ticked.
|
||||||
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
- **Files a pane reads.** These are written into `~/.agenthub/handoff/` AS the member (`write-as`). The dashboard never writes, changes or deletes anything in a member's home itself: the member could have swapped the name for a link. The files are:
|
||||||
- the workspace prompt and its context files;
|
- the workspace prompt and its context files;
|
||||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.59",
|
"version": "2.8.63",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.59",
|
"version": "2.8.63",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "agenthub",
|
"name": "agenthub",
|
||||||
"version": "2.8.59",
|
"version": "2.8.63",
|
||||||
"bin": {
|
"bin": {
|
||||||
"agenthub": "bin/agenthub.js"
|
"agenthub": "bin/agenthub.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
* place, across workspaces), delete into the trash and restore/purge, a
|
* place, across workspaces), delete into the trash and restore/purge, a
|
||||||
* streamed upload into new sub-folders with every conflict policy, a batch
|
* streamed upload into new sub-folders with every conflict policy, a batch
|
||||||
* of many small files in one request (and, in a member's folder, as ONE
|
* of many small files in one request (and, in a member's folder, as ONE
|
||||||
* write-as `extract` of a tar that real `tar` unpacks), a zip of
|
* write-as `extract` of a tar that real `tar` unpacks), a resumable upload
|
||||||
|
* sent in parts and committed from the staged bytes (and a member's upload
|
||||||
|
* written to a hidden temp file, moved onto its name only when whole), a zip of
|
||||||
* files and folders that unzips back to the same bytes — and the guards: no
|
* files and folders that unzips back to the same bytes — and the guards: no
|
||||||
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
|
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
|
||||||
* workspace folder itself never moved or deleted, cross-origin refused.
|
* workspace folder itself never moved or deleted, cross-origin refused.
|
||||||
@@ -293,6 +295,144 @@ describe("File Explorer", () => {
|
|||||||
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
|
assert.ok(!fs.existsSync(path.join(W1, "up/s.txt")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resumes an upload sent in parts, and writes it only once every byte is staged", async () => {
|
||||||
|
const staging = require("../lib/upload-staging");
|
||||||
|
const id = "resume-test-0000000001";
|
||||||
|
const data = Buffer.from("0123456789abcdefghij");
|
||||||
|
const part = (offset, bytes, upload = id) =>
|
||||||
|
request(
|
||||||
|
"POST",
|
||||||
|
`/api/lanes/${lane1}/fs/upload-part?upload=${upload}&offset=${offset}&total=${data.length}`,
|
||||||
|
bytes,
|
||||||
|
{ "Content-Type": "application/octet-stream" }
|
||||||
|
);
|
||||||
|
const status = async () =>
|
||||||
|
(await request("GET", `/api/lanes/${lane1}/fs/upload-part?upload=${id}`)).body;
|
||||||
|
assert.deepEqual(await status(), { received: 0, total: null, done: false });
|
||||||
|
assert.equal((await part(0, data.subarray(0, 8))).body.received, 8);
|
||||||
|
// A part that does not start where the staged bytes end: 409 with the truth.
|
||||||
|
const off = await part(4, data.subarray(4, 12));
|
||||||
|
assert.equal(off.status, 409);
|
||||||
|
assert.equal(off.body.error.code, "EOFFSET");
|
||||||
|
assert.equal(off.body.error.received, 8);
|
||||||
|
// Committing early writes nothing.
|
||||||
|
const early = await request(
|
||||||
|
"POST",
|
||||||
|
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id}`
|
||||||
|
);
|
||||||
|
assert.equal(early.status, 409);
|
||||||
|
assert.equal(early.body.error.code, "EINCOMPLETE");
|
||||||
|
assert.ok(!fs.existsSync(path.join(W1, "res/file.bin")));
|
||||||
|
// A part past the end is refused.
|
||||||
|
assert.equal((await part(8, Buffer.alloc(13))).status, 413);
|
||||||
|
assert.equal((await status()).received, 8);
|
||||||
|
assert.equal((await part(8, data.subarray(8))).body.received, data.length);
|
||||||
|
const commit = () =>
|
||||||
|
request("POST", `/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id}`);
|
||||||
|
const done = await commit();
|
||||||
|
assert.equal(done.status, 201);
|
||||||
|
assert.deepEqual(done.body, { path: "res/file.bin" });
|
||||||
|
assert.deepEqual(fs.readFileSync(path.join(W1, "res/file.bin")), data);
|
||||||
|
// A commit whose answer was lost is answered again, never written twice.
|
||||||
|
fs.writeFileSync(path.join(W1, "res/file.bin"), "changed since");
|
||||||
|
const again = await commit();
|
||||||
|
assert.equal(again.status, 201);
|
||||||
|
assert.deepEqual(again.body, { path: "res/file.bin" });
|
||||||
|
assert.equal(fs.readFileSync(path.join(W1, "res/file.bin"), "utf8"), "changed since");
|
||||||
|
assert.deepEqual(await status(), { received: data.length, total: data.length, done: true });
|
||||||
|
// A taken name keeps the staged bytes for a retry with another policy.
|
||||||
|
const id2 = "resume-test-0000000002";
|
||||||
|
await part(0, data, id2);
|
||||||
|
const taken = await request(
|
||||||
|
"POST",
|
||||||
|
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id2}`
|
||||||
|
);
|
||||||
|
assert.equal(taken.status, 409);
|
||||||
|
assert.equal(taken.body.error.code, "EEXISTS");
|
||||||
|
const kept = await request(
|
||||||
|
"POST",
|
||||||
|
`/api/lanes/${lane1}/fs/upload?path=res/file.bin&staged=${id2}&conflict=keep`
|
||||||
|
);
|
||||||
|
assert.equal(kept.body.path, "res/file (1).bin");
|
||||||
|
assert.deepEqual(fs.readFileSync(path.join(W1, "res/file (1).bin")), data);
|
||||||
|
// A batch goes the same way.
|
||||||
|
const head = Buffer.from(JSON.stringify({ files: [{ path: "b1.txt", size: 2 }] }));
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(head.length);
|
||||||
|
const batch = Buffer.concat([len, head, Buffer.from("hi")]);
|
||||||
|
const id3 = "resume-test-0000000003";
|
||||||
|
await request(
|
||||||
|
"POST",
|
||||||
|
`/api/lanes/${lane1}/fs/upload-part?upload=${id3}&offset=0&total=${batch.length}`,
|
||||||
|
batch,
|
||||||
|
{ "Content-Type": "application/octet-stream" }
|
||||||
|
);
|
||||||
|
const b = await request("POST", `/api/lanes/${lane1}/fs/upload-batch?dir=res&staged=${id3}`);
|
||||||
|
assert.equal(b.status, 201);
|
||||||
|
assert.equal(fs.readFileSync(path.join(W1, "res/b1.txt"), "utf8"), "hi");
|
||||||
|
// Stop: forgotten. A bad id or an unknown upload is refused, never a path.
|
||||||
|
assert.equal(
|
||||||
|
(await request("DELETE", `/api/lanes/${lane1}/fs/upload-part?upload=${id}`)).status,
|
||||||
|
200
|
||||||
|
);
|
||||||
|
assert.equal((await status()).received, 0);
|
||||||
|
assert.equal(
|
||||||
|
(await request("GET", `/api/lanes/${lane1}/fs/upload-part?upload=../../etc`)).status,
|
||||||
|
400
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await request("POST", `/api/lanes/${lane1}/fs/upload?path=x&staged=nope-nope-nope-nope`))
|
||||||
|
.status,
|
||||||
|
404
|
||||||
|
);
|
||||||
|
// Uploads are per user: another user's key never sees this one.
|
||||||
|
assert.equal(staging.status("42", id3).received, 0);
|
||||||
|
assert.ok(fs.statSync(path.join(staging.root(), "local")).isDirectory());
|
||||||
|
for (const i of [id2, id3]) staging.drop("local", i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the bytes of a part cut short, caps unfinished uploads, sweeps old ones", async () => {
|
||||||
|
const staging = require("../lib/upload-staging");
|
||||||
|
const { Readable } = require("node:stream");
|
||||||
|
const id = "cut-short-0000000001";
|
||||||
|
let sent = false;
|
||||||
|
const cut = new Readable({
|
||||||
|
read() {
|
||||||
|
if (!sent) {
|
||||||
|
sent = true;
|
||||||
|
this.push(Buffer.from("abcd"));
|
||||||
|
} else setTimeout(() => this.destroy(new Error("connection reset")), 30);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await assert.rejects(staging.putPart("u1", id, { offset: 0, total: 10, stream: cut }));
|
||||||
|
assert.equal(staging.status("u1", id).received, 4);
|
||||||
|
const rest = Readable.from([Buffer.from("efghij")]);
|
||||||
|
assert.equal(
|
||||||
|
(await staging.putPart("u1", id, { offset: 4, total: 10, stream: rest })).received,
|
||||||
|
10
|
||||||
|
);
|
||||||
|
// At most MAX_OPEN unfinished uploads per user.
|
||||||
|
for (let i = 1; i < staging.MAX_OPEN; i++) {
|
||||||
|
await staging.putPart("u1", `cap-test-${String(i).padStart(10, "0")}`, {
|
||||||
|
offset: 0,
|
||||||
|
total: 5,
|
||||||
|
stream: Readable.from([Buffer.from("a")]),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await assert.rejects(
|
||||||
|
staging.putPart("u1", "cap-test-overflow-01", {
|
||||||
|
offset: 0,
|
||||||
|
total: 5,
|
||||||
|
stream: Readable.from([Buffer.from("a")]),
|
||||||
|
}),
|
||||||
|
{ code: "ETOOMANY" }
|
||||||
|
);
|
||||||
|
// A day untouched: swept.
|
||||||
|
staging.sweep(Date.now() + staging.KEEP_MS + 60_000);
|
||||||
|
assert.equal(staging.status("u1", id).received, 0);
|
||||||
|
assert.deepEqual(fs.readdirSync(path.join(staging.root(), "u1")), []);
|
||||||
|
});
|
||||||
|
|
||||||
it("zips files and folders into one download that unzips to the same bytes", async () => {
|
it("zips files and folders into one download that unzips to the same bytes", async () => {
|
||||||
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
|
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
|
||||||
assert.equal(r.status, 200);
|
assert.equal(r.status, 200);
|
||||||
@@ -404,6 +544,49 @@ describe("File Explorer in a member's folder (S3, run-as stubbed)", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writes an upload to a hidden temp file and moves it onto the name only when complete", async () => {
|
||||||
|
const { Readable } = require("node:stream");
|
||||||
|
const prev = runAs.writeAsAsync;
|
||||||
|
const runs = [];
|
||||||
|
// write-as as a shell would run it: `cat >file` takes whatever arrived.
|
||||||
|
runAs.writeAsAsync = async (_a, file, args, { input } = {}) => {
|
||||||
|
runs.push([path.basename(file), ...args.map((a) => path.basename(a))]);
|
||||||
|
if (!args.length) {
|
||||||
|
const chunks = [];
|
||||||
|
for await (const c of input) chunks.push(c);
|
||||||
|
fs.writeFileSync(file, Buffer.concat(chunks));
|
||||||
|
} else if (args[0] === "move") fs.renameSync(file, args[1]);
|
||||||
|
else if (args[0] === "remove") fs.rmSync(file, { force: true });
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const out = await fx.upload(M, "up.txt", Readable.from([Buffer.from("whole")]), {
|
||||||
|
length: "5",
|
||||||
|
});
|
||||||
|
assert.equal(out.path, "up.txt");
|
||||||
|
assert.equal(fs.readFileSync(path.join(M, "up.txt"), "utf8"), "whole");
|
||||||
|
assert.match(runs[0][0], /^\.up\.txt\.agenthub-upload-/);
|
||||||
|
assert.deepEqual(runs[1].slice(1), ["move", "up.txt"]);
|
||||||
|
// The body ended early (a dropped connection, a dying server): nothing
|
||||||
|
// under the real name, and the temp file is removed.
|
||||||
|
runs.length = 0;
|
||||||
|
await assert.rejects(
|
||||||
|
fx.upload(M, "cut.txt", Readable.from([Buffer.from("par")]), { length: "10" }),
|
||||||
|
{ code: "EBADBODY" }
|
||||||
|
);
|
||||||
|
assert.ok(!fs.existsSync(path.join(M, "cut.txt")));
|
||||||
|
assert.deepEqual(
|
||||||
|
fs.readdirSync(M).filter((n) => n.includes("agenthub-upload")),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
runs.map((r) => r[1] ?? "write"),
|
||||||
|
["write", "remove"]
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
runAs.writeAsAsync = prev;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("says the helper needs updating instead of failing obscurely", async () => {
|
it("says the helper needs updating instead of failing obscurely", async () => {
|
||||||
runAs.writeAsCanMove = () => false;
|
runAs.writeAsCanMove = () => false;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,334 @@
|
|||||||
|
/**
|
||||||
|
* @file Tests for importing an SSH key the user already has and signing
|
||||||
|
* commits with it (`POST /api/me/git/ssh-key/import`, `PUT /api/me/git/signing`,
|
||||||
|
* `server/lib/git-identity.js`): OpenSSH and PEM keys of both types read into
|
||||||
|
* the same public half `ssh-keygen -y` prints, only that half returned, the
|
||||||
|
* private file 0600; a passphrase key, a `.pub`, an ecdsa key and the same key
|
||||||
|
* twice refused; a real commit signed and verified by git; a signing key the
|
||||||
|
* user set themselves kept unless replaced, and taken out (its file kept) on
|
||||||
|
* `clearForeign`; deleting the key stops signing. Per workspace
|
||||||
|
* (`PUT /api/me/git/signing/lane`): only that workspace's commits — and its
|
||||||
|
* sessions' `ah-<id>-*` worktrees — are signed, never another repo's, the
|
||||||
|
* user's own includes stay, and deleting the key stops that workspace's too.
|
||||||
|
* @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 crypto = require("node:crypto");
|
||||||
|
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-signing-"));
|
||||||
|
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");
|
||||||
|
|
||||||
|
let app;
|
||||||
|
let base;
|
||||||
|
const KEYS = path.join(ROOT, "keys");
|
||||||
|
|
||||||
|
function request(method, p, body) {
|
||||||
|
return fetch(`${base}${p}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json().catch(() => null) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A key made by ssh-keygen itself: `{text, pub}` (pub = type + base64). */
|
||||||
|
function keygen(name, args, pass = "") {
|
||||||
|
const file = path.join(KEYS, name);
|
||||||
|
execFileSync("ssh-keygen", ["-q", ...args, "-N", pass, "-C", "me@x", "-f", file]);
|
||||||
|
const pub = fs.readFileSync(`${file}.pub`, "utf8").split(" ").slice(0, 2).join(" ");
|
||||||
|
return { text: fs.readFileSync(file, "utf8"), pub };
|
||||||
|
}
|
||||||
|
const pubOf = (line) => line.split(" ").slice(0, 2).join(" ");
|
||||||
|
const gitGet = (k) => {
|
||||||
|
try {
|
||||||
|
return execFileSync("git", ["config", "--global", "--get", k], { encoding: "utf8" }).trim();
|
||||||
|
} catch {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const removeAll = async () => {
|
||||||
|
await request("PUT", "/api/me/git/signing", { key: null });
|
||||||
|
await request("DELETE", "/api/me/git/ssh-key");
|
||||||
|
};
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
fs.mkdirSync(KEYS);
|
||||||
|
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));
|
||||||
|
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("POST /api/me/git/ssh-key/import", () => {
|
||||||
|
it("reads an OpenSSH ed25519 key into the public half ssh-keygen prints", async () => {
|
||||||
|
const k = keygen("ed", ["-t", "ed25519"]);
|
||||||
|
const r = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal(pubOf(r.body.imported.publicKey), k.pub);
|
||||||
|
assert.equal(r.body.imported.name, "signing");
|
||||||
|
assert.equal(r.body.imported.id, "id_ed25519_signing");
|
||||||
|
// Nothing private in the answer, and the file is the owner's alone.
|
||||||
|
assert.ok(!JSON.stringify(r.body).includes("PRIVATE KEY"));
|
||||||
|
const file = path.join(ROOT, ".claude", "agenthub-ssh", "id_ed25519_signing");
|
||||||
|
assert.equal(fs.statSync(file).mode & 0o777, 0o600);
|
||||||
|
// What was written is a key ssh-keygen itself reads back.
|
||||||
|
assert.equal(
|
||||||
|
pubOf(execFileSync("ssh-keygen", ["-y", "-f", file], { encoding: "utf8" }).trim()),
|
||||||
|
k.pub
|
||||||
|
);
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads OpenSSH RSA and PEM (PKCS#8) RSA keys", async () => {
|
||||||
|
const k = keygen("rsa", ["-t", "rsa", "-b", "2048"]);
|
||||||
|
const a = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
|
||||||
|
assert.equal(a.status, 201);
|
||||||
|
assert.equal(a.body.imported.id, "id_rsa_signing");
|
||||||
|
assert.equal(pubOf(a.body.imported.publicKey), k.pub);
|
||||||
|
const pem = crypto
|
||||||
|
.generateKeyPairSync("rsa", { modulusLength: 2048 })
|
||||||
|
.privateKey.export({ type: "pkcs8", format: "pem" });
|
||||||
|
const b = await request("POST", "/api/me/git/ssh-key/import", {
|
||||||
|
privateKey: pem,
|
||||||
|
name: "work",
|
||||||
|
});
|
||||||
|
assert.equal(b.status, 201);
|
||||||
|
assert.equal(b.body.imported.id, "id_rsa_work");
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a passphrase key, a public key, an ecdsa key and the same key twice", async () => {
|
||||||
|
const enc = keygen("enc", ["-t", "ed25519"], "secret-pass");
|
||||||
|
const r1 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.text });
|
||||||
|
assert.equal(r1.status, 400);
|
||||||
|
assert.equal(r1.body.error.code, "EKEYPASSPHRASE");
|
||||||
|
const r2 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: enc.pub });
|
||||||
|
assert.equal(r2.body.error.code, "EBADKEY");
|
||||||
|
assert.match(r2.body.error.message, /PUBLIC key/);
|
||||||
|
const ec = keygen("ec", ["-t", "ecdsa"]);
|
||||||
|
const r3 = await request("POST", "/api/me/git/ssh-key/import", { privateKey: ec.text });
|
||||||
|
assert.equal(r3.body.error.code, "EBADKEYTYPE");
|
||||||
|
const k = keygen("twice", ["-t", "ed25519"]);
|
||||||
|
assert.equal(
|
||||||
|
(await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text })).status,
|
||||||
|
201
|
||||||
|
);
|
||||||
|
const again = await request("POST", "/api/me/git/ssh-key/import", { privateKey: k.text });
|
||||||
|
assert.equal(again.status, 409);
|
||||||
|
assert.equal(again.body.error.code, "EEXISTS");
|
||||||
|
// A mangled paste never reaches the disk.
|
||||||
|
const lines = k.text.split("\n");
|
||||||
|
const i = 3; // inside the private section
|
||||||
|
lines[i] = lines[i].slice(0, 20) + (lines[i][20] === "Q" ? "R" : "Q") + lines[i].slice(21);
|
||||||
|
const bad = await request("POST", "/api/me/git/ssh-key/import", {
|
||||||
|
privateKey: lines.join("\n"),
|
||||||
|
});
|
||||||
|
assert.equal(bad.status, 400);
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/me/git/signing", () => {
|
||||||
|
it("signs a real commit with the imported key in one step", async () => {
|
||||||
|
const k = keygen("sign", ["-t", "ed25519"]);
|
||||||
|
const r = await request("POST", "/api/me/git/ssh-key/import", {
|
||||||
|
privateKey: k.text,
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal(r.body.signError, null);
|
||||||
|
assert.deepEqual(r.body.signing, { state: "on", key: "id_ed25519_signing", path: null });
|
||||||
|
assert.equal(gitGet("gpg.format"), "ssh");
|
||||||
|
assert.equal(gitGet("commit.gpgsign"), "true");
|
||||||
|
// The PRIVATE file: ssh-keygen signs with it, no agent needed.
|
||||||
|
assert.match(gitGet("user.signingkey"), /agenthub-ssh\/id_ed25519_signing$/);
|
||||||
|
|
||||||
|
const repo = path.join(ROOT, "repo");
|
||||||
|
execFileSync("git", ["init", "-q", repo]);
|
||||||
|
const env = { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "me@x" };
|
||||||
|
Object.assign(env, { GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "me@x" });
|
||||||
|
execFileSync("git", ["-C", repo, "commit", "-q", "--allow-empty", "-m", "signed"], { env });
|
||||||
|
const allowed = path.join(ROOT, "allowed");
|
||||||
|
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
|
||||||
|
const status = execFileSync(
|
||||||
|
"git",
|
||||||
|
["-C", repo, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
|
||||||
|
{ encoding: "utf8" }
|
||||||
|
).trim();
|
||||||
|
assert.equal(status, "G");
|
||||||
|
|
||||||
|
// Deleting the signing key stops signing, so commits keep working.
|
||||||
|
await request("DELETE", "/api/me/git/ssh-key?id=id_ed25519_signing");
|
||||||
|
assert.equal(gitGet("user.signingkey"), "");
|
||||||
|
assert.equal(gitGet("commit.gpgsign"), "");
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a signing key the user set themselves unless told to replace it", async () => {
|
||||||
|
execFileSync("git", ["config", "--global", "user.signingkey", "~/.ssh/id_ed25519"]);
|
||||||
|
const k = keygen("mine", ["-t", "ed25519"]);
|
||||||
|
const r = await request("POST", "/api/me/git/ssh-key/import", {
|
||||||
|
privateKey: k.text,
|
||||||
|
sign: true,
|
||||||
|
});
|
||||||
|
// The key is saved; only the switch was refused, and said so.
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
assert.equal(r.body.signError.code, "EFOREIGNSIGN");
|
||||||
|
assert.deepEqual(r.body.signing, { state: "foreign", key: null, path: "~/.ssh/id_ed25519" });
|
||||||
|
const off = await request("PUT", "/api/me/git/signing", { key: null });
|
||||||
|
assert.equal(off.status, 200);
|
||||||
|
assert.equal(gitGet("user.signingkey"), "~/.ssh/id_ed25519");
|
||||||
|
const on = await request("PUT", "/api/me/git/signing", {
|
||||||
|
key: r.body.imported.id,
|
||||||
|
replace: true,
|
||||||
|
});
|
||||||
|
assert.equal(on.status, 200);
|
||||||
|
assert.equal(on.body.signing.state, "on");
|
||||||
|
const stop = await request("PUT", "/api/me/git/signing", { key: null });
|
||||||
|
assert.equal(stop.body.signing.state, "off");
|
||||||
|
assert.equal(gitGet("gpg.format"), "");
|
||||||
|
const none = await request("PUT", "/api/me/git/signing", { key: "id_ed25519_nope" });
|
||||||
|
assert.equal(none.body.error.code, "ENOKEY");
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("PUT /api/me/git/signing/lane", () => {
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
GIT_AUTHOR_NAME: "t",
|
||||||
|
GIT_AUTHOR_EMAIL: "me@x",
|
||||||
|
GIT_COMMITTER_NAME: "t",
|
||||||
|
GIT_COMMITTER_EMAIL: "me@x",
|
||||||
|
};
|
||||||
|
const commit = (dir, msg) =>
|
||||||
|
execFileSync("git", ["-C", dir, "commit", "-q", "--allow-empty", "-m", msg], { env });
|
||||||
|
/** `G` signed and verified, `N` not signed. */
|
||||||
|
const sig = (dir, allowed) =>
|
||||||
|
execFileSync(
|
||||||
|
"git",
|
||||||
|
["-C", dir, "-c", `gpg.ssh.allowedSignersFile=${allowed}`, "log", "-1", "--format=%G?"],
|
||||||
|
{ encoding: "utf8" }
|
||||||
|
).trim();
|
||||||
|
const lane = async (dir) => {
|
||||||
|
const r = await request("POST", "/api/lanes/ensure", { cwd: dir, exact: true });
|
||||||
|
return r.body.lane.id;
|
||||||
|
};
|
||||||
|
|
||||||
|
it("signs one workspace and its sessions' worktrees, never another repo", async () => {
|
||||||
|
const A = path.join(ROOT, "ws-a");
|
||||||
|
const B = path.join(ROOT, "ws-b");
|
||||||
|
const plain = path.join(ROOT, "ws-plain");
|
||||||
|
for (const d of [A, B]) {
|
||||||
|
execFileSync("git", ["init", "-q", d]);
|
||||||
|
commit(d, "first");
|
||||||
|
}
|
||||||
|
fs.mkdirSync(plain);
|
||||||
|
const [a, b, p] = [await lane(A), await lane(B), await lane(plain)];
|
||||||
|
// A user's own include stays whatever we do.
|
||||||
|
execFileSync("git", ["config", "--global", "includeIf.gitdir:/elsewhere/.path", "/mine"]);
|
||||||
|
const k = keygen("lane", ["-t", "ed25519"]);
|
||||||
|
const imp = await request("POST", "/api/me/git/ssh-key/import", {
|
||||||
|
privateKey: k.text,
|
||||||
|
name: "lane",
|
||||||
|
});
|
||||||
|
const key = imp.body.imported.id;
|
||||||
|
const allowed = path.join(ROOT, "allowed-lane");
|
||||||
|
fs.writeFileSync(allowed, `me@x ${k.pub}\n`);
|
||||||
|
|
||||||
|
const on = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
|
||||||
|
assert.equal(on.status, 200);
|
||||||
|
assert.deepEqual(on.body.laneSigning, [{ laneId: a, key }]);
|
||||||
|
// Nothing account-wide: the global settings stay unset.
|
||||||
|
assert.equal(on.body.signing.state, "off");
|
||||||
|
assert.equal(gitGet("commit.gpgsign"), "");
|
||||||
|
commit(A, "in a");
|
||||||
|
commit(B, "in b");
|
||||||
|
assert.equal(sig(A, allowed), "G");
|
||||||
|
assert.equal(sig(B, allowed), "N");
|
||||||
|
// A session's worktree of A is signed; any other worktree of A is not.
|
||||||
|
const wt = path.join(A, ".claude", "worktrees", `ah-${a}-x1`);
|
||||||
|
const other = path.join(ROOT, "other-wt");
|
||||||
|
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s1", wt]);
|
||||||
|
execFileSync("git", ["-C", A, "worktree", "add", "-q", "-b", "s2", other]);
|
||||||
|
commit(wt, "in session worktree");
|
||||||
|
commit(other, "in other worktree");
|
||||||
|
assert.equal(sig(wt, allowed), "G");
|
||||||
|
assert.equal(sig(other, allowed), "N");
|
||||||
|
// Nothing was written into the repository itself.
|
||||||
|
assert.doesNotMatch(fs.readFileSync(path.join(A, ".git", "config"), "utf8"), /gpgsign/);
|
||||||
|
|
||||||
|
// Not a repo: refused; an unknown workspace: 404.
|
||||||
|
const notRepo = await request("PUT", "/api/me/git/signing/lane", { laneId: p, key });
|
||||||
|
assert.equal(notRepo.status, 409);
|
||||||
|
assert.equal(notRepo.body.error.code, "ENOREPO");
|
||||||
|
const gone = await request("PUT", "/api/me/git/signing/lane", { laneId: 9999, key });
|
||||||
|
assert.equal(gone.status, 404);
|
||||||
|
|
||||||
|
// Off: unsigned again, our file gone, the user's include kept.
|
||||||
|
const off = await request("PUT", "/api/me/git/signing/lane", { laneId: a, key: null });
|
||||||
|
assert.deepEqual(off.body.laneSigning, []);
|
||||||
|
commit(A, "after off");
|
||||||
|
assert.equal(sig(A, allowed), "N");
|
||||||
|
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
|
||||||
|
|
||||||
|
// A workspace removed from the dashboard while it signed can still be turned off.
|
||||||
|
await request("PUT", "/api/me/git/signing/lane", { laneId: b, key });
|
||||||
|
await request("POST", `/api/lanes/${b}/remove`, {});
|
||||||
|
const orphan = await request("GET", "/api/me/git");
|
||||||
|
assert.deepEqual(orphan.body.laneSigning, [{ laneId: b, key }]);
|
||||||
|
const stopGone = await request("PUT", "/api/me/git/signing/lane", { laneId: b, key: null });
|
||||||
|
assert.equal(stopGone.status, 200);
|
||||||
|
assert.deepEqual(stopGone.body.laneSigning, []);
|
||||||
|
const b2 = await lane(B);
|
||||||
|
|
||||||
|
// Deleting the key stops every workspace that signs with it.
|
||||||
|
await request("PUT", "/api/me/git/signing/lane", { laneId: a, key });
|
||||||
|
await request("PUT", "/api/me/git/signing/lane", { laneId: b2, key });
|
||||||
|
const del = await request("DELETE", `/api/me/git/ssh-key?id=${key}`);
|
||||||
|
assert.equal(del.status, 200);
|
||||||
|
assert.deepEqual(del.body.laneSigning, []);
|
||||||
|
commit(A, "after the key went");
|
||||||
|
assert.equal(sig(A, allowed), "N");
|
||||||
|
assert.equal(gitGet("includeIf.gitdir:/elsewhere/.path"), "/mine");
|
||||||
|
assert.deepEqual(
|
||||||
|
fs
|
||||||
|
.readdirSync(path.join(ROOT, ".claude", "agenthub-signing"))
|
||||||
|
.filter((n) => n.endsWith(".gitconfig")),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
execFileSync("git", ["config", "--global", "--unset-all", "includeIf.gitdir:/elsewhere/.path"]);
|
||||||
|
await removeAll();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("takes out a signing setup the user made themselves, keeping their key file", async () => {
|
||||||
|
const mine = path.join(ROOT, "my-own-key");
|
||||||
|
fs.writeFileSync(mine, "not ours");
|
||||||
|
execFileSync("git", ["config", "--global", "user.signingkey", mine]);
|
||||||
|
execFileSync("git", ["config", "--global", "commit.gpgsign", "true"]);
|
||||||
|
const kept = await request("PUT", "/api/me/git/signing", { key: null });
|
||||||
|
assert.equal(kept.body.signing.state, "foreign");
|
||||||
|
const r = await request("PUT", "/api/me/git/signing", { key: null, clearForeign: true });
|
||||||
|
assert.equal(r.status, 200);
|
||||||
|
assert.equal(r.body.signing.state, "off");
|
||||||
|
assert.equal(gitGet("user.signingkey"), "");
|
||||||
|
assert.equal(gitGet("commit.gpgsign"), "");
|
||||||
|
assert.equal(fs.readFileSync(mine, "utf8"), "not ours");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
|
* fine, delete/restore work with no workspace, a copy goes home ↔ workspace
|
||||||
* under each side's own rule — and in server mode a developer never reaches
|
* under each side's own rule — and in server mode a developer never reaches
|
||||||
* another user's home while an admin gets it read-only — except its Trash,
|
* another user's home while an admin gets it read-only — except its Trash,
|
||||||
|
* and a resumable upload in parts lands in the home under the same rule,
|
||||||
* which the admin lists, restores from and purges as the member's own.
|
* which the admin lists, restores from and purges as the member's own.
|
||||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
@@ -166,6 +167,38 @@ describe("File Explorer on the home", () => {
|
|||||||
assert.ok(!fs.existsSync(path.join(WS, "id_ed25519")));
|
assert.ok(!fs.existsSync(path.join(WS, "id_ed25519")));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("resumes an upload into the home in parts, under the home rule", async () => {
|
||||||
|
const raw = (method, p, body) =>
|
||||||
|
fetch(`${base}${p}`, {
|
||||||
|
method,
|
||||||
|
headers: { "Content-Type": "application/octet-stream" },
|
||||||
|
body,
|
||||||
|
}).then(async (r) => ({ status: r.status, body: await r.json() }));
|
||||||
|
const id = "home-resume-00000001";
|
||||||
|
const data = Buffer.from("home bytes");
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await raw(
|
||||||
|
"POST",
|
||||||
|
`${H}/upload-part?upload=${id}&offset=0&total=${data.length}`,
|
||||||
|
data.subarray(0, 4)
|
||||||
|
)
|
||||||
|
).body.received,
|
||||||
|
4
|
||||||
|
);
|
||||||
|
assert.equal((await request("GET", `${H}/upload-part?upload=${id}`)).body.received, 4);
|
||||||
|
await raw(
|
||||||
|
"POST",
|
||||||
|
`${H}/upload-part?upload=${id}&offset=4&total=${data.length}`,
|
||||||
|
data.subarray(4)
|
||||||
|
);
|
||||||
|
// The home rule still holds for the staged bytes: no top-level dot entry.
|
||||||
|
assert.equal((await raw("POST", `${H}/upload?path=.ssh/x&staged=${id}`)).status, 403);
|
||||||
|
const ok = await raw("POST", `${H}/upload?path=docs/up.txt&staged=${id}`);
|
||||||
|
assert.equal(ok.status, 201);
|
||||||
|
assert.equal(fs.readFileSync(path.join(HOME, "docs/up.txt"), "utf8"), "home bytes");
|
||||||
|
});
|
||||||
|
|
||||||
it("refuses an unknown home", async () => {
|
it("refuses an unknown home", async () => {
|
||||||
assert.equal((await request("GET", "/api/homes/someone/fs/list")).status, 404);
|
assert.equal((await request("GET", "/api/homes/someone/fs/list")).status, 404);
|
||||||
});
|
});
|
||||||
@@ -256,13 +289,10 @@ describe("an admin manages a member's Trash", () => {
|
|||||||
|
|
||||||
// …and every user's in one list, each item tagged with its home and owner.
|
// …and every user's in one list, each item tagged with its home and owner.
|
||||||
const all = (await call("GET", "/api/homes/trash", dan)).body;
|
const all = (await call("GET", "/api/homes/trash", dan)).body;
|
||||||
assert.deepEqual(
|
assert.deepEqual(all.items.map((i) => [i.name, i.home, i.owner]).sort(), [
|
||||||
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
|
["junk", "cara", "cara"],
|
||||||
[
|
["old", "cara", "cara"],
|
||||||
["junk", "cara", "cara"],
|
]);
|
||||||
["old", "cara", "cara"],
|
|
||||||
]
|
|
||||||
);
|
|
||||||
// A developer's merged view is only their own.
|
// A developer's merged view is only their own.
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
|
(await call("GET", "/api/homes/trash", cara)).body.items.map((i) => i.home),
|
||||||
|
|||||||
@@ -208,6 +208,9 @@ describe("lane-sources instructions and launch", () => {
|
|||||||
// Claude Code hides its task tools from newer models without this, and the
|
// Claude Code hides its task tools from newer models without this, and the
|
||||||
// chat Progress panel is drawn from those calls.
|
// chat Progress panel is drawn from those calls.
|
||||||
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
|
assert.ok(newSession.includes("CLAUDE_CODE_ENABLE_TODO_TOOLS=1"));
|
||||||
|
// One wheel notch reaches claude as one report; it scrolls this many lines.
|
||||||
|
if (!process.env.CLAUDE_CODE_SCROLL_SPEED)
|
||||||
|
assert.ok(newSession.includes("CLAUDE_CODE_SCROLL_SPEED=5"));
|
||||||
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
assert.deepEqual(fs.readdirSync(dir), ["CLAUDE.md"]);
|
||||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||||
assert.match(text, /^- always answer in Vietnamese/);
|
assert.match(text, /^- always answer in Vietnamese/);
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
* server/lib/avatars.js): an account sets, reads and removes its own picture;
|
* server/lib/avatars.js): an account sets, reads and removes its own picture;
|
||||||
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
|
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
|
||||||
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
|
||||||
* another account never sees it through `/api/me`; staff read it at
|
* another account never sees it through `/api/me`; every account reads it at
|
||||||
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
|
* `/api/users/:id/avatar` (a developer too — only the picture, the rest of
|
||||||
|
* `/users` stays staff-only) and a workspace carries
|
||||||
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
|
||||||
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
|
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
|
||||||
* downloaded, and the avatar routes redirect to it; https only, never a
|
* downloaded, and the avatar routes redirect to it; https only, never a
|
||||||
@@ -196,7 +197,7 @@ it("a picture from a link: https only, never a private or local address", async
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
|
it("every account reads another's avatar; workspaces carry owner_avatar_at", async () => {
|
||||||
const ava = await login("ava", "ava-pass-1");
|
const ava = await login("ava", "ava-pass-1");
|
||||||
const ann = await login("ann", "ann-pass-1");
|
const ann = await login("ann", "ann-pass-1");
|
||||||
const bob = await login("bob", "bob-pass-1");
|
const bob = await login("bob", "bob-pass-1");
|
||||||
@@ -210,8 +211,12 @@ it("staff read another account's avatar; workspaces carry owner_avatar_at", asyn
|
|||||||
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
|
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
|
||||||
403
|
200
|
||||||
);
|
);
|
||||||
|
// Only the picture: the rest of /users stays staff-only for a developer.
|
||||||
|
for (const path of [`/api/users/${id}`, `/api/users/${id}/details`, "/api/users"]) {
|
||||||
|
assert.equal((await fetch(BASE + path, { headers: { cookie: bob } })).status, 403, path);
|
||||||
|
}
|
||||||
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
|
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
|
||||||
.lanes;
|
.lanes;
|
||||||
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
|
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ const STAFF_ONLY_PREFIXES = [
|
|||||||
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
|
||||||
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
// lower-cased form: the host's own AI-tool config files (they may hold keys).
|
||||||
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
|
||||||
|
// The one read under a staff-only prefix every signed-in user may make: an
|
||||||
|
// account's PICTURE (`GET /users/:id/avatar` — the image or a redirect to its
|
||||||
|
// link, nothing else of the account). System Health shows every user's name and
|
||||||
|
// `avatarAt` to everyone, so a developer refused here saw only initials.
|
||||||
|
const EVERYONE_READS = [/^\/users\/\d+\/avatar$/];
|
||||||
|
|
||||||
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
|
||||||
|
|
||||||
@@ -789,6 +794,7 @@ function authGuard(req, res, next) {
|
|||||||
const isRead = req.method === "GET" || req.method === "HEAD";
|
const isRead = req.method === "GET" || req.method === "HEAD";
|
||||||
if (
|
if (
|
||||||
user.role === "developer" &&
|
user.role === "developer" &&
|
||||||
|
!(isRead && EVERYONE_READS.some((re) => re.test(lp))) &&
|
||||||
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
|
||||||
) {
|
) {
|
||||||
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
|
||||||
|
|||||||
@@ -634,16 +634,26 @@ async function purgeTrash(dir, userKey, id) {
|
|||||||
|
|
||||||
// ── Upload ─────────────────────────────────────────────────────────────────
|
// ── Upload ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** A pass-through that fails once more than `max` bytes went by. */
|
/** A pass-through that fails once more than `max` bytes went by (`seen()`: the count). */
|
||||||
function capped(max) {
|
function capped(max) {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
return new Transform({
|
const t = new Transform({
|
||||||
transform(chunk, _enc, cb) {
|
transform(chunk, _enc, cb) {
|
||||||
n += chunk.length;
|
n += chunk.length;
|
||||||
if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
|
if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
|
||||||
cb(null, chunk);
|
cb(null, chunk);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
t.seen = () => n;
|
||||||
|
return t;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A body that stopped short of its announced length (a dropped connection). */
|
||||||
|
function shortBody(limit, length) {
|
||||||
|
if (length == null || length === "") return null;
|
||||||
|
const want = Number(length);
|
||||||
|
if (!Number.isSafeInteger(want) || limit.seen() === want) return null;
|
||||||
|
return bad("EBADBODY", `the upload ended early (${limit.seen()} of ${want} bytes)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -677,16 +687,41 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
|||||||
}
|
}
|
||||||
const acct = accountAt(loc.abs);
|
const acct = accountAt(loc.abs);
|
||||||
const limit = capped(UPLOAD_MAX_BYTES);
|
const limit = capped(UPLOAD_MAX_BYTES);
|
||||||
if (acct) {
|
const feed = (done) =>
|
||||||
// write-as `replace`: a new file renamed over the name, as the member.
|
Promise.all([
|
||||||
const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit });
|
|
||||||
await Promise.all([
|
|
||||||
pipeline(stream, limit).catch((e) => {
|
pipeline(stream, limit).catch((e) => {
|
||||||
limit.destroy(e);
|
limit.destroy(e);
|
||||||
throw e;
|
throw e;
|
||||||
}),
|
}),
|
||||||
done,
|
done,
|
||||||
]);
|
]);
|
||||||
|
if (acct && runAs.writeAsCanMove()) {
|
||||||
|
// As the member: a hidden temp file, moved onto the name only once every
|
||||||
|
// byte arrived. write-as cannot tell a dropped connection (or a server
|
||||||
|
// that died mid-upload) from the end of the file — writing the name
|
||||||
|
// itself would leave a truncated file there under its real name.
|
||||||
|
const tmp = path.join(
|
||||||
|
loc.parent.abs,
|
||||||
|
`.${loc.name}.agenthub-upload-${crypto.randomBytes(4).toString("hex")}`
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await feed(runAs.writeAsAsync(acct, tmp, [], { input: limit }));
|
||||||
|
const short = shortBody(limit, length);
|
||||||
|
if (short) throw short;
|
||||||
|
// `move` never lands on an existing name: one taken meanwhile is refused.
|
||||||
|
await runAs.writeAsAsync(acct, tmp, ["move", loc.abs]).catch((e) => {
|
||||||
|
if (/exists/.test(e.message)) {
|
||||||
|
throw bad("EEXISTS", `${loc.rel} already exists`, { names: [loc.name] });
|
||||||
|
}
|
||||||
|
throw e;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
await runAs.writeAsAsync(acct, tmp, ["remove"]).catch(() => {});
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
} else if (acct) {
|
||||||
|
// An older write-as without `move`: `replace` (a new file renamed over the name).
|
||||||
|
await feed(runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit }));
|
||||||
} else {
|
} else {
|
||||||
const tmp = path.join(
|
const tmp = path.join(
|
||||||
loc.parent.abs,
|
loc.parent.abs,
|
||||||
@@ -694,6 +729,8 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
|||||||
);
|
);
|
||||||
try {
|
try {
|
||||||
await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" }));
|
await pipeline(stream, limit, fs.createWriteStream(tmp, { flags: "wx" }));
|
||||||
|
const short = shortBody(limit, length);
|
||||||
|
if (short) throw short;
|
||||||
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
|
// Linked, not renamed: a name taken meanwhile is refused, never replaced.
|
||||||
await fs.promises.link(tmp, loc.abs).catch(async (e) => {
|
await fs.promises.link(tmp, loc.abs).catch(async (e) => {
|
||||||
if (e.code === "EEXIST")
|
if (e.code === "EEXIST")
|
||||||
|
|||||||
@@ -190,6 +190,11 @@ function makeKeyPair(comment) {
|
|||||||
const pair = crypto.generateKeyPairSync("ed25519");
|
const pair = crypto.generateKeyPairSync("ed25519");
|
||||||
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
|
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
|
||||||
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
|
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
|
||||||
|
return encodeEd25519(seed, pub, comment);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An ed25519 key (32-byte seed + public half) in OpenSSH's own formats. */
|
||||||
|
function encodeEd25519(seed, pub, comment) {
|
||||||
const type = Buffer.from("ssh-ed25519");
|
const type = Buffer.from("ssh-ed25519");
|
||||||
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
|
const pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
|
||||||
const check = crypto.randomBytes(4);
|
const check = crypto.randomBytes(4);
|
||||||
@@ -220,6 +225,182 @@ function makeKeyPair(comment) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** `ssh-rsa …` for an RSA key object (private or public). */
|
||||||
|
function rsaPublicLine(key, comment) {
|
||||||
|
const jwk = crypto.createPublicKey(key).export({ format: "jwk" });
|
||||||
|
const blob = Buffer.concat([
|
||||||
|
sshString(Buffer.from("ssh-rsa")),
|
||||||
|
mpint(Buffer.from(jwk.e, "base64url")),
|
||||||
|
mpint(Buffer.from(jwk.n, "base64url")),
|
||||||
|
]);
|
||||||
|
return `ssh-rsa ${blob.toString("base64")} ${comment}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── An imported key ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** A pasted key file is a few KB; anything far past that is not one. */
|
||||||
|
const IMPORT_MAX = 32 * 1024;
|
||||||
|
const PASSPHRASE_HINT =
|
||||||
|
"this key has a passphrase — a commit made from the dashboard has nowhere to type it. " +
|
||||||
|
'Make a copy without one on your own machine (ssh-keygen -p -N "" -f <copy>) and import the copy';
|
||||||
|
|
||||||
|
/** Reads SSH wire strings out of a buffer; EBADKEY on a short one. */
|
||||||
|
function wireReader(buf) {
|
||||||
|
let at = 0;
|
||||||
|
const need = (n) => {
|
||||||
|
if (at + n > buf.length) throw err("EBADKEY", "not a valid SSH private key");
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
u32() {
|
||||||
|
need(4);
|
||||||
|
const v = buf.readUInt32BE(at);
|
||||||
|
at += 4;
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
bytes() {
|
||||||
|
const n = this.u32();
|
||||||
|
need(n);
|
||||||
|
const v = buf.subarray(at, at + n);
|
||||||
|
at += n;
|
||||||
|
return v;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const b64u = (b) => Buffer.from(b).toString("base64url");
|
||||||
|
const big = (b) => BigInt(`0x${Buffer.from(b).toString("hex") || "0"}`);
|
||||||
|
const unbig = (n) => {
|
||||||
|
let h = n.toString(16);
|
||||||
|
if (h.length % 2) h = `0${h}`;
|
||||||
|
return Buffer.from(h, "hex");
|
||||||
|
};
|
||||||
|
/** An SSH mpint's magnitude without its sign byte. */
|
||||||
|
const mag = (b) => (b[0] === 0 && b.length > 1 ? b.subarray(1) : b);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An `openssh-key-v1` file (`BEGIN OPENSSH PRIVATE KEY`) as a KeyObject plus
|
||||||
|
* its comment. Encrypted ones are refused (EKEYPASSPHRASE): reading them
|
||||||
|
* needs the passphrase, and signing from the dashboard would need it again.
|
||||||
|
*/
|
||||||
|
function parseOpenSsh(text) {
|
||||||
|
const b64 = text.replace(/-----(BEGIN|END) OPENSSH PRIVATE KEY-----/g, "").replace(/\s+/g, "");
|
||||||
|
const buf = Buffer.from(b64, "base64");
|
||||||
|
const magic = Buffer.from("openssh-key-v1\0");
|
||||||
|
if (!buf.subarray(0, magic.length).equals(magic)) {
|
||||||
|
throw err("EBADKEY", "not a valid SSH private key");
|
||||||
|
}
|
||||||
|
const r = wireReader(buf.subarray(magic.length));
|
||||||
|
const cipher = r.bytes().toString();
|
||||||
|
r.bytes(); // kdf name
|
||||||
|
r.bytes(); // kdf options
|
||||||
|
if (cipher !== "none") throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
|
||||||
|
if (r.u32() !== 1) throw err("EBADKEY", "a key file holding one key");
|
||||||
|
r.bytes(); // the public blob — checked against the private half below
|
||||||
|
const p = wireReader(r.bytes());
|
||||||
|
if (p.u32() !== p.u32()) throw err("EBADKEY", "not a valid SSH private key");
|
||||||
|
const type = p.bytes().toString();
|
||||||
|
if (type === "ssh-ed25519") {
|
||||||
|
const pub = p.bytes();
|
||||||
|
const sk = p.bytes();
|
||||||
|
if (pub.length !== 32 || sk.length !== 64 || !sk.subarray(32).equals(pub)) {
|
||||||
|
throw err("EBADKEY", "not a valid SSH private key");
|
||||||
|
}
|
||||||
|
const key = crypto.createPrivateKey({
|
||||||
|
key: { kty: "OKP", crv: "Ed25519", d: b64u(sk.subarray(0, 32)), x: b64u(pub) },
|
||||||
|
format: "jwk",
|
||||||
|
});
|
||||||
|
return { key, comment: p.bytes().toString("utf8") };
|
||||||
|
}
|
||||||
|
if (type === "ssh-rsa") {
|
||||||
|
const n = mag(p.bytes());
|
||||||
|
const e = mag(p.bytes());
|
||||||
|
const d = mag(p.bytes());
|
||||||
|
const qi = mag(p.bytes());
|
||||||
|
const pp = mag(p.bytes());
|
||||||
|
const q = mag(p.bytes());
|
||||||
|
const D = big(d);
|
||||||
|
const key = crypto.createPrivateKey({
|
||||||
|
key: {
|
||||||
|
kty: "RSA",
|
||||||
|
n: b64u(n),
|
||||||
|
e: b64u(e),
|
||||||
|
d: b64u(d),
|
||||||
|
p: b64u(pp),
|
||||||
|
q: b64u(q),
|
||||||
|
dp: b64u(unbig(D % (big(pp) - 1n))),
|
||||||
|
dq: b64u(unbig(D % (big(q) - 1n))),
|
||||||
|
qi: b64u(qi),
|
||||||
|
},
|
||||||
|
format: "jwk",
|
||||||
|
});
|
||||||
|
return { key, comment: p.bytes().toString("utf8") };
|
||||||
|
}
|
||||||
|
throw err("EBADKEYTYPE", `an ${type.replace(/^ssh-/, "")} key — import an ed25519 or RSA one`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A pasted or uploaded private key → `{type, privateKey, publicKey}` in the
|
||||||
|
* same formats a generated key is written in. Takes OpenSSH's own format and
|
||||||
|
* the PEM ones (PKCS#1, PKCS#8); refuses an encrypted key (EKEYPASSPHRASE),
|
||||||
|
* anything but ed25519 or RSA ≥ 2048 bits (EBADKEYTYPE), and a key whose
|
||||||
|
* halves do not sign and verify as one (EBADKEY).
|
||||||
|
*/
|
||||||
|
function parsePrivateKey(text, fallbackComment) {
|
||||||
|
const src = typeof text === "string" ? text.replace(/\r\n?/g, "\n").trim() : "";
|
||||||
|
if (!src || src.length > IMPORT_MAX || !/-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/.test(src)) {
|
||||||
|
throw err(
|
||||||
|
"EBADKEY",
|
||||||
|
/^ssh-|^ecdsa-/.test(src)
|
||||||
|
? "that is a PUBLIC key (.pub) — import the private key file next to it"
|
||||||
|
: "not an SSH private key — the file starts with -----BEGIN … PRIVATE KEY-----"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let key;
|
||||||
|
let comment = "";
|
||||||
|
if (/-----BEGIN OPENSSH PRIVATE KEY-----/.test(src)) {
|
||||||
|
({ key, comment } = parseOpenSsh(src));
|
||||||
|
} else {
|
||||||
|
if (/ENCRYPTED/.test(src)) throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
|
||||||
|
try {
|
||||||
|
key = crypto.createPrivateKey(src);
|
||||||
|
} catch (e) {
|
||||||
|
if (/passphrase/i.test(String(e?.code || e?.message))) {
|
||||||
|
throw err("EKEYPASSPHRASE", PASSPHRASE_HINT);
|
||||||
|
}
|
||||||
|
throw err("EBADKEY", "not a valid SSH private key");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const kind = key.asymmetricKeyType;
|
||||||
|
if (kind !== "ed25519" && kind !== "rsa") {
|
||||||
|
throw err("EBADKEYTYPE", `an ${kind || "unknown"} key — import an ed25519 or RSA one`);
|
||||||
|
}
|
||||||
|
if (kind === "rsa" && (key.asymmetricKeyDetails?.modulusLength ?? 0) < 2048) {
|
||||||
|
throw err("EBADKEYTYPE", "an RSA key under 2048 bits — too weak to sign with");
|
||||||
|
}
|
||||||
|
// The halves belong together: a mangled paste fails here, not at commit time.
|
||||||
|
const probe = Buffer.from("agenthub key check");
|
||||||
|
const hash = kind === "rsa" ? "sha256" : null;
|
||||||
|
if (!crypto.verify(hash, probe, crypto.createPublicKey(key), crypto.sign(hash, probe, key))) {
|
||||||
|
throw err("EBADKEY", "the key's private and public halves do not match");
|
||||||
|
}
|
||||||
|
const label =
|
||||||
|
String(comment || fallbackComment || "agenthub")
|
||||||
|
.replace(/[^\w.@+-]/g, "")
|
||||||
|
.slice(0, 64) || "agenthub";
|
||||||
|
if (kind === "ed25519") {
|
||||||
|
const jwk = key.export({ format: "jwk" });
|
||||||
|
return {
|
||||||
|
type: "ed25519",
|
||||||
|
...encodeEd25519(Buffer.from(jwk.d, "base64url"), Buffer.from(jwk.x, "base64url"), label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
type: "rsa",
|
||||||
|
privateKey: key.export({ type: "pkcs1", format: "pem" }),
|
||||||
|
publicKey: rsaPublicLine(key, label),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
|
/** `SHA256:…` as `ssh-keygen -l` prints it, from a public key line. */
|
||||||
function fingerprint(publicKey) {
|
function fingerprint(publicKey) {
|
||||||
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
|
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
|
||||||
@@ -321,6 +502,243 @@ async function generateSshKey(
|
|||||||
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a key the user already has (`privateKey`: the file's text) as a
|
||||||
|
* NAMED key beside the generated ones — `name`, else `signing`, `signing-2`…
|
||||||
|
* Written exactly like a generated key (0600, as the member), so it serves
|
||||||
|
* clones and pushes too; only its public half is ever returned. The same key
|
||||||
|
* twice is EEXISTS, naming the key that holds it.
|
||||||
|
*/
|
||||||
|
async function importSshKey(account, { privateKey, name = null, comment } = {}) {
|
||||||
|
const parsed = parsePrivateKey(privateKey, comment);
|
||||||
|
const keys = currentKeys(account);
|
||||||
|
const fp = fingerprint(parsed.publicKey);
|
||||||
|
const twin = keys.find((k) => fingerprint(k.publicKey) === fp);
|
||||||
|
if (twin) throw err("EEXISTS", `this key is already here as ${twin.name ?? twin.type}`);
|
||||||
|
let keyName = name == null || name === "" ? null : String(name).trim().toLowerCase();
|
||||||
|
if (keyName === null) {
|
||||||
|
keyName = "signing";
|
||||||
|
for (let n = 2; keys.some((k) => k.name === keyName); n++) keyName = `signing-${n}`;
|
||||||
|
}
|
||||||
|
if (!KEY_NAME_RE.test(keyName)) {
|
||||||
|
throw err("EBADKEYNAME", "a key name of lowercase letters, digits and -, up to 32");
|
||||||
|
}
|
||||||
|
if (keys.some((k) => k.name === keyName))
|
||||||
|
throw err("EEXISTS", `a key named ${keyName} already exists`);
|
||||||
|
if (keys.length >= MAX_KEYS) throw err("ETOOMANYKEYS", `at most ${MAX_KEYS} SSH keys`);
|
||||||
|
const id = keyIdFor(parsed.type, keyName);
|
||||||
|
const file = path.join(paths(account).sshDir, id);
|
||||||
|
writeText(account, file, parsed.privateKey, { secret: true });
|
||||||
|
writeText(account, `${file}.pub`, parsed.publicKey, { secret: false });
|
||||||
|
if (account.kind === "member" || (await terminalSshState(account)) === "on") {
|
||||||
|
await setTerminalSsh(account, true).catch(() => {});
|
||||||
|
}
|
||||||
|
return sshKeysInfo(account).find((k) => k.id === id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit signing (`~/.gitconfig` gpg.format / user.signingkey) ───────────
|
||||||
|
|
||||||
|
/** One global git config value, or "" when unset. */
|
||||||
|
async function globalGet(account, key) {
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
return git(homeOf(account), ["config", "--global", "--get", key]).then(
|
||||||
|
(r) => r.stdout.trim(),
|
||||||
|
() => ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the account's commits are signed with one of ITS AgentHub keys:
|
||||||
|
* `{state: "on", key}` (gpg.format ssh, user.signingkey = that key's private
|
||||||
|
* file — ssh-keygen signs with the private file, a `.pub` needs an agent the
|
||||||
|
* dashboard has not got — and commit.gpgsign true), `{state: "off"}`, or
|
||||||
|
* `{state: "foreign", path}` when the user set a signing key of their own
|
||||||
|
* (never touched without `replace`).
|
||||||
|
*/
|
||||||
|
async function signingState(account) {
|
||||||
|
const signingKey = await globalGet(account, "user.signingkey");
|
||||||
|
if (!signingKey) return { state: "off", key: null, path: null };
|
||||||
|
const k = currentKeys(account).find((x) => x.file === signingKey);
|
||||||
|
// Not one of our keys — the user's own, or one of ours deleted by hand
|
||||||
|
// (commits then fail): either way show the path, so a key can be picked.
|
||||||
|
if (!k) return { state: "foreign", key: null, path: signingKey };
|
||||||
|
const on =
|
||||||
|
(await globalGet(account, "gpg.format")) === "ssh" &&
|
||||||
|
(await globalGet(account, "commit.gpgsign")) === "true";
|
||||||
|
return { state: on ? "on" : "off", key: on ? k.id : null, path: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign every commit of the account with key `id` (null = stop signing).
|
||||||
|
* Over a signing key the user set themselves only with `replace`
|
||||||
|
* (EFOREIGNSIGN); off removes only our own settings. No value is a secret —
|
||||||
|
* the key's PATH crosses sudo's argv, never its content.
|
||||||
|
*/
|
||||||
|
async function setSigning(account, { id = null, replace = false, clearForeign = false } = {}) {
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
const home = homeOf(account);
|
||||||
|
const state = await signingState(account);
|
||||||
|
const unset = (key) =>
|
||||||
|
git(home, ["config", "--global", "--unset", key]).catch(() => {
|
||||||
|
/* already unset */
|
||||||
|
});
|
||||||
|
if (id == null) {
|
||||||
|
// The user's own setup is left alone unless they asked to take it out
|
||||||
|
// (`clearForeign`): only the three settings go, never their key file.
|
||||||
|
if (state.state === "foreign" && !clearForeign) return state;
|
||||||
|
if ((await globalGet(account, "user.signingkey")) !== "") {
|
||||||
|
await unset("commit.gpgsign");
|
||||||
|
await unset("user.signingkey");
|
||||||
|
await unset("gpg.format");
|
||||||
|
}
|
||||||
|
return signingState(account);
|
||||||
|
}
|
||||||
|
const k = currentKeys(account).find((x) => x.id === id);
|
||||||
|
if (!k) throw err("ENOKEY", "no such SSH key");
|
||||||
|
if (state.state === "foreign" && !replace) {
|
||||||
|
throw err(
|
||||||
|
"EFOREIGNSIGN",
|
||||||
|
`your ~/.gitconfig already signs with ${state.path} — confirm to switch to ${k.name ?? k.type}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await git(home, ["config", "--global", "gpg.format", "ssh"]);
|
||||||
|
await git(home, ["config", "--global", "user.signingkey", k.file]);
|
||||||
|
await git(home, ["config", "--global", "commit.gpgsign", "true"]);
|
||||||
|
return signingState(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Commit signing per workspace (`includeIf "gitdir:…"`) ──────────────────
|
||||||
|
//
|
||||||
|
// A workspace is signed by a small config file of OURS per workspace
|
||||||
|
// (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign),
|
||||||
|
// pulled into the account's `~/.gitconfig` by `includeIf "gitdir:<dir>"` for
|
||||||
|
// the workspace's own git dir and for the git dirs of the worktrees its
|
||||||
|
// sessions make (`ah-<id>-*`, pty-run.js). Every git that reads the account's
|
||||||
|
// config — the terminal, the Changes panel, an agent — then signs there and
|
||||||
|
// nowhere else; nothing is written into the user's repository.
|
||||||
|
|
||||||
|
const LANE_SIGN_RE = /^lane-(\d+)\.gitconfig$/;
|
||||||
|
|
||||||
|
/** Where the per-workspace files live: ours, beside the account's keys. */
|
||||||
|
function signingDir(account) {
|
||||||
|
return account.kind === "member"
|
||||||
|
? path.join(paths(account).base, "signing")
|
||||||
|
: path.join(getClaudeHome(), "agenthub-signing");
|
||||||
|
}
|
||||||
|
const laneSignFile = (account, laneId) =>
|
||||||
|
path.join(signingDir(account), `lane-${Number(laneId)}.gitconfig`);
|
||||||
|
|
||||||
|
/** `[{key, value}]` of the `includeIf.*.path` lines naming one of OUR files. */
|
||||||
|
async function ourIncludes(account) {
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
const out = await git(homeOf(account), [
|
||||||
|
"config",
|
||||||
|
"--global",
|
||||||
|
"--null",
|
||||||
|
"--get-regexp",
|
||||||
|
"^includeif\\..*\\.path$",
|
||||||
|
]).then(
|
||||||
|
(r) => r.stdout,
|
||||||
|
() => ""
|
||||||
|
);
|
||||||
|
const dir = signingDir(account);
|
||||||
|
const rows = [];
|
||||||
|
// `--null`: "key\nvalue\0" — a git dir or a path may hold spaces.
|
||||||
|
for (const rec of out.split("\0")) {
|
||||||
|
const at = rec.indexOf("\n");
|
||||||
|
if (at <= 0) continue;
|
||||||
|
const key = rec.slice(0, at);
|
||||||
|
const value = rec.slice(at + 1);
|
||||||
|
if (path.dirname(value) === dir && LANE_SIGN_RE.test(path.basename(value))) {
|
||||||
|
rows.push({ key, value });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take every include of `file` out of `~/.gitconfig` (the user's own includes stay). */
|
||||||
|
async function dropIncludes(account, file) {
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
for (const r of await ourIncludes(account)) {
|
||||||
|
if (r.value !== file) continue;
|
||||||
|
await git(homeOf(account), [
|
||||||
|
"config",
|
||||||
|
"--global",
|
||||||
|
"--fixed-value",
|
||||||
|
"--unset-all",
|
||||||
|
r.key,
|
||||||
|
r.value,
|
||||||
|
]).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The key file a workspace file signs with (its `signingkey` line), or null. */
|
||||||
|
function laneSignKeyFile(account, file) {
|
||||||
|
const text = readText(account, file) || "";
|
||||||
|
const m = /^\s*signingkey\s*=\s*"?(.*?)"?\s*$/m.exec(text);
|
||||||
|
return m ? m[1].replace(/\\(["\\])/g, "$1") : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which workspaces sign their commits, and with which key:
|
||||||
|
* `[{laneId, key}]` (`key` null when the key file it names is gone).
|
||||||
|
*/
|
||||||
|
async function laneSigningList(account) {
|
||||||
|
const keys = currentKeys(account);
|
||||||
|
const seen = new Map();
|
||||||
|
for (const r of await ourIncludes(account)) {
|
||||||
|
const laneId = Number(LANE_SIGN_RE.exec(path.basename(r.value))[1]);
|
||||||
|
if (seen.has(laneId)) continue;
|
||||||
|
const keyFile = laneSignKeyFile(account, r.value);
|
||||||
|
seen.set(laneId, keys.find((k) => k.file === keyFile)?.id ?? null);
|
||||||
|
}
|
||||||
|
return [...seen].map(([laneId, key]) => ({ laneId, key })).sort((a, b) => a.laneId - b.laneId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const gitQuote = (v) => `"${String(v).replace(/(["\\])/g, "\\$1")}"`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the commits of workspace `laneId` (folder `cwd`) with key `id`, or stop
|
||||||
|
* (`id` null). The git dirs come from git itself, run in the workspace's
|
||||||
|
* folder as its account — never from the request. ENOREPO when the folder is
|
||||||
|
* not a git repository.
|
||||||
|
*/
|
||||||
|
async function setLaneSigning(account, { laneId, cwd, id = null }) {
|
||||||
|
const n = Number(laneId);
|
||||||
|
if (!Number.isSafeInteger(n) || n <= 0) throw err("ENOTFOUND", "no such workspace");
|
||||||
|
const file = laneSignFile(account, n);
|
||||||
|
await dropIncludes(account, file);
|
||||||
|
if (id == null) {
|
||||||
|
removeFile(account, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const k = currentKeys(account).find((x) => x.id === id);
|
||||||
|
if (!k) throw err("ENOKEY", "no such SSH key");
|
||||||
|
const { git } = require("./worktree");
|
||||||
|
const rev = (args) => git(cwd, ["rev-parse", ...args]).then((r) => r.stdout.trim());
|
||||||
|
let gitDir;
|
||||||
|
let commonDir;
|
||||||
|
try {
|
||||||
|
gitDir = await rev(["--absolute-git-dir"]);
|
||||||
|
commonDir = await rev(["--path-format=absolute", "--git-common-dir"]);
|
||||||
|
} catch {
|
||||||
|
throw err("ENOREPO", "this workspace's folder is not a git repository");
|
||||||
|
}
|
||||||
|
writeText(
|
||||||
|
account,
|
||||||
|
file,
|
||||||
|
`# Written by AgentHub: commit signing for workspace ${n} (Settings → Git).\n` +
|
||||||
|
`[gpg]\n\tformat = ssh\n[user]\n\tsigningkey = ${gitQuote(k.file)}\n` +
|
||||||
|
`[commit]\n\tgpgsign = true\n[tag]\n\tgpgsign = false\n`,
|
||||||
|
{ secret: false }
|
||||||
|
);
|
||||||
|
const add = (pattern) =>
|
||||||
|
git(homeOf(account), ["config", "--global", "--add", `includeIf.gitdir:${pattern}.path`, file]);
|
||||||
|
await add(gitDir);
|
||||||
|
// The worktrees this workspace's sessions make (`claude --worktree=ah-<id>-…`).
|
||||||
|
await add(path.join(commonDir, "worktrees", `ah-${n}-*`));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
* Delete one key — `{id}`, or the DEFAULT key of `{type}` — or every key when
|
||||||
* neither is given; false when there was none. A login naming a deleted key
|
* neither is given; false when there was none. A login naming a deleted key
|
||||||
@@ -335,6 +753,18 @@ async function removeSshKey(account, which) {
|
|||||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||||
);
|
);
|
||||||
if (!gone.length) return false;
|
if (!gone.length) return false;
|
||||||
|
// Commits signed with a key about to go would all fail: stop signing first —
|
||||||
|
// account-wide, and in every workspace that signs with it.
|
||||||
|
const signingKey = await globalGet(account, "user.signingkey");
|
||||||
|
if (signingKey && gone.some((k) => k.file === signingKey)) {
|
||||||
|
await setSigning(account, { id: null }).catch(() => {});
|
||||||
|
}
|
||||||
|
const goneIds = new Set(gone.map((k) => k.id));
|
||||||
|
for (const l of await laneSigningList(account)) {
|
||||||
|
if (goneIds.has(l.key)) {
|
||||||
|
await setLaneSigning(account, { laneId: l.laneId, id: null }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
for (const k of gone) {
|
for (const k of gone) {
|
||||||
removeFile(account, k.file);
|
removeFile(account, k.file);
|
||||||
removeFile(account, `${k.file}.pub`);
|
removeFile(account, `${k.file}.pub`);
|
||||||
@@ -1133,6 +1563,12 @@ module.exports = {
|
|||||||
sshKeyInfo,
|
sshKeyInfo,
|
||||||
sshKeysInfo,
|
sshKeysInfo,
|
||||||
generateSshKey,
|
generateSshKey,
|
||||||
|
importSshKey,
|
||||||
|
parsePrivateKey,
|
||||||
|
signingState,
|
||||||
|
laneSigningList,
|
||||||
|
setLaneSigning,
|
||||||
|
setSigning,
|
||||||
removeSshKey,
|
removeSshKey,
|
||||||
terminalSshCommand,
|
terminalSshCommand,
|
||||||
terminalSshState,
|
terminalSshState,
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ const RUN_ID_RE = /^agenthub-lane-(\d+)(?:-([1-9]))?$/;
|
|||||||
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
|
/** Highest pane slot a lane may use: ten sessions per workspace, 0–9 (the
|
||||||
* Workspace groups them into tabs of up to four split panes each). */
|
* Workspace groups them into tabs of up to four split panes each). */
|
||||||
const MAX_PANE_SLOT = 9;
|
const MAX_PANE_SLOT = 9;
|
||||||
|
// Lines a fullscreen `claude` scrolls per wheel report (see spawnRun's env).
|
||||||
|
const CLAUDE_SCROLL_SPEED = "5";
|
||||||
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
const EFFORT_LEVELS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
||||||
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
// `auto` is the CLI's own default-ish mode (`claude --permission-mode auto`):
|
||||||
// it asks only for what its classifier reads as risky. `default` is kept for
|
// it asks only for what its classifier reads as risky. `default` is kept for
|
||||||
@@ -663,6 +665,15 @@ function spawnRun(args) {
|
|||||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||||
// wins, so `"0"` there turns them back off.
|
// wins, so `"0"` there turns them back off.
|
||||||
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
...(entry.launcher === "claude" ? { CLAUDE_CODE_ENABLE_TODO_TOOLS: "1" } : {}),
|
||||||
|
// Fullscreen claude scrolls by itself, and through tmux it cannot tell it
|
||||||
|
// runs in xterm.js (XTVERSION answers tmux), so it takes ONE line per wheel
|
||||||
|
// report — while xterm.js sends one report per notch (and damps a trackpad
|
||||||
|
// ×0.3). A notch moved a single line. CLAUDE_CODE_SCROLL_SPEED is Claude's
|
||||||
|
// own multiplier; the dashboard's env or the user's settings.json `env`
|
||||||
|
// (or /config → Scroll speed) still wins.
|
||||||
|
...(entry.launcher === "claude" && !process.env.CLAUDE_CODE_SCROLL_SPEED
|
||||||
|
? { CLAUDE_CODE_SCROLL_SPEED: CLAUDE_SCROLL_SPEED }
|
||||||
|
: {}),
|
||||||
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
// No CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN: an inline claude duplicated its
|
||||||
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
// output whenever the pane narrowed (tmux re-wraps the full-width rules and
|
||||||
// pushes lines into history its redraw cannot clear). The user's own
|
// pushes lines into history its redraw cannot clear). The user's own
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
/**
|
||||||
|
* @file Resumable uploads for the File Explorer: an upload's bytes are sent in
|
||||||
|
* parts and kept on the HOST's disk under
|
||||||
|
* `<data dir>/upload-staging/<user key>/<id>/` (0700/0600; never in a member's
|
||||||
|
* home), so a restart of the dashboard — an update, a manual restart, a crash —
|
||||||
|
* only costs the part in flight: the browser asks how much arrived (`status`)
|
||||||
|
* and sends the rest. Only when every byte is here is the file written where
|
||||||
|
* it goes, by the SAME `upload`/`uploadBatch` code (and guards) as a one-shot
|
||||||
|
* upload, reading the staged file instead of the request (`commit`).
|
||||||
|
*
|
||||||
|
* `<id>` is the browser's own random name for one upload; ids live per user
|
||||||
|
* key, so one user can never read, extend or commit another's. A part must
|
||||||
|
* start exactly where the staged bytes end (`EOFFSET` with `received`), one
|
||||||
|
* request per id at a time (`EBUSY`), never past the `total` announced first.
|
||||||
|
* A committed upload keeps its answer (`result.json`) and drops its bytes, so
|
||||||
|
* a commit whose answer a restart swallowed is answered again, never written
|
||||||
|
* twice; a failed commit (a taken name, say) keeps the bytes for a retry with
|
||||||
|
* another `conflict`. At most `MAX_OPEN` unfinished uploads per user; anything
|
||||||
|
* untouched for `KEEP_MS` is swept.
|
||||||
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use strict";
|
||||||
|
|
||||||
|
const fs = require("node:fs");
|
||||||
|
const path = require("node:path");
|
||||||
|
const { Transform } = require("node:stream");
|
||||||
|
const { pipeline } = require("node:stream/promises");
|
||||||
|
|
||||||
|
const { getDataDir } = require("./claude-home");
|
||||||
|
|
||||||
|
/** The largest upload (the browser sends a whole file — or a batch — under one id). */
|
||||||
|
const TOTAL_MAX = 512 * 1024 * 1024;
|
||||||
|
/** The largest part one request may carry. */
|
||||||
|
const PART_MAX = 16 * 1024 * 1024;
|
||||||
|
const MAX_OPEN = 8;
|
||||||
|
const KEEP_MS = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
const ID_RE = /^[A-Za-z0-9_-]{16,64}$/;
|
||||||
|
const KEY_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
||||||
|
|
||||||
|
const bad = (code, message, extra = {}) => Object.assign(new Error(message), { code, ...extra });
|
||||||
|
|
||||||
|
/** Ids (`<key>/<id>`) a request in THIS process is working on. */
|
||||||
|
const busy = new Set();
|
||||||
|
|
||||||
|
const root = () => path.join(getDataDir(), "upload-staging");
|
||||||
|
|
||||||
|
function dirOf(userKey, id) {
|
||||||
|
const key = String(userKey ?? "");
|
||||||
|
if (!KEY_RE.test(key)) throw bad("EBADBODY", "no user for this upload");
|
||||||
|
if (typeof id !== "string" || !ID_RE.test(id)) {
|
||||||
|
throw bad("EBADBODY", "an upload id is 16–64 letters, digits, '-' or '_'");
|
||||||
|
}
|
||||||
|
return { key, id, dir: path.join(root(), key, id), lock: `${key}/${id}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataFile = (d) => path.join(d.dir, "data");
|
||||||
|
const metaFile = (d) => path.join(d.dir, "meta.json");
|
||||||
|
const resultFile = (d) => path.join(d.dir, "result.json");
|
||||||
|
|
||||||
|
function readJson(file) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(file, "utf8"));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeJson(file, value) {
|
||||||
|
const tmp = `${file}.tmp`;
|
||||||
|
fs.writeFileSync(tmp, JSON.stringify(value), { mode: 0o600 });
|
||||||
|
fs.renameSync(tmp, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeOf = (d) => {
|
||||||
|
try {
|
||||||
|
return fs.statSync(dataFile(d)).size;
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function int(v, name, max) {
|
||||||
|
const n = typeof v === "string" && /^\d+$/.test(v) ? Number(v) : v;
|
||||||
|
if (!Number.isSafeInteger(n) || n < 0 || n > max) throw bad("EBADBODY", `bad ${name}`);
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop uploads untouched for KEEP_MS (every user's — the store is one). */
|
||||||
|
function sweep(now = Date.now()) {
|
||||||
|
let keys = [];
|
||||||
|
try {
|
||||||
|
keys = fs.readdirSync(root());
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const key of keys) {
|
||||||
|
let ids = [];
|
||||||
|
try {
|
||||||
|
ids = fs.readdirSync(path.join(root(), key));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
if (busy.has(`${key}/${id}`)) continue;
|
||||||
|
const dir = path.join(root(), key, id);
|
||||||
|
try {
|
||||||
|
if (now - fs.statSync(dir).mtimeMs > KEEP_MS)
|
||||||
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
/* gone meanwhile */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfinished uploads of `key` (a committed one keeps only its answer). */
|
||||||
|
function openCount(key) {
|
||||||
|
let ids = [];
|
||||||
|
try {
|
||||||
|
ids = fs.readdirSync(path.join(root(), key));
|
||||||
|
} catch {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return ids.filter((id) => !fs.existsSync(path.join(root(), key, id, "result.json"))).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Touch the folder so `sweep` counts from the last part, not the first. */
|
||||||
|
function touch(d) {
|
||||||
|
const now = new Date();
|
||||||
|
try {
|
||||||
|
fs.utimesSync(d.dir, now, now);
|
||||||
|
} catch {
|
||||||
|
/* best effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `{received, total, done}` of one upload (`received: 0`, `total: null` when unknown). */
|
||||||
|
function status(userKey, id) {
|
||||||
|
const d = dirOf(userKey, id);
|
||||||
|
const meta = readJson(metaFile(d));
|
||||||
|
if (!meta) return { received: 0, total: null, done: false };
|
||||||
|
const done = fs.existsSync(resultFile(d));
|
||||||
|
return { received: done ? meta.total : sizeOf(d), total: meta.total, done };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append the request `stream` at `offset` of upload `id` (of `total` bytes in
|
||||||
|
* all). Returns `{received, total}`.
|
||||||
|
*/
|
||||||
|
async function putPart(userKey, id, { offset, total, stream }) {
|
||||||
|
const d = dirOf(userKey, id);
|
||||||
|
const at = int(offset, "offset", TOTAL_MAX);
|
||||||
|
const all = int(total, "total", TOTAL_MAX);
|
||||||
|
if (busy.has(d.lock)) throw bad("EBUSY", "this upload is already receiving a part");
|
||||||
|
busy.add(d.lock);
|
||||||
|
try {
|
||||||
|
let meta = readJson(metaFile(d));
|
||||||
|
if (!meta) {
|
||||||
|
sweep();
|
||||||
|
if (openCount(d.key) >= MAX_OPEN) {
|
||||||
|
throw bad("ETOOMANY", `at most ${MAX_OPEN} unfinished uploads at a time`);
|
||||||
|
}
|
||||||
|
fs.mkdirSync(d.dir, { recursive: true, mode: 0o700 });
|
||||||
|
meta = { total: all, createdAt: new Date().toISOString() };
|
||||||
|
writeJson(metaFile(d), meta);
|
||||||
|
}
|
||||||
|
if (meta.total !== all) throw bad("EBADBODY", `this upload is ${meta.total} bytes`);
|
||||||
|
if (fs.existsSync(resultFile(d))) return { received: all, total: all };
|
||||||
|
const have = sizeOf(d);
|
||||||
|
if (at !== have) {
|
||||||
|
throw bad("EOFFSET", `this upload has ${have} bytes, not ${at}`, { received: have });
|
||||||
|
}
|
||||||
|
const room = Math.min(PART_MAX, all - have);
|
||||||
|
let n = 0;
|
||||||
|
const cap = new Transform({
|
||||||
|
transform(chunk, _enc, cb) {
|
||||||
|
n += chunk.length;
|
||||||
|
if (n > room) return cb(bad("ETOOBIG", "this part goes past the upload's end"));
|
||||||
|
cb(null, chunk);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// A part cut short leaves what arrived: the next one starts from there.
|
||||||
|
await pipeline(stream, cap, fs.createWriteStream(dataFile(d), { flags: "a", mode: 0o600 }));
|
||||||
|
touch(d);
|
||||||
|
return { received: sizeOf(d), total: all };
|
||||||
|
} finally {
|
||||||
|
busy.delete(d.lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write a fully staged upload: `write(stream, size)` is the one-shot upload's
|
||||||
|
* own code, given the staged bytes; it answers `{status, body}`. A commit
|
||||||
|
* already made answers its stored answer again; a failed one keeps the bytes.
|
||||||
|
*/
|
||||||
|
async function commit(userKey, id, write) {
|
||||||
|
const d = dirOf(userKey, id);
|
||||||
|
const meta = readJson(metaFile(d));
|
||||||
|
if (!meta) throw bad("ENOFILE", "no such upload — send it again");
|
||||||
|
if (busy.has(d.lock)) throw bad("EBUSY", "this upload is still being written");
|
||||||
|
busy.add(d.lock);
|
||||||
|
try {
|
||||||
|
const stored = readJson(resultFile(d));
|
||||||
|
if (stored) return stored;
|
||||||
|
const have = sizeOf(d);
|
||||||
|
if (have !== meta.total) {
|
||||||
|
throw bad("EINCOMPLETE", `this upload has ${have} of ${meta.total} bytes`, {
|
||||||
|
received: have,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const stream = fs.createReadStream(dataFile(d));
|
||||||
|
let out;
|
||||||
|
try {
|
||||||
|
out = await write(stream, have);
|
||||||
|
} finally {
|
||||||
|
stream.destroy();
|
||||||
|
}
|
||||||
|
writeJson(resultFile(d), out);
|
||||||
|
fs.rmSync(dataFile(d), { force: true });
|
||||||
|
touch(d);
|
||||||
|
return out;
|
||||||
|
} finally {
|
||||||
|
busy.delete(d.lock);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Forget one upload (the browser stopped it, or has its answer). Also while a
|
||||||
|
* part is still arriving: a Stop drops the request, and the part writes on
|
||||||
|
* into a file no longer listed — never refused, or the stopped upload would
|
||||||
|
* hold one of MAX_OPEN for a day.
|
||||||
|
*/
|
||||||
|
function drop(userKey, id) {
|
||||||
|
const d = dirOf(userKey, id);
|
||||||
|
fs.rmSync(d.dir, { recursive: true, force: true });
|
||||||
|
return { dropped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
status,
|
||||||
|
putPart,
|
||||||
|
commit,
|
||||||
|
drop,
|
||||||
|
sweep,
|
||||||
|
root,
|
||||||
|
TOTAL_MAX,
|
||||||
|
PART_MAX,
|
||||||
|
MAX_OPEN,
|
||||||
|
KEEP_MS,
|
||||||
|
};
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
|
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
|
||||||
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
|
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
|
||||||
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
|
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
|
||||||
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
|
* - GET /api/users/{id}/avatar (Auth) — one account's picture (every account)
|
||||||
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
|
||||||
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
|
||||||
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
* Exports `{ tags, schemas, paths }`, combined into the spec by
|
||||||
@@ -731,12 +731,11 @@ const paths = {
|
|||||||
get: {
|
get: {
|
||||||
tags: ["Auth"],
|
tags: ["Auth"],
|
||||||
operationId: "getUserAvatar",
|
operationId: "getUserAvatar",
|
||||||
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
|
summary: "One account's picture, for owner groups and System Health (every signed-in account)",
|
||||||
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
|
||||||
responses: {
|
responses: {
|
||||||
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
|
||||||
302: { description: "Redirect to that account's avatar link" },
|
302: { description: "Redirect to that account's avatar link" },
|
||||||
403: err("EADMIN — a developer"),
|
|
||||||
404: err("ENOAVATAR"),
|
404: err("ENOAVATAR"),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ const tags = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
const errorCodes =
|
const errorCodes =
|
||||||
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG.";
|
"400 EBADPATH/EBADNAME, 403 ESECRET/EFOREIGN/EREADONLY (another user's home)/EBADORIGIN, 404 ENOFILE/ENOHOME, 409 EEXISTS (with `error.names`)/EUPGRADE/EMEMBERGONE, 413 ETOOBIG; a resumable upload adds 409 EOFFSET/EINCOMPLETE (with `error.received`)/EBUSY and 429 ETOOMANY.";
|
||||||
|
|
||||||
const schemas = {
|
const schemas = {
|
||||||
FsEntry: {
|
FsEntry: {
|
||||||
@@ -141,12 +141,109 @@ const OPS = [
|
|||||||
parameters: [
|
parameters: [
|
||||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||||
{ name: "conflict", in: "query", schema: conflict },
|
{ name: "conflict", in: "query", schema: conflict },
|
||||||
|
{
|
||||||
|
name: "staged",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string" },
|
||||||
|
description:
|
||||||
|
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
body: {
|
||||||
|
required: false,
|
||||||
|
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||||
|
},
|
||||||
|
status: 201,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"get",
|
||||||
|
"/upload-part",
|
||||||
|
"How much of a resumable upload arrived",
|
||||||
|
{
|
||||||
|
opId: "UploadPartStatus",
|
||||||
|
note: "`{received, total, done}` for the caller's own upload id (`received: 0, total: null` when unknown). The browser asks this after the dashboard came back, then sends the rest.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
in: "query",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ok: {
|
||||||
|
description: "`{received, total, done}`",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
received: { type: "integer" },
|
||||||
|
total: { type: "integer", nullable: true },
|
||||||
|
done: { type: "boolean" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"post",
|
||||||
|
"/upload-part",
|
||||||
|
"Send one part of a resumable upload (≤ 16 MB a part, 512 MB in all)",
|
||||||
|
{
|
||||||
|
opId: "UploadPart",
|
||||||
|
note: "Raw bytes appended at `offset` to the caller's upload `upload` of `total` bytes, staged on the host (`<data dir>/upload-staging/`) until `/upload` or `/upload-batch` names it with `?staged=`. A part must start where the staged bytes end (409 EOFFSET with `error.received`), one at a time per id (409 EBUSY); a part cut short keeps what arrived. At most 8 unfinished uploads per user (429 ETOOMANY); one untouched for a day is swept.",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
in: "query",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
|
||||||
|
},
|
||||||
|
{ name: "offset", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
|
||||||
|
{ name: "total", in: "query", required: true, schema: { type: "integer", minimum: 0 } },
|
||||||
],
|
],
|
||||||
body: {
|
body: {
|
||||||
required: true,
|
required: true,
|
||||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||||
},
|
},
|
||||||
status: 201,
|
ok: {
|
||||||
|
description: "`{received, total}`",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { received: { type: "integer" }, total: { type: "integer" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"delete",
|
||||||
|
"/upload-part",
|
||||||
|
"Forget a resumable upload's staged bytes",
|
||||||
|
{
|
||||||
|
opId: "UploadPartDrop",
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "upload",
|
||||||
|
in: "query",
|
||||||
|
required: true,
|
||||||
|
schema: { type: "string", pattern: "^[A-Za-z0-9_-]{16,64}$" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
ok: {
|
||||||
|
description: "`{dropped: true}`",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { type: "object", properties: { dropped: { type: "boolean" } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -178,9 +275,16 @@ const OPS = [
|
|||||||
parameters: [
|
parameters: [
|
||||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||||
{ name: "conflict", in: "query", schema: conflict },
|
{ name: "conflict", in: "query", schema: conflict },
|
||||||
|
{
|
||||||
|
name: "staged",
|
||||||
|
in: "query",
|
||||||
|
schema: { type: "string" },
|
||||||
|
description:
|
||||||
|
"Write the bytes sent before through `/upload-part` under this upload id instead of a body. A commit made before answers its stored answer again; a failed one keeps the bytes for a retry.",
|
||||||
|
},
|
||||||
],
|
],
|
||||||
body: {
|
body: {
|
||||||
required: true,
|
required: false,
|
||||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||||
},
|
},
|
||||||
status: 201,
|
status: 201,
|
||||||
@@ -233,7 +337,7 @@ function operations(prefix, idParam, opPrefix, note) {
|
|||||||
tags: ["Files"],
|
tags: ["Files"],
|
||||||
summary,
|
summary,
|
||||||
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
|
description: `${note} ${x.note ? `${x.note} ` : ""}${errorCodes}`,
|
||||||
operationId: `${opPrefix}${sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
|
operationId: `${opPrefix}${x.opId || sub.replace(/[/-](\w)/g, (_, c) => c.toUpperCase())}`,
|
||||||
parameters: [idParam, ...(x.parameters || [])],
|
parameters: [idParam, ...(x.parameters || [])],
|
||||||
...(x.body ? { requestBody: x.body } : {}),
|
...(x.body ? { requestBody: x.body } : {}),
|
||||||
responses: { [x.status || 200]: x.ok || { description: "Done." } },
|
responses: { [x.status || 200]: x.ok || { description: "Done." } },
|
||||||
@@ -459,6 +563,71 @@ const paths = {
|
|||||||
responses: { 200: { description: "The identity." } },
|
responses: { 200: { description: "The identity." } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/me/git/ssh-key/import": {
|
||||||
|
post: {
|
||||||
|
tags: ["Git identity"],
|
||||||
|
summary:
|
||||||
|
"Import a private key the user already has as a named key; with `sign`, sign commits with it at once",
|
||||||
|
operationId: "importSshKey",
|
||||||
|
requestBody: json(
|
||||||
|
{
|
||||||
|
privateKey: { type: "string", description: "The key file's text (OpenSSH or PEM)." },
|
||||||
|
name: { type: "string", pattern: "^[a-z0-9][a-z0-9-]{0,31}$" },
|
||||||
|
sign: { type: "boolean" },
|
||||||
|
replace: { type: "boolean" },
|
||||||
|
},
|
||||||
|
["privateKey"]
|
||||||
|
),
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
description:
|
||||||
|
"The identity, plus `imported` (the key's public half) and `signError` (a refused `sign`, else null).",
|
||||||
|
},
|
||||||
|
400: { description: "EBADKEY / EKEYPASSPHRASE / EBADKEYTYPE / EBADKEYNAME" },
|
||||||
|
409: { description: "EEXISTS (the same key, or the name is taken) / ETOOMANYKEYS" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/me/git/signing": {
|
||||||
|
put: {
|
||||||
|
tags: ["Git identity"],
|
||||||
|
summary:
|
||||||
|
"Sign every commit with one of the account's keys (`key`: its id), or stop (`key: null`)",
|
||||||
|
operationId: "setCommitSigning",
|
||||||
|
description:
|
||||||
|
"The account-wide setting (every repository of the account). `clearForeign: true` with `key: null` also takes out a signing setup the user made themselves — the three settings only, never their key file. Per workspace: `PUT /api/me/git/signing/lane`.",
|
||||||
|
requestBody: json({
|
||||||
|
key: { type: "string", nullable: true },
|
||||||
|
replace: { type: "boolean" },
|
||||||
|
clearForeign: { type: "boolean" },
|
||||||
|
}),
|
||||||
|
responses: {
|
||||||
|
200: { description: "The identity; `signing.state` is on, off or foreign." },
|
||||||
|
400: { description: "ENOKEY" },
|
||||||
|
409: { description: "EFOREIGNSIGN — ~/.gitconfig already signs with a key of its own." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/api/me/git/signing/lane": {
|
||||||
|
put: {
|
||||||
|
tags: ["Git identity"],
|
||||||
|
summary:
|
||||||
|
"Sign ONE workspace's commits with one of the account's keys (`key`: its id), or stop (`key: null`)",
|
||||||
|
description:
|
||||||
|
"Writes a small file of AgentHub's (`lane-<id>.gitconfig`: gpg.format ssh, user.signingkey, commit.gpgsign) and includes it from the account's `~/.gitconfig` with `includeIf \"gitdir:<the workspace's git dir>\"` and `gitdir:<common dir>/worktrees/ah-<id>-*` (its sessions' worktrees) — nothing is written into the repository. The workspace must be one the caller may see whose folder runs as this same account. `GET /api/me/git` answers `laneSigning: [{laneId, key}]`; deleting a key stops every workspace that signs with it.",
|
||||||
|
operationId: "setLaneCommitSigning",
|
||||||
|
requestBody: json({ laneId: { type: "integer" }, key: { type: "string", nullable: true } }, [
|
||||||
|
"laneId",
|
||||||
|
]),
|
||||||
|
responses: {
|
||||||
|
200: { description: "The identity, with `laneSigning`." },
|
||||||
|
400: { description: "ENOKEY" },
|
||||||
|
403: { description: "EFOREIGN — the workspace runs as another account; EHOSTIDENTITY." },
|
||||||
|
404: { description: "ENOTFOUND — no such workspace (or not the caller's)." },
|
||||||
|
409: { description: "ENOREPO — the workspace's folder is not a git repository." },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/me/git/ssh-terminal": {
|
"/api/me/git/ssh-terminal": {
|
||||||
put: {
|
put: {
|
||||||
tags: ["Git identity"],
|
tags: ["Git identity"],
|
||||||
|
|||||||
@@ -9,7 +9,15 @@
|
|||||||
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
|
* SSH key ID — each optionally tied to one key), and the commit author (`user.name` /
|
||||||
* `user.email` of the account's global git config — a member's commits fail
|
* `user.email` of the account's global git config — a member's commits fail
|
||||||
* without one; a new SSH key is labelled with that email, else
|
* without one; a new SSH key is labelled with that email, else
|
||||||
* `<username>@smartgift.vn`). The account is the signed-in user's
|
* `<username>@smartgift.vn`), a key the user already has imported as a
|
||||||
|
* named one (`POST /ssh-key/import` — the private text goes in, only its
|
||||||
|
* public half ever comes back), and whether commits are signed with one of
|
||||||
|
* the keys (`PUT /signing`: `gpg.format ssh` + `user.signingkey` +
|
||||||
|
* `commit.gpgsign` in the account's `~/.gitconfig`; a signing key the user
|
||||||
|
* set themselves is replaced only on `replace: true`, or taken out on
|
||||||
|
* `clearForeign: true` — their key file is never touched), or — per workspace —
|
||||||
|
* which of the caller's workspaces sign (`PUT /signing/lane`, an
|
||||||
|
* `includeIf "gitdir:…"` of one small file of ours each). The account is the signed-in user's
|
||||||
* own Linux account under S3, else the host's, which only an admin (or local
|
* 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
|
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||||
* request, and every write passes the same-origin guard.
|
* request, and every write passes the same-origin guard.
|
||||||
@@ -39,6 +47,12 @@ const STATUS = {
|
|||||||
ETOOMANYKEYS: 409,
|
ETOOMANYKEYS: 409,
|
||||||
EBADALIAS: 400,
|
EBADALIAS: 400,
|
||||||
ENOKEY: 400,
|
ENOKEY: 400,
|
||||||
|
EBADKEY: 400,
|
||||||
|
EKEYPASSPHRASE: 400,
|
||||||
|
EFOREIGNSIGN: 409,
|
||||||
|
ENOREPO: 409,
|
||||||
|
ENOTFOUND: 404,
|
||||||
|
EFOREIGN: 403,
|
||||||
};
|
};
|
||||||
|
|
||||||
function fail(res, e) {
|
function fail(res, e) {
|
||||||
@@ -61,6 +75,9 @@ async function view(account, username) {
|
|||||||
// The regions a CodeCommit key ID saved for "every region" covers.
|
// The regions a CodeCommit key ID saved for "every region" covers.
|
||||||
codecommitRegions: identity.CODECOMMIT_REGIONS,
|
codecommitRegions: identity.CODECOMMIT_REGIONS,
|
||||||
https: identity.listHttps(account),
|
https: identity.listHttps(account),
|
||||||
|
signing: await identity.signingState(account),
|
||||||
|
// Workspaces whose commits are signed: `[{laneId, key}]`.
|
||||||
|
laneSigning: await identity.laneSigningList(account),
|
||||||
author: await identity.getAuthor(account),
|
author: await identity.getAuthor(account),
|
||||||
// What the author email form suggests while none is set.
|
// What the author email form suggests while none is set.
|
||||||
defaultEmail: identity.defaultEmail(username),
|
defaultEmail: identity.defaultEmail(username),
|
||||||
@@ -106,6 +123,87 @@ router.post("/ssh-key", sameOriginGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import a private key the user already has: `{privateKey, name?, sign?,
|
||||||
|
* replace?}`. With `sign`, commits are signed with it at once (one step for
|
||||||
|
* the user); `replace` lets that take over a signing key they set themselves.
|
||||||
|
*/
|
||||||
|
router.post("/ssh-key/import", sameOriginGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = accountOf(req, { write: true });
|
||||||
|
const key = await identity.importSshKey(account, {
|
||||||
|
privateKey: req.body?.privateKey,
|
||||||
|
name: typeof req.body?.name === "string" ? req.body.name : null,
|
||||||
|
comment: (await identity.getAuthor(account)).email || identity.defaultEmail(usernameOf(req)),
|
||||||
|
});
|
||||||
|
let signError = null;
|
||||||
|
if (req.body?.sign === true && key) {
|
||||||
|
// The key is saved either way; a refused switch is said, not thrown away.
|
||||||
|
await identity
|
||||||
|
.setSigning(account, { id: key.id, replace: req.body?.replace === true })
|
||||||
|
.catch((e) => {
|
||||||
|
signError = { code: e.code || "EINTERNAL", message: e.message };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
res.status(201).json({ ...(await view(account, usernameOf(req))), imported: key, signError });
|
||||||
|
} catch (e) {
|
||||||
|
fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Sign commits with key `{key: id}`, or stop (`{key: null}`); `replace` as above. */
|
||||||
|
router.put("/signing", sameOriginGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = accountOf(req, { write: true });
|
||||||
|
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
|
||||||
|
await identity.setSigning(account, {
|
||||||
|
id,
|
||||||
|
replace: req.body?.replace === true,
|
||||||
|
clearForeign: req.body?.clearForeign === true,
|
||||||
|
});
|
||||||
|
res.json(await view(account, usernameOf(req)));
|
||||||
|
} catch (e) {
|
||||||
|
fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sign the commits of ONE workspace with `{laneId, key}` (`key: null` stops).
|
||||||
|
* The workspace must be one the caller may see (scopeGuard, `laneId` in the
|
||||||
|
* body) whose folder runs as this same account — a workspace another account
|
||||||
|
* runs could never read this account's `~/.gitconfig` (EFOREIGN).
|
||||||
|
*/
|
||||||
|
router.put("/signing/lane", sameOriginGuard, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const account = accountOf(req, { write: true });
|
||||||
|
const laneId = Number(req.body?.laneId);
|
||||||
|
const id = typeof req.body?.key === "string" && req.body.key ? req.body.key : null;
|
||||||
|
// Stopping touches only this account's own file, so it needs no workspace:
|
||||||
|
// one removed from the dashboard while it signed can still be turned off.
|
||||||
|
if (id == null && Number.isSafeInteger(laneId) && laneId > 0) {
|
||||||
|
await identity.setLaneSigning(account, { laneId, id: null });
|
||||||
|
return res.json(await view(account, usernameOf(req)));
|
||||||
|
}
|
||||||
|
const lane = Number.isSafeInteger(laneId) ? require("../lib/lanes").getLane(laneId) : null;
|
||||||
|
if (!lane || !require("../lib/scope").canSeeLane(req, laneId)) {
|
||||||
|
return res.status(404).json({ error: { code: "ENOTFOUND", message: "no such workspace" } });
|
||||||
|
}
|
||||||
|
const folder = identity.accountForDir(lane.cwd);
|
||||||
|
if (
|
||||||
|
folder.kind !== account.kind ||
|
||||||
|
(folder.kind === "member" && folder.acct.login !== account.acct.login)
|
||||||
|
) {
|
||||||
|
throw Object.assign(new Error("this workspace runs as another account"), {
|
||||||
|
code: "EFOREIGN",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await identity.setLaneSigning(account, { laneId, cwd: lane.cwd, id });
|
||||||
|
res.json(await view(account, usernameOf(req)));
|
||||||
|
} catch (e) {
|
||||||
|
fail(res, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const account = accountOf(req, { write: true });
|
const account = accountOf(req, { write: true });
|
||||||
|
|||||||
+63
-18
@@ -8,6 +8,7 @@
|
|||||||
* GET /api/homes/:who/fs/list|trash|zip|download
|
* GET /api/homes/:who/fs/list|trash|zip|download
|
||||||
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
* POST /api/homes/:who/fs/mkdir|newfile|rename|move|copy|delete|upload|trash/restore|trash/purge
|
||||||
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
|
* POST /api/homes/:who/fs/upload-batch|upload-check many small files in one request
|
||||||
|
* GET|POST|DELETE /api/homes/:who/fs/upload-part a resumable upload's parts (then `?staged=`)
|
||||||
*
|
*
|
||||||
* `:who` is `me`, or — for an admin — another user's name: read-only
|
* `:who` is `me`, or — for an admin — another user's name: read-only
|
||||||
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
* (`403 EREADONLY` on every write) and written to the audit log. The one
|
||||||
@@ -24,6 +25,7 @@ const express = require("express");
|
|||||||
const path = require("node:path");
|
const path = require("node:path");
|
||||||
|
|
||||||
const fx = require("../lib/file-explorer");
|
const fx = require("../lib/file-explorer");
|
||||||
|
const staging = require("../lib/upload-staging");
|
||||||
const homes = require("../lib/homes");
|
const homes = require("../lib/homes");
|
||||||
const scope = require("../lib/scope");
|
const scope = require("../lib/scope");
|
||||||
const lanesLib = require("../lib/lanes");
|
const lanesLib = require("../lib/lanes");
|
||||||
@@ -46,12 +48,17 @@ function fail(res, err) {
|
|||||||
EUPGRADE: 409,
|
EUPGRADE: 409,
|
||||||
EMEMBERGONE: 409,
|
EMEMBERGONE: 409,
|
||||||
ETOOBIG: 413,
|
ETOOBIG: 413,
|
||||||
|
EOFFSET: 409,
|
||||||
|
EBUSY: 409,
|
||||||
|
EINCOMPLETE: 409,
|
||||||
|
ETOOMANY: 429,
|
||||||
}[err.code] ?? 500;
|
}[err.code] ?? 500;
|
||||||
res.status(status).json({
|
res.status(status).json({
|
||||||
error: {
|
error: {
|
||||||
code: err.code || "EFS",
|
code: err.code || "EFS",
|
||||||
message: err.message,
|
message: err.message,
|
||||||
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
||||||
|
...(Number.isInteger(err.received) ? { received: err.received } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -294,22 +301,57 @@ router.post(
|
|||||||
TRASH_W
|
TRASH_W
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
/**
|
||||||
|
* The body of an upload: the request, or with `?staged=<id>` the bytes sent
|
||||||
|
* before in parts (`upload-staging.js`; see the workspace routes).
|
||||||
|
*/
|
||||||
|
function stagedOr(req, write) {
|
||||||
|
if (req.query.staged === undefined) return write(req, req.headers["content-length"]);
|
||||||
|
req.resume();
|
||||||
|
return staging.commit(userKey(req), req.query.staged, write);
|
||||||
|
}
|
||||||
|
router.get(
|
||||||
|
"/:who/fs/upload-part",
|
||||||
|
handle(async (req, res) => res.json(staging.status(userKey(req), req.query.upload)), W)
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
"/:who/fs/upload-part",
|
||||||
|
sameOriginGuard,
|
||||||
|
handle(async (req, res) => {
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await staging.putPart(userKey(req), req.query.upload, {
|
||||||
|
offset: req.query.offset,
|
||||||
|
total: req.query.total,
|
||||||
|
stream: req,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
req.resume();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}, W)
|
||||||
|
);
|
||||||
|
router.delete(
|
||||||
|
"/:who/fs/upload-part",
|
||||||
|
sameOriginGuard,
|
||||||
|
handle(async (req, res) => res.json(staging.drop(userKey(req), req.query.upload)), W)
|
||||||
|
);
|
||||||
router.post(
|
router.post(
|
||||||
"/:who/fs/upload",
|
"/:who/fs/upload",
|
||||||
sameOriginGuard,
|
sameOriginGuard,
|
||||||
handle(async (req, res, home) => {
|
handle(async (req, res, home) => {
|
||||||
try {
|
try {
|
||||||
const out = await fx.upload(
|
const r = await stagedOr(req, async (stream, length) => {
|
||||||
home.dir,
|
const out = await fx.upload(
|
||||||
typeof req.query.path === "string" ? req.query.path : "",
|
home.dir,
|
||||||
req,
|
typeof req.query.path === "string" ? req.query.path : "",
|
||||||
{
|
stream,
|
||||||
conflict: req.query.conflict,
|
{ conflict: req.query.conflict, length, trash: trashFor(req, home) }
|
||||||
length: req.headers["content-length"],
|
);
|
||||||
trash: trashFor(req, home),
|
return { status: out.skipped ? 200 : 201, body: out };
|
||||||
}
|
});
|
||||||
);
|
res.status(r.status).json(r.body);
|
||||||
res.status(out.skipped ? 200 : 201).json(out);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.resume();
|
req.resume();
|
||||||
throw err;
|
throw err;
|
||||||
@@ -321,13 +363,16 @@ router.post(
|
|||||||
sameOriginGuard,
|
sameOriginGuard,
|
||||||
handle(async (req, res, home) => {
|
handle(async (req, res, home) => {
|
||||||
try {
|
try {
|
||||||
const out = await fx.uploadBatch(
|
const r = await stagedOr(req, async (stream) => ({
|
||||||
home.dir,
|
status: 201,
|
||||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
body: await fx.uploadBatch(
|
||||||
req,
|
home.dir,
|
||||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||||
);
|
stream,
|
||||||
res.status(201).json(out);
|
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
res.status(r.status).json(r.body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.resume();
|
req.resume();
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
+83
-18
@@ -1879,6 +1879,7 @@ router.post("/:id/git/upload", sameOriginGuard, uploadBody, async (req, res) =>
|
|||||||
// folder's account. `conflict` (fail|skip|keep|replace) settles a taken
|
// folder's account. `conflict` (fail|skip|keep|replace) settles a taken
|
||||||
// name; 409 EEXISTS carries `names` so the browser can ask.
|
// name; 409 EEXISTS carries `names` so the browser can ask.
|
||||||
const fileExplorer = require("../lib/file-explorer");
|
const fileExplorer = require("../lib/file-explorer");
|
||||||
|
const uploadStaging = require("../lib/upload-staging");
|
||||||
|
|
||||||
function fsFail(res, err) {
|
function fsFail(res, err) {
|
||||||
const status =
|
const status =
|
||||||
@@ -1894,12 +1895,17 @@ function fsFail(res, err) {
|
|||||||
EMEMBERGONE: 409,
|
EMEMBERGONE: 409,
|
||||||
ETOOBIG: 413,
|
ETOOBIG: 413,
|
||||||
ENOHOME: 404,
|
ENOHOME: 404,
|
||||||
|
EOFFSET: 409,
|
||||||
|
EBUSY: 409,
|
||||||
|
EINCOMPLETE: 409,
|
||||||
|
ETOOMANY: 429,
|
||||||
}[err.code] ?? 500;
|
}[err.code] ?? 500;
|
||||||
res.status(status).json({
|
res.status(status).json({
|
||||||
error: {
|
error: {
|
||||||
code: err.code || "EFS",
|
code: err.code || "EFS",
|
||||||
message: err.message,
|
message: err.message,
|
||||||
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
||||||
|
...(Number.isInteger(err.received) ? { received: err.received } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -2056,6 +2062,61 @@ router.post("/:id/fs/trash/purge", sameOriginGuard, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An upload's body: the request itself, or — with `?staged=<id>` — the bytes
|
||||||
|
* this user already sent in parts (`upload-staging.js`), so a restart in the
|
||||||
|
* middle costs only the part in flight. `write(stream, length)` answers
|
||||||
|
* `{status, body}`; a staged commit made before answers its stored answer.
|
||||||
|
*/
|
||||||
|
function stagedOr(req, write) {
|
||||||
|
const id = req.query.staged;
|
||||||
|
if (id === undefined) {
|
||||||
|
return write(req, req.headers["content-length"]);
|
||||||
|
}
|
||||||
|
req.resume();
|
||||||
|
return uploadStaging.commit(fsUserKey(req), id, write);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One part of a resumable upload (`?upload=&offset=&total=`, raw bytes); GET = how
|
||||||
|
* much arrived (`{received, total, done}`), DELETE = forget it. The bytes stay
|
||||||
|
* on the host until `/fs/upload` or `/fs/upload-batch` names `?staged=<id>`.
|
||||||
|
*/
|
||||||
|
router.get("/:id/fs/upload-part", async (req, res) => {
|
||||||
|
const t = await gitTargetOr404(req, res);
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
res.json(uploadStaging.status(fsUserKey(req), req.query.upload));
|
||||||
|
} catch (err) {
|
||||||
|
fsFail(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.post("/:id/fs/upload-part", sameOriginGuard, async (req, res) => {
|
||||||
|
const t = await gitTargetOr404(req, res);
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
res.json(
|
||||||
|
await uploadStaging.putPart(fsUserKey(req), req.query.upload, {
|
||||||
|
offset: req.query.offset,
|
||||||
|
total: req.query.total,
|
||||||
|
stream: req,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
req.resume();
|
||||||
|
fsFail(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
router.delete("/:id/fs/upload-part", sameOriginGuard, async (req, res) => {
|
||||||
|
const t = await gitTargetOr404(req, res);
|
||||||
|
if (!t) return;
|
||||||
|
try {
|
||||||
|
res.json(uploadStaging.drop(fsUserKey(req), req.query.upload));
|
||||||
|
} catch (err) {
|
||||||
|
fsFail(res, err);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Upload one file, streamed (no body parser buffers it), to `?path=` — any
|
* Upload one file, streamed (no body parser buffers it), to `?path=` — any
|
||||||
* missing sub-folders are made (a folder upload sends each file with its
|
* missing sub-folders are made (a folder upload sends each file with its
|
||||||
@@ -2065,17 +2126,17 @@ router.post("/:id/fs/upload", sameOriginGuard, async (req, res) => {
|
|||||||
const t = await gitTargetOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
try {
|
try {
|
||||||
const out = await fileExplorer.upload(
|
const write = async (stream, length) => {
|
||||||
t.cwd,
|
const out = await fileExplorer.upload(
|
||||||
typeof req.query.path === "string" ? req.query.path : "",
|
t.cwd,
|
||||||
req,
|
typeof req.query.path === "string" ? req.query.path : "",
|
||||||
{
|
stream,
|
||||||
conflict: req.query.conflict,
|
{ conflict: req.query.conflict, length, trash: fsTrashFor(req, t) }
|
||||||
length: req.headers["content-length"],
|
);
|
||||||
trash: fsTrashFor(req, t),
|
return { status: out.skipped ? 200 : 201, body: out };
|
||||||
}
|
};
|
||||||
);
|
const r = await stagedOr(req, write);
|
||||||
res.status(out.skipped ? 200 : 201).json(out);
|
res.status(r.status).json(r.body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.resume();
|
req.resume();
|
||||||
fsFail(res, err);
|
fsFail(res, err);
|
||||||
@@ -2091,13 +2152,17 @@ router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
|
|||||||
const t = await gitTargetOr404(req, res);
|
const t = await gitTargetOr404(req, res);
|
||||||
if (!t) return;
|
if (!t) return;
|
||||||
try {
|
try {
|
||||||
const out = await fileExplorer.uploadBatch(
|
const write = async (stream) => ({
|
||||||
t.cwd,
|
status: 201,
|
||||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
body: await fileExplorer.uploadBatch(
|
||||||
req,
|
t.cwd,
|
||||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||||
);
|
stream,
|
||||||
res.status(201).json(out);
|
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const r = await stagedOr(req, write);
|
||||||
|
res.status(r.status).json(r.body);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
req.resume();
|
req.resume();
|
||||||
fsFail(res, err);
|
fsFail(res, err);
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
|
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
|
||||||
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
|
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
|
||||||
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
|
||||||
* owner groups — staff only like the rest of `/users`.
|
* owner groups and System Health — the one `/users` read a developer may make
|
||||||
|
* too (`EVERYONE_READS` in server/lib/auth.js).
|
||||||
*
|
*
|
||||||
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
|
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
|
||||||
* user form: display name, email, role, disabled, a new password, workspaces
|
* user form: display name, email, role, disabled, a new password, workspaces
|
||||||
|
|||||||
Reference in New Issue
Block a user