feat(security,device): security 成为独立内核模块(闸门+策略+限式),device 接管内置工具路由

security(新模块 internal/kernelmod/security)
- 凭据闸门:出向拦截成 @{别名}、入向清洗、执行前回填(internal/security/{gate,runtime}.go)
- 工具黑白名单 + 沙箱(读写根、网络、.. 逃逸):internal/security/policy.go
- 敏感词过滤 + 提示词注入预警(Finding 不带原文):internal/security/content.go
- 按主体授权(hard-private 对非内部主体永久拒绝):internal/security/principal.go
- 插件命名限式(可选档位,插件声明/内核执行,只能收窄):internal/security/profile.go
- 启动装配从 cmd/homed 迁入模块;启动顺序判据改盯真实装配点

device(新模块)
- 正式内核模块,Provides FacDevice,KernelState.Device
- 内置工具路由表下沉到 internal/toolroute(避免 core↔kernelboot 环),
  device 保留对外入口;agent 未挂载时退化本地 switch
- Agent 侧只留 api.ToolHost/DeviceSurface 适配层

SDK
- InjectOptions.SecurityProfile:插件按 inputch 声明本轮安全档位(可选)
- InjectOptions.TurnHint:插件注入**本轮事实**,内核以独立 system 消息注入,
  不混进用户正文(人设负责怎么说话,TurnHint 只放这一轮成立的事实)
- FacilitySecurity / FacilityDevice

提示词
- 内核自带 prompt/skill/工具返回全面英文化(中文提示会让模型行为漂移)
- 机制解释下沉到 4 个内置 skill(memory/output/tool-order/plugins),
  skill 描述压到 80 runes 内(原来 94–108 全被索引截断)
- 默认人格模板英文化;[Voice] 段删除,规则并入 persona 块(单一段落)
- 历史记忆工具改名 coldcontext_query/commit,图记忆语义改称 stable memory

修复
- tracker: exec mount → unix.Mount(降权运行 + AmbientCapabilities 下才能挂)、
  区分 EPERM 与不支持;启动日志说明后果与恢复方式
- healthcheck_kernel 输出瘦身:status.Tools 只给摘要(原 98,989 tokens/151% 预算)
- plugin_list 描述讲清 auto_start(按需)与 alive(此刻是否在跑),
  明确不能由 alive=false 推断能力不存在
- pluginmgr/webui 去掉写死的 /home/newqqagent 回退路径(env → pluginDir 反推 → 报错)
- lua: 盘上适配器与内置版本不一致且无基线时响亮告警(deepseek 流式丢失
  tool_calls 导致'模型不调工具'的真因)

测试:go build ./... 通过;go test ./... 60 包全绿
This commit is contained in:
JianFeeeee
2026-10-26 20:55:25 +08:00
parent ca956f69bf
commit 55fa2685ae
92 changed files with 3932 additions and 596 deletions

View File

@ -852,7 +852,7 @@ func isSafeReadonlyTool(name string) bool {
readonlyExact := map[string]bool{
"memory_recall": true,
"memory_introspect": true,
"doc_query": true,
"coldcontext_query": true,
"knowledge_search": true,
"knowledge_list": true,
"person_query": true,

View File

@ -398,7 +398,7 @@ func TestLLMReportCollection(t *testing.T) {
func TestSafeReadonlyTool(t *testing.T) {
// 只读:应放行
readonly := []string{
"memory_recall", "memory_introspect", "doc_query",
"memory_recall", "memory_introspect", "coldcontext_query",
"knowledge_search", "knowledge_list", "person_query",
"person_network", "llm_list_sources", "output_list_channels",
"terminal_list", "files_list",
@ -414,7 +414,7 @@ func TestSafeReadonlyTool(t *testing.T) {
"memory_commit", "memory_edit", "memory_purge", "memory_delete_entity",
"memory_merge", "memory_block_merge",
"knowledge_create", "knowledge_delete",
"doc_commit", "doc_delete",
"coldcontext_commit", "doc_delete",
"person_set_trait", "person_relate",
"output_send__qq", "output_send__cli",
"llm_set_source", "config_set",
@ -438,8 +438,8 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"},
{Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "knowledge_create", Plugin: "knowledge", Description: "create"},
{Name: "cmd_run", Plugin: "cmd", Description: "run cmd"},
@ -465,13 +465,13 @@ func TestCollectToolDefsForLLMNoMutating(t *testing.T) {
}
// 只读工具应被包含
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_list"} {
for _, name := range []string{"memory_recall", "coldcontext_query", "knowledge_search", "files_list"} {
if !allowed[name] {
t.Errorf("expected readonly tool %q in LLM selftest set, missing", name)
}
}
// 写类/副作用工具绝不能被交给 LLM
for _, name := range []string{"memory_commit", "doc_commit", "knowledge_create", "cmd_run"} {
for _, name := range []string{"memory_commit", "coldcontext_commit", "knowledge_create", "cmd_run"} {
if allowed[name] {
t.Errorf("mutating tool %q must NOT be in LLM selftest set", name)
}
@ -486,8 +486,8 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
defs := []sdk.ToolDef{
{Name: "memory_recall", Plugin: "memory", Description: "recall"},
{Name: "memory_commit", Plugin: "memory", Description: "commit"},
{Name: "doc_query", Plugin: "doc", Description: "query"},
{Name: "doc_commit", Plugin: "doc", Description: "commit doc"},
{Name: "coldcontext_query", Plugin: "coldcontext", Description: "query"},
{Name: "coldcontext_commit", Plugin: "coldcontext", Description: "commit doc"},
{Name: "knowledge_search", Plugin: "knowledge", Description: "search"},
{Name: "files_list", Plugin: "files", Description: "list"},
{Name: "files_write", Plugin: "files", Description: "write"},
@ -514,7 +514,7 @@ func TestCollectToolDefsForLLMPluginFilter(t *testing.T) {
if len(got) != 1 || !allowed["files_list"] {
t.Fatalf("expected only files_list for files plugin, got %v", allowed)
}
for _, name := range []string{"memory_recall", "doc_query", "knowledge_search", "files_write"} {
for _, name := range []string{"memory_recall", "coldcontext_query", "knowledge_search", "files_write"} {
if allowed[name] {
t.Errorf("tool %q must NOT be in files-filtered set", name)
}
@ -537,7 +537,7 @@ func TestReportsToChecks(t *testing.T) {
p := &Plugin{}
p.reports = []llmReport{
{ToolName: "qq_get_message", Status: "ok", Detail: "正常"},
{ToolName: "doc_query", Status: "skip", Detail: "无可测数据"},
{ToolName: "coldcontext_query", Status: "skip", Detail: "无可测数据"},
{ToolName: "knowledge_search", Status: "fail", Detail: "搜索超时"},
}
checks := p.reportsToChecks()
@ -551,7 +551,7 @@ func TestReportsToChecks(t *testing.T) {
if !byName["llm_tool/qq_get_message"].Pass {
t.Error("ok report must pass")
}
if !byName["llm_tool/doc_query"].Pass {
if !byName["llm_tool/coldcontext_query"].Pass {
t.Error("skip report must pass")
}
if byName["llm_tool/knowledge_search"].Pass {

View File

@ -151,12 +151,25 @@ func kernelDataDir(p *Plugin) string {
// 算成 /home —— pending 标记落到 /home/kernel-upgrade-pending.json,
// 而第 0 层 update 模块从 ks.DataDir 读 ⇒ **永远读不到标记** ⇒
// agent 发起的升级一旦启动失败就**不会回滚**(静默失效)。
// ★ 不再写死生产路径(2026-10-26 迁移教训):
// 硬编码 /home/newqqagent 在目录/用户迁移后会让 pending 标记落到一个
// **已经不存在的目录**,而第 0 层 update 从 ks.DataDir 读 ⇒ 永远读不到
// ⇒ agent 发起的升级失败时静默不回滚。
//
// 优先级:显式环境变量(部署方声明自己在哪)→ 从 pluginDir 反推 → 空。
// 空表示"不知道",由调用方拒绝执行并报错,而不是猜一个路径。
if d := os.Getenv(dataDirEnv); d != "" {
return d
}
if p.pluginDir != "" {
return filepath.Dir(p.pluginDir)
}
return "/home/newqqagent"
return ""
}
// dataDirEnv 是部署方声明数据目录的环境变量(与 webui 侧同名)。
const dataDirEnv = "HOMEAGENT_DATA_DIR"
// currentKernelVersion 读本机 homed 自报版本(尽力而为)。
func currentKernelVersion(p *Plugin) *update.BinVersion {
v, err := update.ReadBinVersion(kernelBinPath(p))

View File

@ -243,8 +243,20 @@ func (p *Plugin) registerTools(s *sdk.PluginSDK) {
})
s.RegisterTool("plugin_list", sdk.ToolDef{
Name: "plugin_list",
Description: "列出已安装的所有外部插件及其版本。同时返回运行状态(loaded/alive/pid/崩溃次数),子进程插件死了在此体现为 alive=false。",
Name: "plugin_list",
Description: "List every installed plugin with its version and runtime state.\n\n" +
"**Read the lifecycle flags correctly — they are not the same thing:**\n" +
"- `auto_start`: whether the plugin boots with the kernel. **false = on-demand**, " +
"i.e. installed and available but deliberately NOT running until someone needs it.\n" +
"- `loaded` / `alive`: whether it is running **right now**. " +
"`alive=false` on an `auto_start=false` plugin is the **normal idle state**, not an error.\n" +
"- `disabled`: persisted switch turned off. Only this one means \"not allowed\".\n" +
"- `crash_count` / `runtime_stopped`: it was running and died.\n\n" +
"**So: never conclude \"this capability does not exist\" from alive=false.** " +
"An on-demand plugin's tools appear only after it starts — call `plugin_start` " +
"(start now, keeps auto_start setting) or `plugin_enable` (start now AND enable at boot) " +
"and its tools become callable. Use `get_plugin_tools(\"<name>\")` first to see what it offers.\n\n" +
"`plugin_stop` stops without changing the boot setting; `plugin_disable` is the persistent off switch.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{},

View File

@ -85,8 +85,8 @@ func (f *fakePluginMgr) StartPlugin(name string) error {
f.starts = append(f.starts, name)
return nil
}
func (f *fakePluginMgr) IsPluginRuntimeStopped(string) bool { return false }
func (f *fakePluginMgr) RemovePlugin(string) error { return nil }
func (f *fakePluginMgr) IsPluginRuntimeStopped(string) bool { return false }
func (f *fakePluginMgr) RemovePlugin(string) error { return nil }
func (f *fakePluginMgr) StopAndUnload(name string) error {
f.mu.Lock()
defer f.mu.Unlock()

View File

@ -33,7 +33,7 @@
// seemedia 经 SetToolBlocks 注入的块,内核在 task.go `captureBlockMedia` 处**无条件**
// 落进 CAS 并转成一等记忆块挂到本轮 ContextEvent(「无论直视还是回退转写,媒体
// 本体都该进记忆」)。所以记忆载体块化之后,一手看到的图/帧**同时**进了记忆,
// 之后能按多模态向量被 doc_query / memory_recall 召回。本插件不写任何入库代码。
// 之后能按多模态向量被 coldcontext_query / memory_recall 召回。本插件不写任何入库代码。
//
// ★ 但有一个边界:`captureBlockMedia` 只对 **data URL** 做 CAS 入库,http(s) URL
// 会被跳过(不下载、不入记忆、不算向量——避免下载引入超时/SSRF)。因此:

View File

@ -19,7 +19,7 @@ package seq
// skill 会被 skillmgr 汇总成索引注入系统提示词(buildSystemPrompt 的 skillIndex 段):
// 模型**每轮**都能看到"有这么个能力"的一行摘要。只写在 ToolDef.Description 里,
// 模型不主动翻工具列表就看不到——这正是此前 seq 几乎不被使用的原因
// (与 doc_query 的历史教训同构:内核从没解释过 L1/L2/L3,agent 就只能靠猜)。
// (与 coldcontext_query 的历史教训同构:内核从没解释过 L1/L2/L3,agent 就只能靠猜)。
const builtinSkillName = "seq-tools"

View File

@ -0,0 +1,88 @@
package skillmgr
// 内核自带的 skill:把**技术机制**写进 skill,由 skillmgr 注入,而不是塞进
// 系统提示词。
//
// ## 为什么(用户 2026-10-26 的两次裁定)
//
// 1. 「系统提示词把机制讲得太深太技术化,导致 agent 说话没人味」——
// 机制留在提示词里,模型会连**语气**一起模仿:回复变成说明书腔。
// 2. 「技术相关下沉到 skill,由 skillmgr 自行管理注入」——
// 提示词只留"必须做什么","怎么实现"由 skill 讲。
//
// 所以提示词里只剩一两句行为约束,机制在下面这四个 skill 正文里:
//
// kernel-memory 记忆分层与 coldcontext_query 的回填语义
// kernel-output 同步/异步通道怎么真正送达用户
// kernel-tool-order 一次回复里多工具调用的调度与例外
// kernel-plugins 按需插件怎么发现与拉起
//
// ## 为什么内嵌在二进制里,而不是让用户自己装
//
// 它们是**内核行为**的一部分:提示词引用了它们,就必须保证它们一定存在。
// 若依赖外部安装,用户删掉/改名后提示词会指向不存在的 skill —— 那比不写更糟。
//
// ## 落盘纪律:不覆盖用户版本
//
// 只在目标目录**不存在**时写入(与 adapters 的 `.bundled` 语义一致)。
// 用户改过的同名 skill 一律保留:内核不该覆盖人的修改。
import (
"embed"
"io/fs"
"log"
"os"
"path/filepath"
)
//go:embed builtin
var builtinSkills embed.FS
// builtinSkillDir 是内嵌 skill 的根目录名。
const builtinSkillDir = "builtin"
// skillFileName 与 plugin.LoadSKILL 认的文件名一致。
const skillFileName = "SKILL.md"
// materializeBuiltinSkills 把内嵌 skill 落到 skillsDir,返回新写入的数量。
//
// 已存在的目录**不覆盖**:那是用户可能改过的版本。返回 0 表示无需写入。
func materializeBuiltinSkills(skillsDir string) int {
if skillsDir == "" {
return 0
}
entries, err := builtinSkills.ReadDir(builtinSkillDir)
if err != nil {
log.Printf("[skillmgr] read embedded skills: %v", err)
return 0
}
written := 0
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
dstDir := filepath.Join(skillsDir, name)
if _, statErr := os.Stat(dstDir); statErr == nil {
continue // 已存在(用户版本或上次落盘):不覆盖
}
data, readErr := fs.ReadFile(builtinSkills, builtinSkillDir+"/"+name+"/"+skillFileName)
if readErr != nil {
log.Printf("[skillmgr] embedded skill %s: %v", name, readErr)
continue
}
if mkErr := os.MkdirAll(dstDir, 0755); mkErr != nil {
log.Printf("[skillmgr] create %s: %v", dstDir, mkErr)
continue
}
if wErr := os.WriteFile(filepath.Join(dstDir, skillFileName), data, 0644); wErr != nil {
log.Printf("[skillmgr] write %s: %v", dstDir, wErr)
continue
}
written++
}
if written > 0 {
log.Printf("[skillmgr] materialized %d kernel skills (mechanics live here, not in the system prompt)", written)
}
return written
}

View File

@ -0,0 +1,37 @@
---
name: kernel-memory
description: Where past turns and facts live; read before recalling old context.
version: 1.0.0
---
# Memory layers
Four stores, one rule each:
- **Conversation timeline (L1)** - recent turns. Already in your context; just read it.
- **History (L2)** - older turns that left the window. Their full text still exists.
- **Graph memory (L3)** - facts and relations: who is what, the current value of something.
- **Knowledge** - written, reusable documents.
## Retrieving
| Need | Tool |
|---|---|
| A past episode in full | `coldcontext_query` |
| A fact or relation | `memory_recall` |
| Written reference material | `knowledge_search` |
### `coldcontext_query` semantics (read this before using it)
It does **not** return a summary you can reason from directly. It **re-inserts the matched
events into your conversation timeline at their original timestamps**. After the call,
read that section of the timeline; the return value only lists time / source / opening line.
Retrieval is **consuming**: a document served from L2 is removed from L2. If you need it
again later, it lives on in the timeline until pruning moves it back.
## Writing
- Facts worth keeping: `memory_commit` (triples).
- Written knowledge: `knowledge_write`.
- Do not commit transient chatter, tool output, or your own status lines.

View File

@ -0,0 +1,33 @@
---
name: kernel-output
description: Async channels need an explicit output_send call; text alone is not delivered.
version: 1.0.0
---
# Delivering output
Plain return text is **not** delivered on async channels. It is only visible on sync channels.
| Channel | How the user sees it |
|---|---|
| webui / cli / terminal (sync) | Return plain text. The kernel hands it to the caller. |
| qq / wechat / groups (async) | Must call `output_send__{channel}`. |
## Calling `output_send__{channel}`
- `payload` is the message text.
- `meta` carries the routing fields: `user_id` for private chats, `group_id` for groups.
- Several messages in one call: pass `payload` as an array; `type` / `meta` accept arrays
positionally (a single value applies to every item).
- Unsure about a channel's meta shape or type enum: call `output_send__{channel}_help` first.
## Channel identification
Never assume one fixed channel: a conversation can span devices, channels, and sub-tasks.
Read the incoming message and its source. If unclear, `output_list_channels`.
## Amount and ordering
- You decide how many messages and which channels. There is no "one output per turn" limit.
- Repeated sends to the same channel keep their order.
- Keep each message whole; do not resend identical content.

View File

@ -0,0 +1,17 @@
---
name: kernel-plugins
description: Tools are per-plugin; on-demand plugins need plugin_start before their tools.
version: 1.0.0
---
# Plugin capabilities
Tools are grouped per plugin and registered at runtime.
- `get_plugin_tools("{plugin}")` - full tool definitions (name / parameters / purpose).
- A tool missing from your list does **not** mean the capability is absent: some plugins are
**on-demand** and only start when asked.
- `plugin_start("{plugin}")` starts an on-demand plugin; its tools appear afterwards.
- `plugin_status` shows what is running; `plugin_restart` / `plugin_stop` manage lifecycle.
- The matching skill (same name as the plugin, or a related one) states whether the plugin is
resident or on-demand, and how to use it once started.

View File

@ -0,0 +1,22 @@
---
name: kernel-tool-order
description: Tool calls in one reply run in parallel; never depend on their order.
version: 1.0.0
---
# Tool call scheduling
Tool calls in one assistant reply run **in parallel** by default.
## Consequences
- **Never depend on order.** If call B needs call A's result, split them into two turns.
- Outputs to the **same channel keep their order**, so "acknowledge, then conclude" can share
one turn safely.
- Write tools (memory / knowledge / document writes) and tools that do not declare
**parallel-safe** fall back to sequential execution within that batch. This only costs time;
results are unaffected, and you do not need to change how you write the calls.
## Reading a single result
Each call returns its own result. Never infer one call's outcome from another's.

View File

@ -0,0 +1,60 @@
package skillmgr
// 内核自带 skill 的**描述必须能整条进索引**。
//
// ## 为什么这是判据而不是"注意一下"
//
// `SkillIndex()` 每行截断到 80 runes(见 truncateDesc)。内置 skill 的
// 描述一旦超过 80,尾巴会被切掉 —— 而尾巴恰好是"什么时候该读它",
// 于是模型只看到半句机制名,不知道何时适用,也就不会去 skill_info。
//
// 实测(2026-10-26):四条内置描述分别是 94/96/106/108 字符,
// **全部**被截断。这正是用户说的"要巧妙切分 skill 文本"——
// 索引行要自带"机制 + 最关键那条规则 + 何时展开"。
import (
"os"
"path/filepath"
"strings"
"testing"
)
// maxIndexRunes 与 truncateDesc 的上限保持一致(改了这里也必须改那里)。
const maxIndexRunes = 80
func TestBuiltinSkillDescriptionsFitSkillIndex(t *testing.T) {
dirs, err := os.ReadDir(builtinSkillDir)
if err != nil {
t.Fatalf("读不到内置 skill 目录 %s: %v", builtinSkillDir, err)
}
checked := 0
for _, d := range dirs {
if !d.IsDir() {
continue
}
path := filepath.Join(builtinSkillDir, d.Name(), skillFileName)
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("读不到 %s: %v", path, err)
}
var desc string
for _, ln := range strings.Split(string(b), "\n") {
if strings.HasPrefix(ln, "description:") {
desc = strings.TrimSpace(strings.TrimPrefix(ln, "description:"))
break
}
}
if desc == "" {
t.Fatalf("%s 没有 description(索引行会是空的,模型看不到它能干什么)", path)
}
if n := len([]rune(desc)); n > maxIndexRunes {
t.Errorf("★ %s 的 description 有 %d 字符(上限 %d)—— "+
"进索引会被截断,尾巴(何时该读)会丢:\n %q",
d.Name(), n, maxIndexRunes, desc)
}
checked++
}
if checked == 0 {
t.Fatal("没有检查到任何内置 skill —— 目录结构变了?")
}
}

View File

@ -86,6 +86,10 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
}
}
// 内核自带 skill 先落盘(不覆盖用户版本),再由下面的扫描统一加载。
// 机制类内容住在 skill 里,系统提示词只引用、不复述(见 builtin.go 注释)。
materializeBuiltinSkills(p.skillsDir)
if err := os.MkdirAll(p.skillsDir, 0755); err != nil {
return fmt.Errorf("mkdir skills dir: %w", err)
}
@ -257,31 +261,15 @@ func (p *Plugin) SkillIndex() string {
}
desc := e.sk.Description()
if desc == "" || desc == "---" {
desc = "(无描述)"
desc = "(no description)"
}
// ★ 截断必须按**字符**(rune)而不是字节。
//
// 原实现 `desc[:80]` 按字节切,而中文占 3 字节 ⇒ 切点常落在
// 多字节字符中间,产生**非法 UTF-8**。后果实测:
//
// seq-tools v - 把反复出现的同类工具操作编成「序列」存起来,以后一次<E4B880>
//
// 模型看到的是乱码尾部,且"什么时候该用"那部分整段丢失 ——
// 于是它根本不知道这个 skill 何时适用(e2e 实测:该用 seq 的
// 多对象重复场景,模型改去调 file-tools)。
//
// 本仓 25 个 skill 里有 8 个(32%)踩过这个截断。
// 判据见 TestSkillIndexTruncatesByRune。
desc = truncateDesc(desc)
desc = p.truncateDesc(desc)
fmt.Fprintf(&b, "%s v%s - %s\n", e.sk.Name(), e.sk.Version(), desc)
}
return strings.TrimRight(b.String(), "\n")
}
// truncateDesc 把 skill 描述截断到 80 个**字符**(rune),超出加省略号。
//
// 必须是字符而不是字节:中文占 3 字节,按字节切会落在多字节字符中间,
// 产生非法 UTF-8。见 TestSkillIndexTruncatesByRune 的真实缺陷说明。
// truncateDesc truncates a skill description to 80 runes and appends an ellipsis.
func truncateDesc(desc string) string {
const maxRunes = 80
r := []rune(desc)
@ -290,3 +278,31 @@ func truncateDesc(desc string) string {
}
return string(r[:maxRunes]) + "..."
}
// SkillDescriptions returns the full one-line descriptions of enabled skills.
// 供 agent 在有限的提示词空间里准确匹配技能;完整机制与操作步骤仍由 skill_info 按需读取。
// 描述应包括:适用问题/时机 + 该机制的关键区别,避免仅用含糊的目录名。
func (p *Plugin) SkillDescriptions() string {
entries := p.snapshot()
var b strings.Builder
for _, e := range entries {
if !e.enabled {
continue
}
desc := strings.TrimSpace(e.sk.Description())
if desc == "" || desc == "---" {
continue
}
fmt.Fprintf(&b, "%s: %s\n", e.sk.Name(), desc)
}
return strings.TrimRight(b.String(), "\n")
}
func (p *Plugin) truncateDesc(desc string) string {
const maxRunes = 80
r := []rune(desc)
if len(r) <= maxRunes {
return desc
}
return string(r[:maxRunes]) + "..."
}

View File

@ -355,8 +355,13 @@ func TestPackTarSlipRejected(t *testing.T) {
func TestSkillIndex(t *testing.T) {
p, spy, _ := newTestPlugin(t)
if idx := p.SkillIndex(); idx != "" {
t.Fatalf("empty registry should give empty index, got %q", idx)
// The plugin now materializes the four kernel skills before scanning. They are
// part of the built-in skill index, so an empty user registry is not empty.
idx := p.SkillIndex()
for _, want := range []string{"kernel-memory", "kernel-output", "kernel-plugins", "kernel-tool-order"} {
if !strings.Contains(idx, want) {
t.Fatalf("built-in skill missing from index: %q", idx)
}
}
if _, err := spy.call(t, "skill_create", map[string]interface{}{
@ -365,7 +370,7 @@ func TestSkillIndex(t *testing.T) {
}); err != nil {
t.Fatal(err)
}
idx := p.SkillIndex()
idx = p.SkillIndex()
if !strings.Contains(idx, "idx-test v0.2.0") || !strings.Contains(idx, "用于测试的演示技能。") {
t.Fatalf("index missing name/desc: %q", idx)
}

View File

@ -66,10 +66,10 @@ func (h *Handler) handleDataDirStatus(w http.ResponseWriter, r *http.Request) {
ConfigDataDir: configDir,
}
out := map[string]interface{}{
"runtime_dir": runtimeDir,
"config_dir": configDir,
"in_sync": st.InSync(),
"describe": st.Describe(),
"runtime_dir": runtimeDir,
"config_dir": configDir,
"in_sync": st.InSync(),
"describe": st.Describe(),
"under_systemd": systemd.IsUnderSystemd(),
}
if !st.InSync() {

View File

@ -177,16 +177,16 @@ type Handler struct {
func NewHandler(s *sdk.PluginSDK) *Handler {
var (
sup sdk.SupervisorAPI
mem sdk.MemoryAPI
idx sdk.IndexerAPI
ad sdk.AdapterAPI
cfg sdk.ConfigAPI
tm sdk.TextMemoryAPI
ks sdk.KnowledgeAPI
tr sdk.TrackerAPI
se sdk.SettingsAPI
pm sdk.PluginManager
sup sdk.SupervisorAPI
mem sdk.MemoryAPI
idx sdk.IndexerAPI
ad sdk.AdapterAPI
cfg sdk.ConfigAPI
tm sdk.TextMemoryAPI
ks sdk.KnowledgeAPI
tr sdk.TrackerAPI
se sdk.SettingsAPI
pm sdk.PluginManager
st sdk.StatusAPI
llm sdk.LLMAPI
// kc 是内核重启/关机通道;nil 表示该部署没接(端点会明确报不可用)。

View File

@ -167,6 +167,15 @@ func TestEveryPublishedEventHasAnOutwardPath(t *testing.T) {
// (如 seq 的开机序列)用。它是**内核内部**的就绪信号,
// 不是对话流程事件,前端无需感知。
"tools_ready": "内核就绪信号(plugin-host → 订阅者),非对话流程",
// security_ready / device_ready 同属**内核就绪信号**:订阅者是内核侧
// (状态面、healthcheck),前端不需要"装配完成"这一刻的时间点事件。
//
// ★ 但「安全审查是否真的在生效」是用户该知道的事实 —— 那通过
// **状态查询**(status API 读 ks.Security.RuntimeGlobal().Enabled())
// 暴露,而不是靠一条瞬时 SSE 事件(错过就永远不知道)。
// 判据见 internal/security/gate_test.go 的 TestEnabledRuntimeReportsEnabled。
"security_ready": "内核就绪信号(security 模块装配完成),事实由状态查询暴露",
"device_ready": "内核就绪信号(device 数据面装配完成),事实由状态查询暴露",
// delta 已由 appendDeltaSub 覆盖(见 sseSubscribedTypes ②)
"reasoning_delta": "由 appendDeltaSub 订阅",
"content_delta": "由 appendDeltaSub 订阅",

View File

@ -148,5 +148,7 @@ func dataDirOf(binPath string) string {
if d := os.Getenv("HOMEAGENT_DATA_DIR"); d != "" {
return d
}
return "/home/newqqagent"
// ★ 不写死生产路径:迁移后它会指向一个不存在的目录,
// 让升级 pending 标记落错地方(静默不回滚)。返回空由调用方报错。
return ""
}