Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,18 @@ DB_PASSWORD=change-me-strong-user-password
# ⚠️ MySQL root 账号密码,仅用于容器内初始化。请改成与 DB_PASSWORD 不同的强密码。
DB_ROOT_PASSWORD=change-me-strong-root-password

# -------------------- PostgreSQL(可选,替代 MySQL)--------------------
# 仅在用 PostgreSQL 部署时需要:
# docker compose -f docker-compose.yml -f docker-compose.pg.yml up -d
# 该叠加文件把 mateclaw-server 切到 postgres profile,MySQL 容器不启动。
# 注意:即便不启动 MySQL,docker compose 仍会在解析阶段要求上面的
# DB_PASSWORD / DB_ROOT_PASSWORD 有值(任意占位即可,MySQL 容器不会运行)。
# 应用真正使用的库凭据来自下面这三项。
PGSQL_DB_NAME=mateclaw
PGSQL_DB_USERNAME=postgres
# ⚠️ 必填且请改成强密码。docker-compose.pg.yml 会通过 ${PGSQL_DB_PASSWORD:?} 强制要求。
PGSQL_DB_PASSWORD=change-me-strong-pg-password

# ==================== 安全(强烈建议覆盖) ====================

# JWT 签名密钥。若留空,服务器会用内置默认值并在启动日志里 WARN。
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ You hire coworkers, not chat boxes. Each one has a **Role**, a **Goal**, a **Bac
Text-to-speech · Speech-to-text · Image · Music · Video · 3D. First-class, not add-ons. **Sidecar routing** (1.3.0+) means a text-only main model + an image attachment no longer dead-ends — a configured vision model describes the image, and the main model answers. **Image edit** lands too: refer to an earlier conversation attachment by `msg:<id>:<idx>` and ask the model to recolor or restyle it. Four **document-generation tools** (`DocxRenderTool` / `XlsxRenderTool` / `PptxRenderTool` / `PdfRenderTool`) render Markdown straight to Office files inside the JVM — no subprocess, no Office install.

### Enterprise-ready
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL in production, H2 for dev — nothing to change in your code.
RBAC + JWT. **Personal Access Tokens** for headless scripts and CI. **HMAC-SHA-256 outbound webhook signing**. **Distributed Cron lock** so multi-instance deployments don't double-fire. Full audit trail. Flyway-managed schema that auto-heals on upgrade. One JAR to ship. MySQL, PostgreSQL, or KingbaseES in production, H2 for dev — nothing to change in your code.

---

Expand Down Expand Up @@ -203,7 +203,7 @@ Desktop binaries ship via [GitHub Releases](https://github.com/mateaix/mateclaw/
| Digital Employee Runtime | StateGraph · ReAct + Plan-Execute · Role / Goal / Backstory · LESSONS self-evolution |
| Orchestration | Workflow (7 step modes · Pebble DSL) · Triggers (6 pattern types · event governance) · Wiki Transformations (1.3.0+) |
| Capability Extension | SKILL.md packages · MCP (stdio / SSE / HTTP · per-agent binding) · ACP bridge (Claude Code / Codex) |
| Database | H2 (dev) · MySQL 8.0+ (prod) |
| Database | H2 (dev) · MySQL 8.0+ / PostgreSQL 14+ / KingbaseES 8+ (prod) |
| Auth | Spring Security + JWT |
| Frontend | Vue 3 · TypeScript · Vite · Element Plus · TailwindCSS 4 |
| Desktop | Electron · electron-updater · JRE 21 (bundled) |
Expand Down
70 changes: 70 additions & 0 deletions docker-compose.pg.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
# PostgreSQL override for docker-compose.yml.
#
# Usage:
# cp .env.example .env # set PGSQL_DB_PASSWORD (and the rest)
# docker compose -f docker-compose.yml -f docker-compose.pg.yml up -d
#
# The base docker-compose.yml runs the app on MySQL. This override adds a
# PostgreSQL service, re-points mateclaw-server at it, and keeps MySQL from
# starting — so the stack runs on PostgreSQL instead.
#
# Schema: the mateclaw schema is created by Flyway on startup
# (application-postgres.yml -> spring.flyway.init-sqls: CREATE SCHEMA IF NOT
# EXISTS mateclaw), so the postgres container needs no init script.

services:
# Gate MySQL behind a profile so a plain `up` with this override does NOT
# start the MySQL container. Start it explicitly with `--profile mysql` only
# if you want both DBs side by side.
#
# Note: docker compose still interpolates the base mysql service's ${VAR:?}
# at config-load time even though the service won't start, so .env must still
# carry DB_ROOT_PASSWORD / DB_PASSWORD (any non-empty placeholder is fine for
# a PG-only deployment — the mysql container never runs). The PostgreSQL
# credentials the app actually uses come from PGSQL_DB_* below.
mysql:
profiles: ["mysql"]

# PostgreSQL service (not present in the base compose file).
postgres:
image: postgres:16-alpine
container_name: mateclaw-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${PGSQL_DB_NAME:-mateclaw}
POSTGRES_USER: ${PGSQL_DB_USERNAME:-postgres}
POSTGRES_PASSWORD: ${PGSQL_DB_PASSWORD:?PGSQL_DB_PASSWORD is required in .env}
TZ: Asia/Shanghai
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${PGSQL_DB_USERNAME:-postgres} -d ${PGSQL_DB_NAME:-mateclaw}"]
interval: 10s
timeout: 5s
retries: 5

mateclaw-server:
depends_on:
# Drop the inherited mysql dependency (compose merges depends_on maps, so
# without !reset the base's `mysql: service_healthy` would survive and
# block startup since mysql no longer starts). Requires Compose v2.20+.
mysql: !reset null
postgres:
condition: service_healthy
searxng:
condition: service_healthy
environment:
SPRING_PROFILES_ACTIVE: postgres
DB_HOST: postgres
DB_PORT: 5432
# application-postgres.yml reads DB_NAME / DB_USERNAME / DB_PASSWORD.
# Align them with the postgres service's PGSQL_DB_* values so a single set
# of .env values drives both the server and the container.
DB_NAME: ${PGSQL_DB_NAME:-mateclaw}
DB_USERNAME: ${PGSQL_DB_USERNAME:-postgres}
DB_PASSWORD: ${PGSQL_DB_PASSWORD:?PGSQL_DB_PASSWORD is required in .env}

volumes:
postgres_data:
16 changes: 16 additions & 0 deletions mateclaw-server/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,22 @@
<scope>test</scope>
</dependency>

<!-- ===== Testcontainers: real PostgreSQL for the postgresql migration
tree integration tests (PostgresE2EBaseTest and subclasses).
Versions are managed by Spring Boot's testcontainers-bom. Tests
are skipped (not failed) where no Docker daemon is available via
@Testcontainers(disabledWithoutDocker = true). ===== -->
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>postgresql</artifactId>
<scope>test</scope>
</dependency>

<!-- ShedLock: distributed lock for the cron scheduler so a
multi-instance deployment doesn't fire the same job N times.
JDBC mode reuses the existing DataSource, so there is no Redis dependency
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import vip.mate.skill.runtime.SkillFrontmatterParser;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.HashMap;
Expand Down Expand Up @@ -208,14 +209,43 @@ private Map<String, String> collectFiles(Path dir) throws IOException {
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
if (attrs.isRegularFile() && attrs.size() < 1_000_000) { // 跳过超过 1MB 的文件
String relativePath = dir.relativize(file).toString();
files.put(relativePath, Files.readString(file));
byte[] bytes = Files.readAllBytes(file);
// Skill bundles are stored as text (mate_skill_file is a TEXT
// column). Skip binary entries — mirrors ZipSkillFetcher and,
// critically, keeps a NUL byte (which a binary carries and PG
// rejects in text columns) out of the bundle. NUL is valid
// UTF-8, so Files.readString would otherwise keep it verbatim
// and the insert would fail only on PostgreSQL.
if (isLikelyBinary(bytes)) {
log.warn("[GitSkillFetcher] Skipping binary file (not supported in skill bundles): {}", relativePath);
return FileVisitResult.CONTINUE;
}
files.put(relativePath, new String(bytes, StandardCharsets.UTF_8));
}
return FileVisitResult.CONTINUE;
}
});
return files;
}

/**
* Treat a file as binary if it contains a NUL byte (0x00) anywhere — the
* same cheap, reliable test git uses, and the one ZipSkillFetcher applies.
* Text never carries a NUL; binaries (and the rare text file with a stray
* NUL) do, and PostgreSQL rejects NUL in the {@code TEXT} content column.
*/
private static boolean isLikelyBinary(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return false;
}
for (byte b : bytes) {
if (b == 0x00) {
return true;
}
}
return false;
}

/**
* 从 GitHub URL 提取仓库名作为 skill 名称
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -310,19 +310,23 @@ private static ExtractedSkill extract(byte[] zipBytes, Charset charset) throws I

/**
* Heuristic binary detector: an entry is treated as binary if a NUL byte
* (0x00) appears within the inspected prefix. UTF-8 and GBK text never
* contain a NUL, while virtually every binary format (PNG/WOFF/ZIP/class/
* native executable) carries one near the start — this is the same cheap,
* reliable test git uses to decide "is this a text file". Inspecting only a
* prefix keeps it O(1) for large entries.
* (0x00) appears anywhere in it. UTF-8 and GBK text never contain a NUL,
* while virtually every binary format (PNG/WOFF/ZIP/class/native
* executable) carries one — this is the same cheap, reliable test git uses
* to decide "is this a text file".
*
* <p>The whole entry is scanned, not just a prefix: a NUL anywhere in the
* content is fatal for the PostgreSQL text contract ({@code mate_skill_file}
* is a {@code TEXT} column; PG rejects {@code 0x00}), so an otherwise-text
* file with a stray NUL past the first few KB must be caught here too. The
* per-entry cap is 1MB, so a full scan stays cheap.
*/
private static boolean isLikelyBinary(byte[] bytes) {
if (bytes == null || bytes.length == 0) {
return false;
}
int limit = Math.min(bytes.length, 8000);
for (int i = 0; i < limit; i++) {
if (bytes[i] == 0x00) {
for (byte b : bytes) {
if (b == 0x00) {
return true;
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ public ApplyResult applyBundleFiles(Long skillId, Map<String, String> newFiles,
LocalDateTime now = LocalDateTime.now();
for (var entry : incoming.entrySet()) {
String path = entry.getKey();
String content = entry.getValue() == null ? "" : entry.getValue();
String content = stripNul(entry.getValue() == null ? "" : entry.getValue(), path, skillId);
String hash = sha256Hex(content);
int size = content.getBytes(StandardCharsets.UTF_8).length;

Expand Down Expand Up @@ -163,6 +163,34 @@ public int deleteAllForSkill(Long skillId) {
return mapper.deleteBySkillId(skillId);
}

/**
* Strip NUL characters ({@code \u0000}) from bundle content before it is
* persisted.
*
* <p>This is the universal backstop for the PostgreSQL text contract:
* PG rejects {@code 0x00} in {@code text}/{@code jsonb} columns
* ("invalid byte sequence for encoding UTF8: 0x00"), whereas MySQL
* ({@code utf8mb4 TEXT}) and H2 ({@code CLOB}) silently tolerate it — so
* this corruption never surfaces on the default dev database and must be
* caught here. {@code applyBundleFiles} is the single write chokepoint for
* {@code mate_skill_file}, covering the Zip installer, the Git installer
* <em>and</em> the disk→DB reverse flow in {@code SkillFileSyncer}, so the
* guard belongs here rather than at any one fetch boundary.
*
* <p>The fetchers reject genuinely-binary entries up front (see
* {@code ZipSkillFetcher.isLikelyBinary} / the equivalent in
* {@code GitSkillFetcher}); this only ever fires for otherwise-text content
* that carries a stray NUL, so stripping (rather than aborting the whole
* install) is the least-surprising recovery.
*/
private static String stripNul(String content, String path, Long skillId) {
if (content == null || content.indexOf('\0') < 0) {
return content;
}
log.warn("Stripping NUL byte(s) from skill file content before persist: skill_id={} path={}", skillId, path);
return content.replace("\u0000", "");
}

private boolean bucketHasEntries(Map<String, String> files, String prefix) {
for (String key : files.keySet()) {
if (key != null && key.startsWith(prefix)) return true;
Expand Down
3 changes: 3 additions & 0 deletions mateclaw-server/src/main/resources/application-kingbase.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ mybatis-plus:
mate:
wiki:
require-allowed-roots: true
allowed-source-roots: ${MATE_WIKI_ALLOWED_SOURCE_ROOTS:}
watcher-enabled: ${MATE_WIKI_WATCHER_ENABLED:false}
watcher-interval-ms: ${MATE_WIKI_WATCHER_INTERVAL_MS:300000}

# Production hardening: lock down the Swagger UI / OpenAPI document so it is not
# anonymously browsable. Requires a global admin (ROLE_ADMIN); SecurityConfig
Expand Down
25 changes: 20 additions & 5 deletions mateclaw-server/src/main/resources/application-postgres.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,17 @@ spring:
# connectTimeout=10 — TCP connect timeout (s), avoids OS-level stalls
# socketTimeout=30 — socket read timeout (s), prevents dead connections hanging forever
# loginTimeout=10 — database login timeout (s)
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10
#
# stringtype=unspecified — REQUIRED for the JSONB columns in the postgresql
# migration tree. MyBatis / the JDBC driver bind JSON column values as
# java.lang.String via setString, which PostgreSQL types as `varchar`. Writing
# a varchar into a `jsonb` column otherwise fails with "column is of type jsonb
# but expression is of type character varying". With stringtype=unspecified the
# driver sends String params as `unknown`, letting PostgreSQL coerce them to
# jsonb (and validate the JSON) at write time. This covers both the
# JacksonTypeHandler path (CronJobEntity.deliveryConfig) and the plain String
# JSON columns (config_json / settings_json / headers_json / ...).
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10&stringtype=unspecified
driver-class-name: org.postgresql.Driver
username: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
Expand All @@ -29,13 +39,18 @@ spring:
connection-init-sql: SET search_path TO mateclaw

flyway:
# PostgreSQL and KingbaseES share the same migration tree
# (db/migration/kingbase) because they use the same SQL dialect.
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10
# PostgreSQL has its own migration tree (db/migration/postgresql),
# forked from the KingbaseES tree (which shares PostgreSQL's SQL dialect).
# The fork lets PostgreSQL-only optimizations (e.g. TEXT -> JSONB) diverge
# from KingbaseES without affecting Kingbase deployments. At fork time the
# two trees are byte-identical, so switching locations is transparent for
# existing PostgreSQL deployments: Flyway tracks version + checksum, not
# the classpath location, and identical scripts keep identical checksums.
url: jdbc:postgresql://${DB_HOST:localhost}:${DB_PORT:5432}/${DB_NAME:mateclaw}?currentSchema=mateclaw&connectTimeout=10&socketTimeout=30&loginTimeout=10&stringtype=unspecified
user: ${DB_USERNAME:postgres}
password: ${DB_PASSWORD:postgres}
locations:
- classpath:db/migration/kingbase
- classpath:db/migration/postgresql
# Ensure the target schema exists before migrating (PostgreSQL won't
# auto-create a non-public schema).
init-sqls:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- V100: System-level defaults for vision and video sidecar routing.
-- When the agent's primary model lacks the modality required by an attachment,
-- the runtime delegates a single caption call to the model recorded here.
-- Empty value = not configured; the UI then asks the user to pick one.
-- Setting value stores mate_model_config.id as a string (provider+model_name pairs are not unique).
INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
VALUES (1000002001, 'default.vision_model', '',
'Default vision-capable model id (mate_model_config.id) used by sidecar router when primary model lacks VISION modality',
NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET setting_key = EXCLUDED.setting_key;

INSERT INTO mate_system_setting (id, setting_key, setting_value, description, create_time, update_time)
VALUES (1000002002, 'default.video_model', '',
'Default video-capable model id (mate_model_config.id) used by sidecar router when primary model lacks VIDEO modality',
NOW(), NOW())
ON CONFLICT (id) DO UPDATE SET setting_key = EXCLUDED.setting_key;
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
-- See the matching H2 file for context. This migration purges any
-- orphan rows that earlier releases persisted with a blank rule_id and
-- then installs a CHECK constraint so the schema itself rejects blank
-- rule_id, defending against any future code path that bypasses the
-- service-layer guard. CHECK constraints are enforced from MySQL 8.0.16
-- onward; this project targets MySQL 8.0+ so the constraint is live.

DELETE FROM mate_tool_guard_rule
WHERE (rule_id IS NULL OR LENGTH(TRIM(rule_id)) = 0)
AND (builtin IS NULL OR builtin = FALSE);

ALTER TABLE mate_tool_guard_rule
ADD CONSTRAINT ck_tool_guard_rule_id_nonblank
CHECK (rule_id IS NOT NULL AND LENGTH(TRIM(rule_id)) > 0);
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
-- Enforce unique Agent name within a workspace. See H2 variant for context.
--
-- Step 1 — rename pre-existing duplicates. PostgreSQL uses UPDATE ... FROM
-- instead of MySQL's UPDATE ... JOIN syntax, and || instead of CONCAT.
-- md5(random()::text) gives a unique suffix and is portable across both
-- PostgreSQL and KingbaseES (avoids the Kingbase-only SYS_GUID()).
UPDATE mate_agent t
SET name = '__mate_dup_v102__' || t.id || '__' || md5(random()::text)
FROM (
SELECT workspace_id, name, MIN(id) AS keep_id
FROM mate_agent
GROUP BY workspace_id, name
HAVING COUNT(*) > 1
) k
WHERE t.workspace_id = k.workspace_id
AND t.name = k.name
AND t.id <> k.keep_id;

-- Step 2 — add the unique index, idempotent via IF NOT EXISTS (PostgreSQL-native).
CREATE UNIQUE INDEX IF NOT EXISTS uk_agent_workspace_name ON mate_agent (workspace_id, name);
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Drop the dead mate_fact_entity_ref table. See H2 variant for context.
DROP TABLE IF EXISTS mate_fact_entity_ref;
Loading