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.
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>
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.
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.
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.
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).
Two compounding causes made the management view jump from the config
tab back to 'raw' a few seconds after the user selected it:
1. The tab-snap watcher used a single getter returning a new array
(`() => [currentKB?.id, workspaceMode]`). Vue compares the returned
value with Object.is, so a fresh array reference reports a change on
every re-evaluation — including background refreshCurrentKB() calls
that reassign the KB object with the same id. That re-ran the snap and
forced activeTab back to 'raw'. Switch to an array of getters so each
source is compared individually and the callback fires only on a real
id/mode change.
2. RawMaterialPanel's onBeforeUnmount cleared the SSE stream and the 60s
fallback timer but not the per-raw jobPoller setTimeout chain. While a
raw was still processing, leaving the sources tab left that 3s poller
running, calling refreshCurrentKB() indefinitely. Clear jobPoller on
unmount as well.
The config tab pane (.tab-content--config) was set to overflow:hidden,
mirroring the graph pane, but its inner .wiki-config has no bounded height
so its own overflow-y:auto never triggers. Tall config content (model
strategy / processing rules / search-preview cards) overflowed off-screen
with no scrollbar.
Switch the pane to overflow-y:auto like the generic .tab-content. The
existing <=980px media query (overflow:visible) keeps mobile page-scroll
intact. Pure CSS, no logic change.
The chat composables (useStickToBottom / useStream / useMessages / useTyping)
carried '参考 @agentscope-ai/chat …' attribution comments. That package is not
a dependency and is never imported — the lines were pure citation. Rewrite them
as objective functional descriptions so shipped code does not name external
projects.
- useChat.ts reconnectStream: cast the reused assistant message id to string
when calling updateMessage; the id is optional in the Message type, so the
raw value broke the vue-tsc build (TS2345, undefined not assignable).
- useStickToBottom.ts handleScroll: restore the block's indentation (it had
drifted to 1/3-space) and add a comment for the scroll-up release branch.
Verified: vue-tsc --noEmit passes; snowflake precision check clean.
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).
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.
- 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.
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).
- 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.
* 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(desktop): support remote lite build mode without bundled JRE/JAR
Add a dual packaging mode system controlled by the BUILD_MODE env var:
- **local** (default): Full build bundling JRE + Spring Boot JAR, identical
to the previous behavior. Supports both embedded local backend and
remote server connection.
- **remote** (lite): Omits the ~530 MB JRE/JAR resources, producing an
installer that is ~81% smaller (97 MB vs 523 MB on macOS arm64). The
app only supports connecting to a remote server; the "local" option is
hidden from the splash connection chooser.
Changes:
- Replace static electron-builder.json with dynamic electron-builder.cjs
that conditionally includes extraResources based on BUILD_MODE
- Add build mode detection at runtime (checks JAR existence) with graceful
fallback to remote-only mode
- Add IPC handler app:get-build-mode and expose via preload
- Hide "本地运行" option in splash when running a remote build
- Ignore stale 'local' saved config in remote builds
- Add package scripts: package:mac:local, package:mac:remote, etc.
- Add missing build scripts: build.sh, download-jre.sh, build-all-platforms.sh
- Add no-op afterPack hook (trim-playwright-driver.cjs) placeholder
- Add cross-env devDependency for cross-platform BUILD_MODE support
* feat(desktop): add white-label branding system for build-time rebranding
Add a Vite plugin (scripts/branding.cjs) that replaces hardcoded "MateClaw"
strings at build time, enabling white-label/OEM rebranding without modifying
any source code.
Configuration:
- Edit branding.config.json (name, tagline, team, copyright, appId, githubUrl)
- Or set BRAND_* env vars (BRAND_NAME, BRAND_TAGLINE, BRAND_TEAM, etc.)
Usage:
# Default build (MateClaw brand)
npm run package:mac
# Custom brand via env vars
BRAND_NAME=MyAI BRAND_TAGLINE="Smart AI Helper" npm run package:mac:remote
# Or edit branding.config.json and build normally
npm run package:mac:remote
Replacements applied at build time:
- Brand name (window title, About dialog, error messages, console logs)
- Tagline, team name, copyright line
- GitHub repo/issues URLs
- Logo file path
- electron-builder config (productName, appId, artifactName, dmg title, publish repo)
The branding plugin runs in Vite's transform hook, covering the renderer
(App.vue, index.html), electron main process, and preload script.
Server-coupled strings (H2 database name, Spring Boot property names) are
intentionally NOT replaced to avoid breaking backend compatibility.
---------
Co-authored-by: qiaozhipeng <qiaozhipeng@daojia-inc.com>
* 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.