- 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>
Follow-up to #924:
- Add TestGetUpdates_EmptyResultReturnsNilSlice to cover the no-events
polling branch (API returns {} when no pending updates).
- Extract magic 7s into pollTimeoutHeadroom const for readability.
- A.10 AdminRoleBypass (3 sub-cases): admin, operator, owner roles in
ctx allow write_file and cron in group context even when SenderID is
empty (the bypass short-circuits before the synthetic-sender check).
- A.11 ViewerRoleDoesNotBypass (2 sub-cases): viewer and empty roles
still hit the DENY path, guarding against the RBAC bypass leaking to
unprivileged roles.
Integration suite 11 fixtures, all passing on pgvector pg18.
Dashboard users (and other tenant-authenticated admin/operator/owner
actors) dispatching team tasks to agents in Telegram/Discord groups
previously hit the empty-sender DENY rule when the assigned agent
tried to write files — because dashboard users have tenant identities
(e.g. "viettx") that don't match the numeric file-writer grants stored
per channel.
Two-part fix:
1. CheckFileWriterPermission / CheckCronPermission add an RBAC bypass
at the store layer: if ctx carries RoleFromContext in {admin,
operator, owner}, skip the per-user grant check. Tenant RBAC already
pre-authenticated these callers at the gateway edge; requiring a
redundant per-channel grant blocks legitimate admin work.
2. Role propagation through the full dispatch chain:
ctx (RoleFromContext)
→ RunRequest.Role
→ SubagentTask.OriginRole / AnnounceMetadata.OriginRole
/ DelegateRequest.Role
→ InboundMessage.Metadata[MetaOriginRole]
→ subagentAnnounceRouting.Role / teammateRole
→ RunRequest.Role (re-ingress)
→ store.WithRole(ctx, ...) in loop_context
Covers subagent announce, delegate announce, teammate dispatch
(WS dashboard, tool), session_send, and processNormalMessage's
synthetic-sender override path. team_tasks_create persists
origin_role for deferred dispatches.
Viewer and empty roles fall through to the existing sender-based path
(no bypass).
Regression tests:
- A.10 AdminRoleBypass: admin/operator/owner in ctx allowed even with
empty sender (normally DENY).
- A.11 ViewerRoleDoesNotBypass: viewer and empty roles still hit DENY.
Builds + integration + unit tests green.
Closes the two last unpropagated paths identified by the #915 e2e audit.
processNormalMessage now prefers MetaOriginSenderID when the on-wire
SenderID is a synthetic internal sender (notification:, ticker:,
session_send_tool, system:escalation, etc.). Real user senders pass
through unchanged. Defense-in-depth: MetaOriginSenderID is ignored if
it itself looks synthetic, preventing a propagation bug from turning
into a permission bypass.
teams_tasks_mutations dashboard dispatch now injects MetaOriginSenderID
into the teammate InboundMessage metadata, preferring the WS caller's
sender (live dashboard) and falling back to origin_sender_id stored on
the task (for deferred dispatches). Without this, dashboard-triggered
team tasks in group chats would hit the empty-sender DENY rule.
team_tasks_create persists origin_sender_id into task.Metadata at
creation time so the deferred dashboard dispatch path can reconstruct
the original actor identity.
Prevents two silent regressions flagged by the #915 e2e audit:
Flow Q — private skill invisible to creator in group:
ListAccessible filtered s.owner_id = $2 (userID = group principal).
After the ACTOR migration, new skills get owner_id = sender, so the
publisher could not see their own private skill on the next group turn.
Fixed by matching owner_id and skill_user_grants.user_id against both
userID and ActorIDFromContext(ctx) in the query. The OR collapses in
DM contexts where both resolve to the same value.
Ownership check in skill_manage (patch + delete):
Replaced the two-identity compare with isOwnerOfSkill helper that
matches any of {actor, userID, senderID}. Covers (a) new rows with
actor-based owner, (b) legacy rows with group-scope / tenant-merged
owner, (c) rows from the short pre-merge-aware ActorID window where
DM owners got the raw channel sender. A tenant-merged user reliably
matches all three of their skills regardless of vintage.
Gateway consumer resolves raw channel senders to merged tenant
identities for DMs (e.g. Telegram "386246614" → tenant "viettx"). The
previous ActorIDFromContext unconditionally preferred SenderID, which
broke cross-channel ownership continuity — a user's skills / audit
records published from Telegram would carry the channel-specific
numeric ID instead of the stable tenant identity.
New resolution rule:
- group: / guild: scope → SenderID wins (UserID is the group namespace)
- DM / HTTP / cron → UserID wins (already merge-aware from consumer)
- SenderID fallback → only when UserID is empty
Unit coverage updated: 8 sub-tests covering group, guild, DM-merge,
DM-unmerged, HTTP, empty, sender-only, delimited-sender scenarios.
- agent-identity-conventions.md: new "ActorID vs UserID in Group Chats"
section with helper table, group behavior table, propagation chain
diagram, group permission policy, legacy-data tolerance notes.
- 17-changelog.md: entry covering the security fix, propagation
additions, ACTOR migration list, scope-intentional sites, tests,
and the no-DB-migration decision with its legacy-fallback rationale.
Extends Telegram group write_file permission coverage with four new
fixtures that exercise the edges exposed by the audit:
- A.6 EmptySenderDenied: empty SenderID in group context must deny
(closes silent-bypass BUG-A)
- A.7 SyntheticSendersDenied: 7 sub-cases for subagent:, notification:,
teammate:, system:, ticker:, session_send_tool, subagent:delegate:
(reporter-reported denial BUG-B)
- A.8 PropagatedSenderAllowed: positive case for F2/F3 — once the real
sender is propagated through announce re-ingress, a granted user can
still write files
- A.9 DMEmptySenderPasses: guards against F1's deny-on-empty rule
bleeding into DM context
Full suite: 9 fixtures, all passing on pgvector pg18.
Resolves issue #915 (Telegram group write_file permission denied after
/addwriter) by reconstructing the real sender on announce re-ingress
and migrating ownership/audit call sites off the scope-coupled UserID.
Propagation chain (new MetaOriginSenderID key):
SubagentTask.OriginSenderID
→ AnnounceMetadata.OriginSenderID
→ InboundMessage.Metadata[MetaOriginSenderID]
→ subagentAnnounceRouting.SenderID
→ RunRequest.SenderID
Same chain for delegate_tool.announceToParent, team_tool_dispatch,
sessions_send, team_tasks_blocker escalation.
ACTOR migration (owner/audit now uses ActorIDFromContext):
- publish_skill: skill owner = individual sender
- skill_manage create/patch/delete: owner = sender; ownership check
accepts actor or legacy UserID for pre-migration rows
- delegate_tool: DelegateRequest.UserID + DomainEvent.UserID = actor
- team_tasks_blocker: blocker attribution + escalation = actor
- team_tool_cache: team access-policy check = actor (fixes per-user
allow/deny lists in group chats)
Scope-intentional (unchanged, commented): cron.go, team_tasks_create.go.
CheckFileWriterPermission/CheckCronPermission previously returned nil
on empty SenderID in group/guild context (fail-open). This allowed
subagent/delegate/teammate/dashboard system turns to write files
without any writer grant — silent privilege bypass (#915).
Post-fix: empty or synthetic-prefix senders (subagent:, notification:,
teammate:, system:, ticker:, session_send_tool) are denied in group
context. DM / HTTP contexts remain unchanged. Fail-open still applies
only to DB errors (availability preserved).
bus.IsInternalSender now also recognises subagent: prefix.
Introduce ActorIDFromContext(ctx) that returns SenderID if set, else
falls back to UserID. Separates ACTOR (acting principal) from SCOPE
(memory/namespace) semantics so permission checks, audit trails, and
ownership fields can migrate off the scope-coupled UserIDFromContext.
No callers yet; migration of existing sites is a follow-up.
Add pageSize to useUiStore (persisted in localStorage). Update
PAGE_SIZES default to [20, 50, 100, 200]. All 12 paginated pages
now read initial value from store and sync changes back globally.
- PG migration 000054 + SQLite v21→v22: add nullable name column
- HookConfig.Name in Go struct, PG/SQLite scan/insert, WS handlers
- Builtin seed writes spec ID as hook name
- UI: name input in create/edit form, displayed in hook list row
- Beta card: "Learn more" button opens modal explaining hooks×skills×MCP
- Default handler_type changed from http to script
- Script editor section: max-h-[50vh] with scroll, border always visible
- i18n: all new keys in en/vi/zh
Input-mutation hooks should be opt-in. Adds an explicit YAML toggle so
operators turn them on after verifying the regex / policy matches their
content, instead of getting silent mutation on first install.
- internal/hooks/builtin/loader.go: Spec.DefaultDisabled (yaml
default_disabled). Reverse-named so zero value preserves prior
default-on behavior for any spec that omits the field.
- internal/hooks/builtin/seed.go:
- Fresh insert path honors the flag → row created with enabled=false
- Version-bump path: when an UPDATE crosses the boundary that newly
introduces default_disabled=true, flip enabled→false once. Subsequent
boots without a version bump leave the user's manual re-enable alone
(the switch fall-through does nothing on equal versions). Logs
hooks.builtin_default_off_applied for audit.
- internal/hooks/builtin/builtins.yaml: pii-redactor v1 → v2 with
default_disabled: true. Existing installs auto-flip to disabled on
next boot via the version-bump branch above; users re-enable through
the UI toggle when ready.
- Tests:
- TestSeed_DefaultDisabledOnFreshInsert (insert respects flag)
- TestSeed_DefaultDisabledFlipsOnVersionBump (one-shot flip + user
re-enable preserved on the next no-bump boot)
User-reported polish on the hooks page.
- Form modal: widen from sm:max-w-lg → sm:max-w-3xl lg:max-w-4xl. Pair
Event/Scope and Matcher/if_expr into 2-col grids; collapse
timeout/on_timeout/priority/enabled into a single 4-col row on lg+.
Reads the same on mobile (single column).
- Test panel: replaced the floating bottom-right drawer with a centered
Dialog (sm:max-w-4xl). Two-column layout — left is the sample event
form with per-field hints, right is the result with empty-state
illustration, prominent decision pill, separated reason / stdout /
stderr / error / updatedInput sections. Run button is now full-width
with a Play icon. New i18n strings cover all hints + empty/error
states (en/vi/zh).
- Beta info card: top-of-page collapsible explainer with three "how it
works" points (event → handler → test). Persists collapsed state in
localStorage so dismissed users don't see it expand on every reload.
i18n in en/vi/zh under the new beta.* namespace.
User report: hook detail page ugly, script not syntax-highlighted, edit
dialog crashes silently with no toast.
- Replace react-simple-code-editor (React 19 compat issues — likely cause
of the silent edit-dialog crash) with a native textarea + <pre> overlay.
Same UX: caret over highlighted source, scroll-synced. Drops the dep
entirely and removes a render-path black box. Bundle size went DOWN
(62 KB → was 65 KB before Phase 06; net ~+1 KB for Prism core).
- Import prismjs/themes/prism-tomorrow.css so token colors actually show.
Editor surface uses #2d2d2d to match the Tomorrow theme palette.
- Harden HookFormDialog reset against null `initial.config` — previous
cast threw when the row was inserted with no config payload.
- Redesign HookOverviewTab:
- Header card with event/handler/scope chips + status pill
- Metadata grid (timeout/on_timeout/priority/source/version/timestamps)
- Handler-specific cards: script source rendered via the read-only
ScriptEditor (full Prism highlight), HTTP shows url/method/body,
prompt shows template + model. No more raw JSON dump.
- Extract HttpConfigCard / PromptConfigCard subcomponents to keep
types narrow at the unknown→string boundary.
- ErrorBoundary now toast.error()s when it catches — user sees the
failure visually instead of just the generic fallback card. Full stack
still logs to console for devs.
Code-review findings from Wave 1 audit (commits 097776c5..f76a681e).
Critical (security):
- C1 internal/gateway/methods/hooks.go: parseHookConfigParams strips
caller-supplied source/id/created_by/version. Without this a tenant
admin could POST {"source":"builtin"} and escalate their UI hook into
the dispatcher's builtin capability tier (which is allowed to mutate
event input). Default Source resolves to "ui" via Validate.
- C2 internal/gateway/methods/hooks.go: handleUpdate also strips source +
created_by from the patch map. Same forge surface via PATCH instead of
POST.
High:
- H1 internal/gateway/methods/hooks.go: Update + Delete handlers wrap
hooks.ErrBuiltinReadOnly into the i18n key MsgHookBuiltinReadOnly so
users get a localized "builtin hooks are read-only" message instead of
the raw English sentinel string.
- H3 internal/hooks/handlers/script.go: bound tenantSems map with
opportunistic sweep — once map crosses 64 entries, idle slots
(no in-flight grants AND last-used > 1h ago) are reclaimed under the
same lock the acquire path already takes. Goroutine-free; common-path
cost unchanged.
Medium:
- M1 internal/hooks/dispatcher.go: builtinAllowlistLookup uses
atomic.Pointer instead of plain package var. Fixes the parallel-test
race where one test installs a lookup while another reads.
- M3 ui/web/index.html: drop http: https: from CSP connect-src — keep
'self' + ws:/wss: only. Same-origin XHR/WS still works; cross-origin
HTTP is no longer wildcard-permitted.
Tests:
- internal/gateway/methods/hooks_source_strip_test.go pins the C1+C2
strip behavior at the parse layer with a regression case that posts
every forge field and asserts each is zeroed.
Phase 07 runtime migration. Post-Wave-1 Standard deployments that pre-dated
the edition gate may still carry enabled command-type hook rows in the DB;
those keep firing via the dispatcher after the UI stops letting users create
new ones. This helper flips them off at startup so live traffic never fires
a disabled-by-policy handler again.
- internal/hooks/migration_command_autodisable.go:
DisableLegacyCommandHooks(ctx, hookStore, edition). Standard only; Lite
returns early. Master-scope context + List(Enabled=true) → skip
non-command + source='builtin' rows → Update enabled=false. Per-row
WARN log with hook_id + tenant_id for audit; INFO summary when n>0.
Idempotent: second boot finds nothing.
- cmd/gateway_managed.go: invoke after builtin.Seed, BEFORE handler
registration so no HTTP/WS traffic races the migration.
- Tests (fake store, 5 cases):
* Standard with mixed rows → only command+source=ui gets disabled
* http + script/builtin + command/builtin rows untouched
* Idempotent second run → n=0
* Lite edition → n=0, nothing touched
* nil store → n=0, no error
* List error → propagated up
Note: Phase 04/05 never seed command-typed builtins, but the carve-out is
kept defensively so a future accidental builtin command row can't be
disabled by this migration.
First real builtin under Phase 04 infrastructure. Registers two DB rows
(user_prompt_submit + pre_tool_use) on startup, runs the embedded JS with
source-tier gate + dispatcher dotted-path allowlist.
- internal/hooks/builtin/pii-redactor.js (ES5.1, goja-compatible):
handle(event) returns updatedInput only when email or E.164-ish phone
actually matched. Redacts rawInput + toolInput.{command,query,content}.
- builtins.yaml: pii-redactor entry (version 1, priority 900, timeout 2s,
on_timeout=allow — missing redaction < blocking conversation).
- Drop _placeholder.js; pii-redactor.js is now the first real //go:embed match.
- Export builtin.Source(name) so handler tests load embedded JS without
going through Load()/cache state.
- Tests:
* loader_test: registry contains pii-redactor after Load().
* script_pii_redactor_test (handler-level, real goja runtime): email
masked in rawInput; +E164 phone masked; toolInput.command redacted,
toolInput.path left alone (not in mutable_fields); no PII → no
mutation; redact(redact(s)) == redact(s).
* dispatcher_test: 3 dotted-path walker cases (toolInput.command only
lets that key through; rawInput-only doesn't let forged toolInput
override original event keys; toolInput wildcard merges all keys).
Bench: ~242µs/op on 1 KiB Apple M4 Max, well under <1ms target.
Two pre-existing issues kept the PG hook tests from running under the
documented flow (TEST_DATABASE_URL="postgres://postgres:test@localhost:5433/
goclaw_test?sslmode=disable"):
1. migrate.New path had one extra `../` — resolved above the repo root.
Fixed from `file://../../../../migrations` to `file://../../../migrations`.
2. `seedTenantAndAgent` derived slug + agent_key from `uuid[:8]`, and two
UUIDv7 calls in the same millisecond share their 8-char prefix. The
second tenant INSERT got silently swallowed by ON CONFLICT DO NOTHING on
the unique slug constraint, so the follow-up agent INSERT failed the
`agents_tenant_id_fkey` FK. Fixed by using the full UUID in the slug /
agent_key suffix.
Verified: `TestPGHookStore_*` (6 tests) now green under the integration
DB flow; `TestHooks*` under `-tags integration` still green.
Wave 1 Phase 03. Wires the Phase 02 Goja handler into the dispatcher, widens
the DB schema for script + builtin sources, and refactors Dispatcher.Fire to
return a FireResult so builtin-source hooks can mutate event input.
Dispatcher:
- FireResult{Decision, UpdatedToolInput, UpdatedRawInput} replaces the plain
Decision return. stdDispatcher.runSync keeps a local evMut copy so a
builtin-source hook's updatedInput flows to downstream hooks in the chain
and to the caller; non-builtin (source=ui) script mutations are dropped +
WARN-logged (defense-in-depth; source tier enforced at the dispatcher, not
only at the handler).
- applyBuiltinMutation + placeholder builtinAllowlistFor (rawInput, toolInput)
— Phase 04 overrides the allowlist from builtins.yaml.
- noopDispatcher returns FireResult{DecisionAllow}.
- Script mutation tests: TestDispatcher_ScriptMutation_BuiltinSourceApplies,
TestDispatcher_ScriptMutation_UISourceDenied.
Pipeline + callers (14 Fire sites refactored):
- FireHook wrapper returns FireResult; context_stage.go:52 applies
UpdatedRawInput → state.Input.Message; tool_stage.go:52 applies
UpdatedToolInput → tc.Arguments before ExecuteToolCall.
- delegate_bridge + delegate_tool keep the Decision branch; Updated* ignored.
- dispatcher_test / delegate_bridge_test / delegate_tool_hooks_test /
integration test helpers updated for the new shape.
Validation:
- edition_gate allows HandlerScript on every edition (sandboxed, no shell
escape surface).
- config.validateHandler rejects empty source, source > 32 KiB, goja compile
error; validateTimeout rejects on_timeout=ask|defer (reserved).
- Six new config tests cover the script path.
Migrations:
- PG migration 000053 relaxes handler_type + source CHECKs and drops
uq_hooks_{global,tenant,agent} — scripts routinely want many small hooks
per event. RequiredSchemaVersion → 53.
- SQLite SchemaVersion → 21; patch 20 is a SELECT 1 placeholder because
SQLite can't ALTER a CHECK — rebuildAgentHooksV21 runs outside the
migration tx (parallel to backfillV16) to rename/recreate the table with
widened CHECKs. schema.sql fresh-DB path matches.
- H9 fix: PGHookStore.Create + SqliteHookStore.Create honor caller-provided
cfg.ID (uuid.Nil falls back to UUIDv7), unblocking Phase 04 idempotent
UUIDv5 seed. TestCreateHonorsFixedID on both stores.
Config:
- config.HooksConfig{ScriptConcurrency, ScriptPerTenantConcurrency,
ScriptCacheSize, BuiltinDisable}; buildHookHandlers wires the script
handler with the caps from appCfg.Hooks.
Gates green: go build ./... + sqliteonly, go test -race -count=1 across
hooks/pipeline/sqlitestore.
Wave 1 Phase 01 — UI gates before script-handler work begins.
Backend:
- WS connect response adds `is_master_scope` (store.IsMasterScope with
role+tenant scoped ctx) and `edition` (edition.Current().Name).
Web:
- Auth store persists both values; ws-provider pipes them from ws-client
on connect and resets on disconnect.
- Hook form: removes `command` radio + legacy tooltip; scope options
hide `global` when caller is not master scope.
- Legacy rows with `handler_type="command"` coerce to `http` on edit
(phase 07 auto-disables them). Schema enum narrowed to http|prompt.
- i18n: drops 4 obsolete keys (form.command, form.allowedEnvVars,
form.cwd, form.commandDisabledTooltip) from en/vi/zh.
Security invariant: is_master_scope is an advisory UI hint only —
backend still enforces master scope on write paths.
Adds /hooks page with tenant-scoped list, create/edit dialog (per-handler
config forms for command/http/prompt), history table with diff viewer,
and dry-run test panel that calls hooks.test RPC. Sidebar entry gated by
edition/scope through existing auth store.
i18n: new hooks.json namespace + sidebar.json additions for en/vi/zh.
Routes + query-keys wired; no new runtime deps added.
Adds config.defaults WS method so UI can render pruning-knob placeholders
that mirror the server's internal fallback values. Exports
agent.DefaultPruningValues() as the single source of truth used by both
the resolver and this RPC — keeps UI in sync without a parallel table.
Exposes agent hooks over WebSocket (hooks.list/create/update/delete/
toggle/test/history) behind existing auth layer. Additions:
- Gateway wiring: buildHookHandlers() factory reused by dispatcher and
hooks.test runner, so UI test panel exercises production code paths
- GetByID tenant-scope guard: non-master callers only see own tenant +
global rows (matches List() behavior on both PG and SQLite)
- i18n keys + catalogs (en/vi/zh) for hooks error messages
- Protocol method constants for hooks.* and config.defaults
Adds PromptHandler that runs an LLM over matched hook events (user prompts,
tool calls) to produce decisions (allow/block/modify). Features:
- Budget integration: per-tenant token spend enforced via budget.Store
- Per-turn counter seeded in ContextStage and propagated through state.Ctx
so PreToolUse fires under the same cap as UserPromptSubmit
- prompt_template required (non-empty) at validate time to prevent
misconfigured hooks from silently no-oping
- Injection-hardened: tool_input/raw_input are quoted into system prompt
rather than concatenated
- Resolver abstraction (RegistryResolver) picks model from provider registry
with SystemConfigs fallback
Atomic budget store for prompt-handler LLM spend, with PG and SQLite
implementations. Enforces per-hook token caps to prevent runaway cost
from matcher-bypass scenarios.
Fixes#922 - Zalo OA bot was silently dropping all incoming messages due to JSON field mapping mismatches.
- Align zaloBotInfo/zaloFrom/zaloChat JSON tags with live API (display_name, chat_type)
- Change getUpdates to decode single-object response (API returns one update, not an array)
- Add dedicated pollClient with Timeout=0 + per-call context timeout for long-polling
- Update tests accordingly
Opt-in feature (features.auto_react: true) that automatically likes
a user's Facebook comment via Pancake /likes endpoint when the comment
webhook is received, as an engagement signal.
- Add AutoReact bool to Features config struct
- Add ReactComment() to APIClient (multipart POST to /likes, auth via api_key)
- Add reactCommentAsync() to comment handler; fires before keyword filter,
independent of comment_reply feature
- Bound with 10-slot semaphore + 5s stopCtx timeout per goroutine
- Guard: only fires on platform=facebook with non-empty convID + msgID
- Startup warning when auto_react=true without webhook_secret (F9)
- Update feature gate: exit only when BOTH auto_react AND comment_reply disabled
- Add 8 tests (ReactComment contract, error body, invalid IDs, handler variants)
SearchByEmbedding query uses COALESCE(description, '') without an AS
alias, causing PostgreSQL to name the column 'coalesce'. The sqlx scan
struct expects db:"description", so the scan fails with:
missing destination name coalesce in *[]pg.skillEmbeddingSearchRow
The BackfillEmbeddings query on line 84 already has the correct
AS description alias — this fix makes the search query consistent.
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- webhook_handler.go: demote per-event "page_id resolution" log from Info to
Debug. Firing on every webhook was the exact anti-pattern called out in the
routing-metadata refactor review.
- pancake.go: move reply_to_comment_id guard above rememberOutboundEcho so a
missing-metadata error does not stamp phantom echoes that would pollute
inbound echo dedup.
- docs/journals: drop three Pancake journals (not needed in repo).
* feat(channels): make Pancake platform a required select with 11 options
- Add mandatory platform select field (11 options) to Pancake channel schema
- Add config required validation on channel instance form (create-only)
- Add i18n fieldOptions/fieldConfig for platform in en/vi/zh locales
- Add TDD tests for Pancake config schema (channel-schemas.test.ts)
- Add backend test TestFactoryExplicitPlatformPreserved
- Add slog.Debug for auto-detect path in pancake.go
- Update Platform field comment in types.go for clarity
- Update plan status to completed; add changelog entry
* feat(channels): hide comment_reply for non-social Pancake platforms
Extend showWhen to accept string | string[] values. Apply it to
features.comment_reply so the field only appears for platforms that
support public posts/comments (facebook, instagram, threads, tiktok,
youtube). E-commerce platforms (shopee, lazada, tokopedia) and
messaging-only platforms (line, google, chat_plugin) no longer show
the irrelevant Comment Reply toggle.
Also guard depValue before String() coercion to avoid the "undefined"
literal matching hazard.
* feat(channels): Pancake comment reply fix + platform select + UI improvements
- Fix comment reply: pass message_id (reply_to_comment_id) to Pancake API
ReplyComment now requires messageID param; guard added for empty ID
- Add SendMessageRequest.MessageID field (omitempty) for reply_comment action
- Platform select: required field with 11 options, showWhen gate for comment_reply
- Webhook Page ID moved to Advanced collapsible section in channel form
(auto-expands when existing value is configured)
- routing_metadata.go: centralize routing metadata key constants
- config-flatten.ts: flatten/unflatten nested config for form state
Closes: Pancake comment reply returns 'Missing required field: message_id'