Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ec1978176 | |||
| 12ea853170 | |||
| 7e41c81d53 | |||
| 2d6c751fb2 | |||
| e0b8d5641e | |||
| faa96d1964 | |||
| 68c3cfaf26 | |||
| 1ad2fec073 | |||
| 1294ce1faa | |||
| 5d62003fe5 | |||
| 12d53afaeb | |||
| 4263b3eb26 | |||
| d58f13e1ce | |||
| 81b3cbda65 | |||
| 0a99eed369 | |||
| 1ec9f2e592 | |||
| a401a318c5 | |||
| 5caad0df2d | |||
| 01e66ba122 | |||
| 923c1635b4 | |||
| c37bc5205b |
@@ -287,6 +287,11 @@ function UploadChip({ job }: { job: UploadJob }) {
|
||||
{isArmed("stop") && !stopping && (
|
||||
<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}>
|
||||
{job.folder}
|
||||
{job.name ? ` · ${job.name}` : ""}
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { CodeBlock } from "./CodeBlock";
|
||||
import { onLinkClick } from "../../lib/openInPreview";
|
||||
|
||||
type Block =
|
||||
| { kind: "code"; lang: string; code: string }
|
||||
@@ -305,7 +306,13 @@ const LABEL_CLASS = (word: string) =>
|
||||
|
||||
function UrlLink({ url }: { url: string }) {
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer" className={LINK_CLASS}>
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, url)}
|
||||
className={LINK_CLASS}
|
||||
>
|
||||
{url}
|
||||
</a>
|
||||
);
|
||||
@@ -466,6 +473,7 @@ function renderInline(text: string, baseKey = ""): React.ReactNode[] {
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => onLinkClick(e, href)}
|
||||
className="text-violet-700 dark:text-violet-300 hover:text-violet-800 dark:hover:text-violet-200 underline underline-offset-2 decoration-violet-400/40 hover:decoration-violet-300/70"
|
||||
>
|
||||
{renderInline(linkM[1]!, `${baseKey}-l${n}`)}
|
||||
|
||||
@@ -865,7 +865,7 @@ function MessageListView({
|
||||
className={
|
||||
compact
|
||||
? "space-y-3 py-1 pr-2"
|
||||
: "mx-auto flex w-full max-w-3xl flex-col gap-3 px-4 py-6"
|
||||
: "mx-auto flex w-full max-w-5xl flex-col gap-3 px-4 py-6"
|
||||
}
|
||||
>
|
||||
{items.map((item) => {
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
type FsPlace,
|
||||
type GitSource,
|
||||
isHomePlace,
|
||||
newUploadId,
|
||||
} from "../../lib/api";
|
||||
import type { Lane } from "../../lib/types";
|
||||
import { useDialog } from "../../hooks/useDialog";
|
||||
@@ -529,7 +530,13 @@ export function FileBrowser({
|
||||
await fn();
|
||||
if (done) toast.success(done);
|
||||
} 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 {
|
||||
setBusy(false);
|
||||
changed();
|
||||
@@ -682,13 +689,21 @@ export function FileBrowser({
|
||||
// false once the server says a batch cannot be written here (an old helper).
|
||||
let batchable = true;
|
||||
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. */
|
||||
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
||||
if (signal.aborted) return false;
|
||||
const dest = join(dir, rel);
|
||||
show(rel);
|
||||
// One id for every attempt: a retry with another `conflict` reuses the staged bytes.
|
||||
const upload = newUploadId();
|
||||
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 {
|
||||
const r = await send(forAll ?? "fail").catch(async (e) => {
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -701,6 +716,9 @@ export function FileBrowser({
|
||||
} catch (e) {
|
||||
if (errCode(e) === "ECANCEL" || errCode(e) === "EABORT") return false;
|
||||
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += file.size;
|
||||
done++;
|
||||
@@ -720,6 +738,7 @@ export function FileBrowser({
|
||||
return a.choice;
|
||||
};
|
||||
if (signal.aborted) return "cancel";
|
||||
const upload = newUploadId();
|
||||
try {
|
||||
const c = await api.files.uploadCheck(
|
||||
place,
|
||||
@@ -733,7 +752,12 @@ export function FileBrowser({
|
||||
let policy = await choose(c.taken);
|
||||
if (!policy) return "cancel";
|
||||
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) => {
|
||||
// Taken meanwhile: ask about those, then once more.
|
||||
if (errCode(e) !== "EEXISTS") throw e;
|
||||
@@ -752,6 +776,9 @@ export function FileBrowser({
|
||||
return "fallback";
|
||||
}
|
||||
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
||||
} finally {
|
||||
onWait(false);
|
||||
void api.files.dropUpload(place, upload);
|
||||
}
|
||||
sent += size;
|
||||
done += b.length;
|
||||
@@ -1693,7 +1720,6 @@ export function FileBrowser({
|
||||
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{ctx.menu && (
|
||||
|
||||
@@ -489,8 +489,9 @@ function FileActions({
|
||||
title={t("changes.discardConfirm")}
|
||||
disabled={busy}
|
||||
onClick={() => onAct("discard", f.path, true)}
|
||||
className={`${HUNK_CHIP} border-status-danger bg-status-danger/20 text-status-danger`}
|
||||
className="flex shrink-0 items-center gap-1 rounded bg-status-danger/10 px-1 py-0.5 text-[11px] leading-3 text-status-danger hover:bg-status-danger/20 disabled:opacity-40"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
{t("changes.discardShortConfirm")}
|
||||
</button>
|
||||
) : (
|
||||
|
||||
@@ -373,12 +373,17 @@ export function LaneGitHeader({
|
||||
className="flex min-w-0 flex-1 items-baseline gap-1.5 font-mono text-[11px]"
|
||||
title={`${git.head} ${git.subject}`}
|
||||
>
|
||||
<span className="flex shrink-0 items-center gap-1 self-center font-semibold text-fg-primary">
|
||||
<GitBranch className="h-3 w-3 text-fg-muted" />
|
||||
{git.branch}
|
||||
{/* A long branch name truncates too (after the subject has gone),
|
||||
or it overflowed onto the head and the action icons. */}
|
||||
<span
|
||||
className="flex min-w-[5rem] items-center gap-1 self-center font-semibold text-fg-primary"
|
||||
title={git.branch}
|
||||
>
|
||||
<GitBranch className="h-3 w-3 shrink-0 text-fg-muted" />
|
||||
<span className="truncate">{git.branch}</span>
|
||||
</span>
|
||||
<span className="shrink-0 text-fg-muted">{git.head}</span>
|
||||
<span className="min-w-0 truncate text-fg-muted">{git.subject}</span>
|
||||
<span className="min-w-0 shrink-[999] truncate text-fg-muted">{git.subject}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -145,7 +145,7 @@ export function SessionReplay({
|
||||
{livePane ? (
|
||||
<div
|
||||
data-testid="session-replay-live"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.live")}</span>
|
||||
<button
|
||||
@@ -160,7 +160,7 @@ export function SessionReplay({
|
||||
) : session && !resumable ? (
|
||||
<div
|
||||
data-testid="session-replay-no-resume"
|
||||
className="mx-auto flex w-full max-w-3xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
className="mx-auto flex w-full max-w-5xl items-center gap-2 rounded-2xl border border-border p-3 text-xs text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("replay.noResume", { tool })}</span>
|
||||
<button
|
||||
@@ -172,7 +172,7 @@ export function SessionReplay({
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mx-auto w-full max-w-3xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<div className="mx-auto w-full max-w-5xl rounded-2xl border border-border p-2 shadow-sm transition-colors focus-within:border-accent/50">
|
||||
<PromptEditor
|
||||
bare
|
||||
images={images}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* @file UrlPreview.tsx
|
||||
* @description The session panel's Preview tab: a web page (a lane's dev
|
||||
* server, mostly) shown beside the console. One bar — address, Reload, Open
|
||||
* in a browser tab — and, before anything is open, the workspace's own ports
|
||||
* as one-click chips. The page runs in an `<iframe src>` on ITS OWN origin:
|
||||
* `normalizePreviewUrl` refuses the dashboard's origin and anything but
|
||||
* http(s), and the sandbox leaves out top navigation so a page cannot move
|
||||
* the dashboard away. A site that forbids framing (most login pages) shows
|
||||
* blank or as the browser's broken-page icon; that is the browser's rule and
|
||||
* cannot be read from here, so any page not on this machine gets a strip
|
||||
* saying so with the Open button beside it. The
|
||||
* last address is remembered per workspace in localStorage (a viewer
|
||||
* convenience, never the server's).
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ExternalLink, Globe, RotateCw } from "lucide-react";
|
||||
import { isLoopbackUrl, normalizePreviewUrl } from "../../lib/openInPreview";
|
||||
import { isSubmitEnter } from "../../lib/keys";
|
||||
import { toast } from "../../lib/toast";
|
||||
|
||||
const KEY = (laneId: number) => `agenthub.preview.url.${laneId}`;
|
||||
|
||||
function readSaved(laneId: number): string {
|
||||
try {
|
||||
return window.localStorage.getItem(KEY(laneId)) ?? "";
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/** The frame's sandbox: everything a web app needs, minus moving the
|
||||
* dashboard (`allow-top-navigation*`). `allow-same-origin` is the PAGE's
|
||||
* own origin — never the dashboard's, which `normalizePreviewUrl` refuses. */
|
||||
const FRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals allow-downloads";
|
||||
|
||||
export function UrlPreview({
|
||||
laneId,
|
||||
ports,
|
||||
request,
|
||||
}: {
|
||||
laneId: number;
|
||||
/** The lane's bound ports, by name — the chips of the empty state. */
|
||||
ports: Record<string, number>;
|
||||
/** A link asked for this page (`openInPreview`); a new nonce re-opens it. */
|
||||
request: { url: string; nonce: number } | null;
|
||||
}) {
|
||||
const { t } = useTranslation("lanes");
|
||||
const [url, setUrl] = useState(() => readSaved(laneId));
|
||||
const [draft, setDraft] = useState(url);
|
||||
const [reload, setReload] = useState(0);
|
||||
|
||||
// Another workspace: its own last page.
|
||||
useEffect(() => {
|
||||
const saved = readSaved(laneId);
|
||||
setUrl(saved);
|
||||
setDraft(saved);
|
||||
}, [laneId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (request) go(request.url);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- keyed on the nonce
|
||||
}, [request?.nonce]);
|
||||
|
||||
function go(raw: string) {
|
||||
const next = normalizePreviewUrl(raw);
|
||||
if (!next) {
|
||||
toast.error(t("preview.badUrl"));
|
||||
return;
|
||||
}
|
||||
setUrl(next);
|
||||
setDraft(next);
|
||||
setReload((n) => n + 1);
|
||||
try {
|
||||
window.localStorage.setItem(KEY(laneId), next);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}
|
||||
|
||||
const openTab = () => url && window.open(url, "_blank", "noopener,noreferrer");
|
||||
const portList = Object.entries(ports ?? {}).filter(([, p]) => Number.isInteger(p) && p > 0);
|
||||
const btn =
|
||||
"flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-fg-muted transition-colors hover:bg-surface-4 hover:text-fg-primary disabled:opacity-40 disabled:hover:bg-transparent";
|
||||
|
||||
return (
|
||||
<div className="flex min-h-0 flex-1 flex-col" data-testid="url-preview">
|
||||
<div className="flex shrink-0 items-center gap-1 border-b border-border px-2 py-1.5">
|
||||
<input
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (isSubmitEnter(e)) {
|
||||
e.preventDefault();
|
||||
go(draft);
|
||||
}
|
||||
}}
|
||||
placeholder="localhost:3000"
|
||||
aria-label={t("preview.address")}
|
||||
spellCheck={false}
|
||||
className="min-w-0 flex-1 rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={() => setReload((n) => n + 1)}
|
||||
title={t("preview.reload")}
|
||||
aria-label={t("preview.reload")}
|
||||
>
|
||||
<RotateCw className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={btn}
|
||||
disabled={!url}
|
||||
onClick={openTab}
|
||||
title={t("preview.openTab")}
|
||||
aria-label={t("preview.openTab")}
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{url && !isLoopbackUrl(url) && (
|
||||
// An outside site: most refuse to be framed, and a refusal cannot be
|
||||
// detected from here — so say it, with the way out beside it.
|
||||
<div
|
||||
data-testid="url-preview-blocked-hint"
|
||||
className="flex shrink-0 items-center gap-2 border-b border-border bg-surface-2 px-2 py-1.5 text-[11px] text-fg-secondary"
|
||||
>
|
||||
<span className="min-w-0 flex-1">{t("preview.blockedHint")}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openTab}
|
||||
className="flex shrink-0 items-center gap-1 rounded-md px-1.5 py-0.5 font-medium text-accent hover:bg-surface-4"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
{t("preview.openTabShort")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{url ? (
|
||||
<iframe
|
||||
key={`${url}#${reload}`}
|
||||
src={url}
|
||||
title={t("preview.title")}
|
||||
sandbox={FRAME_SANDBOX}
|
||||
referrerPolicy="no-referrer"
|
||||
className="min-h-0 w-full flex-1 border-0 bg-white"
|
||||
data-testid="url-preview-frame"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-3 px-6 text-center">
|
||||
<Globe className="h-6 w-6 text-fg-muted" />
|
||||
<p className="text-xs text-fg-secondary">{t("preview.empty")}</p>
|
||||
{portList.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-1.5">
|
||||
{portList.map(([name, port]) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => go(`localhost:${port}`)}
|
||||
title={name}
|
||||
className="rounded-full border border-border px-2.5 py-0.5 font-mono text-[11px] text-fg-secondary transition-colors hover:bg-surface-4 hover:text-fg-primary"
|
||||
>
|
||||
localhost:{port}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-fg-muted">{t("preview.loginHint")}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -31,6 +31,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
gitTarget: (lane: number, session?: string | null, source?: number | null) =>
|
||||
source ? { lane, source } : session ? { lane, session } : lane,
|
||||
isHomePlace: (p: unknown) => typeof p === "object" && p !== null && "home" in p,
|
||||
newUploadId: (() => {
|
||||
let n = 0;
|
||||
return () => `upload-id-${String(++n).padStart(8, "0")}`;
|
||||
})(),
|
||||
api: {
|
||||
lanes: {
|
||||
list: vi.fn(),
|
||||
@@ -52,8 +56,9 @@ vi.mock("../../../lib/api", () => ({
|
||||
allTrash: vi.fn(),
|
||||
restore: vi.fn(),
|
||||
purge: vi.fn(),
|
||||
upload: vi.fn(),
|
||||
uploadBatch: vi.fn(),
|
||||
uploadStaged: vi.fn(),
|
||||
uploadBatchStaged: vi.fn(),
|
||||
dropUpload: vi.fn(async () => null),
|
||||
uploadCheck: vi.fn(),
|
||||
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 () => {
|
||||
vi.mocked(api.files.upload)
|
||||
vi.mocked(api.files.uploadStaged)
|
||||
.mockRejectedValueOnce(
|
||||
Object.assign(new Error("taken"), { code: "EEXISTS", names: ["a.txt"] })
|
||||
)
|
||||
@@ -249,20 +254,24 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Keep both" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.upload).toHaveBeenLastCalledWith(
|
||||
expect(api.files.uploadStaged).toHaveBeenLastCalledWith(
|
||||
1,
|
||||
"a.txt",
|
||||
file,
|
||||
"keep",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: 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 () => {
|
||||
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();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
@@ -271,33 +280,32 @@ describe("FileExplorer", () => {
|
||||
const ask = await screen.findByTestId("fx-conflict");
|
||||
await user.click(within(ask).getByRole("button", { name: "Replace" }));
|
||||
await waitFor(() =>
|
||||
expect(api.files.uploadBatch).toHaveBeenCalledWith(
|
||||
expect(api.files.uploadBatchStaged).toHaveBeenCalledWith(
|
||||
1,
|
||||
"",
|
||||
files.map((file) => ({ file, rel: file.name })),
|
||||
"replace",
|
||||
expect.any(Function),
|
||||
expect.any(AbortSignal)
|
||||
expect.objectContaining({ upload: expect.any(String), signal: expect.any(AbortSignal) })
|
||||
)
|
||||
);
|
||||
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 () => {
|
||||
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();
|
||||
open();
|
||||
await screen.findByText("a.txt");
|
||||
const files = ["x.txt", "y.txt"].map((n) => new File(["hi"], n));
|
||||
await user.upload(screen.getByTestId("fx-upload-files"), files);
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatch).not.toHaveBeenCalled();
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(2));
|
||||
expect(api.files.uploadBatchStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
await screen.findByText("a.txt");
|
||||
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);
|
||||
fireEvent.change(screen.getByTestId("fx-upload-folder"), { target: { files } });
|
||||
await waitFor(() => expect(api.files.upload).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.upload).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
await waitFor(() => expect(api.files.uploadStaged).toHaveBeenCalledTimes(1));
|
||||
expect(vi.mocked(api.files.uploadStaged).mock.calls[0]![1]).toBe("repo/src/a.js");
|
||||
expect(toast.info).toHaveBeenCalledTimes(1);
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -320,7 +328,7 @@ describe("FileExplorer", () => {
|
||||
Object.defineProperty(big, "size", { value: 600 * 1024 * 1024 });
|
||||
fireEvent.change(screen.getByTestId("fx-upload-files"), { target: { files: [big] } });
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalled());
|
||||
expect(api.files.upload).not.toHaveBeenCalled();
|
||||
expect(api.files.uploadStaged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("restores from the Trash", async () => {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @file UrlPreview.test.tsx
|
||||
* @description The Preview tab: port chips before a page is open, the
|
||||
* address bar opening a page in a sandboxed frame without top navigation,
|
||||
* a link's request opening its page, the dashboard's own origin refused, and
|
||||
* the framing-refusal strip shown for an outside site only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import "../../../i18n";
|
||||
import { UrlPreview } from "../UrlPreview";
|
||||
|
||||
beforeEach(() => window.localStorage.clear());
|
||||
|
||||
describe("UrlPreview", () => {
|
||||
it("offers the workspace's ports, and a chip opens that page", () => {
|
||||
render(<UrlPreview laneId={1} ports={{ web: 3001 }} request={null} />);
|
||||
fireEvent.click(screen.getByText("localhost:3001"));
|
||||
const frame = screen.getByTestId("url-preview-frame");
|
||||
expect(frame.getAttribute("src")).toBe("http://localhost:3001/");
|
||||
expect(frame.getAttribute("sandbox")).not.toMatch(/top-navigation/);
|
||||
expect(window.localStorage.getItem("agenthub.preview.url.1")).toBe("http://localhost:3001/");
|
||||
});
|
||||
|
||||
it("opens what the address bar names, and refuses the dashboard itself", () => {
|
||||
render(<UrlPreview laneId={2} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: window.location.origin + "/settings" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-frame")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "127.0.0.1:8080/app" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://127.0.0.1:8080/app"
|
||||
);
|
||||
});
|
||||
|
||||
it("warns that an outside site may refuse to be framed, never for a local page", () => {
|
||||
render(<UrlPreview laneId={4} ports={{}} request={null} />);
|
||||
const input = screen.getByRole("textbox");
|
||||
fireEvent.change(input, { target: { value: "localhost:3000" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.queryByTestId("url-preview-blocked-hint")).toBeNull();
|
||||
fireEvent.change(input, { target: { value: "https://youtube.com" } });
|
||||
fireEvent.keyDown(input, { key: "Enter" });
|
||||
expect(screen.getByTestId("url-preview-blocked-hint")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("opens a link's request", () => {
|
||||
render(
|
||||
<UrlPreview laneId={3} ports={{}} request={{ url: "http://localhost:5173/", nonce: 1 }} />
|
||||
);
|
||||
expect(screen.getByTestId("url-preview-frame").getAttribute("src")).toBe(
|
||||
"http://localhost:5173/"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,8 @@
|
||||
* puts `/<name>` (`/<plugin>:<name>`) at the front of the message — the same
|
||||
* text the `/` autocomplete inserts, so the CLI runs it as typed. Both lists
|
||||
* are read when the menu first opens (`GET /api/cc-config/skills|plugins`),
|
||||
* and Manage opens the Claude config page on that tab.
|
||||
* and Manage opens the Claude config page on that tab. The menu opens
|
||||
* downward when the box sits too near the top to fit it above.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -77,7 +78,36 @@ function loadLists(cwd: string): Promise<Lists> {
|
||||
}
|
||||
|
||||
const PANEL =
|
||||
"absolute w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
"absolute z-40 w-60 rounded-xl border border-border bg-surface-1 py-1 shadow-lg animate-fade-in";
|
||||
/** Room the menu and its tallest submenu (Project files: search + list) need. */
|
||||
const MENU_ROOM = 380;
|
||||
|
||||
/** The visible band the button sits in: the viewport, narrowed by any
|
||||
* scrolling/clipping ancestor (the page body scrolls inside the layout). */
|
||||
function clipBand(el: HTMLElement): { top: number; bottom: number } {
|
||||
let top = 0;
|
||||
let bottom = window.innerHeight;
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
const o = getComputedStyle(p);
|
||||
if (/(auto|scroll|hidden|clip)/.test(o.overflowY + o.overflow)) {
|
||||
const r = p.getBoundingClientRect();
|
||||
top = Math.max(top, r.top);
|
||||
bottom = Math.min(bottom, r.bottom);
|
||||
}
|
||||
}
|
||||
return { top, bottom };
|
||||
}
|
||||
|
||||
/** Open downward when there is not room above but more below. */
|
||||
function opensDown(el: HTMLElement | null): boolean {
|
||||
if (!el) return false;
|
||||
const r = el.getBoundingClientRect();
|
||||
const band = clipBand(el);
|
||||
const above = r.top - band.top;
|
||||
const below = band.bottom - r.bottom;
|
||||
return above < MENU_ROOM && below > above;
|
||||
}
|
||||
|
||||
const ROW =
|
||||
"flex w-full items-center gap-2.5 px-3 py-1.5 text-left text-sm text-fg-secondary hover:bg-surface-3 hover:text-fg-primary";
|
||||
|
||||
@@ -195,6 +225,7 @@ export function ChatAddMenu({
|
||||
const { t } = useTranslation("run");
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [down, setDown] = useState(false);
|
||||
const [sub, setSub] = useState<Sub | null>(null);
|
||||
const [context, setContext] = useState<Attachment[] | null>(null);
|
||||
const [recent, setRecent] = useState<Attachment[]>([]);
|
||||
@@ -286,6 +317,8 @@ export function ChatAddMenu({
|
||||
/>
|
||||
))
|
||||
);
|
||||
// A submenu lines up with the menu's edge nearest the button.
|
||||
const edge = down ? "top-0" : "bottom-0";
|
||||
const shownPlugin = lists?.plugins.find((p) => p.name === plugin);
|
||||
|
||||
return (
|
||||
@@ -296,13 +329,17 @@ export function ChatAddMenu({
|
||||
aria-label={t("chat.add")}
|
||||
title={t("chat.add")}
|
||||
aria-expanded={open}
|
||||
onClick={() => (open ? close() : setOpen(true))}
|
||||
onClick={() => {
|
||||
if (open) return close();
|
||||
setDown(opensDown(ref.current));
|
||||
setOpen(true);
|
||||
}}
|
||||
className="flex h-8 w-8 items-center justify-center rounded-full text-fg-muted transition-colors hover:bg-surface-3 hover:text-fg-primary"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</button>
|
||||
{open && (
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} bottom-full left-0 mb-2`}>
|
||||
<div role="menu" data-testid="chat-add-menu" className={`${PANEL} left-0 ${down ? "top-full mt-2" : "bottom-full mb-2"}`}>
|
||||
<Row
|
||||
icon={<Paperclip className="h-4 w-4" />}
|
||||
label={t("chat.upload")}
|
||||
@@ -376,13 +413,13 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "project" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1 w-72 pt-2`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1 w-72 pt-2`}>
|
||||
<ProjectFiles cwd={cwd} onPick={(rel) => attach(projectAttachment(rel))} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(sub === "context" || sub === "recent") && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{(sub === "context" ? context : recent)?.length ? (
|
||||
(sub === "context" ? context! : recent).map((it) => (
|
||||
@@ -405,7 +442,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "skills" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">{entries(lists?.skills ?? [], "")}</div>
|
||||
<div className="my-1 border-t border-border" />
|
||||
<Row
|
||||
@@ -417,7 +454,7 @@ export function ChatAddMenu({
|
||||
)}
|
||||
|
||||
{sub === "plugins" && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{lists && lists.plugins.length === 0 && (
|
||||
<p className="px-3 py-1.5 text-xs text-fg-muted">{t("chat.none")}</p>
|
||||
@@ -443,7 +480,7 @@ export function ChatAddMenu({
|
||||
onClick={() => manage("plugins")}
|
||||
/>
|
||||
{shownPlugin && (
|
||||
<div role="menu" className={`${PANEL} bottom-0 left-full ml-1`}>
|
||||
<div role="menu" className={`${PANEL} ${edge} left-full ml-1`}>
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{entries(
|
||||
shownPlugin.items,
|
||||
|
||||
@@ -16,8 +16,9 @@
|
||||
* keys, Home and End move between tabs (one tab stop, APG). Errors are
|
||||
* toasts. The `+` menu opens a new tab in the free slot the server picks
|
||||
* (`slot: "auto"`) — a plain shell (`POST /api/lanes/:id/start {shell: true}`)
|
||||
* or an installed, enabled AI tool started bare on its default model, no
|
||||
* prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* or an installed, enabled AI tool started bare on its default model and
|
||||
* the account's Session defaults (permission mode, Codex sandbox/approval/
|
||||
* bypass), no prompt (a prompt, proxy or worktree is still the workspace home's prompt
|
||||
* box) — plus a link to Settings → Agents. Ten sessions is the server's limit
|
||||
* (`MAX_PANE_SLOT`). The bar is the console's only header: Home (the
|
||||
* workspace's home) at its left end, `actions` (the single pane's split menus)
|
||||
@@ -72,6 +73,7 @@ import {
|
||||
type GroupsUpdate,
|
||||
} from "./PaneGrid";
|
||||
import { insertionIndex } from "../../lib/laneOrderStorage";
|
||||
import { readCodexDefaults, readDefaultMode } from "../../lib/sessionDefaults";
|
||||
import {
|
||||
type PaneGroups,
|
||||
MAX_PANES,
|
||||
@@ -140,15 +142,21 @@ export function newTabTools(lane: Lane, aiTools: AiToolsState): string[] {
|
||||
];
|
||||
}
|
||||
|
||||
/** The start body of a bare tool on its default model, no prompt. Only a
|
||||
* different tool is named, as the home does: the workspace's own keeps
|
||||
* starting even once disabled. */
|
||||
/** The start body of a bare tool on its default model, no prompt, in the
|
||||
* account's Session defaults (Claude's permission mode, Codex's sandbox,
|
||||
* approval and bypass) — the same ones the workspace home's prompt box starts
|
||||
* from. Only a different tool is named, as the home does: the workspace's own
|
||||
* keeps starting even once disabled. */
|
||||
export function newTabBody(id: string, lane: Lane, aiTools: AiToolsState) {
|
||||
const model = aiTools.byId(id)?.defaultModel;
|
||||
const codex = id === "codex" ? readCodexDefaults() : null;
|
||||
return {
|
||||
prompt: "",
|
||||
...(id !== lane.tool ? { tool: id } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(id === "claude" ? { permissionMode: readDefaultMode() } : {}),
|
||||
...(codex ? { sandbox: codex.sandbox, approval: codex.approval } : {}),
|
||||
...(codex?.bypass && aiTools.byId(id)?.bypass ? { bypass: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -689,8 +689,11 @@ export const PaneChat = memo(function PaneChat({
|
||||
// another dashboard on the same database, or the socket dropped) would
|
||||
// otherwise leave Stop up after the turn ended.
|
||||
intervalMs: working || waiting ? 5000 : undefined,
|
||||
// The transcript growing re-reads it too: a hook that reached another
|
||||
// dashboard on the same database sends this one no agent_updated, and
|
||||
// without a poll Stop never showed for a turn the chat calls Thinking….
|
||||
match: (msg) =>
|
||||
msg.type === "agent_updated" &&
|
||||
(msg.type === "agent_updated" || msg.type === "transcript_changed") &&
|
||||
(msg.data as { session_id?: string } | undefined)?.session_id === sessionId,
|
||||
});
|
||||
|
||||
@@ -855,7 +858,7 @@ export const PaneChat = memo(function PaneChat({
|
||||
)}
|
||||
</div>
|
||||
<div className="mx-4">
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<div className="mx-auto w-full max-w-5xl">
|
||||
{menu && (
|
||||
<PaneMenuCard
|
||||
// A new question starts with its field closed.
|
||||
|
||||
@@ -86,10 +86,12 @@ import {
|
||||
sameFilesHere,
|
||||
} from "../../lib/promptAttach";
|
||||
import { attachImeInput } from "../../lib/imeInput";
|
||||
import { wheelReports, type WheelState } from "../../lib/wheelReports";
|
||||
import { nativePasteKey, nativePasteWorks, readNativePaste } from "../../lib/nativePaste";
|
||||
import { usePermissions } from "../AuthGate";
|
||||
import { TERMINAL_FONTS, readTerminalFont } from "../../lib/terminalFont";
|
||||
import { FONT_SIZE_EVENT, readTerminalFontSize } from "../../lib/fontSize";
|
||||
import { openInPreview } from "../../lib/openInPreview";
|
||||
|
||||
type DropState = "hint" | "paste" | "uploading" | "failed" | "tooBig" | "tooBigLocalPaste";
|
||||
|
||||
@@ -462,7 +464,10 @@ export const TerminalView = memo(function TerminalView({
|
||||
searchRef.current = search;
|
||||
term.loadAddon(new ClipboardWithProvider(undefined, clipboardProvider));
|
||||
term.loadAddon(
|
||||
new WebLinksAddon((_e, uri) => {
|
||||
new WebLinksAddon((e, uri) => {
|
||||
// A local page opens in the session panel's Preview (one rule
|
||||
// for every link, `lib/openInPreview.ts`); Ctrl/Cmd keeps a tab.
|
||||
if (!e.ctrlKey && !e.metaKey && !e.shiftKey && openInPreview(uri)) return;
|
||||
window.open(uri, "_blank", "noopener,noreferrer");
|
||||
})
|
||||
);
|
||||
@@ -520,9 +525,32 @@ export const TerminalView = memo(function TerminalView({
|
||||
// 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.
|
||||
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) => {
|
||||
const mode = term.modes.mouseTrackingMode;
|
||||
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 (ev.deltaY && wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(`\x1b[<${ev.deltaY < 0 ? 64 : 65};1;1M`);
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/react";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { TerminalView, altArrowSeq, loadXterm } from "../TerminalView";
|
||||
import { pasteToLane, pasteToRun } from "../../../lib/panePaste";
|
||||
import { __resetNativePaste, nativePasteWorks } from "../../../lib/nativePaste";
|
||||
@@ -326,13 +327,33 @@ describe("TerminalView", () => {
|
||||
expect(wheelHandlerMock.mock.calls[0]![0]!(new Event("wheel"))).toBe(false);
|
||||
});
|
||||
|
||||
it("lets the wheel through once the pane's program tracks the mouse", () => {
|
||||
// tmux with `mouse on` sets this; the wheel must reach it as a real SGR
|
||||
// report, so returning false here would kill scrolling entirely.
|
||||
/** Gives the mocked terminal an on-screen box: 10px per column and per row. */
|
||||
const screenBox = () => {
|
||||
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.buffer.active.type = "alternate";
|
||||
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", () => {
|
||||
@@ -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(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";
|
||||
expect(wheel(new WheelEvent("wheel", { deltaY: -100 }))).toBe(true);
|
||||
expect(ws.sent).toHaveLength(2);
|
||||
screenBox();
|
||||
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", () => {
|
||||
|
||||
@@ -19,14 +19,30 @@
|
||||
* the next one below) — and, for
|
||||
* a user with their own Linux account (S3), their own HTTPS
|
||||
* 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): one picker signs every commit with one of the keys
|
||||
* (`PUT /api/me/git/signing`), and **Import a key you have** takes a private
|
||||
* key file or paste (`POST /api/me/git/ssh-key/import`) and, ticked by
|
||||
* default, signs with it in the same step. The private key and
|
||||
* every token are write-only: the server never returns them. A host identity
|
||||
* seen by a non-admin is read-only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { useCallback, useEffect, useState, type ReactNode } from "react";
|
||||
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 { confirmDialog } from "../../lib/confirm";
|
||||
import { copyText } from "../../lib/clipboard";
|
||||
@@ -216,6 +232,218 @@ function GitHostForm({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Commit signing: the key every commit is signed with (one picker — choosing
|
||||
* a key turns signing on, "Don't sign" off) and Import a key the user already
|
||||
* has, which signs with it in the same step unless unticked.
|
||||
*/
|
||||
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 signing = id.signing ?? { state: "off" as const, key: null, path: null };
|
||||
const current = signing.state === "on" ? keys.find((k) => k.id === signing.key) : undefined;
|
||||
const [form, setForm] = useState<{ text: string; name: string; sign: boolean } | null>(null);
|
||||
|
||||
/** Sign with `keyId` (null = stop), asking before taking over the user's own setup. */
|
||||
const choose = async (keyId: string | null) => {
|
||||
if (keyId && signing.state === "foreign") {
|
||||
if (
|
||||
!(await confirmDialog({
|
||||
title: t("gitIdentity.signReplaceConfirm", {
|
||||
path: signing.path,
|
||||
key: keyLabel(keyId),
|
||||
}),
|
||||
confirmLabel: t("gitIdentity.signReplace"),
|
||||
}))
|
||||
)
|
||||
return;
|
||||
}
|
||||
await run(
|
||||
() => api.gitIdentity.setSigning(keyId, signing.state === "foreign"),
|
||||
keyId ? t("gitIdentity.signSaved", { key: keyLabel(keyId) }) : t("gitIdentity.signStopped")
|
||||
);
|
||||
};
|
||||
|
||||
const doImport = async () => {
|
||||
if (!form) return;
|
||||
let imported: string | null = null;
|
||||
let foreign = false;
|
||||
const ok = await run(
|
||||
async () => {
|
||||
const r = await api.gitIdentity.importKey({
|
||||
privateKey: form.text,
|
||||
...(form.name.trim() ? { name: form.name.trim() } : {}),
|
||||
sign: form.sign,
|
||||
});
|
||||
imported = r.imported?.id ?? null;
|
||||
foreign = r.signError?.code === "EFOREIGNSIGN";
|
||||
return r;
|
||||
},
|
||||
t("gitIdentity.imported", { name: form.name.trim() || "signing" })
|
||||
);
|
||||
if (!ok) return;
|
||||
setForm(null);
|
||||
// Saved, but the user's own signing setup was left alone: ask once.
|
||||
if (imported && foreign) await choose(imported);
|
||||
};
|
||||
|
||||
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 });
|
||||
};
|
||||
|
||||
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: "", sign: true })}
|
||||
>
|
||||
<Upload className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.importKey")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-[11px] text-fg-muted">{t("gitIdentity.signIntro")}</p>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
data-testid="git-signing-state"
|
||||
className={`min-w-0 flex-1 text-[11px] ${
|
||||
signing.state === "on" ? "text-status-success" : "text-fg-secondary"
|
||||
}`}
|
||||
>
|
||||
{signing.state === "on"
|
||||
? t("gitIdentity.signOn", { key: keyLabel(signing.key) })
|
||||
: signing.state === "foreign"
|
||||
? t("gitIdentity.signForeign", { path: signing.path })
|
||||
: t("gitIdentity.signOff")}
|
||||
</span>
|
||||
{keys.length > 0 && (
|
||||
<select
|
||||
aria-label={t("gitIdentity.signKey")}
|
||||
value={signing.state === "on" ? (signing.key ?? "") : ""}
|
||||
disabled={busy || !id.writable}
|
||||
onChange={(e) => void choose(e.target.value || null)}
|
||||
className="select !py-1 !text-xs"
|
||||
>
|
||||
<option value="">
|
||||
{signing.state === "foreign" ? signing.path : t("gitIdentity.signNone")}
|
||||
</option>
|
||||
{keys
|
||||
.filter((k) => k.id)
|
||||
.map((k) => (
|
||||
<option key={k.id} value={k.id}>
|
||||
{keyLabel(k.id)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{current && (
|
||||
<Step n={1}>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="flex-1">{t("gitIdentity.signHostStep")}</span>
|
||||
<button type="button" className={BTN} onClick={() => void copyText(current.publicKey)}>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
{t("gitIdentity.copy")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="truncate font-mono text-[10px] text-fg-muted">{current.fingerprint}</div>
|
||||
</Step>
|
||||
)}
|
||||
|
||||
{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]`}
|
||||
/>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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`}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-[11px] text-fg-secondary">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.sign}
|
||||
onChange={(e) => setForm({ ...form, sign: e.target.checked })}
|
||||
/>
|
||||
{t("gitIdentity.importSign")}
|
||||
</label>
|
||||
</div>
|
||||
<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() {
|
||||
const { t } = useTranslation("settings");
|
||||
const [id, setId] = useState<GitIdentity | null>(null);
|
||||
@@ -572,6 +800,10 @@ export function GitIdentitySettings() {
|
||||
)}
|
||||
</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="flex items-center gap-2">
|
||||
<KeyRound className="h-4 w-4 text-fg-muted" aria-hidden />
|
||||
@@ -616,6 +848,11 @@ export function GitIdentitySettings() {
|
||||
onRemove={() => removeNamed(k.id!)}
|
||||
>
|
||||
<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}>
|
||||
{t("gitIdentity.namedGitStepHost", { name: k.name })}
|
||||
{loginRows(rows, (r) => `ssh://${r.alias}/ → ${r.user}@${r.host}`)}
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
* 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
|
||||
* 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: a key picked signs commits, an imported key signs in the
|
||||
* same step, and taking over the user's own signing key asks first.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
@@ -31,6 +33,8 @@ vi.mock("../../../lib/api", () => ({
|
||||
saveHttps: vi.fn(),
|
||||
saveAuthor: vi.fn(),
|
||||
removeHttps: vi.fn(),
|
||||
importKey: vi.fn(),
|
||||
setSigning: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -335,4 +339,55 @@ describe("GitIdentitySettings", () => {
|
||||
});
|
||||
expect(await within(work).findByText("ssh://git-2/ → git@github.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("signs commits with an imported key in one step", 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 } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.importKey).mockResolvedValue({
|
||||
...view({
|
||||
sshKeys: [signingKey],
|
||||
signing: { state: "on", key: "id_ed25519_signing", path: null },
|
||||
}),
|
||||
imported: signingKey,
|
||||
signError: null,
|
||||
});
|
||||
render(<GitIdentitySettings />);
|
||||
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",
|
||||
sign: true,
|
||||
});
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent(/signed with signing/);
|
||||
expect(confirmDialog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks before taking over a signing key the user set up themselves", async () => {
|
||||
const k = { ...KEY, id: "id_ed25519", type: "ed25519" as const, name: null };
|
||||
vi.mocked(api.gitIdentity.get).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "foreign", key: null, path: "~/.ssh/id_ed25519" } })
|
||||
);
|
||||
vi.mocked(api.gitIdentity.setSigning).mockResolvedValue(
|
||||
view({ sshKeys: [k], signing: { state: "on", key: "id_ed25519", path: null } })
|
||||
);
|
||||
render(<GitIdentitySettings />);
|
||||
expect(await screen.findByTestId("git-signing-state")).toHaveTextContent("~/.ssh/id_ed25519");
|
||||
await userEvent.selectOptions(screen.getByLabelText("Signing key"), "id_ed25519");
|
||||
expect(confirmDialog).toHaveBeenCalled();
|
||||
await waitFor(() =>
|
||||
expect(api.gitIdentity.setSigning).toHaveBeenCalledWith("id_ed25519", true)
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
"jobs.expand": "Show background tasks",
|
||||
"uploads.stop": "Stop upload",
|
||||
"uploads.stopConfirm": "Click again to stop",
|
||||
"uploads.waiting": "The dashboard is restarting — the upload resumes when it is back.",
|
||||
"uploads.stopping": "Stopping…",
|
||||
"uploads.stopped_one": "Upload stopped — {{count}} of {{total}} file sent.",
|
||||
"uploads.stopped_other": "Upload stopped — {{count}} of {{total}} files sent.",
|
||||
@@ -385,6 +386,16 @@
|
||||
"menu.remove": "Delete workspace…",
|
||||
"panel.changes": "Changes",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Preview",
|
||||
"preview.title": "Page preview",
|
||||
"preview.address": "Page address",
|
||||
"preview.reload": "Reload",
|
||||
"preview.openTab": "Open in a browser tab",
|
||||
"preview.badUrl": "Can't open that address — only an http(s) page other than AgentHub itself.",
|
||||
"preview.empty": "Type an address, or click a localhost link in the chat or terminal.",
|
||||
"preview.loginHint": "A blank page? The site may forbid embedding or need a login — open it in a browser tab.",
|
||||
"preview.blockedHint": "Sites like YouTube, Google or a login page refuse to show inside another page. A blank page or a broken-page icon means that — open it in a browser tab.",
|
||||
"preview.openTabShort": "Open tab",
|
||||
"replay.placeholder": "Message to continue this session…",
|
||||
"replay.hint": "Sending resumes the session in a free pane.",
|
||||
"replay.send": "Send",
|
||||
@@ -635,6 +646,7 @@
|
||||
"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.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.conflictBody": "This name is taken in the destination: {{names}}",
|
||||
"fx.conflictAll": "Do this for every remaining file",
|
||||
|
||||
@@ -883,6 +883,28 @@
|
||||
"authorName": "Name",
|
||||
"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.",
|
||||
"signTitle": "Commit signing",
|
||||
"signIntro": "Sign every commit — from your terminal, the Changes panel or an agent — with one of your SSH keys, so a host that requires signed commits accepts your push.",
|
||||
"signOff": "Commits are not signed.",
|
||||
"signOn": "Every commit is signed with {{key}}.",
|
||||
"signForeign": "Commits are signed with a key you set up yourself: {{path}}.",
|
||||
"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.",
|
||||
"signReplaceConfirm": "Your git already signs with {{path}}. Switch to {{key}}?",
|
||||
"signReplace": "Switch",
|
||||
"signSaved": "Commits are now signed with {{key}}.",
|
||||
"signStopped": "Commits are no longer signed.",
|
||||
"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)",
|
||||
"importSign": "Sign my commits with it",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -904,9 +926,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.",
|
||||
"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.",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||
"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",
|
||||
"awsSub": "RSA 4096 key — AWS accepts only RSA",
|
||||
"stepCopy": "Copy the public key.",
|
||||
@@ -932,10 +954,10 @@
|
||||
"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>/…",
|
||||
"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",
|
||||
"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):",
|
||||
"namedAwsSub": "RSA · for one more AWS account.",
|
||||
"removeKeyNamed": "Delete key {{name}}"
|
||||
|
||||
@@ -262,6 +262,7 @@
|
||||
"jobs.expand": "Xem các việc chạy nền",
|
||||
"uploads.stop": "Ngưng tải lên",
|
||||
"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.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.",
|
||||
@@ -385,6 +386,16 @@
|
||||
"menu.remove": "Xóa workspace…",
|
||||
"panel.changes": "Thay đổi",
|
||||
"panel.analyst": "Analyst",
|
||||
"panel.preview": "Xem trang",
|
||||
"preview.title": "Xem trước trang web",
|
||||
"preview.address": "Địa chỉ trang",
|
||||
"preview.reload": "Tải lại",
|
||||
"preview.openTab": "Mở trong tab trình duyệt",
|
||||
"preview.badUrl": "Không mở được địa chỉ này — chỉ mở được trang http(s), không phải chính AgentHub.",
|
||||
"preview.empty": "Nhập địa chỉ, hoặc bấm một link localhost trong chat hay terminal.",
|
||||
"preview.loginHint": "Trang trắng? Trang có thể chặn nhúng hoặc cần đăng nhập — hãy mở trong tab trình duyệt.",
|
||||
"preview.blockedHint": "Các trang như YouTube, Google hay trang đăng nhập không cho hiện bên trong trang khác. Thấy trang trắng hoặc biểu tượng lỗi là vì vậy — hãy mở trong tab trình duyệt.",
|
||||
"preview.openTabShort": "Mở tab",
|
||||
"replay.placeholder": "Nhắn để tiếp tục session này…",
|
||||
"replay.hint": "Gửi tin sẽ resume session trong một pane trống.",
|
||||
"replay.send": "Gửi",
|
||||
@@ -635,6 +646,7 @@
|
||||
"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.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.conflictBody": "Tên này đã có ở nơi đến: {{names}}",
|
||||
"fx.conflictAll": "Áp dụng cho tất cả file còn lại",
|
||||
|
||||
@@ -871,6 +871,28 @@
|
||||
"authorName": "Tên",
|
||||
"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.",
|
||||
"signTitle": "Ký commit",
|
||||
"signIntro": "Ký mọi commit — trong terminal, ở panel Thay đổi hay do agent tạo — bằng một SSH key của bạn, để host yêu cầu commit có chữ ký chấp nhận lần push của bạn.",
|
||||
"signOff": "Commit chưa được ký.",
|
||||
"signOn": "Mọi commit đang được ký bằng {{key}}.",
|
||||
"signForeign": "Commit đang được ký bằng key bạn tự cấu hình: {{path}}.",
|
||||
"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.",
|
||||
"signReplaceConfirm": "Git của bạn đang ký bằng {{path}}. Chuyển sang {{key}}?",
|
||||
"signReplace": "Chuyển",
|
||||
"signSaved": "Commit giờ được ký bằng {{key}}.",
|
||||
"signStopped": "Commit không còn được ký.",
|
||||
"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)",
|
||||
"importSign": "Dùng key này để ký commit",
|
||||
"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.",
|
||||
"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.",
|
||||
@@ -892,9 +914,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.",
|
||||
"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.",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket",
|
||||
"gitHubTitle": "GitHub · GitLab · Gitea · Bitbucket · Backlog",
|
||||
"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",
|
||||
"awsSub": "Key RSA 4096 — AWS chỉ nhận RSA",
|
||||
"stepCopy": "Copy public key.",
|
||||
@@ -920,10 +942,10 @@
|
||||
"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>/…",
|
||||
"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",
|
||||
"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):",
|
||||
"namedAwsSub": "RSA · cho thêm một tài khoản AWS.",
|
||||
"removeKeyNamed": "Xoá key {{name}}"
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @file openInPreview.test.ts
|
||||
* @description The one link rule: which URLs the Preview takes (http(s),
|
||||
* never the dashboard's origin, `0.0.0.0` read as `localhost`), that a local
|
||||
* link goes to the session panel only when one answers, and that a
|
||||
* Ctrl/Cmd click keeps the browser's own tab.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
OPEN_IN_PREVIEW_EVENT,
|
||||
isLoopbackUrl,
|
||||
normalizePreviewUrl,
|
||||
onLinkClick,
|
||||
openInPreview,
|
||||
type OpenInPreview,
|
||||
} from "../openInPreview";
|
||||
|
||||
const SELF = "http://localhost:4820";
|
||||
|
||||
describe("normalizePreviewUrl", () => {
|
||||
it("adds http:// to a bare host:port and reads 0.0.0.0 as localhost", () => {
|
||||
expect(normalizePreviewUrl("localhost:3000", SELF)).toBe("http://localhost:3000/");
|
||||
expect(normalizePreviewUrl("http://0.0.0.0:5173/a", SELF)).toBe("http://localhost:5173/a");
|
||||
});
|
||||
it("refuses the dashboard's own origin, other schemes and credentials", () => {
|
||||
expect(normalizePreviewUrl("http://localhost:4820/settings", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("javascript:alert(1)", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("file:///etc/passwd", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl("http://u:p@localhost:3000", SELF)).toBeNull();
|
||||
expect(normalizePreviewUrl(" ", SELF)).toBeNull();
|
||||
});
|
||||
it("accepts an external https page", () => {
|
||||
expect(normalizePreviewUrl("https://example.com/x", SELF)).toBe("https://example.com/x");
|
||||
});
|
||||
});
|
||||
|
||||
describe("isLoopbackUrl", () => {
|
||||
it("is true only for this machine's hosts", () => {
|
||||
expect(isLoopbackUrl("http://localhost:3000")).toBe(true);
|
||||
expect(isLoopbackUrl("http://127.0.0.1:8080/x")).toBe(true);
|
||||
expect(isLoopbackUrl("http://[::1]:3000")).toBe(true);
|
||||
expect(isLoopbackUrl("https://github.com")).toBe(false);
|
||||
expect(isLoopbackUrl("not a url")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("openInPreview", () => {
|
||||
let off: (() => void) | null = null;
|
||||
afterEach(() => off?.());
|
||||
const listen = (take: boolean) => {
|
||||
const seen: string[] = [];
|
||||
const fn = (e: Event) => {
|
||||
const d = (e as CustomEvent<OpenInPreview>).detail;
|
||||
seen.push(d.url);
|
||||
if (take) d.handled = true;
|
||||
};
|
||||
window.addEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||
off = () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, fn);
|
||||
return seen;
|
||||
};
|
||||
|
||||
it("hands a local page to a listening panel", () => {
|
||||
const seen = listen(true);
|
||||
expect(openInPreview("http://localhost:3000/login")).toBe(true);
|
||||
expect(seen).toEqual(["http://localhost:3000/login"]);
|
||||
});
|
||||
it("falls back when no panel takes it, and never sends an external page", () => {
|
||||
const seen = listen(false);
|
||||
expect(openInPreview("http://localhost:3000")).toBe(false);
|
||||
expect(openInPreview("https://github.com/login")).toBe(false);
|
||||
expect(seen).toEqual(["http://localhost:3000/"]);
|
||||
});
|
||||
it("leaves a Ctrl/Cmd click to the browser", () => {
|
||||
listen(true);
|
||||
const preventDefault = vi.fn();
|
||||
const base = { ctrlKey: false, metaKey: false, shiftKey: false, button: 0, preventDefault };
|
||||
onLinkClick({ ...base, ctrlKey: true }, "http://localhost:3000");
|
||||
expect(preventDefault).not.toHaveBeenCalled();
|
||||
onLinkClick(base, "http://localhost:3000");
|
||||
expect(preventDefault).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -432,6 +432,14 @@ describe("readRunBusy", () => {
|
||||
let rows = ["✻ Thinking… (4s · ↓ 120 tokens · esc to interrupt)"];
|
||||
const off = registerPane("agenthub-lane-8", () => {}, () => true, undefined, undefined, () => rows);
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// Claude's spinner with the footer's hint gone (a narrow pane, a tip).
|
||||
rows = ["✶ Skedaddling… (41s · ↓ 2.8k tokens · thought for 2s)", " ⏵⏵ auto mode on"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
rows = ["✻ Thinking… (1m 3s)"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(true);
|
||||
// The line a finished turn leaves is not.
|
||||
rows = ["✻ Cooked for 1m 2s", "❯ "];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
// Claude's permission footer is not a turn at work.
|
||||
rows = ["Esc to cancel · Tab to amend"];
|
||||
expect(readRunBusy("agenthub-lane-8", "claude")).toBe(false);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
+218
-24
@@ -1188,7 +1188,9 @@ const sendRaw = <T>(
|
||||
xhr.upload.onprogress = (e) => onProgress?.(e.loaded);
|
||||
xhr.onerror = () => reject(Object.assign(new Error("network error"), { code: "ENET" }));
|
||||
xhr.onload = () => {
|
||||
let body: { error?: { code?: string; message?: string; names?: string[] } } = {};
|
||||
let body: {
|
||||
error?: { code?: string; message?: string; names?: string[]; received?: number };
|
||||
} = {};
|
||||
try {
|
||||
body = JSON.parse(xhr.responseText);
|
||||
} catch {
|
||||
@@ -1206,12 +1208,148 @@ const sendRaw = <T>(
|
||||
code: body.error?.code,
|
||||
status: xhr.status,
|
||||
names: body.error?.names,
|
||||
received: body.error?.received,
|
||||
})
|
||||
);
|
||||
};
|
||||
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. */
|
||||
export interface FsHome {
|
||||
id: string;
|
||||
@@ -3300,6 +3438,28 @@ export const api = {
|
||||
onProgress,
|
||||
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`
|
||||
* relative to it): a 4-byte length, the JSON file list, then the bytes.
|
||||
@@ -3312,25 +3472,27 @@ export const api = {
|
||||
conflict: FsConflict = "fail",
|
||||
onProgress?: (sent: number) => void,
|
||||
signal?: AbortSignal
|
||||
) => {
|
||||
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 sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
) =>
|
||||
sendRaw<{ saved: number; skipped: number; paths: string[] }>(
|
||||
fsUrl(t, "/upload-batch", new URLSearchParams({ dir, conflict })),
|
||||
new Blob([len, head, ...files.map((f) => f.file)], { type: "application/octet-stream" }),
|
||||
batchBody(files),
|
||||
onProgress,
|
||||
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. */
|
||||
uploadCheck: (t: FsPlace, dir: string, paths: string[]) =>
|
||||
request<{ taken: string[]; batch: boolean }>(fsUrl(t, "/upload-check"), {
|
||||
@@ -3356,6 +3518,22 @@ export const api = {
|
||||
/** DELETE /api/me/git/ssh-key?id= — one key by its id (a named one). */
|
||||
removeKeyById: (id: string) =>
|
||||
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 — sign commits with key `key` (its id), or stop (null). */
|
||||
setSigning: (key: string | null, replace = false) =>
|
||||
request<GitIdentity>("/me/git/signing", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ key, replace }),
|
||||
}),
|
||||
/** PUT /api/me/git/ssh-terminal — use the key for git typed in the account's terminal. */
|
||||
setTerminalSsh: (on: boolean) =>
|
||||
request<GitIdentity>("/me/git/ssh-terminal", { method: "PUT", body: JSON.stringify({ on }) }),
|
||||
@@ -5232,6 +5410,21 @@ export interface GitCredential {
|
||||
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. */
|
||||
export interface GitIdentity {
|
||||
/** `member`: the user's own Linux account (S3); `host`: the shared host account. */
|
||||
@@ -5249,13 +5442,14 @@ export interface GitIdentity {
|
||||
* 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.
|
||||
*/
|
||||
sshKeys?: {
|
||||
id?: string;
|
||||
type?: "ed25519" | "rsa";
|
||||
name?: string | null;
|
||||
publicKey: string;
|
||||
fingerprint: string;
|
||||
}[];
|
||||
sshKeys?: GitSshKey[];
|
||||
/**
|
||||
* Commit signing (`gpg.format ssh` + `user.signingkey` + `commit.gpgsign`
|
||||
* in `~/.gitconfig`): `on` with one of these keys (`key` = its id), `off`,
|
||||
* or `foreign` — a signing key the user set themselves (`path`). Absent
|
||||
* from an older server.
|
||||
*/
|
||||
signing?: { state: "on" | "off" | "foreign"; key: string | null; path: string | null };
|
||||
/** The terminal switch (shared by both keys); null without a key. */
|
||||
sshTerminal?: "on" | "off" | "foreign" | null;
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @file openInPreview.ts — the ONE rule every link in the dashboard follows
|
||||
* (a chat's markdown link or bare URL, a terminal's link): a page served by
|
||||
* THIS machine (`localhost`, `127.0.0.1`, `0.0.0.0`, `[::1]`) opens in the
|
||||
* session view's Preview tab, everything else in a browser tab as before.
|
||||
* The request travels as a window event (like `openInChanges.ts`); the
|
||||
* Workspace page answers it only while it shows a session panel, so with no
|
||||
* listener — another page, or a remote dashboard where "localhost" is the
|
||||
* viewer's own machine — the link falls back to a browser tab. Also holds the
|
||||
* URL checks the Preview tab itself uses.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const OPEN_IN_PREVIEW_EVENT = "agenthub:open-in-preview";
|
||||
|
||||
/** The event's detail. `handled` is set by the listener, read by the sender. */
|
||||
export type OpenInPreview = { url: string; handled: boolean };
|
||||
|
||||
const LOOPBACK = new Set(["localhost", "127.0.0.1", "0.0.0.0", "[::1]", "::1"]);
|
||||
|
||||
/** A URL the Preview may show: http(s) only, `0.0.0.0` read as `localhost`
|
||||
* (a dev server prints it, a browser cannot open it), never the dashboard's
|
||||
* own origin (the frame would then BE the dashboard, cookies and API
|
||||
* included). A bare `localhost:3000` gets `http://`. Null when refused. */
|
||||
export function normalizePreviewUrl(raw: string, self = window.location.origin): string | null {
|
||||
let text = raw.trim();
|
||||
if (!text) return null;
|
||||
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(text)) text = `http://${text}`;
|
||||
let u: URL;
|
||||
try {
|
||||
u = new URL(text);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (u.protocol !== "http:" && u.protocol !== "https:") return null;
|
||||
if (u.username || u.password) return null;
|
||||
if (u.hostname === "0.0.0.0") u.hostname = "localhost";
|
||||
if (u.origin === self) return null;
|
||||
return u.toString();
|
||||
}
|
||||
|
||||
/** True for a page served by the machine the BROWSER runs on. */
|
||||
export function isLoopbackUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return (u.protocol === "http:" || u.protocol === "https:") && LOOPBACK.has(u.hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** The dashboard itself is opened from this machine: only then does a
|
||||
* `localhost` link name the same machine the agents run on. */
|
||||
export function dashboardIsLocal(hostname = window.location.hostname): boolean {
|
||||
return LOOPBACK.has(hostname);
|
||||
}
|
||||
|
||||
/** Asks the open session panel to show `url`. False when nothing took it
|
||||
* (not a local page, a remote dashboard, no session panel). */
|
||||
export function openInPreview(url: string): boolean {
|
||||
if (!isLoopbackUrl(url) || !dashboardIsLocal()) return false;
|
||||
const target = normalizePreviewUrl(url);
|
||||
if (!target) return false;
|
||||
const detail: OpenInPreview = { url: target, handled: false };
|
||||
window.dispatchEvent(new CustomEvent(OPEN_IN_PREVIEW_EVENT, { detail }));
|
||||
return detail.handled;
|
||||
}
|
||||
|
||||
/** A link click: Preview when the rule says so, else a browser tab. A
|
||||
* Ctrl/Cmd/Shift/middle click always keeps the browser's own behaviour. */
|
||||
export function onLinkClick(
|
||||
e: {
|
||||
ctrlKey: boolean;
|
||||
metaKey: boolean;
|
||||
shiftKey: boolean;
|
||||
button: number;
|
||||
preventDefault: () => void;
|
||||
},
|
||||
url: string
|
||||
): void {
|
||||
if (e.ctrlKey || e.metaKey || e.shiftKey || e.button !== 0) return;
|
||||
if (openInPreview(url)) e.preventDefault();
|
||||
}
|
||||
@@ -368,10 +368,15 @@ function typeRow(options: PaneMenuOption[]): number {
|
||||
/** The status line a tool's TUI shows only while a turn runs, read off its
|
||||
* binary's own strings: Claude's and Codex's `esc to interrupt`, agy's
|
||||
* `Thinking… (3s · esc to cancel)` — in parentheses after an ellipsis, so
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it. A
|
||||
* tool missing here (omp, Cursor) has no line we know. */
|
||||
* Claude's permission footer (`Esc to cancel · Tab to amend`) is not it.
|
||||
* Claude ≥ 2.1.3xx keeps `esc to interrupt` only in its footer, which a
|
||||
* narrow pane, a tip or a notice replaces, so its spinner line counts too:
|
||||
* a verb, an ellipsis and the turn's time in parentheses
|
||||
* (`✶ Skedaddling… (41s · ↓ 2.8k tokens)`) — the line a finished turn leaves
|
||||
* (`✻ Cooked for 1m 2s`) has neither. A tool missing here (omp, Cursor) has
|
||||
* no line we know. */
|
||||
export const BUSY_LINE: Readonly<Record<string, RegExp>> = {
|
||||
claude: /\besc to interrupt\b/i,
|
||||
claude: /\besc to interrupt\b|…\s*\(\s*\d+[smh]\b/i,
|
||||
codex: /\besc to interrupt\b/i,
|
||||
antigravity: /…\s*\([^)]*\besc to cancel\)/i,
|
||||
};
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
* 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
|
||||
* 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>
|
||||
*/
|
||||
|
||||
@@ -25,6 +27,8 @@ export interface UploadJob {
|
||||
sent: number;
|
||||
bytes: number;
|
||||
state: "running" | "stopping" | "interrupted";
|
||||
/** The dashboard is away (a restart): the upload waits and then resumes. */
|
||||
waiting?: boolean;
|
||||
}
|
||||
|
||||
type Stored = UploadJob & { beat: number };
|
||||
@@ -115,7 +119,8 @@ export function beginUpload(init: { folder: string; total: number; bytes: number
|
||||
return {
|
||||
id,
|
||||
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: () => {
|
||||
controllers.delete(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));
|
||||
}
|
||||
@@ -141,6 +141,8 @@ import { LaneChangesPanel } from "../components/lanes/LaneChangesPanel";
|
||||
import { FileExplorer } from "../components/lanes/FileExplorer";
|
||||
import { WorkspaceContext } from "../components/lanes/WorkspaceContext";
|
||||
import { OPEN_IN_CHANGES_EVENT, type OpenInChanges } from "../lib/openInChanges";
|
||||
import { OPEN_IN_PREVIEW_EVENT, type OpenInPreview } from "../lib/openInPreview";
|
||||
import { UrlPreview } from "../components/lanes/UrlPreview";
|
||||
import { LaneGitRailCounts } from "../components/lanes/LaneGitHeader";
|
||||
import { WorkspaceHome } from "../components/lanes/WorkspaceHome";
|
||||
import { SessionReplay } from "../components/lanes/SessionReplay";
|
||||
@@ -148,6 +150,8 @@ import { DestructiveLaneModal } from "../components/lanes/DestructiveLaneModal";
|
||||
|
||||
/** localStorage key for the Changes panel's open/closed state. */
|
||||
const CHANGES_OPEN_KEY = "agenthub.workspace.changesOpen";
|
||||
/** The session panel's tab — Changes or Preview. A viewer preference. */
|
||||
const PANEL_TAB_KEY = "agenthub.workspace.panelTab";
|
||||
/** localStorage key for how wide the Changes panel is. */
|
||||
const CHANGES_SIZE_KEY = "agenthub.workspace.changesSize";
|
||||
/** localStorage key for the last width the user dragged the panel to — what
|
||||
@@ -799,6 +803,25 @@ export function Workspace() {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// The panel's two tabs: Changes (git + files) and Preview (a web page).
|
||||
// Both stay mounted once shown (CSS), so an unsaved edit or a loaded page
|
||||
// survives a switch.
|
||||
const [panelTab, setPanelTab] = useState<"changes" | "preview">(() => {
|
||||
try {
|
||||
return window.localStorage.getItem(PANEL_TAB_KEY) === "preview" ? "preview" : "changes";
|
||||
} catch {
|
||||
return "changes";
|
||||
}
|
||||
});
|
||||
const [previewShown, setPreviewShown] = useState(panelTab === "preview");
|
||||
useEffect(() => {
|
||||
if (panelTab === "preview") setPreviewShown(true);
|
||||
try {
|
||||
window.localStorage.setItem(PANEL_TAB_KEY, panelTab);
|
||||
} catch {
|
||||
/* disabled storage: the tab just forgets between visits */
|
||||
}
|
||||
}, [panelTab]);
|
||||
useEffect(() => {
|
||||
try {
|
||||
window.localStorage.setItem(CHANGES_OPEN_KEY, changesOpen ? "1" : "0");
|
||||
@@ -1050,6 +1073,22 @@ export function Workspace() {
|
||||
window.addEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_CHANGES_EVENT, onOpen);
|
||||
}, []);
|
||||
// A link to a local page asks for it (`openInPreview`): taken only while a
|
||||
// session panel is on screen, so elsewhere the link opens a browser tab.
|
||||
const [previewRequest, setPreviewRequest] = useState<{ url: string; nonce: number } | null>(null);
|
||||
const previewHostRef = useRef(false);
|
||||
useEffect(() => {
|
||||
const onOpen = (e: Event) => {
|
||||
const detail = (e as CustomEvent<OpenInPreview>).detail;
|
||||
if (!previewHostRef.current) return;
|
||||
detail.handled = true;
|
||||
setChangesOpen(true);
|
||||
setPanelTab("preview");
|
||||
setPreviewRequest((r) => ({ url: detail.url, nonce: (r?.nonce ?? 0) + 1 }));
|
||||
};
|
||||
window.addEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||
return () => window.removeEventListener(OPEN_IN_PREVIEW_EVENT, onOpen);
|
||||
}, []);
|
||||
// Asked once: a panel reopened or moved to another tree must not replay it.
|
||||
useEffect(() => {
|
||||
if (!changesOpen) setChangesRequest(null);
|
||||
@@ -1230,6 +1269,7 @@ export function Workspace() {
|
||||
// The session view (console or replay) fills the page edge to edge, like an
|
||||
// editor: no page padding, no cards, dividers between its regions.
|
||||
const sessionView = !!currentLane && !home;
|
||||
previewHostRef.current = sessionView;
|
||||
const viewportLocked =
|
||||
sessionView || (!home && (paneHasActiveRun || (!!currentLane && changesOpen && changesFull)));
|
||||
|
||||
@@ -1567,9 +1607,25 @@ export function Workspace() {
|
||||
className="flex h-10 shrink-0 items-center gap-1 border-b border-border px-2"
|
||||
role="toolbar"
|
||||
>
|
||||
<span className="px-1 text-xs font-medium text-fg-secondary">
|
||||
{tLanes("panel.changes")}
|
||||
</span>
|
||||
<div role="tablist" className="flex items-center gap-0.5">
|
||||
{(["changes", "preview"] as const).map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-testid={`panel-tab-${tab}`}
|
||||
aria-selected={panelTab === tab}
|
||||
onClick={() => setPanelTab(tab)}
|
||||
className={`rounded-md px-2 py-1 text-xs font-medium transition-colors ${
|
||||
panelTab === tab
|
||||
? "bg-surface-4 text-fg-primary"
|
||||
: "text-fg-muted hover:text-fg-primary"
|
||||
}`}
|
||||
>
|
||||
{tLanes(tab === "changes" ? "panel.changes" : "panel.preview")}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{/* The opened session's analysis page — a page of its own. */}
|
||||
<button
|
||||
type="button"
|
||||
@@ -1652,7 +1708,22 @@ export function Workspace() {
|
||||
<PanelRightClose className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex min-h-0 flex-1 flex-col px-3 pt-2">
|
||||
{previewShown && (
|
||||
<div
|
||||
className={panelTab === "preview" ? "flex min-h-0 flex-1 flex-col" : "hidden"}
|
||||
>
|
||||
<UrlPreview
|
||||
laneId={currentLane.id}
|
||||
ports={currentLane.ports ?? {}}
|
||||
request={previewRequest}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className={
|
||||
panelTab === "changes" ? "flex min-h-0 flex-1 flex-col px-3 pt-2" : "hidden"
|
||||
}
|
||||
>
|
||||
{panelAt &&
|
||||
[
|
||||
...(showRoot
|
||||
|
||||
@@ -1053,7 +1053,28 @@ describe("split panes", () => {
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", { slot: "auto", prompt: "" });
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: "auto",
|
||||
prompt: "",
|
||||
permissionMode: "auto",
|
||||
});
|
||||
});
|
||||
|
||||
it("a new AI pane starts in the account's Session defaults (bypass included)", async () => {
|
||||
localStorage.setItem("agenthub.permissionMode", "bypassPermissions");
|
||||
lanesToReturn = lanesToReturn.map((l) => ({ ...l, tool: "claude" }));
|
||||
withRuns([live(0), live(1)]);
|
||||
await open("/workspace?lane=1&run=agenthub-lane-1");
|
||||
await ctrlClick(screen.getByTestId("console-tab-1"));
|
||||
const user = userEvent.setup();
|
||||
await user.click(screen.getByTestId("pane-split-right-1"));
|
||||
await user.click(screen.getByTestId("pane-split-new-right-1-claude"));
|
||||
expect(api.lanes.action).toHaveBeenCalledWith(1, "start", {
|
||||
slot: "auto",
|
||||
prompt: "",
|
||||
permissionMode: "bypassPermissions",
|
||||
});
|
||||
localStorage.removeItem("agenthub.permissionMode");
|
||||
});
|
||||
|
||||
it("with one pane, the split menus sit beside Tools and split it", async () => {
|
||||
|
||||
@@ -7316,7 +7316,7 @@ exports[`screen snapshots > Settings — about 1`] = `
|
||||
<span
|
||||
class="text-status-warning/90"
|
||||
>
|
||||
UI build v2.8.52 (rebuild client to match)
|
||||
UI build v2.8.61 (rebuild client to match)
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
+36
-1
File diff suppressed because one or more lines are too long
@@ -416,6 +416,7 @@ The dashboard web UI merges workspaces and runs into a single **Workspace** page
|
||||
- **Tabs** — the session view's console is a tab bar (`client/src/components/run/ConsoleTabs.tsx`) over the workspace's live tmux sessions — at most ten, its slots 0–9: slot 0 is the workspace's `agenthub-lane-<id>` (its `run_id`), slots 1–9 are `agenthub-lane-<id>-<slot>`. Every tab is a **combo**: a group of one to four split panes, each session in exactly one tab (`PaneGroups` in `client/src/lib/paneLayout.ts`). A one-pane tab shows its AI tool's icon and name (or **Terminal**); a combo tab shows every pane's icon and name, and the most urgent state dot. The active tab is the one on screen and its focused pane is the URL's `?run=`, so the Changes panel and Analyst follow it. A click shows a tab; **Ctrl/Cmd+click** on a one-pane tab or a `+` item brings it into the tab on screen, in the same order as a join below (a full tab — four panes — opens it as a tab of its own instead), and a one-pane tab dragged onto a pane joins that tab there. Any tab drags along the bar to reorder it; a pane's header dragged onto the bar takes that pane out into a tab of its own at that place. Dropped on the MIDDLE of another tab (it highlights), a tab — or a pane's header — joins that tab instead, in a fixed order: the second pane goes to the right (left | right), the third under the right pane, the fourth under the left pane (2×2) — the new pane always splits the BIGGEST one by the tree's shape (dragged dividers ignored, a tie goes to the later pane), so a three-pane tab of any shape fills its one missing quarter; a drop that would pass four panes reorders instead (`joinGroup`). A pane drag (a pane's header, or a one-pane tab) held over another tab for 0.6 s opens that tab (`SPRING_MS`), so the pane can be dropped on any edge of that tab's panes instead of the fixed join order; on a bar wider than the screen, dragging near either end scrolls it. Every live session stays mounted and those of other tabs are hidden with CSS, so switching tabs never drops a terminal. A tab's **×** stops every session in it (`DELETE /api/run/<runId>`, after a confirm when one is an AI tool); with no tab left it moves to the home. The **+** menu opens a new tab in the first free slot, which the server picks (`slot: "auto"`, under the workspace lock): **New terminal** (your own default shell in the workspace folder — `POST /api/lanes/:id/start {slot, shell: true}`, recorded as tool `shell`; it is not an agent, so the workspace's `run_id`/`status` are left alone), each installed, enabled AI tool (started bare, on its default model, no prompt), and **Agent settings…** (Settings → Agents). With ten sessions open **+** is disabled. EVERY tab is bound to the sidebar's selection — unless Settings → Appearance → **Tabs from every workspace** is on (`agenthub.workspace.allTabs`, `client/src/lib/workspaceTabs.ts`, synced to the account): then the bar holds every workspace's live sessions, each tab named `<workspace> · <session>`, a combo may mix workspaces, and focusing a pane selects its workspace (`?lane=` follows `?run=`, so the Changes panel and the sidebar follow too). Those tabs are stored once, under `all` in `agenthub.workspace.paneLayout`, their leaves `paneKey`s (lane id × 10 + slot); a split's **New terminal** starts in the target pane's own workspace. A tab has no header of its own (no title, no **Active runs** list, no **New run**): other sessions are opened from the sidebar or the home's Recents. (The old fixed 1/2/4 split layout and its `agenthub.workspace.splitView` key are gone; **Split panes** replaces them.) **Settings** opened on a workspace — the sidebar's Settings, the `+` menu's Agent settings, any Settings link inside it — is one more tab at the bar's end (`?settings=<section>` beside `lane` and `run`, `client/src/lib/settingsLink.ts`): the Settings pages (`SettingsHub` with a `section`), shown instead of the panes, which stay mounted behind them, kept until the tab's X and reopened on the section it was on. Off a workspace a Settings link still opens the `/settings` page.
|
||||
|
||||
- **Split panes** — the console splits like VS Code (`client/src/components/run/PaneGrid.tsx`, model in `client/src/lib/paneLayout.ts`): one binary tree of splits PER TAB, each side by side or stacked at a ratio, whose leaves are the workspace's slots — at most four panes in a tab, a session never shown twice. **Ctrl/Cmd+click** a one-pane tab, a `+` item or a sidebar session row brings it into the tab on screen at the 2×2 grid's next place — the same order as a tab join, whichever pane is focused (the intent rides on the navigation's state, never in the URL); with more than one pane each gets a slim header: **drag** it (or a tab) onto another pane — its edge splits there, its middle swaps the two (a session from another tab takes the pane's place and that one moves to a tab of its own) —; while the drag lasts the moved pane is dimmed, the drag shows a small chip with its name, and the drop preview says what will happen (*Place on the left*, *Swap places*…, `dropAction`), its **▾** shows another live session in it (swapping with the pane that showed it), **Split right / Split down** puts a not-shown session or a **New terminal** beside it, its **⤡** takes the pane out of this tab into a tab of its own (its session keeps running), and its **×** (also in the header's right-click menu) stops just that pane's session — the same `DELETE /api/run/<runId>` and AI-tool confirm as a tab's **×** — without taking it out first. A session dropped or swapped in from another tab leaves that tab. Dividers drag (or move by arrow keys) between 10% and 90%. Clicking in (or tabbing into) a pane focuses it, `?run=` follows — so the right panel, Analyst and the sidebar read the focused session — and the keyboard goes with it: only the focused pane's terminal takes it when it appears. A pane closes when its terminal reports that its session ended (exited, closed from another browser); the run list itself only prunes a remembered layout on a workspace's first read, and a pane opened here whose session never got listed — one empty read, which the server returns on any tmux error, never wipes a split. A session ending while the home or a replay is shown only closes its pane; the URL stays. The tabs and their layouts are a viewer preference kept per workspace in `localStorage` (`agenthub.workspace.paneLayout`, `{groups, active}`; a stored pre-tab single layout reads as one tab), never on the server. Every pane is an absolutely placed sibling keyed by its slot, so splitting, swapping or resizing never re-parents — and so never remounts — a live terminal; the session view itself fills the page edge to edge, its tab bar and the right panel's activity bar one header strip.
|
||||
- **Preview** — the session view's right panel has two tabs, **Changes** and **Preview** (`client/src/components/lanes/UrlPreview.tsx`). Preview shows a web page by address — before one is open, the workspace's bound ports are offered as `localhost:<port>` chips — with Reload and **Open in a browser tab**; the last address is remembered per workspace in this browser (`agenthub.preview.url.<id>`). Every link follows one rule (`client/src/lib/openInPreview.ts`): a chat link, bare URL or terminal link to `localhost`/`127.0.0.1`/`0.0.0.0`/`[::1]` opens in Preview while a session view is open and the dashboard itself is opened on this machine; any other link, and any Ctrl/Cmd click, opens a browser tab. The page runs on its own origin (the dashboard's own is refused) with no top navigation. A site that forbids embedding — YouTube, Google, most login pages — shows blank or a broken-page icon; any page not on this machine gets a strip under the address bar saying so, with **Open tab** beside it.
|
||||
- **Workspace grid** — one card per workspace, 1 column, 2 at `md`, 3 at `xl`. Each card carries the workspace id, liveness dot and status, title, declared stage and time-on-stage, the `auto:` chip when detection leads, the kind and CI tags, the working-copy facts from `GET /api/lanes/:id/git`, and the action row. The needs-you message (`needs_action`) is on the workspace list tile only — the detail card no longer repeats it as a banner.
|
||||
|
||||
Run history is per workspace, queryable via `GET /api/run/history?laneId=<n>`.
|
||||
|
||||
+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.
|
||||
- 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.
|
||||
- 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:
|
||||
- the workspace prompt and its context files;
|
||||
- the AI Proxy settings: no key, only that pane's own token for the gateway;
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.52",
|
||||
"version": "2.8.61",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "agenthub",
|
||||
"version": "2.8.52",
|
||||
"version": "2.8.61",
|
||||
"hasInstallScript": true,
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "agenthub",
|
||||
"version": "2.8.52",
|
||||
"version": "2.8.61",
|
||||
"bin": {
|
||||
"agenthub": "bin/agenthub.js"
|
||||
},
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* place, across workspaces), delete into the trash and restore/purge, a
|
||||
* 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
|
||||
* 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
|
||||
* `..`, no symlink walking out, nothing in `.git`, no secret file, the
|
||||
* 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")));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const r = await request("GET", `/api/lanes/${lane1}/fs/zip?p=src&p=site/css/app.css`);
|
||||
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 () => {
|
||||
runAs.writeAsCanMove = () => false;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* @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; deleting the key stops signing.
|
||||
* @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();
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,7 @@
|
||||
* 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
|
||||
* 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.
|
||||
* @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")));
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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.
|
||||
const all = (await call("GET", "/api/homes/trash", dan)).body;
|
||||
assert.deepEqual(
|
||||
all.items.map((i) => [i.name, i.home, i.owner]).sort(),
|
||||
[
|
||||
["junk", "cara", "cara"],
|
||||
["old", "cara", "cara"],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(all.items.map((i) => [i.name, i.home, i.owner]).sort(), [
|
||||
["junk", "cara", "cara"],
|
||||
["old", "cara", "cara"],
|
||||
]);
|
||||
// A developer's merged view is only their own.
|
||||
assert.deepEqual(
|
||||
(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
|
||||
// chat Progress panel is drawn from those calls.
|
||||
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"]);
|
||||
const text = fs.readFileSync(path.join(dir, "CLAUDE.md"), "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
|
||||
@@ -634,16 +634,26 @@ async function purgeTrash(dir, userKey, id) {
|
||||
|
||||
// ── 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) {
|
||||
let n = 0;
|
||||
return new Transform({
|
||||
const t = new Transform({
|
||||
transform(chunk, _enc, cb) {
|
||||
n += chunk.length;
|
||||
if (n > max) return cb(bad("ETOOBIG", `a file may be at most ${max / 1024 / 1024} MB`));
|
||||
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 limit = capped(UPLOAD_MAX_BYTES);
|
||||
if (acct) {
|
||||
// write-as `replace`: a new file renamed over the name, as the member.
|
||||
const done = runAs.writeAsAsync(acct, loc.abs, ["replace"], { input: limit });
|
||||
await Promise.all([
|
||||
const feed = (done) =>
|
||||
Promise.all([
|
||||
pipeline(stream, limit).catch((e) => {
|
||||
limit.destroy(e);
|
||||
throw e;
|
||||
}),
|
||||
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 {
|
||||
const tmp = path.join(
|
||||
loc.parent.abs,
|
||||
@@ -694,6 +729,8 @@ async function upload(dir, rel, stream, { conflict, length, trash } = {}) {
|
||||
);
|
||||
try {
|
||||
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.
|
||||
await fs.promises.link(tmp, loc.abs).catch(async (e) => {
|
||||
if (e.code === "EEXIST")
|
||||
|
||||
@@ -190,6 +190,11 @@ function makeKeyPair(comment) {
|
||||
const pair = crypto.generateKeyPairSync("ed25519");
|
||||
const pub = Buffer.from(pair.publicKey.export({ format: "jwk" }).x, "base64url");
|
||||
const seed = Buffer.from(pair.privateKey.export({ format: "jwk" }).d, "base64url");
|
||||
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 pubBlob = Buffer.concat([sshString(type), sshString(pub)]);
|
||||
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. */
|
||||
function fingerprint(publicKey) {
|
||||
const blob = Buffer.from(String(publicKey).trim().split(/\s+/)[1] || "", "base64");
|
||||
@@ -321,6 +502,109 @@ async function generateSshKey(
|
||||
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 } = {}) {
|
||||
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) {
|
||||
if (state.state === "foreign") 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -335,6 +619,11 @@ async function removeSshKey(account, which) {
|
||||
id != null ? k.id === id : type != null ? k.id === KEY_FILES[type] : true
|
||||
);
|
||||
if (!gone.length) return false;
|
||||
// Commits signed with a key about to go would all fail: stop signing first.
|
||||
const signingKey = await globalGet(account, "user.signingkey");
|
||||
if (signingKey && gone.some((k) => k.file === signingKey)) {
|
||||
await setSigning(account, { id: null }).catch(() => {});
|
||||
}
|
||||
for (const k of gone) {
|
||||
removeFile(account, k.file);
|
||||
removeFile(account, `${k.file}.pub`);
|
||||
@@ -1133,6 +1422,10 @@ module.exports = {
|
||||
sshKeyInfo,
|
||||
sshKeysInfo,
|
||||
generateSshKey,
|
||||
importSshKey,
|
||||
parsePrivateKey,
|
||||
signingState,
|
||||
setSigning,
|
||||
removeSshKey,
|
||||
terminalSshCommand,
|
||||
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
|
||||
* Workspace groups them into tabs of up to four split panes each). */
|
||||
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"]);
|
||||
// `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
|
||||
@@ -663,6 +665,15 @@ function spawnRun(args) {
|
||||
// (routes/sessions.js sessionOutline). A user's settings.json `env` still
|
||||
// wins, so `"0"` there turns them back off.
|
||||
...(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
|
||||
// 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
|
||||
|
||||
@@ -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 @@ const tags = [
|
||||
];
|
||||
|
||||
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 = {
|
||||
FsEntry: {
|
||||
@@ -141,12 +141,109 @@ const OPS = [
|
||||
parameters: [
|
||||
{ name: "path", in: "query", required: true, schema: { type: "string" } },
|
||||
{ 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: {
|
||||
required: true,
|
||||
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: [
|
||||
{ name: "dir", in: "query", schema: { type: "string", default: "" } },
|
||||
{ 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: true,
|
||||
required: false,
|
||||
content: { "application/octet-stream": { schema: { type: "string", format: "binary" } } },
|
||||
},
|
||||
status: 201,
|
||||
@@ -233,7 +337,7 @@ function operations(prefix, idParam, opPrefix, note) {
|
||||
tags: ["Files"],
|
||||
summary,
|
||||
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 || [])],
|
||||
...(x.body ? { requestBody: x.body } : {}),
|
||||
responses: { [x.status || 200]: x.ok || { description: "Done." } },
|
||||
@@ -459,6 +563,45 @@ const paths = {
|
||||
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",
|
||||
requestBody: json({ key: { type: "string", nullable: true }, replace: { 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/ssh-terminal": {
|
||||
put: {
|
||||
tags: ["Git identity"],
|
||||
|
||||
@@ -9,7 +9,12 @@
|
||||
* 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
|
||||
* 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`). The account is the signed-in user's
|
||||
* own Linux account under S3, else the host's, which only an admin (or local
|
||||
* mode) may change (`EHOSTIDENTITY`). Nothing here takes a path from the
|
||||
* request, and every write passes the same-origin guard.
|
||||
@@ -39,6 +44,9 @@ const STATUS = {
|
||||
ETOOMANYKEYS: 409,
|
||||
EBADALIAS: 400,
|
||||
ENOKEY: 400,
|
||||
EBADKEY: 400,
|
||||
EKEYPASSPHRASE: 400,
|
||||
EFOREIGNSIGN: 409,
|
||||
};
|
||||
|
||||
function fail(res, e) {
|
||||
@@ -61,6 +69,7 @@ async function view(account, username) {
|
||||
// The regions a CodeCommit key ID saved for "every region" covers.
|
||||
codecommitRegions: identity.CODECOMMIT_REGIONS,
|
||||
https: identity.listHttps(account),
|
||||
signing: await identity.signingState(account),
|
||||
author: await identity.getAuthor(account),
|
||||
// What the author email form suggests while none is set.
|
||||
defaultEmail: identity.defaultEmail(username),
|
||||
@@ -106,6 +115,46 @@ 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 });
|
||||
res.json(await view(account, usernameOf(req)));
|
||||
} catch (e) {
|
||||
fail(res, e);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/ssh-key", sameOriginGuard, async (req, res) => {
|
||||
try {
|
||||
const account = accountOf(req, { write: true });
|
||||
|
||||
+63
-18
@@ -8,6 +8,7 @@
|
||||
* 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/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
|
||||
* (`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 fx = require("../lib/file-explorer");
|
||||
const staging = require("../lib/upload-staging");
|
||||
const homes = require("../lib/homes");
|
||||
const scope = require("../lib/scope");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
@@ -46,12 +48,17 @@ function fail(res, err) {
|
||||
EUPGRADE: 409,
|
||||
EMEMBERGONE: 409,
|
||||
ETOOBIG: 413,
|
||||
EOFFSET: 409,
|
||||
EBUSY: 409,
|
||||
EINCOMPLETE: 409,
|
||||
ETOOMANY: 429,
|
||||
}[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EFS",
|
||||
message: err.message,
|
||||
...(Array.isArray(err.names) ? { names: err.names } : {}),
|
||||
...(Number.isInteger(err.received) ? { received: err.received } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -294,22 +301,57 @@ router.post(
|
||||
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(
|
||||
"/:who/fs/upload",
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.upload(
|
||||
home.dir,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
req,
|
||||
{
|
||||
conflict: req.query.conflict,
|
||||
length: req.headers["content-length"],
|
||||
trash: trashFor(req, home),
|
||||
}
|
||||
);
|
||||
res.status(out.skipped ? 200 : 201).json(out);
|
||||
const r = await stagedOr(req, async (stream, length) => {
|
||||
const out = await fx.upload(
|
||||
home.dir,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, length, trash: trashFor(req, home) }
|
||||
);
|
||||
return { status: out.skipped ? 200 : 201, body: out };
|
||||
});
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
throw err;
|
||||
@@ -321,13 +363,16 @@ router.post(
|
||||
sameOriginGuard,
|
||||
handle(async (req, res, home) => {
|
||||
try {
|
||||
const out = await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
const r = await stagedOr(req, async (stream) => ({
|
||||
status: 201,
|
||||
body: await fx.uploadBatch(
|
||||
home.dir,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, trash: trashFor(req, home) }
|
||||
),
|
||||
}));
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
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
|
||||
// name; 409 EEXISTS carries `names` so the browser can ask.
|
||||
const fileExplorer = require("../lib/file-explorer");
|
||||
const uploadStaging = require("../lib/upload-staging");
|
||||
|
||||
function fsFail(res, err) {
|
||||
const status =
|
||||
@@ -1894,12 +1895,17 @@ function fsFail(res, err) {
|
||||
EMEMBERGONE: 409,
|
||||
ETOOBIG: 413,
|
||||
ENOHOME: 404,
|
||||
EOFFSET: 409,
|
||||
EBUSY: 409,
|
||||
EINCOMPLETE: 409,
|
||||
ETOOMANY: 429,
|
||||
}[err.code] ?? 500;
|
||||
res.status(status).json({
|
||||
error: {
|
||||
code: err.code || "EFS",
|
||||
message: err.message,
|
||||
...(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
|
||||
* 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);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.upload(
|
||||
t.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
req,
|
||||
{
|
||||
conflict: req.query.conflict,
|
||||
length: req.headers["content-length"],
|
||||
trash: fsTrashFor(req, t),
|
||||
}
|
||||
);
|
||||
res.status(out.skipped ? 200 : 201).json(out);
|
||||
const write = async (stream, length) => {
|
||||
const out = await fileExplorer.upload(
|
||||
t.cwd,
|
||||
typeof req.query.path === "string" ? req.query.path : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, length, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
return { status: out.skipped ? 200 : 201, body: out };
|
||||
};
|
||||
const r = await stagedOr(req, write);
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
@@ -2091,13 +2152,17 @@ router.post("/:id/fs/upload-batch", sameOriginGuard, async (req, res) => {
|
||||
const t = await gitTargetOr404(req, res);
|
||||
if (!t) return;
|
||||
try {
|
||||
const out = await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
req,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
);
|
||||
res.status(201).json(out);
|
||||
const write = async (stream) => ({
|
||||
status: 201,
|
||||
body: await fileExplorer.uploadBatch(
|
||||
t.cwd,
|
||||
typeof req.query.dir === "string" ? req.query.dir : "",
|
||||
stream,
|
||||
{ conflict: req.query.conflict, trash: fsTrashFor(req, t) }
|
||||
),
|
||||
});
|
||||
const r = await stagedOr(req, write);
|
||||
res.status(r.status).json(r.body);
|
||||
} catch (err) {
|
||||
req.resume();
|
||||
fsFail(res, err);
|
||||
|
||||
Reference in New Issue
Block a user