diff --git a/client/src/components/lanes/LaneChangesPanel.tsx b/client/src/components/lanes/LaneChangesPanel.tsx index 834a7368..da915441 100644 --- a/client/src/components/lanes/LaneChangesPanel.tsx +++ b/client/src/components/lanes/LaneChangesPanel.tsx @@ -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. const mergeAct = async ( action: "resolve" | "merge-commit" | "merge-abort", @@ -2756,6 +2777,17 @@ export function LaneChangesPanel({ > {t(amend ? "changes.amendAndPush" : "changes.commitAndPush")} + )} diff --git a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx index eba4beb7..856210e9 100644 --- a/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx +++ b/client/src/components/lanes/__tests__/LaneChangesPanel.test.tsx @@ -770,6 +770,24 @@ describe("LaneChangesPanel", () => { }); }); + it("discards every file at once only after the dialog is confirmed", async () => { + const user = userEvent.setup(); + render(); + 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 () => { const user = userEvent.setup(); render(); diff --git a/client/src/components/run/RunSetup.tsx b/client/src/components/run/RunSetup.tsx index db119929..a4a37f9d 100644 --- a/client/src/components/run/RunSetup.tsx +++ b/client/src/components/run/RunSetup.tsx @@ -308,7 +308,7 @@ export function PromptEditor({ Promise.all([ api.ccConfig.commands(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]) => { // A plugin's commands and skills are addressed as `/:`; one // installed at both user and project scope is listed once. diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index 24afd8e4..245e4b99 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -17,9 +17,10 @@ * 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 * 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 - * dashboard's own machine pastes a file manager's files 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 + * reads them when the prompt is sent — an image as its bare path, which claude + * shows as `[Image #n]` like a native drop; a browser on the dashboard's own + * 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 * pasted into whichever of the workspace's panes was focused last. * 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 { MENU_CURSOR_ROW, notePaneFocus, registerPane } from "../../lib/panePaste"; import { - claudeMention, + claudePastes, + droppedFilePaths, isLocalBrowser, localCandidates, sameFilesHere, @@ -153,6 +155,15 @@ const hasFiles = (e: DragEvent) => Array.from(e.dataTransfer.types).includes("Fi const hasText = (e: DragEvent) => !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 { runId: string; wsBaseUrl: string; @@ -168,8 +179,9 @@ interface TerminalViewProps { * 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). */ covered?: boolean; - /** The pane's AI tool: a `claude` pane is handed a pasted or dropped file - * as an `@` mention (`claudeMention`), any other one its plain path. */ + /** The pane's AI tool: a `claude` pane is handed a pasted or dropped image + * as its bare path and any other file as an `@` mention (`claudePastes`), + * any other tool its plain path. */ tool?: string; } @@ -241,8 +253,10 @@ export const TerminalView = memo(function TerminalView({ e.preventDefault(); dragDepth.current = 0; setDrop(null); + const local = isLocalBrowser() ? droppedFilePaths(e.dataTransfer) : null; 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(); return; } @@ -262,6 +276,14 @@ export const TerminalView = memo(function TerminalView({ const ctrlV = ctrlVRef.current; ctrlVRef.current = false; 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; e.preventDefault(); e.stopPropagation(); @@ -269,6 +291,19 @@ export const TerminalView = memo(function TerminalView({ 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 * browser on the dashboard's machine is handed beside the bytes) once the * server finds the same files there — nothing uploaded —, else the paths @@ -286,13 +321,7 @@ export const TerminalView = memo(function TerminalView({ return []; }); } - if (paths.length) { - // 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(); - } + if (paths.length) pastePaths(paths); // Over the server's limit (ETOOBIG): refused before the body is sent. const tooBig = errors .map((e) => e as { code?: string; maxBytes?: number } | null) diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index a5b36457..10383b65 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -531,6 +531,9 @@ "changes.unversionedGroup": "Unversioned Files", "changes.commitTicked": "Commit ({{count}})", "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.pushFailed": "Committed, but the push failed: {{error}}", "changes.tickFile": "Include {{path}} in the commit", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index c3f22283..92fb7a77 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -531,6 +531,9 @@ "changes.unversionedGroup": "File chưa theo dõi", "changes.commitTicked": "Commit ({{count}})", "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.pushFailed": "Đã commit, nhưng push thất bại: {{error}}", "changes.tickFile": "Đưa {{path}} vào commit", diff --git a/client/src/lib/__tests__/promptAttach.test.ts b/client/src/lib/__tests__/promptAttach.test.ts new file mode 100644 index 00000000..07d2beaa --- /dev/null +++ b/client/src/lib/__tests__/promptAttach.test.ts @@ -0,0 +1,20 @@ +/** + * @file promptAttach.test.ts — `claudePastes`: how files reach a `claude` pane. + * @author Nguyễn Ngọc Trí Vĩ + */ +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"', + ]); + }); +}); diff --git a/client/src/lib/api.ts b/client/src/lib/api.ts index f39d0f3d..fd2a7ea9 100644 --- a/client/src/lib/api.ts +++ b/client/src/lib/api.ts @@ -2210,7 +2210,8 @@ export const api = { * each one contributes (skills/agents/commands/hooks counts). * @returns {@link CcPluginsResponse} — the manifest path/status plus plugins. */ - plugins: () => request("/cc-config/plugins"), + plugins: (cwd?: string) => + request(`/cc-config/plugins${ccQuery(undefined, cwd)}`), /** * GET /api/cc-config/mcp - configured MCP servers, user and project-scoped. * @returns {@link CcMcpResponse} — servers split into `user`/`projectScoped`. diff --git a/client/src/lib/promptAttach.ts b/client/src/lib/promptAttach.ts index 425dee85..5e10e36d 100644 --- a/client/src/lib/promptAttach.ts +++ b/client/src/lib/promptAttach.ts @@ -169,6 +169,22 @@ export function claudeMention(path: string): string { 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"; // A skill's name as Claude Code types it: `name` or `plugin:name`. const SKILL_RE = /^[\w.-]+(?::[\w.-]+)?$/; diff --git a/docs/API.md b/docs/API.md index 37004789..cf25c28d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2787,7 +2787,7 @@ POST /api/cc-config/mcp/add Body: { name, scope, transport?, url?, 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 `/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 `/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 `""`. @@ -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 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) -POST /api/run/drop-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under /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-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 -; 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-image Raw image body (png/jpeg/gif/webp, ≤20 MB) → {path}: saved 0600 under /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; ≤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 -; 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:[…]} (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/:id/permission-mode A live claude pane's mode, read off its mode line → {mode} (null while a menu hides it) diff --git a/docs/LANES.md b/docs/LANES.md index 1268dca3..b8c705d0 100644 --- a/docs/LANES.md +++ b/docs/LANES.md @@ -681,7 +681,9 @@ reads the workspace's git surface in five modes: the file's header. - **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 - 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. 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 diff --git a/server/__tests__/run.test.js b/server/__tests__/run.test.js index 61892ed6..780fa836 100644 --- a/server/__tests__/run.test.js +++ b/server/__tests__/run.test.js @@ -354,7 +354,19 @@ describe("POST /api/run/drop-image", () => { }); assert.equal(status, 413); 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); }); }); diff --git a/server/__tests__/server-s3-review2.test.js b/server/__tests__/server-s3-review2.test.js index 48fd8010..3e27954c 100644 --- a/server/__tests__/server-s3-review2.test.js +++ b/server/__tests__/server-s3-review2.test.js @@ -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", () => { 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(); diff --git a/server/lib/cc-discovery.js b/server/lib/cc-discovery.js index d537d937..3dd65aa5 100644 --- a/server/lib/cc-discovery.js +++ b/server/lib/cc-discovery.js @@ -215,10 +215,15 @@ function readSkillsAt(scope, claudeDir) { 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 = {}) { const out = []; if (opts.scope !== "project") { - out.push(...readSkillsAt("user", getClaudeHome())); + out.push(...readSkillsAt("user", opts.userClaudeDir || getClaudeHome())); } if (opts.scope !== "user") { out.push(...readSkillsAt("project", getProjectClaudeDir(opts.cwd))); @@ -257,7 +262,7 @@ function readSimpleMdSurface(subdir) { return (opts = {}) => { const out = []; if (opts.scope !== "project") { - out.push(...readMdFilesAt("user", getClaudeHome(), subdir)); + out.push(...readMdFilesAt("user", opts.userClaudeDir || getClaudeHome(), subdir)); } if (opts.scope !== "user") { out.push(...readMdFilesAt("project", getProjectClaudeDir(opts.cwd), subdir)); @@ -423,18 +428,18 @@ function readPluginContributions(installPath) { }; } -function readEnabledPluginsMap() { - const userSettings = readJson(path.join(getClaudeHome(), "settings.json")); +function readEnabledPluginsMap(home = getClaudeHome()) { + const userSettings = readJson(path.join(home, "settings.json")); if (!userSettings.ok || !userSettings.data) return {}; const ep = userSettings.data.enabledPlugins; return ep && typeof ep === "object" ? ep : {}; } -function readPlugins() { - const home = getClaudeHome(); +/** @param {string} [home] whose Claude home (a member's under S3); the host's by default. */ +function readPlugins(home = getClaudeHome()) { const manifestPath = path.join(home, "plugins", "installed_plugins.json"); const manifest = readJson(manifestPath); - const enabledMap = readEnabledPluginsMap(); + const enabledMap = readEnabledPluginsMap(home); const plugins = []; if (manifest.ok && manifest.data && 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); if (!base.endsWith(".md") && require("./ai-tools").CONFIG_SECRET_RE.test(base)) return true; 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; // 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. diff --git a/server/routes/cc-config.js b/server/routes/cc-config.js index c2067a23..442560e9 100644 --- a/server/routes/cc-config.js +++ b/server/routes/cc-config.js @@ -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 * 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 * once it has answered; `none` = nothing to list. */ function listOpts(req, res) { - const opts = { scope: scopeOf(req), cwd: cwdOf(req) }; - if (scope.memberId(req) == null) return opts; + const opts = { scope: scopeOf(req), cwd: cwdOf(req), userClaudeDir: userClaudeDir(req) }; 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 (!path.isAbsolute(asked) || !scope.insideOwnWorkspace(req, path.resolve(asked))) { 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) }) }); }); -router.get("/plugins", (_req, res) => { - res.json(cc.readPlugins()); +router.get("/plugins", (req, res) => { + 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) => { diff --git a/server/routes/run.js b/server/routes/run.js index 0b5216fd..4025f21f 100644 --- a/server/routes/run.js +++ b/server/routes/run.js @@ -114,21 +114,31 @@ const DROP_TYPES = { "image/webp": "webp", }; 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; -/** 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) { - if (!(Number(req.headers["content-length"]) > DROP_MAX_BYTES)) return next(); +const localDrop = (req) => !require("../lib/auth").isServerMode() && isLocalRequest(req); +const dropLimit = (req) => (localDrop(req) ? DROP_LOCAL_MAX_BYTES : DROP_MAX_BYTES); +function tooBig(res, max) { res.set("Connection", "close"); res.status(413).json({ error: { code: "ETOOBIG", - message: `a dropped file may be at most ${DROP_MAX_BYTES / 1024 / 1024} MB`, - max_bytes: DROP_MAX_BYTES, + message: `a dropped file may be at most ${max / 1024 / 1024} MB`, + 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 * workspace `?laneId=` names (scopeGuard already refused one this user may * 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 }; } -// Saves one dropped body under the drop dir and answers its path. Pruned on -// write: files older than a day go. +// The drop dir, made and pruned: 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) { try { - 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 */ - } - } + const { dir, member } = prepareDropDir(req); 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" }); res.json({ path: file }); } 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 // random prefix, so it can neither leave the drop dir nor collide, and the // 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( "/drop-file", refuseOversize, + streamLocalDrop, express.raw({ type: () => true, limit: DROP_MAX_BYTES }), (req, res) => { - const safe = path - .basename(String(req.query.name || "")) - .replace(/[^A-Za-z0-9._-]/g, "_") - .replace(/^\.+/, "") - .slice(-100); + const safe = dropName(req); if (!safe || !Buffer.isBuffer(req.body) || req.body.length === 0) { return res.status(400).json({ error: "?name= and a non-empty file body required" }); }