From 623934e2fb761d4d2d59071d2f4af8cd7400bc48 Mon Sep 17 00:00:00 2001
From: xeri <109935338+xeri@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:19:56 +1200
Subject: [PATCH 1/5] security: redact the pending enrollment ticket in
diagnostics
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
redactConfig masked the agent token, agentID, and cert fingerprint but not
Agent.EnrollTicket, so a pending single-use tkt_ credential rode verbatim into
config.redacted.toml — precisely the failing-to-pair window when a user grabs a
bundle. Mask it and add it to the leak test's secret list (the test passed blind
because it never set the field).
Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy
---
app/tools.go | 6 ++++++
app/tools_test.go | 3 +++
2 files changed, 9 insertions(+)
diff --git a/app/tools.go b/app/tools.go
index dcf932c..1553672 100644
--- a/app/tools.go
+++ b/app/tools.go
@@ -156,6 +156,12 @@ func redactConfig(cfg *config.Config) config.Config {
if r.Agent.CertFingerprint != "" {
r.Agent.CertFingerprint = secret
}
+ if r.Agent.EnrollTicket != "" {
+ // A pending single-use enrollment ticket is a live credential until the
+ // gateway confirms enrollment — exactly the failing-to-pair window in which
+ // a user grabs a bundle. Never let it ride along.
+ r.Agent.EnrollTicket = secret
+ }
if r.Agent.GatewayHost != "" {
r.Agent.GatewayHost = host
}
diff --git a/app/tools_test.go b/app/tools_test.go
index ad4ee92..f15d3e2 100644
--- a/app/tools_test.go
+++ b/app/tools_test.go
@@ -18,6 +18,7 @@ func sampleConfig() *config.Config {
cfg.Agent.Token = "AGENTTOKENSECRET"
cfg.Agent.GatewayHost = "gw.secret.example.com"
cfg.Agent.CertFingerprint = "sha256:deadbeefsecret"
+ cfg.Agent.EnrollTicket = "tkt_ENROLLTICKETSECRET"
cfg.Agent.Tunnels = []config.Tunnel{{ID: "t1", Name: "mc", LocalAddr: "10.9.8.7:25565"}}
cfg.Gateway.Token = "GWTOKENSECRET"
cfg.Gateway.PublicHost = "public.secret.example.com"
@@ -35,8 +36,10 @@ func TestRedactConfigMasksEverySecret(t *testing.T) {
"AGENTIDSECRET", "AGENTTOKENSECRET", "gw.secret.example.com",
"deadbeefsecret", "10.9.8.7", "GWTOKENSECRET",
"public.secret.example.com", "192.168.50.1", "127.0.0.99",
+ "ENROLLTICKETSECRET",
}
blob := r.Agent.AgentID + r.Agent.Token + r.Agent.GatewayHost + r.Agent.CertFingerprint +
+ r.Agent.EnrollTicket +
r.Gateway.Token + r.Gateway.PublicHost + r.Gateway.BindAddr + r.Metrics.PrometheusAddr
for _, t2 := range r.Agent.Tunnels {
blob += t2.LocalAddr
From d6a016d5b9107b9fd9635dd419200c4d468bceb5 Mon Sep 17 00:00:00 2001
From: xeri <109935338+xeri@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:20:08 +1200
Subject: [PATCH 2/5] gateway: push only in-scope tunnels on reconnect so a
narrowed scope shows no phantom
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
pushConfigOnConnect reconciled the scope-filtered subset (never a bind bypass) but
hashed and pushed the full stored set. If a scope was narrowed after a tunnel was
adopted, the agent was handed a tunnel the gateway won't bind — a phantom "live"
tunnel with no listener, against the "config drift never appears" promise. Push
validSpecs for both the hash and the payload; in the common (unchanged-scope) case
valid == stored, so nothing changes. Covered by TestGatewayConfigScopeNarrowingHidesTunnel.
Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy
---
internal/e2e/gatewayconfig_test.go | 47 ++++++++++++++++++++++++++++++
internal/gateway/gateway.go | 18 ++++++++----
2 files changed, 59 insertions(+), 6 deletions(-)
diff --git a/internal/e2e/gatewayconfig_test.go b/internal/e2e/gatewayconfig_test.go
index ba20f26..3cd22e5 100644
--- a/internal/e2e/gatewayconfig_test.go
+++ b/internal/e2e/gatewayconfig_test.go
@@ -127,3 +127,50 @@ func TestGatewayConfigDriftOnReconnect(t *testing.T) {
t.Fatalf("reconnect must not bump the generation: %+v", agents)
}
}
+
+// TestGatewayConfigScopeNarrowingHidesTunnel: narrowing an agent's scope after a
+// tunnel was adopted must not leave the agent showing a phantom tunnel. On the next
+// reconnect the gateway reconciles and pushes only the in-scope set, so the
+// now-out-of-scope tunnel is neither bound here nor learned by the agent. (gateway-config)
+func TestGatewayConfigScopeNarrowingHidesTunnel(t *testing.T) {
+ echoA, closeA := echoServer(t)
+ defer closeA()
+ h := newHarnessWith(t, echoA, harnessOpts{enroll: true})
+ h.waitPublicPort()
+ rec := waitConfigGen(t, h.gw, 1)
+
+ // Adopt a second tunnel B alongside A (A pinned to its resolved port so it does
+ // not flap through the promote).
+ echoB, closeB := echoServer(t)
+ defer closeB()
+ portA, ok := h.agent.TunnelPublicPort(h.tunnelID)
+ if !ok {
+ t.Fatal("tunnel A has no resolved port before promote")
+ }
+ tunnelB := config.NewID()
+ h.agent.ApplyTunnels([]config.Tunnel{
+ {ID: h.tunnelID, Name: "test", Type: config.TunnelTCP, LocalAddr: echoA, PublicPort: portA, Enabled: true},
+ {ID: tunnelB, Name: "b", Type: config.TunnelTCP, LocalAddr: echoB, PublicPort: 0, Enabled: true},
+ })
+ waitConfigGen(t, h.gw, 2)
+ waitPortForTunnel(t, h.agent, tunnelB)
+
+ // Narrow the agent's scope to tunnel A only, then reconnect it.
+ if !h.gw.SetAgentScope(rec.AgentID, gateway.Scope{TunnelIDs: []string{h.tunnelID}}) {
+ t.Fatal("SetAgentScope reported the agent was not found")
+ }
+ h.stopAgent()
+ h.startAgent()
+
+ // A comes back live; B must be neither pushed to the agent nor bound.
+ h.waitPublicPort()
+ roundTrip(t, fmt.Sprintf("127.0.0.1:%d", portA), []byte("in-scope tunnel survives"))
+ deadline := time.Now().Add(2 * time.Second)
+ for time.Now().Before(deadline) {
+ if _, ok := h.agent.TunnelPublicPort(tunnelB); !ok {
+ return // B correctly excluded from the pushed set
+ }
+ time.Sleep(20 * time.Millisecond)
+ }
+ t.Fatal("out-of-scope tunnel B was still pushed to the agent after scope narrowing")
+}
diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go
index b203140..40c7caf 100644
--- a/internal/gateway/gateway.go
+++ b/internal/gateway/gateway.go
@@ -283,8 +283,8 @@ func (g *Gateway) RevokeAgent(agentID string) bool {
}
ok := g.agents.Revoke(agentID)
if a := g.act(); a != nil {
- // One actor hop: read the live session and evict it in the same fn. (Calling
- // a.session here would nest a.do inside a.do and deadlock.)
+ // One actor hop: read the live session and evict it in the same fn. Looking it
+ // up through an accessor that itself calls a.do would nest a.do and deadlock.
a.do(func() {
if s := a.agents[agentID]; s != nil {
a.evict(s, "agent revoked")
@@ -1369,16 +1369,22 @@ func (g *Gateway) pushConfigOnConnect(sess *agentSession) {
if !ok || gen == 0 {
return // bootstrap: await the agent's seed propose_config
}
- g.act().reconcile(sess, g.validSpecs(sess, stored), g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient)
- hash := control.HashTunnels(stored)
+ // Push exactly the set the current scope permits binding, not the raw stored
+ // set: if the scope was narrowed after a tunnel was adopted, that tunnel is no
+ // longer bound here, and pushing it would show the agent a phantom "live" tunnel
+ // with no listener behind it. In the common (unchanged-scope) case valid==stored,
+ // so the hash and payload are identical and nothing changes.
+ valid := g.validSpecs(sess, stored)
+ g.act().reconcile(sess, valid, g.cfg.Gateway.BindAddr, g.cfg.Gateway.PortAllowlist, g.handleClient)
+ hash := control.HashTunnels(valid)
if sess.reportedConfigHash == hash && sess.agentConfigGen.Load() == gen {
return // agent is already in sync; listeners are (re)bound, nothing to push
}
- if err := sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: gen, Hash: hash, Tunnels: stored}); err != nil {
+ if err := sess.writeControl(control.TypePushConfig, control.PushConfig{Generation: gen, Hash: hash, Tunnels: valid}); err != nil {
sess.logger.Warn("push_config on connect failed", "err", err)
return
}
- sess.logger.Info("pushed authoritative config", "generation", gen, "tunnels", len(stored))
+ sess.logger.Info("pushed authoritative config", "generation", gen, "tunnels", len(valid))
}
// adoptProposal handles a propose_config from an enrolled gateway-config agent: a
From 96af605baf1e7a8e5b4c14d650f4fd86f99b25ba Mon Sep 17 00:00:00 2001
From: xeri <109935338+xeri@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:20:19 +1200
Subject: [PATCH 3/5] lint: clear the golangci gate before its first CI run
Three pre-existing findings would fail the gate on first push (it has never run):
- unused: (*actor).session(agentID) had no callers (RevokeAgent inlines the lookup
to avoid nesting a.do); remove the dead accessor.
- SA4000: the HashTunnels determinism check compared identical expressions
(HashTunnels(a) != HashTunnels(a)); store the two calls before comparing, same intent.
- misspell: "alltime" in proxyforward_alltime_bytes_total is a Prometheus metric-name
token (hyphens are invalid there); add a documented ignore-rule.
Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy
---
.golangci.yml | 5 +++++
internal/control/control_test.go | 2 +-
internal/gateway/actor.go | 7 -------
3 files changed, 6 insertions(+), 8 deletions(-)
diff --git a/.golangci.yml b/.golangci.yml
index 4dbf049..1e0b71f 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -19,6 +19,11 @@ linters:
# comments are the spec and its explicitness is deliberate; those checks
# would fight it for no correctness gain.
checks: ["all", "-QF*", "-ST*"]
+ misspell:
+ # "alltime" is a Prometheus metric-name token (proxyforward_alltime_bytes_total).
+ # Metric names cannot contain hyphens, so "all-time" is not a valid alternative.
+ ignore-rules:
+ - alltime
exclusions:
presets:
diff --git a/internal/control/control_test.go b/internal/control/control_test.go
index 6b5c5de..e618c6f 100644
--- a/internal/control/control_test.go
+++ b/internal/control/control_test.go
@@ -189,7 +189,7 @@ func TestHashTunnels(t *testing.T) {
t.Fatal("empty and populated sets collide")
}
// Deterministic across calls (no map iteration order leaking in).
- if HashTunnels(a) != HashTunnels(a) {
+ if h1, h2 := HashTunnels(a), HashTunnels(a); h1 != h2 {
t.Fatal("hash is not deterministic")
}
}
diff --git a/internal/gateway/actor.go b/internal/gateway/actor.go
index f7eb7d1..98700e2 100644
--- a/internal/gateway/actor.go
+++ b/internal/gateway/actor.go
@@ -621,13 +621,6 @@ func (a *actor) sessions() []*agentSession {
return out
}
-// session returns the live session for one agentID, or nil.
-func (a *actor) session(agentID string) *agentSession {
- var sess *agentSession
- a.do(func() { sess = a.agents[agentID] })
- return sess
-}
-
// TunnelSnapshot is one registered tunnel's live state, for status surfaces.
type TunnelSnapshot struct {
AgentID string // the agent that registered this tunnel
From 0e1ba733ac6c291e31d04b4e942aa4551bff7e7e Mon Sep 17 00:00:00 2001
From: xeri <109935338+xeri@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:20:33 +1200
Subject: [PATCH 4/5] ui: consume the pxf:// deep link into pairing; reusable
codes no longer expire
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The backend parsed, forwarded, stashed, and emitted a clicked pxf:// link, but no
React code drained it — the link surfaced the window and died there. App now takes
the pending deep link on mount and subscribes to pxf:deeplink, opening the wizard
straight onto the agent paste step with the code prefilled (confirm-to-connect,
never auto). Also: the wizard passed ttl 600 even for a reusable code, so a code
labelled "until you revoke it" silently expired in 10 minutes — reusable now maps
to ttl 0. devmock gains a &deeplink=1 axis for headless verification.
Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy
---
frontend/src/App.tsx | 18 +++++++++++++++++-
frontend/src/devmock.ts | 9 ++++++---
frontend/src/screens/Wizard.tsx | 14 ++++++++++++--
3 files changed, 35 insertions(+), 6 deletions(-)
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index becc279..5eb3284 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -18,6 +18,8 @@ import {Spinner} from './components/ui'
import {UIStatus, useTick} from './state'
import {prefersReduced} from './motion'
import {resetBands} from './rubberband'
+import {TakePendingDeepLink} from '../wailsjs/go/app/App'
+import {EventsOn} from '../wailsjs/runtime/runtime'
const supportsVT = typeof (document as Document & {startViewTransition?: unknown}).startViewTransition === 'function'
@@ -117,7 +119,21 @@ export default function App() {
useEffect(() => {
if (backendWizard) setWizardHold(true)
}, [backendWizard])
+
+ // A clicked pxf:// pairing invite arrives from the OS via the backend: stashed for
+ // a cold launch (drained once) or emitted on a warm one. Either way, open the wizard
+ // straight onto the agent paste step with the code prefilled. The user still reviews
+ // and confirms — a clicked link never auto-connects.
+ const [deepLink, setDeepLink] = useState('')
+ useEffect(() => {
+ let done = false
+ TakePendingDeepLink().then(url => { if (!done && url) { setDeepLink(url); setWizardHold(true) } }).catch(() => {})
+ const off = EventsOn('pxf:deeplink', (url: string) => { setDeepLink(url); setWizardHold(true) })
+ return () => { done = true; off() }
+ }, [])
+
const finishWizard = () => {
+ setDeepLink('') // a later manual reopen starts at role selection, not a stale link
const doc = document as Document & {startViewTransition?: (cb: () => void) => void}
if (!prefersReduced() && doc.startViewTransition) {
// Glaze the handover: the ambient glow flares and a glare sweep crosses
@@ -142,7 +158,7 @@ export default function App() {
if (backendWizard || wizardHold) {
return (
}>
-
+
)
}
diff --git a/frontend/src/devmock.ts b/frontend/src/devmock.ts
index ab4628d..3310ac3 100644
--- a/frontend/src/devmock.ts
+++ b/frontend/src/devmock.ts
@@ -58,6 +58,7 @@ export function installDevMock() {
const axisGeo = params.get('geo') || '' // off | empty | error | pending
const axisFleet = params.get('fleet') || '' // '' (single agent) | multi | old
const axisConflict = params.get('conflict') || '' // '' | port | clone | both
+ const axisDeepLink = params.get('deeplink') === '1' // simulate a clicked pxf:// invite
const fx = params.get('fx')
if (fx) document.documentElement.dataset.fx = fx // &fx=high | &fx=low
@@ -949,9 +950,11 @@ export function installDevMock() {
return undefined
})()
: Promise.reject(new Error(`agent ${agentId} not found (it may have already disconnected or been removed)`))),
- // No OS deep link in browser dev; the real app pulls this once on mount to open
- // straight into pairing when launched via a clicked pxf:// link.
- TakePendingDeepLink: () => ok(''),
+ // The real app pulls this once on mount to open straight into pairing when
+ // launched via a clicked pxf:// link. In browser dev &deeplink=1 simulates one.
+ TakePendingDeepLink: () => ok(axisDeepLink
+ ? 'pxf://play.example.com:8474/v1/pair/tkt_0123456789abcdef0123456789abcdef#sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
+ : ''),
Version: () => ok('0.1.0-dev'),
LogsSince: (seq: number) => ok(axisAttached ? [] : logs.filter(l => l.seq > seq)),
TestReachability: () => new Promise(r => setTimeout(() => r('Reachable: play.example.com:25565 answered in 38ms — players can connect.'), 700)),
diff --git a/frontend/src/screens/Wizard.tsx b/frontend/src/screens/Wizard.tsx
index 4dc3dd3..77d7716 100644
--- a/frontend/src/screens/Wizard.tsx
+++ b/frontend/src/screens/Wizard.tsx
@@ -16,7 +16,7 @@ type Kind = 'gateway' | 'agent'
* before handing over to the console. Designed so a non-technical user can
* finish in under a minute; the pairing code is the only thing that moves
* between machines. */
-export function Wizard({status, onDone}: {status: UIStatus | null; onDone: () => void}) {
+export function Wizard({status, onDone, deepLink}: {status: UIStatus | null; onDone: () => void; deepLink?: string}) {
const [act, setAct] = useState('role')
const [kind, setKind] = useState('gateway')
const [err, setErr] = useState('')
@@ -36,6 +36,15 @@ export function Wizard({status, onDone}: {status: UIStatus | null; onDone: () =>
.catch(() => {})
}, [])
+ // A pxf:// deep link (a clicked pairing invite) drops the user straight onto the
+ // agent paste step with the code prefilled — parsed inline below, still
+ // confirm-to-connect. A pairing code always means "make this machine the agent".
+ useEffect(() => {
+ if (deepLink && deepLink.startsWith('pxf://')) {
+ setErr(''); setKind('agent'); setPairing(deepLink); setAct('agent')
+ }
+ }, [deepLink])
+
// Preview a role's ambient hue on hover — the whole backdrop leans in.
const preview = (role: '' | Kind) => {
document.documentElement.dataset.role = role || 'unset'
@@ -198,7 +207,8 @@ function GatewayLive({status, controlPort, onDone}: {
let cancelled = false
setBusy(true); setCode(''); setErr('')
const poll = (n: number) => {
- IssuePairingCode(reusable, 600, [], []).then(c => { if (!cancelled) { setCode(c); setBusy(false) } })
+ // Single-use expires in 10 min; a reusable code lives until it is revoked (ttl 0).
+ IssuePairingCode(reusable, reusable ? 0 : 600, [], []).then(c => { if (!cancelled) { setCode(c); setBusy(false) } })
.catch(e => { if (!cancelled) { if (n < 20) setTimeout(() => poll(n + 1), 250); else { setErr(String(e)); setBusy(false) } } })
}
poll(0)
From a745be577cfad5d5c92651d3cbc980a23c628237 Mon Sep 17 00:00:00 2001
From: xeri <109935338+xeri@users.noreply.github.com>
Date: Thu, 16 Jul 2026 17:20:45 +1200
Subject: [PATCH 5/5] docs: per-agent identity/enrollment/pairing/revocation;
rewrite residual-risk
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Phase 9 of the identity work. architecture.md gains identity/enrollment/pairing rows
in "The numbers" and a deep-dive section (Ed25519 identity, proof-of-possession auth,
enrollment tickets, AgentStore, validators, revocation, scope, the pxf:// scheme +
click-to-pair, the version axes, and an honest "deferred" list). CLAUDE.md's stale
residual-risk paragraph is rewritten — per-agent identity + revocation shipped; the
shared token is now only a migration fallback with a narrowed, path-scoped risk — and
"revoked" joins the fatal-auth list. polish-backlog records the backend-ahead-of-GUI
agent-management surfaces. Also drops a stale (CapEnroll) comment (identity is
field-driven, not a capability) and adds gateway_agents.json to doccheck's runtime-file
allowlist. Citations land green.
Claude-Session: https://claude.ai/code/session_014EY1emYd9vPqNhwW4mb4Jy
---
CLAUDE.md | 25 ++++---
docs/agent/architecture.md | 100 ++++++++++++++++++++++++++++
docs/agent/polish-backlog.md | 37 ++++++++++
internal/control/control.go | 8 ++-
internal/doccheck/citations_test.go | 1 +
5 files changed, 159 insertions(+), 12 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index ec49b00..1b7943c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -75,20 +75,27 @@ Each entry: the rule, why, and the symbol that embodies it today. Numbers live i
dies; failed auth rate-limits per IP, fail2ban-style — successes never count
(`limits.go authLimiter`). Public conns gate globally and per-IP (`limits.go
connGate`; defaults in `config.go`).
-- One shared gateway token admits **many** agents, told apart by self-asserted
- `agentID`: a matching agentID **supersedes** (reconnect), a distinct one is admitted
- **alongside**. Supersede is anti-flap dampened so an ID collision degrades to a slow
- contest, not a loop (`actor.go admit`, `noteSupersede`). Residual risk that ships
- (shared token + self-asserted ID + FCFS ports + no per-agent revocation): a
- token-holder can supersede or port-squat any agent, recoverable only by rotating the
- shared token — the mitigation (per-agent tokens/revocation) is deferred.
+- **Per-agent identity is the trust model.** Each agent proves possession of a long-term
+ Ed25519 key the gateway allowlists; its derived `agentID` (`agt_…`) is unforgeable,
+ individually **scoped** (ports/tunnels) and **revocable** — revocation evicts the live
+ session and makes the next connect fatal `ErrCodeRevoked`. Agents join via a single-use
+ enrollment ticket in the pairing code (`gateway/auth.go identityValidator`, `agentstore.go`,
+ `link/cred.go`; mechanics in `docs/agent/architecture.md`). A matching agentID
+ **supersedes** (reconnect), a distinct one is admitted **alongside**; supersede is
+ anti-flap dampened so an ID collision degrades to a slow contest, not a loop
+ (`actor.go admit`, `noteSupersede`).
+- The legacy **shared token** survives only as a migration fallback, accepted while
+ `Gateway.AcceptSharedToken` is on (default for now). A shared-token agent self-asserts its
+ `agentID`, so the old residual risk — supersede or port-squat, recoverable only by rotating
+ the token — persists **for that path only**; enrolled agents are protected by their key and
+ by revocation, and disabling `AcceptSharedToken` once every agent is enrolled closes it.
- The IPC pipe ACL admits Administrators, SYSTEM, and the interactive user only
(`ipc/server_windows.go pipeSecurity`).
- Diagnostics bundles redact every secret, host, IP, and identity; peer IPs become
stable sha256 pseudonyms (`app/tools.go`, leak-tested in `app/tools_test.go`).
Anything new that exports data must pass the same no-leak test style.
-- Fatal auth errors (`bad_token`, `agent_conflict`, `version`) stop the agent instead
- of retry-hammering the gateway (fatal classification in `agent.go Run`), and
+- Fatal auth errors (`bad_token`, `agent_conflict`, `version`, `revoked`) stop the agent
+ instead of retry-hammering the gateway (fatal classification in `agent.go isFatal`), and
surface in the UI via `EngineFatal` on the tick.
### Liveness & lifecycle
diff --git a/docs/agent/architecture.md b/docs/agent/architecture.md
index a6359b6..cd9e896 100644
--- a/docs/agent/architecture.md
+++ b/docs/agent/architecture.md
@@ -108,6 +108,11 @@ persisted to config.
| Pipe | 5 s request / 2 min idle timeouts; ACL BA+SY+IU | `ipc/server_windows.go` |
| Cert | ECDSA P-256, 20-year validity (trust = pin, not expiry) | `link/cert.go:86` |
| Key exchange | X25519MLKEM768 (PQ hybrid, Go default; `CurvePreferences` unset) | `link/cert.go`, `link/pq_test.go` |
+| Agent identity | Ed25519 (`agent_identity.key`, PKCS#8 PEM, `0600`); `agentID` = `agt_` + 8-char Crockford base32 of sha256(pubkey)[:5] = 40 bits | `link/cred.go LoadOrCreateIdentity fingerprint` |
+| Enrollment ticket | `tkt_` + 128-bit nonce; single-use default, reusable optional; TTL caller-set, 0 = never (UI single-use default 10 min) | `link/cred.go NewEnrollTicket`, `gateway/agentstore.go IssueEnrollment` |
+| Agent allowlist | `gateway_agents.json` (`0600`, atomic write + AV-retry): identity + scope + desired config per agent | `gateway/agentstore.go AgentStore` |
+| Pairing code | `pxf://host:port/v1/pair/#sha256:<64hex>`, ≤ 512 B before parse | `link/pairing.go ParsePairingCode` |
+| Agent auth | Ed25519 proof-of-possession over `proxyforward-agent-auth-v1` + gateway cert FP — no bearer token in steady state | `link/cred.go AgentAuthMessage` |
| Perf floor | ≥20 MiB/s, worst cross-stream RTT ≤500 ms (64 MiB loopback burst); per-transport twins `TestBurstThroughputPerConn` / `TestBurstThroughputQUIC` | `e2e_test.go:716,719` |
| Blur ladder | control 10, Signal Glass 20, card frost 30, chrome 36, island 40, float 48, pop 56 px | `tokens.css` |
| Switch geometry | 40×22 track, 1px rim + 2px seat → 16px knob (7px radius), 18px travel; ×`--ui-scale` | `tokens.css`, `ui.tsx Switch` |
@@ -180,6 +185,101 @@ change) only once a later rung succeeds — the "UDP blocked" tell; if every run
link is simply down, so nothing is cooled. The rung that connects is reported to the GUI as
`ActiveTransport` (tick `Status.Transport`) so a user can see what auto settled on.
+## Per-agent identity, enrollment & revocation (`internal/link/cred.go`, `internal/gateway/agentstore.go`, `internal/gateway/auth.go`)
+
+The trust root is still the gateway's pinned self-signed cert (`link/cert.go`); layered on
+top is a per-agent cryptographic identity so agents are told apart, scoped, and revoked
+individually rather than sharing one bearer token.
+
+**Identity.** On first run the agent generates a long-term Ed25519 keypair and persists the
+PKCS#8 private key `0600` beside its config (`link/cred.go LoadOrCreateIdentity`); the private
+half never leaves the machine, and a corrupt/non-Ed25519 file is a fatal, actionable error,
+never a silent regeneration (which would orphan the allowlist entry). The **canonical
+identity is the raw public key** — the gateway allowlist is keyed by it. The human-facing
+`agentID` is *derived*: `agt_` + an 8-char Crockford-base32 fingerprint (no confusable
+`i/l/o/u`) of the first 40 bits of sha256(pubkey) (`link/cred.go AgentID fingerprint`).
+Derived, so it is stable (the same machine always re-derives it; re-pairing never dupes) and
+unforgeable (bound to a private key nobody else holds). The ID grammar is
+`_`: `gw_` over the cert DER, `agt_` over the pubkey, `tnl_` a slug of the
+tunnel name with a `-2` collision suffix (`link/cred.go GatewayID TunnelID`). A freely-editable
+nickname layers on top as display sugar.
+
+**Steady-state auth is proof-of-possession, not a bearer token.** In the hello the agent
+sends `AgentPubKey` plus an Ed25519 `AgentSig` over `AgentAuthMessage` = the constant
+`proxyforward-agent-auth-v1` joined to the *pinned gateway cert fingerprint* (`link/cred.go
+AgentAuthMessage SignAgentAuth`). The gateway verifies the signature, checks the pubkey is
+allowlisted and not revoked (`gateway/auth.go identityValidator`), and admits — no extra
+round-trip, and the agent still speaks first, so hello frames to a legacy gateway stay
+byte-identical (all new fields `omitempty`). Binding to the cert fingerprint (rather than the
+originally-specced per-session TLS exporter) means a signature made for one gateway can never
+be replayed to another; same-gateway replay resistance rests on TLS 1.3 confidentiality —
+only the real gateway or the agent itself ever sees the signature and either already holds the
+private key — so the signature is static per (agent, gateway) pair and the identical message
+works over both TCP and QUIC. The bearer token now survives only as the enrollment ticket.
+
+**Enrollment.** The gateway mints a single-use ticket `tkt_` + 128-bit nonce (`link/cred.go
+NewEnrollTicket`) and embeds it in a pairing code. On first contact the agent replays it in
+`Hello.EnrollTicket`; the gateway validates-and-consumes it under one lock (a spent single-use
+ticket is refused — `ErrTicketConsumed`), records the pubkey, derives and stores the
+`agentID`, and returns it in `HelloOK.AssignedAgentID` alongside `GatewayID`. Single-use is
+the default; a **reusable** ticket (enrolls many agents until revoked) and an optional expiry
+(zero = never) are the flagged alternatives. Enrollment is **field-driven, not a capability**
+— acted on before capability negotiation, so there is deliberately no `CapEnroll`.
+
+**AgentStore.** The allowlist and outstanding tickets persist to `gateway_agents.json` (`0600`,
+single writer, atomic temp+rename with the AV-retry of `setup.atomicWrite`) — deliberately
+*not* in `analytics.db`, which is role-blind history (`gateway/agentstore.go AgentStore
+LoadAgentStore`). Each record carries identity, nickname, scope, and the gateway-authoritative
+desired tunnel set (see "Control-plane message flow").
+
+**Validators.** A `compositeValidator` tries the identity path first, then — only while
+`Gateway.AcceptSharedToken` is on (a migration default) — falls back to the legacy shared token
+(`gateway/auth.go compositeValidator sharedTokenValidator`; token and fingerprint still compare
+in constant time). Every accept path (TCP control, QUIC, per-conn data) funnels through the one
+`Validator.Validate` seam, so identity is enforced uniformly.
+
+**Revocation.** `Gateway.RevokeAgent` removes the pubkey from the allowlist and evicts any live
+session at once; the next connect is a fatal `ErrCodeRevoked`, which the agent classifies as
+fatal (`agent.go isFatal`) and stops on rather than retry-hammering — surfaced in the GUI via
+`EngineFatal`. Regression: e2e `TestEnrollAndRevoke`.
+
+**Scope.** A ticket/record carries `Scope{Ports, TunnelIDs}` (empty = unrestricted), enforced
+at bind in `validateSpec` — an out-of-scope port is `ErrCodePortNotAllowed`, an out-of-scope
+tunnel `ErrCodeBadTunnel` — on *both* the register path and the gateway-config push/adopt path,
+so a scoped agent can never bind outside its grant by any route (`gateway/agentstore.go Scope`;
+`gateway/gateway.go validSpecs`). Regressions: `TestGatewayConfigScopeNarrowingHidesTunnel`,
+`gateway/scope_test.go`.
+
+**Pairing scheme & click-to-pair.** The pairing code is `pxf://host:port/v1/pair/#sha256:`
+(`link/pairing.go PairingCode`). `pxf` is a permanent brand *and* the OS deep-link scheme
+(`wails.json` registers it via the NSIS macros); the `/v1/` segment versions the code's *shape*
+independently of the wire `ProtocolVersion`; the `/pair/` segment is a role/kind marker so a
+wrong-kind link fails loudly instead of half-parsing (`link/pairing.go tokenFromV1Path`). The
+parser caps input at 512 bytes before parsing and validates host, port, and the 64-hex
+fingerprint; it is fuzzed (`link/pairing_fuzz_test.go FuzzParsePairingCode`). A clicked `pxf://`
+link reaches the app as `os.Args` on a cold launch or via single-instance forwarding on a warm
+one (`main.go deepLinkArg`, `app/app.go HandleDeepLink TakePendingDeepLink`); the frontend drains
+it on mount and opens the wizard straight onto the agent paste step with the code prefilled —
+confirm-to-connect, never auto-connect (`frontend/src/App.tsx`, `frontend/src/screens/Wizard.tsx`).
+
+**Version axes** (independent, so any one evolves alone):
+
+| Axis | Where | Bumps when |
+|---|---|---|
+| Scheme | `pxf` | Never (permanent brand) |
+| Pairing format | the `/v1/` path segment | The code's shape changes |
+| Protocol | `Hello.ProtocolVersion` (stays 1) | The hello exchange itself breaks |
+| Config authority | `Hello.ConfigGeneration` (per-agent, monotonic) | The gateway adopts a new desired set |
+| Crypto suite | TLS-negotiated (`X25519MLKEM768` today) | An algorithm is added/retired |
+
+**Deferred (honest state).** Per-agent **key rotation** ("old key signs new") and PQ
+*signatures* are roadmap, not shipped (the KEM is already PQ; forging Ed25519 needs a quantum
+computer at attack time, so there is no harvest-now risk). There is no on-disk `config_version`
+schema field yet — there are no config migrations and the local config dir is disposable. The
+`pf1://` scheme was dropped outright rather than kept as a compat parser (pre-release, no codes
+in the wild). Several agent-management surfaces are backend-complete but not yet fully exposed in
+the GUI — tracked in `docs/agent/polish-backlog.md`.
+
## Analytics data model (`internal/analytics/schema.go`)
- `rrd(tier,t,…)` — persisted image of stats tiers 2–4 (28 OHLC/gauge columns; −1 =
diff --git a/docs/agent/polish-backlog.md b/docs/agent/polish-backlog.md
index d7df9cf..97e8aa7 100644
--- a/docs/agent/polish-backlog.md
+++ b/docs/agent/polish-backlog.md
@@ -163,3 +163,40 @@ Ordered by user-visible impact. "Fix" describes the smallest on-system change.
`*_other.go` stubs honest, but `ipc.Serve` returns `ErrUnsupported` off Windows
(`internal/ipc/stub_other.go`), so the engine never starts. Shipping them means a
real unix-socket IPC port — an `overhaul`-skill change, not a stub fix.
+
+## Agent-management surfacing (backend ahead of the GUI)
+
+The per-agent identity/enrollment/config work landed backend-first; these surfaces are
+implemented and tested in Go but only partly exposed. None is a bug — each is an unbuilt
+GUI affordance, so they belong here, not in the "Reality check" (nothing is *oversold*).
+
+26. **Enrollment issuance is under-surfaced** — `IssuePairingCode` (mode + scope + ttl) is
+ reachable only from the first-run wizard's reusable toggle (`frontend/src/screens/Wizard.tsx`),
+ with ttl and scope fixed. The Overview pairing strip still hands out the *legacy
+ shared-token* code (`frontend/src/screens/Overview.tsx`), and the Agents screen has no
+ issue-code control — so the headline per-agent-enrollment path isn't the default one.
+ Fix: an issue-code control (mode/scope/ttl) on the Agents screen, and switch Overview's
+ strip to `IssuePairingCode`.
+27. **Port-conflict cards are informational, not one-click** — the gateway auto-reassigns a
+ clashing public port and logs it, but the roster's conflict cards
+ (`frontend/src/screens/Agents.tsx ConflictCards`) only offer "View agent"; the planned
+ one-click "take the port / evict the other" needs a gateway reclaim op. Auto-reassign
+ already avoids the hard failure, so this is a convenience affordance. Fix: a reclaim
+ engine op + a card action.
+28. **No per-agent "Update" nudge** — `AgentView` (`gateway.go AgentView`) carries no agent
+ app version, so a version-skewed agent can't be flagged in the roster. Fix: thread the
+ agent's reported version onto `AgentView` and render a card badge.
+29. **Config overlay/promote has no GUI** — gateway-authoritative sync works at the wire
+ level (push/propose/adopt), but no App method or view shows a pending local edit and a
+ "propose to gateway" action. The gateway reconciles silently, so nothing is broken; the
+ L2 overlay view is simply unbuilt. Fix: surface the pending overlay + a promote button
+ when the agent's set diverges.
+30. **Per-agent transport pin absent** — transport (auto / quic / per-conn / mux) is one
+ global agent setting; per-agent management (`frontend/src/screens/Agents.tsx`) can't pin
+ a single agent's transport. Low priority. Fix: a per-agent override if ever needed.
+31. **`link.TunnelID` minter is unused in production** — the typed `tnl_` + `-2`-suffix
+ minter (`link/cred.go TunnelID`, tested by `TestTunnelID`) is not on the real
+ tunnel-create path, which still mints random hex (`config/config.go NewID`). Cross-agent
+ tunnel-ID uniqueness isn't required (per-agent namespacing + port auto-reassign handle
+ clashes), so it's a latent primitive, not a bug. Fix: adopt `tnl_` IDs on create, or drop
+ the unused minter.
diff --git a/internal/control/control.go b/internal/control/control.go
index e5f631b..67dd115 100644
--- a/internal/control/control.go
+++ b/internal/control/control.go
@@ -182,9 +182,11 @@ type Hello struct {
LocalIPs []string `json:"localIps,omitempty"`
// AgentPubKey is the agent's long-term Ed25519 public key; AgentSig is its
// signature over this connection's TLS channel binding (proof of possession).
- // Together they authenticate the agent per-identity against the gateway's
- // allowlist (CapEnroll). Both omitempty: a legacy agent that only knows the
- // shared token sends neither and the gateway falls back to the token.
+ // Together they authenticate an already-enrolled agent per-identity against
+ // the gateway's allowlist; first-contact enrollment rides EnrollTicket below.
+ // Identity is field-driven, not a capability (acted on before negotiation).
+ // Both omitempty: a legacy agent that only knows the shared token sends
+ // neither and the gateway falls back to the token.
AgentPubKey []byte `json:"agentPubKey,omitempty"`
AgentSig []byte `json:"agentSig,omitempty"`
// EnrollTicket is a pairing ticket present only on a first-contact enrollment
diff --git a/internal/doccheck/citations_test.go b/internal/doccheck/citations_test.go
index c1a9a23..7ad5df5 100644
--- a/internal/doccheck/citations_test.go
+++ b/internal/doccheck/citations_test.go
@@ -42,6 +42,7 @@ var ignoredFiles = map[string]bool{
"stats.json": true,
"stats.redacted.json": true,
"config.toml": true,
+ "gateway_agents.json": true, // the gateway's per-agent allowlist, written at runtime
}
// Placeholder test names used in prose ("go test -run TestX ...").