Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions dashboard/src/main.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -781,13 +781,13 @@ describe("MCP access page", () => {
expect(screen.queryByText(/mcp-serve/)).not.toBeInTheDocument();

await user.type(screen.getByLabelText("Token name"), "local agent");
await user.selectOptions(screen.getByLabelText("Owner"), "bob");
await user.selectOptions(screen.getByLabelText("Lifecycle owner"), "bob");
await user.click(screen.getByRole("button", { name: "Create token" }));

expect(onCreate).toHaveBeenCalledWith("local agent", "bob");
expect(screen.getAllByText("@bob").length).toBeGreaterThan(0);
expect(await screen.findByText("gab_mcp_secret")).toBeInTheDocument();
await user.selectOptions(screen.getAllByLabelText("Owner for local agent")[0], "admin");
await user.selectOptions(screen.getAllByLabelText("Lifecycle owner for local agent")[0], "admin");
expect(onUpdateOwner).toHaveBeenCalledWith("token-1", "admin");

await user.click(screen.getAllByRole("button", { name: "Revoke" })[0]);
Expand Down Expand Up @@ -841,8 +841,8 @@ describe("MCP access page", () => {
/>,
);

expect(screen.getByText("Issue and revoke read-only tokens linked to your user.")).toBeInTheDocument();
expect(screen.queryByLabelText("Owner")).not.toBeInTheDocument();
expect(screen.getByText("Issue and revoke globally readable knowledge tokens linked to your user.")).toBeInTheDocument();
expect(screen.queryByLabelText("Lifecycle owner")).not.toBeInTheDocument();

await user.type(screen.getByLabelText("Token name"), "reader agent");
await user.click(screen.getByRole("button", { name: "Create token" }));
Expand Down
21 changes: 14 additions & 7 deletions dashboard/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3070,7 +3070,7 @@ function McpPage({
const mcpEndpointUrl = `${publicBaseUrl}/api/mcp`;
return (
<div className="grid min-w-0 gap-4">
<PageTitle icon={<KeyRound className="h-5 w-5 text-muted" aria-hidden />} title="MCP access" subtitle={user?.is_admin ? "Issue and revoke read-only tokens for any dashboard user." : "Issue and revoke read-only tokens linked to your user."} action={<RefreshButton onClick={onRefresh} />} />
<PageTitle icon={<KeyRound className="h-5 w-5 text-muted" aria-hidden />} title="MCP access" subtitle={user?.is_admin ? "Issue and revoke globally readable knowledge tokens for any dashboard user." : "Issue and revoke globally readable knowledge tokens linked to your user."} action={<RefreshButton onClick={onRefresh} />} />
{error ? <Banner tone="error" text={error.message} /> : null}
{actionError ? <Banner tone="error" text={actionError} /> : null}
<McpSetupGuide dashboardUrl={mcpDashboardUrl} endpointUrl={mcpEndpointUrl} dashboardUrlSource={dashboardUrlSource ?? "request"} />
Expand Down Expand Up @@ -3113,17 +3113,17 @@ function McpPage({
<input className="control" value={name} placeholder="local agent" disabled={creating} onChange={(event) => setName(event.target.value)} />
</Field>
{user?.is_admin ? (
<Field label="Owner">
<Field label="Lifecycle owner">
<McpUserSelect
ariaLabel="Owner"
ariaLabel="Lifecycle owner"
value={userLogin}
ownerOptions={ownerOptions}
disabled={creating || ownerOptions.length === 0}
selectedSuffix={(option) => option.login === user?.login ? " (you)" : ""}
emptyLabel="No known users"
onChange={setUserLogin}
/>
{selectedOwner ? <span className="font-normal text-muted">Token will be linked to @{selectedOwner.login}.</span> : null}
{selectedOwner ? <span className="font-normal text-muted">Token management will be linked to @{selectedOwner.login}; this does not restrict repository reads.</span> : null}
</Field>
) : null}
<button className="inline-flex h-9 items-center justify-center gap-2 self-end rounded-md bg-primary px-3 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-60" type="submit" disabled={creating || !name.trim() || Boolean(user?.is_admin && !userLogin)}>
Expand Down Expand Up @@ -3270,7 +3270,7 @@ function McpTokenList({
<div className="min-w-0">
<h3 className="break-words text-sm font-semibold [overflow-wrap:anywhere]">{token.name}</h3>
<div className="mt-1 break-all font-mono text-xs text-muted">{token.id}</div>
{canManageOwners ? <McpOwnerSelect token={token} ownerOptions={ownerOptions} disabled={updatingOwnerId === token.id} onUpdateOwner={onUpdateOwner} /> : token.user_login ? <div className="mt-1 text-xs text-muted">Owner: @{token.user_login}</div> : null}
{canManageOwners ? <McpOwnerSelect token={token} ownerOptions={ownerOptions} disabled={updatingOwnerId === token.id} onUpdateOwner={onUpdateOwner} /> : token.user_login ? <div className="mt-1 text-xs text-muted">Lifecycle owner: @{token.user_login}</div> : null}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<MiniStat label="Created" value={<TimeText value={token.created_at} relative now={now} />} />
Expand All @@ -3288,7 +3288,7 @@ function McpTokenList({
<thead>
<tr className="border-b border-border bg-slate-50 text-left text-xs text-muted">
<th className="px-3 py-2 font-semibold">Name</th>
<th className="px-3 py-2 font-semibold">Owner</th>
<th className="px-3 py-2 font-semibold">Lifecycle owner</th>
<th className="px-3 py-2 font-semibold">Created</th>
<th className="px-3 py-2 font-semibold">Last used</th>
<th className="px-3 py-2 font-semibold">ID</th>
Expand Down Expand Up @@ -3324,7 +3324,7 @@ function McpOwnerSelect({ token, ownerOptions, disabled, onUpdateOwner }: { toke
const value = token.user_login ?? "";
return (
<McpUserSelect
ariaLabel={`Owner for ${token.name}`}
ariaLabel={`Lifecycle owner for ${token.name}`}
value={value}
ownerOptions={ownerOptions}
disabled={disabled || ownerOptions.length === 0}
Expand Down Expand Up @@ -3460,6 +3460,13 @@ function UserMenu({ user, loading }: { user: UserProfile | undefined; loading: b
<div className="text-xs text-slate-400">Signed in · {mode}</div>
</div>
{avatar}
{user?.login ? (
<form action="/auth/logout" method="post">
<button className="rounded-md border border-slate-700 px-2 py-1 text-xs font-semibold text-slate-300 hover:bg-slate-800 hover:text-white" type="submit">
Sign out
</button>
</form>
) : null}
</div>
);
}
Expand Down
20 changes: 20 additions & 0 deletions docs/dashboard-github-oauth.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ GITHUB_AGENT_BRIDGE_DASHBOARD_ALLOWED_TEAMS=
GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS=your-github-login
GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS=
GITHUB_AGENT_BRIDGE_DASHBOARD_PUBLIC_URL=
GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_MAX_AGE_SECONDS=28800
GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_VERSION=1
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PUBLIC_KEY=
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PRIVATE_KEY=
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_CONTACT=mailto:admin@example.com
Expand Down Expand Up @@ -96,6 +98,11 @@ Use at least one authorization allowlist:
- `GITHUB_AGENT_BRIDGE_DASHBOARD_PUBLIC_URL`: optional external dashboard
origin, for example `https://bridge.example.com`, used in shareable dashboard
links when the service runs behind nginx or another reverse proxy.
- `GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_MAX_AGE_SECONDS`: absolute signed
session lifetime. The default is 28,800 seconds (8 hours).
- `GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_VERSION`: positive integer embedded in
new sessions. Increment it to revoke every existing dashboard session without
rotating the signing secret.
- `GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PUBLIC_KEY`: browser Push API VAPID public
key served to signed-in dashboard users.
- `GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PRIVATE_KEY`: matching VAPID private key
Expand All @@ -120,6 +127,16 @@ listed in an `ALLOWED_*` variable.
Per-repository dashboard scopes are part of the issue #4 architecture but are
not implemented in the current dashboard backend.

Dashboard sessions contain signed issue and expiry times. User and admin
allowlist configuration is re-evaluated on every authenticated request, so
removing a directly listed login takes effect immediately. Organization and
team memberships are captured during OAuth and are refreshed by requiring a
new OAuth login when the absolute session lifetime expires; removing an org or
team member therefore takes effect within at most the configured session
lifetime. The **Sign out** action clears the browser session. Rotate the signing
key or increment the session version when all outstanding sessions must be
invalidated immediately.

## Browser Push Notifications

Completion notifications use the browser Push API, not GitHub mention comments.
Expand Down Expand Up @@ -168,6 +185,9 @@ When exposing the dashboard beyond localhost:
- terminate TLS before the dashboard;
- set the OAuth App homepage and callback URL to the public HTTPS origin;
- keep cookies `Secure`, `HttpOnly`, and `SameSite=Lax`;
- preserve the browser `Origin` and `Referer` headers. Cookie-authenticated
`POST`, `PUT`, `PATCH`, and `DELETE` requests fail closed unless one of those
headers matches the configured/forwarded dashboard origin;
- restrict proxy access to the intended operator network where possible;
- do not publish the raw SQLite file or arbitrary filesystem paths;
- rotate `GITHUB_OAUTH_CLIENT_SECRET` and
Expand Down
4 changes: 4 additions & 0 deletions docs/ingestion.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,10 @@ would couple rotation and increase the blast radius of a leak.
`GITHUB_AGENT_BRIDGE_WEBHOOK_MAX_BYTES` defaults to 1 MiB. GitHub must send
`Content-Type: application/json`, `X-GitHub-Delivery`, `X-GitHub-Event`, and a
valid `X-Hub-Signature-256` computed over the unmodified request bytes.
The ingress enforces this as an incremental hard cap while streaming the body,
including requests without `Content-Length`; it does not buffer an oversized
body before returning `413`. Set the reverse proxy limit to the same value. The
packaged nginx example uses `client_max_body_size 1m`, matching the default.

### GitHub configuration

Expand Down
2 changes: 2 additions & 0 deletions docs/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ GITHUB_AGENT_BRIDGE_DASHBOARD_ALLOWED_TEAMS=
GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS=your-github-login
GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS=
GITHUB_AGENT_BRIDGE_DASHBOARD_PUBLIC_URL=https://bridge.example.com
GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_MAX_AGE_SECONDS=28800
GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_VERSION=1
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PUBLIC_KEY=replace-with-vapid-public-key
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PRIVATE_KEY=replace-with-vapid-private-key
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_CONTACT=mailto:admin@example.com
Expand Down
9 changes: 9 additions & 0 deletions docs/mcp.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ dashboard admins, the signed-in admin, and any users already linked to existing
MCP tokens. Admins can use that selector to issue new tokens for a known user or
link an existing active token to a user without exposing the token secret again.

The linked user is a **lifecycle owner**, used to decide who can list and revoke
the token. It is not a repository authorization scope. Repository knowledge is
currently a single deployment-wide read model, just like the signed-in
dashboard knowledge page: every valid MCP token can list and read every
repository stored in that bridge database. Do not share one bridge database or
its MCP endpoint across mutually untrusted tenants. A future repository ACL
must be enforced consistently by both the dashboard read model and MCP; deriving
access from incidental job authorship would not be a reliable security boundary.

The dashboard MCP page shows both a public dashboard URL for token management
and the public MCP endpoint URL. Behind a reverse proxy, set
`GITHUB_AGENT_BRIDGE_DASHBOARD_PUBLIC_URL` to the external origin, for example
Expand Down
3 changes: 3 additions & 0 deletions docs/nginx-dashboard.conf
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ server {
# Keep webhook ingestion independent from dashboard/UI restarts. systemd
# owns this socket and queues short bursts while the ingress process swaps.
location = /api/webhooks/github {
# Keep this aligned with GITHUB_AGENT_BRIDGE_WEBHOOK_MAX_BYTES. nginx
# rejects oversized bodies before proxy buffering or Python allocation.
client_max_body_size 1m;
proxy_pass http://127.0.0.1:8766;
proxy_http_version 1.1;
proxy_set_header Host $host;
Expand Down
2 changes: 2 additions & 0 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -538,6 +538,8 @@ GITHUB_AGENT_BRIDGE_DASHBOARD_ALLOWED_TEAMS=example-org/platform
GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_USERS=alice
GITHUB_AGENT_BRIDGE_DASHBOARD_ADMIN_TEAMS=example-org/bridge-admins
GITHUB_AGENT_BRIDGE_DASHBOARD_PUBLIC_URL=https://bridge.example.com
GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_MAX_AGE_SECONDS=28800
GITHUB_AGENT_BRIDGE_DASHBOARD_SESSION_VERSION=1
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PUBLIC_KEY=replace-with-vapid-public-key
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_PRIVATE_KEY=replace-with-vapid-private-key
GITHUB_AGENT_BRIDGE_WEB_PUSH_VAPID_CONTACT=mailto:admin@example.com
Expand Down
Loading
Loading