The skill detail drawer could only show and edit SKILL.md; the bundle
files under scripts/ and references/ had no console surface, and
templates/ was readable by agents but absent from the canonical
store's bucket set.
- admin endpoints on /api/v1/skills/{id}/files: list (self-heals an
empty canonical store from on-disk files), read, upsert, delete.
Writes update the canonical row, materialize the workspace cache,
and re-resolve the skill so agents pick changes up immediately.
Path envelope enforces the three buckets and blocks traversal;
builtin skill files stay read-only; virtual skills own no files.
- templates/ becomes a first-class DB-persisted bucket shared across
the syncer, the workspace write/delete envelope, and prune guards.
- the agent-facing write_file action now mirrors into the canonical
store and re-resolves instead of writing only the local filesystem.
- SkillMarket detail drawer gains a Files tab: grouped list, viewer,
inline editor, create and delete, refetched on every entry.
Skills backed by a network service could show ready while the service
was unreachable from the current deployment (intranet-only address,
wrong network segment) — the failure only surfaced mid-task.
- endpoint requirement type: TCP-connect probe (1.5s timeout) of the
declared service address; accepts http(s)://host[:port][/path],
host:port, and bare-host forms
- URL-shaped check targets infer the endpoint type without an explicit
declaration; unparseable targets report UNKNOWN instead of missing
- probe results cached 60s per host:port so refresh passes stay cheap
and a VPN connect is picked up within a minute
- unreachable endpoints surface as setup-needed on the skill card,
pre-flight requirement rows, and the agent-facing catalog
The runtime resolved SKILL.md from the workspace directory while the
admin console read the skill_content column, so out-of-band file edits
(agent shell tools in a chat session) changed runtime behavior but never
showed up in the console, and a failed workspace export left agents
executing stale content the console claimed was current.
- SkillContentReconciler: three-way sync between the canonical DB column
and the convention-workspace file, anchored on a sidecar hash marker.
File-side edits ingest into the DB, DB-side edits materialize to the
file, two-sided conflicts resolve DB-wins with a backup.
- Skill detail GET performs a read-time reconcile and triggers a
single-skill rescan when the file side changed, so a console query is
always current without waiting for the runtime cache TTL.
- SkillMarket detail drawer refetches the row and runtime status on open
instead of rendering the page-load list snapshot.
Bundled skill scripts/ and references/ are now persisted to mate_skill_file during startup sync; a workspace missing its scripts directory is force-restored from the classpath bundle even when the SKILL.md version is unchanged; and builtin skills with neither DB rows nor on-disk files backfill from the classpath. Fixes installs performed from builds whose jar shipped without bundle scripts.
Send agent-generated files as native WeChat attachments via the iLink upload flow, and fix the wire protocol for file uploads: dedicated wire ObjectMapper (bypasses the global Long-to-String serializer), md5/len fields and encrypt_type on media items, channel_version 1.0.2, and explicit business-error handling on ret != 0. The weixin adapter now routes generated-file URLs through GeneratedFileScrubber, matching WeCom/Feishu behavior.
Fixes#307
Entity extraction previously constrained entity types but let the
model freely invent any relation between entities, producing noise
that diluted the entities a knowledge base actually cares about.
Adds an optional per-KB relation schema (subjectType/predicate/
objectType triples): when set, the extraction prompt is scoped to
only those relations, and a hard filter drops anything that slips
through before it is persisted. Empty/unset keeps the existing
open-vocabulary behaviour.
Windows-authored zips often store entry names in the local codepage (GBK)
without setting the ZIP UTF-8 flag, while file content stays UTF-8. The
previous fallback decoded the whole archive with one charset, so a single
GBK-named entry forced already-correct UTF-8 content to be re-decoded as
GBK, corrupting valid Chinese text into mojibake. Names and content now
each try UTF-8 first and fall back to GBK independently, per entry.
Global transformation templates (workspace_id IS NULL, e.g. the 7 built-in
starter packs made global by V165) were shared across every workspace but
not actually read-only: any workspace member could edit or delete them,
mutating/affecting all workspaces, with deletes unrecoverable (Flyway
seed runs once).
- Controller: reject update/delete of null-workspace templates with 403
(err.wiki.global_template_readonly); read/apply paths unchanged.
- Service: defense-in-depth — update/delete also reject global templates,
guarding non-HTTP callers (WikiTool LLM entry points). delete() now
checks the entity before deleting instead of deleting blindly.
- findByName: add deterministic ORDER BY (workspace_id IS NULL) ASC so a
workspace-local template wins over a same-named global one (was LIMIT 1
with no ordering). Consistent across H2/MySQL/Kingbase.
- i18n: new err.wiki.global_template_readonly (zh + en).
- Tests: +2 controller mock tests (403 on update/delete, no service write),
+2 E2E tests (global template stays intact; findByName prefers local).
Tests: 10/10 green (4 controller + 6 E2E).
buildWikiContext enumerated an agent's bound knowledge-base pages into
the system prompt capped only by maxContextChars (default 10000, sized
for large cloud models). On a small-context model a large KB therefore
consumed a big fixed slice of the window on every turn — the "tool token
estimate fills the context" report in #521 (the growth lands in the
system-prompt bucket, not the tool-schema bucket; wiki tool schemas are
fixed-size and do not scale with file count).
Add a budgeted buildWikiContext(agentId, budgetTokens) overload mirroring
buildRelevantContext: the page enumeration also stops once the estimated
token total exceeds the budget, appending the existing
'... and more (use wiki_list_pages)' hint. AgentGraphBuilder passes the
same prefix budget it already applies to the memory block; the legacy
Integer.MAX_VALUE path keeps chars-only behavior for large models.
Tests cover null-budget (all pages), token-budget truncation, and
zero-budget skip.
Custom (user-added) providers were hard-coded supportModelDiscovery=false in
createCustomProvider, so self-hosted OpenAI-compatible endpoints (vLLM /
Xinference / LocalAI / gateways) never surfaced the 'discover models' button —
users had to add every model id by hand.
- ModelProtocol: add per-protocol supportsSelfConfiguredDiscovery() + resolve()
helper (single source of truth for chat-model class and capability flags).
baseUrl+apiKey protocols (openai-compatible, dashscope-native, gemini-native,
anthropic-messages) => true; OAuth protocols => false. The flag is deliberately
narrower than 'can ever discover' (built-in ChatGPT-OAuth still discovers via
its OAuth session); javadoc warns against reusing it to gate the button.
- createCustomProvider: default supportModelDiscovery from the resolved protocol
instead of always false. Existing rows are unaffected (no migration).
- OpenAiModelsPath: new single source of truth for the models-listing path,
honoring an optional 'modelsPath' generateKwargs override (mirrors the existing
'completionsPath' override) for endpoints behind a reverse proxy / non-standard
prefix (e.g. /openai/v1/models) that would otherwise 404 on /v1/models.
Shared by BOTH discovery (ModelDiscoveryService) and the failover liveness
probe (OpenAiCompatibleListModelsProbe) so an override can't make a provider
discoverable yet still marked unhealthy by a probe hitting the wrong path.
- Tests: ModelProtocolTest (capability table + resolve fallback), OpenAiModelsPathTest
(path branch table + vendor cases + modelsPath override), and custom-provider
discovery-default assertions. Path-resolution coverage consolidated into
OpenAiModelsPathTest (was split across the discovery + probe test files).
- Docs: zh/en models.md note custom-provider discovery + modelsPath override.
Refs matevip/mateclaw#519
Give raw-material uploads a dedicated five-minute timeout and process file-picker and drag/drop uploads through a shared two-worker queue, so constrained uplinks no longer abort multipart requests at the global 30-second deadline.
Switch wiki processing jobs and page citations to application-assigned IDs: the PostgreSQL/Kingbase migrations define plain BIGINT primary keys without identity defaults, so database-generated keys fail on insert.
Eight entities (fact, fact contradiction, morning-card seen, wiki hot
cache / relation / transformation / transformation run / image caption
cache) declared IdType.AUTO while their PostgreSQL-compatible migrations
define the primary key as a plain BIGINT with no identity default.
MyBatis-Plus omits the id column from the generated INSERT under AUTO,
so every insert fails with a NOT NULL violation on those databases —
silently on paths that only log a warning. Switch them to snowflake
ASSIGN_ID, which works on all dialects since auto-increment columns
accept explicit values. Add a parameterized contract test pinning the
id strategy for all eight entities.
The lint script referenced eslint with --ext flags but the repo never had
an ESLint config file, so pnpm lint always failed. Add a flat config
(typescript-eslint recommended + vue essential) with legacy-code rules
downgraded to warnings, drop the flat-config-incompatible --ext flags,
and move pnpm build approvals from the no-longer-read
pnpm.onlyBuiltDependencies field to pnpm-workspace.yaml allowBuilds.
Give raw-material uploads a dedicated five-minute timeout and process file-picker and drag/drop uploads through a shared two-worker queue, so constrained uplinks no longer abort multipart requests at the global 30-second deadline.
Switch wiki processing jobs and page citations to application-assigned IDs: the PostgreSQL/Kingbase migrations define plain BIGINT primary keys without identity defaults, so database-generated keys fail on insert.
- Settings → System gains a 'default workspace storage path' item: validated
on save (absolute, creatable), applied immediately without restart, and
re-applied from the database on startup. Blank clears the override;
existing data is never migrated.
- Desktop local file/command tools get a renderer settings page (allowed
directory list with per-row delete, add via native picker, enable toggle,
tunnel status); the native dialog additionally gains a 'remove directory'
flow, fixing the whitelist that could only grow.
- System settings save surfaces backend validation errors as a toast.
Let skill Python scripts install packages from a configurable pip index instead of the default PyPI. docker-compose passes PIP_INDEX_URL / PIP_TRUSTED_HOST into the container; for the desktop app (host JVM, no Docker env) SkillScriptExecutionService falls back to mateclaw.pip.index-url / trusted-host Spring config and injects them into the subprocess, auto-deriving the trusted host for plain-HTTP LAN mirrors. The runtime image gains pip and a build toolchain (with the PEP 668 marker removed so on-the-fly installs work), and the script timeout ceiling is raised to accommodate large installs.
Wire MCP standard notifications/progress into the existing SSE stream so long-running MCP tool calls surface live progress instead of a bare spinner. A per-call progressToken maps back to (conversationId, toolCallId); ProgressAwareMcpToolCallback injects it into tools/call _meta and calls McpSyncClient directly (falling back to the delegate on error, and applying identity forwarding first). Progress events skip the ring buffer and are replayed from a latest-value snapshot on SSE reconnect. Frontend renders a gradient progress bar in ToolCallSegment when a running tool reports progress.
- Shell scan: a token normalizing to the filesystem root (//, /., /..) is
only skipped when the command carries no destructive verb; with
rm/rmdir/shred/srm present the scan fails closed, so 'rm -rf //' is
refused while sed empty replacements (s/pattern//) stay allowed.
- Chat-upload fallback: a boundary violation is waived only when the
requested path itself normalizes inside one of the conversation's
candidate upload directories; a basename match against a stored
attachment no longer clears the violation. Resolver/DB failures keep
the BLOCK finding. The unused candidate-roots resolve overload is
removed.
- Regression tests for destructive root tokens, sed allowance, the
fail-closed compound case, stored-upload-path allowance, basename
collisions, cross-conversation paths, and resolver failure.
Skip absolute-path tokens that normalize to the filesystem root in the shell boundary scan (shell syntax like sed's s/pattern// was misread as a path outside the workspace), and add a DB-backed chat-upload fallback in WorkspaceBoundaryGuardian: when a file-tool path triggers a boundary violation, resolve the conversation's real candidate upload roots so attachments stored in workspace-scoped directories are found even when the thread-local workspaceBasePath is null.
Adds a read-only GET /api/v1/settings/search-providers catalog (admin-gated, no secrets), grouped collapsible provider cards, and a schema-driven plugin config form. Breaks a SystemSettingService<->PluginManager circular dependency via parameter @Lazy (with a context smoke test), and fixes PluginManager.updateConfig to merge instead of overwrite so omitted/blank secret fields are preserved. Hardens plugin search-provider id validation (reject-not-trim, case-insensitive conflict) and insulates the provider bridge hot path from throwing plugin code.
- Remove "(issue #477)" internal planning references from shipped Java
(SearchProviderRegistry, PluginSearchBridge, and the two new tests) — code
should describe what it does, not point at issue trackers.
- Drop "openclaw" external-project attribution from the search provider
comments (SearchProviderRegistry, SearchCache, SearchQuery), restating them
as objective functional descriptions.
- requireSessionOwnership now also checks session.kbId() == path kbId (404 on
mismatch), so a research session started under one KB cannot be addressed via
another KB path even when the caller's key is bound to both — defense-in-depth
on top of the keyId ownership check.
- Drop internal "R7" / "review #446" markers from the controller Javadoc in
favour of functional wording.
- Import Set/Map/concurrent types and static any() instead of inline FQNs in the
new kb-open research/auth tests, per code style.
* feat(kb-open): Deep Research open API (start/SSE/status/cancel)
Implements the async Deep Research endpoint for the KB Open API (#443).
Research is a multi-step LLM pipeline (plan → retrieve+draft → compose)
that runs asynchronously and broadcasts progress via SSE.
Endpoints:
- POST /{kbId}/research start (returns sessionId + streamUrl)
- GET /{kbId}/research/{id}/stream SSE progress (?token= for EventSource)
- GET /{kbId}/research/{id}/status query status / final report
- POST /{kbId}/research/{id}/cancel cancel running session
Components:
- KbOpenResearchController: 4 endpoints, @RequireKbScope("kb:search")
- KbResearchSessionRegistry: in-memory session tracking with keyId
ownership (a caller can only query/cancel their own sessions)
Security:
- R7: SSE uses ?token= query param (KbOpenApiAuthFilter already supports
this fallback for EventSource which can't set Authorization headers)
- Session ownership: status/cancel/stream all verify keyId match
- Cancel checks session is RUNNING (409 otherwise)
Reuses existing WikiResearchService.research() + ChatStreamTracker for
the actual research pipeline and SSE broadcasting.
Tests (6 new, all green):
- KbResearchSessionRegistryTest: register/complete/fail/cancel lifecycle,
cancel-on-completed no-op, unknown session returns empty
Closes#443
* fix(kb-open-research): cooperative cancel, sticky terminal, TTL, concurrency cap
Review #446 — address all 4 job-lifecycle/cost blockers + nits:
1. Cooperative cancellation (was: cancel only flipped status, pipeline ran
to completion). Cancel endpoint now calls streamTracker.requestStop();
WikiResearchService.ensureNotCancelled() checks isStopRequested at each
stage boundary (plan→draft, draft→compose) and inside the parallel draft
fan-out — so cancel actually halts the expensive LLM calls, not just the
SSE stream. Throws ResearchCancelledException (caught locally, no error
broadcast).
2. Sticky CANCELLED terminal. complete()/fail() now no-op on a CANCELLED
session, so a user who cancelled never sees a COMPLETED report surface
via /status.
3. Session registry TTL. Terminal sessions get an updatedAt timestamp and
are evicted by a @Scheduled sweep after
mate.kbopen.research.session-ttl (default 30m). RUNNING sessions are
never evicted. Prevents unbounded memory growth.
4. Per-key concurrency cap. startIfAllowed() rejects new research when a
key already has mate.kbopen.research.max-concurrent-per-key (default 3)
RUNNING sessions → 429. Stops one key from spawning ~60 parallel
multi-step LLM pipelines per minute under the per-min rate limiter.
5. Inline FQN → import (controller LinkedHashMap, test List.of).
Nits (inherited from P0-A rebase):
- V162→V164, prefix VARCHAR(12), design doc moved to rfcs/.
- Design doc: kb:search scope row now documents it covers /research/**.
31 tests pass (12 registry incl. sticky-cancel/concurrency/TTL +
13 service + 4 rate limiter + 4 controller + ...).
* fix(kb-open): scope-limited ?token= SSE auth fallback in KbOpenApiAuthFilter
R7: the SSE progress stream (/research/{id}/stream) is consumed by browser
EventSource, which cannot set an Authorization header. The filter's
extractBearerToken() never read ?token= (still a TODO), so the SSE endpoint
was unreachable from the browser — the headline use case got 401.
Fix: accept ?token= ONLY on SSE stream paths (isSseStreamPath, suffix
/stream), reject it everywhere else so the API key does not leak into
access/proxy logs for normal calls (R5). Matches the JwtAuthFilter convention
(getRequestURI logs carry no query string).
Also bypass the per-minute rate limiter on the SSE path: EventSource
reconnects/heartbeats would otherwise burn the key's window and 429 its own
POST /research start. Rate limiting belongs on the cost-producing endpoints.
Tests (6 new, KbOpenApiAuthFilterTest):
- non-SSE: header passes, ?token= rejected (no authenticate call)
- SSE: ?token= authenticates, missing token → 401
- SSE: bypasses rate limiter; non-SSE still hits it
* fix(kb-open-research): make per-key concurrency cap atomic (no check-then-act race)
startIfAllowed() did stream-and-count then put() — not atomic. Two
concurrent starts for the same key could both pass the count check (both
see < cap) and both put, admitting more sessions than the cap. On the
virtual-thread start endpoint this is a real DoS/cost-bypass path.
Fix: maintain a per-key AtomicInteger running counter (runningPerKey),
incremented atomically on start (incrementAndGet + rollback on overflow)
and decremented on each RUNNING→terminal transition (complete/fail/cancel).
The counter is kept in lock-step with status==RUNNING; since terminal
states are sticky, each session decrements exactly once.
cancel() also rewritten to capture the pre-transition state cleanly (the
old return check relied on Map.computeIfPresent returning the new value,
which worked but read as 'before.status==CANCELLED').
Tests (+2): cancelled/failed release slot (counter consistency), and a
concurrent-start test (12 virtual threads, cap=3) asserting exactly cap
admits — would be flaky/fail under the old impl.
* refactor(kb-open-research): remove unused register() back-compat method
register() was left over from the initial impl — it bypassed the per-key
concurrency cap (no startIfAllowed check) and, after the atomic-counter fix,
incremented runningPerKey without any overflow rollback. With no production
caller (the start endpoint uses startIfAllowed), it only existed for tests to
set up a RUNNING session. Drop it and route the tests through startIfAllowed
so nothing can accidentally ship a path that ignores the cap.
After the workspace-aware chat-uploads change, the upload root became
absolute (the resolver normalizes via toAbsolutePath/normalize, and the
autoconfiguration rewrites baseDir to an absolute path). ChatController.upload
then set ChatUploadResponse.path to that absolute path — despite the inline
comment promising a relative path "to avoid exposing the server's absolute
path". The field is rendered into the LLM prompt ("附件: foo (path)") and
returned to the client, so this leaked the server filesystem layout into both
the prompt and the response, and broke portability if the deploy dir moves.
Extract toRelativeUploadPath(uploadRoot, convId, storedName) which makes the
path relative to the upload root's parent (preserving the trailing sub-dir
name, e.g. chat-uploads/{convId}/{storedName}) and normalizes separators to
'/'. Retrieval is unaffected: it goes through the basename-based
ChatUploadResolver and the /api/v1/chat/files/... URL, not this field.
Adds ChatControllerUploadPathTest (default root, absolute workspace-scoped
root, custom base-dir name) asserting the result is relative and leak-free.
Addresses the blocker item in #452.
Adversarial review of PR #464 found that classify() promoted an absent
channelType to the 'authenticated' trust branch, stamping an untrusted
ThreadLocal username (e.g. stale value on a reused thread, or internal
tasks like SkillConsolidation/Reflection that carry no channel) with
authenticated trust — contradicting the fail-closed contract the service
documents.
- classify(): channel==null/blank now resolves to NONE (no injection);
only the explicit 'web' channel may yield authenticated. Unrecognised
non-web channels downgrade to external, never authenticated.
- signingKey(): replace the one-shot keyParseAttempted latch with
lastAttemptedPem so a corrected/hot-reloaded PEM re-parses on the next
call without an app restart. Still fail-closed when PEM is unchanged.
- Tests: 4 new cases lock the regression (null+dirty-ThreadLocal->NONE,
blank->NONE, novel channel->external, self-heal after config fix).
- .gitignore: exclude local .codebase-memory/ agent index.
MCP+identity suite: 93/93 green.
Two SSRF hardenings on top of the private-network deployment mode:
1. Redirect / subresource re-validation. The SSRF guard previously ran only on
the initial navigation URL in the tool layer, so a public page that 302s to
169.254.169.254 (or a script fetch / img to a metadata IP) reached the target
unchecked — worse now that private-network mode exists. Install a per-context
request interceptor (BrowserLauncher.applyContextDefaults) that re-runs
UrlSafetyChecker on every http(s) request and aborts blocked ones. Non-network
schemes (data:/blob:/about:) pass through; unexpected checker faults fail open
so a transient error cannot wedge the page (the initial URL was already checked).
2. Allowlist can no longer open a cloud-metadata endpoint. Metadata hostnames and
IPs are now checked BEFORE the allowlist short-circuits, so an operator entry
like 169.254.0.0/16 or metadata.google.internal can never expose instance
metadata. Ordinary private-host allowlisting is unaffected (regression-tested).
Also correct the 192.0.0.192 comment (Oracle Cloud IMDS, not Azure).
The identity forwarded to opt-in MCP servers was a one-dimensional string
(ChatOrigin.requesterId): a MateClaw username for web logins, but a webchat
visitorId for visitors and an IM sender id for IM — indistinguishable to the
REST backend. The signed-token mode (d204b702) made this worse: an RS256
signature over an unauthenticated visitorId reads as "MateClaw authenticated
this user" to any backend that trusts the signature.
Introduce an identity-typing dimension at McpIdentityForwardService:
- classify() branches on ChatOrigin: authenticated (web login, sub=immutable
userId), anonymous (webchat visitor, trust=anonymous), external (IM sender,
trust=external), or none (cron/system → nothing injected, fail-closed).
- mint() adds `trust` and `channel_type` claims; plaintext value is prefixed
`trust:subject` so backends can tell the kinds apart without a JWT.
The immutable userId reaches resolve() without coupling it to the user store:
JwtAuthFilter stamps user.id into auth.setDetails() (both JWT and PAT paths),
and ChatController.memoryOrigin carries it on a new ChatOrigin.requesterUserId
field (only-add, per the record's evolution rule).
Resolves the webchat semantic mismatch raised in #459 and the "sub should be
an immutable user id" follow-up. 82 tests green (4 identity classes covered
with claim assertions + full ChatOrigin/MCP regression).
(cherry picked from commit b5d2cfbf98b39848d7139c743a0b81fea71e8ffe)
Replace java.util.Set.of / java.util.Map.of inline fully-qualified calls with
top-of-file imports per code style (test sources sync to the open-source repo).
* feat(mcp): forward authenticated user identity to opt-in STDIO MCP servers
A STDIO MCP server is one shared subprocess per configuration; its env is fixed
at spawn and STDIO has no per-request header channel, so per-user identity must
travel in-band with each tool call. Previously nothing carried it, so an MCP
server could not call its downstream REST backend on behalf of the acting user.
Inject the authenticated username (from ToolExecutionContext) into each tool
call's JSON arguments under the reserved key `__mateclaw_user__`, for servers an
operator explicitly opts in via `mateclaw.mcp.identity-forward.servers` (by name
or id). The MCP server reads/strips it and forwards on-behalf-of alongside its
own backend API key.
- McpIdentityForwardProperties: per-server opt-in allowlist (name or id).
- IdentityForwardingToolCallback: wraps an MCP callback, merges the username
into the args; injected by trusted code, overwrites any LLM-supplied value
(no spoofing); forwards unchanged when there is no user or args aren't an
object/are malformed.
- McpClientManager: captures server names; wraps opt-in servers' callbacks
inside the prefix wrapper (so name-prefixing / return-direct still see the raw
delegate). Non-opt-in servers are untouched — username never leaks to them.
- Tests: injection, LLM-value overwrite, empty/non-object/malformed inputs,
no-user passthrough, opt-in matching by id/name.
- Docs (zh/en mcp.md): opt-in config, `__mateclaw_user__` contract, FastMCP
Python skeleton, trust model.
Default off (empty allowlist) — zero behavior change for existing servers.
Plaintext username suits a trusted-network REST backend keyed by an API key;
a signed short-lived token is noted as the stronger-isolation follow-up.
* feat(mcp): add signed-token trust model for MCP identity forwarding
Plaintext username forwarding makes the REST backend trust an unverifiable
assertion from the (shared, LLM-adjacent) MCP service — a confused-deputy model.
Add an opt-in signed-token mode so identity crosses the trust boundary as a
short-lived RS256 JWT the backend can verify with a public key.
- McpIdentityForwardProperties: nested `token` config (enabled, issuer,
ttl-seconds, key-id, private-key-pem, audiences) + USER_ARG/TOKEN_ARG keys.
- McpIdentityForwardService: resolves the injection — plaintext username
(__mateclaw_user__) when token mode off, else a minted RS256 JWT
(__mateclaw_token__) with sub=user, aud=server, short exp, jti. Lazy key
parse; fail-closed when token mode is on but the key is missing/unparseable
(no silent downgrade to plaintext). Signs with MateClaw's private key so the
backend only needs the public key (cannot mint/impersonate).
- IdentityForwardingToolCallback: now delegates the what-to-inject decision to
the service (keyed by per-server audience); static withClaim() keeps the
JSON-merge logic (overwrites LLM-supplied key, leaves non-object/malformed
args untouched).
- McpClientManager: injects the service; passes service + audience through the
wrap path for opt-in servers only.
- Tests: token mint+verify (with an in-test RSA keypair, asserting sub/aud/iss/
exp/jti), plaintext mode, no-user and no-key fail-closed, audience resolution.
- Docs (zh/en): token config, key generation, claims, REST-side verification
example, public-key distribution + JWKS-endpoint follow-up.
Default unchanged: token.enabled=false → plaintext (back-compat); whole feature
still opt-in per server and off by default.
mate_memory_recall.filename is VARCHAR(256), but the snippet-level recall
tracker assembles the key as `path + '#' + H2-heading-slug`. When the LLM
writes an over-long daily-note heading (the summarize prompt placed no
length cap on the `##` title), the CJK-preserving slug pushes the filename
past the column, and writes fail with Data too long / string too long.
Three layers of defence, root cause + hard caps:
1. prompt (source) — summarize-system.txt now asks for short (≤30 chars)
`##` titles; details go in the body, not the heading.
2. slug cap (close to source) — MemoryRecallTracker.sanitizeSectionKey
caps the slug at MAX_SECTION_SLUG=200, leaving path+'#' well under 256.
3. write-side cap (catches every path) — MemoryRecallService.recordRecall
truncates filename to MAX_FILENAME_LENGTH=255 at the entry point, so
the select/insert/update branches share one value and the dup-key
concurrency fallback still matches. Covers trackActiveRetrieval too,
which bypasses sanitizeSectionKey.
Tests: MemoryRecallFilenameTruncationTest covers both caps (over-long CJK
heading, normal heading untouched, ascii slug, date prefix survives) plus
an end-to-end assertion that the stored value fits VARCHAR(256). Existing
memory-suite unit tests still green.
Replace the single inline org.assertj.core.api.Assertions.assertThat call
with the static import already used for assertThatThrownBy, per code style
(test sources sync to the open-source repo).
* feat(kb-open): P0-B 9 open API endpoints
Implements the 9 read-only KB Open API endpoints on top of the P0-A
auth skeleton (#441). Each returns an explicit DTO (A5: never raw
entities) and delegates assembly to service-layer methods that return
pure DTOs (A6: no HTTP coupling, MCP-ready).
Endpoints:
- GET /pages/{slug} entity card (mode=summary/full/section:{heading})
- POST /search hybrid retrieval (granularity=entity/chunk)
- POST /search/chunks chunk-level semantic search
- POST /pages/{slug}/traverse entity relation graph (depth ≤ 2)
- GET /pages/{slug}/trace provenance (page → chunk → raw)
- GET /taxonomy pageType/entityType/relationType enumeration
- GET /whats-new recent changes + stale pages
- GET /stats KB statistics
- GET /pages lightweight page list
Components:
- KbOpenApiController: 9 endpoints, each @RequireKbScope annotated
- KbOpenApiService: assembly layer (card, traverse BFS, metadata parsing)
- KbOpenApiDtos: all response DTOs as records (PageCard, TraceResult,
TaxonomyResult, KbStats, WhatsNewResult, TraverseResult, PageList)
Traverse (pragmatic version):
- depth ≤ 2 with explosion guard, predicate LIKE matching
- slug → pageId → mention → primaryEntity (salience-highest)
- neighbor nodes echo slug when available (R11)
- edge sourceHandle via evidenceChunkId → citing page
Tests (4 new, all green):
- KbOpenApiControllerTest: 404 on missing page/slug, delegation to service
Closes#442
* fix(kb-open): address review feedback on #445
BLOCKERS:
- stats.pagesWithLinks always returned 0 because listByKbId() nulls out
content. Switch to listByKbIdWithContent() so [[wiki link]] detection works.
- Test file: replace inline java.util.List.of() FQN with import + simple name
(sync-opensource would expose the unidiomatic style).
NITS (inherited from P0-A rebase):
- V162→V164, prefix VARCHAR(12), FQN imports, parseScopes trim, ?token=
fallback removal, design doc moved to rfcs/ — all now in ancestor commit
6fd62440.
EXTRA:
- whatsNew staleReason: hardcoded Chinese "上游 fact 页面变更" → English
"Upstream fact page changed" (external-facing API response).
* chore(wiki): drop RFC-012 prefix from progress field Javadocs (#449 nit)
Per #449 review (4825113234): the internal RFC-012 reference should not
appear in code. progressPhase/progressTotal/progressDone Javadocs still
carried the "RFC-012 M2 v2 UI:" prefix after #449's English translation
pass — drop it now that these lines are touched.
Zero behavior change.
* chore(kb-open): drop inline FQN in parseScopes (#444 nit)
Per #444 review (4825157096): parseScopes used
`.collect(java.util.stream.Collectors.toUnmodifiableSet())` while
`Collectors` is already imported at the top of the file. Use the simple
name. Zero behavior change.
@SpringBootTest + H2 coverage asserting that both listRecent and search exclude
a still-running sibling conversation (stream_status='running') and the caller's
own current conversation, so concurrent sessions of the same agent cannot leak
into each other's session_search results.
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.
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.
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).
Add unit coverage for the StageInstructions custom deserializer: plain-string
shorthand, full object with instructions+template, unknown-field skipping, and
both forms coexisting on one WikiPageTypeDef (backward compatibility).
- 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.
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.
* 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.
* 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.
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.
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.
- 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
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.
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
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().
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.
- 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
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
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.
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.
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).
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.
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.
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.
Adds WebChatAttachmentE2ETest — second HTTP-level test in the webchat
suite. Boots RANDOM_PORT, drives Spring's multipart parser for real,
and verifies the cross-endpoint wiring that turns an upload into an
agent-addressable attachment.
Coverage (7 tests):
- upload + /stream round-trip: persisted user message's content_parts
carries the file part with a server path that points into the
conversation's upload dir; bytes on disk match what was uploaded
- unknown attachmentId → silently dropped (no error, text-only parts)
- foreign visitor cannot reference another visitor's fileId
(consume() is conversation-scoped)
- upload without visitorToken → HTTP 401
- upload with disallowed extension → HTTP 400
- GET /files streams back the uploaded bytes
- GET /files without visitorToken → HTTP 401
AgentService is @MockBean'd so /stream returns instantly; what we
assert is the persisted user-message shape (DB row content_parts),
not the agent's actual file consumption (which would need a real
agent + tool runtime — out of scope for the wire-format focus).
Worth noting: RHttpStatusAdvice maps R.fail(401/400) to the matching
HTTP status, so 4xx assertions are on the HTTP status, not the body.
Stack: feat/webchat-stream-e2e-test → feat/webchat-attachment-e2e
Follow-up to PR #363.
Adds WebChatStreamE2ETest — first test in the suite to boot a real
servlet container (RANDOM_PORT) and exercise /stream over actual HTTP,
parsing the SSE wire format that any third-party SDK would see.
AgentService is swapped with a Mockito @MockBean so chatStructuredStream
returns canned StreamDeltas — fast, deterministic, no real LLM.
Coverage:
- happy path: meta → content_delta* → done, assistant reply persisted
- multi-chunk reply with thinking_delta + _usage_final event
(verifies persisted prompt_tokens / completion_tokens / runtime_model)
- bad API key → SSE error event "Invalid API Key"
- blank message → SSE error event "Message is required"
- channel with no bound agent → SSE error event "No agent configured"
- explicit sessionId → meta echoes it + seeds conversation namespace
- invalid visitorId charset → SSE error event
7 tests, ~5s. Mid-stream stop is covered by WebChatStopStreamTest at
the controller level; attachment ingestion is left for a follow-up
since it requires POST /upload first.
Stack: feat/webchat-docs → feat/webchat-stream-e2e-test
Epic issue: #355
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.
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.
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.
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.
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.
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.
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.
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.
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
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
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
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
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
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.
- 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.
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.
- 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
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.
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.
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
* 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).
Closesmatevip/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.
- 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
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.