Watch
0
0
Fork
You've already forked hyperhive
0

add external gitlab accounts: stored in bao, linked per agent, allowed in squid, usable with glab

This commit is contained in:
atlas 2026-11-02 16:11:25 +01:00
commit d6e983995d
20 changed files with 1322 additions and 51 deletions

View file

@ -90,9 +90,10 @@ box it gets:
[subagent](docs/tools/subagent.md)
- **forge** — its own Forgejo account, the `hive-forge` CLI, git
credentials, and notifications turned into todos. Optional linked
accounts on other forges, and GitHub (`gh` + push) via
accounts on other forges, GitLab (`glab` + push), and GitHub (`gh` + push) via
`services.hyperhive.agent.github.enable`. →
[forge](docs/integrations/forge.md) · [github](docs/integrations/github.md)
[forge](docs/integrations/forge.md) · [gitlab](docs/integrations/gitlab.md) ·
[github](docs/integrations/github.md)
- **matrix** — its own account(s) on the swarm homeserver, as MCP tools. →
[matrix](docs/tools/matrix.md)
- **knowledge** — the swarm's shared `internal/knowledge` repo, read-only

View file

@ -58,6 +58,8 @@ declarations.
the PAT injected?** → [`integrations/github.md`](integrations/github.md)
(operator content up top; the `gh`/git-push + notification-poller
mechanics are in a collapsed "Implementation" section at the bottom).
- **How to give an agent GitLab accounts (`glab` + `git push`, more than one
instance)?** → [`integrations/gitlab.md`](integrations/gitlab.md).
- **What's `/knowledge`? How does the swarm-wide knowledge repo sync,
and how to contribute a document?** → [`integrations/knowledge.md`](integrations/knowledge.md).
- **What does `hivectl` do? Provisioning, gateway users, container

View file

@ -0,0 +1,88 @@
# GitLab accounts
Give an agent managed accounts on one or more GitLab instances: a `glab` CLI
and `git push` over HTTPS, both authenticated by operator-supplied access
tokens, with no manual `glab auth login`.
An agent can hold any number of accounts, each under a label you choose. Two labels
can name two instances, such as `gitlab.com` and a self-hosted one. Provisioning
needs no per-agent nix declaration and no rebuild: the agent fetches its tokens
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):
```text
PUT /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}
{"url": "https://gitlab.example.org", "token": "<access token>"}
```
- `label` is lowercase letters, digits and hyphens. It names the account in the
store and in the account list.
- `url` is the instance's base URL. The host must be a DNS name: an IP
address, `localhost` and a single-label name get a 400, because the agent
reaches the instance through its hive's egress proxy, which takes names
only. A port and a subfolder (`https://example.org:8443/gitlab`) are part of
the address. The path may hold letters, digits and `. _ ~ / -`; a URL with
user information, a query, a fragment or any other character gets a 400, so
every stored account is one the agent uses.
- A plain `http://` URL works, and then the token crosses the network in
cleartext. Use `https://` unless the instance sits on a trusted network.
- `token` is a GitLab personal, group or project access token. Scope it
`api` and `write_repository`: `glab` needs the first, and `git push` over
HTTPS needs the second. Use a dedicated bot account and the narrowest token
that does the agent's work, because the agent has passwordless sudo and can
act within the token's scopes.
A label that holds an account already answers 409; delete it first
(`DELETE .../gitlab-accounts/{label}`), then link again. No route hands a
token back. `GET /api/hives/{hive}/agents/{agent}/linked-accounts` lists the
account as kind `gitlab`, with its label and URL.
Linking also allows the instance's host in the agent's egress, as a
`link:gitlab:<label>` entry on the swarm's allowed-domains list. swarm-controller
derives the host from the URL it stored, so nothing the agent writes can widen
its egress. Deleting the account removes the entry.
## What the agent gets
An agent with a store identity (`services.hyperhive.agent.bao.addr`) has:
- the `glab` command, which reads its configuration from `<state>/glab/`;
- a git credential helper, `git-credential-hive-gitlab`, for each linked
instance;
- the `hive-agent-gitlab-accounts` unit and its timer.
The unit logs in to the store with the agent's own certificate, lists
`swarm/agents/<agent>/gitlab/`, reads each account, and writes two files in
`<state>/glab/` (mode `0600`) on boot and every two minutes, replacing a file
only when its content changed:
- `config.yml`, `glab`'s own configuration with one `hosts` entry per instance,
keyed by host and port, holding the token, the API protocol and the
subfolder. It sets `git_protocol: https`. The `host` default is the first
label's instance; inside a repository `glab` uses the host of its remote.
- `gitconfig`, one `[credential "<scheme>://<host>"]` block per instance, which
`/etc/gitconfig` includes. The helper answers only for a host that has an
account, so it never touches GitHub or the swarm's forge.
The unit generates both files. A deleted account leaves them on the next run, and
a hand edit doesn't survive. `glab` keeps one token per host: when two labels
name the same host, the first label in alphabetical order wins. If the unit
can't read one of the accounts, it replaces nothing and retries, so the files
keep the last complete set.
The unit fails fast and restarts itself every 30 seconds for up to a day, as
the other store fetches do, so a store that's down or sealed never holds the
container's boot open.
## Rolling out
The endpoint, the store path and the agent unit are new, and each end ignores
what it doesn't know. An old controller has no GitLab route, so no account gets
linked. An old agent has no unit, so a stored account sits unused. A new agent
under an old controller lists a store directory that doesn't exist and writes an
empty `glab` configuration.

View file

@ -32,6 +32,7 @@ the rest of the cell says how.
| `swarm/agents/<agent>/observability` | `swarm-controller`, when an operator sets, clears or creates an agent's permissions with `logs` or `metrics` in the old or new list, and when it destroys the agent; the leaf stays absent for an agent that never held either | the agent (read-only, its own prefix); `swarm-logs-auth` and `swarm-metrics-agent-auth`, which deny when it's absent or unreadable | ✅ the controller rewrites it with the permission, and every five minutes makes it equal the record's | ✅ the stores honour a changed grant for up to `deploy.victorialogs.authVerdictTtl` and `deploy.victoriametrics.agentAuthVerdictTtl` (60 s by default) |
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and every 5 minutes | the agent | ✅ when missing or no longer matching the forge | ✅ every 10 minutes |
| `swarm/agents/<agent>/forge/<label>` | `swarm-controller`, when you link an external forge account in the swarm UI | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes |
| `swarm/agents/<agent>/gitlab/<label>` | `swarm-controller`, when you link a GitLab account through the API | the agent | ❌ your token; delete it and link the label again | ✅ every 2 minutes; a deleted account leaves the agent's `glab` configuration on the next run |
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when you link a GitHub account in the swarm UI | the agent | ❌ your token; delete it and link again | ✅ every 2 minutes |
| `swarm/agents/<agent>/backend-env` | `hive-agent-backend-env`, in the agent's container, from the agent's `backendEnvironmentFile` when the leaf holds nothing (field `env`, the file's exact text); or `swarm-controller`, when you set the API-key environment in the swarm UI | the agent | ❌ nothing replaces it without you; a save in the swarm UI overwrites it, including text an agent wrote into an empty leaf; the agent's file stays as it was | ❌ the file in `/run` refreshes every 2 minutes, and the harness and the subagent daemon load it at their next start |
| `swarm/agents/<agent>/claude-credentials` | `swarm-claude-login`, when you log the agent in from its card; one version per login | the agent, while it waits for a login | ❌ log in again | ✅ every 30 seconds while waiting |

View file

@ -784,6 +784,7 @@ beneath the error.
| --------- | ------------------------------------------- | ------------- | ------------------------ |
| matrix | `swarm/agents/<agent>/matrix/<account>` | the account | its `homeserver`, if set |
| forgejo | `swarm/agents/<agent>/forge/<label>` | the label | its `url` |
| gitlab | `swarm/agents/<agent>/gitlab/<label>` | the label | its `url` |
| github | `swarm/agents/<agent>/github-token`, if any | `github` | `github.com` |
| `backend` | `swarm/agents/<agent>/backend-env`, if any | `backend-env` | none |
@ -807,6 +808,11 @@ a link dialog closes.
label the store doesn't list stays untouched. swarm-controller answers 409
to a link for a label that already holds an account, so replacing one
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
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`.
One token per agent. The agent's
@ -834,6 +840,7 @@ request:
| --------- | ------------------------------------------------------------------- |
| matrix | `DELETE /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}` |
| forgejo | `DELETE /api/hives/{hive}/agents/{agent}/forge-accounts/{label}` |
| gitlab | `DELETE /api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}` |
| github | `DELETE /api/hives/{hive}/agents/{agent}/github-account` |
| `backend` | `DELETE /api/hives/{hive}/agents/{agent}/backend-env` |

View file

@ -18,7 +18,7 @@ import { ConfirmDialog } from "../../ui/confirm-dialog/ConfirmDialog.js";
import { accountKindLabel } from "./account-kind.js";
import "./LinkedAccounts.css";
type AccountKind = "matrix" | "forgejo" | "github" | "backend";
type AccountKind = "matrix" | "forgejo" | "gitlab" | "github" | "backend";
// Mirrors `linked_accounts::LinkedAccount`.
interface LinkedAccount {
@ -36,6 +36,8 @@ function accountUrl(hive: string, agent: string, a: LinkedAccount): string {
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":

View file

@ -78,6 +78,7 @@ in
./frontend.nix
./github.nix
./github-token.nix
./gitlab-accounts.nix
./hive-ca.nix
./hive-facts.nix
./logs.nix

View file

@ -0,0 +1,284 @@
# This agent's accounts on GitLab instances: fetched from the swarm secret store
# by the agent itself, into the `glab` configuration, and a `glab` and a git
# credential helper that use it.
#
# An operator links an account through `swarm-controller`, which stores it at
# `swarm/agents/<agent>/gitlab/<label>` (`swarm_secret_client::gitlab`). The
# agent's grant lists and reads its own subtree, so this unit lists
# `swarm/agents/<agent>/gitlab/`, reads each account, and writes
# `<state>/glab/config.yml`: one `hosts` entry per instance, which is `glab`'s
# own multi-host schema. It also writes `<state>/glab/gitconfig`, a
# `[credential "<scheme>://<host>"]` block per instance, which /etc/gitconfig
# includes.
#
# The files are generated: every run rebuilds them from the store, so an
# account the operator deleted is gone from them after the next run. A run in
# which any account could not be read replaces nothing. A file is replaced by
# rename, and only when its bytes changed.
#
# The wrapper and the helper read the files at invocation, so a fetched account
# takes effect with no rebuild.
{
pkgs,
lib,
config,
...
}:
let
cfg = config.services.hyperhive.agent.bao;
agentName = config.services.hyperhive.agent.user.name;
stateDir = "/agents/${agentName}/state";
glabDir = "${stateDir}/glab";
# The server CA id ./bao.nix loads.
serverCaCredential = "hive-agent-bao-server-ca";
unitName = "hive-agent-gitlab-accounts";
# The nix half of `swarm_secret_client::gitlab::accounts_dir` plus
# `path::MOUNT`.
accountsDir = "secret/swarm/agents/${agentName}/gitlab";
runtimeDir = unitName;
# The store's whole answer for one account, token included: kept in the
# unit's own `0700` directory, never in the state dir.
rawFile = "/run/${runtimeDir}/account.json";
listFile = "/run/${runtimeDir}/list.json";
errFile = "/run/${runtimeDir}/bao.err";
# One account's line, then every readable account's: both hold tokens.
oneFile = "/run/${runtimeDir}/one.jsonl";
accountsFile = "/run/${runtimeDir}/accounts.jsonl";
configured = cfg.addr != null;
storeRetry = import ../host-modules/lib/store-retry.nix { };
# `glab`'s schema is `hosts.<host[:port]>.{token,api_protocol,subfolder}`
# beside the global keys, and the file must be mode `0600`. JSON is YAML, so
# `jq` writes it directly. `glab` keeps one token per host: of several labels
# on one host the first wins, and the first label's host is the default one.
glabConfigProgram = ''
(.[0].host? // null) as $default
| group_by(.host)
| map(.[0]) as $hosts
| { git_protocol: "https", check_update: false, telemetry: false }
+ (if $hosts == [] then {} else { host: $default } end)
+ (if $hosts == [] then {} else
{ hosts: ($hosts | map({
key: .host,
value: ({ token: .value, api_protocol: .scheme }
+ (if .subfolder == "" then {} else { subfolder: .subfolder } end))
}) | from_entries) }
end)
'';
# One git credential block per instance, and nothing for a host with no
# account. `username = oauth2` is GitLab's placeholder for a token.
gitconfigProgram = ''
group_by(.host) | map(.[0])[]
| "[credential \"\(.scheme)://\(.host)\"]\n\thelper = ${gitCredHelper}/bin/git-credential-hive-gitlab\n\tusername = oauth2"
'';
# One line per account: the label, token and url, and the url's scheme,
# host[:port] and subfolder. A url that is not http(s) with a DNS-name host
# yields no line, so nothing else reaches the files written from it.
accountProgram = ''
.data.data
| { label: $label, value: (.value | strings), url: (.url | strings) }
| select(.url | test("^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$"))
| . + (.url | capture("^(?<scheme>https?)://(?<host>[A-Za-z0-9.-]+(:[0-9]+)?)(?<path>.*)$"))
| . + { subfolder: (.path | ltrimstr("/") | rtrimstr("/")) }
| del(.path)
'';
glabWrapper = pkgs.writeShellScriptBin "glab" ''
export GLAB_CONFIG_DIR=${lib.escapeShellArg glabDir}
exec ${pkgs.glab}/bin/glab "$@"
'';
gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-gitlab" ''
# git credential-helper protocol: only the `get` action needs an answer.
[ "''${1:-}" = "get" ] || exit 0
host=
while IFS='=' read -r key value; do
[ -n "$key" ] || break
if [ "$key" = host ]; then host=$value; fi
done
[ -n "$host" ] || exit 0
# glab prints nothing for a host it holds no token for.
token="$(GLAB_CONFIG_DIR=${lib.escapeShellArg glabDir} \
${pkgs.glab}/bin/glab config get token --host "$host" 2>/dev/null)" || exit 0
[ -n "$token" ] || exit 0
printf 'username=oauth2\n'
printf 'password=%s\n' "$token"
'';
in
{
config = lib.mkIf configured {
# No bare pkgs.glab here: the wrapper *is* `glab` and hardcodes the real
# binary path, so it can't be shadowed.
environment.systemPackages = [
glabWrapper
gitCredHelper
];
# Pulls in the per-instance credential blocks the fetch writes. A missing
# file is not an error to git. System /etc/gitconfig merges under the
# agent's ~/.gitconfig, and the lines of every module's text join.
environment.etc."gitconfig".text = ''
[include]
path = ${glabDir}/gitconfig
'';
systemd.services.${unitName} = {
description = "fetch this agent's GitLab accounts from the secret store";
after = [
"network.target"
"hive-agent-bao-identity.service"
];
wantedBy = [ "multi-user.target" ];
path = [
pkgs.openbao
pkgs.coreutils
pkgs.diffutils
pkgs.jq
];
# ../host-modules/lib/store-retry.nix.
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
serviceConfig = storeRetry.serviceConfig // {
Type = "oneshot";
# Not `RemainAfterExit`, so the timer below can start it again.
RemainAfterExit = false;
# A failed fetch ends the start job within a minute and the unit
# restarts itself, so the unit never holds a boot open.
TimeoutStartSec = 60;
User = agentName;
Group = agentName;
RuntimeDirectory = runtimeDir;
RuntimeDirectoryMode = "0700";
# `glab` refuses a configuration that is not `0600`.
UMask = "0077";
LoadCredential = [ serverCaCredential ];
};
environment = {
BAO_ADDR = cfg.addr;
BAO_CLIENT_CERT = cfg.clientCertFile;
BAO_CLIENT_KEY = cfg.clientKeyFile;
};
script = ''
set -euo pipefail
# No identity delivered: ./bao.nix's check reports that.
for f in "$BAO_CLIENT_CERT" "$BAO_CLIENT_KEY"; do
if [ ! -s "$f" ]; then
echo "this agent has no store identity, so it cannot fetch its GitLab accounts." >&2
exit 0
fi
done
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
fi
err=${lib.escapeShellArg errFile}
raw=${lib.escapeShellArg rawFile}
list=${lib.escapeShellArg listFile}
one=${lib.escapeShellArg oneFile}
accounts=${lib.escapeShellArg accountsFile}
trap 'rm -f "$err" "$raw" "$list" "$one" "$accounts"' EXIT
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
echo "the swarm secret store at $BAO_ADDR did not accept this agent's certificate login:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
export BAO_TOKEN
# An empty directory is a 404, which `bao` answers with `{}` on stdout
# and nothing on stderr; a denial or an unreachable store prints
# nothing on stdout. Nothing linked is a list of no names, which
# empties the configuration.
if ! bao kv list -format=json ${lib.escapeShellArg accountsDir} >"$list" 2>"$err"; then
if [ ! -s "$err" ] && jq -e '. == {}' "$list" >/dev/null 2>&1; then
echo "no GitLab accounts are linked to this agent (nothing under ${accountsDir})."
echo '[]' >"$list"
else
echo "could not list ${accountsDir}:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
exit 1
fi
fi
if ! jq -e 'arrays' "$list" >/dev/null; then
echo "listing ${accountsDir} returned no array of names." >&2
exit 1
fi
# Moves $1 over $2 and succeeds when the bytes differ; drops $1 otherwise.
replace() {
if cmp -s "$1" "$2"; then
rm -f "$1"
return 1
fi
mv -f "$1" "$2"
}
# ⚠️ A token goes from the store's answer into a file; it is never in a
# variable or an argument. An account that holds no usable url is
# logged and left out. One that cannot be read fails the run after the
# others, with nothing replaced, so the files keep the last full set.
: >"$accounts"
unreadable=
while IFS= read -r label; do
# A key ending in `/` is a directory below this one, not an account.
if [[ ! "$label" =~ ^[a-z0-9-]+$ ]]; then
echo "skipping listed key $(printf '%q' "$label"): not a label." >&2
continue
fi
path="${accountsDir}/$label"
if ! bao kv get -format=json "$path" >"$raw" 2>"$err"; then
echo "could not read $path:" >&2
if [ -s "$err" ]; then cat "$err" >&2; fi
unreadable=1
continue
fi
if ! jq -c --arg label "$label" ${lib.escapeShellArg accountProgram} "$raw" >"$one" 2>/dev/null \
|| [ ! -s "$one" ]; then
echo "$path holds no string value and an http(s) url with a host name; left out." >&2
continue
fi
cat "$one" >>"$accounts"
done < <(jq -r '.[]' "$list")
if [ -n "$unreadable" ]; then
echo "not every account could be read; the glab configuration is left as it is." >&2
exit 1
fi
mkdir -p ${lib.escapeShellArg glabDir}
staged_config=${lib.escapeShellArg glabDir}/.config.yml.new
staged_gitconfig=${lib.escapeShellArg glabDir}/.gitconfig.new
rm -f "$staged_config" "$staged_gitconfig"
jq -s ${lib.escapeShellArg glabConfigProgram} "$accounts" >"$staged_config"
jq -s -r ${lib.escapeShellArg gitconfigProgram} "$accounts" >"$staged_gitconfig"
changed=
replace "$staged_config" ${lib.escapeShellArg glabDir}/config.yml && changed=1
replace "$staged_gitconfig" ${lib.escapeShellArg glabDir}/gitconfig && changed=1
if [ -n "$changed" ]; then
echo "wrote the glab configuration from ${accountsDir}."
fi
'';
};
# The same cadence as ./forge-accounts.nix.
systemd.timers.${unitName} = {
description = "re-fetch this agent's GitLab accounts from the secret store";
wantedBy = [ "timers.target" ];
timerConfig = {
OnUnitInactiveSec = "2min";
RandomizedDelaySec = "20s";
};
};
};
}

View file

@ -52,9 +52,9 @@
# `/etc/hyperhive-bridge-dns` (containing the gateway IP) since
# isolation is always on; the oneshot reads it and rewrites
# resolv.conf on every boot. Ordered before the first DNS consumers
# (the forge-token, bao-identity, github-token and forge-accounts
# oneshots) and the network targets so name resolution works for
# the very first turn.
# (the forge-token, bao-identity, github-token, forge-accounts and
# gitlab-accounts oneshots) and the network targets so name resolution
# works for the very first turn.
systemd.services.hyperhive-isolated-dns = {
description = "point resolv.conf at the hive bridge resolver (isolated containers)";
wantedBy = [ "multi-user.target" ];
@ -67,14 +67,15 @@
# first turn errors — self-heals next turn, but better not to flap).
# `hive-matrix-daemon` likewise syncs over the network; the `before`
# is a harmless no-op when matrix is disabled (the unit is absent);
# so are the bao-identity / github-token / forge-accounts edges on
# agents that don't enable those oneshots.
# so are the bao-identity / github-token / forge-accounts /
# gitlab-accounts edges on agents that don't enable those oneshots.
before = [
"network-online.target"
"hive-agent-forge-token.service"
"hive-agent-bao-identity.service"
"hive-agent-github-token.service"
"hive-agent-forge-accounts.service"
"hive-agent-gitlab-accounts.service"
"hive-agent.service"
"hive-matrix-daemon.service"
];

View file

@ -402,8 +402,8 @@ let
# The two `list` paths end in `/`: bao checks a LIST against the directory
# with a trailing slash, where `+` matches the empty last segment, so a
# slashless list path loses that request to the `+/matrix/+` and `+/forge/+`
# delete stanzas and the LIST is denied.
# slashless list path loses that request to the `+/matrix/+`, `+/forge/+`
# and `+/gitlab/+` delete stanzas and the LIST is denied.
controllerPolicyText = ''
path "auth/cert/certs/hive-*" {
capabilities = ["create", "update", "read", "delete"]
@ -472,6 +472,10 @@ let
capabilities = ["list"]
}
path "${credentialMountPath}/metadata/swarm/agents/+/gitlab/" {
capabilities = ["list"]
}
path "${credentialMountPath}/metadata/swarm/agents/+/matrix/+" {
capabilities = ["delete"]
}
@ -480,6 +484,10 @@ let
capabilities = ["delete"]
}
path "${credentialMountPath}/metadata/swarm/agents/+/gitlab/+" {
capabilities = ["delete"]
}
path "${credentialMountPath}/metadata/swarm/agents/+/github-token" {
capabilities = ["delete"]
}

View file

@ -48,6 +48,8 @@ let
fetchUnit = machine: machine.systemd.services.hive-agent-forge-token;
proxyUnit = machine: machine.systemd.services.hive-agent-proxy-credential;
accountsUnit = machine: machine.systemd.services.hive-agent-forge-accounts;
gitlabUnit = machine: machine.systemd.services.hive-agent-gitlab-accounts;
bootWait = import ./boot-wait.nix { inherit lib; };
tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile;
proxyEnvEntry = "-/run/hive-agent-proxy-credential/proxy.env";
@ -269,6 +271,108 @@ let
name = "the account fetch re-runs every two minutes";
ok = agentForgeBao.systemd.timers.hive-agent-forge-accounts.timerConfig.OnUnitInactiveSec == "2min";
}
{
# ../agent-modules/gitlab-accounts.nix. The switch is the store address,
# as for the forge accounts; `glab` and the credential helper come with it.
name = "an agent with a store address fetches its GitLab accounts and has glab, and one without has neither";
ok =
let
named = n: machine: lib.any (p: (p.name or "") == n) machine.environment.systemPackages;
in
agentForgeBao.systemd.services ? hive-agent-gitlab-accounts
&& agentForgeBao.systemd.timers ? hive-agent-gitlab-accounts
&& named "glab" agentForgeBao
&& named "git-credential-hive-gitlab" agentForgeBao
&& !(agentForgeNoBao.systemd.services ? hive-agent-gitlab-accounts)
&& !(agentForgeNoBao.systemd.timers ? hive-agent-gitlab-accounts)
&& !(named "glab" agentForgeNoBao)
&& !(named "git-credential-hive-gitlab" agentForgeNoBao);
}
{
# The real `glab` binary is only reachable through the wrapper that
# points it at the generated configuration, and the nix half of
# `swarm_secret_client::gitlab::{accounts_dir,account_path}` reads a
# directory of its own, never the forge accounts'.
name = "the GitLab fetch lists the agent's own gitlab directory, and glab is only the wrapper";
ok =
let
name = agentForgeBao.services.hyperhive.agent.user.name;
s = (gitlabUnit agentForgeBao).script;
in
lib.hasInfix "bao kv list -format=json secret/swarm/agents/${name}/gitlab >" s
&& lib.hasInfix "path=\"secret/swarm/agents/${name}/gitlab/$label\"" s
&& !(lib.hasInfix "agents/${name}/forge" s)
&& !(lib.elem pkgs.glab agentForgeBao.environment.systemPackages);
}
{
# glab's own multi-host schema: `hosts.<host>.token` beside the global
# keys, written to `<state>/glab/config.yml`; and the include that makes
# git read the per-host credential blocks.
name = "the GitLab fetch writes glab's hosts-keyed config and git's per-host credential blocks";
ok =
let
name = agentForgeBao.services.hyperhive.agent.user.name;
s = (gitlabUnit agentForgeBao).script;
in
# The pattern `swarm_secret_client::gitlab::URL_PATTERN` states, which
# the controller applies before it stores an account.
lib.hasInfix "test(\"^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$\")" s
&& lib.hasInfix "token: .value, api_protocol: .scheme" s
&& lib.hasInfix "hosts: (" s
&& lib.hasInfix "git_protocol: \"https\"" s
&& lib.hasInfix "/agents/${name}/state/glab/config.yml" s
&& lib.hasInfix "/agents/${name}/state/glab/gitconfig" s
&& lib.hasInfix "[credential \\\"\\(.scheme)://\\(.host)\\\"]" s
&&
lib.hasInfix "path = /agents/${name}/state/glab/gitconfig"
agentForgeBao.environment.etc."gitconfig".text;
}
{
# glab refuses a config that is not `0600`, and the agent owns its state
# dir. The tokens only pass through the unit's own `0700` directory.
name = "the GitLab fetch runs as the agent with its own store identity, and keeps tokens out of variables";
ok =
let
u = gitlabUnit agentForgeBao;
name = agentForgeBao.services.hyperhive.agent.user.name;
in
u.serviceConfig.User == name
&& u.serviceConfig.UMask == "0077"
&& u.serviceConfig.RuntimeDirectoryMode == "0700"
&& u.environment.BAO_CLIENT_CERT == "/run/hive-agent-bao-identity/current/cert"
&& !(lib.hasInfix "echo \"$(bao" u.script)
&& !(lib.hasInfix "token=\"$(" u.script);
}
{
# The boot-wait rule: the fetch states a finite budget below the 90 s
# manager default, fails fast, and retries as a fresh start job, so it
# never holds a boot open. The control: a limit at the stated budget
# does flag it, so the empty answer below is not a walk that misses it.
name = "the GitLab fetch states a short budget, retries with a backoff, and stays out of a boot wait over it";
ok =
let
u = gitlabUnit agentForgeBao;
reaching =
limit:
map (r: r.key) (
bootWait.overBudget {
guest = agentForgeBao;
inherit limit;
}
);
in
u.serviceConfig.TimeoutStartSec == 60
&& u.serviceConfig.Restart == "on-failure"
&& u.serviceConfig.RestartSec == 30
&& u.startLimitBurst == 2880
&& !(lib.elem "hive-agent-gitlab-accounts.service" (reaching 61))
&& lib.elem "hive-agent-gitlab-accounts.service" (reaching 60);
}
{
name = "the GitLab fetch re-runs every two minutes";
ok =
agentForgeBao.systemd.timers.hive-agent-gitlab-accounts.timerConfig.OnUnitInactiveSec == "2min";
}
{
# ../agent-modules/egress-proxy.nix. Presence arm: the unit and its timer
# exist, and the agent is told nothing until a credential is fetched.

View file

@ -2379,15 +2379,15 @@ let
&& !(lib.hasInfix "secret/metadata/*" s);
}
{
# `linked_accounts` names an agent's matrix accounts and forge labels by
# listing their two directories. bao checks a LIST against the directory
# `linked_accounts` names an agent's matrix accounts, forge labels and GitLab labels by
# listing their three directories. bao checks a LIST against the directory
# with a trailing slash, so each stanza names that slashed path and
# reaches that one directory: not the agent's other keys, not `agents/`
# itself, not anything below. Pinned as whole
# stanzas, and as the only metadata stanzas under `agents/` beside the
# queue revocation and the account deletes, so a widened path or an added
# capability fails.
name = "the controller may list each agent's matrix and forge accounts, and nothing else under agents";
name = "the controller may list each agent's matrix, forge and gitlab accounts, and nothing else under agents";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-controller-policy.script;
@ -2395,11 +2395,12 @@ let
in
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/\" {\n capabilities = [\"list\"]\n}" s
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/forge/\" {\n capabilities = [\"list\"]\n}" s
&& stanzas == 7;
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/gitlab/\" {\n capabilities = [\"list\"]\n}" s
&& stanzas == 9;
}
{
# `linked_accounts` deletes one matrix account, forge account, GitHub
# token or backend environment through `delete_all_versions`, which
# `linked_accounts` deletes one matrix account, forge account, GitLab
# account, GitHub token or backend environment through `delete_all_versions`, which
# addresses its metadata path.
# A trailing `+` is one segment, so each stanza reaches the accounts
# directly in that directory and nothing deeper. Pinned as whole stanzas,
@ -2411,6 +2412,7 @@ let
in
lib.hasInfix "path \"secret/metadata/swarm/agents/+/matrix/+\" {\n capabilities = [\"delete\"]\n}" s
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/forge/+\" {\n capabilities = [\"delete\"]\n}" s
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/gitlab/+\" {\n capabilities = [\"delete\"]\n}" s
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/github-token\" {\n capabilities = [\"delete\"]\n}" s
&& lib.hasInfix "path \"secret/metadata/swarm/agents/+/backend-env\" {\n capabilities = [\"delete\"]\n}" s;
}

View file

@ -32,10 +32,10 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
- **Agent credentials** — at start and every five minutes it re-checks every
agent's forge token and matrix account, and renews store certificates and
queue secrets as they age.
- **Linked external accounts** — an operator-supplied matrix, forge or
- **Linked external accounts** — an operator-supplied matrix, forge, GitLab or
GitHub account, or backend environment, for one agent, stored in the swarm secret store
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
`.../forge-accounts/{label}`, `.../github-account`, `.../backend-env`, whose save replaces what is stored); distinct from the agent's own swarm-minted
`.../forge-accounts/{label}`, `.../gitlab-accounts/{label}`, `.../github-account`, `.../backend-env`, whose save replaces what is stored); distinct from the agent's own swarm-minted
accounts above. `GET .../linked-accounts` lists them, plus the agent's own
`main` matrix account, by kind, name and host, never with a credential.
Each of the four also takes a `DELETE`; matrix's also takes

View file

@ -0,0 +1,322 @@
//! An agent's accounts on GitLab instances: an operator hands us a base URL and
//! a token, we put them in the swarm's secret store.
//!
//! The agent end is `nix/agent-modules/gitlab-accounts.nix`, which lists the
//! agent's accounts and reads each under the agent's own certificate, and writes
//! the `glab` configuration. No hive is in the path.
use axum::Json;
use axum::extract::State;
use axum::http::StatusCode;
use serde::{Deserialize, Serialize};
use swarm_secret_client::gitlab;
use utoipa::ToSchema;
use super::linked_accounts::link;
use crate::backend_egress::host_of;
use crate::routes::agents::swarm_hive;
use crate::routes::{AppState, error_problem};
/// The account to store for one agent's GitLab instance.
///
/// No `Debug` derive: this carries a token.
#[derive(Deserialize, ToSchema)]
pub struct PutGitlabAccountRequest {
/// The instance's base URL, `http://` or `https://`, with a DNS name as its
/// host. A trailing slash is dropped.
#[schema(example = "https://gitlab.example.org")]
url: String,
/// The access token, scoped `api` and `write_repository`. Never logged, and
/// never returned by this route.
token: String,
}
/// `put_gitlab_account`'s success body.
#[derive(Debug, Serialize, ToSchema)]
pub struct PutGitlabAccountResponse {
/// The base URL as stored.
url: String,
}
/// Store an agent's GitLab account, unless one is stored under the label
/// already.
#[utoipa::path(
put,
path = "/api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("label" = String, Path, description = "the account's name"),
),
request_body = PutGitlabAccountRequest,
responses(
(status = 200, description = "stored", body = PutGitlabAccountResponse),
(status = 400, description = "the agent or label is not an identifier, the URL is not http(s) with a DNS name as its host, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
(status = 409, description = "an account is stored under the label already; nothing was written (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn put_gitlab_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
Json(req): Json<PutGitlabAccountRequest>,
) -> Result<Json<PutGitlabAccountResponse>, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let secret_path = gitlab::account_path(&agent, &label)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let account = account(req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e))?;
let store = state.accounts.open().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
link(&store, &secret_path, &account).await.map_err(|e| {
// The path names the agent and the label; the value is not in it.
tracing::warn!(path = %secret_path, error = ?e, "linking the gitlab account failed");
e.problem(&existing(&agent, &label))
})?;
crate::link_egress::added(
&state,
&agent,
&crate::link_egress::Link::Gitlab {
label: &label,
url: &account.url,
},
)
.await?;
tracing::info!(%hive, %agent, %label, url = %account.url, "gitlab account stored");
Ok(Json(PutGitlabAccountResponse { url: account.url }))
}
/// The account a refused link names.
fn existing(agent: &str, label: &str) -> String {
format!("agent {agent} already has gitlab account {label:?}")
}
/// The request as it is stored, or why it cannot be.
///
/// The URL must match [`gitlab::URL_PATTERN`], the rule the agent end filters by,
/// so a stored account is never one the agent drops. The host must also be a
/// name the egress allow list takes: the agent reaches the instance through its
/// hive's proxy, so an address or `localhost` would be stored and never reachable.
fn account(req: PutGitlabAccountRequest) -> Result<gitlab::Account, String> {
let url = req.url.trim().trim_end_matches('/');
if !(url.starts_with("http://") || url.starts_with("https://")) {
return Err("url must start with http:// or https://".to_owned());
}
let authority = url
.split_once("://")
.and_then(|(_, rest)| rest.split(['/', '?', '#']).next())
.unwrap_or_default();
if authority.contains('@') {
return Err("url must not carry user information; the token is its own field".to_owned());
}
if !gitlab::is_instance_url(url) {
return Err(format!(
"url must match {}: http(s), a host name, an optional port, and an optional path of \
letters, digits and . _ ~ / - (no query, fragment, '@' or spaces)",
gitlab::URL_PATTERN
));
}
let host = host_of(url).ok_or("url has no host")?;
swarm_wire::egress_allow::normalize_domain(host).map_err(|why| format!("url host: {why}"))?;
if req.token.trim().is_empty() {
return Err("token is required".to_owned());
}
Ok(gitlab::Account {
value: req.token,
url: url.to_owned(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use super::super::linked_accounts::{Accounts, tests::FakeStore};
use super::{PutGitlabAccountRequest, account};
fn request(url: &str, token: &str) -> PutGitlabAccountRequest {
PutGitlabAccountRequest {
url: url.to_owned(),
token: token.to_owned(),
}
}
#[test]
fn the_url_loses_its_trailing_slash_and_the_token_is_kept() {
let a = account(request("https://gitlab.example.org/ ", "t0k3n")).expect("valid");
assert_eq!(a.url, "https://gitlab.example.org");
assert_eq!(a.value, "t0k3n");
// A port and a subfolder are part of the instance's address.
let a = account(request("https://gitlab.example.org:8443/gl/", "t")).expect("valid");
assert_eq!(a.url, "https://gitlab.example.org:8443/gl");
}
#[test]
fn a_url_that_is_not_http_is_refused() {
assert!(account(request("gitlab.example.org", "t")).is_err());
assert!(account(request("file:///etc/passwd", "t")).is_err());
// The control: plain http with a name is accepted.
assert!(account(request("http://gitlab.lan", "t")).is_ok());
}
#[test]
fn a_host_the_egress_list_would_refuse_is_refused() {
for url in [
"http://10.0.0.5",
"https://10.0.0.5:8443",
"http://[::1]",
"https://localhost",
"https://gitlab",
"https://*.example.org",
"https://",
] {
assert!(account(request(url, "t")).is_err(), "{url}");
}
}
#[test]
fn user_information_in_the_url_is_refused() {
assert!(account(request("https://oauth2:t0k3n@gitlab.example.org", "t")).is_err());
// A path character the agent end drops is refused here too.
for url in [
"https://gitlab.example.org/@x",
"https://gitlab.example.org/?x=1",
"https://gitlab.example.org/#f",
"https://gitlab.example.org/a b",
] {
assert!(account(request(url, "t")).is_err(), "{url}");
}
}
#[test]
fn an_empty_token_is_refused() {
assert!(account(request("https://gitlab.example.org", " ")).is_err());
}
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
fn state(store: &Arc<FakeStore>) -> crate::routes::AppState {
crate::routes::AppState {
hives: std::sync::Arc::new(vec![crate::routes::hives::HiveEntry {
name: "pr1ma".to_owned(),
domain: "pr1ma.example".to_owned(),
dashboard: "pr1ma.hives.example".to_owned(),
}]),
links: std::sync::Arc::new(Vec::new()),
status: None,
wanted: None,
agent_status: None,
agent_icons: None,
agents_md: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),
))),
webhook_secret: None,
config_prs: std::sync::Arc::default(),
swarm_name: None,
auth: None,
forge: std::sync::Arc::default(),
create_gate: std::sync::Arc::default(),
accounts: Accounts::Fake(Arc::clone(store)),
try_outs: super::super::review_classifier::TryOuts::default(),
reviews: std::sync::Arc::default(),
runs: crate::review_classifier::runs::Runs::Live(None),
builds: crate::builder::runs::Builds::Live(None),
alerts: None,
}
}
async fn put(
store: &Arc<FakeStore>,
agent: &str,
label: &str,
url: &str,
) -> Result<axum::Json<super::PutGitlabAccountResponse>, problem_details::ProblemDetails> {
super::put_gitlab_account(
axum::extract::State(state(store)),
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned(), label.to_owned())),
axum::Json(request(url, "t0k3n")),
)
.await
}
#[tokio::test]
async fn a_plain_account_reaches_the_store_under_the_agents_gitlab_directory() {
let store = Arc::default();
let stored = put(&store, "atlas", "work", "https://gitlab.example.org")
.await
.expect("nothing is stored");
assert_eq!(stored.url, "https://gitlab.example.org");
assert_eq!(store.written(), ["swarm/agents/atlas/gitlab/work"]);
}
/// Refused before the store, whatever the part: an address, a label or an
/// agent that is not an identifier.
#[tokio::test]
async fn a_request_the_swarm_would_refuse_is_a_400_and_writes_nothing() {
let store = Arc::default();
for (agent, label, url) in [
("atlas", "work", "http://10.0.0.5:8080"),
("atlas", "work", "https://localhost"),
("atlas", "work", "https://gitlab.example.org/@x"),
("atlas", "work", "https://gitlab.example.org/?x=1"),
("atlas", "My_Label", "https://gitlab.example.org"),
("atlas", "../x", "https://gitlab.example.org"),
("../x", "work", "https://gitlab.example.org"),
] {
let problem = put(&store, agent, label, url).await.expect_err("refused");
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{agent} {label} {url}: {problem:?}"
);
}
assert!(store.written().is_empty());
}
#[tokio::test]
async fn linking_a_label_twice_is_a_409_and_the_first_account_stays() {
use super::super::linked_accounts::{AccountStore, link};
use swarm_secret_client::gitlab;
let store = FakeStore::default();
let path = gitlab::account_path("atlas", "work").expect("a valid path");
let first = account(request("https://gitlab.example.org", "t0k3n-first")).expect("valid");
let second = account(request("https://gitlab.lan", "t0k3n-second")).expect("valid");
link(&store, &path, &first)
.await
.expect("nothing is stored");
let problem = link(&store, &path, &second)
.await
.expect_err("an account is stored")
.problem(&super::existing("atlas", "work"));
assert_eq!(problem.status, Some(axum::http::StatusCode::CONFLICT));
let detail = problem.detail.expect("a detail");
assert!(
detail.contains(r#"agent atlas already has gitlab account "work""#),
"{detail}"
);
assert_eq!(store.written(), std::slice::from_ref(&path));
let kept: gitlab::Account = store
.read_optional(&path)
.await
.expect("store answers")
.expect("still stored");
assert_eq!(kept.url, "https://gitlab.example.org");
assert_eq!(kept.value, "t0k3n-first");
}
}

View file

@ -1,14 +1,16 @@
//! The hosts an agent's linked accounts reach, kept as derived entries of the
//! egress allow list (`crate::egress_allow`), so a restricted agent with a
//! linked GitHub, external matrix or external forge account reaches that
//! service through its hive's proxy.
//! linked GitHub, external matrix, external forge or GitLab account reaches
//! that service through its hive's proxy.
//!
//! An entry is marked `derived` and carries the account as its `source`
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`), so the
//! backend environment's entries (`crate::backend_egress`) and each account's
//! are replaced and dropped independently. A hive renders it like any other.
//! (`link:github`, `link:matrix:<account>`, `link:forge:<label>`,
//! `link:gitlab:<label>`), so
//! the backend environment's entries (`crate::backend_egress`) and each
//! account's are replaced and dropped independently. A hive renders it like
//! any other.
//!
//! The controller alone writes the three leaves the entries come from; the
//! The controller alone writes the four leaves the entries come from; the
//! agent's store policy grants it `create` on its own `backend-env` leaf and
//! nothing else (`swarm-secret-client/src/policy.rs`), so what these leaves
//! hold is the operator's choice, unlike the backend environment's.
@ -55,6 +57,10 @@ pub(crate) enum Link<'a> {
label: &'a str,
url: &'a str,
},
Gitlab {
label: &'a str,
url: &'a str,
},
}
pub(crate) fn github_source() -> String {
@ -69,12 +75,17 @@ pub(crate) fn forge_source(label: &str) -> String {
format!("{LINK_SOURCE_PREFIX}forge:{label}")
}
pub(crate) fn gitlab_source(label: &str) -> String {
format!("{LINK_SOURCE_PREFIX}gitlab:{label}")
}
impl Link<'_> {
fn source(&self) -> String {
match self {
Self::Github => github_source(),
Self::Matrix { account, .. } => matrix_source(account),
Self::Forge { label, .. } => forge_source(label),
Self::Gitlab { label, .. } => gitlab_source(label),
}
}
@ -87,7 +98,7 @@ impl Link<'_> {
let hosts: Vec<Option<&str>> = match self {
Self::Github => GITHUB_HOSTS.iter().map(|h| Some(*h)).collect(),
Self::Matrix { homeserver, .. } => homeserver.iter().map(|u| host_of(u)).collect(),
Self::Forge { url, .. } => vec![host_of(url)],
Self::Forge { url, .. } | Self::Gitlab { url, .. } => vec![host_of(url)],
};
let mut entries = Vec::new();
for host in hosts {
@ -221,6 +232,13 @@ async fn wanted_entries(
},
None => continue,
},
AccountKind::Gitlab => match account.host.as_deref() {
Some(url) => Link::Gitlab {
label: &account.name,
url,
},
None => continue,
},
AccountKind::Matrix | AccountKind::Backend => continue,
};
entries.extend(link.entries(agent, base));
@ -370,6 +388,18 @@ mod tests {
forge.entries("atlas", &base()),
[entry("forge.example.org", "atlas", Some("link:forge:oss"))]
);
let gitlab = Link::Gitlab {
label: "work",
url: "https://GitLab.Example.org:8443/sub",
};
assert_eq!(
gitlab.entries("atlas", &base()),
[entry(
"gitlab.example.org",
"atlas",
Some("link:gitlab:work")
)]
);
}
#[test]
@ -385,6 +415,8 @@ mod tests {
] {
let forge = Link::Forge { label: "x", url };
assert_eq!(hosts(&forge), Vec::<String>::new(), "{url:?}");
let gitlab = Link::Gitlab { label: "x", url };
assert_eq!(hosts(&gitlab), Vec::<String>::new(), "{url:?}");
}
// Controls: a public name gets one, and a matrix account with no
// homeserver has none to derive.
@ -393,6 +425,11 @@ mod tests {
url: "https://forge.example.org",
};
assert_eq!(hosts(&forge), ["forge.example.org"]);
let gitlab = Link::Gitlab {
label: "x",
url: "https://gitlab.example.org",
};
assert_eq!(hosts(&gitlab), ["gitlab.example.org"]);
let matrix = Link::Matrix {
account: "work",
homeserver: None,
@ -475,6 +512,22 @@ mod tests {
Path(("pr1ma".to_owned(), parts.to_owned()))
}
/// Link `atlas`'s GitLab account `work` through the route.
async fn link_gitlab(state: &AppState) {
let _ = crate::gitlab_account::put_gitlab_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
axum::Json(
serde_json::from_value(
json!({"url": "https://gitlab.example.org:8443/", "token": "t"}),
)
.expect("a body"),
),
)
.await
.expect("linked");
}
#[tokio::test]
async fn linking_and_unlinking_through_the_routes_moves_the_entries() {
let nats = TestNats::start();
@ -501,6 +554,7 @@ mod tests {
)
.await
.expect("linked");
link_gitlab(&state).await;
let _ = crate::matrix_account::put_matrix_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
@ -514,9 +568,10 @@ mod tests {
.await
.expect("linked");
let forge = entry("forge.example.org", "atlas", Some("link:forge:oss"));
let gitlab = entry("gitlab.example.org", "atlas", Some("link:gitlab:work"));
let matrix = entry("matrix.example.org", "atlas", Some("link:matrix:work"));
let mut all = github_entries("atlas");
all.extend([forge.clone(), matrix.clone()]);
all.extend([forge.clone(), gitlab.clone(), matrix.clone()]);
assert_eq!(list_of(&client).await, sorted(all));
// The backend environment's own save and delete leave them alone.
@ -535,14 +590,14 @@ mod tests {
crate::linked_accounts::delete_backend_env(State(state.clone()), path("atlas"))
.await
.expect("deleted");
assert_eq!(list_of(&client).await.len(), 2 + GITHUB_HOSTS.len());
assert_eq!(list_of(&client).await.len(), 3 + GITHUB_HOSTS.len());
crate::linked_accounts::delete_github_account(State(state.clone()), path("atlas"))
.await
.expect("deleted");
assert_eq!(
list_of(&client).await,
sorted(vec![forge.clone(), matrix.clone()])
sorted(vec![forge.clone(), gitlab.clone(), matrix.clone()])
);
crate::linked_accounts::delete_forge_account(
State(state.clone()),
@ -550,6 +605,16 @@ mod tests {
)
.await
.expect("deleted");
assert_eq!(
list_of(&client).await,
sorted(vec![gitlab.clone(), matrix.clone()])
);
crate::linked_accounts::delete_gitlab_account(
State(state.clone()),
Path(("pr1ma".to_owned(), "atlas".to_owned(), "work".to_owned())),
)
.await
.expect("deleted");
assert_eq!(list_of(&client).await, [matrix]);
crate::linked_accounts::delete_matrix_account(
State(state.clone()),
@ -571,6 +636,10 @@ mod tests {
"swarm/agents/atlas/matrix/work",
json!({"value": "t", "homeserver": "https://matrix.example.org"}),
);
let store = store.with(
"swarm/agents/atlas/gitlab/work",
json!({"value": "t", "url": "https://gitlab.example.org:8443"}),
);
// The agent's own account has a homeserver of its own and no entry.
let store = store.with(
"swarm/agents/atlas/matrix/main",
@ -583,6 +652,7 @@ mod tests {
// An orphan: iris has no leaf for it.
entry("github.com", "iris", Some("link:github")),
// An orphan on a live agent whose forge leaf is gone.
entry("gitlab.example.org", "atlas", Some("link:gitlab:old")),
entry("forge.example.org", "atlas", Some("link:forge:old")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("crates.io", "atlas", None),
@ -596,6 +666,7 @@ mod tests {
assert_eq!(reconcile(&client, &store, &live).await.expect("pass"), 0);
let mut want = github_entries("atlas");
want.extend([
entry("gitlab.example.org", "atlas", Some("link:gitlab:work")),
entry("matrix.example.org", "atlas", Some("link:matrix:work")),
entry("llm.example.com", "atlas", Some("backend-env")),
entry("crates.io", "atlas", None),
@ -607,6 +678,38 @@ mod tests {
assert_eq!(list_of(&client).await, sorted(want));
}
/// The host of a GitLab entry is the controller-written leaf's `url`. The
/// one object an agent can create in the store is its `backend-env` leaf,
/// and a GitLab host named there yields no `link:gitlab` entry.
#[tokio::test]
async fn a_gitlab_entry_comes_from_the_controller_leaf_and_nothing_an_agent_writes() {
let agent_written = FakeStore::default().with(
"swarm/agents/atlas/backend-env",
json!({"env": "GITLAB_HOST=https://evil.example.com\nANTHROPIC_BASE_URL=https://evil.example.com\n"}),
);
let got = wanted_entries(&agent_written, "atlas", &base())
.await
.expect("store answers");
assert_eq!(got, []);
// The control: the controller's leaf yields the entry.
let leaf = agent_written.with(
"swarm/agents/atlas/gitlab/work",
json!({"value": "t", "url": "https://gitlab.example.org:8443/sub"}),
);
let got = wanted_entries(&leaf, "atlas", &base())
.await
.expect("store answers");
assert_eq!(
got,
[entry(
"gitlab.example.org",
"atlas",
Some("link:gitlab:work")
)]
);
}
#[tokio::test]
async fn a_store_error_prunes_nothing_and_adds_nothing() {
let nats = TestNats::start();

View file

@ -1,28 +1,31 @@
//! The accounts linked to one agent, as kind, name and host: what the swarm
//! UI's agent panel lists, and deletes.
//!
//! [`link`] is the write the three link routes share: it refuses a path that
//! [`link`] is the write the four link routes share: it refuses a path that
//! already holds an account, so replacing one takes a delete first. The
//! backend environment route is the exception: it overwrites.
//!
//! Read from the paths [`crate::matrix_account`], [`crate::forge_account`],
//! [`crate::github_account`] and [`crate::backend_env`] write, plus the
//! agent's own `main` matrix account, which `matrix_account::agent_token`
//! mints into the same directory. Each entry is read for its host and nothing
//! else leaves this module: [`LinkedAccount`] has no field a credential could
//! land in. A backend environment is listed as set, with no host.
//! [`crate::gitlab_account`], [`crate::github_account`] and
//! [`crate::backend_env`] write, plus the agent's own `main` matrix account,
//! which `matrix_account::agent_token` mints into the same directory. Each
//! entry is read for its host and nothing else leaves this module:
//! [`LinkedAccount`] has no field a credential could land in. A backend
//! environment is listed as set, with no host.
//!
//! Naming the matrix accounts and forge labels takes `list` on each agent's
//! `matrix` and `forge` metadata directories, and deleting an account takes
//! `delete` on its metadata path. `controllerPolicyText` in
//! `nix/host-modules/swarm-bao.nix` grants both on those paths alone.
//! Naming the matrix accounts, forge labels and GitLab labels takes `list` on
//! each agent's `matrix`, `forge` and `gitlab` metadata directories, and
//! deleting an account takes `delete` on its metadata path.
//! `controllerPolicyText` in `nix/host-modules/swarm-bao.nix` grants both on
//! those paths alone.
//!
//! A delete removes the store entry only, and the agent is not told. Its
//! GitHub and forge token files stay in place, since the units that write them
//! never delete one; its matrix daemon drops the account when it next re-lists.
//! A deleted backend environment leaves the agent's copy in `/run` until its
//! next boot, and an agent with a non-empty `backendEnvironmentFile` creates
//! the leaf again from that file.
//! never delete one; its matrix daemon drops the account when it next re-lists,
//! and its GitLab fetch drops the account from the `glab` configuration when it
//! next runs. A deleted backend environment leaves the agent's copy in `/run`
//! until its next boot, and an agent with a non-empty `backendEnvironmentFile`
//! creates the leaf again from that file.
use std::future::Future;
@ -31,7 +34,7 @@ use axum::extract::State;
use axum::http::StatusCode;
use serde::de::{DeserializeOwned, IgnoredAny};
use serde::{Deserialize, Serialize};
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, matrix};
use swarm_secret_client::{Error, SecretStore, backend_env, forge, github, gitlab, matrix};
use utoipa::{IntoParams, ToSchema};
use crate::routes::agents::swarm_hive;
@ -47,6 +50,7 @@ const GITHUB_HOST: &str = "github.com";
pub enum AccountKind {
Matrix,
Forgejo,
Gitlab,
Github,
Backend,
}
@ -55,10 +59,11 @@ pub enum AccountKind {
#[derive(Clone, Debug, PartialEq, Eq, Serialize, ToSchema)]
pub struct LinkedAccount {
pub(crate) kind: AccountKind,
/// The matrix account name, the forge label, `github`, or `backend-env`.
/// The matrix account name, the forge or GitLab label, `github`, or
/// `backend-env`.
#[schema(example = "main")]
pub(crate) name: String,
/// The matrix homeserver, the forge base URL, or `github.com`. `None` for a
/// The matrix homeserver, the forge or GitLab base URL, or `github.com`. `None` for a
/// matrix account stored without a homeserver.
#[schema(example = "https://matrix.example.org")]
pub(crate) host: Option<String>,
@ -190,7 +195,7 @@ async fn objects(store: &impl AccountStore, dir: &str) -> Result<Vec<String>, Er
Ok(keys.into_iter().filter(|k| !k.ends_with('/')).collect())
}
/// Every account the store holds for `agent`: matrix, then forgejo, then
/// Every account the store holds for `agent`: matrix, then forgejo, then gitlab, then
/// github, then the backend environment.
///
/// A missing directory lists nothing, and a listed name whose object is gone
@ -230,6 +235,18 @@ pub(crate) async fn linked_accounts(
}
}
for label in objects(store, &gitlab::accounts_dir(agent)?).await? {
let path = gitlab::account_path(agent, &label)?;
if let Some(a) = store.read_optional::<gitlab::Account>(&path).await? {
out.push(LinkedAccount {
kind: AccountKind::Gitlab,
name: label,
host: Some(a.url),
reserved: false,
});
}
}
let path = github::account_path(agent)?;
if store
.read_optional::<github::Credential>(&path)
@ -413,7 +430,7 @@ where
remove(store, path).await
}
/// Delete the forge account or GitHub token at `path`.
/// Delete the forge account, GitLab account or GitHub token at `path`.
async fn remove_plain<T: DeserializeOwned + Send>(
store: &impl AccountStore,
path: &str,
@ -544,6 +561,53 @@ pub async fn delete_forge_account(
Ok(StatusCode::NO_CONTENT)
}
/// Delete an agent's GitLab account from the store, every version of it.
#[utoipa::path(
delete,
path = "/api/hives/{hive}/agents/{agent}/gitlab-accounts/{label}",
params(
("hive" = String, Path, description = "hive the agent runs on"),
("agent" = String, Path, description = "agent the account belongs to"),
("label" = String, Path, description = "the account's label"),
),
responses(
(status = 204, description = "deleted"),
(status = 400, description = "the agent or label is not an identifier, or the hive is not in this swarm (problem+json)", body = String),
(status = 404, description = "no such account is stored (problem+json)", body = String),
(status = 500, description = "the store could not be read or written (problem+json)", body = String),
),
tag = "agents"
)]
pub async fn delete_gitlab_account(
State(state): State<AppState>,
axum::extract::Path((hive, agent, label)): axum::extract::Path<(String, String, String)>,
) -> Result<StatusCode, problem_details::ProblemDetails> {
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
let agent = hive_types::Ident::parse(&agent)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let label = hive_types::Ident::parse(&label)
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
.into_string();
let path = gitlab::account_path(&agent, &label)
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
let store = state.accounts.open().await.map_err(|e| {
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
})?;
remove_plain::<gitlab::Account>(&store, &path)
.await
.map_err(|e| {
tracing::warn!(%hive, %agent, %label, error = ?e, "deleting the gitlab account failed");
e.problem()
})?;
crate::link_egress::removed(&state, &agent, &crate::link_egress::gitlab_source(&label)).await?;
tracing::info!(%hive, %agent, %label, "gitlab account deleted");
Ok(StatusCode::NO_CONTENT)
}
/// Delete an agent's GitHub token from the store, every version of it.
#[utoipa::path(
delete,
@ -645,7 +709,7 @@ pub(crate) mod tests {
AccountKind, AccountStore, LinkedAccount, Removal, linked_accounts, remove_matrix,
remove_plain,
};
use swarm_secret_client::{forge, github};
use swarm_secret_client::{forge, github, gitlab};
/// Objects by path. A list answers the next segment of every path under
/// the directory, with a trailing `/` when it goes deeper, as the store
@ -773,6 +837,10 @@ pub(crate) mod tests {
"swarm/agents/atlas/forge/myforge",
json!({"value": "t0k3n-forge", "url": "https://forge.example.org"}),
)
.with(
"swarm/agents/atlas/gitlab/work",
json!({"value": "t0k3n-gl", "url": "https://gitlab.example.org"}),
)
.with(
"swarm/agents/atlas/github-token",
json!({"value": "t0k3n-gh"}),
@ -820,6 +888,12 @@ pub(crate) mod tests {
Some("https://forge.example.org"),
false
),
row(
AccountKind::Gitlab,
"work",
Some("https://gitlab.example.org"),
false
),
row(AccountKind::Github, "github", Some("github.com"), false),
row(AccountKind::Backend, "backend-env", None, false),
]
@ -887,6 +961,9 @@ pub(crate) mod tests {
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge")
.await
.expect("stored");
remove_plain::<gitlab::Account>(&store, "swarm/agents/atlas/gitlab/work")
.await
.expect("stored");
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token")
.await
.expect("stored");
@ -898,6 +975,7 @@ pub(crate) mod tests {
[
"swarm/agents/atlas/matrix/catgirl",
"swarm/agents/atlas/forge/myforge",
"swarm/agents/atlas/gitlab/work",
"swarm/agents/atlas/github-token",
"swarm/agents/atlas/backend-env",
]
@ -915,6 +993,7 @@ pub(crate) mod tests {
)
.await,
remove_plain::<forge::Account>(&store, "swarm/agents/atlas/forge/myforge").await,
remove_plain::<gitlab::Account>(&store, "swarm/agents/atlas/gitlab/work").await,
remove_plain::<github::Credential>(&store, "swarm/agents/atlas/github-token").await,
remove_plain::<IgnoredAny>(&store, "swarm/agents/atlas/backend-env").await,
];

View file

@ -52,6 +52,7 @@ mod forge;
mod forge_account;
mod forge_events;
mod github_account;
mod gitlab_account;
mod held_deploys;
mod inference;
mod issue_report;

View file

@ -31,9 +31,9 @@ use crate::{
agent_control, agent_custom, agent_icon, agent_input, agent_limits, agent_login,
agent_permissions, agent_renewal, agent_state_stream, agent_status, agents_md, alerts,
archetypes, auth, backend_env, builder, claude_login, config_pr, egress_allow, forge,
forge_account, github_account, inference, issue_report, linked_accounts, matrix_account,
message_stream, notify_flows, notify_router_api, pulls, queue_identity, review_classifier,
schedules, status, subagent_term, term_stream, wanted, webhook,
forge_account, github_account, gitlab_account, inference, issue_report, linked_accounts,
matrix_account, message_stream, notify_flows, notify_router_api, pulls, queue_identity,
review_classifier, schedules, status, subagent_term, term_stream, wanted, webhook,
};
/// Where the daemon binds, overridable via `SWARM_CONTROLLER_SOCKET`.
@ -587,6 +587,10 @@ fn linked_account_routes(router: OpenApiRouter<AppState>) -> OpenApiRouter<AppSt
forge_account::put_forge_account,
linked_accounts::delete_forge_account
))
.routes(routes!(
gitlab_account::put_gitlab_account,
linked_accounts::delete_gitlab_account
))
.routes(routes!(
github_account::put_github_account,
linked_accounts::delete_github_account

View file

@ -0,0 +1,260 @@
//! The GitLab agreement: where an agent's accounts on GitLab instances live in
//! the store, and what the objects at those paths hold.
//!
//! An operator hands `swarm-controller` a base URL and a token, the controller
//! stores them at [`account_path`], and `nix/agent-modules/gitlab-accounts.nix`
//! lists [`accounts_dir`] and reads each account under the agent's own
//! certificate. The directory is named apart from [`crate::forge`]'s `forge/`,
//! so a label of one kind is never read as the other.
use serde::{Deserialize, Serialize};
use crate::{
Error,
path::{Kind, checked_segment, principal_prefix},
};
/// The path holding `agent`'s account on the GitLab instance it calls `label`.
///
/// # Errors
/// [`Error::PathSegment`] when either name contains anything but
/// `[A-Za-z0-9_-]`, which is what keeps one agent's name from addressing
/// another agent's secret.
pub fn account_path(agent: &str, label: &str) -> Result<String, Error> {
checked_segment("label", label)?;
Ok(format!("{}/{label}", accounts_dir(agent)?))
}
/// The directory every one of `agent`'s [`account_path`]s is under, in the
/// form [`crate::SecretStore::list`] takes.
///
/// # Errors
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`.
pub fn accounts_dir(agent: &str) -> Result<String, Error> {
let prefix = principal_prefix(Kind::Agent, agent)?;
Ok(format!("{prefix}/gitlab"))
}
/// The shape of an instance URL the agent end accepts, as a regular
/// expression: `gitlab-accounts.nix` filters every stored account through
/// exactly this pattern (a `jq` `test`) and leaves out one that doesn't match.
/// [`is_instance_url`] is the same rule in Rust, which `swarm-controller`
/// applies before it stores an account, so a stored URL is always one the
/// agent uses. A module-eval case pins the nix side to this literal.
pub const URL_PATTERN: &str = "^https?://[A-Za-z0-9.-]+(:[0-9]+)?(/[A-Za-z0-9._~/-]*)?$";
/// Whether `url` matches [`URL_PATTERN`]: `http` or `https`, a host of letters,
/// digits, dots and hyphens, an optional numeric port, and an optional path of
/// letters, digits and `. _ ~ / -`. No user information, query or fragment.
#[must_use]
pub fn is_instance_url(url: &str) -> bool {
let Some(rest) = url
.strip_prefix("https://")
.or_else(|| url.strip_prefix("http://"))
else {
return false;
};
let (authority, path) = rest.find('/').map_or((rest, ""), |i| rest.split_at(i));
let (host, port) = match authority.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (authority, None),
};
!host.is_empty()
&& host
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'.' || b == b'-')
&& port.is_none_or(|p| !p.is_empty() && p.bytes().all(|b| b.is_ascii_digit()))
&& path
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b"._~/-".contains(&b))
}
/// What an [`account_path`] holds.
///
/// No `Debug` derive: `value` is a live GitLab credential, and a derived
/// `Debug` is one `{:?}` away from a log line.
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Account {
/// The access token. `gitlab-accounts.nix` reads this field by name.
pub value: String,
/// The instance's base URL, without a trailing slash. Not secret.
/// `gitlab-accounts.nix` reads this field by name too.
pub url: String,
}
impl std::fmt::Debug for Account {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Account")
.field("value", &"<redacted>")
.field("url", &self.url)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Whether `policy` has a stanza whose prefix `path` starts with.
fn covers(policy: &str, path: &str) -> bool {
policy.lines().any(|line| {
line.strip_prefix("path \"")
.and_then(|rest| rest.split_once("\" {"))
.and_then(|(p, _)| p.strip_suffix('*'))
.is_some_and(|prefix| path.starts_with(prefix))
})
}
#[test]
fn an_account_and_its_directory_land_under_the_agent_prefix() {
// Spelled out: `gitlab-accounts.nix` and `swarm-bao.nix` spell the
// same strings.
assert_eq!(
account_path("atlas", "work").expect("both segments are legal"),
"swarm/agents/atlas/gitlab/work"
);
assert_eq!(
accounts_dir("atlas").expect("legal"),
"swarm/agents/atlas/gitlab"
);
}
#[test]
fn the_gitlab_directory_is_not_the_forgejo_one() {
let gitlab = accounts_dir("atlas").expect("legal");
let forge = crate::forge::accounts_dir("atlas").expect("legal");
assert_ne!(gitlab, forge);
assert!(!gitlab.starts_with(&format!("{forge}/")), "{gitlab}");
assert!(!forge.starts_with(&format!("{gitlab}/")), "{forge}");
}
#[test]
fn a_name_that_could_escape_the_directory_is_refused() {
for bad in ["../argus", "a/b", "a.b", ""] {
assert!(account_path("atlas", bad).is_err(), "label {bad:?}");
assert!(account_path(bad, "work").is_err(), "agent {bad:?}");
assert!(accounts_dir(bad).is_err(), "agent {bad:?}");
}
// The control: the legal charset stays reachable.
assert!(account_path("a-b_C9", "d-e0").is_ok());
}
#[test]
fn the_agents_own_grants_list_its_accounts_and_read_each() {
let policy = crate::policy::effective_agent("atlas");
let dir = accounts_dir("atlas").expect("legal");
assert!(covers(&policy, &format!("secret/metadata/{dir}/")));
assert!(covers(
&policy,
&format!(
"secret/data/{}",
account_path("atlas", "work").expect("legal")
)
));
// The control: another agent's accounts are outside both grants.
let other = accounts_dir("argus").expect("legal");
assert!(!covers(&policy, &format!("secret/metadata/{other}/")));
assert!(!covers(
&policy,
&format!(
"secret/data/{}",
account_path("argus", "work").expect("legal")
)
));
}
#[test]
fn the_agent_can_write_nothing_under_its_accounts() {
// Egress is derived from these leaves, so an agent that could write
// one would choose its own allowed hosts.
let policy = crate::policy::effective_agent("atlas");
let writes = |path: &str| {
policy
.split("path \"")
.skip(1)
.filter(|stanza| {
stanza.split_once("\" {").is_some_and(|(p, _)| {
p.strip_suffix('*')
.map_or(p == path, |prefix| path.starts_with(prefix))
})
})
.any(|stanza| {
["create", "update", "delete"]
.iter()
.any(|cap| stanza.contains(&format!("\"{cap}\"")))
})
};
let leaf = format!(
"secret/data/{}",
account_path("atlas", "work").expect("legal")
);
assert!(!writes(&leaf), "{policy}");
// The control: the same scan finds the one write the agent has.
let seed = format!(
"secret/data/{}/{}",
principal_prefix(Kind::Agent, "atlas").expect("legal"),
crate::backend_env::LEAF
);
assert!(writes(&seed), "{policy}");
}
/// One table for the rule: the same rows were fed to `jq`'s `test` with
/// [`URL_PATTERN`] when this was written, and the module-eval case pins
/// `gitlab-accounts.nix` to the same pattern.
#[test]
fn the_instance_url_rule_takes_exactly_the_documented_shapes() {
for ok in [
"https://gitlab.example.org",
"http://gitlab.lan",
"https://GitLab.Example.org:8443",
"https://gitlab.example.org/gl",
"https://gitlab.example.org/apps/gitlab_2/x~y.z-w",
"https://10.0.0.5",
] {
assert!(is_instance_url(ok), "{ok}");
}
for bad in [
"",
"ftp://gitlab.example.org",
"gitlab.example.org",
"https://",
"https://:8443",
"https://gitlab.example.org:",
"https://gitlab.example.org:80a",
"https://user@gitlab.example.org",
"https://gitlab.example.org/@x",
"https://gitlab.example.org/?x=1",
"https://gitlab.example.org?x=1",
"https://gitlab.example.org/#frag",
"https://gitlab.example.org/a b",
"https://gitlab.example.org/a%20b",
"https://gitlab.example.org/a:b",
"https://gitlab_x.example.org",
"https://[::1]",
] {
assert!(!is_instance_url(bad), "{bad:?}");
}
}
#[test]
fn the_account_fields_match_what_the_nix_reader_asks_for() {
let json = serde_json::to_string(&Account {
value: "t".to_owned(),
url: "https://gitlab.example.org".to_owned(),
})
.expect("two Strings serialise");
assert_eq!(json, r#"{"value":"t","url":"https://gitlab.example.org"}"#);
}
#[test]
fn debug_never_prints_an_accounts_value() {
let a = Account {
value: "0123456789abcdef".to_owned(),
url: "https://gitlab.example.org".to_owned(),
};
let shown = format!("{a:?}");
assert!(!shown.contains("0123456789abcdef"), "{shown}");
assert!(shown.contains("gitlab.example.org"), "{shown}");
}
}

View file

@ -42,6 +42,7 @@ pub mod claude_login;
pub mod client;
pub mod forge;
pub mod github;
pub mod gitlab;
pub mod grafana;
pub mod matrix;
pub mod mtls;