diff --git a/client/src/components/lanes/CloneSourcesForm.tsx b/client/src/components/lanes/CloneSourcesForm.tsx index eb26c26b..2ec23687 100644 --- a/client/src/components/lanes/CloneSourcesForm.tsx +++ b/client/src/components/lanes/CloneSourcesForm.tsx @@ -4,18 +4,58 @@ * (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and * added as an extra folder, one after another. A host that wants a login * (409 `ECREDENTIALS`) opens one login row; Retry re-runs only the failed - * URLs with it, like Add workspace → Clone. + * URLs with it, like Add workspace → Clone. Before anything runs, each URL + * is checked against the user's own git identity (`GET /api/me/git`): an SSH + * link while the account has no SSH key is not cloned at all — the user is + * sent to Settings → Git first — and an HTTPS host with no saved login offers + * the login row up front (optional: a public repo needs none). The identity is + * read again when the window regains focus, so a key made in another tab + * counts at once. * @author Nguyễn Ngọc Trí Vĩ */ -import { useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { Check, Loader2, X } from "lucide-react"; -import { api, type LaneSourcesView } from "../../lib/api"; +import { api, type GitIdentity, type LaneSourcesView } from "../../lib/api"; -type Row = { url: string; state: "wait" | "busy" | "done" | "failed"; note?: string }; +type Row = { + url: string; + state: "wait" | "busy" | "done" | "failed"; + note?: string; + /** The note points at Settings → Git (no key, or a key the host refused). */ + ssh?: boolean; +}; type Login = { host: string; username: string; token: string; remember: boolean }; +/** How a clone URL reaches its host: `ssh://…` or scp-like `user@host:path`, else HTTPS. */ +export function cloneTarget(url: string): { kind: "ssh" | "https"; host: string } | null { + const scp = /^(?:[^@\s/]+@)?([^:/\s]+):(?!\/\/)/.exec(url); + if (scp?.[1]) return { kind: "ssh", host: scp[1] }; + try { + const u = new URL(url); + if (u.protocol === "ssh:") return { kind: "ssh", host: u.hostname }; + if (u.protocol === "https:" || u.protocol === "http:") return { kind: "https", host: u.host }; + } catch { + /* not a URL yet — the server's allowlist answers that one */ + } + return null; +} + +/** What the identity lacks for one URL: `ssh` blocks the clone, `https` only asks. */ +function missingFor( + url: string, + id: GitIdentity | null +): { kind: "ssh" | "https"; host: string } | null { + const target = cloneTarget(url); + if (!id || !target) return null; + if (target.kind === "ssh") { + const keys = id.sshKeys?.length ?? (id.ssh ? 1 : 0); + return keys > 0 ? null : target; + } + return id.https.some((h) => h.host === target.host) ? null : target; +} + export function CloneSourcesForm({ laneId, parent, @@ -31,6 +71,24 @@ export function CloneSourcesForm({ const [rows, setRows] = useState([]); const [busy, setBusy] = useState(false); const [login, setLogin] = useState(null); + const [identity, setIdentity] = useState(null); + + // An unknown identity (an older server, a failed read) checks nothing: the + // server's own ECREDENTIALS / ESSHAUTH answers still cover it. + const loadIdentity = useCallback( + () => + Promise.resolve() + .then(() => api.gitIdentity.get()) + .then(setIdentity) + .catch(() => {}), + [] + ); + useEffect(() => { + void loadIdentity(); + const onFocus = () => void loadIdentity(); + window.addEventListener("focus", onFocus); + return () => window.removeEventListener("focus", onFocus); + }, [loadIdentity]); const urls = (s: string) => [ ...new Set( @@ -44,11 +102,19 @@ export function CloneSourcesForm({ const cloneAll = async (list: string[]) => { if (busy || !list.length) return; setBusy(true); - const next: Row[] = list.map((url) => ({ url, state: "wait" })); + // An SSH link with no key on the account would only fail at the host: + // it is not tried, and its row says what to set up first. + const next: Row[] = list.map((url) => { + const miss = missingFor(url, identity); + return miss?.kind === "ssh" + ? { url, state: "failed", note: t("clone.sshNoKey", { host: miss.host }), ssh: true } + : { url, state: "wait" }; + }); const show = () => setRows([...next]); show(); let asked: Login | null = null; for (const row of next) { + if (row.state === "failed") continue; row.state = "busy"; show(); try { @@ -80,6 +146,7 @@ export function CloneSourcesForm({ }; } else if (e.code === "ESSHAUTH") { row.note = t("clone.sshRefused", { host }); + row.ssh = true; } else { row.note = e.message || String(err); } @@ -99,6 +166,18 @@ export function CloneSourcesForm({ const failed = rows.filter((r) => r.state === "failed").map((r) => r.url); + // Checked as the user types, so a missing key is said before Clone is pressed. + const missing = urls(text) + .map((u) => missingFor(u, identity)) + .filter((m): m is NonNullable => m !== null); + const sshHosts = [...new Set(missing.filter((m) => m.kind === "ssh").map((m) => m.host))]; + const httpsHosts = [...new Set(missing.filter((m) => m.kind === "https").map((m) => m.host))]; + const settingsLink = ( + + {t("clone.sshSettings")} + + ); + return (

{t("home.cloneFolders")}

@@ -112,6 +191,29 @@ export function CloneSourcesForm({ className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary" />

{t("home.cloneHint", { path: parent })}

+ {sshHosts.length > 0 && ( +

+ {t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink} +

+ )} + {httpsHosts.length > 0 && !login && ( +

+ {t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "} + +

+ )} {login && (
@@ -178,7 +280,12 @@ export function CloneSourcesForm({ {r.url} - {r.note && {r.note}} + {r.note && ( + + {r.note} + {r.ssh && <> {settingsLink}} + + )} ))} diff --git a/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx b/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx index 67dbb5a9..8636d976 100644 --- a/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx +++ b/client/src/components/lanes/__tests__/CloneSourcesForm.test.tsx @@ -1,7 +1,9 @@ /** * @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as * an extra folder, a host that wants a login opens the login row, and Retry - * re-runs only the failed URLs with that login. + * re-runs only the failed URLs with that login. An SSH link while the + * account has no key is never sent — the user is pointed at Settings → Git — + * and an HTTPS host with no saved login is flagged before Clone. * @author Nguyễn Ngọc Trí Vĩ */ @@ -11,16 +13,20 @@ import userEvent from "@testing-library/user-event"; import "../../../i18n"; vi.mock("../../../lib/api", () => ({ - api: { lanes: { cloneFolder: vi.fn() } }, + api: { lanes: { cloneFolder: vi.fn() }, gitIdentity: { get: vi.fn() } }, })); -import { api, type LaneSourcesView } from "../../../lib/api"; -import { CloneSourcesForm } from "../CloneSourcesForm"; +import { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api"; +import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm"; const view = { sources: [] } as unknown as LaneSourcesView & { path: string }; describe("CloneSourcesForm", () => { - beforeEach(() => vi.mocked(api.lanes.cloneFolder).mockReset()); + beforeEach(() => { + vi.mocked(api.lanes.cloneFolder).mockReset(); + // Unknown identity: nothing is checked up front. + vi.mocked(api.gitIdentity.get).mockReset().mockRejectedValue(new Error("old server")); + }); it("clones each URL, asks for a login where needed, and retries only that one", async () => { const user = userEvent.setup(); @@ -58,4 +64,36 @@ describe("CloneSourcesForm", () => { }); expect(screen.getByTestId("workspace-clone-input")).toHaveValue(""); }); + + it("tells an SSH link from an HTTPS one", () => { + expect(cloneTarget("git@github.com:o/r.git")).toEqual({ kind: "ssh", host: "github.com" }); + expect(cloneTarget("ssh://git@h.test:2222/o/r")).toEqual({ kind: "ssh", host: "h.test" }); + expect(cloneTarget("https://h.test/o/r.git")).toEqual({ kind: "https", host: "h.test" }); + expect(cloneTarget("not a url")).toBeNull(); + }); + + it("does not clone an SSH link before the account has a key, and flags an HTTPS host with no login", async () => { + const user = userEvent.setup(); + vi.mocked(api.gitIdentity.get).mockResolvedValue({ + ssh: null, + sshKeys: [], + https: [], + } as unknown as GitIdentity); + vi.mocked(api.lanes.cloneFolder).mockResolvedValue(view); + render(); + await waitFor(() => expect(api.gitIdentity.get).toHaveBeenCalled()); + + await user.type( + screen.getByTestId("workspace-clone-input"), + "git@github.com:o/r.git\nhttps://h.test/a.git" + ); + expect(screen.getByTestId("workspace-clone-need-ssh")).toHaveTextContent("github.com"); + expect(screen.getByTestId("workspace-clone-need-login")).toHaveTextContent("h.test"); + + await user.click(screen.getByTestId("workspace-clone-run")); + await waitFor(() => expect(api.lanes.cloneFolder).toHaveBeenCalledTimes(1)); + expect(api.lanes.cloneFolder).toHaveBeenCalledWith(3, { url: "https://h.test/a.git" }); + expect(await screen.findByText(/has no SSH key yet/)).toBeInTheDocument(); + expect(screen.getByTestId("workspace-clone-input")).toHaveValue("git@github.com:o/r.git"); + }); }); diff --git a/client/src/i18n/locales/en/lanes.json b/client/src/i18n/locales/en/lanes.json index b9c34d0e..c70e74bf 100644 --- a/client/src/i18n/locales/en/lanes.json +++ b/client/src/i18n/locales/en/lanes.json @@ -220,6 +220,7 @@ "clone.remember": "Remember this login for my account", "clone.sshRefused": "{{host}} refused the SSH login. Add your public key to that host, or make one in Settings → Git.", "clone.sshSettings": "Open Settings → Git", + "clone.sshNoKey": "Not cloned: your account has no SSH key yet. Make one in Settings → Git, add its public key to {{host}}, then retry.", "moreActions": "More actions", "pipelinePicker.label": "Pipeline template", "pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}", @@ -421,6 +422,9 @@ "home.cloneRun": "Clone", "home.cloneRetry": "Retry the failed ones", "home.cloneDone": "Added", + "home.cloneNeedSsh": "SSH links need an SSH key first — your account has none. Make one in Settings → Git and add its public key to {{hosts}} before cloning.", + "home.cloneNeedLogin": "No saved login for {{hosts}}. A private repository needs your username and an access token (or save one in Settings → Git).", + "home.cloneEnterLogin": "Enter a login", "home.suggested": "Untracked worktrees", "home.remove": "Remove", "home.missing": "missing", diff --git a/client/src/i18n/locales/vi/lanes.json b/client/src/i18n/locales/vi/lanes.json index 0536f7ca..36e49290 100644 --- a/client/src/i18n/locales/vi/lanes.json +++ b/client/src/i18n/locales/vi/lanes.json @@ -220,6 +220,7 @@ "clone.remember": "Lưu đăng nhập này cho tài khoản của tôi", "clone.sshRefused": "{{host}} từ chối đăng nhập SSH. Hãy thêm public key của bạn vào host đó, hoặc tạo key trong Cài đặt → Git.", "clone.sshSettings": "Mở Cài đặt → Git", + "clone.sshNoKey": "Chưa clone: tài khoản của bạn chưa có SSH key. Hãy tạo key trong Cài đặt → Git, thêm public key vào {{host}}, rồi thử lại.", "moreActions": "Thêm hành động", "pipelinePicker.label": "Mẫu pipeline", "pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}", @@ -421,6 +422,9 @@ "home.cloneRun": "Clone", "home.cloneRetry": "Thử lại các repo lỗi", "home.cloneDone": "Đã thêm", + "home.cloneNeedSsh": "Link SSH cần SSH key trước — tài khoản của bạn chưa có. Hãy tạo key trong Cài đặt → Git và thêm public key vào {{hosts}} trước khi clone.", + "home.cloneNeedLogin": "Chưa lưu login cho {{hosts}}. Repo private cần username và access token của bạn (hoặc lưu sẵn trong Cài đặt → Git).", + "home.cloneEnterLogin": "Nhập login", "home.suggested": "Worktree chưa theo dõi", "home.remove": "Gỡ", "home.missing": "không còn", diff --git a/docs/API.md b/docs/API.md index 3aac5fb3..e5c1872f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1593,7 +1593,7 @@ What a workspace's home page edits (`server/lib/lane-sources.js`, `server/routes - **Instructions** — the `instructions` key is required (`400 EBADFIELD` without it), a string or `null`; blank clears; over 16 000 characters is `413 ETOOBIG`. The lane is re-broadcast (`lane_update`). The text is stored in `lanes.instructions` but is NOT part of the lane payload (`GET /api/lanes`, `lane_update`) — read it here. - **Folder** — must be absolute, exist and be a directory (`400 EBADPATH`), must not be or contain the OS home (`400 EBADHOME`), and must not be the workspace's own folder or one it already has (`400 EDUP`). Stored by real path. Removing it only drops the row. -- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. +- **Clone** — clones a related repo BESIDE the workspace folder (into its parent; no path is taken from the request) and adds it as a Folder. The clone is `POST /api/lanes/clone`'s own: the same URL allowlist (`400 EBADURL`), one plain `name` (default: the repo's), `409 EEXISTS` for a taken name, run as the folder's account with that account's git identity, `409 ECREDENTIALS` (with `auth: {protocol, host}`) / `ESSHAUTH` / `ENOREPO`; a tool that takes no folders is `400 EUNSUPPORTED`. Answers the view plus `path`. Removing the folder later never deletes the clone. The workspace home's **Add folder** form takes several URLs, one per line, and clones them in turn. Before cloning it checks each URL against `GET /api/me/git`: an SSH link (`ssh://…`, `user@host:path`) while the account has no SSH key is not sent at all and points to Settings → Git (make a key, add its public key to the host), and an HTTPS host with no saved login is flagged with an optional login row — a public repo needs none. The server's own `ECREDENTIALS`/`ESSHAUTH` answers still decide. - **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `/workspace-context//` with a name it derives from `X-File-Name` (directory parts and path characters dropped, `name (2).ext` on a clash), so a request never names a path. It is a COPY taken at upload: later edits to the original are not seen — remove and upload it again, or add its directory as a Folder (read live from disk). Removing it deletes that copy. At most 50 sources per workspace (`400 ETOOMANY`). - **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `/projects//memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content. - Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.