feat(workspace): check the git identity before cloning extra folders
The workspace's "Or clone repositories" box now reads GET /api/me/git and checks every URL as it is typed: an SSH link while the account has no SSH key is never sent and points to Settings → Git (make a key, add its public key to the host); an HTTPS host with no saved login is flagged with an optional login row, since a public repo needs none. The identity is re-read when the window regains focus, and a refused SSH login links to the same settings. The server's ECREDENTIALS / ESSHAUTH answers still decide.
This commit is contained in:
@@ -4,18 +4,58 @@
|
|||||||
* (`POST /api/lanes/:id/sources/clone`, the server picks the folder) and
|
* (`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
|
* 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
|
* (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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Check, Loader2, X } from "lucide-react";
|
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 };
|
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({
|
export function CloneSourcesForm({
|
||||||
laneId,
|
laneId,
|
||||||
parent,
|
parent,
|
||||||
@@ -31,6 +71,24 @@ export function CloneSourcesForm({
|
|||||||
const [rows, setRows] = useState<Row[]>([]);
|
const [rows, setRows] = useState<Row[]>([]);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [login, setLogin] = useState<Login | null>(null);
|
const [login, setLogin] = useState<Login | null>(null);
|
||||||
|
const [identity, setIdentity] = useState<GitIdentity | null>(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) => [
|
const urls = (s: string) => [
|
||||||
...new Set(
|
...new Set(
|
||||||
@@ -44,11 +102,19 @@ export function CloneSourcesForm({
|
|||||||
const cloneAll = async (list: string[]) => {
|
const cloneAll = async (list: string[]) => {
|
||||||
if (busy || !list.length) return;
|
if (busy || !list.length) return;
|
||||||
setBusy(true);
|
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]);
|
const show = () => setRows([...next]);
|
||||||
show();
|
show();
|
||||||
let asked: Login | null = null;
|
let asked: Login | null = null;
|
||||||
for (const row of next) {
|
for (const row of next) {
|
||||||
|
if (row.state === "failed") continue;
|
||||||
row.state = "busy";
|
row.state = "busy";
|
||||||
show();
|
show();
|
||||||
try {
|
try {
|
||||||
@@ -80,6 +146,7 @@ export function CloneSourcesForm({
|
|||||||
};
|
};
|
||||||
} else if (e.code === "ESSHAUTH") {
|
} else if (e.code === "ESSHAUTH") {
|
||||||
row.note = t("clone.sshRefused", { host });
|
row.note = t("clone.sshRefused", { host });
|
||||||
|
row.ssh = true;
|
||||||
} else {
|
} else {
|
||||||
row.note = e.message || String(err);
|
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);
|
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<typeof m> => 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 = (
|
||||||
|
<a href="/settings/git#git-identity" target="_blank" rel="noreferrer" className="underline">
|
||||||
|
{t("clone.sshSettings")}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
<div className="space-y-2 border-t border-border-light pt-2" data-testid="workspace-clone">
|
||||||
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
<p className="text-[10px] uppercase tracking-wide text-fg-muted">{t("home.cloneFolders")}</p>
|
||||||
@@ -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"
|
className="w-full rounded border border-border bg-surface-1 px-2 py-1 font-mono text-[11px] text-fg-primary"
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
<p className="text-[11px] text-fg-muted">{t("home.cloneHint", { path: parent })}</p>
|
||||||
|
{sshHosts.length > 0 && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
data-testid="workspace-clone-need-ssh"
|
||||||
|
className="text-[11px] text-status-danger"
|
||||||
|
>
|
||||||
|
{t("home.cloneNeedSsh", { hosts: sshHosts.join(", ") })} {settingsLink}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{httpsHosts.length > 0 && !login && (
|
||||||
|
<p data-testid="workspace-clone-need-login" className="text-[11px] text-status-warning">
|
||||||
|
{t("home.cloneNeedLogin", { hosts: httpsHosts.join(", ") })}{" "}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="underline"
|
||||||
|
onClick={() =>
|
||||||
|
setLogin({ host: httpsHosts[0] ?? "", username: "", token: "", remember: true })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("home.cloneEnterLogin")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
{login && (
|
{login && (
|
||||||
<div className="space-y-1.5 rounded border border-border-light p-2">
|
<div className="space-y-1.5 rounded border border-border-light p-2">
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -178,7 +280,12 @@ export function CloneSourcesForm({
|
|||||||
{r.url}
|
{r.url}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{r.note && <span className="ml-[18px] block text-status-danger">{r.note}</span>}
|
{r.note && (
|
||||||
|
<span className="ml-[18px] block text-status-danger">
|
||||||
|
{r.note}
|
||||||
|
{r.ssh && <> {settingsLink}</>}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @file Tests for CloneSourcesForm: every URL in the box is cloned in turn as
|
* @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
|
* 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ĩ <vinnt@smartgift.vn>
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -11,16 +13,20 @@ import userEvent from "@testing-library/user-event";
|
|||||||
import "../../../i18n";
|
import "../../../i18n";
|
||||||
|
|
||||||
vi.mock("../../../lib/api", () => ({
|
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 { api, type GitIdentity, type LaneSourcesView } from "../../../lib/api";
|
||||||
import { CloneSourcesForm } from "../CloneSourcesForm";
|
import { CloneSourcesForm, cloneTarget } from "../CloneSourcesForm";
|
||||||
|
|
||||||
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
const view = { sources: [] } as unknown as LaneSourcesView & { path: string };
|
||||||
|
|
||||||
describe("CloneSourcesForm", () => {
|
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 () => {
|
it("clones each URL, asks for a login where needed, and retries only that one", async () => {
|
||||||
const user = userEvent.setup();
|
const user = userEvent.setup();
|
||||||
@@ -58,4 +64,36 @@ describe("CloneSourcesForm", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByTestId("workspace-clone-input")).toHaveValue("");
|
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(<CloneSourcesForm laneId={3} parent="/w" onAdded={vi.fn()} />);
|
||||||
|
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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"clone.remember": "Remember this login for my account",
|
"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.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.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",
|
"moreActions": "More actions",
|
||||||
"pipelinePicker.label": "Pipeline template",
|
"pipelinePicker.label": "Pipeline template",
|
||||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||||
@@ -421,6 +422,9 @@
|
|||||||
"home.cloneRun": "Clone",
|
"home.cloneRun": "Clone",
|
||||||
"home.cloneRetry": "Retry the failed ones",
|
"home.cloneRetry": "Retry the failed ones",
|
||||||
"home.cloneDone": "Added",
|
"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.suggested": "Untracked worktrees",
|
||||||
"home.remove": "Remove",
|
"home.remove": "Remove",
|
||||||
"home.missing": "missing",
|
"home.missing": "missing",
|
||||||
|
|||||||
@@ -220,6 +220,7 @@
|
|||||||
"clone.remember": "Lưu đăng nhập này cho tài khoản của tôi",
|
"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.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.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",
|
"moreActions": "Thêm hành động",
|
||||||
"pipelinePicker.label": "Mẫu pipeline",
|
"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}}",
|
"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.cloneRun": "Clone",
|
||||||
"home.cloneRetry": "Thử lại các repo lỗi",
|
"home.cloneRetry": "Thử lại các repo lỗi",
|
||||||
"home.cloneDone": "Đã thêm",
|
"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.suggested": "Worktree chưa theo dõi",
|
||||||
"home.remove": "Gỡ",
|
"home.remove": "Gỡ",
|
||||||
"home.missing": "không còn",
|
"home.missing": "không còn",
|
||||||
|
|||||||
+1
-1
@@ -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.
|
- **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.
|
- **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 `<data dir>/workspace-context/<lane id>/` 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`).
|
- **File** — up to 10 MB (`413 ETOOBIG`), not empty (`400 EEMPTY`). AgentHub stores it under `<data dir>/workspace-context/<lane id>/` 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 `<CLAUDE_HOME>/projects/<encoded cwd>/memory/*.md` — `{files: [{label, path, size, mtime, truncated, content}]}`, each at most 256 KB of content.
|
- **Memory** — read-only: the folder's `CLAUDE.md`, `.claude/CLAUDE.md`, `CLAUDE.local.md`, `AGENTS.md`, and for a Claude workspace `<CLAUDE_HOME>/projects/<encoded cwd>/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`.
|
- Every write is same-origin only (`403 EBADORIGIN`); an unknown workspace is `404 ENOLANE`, an unknown source `404 ENOSOURCE`.
|
||||||
|
|||||||
Reference in New Issue
Block a user