feat: Discard all, claude-style image pastes, large local drops, per-account / list

- Changes panel: **Discard all** beside Commit discards every non-conflicted
  file in one `discard`, after a dialog naming how many untracked files go.
- Terminal: a `claude` pane gets dropped/pasted images as bare paths (shown as
  `[Image #n]`) and other files as `@` mentions (`claudePastes`); a local
  browser's copied `file://` paths and text-drag file lists paste by path.
- `POST /api/run/drop-file`: a loopback browser outside server mode may drop
  up to 2 GB, streamed to disk; remote stays 20 MB.
- cc-config skills/commands/plugins read the USER scope of the account the
  pane runs as (S3 member home by `cwd`), never another user's or the host's
  for a non-admin without an account; the run form passes its cwd to plugins.
This commit is contained in:
2026-10-21 14:27:36 +07:00
parent 0547488f13
commit aee4fc190e
16 changed files with 380 additions and 65 deletions
@@ -1422,6 +1422,27 @@ export function LaneChangesPanel({
} }
}; };
// Discard all: every non-conflicted file in one `discard`, asked once in a
// dialog that names how many untracked files it will delete.
const discardAll = async () => {
const rows = files.filter((f) => !f.conflicted);
if (!rows.length) return;
const ok = await confirmDialog({
title: t("changes.discardAllTitle"),
message: t("changes.discardAllConfirm", {
count: rows.length,
untracked: rows.filter((f) => f.untracked).length,
}),
confirmLabel: t("changes.discardAll"),
});
if (ok)
await act(
"discard",
rows.map((f) => f.path),
true
);
};
// resolve / merge-commit / merge-abort: same shape as `act`, other body. // resolve / merge-commit / merge-abort: same shape as `act`, other body.
const mergeAct = async ( const mergeAct = async (
action: "resolve" | "merge-commit" | "merge-abort", action: "resolve" | "merge-commit" | "merge-abort",
@@ -2756,6 +2777,17 @@ export function LaneChangesPanel({
> >
{t(amend ? "changes.amendAndPush" : "changes.commitAndPush")} {t(amend ? "changes.amendAndPush" : "changes.commitAndPush")}
</button> </button>
<button
type="button"
data-testid="lane-changes-discard-all"
disabled={busy}
onClick={() => void discardAll()}
title={t("changes.discardAllTitle")}
className="shrink-0 flex items-center gap-1 rounded border border-border-light px-1.5 py-0.5 text-[11px] text-status-danger hover:bg-status-danger/10 disabled:cursor-not-allowed disabled:opacity-40"
>
<Trash2 className="h-3 w-3" aria-hidden />
{t("changes.discardAll")}
</button>
</div> </div>
</div> </div>
)} )}
@@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => {
}); });
}); });
it("discards every file at once only after the dialog is confirmed", async () => {
const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />);
await screen.findByTestId("lane-changes-overview");
vi.mocked(confirmDialog).mockResolvedValueOnce(false);
await user.click(screen.getByTestId("lane-changes-discard-all"));
expect(api.lanes.gitAction).not.toHaveBeenCalled();
await user.click(screen.getByTestId("lane-changes-discard-all"));
await waitFor(() =>
expect(api.lanes.gitAction).toHaveBeenCalledWith(7, "discard", {
paths: ["server/lib/git-ops.js", "notes.txt", "docs/API.md"],
confirm: true,
})
);
});
it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => { it("right-click on a changed file offers Commit File, a two-click discard, copy path and patch", async () => {
const user = userEvent.setup(); const user = userEvent.setup();
render(<LaneChangesPanel laneId={7} />); render(<LaneChangesPanel laneId={7} />);
+1 -1
View File
@@ -308,7 +308,7 @@ export function PromptEditor({
Promise.all([ Promise.all([
api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })), api.ccConfig.commands(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })), api.ccConfig.skills(undefined, fileCwd).catch(() => ({ items: [] as CcMdItem[] })),
api.ccConfig.plugins().catch(() => ({ plugins: [] })), api.ccConfig.plugins(fileCwd).catch(() => ({ plugins: [] })),
]).then(([c, sk, pl]) => { ]).then(([c, sk, pl]) => {
// A plugin's commands and skills are addressed as `/<plugin>:<name>`; one // A plugin's commands and skills are addressed as `/<plugin>:<name>`; one
// installed at both user and project scope is listed once. // installed at both user and project scope is listed once.
+43 -14
View File
@@ -17,9 +17,10 @@
* Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server * Files (images or any other) dropped or pasted on it — Ctrl+V included — are saved on the server
* (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a * (`POST /api/run/drop-file`) and their PATHS pasted into the pane — what a
* local terminal does on a drop — as `@` mentions in a `claude` pane, which * local terminal does on a drop — as `@` mentions in a `claude` pane, which
* reads them (an image as an image) when the prompt is sent; a browser on the * reads them when the prompt is sent — an image as its bare path, which claude
* dashboard's own machine pastes a file manager's files by their own paths * shows as `[Image #n]` like a native drop; a browser on the dashboard's own
* instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with * machine pastes a file manager's files (dropped, or copied: `file://` text)
* by their own paths instead, uploading nothing. A text drag (a Files/Changes row, a skill) is pasted as its text. The pane also registers with
* `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be * `lib/panePaste.ts`, so a diff-line comment from the Changes panel can be
* pasted into whichever of the workspace's panes was focused last. * pasted into whichever of the workspace's panes was focused last.
* Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission * Keyboard: the terminal keeps Tab and Shift+Tab (claude cycles its permission
@@ -66,7 +67,8 @@ import { api } from "../../lib/api";
import { writeClipboard } from "../../lib/clipboard"; import { writeClipboard } from "../../lib/clipboard";
import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste"; import { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste";
import { import {
claudeMention, claudePastes,
droppedFilePaths,
isLocalBrowser, isLocalBrowser,
localCandidates, localCandidates,
sameFilesHere, sameFilesHere,
@@ -153,6 +155,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi
const hasText = (e: DragEvent) => const hasText = (e: DragEvent) =>
!hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain"); !hasFiles(e) && Array.from(e.dataTransfer.types).includes("text/plain");
/** The `file://` paths a paste carries as text — a file manager's copy — or
* null: its uri-list, else its plain text when every line is one. */
function copiedFilePaths(dt: DataTransfer | null): string[] | null {
const text = dt?.getData("text/plain") ?? "";
if (droppedFilePaths(dt)) return droppedFilePaths(dt);
if (!/^file:\/\//.test(text.trim())) return null;
return droppedFilePaths({ getData: () => text } as unknown as DataTransfer);
}
interface TerminalViewProps { interface TerminalViewProps {
runId: string; runId: string;
wsBaseUrl: string; wsBaseUrl: string;
@@ -168,8 +179,9 @@ interface TerminalViewProps {
* so it is moved out of the viewport with a transform, which keeps its * so it is moved out of the viewport with a transform, which keeps its
* layout size (no PTY resize; the chat still reads and answers its menus). */ * layout size (no PTY resize; the chat still reads and answers its menus). */
covered?: boolean; covered?: boolean;
/** The pane's AI tool: a `claude` pane is handed a pasted or dropped file /** The pane's AI tool: a `claude` pane is handed a pasted or dropped image
* as an `@` mention (`claudeMention`), any other one its plain path. */ * as its bare path and any other file as an `@` mention (`claudePastes`),
* any other tool its plain path. */
tool?: string; tool?: string;
} }
@@ -241,8 +253,10 @@ export const TerminalView = memo(function TerminalView({
e.preventDefault(); e.preventDefault();
dragDepth.current = 0; dragDepth.current = 0;
setDrop(null); setDrop(null);
const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null;
const text = e.dataTransfer.getData("text/plain"); const text = e.dataTransfer.getData("text/plain");
if (text) termRef.current?.paste(text + " "); if (local) pastePaths(local);
else if (text) termRef.current?.paste(text + " ");
termRef.current?.focus(); termRef.current?.focus();
return; return;
} }
@@ -262,6 +276,14 @@ export const TerminalView = memo(function TerminalView({
const ctrlV = ctrlVRef.current; const ctrlV = ctrlVRef.current;
ctrlVRef.current = false; ctrlVRef.current = false;
const files = Array.from(e.clipboardData?.files ?? []); const files = Array.from(e.clipboardData?.files ?? []);
// A file manager's copied files reach a browser as `file://` text only.
const local = files.length === 0 && isLocalBrowser() ? copiedFilePaths(e.clipboardData) : null;
if (local) {
e.preventDefault();
e.stopPropagation();
pastePaths(local);
return;
}
if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return; if (files.length === 0 && (!ctrlV || e.clipboardData?.getData("text/plain"))) return;
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
@@ -269,6 +291,19 @@ export const TerminalView = memo(function TerminalView({
else termRef.current?.input("\x16", true); else termRef.current?.input("\x16", true);
}; };
/** Pastes files' paths as a local terminal hands them over — paste(), not
* a raw send: it honours bracketed-paste mode, so the CLI sees a paste
* rather than typed keystrokes. A `claude` pane gets its images as bare
* paths, which it shows as `[Image #n]`, and other files as `@` mentions
* (`claudePastes`). */
const pastePaths = (paths: string[]) => {
const term = termRef.current;
if (!term) return;
for (const text of tool === "claude" ? claudePastes(paths) : [paths.join(" ")])
term.paste(text + " ");
term.focus();
};
/** Pastes the files' paths: their own (`local`, the `file://` paths a /** Pastes the files' paths: their own (`local`, the `file://` paths a
* browser on the dashboard's machine is handed beside the bytes) once the * browser on the dashboard's machine is handed beside the bytes) once the
* server finds the same files there — nothing uploaded —, else the paths * server finds the same files there — nothing uploaded —, else the paths
@@ -286,13 +321,7 @@ export const TerminalView = memo(function TerminalView({
return []; return [];
}); });
} }
if (paths.length) { if (paths.length) pastePaths(paths);
// paste(), not a raw send: it honours bracketed-paste mode, so claude
// sees pasted paths rather than typed keystrokes.
const asText = tool === "claude" ? claudeMention : (p: string) => p;
termRef.current?.paste(paths.map(asText).join(" ") + " ");
termRef.current?.focus();
}
// Over the server's limit (ETOOBIG): refused before the body is sent. // Over the server's limit (ETOOBIG): refused before the body is sent.
const tooBig = errors const tooBig = errors
.map((e) => e as { code?: string; maxBytes?: number } | null) .map((e) => e as { code?: string; maxBytes?: number } | null)
+3
View File
@@ -531,6 +531,9 @@
"changes.unversionedGroup": "Unversioned Files", "changes.unversionedGroup": "Unversioned Files",
"changes.commitTicked": "Commit ({{count}})", "changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit and Push", "changes.commitAndPush": "Commit and Push",
"changes.discardAll": "Discard all",
"changes.discardAllTitle": "Discard all changes?",
"changes.discardAllConfirm": "Every change in {{count}} file(s) goes back to the last commit, and {{untracked}} new file(s) git does not track yet are deleted. This cannot be undone.",
"changes.amendAndPush": "Amend and Push", "changes.amendAndPush": "Amend and Push",
"changes.pushFailed": "Committed, but the push failed: {{error}}", "changes.pushFailed": "Committed, but the push failed: {{error}}",
"changes.tickFile": "Include {{path}} in the commit", "changes.tickFile": "Include {{path}} in the commit",
+3
View File
@@ -531,6 +531,9 @@
"changes.unversionedGroup": "File chưa theo dõi", "changes.unversionedGroup": "File chưa theo dõi",
"changes.commitTicked": "Commit ({{count}})", "changes.commitTicked": "Commit ({{count}})",
"changes.commitAndPush": "Commit và Push", "changes.commitAndPush": "Commit và Push",
"changes.discardAll": "Bỏ tất cả",
"changes.discardAllTitle": "Bỏ tất cả thay đổi?",
"changes.discardAllConfirm": "Mọi thay đổi trong {{count}} file sẽ trở về commit cuối, và {{untracked}} file mới chưa được git theo dõi sẽ bị xoá. Không thể hoàn tác.",
"changes.amendAndPush": "Amend và Push", "changes.amendAndPush": "Amend và Push",
"changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}", "changes.pushFailed": "Đã commit, nhưng push thất bại: {{error}}",
"changes.tickFile": "Đưa {{path}} vào commit", "changes.tickFile": "Đưa {{path}} vào commit",
@@ -0,0 +1,20 @@
/**
* @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, expect, it } from "vitest";
import { claudePastes } from "../promptAttach";
describe("claudePastes", () => {
it("pastes each image as its bare path and the other files as one @ mention paste", () => {
expect(
claudePastes(["/d/drops/a.png", "/d/drops/b-report.pdf", "/d/x.JPG", "/d/c.log"])
).toEqual(["/d/drops/a.png", "/d/x.JPG", "@/d/drops/b-report.pdf @/d/c.log"]);
});
it("keeps a Windows or spaced image path a mention", () => {
expect(claudePastes(["C:\\u\\drops\\a.png", "/home/u/my shot.png"])).toEqual([
'@C:/u/drops/a.png @"/home/u/my shot.png"',
]);
});
});
+2 -1
View File
@@ -2210,7 +2210,8 @@ export const api = {
* each one contributes (skills/agents/commands/hooks counts). * each one contributes (skills/agents/commands/hooks counts).
* @returns {@link CcPluginsResponse} — the manifest path/status plus plugins. * @returns {@link CcPluginsResponse} — the manifest path/status plus plugins.
*/ */
plugins: () => request<CcPluginsResponse>("/cc-config/plugins"), plugins: (cwd?: string) =>
request<CcPluginsResponse>(`/cc-config/plugins${ccQuery(undefined, cwd)}`),
/** /**
* GET /api/cc-config/mcp - configured MCP servers, user and project-scoped. * GET /api/cc-config/mcp - configured MCP servers, user and project-scoped.
* @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`. * @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`.
+16
View File
@@ -169,6 +169,22 @@ export function claudeMention(path: string): string {
return /\s/.test(p) ? `@"${p}"` : `@${p}`; return /\s/.test(p) ? `@"${p}"` : `@${p}`;
} }
const IMAGE_PATH = /\.(png|jpe?g|gif|webp)$/i;
/** What a terminal pastes for files dropped or pasted on a `claude` pane, one
* paste per entry, as a local terminal hands them over: the images as their
* bare paths, each a paste of its own — the shape Claude Code turns into
* `[Image #n]`, as it does a native drop — and every other file as an `@`
* mention, together in one. A Windows path, or
* one with a space, stays a mention — the bare path stayed plain text there
* (see `claudeMention`). */
export function claudePastes(paths: string[]): string[] {
const bare = (p: string) => IMAGE_PATH.test(p) && p.startsWith("/") && !/\s/.test(p);
const images = paths.filter(bare);
const others = paths.filter((p) => !bare(p)).map(claudeMention);
return [...images, others.join(" ")].filter(Boolean);
}
export const SKILL_MIME = "application/x-agenthub-skill"; export const SKILL_MIME = "application/x-agenthub-skill";
// A skill's name as Claude Code types it: `name` or `plugin:name`. // A skill's name as Claude Code types it: `name` or `plugin:name`.
const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/; const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/;
+3 -3
View File
@@ -2787,7 +2787,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?,
POST /api/cc-config/mcp/remove Body: { name, scope? } POST /api/cc-config/mcp/remove Body: { name, scope? }
``` ```
The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`. The list reads take an optional `cwd` naming the project whose `.claude/` is read (default: the server's own cwd); the run console's `/` autocomplete passes the directory the run will start in, so a workspace's own project commands and skills are the ones offered. `skills`, `commands` and `plugins` read their USER scope from the account the pane will run as: under S3 the `cwd` folder's account (a member's `~/.claude`, where a Hub install puts a skill), a non-admin with no `cwd` their own, an admin otherwise the host's Claude home, and a non-admin with no account of their own NOTHING (never the host's or another member's) — so a skill a developer installed shows up in their `/` list, and an admin's install into the host's home no longer shows up in a member's. In server mode a non-admin's `cwd` (on `/api/cc-config/*` and `/api/codex-config/*`) must lie inside one of their own workspaces, else `403 EFOLDER`. `GET /api/cc-config/file` and `GET /api/codex-config/file` never serve a credential file (`.credentials.json`, `auth.json`, any non-Markdown name matching the AI Tools secret pattern, anything under `<CLAUDE_HOME>/agenthub-*`): `400 READ_DENIED`.
`scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`. `scope` is `"user"`, `"project"`, or `"auto-memory"`. `type` is one of `skills`, `agents`, `commands`, `output-styles`, `memory`, `auto-memory`. `name` is required for everything except `memory` (which is `CLAUDE.md` itself). On `PUT`, `name` is validated against `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$` (for `auto-memory` it must instead be a flat `*.md` filename). Settings are returned with secret-like keys (matching `/token|secret|password|api[_-]?key|auth/i`) replaced by `"<redacted>"`.
@@ -2884,8 +2884,8 @@ GET /api/run/history?limit=&laneId=&tool= Persisted run history; laneId narr
GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50 GET /api/run/interrupted {items} — sessions whose pane died under them (see below), newest per session, max 50
POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise POST /api/run/interrupted/dismiss {sessionIds: string[]} (≤ 100) → {dismissed}: stop offering them; resumes nothing. 400 EBADFIELD otherwise
GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu) GET /api/run/files?cwd=&q=&dirs=1 Fuzzy file search inside cwd for the @-file autocomplete (dirs=1 adds folders as `dir/`, for the + menu)
POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under <data dir>/drops/ (0700; S3: the member's ~/.agenthub/handoff/drops/) with a random name, pruned after a day; the terminal pastes the path (a `claude` pane: an image as its bare path, each in a paste of its own, which Claude Code shows as `[Image #n]`; any other file as an `@` mention)
POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over 20 MB before reading the body, and close the connection; of several files only the refused ones are lost POST /api/run/drop-file?name=&laneId= Any raw file body (≤20 MB; ≤2 GB from a loopback browser outside server mode, streamed to disk — Chrome hands a dropped file no `file://` path) → {path}: same dir (S3: the handoff dir of the account workspace `laneId`'s panes run as, else the requester's), 0600, named <random>-<name reduced to a [A-Za-z0-9._-] basename>; what the terminal (drop or paste, Ctrl+V included), the prompt box and its paste use for every dropped file or image — except a browser on a loopback host dropping or pasting a file manager's files, whose `file://` paths are attached as they are, uploading nothing, once `POST /api/run/local-paths` finds the same files here. Both drop routes answer 413 `ETOOBIG` (with `max_bytes`, the limit the UI shows) to a `Content-Length` over their limit before reading the body, and close the connection; of several files only the refused ones are lost
POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise POST /api/run/local-paths {paths:[<absolute>…]} (1–100) → {sizes:[bytes|null…]}: each path's size when it is a regular file on this machine, else null. The composer and the terminal use a dropped file's own `file://` path only when every size matches the dropped bytes' — "localhost" in the address bar is no proof (an SSH tunnel's browser is on another machine). 403 EREMOTE in server mode (a pane runs as an account that cannot read the host's files) and to anything but a loopback socket without X-Forwarded-For/Forwarded; 400 EBADFIELD otherwise
GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise GET /api/run/drop/:name A dropped png/jpg/gif/webp, or txt/md/csv/tsv/log/json/xml/yaml as text/plain, served back (nosniff, sandbox CSP) from the requester's own drop dir (S3: their member account's, owner-checked — for an admin, then the host's, where their host-folder workspaces drop); only a name drop-image/drop-file made → 404 otherwise
GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it) GET /api/run/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it)
+3 -1
View File
@@ -681,7 +681,9 @@ reads the workspace's git surface in five modes:
the file's header. the file's header.
- **Overview** — every changed file with its `+N −M`. Polls every 15s, because - **Overview** — every changed file with its `+N −M`. Polls every 15s, because
it is the cheap shape and the one you leave open while an agent works. Each it is the cheap shape and the one you leave open while an agent works. Each
row carries its own **stage** / **unstage** / **discard** buttons. row carries its own **stage** / **unstage** / **discard** buttons, and
**Discard all** beside Commit sends every non-conflicted file in ONE
`discard` after a dialog that says how many untracked files it deletes.
- **Review** — every file's full diff, stacked, for reading a whole change-set. - **Review** — every file's full diff, stacked, for reading a whole change-set.
Clicking a file's header folds it shut; only clicking the **path itself** Clicking a file's header folds it shut; only clicking the **path itself**
opens that file. (A header that navigated on any click made folding a opens that file. (A header that navigated on any click made folding a
+13 -1
View File
@@ -354,7 +354,19 @@ describe("POST /api/run/drop-image", () => {
}); });
assert.equal(status, 413); assert.equal(status, 413);
assert.equal(body.error.code, "ETOOBIG"); assert.equal(body.error.code, "ETOOBIG");
assert.equal(body.error.max_bytes, 20 * 1024 * 1024); // the UI shows the server's limit // The UI shows the server's limit: a loopback browser's, here.
assert.equal(body.error.max_bytes, 2 * 1024 ** 3);
});
it("streams a loopback drop over the remote limit to disk", async () => {
const buf = Buffer.alloc(21 * 1024 * 1024, 7);
const r = await fetchJson("/api/run/drop-file?name=big.bin", {
method: "POST",
raw: { type: "application/octet-stream", buf },
});
assert.equal(r.status, 200);
assert.match(r.body.path, /[\\/]drops[\\/][0-9a-f]{16}-big\.bin$/);
assert.equal(fs.statSync(r.body.path).size, buf.length);
}); });
}); });
@@ -437,6 +437,82 @@ describe("the top of a member's home", () => {
}); });
}); });
describe("the run form's `/` list", () => {
it("reads the user skills, commands and plugins of the account the pane runs as", async () => {
const skill = (claude, name) => {
fs.mkdirSync(path.join(claude, "skills", name), { recursive: true });
fs.writeFileSync(
path.join(claude, "skills", name, "SKILL.md"),
`---\nname: ${name}\n---\nx\n`
);
};
const hostClaude = process.env.CLAUDE_HOME;
skill(hostClaude, "host-only");
skill(path.join(MIA, ".claude"), "mia-skill");
skill(path.join(BOB, ".claude"), "bob-skill");
fs.mkdirSync(path.join(MIA, ".claude", "commands"), { recursive: true });
fs.writeFileSync(path.join(MIA, ".claude", "commands", "mia-cmd.md"), "do it\n");
fs.mkdirSync(path.join(MIA, ".claude", "plugins"), { recursive: true });
fs.writeFileSync(
path.join(MIA, ".claude", "plugins", "installed_plugins.json"),
JSON.stringify({ plugins: { "mp@market": [{ scope: "user" }] } })
);
const names = (r) => r.body.items.map((i) => i.name);
const mia = await call("mia", "GET", "/api/cc-config/skills?scope=user");
assert.deepEqual(names(mia), ["mia-skill"], JSON.stringify(mia.body));
const cmds = await call("mia", "GET", "/api/cc-config/commands?scope=user");
assert.deepEqual(names(cmds), ["mia-cmd"]);
// Plugins stay staff-only; an admin's run form in a member folder lists that member's.
const plugins = await call(
"boss",
"GET",
`/api/cc-config/plugins?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
);
assert.deepEqual(
plugins.body.plugins.map((p) => p.key),
["mp@market"],
JSON.stringify(plugins.body)
);
// A folder picks its own account's home.
const bob = await call(
"bob",
"GET",
`/api/cc-config/skills?scope=user&cwd=${encodeURIComponent(BOB)}`
);
assert.deepEqual(names(bob), ["bob-skill"], JSON.stringify(bob.body));
// An admin's Config Explorer view, no folder named, stays the host's.
const boss = await call("boss", "GET", "/api/cc-config/skills?scope=user");
assert.deepEqual(names(boss), ["host-only"]);
});
it("never lists another member's or the host's skills as someone's own", async () => {
const names = (r) => r.body.items.map((i) => i.name);
const bob = await call("bob", "GET", "/api/cc-config/skills?scope=user");
assert.deepEqual(names(bob), ["bob-skill"]);
const intoMia = await call(
"bob",
"GET",
`/api/cc-config/skills?cwd=${encodeURIComponent(path.join(MIA, "proj"))}`
);
assert.equal(intoMia.status, 403);
assert.equal(intoMia.body.error.code, "EFOLDER");
// A developer with no Linux account of their own gets nothing, not the host's.
await auth.createUser("eve", "eve-pass-123", "developer");
const login = await fetch(`${BASE}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username: "eve", password: "eve-pass-123" }),
});
cookies.eve = login.headers.get("set-cookie").split(";")[0];
for (const kind of ["skills", "commands"]) {
const r = await call("eve", "GET", `/api/cc-config/${kind}`);
assert.equal(r.status, 200, JSON.stringify(r.body));
assert.deepEqual(r.body.items, [], kind);
}
});
});
describe("a disabled member's session", () => { describe("a disabled member's session", () => {
it("is still read from their home, and does not stop the liveness reaper", () => { it("is still read from their home, and does not stop the liveness reaper", () => {
db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run(); db.prepare("UPDATE users SET disabled = 1 WHERE username = 'dan'").run();
+17 -8
View File
@@ -215,10 +215,15 @@ function readSkillsAt(scope, claudeDir) {
return skills.sort((a, b) => a.name.localeCompare(b.name)); return skills.sort((a, b) => a.name.localeCompare(b.name));
} }
/**
* @param {{scope?: string, cwd?: string, userClaudeDir?: string}} opts
* `userClaudeDir` = whose user scope to read (a member's `~/.claude` under
* S3); the host's Claude home when absent.
*/
function readSkills(opts = {}) { function readSkills(opts = {}) {
const out = []; const out = [];
if (opts.scope !== "project") { if (opts.scope !== "project") {
out.push(...readSkillsAt("user", getClaudeHome())); out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome()));
} }
if (opts.scope !== "user") { if (opts.scope !== "user") {
out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd))); out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd)));
@@ -257,7 +262,7 @@ function readSimpleMdSurface(subdir) {
return (opts = {}) => { return (opts = {}) => {
const out = []; const out = [];
if (opts.scope !== "project") { if (opts.scope !== "project") {
out.push(...readMdFilesAt("user", getClaudeHome(), subdir)); out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir));
} }
if (opts.scope !== "user") { if (opts.scope !== "user") {
out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir)); out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir));
@@ -423,18 +428,18 @@ function readPluginContributions(installPath) {
}; };
} }
function readEnabledPluginsMap() { function readEnabledPluginsMap(home = getClaudeHome()) {
const userSettings = readJson(path.join(getClaudeHome(), "settings.json")); const userSettings = readJson(path.join(home, "settings.json"));
if (!userSettings.ok || !userSettings.data) return {}; if (!userSettings.ok || !userSettings.data) return {};
const ep = userSettings.data.enabledPlugins; const ep = userSettings.data.enabledPlugins;
return ep && typeof ep === "object" ? ep : {}; return ep && typeof ep === "object" ? ep : {};
} }
function readPlugins() { /** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */
const home = getClaudeHome(); function readPlugins(home = getClaudeHome()) {
const manifestPath = path.join(home, "plugins", "installed_plugins.json"); const manifestPath = path.join(home, "plugins", "installed_plugins.json");
const manifest = readJson(manifestPath); const manifest = readJson(manifestPath);
const enabledMap = readEnabledPluginsMap(); const enabledMap = readEnabledPluginsMap(home);
const plugins = []; const plugins = [];
if (manifest.ok && manifest.data && manifest.data.plugins) { if (manifest.ok && manifest.data && manifest.data.plugins) {
for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) { for (const [pluginKey, instances] of Object.entries(manifest.data.plugins)) {
@@ -847,7 +852,11 @@ function isSecretConfigPath(resolved) {
const base = path.basename(resolved); const base = path.basename(resolved);
if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true; if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true;
const rel = path.relative(getClaudeHome(), resolved); const rel = path.relative(getClaudeHome(), resolved);
if (!rel.startsWith("..") && !path.isAbsolute(rel) && rel.split(path.sep)[0].startsWith("agenthub-")) if (
!rel.startsWith("..") &&
!path.isAbsolute(rel) &&
rel.split(path.sep)[0].startsWith("agenthub-")
)
return true; return true;
// AgentHub's data dir is no Claude config: it holds the database, the push // AgentHub's data dir is no Claude config: it holds the database, the push
// keys and every gateway pane's settings file WITH its live pane token. // keys and every gateway pane's settings file WITH its live pane token.
+36 -5
View File
@@ -95,16 +95,45 @@ function cwdOf(req) {
} }
/** /**
* `{scope, cwd}` for the two lists a member may read (the run form's `/` * The Claude home whose USER skills, commands and plugins a request's sessions
* load — so the run form's `/` list matches what its pane's Claude can call.
* Under S3 a pane runs as its FOLDER's account: `?cwd=` picks that account; a
* non-admin with no `?cwd=` reads their own. Undefined = the host's (local
* mode, or an admin in a host folder / the Config Explorer); null = none, for
* a non-admin with no account of their own — another user's or the host's
* skills are never listed as theirs.
*/
function userClaudeDir(req) {
if (!req.user) return undefined;
const runAs = require("../lib/run-as");
const cwd = typeof req.query.cwd === "string" && req.query.cwd;
let acct = null;
try {
if (cwd && path.isAbsolute(cwd)) acct = runAs.ownerForRead(path.resolve(cwd));
else if (req.user.role !== "admin") acct = runAs.accountForUser(req.user.username);
} catch {
acct = null;
}
if (acct) return path.join(acct.home, ".claude");
return req.user.role === "admin" ? undefined : null;
}
/**
* `{scope, cwd, userClaudeDir}` for the two lists a member may read (the run form's `/`
* autocomplete). Server mode keeps a member's project items to their own * autocomplete). Server mode keeps a member's project items to their own
* workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is * workspaces: another folder answers 403 EFOLDER, and with no `?cwd=` there is
* no project at all (never the dashboard's own working directory). Returns null * no project at all (never the dashboard's own working directory). Returns null
* once it has answered; `none` = nothing to list. * once it has answered; `none` = nothing to list.
*/ */
function listOpts(req, res) { function listOpts(req, res) {
const opts = { scope: scopeOf(req), cwd: cwdOf(req) }; const opts = { scope: scopeOf(req), cwd: cwdOf(req), userClaudeDir: userClaudeDir(req) };
if (scope.memberId(req) == null) return opts;
const asked = typeof req.query.cwd === "string" && req.query.cwd; const asked = typeof req.query.cwd === "string" && req.query.cwd;
// No account of their own: only a named workspace's project items, if any.
if (opts.userClaudeDir === null) {
if (opts.scope === "user" || !asked) return { none: true };
opts.scope = "project";
}
if (scope.memberId(req) == null) return opts;
if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" }; if (!asked) return opts.scope === "project" ? { none: true } : { ...opts, scope: "user" };
if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) { if (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) {
res.status(403).json({ res.status(403).json({
@@ -137,8 +166,10 @@ router.get("/output-styles", (req, res) => {
res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) }); res.json({ items: cc.readOutputStyles({ scope: scopeOf(req), cwd: cwdOf(req) }) });
}); });
router.get("/plugins", (_req, res) => { router.get("/plugins", (req, res) => {
res.json(cc.readPlugins()); const home = userClaudeDir(req);
if (home === null) return res.json({ manifestPath: null, manifestExists: false, plugins: [] });
res.json(cc.readPlugins(home));
}); });
router.get("/mcp", (req, res) => { router.get("/mcp", (req, res) => {
+94 -31
View File
@@ -114,21 +114,31 @@ const DROP_TYPES = {
"image/webp": "webp", "image/webp": "webp",
}; };
const DROP_MAX_BYTES = 20 * 1024 * 1024; const DROP_MAX_BYTES = 20 * 1024 * 1024;
// A browser on this machine (loopback, never server mode) crosses no network:
// its drop is streamed to disk, so a video or a dump is no reason to refuse.
// Chrome hands a dropped file no `file://` path, so it cannot attach by path.
const DROP_LOCAL_MAX_BYTES = 2 * 1024 ** 3;
const DROP_TTL_MS = 24 * 60 * 60_000; const DROP_TTL_MS = 24 * 60 * 60_000;
/** A body declared over the limit is refused before a byte of it is read, and const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req);
* the connection is closed: express.raw would read (and discard) all of it const dropLimit = (req) => (localDrop(req) ? DROP_LOCAL_MAX_BYTES : DROP_MAX_BYTES);
* first, so a 4 GB drop crossed the network in full just to be told 413. */ function tooBig(res, max) {
function refuseOversize(req, res, next) {
if (!(Number(req.headers["content-length"]) > DROP_MAX_BYTES)) return next();
res.set("Connection", "close"); res.set("Connection", "close");
res.status(413).json({ res.status(413).json({
error: { error: {
code: "ETOOBIG", code: "ETOOBIG",
message: `a dropped file may be at most ${DROP_MAX_BYTES / 1024 / 1024} MB`, message: `a dropped file may be at most ${max / 1024 / 1024} MB`,
max_bytes: DROP_MAX_BYTES, max_bytes: max,
}, },
}); });
} }
/** A body declared over the limit is refused before a byte of it is read, and
* the connection is closed: express.raw would read (and discard) all of it
* first, so a 4 GB drop crossed the network in full just to be told 413. */
function refuseOversize(req, res, next) {
const max = dropLimit(req);
if (!(Number(req.headers["content-length"]) > max)) return next();
tooBig(res, max);
}
/** Where a drop goes: the handoff dir of the account the PANE runs as — the /** Where a drop goes: the handoff dir of the account the PANE runs as — the
* workspace `?laneId=` names (scopeGuard already refused one this user may * workspace `?laneId=` names (scopeGuard already refused one this user may
* not see), else the requester's own — written AS that member under S3, so * not see), else the requester's own — written AS that member under S3, so
@@ -147,28 +157,33 @@ function dropDir(req) {
: { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null }; : { dir: path.join(require("../lib/claude-home").getDataDir(), "drops"), member: null };
} }
// Saves one dropped body under the drop dir and answers its path. Pruned on // The drop dir, made and pruned: files older than a day go.
// write: files older than a day go. function prepareDropDir(req) {
const { dir, member } = dropDir(req);
const runAs = require("../lib/run-as");
if (member) runAs.mkdirAs(member, dir);
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const entry of fs.readdirSync(dir)) {
const f = path.join(dir, entry);
try {
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
if (member) runAs.removeAs(member, f);
else fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
return { dir, member };
}
// Saves one dropped body under the drop dir and answers its path.
function saveDrop(req, res, name, body) { function saveDrop(req, res, name, body) {
try { try {
const { dir, member } = dropDir(req); const { dir, member } = prepareDropDir(req);
const runAs = require("../lib/run-as");
if (member) runAs.mkdirAs(member, dir);
else fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
// ponytail: prune-on-write, a day old; a sweep job only if drops pile up.
const cutoff = Date.now() - DROP_TTL_MS;
for (const entry of fs.readdirSync(dir)) {
const f = path.join(dir, entry);
try {
if (fs.lstatSync(f).mtimeMs >= cutoff) continue;
if (member) runAs.removeAs(member, f);
else fs.unlinkSync(f);
} catch {
/* raced with another prune — fine */
}
}
const file = path.join(dir, name); const file = path.join(dir, name);
if (member) runAs.handoffWrite(member, path.join("drops", name), body); if (member) require("../lib/run-as").handoffWrite(member, path.join("drops", name), body);
else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" }); else fs.writeFileSync(file, body, { mode: 0o600, flag: "wx" });
res.json({ path: file }); res.json({ path: file });
} catch (err) { } catch (err) {
@@ -198,16 +213,64 @@ router.post(
// so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a // so its own name is kept — reduced to a basename of [A-Za-z0-9._-] behind a
// random prefix, so it can neither leave the drop dir nor collide, and the // random prefix, so it can neither leave the drop dir nor collide, and the
// space-joined paste of several paths stays parseable. // space-joined paste of several paths stays parseable.
const dropName = (req) =>
path
.basename(String(req.query.name || ""))
.replace(/[^A-Za-z0-9._-]/g, "_")
.replace(/^\.+/, "")
.slice(-100);
/** A local browser's drop is piped to its file as it arrives, never held in
* memory whole; a body that runs past the limit (or is empty) leaves no file. */
function streamLocalDrop(req, res, next) {
if (!localDrop(req)) return next();
const safe = dropName(req);
if (!safe) return res.status(400).json({ error: "?name= and a non-empty file body required" });
let file;
let out;
try {
const { dir, member } = prepareDropDir(req);
if (member) return next(); // a member's file is written as them, by saveDrop
file = path.join(dir, `${crypto.randomBytes(8).toString("hex")}-${safe}`);
out = fs.createWriteStream(file, { mode: 0o600, flags: "wx" });
} catch (err) {
return res.status(500).json({ error: err.message });
}
let bytes = 0;
let failed = null;
const fail = (status, send) => {
if (failed) return;
failed = status;
req.unpipe(out);
out.destroy();
fs.rm(file, { force: true }, () => {});
if (!res.headersSent) send();
};
req.on("data", (c) => {
bytes += c.length;
if (bytes > DROP_LOCAL_MAX_BYTES) fail(413, () => tooBig(res, DROP_LOCAL_MAX_BYTES));
});
req.on("aborted", () => fail(400, () => {}));
out.on("error", (err) => fail(500, () => res.status(500).json({ error: err.message })));
out.on("finish", () => {
if (failed) return;
if (bytes === 0) {
return fail(400, () =>
res.status(400).json({ error: "?name= and a non-empty file body required" })
);
}
res.json({ path: file });
});
req.pipe(out);
}
router.post( router.post(
"/drop-file", "/drop-file",
refuseOversize, refuseOversize,
streamLocalDrop,
express.raw({ type: () => true, limit: DROP_MAX_BYTES }), express.raw({ type: () => true, limit: DROP_MAX_BYTES }),
(req, res) => { (req, res) => {
const safe = path const safe = dropName(req);
.basename(String(req.query.name || ""))
.replace(/[^A-Za-z0-9._-]/g, "_")
.replace(/^\.+/, "")
.slice(-100);
if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) { if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) {
return res.status(400).json({ error: "?name= and a non-empty file body required" }); return res.status(400).json({ error: "?name= and a non-empty file body required" });
} }