Compare commits

..

18 Commits

Author SHA1 Message Date
nntrivi2001 246b554dab chore: v2.8.62 — every account sees other users' avatars 2026-11-02 16:40:37 +07:00
nntrivi2001 7ee79292fa docs(api): GET /api/users/:id/avatar is open to every signed-in account 2026-11-02 16:40:37 +07:00
nntrivi2001 600e8eec6a fix(avatars): every signed-in account sees other users' pictures
System Health shows every user's name and avatarAt to everyone, but the
picture route GET /api/users/:id/avatar sat under the staff-only /users
prefix, so a developer got 403 EADMIN and saw only initials. authGuard now
lets any signed-in account make that one read (EVERYONE_READS); every
other /users path stays staff-only, and the test checks both.
2026-11-02 16:40:29 +07:00
nntrivi2001 3ec1978176 chore: v2.8.61 — uploads survive a restart, SSH key import 2026-11-02 16:14:32 +07:00
nntrivi2001 12ea853170 feat(files): uploads survive a dashboard restart
An upload through the File Explorer is now sent in 8 MB parts that the
server stages on the host (<data dir>/upload-staging/<user>/<id>/,
server/lib/upload-staging.js) and writes only once every byte is there,
through the same upload/uploadBatch code and guards (?staged=<id>). A
restart, an update or a crash pauses it: the browser waits up to 3 min,
asks how much arrived (GET /fs/upload-part) and sends the rest; the dock
says the dashboard is restarting meanwhile. A commit's answer is stored
and replayed, never written twice; a refused one (EEXISTS) keeps the
bytes for a retry with another conflict policy.

A file uploaded into a member's folder is now written to a hidden temp
name and moved onto its name only when whole: write-as cannot tell a
dropped connection from the end of a file, and `replace` left a truncated
file under the real name when the server died mid-upload.

New: GET|POST|DELETE /api/{lanes/:id,homes/:who}/fs/upload-part; 409
EOFFSET/EINCOMPLETE/EBUSY, 429 ETOOMANY. Verified on a sandbox server:
SIGTERM mid-upload of a 60 MB file in a real browser, resumed and
sha256-identical.
2026-11-02 16:12:57 +07:00
nntrivi2001 7e41c81d53 feat(git-identity): import an existing SSH key and sign commits with it
Settings → Git gains Commit signing: one picker signs every commit
(terminal, Changes panel, agents) with one of the account's keys —
gpg.format ssh, user.signingkey = the key's private file, commit.gpgsign
true in its own ~/.gitconfig; a signing key the user set up themselves is
replaced only after a confirm (EFOREIGNSIGN without `replace`). Import a key
you have takes a key file or paste (OpenSSH or PEM, ed25519 or RSA >= 2048)
into ~/.config/agenthub/ssh/ as a named key, written like a generated one
(member: write-as, 0600), and by default signs with it in the same step.
A passphrase key, a .pub, an ecdsa key, a mismatched paste and the same key
twice are refused; only the public half ever comes back. Deleting the
signing key stops signing so commits keep working.

The SSH tab is renamed "Git (GitHub, GitLab, Gitea, Backlog…)" with the
Gitea and Backlog steps, and the File Explorer's ESECRET toast now says
that top-level dot folders of a home are edited in a terminal, and where
the SSH keys live instead.
2026-11-02 16:12:57 +07:00
nntrivi2001 2d6c751fb2 fix(terminal): touchpad scrolls 1:1 with the finger
xterm.js sends one wheel report per event whatever its size and damps a
touchpad's pixel deltas x0.3, so a swipe crawled. With mouse tracking on,
TerminalView now sends the SGR reports itself (lib/wheelReports.ts): one
per 5 rows of travel (what one report scrolls in a Claude pane), the
remainder carried between events, at least one per wheel notch, at the
pointer's cell with xterm's modifier bits.
2026-11-02 16:06:43 +07:00
nntrivi2001 e0b8d5641e chore: v2.8.60 — Claude panes scroll faster 2026-11-02 15:32:46 +07:00
nntrivi2001 faa96d1964 fix(terminal): Claude panes scroll 5 lines per wheel notch
Fullscreen Claude Code scrolls one line per wheel report when its terminal
does not answer XTVERSION as xterm.js (behind tmux it never does), and
xterm.js sends one report per notch — so a notch moved a single line.
spawnRun now sets CLAUDE_CODE_SCROLL_SPEED=5 unless the dashboard's env
already sets it; settings.json env and /config Scroll speed still win.
2026-11-02 15:32:46 +07:00
nntrivi2001 68c3cfaf26 chore: v2.8.59 — Preview explains a blank outside site 2026-11-02 15:10:55 +07:00
nntrivi2001 1ad2fec073 fix(preview): say why an outside site shows blank, with Open tab beside it
YouTube, Google and login pages refuse framing and the browser draws a
broken-page icon; the hint lived only in the empty state. A page not on this
machine now gets a strip under the address bar with Open tab. The refused-
address toast says what is allowed in plain words.
2026-11-02 15:10:22 +07:00
nntrivi2001 1294ce1faa chore: v2.8.58 — Preview tab in the session panel 2026-11-02 15:06:43 +07:00
nntrivi2001 5d62003fe5 feat(workspace): Preview tab in the session panel; local links open there
The session panel's header becomes two tabs, Changes and Preview. Preview
shows a web page by address (Reload, Open in a browser tab, the workspace's
ports as chips, last address per workspace) in an iframe on the page's own
origin: http(s) only, no userinfo, never the dashboard's origin, no top
navigation. One rule for every link (lib/openInPreview.ts): a chat or
terminal link to localhost/127.0.0.1/0.0.0.0/[::1] opens in Preview while a
session panel answers and the dashboard is on loopback; anything else, or a
Ctrl/Cmd click, opens a browser tab as before.
2026-11-02 15:06:04 +07:00
nntrivi2001 12d53afaeb fix(changes): long branch name truncates; armed Discard matches the row icons
The branch name was shrink-0, so a long one ran over the head sha and the
action icons. It now truncates after the commit subject (full name in the
tooltip). The armed per-file Discard is the trash icon plus a small red
word on a faint tint, sized like its neighbours, instead of a bordered chip.
2026-11-02 14:44:23 +07:00
nntrivi2001 4263b3eb26 chore: v2.8.57 — chat column widens to 64rem 2026-11-02 14:42:01 +07:00
nntrivi2001 d58f13e1ce fix(chat): conversation column widens to 64rem instead of 48rem
A wide pane left the chat squeezed into a 768px column with empty space
either side, so a wide table was cut off. The message list, the prompt
box and the session replay now share max-w-5xl.
2026-11-02 14:41:56 +07:00
nntrivi2001 81b3cbda65 chore: v2.8.56 — new tab / split pane starts in the Session defaults 2026-11-02 13:34:36 +07:00
nntrivi2001 0a99eed369 fix(workspace): a new tab or split pane starts in the account's Session defaults
newTabBody sent only prompt/tool/model, so a Claude tab opened from the tab
bar + or a split menu always started in auto mode even with Bypass
permissions set as the default; a Codex one ignored its sandbox, approval
and YOLO defaults. It now sends the same defaults the workspace home's
prompt box starts from.
2026-11-02 13:34:10 +07:00
53 changed files with 2931 additions and 163 deletions
+4 -3
View File
File diff suppressed because one or more lines are too long
@@ -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) => {
+30 -4
View File
@@ -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}
+180
View File
@@ -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 -5
View File
@@ -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 } : {}),
};
}
+1 -1
View File
@@ -858,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.
+29 -1
View File
@@ -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)
);
});
});
+2 -2
View File
@@ -2,7 +2,7 @@
* @file UserAvatar.tsx
* @description An account's round picture: the signed-in account's own
* (`GET /api/me/avatar`) or, with `userId`, another's (`GET
* /api/users/:id/avatar`, staff only), versioned by `avatarAt`; without one —
* /api/users/:id/avatar`, any signed-in account), versioned by `avatarAt`; without one —
* or when the image fails to load — the first letters of its name. Used by the
* sidebar's account row, its workspace owner groups, Settings → Account, the
* Users page and its editor, the pool board and member picker, and System
@@ -23,7 +23,7 @@ export function UserAvatar({
}: {
name: string;
avatarAt?: string | null;
/** Another account's (staff only); omitted = the signed-in account's own. */
/** Another account's; omitted = the signed-in account's own. */
userId?: number;
/** How many letters of the name stand in for a missing picture. */
letters?: number;
+12
View File
@@ -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",
+26 -4
View 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}}"
+12
View File
@@ -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",
+26 -4
View File
@@ -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);
});
});
@@ -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);
});
});
+219 -25
View File
@@ -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;
@@ -1430,7 +1568,7 @@ export const api = {
},
// ───────────────────────── Server mode staff views (routes/users.js) ─────────────────────────
users: {
/** URL of one account's avatar (admin/analyst), versioned by `avatarAt` (null = none). */
/** URL of one account's avatar (any signed-in account), versioned by `avatarAt` (null = none). */
avatarUrl: (id: number, avatarAt?: string | null) =>
avatarAt ? `${BASE}/users/${id}/avatar?v=${encodeURIComponent(avatarAt)}` : null,
/** GET /api/users — every account with its recent usage (admin/analyst). */
@@ -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;
/**
+83
View File
@@ -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();
}
+6 -1
View File
@@ -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);
+59
View File
@@ -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));
}
+75 -4
View File
@@ -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
+22 -1
View File
@@ -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.55 (rebuild client to match)
UI build v2.8.62 (rebuild client to match)
</span>
</p>
</div>
+37 -2
View File
File diff suppressed because one or more lines are too long
+1
View File
@@ -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
View File
@@ -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;
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "agenthub",
"version": "2.8.55",
"version": "2.8.62",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "agenthub",
"version": "2.8.55",
"version": "2.8.62",
"hasInstallScript": true,
"license": "UNLICENSED",
"dependencies": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "agenthub",
"version": "2.8.55",
"version": "2.8.62",
"bin": {
"agenthub": "bin/agenthub.js"
},
+184 -1
View File
@@ -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 {
+206
View File
@@ -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();
});
});
+37 -7
View File
@@ -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),
+3
View File
@@ -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/);
+9 -4
View File
@@ -4,8 +4,9 @@
* server/lib/avatars.js): an account sets, reads and removes its own picture;
* only PNG/JPEG/GIF/WebP by magic bytes (an SVG is refused), at most 1 MB,
* served with `nosniff` and a `sandbox` CSP; `avatarAt` reaches `/api/auth/me`;
* another account never sees it through `/api/me`; staff read it at
* `/api/users/:id/avatar` (a developer is refused) and a workspace carries
* another account never sees it through `/api/me`; every account reads it at
* `/api/users/:id/avatar` (a developer too — only the picture, the rest of
* `/users` stays staff-only) and a workspace carries
* `owner_avatar_at` for the sidebar's owner groups. A picture from a link
* (`POST /api/me/avatar/url`, server/lib/avatar-url.js) is stored, never
* downloaded, and the avatar routes redirect to it; https only, never a
@@ -196,7 +197,7 @@ it("a picture from a link: https only, never a private or local address", async
}
});
it("staff read another account's avatar; workspaces carry owner_avatar_at", async () => {
it("every account reads another's avatar; workspaces carry owner_avatar_at", async () => {
const ava = await login("ava", "ava-pass-1");
const ann = await login("ann", "ann-pass-1");
const bob = await login("bob", "bob-pass-1");
@@ -210,8 +211,12 @@ it("staff read another account's avatar; workspaces carry owner_avatar_at", asyn
assert.deepEqual(Buffer.from(await got.arrayBuffer()), PNG);
assert.equal(
(await fetch(`${BASE}/api/users/${id}/avatar`, { headers: { cookie: bob } })).status,
403
200
);
// Only the picture: the rest of /users stays staff-only for a developer.
for (const path of [`/api/users/${id}`, `/api/users/${id}/details`, "/api/users"]) {
assert.equal((await fetch(BASE + path, { headers: { cookie: bob } })).status, 403, path);
}
const lanes = (await (await fetch(BASE + "/api/lanes", { headers: { cookie: ann } })).json())
.lanes;
assert.ok(lanes.find((l) => l.cwd === "/w/ava").owner_avatar_at);
+6
View File
@@ -128,6 +128,11 @@ const STAFF_ONLY_PREFIXES = [
// Staff-only paths with an id in the middle, matched on `apiPath()`'s
// lower-cased form: the host's own AI-tool config files (they may hold keys).
const STAFF_ONLY_PATTERNS = [/^\/ai-tools\/[^/]+\/config(\/|$)/];
// The one read under a staff-only prefix every signed-in user may make: an
// account's PICTURE (`GET /users/:id/avatar` — the image or a redirect to its
// link, nothing else of the account). System Health shows every user's name and
// `avatarAt` to everyone, so a developer refused here saw only initials.
const EVERYONE_READS = [/^\/users\/\d+\/avatar$/];
const isServerMode = () => process.env.AGENTHUB_MODE === "server";
@@ -789,6 +794,7 @@ function authGuard(req, res, next) {
const isRead = req.method === "GET" || req.method === "HEAD";
if (
user.role === "developer" &&
!(isRead && EVERYONE_READS.some((re) => re.test(lp))) &&
(STAFF_ONLY_PREFIXES.some(under) || STAFF_ONLY_PATTERNS.some((re) => re.test(lp)))
) {
return deny(res, 403, "EADMIN", "only an admin or analyst may open this");
+43 -6
View File
@@ -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")
+293
View File
@@ -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,
+11
View File
@@ -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
+252
View File
@@ -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,
};
+2 -3
View File
@@ -21,7 +21,7 @@
* - POST /api/me/avatar/url (Auth) — the picture as a link, redirected to
* - GET/PATCH /api/users/{id} (Auth) — the Users page's Edit user form (its `quota` too)
* - PUT /api/users/quota-default, GET /api/me/quota (Auth) — spend quotas per user
* - GET /api/users/{id}/avatar (Auth) — one account's picture (admin/analyst)
* - GET /api/users/{id}/avatar (Auth) — one account's picture (every account)
* - GET /api/users/tmux, POST /api/users/{id}/tmux-restart, POST /api/users/tmux-restart-idle
* (Auth) — each member's own tmux server and an admin's restart of it (S3)
* Exports `{ tags, schemas, paths }`, combined into the spec by
@@ -731,12 +731,11 @@ const paths = {
get: {
tags: ["Auth"],
operationId: "getUserAvatar",
summary: "One account's picture, for the sidebar's owner groups (admin/analyst)",
summary: "One account's picture, for owner groups and System Health (every signed-in account)",
parameters: [{ name: "id", in: "path", required: true, schema: { type: "integer" } }],
responses: {
200: { description: "The image (PNG, JPEG, GIF or WebP)" },
302: { description: "Redirect to that account's avatar link" },
403: err("EADMIN — a developer"),
404: err("ENOAVATAR"),
},
},
+147 -4
View File
@@ -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"],
+50 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+2 -1
View File
@@ -23,7 +23,8 @@
* tool the accounts it may run on (pin, group, quota, its own 5 h / 7 d share
* of each — `accountUsage` with `ownerId`) and the ones it actually ran on;
* `GET /:id/avatar` its picture (server/lib/avatars.js), for the sidebar's
* owner groups — staff only like the rest of `/users`.
* owner groups and System Health — the one `/users` read a developer may make
* too (`EVERYONE_READS` in server/lib/auth.js).
*
* The Users page's account changes — `POST /` (create), `PATCH /:id` (the Edit
* user form: display name, email, role, disabled, a new password, workspaces