feat(manager): version 1.4.2 — H2 sk- hash + M9 audit drawer + M3 docs + M7 vault

Bundled release bumping Manager to 1.4.2 with four product-doc gap
closures lined up in a single deploy.

VERSION:
  - 1.2.0 → 1.4.2 (catches up after Sprints 1-5 shipped under 1.2.0)

H2 — sk- hash phase A (server-side, zero client impact):
  - tokens table: new key_hash varchar(64) index column
  - Token.Insert() dual-writes Key + KeyHash on every new token
  - BackfillTokenKeyHash() runs at startup, batches 500 rows at a
    time, idempotent. Fills legacy rows that pre-date the column
    without blocking app boot
  - 5 unit tests pin: sha256 correctness, dual-write on Insert,
    empty Key → empty hash, backfill behaviour, idempotency
  - Phase B (switch lookup index off plaintext + drop Key column)
    can ship later once telemetry shows key_hash IS NULL count is 0

M9 — task detail drawer with audit timeline:
  - Deployments page click → Sheet drawer with RunDetailPanel +
    new RunAuditTimeline component
  - Timeline pulls from existing /api/agnet/deployments/:id/events
    which Sprint 1 already wired to the persistent
    agnet_audit_events table — no new backend
  - Vertical timeline w/ coloured dots (primary / amber / rose by
    classifyEventLevel), occurred_at + correlation_id per row,
    max-height + overflow for long traces
  - 15s polling; empty/loading/error states all rendered

M3 — project_doc as a first-class binding step:
  - Resource binding wizard split "SK or project docs" into two
    distinct steps: "Connect project docs" + "Connect SK skill packs"
  - Each step's Connect button pre-selects the matching type in
    the advanced sheet so users don't accidentally tag a doc repo
    as Git or SK
  - Summary dialog still receives the combined skOrDocSources view
    to keep the recommendation-card contract unchanged

M7 — secret vault status (admin panel):
  - controller/secret_store.go: new GetSecretStoreStatus handler
    + fetchHealth() method. Hits OpenBao /sys/health (token-less
    upstream endpoint), maps to a sanitized response — NEVER
    returns secret names or values per product docs §13.9
  - Graceful degradation: env vars unset → "not configured" pill;
    network error → "unreachable"; sealed → amber warning; healthy
    → green
  - Mounted at GET /api/secret-store/status behind middleware.AdminAuth
  - New SecretStoreSection in system-settings/maintenance,
    registered before Performance. Read-only card with refresh
    button, 7 status fields, message line, "how to enable" hint

Verification:
  - go vet ./... clean
  - go test ./controller/... ./middleware/... ./model/... all green
  - tsc --noEmit clean
  - Backend M7 endpoint deliberately tolerant — production may not
    have OPENBAO_ADDR set yet, UI shows "not configured" instead of
    500ing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 11:19:42 +08:00
co-authored by Claude Opus 4.7
parent 3fa345ff5e
commit d8f61957f0
11 changed files with 839 additions and 16 deletions
+1 -1
View File
@@ -1 +1 @@
1.2.0
1.4.2
+106
View File
@@ -10,6 +10,8 @@ import (
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/heicode/manager/common"
)
@@ -102,3 +104,107 @@ func readSecretStoreError(body io.Reader) string {
}
return strings.Join(payload.Errors, "; ")
}
// secretStoreHealth is what `GET /v1/sys/health` returns on the
// OpenBao/Vault side. We only pull the public-safe fields — version
// string, initialized/sealed/standby flags, server time. No cluster
// IDs, no auth lease ttls, nothing that could leak operator detail.
type secretStoreHealth struct {
Initialized bool `json:"initialized"`
Sealed bool `json:"sealed"`
Standby bool `json:"standby"`
Version string `json:"version"`
ServerTime int64 `json:"server_time_utc"`
}
// fetchHealth pings the OpenBao /sys/health endpoint. That endpoint
// is intentionally token-less in upstream Vault — it's the standard
// liveness probe — so we don't include X-Vault-Token here. Vault
// returns non-2xx HTTP codes for sealed/uninitialized states by
// design (200=initialized+unsealed, 429=standby, 472=DR secondary,
// 473=performance standby, 501=not initialized, 503=sealed). All of
// those still ship a JSON body with the same fields, so we always
// decode and let the caller interpret the status field separately.
func (s secretStoreClient) fetchHealth() (secretStoreHealth, int, error) {
url := fmt.Sprintf("%s/v1/sys/health", s.address)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return secretStoreHealth{}, 0, err
}
resp, err := s.client.Do(req)
if err != nil {
return secretStoreHealth{}, 0, err
}
defer resp.Body.Close()
var h secretStoreHealth
if err := common.DecodeJson(resp.Body, &h); err != nil {
return secretStoreHealth{}, resp.StatusCode, err
}
return h, resp.StatusCode, nil
}
// secretStoreStatusResponse is the public-safe envelope rendered by
// the admin status page. Crucially does NOT include the configured
// address (could leak internal network topology), the token, the
// mount path, or any secret names.
type secretStoreStatusResponse struct {
Configured bool `json:"configured"` // env vars present?
Reachable bool `json:"reachable"` // /sys/health responded?
Initialized bool `json:"initialized,omitempty"`
Sealed bool `json:"sealed,omitempty"`
Standby bool `json:"standby,omitempty"`
Version string `json:"version,omitempty"`
Message string `json:"message,omitempty"` // human-readable status
CheckedAt int64 `json:"checked_at"` // unix ms (server clock)
}
// GetSecretStoreStatus returns a sanitized vault health snapshot for
// the admin "密钥保管器" status panel. Admin-only (route is mounted
// under the rootRoute group in api-router.go so non-admins can't even
// reach it). The handler is deliberately tolerant: missing env vars,
// network errors, and sealed-vault responses ALL render usable JSON
// the UI can present — we don't bubble HTTP 500 for any of them, the
// "missing config" / "network error" / "sealed" states are valid
// operating modes the UI must visualise distinctly.
func GetSecretStoreStatus(c *gin.Context) {
out := secretStoreStatusResponse{
CheckedAt: time.Now().UnixMilli(),
}
client, err := newSecretStoreClientFromEnv()
if err != nil {
// Most common case in current production: OPENBAO_TOKEN not
// set. Render as "not configured" instead of a server error.
out.Configured = false
out.Message = "secret store env vars not set (OPENBAO_ADDR / OPENBAO_TOKEN). The vault container may still be running but Manager is not wired up to it yet."
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
out.Configured = true
health, status, err := client.fetchHealth()
if err != nil {
out.Reachable = false
out.Message = "network error contacting secret store: " + err.Error()
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
return
}
out.Reachable = true
out.Initialized = health.Initialized
out.Sealed = health.Sealed
out.Standby = health.Standby
out.Version = health.Version
// Vault uses non-2xx HTTP for sealed/uninit states — surface a
// short reason so the UI can show a coloured pill without parsing
// the boolean matrix itself.
switch {
case !health.Initialized:
out.Message = fmt.Sprintf("secret store reachable but not initialized (HTTP %d)", status)
case health.Sealed:
out.Message = fmt.Sprintf("secret store reachable but SEALED — operator must unseal (HTTP %d)", status)
case health.Standby:
out.Message = fmt.Sprintf("secret store in standby mode (HTTP %d)", status)
default:
out.Message = "secret store is healthy"
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
+8
View File
@@ -303,6 +303,14 @@ func migrateDB() error {
return err
}
}
// H2 phase A: best-effort backfill of tokens.key_hash for legacy
// rows that pre-date the column. Idempotent; safe to run on every
// startup. Log + continue on error — auth still works via
// plaintext Key lookup during phase A, the backfill is only for
// future phase B.
if err := BackfillTokenKeyHash(); err != nil {
common.SysLog("BackfillTokenKeyHash on startup: " + err.Error())
}
return nil
}
+85 -3
View File
@@ -1,6 +1,8 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strings"
@@ -16,6 +18,21 @@ type Token struct {
Id int `json:"id"`
UserId int `json:"user_id" gorm:"index"`
Key string `json:"key" gorm:"type:varchar(128);uniqueIndex"`
// KeyHash is the SHA-256 hex digest of the raw `Key` value. H2
// phase A: dual-write — every Insert and key-update path writes
// both Key and KeyHash. ValidateUserToken still looks up by Key
// for now; once telemetry shows zero-NULL coverage we can flip
// the lookup to KeyHash, drop the Key uniqueIndex, and stop
// storing plaintext bearers in the DB. This is the upgrade path
// the V2 device-binding work needs to complete the "no long-lived
// plaintext credential at rest" promise from the product docs §13.9.
//
// Indexed for forward compatibility — phase B switches lookups to
// this column. Nullable / empty during the rollout window:
// - new rows: filled by Token.Insert / UpdateKey paths
// - legacy rows: filled by BackfillTokenKeyHash on startup
// SHA-256 hex == 64 chars; varchar(64) is the natural size.
KeyHash string `json:"-" gorm:"type:varchar(64);index;column:key_hash;default:''"`
Status int `json:"status" gorm:"default:1"`
Name string `json:"name" gorm:"index" `
CreatedTime int64 `json:"created_time" gorm:"bigint"`
@@ -474,10 +491,75 @@ func GetTokenByKey(key string, fromDB bool) (token *Token, err error) {
return token, err
}
// BackfillTokenKeyHash fills tokens.key_hash for any rows that still
// have an empty hash but a non-empty plaintext Key. Idempotent — safe
// to call on every container startup. Batches rows so a large legacy
// `tokens` table (10k+ rows) doesn't blow up memory.
//
// This is the H2 phase-A backfill. Once telemetry shows
// COUNT(*) WHERE key_hash = '' AND key != '' equals zero across all
// environments, phase B switches ValidateUserToken to look up by hash
// and we can drop the Key column.
func BackfillTokenKeyHash() error {
if DB == nil {
return nil
}
const batchSize = 500
totalUpdated := 0
for {
var rows []Token
// Select only id + key, skip deleted-at rows, only the ones
// that still need a hash. The model's regular Find honours
// soft-delete; that's correct — we don't backfill tombstones.
err := DB.Select("id", "key").
Where("(key_hash IS NULL OR key_hash = '') AND key IS NOT NULL AND key <> ''").
Limit(batchSize).
Find(&rows).Error
if err != nil {
return err
}
if len(rows) == 0 {
break
}
for _, r := range rows {
hash := computeKeyHash(r.Key)
if hash == "" {
continue
}
if err := DB.Model(&Token{}).Where("id = ?", r.Id).
Update("key_hash", hash).Error; err != nil {
return err
}
totalUpdated++
}
if len(rows) < batchSize {
break
}
}
if totalUpdated > 0 {
common.SysLog(fmt.Sprintf("BackfillTokenKeyHash: filled %d rows", totalUpdated))
}
return nil
}
// computeKeyHash returns the SHA-256 hex digest of `key`. H2 phase A —
// stored in tokens.key_hash alongside the plaintext Key so we can move
// the auth lookup off plaintext in phase B without a downtime
// migration. Empty key returns empty string (zero-value, intentional).
func computeKeyHash(key string) string {
if key == "" {
return ""
}
sum := sha256.Sum256([]byte(key))
return hex.EncodeToString(sum[:])
}
func (token *Token) Insert() error {
var err error
err = DB.Create(token).Error
return err
// H2: always populate KeyHash on write so phase B can flip the
// lookup index without a one-off backfill round-trip. Safe to
// re-set on every Insert — value is deterministic from Key.
token.KeyHash = computeKeyHash(token.Key)
return DB.Create(token).Error
}
// Update Make sure your token's fields is completed, because this will update non-zero values
+171
View File
@@ -0,0 +1,171 @@
package model
import (
"crypto/sha256"
"encoding/hex"
"testing"
)
// H2 phase A — tests pin the dual-write behaviour:
// 1. Insert() populates KeyHash from Key automatically.
// 2. computeKeyHash matches stdlib sha256 hex.
// 3. BackfillTokenKeyHash fills legacy rows in batches and is
// idempotent (running it twice doesn't change anything).
// 4. Empty Key produces empty hash (no spurious SHA of "").
//
// All tests share the package-level DB set up in task_cas_test.go's
// TestMain. We DELETE FROM tokens at the top of each case so the
// shared SQLite stays isolated.
func resetTokensTable(t *testing.T) {
t.Helper()
if err := DB.AutoMigrate(&Token{}); err != nil {
t.Fatalf("migrate: %v", err)
}
if err := DB.Exec("DELETE FROM tokens").Error; err != nil {
t.Fatalf("truncate: %v", err)
}
}
func TestComputeKeyHash_MatchesStdlibSHA256(t *testing.T) {
cases := []struct{ in, want string }{
{"", ""},
{"a", func() string {
s := sha256.Sum256([]byte("a"))
return hex.EncodeToString(s[:])
}()},
{"sk-deadbeef1234567890abcdef", func() string {
s := sha256.Sum256([]byte("sk-deadbeef1234567890abcdef"))
return hex.EncodeToString(s[:])
}()},
}
for _, c := range cases {
got := computeKeyHash(c.in)
if got != c.want {
t.Errorf("computeKeyHash(%q) = %q, want %q", c.in, got, c.want)
}
}
}
func TestTokenInsert_PopulatesKeyHash(t *testing.T) {
resetTokensTable(t)
tok := &Token{
UserId: 7,
Name: "h2-test",
Key: "sk-h2-insert-deadbeef0001",
}
if err := tok.Insert(); err != nil {
t.Fatalf("insert: %v", err)
}
if tok.KeyHash == "" {
t.Fatal("KeyHash should be set after Insert()")
}
want := computeKeyHash(tok.Key)
if tok.KeyHash != want {
t.Errorf("KeyHash = %q, want %q", tok.KeyHash, want)
}
// Confirm DB row carries the hash.
var stored Token
if err := DB.First(&stored, tok.Id).Error; err != nil {
t.Fatalf("lookup: %v", err)
}
if stored.KeyHash != want {
t.Errorf("DB row KeyHash = %q, want %q", stored.KeyHash, want)
}
}
func TestTokenInsert_EmptyKey_YieldsEmptyHash(t *testing.T) {
// Defensive: rows that legitimately have no key (e.g. test fixtures)
// should not get a SHA-256 of empty string in the hash column.
resetTokensTable(t)
tok := &Token{
UserId: 8,
Name: "h2-no-key",
Key: "",
}
// SQLite uniqueIndex on Key blocks two empty-key rows; that's
// orthogonal to this test which only asserts hash behaviour
// against the row we did insert.
if err := tok.Insert(); err != nil {
t.Fatalf("insert: %v", err)
}
if tok.KeyHash != "" {
t.Errorf("empty Key should yield empty KeyHash, got %q", tok.KeyHash)
}
}
func TestBackfillTokenKeyHash_FillsLegacyRows(t *testing.T) {
resetTokensTable(t)
// Seed: 3 rows directly via DB.Create (bypasses Insert hook) to
// simulate legacy rows from before the column existed.
for i, k := range []string{"sk-leg-001", "sk-leg-002", "sk-leg-003"} {
row := Token{UserId: i + 1, Name: "legacy", Key: k, KeyHash: ""}
if err := DB.Create(&row).Error; err != nil {
t.Fatalf("seed %d: %v", i, err)
}
}
// One row with empty Key — backfill must leave it alone.
if err := DB.Create(&Token{UserId: 99, Name: "no-key", Key: "", KeyHash: ""}).Error; err != nil {
t.Fatalf("seed empty: %v", err)
}
if err := BackfillTokenKeyHash(); err != nil {
t.Fatalf("backfill: %v", err)
}
// All three keyed rows now have a hash.
var keyed []Token
if err := DB.Where("key <> ''").Find(&keyed).Error; err != nil {
t.Fatalf("query: %v", err)
}
for _, r := range keyed {
want := computeKeyHash(r.Key)
if r.KeyHash != want {
t.Errorf("row id=%d key=%q has KeyHash=%q, want %q",
r.Id, r.Key, r.KeyHash, want)
}
}
// The empty-key row stays empty.
var emptyKeyRow Token
if err := DB.Where("key = ''").First(&emptyKeyRow).Error; err != nil {
t.Fatalf("empty row: %v", err)
}
if emptyKeyRow.KeyHash != "" {
t.Errorf("empty-key row should keep empty hash, got %q", emptyKeyRow.KeyHash)
}
}
func TestBackfillTokenKeyHash_Idempotent(t *testing.T) {
// Production safety: backfill runs on every container start.
// Running it on an already-backfilled table must be a no-op,
// not a double-write that thrashes Redis cache invalidation.
resetTokensTable(t)
tok := &Token{UserId: 1, Name: "ok", Key: "sk-stable-001"}
if err := tok.Insert(); err != nil {
t.Fatalf("insert: %v", err)
}
firstHash := tok.KeyHash
// First backfill — should already see hash filled by Insert, do nothing.
if err := BackfillTokenKeyHash(); err != nil {
t.Fatalf("backfill 1: %v", err)
}
// Second backfill — same.
if err := BackfillTokenKeyHash(); err != nil {
t.Fatalf("backfill 2: %v", err)
}
var after Token
if err := DB.First(&after, tok.Id).Error; err != nil {
t.Fatalf("lookup: %v", err)
}
if after.KeyHash != firstHash {
t.Errorf("hash drifted across backfills: was %q, now %q",
firstHash, after.KeyHash)
}
}
+4
View File
@@ -29,6 +29,10 @@ func SetApiRouter(router *gin.Engine) {
apiRouter.GET("/uptime/status", controller.GetUptimeKumaStatus)
apiRouter.GET("/models", middleware.UserAuth(), controller.DashboardListModels)
apiRouter.GET("/status/test", middleware.AdminAuth(), controller.TestStatus)
// M7 — secret-store (OpenBao / Vault) status snapshot for the
// admin "密钥保管器" panel. Admin-only because the response
// reveals seal state + version which we don't want public.
apiRouter.GET("/secret-store/status", middleware.AdminAuth(), controller.GetSecretStoreStatus)
apiRouter.GET("/notice", controller.GetNotice)
apiRouter.GET("/user-agreement", controller.GetUserAgreement)
apiRouter.GET("/privacy-policy", controller.GetPrivacyPolicy)
+185 -10
View File
@@ -62,6 +62,13 @@ import {
type ResourceType,
} from '@/lib/heicode-mcp'
import { CreateAgnetDeploymentSheet } from './create-agnet-deployment-sheet'
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import { toast } from 'sonner'
type StatusKey = 'running' | 'success' | 'failed' | 'pending'
@@ -679,10 +686,141 @@ export function AgnetDeploymentsPage() {
open={createOpen}
onOpenChange={setCreateOpen}
/>
{/* M9 — task detail drawer. Click a deployment card → audit
timeline + permission manifest fold render here. selectedRun
is derived from selectedRunId; the drawer mirrors that
source-of-truth and closes by clearing the id. */}
<Sheet
open={Boolean(selectedRunId) && Boolean(selectedRun)}
onOpenChange={(o) => {
if (!o) setSelectedRunId(undefined)
}}
>
<SheetContent className='w-[min(720px,96vw)] sm:max-w-none overflow-y-auto'>
{selectedRun && (
<>
<SheetHeader>
<SheetTitle className='text-base'>
{selectedRun.orchestration_plan?.objective ||
selectedRun.orchestration_plan?.template_hint ||
selectedRun.deployment_id}
</SheetTitle>
<SheetDescription className='font-mono text-[11px]'>
{selectedRun.deployment_id}
</SheetDescription>
</SheetHeader>
<div className='mt-4 space-y-4'>
<RunDetailPanel dep={selectedRun} />
<RunAuditTimeline deploymentId={selectedRun.deployment_id} />
</div>
</>
)}
</SheetContent>
</Sheet>
</>
)
}
// RunAuditTimeline — M9. Renders the full audit-event stream for a
// single deployment, oldest-first (chronological). Data is the
// persistent audit table from Sprint 1; an empty list means either
// the deployment is too fresh to have generated audit rows yet, or
// the server lost connectivity to PostgreSQL (we log + swallow).
//
// Visual: vertical timeline with a coloured dot per event, the event
// name on top, then occurred_at + correlation_id below in muted text.
// "Error" and "rejected" events get a red dot; everything else uses
// the primary tone. Keeps the drawer skim-friendly under heavy
// timelines (60+ events) by setting max-height + overflow.
function RunAuditTimeline({ deploymentId }: { deploymentId: string }) {
const { t } = useTranslation()
const { data = [], isLoading } = useQuery({
queryKey: ['agnet', 'deployment-events', deploymentId],
queryFn: () => getAgnetDeploymentEvents(deploymentId),
enabled: Boolean(deploymentId),
refetchInterval: 15_000,
})
return (
<div className='rounded-2xl border border-[color-mix(in_oklch,var(--primary)_18%,var(--border))] bg-[color-mix(in_oklch,var(--card)_52%,transparent)] p-4'>
<div className='flex items-center justify-between'>
<div>
<p className='text-[11px] font-semibold tracking-[0.16em] text-muted-foreground uppercase'>
{t('Audit timeline')}
</p>
<p className='mt-1 text-xs text-muted-foreground'>
{t(
'Every observable transition for this deployment. Survives container restarts (stored in DB).'
)}
</p>
</div>
<span className='inline-flex items-center rounded-full border border-[color-mix(in_oklch,var(--primary)_25%,var(--border))] bg-[color-mix(in_oklch,var(--primary)_10%,transparent)] px-2 py-0.5 text-[10px] font-mono text-primary'>
{data.length} {t('events')}
</span>
</div>
{isLoading ? (
<div className='mt-4 space-y-2'>
<Skeleton className='h-6 w-full' />
<Skeleton className='h-6 w-5/6' />
<Skeleton className='h-6 w-2/3' />
</div>
) : data.length === 0 ? (
<p className='mt-4 text-xs text-muted-foreground italic'>
{t('No audit events yet for this deployment.')}
</p>
) : (
<ol className='mt-4 max-h-[400px] space-y-2 overflow-y-auto pe-1'>
{data.map((entry, idx) => {
const eventName = String(
entry.event || entry.action || '(unknown)'
)
const occurred = String(entry.occurred_at || '')
const correlation = String(entry.correlation_id || '')
const level = classifyEventLevel(entry)
const dotClass =
level === 'error'
? 'bg-rose-500 ring-rose-500/30'
: level === 'warn'
? 'bg-amber-500 ring-amber-500/30'
: 'bg-primary ring-primary/30'
return (
<li
key={
(entry.event_id as string) ||
`${eventName}-${idx}-${occurred}`
}
className='flex items-start gap-3 rounded-lg border border-dashed border-[color-mix(in_oklch,var(--primary)_14%,var(--border))] bg-background/40 p-2'
>
<span
className={cn(
'mt-1 inline-block h-2.5 w-2.5 shrink-0 rounded-full ring-2',
dotClass
)}
/>
<div className='min-w-0 flex-1'>
<p className='font-mono text-xs text-foreground'>
{eventName}
</p>
<p className='mt-0.5 text-[11px] text-muted-foreground'>
{occurred || '—'}
{correlation && (
<>
<span className='mx-2'>·</span>
<span className='font-mono'>{correlation}</span>
</>
)}
</p>
</div>
</li>
)
})}
</ol>
)}
</div>
)
}
// =============================================================================
// Events page
// =============================================================================
@@ -1168,13 +1306,22 @@ export function AgnetSKSourcesPage() {
const [summaryOpen, setSummaryOpen] = useState(false)
const projectSources = resources.filter((r) => r.type === 'git')
const skSources = resources.filter(
(r) => r.type === 'sk' || r.type === 'project_doc'
)
// M3 — project_doc is now a first-class binding type with its own
// step, separated from SK skill packs. The docs/04 §"绑定资源"
// contract lists "项目文档" as a distinct category alongside Git
// and SK; the previous merged "SK or project docs" step blurred
// that line and made users wonder which one they were picking.
const docSources = resources.filter((r) => r.type === 'project_doc')
const skSources = resources.filter((r) => r.type === 'sk')
const cloudSources = resources.filter(
(r) => r.type === 'cloud_account' || r.type === 'cloud_resource'
)
// skSources used to include project_doc rows for "do we have any
// doc-ish source" gating downstream — preserve that contract by
// exposing a combined view for any callers that still want it.
const skOrDocSources = [...skSources, ...docSources]
const steps = [
{
key: 'code',
@@ -1187,11 +1334,23 @@ export function AgnetSKSourcesPage() {
},
{
key: 'docs',
title: t('Connect SK or project docs'),
title: t('Connect project docs'),
summary:
docSources.length > 0
? t('{{n}} doc source connected', { n: docSources.length })
: t(
'Link product requirements, design docs or wiki repos so Agnet has project context.'
),
done: docSources.length > 0,
optional: true,
},
{
key: 'sk',
title: t('Connect SK skill packs'),
summary:
skSources.length > 0
? t('{{n}} source connected', { n: skSources.length })
: t('Pick an existing SK / docs repository or skip'),
? t('{{n}} SK source connected', { n: skSources.length })
: t('Pick a reusable skill / agent toolset repository, or skip.'),
done: skSources.length > 0,
optional: true,
},
@@ -1222,7 +1381,7 @@ export function AgnetSKSourcesPage() {
const completed = steps.filter((s) => s.done).length
const total = steps.length
const prereqsDone =
projectSources.length > 0 || skSources.length > 0
projectSources.length > 0 || skOrDocSources.length > 0
return (
<PageSurface
@@ -1271,13 +1430,26 @@ export function AgnetSKSourcesPage() {
</p>
)}
</div>
{step.key === 'code' || step.key === 'docs' ? (
{step.key === 'code' ||
step.key === 'docs' ||
step.key === 'sk' ? (
<Button
type='button'
variant={step.done ? 'ghost' : 'default'}
size='sm'
className='shrink-0 rounded-xl'
onClick={() => setAdvancedOpen(true)}
onClick={() => {
// Pre-select the right resource type so users
// don't accidentally bind a doc as a git source.
const presetType =
step.key === 'docs'
? 'project_doc'
: step.key === 'sk'
? 'sk'
: 'git'
setResourceForm((v) => ({ ...v, type: presetType }))
setAdvancedOpen(true)
}}
>
{step.done ? t('Manage') : t('Connect')}
</Button>
@@ -1548,7 +1720,10 @@ export function AgnetSKSourcesPage() {
<RecommendationSummaryDialog
onClose={() => setSummaryOpen(false)}
projectSources={projectSources}
skSources={skSources}
// Pre-M3 the summary dialog received a combined SK + docs
// list; preserve that so the summary still shows every
// doc-ish binding even after we split the steps.
skSources={skOrDocSources}
/>
)}
</PageSurface>
@@ -0,0 +1,211 @@
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { CheckCircle2, AlertTriangle, RefreshCw, ShieldOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
import { SettingsSection } from '../components/settings-section'
// M7 — admin-only secret-store (OpenBao / Vault) status snapshot.
// Pure read; deliberately exposes only:
// - configuration state (env vars set?)
// - reachability
// - sealed / initialized / standby flags
// - version string
// - human-readable message
// Per product docs §13.9 we MUST NOT show any secret names or values
// — those don't even leave the backend. The status endpoint is mounted
// behind AdminAuth; this UI is rendered inside system-settings which
// is already admin-gated.
type StoreStatus = {
configured: boolean
reachable: boolean
initialized?: boolean
sealed?: boolean
standby?: boolean
version?: string
message?: string
checked_at: number
}
type ApiEnvelope<T> = {
success: boolean
message?: string
data?: T
}
function formatTs(ms: number): string {
if (!ms) return '—'
const d = new Date(ms)
return Number.isNaN(d.getTime()) ? '—' : d.toLocaleString()
}
export function SecretStoreSection() {
const { t } = useTranslation()
const [status, setStatus] = useState<StoreStatus | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const refresh = async () => {
setLoading(true)
setError('')
try {
const res = await api.get<ApiEnvelope<StoreStatus>>(
'/api/secret-store/status'
)
if (!res.data?.success || !res.data.data) {
setError(res.data?.message || t('Failed to read secret store status'))
return
}
setStatus(res.data.data)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e)
setError(msg)
} finally {
setLoading(false)
}
}
// Fetch once on mount; admin clicks refresh for subsequent polls.
useEffect(() => {
void refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// Pick the colour-coded summary pill based on the three operational
// states the backend can return. Order matters: not-configured beats
// reachability, and sealed beats reachable-but-otherwise-fine.
let toneClass = 'bg-muted/40 text-muted-foreground ring-border/60'
let toneIcon = ShieldOff
let toneLabel = t('Unknown')
if (status) {
if (!status.configured) {
toneClass = 'bg-muted/40 text-muted-foreground ring-border/60'
toneIcon = ShieldOff
toneLabel = t('Not configured')
} else if (!status.reachable) {
toneClass = 'bg-rose-500/15 text-rose-400 ring-rose-500/30'
toneIcon = AlertTriangle
toneLabel = t('Unreachable')
} else if (status.sealed) {
toneClass = 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
toneIcon = AlertTriangle
toneLabel = t('Sealed')
} else if (!status.initialized) {
toneClass = 'bg-amber-500/15 text-amber-400 ring-amber-500/30'
toneIcon = AlertTriangle
toneLabel = t('Not initialized')
} else {
toneClass = 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30'
toneIcon = CheckCircle2
toneLabel = t('Healthy')
}
}
const ToneIcon = toneIcon
return (
<SettingsSection
title={t('Secret vault status')}
description={t(
'Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.'
)}
>
<div className='rounded-lg border bg-card/40 p-4'>
<div className='flex items-center justify-between gap-3'>
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-xs font-medium ring-1 ring-inset',
toneClass
)}
>
<ToneIcon className='h-3.5 w-3.5' />
{toneLabel}
</span>
<Button
type='button'
variant='outline'
size='sm'
onClick={() => void refresh()}
disabled={loading}
>
<RefreshCw
className={cn('h-3.5 w-3.5 me-1', loading && 'animate-spin')}
/>
{t('Refresh')}
</Button>
</div>
{error && (
<p className='mt-3 rounded-md border border-rose-500/30 bg-rose-500/5 p-2 text-xs text-rose-400'>
{error}
</p>
)}
{status && (
<div className='mt-4 grid gap-3 sm:grid-cols-2'>
<Field label={t('Configured')} value={status.configured ? 'Yes' : 'No'} />
<Field
label={t('Reachable')}
value={
status.configured ? (status.reachable ? 'Yes' : 'No') : '—'
}
/>
<Field
label={t('Initialized')}
value={
status.reachable
? status.initialized
? 'Yes'
: 'No'
: '—'
}
/>
<Field
label={t('Sealed')}
value={
status.reachable ? (status.sealed ? 'Yes' : 'No') : '—'
}
/>
<Field
label={t('Standby')}
value={
status.reachable ? (status.standby ? 'Yes' : 'No') : '—'
}
/>
<Field label={t('Version')} value={status.version || '—'} />
<Field
label={t('Last checked')}
value={formatTs(status.checked_at)}
/>
</div>
)}
{status?.message && (
<p className='mt-3 rounded-md border border-dashed border-border/60 bg-background/40 p-2 text-xs text-muted-foreground'>
{status.message}
</p>
)}
{status && !status.configured && (
<p className='mt-3 text-xs text-muted-foreground'>
{t(
'To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh.'
)}
</p>
)}
</div>
</SettingsSection>
)
}
function Field({ label, value }: { label: string; value: string }) {
return (
<div>
<p className='text-[11px] uppercase tracking-[0.1em] text-muted-foreground'>
{label}
</p>
<p className='mt-0.5 font-mono text-sm text-foreground'>{value}</p>
</div>
)
}
@@ -10,6 +10,7 @@ import { HeaderNavigationSection } from './header-navigation-section'
import { LogSettingsSection } from './log-settings-section'
import { NoticeSection } from './notice-section'
import { PerformanceSection } from './performance-section'
import { SecretStoreSection } from './secret-store-section'
import { SidebarModulesSection } from './sidebar-modules-section'
import { UpdateCheckerSection } from './update-checker-section'
@@ -79,6 +80,15 @@ const MAINTENANCE_SECTIONS = [
)
},
},
{
// M7 — secret-store (OpenBao / Vault) status panel. Read-only,
// shows seal state + reachability; never exposes secret names
// or plaintext values.
id: 'secret-store',
titleKey: 'Secret vault status',
descriptionKey: 'OpenBao / Vault health and seal state',
build: (_settings: MaintenanceSettings) => <SecretStoreSection />,
},
{
id: 'performance',
titleKey: 'Performance',
+29 -1
View File
@@ -4108,6 +4108,34 @@
"When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.": "When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.",
"I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.": "I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.",
"Decline and sign out": "Decline and sign out",
"I agree, continue": "I agree, continue"
"I agree, continue": "I agree, continue",
"Audit timeline": "Audit timeline",
"Every observable transition for this deployment. Survives container restarts (stored in DB).": "Every observable transition for this deployment. Survives container restarts (stored in DB).",
"events": "events",
"No audit events yet for this deployment.": "No audit events yet for this deployment.",
"Connect project docs": "Connect project docs",
"{{n}} doc source connected": "{{n}} doc source connected",
"Link product requirements, design docs or wiki repos so Agnet has project context.": "Link product requirements, design docs or wiki repos so Agnet has project context.",
"Connect SK skill packs": "Connect SK skill packs",
"{{n}} SK source connected": "{{n}} SK source connected",
"Pick a reusable skill / agent toolset repository, or skip.": "Pick a reusable skill / agent toolset repository, or skip.",
"Secret vault status": "Secret vault status",
"OpenBao / Vault health and seal state": "OpenBao / Vault health and seal state",
"Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.": "Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.",
"Not configured": "Not configured",
"Unreachable": "Unreachable",
"Sealed": "Sealed",
"Not initialized": "Not initialized",
"Healthy": "Healthy",
"Unknown": "Unknown",
"Failed to read secret store status": "Failed to read secret store status",
"Refresh": "Refresh",
"Configured": "Configured",
"Reachable": "Reachable",
"Initialized": "Initialized",
"Standby": "Standby",
"Version": "Version",
"Last checked": "Last checked",
"To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh.": "To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh."
}
}
+29 -1
View File
@@ -4108,6 +4108,34 @@
"When the platform calls third-party models (OpenAI / Anthropic / Google etc.), your inputs and the model outputs are processed by the corresponding model vendor. Each vendor sets its own privacy, retention and training-use terms. Heicode does NOT modify those terms and cannot make any privacy promises on behalf of the vendors. Please review the vendor terms before submitting sensitive content.": "平台调用各家模型(OpenAI / Anthropic / Google 等)时,你输入的内容和模型返回的输出会经过对应原厂处理。**这些数据的隐私、保留期、是否用于训练等条款由各模型原厂自行决定**,Heicode 不修改原厂条款,也无法代表原厂做任何承诺。提交敏感内容前请先阅读对应原厂的条款。",
"I have read and understood both notices above. I am responsible for backing up my own data and reviewing the model vendor terms.": "我已阅读并理解上述两点。我会自行备份数据,并自行阅读相关模型原厂的条款。",
"Decline and sign out": "拒绝并退出",
"I agree, continue": "我已知悉,继续使用"
"I agree, continue": "我已知悉,继续使用",
"Audit timeline": "审计时间线",
"Every observable transition for this deployment. Survives container restarts (stored in DB).": "本次部署所有可观察的状态变化,容器重启不丢(落到数据库)。",
"events": "条事件",
"No audit events yet for this deployment.": "该部署暂无审计事件。",
"Connect project docs": "绑定项目文档",
"{{n}} doc source connected": "已绑定 {{n}} 份项目文档",
"Link product requirements, design docs or wiki repos so Agnet has project context.": "绑定需求文档、设计文档或 wiki 仓库,让 Agnet 拿到项目上下文。",
"Connect SK skill packs": "绑定 SK 技能包",
"{{n}} SK source connected": "已绑定 {{n}} 个 SK 来源",
"Pick a reusable skill / agent toolset repository, or skip.": "选一个可复用的技能包 / Agent 工具集仓库,也可以跳过。",
"Secret vault status": "密钥保管器状态",
"OpenBao / Vault health and seal state": "OpenBao / Vault 的健康状态和封存状态",
"Heicode stores long-lived credentials in OpenBao / Vault. This panel surfaces health and seal state for the operator — secret names and values are NEVER displayed here.": "Heicode 把长期凭证存在 OpenBao / Vault 里。这个面板只给运维同事看健康状态、是否封存——**永远不会**在这里显示密钥名称或明文。",
"Not configured": "未配置",
"Unreachable": "不可达",
"Sealed": "已封存",
"Not initialized": "未初始化",
"Healthy": "正常",
"Unknown": "未知",
"Failed to read secret store status": "读取密钥保管器状态失败",
"Refresh": "刷新",
"Configured": "已配置",
"Reachable": "可达",
"Initialized": "已初始化",
"Standby": "备用模式",
"Version": "版本",
"Last checked": "最近检查",
"To enable: set OPENBAO_ADDR + OPENBAO_TOKEN (or VAULT_*) env vars on the Manager container and restart. The status panel will pick up the connection on the next refresh.": "启用方法:在 Manager 容器上配置 OPENBAO_ADDR + OPENBAO_TOKEN 环境变量(或 VAULT_*),然后重启容器。状态面板会在下次刷新时自动识别。"
}
}