Commit Graph
1371 Commits
Author SHA1 Message Date
Duy /zuey/andviettranx 2cbf838158 feat(packages): GitHub Releases binary installer (#898)
* 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>
2026-04-16 15:09:48 +07:00
viettranx 0a4b8df001 test(zalo): cover empty getUpdates response + extract poll timeout headroom
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.
2026-04-16 14:20:02 +07:00
viettranx f1e71ee8fe test(integration): cover RBAC role bypass + non-bypass for file_writer permission
- 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.
2026-04-16 14:17:48 +07:00
viettranx 6a9b747b65 feat: propagate RBAC role through dispatch + bypass file-writer grant for admins
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.
2026-04-16 14:17:48 +07:00
viettranx 51cf103714 feat(ui): extend cron run history page size options to 200 2026-04-16 14:17:48 +07:00
viettranx 1bd90123c6 fix(gateway): override synthetic senders with MetaOriginSenderID + propagate through dashboard dispatch
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.
2026-04-16 14:17:48 +07:00
viettranx 2fcb9a9ee0 fix(skills): widen ListAccessible + ownership match to cover actor/user/sender identities
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.
2026-04-16 14:17:48 +07:00
viettranx 3976cf1910 fix(store): make ActorIDFromContext tenant-merge-aware in DM contexts
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.
2026-04-16 14:17:48 +07:00
viettranx e6e351aca0 docs: document ACTOR vs SCOPE pattern + #915 changelog entry
- 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.
2026-04-16 14:17:48 +07:00
viettranx 24a098c580 test(integration): add #915 regression fixtures for re-ingress + synthetic senders
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.
2026-04-16 14:17:48 +07:00
viettranx 9e5cec03ba feat: preserve acting sender + migrate ACTOR sites across tool chain
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.
2026-04-16 14:17:48 +07:00
viettranx 64eb29e415 fix(store): close permission fail-open on synthetic senders in group chats
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.
2026-04-16 14:17:48 +07:00
viettranx 7cfcbbf9db fix(vault): include shared docs in agent read paths (#917)
- Patch vault_search, ListDocuments, CountDocuments, ListTreeEntries to
  include shared docs (agent_id IS NULL) for agents in the tenant
- Keep DELETE/GetDocument/GetByBasename strict (auth-intent)
- Add CHECK invariant vault_documents_scope_consistency (PG migration
  000055 NOT VALID, SQLite triggers v24) to prevent future drift
- Update docs and changelog

Affects PostgreSQL and SQLite (desktop edition).
2026-04-16 14:17:48 +07:00
viettranx 4fe293ae1e test(integration): cover Telegram group write_file permission flow
Add 5 fixtures mirroring gateway_consumer_normal.go + commands_writers.go
context/grant shapes. Proves the ConfigPermissionStore permission flow
resolves correctly in isolation: granted sender allowed, ungranted
denied, fail-open path distinguished, DM bypass preserved, and the
"|"-delimited sender split behaves as specified.

Regression coverage for issue #915.
2026-04-16 14:17:48 +07:00
viettranx 237626b660 feat(store): add ActorIDFromContext helper
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.
2026-04-16 14:17:48 +07:00
viettranx 207bc56dbd feat(ui): persist page size preference across all list pages
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.
2026-04-16 14:17:48 +07:00
viettranx f8a6f9363f feat(ui): hook test panel tool combobox with JSON auto-fill
Replace plain text input with single-select combobox listing builtin
tools, auto-fill JSON skeleton on selection, expand dialog sizing.
2026-04-16 14:17:48 +07:00
viettranx b097a29a61 fix(ui): heartbeat delivery Select full-width + popper positioning
Ensure Select triggers in heartbeat section use full available width
and render dropdown with popper positioning for consistent alignment.
2026-04-16 14:17:48 +07:00
viettranx ee328d4266 refactor: consolidate hooks table migration and drop deprecated agent_id column
- Consolidated PG migration 054: agent_hooks rename + junction table + deprecated column drop
- Updated SQLite schema v19 with final consolidated migration
- Removed obsolete schema rebuild files (v21, v23)
- Updated Go store layer (pg/hooks.go, sqlitestore/hooks.go)
- Updated integration tests to use new table names (hooks, hook_agents)
- Updated TypeScript protocol, UI components, and i18n strings
- Updated gateway methods to reflect schema changes

Tables: agent_hooks → hooks, agent_hook_agents → hook_agents
2026-04-16 14:17:48 +07:00
viettranx 6659ba46a8 feat(tts): consolidate TTS config UI with provider-aware pickers and synthesize endpoint
- Add 4-provider catalog (OpenAI, ElevenLabs, Edge, MiniMax) with static
  voice/model data for web and desktop
- Rewrite /tts page as 5 modular sections: provider setup, credentials,
  voice/model picker, test playground, collapsible advanced settings
- Add POST /v1/tts/synthesize endpoint with 15s timeout, 500-char cap,
  RoleOperator auth, ElevenLabs model validation, rate limit reuse
- Fix Edge TTS provider to honor per-request opts.Voice instead of
  always using construction-time default (C1)
- Gate agent detail TTS section on global provider config with
  empty-state CTA for owners, catalog-driven model select, override
  checkbox, and inline voice test button
- Remove TTS from builtin-tools dialog (STT preserved); hide tts tool
  from builtin-tools page list
- Add desktop parity: provider-aware VoicePicker, TTS config hook via
  WS config.get, empty-state component, synced i18n locales (en/vi/zh)
- Add 109 web vitest tests + 12 Go unit tests covering handler
  validation, legacy config regression, and catalog parity
2026-04-16 14:17:48 +07:00
viettranx df82fafb28 feat(hooks): name field + beta explainer modal + UI polish
- 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
2026-04-16 14:17:48 +07:00
viettranx c0acfa1bd9 feat(hooks): phase-08 integration tests (A-F buckets)
27 test cases across 7 files exercising real PG + SQLite backends:
- A: tenant isolation (resolver scope, cross-tenant guard)
- B: sandbox escape + per-tenant fairness (goja hardening e2e)
- C: capability tier + FireResult propagation (source-tier gate)
- D: builtin seed reconciliation + idempotency + v21 rebuild
- E: command auto-disable migration (Standard vs Lite)
- F: WS method-equivalent (Validate, Create, TestRunner)
All green with -race on PG; D6 green on sqliteonly.
2026-04-16 14:17:48 +07:00
viettranx feaf7b9e40 feat(hooks/builtin): default_disabled flag + flip pii-redactor off
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)
2026-04-16 14:17:48 +07:00
viettranx 04354e833f feat(hooks/ui): wider form modal + dialog test panel + collapsible beta card
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.
2026-04-16 14:17:48 +07:00
viettranx 7096a8e02c fix(hooks/ui): native script editor + redesigned detail + error toast
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.
2026-04-16 14:17:48 +07:00
viettranx f3d6c99dda fix(hooks): close source-tier forge + i18n + concurrency hardening
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.
2026-04-16 14:17:47 +07:00
viettranx 31eb953508 feat(hooks/ui): script editor + builtin readonly + CSP hardening
Phase 06 UI: surface the script handler + builtin affordances that
backend Phases 02-05 already support.

- Deps: prismjs@^1.30.0 (CVE-2024-53382 patched) + react-simple-code-editor +
  @types/prismjs. pnpm override pins prismjs to ≥1.30.0 across transitives.
- CSP meta in index.html: default-src 'self', script-src 'self' (no
  'unsafe-eval' — Prism uses static highlight, never executes user JS),
  style-src allows Google Fonts + jsdelivr katex, connect-src allows ws:/wss:
  for WS, frame-ancestors 'none' + base-uri 'self'.
- Schema: HookHandlerTypeEnum adds "script"; script_source field with 32 KiB
  cap + required-when-script refinement.
- HookConfig TS type: handler_type adds "script", source adds "builtin".
- Components:
  * script-editor.tsx — Prism.highlight JS with readonly + inline compile-error
    banner. parseLineCol exported helper matches goja "Line N:M" format
    (verified against goja parser/error.go); locale-aware banner via
    t("form.compileError", {line, col}); fallback renders raw message.
  * system-badge.tsx — "System" chip rendered next to the scope on list rows
    where source==="builtin".
- hook-form-dialog.tsx: adds "script" radio; renders ScriptEditor when
  selected; readonly cascade for builtin rows (event/scope/matcher/if_expr +
  all handler sub-forms + timeout/on_timeout/priority all disabled; enabled
  switch stays actionable). Save button label flips to "Save toggle" on
  builtin. Top-of-form banner informs user.
- hook-list-row.tsx: SystemBadge + delete button disabled for builtin rows.
  New emerald color for script handler chip.
- hooks-page.tsx: buildConfig script branch → {source: data.script_source}.
- i18n: en/vi/zh get scriptSource, scriptSchemaHelp, compileError,
  builtinReadonly, saveToggle, systemBadge, reason, scriptSourceRequired.
- Tests: 5 parseLineCol cases (happy + multi-digit + null + garbage +
  multi-error first-match) — all green.
- Build: pnpm tsc --noEmit + lint + build all clean. Hooks page chunk
  65 KB / 19 KB gzip.
2026-04-16 14:17:47 +07:00
viettranx 33d188f01f feat(hooks/migration): auto-disable legacy command hooks on Standard boot
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.
2026-04-16 14:17:47 +07:00
viettranx a6592fd65f feat(hooks/builtin): ship pii-redactor exemplar — email + phone masking
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.
2026-04-16 14:17:47 +07:00
viettranx 8ee540dc7e feat(hooks/builtin): embedded registry + UPSERT seed + store-layer readonly
Phase 04 of hooks Wave 1. Adds infrastructure for shipping canonical hook
rows with the binary; Phase 05 plants the first real builtin (pii-redactor).

- internal/hooks/builtin: loader (//go:embed yaml+js), Seed() with version
  reconciliation (newer embed overwrites DB; downgrade warn-only; preserves
  user's enabled toggle), AllowlistFor() for per-id mutable-field lookup.
  Stable UUIDv5 namespace 082ab084-a25f-52b4-a4a4-eb8a816bd9a8 keys rows
  across restarts so Seed is idempotent on every boot.
- internal/hooks/store.go: ErrBuiltinReadOnly sentinel + WithSeedBypass
  ctx marker (seeder bypasses the guard; users cannot).
- PG + SQLite hook stores: Update/Delete now reject any patch other than
  enabled=* on source='builtin' rows. Fail-closed on GetByID errors.
- Dispatcher: SetBuiltinAllowlistLookup setter lets the gateway wire the
  registry-backed strict allowlist; unwired tests keep Phase 03 permissive
  default. Gateway installs a strip-all lookup first so a Load() failure
  fails closed instead of opening mutation to the permissive default.
- i18n: MsgHookBuiltinReadOnly (en/vi/zh).
- yaml.v3 promoted to direct dep.
- Tests: loader parse + namespace stability + seed idempotency
  (3 boots → N rows), version bump, downgrade detection, operator
  BuiltinDisable escape hatch, PG + SQLite readonly guard round-trip.
2026-04-16 14:17:47 +07:00
viettranx d8e5fad8e4 test(store/pg): fix hook test DB setup — migrations path + UUIDv7 slug collision
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.
2026-04-16 14:17:47 +07:00
viettranx b7592bcacf feat(hooks/script): integrate script handler — FireResult + source-tier gate + migration
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.
2026-04-16 14:17:47 +07:00
viettranx a556161d0d feat(hooks/script): goja ES5.1 script handler + deny-all sandbox
Wave 1 Phase 02 — pure runtime + sandbox. Dispatcher wiring, edition gate,
and DB migration land in Phase 03.

Handler:
- internal/hooks/handlers/script.go — ScriptHandler with two-layer semaphore
  (global 10 / per-tenant 3), hashicorp/golang-lru v2 program cache (cap 500),
  InvalidateHook hook for Phase 03, watchdog goroutine interrupting on ctx
  cancel, sanitized error text (strips \n + goja " at <frame>" inline tails)
- internal/hooks/handlers/script_sandbox.go — deny-all/allowlist hardening,
  SetMaxCallStackSize(256), prototype-chain nullify running BEFORE deny pass
  so Function.prototype.constructor can still be reached
- internal/hooks/handlers/script_runtime.go — LRU compile, JSON round-trip +
  deep-freeze event binding, 4KiB truncating stdout capture, strict return
  parser enforcing {decision, reason?, updatedInput?}

Types:
- HandlerScript HandlerType = "script"
- DecisionAsk / DecisionDefer reserved (Wave 1 treats as block + warn)
- hooks.ScriptResult + WithScriptResult / ScriptResultFrom ctx helpers for
  Phase 03 dispatcher to apply builtin-source UpdatedInput

Tests (go test -race green):
- script_test.go — 12 unit cases (happy/error/timeout/ask/defer/truncate/H1)
- script_sandbox_corpus_test.go — 27 cases: escape primitives (constructor
  chain, Reflect, Proxy, Symbol, Promise, __proto__ walk, eval, Function,
  Date chain, JSON replacer, toJSON DoS), resource bombs (recursion, loop,
  memory, ReDoS), typeof sanity (Reflect/Proxy/Symbol/Promise/GoError/
  globalThis/eval/Function undefined), mutation defense (toolInput/rawInput
  frozen, Go-side map unchanged)

Deps: github.com/dop251/goja, github.com/hashicorp/golang-lru/v2.

Gates: go vet clean, go test -race clean, go build ./... + sqliteonly clean.
2026-04-16 14:17:47 +07:00
viettranx 3a25358049 feat(hooks/ui-gates): surface is_master_scope + edition, drop command handler
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.
2026-04-16 14:17:47 +07:00
viettranx 95bdb23a36 docs(hooks): user guide + example configs + changelog + Make targets
- docs/agent-hooks.md: handler reference, lifecycle events, security model
- examples/hooks/: 5 runnable JSON configs (audit, lint, block-rm-rf,
  Discord notify, context injector)
- docs/17-changelog.md: Wave 0 entry
- Makefile: hooks-specific test targets
- CLAUDE.md: cross-reference
2026-04-16 14:17:47 +07:00
viettranx f90f11a9f9 test(hooks): integration coverage — e2e, rbac, tracing, chaos
Adds cross-cutting integration tests exercising hooks RPC surface:

- hooks_e2e_test: create → dispatcher fire → audit entry → history list
- hooks_rbac_test: tenant-scope guard, master-only global scope enforcement
- hooks_tracing_test: audit entries include handler timing + outcome
- hooks_chaos_test: handler timeout behavior, on_timeout block/allow paths
- hooks_pipeline_test: extends existing pipeline coverage
2026-04-16 14:17:47 +07:00
viettranx 727f345344 feat(web): hooks management UI — list, editor, history, test panel
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.
2026-04-16 14:17:47 +07:00
viettranx 1f560e15d7 feat(gateway): config.defaults RPC exposing SSoT pruning values
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.
2026-04-16 14:17:47 +07:00
viettranx aa1b2f026a feat(hooks): WS RPC surface + tenant-scope guards + shared test runner
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
2026-04-16 14:17:47 +07:00
viettranx 553340b3a9 feat(hooks/prompt): LLM-driven prompt handler with per-turn cap
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
2026-04-16 14:17:47 +07:00
viettranx ab50e571c5 feat(hooks/budget): per-tenant token spend tracking subsystem
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.
2026-04-16 14:17:47 +07:00
Uğur Tafralı 12cfcec59a fix(zalo): correct json field mappings (#924)
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
2026-04-16 14:17:07 +07:00
Plateau Nguyen 8873548102 feat(pancake): add comment auto-react (like) on Facebook (#919)
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)
2026-04-16 10:14:43 +07:00
JinwooHyunandClaude Opus 4.6 a8c01dbc49 fix(skills): add missing AS alias for COALESCE in embedding search query (#896)
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>
2026-04-15 23:00:11 +07:00
Viet Tran 60ca45ec97 fix(pancake): demote hot-path webhook log to Debug; guard commentID before echo (#916)
- 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).
2026-04-15 22:30:23 +07:00
Plateau Nguyen 85e6f216b5 feat(channels): Pancake comment reply fix + platform select + UI improvements (#904)
* 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'
2026-04-15 22:26:13 +07:00
viettranx c189160f91 docs: remove journals folder 2026-04-15 21:12:36 +07:00
viettranx 17b0bc5365 test(sqlitestore): regression test for agent Update null→default coercion
Covers PR #897 fix path — verifies Update() coerces null values to schema
defaults for NOT NULL columns (other_config, tools_config, self_evolve,
skill_evolve, is_default, etc.), preventing the "NOT NULL constraint failed"
crash on fresh SQLite DBs when UI sends null for disabled/unset fields.
2026-04-15 21:12:01 +07:00
viettranx fb3552f7e5 fix(hooks/security): add SSRF-safe http client missed by phase 2 commit
Phase 2 (a587231b) advertised SSRF hardening for the http hook handler
but the supporting `internal/security` package was never created, so the
production HTTPHandler fell back to a bare http.Client and admin-config
webhooks could probe loopback / link-local / RFC1918 / cloud-metadata.

- internal/security/ssrf.go: Validate(rawURL) parses + resolves once,
  rejects loopback/link-local/private/multicast/unspecified + 169.254.169.254;
  NewSafeClient(timeout) returns an http.Client whose DialContext pins the
  resolved IP from context (defense-in-depth re-checks the dialed IP) and
  refuses redirects (CheckRedirect = ErrUseLastResponse)
- internal/hooks/handlers/http_handler.go: call Validate before each request,
  attach pinned IP via security.WithPinnedIP(ctx, ip)
- cmd/gateway_managed.go: construct HTTPHandler with
  Client: security.NewSafeClient(10*time.Second)
- tests: 13 new ssrf_test.go cases (every block category + redirect refused
  + dial pinned); existing http_test.go retrofitted with
  security.SetAllowLoopbackForTest helper for httptest.NewServer
- plan: phase-02 Step 2a marked done with reference to this commit

Refs: GitHub Issue #875
2026-04-15 21:12:01 +07:00
viettranx 97f9784443 feat(hooks): phase 2 — handlers, pipeline wiring, ssrf-safe http client
- add command + http handlers (internal/hooks/handlers) with edition gating
  and Authorization-header decryption via crypto.Decrypt
- add SSRF-safe dialer (internal/security/ssrf.go): DNS-once + pinned IP,
  blocks loopback / link-local / private ranges
- wire dispatcher into pipeline stages: ContextStage fires SessionStart
  (async) + UserPromptSubmit (sync); ToolStage fires PreToolUse (sync, COW
  staging for updatedInput) + PostToolUse (async); FinalizeStage fires
  Stop (async)
- bridge delegate events to dispatcher (SubagentStart / SubagentStop)
- construct dispatcher in cmd/gateway_managed.go with both handlers and
  thread it through agent.ResolverDeps + delegateTool.SetHookDispatcher
- unit tests for both handlers + integration tests covering HTTP allow with
  audit row, HTTP block, command Lite-only gate, delegate bridge subscribe
- defer worker pool optimization (Phase 2 Step 3) to Phase 4

Refs: GitHub Issue #875
2026-04-15 21:12:01 +07:00