Commit Graph
1370 Commits
Author SHA1 Message Date
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
Duy /zuey/ 20e34deb6a feat(teams): inline rename for team name + description (#571) (#906)
* feat(teams): add inline rename for team name and description (#571)

- Add reusable InlineEditText component (click-to-edit, Enter/Esc/blur)
- Wire inline edit on team detail header (name) and info dialog (description)
- Refactor useTeams hook: updateTeamSettings → updateTeam(teamId, patch)
- Backend: fix teams.update handler to accept *map[string]any for Settings
  so partial updates (name-only / description-only) do not wipe existing
  settings (would silently downgrade v2 → v1)
- Add rename.* i18n keys for en/vi/zh
- Follow mobile rules: text-base md:text-sm, touch target ≥44px on coarse pointers
- Stale-value guard + unmount safety + snapshot-based no-op detection

Closes #571

* fix: remove obsolete team_key reference in agent-identity-conventions

* docs: add journal on rename Agent Teams feature (#571)

Captures the critical data-loss bug caught by code review — backend
teams.update handler silently wiping v2 team settings on partial patches
before the fix (Settings *map[string]any).
2026-04-15 21:11:35 +07:00