Sprint 3. Closes M5 from the product-doc gap analysis — gives users
a safe, opt-in view of "which agents got which permissions over which
resources" without exposing plaintext credentials.
Per docs/product-package/10-frontend-detail-spec.md §"执行前确认卡"
and §13.9:
- Default folded. Only a "Show manifest" button surfaces.
- When expanded, shows the five policy-safe columns per resource
grant: resource (type + id), allowed actions, constraints,
secret_ref, status.
- secret_ref is rendered via maskSecretRef() which keeps the
vault path scheme + first 6 chars of the leaf and ellipsises
the rest. The full plaintext value (if anyone ever puts one
there by mistake) is NEVER rendered.
- Constraints render as compact key=value chips, value truncated
at 24 chars to keep the row scannable.
- Status pill colors mirror the device-binding active/revoked
palette established in May.
- Footer note reminds the reader: "Plaintext credentials are
never shown. The secret_ref column is a vault pointer, not
the secret itself."
Existing "Permission manifest" stub card grew the new toggle in
place (no new card column added); the Events / Audit usage cards
stay on the same grid. M5 unblocks Sprint 4 (M6 execution-confirm
card) which depends on the same data layout.
Verification:
- tsc --noEmit clean
- go test ./controller/... ./middleware/... ./model/... all green
- no backend change in this commit — pure frontend work over the
existing AgnetDeployment payload shape
- i18n additions for en + zh
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 2. Materialises the six platform-recommended Agnet roles
documented in docs/product-package/13-platform-description.md §3.
Backend:
- controller/agnet_role_template.go (new): AgnetRoleTemplate type
+ canonical catalog returned by GET /api/agnet/role-templates.
Six roles: product / architect / frontend / backend / reviewer / ops.
Stored as constants (not DB rows) because they are platform
contracts, not user-editable data. Each entry carries:
- stable key (frontend dispatches on this — never rename)
- display name + summary (translatable)
- default model recommendation
- default permission scope hints
- risk classification (low/medium/high) — Ops alone is high,
matching the production-deploy-needs-approval rule
- router/api-router.go: mount GET /api/agnet/role-templates inside
the existing /api/agnet group (same auth as the other endpoints)
- controller/agnet_role_template_test.go (new): 4 tests pin the
six-role set, risk-level matrix, HTTP envelope shape, and the
closed-set helper that will gate validation later
Frontend:
- features/agnet-console/api.ts: new AgnetRoleTemplate type + a
module-level cached listAgnetRoleTemplates() helper. Caching
means the picker doesn't refetch every time the deployment sheet
opens.
- features/agnet-console/create-agnet-deployment-sheet.tsx:
- Replace free-text role_template Input with a Select bound to
the catalog; falls back to Input if the catalog is empty so
the form stays usable when the endpoint is down.
- Fix two informal role names in built-in presets (debugger →
reviewer, executor → backend) so presets reference only
canonical keys.
Verification:
- go test ./controller/... ./middleware/... ./model/... all green
(4 new role-template tests + existing suite)
- frontend tsc --noEmit clean
- zero touch on the token / device-signature hot paths
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sprint 1 of the post-product-doc gap closure. Replaces the previous
in-process `agnetEvents map[string][]agnetEvent` (lost on every
container restart) with a real GORM table `agnet_audit_events`.
What changed:
- model/agnet_audit.go (new): AgnetAuditEvent model + InsertAgnetAuditEvent
/ ListAgnetAuditEvents / ListAgnetAuditEventsByDeployment helpers.
Indexes picked for the dashboard queries: user_id, deployment_id,
binding_scope, occurred_at desc.
- model/main.go: AutoMigrate &AgnetAuditEvent{} alongside the existing
schema (SQLite/MySQL/PostgreSQL compatible per CLAUDE.md Rule 2).
- controller/agnet_control_plane.go: drop agnetEvents map; the 3
producer sites (deployment accepted / stop / sk_snapshot_refreshed)
now call recordAgnetAuditEvent which writes to DB best-effort.
The 3 reader sites (events list / logs / audit-logs) now query
the table; AgnetListAuditLogs also supports limit/offset pagination.
- controller/agnet_control_plane_test.go: reset helper no longer
touches the deleted map.
- model/agnet_audit_test.go (new): 4 tests covering persistence,
nil-guard production safety, filter+paginate, chronological reads.
Sidebar UX:
- "Preparation checklist" → "Resource binding"
Per product-package doc README §统一表述 — user-facing term is
"资源绑定" not "准备清单". URL /sk-sources kept to preserve
bookmarks; can rename in a later pass with redirect.
Verification:
- go test ./controller/... ./middleware/... ./model/... all green
- go vet clean
- frontend tsc --noEmit clean
- audit writes are best-effort: errors log via SysLog but never
fail the user API call; DB nil-guards in place
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User feedback: "都已经已撤销了为什么还有记录" — once a user clicks
revoke they expect the row gone from the list, not lingering with
a "已撤销" badge. The old behaviour treated the page as a security
audit log, which conflicts with its primary use as an active-device
management surface.
GetUserDeviceBoundTokens now filters `revoked_at = 0`. The row stays
in DB (soft-delete) so:
- audit trail (RevokedAt / RevokedReason / DeviceLastSeenIp /
DeviceFingerprint) remains inspectable by admins
- re-pair from the same physical device still self-heals the row
via the reactivate branch in PairDevice — covered by existing
TestPairDevice_RepairAfterRevokeReactivates
If a user wants security audit history in the UI, that should be a
separate "Security activity" page; not the device-management list.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previous SetTrustedProxies commit (407dbb7) was necessary but
insufficient. In production Manager sits behind Cloudflare in
proxy mode, which:
- strips the inbound X-Forwarded-For header
- sets CF-Connecting-IP with the real client IP
Gin's default ClientIP() only knows about X-Forwarded-For + X-Real-IP
— it does NOT recognize CF-Connecting-IP. So every request showed the
docker bridge peer (10.2.3.4) in audit fields and rate-limit buckets
even after we added private ranges to TrustedProxies.
Setting TrustedPlatform = gin.PlatformCloudflare instructs Gin to
read CF-Connecting-IP as ground truth, bypassing the XFF parser.
When the header is absent (health checks, direct non-CF probes)
Gin falls back through TrustedProxies → XFF → RemoteAddr as before.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Manager was created via gin.New() without calling SetTrustedProxies,
which in Gin v1.7+ defaults to trusting NOTHING — c.ClientIP() returned
the docker bridge peer (e.g. 10.2.3.4) instead of the real client IP
populated in X-Forwarded-For by the front reverse proxy.
Symptoms observed in production:
- Devices page showed every user's "Last IP" as 10.2.3.4 / 10.2.3.5
- tokens.device_last_seen_ip audit field useless for security review
- Token IP allowlists effectively bypassed (always saw docker IP)
- Rate-limit buckets keyed on docker IP — all users share a bucket
Fix: SetTrustedProxies with the standard RFC1918 + loopback ranges.
Covers every realistic Manager topology (docker compose, k8s ClusterIP,
reverse proxy on same VM). Cloudflare-direct topologies still need the
CF published ranges added; document that inline rather than auto-fetch
since we currently always front with Caddy/nginx.
UI cosmetic: When device_name is empty (pre-0.3.3 desktop clients
didn't always send it), Devices page now synthesises a label like
"Windows · 4f3a" from platform + last 4 chars of device_id instead
of the generic "Unnamed device", so users can tell their devices
apart at a glance.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Server-side bug fixes (zero client-impact):
- fix(devices): re-pair after revoke now reactivates the row instead of
returning a stale "reused:true" response. Before this, a user who
revoked a device in Web UI then re-launched the desktop app got
HTTP 200 from /pair but every subsequent V2 request 401'd with
ErrDeviceRevoked, leaving them locked out.
- feat(v2): V2 auth failures now carry X-Heicode-Server-Time and
X-Heicode-Auth-Error response headers. Lets the desktop client
distinguish clock drift (timestamp_drift) from revoke/signature
failures and show actionable messages instead of "Token invalid".
- fix(devices): RenameUserDevice rejects whitespace-only names (400)
and truncates by rune count instead of bytes, so multi-byte UTF-8
names (Chinese / Japanese) don't get mangled at the 64-byte boundary.
- feat(devices): RevokeUserDevice writes a SysLog audit line with
user_id / token_id / device_id / device_name / operator IP / reason.
Symmetric with the existing "reactivated revoked device" log so
admins can trace both transitions when investigating lockouts.
- fix(devices): GetUserDeviceBoundTokens sort uses
CASE WHEN device_last_used_at = 0 THEN device_bound_at ELSE
device_last_used_at END DESC so a freshly-paired device doesn't
sink below older but actively-used machines in the Devices list.
Portable across SQLite / MySQL / PostgreSQL.
Web UI (web/default):
- New /devices route + features/devices/ page with table, revoke
AlertDialog, rename Dialog, greyed-out revoked rows, empty state.
- Sidebar "Personal" group now shows "Devices" between Models and
Account security (Smartphone icon).
- i18n strings added to zh.json + en.json.
Tests:
- 8 new tests covering re-pair reactivation, rename validation
edge cases, sort order, audit log shape, V2 error code mapping,
and diagnostic header emission. Full controller / middleware /
model suite remains green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The cc-haha desktop client used to read its balance from
/v1/dashboard/billing/{subscription,usage}. Those endpoints honor the
token row's UnlimitedQuota flag — and device-bound tokens have that
flag set true because they are an auth mechanism, not a billing
boundary. Result: the balance pill always showed 100_000_000 USD
regardless of the user's real balance.
The right source is the user row (User.Quota / UsedQuota /
RequestCount), which is what /api/user/self surfaces to the web
dashboard. But that endpoint is UserAuth-only (session cookie / JWT),
which the desktop client doesn't carry — it holds a sk- bearer or
signs requests with its V2 device key.
This commit adds a slim sibling endpoint /api/heicode/self mounted on
TokenAuth so either sk- or V2 signature authenticates. Returns only
the fields the desktop balance pill + usage panel consume (quota,
used_quota, request_count, plus username/group/role for the title
bar) — no PII beyond what relay calls already expose. Quota numbers
go through the same QuotaPerUnit / display-type normalization that
billing.go uses, so the desktop pill and web dashboard show the same
number.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old behavior: any (user_id, device_id) duplicate returned 409 and the
per-user device cap was checked BEFORE the dup-check. Combined effect:
a client that already paired once but lost the Manager row (or just
wants to re-confirm on every startup) hit 409 or 403 forever, with no
way to recover except an admin DELETE.
New behavior:
- Same (user_id, device_id, pubkey) tuple → 200 with reused:true.
Lets bootstrap call pair on every login as an idempotent liveness
probe.
- Same (user_id, device_id) but different pubkey → 409 with explicit
"already paired with different key" message. Client treats this as
a signal to clear local identity and regenerate.
- Cap check moved AFTER the dup check so re-pair of an existing
device is never blocked by "device limit reached".
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After 67225fd fixed the V2 chat 403 caused by missing SetupContextForToken,
the next probe call surfaced a new 403:
"token quota is not enough, token remain quota: \$0.000000,
need quota: \$0.001590"
Root cause: PairDevice initialised the new tokens row with
UnlimitedQuota=false and didn't set RemainQuota, so it defaulted to 0.
Every subsequent V2 chat then failed at pre-consume since the token had
no spendable budget — even though the user's actual User.Quota was
positive.
Device tokens aren't a billing boundary in our model; they're the
Ed25519 binding for a single client install. Quota belongs on the User
row. Flip UnlimitedQuota=true so the relay path consumes from
User.Quota directly, matching exactly what the legacy sk- bearer was
already doing (legacy tokens in this deployment are unlimited too).
Verified end-to-end via /tmp/v2_probe2.js after deploy: POST
/v1/messages with full V2 envelope returns HTTP 200 with the model's
reply.
V2 chat returned HTTP 403 with body
{"error":{"type":"new_api_error","message":"record not found ..."}}
even after Manager body_decrypt and Ed25519 verify both passed and the
device row was found. Root cause: the V2 dispatch in TokenAuth set
`id` + `token_id` directly via VerifyV2DeviceSignedRequest, called
applyTokenPolicyAndContext for IP/user/group checks, then jumped to
c.Next() — skipping SetupContextForToken entirely.
SetupContextForToken populates eight more keys the downstream relay
and billing pipeline expect:
token_key, token_name, token_unlimited_quota, token_quota,
token_model_limit_enabled, token_model_limit,
ContextKeyTokenGroup, ContextKeyTokenCrossGroupRetry
Without them, channel distribute / pre-consume / log_consume
silently misroute and a generic "record not found" leaks out as 403.
The legacy bearer path didn't have this bug because it always
finished with SetupContextForToken before c.Next().
Verified with /tmp/v2_probe2.js (after this deploys): POST
/v1/messages with full V2 envelope returns HTTP 200 with the model's
reply body.
Real test in C:\temp\v2_probe.js shows POST /api/devices/pair returning
HTTP 200 with body {"success":false, "message":"Unauthorized, invalid
access token"} when called with a Bearer sk- — the same sk- the OAuth
callback hands the client. Root cause: the route group used UserAuth(),
which only accepts a session cookie or a user JWT in Authorization, not
a relay-tier sk- bearer.
The OAuth-redirect flow (Heicode default) never produces a JWT — it
just hands cc-haha a sk-. So in production the pair call after
"一键登录" always 401'd, device-binding never activated, and V2
encryptedFetch silently fell back to legacy bearer for every request.
Fix: split the /devices route into two groups.
- /devices/* (list, rename, revoke): still UserAuth(). A sk- must
NOT be allowed to enumerate or revoke another device — that
would let an attacker with a stolen sk- delete the legitimate
owner's device binding.
- /devices/pair: TokenOrUserAuth(). Pair is the bootstrap step, by
definition no device key exists yet, so sk- IS the only credential
available on the OAuth-redirect flow.
TokenOrUserAuth calls c.Set("id", token.UserId) via its TokenAuth
fallback, so the PairDevice controller's c.GetInt("id") keeps working.
Verified by re-running v2_probe.js after deploy: pair returns
HTTP 200 success:true.
Eliminate sk- bearer from the client wire entirely. V2 requests
authenticate via Ed25519 device signature (over a canonical that
binds method/path/timestamp/nonce/fingerprint/eph-pubkey/plaintext-
body-hash) and encrypt the request body with X25519 ECDH +
ChaCha20-Poly1305-AEAD. Server-issued sk- tokens still exist for
legacy callers during a 30-day deadline window; after the deadline
bare-bearer sk- on /v1/* is rejected.
What's new server-side:
- model/server_key.go + service/server_keys.go: long-lived X25519
keypair persisted in DB. Private half is AES-256-GCM-sealed with a
key derived from CRYPTO_SECRET so a SQL dump alone doesn't leak it.
Generated on first launch by main.go::EnsureServerECDHKey.
- common/crypto.go: SealWithCryptoSecret / UnsealWithCryptoSecret
helpers (AES-GCM); SafeWipe defense-in-depth zero-out.
- controller/server_pubkey.go + GET /api/server-pubkey: public
endpoint clients fetch at startup to obtain the ECDH pubkey.
- middleware/body_decrypt.go: ChaCha20-Poly1305 decrypt of V2 bodies.
AD binds device_id/timestamp/nonce/method/path so tampering any
fails AEAD verify. Replaces c.Request.Body with plaintext for
downstream relay handlers to consume unchanged.
- middleware/device_signature.go: new VerifyV2DeviceSignedRequest()
looks up token by device_id (not bearer) and verifies an extended
canonical that includes the ephemeral pubkey + plaintext body hash.
- middleware/auth.go::TokenAuth: dispatch on Content-Encoding header.
V2 path skips ValidateUserToken entirely. Legacy path adds a 30-day
/v1/* deadline knob.
- model/token.go::FindTokenByDeviceId: V2 lookup helper.
- controller/device.go::PairDevice: stops returning the sk in
responses. Client identifies itself by device_id + signature from
now on, no bearer needed.
- setting/operation_setting/device_binding_setting.go: new
LegacySkV1DeadlineMs knob (0 = disabled until operator sets it).
Backward compatibility: V1 device-signed tokens (those issued by
the earlier PairDevice that DID return a sk-) keep working through
the legacy bearer path; the existing V1 signature middleware still
runs for them. The 30-day deadline is opt-in until ops sets it.
Tests: V1 regression suite passes (middleware + common).
V2-specific tests come in a follow-up commit alongside the client
encryptedFetch wiring; deferring lets us land the server-side
plumbing first without coupling.