Watch
0
0
Fork
You've already forked hyperhive
0

swarm-ui: link a gitlab account to an agent

This commit is contained in:
atlas 2026-11-02 17:27:48 +01:00 • committed by mara
commit 3e5b5b54a2
8 changed files with 240 additions and 56 deletions

View file

@ -11,9 +11,12 @@ from the swarm secret store itself.
## Linking an account
The swarm UI has no GitLab dialog yet, so link an account with the controller's
API. This is the same route family as the other
[linked accounts](../swarm/ui.md#linking-external-accounts):
In the swarm UI, open an agent, choose **link account**, then **gitlab
account**. Fill in a label, the instance URL (it starts as `https://gitlab.com`)
and the token. The form shows the recommended scopes and the controller's
answer if it refuses the input. The linked account appears in the agent's list
of [linked accounts](../swarm/ui.md#linking-external-accounts) with a delete
action. The UI calls the controller's API, which you can also call directly:
```text
PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}

View file

@ -810,8 +810,7 @@ a link dialog closes.
takes a delete, then a new link.
- **link a gitlab account** — `PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}`
with a GitLab base URL and an access token, stored at
`swarm/agents/<agent>/gitlab/<label>`. The swarm UI has no dialog for it yet;
the API is the way to link one. What the token needs and how the agent uses
`swarm/agents/<agent>/gitlab/<label>`. Link it from the agent's **link account** menu. What the token needs and how the agent uses
it: [GitLab accounts](../integrations/gitlab.md).
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
with a personal access token, stored at `swarm/agents/<agent>/github-token`.

View file

@ -8,11 +8,12 @@ import { Badge } from "@hive/shared/badge.js";
import { Dropdown, type DropdownOption } from "@hive/shared/dropdown.js";
import { LinkIcon } from "@hive/shared/icons.js";
export type LinkKind = "matrix" | "forge" | "github" | "backend";
export type LinkKind = "matrix" | "forge" | "gitlab" | "github" | "backend";
const OPTIONS: DropdownOption[] = [
{ value: "matrix", label: "matrix account" },
{ value: "forge", label: "forge account" },
{ value: "gitlab", label: "gitlab account" },
{ value: "github", label: "github account" },
{ value: "backend", label: "API-key environment" },
];
@ -41,7 +42,7 @@ export function LinkAccountMenu({
disabled={!hasHive}
title={
hasHive
? `link a matrix, forge or github account, or set the API-key environment, for ${agent}`
? `link a matrix, forge, gitlab or github account, or set the API-key environment, for ${agent}`
: "no hive on record for this agent — nothing to link against"
}
/>

View file

@ -16,6 +16,7 @@ import { Badge } from "@hive/shared/badge.js";
import { CheckboxField } from "../../ui/checkbox-field/CheckboxField.js";
import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { accountKindLabel } from "./account-kind.js";
import { deleteUrl, listUrl } from "./account-api.js";
import "./LinkedAccounts.css";
type AccountKind = "matrix" | "forgejo" | "gitlab" | "github" | "backend";
@ -28,23 +29,6 @@ interface LinkedAccount {
reserved: boolean;
}
// The DELETE route for one account, beside the PUT that links it.
function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
const base = `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
switch (a.kind) {
case "matrix":
return `${base}/matrix-accounts/${encodeURIComponent(a.name)}`;
case "forgejo":
return `${base}/forge-accounts/${encodeURIComponent(a.name)}`;
case "gitlab":
return `${base}/gitlab-accounts/${encodeURIComponent(a.name)}`;
case "github":
return `${base}/github-account`;
case "backend":
return `${base}/backend-env`;
}
}
export function LinkedAccounts({
hive,
agent,
@ -68,9 +52,7 @@ export function LinkedAccounts({
useEffect(() => {
let cancelled = false;
(async () => {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/linked-accounts`,
);
const r = await fetch(listUrl(hive, agent));
if (!r.ok) {
if (!cancelled) setError(await readApiError(r));
return;
@ -97,7 +79,7 @@ export function LinkedAccounts({
if (!deleteTarget) return;
setDeleting(true);
try {
const url = accountUrl(hive, agent, deleteTarget);
const url = deleteUrl(hive, agent, deleteTarget);
const r = await fetch(
deleteTarget.kind === "matrix" && revoke ? `${url}?revoke=true` : url,
{ method: "DELETE" },

View file

@ -0,0 +1,72 @@
// The routes and request body behind linking, listing and deleting an
// external forge account. Pure, so `npm test` runs them without a browser.
/** The kinds that share the label + URL + token shape. */
export type ForgeLinkKind = "forge" | "gitlab";
/** `hive_types::Ident`, which the controller checks for the label. */
export const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
function agentBase(hive: string, agent: string): string {
return `/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}`;
}
/** PUT target for linking `label` under `kind`. */
export function linkUrl(
hive: string,
agent: string,
kind: ForgeLinkKind,
label: string,
): string {
const route = kind === "gitlab" ? "gitlab-accounts" : "forge-accounts";
return `${agentBase(hive, agent)}/${route}/${encodeURIComponent(label)}`;
}
/** The PUT body; the token is sent as typed, the URL trimmed. */
export function linkBody(url: string, token: string): string {
return JSON.stringify({ url: url.trim(), token });
}
/** GET target for the agent's linked accounts. */
export function listUrl(hive: string, agent: string): string {
return `${agentBase(hive, agent)}/linked-accounts`;
}
/** DELETE target for one linked account, as listed by `listUrl`. */
export function deleteUrl(
hive: string,
agent: string,
a: { kind: string; name: string },
): string {
const base = agentBase(hive, agent);
const name = encodeURIComponent(a.name);
switch (a.kind) {
case "matrix":
return `${base}/matrix-accounts/${name}`;
case "forgejo":
return `${base}/forge-accounts/${name}`;
case "gitlab":
return `${base}/gitlab-accounts/${name}`;
case "github":
return `${base}/github-account`;
default:
return `${base}/backend-env`;
}
}
/** A message for input the controller would refuse anyway, or null. The
* controller's own URL rule stays the gate and its 400 is shown as is. */
export function checkLink(
label: string,
url: string,
token: string,
): string | null {
if (!new RegExp(`^${LABEL_PATTERN}$`).test(label)) {
return "label: 1-63 chars, lowercase letters, digits, hyphen";
}
if (!/^https?:\/\/\S+$/.test(url.trim())) {
return "URL must start with http:// or https://";
}
if (token === "") return "token is required";
return null;
}

View file

@ -98,6 +98,7 @@ export function useAgentActions(
const [customTarget, setCustomTarget] = useState<AgentRow | null>(null);
const [matrixTarget, setMatrixTarget] = useState<AgentRow | null>(null);
const [forgeTarget, setForgeTarget] = useState<AgentRow | null>(null);
const [gitlabTarget, setGitlabTarget] = useState<AgentRow | null>(null);
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
const [backendTarget, setBackendTarget] = useState<AgentRow | null>(null);
const [linkVersion, setLinkVersion] = useState(0);
@ -111,6 +112,10 @@ export function useAgentActions(
setForgeTarget(null);
linkClosed();
};
const closeGitlab = () => {
setGitlabTarget(null);
linkClosed();
};
const closeGithub = () => {
setGithubTarget(null);
linkClosed();
@ -233,12 +238,28 @@ export function useAgentActions(
>
{forgeTarget?.hive ? (
<LinkForgeAccountForm
kind="forge"
hive={forgeTarget.hive}
agent={forgeTarget.name}
onClose={closeForge}
/>
) : null}
</Dialog>
<Dialog
open={gitlabTarget !== null}
onClose={closeGitlab}
label="link a gitlab account"
plain
>
{gitlabTarget?.hive ? (
<LinkForgeAccountForm
kind="gitlab"
hive={gitlabTarget.hive}
agent={gitlabTarget.name}
onClose={closeGitlab}
/>
) : null}
</Dialog>
<Dialog
open={githubTarget !== null}
onClose={closeGithub}
@ -330,6 +351,7 @@ export function useAgentActions(
openLink: (row, kind) => {
if (kind === "matrix") setMatrixTarget(row);
else if (kind === "forge") setForgeTarget(row);
else if (kind === "gitlab") setGitlabTarget(row);
else if (kind === "github") setGithubTarget(row);
else setBackendTarget(row);
},

View file

@ -1,10 +1,11 @@
// <LinkForgeAccountForm> — writes an external forge account (base URL +
// token) for one agent into the swarm secret store.
// PUTs `/api/hives/{hive}/agents/{agent}/forge-accounts/{label}` — 200
// token) for one agent into the swarm secret store. `kind` picks the
// route and the wording: "forge" (Forgejo) or "gitlab".
// PUTs `/api/hives/{hive}/agents/{agent}/{forge,gitlab}-accounts/{label}` — 200
// (`{ url }`) on success, 400/409/500 as `problem+json`, shown via
// `ApiErrorPanel` like `LinkMatrixAccountForm`.
//
// The label is what the agent passes to `hive-forge -f <label>`. A blind
// A forge label is what the agent passes to `hive-forge -f <label>`. A blind
// set: no route hands a token back. Linked labels and URLs show on
// the agent panel (`LinkedAccounts`).
import { useState } from "preact/hooks";
@ -13,11 +14,17 @@ import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import { Button } from "../ui/button/Button.js";
import {
checkLink,
LABEL_PATTERN,
linkBody,
linkUrl,
type ForgeLinkKind,
} from "../agent/detail/account-api.js";
import "./LinkMatrixAccountForm.css";
// `hive_types::Ident`, which the server checks: `hive-forge -f` accepts
// nothing wider. A UX hint only; the server is the gate.
const LABEL_PATTERN = "[a-z0-9\\-]{1,63}";
// Prefilled for gitlab; the field stays editable for a self-hosted instance.
const GITLAB_DEFAULT_URL = "https://gitlab.com";
type SubmitState =
| { status: "idle" }
@ -26,31 +33,35 @@ type SubmitState =
| { status: "error"; problem: ProblemDetails };
export function LinkForgeAccountForm({
kind,
hive,
agent,
onClose,
}: {
kind: ForgeLinkKind;
hive: string;
agent: string;
onClose?: () => void;
}) {
const [label, setLabel] = useState("");
const [url, setUrl] = useState("");
const [url, setUrl] = useState(kind === "gitlab" ? GITLAB_DEFAULT_URL : "");
const [token, setToken] = useState("");
const [result, setResult] = useState<SubmitState>({ status: "idle" });
async function submit(e: Event) {
e.preventDefault();
const invalid = checkLink(label, url, token);
if (invalid) {
setResult({ status: "error", problem: { detail: invalid } });
return;
}
setResult({ status: "submitting" });
try {
const r = await fetch(
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/forge-accounts/${encodeURIComponent(label)}`,
{
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ url: url.trim(), token }),
},
);
const r = await fetch(linkUrl(hive, agent, kind, label), {
method: "PUT",
headers: { "content-type": "application/json" },
body: linkBody(url, token),
});
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
@ -68,38 +79,59 @@ export function LinkForgeAccountForm({
return (
<Panel
title={`link a forge account — ${agent}`}
title={`link a ${kind === "gitlab" ? "gitlab" : "forge"} account — ${agent}`}
icon="🔗"
onClose={onClose}
>
<p>
Writes the account to the swarm secret store. The agent fetches it
within two minutes and can then use{" "}
<code>hive-forge -f {label || "<label>"}</code>. Create the token on the
external forge first; nothing is created there.
</p>
{kind === "gitlab" ? (
<>
<p>
Writes the account to the swarm secret store. The agent fetches it
within two minutes and can then use <code>glab</code> and{" "}
<code>git push</code> against the instance. Create the token on
GitLab first; nothing is created there.
</p>
<p>
Scope the token <code>api</code> and <code>write_repository</code>:
the first is for <code>glab</code>, the second for pushing over
HTTPS. Use a dedicated bot account; the agent can act within the
token's scopes.
</p>
</>
) : (
<p>
Writes the account to the swarm secret store. The agent fetches it
within two minutes and can then use{" "}
<code>hive-forge -f {label || "<label>"}</code>. Create the token on
the external forge first; nothing is created there.
</p>
)}
<form class="link-matrix-account-form" onSubmit={submit}>
<TextField
id="forge-account-label"
id={`${kind}-account-label`}
label="label"
value={label}
pattern={LABEL_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphen"
placeholder="e.g. myforge"
placeholder={kind === "gitlab" ? "e.g. gitlab-com" : "e.g. myforge"}
required
onInput={setLabel}
/>
<TextField
id="forge-account-url"
label="forge URL"
id={`${kind}-account-url`}
label={kind === "gitlab" ? "instance URL" : "forge URL"}
type="url"
value={url}
placeholder="https://forge.example.org"
placeholder={
kind === "gitlab"
? "https://gitlab.example.org"
: "https://forge.example.org"
}
required
onInput={setUrl}
/>
<TextField
id="forge-account-token"
id={`${kind}-account-token`}
label="access token"
type="password"
value={token}

View file

@ -0,0 +1,73 @@
// The link / list / delete requests for forge and gitlab accounts, and the
// client-side check before a link is sent. Run by `npm test` with Node's
// type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
import {
checkLink,
deleteUrl,
linkBody,
linkUrl,
listUrl,
} from "../src/agent/detail/account-api.ts";
test("a gitlab link PUTs to gitlab-accounts, a forge link to forge-accounts", () => {
assert.equal(
linkUrl("h1", "atlas", "gitlab", "gitlab-com"),
"/api/hives/h1/agents/atlas/gitlab-accounts/gitlab-com",
);
assert.equal(
linkUrl("h1", "atlas", "forge", "myforge"),
"/api/hives/h1/agents/atlas/forge-accounts/myforge",
);
});
test("path segments are encoded", () => {
assert.equal(
linkUrl("a/b", "x y", "gitlab", "l?"),
"/api/hives/a%2Fb/agents/x%20y/gitlab-accounts/l%3F",
);
});
test("the body carries the trimmed url and the token untouched", () => {
assert.deepEqual(JSON.parse(linkBody(" https://gitlab.com ", " tok en ")), {
url: "https://gitlab.com",
token: " tok en ",
});
});
test("list and delete hit the linked-accounts and gitlab routes", () => {
assert.equal(
listUrl("h1", "atlas"),
"/api/hives/h1/agents/atlas/linked-accounts",
);
assert.equal(
deleteUrl("h1", "atlas", { kind: "gitlab", name: "gitlab-com" }),
"/api/hives/h1/agents/atlas/gitlab-accounts/gitlab-com",
);
// Control: the other kinds keep their routes.
assert.equal(
deleteUrl("h1", "atlas", { kind: "forgejo", name: "f" }),
"/api/hives/h1/agents/atlas/forge-accounts/f",
);
assert.equal(
deleteUrl("h1", "atlas", { kind: "github", name: "x" }),
"/api/hives/h1/agents/atlas/github-account",
);
});
test("checkLink accepts a good input and names what is wrong otherwise", () => {
assert.equal(checkLink("gitlab-com", "https://gitlab.com", "t"), null);
assert.equal(checkLink("a", " http://gl.lan:8443/x ", "t"), null);
assert.match(checkLink("Bad", "https://gitlab.com", "t"), /label/);
assert.match(checkLink("", "https://gitlab.com", "t"), /label/);
assert.match(checkLink("a".repeat(64), "https://gitlab.com", "t"), /label/);
assert.match(checkLink("a", "gitlab.com", "t"), /URL/);
assert.match(checkLink("a", "ftp://gitlab.com", "t"), /URL/);
assert.match(checkLink("a", "https://gitlab.com", ""), /token/);
});
test("a message never contains the token", () => {
const msg = checkLink("Bad", "nope", "s3cr3t-value");
assert.ok(!String(msg).includes("s3cr3t-value"));
});