Commit Graph

920 Commits

Author SHA1 Message Date
matevip
07d6f01b56 fix(wiki): make built-in transformation starter pack visible in every workspace 2026-06-29 14:35:09 +08:00
matevip
64b5587f56 feat(wiki): route cheap ingest steps to a configurable light model 2026-06-29 14:10:57 +08:00
matevip
d512643960 feat(tool): configurable SSRF allowlist for outbound HTTP guards 2026-06-29 10:33:38 +08:00
mateaix
83660893a6 fix(chat): restrict generated-file link regex to http(s)/relative URLs
Follow-up to #447. The generated-file link extraction accepted any
non-')' text before the path, so a paren-free javascript:/data: URL
embedding /api/v1/files/generated/<id> could be captured and bound to an
<a href>, enabling XSS on click. Adopt the scheme-restricted pattern
already used by SegmentSupersedeDetector and the channel adapters, on
both backend (ChatController) and frontend (useChat). Also replace the
inline fully-qualified Pattern/Matcher with imports and drop an unused
run-overview i18n key.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:25:11 +08:00
jack
3c6c765e51
feat(chat): surface generated-file artifacts in the run-overview rail
Extract generated-file download links from tool results — on the backend (persisted to message metadata for history) and on the frontend (live during SSE) — de-duplicate by URL, and render them as a Generated Files section with file-type icons and a rail badge.
2026-06-28 16:06:06 +08:00
倪程伟
2d04ce92ef
feat(kb-open): P0-A open-API auth — API keys, rate limit, centralized authorization
Hashed API-key auth (SHA-256, plaintext shown once), per-key sliding-window rate limit, and a fail-closed filter + scope/KB-binding interceptor enforcing empty-binding=zero-access. Admin CRUD for key lifecycle. Migration V164 across h2/mysql/kingbase.
2026-06-28 14:45:53 +08:00
倪程伟
2f46619e5b
fix(wiki): close IDOR in WikiRelationController & WikiEntityController (cross-KB id binding)
Every endpoint now binds its independent id param to an authorized KB: rawId/chunkId resolve-then-workspace-check, pageId is asserted to belong to the path kbId, and slugs stay kbId-scoped. Adds unit tests for same-KB/cross-KB/unknown cases.
2026-06-28 14:41:19 +08:00
倪程伟
04197d7ba9
chore(wiki): address #437 review nits (import convention + English Javadoc)
Pure style cleanup, zero behavior change: replace inline FQN return type in WikiRawMaterialService.listFailures with an import + simple name, and translate the new WikiRawMaterialEntity field Javadocs to English.
2026-06-28 14:24:19 +08:00
倪程伟
fbbd1218e8
feat(wiki): KB processing-failure visibility (error-code chain + silent sub-step alerts + cross-KB failure center)
Propagates structured error codes through the KB processing pipeline, surfaces silent sub-step warnings as a non-failure warning state, and adds a cross-KB failure center for aggregated visibility.
2026-06-28 13:07:34 +08:00
倪程伟
7be8f81353
feat(agent): per-employee model-chain preference (provider + model, repeatable provider)
Lets an employee pin an ordered fallback chain of (provider, model) entries; the same provider may appear multiple times with different models. Build-time dedup keys on exact (provider, model).
2026-06-28 13:05:48 +08:00
matevip
8522aa7591 feat(tool): desktop local file/shell tools via WebSocket tunnel 2026-06-26 18:24:25 +08:00
Sharon
4e82185be7
fix(wiki): replace broken @JsonCreator with custom StdDeserializer for StageInstructions (#424)
Jackson treated the @JsonCreator factory method as a properties creator
(matching the 'instructions' parameter name to the JSON field), not a
string/delegating creator, so plain-string values still failed at runtime
with "no String-argument constructor/factory method".

Replace with @JsonDeserialize + StdDeserializer that explicitly checks
VALUE_STRING vs START_OBJECT tokens, handling both shorthand strings and
full {instructions, template} objects.
2026-06-26 14:33:32 +08:00
matevip
26dd8a37d5 fix(workspace): harden chat-upload base-path containment + cleanup guard
- resolveAgentBasePath: the relative-override branch now normalizes the
  resolved path and rejects values that escape the workspace root via "../"
  (the absolute branch already did this), keeping attachment/media/tool I/O
  contained when an agent's workspaceBasePath is a relative override.
- cleanAttachmentFiles: return early on a null/blank conversationId so a bare
  upload root can never be walked and deleted wholesale.
- Translate the chat-upload Javadoc/comments to English (cleanAttachmentFiles,
  BaseAgent image-path resolver) per code style.
- Add a resolver test for the relative-override escape fallback.
2026-06-26 14:25:56 +08:00
倪程伟
fcb488c567
feat(workspace): chat-uploads 上传目录工作空间/Agent 感知化 (#422)
* feat(workspace): chat-uploads 上传目录工作空间/Agent 感知化 (#421)

把硬编码的 data/chat-uploads/{conversationId}/ 改为按工作空间/Agent 解析,
解析优先级:Agent workspaceBasePath → Workspace basePath → 可配置默认目录
(新配置 mateclaw.chat.upload.base-dir,默认 data/chat-uploads,保持现网零行为变化)。

- 新增 ChatUploadLocationResolver 中央解析器:写路径返回唯一根,读/清理
  路径返回候选根列表(工作空间根 + 默认根)做双重查找,保证迁移前旧附件
  仍可解析/清理;conversationId→ConversationEntity 查询带 5min 缓存。
- 新增 ChatUploadProperties + ChatUploadAutoConfiguration(启动建目录)。
- 复用 AgentGraphBuilder.resolveAgentBasePath(提升为 public)的优先级与
  安全规则(相对路径在 workspace 根下解析,绝对路径逃逸被拒)。
- 所有写入/读取点改为走 resolver;读取点走双重查找。
- 解决 Spring 循环依赖(resolver → agentService → ... → conversationService
  → resolver):resolver 的 AgentService 注入加 @Lazy。

向后兼容:默认目录不变;双重查找覆盖历史消息里的相对路径;
服务端点 URL 契约不变,前端无需改动。

测试:新增 ChatUploadLocationResolverTest(8 用例);修复受影响的现有测试构造。

* refactor(workspace): address review findings on chat-uploads resolver (#421)

应用 code review 的 4 项修复:

1. (correctness) ChatUploadLocationResolver 缓存新增 ConversationDeletedEvent
   监听器,删除会话时立即失效 conversationId→ConversationEntity 映射。
   否则备份恢复后用相同 id 重建会话,会继承最长 5 分钟的过期 workspace/agent
   映射,导致 cleanAttachmentFiles 走错(过期的)上传目录。复用既有
   @EventListener-on-bean 模式(与 AsyncTaskService / WorkspaceLookupCache 一致)。

2. 收紧 resolveWorkspaceScopedRoot 里 3 个过宽的 catch(Exception) →
   MateClawException + warn,让真正的 bug(NPE / DataAccessException)暴露
   而非被静默降级为 debug 日志。

3. 更新 ChatController.upload 过期注释:会话尚未创建时附件暂存默认目录,
   会话创建后读取走双重查找仍能命中。

4. 移除不可达分支(agentWorkspaceId != workspaceId)—— 会话的 agent 必然
   归属会话的 workspace(创建时强约束),直接用会话 workspace 即可,
   少一次冗余 DB 查询与一层推测性逻辑。

测试:ChatUploadLocationResolverTest (8) + ConversationServiceCleanAttachmentFilesTest (2) 全绿。
2026-06-26 14:20:54 +08:00
matevip
7705778903 fix(agent): normalize replayed tool-call arguments to valid JSON (#410)
Strict OpenAI-compatible providers reject the /chat/completions request
with HTTP 400 when an assistant message in history carries a tool call
whose function.arguments is not parseable JSON. Normalize blank or
non-JSON arguments to "{}" at the send chokepoint so streaming,
history-replay, and older-persisted tool calls all stay well-formed.
2026-06-26 11:19:19 +08:00
matevip
e0ce5e2ea7 fix(sso): gate SSO runtime beans on mateclaw.sso.enabled
SsoService / SsoStateService / SsoController / SsoProviderRegistry were
unconditional component-scanned beans, but their configuration
(SsoProperties) is only registered by the conditional auto-configuration.
With SSO disabled (the default) the services were still instantiated and
startup failed: "required a bean of type SsoProperties that could not be
found". Gate the four beans on the same mateclaw.sso.enabled=true
condition so the SSO stack loads as a unit — disabled = no beans and no
exposed endpoints; enabled = the auto-configuration provides SsoProperties
and everything wires.

Verified: backend starts clean with SSO disabled (default).
2026-06-26 10:27:16 +08:00
matevip
b048718298 fix(sso): inline FQN→import + harden auto-create orphan rollback
- Replace inline fully-qualified names with top-of-file imports across
  SsoService / SsoStateService / FeishuSsoProvider (ObjectMapper, Autowired,
  Map.of, Date, DuplicateKeyException, Mac, URLEncoder) per code style.
- createSsoUser: roll back the freshly inserted user on any non-duplicate
  identity-insert failure, preventing passwordless orphan accounts. The two
  inserts share no transaction — the method is self-invoked and the enclosing
  callback performs a network call, so a method-level @Transactional would not
  apply; an explicit rollback in the catch is the correct guard here.
2026-06-26 10:09:43 +08:00
倪程伟
03a6d61131
feat(sso): 飞书 OAuth2 单点登录 (ISSUE #405 P0) (#419)
* feat(sso): feishu OAuth2 single sign-on (ISSUE #405 P0)

Implements the SSO design (ISSUE #405) with feishu as the first IdP
and a generic OAuth2 provider abstraction for future dingtalk/wecom
extensions. SSO is disabled by default — existing deployments are
unaffected until mateclaw.sso.enabled=true.

Backend:
- SsoProvider interface + SsoUserInfo record: generic IdP abstraction
- FeishuSsoProvider: OAuth2 authorization-code flow (app_access_token
  with Caffeine cache → user_access_token → user info). apiBase switches
  between feishu.cn / larksuite.com by domain config.
- SsoProviderRegistry: conditional registration, lists enabled providers
- SsoStateService: HMAC-signed OAuth2 state + self-contained bind_token
  JWT, both persisted to sso_state DB table for multi-node correctness.
  State is one-time-consumable (conditional UPDATE), bind_token jti
  anti-replay via PK insert. Hourly ShedLock purge (LambdaQuery + Java
  time, works on all 3 dialects).
- SsoService: authorize/callback/bind, user mapping (union_id first →
  external_id fallback), auto-create with concurrent idempotency
  (DuplicateKeyException → rollback orphan user → re-query), link-only
  mode issues bind_token for existing-account binding.
- SsoController: 4 endpoints (/providers, /authorize, /callback, /bind)
  all permitAll.
- V159 migration (h2/mysql/kingbase): mate_user_external_identity,
  sso_state, ALTER mate_user.password NULL (SSO-only users).
- AuthService: generateToken promoted to public; login() guards
  password=null (SSO-only users cannot password-login).
- SecurityConfig: /auth/sso/** added to permitAll whitelist.
- LoginRateLimitFilter: expanded to cover /auth/sso/bind (brute-force
  surface equivalent to /auth/login).
- application.yml: mateclaw.sso.* config block (all env-var driven).

Frontend:
- Login.vue: dynamic SSO buttons (only shown when providers configured),
  OAuth2 callback detection (?sso=callback), link-only bind dialog,
  shared applyLogin flow (localStorage + workspace + route).
- api/index.ts: ssoApi (providers, authorize, callback, bind).

Tests: SsoStateServiceTest (11) — state issue/verify/replay/tamper,
bind_token issue/verify/anti-replay/garbage. Regression: PAT (23) +
Approval resolve (13) all green.

Not in scope (P1/P2): link-only bind/unbind management endpoints,
user enable/disable endpoint, dingtalk/wecom providers, admin SSO
config page. Workspace assignment for auto-created users remains a
product decision (design doc §12 item 2).

* fix(sso): self-review fixes — P0 security + P1 quality

P0-1 BindRequired serialization: replaced the R.fail(200, Map.toString())
hack with a structured SsoCallbackResponse record. Controller no longer
catches an exception for a non-error path; frontend reads bindRequired
flag directly instead of regex-parsing a stringified map.

P0-2 createSsoUser unbounded recursion: added a retry flag — second
DuplicateKeyException (extreme race where identity was concurrently
deleted) now throws a 503 instead of recursing to stack overflow.

P0-3 state TTL not enforced: verifyState's conditional UPDATE now
includes created_at > cutoff, so a state unused for 5+ min is rejected
at consumption time, not just at the 1h purge. Without this the 5-min
window was advisory only.

P1-5 SsoStateService unused ObjectMapper: removed dead injection.

P1-6 audit JSON string concat: replaced with ObjectMapper serialization
(provider/externalId no longer risk breaking the JSON structure).

P1-7 LoginRateLimitFilter shared counter: documented the intentional
decision that login + bind share a per-IP counter (same brute-force
surface) with guidance on switching to per-path if finer isolation
is needed.
2026-06-26 10:00:31 +08:00
倪程伟
2f12c269f4
feat(webchat): API-Key 渠道补齐审批 resolve + replay (ISSUE #413 P1) (#415)
* feat(webchat): add approval resolve + replay for API-Key channel (ISSUE #413 P1)

Before this, a WebChat (API-Key) channel that hit a ToolGuard-protected
tool parked the turn in a pending approval the visitor could never
clear — it hung for 30 min until the GC timeout and the turn was
wasted. This PR closes the loop, mirroring the web ChatController.

A1 — no code change. tool_approval_requested already reaches the SDK
via ToolExecutionGuardHelper's streamTracker.broadcastObject (direct
SSE push, bypassing the StreamDelta path). Adding it to
forwardVisitorEvent would double-deliver; the default-drop is correct.

A2 — new /sessions/approve and /sessions/deny REST endpoints. Auth is
the existing visitorToken + conversationId ownership guard; the actor
is webchatUsername(visitorId), which resolves the 'no MateClaw
username' blocker noted in the old stopSession javadoc. Both broadcast
tool_approval_resolved so the SDK clears its banner in real time.

A3 — approve returns an SSE stream: resolveAndConsume (atomic DB +
metadata + memory), restoreChatOrigin (recovers the webchat origin
captured at createPending), then chatWithReplayStream replays the
tool call and continues the turn. Replay may re-trigger approvals,
which the existing tool_approval_requested direct push handles.

A4 — stopSession now sweeps pending approvals (denyAllByConversation)
and broadcasts each resolution, so stopping a stream no longer leaves
approvals lingering for the GC.

Tests: WebChatApprovalInteractionTest (7) — deny resolves + broadcasts,
deny auth/ownership guards, idempotent unknown-pending, stop sweep
clears pending, stop no-op when nothing pending.

Regression: WebChatStopStreamTest (5), WebChatArchivePinTest (6),
WebChatSchemaFieldsTest (5), WebChatWikiPageListTest (8),
ApprovalWorkflowServiceResolveTest (13), GcTest (7), RecoveryTest (7).

* fix(webchat): IDOR guard + SSE hang fix (PR #415 review)

Addresses all review feedback from mateaix:

P0 IDOR (security): /sessions/approve and /sessions/deny accepted a
client-supplied pendingId without cross-checking it belonged to the
caller's conversation. A visitor could resolve / replay another
visitor's guarded tool call. Fix: getPending(pendingId) then assert
conversationId matches before resolving. Added getPending delegate on
ApprovalWorkflowService so the webchat controller (which holds the
workflow facade) can do the precise lookup.

SSE hang: approveSession's already-resolved / error branches broadcast
'done' before streamTracker.register/attach, so the event had no
subscriber and the SSE hung to the 10-min timeout. Fix: register+attach
first, then resolveAndConsume. Removed the now-duplicate register/attach
in the replay branch.

Tests: +2 IDOR cases (cross-visitor pendingId rejected 404; mismatched
pendingId rejected 404). denyResolvesPending now asserts via getPending
(findPendingByConversation returns the earliest pending, polluted by
cross-test map state). denyUnknownPendingIsSafe updated to expect 404
(no longer leaks pendingId existence). Isolated IDOR victim/attacker
visitor IDs to avoid cross-test conversationId collisions.

Regression: WebChatStopStreamTest (5), WebChatArchivePinTest (6),
ApprovalWorkflowServiceResolveTest (13), GcTest (7), RecoveryTest (7).

* style(webchat): use simple ChatOrigin name in approveSession (PR #415 review)

Reviewer flagged fully-qualified inline types (ResolveOutcome was fixed
in the prior commit; ChatOrigin was missed). Add the import and switch
the 3 FQN references in approveSession to the simple name, matching the
ResolveOutcome cleanup. chatStream's pre-existing FQN usages are out of
this PR's scope and left untouched.
2026-06-25 11:18:52 +08:00
倪程伟
b478eef78c
feat(im): resolve workflow approvals via feishu/wecom card clicks (ISSUE #413 P2-B3) (#416)
Before this, a workflow await_approval step whose approverChannels
pointed at feishu/wecom was effectively dead for IM interaction. Even
after PR #414 (B1) pushed the notice to the IM group, clicking the
card's Approve/Deny buttons did nothing useful:

- Identity check (requester==clicker) failed-closed: wf- approvals
  have userId=null (system-initiated), so every click was rejected.
- Even if it passed, the synthetic /approve injection was a dead end:
  the router routes by conversationId, but wf- ids use a synthetic
  workflow:run:{runId} key that no IM conversation matches, so
  findPendingByConversation returned null and the /approve was fed
  to the LLM as plain text.

B3 fix: both ToolGuardCardHandlers now detect the wf- prefix and
resolve inline (approvalService.resolve), bypassing the synthetic
injection entirely. The WorkflowApprovalResolvedEvent published
inside resolve is picked up by ApprovalResumeBridge (activated in
PR #414 B2), which resumes the paused run. This mirrors the Web /
WebChat resolve path (PR #415).

Identity policy: any audience member may resolve a wf- approval.
The card only reaches channels declared in await_approval's
approverChannels, so whoever sees it is a designated approver.
Regular tool approvals keep the strict requester==clicker guard.

Tests:
- wecom ToolGuardCardHandlerTest: +2 wf- cases (inline resolve, no
  synthetic injection; already-resolved renders expired). Existing 6
  cases updated for the new 3-arg constructor.
- feishu FeishuCardDispatcherTest: updated for the new factory
  constructor signature.

Regression: ApprovalWorkflowServiceResolveTest (13), GcTest (7),
RecoveryTest (7), feishu dispatcher (4), button value (7),
renderer (3+3) — all green.
2026-06-25 09:56:36 +08:00
倪程伟
20014c72ff
fix(workflow): activate approval notify + resolve→resume bridge (ISSUE #413 P0) (#414)
Two P0 fixes from ISSUE #413 — both address workflow await_approval
approvals that silently failed in production:

B1 — AwaitApprovalStepAdapter now dispatches the approval notice to
every channel in approverChannels that carries a target. Previously
approverChannels was write-only metadata: a workflow that declared
["feishu:oc_xxx"] silently dropped the notice and the IM group never
learned an approval was waiting. Element format is "channelType"
(no push, operator uses admin console) or "channelType:targetId".
Each channel failure is logged and skipped — it must not fail the step.

B2 — requestWorkflowApproval now registers the wf- approval into the
in-memory map via registerRecovered. Previously it only did
approvalMapper.insert, so getPending("wf-...") returned null,
performResolve short-circuited at the not-pending guard, the
WorkflowApprovalResolvedEvent was never published, and
ApprovalResumeBridge was dead code. With this fix, resolving a wf-
approval walks the full two-phase contract and the bridge fires.

Tests:
- WorkflowApprovalResumeBridgeTest (3): map registration, event
  publish on resolve, safe no-op for unregistered wf- ids.
- AwaitApprovalNotifyTest (2): targeted channels dispatched, bare
  "web" skipped, channel failure non-fatal.

Regression: ApprovalWorkflowServiceResolveTest (13), AwaitApprovalRuntimeTest (3),
DispatchChannelRuntimeTest (3), GcTest (7), RecoveryTest (7) — all green.
2026-06-25 09:54:55 +08:00
MIST
1d1c35aadf feat(cli): project-level CLI framework with operational data export command 2026-06-25 09:31:48 +08:00
matevip
6b2024b71e fix(operational): guard blank provider/username keys to prevent export crash 2026-06-24 18:06:45 +08:00
matevip
9310335cc8 fix(operational): admin gate, atomic one-time download, lock safety and Excel ID precision 2026-06-24 17:48:42 +08:00
MIST
c2620720d2
feat(operational): one-click operational data export with 9-sheet Excel (#411)
Add an async export feature on the Dashboard page -- global admins can
generate and download a multi-sheet operational data report (.xlsx
packaged as .zip).  The export covers 9 sheets:

1. Overview - interval KPIs, system snapshot, 7-day trend, period comparison,
   model details (configured providers only), agent activity ranking top 10
2. Token Usage - daily breakdown by runtime_provider with avg tokens/msg
3. Skill Stats - skill list with usage count, last-call time, bound agents
4. User Stats - per-(workspace, user) aggregated tokens, duration, last active
5. User Conversations - detail rows pairing user-asst messages
6. Security and Audit - unified view across 6 sources (guard rules, audit logs,
   approvals, grants, config, business audit events)
7. Channel Stats - per-channel conversation count, tokens, unique users
8. Model Config - enabled plus API-key-configured models with parameters
9. Cron Jobs - execution records with duration and token usage

Backend highlights:
- generate/progress/download endpoints guarded by PreAuthorize hasRole ADMIN
- single AtomicBoolean lock (409 when busy), 90-day frontend cap, 5-min deadline
- metadata-based tool-call counting, deleted=0 filtering everywhere
- value label mapping (chat to dialogue, TRUE to enabled, etc.)
- one-time downloadToken, file auto-cleanup after 24h or download

Frontend highlights:
- SVG ring progress bar with smooth dashoffset transition plus slow rotation
- visibility gated by workspaceStore.isGlobalAdmin (v-if on button)
- 1-second polling driving progress state machine (idle/generating/done)
- Element Plus date-picker (30-day default, 90-day max)
2026-06-24 17:38:08 +08:00
matevip
982c6c048c feat(security): gate Swagger/OpenAPI UI behind mateclaw.openapi.expose-ui flag
- explicit SecurityConfig authorization for /swagger-ui*, /v3/api-docs*, /webjars/**
- public for local/default profile; admin-only (ROLE_ADMIN) by default in production DB profiles
- override via MATECLAW_OPENAPI_EXPOSE_UI; add RANDOM_PORT integration tests and docs
2026-06-24 10:42:46 +08:00
倪程伟
865513a3b6
docs(api): 完善 WebAPI 文档与 OpenAPI / Swagger 配置 (#407)
Closes #406

- 新增 OpenApiConfig 全局配置 Bean:标题/描述/服务器 + bearerAuth 安全方案
  (覆盖 JWT 与 mc_ PAT,对齐 JwtAuthFilter 前缀分发)
- application.yml 增 springdoc default-flat-param-object + mateclaw.openapi.* 外置项
- api.md 中英双语补全「通用约定」(R<T> 信封、ResultCode、错误模型、IPage 分页、
  ID 约定、三态认证、X-Workspace-Id 机制)+ 9 个旗舰端点完整参考
- 新增 openapi.md 中英双语 Swagger 使用指南
2026-06-24 10:07:11 +08:00
matevip
903c7bd72e feat(agent): parallel delegation optional fail-fast and per-call timeout override 2026-06-23 18:23:07 +08:00
matevip
a94a756677 feat(agent): register send continuations in the sub-agent registry 2026-06-23 18:22:56 +08:00
matevip
dd3dcc55ee feat(agent): SessionListTool discovers persisted sub-agent sessions for send 2026-06-23 18:22:45 +08:00
matevip
acfac0b56f feat(agent): add SessionSendTool for multi-turn sub-agent follow-ups 2026-06-23 18:22:32 +08:00
matevip
d3d86d5481 feat(agent): add SessionListTool for enumerating live sub-agents 2026-06-23 18:22:10 +08:00
matevip
14ee45a30d feat(agent): introduce SubagentRunContext value object for delegation runtime identity 2026-06-23 18:21:59 +08:00
matevip
f08abad076 feat(skill): self-evolving skills — out-of-band reflection, curator consolidation, agent-authored skill files 2026-06-23 13:51:04 +08:00
matevip
252a6fc425 fix(tool-guard): enforce workspace boundary for execute_code and trust spill roots (#403)
execute_code (bash/sh/shell) bypassed the workspace boundary guard, so shell
code run through it could read/write/delete paths outside the workspace sandbox
(e.g. cat /etc/passwd) while the same paths were blocked for read_file and the
shell tools. Bring execute_code under the guard (scan only shell-language code,
report the code param), and trust the tool-result spill roots so a legitimate
spilled result stays readable. Adds regression tests.
2026-06-23 10:11:54 +08:00
matevip
5ff58b00ad fix(plans): scrub injected context from persisted plan goal (#402) 2026-06-22 17:54:27 +08:00
matevip
30252a377d feat(docs): structure the in-app help viewer to match the docs site 2026-06-22 17:28:35 +08:00
matevip
2664b26763 fix(plans): parent delegated-step child conversations so they don't leak into the conversation list 2026-06-21 23:18:49 +08:00
matevip
eca4229751 feat(plans): per-step agent delegation + fix kanban pending column (issue #385) 2026-06-21 21:20:58 +08:00
matevip
1373b78b0a chore(repo): drop unused npm/yarn lockfiles and fix inline FQNs
- Remove mateclaw-ui/package-lock.json and yarn.lock. This is a pnpm
  monorepo where pnpm-lock.yaml is the only lockfile; the npm/yarn locks
  were stray duplicates. Add a .gitignore rule so they are not committed
  again by mistake.
- SourceEvidenceLedger: reference Pattern/Matcher by their imported
  simple names instead of inline fully-qualified names.
2026-06-21 10:06:28 +08:00
SuperCoderMan521
cb87569264 feat(wiki): make [n] citation markers clickable, linking to wiki pages (#305)
Backend (SourceEvidenceLedger):
- appendWikiSourceTable now normalizes existing source lines in-place to
  canonical "[N] Title - section - page N" format instead of skipping them
- Added replaceSourceLine helper that matches a full source line by regex
  and replaces it with the canonical form
- When source lines exist without a "来源:" header, automatically insert
  one so the frontend preprocessor can locate the source table

Frontend (useMarkdownRenderer):
- Added data-citation-index / data-citation-title to DOMPurify whitelist
- Added preprocessWikiCitations preprocessor: parses the canonical source
  table to build an index-to-title map, replaces [n] markers in the answer
  body with clickable <a> links, and wraps entire source-table rows so the
  full line is clickable
- Integrated into the render pipeline after wikilink substitution and
  before Marked parsing

Frontend (useGlobalWikilinkClick):
- Extended the click delegation selector to match both .wiki-link and
  .wiki-citation elements
- Title extraction falls back: data-citation-title || data-wiki-title

Tests: added three test cases for source-line normalization, idempotency,
and automatic header insertion
2026-06-21 09:58:41 +08:00
matevip
c656aff349 feat(plans): Kanban boards in the Agents workspace
Live lifecycle board (grid<->board toggle) plus an assignee-swimlane plan
board that groups follow-up re-runs of one goal into a single xN card.
Custom right-side detail/goal panels with markdown output. Fixes plans
being persisted under the per-run trace id so the board actually populates.

Closes #385
2026-06-20 17:52:29 +08:00
倪程伟
22a212a2e6 feat(agent): add wiki_disabled opt-out flag for knowledge bases
Issue #304. Operators who want an agent with NO knowledge base had no
way to express it: leaving the KB picker empty fell through to "inherit
workspace-wide" (every KB visible), so the agent ended up ingesting
every KB's context. This adds the same opt-out toggle that
skills_disabled (V126) / tools_disabled already provide.

Backend:
- V154 migration (h2 + mysql + kingbase): mate_agent.wiki_disabled
  BOOLEAN/TINYINT/SMALLINT NOT NULL DEFAULT FALSE. Legacy agents stay
  bit-identical.
- AgentEntity.wikiDisabled: Boolean field, @TableField("wiki_disabled").
- AgentBindingService.getBoundKbIds: short-circuit at the top —
  wiki_disabled=true returns Set.of() regardless of binding rows. Mirrors
  the precedence contract of getBoundSkillIds vs skills_disabled.
- AgentBindingService.setKbBindings: a non-empty save auto-clears a
  stale wiki_disabled flag (same contract as setSkillBindings /
  setToolBindings on their respective flags). Empty saves leave the flag
  untouched — the UI toggle owns the bit, not the binding writer.
- AgentBindingServiceWikiDisabledTest: 5 cases covering all three
  return states + the stale-flag auto-clear + empty-save no-op.

Frontend:
- Agents.vue KB picker: add the "此智能体不使用任何知识库" /
  "This agent uses no knowledge bases" toggle, mirroring the skills /
  tools picker layout. Tab badge shows "Off" when the toggle is on.
- types/index.ts: add Agent.wikiDisabled?: boolean.
- Save logic: when wikiDisabled is on, send an empty KB list (the
  setKbs contract then leaves the flag alone server-side, exactly as
  setSkills / setTools behave for their opt-out flags).
- i18n (zh + en): new strings for toggle label, hint, badge, and the
  scope description shown when the toggle is on.

Stacked on top of #382 (which introduced AgentBindingResolver
.getBoundKbIds). No agent-runtime changes — wiki tools already degrade
cleanly when getBoundKbIds returns Set.of().
2026-06-20 07:21:07 +08:00
倪程伟
f6156f6093 feat(webchat): expose agent-bound wiki pages to API-Key callers
Add GET /api/v1/channels/webchat/wiki/pages mirroring /skills, so
downstream integrators can build a [[slug]] picker UI that points the
LLM at specific wiki pages. The picker token format is the universal
Obsidian/Wikipedia wikilink convention; the LLM consumes [[slug]] via
the existing wiki_read_page(slug=...) tool, so no agent-runtime changes
are needed.

- AgentBindingResolver.getBoundKbIds(agentId): three-state mirror of
  getBoundSkillIds. null = no rows (fall through to workspace-wide KBs),
  Set.of() = explicitly scoped to zero KBs, non-empty = explicit scope.
- WebChatController.listWikiPages: API Key + visitorToken auth chain,
  agentId workspace anti-escalation, visibility excludes pageType=
  synthesis (LLM intermediate artifacts), 100-page cap forces keyword
  filter, response carries only display-level metadata.
- WebChatWikiPageView DTO: kbId/kbName/slug/title/summary/pageType;
  content/embedding/sourceRawIds deliberately stay admin-console-only.
- WikiTool.wiki_read_page @Tool description: document the [[slug]]
  convention so the LLM treats each token as a wiki-page reference.
- WebChatWikiPageListTest: 8 cases covering happy path, keyword filter,
  synthesis exclusion, anti-escalation, auth failures, cap behavior,
  and the no-binding → workspace-wide fallback.

Closes #381.
2026-06-20 07:21:07 +08:00
matevip
4804954ad2 feat(wiki): configurable entity types, type legend filter & theme-aligned graph colors (#336)
- per-KB entity-type whitelist (config UI + persistence; empty = built-in defaults)
- entity graph: legend grouped by type with click-to-filter; nodes colored by type
- always show entity names on graph nodes (not only on hover)
- earthy categorical palette aligned to the app theme, shared by entity & page graphs
- theme-aware graph label color (resolve CSS var for canvas, light/dark correct)
- manual extract = full rebuild: idempotent force re-extraction + orphan pruning,
  guarded against data loss on a fully-failed run
- regression test for force re-extraction; zh/en i18n
2026-06-19 07:18:09 +08:00
倪程伟
31c98e923d feat(webchat): expose agent-bound skill list to API-Key callers
GET /api/v1/channels/webchat/skills?agentId=<optional>&visitorId=<required>
Headers: X-MC-Key + X-MC-Visitor-Token

Downstream systems integrating via the webchat SSE endpoint have no way
today to enumerate the skills a visitor can invoke — the existing
GET /api/v1/skills is JWT + workspace-role gated, unreachable from the
API-Key-authenticated webchat channel. Without a list, integrators
can't render a slash picker UI; visitors have to know skill slugs by
heart.

The new endpoint mirrors the /stream auth chain (resolveChannel +
verifyVisitorToken) and reuses AgentBindingResolver.getBoundSkillIds
to scope visibility. Only enabled skills explicitly bound to the agent
surface; agents with no explicit bindings return an empty list rather
than inheriting the global pool (the agent config stays the source of
truth for what surfaces in visitor UI). The agentId anti-escalation
guard from /stream is reused verbatim — an explicit agentId must
belong to the channel's workspace.

Returns WebChatSkillView (id / name / nameZh / nameEn / description /
icon). Deliberately omits SKILL.md content, configJson and
securityScanResult: those never leave the admin console.

Issue: #373
2026-06-19 06:20:59 +08:00
倪程伟
7c36b0d752 fix(conversation): use notLikeLeft to avoid over-broad malformed-id filter
The previous notLike(column, "%:") form auto-wraps the value with extra %
on both sides AND escapes the user-supplied %, producing a %%:% pattern
that matches any id CONTAINING a colon — silently filtering out every
webchat:<key>:<visitor>, feishu:<chatId>, cron:<jobId> conversation from
the admin list / page. The frontend sidebar ends up empty.

Switch to notLikeLeft(column, ":") which only prepends the wildcard,
giving the intended NOT LIKE '%:' (does not end with a colon).

Strengthen the three malformedIdGuard tests to assert on the bound param
value ("%:" — ends-with colon) in addition to the SQL keyword, so this
regression cannot return silently. The assertions must call
getTargetSql() first to trigger MyBatis-Plus's nested-wrapper param
merge — getParamNameValuePairs() is empty on the parent until then.
2026-06-19 06:20:59 +08:00
倪程伟
f70e56cfc3 fix(conversation): exclude malformed conversationIds from admin list/page
conversationId ending in ":" (e.g. webchat:<key8>: with empty visitorId,
from older webchat versions) leaks into the admin console via the
'webchat:%' username LIKE, then 500/403s on open because the trailing ":"
makes some reverse proxies strip the path tail — landing a GET on the
@DeleteMapping variant of /{conversationId} (issue #369).

Add applyMalformedIdGuard — a NOT LIKE '%:' clause — to both listConversations
(lenient + strict overloads) and pageConversations so these rows never
surface. isConversationOwner already rejects unknown ids with 403, so no
change is needed on the direct-access endpoints; once the rows are out of
the lists, admin can no longer reach them.

The two existing strict/non-admin assertions changed from "no LIKE keyword"
to "no webchat:% param value" — applyMalformedIdGuard emits a NOT LIKE
itself, so the LIKE keyword is now present in every query.

Tests cover the guard on lenient, page, and strict paths.
2026-06-19 06:20:59 +08:00
倪程伟
ffef9bab00 fix(exception): return 405 for HttpRequestMethodNotSupportedException
Spring's default lets HttpRequestMethodNotSupportedException escape to the
catch-all @ExceptionHandler(Exception.class), surfacing as a 500 with a full
stack trace. Return a clean 405 so the client gets a structured R body and
the log stays at WARN.

This is also the second line of defence against malformed path segments that
confuse reverse proxies — e.g. a conversationId ending in ":" can make a
proxy strip the trailing path, landing a GET /api/v1/conversations/<id> on
the @DeleteMapping variant and triggering exactly this exception (issue #369).
2026-06-19 06:20:59 +08:00
matevip
03a4cbd3e1 fix(webchat): gate admin-console webchat list visibility on global admin
Align the conversation list/page with the isConversationOwner cross-workspace
guard: only a global admin can open a webchat-owned conversation, so only
admins should see those rows. Previously listConversations / pageConversations
surfaced webchat principals to every authenticated user, who would then 403 on
opening them (list-vs-access asymmetry).

Also fixes ConversationServiceWebchatVisibilityTest, which still asserted the
pre-guard owner behavior and never mocked AuthService, so it threw NPE at
runtime once isConversationOwner started resolving the requester. The owner
matrix is covered by ConversationServiceOwnershipWorkspaceTest; this test now
pins the admin-gated list visibility for both admin and non-admin callers.
2026-06-18 07:01:24 +08:00
倪程伟
cf5d47d249 fix(security): cross-workspace guard on isConversationOwner (#344)
Closes the authorization asymmetry between list endpoints (which filter
by workspaceId) and direct-access endpoints (which did not): a logged-in
user could reach another workspace's system / IM / webchat-owned
conversation by id and run any of messages / delete / rename / pin /
setModel / clear / chat-files download on it.

Per the maintainer's guidance on issue #344, workspaces are now treated
as untrusted isolation boundaries — the fix is the cross-cutting
hardening, kept out of feature work.

Behavior change (only for shared, non-direct convs):
- requester is a global admin (user.role=admin) → pass
- requester is a member of the conversation's workspace → pass
- otherwise → deny

Preserved to avoid regressions:
- direct owner (username == conv.username) → pass without lookup
- convs without workspace_id (legacy rows) → legacy system-owner check
- anonymous user (authService returns null, e.g. permitAll reconnect)
  → legacy system-owner check

Callers in ConversationController / ChatController / SubagentController /
GoalController / ApprovalController (18 sites) are unchanged — the
signature stays isConversationOwner(conversationId, username). The
workspace membership check is done via WorkspaceService.hasPermissionCached
(Caffeine-backed, same cache the WorkspaceAccessInterceptor uses) and
ignores the X-Workspace-Id header, which is client-controlled.

Tests: 11 cases in ConversationServiceOwnershipWorkspaceTest covering
each branch of the new logic. No caller-side test changes — the 66
caller tests (ConversationService*Test, ChatController*Test,
SubagentController*Test, GoalController*Test, ApprovalController*Test)
still pass.
2026-06-18 06:34:48 +08:00
倪程伟
6bbb6489f4 feat(webchat): expose phase / tool_start / tool_end / plan as SSE events
Previously WebChatController.chatStream silently dropped every agent
lifecycle event except _usage_final (and content_delta / thinking_delta
derived from delta.payload). Visitors sat with nothing between the
meta event and the first content chunk — typically 3–10s when the
agent plans / recalls memory / runs tools, longer when the agent
chained multiple tool calls. The JWT chat path (ChatController) had
this wiring; webchat did not.

Curated 4-event subset (per design review):
- phase        — high-level phase transition (planning / generating /
                 summarizing / ...). SDK shows a "AI is thinking..."
                 typing indicator before the first token.
- tool_start   — agent invoked a tool. SDK shows a localized badge
                 ("Searching...", "Reading file.pdf", ...).
- tool_end     — tool completed. SDK clears the badge.
- plan         — Plan-Execute agents expose their step list. SDK can
                 render a checklist.

Deliberately NOT forwarded (internal noise / leak risk):
- _usage_final, _routing_decision — consumed internally
- finish_reason                  — implicit in `done`
- feedback_event                 — visitor can't retry/regenerate anyway
- perf_summary, iteration_*      — internal metrics
- plan_step_started/completed    — too granular; the plan event covers
                                   the visitor's needs

Critical safety constraint: tool_start / tool_end carry ONLY the tool
name. Tool arguments and results are dropped — agent tool calls can
contain PII (file paths, user queries, credentials), and relaying
those to a 3rd-party website frontend is a data leak. The SDK maps
tool name → localized label via its own lookup.

Backward compat: existing clients ignore unknown event types per the
SSE spec, so adding these is non-breaking.

Tests: 5 new cases in WebChatStreamE2ETest covering each event type
+ a regression case asserting internal events are silently dropped.
85/85 webchat tests green.

Docs: docs/zh/webchat.md gains a "实时进度事件" subsection.

Stack: feat/webchat-attachment-e2e → feat/webchat-stream-phase-events
Follow-up to epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
093b9e9908 feat(webchat): audit visitor-side writes via AuditEventService.recordAs (epic #355 PR 7)
AuditEventService gains recordAs(actor, workspaceId, ...) — an overload
that takes an explicit actor string instead of deriving one from
SecurityContext. Used for non-MateClaw principals (currently just
webchat visitors), where there is no Spring Security auth and the
default record() path would write "system".

WebChatController injects AuditEventService and adds an audit() helper
that constructs the canonical actor string "webchat:<channelId>:<visitorId>"
so audit-event searches can filter by channel or visitor. Eight write
endpoints now log an audit row on success:

  webchat.create-session, webchat.rename-session, webchat.pin-session,
  webchat.archive-session, webchat.delete-session, webchat.stop-session,
  webchat.regenerate-session, webchat.upload-file

/stream is intentionally NOT audited — message-volume noise, and
conversationService.saveMessage already leaves a durable trail.

Each row carries:
- username = webchat:<channelId>:<visitorId>
- action   = webchat.<verb>
- resource = CONVERSATION / <conversationId>
- detailJson = {sessionId, ...action-specific fields}

WebChatAuditTrailTest (@SpringBootTest, 2 cases):
- createSession lands a row with the exact actor string + action
- rename + pin + archive + stop each leave a row (4 distinct actions)

Audit insert is async; tests poll up to 3s for the row to appear.

Regression: 9 webchat test classes (64 tests) green.

Part of epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
db3644dc8d refactor(webchat): centralise error codes + dedupe /sessions/page auth (epic #355 PR 6)
Two cleanups promised in the plan, kept narrow to avoid cascading churn:

1. New WebChatErrors enum — single source of truth for the visitor-facing
   HTTP error codes + messages. All future R.fail() calls can reference
   WebChatErrors.INVALID_API_KEY etc. instead of bare literals. This PR
   doesn't migrate every existing call site (that's a noisy sweep better
   done in a follow-up); the enum just needs to exist so audit/OpenAPI
   work in PR 7/8 can quote canonical messages.

2. pageSessions now delegates auth to listSessions instead of duplicating
   the resolveChannel + verifyVisitorToken block. Same external behavior;
   -15 lines of duplication. The pagination/keyword logic stays where it
   is (it's specific to the /page variant and doesn't belong in
   listSessions).

Visitor-token `required=true` migration from plan §6 was dropped: changing
it would flip missing-token responses from 401 to 400, which violates the
current error-code contract that visitors and tests rely on. The
`required=false` + explicit-verify pattern stays.

Regression: 7 webchat test classes, 42/42 green.

Part of epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
f04d57a483 feat(webchat): regenerate last assistant reply (epic #355 PR 4)
New endpoint POST /api/v1/channels/webchat/sessions/regenerate. Behavior:

1. Auth (API Key + visitorToken + ownsConversation — same chain as the
   other session mutations).
2. streamTracker.requestStop() — kill any in-flight stream first so its
   doOnComplete doesn't race the delete/save below.
3. Find last role=user message (seed) and last role=assistant message
   (target).
4. Delete the last assistant message if present.
5. Reuse chatStream by handing it a synthetic WebChatRequest whose
   message is the seed user content. chatStream saves a fresh user
   message (new id, same content) and starts the agent turn.

Trade-off: chatStream saves a new user message rather than replaying the
existing one in place, so the user-side message count grows by 1 per
regenerate. Acceptable — the alternative (refactoring chatStream into
reusable chunks) is a 4-hour distraction from the actual feature, and the
extra row is harmless (history still reads naturally: user, asst, user,
asst, user, asst instead of user, asst, asst).

ConversationService gains findLastMessageByRole() and deleteMessageById()
helpers; both are scoped exactly to what regenerate needs.

WebChatRegenerateTest (@SpringBootTest, 5 cases):
- empty thread (no user message) → error event, no DB change
- deletes the last assistant reply (count strictly decreases)
- bad token → no DB change (auth fails before mutation)
- unknown sessionId → returns emitter without throwing
- seeds from the LAST user message when multiple exist

Tests don't assert on the actual LLM stream content — that's left for
PR 5's WebChatStreamE2ETest, which mocks the chat model.

Part of epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
961ecad7f1 feat(webchat): pin + archive endpoints (epic #355 PR 3)
Two new session-state mutations, both following the rename endpoint's
shape (PUT + {flag: true|false} body + visitorId/sessionId query):

- PUT /api/v1/channels/webchat/sessions/pinned — flips mate_conversation.pinned
- PUT /api/v1/channels/webchat/sessions/archive — flips mate_conversation.archived

Archive complements delete as a "soft-close" — the thread stays on disk
(history preserved, addressable, downloadable) but is hidden from the
default /sessions listing. Pin makes a thread sort first in the visitor's
listing, mirroring the admin-console behavior.

Archive dominates pin: an archived+pinned thread is still hidden by
default. Callers opt back in via includeArchived=true (added in PR 1).

ConversationService gains setArchived(), mirroring the existing
setPinned() pattern.

WebChatArchivePinTest (@SpringBootTest, 6 cases):
- pin flips column + view reflects pinned=1
- archive hides from default listing, includeArchived=true shows it,
  un-archive restores
- archived+pinned still hidden (archive dominates)
- malformed body / wrong type → 400
- unknown sessionId → 404
- bad token → 401

Part of epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
bccc5767ed feat(webchat): visitorToken revocation + 7-day expiry + Caffeine cache (epic #355 PR 2)
Closes the "no way to ban a single visitor without burning the global
JWT secret" gap from the epic. Two changes:

1. Token format: HMAC payload now includes exp, format is
   `<base64sig>.<expEpochSec>`. Default TTL 7 days (VISITOR_TOKEN_TTL_SECONDS).
   Expiry participates in the HMAC, so bumping it client-side invalidates
   the signature. /stream still mints fresh tokens on first contact — a
   revoked visitor can start a new /stream (gets a new token), they just
   can't use the old one on management endpoints.

2. WebChatTokenRevocationService — DB-backed registry (webchat_revoked_visitor
   table from V148) with a 5-minute Caffeine cache in front. revoke() /
   unrevoke() / isRevoked(). The cache accepts up to 10min eventual
   consistency across instances — webchat is low-volume, and a fresh node
   sees revocations immediately on cold cache. DB remains source of truth.

WebChatController.verifyVisitorToken becomes an instance method that chains
verifyVisitorTokenSignature (static, HMAC + exp) with isRevoked (instance,
DB + cache). All 9 management endpoints now check revocation transitively.

Admin endpoint: POST /api/v1/admin/webchat/revoked-visitor (and DELETE to
un-revoke). Mounted under /api/v1/admin/** so it requires a MateClaw JWT
— visitors can't reach it. Records an audit row (action=webchat.revoke-
visitor, resourceType=CHANNEL) via AuditEventService.

Tests:
- WebChatTokenRevocationTest (@SpringBootTest, 7 cases): revoke blocks
  /sessions with 401, un-revoke restores, double-revoke idempotent,
  expired token rejected even without revocation, /stream unaffected
  (signature still verifies), admin endpoint inserts row + audit.
- WebChatVisitorTokenTest extended to 16 cases — added expired-token,
  tampered-exp, and "differs when exp differs" coverage; existing
  verify_* cases moved to verifyVisitorTokenSignature (the static half).

Regression: WebChatSchemaFieldsTest (5/5), WebChatCreateSessionTest (9/9),
WebChatSessionManagementTest (5/5), WebChatStopStreamTest (5/5).

Part of epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
332f3339a9 feat(webchat): archive flag + revoked-visitor table + view fields (epic #355 PR 1)
Schema foundations for the rest of the epic. Three additions:

1. mate_conversation.archived — INT default 0. Lets a visitor "soft-close"
   a thread: stays on disk (history preserved, addressable, downloadable)
   but excluded from default /sessions listing. Pinned/archived are
   orthogonal; archive dominates (archived+pinned still hidden by default).

2. webchat_revoked_visitor — registry table consumed in PR 2 by the
   WebChatTokenRevocationService. Unique on (channel_id, visitor_id, deleted)
   so re-revoke is idempotent and deleted=1 un-revokes. Migration written
   for all three DBs (h2 IF NOT EXISTS, MySQL INFORMATION_SCHEMA guard,
   KingbaseES native IF NOT EXISTS) following the V147 pattern.

3. WebChatSessionView gains pinned/archived/streamStatus so the visitor-
   side listing surfaces the same state the admin console sees. Closes
   the "field exposure" gap from the epic.

loadVisitorSessions gains an includeArchived flag (default false) — the
default hides archived threads and excludes them from the empty-session
quota, since the visitor already declared they're done with them.
GET /sessions and GET /sessions/page thread an `includeArchived=true`
query param through.

Tests (WebChatSchemaFieldsTest, @SpringBootTest + H2 + V148):
- revoked-visitor table is queryable
- archived column is read/write
- view exposes pinned/archived/streamStatus
- archived hidden by default, visible with includeArchived=true
- archived empty threads don't saturate the 5-thread quota

Regression: WebChatCreateSessionTest (9/9), WebChatSessionManagementTest
(5/5) — both updated for the new includeArchived param.

Part of epic #355.
2026-06-18 06:33:17 +08:00
倪程伟
23c1d49241 feat(webchat): stop an in-flight session stream (POST /sessions/stop)
Until now webchat had no way to actually interrupt a running stream —
ChatController's /api/v1/chat/{id}/stop was technically permitAll'd but
silently no-op'd on webchat streams because WebChatController.chatStream
dropped the subscribe() return value, so ChatStreamTracker.requestStop
had no Disposable to dispose. Visitors could only "stop" client-side by
closing the SSE connection; the server-side LLM call kept running,
burning tokens and firing any side-effecting tools to completion.

Two changes (issue #353):

1. WebChatController.chatStream: keep the Disposable and register it
   with streamTracker.setDisposable, mirroring ChatController#chatStream
   line 495. Now requestStop actually disposes the Flux.

2. New endpoint POST /api/v1/channels/webchat/sessions/stop:
   - Auth mirrors the other session-management endpoints: X-MC-Key +
     X-MC-Visitor-Token + ownsConversation (404 on unknown sessionId,
     so callers can't probe the namespace).
   - Returns {stopped: true|false}; false means no active stream
     (idempotent, not an error).
   - No approval sweep — webchat has no MateClaw username and exposes
     no approval UI today; defer until that surfaces.

WebChatStopStreamTest (@SpringBootTest, H2, V147) — 5 cases:
- stopActiveStream registers a real Flux.never() Disposable on the
  tracker and asserts both stopped=true AND disposable.isDisposed(),
  proving the chatStream wiring change is what makes the endpoint work.
- noActiveStreamReturnsFalse — idempotent path.
- bad token / bad API key → 401.
- unknown sessionId → 404.
2026-06-18 06:33:17 +08:00
倪程伟
d84be668fa feat(webchat): explicit empty-session creation endpoint POST /sessions
Complements the implicit getOrCreate in /stream: lets a caller pre-create
an empty thread (message_count = 0) and receive sessionId / conversationId /
visitorToken up front, then decide when to send the first message via
/stream. Mirrors how downstream CRM/ticketing systems model "create the
conversation object first, message later".

Auth is the visitor's first touch — only X-MC-Key is required (no
X-MC-Visitor-Token, which the visitor can't have yet); the server signs
and returns a fresh visitorToken the caller must echo back on subsequent
GET/PUT/DELETE.

Behavior (issue #351):
- Idempotent on sessionId collision → returns the existing thread 200,
  does NOT clobber title.
- Empty-session quota ≤ 5 per (channel, visitor); 409 with a clear
  message when exceeded. Existing rows are exempt (re-create is idempotent).
- Caller-supplied title (1-100 chars) is persisted; absent title leaves
  the default "新对话" so the first /stream user message still derives
  it. getOrCreateWebchatConversation now accepts an optional title and
  only writes it on insert (existing rows untouched).
- agentId override mirrors /stream's workspace check.

ConversationService.getOrCreateWebchatConversation gains a title-aware
overload; the original 5-arg signature delegates with title = null.

End-to-end coverage in WebChatCreateSessionTest (@SpringBootTest, H2
with V147 migration): happy path, caller-title survives first user
message, default-title still derived, idempotent collision, quota 409,
bad API key 401, illegal sessionId/title 400, listed after creation.
2026-06-17 23:21:08 +08:00
倪程伟
bdda9c7357 perf(webchat): scope session listing to the visitor; cap upload disk use
Two webchat hardening fixes:

- Session listing no longer pulls every system-owned conversation into
  memory. listSessions/pageSessions went through listConversations(owner)
  whose `username IN (owner, system)` loaded all IM/cron rows just to show
  one visitor's handful of threads. New listWebchatConversations(username)
  queries only the visitor's own rows; the channel prefix is matched
  in-memory with a literal startsWith (so a '_'/'%' in the api key's first
  8 chars can't act as a LIKE wildcard).
- Upload now enforces a per-conversation quota (file count + total bytes,
  both configurable) so a visitor can't fill the disk with many
  individually-under-cap files. Pairs with the existing staging TTL sweep.
2026-06-17 23:21:08 +08:00
倪程伟
ee3e391977 fix(webchat): list sessions whose conversationId hashed (long ids)
When webchat:<key8>:<visitorId>:<sessionId> exceeds 64 chars the
conversationId folds visitorId+sessionId into an unrecoverable hash, so
the thread fell outside listSessions' conversationId-prefix filter and its
sessionId could not be recovered — the thread was invisible and
unaddressable (common with a UUID visitorId + a >10-char sessionId).

Persist the sessionId on creation (new nullable webchat_session_id column)
and enumerate by username + channel prefix (webchat:<key8>:), which also
matches the hashed form. sessionId is read from the column, falling back to
parsing the conversationId only for legacy rows.

Adds a @SpringBootTest covering listing (incl. the hashed thread), message
pagination, session paging/search, rename, and token rejection end-to-end.

Refs matevip/mateclaw#346
2026-06-17 23:21:08 +08:00
倪程伟
4842a2208a feat(webchat): message pagination, session list paging/search, rename
Bring the webchat visitor session API closer to the admin console's:

- GET /sessions/messages gains beforeId + limit. With a limit it returns
  {messages, hasMore} (latest N, then pull-up for older) using the
  external path-stripped view; without it, the full list as before.
- GET /sessions/page paginates + keyword-searches a visitor's threads
  (in-memory: a visitor's thread set is bounded to its own namespace).
- PUT /sessions/title renames a thread (1-100 chars).

All keep the webchat auth model (API key + visitor token, server-derived
conversationId, ownership guard). Message views go through the shared
toExternalMessageViews helper so the paginated path is sanitized too.

Refs matevip/mateclaw#346
2026-06-17 23:21:08 +08:00
倪程伟
594880bd64 feat(webchat): support inbound file upload and outbound download
WebChat had no file support: the /stream body carried only text, and
agent-produced files had no visitor-reachable download path (the JWT
/chat/files endpoint is unreachable for API-key visitors).

Add webchat-authenticated file transfer, reusing MessageContentPart +
the existing upload dir + agent multimodal injection:

- WebChatFileService: validate (size cap, extension whitelist, filename
  sanitize), store under the conversation's upload dir, stage by opaque
  fileId, traversal-safe resolve. Untrusted-uploader hardening lives here.
- POST /upload (multipart) and GET /files, both authed by API key +
  visitor token with a server-derived conversationId (never client paths).
  Downloads send non-images as attachment + X-Content-Type-Options:nosniff.
- /stream gains attachmentIds; the server resolves each id from the
  staging registry (client metadata is never trusted), builds parts, and
  persists them on the user message so the agent's multimodal/file tools
  pick them up from history — same path as the JWT web chat.
- Strip server-side file paths from the visitor-facing message view
  (listMessageViewsExternal + includePath flag) so the filesystem layout
  is not disclosed.

Refs matevip/mateclaw#342
2026-06-17 23:21:08 +08:00
倪程伟
8e339f083a docs(security): correct generated-file TTL comment (7 days, not 10 min)
The permitAll comment claimed a 10-minute TTL, but GeneratedFileCache.TTL
is 7 days. The stale figure could mislead future security reasoning about
how long an unauthenticated capability URL stays live. Align the comment
with the actual value; the unguessable UUID remains the access guard.

Refs matevip/mateclaw#344
2026-06-17 23:21:08 +08:00
倪程伟
7f4c62c3d6 fix(conversation): surface webchat visitor sessions in the admin console
WebChat conversations are owned by an external visitor principal
(webchat:<visitorId>) so each visitor's threads stay isolated for the
self-service session API. But the console list/page/owner-check only
recognized the current user + system owners, so these conversations were
invisible in the sidebar and the Sessions page — and would 403 on open
even if surfaced.

Treat webchat: owners like system owners for the console: include them in
the lenient list/page queries and in isConversationOwner. The strict
listConversations overload (used by the visitor self-service path) is
unchanged, so a visitor's own access is not widened.

Refs matevip/mateclaw#340
2026-06-17 23:21:08 +08:00
matevip
1522009aec feat(agent): one-sentence AI employee creation wizard
Turn a single natural-language requirement into a ready-to-review
employee: the model proposes name, persona, runtime type and a
validated set of skills/tools/knowledge base, which the user confirms
or tweaks before the agent is created.

- backend: POST /api/v1/agents/generate builds a draft from the
  workspace's real capability catalog; every suggested tool/skill/KB is
  re-validated against the catalog so nothing hallucinated is offered
- frontend: 3-step wizard at /agents/create reusing the existing
  create + binding endpoints; reusable capability picker shows selected
  items as compact chips with an on-demand searchable catalog
2026-06-17 17:38:42 +08:00
matevip
6e7c137154 feat(wiki): entity-level knowledge graph extraction (#336)
Add an opt-in named-entity extraction pass so the wiki knowledge graph
captures fine-grained entities (people, organizations, locations, ...)
and their relations, not just page-level link relations.

- new tables mate_wiki_entity / _mention / _relation (h2/mysql/kingbase)
- structured LLM extraction per chunk with entity resolution
  (normalized-key dedup + embedding near-merge), mention/relation
  persistence and page linking via chunk citations
- per-KB opt-in toggle (off by default); async dispatch after embedding
- read API: entity list, KB graph, entity ego-graph, manual extract
- UI: entity-layer toggle in the graph view + KB config toggle
- replace inline fully-qualified class names with imports in WikiProcessingService

Closes #336
2026-06-17 14:17:54 +08:00
matevip
fe68f22aa8 feat(memory): bound always-on memory growth with injection budget, consolidation, and file ceilings
Always-on memory (structured user/feedback blocks, PROFILE.md, MEMORY.md) is injected into every system prompt but only ever grew, inflating per-turn context over time. This adds deterministic size control across all always-on sources:

- Injection budget: cap the always-on structured block by total chars and per-type entry count, keeping the most-recently-updated entries (LRU by Updated date) and disclosing how many were omitted
- Nightly consolidation: a dedicated scheduled pass merges duplicate/stale user & feedback entries via the LLM, preserving each entry's original Updated date; runs per owner bucket (shared + personal) with a per-run cap and a never-grow safety guard
- File ceilings: deterministic backstop truncates PROFILE.md / MEMORY.md at a section boundary when a rewrite overruns its budget
- Manual trigger endpoint for the consolidation maintenance task

All knobs under mate.memory.*; covered by unit tests.
2026-06-17 11:04:19 +08:00
matevip
1affbd7b82 feat(agent): loop-engineering robustness — goal continuation, plan re-plan, stall detection
- goal: continue (not skip) on max-iterations and evidence-insufficient turns.
  A max-iterations turn grants a fresh iteration budget ("hard continuation"),
  bounded per run and sized into the graph recursion ceiling, so a task too big
  for one budget keeps going instead of stalling until the next user message.
- plan-execute: re-plan the remaining work on a step exception, and on a
  signature-based stall (repeated failures / identical results / no usable
  result) instead of advancing dependent steps with junk; bounded by a per-run
  re-plan cap, with a graduated change-strategy nudge before the hard stop.
- plan-execute: auto-derive a goal from a genuine multi-step plan, seeding the
  acceptance criteria from the plan steps, so the goal subsystem engages without
  the model calling setGoal; broadcast goal_created so the UI hydrates.
- react: refund the iteration for setup-only rounds (load_skill / enable_tool)
  so a tight budget is not eaten by the load-then-use two-step.
- ui: re-fetch the active goal when a turn finishes so a goal created or mutated
  mid-conversation surfaces without depending on an SSE event.
- streaming: make retry backoff / total-time budget instance fields with a
  test-only seam; clarify that the wall-clock budget (not max-retries) bounds a
  sustained SERVER_ERROR loop to ~8 attempts, fixing the slow/flaky retry test.
2026-06-17 06:37:08 +08:00
matevip
85ceafa055 fix(agent): scope KB grounding to wiki-equipped agents and wiki tools
The knowledge-base trust verification recorded wiki citations from every
non-readFile tool response by sniffing its JSON for a top-level title /
pages / chunks field. Tools like getGoalStatus return a top-level title,
which falsely populated the citation set and then forced [n] citations on
the final answer (otherwise flagged EVIDENCE_INSUFFICIENT). Gate citation
mining on the wiki_* tool name instead.

Likewise, the grounded answer contract (cite-or-refuse) was appended to
every ReasoningNode call unconditionally, degrading general agents that
have no knowledge base. Append it only when the agent has a wiki_* tool
bound, scoping the strict regime to KB-grounded scenarios.

Adds a regression test asserting a non-wiki tool with a top-level title
creates no wiki citations.
2026-06-16 07:48:04 +08:00
jack
88be1f748a
[#305] [Feature] Add knowledge base trust verification (#334)
Co-authored-by: SuperCoderMan521 <SuperCoderManqq.com>
2026-06-16 07:47:04 +08:00
matevip
d9a9d07704 fix(wiki): broken-link rescan precision, slug/title resolution, and dangling-link reconcile (#333)
- rescan: keep the KB id as a string end to end so the 19-digit snowflake id
  isn't truncated past Number.MAX_SAFE_INTEGER (rescan no longer 404s)
- lint: resolve [[...]] targets against page slugs AND titles like the viewer,
  so a title reference to an existing page is no longer reported broken
- ingest: derive the slug deterministically from the title (no inconsistent
  romanization), auto-recompute broken links once a KB finishes importing,
  and reconcile dangling [[concept]] links — redirect to the covering page via
  declared aliases, or demote to plain text when uncovered
- add the page aliases column migration for h2 / mysql / kingbase
2026-06-15 16:14:25 +08:00
matevip
4cbd2b50f3 feat(dashboard): show the connected database on the dashboard
Surface the connected database product as a subtle chip in the Dashboard
header. SystemHealthService now reports a database label on /system/health
(reused by the front-end — no extra request), derived from a new
DatabaseBootstrapRunner.getDatabaseLabel() that reads the JDBC product name
once and normalizes it to a canonical label (MySQL / MariaDB / PostgreSQL /
H2, and 人大金仓 for the KingbaseES family), collapsing driver version noise.
2026-06-15 08:47:53 +08:00
matevip
710b756281 test(chat): cover gateway-resilience error classification; fix PKIX casing
Add ErrorClassificationTest regression cases for the AI-gateway retry
hardening: 5xx-before-4xx ordering (a proxy 502 whose body says
"bad request" stays retryable), Chinese / numeric provider billing
patterns, and DNS / TLS infrastructure-fatal detection.

Fix the cert-trust pattern while adding its test: Java's ValidatorException
emits "PKIX path building failed" with an uppercase PKIX and the error
chain is not lower-cased, so the previous lowercase pattern never matched
— an untrusted/expired cert chain fell through to the retryable
SERVER_ERROR bucket and was retried in vain instead of failing over.
2026-06-15 08:09:59 +08:00
MIST
42f1d5b685 fix(chat): resilient retry for transient AI gateway errors 2026-06-15 08:02:02 +08:00
倪程伟
7c4380a116
feat(docs): expose bundled help docs via in-app viewer
Closes #330
2026-06-15 07:48:49 +08:00
matevip
6bebfed07c fix(feishu): surface recent-file disk-scan failures at warn level
The disk fallback's catch block logged at debug, so a failed scan
silently dropped recovered files — reproducing the same 'bot can't see
the file' symptom the fallback was added to fix. Promote to warn with
the full stack trace, matching cacheRecentFile's logging.
2026-06-14 17:25:26 +08:00
倪程伟
f361e0e917 fix: reuse shared HttpClient to prevent thread-leak OOM on model test
openAiCompatibleClientBuilder() was creating a new java.net.http.HttpClient
per request. Each instance spawns a selector thread and connection pool that
are never closed, exhausting the OS thread limit under frequent model-test
calls (e.g. DeepSeek provider).

Elevate the HttpClient to a static singleton so all OpenAI-compatible
provider requests share one connection pool and one selector thread.

Closes matevip/mateclaw#328
2026-06-14 16:57:13 +08:00
倪程伟
515cba88ee test(feishu): add TTL filter and unit tests for recent-file disk fallback
loadRecentFilesFromDisk now filters out files older than RECENT_FILE_TTL_MINUTES
(60 min) so the disk fallback matches the Caffeine cache TTL and does not inject
stale attachments into future conversations.

Testability refactoring:
- recentFileCache: private → package-private (tests can seed the cache directly)
- chatUploadsRoot: new package-private Path field (tests redirect to @TempDir)
- loadRecentFilesFromDisk: add (Path dir, long cutoffMs) package-private overload;
  private (String) wrapper delegates to it
- injectRecentFiles: private → package-private

New test class FeishuRecentFileCacheTest (14 cases):
- loadRecentFilesFromDisk: non-existent dir, empty dir, fresh files sorted
  newest-first, stale files excluded by TTL, mixed fresh+stale, >5 files capped,
  timestamp-prefix stripping, MIME guessing from extension
- injectRecentFiles: Caffeine cache hit, cache-miss disk fallback, empty disk,
  duplicate-path dedup, image vs file part typing, null textContent guard

Relates to #325
2026-06-14 16:57:13 +08:00
倪程伟
39a55db65f fix(feishu): recover recent files from disk when in-memory cache misses
The per-chat recent file cache (Caffeine, 60 min TTL) is purely
in-memory.  After a process restart, GC eviction, or TTL expiry the
cache is empty, but the staged copies under data/chat-uploads/ survive
on disk.  A follow-up text message that should have seen the cached file
instead found nothing — the bot replied as if no file was ever sent.

Changes:
- injectRecentFiles(): fall back to scanning data/chat-uploads/{id}/
  when the Caffeine cache misses, sorted by last-modified time, capped
  at RECENT_FILE_MAX_PER_CHAT (5).
- cacheRecentFile(): promote catch log from debug → warn with full
  stack trace so silent download failures are visible in production
  logs.  Add entry-level info log for correlation.
- New helper loadRecentFilesFromDisk() + guessContentType().

Closes #325
Relates to #201
2026-06-14 16:57:13 +08:00
matevip
07da1d610b feat(db): add PostgreSQL Spring profile 2026-06-14 16:47:02 +08:00
matevip
ed6eac310a fix(cron): restore ShedLock DB-time for dialects that support it
The KingbaseES change removed usingDbTime() unconditionally, which made
every deployment (MySQL/H2/PostgreSQL) fall back to app-server time for
distributed lock timing — reintroducing node clock-drift risk in
multi-instance setups. Re-enable usingDbTime() for databases in ShedLock's
built-in dialect map and skip it only for KingbaseES, which is not covered
and would otherwise throw at lock acquisition.
2026-06-14 10:44:52 +08:00
铭萱
446f34b6b5
feat(db): support KingbaseES (人大金仓) domestic database (#324)
Add KingbaseES support as an opt-in profile: dedicated migration tree, bilingual seed data, runtime DbType detection (KINGBASE_ES / POSTGRE_SQL), and JDBC URL handling in the datasource manager.
2026-06-14 10:30:09 +08:00
matevip
936c8621ed fix(wiki): extract uploaded documents via a sandbox-exempt path (#323) 2026-06-13 07:29:39 +08:00
matevip
48024e4a83 fix(wiki): dedup pages by title and bound route prompt growth (#321) 2026-06-12 15:17:32 +08:00
matevip
c1691e466c fix(agent): stop ProgressLedger from pinning virtual-thread carriers under parallel progress_update 2026-06-12 11:07:55 +08:00
matevip
e6f8da9606 fix(agent): recover tool-call follow-up on interleaved-thinking models (#256) 2026-06-12 11:07:55 +08:00
matevip
5235e30138 fix(tool): keep Snowflake ids precise across the tool boundary (#319) 2026-06-11 15:17:51 +08:00
matevip
bad216d686 feat(tool): add image_analyze for on-demand image re-analysis (#303) 2026-06-11 13:53:56 +08:00
matevip
aaf06b262c feat(agent): retain image context across turns for follow-ups (#303) 2026-06-11 13:53:56 +08:00
matevip
dfc8e4c786 fix(channel): warn when wecom inbound image is stored URL-only (#303) 2026-06-11 13:53:56 +08:00
matevip
4a3d4cf568 fix(mcp): auto-heal stale MCP connections after server restart (#317) 2026-06-11 09:59:25 +08:00
倪程伟
18daad79b2
feat(wiki): unify raw materials & source watcher into a Sources tab with per-KB auto-sync (#316)
* feat(wiki): unify raw materials & source watcher into a Sources tab with per-KB auto-sync

The raw-material directory scan and the Advanced "source watcher" sub-tab were
the same engine (same kb.sourceDirectory, same WikiDirectoryScanService) split
across two surfaces with two editable directory inputs. Merge them into one
"Sources" tab (upload / paste / directory manual scan + auto-sync toggle +
the raw-material list) and drop the watcher sub-tab from Advanced.

Auto-sync is now per-KB opt-in: a new watcher_enabled column (V146) gates the
periodic scan per knowledge base. The server-global mate.wiki.watcher-enabled
stays as an ops master switch — a KB is auto-scanned only when both are on
(AND). Manual scans are unaffected. Scan interval stays global for now
(tracked separately).

Closes matevip/mateclaw#314

* docs(wiki): document source-watcher global switch env vars

Expose MATE_WIKI_WATCHER_ENABLED / MATE_WIKI_WATCHER_INTERVAL_MS as
explicit placeholders in application-mysql.yml, .env.example and
docker-compose.yml, mirroring MATE_WIKI_ALLOWED_SOURCE_ROOTS. Notes the
AND semantics (global ops gate + per-KB toggle) so operators know the
global switch alone is not sufficient.
2026-06-11 09:29:26 +08:00
matevip
83615593cd fix(tool/guard): harden workspace filesystem sandbox (#313)
- Fail closed to a global fallback sandbox root when a conversation has no
  per-workspace base path, instead of leaving file/shell tools unconstrained
- Refuse shell commands that delete the workspace root directory itself
- Block workspace-boundary escapes at the policy layer before the approval
  prompt, not only at execution time
- Approval bar now shows the actual command / target path being approved
2026-06-10 17:17:07 +08:00
matevip
7478491652 feat(llm): add Claude Fable 5 support 2026-06-10 10:14:51 +08:00
matevip
c1fd39a1e2 fix(agent/plan): 分流器证据闸门防止复杂任务不执行就停止(v2 剥离 memory-context) 2026-06-09 17:55:26 +08:00
matevip
64b8e167d5 fix(mcp): refresh agent cache on MCP connection change + non-blocking connect (#289)
Closes #289 — after an MCP server (re)connects, chat queries kept replying
"from memory" instead of calling MCP tools.

Root cause: agents snapshot their tool set at build time and are cached in
AgentService.agentInstances, but MCP server lifecycle changes never
invalidated that cache (unlike model-config / tool-guard changes which do).
A stale, tool-less agent graph survived until process restart.

Changes:
- Add McpServerChangedEvent; McpServerService publishes it on connect /
  disconnect / reconnect / delete / (re)connect-failure / batch refresh /
  startup init. AgentService listens and calls refreshAllAgents(), so the
  next turn rebuilds against the live MCP tool set. Also closes the boot
  race where the web server accepts requests before the @Order(200) MCP
  init runner finishes.
- Make create/update/toggle connect asynchronously on a dedicated pool
  ("mcp-connect") so a slow/unreachable server can no longer freeze the
  admin request; status returns immediately as "connecting".
- UI: render the new "connecting" status (pulsing amber dot), show a
  friendly "connecting in background" toast, and poll until the status
  settles (window widened to ~40s to outlast the default connect timeout).
- UI: MCP config modal no longer closes on outside/backdrop click — only
  the × and Cancel buttons close it, so an accidental click can't discard
  unsaved config.

Verified E2E: ckjia-shopping (参考价) MCP server connected at runtime with
no backend restart; the cached 通用助手 agent immediately enabled and called
ckjia_shopping_recommend, returning real product cards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 14:14:08 +08:00
matevip
846c1c31ca fix(channel): bound webchat conversationId/username to prevent VARCHAR(64) overflow
The webchat conversationId (webchat:<key8>:<visitorId>[:<sessionId>]) and the
derived username (webchat:<visitorId>) are written to VARCHAR(64) columns, but
visitorId had no validation and sessionId allows 64 chars — so a long visitorId,
or a legitimate 64-char sessionId, overflows the column and the getOrCreateConversation
INSERT throws (500 on /stream). Validate visitorId (charset + blank->UUID) and
fold the variable part into a stable hash when the derived id/username would
exceed 64 chars, keeping short ids byte-identical (backward compatible). Also
make listSessions filter on exact owner username, not just the conversationId
prefix, so system-owned rows can never leak via a crafted visitorId. Adds
boundary regression tests.
2026-06-09 11:36:41 +08:00
倪程伟
77b6baeccc feat(channel): webchat session-management endpoints (list / messages / delete)
Add per-visitor session management for the WebChat Web/API access mode:
list a visitor's conversation threads, fetch a thread's messages, and
delete a thread.

Authorization: visitorId is a client-asserted request param, so it cannot
be trusted on its own — deriving conversationId from it and then checking
ownership against it is tautological (any caller passes). Instead, /stream
issues a per-visitor token = HMAC-SHA256(jwtSecret, channelId:visitorId),
returned in the meta event; the management endpoints require it back via
the X-MC-Visitor-Token header and verify it in constant time. The signing
secret is server-only (unlike the public channel API key) and the channelId
in the payload makes tokens non-portable across channels.

Includes regression tests for token issuance/verification semantics
(forged visitorId rejected, cross-visitor and cross-channel tokens rejected,
tampered tokens rejected).
2026-06-09 11:10:13 +08:00