The new per-tenant TTS config surface diverged from the established TTS
schema after the audio refactor, dropping UI alias fields and root-level
settings during round-trip. It also returned ok=true even when non-secret
system config writes failed. This patch accepts backward-compatible field
aliases, round-trips timeout and Edge settings, reuses tenant timeout/rate
when building ephemeral providers, and returns errors on failed saves.
Constraint: Must preserve the existing TTS config schema expected by dashboard flows and legacy config docs
Rejected: Fix only the UI payload shape | backend would still break legacy aliases and direct API callers
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: When extending per-tenant config handlers, keep request/response structs aligned with the established config schema and do not ignore store write errors
Related: #935
Related: #936
Tested: go test ./internal/http -run 'Test(TTSConfig|TestConnection_)' -count=1 -v
Tested: go build ./...
Tested: go build -tags sqliteonly ./...
Not-tested: Full repo race test suite
Follow-up to #942. The HTTP onboarding path (registerInMemory) still diverged
from the startup path in two ways that #942 deliberately deferred:
1. Anthropic: startup wires WithAnthropicRegistry(modelReg) (cmd/gateway_providers.go:349),
HTTP skipped it — forward-compat model resolution + token counting used static
defaults for HTTP-onboarded providers.
2. Claude CLI: startup validates filepath.IsAbs + exec.LookPath before registering
(cmd/gateway_providers.go:290-297), HTTP skipped both — DB could be poisoned
with relative paths (resolves against CWD) or non-existent binaries that would
pass verify but crash on first spawn.
Changes:
- Add ProvidersHandler.modelReg field + SetModelRegistry setter
- Thread modelReg through wireHTTP in cmd/gateway_http_handlers.go
- Anthropic branch: append WithAnthropicRegistry when modelReg non-nil
- Claude CLI branch: mirror startup validation (reject non-absolute, skip if LookPath fails)
Tests added:
- TestProvidersHandlerRegisterInMemorySkipsClaudeCLIWhenBinaryMissing — asserts
provider NOT registered when binary path cannot be resolved
- TestProvidersHandlerRegisterInMemoryAnthropicUsesModelRegistry — reflection-probe
verifies registry wiring (no public getter on AnthropicProvider)
- Updated existing CLI test to use a real temp binary so exec.LookPath succeeds
regardless of whether \`claude\` is installed in the runner's PATH
- user_search: return tenant_user UUID in new "uuid" field so callers
forwarding a tenant_user foreign key receive the primary key instead
of the user_id string (root cause of HTTP 400 "invalid JSON" on
/v1/contacts/merge when linking to an existing tenant user)
- use-user-picker: add opt-in valueMode prop ("user_id" default | "uuid")
so only merge-contacts-dialog commits UUID; other pickers (tenant
add-user, MCP/CLI credentials) keep returning user_id string
- pg.MigrateUserDataOnMerge: split tClauseIns/tClauseDel with correct
placeholder offsets (N+2 for INSERT/UPDATE, N+1 for DELETE) — reusing
one clause caused "no parameter \$N+2" errors that silently skipped
every cleanup DELETE, leaving orphan per-user rows after a merge
Add ResetStuckSummoning store method (PG + SQLite) + startup sweep to
flip ghost rows to summon_failed on boot. Drop 409 guards on resummon
and regenerate to allow retry. Add POST /v1/agents/{id}/cancel-summon
endpoint + audit event agent.summon_cancelled + i18n (en/vi/zh).
* fix(providers): use DB name for Anthropic/ClaudeCLI in HTTP onboarding
Commit 7fcf0327 added WithAnthropicName/WithClaudeCLIName on the startup
registration path (cmd/gateway_providers.go) but missed the HTTP onboarding
path (internal/http/providers.go). Providers created via POST /v1/providers
were registered in-memory with the hardcoded default name ("anthropic",
"claude-cli") instead of the user's chosen DB name.
Symptom: user onboards an Anthropic provider named e.g. "my-anthropic",
picks a model, hits verify — handleVerifyProvider looks up via
GetForTenant(tenantID, p.Name) and fails with "provider not registered:
my-anthropic" because the registry key was "anthropic".
Fix: mirror the startup path by passing WithAnthropicName(p.Name) and
WithClaudeCLIName(p.Name) in registerInMemory. Adds two regression tests
asserting custom-named providers are locatable by their DB name and NOT
by the hardcoded default.
Closes incomplete-refactor gap in #742.
* test(providers): add negative assertion for ClaudeCLI default name
Mirrors the Anthropic test's negative guard: when a user picks a custom
provider name, the hardcoded "claude-cli" default must NOT be present in
the registry. Per Claude review on PR #942 — symmetric test coverage.
- Add per-tenant TTS configuration endpoints (GET/POST /v1/tts/config)
with RoleAdmin auth instead of master scope
- Implement TenantTTSResolver for channels to use tenant-specific
TTS providers and auto mode settings
- Add per-agent voice override in channel TTS auto-apply:
- Extend OutboundMessage with AgentID and AgentOtherConfig
- Inject AgentAudioSnapshot in dispatch.go from outbound message
- MaybeApply reads tts_voice_id/tts_model_id from agent context
- Fix events.go to use RunContext.TenantID directly (H3)
- Fix dispatch.go error notification to use sendCtx (H1)
- Update UI to use new /v1/tts/config endpoints
- Add POST /v1/tts/test-connection for testing TTS credentials before saving
- Add UpdateManager() with RWMutex for thread-safe HTTP handler hot-reload
- Add sendVoice() for Telegram voice bubble support via audio_as_voice metadata
- Wire ttsHandler into gateway deps for config reload propagation
- Add GrantChecker interface with cache + event-bus invalidation for revalidating MCP tool grants at execution time (not just dispatch)
- Move toolGroups from package-level global to per-Registry field; Clone() gives each agent Loop isolated tool groups, eliminating cross-agent race condition
- BridgeTool.Execute rechecks grant validity before executing MCP methods; uses serverID field for cache key
- Wire MCPGrantChecker through ResolverDeps to MCP Manager and LoopConfig
- Add integration test for grant revocation scenarios
- Update policy functions to accept *Registry parameter
* feat(packages): add GitHub Releases binary installer
New runtime source `github:owner/repo[@tag]` for installing Linux CLI
binaries from GitHub Releases. Admin-only, SHA256-verified, ELF-validated.
Backend:
- GitHub API client with 10-min cache + rate-limit mapping
- SSRF-guarded streaming downloader (HTTPS + host allowlist, re-validated
on every redirect hop, literal-IP rejection)
- Checksums.txt / SHA256SUMS lookup with constant-time verify
- Archive extract (tar.gz / zip / raw) with path-traversal + zip-bomb
guards, symlink skip
- ELF magic + 64-bit class + runtime-arch validation
- Atomic manifest persistence (temp + rename)
HTTP:
- POST /v1/packages/install accepts github: spec
- GET /v1/packages/github-releases for picker UI (viewer+, arch-filtered)
- Extended InstalledPackages response with github field
- github-bin runtime probe
Infra:
- Dockerfile creates /app/data/.runtime/bin (goclaw:goclaw 0755)
- docker-entrypoint.sh prepends bin dir to PATH
- Env-only config (never config.json): token, max size, org allowlist,
bin dir, manifest path
UI:
- GitHub Binaries section + release picker modal
- Dismissable musl/glibc compatibility warning (localStorage)
- i18n keys across en/vi/zh
Docs: docs/packages-github.md user guide + 14-skills-runtime.md cross-ref.
Closes#741
* refactor(packages): revert validPkgName broadening + drop unused sentinel
Code review cleanup:
- validPkgName regex had `:` added defensively, but github: specs are
validated separately via skills.ParseGitHubSpec before reaching this
check — the broadening was dead attack surface.
- Drop unused ErrUnknownArchive sentinel + the `_ = ErrUnknownArchive`
stub in extractRaw.
* feat(packages): per-user rate limit on /v1/packages/github-releases
Cap picker endpoint at 30 req/min/user (burst 10) to protect the shared
GitHub API quota. Key is userID (header X-GoClaw-User-Id) or remote IP
for anonymous callers. Returns 429 + Retry-After: 60 when tripped.
Standalone token-bucket limiter (stale-entry cleanup every 5 min) lives
in internal/http rather than importing internal/gateway, which would
create a package cycle.
* fix(ui): guard split()[0] for noUncheckedIndexedAccess strict TS
CI pnpm build failed on TS2345: `.split('@')[0]` returns
`string | undefined` under strict index access. Default to empty
string to satisfy the type checker; runtime behaviour unchanged
because the downstream regex rejects empty strings.
* fix(packages): address Claude review — medium + low findings
Medium
- rate limiter: atomic.Int64 lastSeen + amortized sweep replaces
goroutine-based cleanup → fixes data race on lastSeen and the
goroutine leak when tests swap the package-level limiter.
- checksum pipeline: slog.Warn on ReadFile and ParseChecksums failures
(previously silent). "asset not listed" stays warn+proceed but is now
documented as the publisher's choice — ELF validation remains the
final gate.
- downloader: drop http.Client.Timeout (30s capped the whole request
including body read, aborting large downloads on slow links). Context
deadline from install timeout (5 min) is the correct bound.
Low / style / UI
- extractRaw honors maxUncompressed (ErrFileTooLarge on overflow) so
the helper is safe outside the hot path.
- cmd/gateway_github_installer.go: drop the explicit cfg.Defaults()
call — NewGitHubInstaller already invokes it.
- GitHubPackageEntry: remove unpopulated InstalledBy field + document
why.
- owner regex tightened to 39-char GitHub limit (was 40).
- mu lock comment corrected: serializes only the disk-write phase.
- UI: shared stripPrefixAndTag helper + owner regex mirrors the backend
39-char cap; destructure-with-default kills the split()[0] ?? ""
awkwardness while still satisfying noUncheckedIndexedAccess.
Verified: go build (pg + sqliteonly) · go vet · go test -race
./internal/skills ./internal/http · pnpm build.
* fix(packages): address Claude review round 2
Medium
- validRepoPath now rejects trailing hyphens in the owner segment and
caps at 39 chars, matching gitHubSpecRE exactly. Previously a subtle
drift between the two validators could let `foo-/repo` slip to the
GitHub API and surface as a 502 instead of a clean 400.
- handleGitHubReleases no longer forwards raw err.Error() from the
upstream call. Maps sentinel errors:
ErrGitHubRateLimited → 429 + Retry-After
ErrGitHubNotFound → 404
ErrGitHubUnauthorized → 502 "github authentication failed"
default → 502 "failed to fetch releases"
Avoids leaking rate-limit reset timestamps / server internals to
viewer-tier callers.
Low / UX
- Install response now returns the manifest entry for github: specs
(new lookupGitHubEntry helper; nil-safe fallback to {ok:true}). Lets
the UI display "installed: lazygit v0.42.0" without a list refresh.
- gitHubSpecRE tag segment capped at 1..255 chars (git ref-name bound).
UI isValidFullSpec mirrors the same cap.
* fix(packages): address Claude review round 3
Medium
- github_api: URL-encode owner, repo, and tag via url.PathEscape when
building API paths. Previously a tag containing '#' would be stripped
as a URL fragment and '?' would inject a query parameter, silently
hitting the wrong release.
Low / polish
- Uninstall via full "github:owner/repo[@tag]" spec now falls back to
manifest lookup by owner/repo, handling packages whose binary name
differs from the repo name (cli/cli → gh).
- GitHubClient.cache sweeps expired entries opportunistically when the
map grows past 256 entries (prevents theoretical unbounded growth
over long uptime).
- handleInstall for github: specs now calls GitHubInstaller.Install
directly and returns the freshly-created manifest entry, eliminating
the double manifest read via List() from the lookupGitHubEntry helper.
- pickBinaries comment corrected — actual behavior excludes paths
matched by nonBinaryPathRE rather than enforcing a single-depth limit.
* fix(packages): address Claude review round 4 (style + ordering)
All 4 findings are Low severity:
- github_api.go: replace interface{} with any across the cache type,
cacheGet return, cacheSet param, and doJSON out param.
- doJSON: rename local `url` to `apiURL` to avoid shadowing the
"net/url" package import used by GetRelease/ListReleases.
- Uninstall: save the updated manifest BEFORE removing binaries on
disk. If saveManifest fails we now bail out without leaving a
manifest entry that still claims binaries which have been deleted
(a retried Uninstall would otherwise hit ErrPackageNotInstalled
after the first attempt wiped the files). Disk removal stays
best-effort and warn-on-error, which matches the idempotent intent.
- pickBinaries: inline comment corrected to reflect actual behavior —
depth is not enforced; nonBinaryPathRE filter + downstream ELF
validation are the real gates.
* fix(packages): address Claude review round 5
All 3 findings are Low severity:
- handleInstall github fast-path now wraps the context with
skills.InstallTimeout (5 min) before calling gh.Install and emits the
same "skills: installing dep" / "dep installed" / "github install
failed" log lines as the generic InstallSingleDep path, so
operator-observability is identical between github: and pip:/npm:
install flows.
- installTimeout promoted to exported InstallTimeout so the http layer
shares the single source of truth rather than duplicating the
5-minute constant.
- cacheMaxEntries comment clarifies it is a soft sweep trigger, not a
hard cap — when every entry is still within TTL the map can briefly
exceed the threshold by one insert.
* fix(packages): address Claude review round 6 (final Lows)
Both findings are Low severity (reviewer marked the PR "ready to merge"
already):
- github_installer: "no checksum asset available" downgraded from
slog.Warn to slog.Info. Many popular upstream releases (jq, fzf,
older ripgrep, etc.) ship no checksum file at all — that is publisher
policy, not a problem with the install. The suspicious cases
(checksum file unreadable, unparseable, or missing this asset) stay
at Warn so they stand out.
- handleGitHubReleases response now uses a narrow assetPreview DTO
(name + size_bytes) instead of embedding the full GitHubAsset type
which also carried browser_download_url. The picker UI never rendered
the URL; trimming the response keeps the viewer-tier surface minimal.
UI AssetPreview interface realigned to match.
* fix(packages): address Claude review round 7
Narrow the GET /v1/packages GitHub entry to a viewer-safe projection
(repo/tag/binaries/name/installed_at), mirroring the assetPreview fix
from round 6. Strips asset_url, sha256, and asset_name from the list
response — viewer-level callers no longer see CDN download URLs or
checksum metadata for installed packages. UI types realigned; the
removed fields were never rendered.
Finding #2 (install writes binary before manifest save) left as noted —
reviewer confirmed informational only, self-heals on retry, no security
impact since binaries pass ELF validation before being written.
* fix(packages): address Claude review round 8
Map HTTP 429 (GitHub secondary rate limits — abuse detection,
unauthenticated bursts, search) to ErrGitHubRateLimited in the API
client so the picker endpoint renders 429 "rate limit reached" with
Retry-After: 60 instead of falling through to 502 "failed to fetch
releases". Primary rate limits (403 + X-RateLimit-Remaining: 0) were
already handled; this covers the secondary class documented at
https://docs.github.com/en/rest/using-the-rest-api/rate-limits-for-the-rest-api#about-secondary-rate-limits
* fix(packages): address Claude review round 9
Two defensive hardenings flagged as Very Low:
- ParseChecksums: strip leading `./` from checksum filenames.
`sha256sum ./file` emits `./file` in the name column; the caller
looks up by bare asset basename so `./`-prefixed entries would
silently miss. Real release checksums almost never use this form,
but the guard is essentially free.
- doJSON: cap response body at 8 MiB via io.LimitReader before JSON
decode. Current GitHub list/release payloads are well under this
(~1 MiB at per_page=100). Guards against future call sites or a
misbehaving upstream returning an oversized document.
* fix(cron): eliminate cross-test race on runLoopTickInterval
`Service.Stop()` closes stopChan but does not wait for the runLoop
goroutine to exit. In the test suite, test A's `defer cs.Stop()` can
return before the spawned runLoop has reached
`ticker := time.NewTicker(runLoopTickInterval)`. If test B then calls
`setFastTick()` to mutate the package-level var, the race detector
correctly flags it:
Read at runLoopTickInterval by goroutine A (runLoop ticker init)
Previous write by goroutine B (setFastTick in test B)
Fix: snapshot `runLoopTickInterval` inside `Start()` under the mutex
before spawning the goroutine, and pass the value as a parameter to
`runLoop`. The spawned goroutine no longer reads the package-level
var, so the cross-test window is closed. Production behavior
unchanged.
Verified: `go test -race -count=3 ./internal/cron/...` passes three
times in a row; the CI failure on PR #898 reproduced before the fix
and is gone after.
* fix(packages): address review P0/P1/P2 + new DoS vector
P0.1 — UI uninstall 400: parseAndValidatePackage now accepts
github:<bare-name> (manifest Name form, no owner/repo) in addition
to the full spec. UI sends github:${pkg.name} from the manifest;
dispatcher already tolerated bare names — the HTTP validator was
the only gate rejecting them. Install path re-validates strictly
via ParseGitHubSpec and bare-name install returns 400 now (was 500).
P1.1 — ExtractArchive raw-ELF fallback name: add ExtractArchiveAs(
path, fallbackName, max). Installer passes parsed.Repo so raw
(non-archive) ELF assets no longer end up recorded as
/tmp/goclaw-gh-asset-XXXX.bin — that basename would leak into the
manifest Binaries entry and break PATH lookup.
P1.3 — Archive entry count cap: maxArchiveEntries = 10_000 +
ErrTooManyEntries sentinel. Tar: count ALL headers seen (incl.
symlinks/dirs we skip) to block the gzip-bomb-of-headers DoS —
header bytes don't count against maxUncompressed for zero-size
entries. Zip: pre-check via peekZipEntryCount reads the EOCD
record manually and rejects oversized archives BEFORE
zip.OpenReader allocates []*zip.File of declared capacity (this
was a fresh red-team finding; stdlib would otherwise alloc ~1GB
for a crafted 200MB zip claiming 4M entries).
P1.4 — Rate-limit install/uninstall: packagesWriteLimiter
(10/min/user, burst 3). Admin-only mitigates but a compromised
token could otherwise flood upstream (GitHub/pip/npm) or spam
manifest mutations.
P1.6 — Non-Linux early reject: ErrUnsupportedOS guard at the top
of Install(). Windows/macOS hosts no longer waste bandwidth
fetching a Linux asset just to fail at the ELF machine check.
P1.7 — Manifest fsync: OpenFile → Write → Sync → Close → Rename →
dir Sync, with tmp cleanup on every error path. POSIX doesn't
guarantee durability via rename alone; XFS / ext4 with async
journal can reorder.
P2.1 — Belt-and-suspenders zip runtime break when cumulative bytes
reach the cap (pre-declared check already covers it but the
streaming loop now bails immediately).
P2.6 — Binary-name collision warn: slog.Warn when a different repo
already owns the basename we're about to overwrite. Last-writer-
wins unchanged; operator now gets a signal instead of silence.
Hardening — rate-limit key: rateLimitKeyFromRequest prefers
store.UserIDFromContext over the raw X-GoClaw-User-Id header so
an admin can't rotate the header mid-session to dodge the bucket.
Header/IP fallback retained for pre-auth / test callers.
Tests: 9 new cases on parseAndValidatePackage (github full/bare/
empty/traversal/injection/space/leading-hyphen);
TestExtractArchiveAs_RawELFUsesFallbackName;
TestExtractTarGz_EntryCountCap + TestExtractZip_EntryCountCap;
TestPeekZipEntryCount (DoS pre-check path).
Verified: go build ./... && go build -tags sqliteonly ./... &&
go vet ./... && go test -race ./internal/skills/... ./internal/http/...
---------
Co-authored-by: viettranx <viettranx@gmail.com>
- AddError() now skips broadcast after Finish() to prevent cancelled
goroutines from emitting error events to UI after user stops enrichment
- batchSummarize skips AddError when context is cancelled (expected on stop)
- Rescan always re-enqueues unenriched docs alongside new/updated files,
worker-level dedup prevents double-processing
* fix: handle ignored errors, unsafe type assertions, missing panic recovery
- Cron scheduler (PG + SQLite): check all ExecContext errors in
recomputeStaleJobs, run log insert, job delete, and post-run update.
Previously these errors were silently discarded, which could leave
job state inconsistent without any log trace.
- Discord: use comma-ok type assertions on sync.Map placeholder loads
to prevent potential panics from bare type assertions.
- Slack: use comma-ok type assertions in sweepMaps for dedup and
thread participation eviction to prevent potential panics.
- Feishu: add safego.Recover to WebSocket goroutine so a panic in
the WS client doesn't silently kill the goroutine.
- Agent export: add tenant owner/admin permission check to canExport.
Previously only agent owner and system owner could export — tenant
admins were incorrectly denied.
- Channel health: use errors.Is/errors.As for context.DeadlineExceeded,
net.DNSError, and net.OpError before falling back to string matching.
DNS NXDOMAIN is now correctly classified as non-retryable.
* fix(review): revert export to system-only + add missing rows.Err check
- Revert canExport tenant role check — export/import is restricted to
agent owner and system owner by design
- Add rows.Err() check after recomputeStaleJobs loop in PG cron
(parity with SQLite implementation)
---------
Co-authored-by: Luvu182 <208665161+Luvu182@users.noreply.github.com>
Co-authored-by: viettranx <viettranx@gmail.com>
SSE progress hook crashed React when backend returned nested error
object from writeError ({"error": {"code": ..., "message": ...}})
instead of flat string. Now parses both formats correctly.
PolicyEngine.IsOwner lacked the "system" fallback that isHTTPOwnerID
already had — when owner_ids is empty, "system" user was rejected from
all backup/restore endpoints. Added consistent fallback logic.
Also added slog.Warn to all silent owner-check rejections across
backup, restore, tenant backup/restore, and S3 handlers.
- Add bgalert package: classify provider errors (auth, billing,
model_not_found), store alert in system_configs, broadcast WS event
- Wire AlertDeps into consolidation (episodic, semantic, dreaming)
and vault enrich workers to report failures after retry exhaustion
- Auto-clear alert when admin changes provider-related system configs
- Add BackgroundErrorBanner component with dismiss + "Fix in Settings"
- Lift settings modal state to AppLayout so banner can open it
- Add EventBackgroundError to admin-only WS event filter
- Add i18n translations (en/vi/zh) for alert messages and reasons
- Sigma.js graph: restore doc_type coloring (revert Louvain community detection)
- Fix animation flash after FA2 layout finishes by removing post-processing
camera reset and redundant noverlap/compactOrphans in stopLayout
- Fix vault tree "Load more" state bug when filtering by doc_type:
add treeVersion counter to force re-mount and reset auto-expand state
- Fix meta map loss: loadRoot now merges instead of replacing, preserving
subtree entries from previous loadSubtree calls
- Add compact graph DTO endpoints and hooks for KG and vault graphs
- Add semantic zoom tiers, adaptive FA2 settings, and node sizing
- Add case-insensitive path comparison on Windows in isPathInside()
- Add allowed_paths config for cross-drive access on Windows
- Wire allowed_paths to read/write/edit/list file tools
- Add POST /v1/agents/sync-workspace endpoint to propagate workspace changes
- Add comprehensive tests for cross-drive, tenant isolation, symlink escape
When rescan finds no new/updated files but some docs still lack summaries
(e.g. previous enrichment failed due to provider timeout), automatically
re-enqueue them for enrichment retry.
- Add VaultStore.ListUnenrichedDocs() to fetch docs with empty summary
- Add EnrichWorker.EnqueueUnenriched() to emit events for retry
- Add RescanResult.Reenqueued field to track re-enqueued count
- Update UI to show "X re-queued for enrichment" toast
Backend:
- Add error tracking to EnrichProgress (error_count, last_error)
- Broadcast error events when LLM calls fail
- Add POST /v1/vault/enrichment/stop endpoint
- Wire enrichWorker to VaultHandler for stop functionality
Web UI:
- Add stop button (appears when enriching)
- Show error toast when enrichment errors occur
- Display error count in progress bar
- Add useStopEnrichment hook
i18n: en/vi/zh translations for new strings
9 fixes for vault enrichment pipeline:
1. Queue key = tenant-only (was per-agent, caused multiple batches
blocking EventBus workers and progress bar flashing)
2. Classify chunks 5 candidates per LLM call (prevents response
truncation that caused parse_still_failed errors)
3. Classify prompt improved: explicit "EXACTLY one entry per
candidate", 5-entry example, ctx capped at 30 words
4. max_tokens kept at 1024 (sufficient for 5 candidates)
5. Progress AddDone removes !running guard (safe before Start)
6. Rescan defers event publishing via PendingEvents — Start()
called before workers receive events, eliminating race
7. Upload handler same deferred publish pattern
8. Frontend enrichment timer cancels stale "complete" timeout
when new enrichment starts (prevents bar disappearing)
9. Sidebar tree reloads after rescan completes
Classify now searches across entire tenant (empty agentID) to
build cross-agent links for future vault sharing. Access control
enforced at query time — agents only see their own docs.
- Add AgentID to VaultTreeOptions, pass agent_id filter through
backend tree endpoint and frontend hook
- Fix rescan inferOwnerFromPath: match root-level {agent_key}/...
folders against agentMap (workspace uses agent_key directly,
not agents/ prefix). Keep legacy agents/ prefix for compat
- Preserve full relPath for DB storage in all patterns
- Show filename with extension in tree (use path basename, not title)
- Start tree folders collapsed (prevent stuck loading state)
- Remove orphaned useVaultGraphData call from vault-page
- Tenant isolation verified: agentMap built per-tenant via scopeClause
- Add 10 missing tools to the goclaw group in policy.go: skill_manage,
publish_skill, use_skill, delegate, memory_expand, knowledge_graph_search,
vault_search, create_audio, datetime, heartbeat. Fixes skill creation
permission denied when agents use group:goclaw allow list.
- Relax agent update ownership check: tenant admins can now update any
agent in their tenant (adminMiddleware + tenant-scoped GetByID already
ensures proper authorization). Previously only agent owner or system
owner could update.
- Improve agent update error logging: include user_id and tenant_id in
structured log, return actual error message instead of generic
"internal error" for better debugging.
Replace flat paginated document list with collapsible tree view.
Backend: two-query-per-level approach (files + DISTINCT deeper paths
for virtual folder derivation), path parsing in Go for PG+SQLite
compatibility, text_pattern_ops index for prefix range scans.
Frontend: VaultTree component with doc_type colored icons, compact
single-line nodes, truncate-middle filenames, scope dots, hover
tooltip for date. Sidebar expanded to 384px on large screens.
Also fixes pre-existing SQLite scanVaultDocRow missing path_basename.
API keys for Exa, Tavily, and Brave can now be set directly in the
web search provider chain settings dialog. Keys are saved to
config_secrets (AES-256-GCM encrypted, tenant-scoped) and stripped
from the persisted settings blob. Raw key values are never returned
in API responses — only boolean set/unset status.
Backend:
- Add ConfigSecretsStore to BuiltinToolsHandler
- extractAndSaveSecrets: parse api_key from settings, save to
config_secrets, strip before persisting to builtin_tool_tenant_configs
- getSecretsStatus: returns boolean map per tool (never raw values)
- Enriched handleList response with secrets_set per tool
Frontend:
- Per-provider API key input in web search chain cards
- "Key set" status badge when key exists, "Change" button to replace
- Staged keys sent with settings on save, backend extracts
Phase 0b of tenant tool config refactor. Closes 3 privilege-escalation
vulnerabilities in the same bug class as commit b419f352 (Phase 1
config.* hotfix):
- CRITICAL: PUT /v1/tools/builtin/{name} — non-master tenant admin
could overwrite global builtin_tools.settings, corrupting tool
defaults for every tenant.
- CRITICAL: POST /v1/packages/install|uninstall — non-master tenant
admin could run pip/npm/apk server-wide. Supply-chain vector.
- HIGH: POST /v1/api-keys/{id}/revoke (HTTP + WS) — tenant admin
could revoke NULL-tenant system keys because store SQL matches
(tenant_id = \$N OR tenant_id IS NULL).
Implementation:
- Export store.IsMasterScope as the single predicate; rewire Phase 1
config.* middleware to delegate (no behaviour change).
- Add http.requireMasterScope helper symmetric to requireTenantAdmin.
- Guard handleUpdate (builtin_tools) and handleInstall/handleUninstall
(packages) with master-scope check before any mutation or shell exec.
- Fix api_keys.Revoke at the handler layer: fetch key via new
APIKeyStore.Get, verify key.TenantID matches caller tenant for
non-owner callers. Applies to both HTTP and WS paths.
- Harden WS router to inject role into ctx so store.IsOwnerRole works
from WS handlers (closes a latent drift between the HTTP and WS
layers that broke the initial WS api_keys.revoke fix).
- Drop unused APIKeyStore.Delete (YAGNI + removes dormant vuln with
the same tenant_id IS NULL arm).
- Emit security.tenant_scope_violation and security.api_key_revoke_
forbidden slog events on every rejection for future SIEM alerting.
- New MsgMasterScopeRequired i18n key + en/vi/zh catalogs.
Tests cover the guard predicate, all 3 HTTP endpoints, and the full
WS api_keys.revoke matrix (cross-tenant deny, system-key deny for
tenant admins, system owner bypass, own-tenant happy path). 14 other
admin-gated write endpoints verified safe by static audit.
Tenant config changes for builtin tools and skills silently failed to
invalidate cached agent Loops, leaving tenants stuck on stale tool/skill
sets until the 10-minute TTL expired or an unrelated event wiped the
cache. Master-level skill CRUD had the same gap in the opposite
direction.
- Add CacheInvalidatePayload.TenantID so events can scope to one tenant
- Add Router.InvalidateTenant(tenantID) with prefix match on
"tenantID:agentKey" cache keys; uuid.Nil is a no-op
- Rework emitCacheInvalidate helpers in builtin_tools + skills handlers
to carry tenant scope; add defense-in-depth uuid.Nil guards in four
tenant-config handlers
- Update TopicCacheBuiltinTools + TopicCacheSkills subscribers to branch
on payload.TenantID (tenant event wipes that tenant, global event
keeps the existing InvalidateAll path)
- Wire emitCacheInvalidate into master skill CRUD paths (update, delete,
toggle, upload, install-deps, rescan-deps, import) that previously
only called BumpVersion
- Document the system-owner bypass in requireTenantAdmin so handlers
keep guarding uuid.Nil themselves
handleUpdate accepted any string in the agent_key allowlist field
without running it through isValidSlug. A client could rename an
agent to "weird:key", which would break router cache exact-segment
invalidation (the cache splits on the last colon for invalidation
matching). Add the slug check inline after the allowlist filter and
return MsgInvalidSlug on failure. The slug regex already rejects
colons, slashes, whitespace, and other characters that would confuse
path rendering or cache key parsing — add a dedicated predicate test
covering the full trap surface.
Rewrite inline comments added during the agent identity hardening so
they explain the code as it stands today, rather than tying to internal
plan terminology (phase numbers, FR/NFR/H/M/C codes, PR references,
trap zone labels). Commit history already carries the plan archaeology.
Comments now keep the non-obvious invariants (cache boundaries, bypass
gaps, silent-nil traps, dual-tenant semantics) and drop the scaffolding.
Comment-only — no runtime behavior change.
Final step of Phase 4: rename the silently-nil-on-error helper so its
behavior is self-documenting at the call site. No behavior change — all
remaining call sites (~24) were already classified as WARN-acceptable
(read-only SELECT WHERE paths with validated inputs from cursor
pagination or WS boundary) during sub-step 4b migration.
The parseUUID error variant stays as-is for CRITICAL writes.
Also cleans up a now-stale comment in vault_handler_upload.go.
Phase 4 Step 4d of agent identity hardening (TD-1).
validateTeamMembership short-circuits on owner role and nil teamAccess
(lite edition), leaving downstream mustParseUUID calls as a silent-nil
trap. Validate UUIDs at the HTTP boundary — before workspace resolution,
store upsert, or event publish — so bad form input is rejected for every
caller regardless of role or edition.
Closes the owner/lite gap identified in red-team H9.
Phase 1 Fix B of agent identity hardening.
- Replace density-branched FA2 layout with unified adaptive algorithm
based on orphan ratio instead of misleading edge density threshold
- Remove Louvain community grid seeding (caused ring/rectangle artifacts)
- Enable strongGravity + linLog + outboundAttractionDistribution for
organic cluster layout on both vault and KG graphs
- Update vault type colors: note→teal, media→pink (softer for dominant types)
- Increase light-mode edge visibility (slate-400 @ 80% vs gray-300 @ 60%)
- Enrich vault links API with doc_names map for outlink target titles
- Show document names instead of truncated UUIDs in link badges
- Use existing backlink title field from backend JOIN
- Horizontal scroll for link badges to preserve content viewport
- Move "Add Link" button to bottom bar in detail dialog
- Remove unused graphology-communities-louvain dependency
- Convert importCron/importUserProfiles/importUserOverrides to multi-row
UPSERT with chunking (200→2 queries per import)
- Add GetTenantsByIDs batch method to TenantStore (PG+SQLite), refactor
handleMine() to single-query tenant fetch (N→1)
- Refactor buildAgentKeyMap() to use existing GetByKeys with tenant-scoped
context instead of per-key SELECT (N→1)
- Add GetDocumentsByIDs, GetDocumentByBasename, CreateLinks batch methods
to VaultStore (PG+SQLite)
- Batch-fetch docs in enrichment Phase 0, carry title through pipeline to
avoid per-doc refetch in classify phase
- Replace ListDocuments(limit=500) fallback in wikilink resolution with
targeted GetDocumentByBasename DB query
- Batch CreateLink calls in SyncDocLinks and classifyLinks
- Refactor usage handleGet/handleSummary to use ListPagedRich instead of
List+GetOrCreate N+1 loop (100→1 queries)
- Batch INSERT for team import members/comments/events/links (250→5)
- Add migration 47: UNIQUE constraint on cron_jobs(agent_id,tenant_id,name)
with dedup, SQLite schema v15
Sparse graphs (edges/node < 0.8) previously used Louvain community
detection which created hundreds of singleton communities → grid layout.
Now uses random disc init + FA2 with strongGravityMode + higher gravity
to pull orphan nodes inward. Dense graphs keep Louvain-seeded layout.
Backend:
- Add cross-agent routes for all vault CRUD (/v1/vault/documents/*, /v1/vault/links/*)
alongside existing per-agent routes for backward compat.
- All handlers skip agent ownership check when agentID is empty.
- Path traversal validation (reject .. and leading /) on create.
- Scope defaults to "shared" when creating without agent.
- Enrichment progress broadcast via WS event (vault.enrich.progress)
using existing MessageBus infrastructure.
- GET /v1/vault/enrichment/status for polling fallback.
Frontend:
- Fix VaultDocument.agent_id type to optional/nullable — prevents
runtime crash on shared/team docs without agent.
- All vault hooks use cross-agent URLs (/v1/vault/...) — no more
skipping docs with null agent_id in graph/links/mutations.
- Remove dead agentId params from useUpdateDocument, useDeleteDocument,
useCreateLink, useDeleteLink, useVaultLinks, LinkBadge.
- Pass team_id from selected team filter to create dialog.
- WS-based enrichment progress bar on vault page (useWsEvent hook).
- Disable rescan button during enrichment, auto-clear after 3s.
- Fix rescan storing stripped paths (teams/{uuid}/... → file.md) causing
enrichment worker to fail locating files on disk. Now stores full
workspace-relative path with backward-compat fallback for old records.
- Refactor enrichment into chunked pipeline: batch 5 files per LLM call
(was 1:1), 3 parallel goroutines via semaphore — reduces LLM calls ~80%.
- Extract shared chatWithRetry for summarize + classify (3 retries,
escalating timeouts 5m→7m→10m).
- Add POST /v1/vault/documents route for cross-agent doc creation
(agentID optional, defaults scope to shared when no agent).
- Add path traversal validation (reject .. and leading /) on create.
- Skip media files in syncWikilinks to prevent binary garbage parsing.
- Bound syncWikilinks file read to 4MB.
UI:
- Replace bottom detail panel with modal dialog for all doc views
- Redesign sidebar with type icons, compact layout, inline metadata
- Always show team select, fix missing load() call for teams hook
- Align header heights between sidebar and main panel
Backend:
- Add TeamIDs filter to VaultListOptions and VaultSearchOptions
- Non-owner users now see personal + their teams' docs (was personal-only)
- Refactor PG search to use searchTeamFilter for FTS and vector queries
- Update both PG and SQLite stores with shared team filter helpers