Просмотр исходного кода

支持隔离 Bot 与 Profile 共享并沉淀技能

Samien 1 месяц назад
Родитель
Сommit
43f2d1e862

+ 1 - 0
deploy/computer/Dockerfile

@@ -96,5 +96,6 @@ RUN chmod 0755 /usr/local/bin/prepare-yaoyao-firefox.sh \
     && sed -i '2i /usr/local/bin/prepare-yaoyao-firefox.sh || exit 1' /usr/local/bin/start-openmausbot-cua-driver.sh
     && sed -i '2i /usr/local/bin/prepare-yaoyao-firefox.sh || exit 1' /usr/local/bin/start-openmausbot-cua-driver.sh
 
 
 COPY compose_bridge.py /usr/local/libexec/yaoyao/compose_bridge.py
 COPY compose_bridge.py /usr/local/libexec/yaoyao/compose_bridge.py
+COPY skill_bundle.py /usr/local/libexec/yaoyao/skill_bundle.py
 RUN printf '\n[program:yaoyao-compose-desktop]\ncommand=python3 /usr/local/libexec/yaoyao/compose_bridge.py\nuser=root\nautorestart=unexpected\nexitcodes=0\nstartsecs=0\nstdout_logfile=/var/log/supervisor/yaoyao-compose.log\nstderr_logfile=/var/log/supervisor/yaoyao-compose.error.log\npriority=40\n' >> /etc/supervisor/supervisord.conf
 RUN printf '\n[program:yaoyao-compose-desktop]\ncommand=python3 /usr/local/libexec/yaoyao/compose_bridge.py\nuser=root\nautorestart=unexpected\nexitcodes=0\nstartsecs=0\nstdout_logfile=/var/log/supervisor/yaoyao-compose.log\nstderr_logfile=/var/log/supervisor/yaoyao-compose.error.log\npriority=40\n' >> /etc/supervisor/supervisord.conf
 LABEL cn.samien.yaoyao.compose-desktop="1"
 LABEL cn.samien.yaoyao.compose-desktop="1"

+ 1 - 0
deploy/computer/Dockerfile.cursor

@@ -15,6 +15,7 @@ RUN set -eux; \
     install -d -o cua -g cua -m 0700 /home/cua /home/cua/workspace /run/user/1000; \
     install -d -o cua -g cua -m 0700 /home/cua /home/cua/workspace /run/user/1000; \
     install -d -m 0755 /run/yaoyao-private
     install -d -m 0755 /run/yaoyao-private
 COPY compose_bridge.py /usr/local/libexec/yaoyao/compose_bridge.py
 COPY compose_bridge.py /usr/local/libexec/yaoyao/compose_bridge.py
+COPY skill_bundle.py /usr/local/libexec/yaoyao/skill_bundle.py
 COPY cursor-desktop.py /usr/local/libexec/yaoyao/cursor-desktop.py
 COPY cursor-desktop.py /usr/local/libexec/yaoyao/cursor-desktop.py
 COPY cursor-browser.sh /usr/local/bin/yaoyao-browser
 COPY cursor-browser.sh /usr/local/bin/yaoyao-browser
 RUN chmod 0755 /usr/local/bin/yaoyao-browser; \
 RUN chmod 0755 /usr/local/bin/yaoyao-browser; \

+ 7 - 1
deploy/computer/compose_bridge.py

@@ -10,6 +10,7 @@ import socketserver
 import subprocess
 import subprocess
 import threading
 import threading
 import time
 import time
+from skill_bundle import install as install_skill
 
 
 DESKTOP_ID = os.environ.get('YAOYAO_COMPOSE_DESKTOP_ID')
 DESKTOP_ID = os.environ.get('YAOYAO_COMPOSE_DESKTOP_ID')
 if not DESKTOP_ID:
 if not DESKTOP_ID:
@@ -91,7 +92,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
                         ready = result['exitCode'] == 0
                         ready = result['exitCode'] == 0
                     except Exception:
                     except Exception:
                         pass
                         pass
-                result = {'id': DESKTOP_ID, 'protocol': 1, 'instance': INSTANCE, 'ready': ready}
+                result = {'id': DESKTOP_ID, 'protocol': 1, 'instance': INSTANCE, 'ready': ready, 'features': ['skills-v1']}
             elif op == '/acquire':
             elif op == '/acquire':
                 with LOCK:
                 with LOCK:
                     if RESTARTING:
                     if RESTARTING:
@@ -130,6 +131,11 @@ class Handler(http.server.BaseHTTPRequestHandler):
                     result = {'data': base64.b64encode(data).decode(), 'width': int.from_bytes(data[16:20], 'big'), 'height': int.from_bytes(data[20:24], 'big'), 'capturedAt': int(time.time()*1000)}
                     result = {'data': base64.b64encode(data).decode(), 'width': int.from_bytes(data[16:20], 'big'), 'height': int.from_bytes(data[20:24], 'big'), 'capturedAt': int(time.time()*1000)}
                 finally:
                 finally:
                     Path(path).unlink(missing_ok=True)
                     Path(path).unlink(missing_ok=True)
+            elif op == '/skills-install':
+                with LOCK:
+                    current(body)
+                    result = install_skill(body.get('bundle', {}))
+                    current(body)
             elif op == '/execute':
             elif op == '/execute':
                 argv = body.get('argv')
                 argv = body.get('argv')
                 if not isinstance(argv, list) or not argv or len(argv) > 64 or any(not isinstance(x, str) or '\0' in x for x in argv) or sum(map(len, argv)) > 65536:
                 if not isinstance(argv, list) or not argv or len(argv) > 64 or any(not isinstance(x, str) or '\0' in x for x in argv) or sum(map(len, argv)) > 65536:

+ 111 - 0
deploy/computer/skill_bundle.py

@@ -0,0 +1,111 @@
+"""Fixed skill transfer operations; installation never executes package code."""
+import base64
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import shutil
+import stat
+import sys
+import tempfile
+
+MAX_FILE = 1024 * 1024
+MAX_PACKAGE = 4 * MAX_FILE
+MAX_FILES = 128
+ROOT = Path('/opt/yaoyao-skills')
+
+
+def path_name(value):
+    if not isinstance(value, str) or len(value) > 512:
+        raise ValueError('技能文件路径无效')
+    path = PurePosixPath(value)
+    if not value or path.is_absolute() or str(path) != value or any(part.startswith('.') or re.search(r'[\x00-\x1f\x7f\\:*?"<>|]', part) for part in path.parts):
+        raise ValueError('技能文件必须使用包内相对路径')
+    return value
+
+
+def fingerprint(files):
+    digest = hashlib.sha256()
+    for name in sorted(files):
+        digest.update(name.encode() + b'\0' + hashlib.sha256(files[name]).digest())
+    return digest.hexdigest()
+
+
+def install(body):
+    namespace, revision = body.get('namespace'), body.get('revision')
+    if any(not isinstance(value, str) or not re.fullmatch(r'[a-f0-9]{64}', value) for value in (namespace, revision)):
+        raise ValueError('技能版本无效')
+    encoded = body.get('files')
+    if not isinstance(encoded, dict) or not 0 < len(encoded) <= MAX_FILES:
+        raise ValueError('技能文件数量超过限制')
+    files = {}
+    for name, data in encoded.items():
+        path_name(name)
+        if not isinstance(data, str) or len(data) > MAX_FILE * 4 // 3 + 4:
+            raise ValueError('技能文件超过限制')
+        files[name] = base64.b64decode(data, validate=True)
+    if 'SKILL.md' not in files or sum(map(len, files.values())) > MAX_PACKAGE or any(len(data) > MAX_FILE for data in files.values()) or fingerprint(files) != revision:
+        raise ValueError('技能包不完整或版本不匹配')
+    # ROOT and all descendants are root-owned. The cua user cannot substitute
+    # parents, change modes or overwrite a previously installed revision.
+    for directory in (ROOT, ROOT / namespace):
+        if directory.is_symlink():
+            raise ValueError('技能存储路径不能重定向')
+        directory.mkdir(exist_ok=True, mode=0o755)
+        if directory.stat().st_uid != 0 or directory.stat().st_mode & 0o022:
+            raise ValueError('技能目录权限无效')
+    target = ROOT / namespace / revision
+    if target.is_symlink():
+        raise ValueError('技能版本路径不能重定向')
+    if not target.exists():
+        stage = Path(tempfile.mkdtemp(prefix='.install-', dir=target.parent))
+        try:
+            for name, data in files.items():
+                path = stage / name
+                path.parent.mkdir(parents=True, exist_ok=True)
+                path.write_bytes(data)
+                path.chmod(0o444)
+            for root, dirs, _ in os.walk(stage):
+                Path(root).chmod(0o555)
+            os.replace(stage, target)
+        finally:
+            if stage.exists():
+                for root, _, _ in os.walk(stage):
+                    Path(root).chmod(0o755)
+                shutil.rmtree(stage)
+    return {'path': str(target), 'revision': revision}
+
+
+def collect(directory):
+    root = Path(directory)
+    if not root.is_absolute() or root.is_symlink() or root.resolve() != root or not root.is_dir():
+        raise ValueError('草稿必须是隔离环境中的真实绝对目录')
+    files = {}
+    for parent, dirs, names in os.walk(root, followlinks=False):
+        if any((Path(parent) / name).is_symlink() for name in dirs):
+            raise ValueError('技能草稿不能包含符号链接')
+        for name in sorted(names):
+            path = Path(parent) / name
+            relative = path_name(path.relative_to(root).as_posix())
+            fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
+            with os.fdopen(fd, 'rb') as stream:
+                info = os.fstat(stream.fileno())
+                if not stat.S_ISREG(info.st_mode) or info.st_size > MAX_FILE:
+                    raise ValueError('技能文件必须为限额内的普通文件')
+                files[relative] = stream.read(MAX_FILE + 1)
+            if len(files) > MAX_FILES or sum(map(len, files.values())) > MAX_PACKAGE:
+                raise ValueError('技能包超过限制')
+    if 'SKILL.md' not in files:
+        raise ValueError('草稿缺少 SKILL.md')
+    return {'files': {name: base64.b64encode(data).decode() for name, data in files.items()}, 'revision': fingerprint(files)}
+
+
+if __name__ == '__main__':
+    if sys.argv[1] == 'install':
+        result = install(json.load(sys.stdin))
+    elif sys.argv[1] == 'collect':
+        result = collect(sys.argv[2])
+    else:
+        raise ValueError('未知技能传输操作')
+    print(json.dumps(result))

+ 19 - 0
docs/local-vm.md

@@ -28,6 +28,25 @@
 
 
 Compose 部署同样可为已选择的桌面启用本机协作模式,仍由 Compose 管理桌面数量和生命周期。所有模式沿用原有虚拟机网络策略,不增加网站发布或端口转发。
 Compose 部署同样可为已选择的桌面启用本机协作模式,仍由 Compose 管理桌面数量和生命周期。所有模式沿用原有虚拟机网络策略,不增加网站发布或端口转发。
 
 
+### Profile 技能共享与 Bot 经验沉淀
+
+虚拟机模式和本机协作模式都提供以下技能工具,不需要开启“允许本机环境”:
+
+- `computer_skills_list`:发现当前基础 Profile 的技能及 `skills.external_dirs` 中的技能,尊重全局禁用列表。外部目录由 Profile 配置指定,工具参数不能选择 Profile 或宿主目录。
+- `computer_skill_view`:读取技能正文、关联文件,并将完整技能包安装到隔离电脑的 `/opt/yaoyao-skills/<来源>/<内容版本>`。目录和文件由 root 持有,对执行工具的 cua 用户只读;不会挂载宿主 Hermes 配置、凭据或聊天数据。
+- `computer_skill_publish`:把虚拟机中的完整草稿发布到当前 Profile 的 `skills/bot-learned/<技能名>`。先在草稿目录执行 `validation_command`,命令成功且验证前后的草稿内容一致才发布。验证输出、依赖和临时文件应写到草稿目录外。技能包最多 128 个文件、每个 1 MiB、总计 4 MiB,拒绝符号链接、越界路径、隐藏文件和密钥文件扩展名。
+- `computer_skill_history` / `computer_skill_restore`:查询最近 50 次发布记录,或基于当前版本回退到已有历史版本。历史记录和完整版本保存在该 Profile 的 `.yaoyao-skills`,记录 Bot、任务、时间和验证结果。
+
+Bot 在有价值的任务完成、可复用问题修复后,会收到主动整理并发布技能的指导;普通问答不要求保存,用户明确禁止保存时应遵从。沉淀发生在当前任务的工具调用中,不启动额外后台模型任务。技能原文是任务参考,不能改变工具授权。具体任务是否值得整理仍由模型判断,发布成功由工具结果确认。
+
+新建时 `expected_revision` 为 `null`;修改时必须提供读取到的内容版本。只允许自动修改有发布记录、属于当前账号的 Bot 技能,不覆盖用户技能、其他账号技能或外部目录技能。多个 Bot 的发布串行提交,版本过期时返回冲突;原生 Profile 在发布前改动了文件也会再次检查。每轮首次读取的完整技能包固定在该轮,其他会话的修改下一轮生效。
+
+已发布技能采用普通 `SKILL.md` 与支持文件结构,原生 Profile 和同一 Profile 下其他 Bot 都可以发现。发布器添加“通过隔离 Linux 电脑执行”的要求,同时保持 macOS Profile 可见;Profile 需要连接虚拟机才能运行其中的 Linux 脚本。软件依赖仍需在虚拟机内安装,MCP 与应用授权不会随技能文件自动获得。
+
+同一共享桌面的成员共用文件系统,已安装的技能副本也可被其他成员读取;需要保密的技能应使用独立桌面。Bot 不会自动导入其他 Profile、插件私有目录或未授权的组织镜像技能。
+
+发布遵从 Profile 已有的 `skills.write_approval` 和 `skills.guard_agent_created` 配置。开启写入审核时,隔离工具返回需在原生 Profile 发布的说明,不直接绕过审核;开启技能扫描时,扫描通过后才提交。首次使用需要更新配套 Runner;Compose 桌面还需使用包含 `skills-v1` 私有桥能力的新镜像,旧镜像会明确提示更新。桌面数量和生命周期继续由 Compose 管理。本地容器通过固定安装器提供只读副本,不需要扩展宿主挂载范围。
+
 兼容旧客户端:取消“允许本机环境”会同时退出本机协作模式,恢复虚拟机模式;不会忽略撤销请求。`PUT /api/app/agents/:id/computer-selection` 新增可选 `vmExecution: worker | profile`,未传入时保留原选择。显式同时提交 `profile` 和 `allowHostEnvironment: false` 会拒绝冲突配置。
 兼容旧客户端:取消“允许本机环境”会同时退出本机协作模式,恢复虚拟机模式;不会忽略撤销请求。`PUT /api/app/agents/:id/computer-selection` 新增可选 `vmExecution: worker | profile`,未传入时保留原选择。显式同时提交 `profile` 和 `allowHostEnvironment: false` 会拒绝冲突配置。
 
 
 2026-09-14 验证:1119 项服务端及客户端回归通过;新增模式、旧模式本机选项、云端本机选项共 3 项浏览器流程通过(含浅色桌面、深色 390px 页面及刷新恢复)。独立临时 Hermes 配置和真实 Docker 桌面验证了原生终端、虚拟机命令、虚拟机文件读取、Profile 项目上下文与完整回复。模型响应由确定性的本机测试服务提供,不依赖外部模型;测试不使用用户会话。协议测试另覆盖会话编号更新、跨模式会话拒绝、文件复制、团队工具、接管等待与交还继续。
 2026-09-14 验证:1119 项服务端及客户端回归通过;新增模式、旧模式本机选项、云端本机选项共 3 项浏览器流程通过(含浅色桌面、深色 390px 页面及刷新恢复)。独立临时 Hermes 配置和真实 Docker 桌面验证了原生终端、虚拟机命令、虚拟机文件读取、Profile 项目上下文与完整回复。模型响应由确定性的本机测试服务提供,不依赖外部模型;测试不使用用户会话。协议测试另覆盖会话编号更新、跨模式会话拒绝、文件复制、团队工具、接管等待与交还继续。

+ 1 - 1
scripts/build-desktop.mjs

@@ -40,7 +40,7 @@ await cp(resolve(root,'build-info.json'),resolve(out,'build-info.json'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const manifest = JSON.parse(await readFile(resolve(root, 'release.json'), 'utf8'))
 const service = resolve(out, 'web-service')
 const service = resolve(out, 'web-service')
 await mkdir(service)
 await mkdir(service)
-for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json', 'hermes_worker.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
+for (const file of ['server.mjs', 'ui', 'release.json', 'build-info.json', 'hermes_worker.py', 'profile_skills.py', 'skill_bundle.py', 'guest_proxy.py', 'computer-image', 'third-party']) await cp(resolve(out, file), resolve(service, file), { recursive: true })
 for (const file of ['LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', 'licenses']) await cp(resolve(root, file), resolve(service, file), { recursive: true })
 for (const file of ['LICENSE', 'NOTICE', 'THIRD_PARTY_NOTICES.md', 'licenses']) await cp(resolve(root, file), resolve(service, file), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(resolve(root, 'bin'), resolve(service, 'bin'), { recursive: true })
 await cp(await realpath(process.execPath), resolve(service, 'node'))
 await cp(await realpath(process.execPath), resolve(service, 'node'))

+ 2 - 0
scripts/build-runner.mjs

@@ -6,6 +6,8 @@ import { fileURLToPath } from 'node:url'
 export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
 export async function buildRunner(directory = resolve(import.meta.dirname, '../.runner-build')) {
   await mkdir(directory, { recursive: true })
   await mkdir(directory, { recursive: true })
   await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
+  await copyFile(resolve(import.meta.dirname,'../src/runner/worker/profile_skills.py'),resolve(directory,'profile_skills.py'))
+  await copyFile(resolve(import.meta.dirname,'../deploy/computer/skill_bundle.py'),resolve(directory,'skill_bundle.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
   await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
   await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'computer-image'),{recursive:true})
   await cp(resolve(import.meta.dirname,'../deploy/computer'),resolve(directory,'computer-image'),{recursive:true})
   await cp(resolve(import.meta.dirname,'../third-party'),resolve(directory,'third-party'),{recursive:true})
   await cp(resolve(import.meta.dirname,'../third-party'),resolve(directory,'third-party'),{recursive:true})

+ 2 - 0
scripts/copy-worker.mjs

@@ -3,6 +3,8 @@ import {resolve} from 'node:path'
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 const directory=resolve(import.meta.dirname,'../dist-server/runner/worker')
 await mkdir(directory,{recursive:true})
 await mkdir(directory,{recursive:true})
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
 await copyFile(resolve(import.meta.dirname,'../src/runner/worker/hermes_worker.py'),resolve(directory,'hermes_worker.py'))
+await copyFile(resolve(import.meta.dirname,'../src/runner/worker/profile_skills.py'),resolve(directory,'profile_skills.py'))
+await copyFile(resolve(import.meta.dirname,'../deploy/computer/skill_bundle.py'),resolve(directory,'skill_bundle.py'))
 
 
 await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
 await copyFile(resolve(import.meta.dirname,'../src/runner/network/guest_proxy.py'),resolve(directory,'guest_proxy.py'))
 
 

+ 10 - 1
src/runner/computers/compose.ts

@@ -1,5 +1,5 @@
 import {randomUUID} from 'node:crypto'
 import {randomUUID} from 'node:crypto'
-import {ContainerComputerProvider,ComputerError,type ComputerSpecification,type ComputerState,type CommandResult} from './container.js'
+import {ContainerComputerProvider,ComputerError,type ComputerSpecification,type ComputerState,type CommandResult,type SkillInstallation} from './container.js'
 import type {DesktopRelay} from '../../shared/composeDesktops.js'
 import type {DesktopRelay} from '../../shared/composeDesktops.js'
 
 
 /** Uses only the fixed desktops declared by Compose; never calls a container engine. */
 /** Uses only the fixed desktops declared by Compose; never calls a container engine. */
@@ -41,6 +41,15 @@ export class ComposeComputerProvider extends ContainerComputerProvider {
     options.authorize();if(result.exitCode)throw Object.assign(new ComputerError('computer_command_failed','桌面命令执行失败'),{code:result.exitCode,stdout:result.stdout,stderr:result.stderr})
     options.authorize();if(result.exitCode)throw Object.assign(new ComputerError('computer_command_failed','桌面命令执行失败'),{code:result.exitCode,stdout:result.stdout,stderr:result.stderr})
     return result
     return result
   }
   }
+  async installSkill(spec:ComputerSpecification,bundle:SkillInstallation,_script:string,options:{authorize():void;signal?:AbortSignal}):Promise<{path:string;revision:string}>{
+    options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
+    const lease=this.leases.get(spec.id);if(!lease)throw new ComputerError('computer_lease_stale','需要当前共享桌面的控制权')
+    const health=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey})
+    if(!health.features?.includes('skills-v1'))throw new ComputerError('computer_skills_unavailable','请更新 Compose 桌面镜像以使用共享技能')
+    options.authorize()
+    const result=await this.relay(spec.id,'skills-install',{ownerKey:spec.ownerKey,...lease,bundle})
+    options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止');return result
+  }
   async capture(spec:ComputerSpecification,authorize:()=>void){authorize();const result=await this.relay(spec.id,'frame',{ownerKey:spec.ownerKey});authorize();return result}
   async capture(spec:ComputerSpecification,authorize:()=>void){authorize();const result=await this.relay(spec.id,'frame',{ownerKey:spec.ownerKey});authorize();return result}
   async health(spec:ComputerSpecification,authorize:()=>void){authorize();const result=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey});if(!result.ready)throw new ComputerError('compose_desktop_offline','Compose 桌面尚未就绪');return result}
   async health(spec:ComputerSpecification,authorize:()=>void){authorize();const result=await this.relay(spec.id,'health',{ownerKey:spec.ownerKey});if(!result.ready)throw new ComputerError('compose_desktop_offline','Compose 桌面尚未就绪');return result}
   async configureNetwork(_spec:ComputerSpecification,authorize:()=>void){authorize()}
   async configureNetwork(_spec:ComputerSpecification,authorize:()=>void){authorize()}

+ 12 - 0
src/runner/computers/container.ts

@@ -11,6 +11,7 @@ export const CUA_SOCKET = '/run/user/1000/openmausbot-cua.sock'
 const managedLabel='cn.samien.yaoyao.computer', runnerLabel='cn.samien.yaoyao.runner', specLabel='cn.samien.yaoyao.computer-spec', ownerLabel='cn.samien.yaoyao.computer-owner'
 const managedLabel='cn.samien.yaoyao.computer', runnerLabel='cn.samien.yaoyao.runner', specLabel='cn.samien.yaoyao.computer-spec', ownerLabel='cn.samien.yaoyao.computer-owner'
 const mib=1024*1024
 const mib=1024*1024
 export interface CommandResult {stdout:string;stderr:string}
 export interface CommandResult {stdout:string;stderr:string}
+export interface SkillInstallation {namespace:string;revision:string;files:Record<string,string>}
 export type ContainerCommand=(runtime:'docker'|'podman',args:string[],options?:{timeout?:number;signal?:AbortSignal;input?:Buffer})=>Promise<CommandResult>
 export type ContainerCommand=(runtime:'docker'|'podman',args:string[],options?:{timeout?:number;signal?:AbortSignal;input?:Buffer})=>Promise<CommandResult>
 const command:ContainerCommand=(runtime,args,options={})=>new Promise((resolveResult,reject)=>{
 const command:ContainerCommand=(runtime,args,options={})=>new Promise((resolveResult,reject)=>{
   const child=spawn(runtime,args,{stdio:'pipe',signal:options.signal}),out:Buffer[]=[],err:Buffer[]=[]
   const child=spawn(runtime,args,{stdio:'pipe',signal:options.signal}),out:Buffer[]=[],err:Buffer[]=[]
@@ -225,6 +226,17 @@ export class ContainerComputerProvider implements ComputerProvider {
       }
       }
     })
     })
   }
   }
+  async installSkill(value:ComputerSpecification,bundle:SkillInstallation,script:string,options:{authorize():void;signal?:AbortSignal}):Promise<{path:string;revision:string}> {
+    const spec=this.validateSpecification(value)
+    return this.serial(spec,async()=>{
+      options.authorize();if(options.signal?.aborted)throw new ComputerError('computer_cancelled','操作已停止')
+      const state=await this.inspect(spec)
+      if(!state?.running)throw new ComputerError('computer_not_running','电脑环境尚未运行')
+      options.authorize()
+      const result=await this.run(this.runtime,['exec','-i','--user','0:0','--',state.containerId,'python3','-c',script,'install'],{input:Buffer.from(JSON.stringify(bundle)),signal:options.signal,timeout:30000})
+      options.authorize();return JSON.parse(result.stdout)
+    })
+  }
   async capture(value:ComputerSpecification,authorize:()=>void):Promise<{data:string;width:number;height:number;capturedAt:number}> {
   async capture(value:ComputerSpecification,authorize:()=>void):Promise<{data:string;width:number;height:number;capturedAt:number}> {
     const spec=this.validateSpecification(value),state=await this.inspect(spec)
     const spec=this.validateSpecification(value),state=await this.inspect(spec)
     authorize();if(!state?.running)throw new ComputerError('computer_not_running','电脑尚未运行')
     authorize();if(!state?.running)throw new ComputerError('computer_not_running','电脑尚未运行')

+ 15 - 3
src/runner/worker/gateway.ts

@@ -18,6 +18,7 @@ import {ComputerControls} from './control.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {ComputerPublicProxy} from '../network/computerProxy.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerProxyEnvironment,type WorkerContextConfiguration,type WorkerTool,type WorkerFrame} from './process.js'
 import {HermesWorkerProcess,type WorkerModel,type WorkerProxyEnvironment,type WorkerContextConfiguration,type WorkerTool,type WorkerFrame} from './process.js'
 import {HOST_TOOLS,hostTool,hostPath,readHostFile,writeHostFile} from './hostTools.js'
 import {HOST_TOOLS,hostTool,hostPath,readHostFile,writeHostFile} from './hostTools.js'
+import {ProfileSkillSession,SKILL_TOOLS,SKILL_RULES} from './skills.js'
 import {homedir,platform} from 'node:os'
 import {homedir,platform} from 'node:os'
 
 
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean}
 export interface ComputerTarget {environmentId:string;ownerKey:string;agentId:string;hostAccess?:boolean;profileSession?:boolean}
@@ -149,6 +150,7 @@ export class ComputerGateway {
   private checking=false
   private checking=false
   private hostCwd=homedir()
   private hostCwd=homedir()
   private profileSession?:ProfileComputerSession
   private profileSession?:ProfileComputerSession
+  private skillSession?:ProfileSkillSession
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
   private team?:{id:string;catalog:WorkerTool[];call(name:string,args:Record<string,unknown>,callId:string):Promise<unknown>}
   constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>,readonly profileTarget?:GatewayTarget){ }
   constructor(readonly runtime:ComputerRuntime,readonly meta:ComputerTarget,readonly workId:string,readonly check:()=>Promise<void>,readonly publish:(body:Record<string,unknown>)=>Promise<any>,readonly profileTarget?:GatewayTarget){ }
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
   async connect(){this.runtime.assertTarget(this.meta);await this.runtime.ready;await this.authorized()}
@@ -201,7 +203,7 @@ export class ComputerGateway {
         if(typeof params.workMarker==='string'&&/^\[yaoyao-run:[0-9a-f-]{36}:[0-9a-f-]{36}\]$/.test(params.workMarker))live.workMarker=params.workMarker
         if(typeof params.workMarker==='string'&&/^\[yaoyao-run:[0-9a-f-]{36}:[0-9a-f-]{36}\]$/.test(params.workMarker))live.workMarker=params.workMarker
         await this.profileSession.bind();this.guard()
         await this.profileSession.bind();this.guard()
         live.running=true;live.session.outcome='uncertain';this.runtime.save(live.session)
         live.running=true;live.session.outcome='uncertain';this.runtime.save(live.session)
-        return this.profileSession.rpc(method,{...params,text:`${PROFILE_COMPUTER_RULES}\n${String(params.text??'')}`})
+        return this.profileSession.rpc(method,{...params,text:`${PROFILE_COMPUTER_RULES}\n${SKILL_RULES}\n${String(params.text??'')}`})
       }
       }
       return this.profileSession.rpc(method,params)
       return this.profileSession.rpc(method,params)
     }
     }
@@ -239,7 +241,7 @@ export class ComputerGateway {
     if(!this.profileTarget)throw new HttpError(409,'缺少本机 Profile 连接,请更新执行节点','computer_profile_unavailable')
     if(!this.profileTarget)throw new HttpError(409,'缺少本机 Profile 连接,请更新执行节点','computer_profile_unavailable')
     if(params.session_id&&!session.profileSessionId)throw new HttpError(409,'本机 Profile 会话尚未建立,请新建会话','computer_profile_session_invalid')
     if(params.session_id&&!session.profileSessionId)throw new HttpError(409,'本机 Profile 会话尚未建立,请新建会话','computer_profile_session_invalid')
     const native=new ProfileComputerSession(this.profileTarget,session.profile,this.workId,()=>this.guard(),
     const native=new ProfileComputerSession(this.profileTarget,session.profile,this.workId,()=>this.guard(),
-      ()=>[...TOOLS,...HOST_TOOLS.filter(tool=>tool.name==='computer_copy_file'),...(this.team?.catalog??[])],
+      ()=>[...TOOLS,...SKILL_TOOLS,...HOST_TOOLS.filter(tool=>tool.name==='computer_copy_file'),...(this.team?.catalog??[])],
       async(name,args)=>{
       async(name,args)=>{
         const live=this.live
         const live=this.live
         if(!live||!this.active||live.paused)throw new HttpError(410,'本轮电脑工具已结束或暂停','computer_cancelled')
         if(!live||!this.active||live.paused)throw new HttpError(410,'本轮电脑工具已结束或暂停','computer_cancelled')
@@ -273,7 +275,7 @@ export class ComputerGateway {
       live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       live.received=false;live.running=true;live.session.outcome='uncertain';live.session.history.push({role:'user',content:prompt});this.runtime.save(live.session)
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
       const directory=join(this.runtime.home,'workers',live.session.id,this.workId,String(live.segment=(live.segment??0)+1))
       await mkdir(directory,{recursive:true,mode:0o700})
       await mkdir(directory,{recursive:true,mode:0o700})
-      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,contextConfig:live.contextConfig,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.config.network??'none',tools:[...TOOLS,...(this.meta.hostAccess?HOST_TOOLS:[]),...(this.team?.catalog??[])],prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
+      const worker=new HermesWorkerProcess(this.runtime.config.python,this.runtime.script,{mode:'run',home:directory,hermesSource:this.runtime.config.hermesSource,model:live.model,contextConfig:live.contextConfig,proxyEnv:live.proxyEnv,sessionId:live.session.id,taskId:this.workId,cwd:live.session.cwd,...(this.meta.hostAccess?{host:{cwd:this.hostCwd,platform:platform()}}:{}),network:this.runtime.config.network??'none',tools:[...TOOLS,...SKILL_TOOLS,...(this.meta.hostAccess?HOST_TOOLS:[]),...(this.team?.catalog??[])],skillInstructions:SKILL_RULES,prompt:live.images.length?[{type:'text',text:prompt},...live.images]:prompt,history})
       live.worker=worker
       live.worker=worker
       this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
       this.runtime.workers.set(this.workId,{environmentId:this.meta.environmentId,ownerKey:this.meta.ownerKey,process:worker});void worker.exited.then(()=>{if(this.runtime.workers.get(this.workId)?.process===worker)this.runtime.workers.delete(this.workId)})
       worker.onTool=(name,args,id,callId)=>{
       worker.onTool=(name,args,id,callId)=>{
@@ -302,6 +304,16 @@ export class ComputerGateway {
     try{
     try{
       const team=this.team?.catalog.find(tool=>tool.name===name)
       const team=this.team?.catalog.find(tool=>tool.name===name)
       if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
       if(team)return await this.team!.call(team.id??name,z.record(z.string(),z.unknown()).parse(args),id)
+      if(SKILL_TOOLS.some(tool=>tool.name===name)){
+        this.skillSession??=new ProfileSkillSession({
+          python:this.runtime.config.python,script:this.runtime.script,hermesSource:this.runtime.config.hermesSource,hermesHome:this.runtime.config.hermesHome,
+          profile:live.session.profile,ownerKey:this.meta.ownerKey,agentId:this.meta.agentId,taskId:this.workId,signal:live.controller.signal,
+          authorize:async()=>{await this.authorized();if(live.paused||live.received)throw new HttpError(410,'本轮技能工具已结束或暂停','computer_cancelled');this.runtime.pool.authorize(live.lease)},
+          execute:(argv,input)=>this.runtime.pool.use(live.lease,context=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})),
+          install:(bundle,script)=>this.runtime.pool.use(live.lease,context=>this.runtime.provider.installSkill(this.spec(live.session),bundle,script,context)),
+        })
+        return await this.skillSession.call(name,args)
+      }
       return await this.runtime.pool.use(live.lease,async context=>{
       return await this.runtime.pool.use(live.lease,async context=>{
         const execute=(argv:string[],input?:Buffer)=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})
         const execute=(argv:string[],input?:Buffer)=>this.runtime.provider.execute(this.spec(live.session),argv,{...context,input})
         if(this.meta.hostAccess&&name.startsWith('host_'))return hostTool(name,args,{...context,cwd:this.hostCwd})
         if(this.meta.hostAccess&&name.startsWith('host_'))return hostTool(name,args,{...context,cwd:this.hostCwd})

+ 20 - 2
src/runner/worker/hermes_worker.py

@@ -264,7 +264,7 @@ def run_model():
     else:
     else:
         environment += "Host files and commands are not available through computer tools. Never attempt to execute a host command through them. "
         environment += "Host files and commands are not available through computer tools. Never attempt to execute a host command through them. "
     result = agent.run_conversation(BOOT["prompt"], conversation_history=BOOT.get("history", []), task_id=BOOT["taskId"],
     result = agent.run_conversation(BOOT["prompt"], conversation_history=BOOT.get("history", []), task_id=BOOT["taskId"],
-        system_message="You are a controlled Hermes worker. " + environment + "Other supplied application and team tools follow their descriptions. Use only supplied tools and follow the user's task and permission scope.")
+        system_message="You are a controlled Hermes worker. " + environment + "Other supplied application and team tools follow their descriptions. Use only supplied tools and follow the user's task and permission scope.\n" + str(BOOT.get("skillInstructions", "")))
     # These are engine result flags, never inferred from model-generated text.
     # These are engine result flags, never inferred from model-generated text.
     failure_code = ("context_compaction_disabled" if result.get("compaction_disabled") is True
     failure_code = ("context_compaction_disabled" if result.get("compaction_disabled") is True
                     else "context_compaction_failed" if result.get("compression_exhausted") is True
                     else "context_compaction_failed" if result.get("compression_exhausted") is True
@@ -277,7 +277,25 @@ def run_model():
 
 
 try:
 try:
     with contextlib.redirect_stdout(sys.stderr):
     with contextlib.redirect_stdout(sys.stderr):
-        if BOOT.get("mode") == "resolve-workspace":
+        if BOOT.get("mode") == "skills":
+            config, _ = load_profile()
+            from profile_skills import ProfileSkills, SkillError
+            def authorize_skill_commit():
+                emit("tool", id="commit", name="skill_commit", arguments={})
+                frame = json.loads(sys.stdin.readline())
+                if frame.get("nonce") != NONCE or frame.get("type") != "result" or frame.get("id") != "commit" or frame.get("result") != {"authorized": True}:
+                    raise SkillError("skill_cancelled", "技能发布授权已失效")
+            try:
+                profile_home = Path(BOOT["hermesHome"]).resolve()
+                if BOOT["profile"] != "default":
+                    profile_home = profile_home / "profiles" / BOOT["profile"]
+                result = ProfileSkills(profile_home, config, BOOT["provenance"], authorize_skill_commit).call(BOOT["action"], BOOT.get("arguments", {}))
+                emit("skills", result=result)
+            except SkillError as error:
+                emit("skills", result={"error": str(error), "code": error.code})
+            except Exception:
+                emit("skills", result={"error": "无法读取或发布技能,请检查 Profile 技能目录", "code": "skill_storage_error"})
+        elif BOOT.get("mode") == "resolve-workspace":
             emit("resolved", **resolve_workspace(load_profile()[0]))
             emit("resolved", **resolve_workspace(load_profile()[0]))
         elif BOOT.get("mode") == "resolve":
         elif BOOT.get("mode") == "resolve":
             resolve_model()
             resolve_model()

+ 361 - 0
src/runner/worker/profile_skills.py

@@ -0,0 +1,361 @@
+"""Profile-owned skill storage. Called by the Runner, never by model dispatch.
+
+The caller supplies the Profile and provenance. Package paths are relative,
+publication is version checked, and host programs from skills are never run.
+"""
+import base64
+import contextlib
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+import re
+import shutil
+import stat
+import tempfile
+import time
+
+MAX_FILE = 1024 * 1024
+MAX_PACKAGE = 4 * MAX_FILE
+MAX_FILES = 128
+NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,63}\Z")
+REVISION = re.compile(r"[a-f0-9]{64}\Z")
+EXCLUDED = {"node_modules", "venv", "__pycache__", "site-packages"}
+
+
+class SkillError(Exception):
+    def __init__(self, code, message):
+        self.code = code
+        super().__init__(message)
+
+
+def require(condition, code="skill_invalid", message="技能包格式或路径无效"):
+    if not condition:
+        raise SkillError(code, message)
+
+
+def file_path(value):
+    require(isinstance(value, str) and 0 < len(value) <= 512)
+    path = PurePosixPath(value)
+    require(not path.is_absolute() and str(path) == value and "\\" not in value)
+    require(all(not part.startswith(".") and not re.search(r'[\x00-\x1f\x7f\\:*?"<>|]', part) and part not in EXCLUDED for part in path.parts))
+    require(not value.lower().endswith((".pem", ".key", ".p12", ".pfx")))
+    return path
+
+
+def metadata(content):
+    import yaml
+    require(len(content.encode()) <= MAX_FILE)
+    match = re.match(r"\A\ufeff?---\s*\n(.*?)\n---\s*(?:\n|$)", content, re.S)
+    require(match is not None, message="SKILL.md 必须包含 YAML 元数据")
+    try:
+        meta = yaml.safe_load(match.group(1))
+    except Exception:
+        raise SkillError("skill_invalid", "技能元数据无法解析") from None
+    require(isinstance(meta, dict))
+    name, description = meta.get("name"), meta.get("description")
+    require(isinstance(name, str) and NAME.fullmatch(name) is not None, message="技能名必须为小写字母、数字和连字符,最多 64 字符")
+    require(isinstance(description, str) and 0 < len(description.strip()) <= 1024, message="请提供不超过 1024 字符的技能说明")
+    return meta
+
+
+def revision(files):
+    # Identical to the guest installer; independent of JSON escaping/order.
+    digest = hashlib.sha256()
+    for name in sorted(files):
+        digest.update(name.encode() + b"\0" + hashlib.sha256(files[name]).digest())
+    return digest.hexdigest()
+
+
+def decode_package(encoded):
+    require(isinstance(encoded, dict) and 0 < len(encoded) <= MAX_FILES)
+    files = {}
+    for name, data in encoded.items():
+        file_path(name)
+        require(isinstance(data, str) and len(data) <= (MAX_FILE * 4 // 3 + 4))
+        try:
+            files[name] = base64.b64decode(data, validate=True)
+        except Exception:
+            raise SkillError("skill_invalid", "技能文件编码无效") from None
+        require(len(files[name]) <= MAX_FILE)
+    require(sum(map(len, files.values())) <= MAX_PACKAGE and "SKILL.md" in files)
+    try:
+        meta = metadata(files["SKILL.md"].decode("utf-8"))
+    except UnicodeError:
+        raise SkillError("skill_invalid", "SKILL.md 必须使用 UTF-8") from None
+    return files, meta
+
+
+def read_package(directory):
+    require(directory.is_dir() and not directory.is_symlink(), "skill_path_unsafe", "技能目录不能是符号链接")
+    files = {}
+    for root, dirs, names in os.walk(directory, followlinks=False):
+        dirs[:] = sorted(d for d in dirs if not d.startswith(".") and d not in EXCLUDED)
+        require(not any((Path(root) / d).is_symlink() for d in dirs), "skill_path_unsafe", "技能包不能包含目录链接")
+        for name in sorted(names):
+            if name.startswith("."):
+                continue
+            path = Path(root) / name
+            relative = path.relative_to(directory).as_posix()
+            file_path(relative)
+            # O_NOFOLLOW prevents a link substitution between scan and open.
+            fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))
+            with os.fdopen(fd, "rb") as stream:
+                info = os.fstat(stream.fileno())
+                require(stat.S_ISREG(info.st_mode) and info.st_size <= MAX_FILE, "skill_path_unsafe", "技能文件必须为限额内的普通文件")
+                files[relative] = stream.read(MAX_FILE + 1)
+            require(len(files) <= MAX_FILES and sum(map(len, files.values())) <= MAX_PACKAGE)
+    require("SKILL.md" in files)
+    metadata(files["SKILL.md"].decode("utf-8"))
+    return files
+
+
+def encode(files):
+    return {name: base64.b64encode(data).decode() for name, data in files.items()}
+
+
+def write_json(path, value):
+    require(not path.is_symlink(), "skill_path_unsafe", "技能记录不能重定向")
+    fd, temporary = tempfile.mkstemp(prefix=".record-", dir=path.parent)
+    try:
+        with os.fdopen(fd, "w", encoding="utf-8") as stream:
+            json.dump(value, stream, ensure_ascii=False)
+            stream.flush()
+            os.fsync(stream.fileno())
+        os.replace(temporary, path)
+    finally:
+        Path(temporary).unlink(missing_ok=True)
+
+
+def safe_directory(path):
+    # Profile root was canonicalized by the configuration reader. Reject all
+    # redirects below it, including the internal journal/history directories.
+    require(not path.is_symlink(), "skill_path_unsafe", "技能存储目录不能重定向")
+    path.mkdir(exist_ok=True, mode=0o700)
+    require(path.is_dir())
+    return path
+
+
+@contextlib.contextmanager
+def locked(path):
+    fd = os.open(path, os.O_RDWR | os.O_CREAT | getattr(os, "O_NOFOLLOW", 0), 0o600)
+    with os.fdopen(fd, "r+b") as stream:
+        if os.name == "nt":
+            import msvcrt
+            stream.write(b"\0"); stream.flush(); stream.seek(0)
+            msvcrt.locking(stream.fileno(), msvcrt.LK_LOCK, 1)
+        else:
+            import fcntl
+            fcntl.flock(stream, fcntl.LOCK_EX)
+        try:
+            yield
+        finally:
+            if os.name == "nt":
+                stream.seek(0); msvcrt.locking(stream.fileno(), msvcrt.LK_UNLCK, 1)
+            else:
+                fcntl.flock(stream, fcntl.LOCK_UN)
+
+
+class ProfileSkills:
+    def __init__(self, home, config, provenance, authorize=lambda: None):
+        self.home = Path(home).resolve()
+        self.config = config
+        self.provenance = provenance
+        self.authorize = authorize
+        self.local = self.home / "skills"
+        self.state = self.home / ".yaoyao-skills"
+
+    def roots(self):
+        roots = [self.local]
+        configured = self.config.get("skills", {}).get("external_dirs", [])
+        if isinstance(configured, str):
+            configured = [configured]
+        for value in configured if isinstance(configured, list) else []:
+            if isinstance(value, str):
+                path = Path(os.path.expandvars(os.path.expanduser(value)))
+                roots.append((self.home / path).resolve())
+        return list(dict.fromkeys(roots))
+
+    def entries(self, include_disabled=False):
+        disabled = self.config.get("skills", {}).get("disabled", [])
+        seen = set()
+        for source in self.roots():
+            if not source.is_dir() or source.is_symlink():
+                continue
+            for root, dirs, names in os.walk(source, followlinks=False):
+                dirs[:] = sorted(d for d in dirs if not d.startswith((".", "_")) and d not in EXCLUDED and not (Path(root) / d).is_symlink())
+                if "SKILL.md" not in names:
+                    continue
+                dirs[:] = []  # Supporting resources are not standalone skills.
+                skill = Path(root)
+                try:
+                    require(not (skill / "SKILL.md").is_symlink())
+                    require((skill / "SKILL.md").stat().st_size <= MAX_FILE)
+                    meta = metadata((skill / "SKILL.md").read_text(encoding="utf-8"))
+                    name = meta["name"]
+                    if name in seen:
+                        continue
+                    seen.add(name)
+                    if not include_disabled and name in disabled:
+                        continue
+                    yield name, skill, meta
+                except (SkillError, OSError, UnicodeError):
+                    continue
+
+    def find(self, name, include_disabled=False):
+        require(isinstance(name, str) and NAME.fullmatch(name) is not None)
+        return next((entry for entry in self.entries(include_disabled) if entry[0] == name), None)
+
+    def summary(self, entry):
+        name, path, meta = entry
+        extra = meta.get("metadata", {})
+        hermes = extra.get("hermes", {}) if isinstance(extra, dict) else {}
+        platforms = meta.get("platforms", hermes.get("platforms", []) if isinstance(hermes, dict) else [])
+        if isinstance(platforms, str):
+            platforms = [platforms]
+        if not isinstance(platforms, list):
+            platforms = []
+        return {"name": name, "description": meta["description"], "compatibility": str(meta.get("compatibility", ""))[:500],
+                "platforms": platforms, "linuxCompatible": not platforms or "linux" in platforms,
+                "managed": path == self.local / "bot-learned" / name}
+
+    def bundle(self, name):
+        entry = self.find(name)
+        require(entry is not None, "skill_missing", "当前 Profile 没有启用此技能")
+        files = read_package(entry[1])
+        return {**self.summary(entry), "revision": revision(files), "files": encode(files)}
+
+    def recover(self, name):
+        target = self.local / "bot-learned" / name
+        backup = self.state / (name + ".previous")
+        pending = self.state / (name + ".pending.json")
+        require(not pending.is_symlink() and not target.is_symlink() and not backup.is_symlink(), "skill_path_unsafe", "技能事务路径被修改")
+        if pending.exists():
+            transaction = json.loads(pending.read_text())
+            if target.exists() and revision(read_package(target)) == transaction["revision"]:
+                history = safe_directory(self.state / name)
+                write_json(history / "history.json", transaction["records"])
+            pending.unlink()
+        if backup.exists():
+            require(not backup.is_symlink() and not target.is_symlink(), "skill_path_unsafe", "技能事务路径被修改")
+            if not target.exists():
+                os.replace(backup, target)
+            else:
+                shutil.rmtree(backup)
+
+    def publish(self, name, encoded, expected, validation, restore=None):
+        require(isinstance(name, str) and NAME.fullmatch(name) is not None)
+        require(expected is None or isinstance(expected, str) and REVISION.fullmatch(expected) is not None)
+        require(isinstance(validation, str) and 1 <= len(validation.strip()) <= 4000, message="发布时必须说明验证方式与结果")
+        approval = self.config.get("skills", {}).get("write_approval", False)
+        require(str(approval).lower() not in ("true", "on", "yes", "1", "approve", "enabled"), "skill_write_disabled", "此 Profile 要求技能写入审核,请在本机 Profile 中发布")
+        safe_directory(self.local); safe_directory(self.local / "bot-learned"); safe_directory(self.state)
+        with locked(self.state / "publish.lock"):
+            self.recover(name)
+            existing = self.find(name, True)
+            target = self.local / "bot-learned" / name
+            require(existing is None or existing[1] == target, "skill_not_managed", "同名技能由用户或外部来源维护,请使用新名称")
+            require(not target.is_symlink(), "skill_path_unsafe", "技能目标不能为链接")
+            # Never adopt a pre-existing directory without our provenance.
+            history = safe_directory(self.state / name)
+            journal = history / "history.json"
+            require(not journal.is_symlink())
+            records = json.loads(journal.read_text()) if journal.exists() else []
+            require(not target.exists() or bool(records), "skill_not_managed", "此技能没有 Bot 发布记录,不能自动接管")
+            require(not records or records[-1].get("ownerKey") == self.provenance.get("ownerKey"), "skill_not_managed", "此技能由其他账号维护")
+            before = read_package(target) if target.exists() else None
+            current = revision(before) if before else None
+            require(current == expected, "skill_revision_conflict", "技能已被更新,请重新读取后合并")
+            if restore:
+                require(REVISION.fullmatch(restore) is not None and any(item["revision"] == restore for item in records), "skill_revision_missing", "未找到可回退版本")
+                snapshot = history / (restore + ".json")
+                require(not snapshot.is_symlink())
+                encoded = json.loads(snapshot.read_text())
+            files, meta = decode_package(encoded)
+            require(not restore or revision(files) == restore, "skill_revision_invalid", "历史版本校验失败")
+            require(meta["name"] == name, message="技能名与 SKILL.md 不一致")
+            # Keep it discoverable by a macOS Profile. The execution contract is
+            # a requirement on the tool target, not on the host OS of the model.
+            if not restore:
+                import yaml
+                meta.pop("platforms", None)
+                if isinstance(meta.get("metadata"), dict) and isinstance(meta["metadata"].get("hermes"), dict):
+                    meta["metadata"]["hermes"].pop("platforms", None)
+                meta["compatibility"] = "Requires an assigned Linux virtual machine and computer_shell; dependencies must be installed inside that VM."
+                content = files["SKILL.md"].decode("utf-8")
+                body = re.sub(r"\A\ufeff?---\s*\n.*?\n---\s*(?:\n|$)", "", content, count=1, flags=re.S)
+                note = "本技能在隔离 Linux 电脑执行。使用 computer_skill_view 获取虚拟机路径,通过 computer_shell 执行脚本;请勿在宿主机直接运行。输出和依赖安装在虚拟机工作目录。\n\n"
+                if not body.startswith(note):
+                    body = note + body
+                files["SKILL.md"] = ("---\n" + yaml.safe_dump(meta, allow_unicode=True, sort_keys=False) + "---\n" + body).encode()
+            require(len(files["SKILL.md"]) <= MAX_FILE and sum(map(len, files.values())) <= MAX_PACKAGE)
+            new_revision = revision(files)
+            if new_revision == current:
+                return {"name": name, "revision": current, "unchanged": True}
+            stage = Path(tempfile.mkdtemp(prefix=".publish-", dir=self.local / "bot-learned"))
+            try:
+                for relative, data in files.items():
+                    path = stage / relative
+                    path.parent.mkdir(parents=True, exist_ok=True)
+                    path.write_bytes(data)
+                    path.chmod(0o644)
+                stage.chmod(0o755)
+                if str(self.config.get("skills", {}).get("guard_agent_created", False)).lower() in ("true", "yes", "on", "1"):
+                    try:
+                        from tools.skills_guard import scan_skill, should_allow_install
+                        allowed, _ = should_allow_install(scan_skill(stage, source="agent-created"))
+                    except Exception:
+                        raise SkillError("skill_scan_unavailable", "Profile 要求技能扫描,但扫描未能完成") from None
+                    require(allowed is True, "skill_scan_blocked", "Profile 的技能扫描未通过,请检查草稿")
+                self.authorize()  # Recheck after staging/scanning, before commit.
+                latest = revision(read_package(target)) if target.exists() else None
+                require(latest == current, "skill_revision_conflict", "技能在发布前被修改,请重新读取后合并")
+                for version, package in [(current, before), (new_revision, files)]:
+                    if version:
+                        snapshot = history / (version + ".json")
+                        require(not snapshot.is_symlink())
+                        if not snapshot.exists():
+                            write_json(snapshot, encode(package))
+                records.append({"revision": new_revision, "previous": current, "at": int(time.time() * 1000),
+                                "validation": validation, "restoredFrom": restore, **self.provenance})
+                pending = self.state / (name + ".pending.json")
+                write_json(pending, {"revision": new_revision, "records": records})
+                # Keep the preceding package for recovery if the process dies
+                # between the two renames. Readers never see partial files.
+                if target.exists():
+                    os.replace(target, self.state / (name + ".previous"))
+                os.replace(stage, target)
+                self.recover(name)
+                for cache in (self.home / ".skills_prompt_snapshot.json",):
+                    cache.unlink(missing_ok=True)
+            finally:
+                if stage.exists():
+                    shutil.rmtree(stage)
+            return {"name": name, "revision": new_revision, "previous": current, "published": True, "profileAvailable": True}
+
+    def call(self, action, args):
+        if self.state.exists():
+            require(not self.state.is_symlink() and not self.local.is_symlink() and not (self.local / "bot-learned").is_symlink(), "skill_path_unsafe", "技能存储目录不能重定向")
+            pending_names = {path.name.removesuffix(".pending.json") for path in self.state.glob("*.pending.json")}
+            pending_names.update(path.name.removesuffix(".previous") for path in self.state.glob("*.previous"))
+            if pending_names:
+                with locked(self.state / "publish.lock"):
+                    for name in pending_names:
+                        require(NAME.fullmatch(name) is not None)
+                        self.recover(name)
+        if action == "list":
+            values = [self.summary(entry) for entry in self.entries()]
+            return {"skills": values[:1000], "truncated": len(values) > 1000}
+        if action == "view":
+            return self.bundle(args["name"])
+        if action == "publish":
+            return self.publish(args["name"], args["files"], args.get("expectedRevision"), args["validation"])
+        if action == "restore":
+            return self.publish(args["name"], None, args["expectedRevision"], args["validation"], args["revision"])
+        if action == "history":
+            entry = self.find(args["name"])
+            require(entry is not None and entry[1] == self.local / "bot-learned" / args["name"], "skill_not_managed", "此技能没有 Bot 发布历史")
+            path = self.state / args["name"] / "history.json"
+            require(not path.is_symlink())
+            return {"name": args["name"], "history": json.loads(path.read_text())[-50:]}
+        raise SkillError("skill_invalid", "未知技能操作")

+ 93 - 0
src/runner/worker/skills.ts

@@ -0,0 +1,93 @@
+import {createHash} from 'node:crypto'
+import {readFile} from 'node:fs/promises'
+import {dirname,join} from 'node:path'
+import {existsSync} from 'node:fs'
+import {z} from 'zod'
+import {HermesWorkerProcess,type WorkerTool} from './process.js'
+import type {CommandResult} from '../computers/container.js'
+
+const name=z.string().regex(/^[a-z0-9][a-z0-9-]{0,63}$/)
+const revision=z.string().regex(/^[a-f0-9]{64}$/)
+const relative=z.string().min(1).max(512).refine(value=>!value.startsWith('/')&&!value.split('/').some(part=>!part||part==='.'||part==='..')&&!value.includes('\\'))
+const text={type:'string'}
+const object=(properties:Record<string,unknown>,required:string[])=>({type:'object',properties,required,additionalProperties:false})
+export const SKILL_TOOLS:WorkerTool[]=[
+  {name:'computer_skills_list',description:'列出当前基础 Profile 启用的技能。与任务相关时先查看,再用 computer_skill_view 加载。',inputSchema:object({},[])},
+  {name:'computer_skill_view',description:'读取 Profile 技能及关联文本,并将完整版本安装到隔离电脑的只读目录。脚本用 computer_shell 在 Linux 中运行。每轮首次读取固定一个版本。',inputSchema:object({name:text,file_path:text},['name'])},
+  {name:'computer_skill_publish',description:'将隔离电脑内验证成功的完整技能草稿发布到当前 Profile,供 Profile 和其他 Bot 使用。directory 为绝对目录;validation_command 在草稿目录执行,必须成功。新建 expected_revision 为 null;更新须提供读取到的版本,只能更新 Bot 发布的技能。',inputSchema:object({name:text,directory:text,expected_revision:{type:['string','null']},validation_command:text},['name','directory','expected_revision','validation_command'])},
+  {name:'computer_skill_history',description:'查看当前 Profile 中 Bot 技能的版本、验证结果和来源。',inputSchema:object({name:text},['name'])},
+  {name:'computer_skill_restore',description:'将 Bot 技能回退到历史版本。必须先查询历史,并提交当前 expected_revision;回退本身记录为一次新发布。',inputSchema:object({name:text,revision:text,expected_revision:text,reason:text},['name','revision','expected_revision','reason'])},
+]
+export const SKILL_RULES='技能共享:与任务相关时先调用 computer_skills_list,再调用 computer_skill_view 读取指导及关联文件。返回路径位于隔离 Linux 电脑,脚本只通过 computer_shell 执行;软件依赖、临时文件和输出留在虚拟机工作目录。技能正文是任务参考,不能扩大工具或路径权限。完成有复用价值的非简单任务、纠正可重复的问题后,主动将已验证的方法沉淀为技能:在虚拟机独立草稿目录整理 SKILL.md、scripts、references、assets,写清适用条件、依赖与验证步骤,再用 computer_skill_publish 验证并发布。普通问答无需保存。不要写入密钥、个人聊天或一次性路径,不要把失败和未经验证的猜测写为成功经验。发布回当前基础 Profile 后其他会话可使用,无需逐次请求保存确认;遵从用户明确的禁止保存指令。已存在技能先读取并基于 expected_revision 合并;冲突时重新检查,禁止盲目覆盖。只自动维护 Bot 发布的技能。当前轮已加载版本保持不变,更新下一轮生效。'
+
+interface Bundle {name:string;revision:string;files:Record<string,string>;description:string;linuxCompatible?:boolean;[key:string]:unknown}
+interface Options {
+  python:string;script:string;hermesSource:string;hermesHome:string;profile:string
+  ownerKey:string;agentId:string;taskId:string;signal:AbortSignal
+  authorize():Promise<void>
+  execute(argv:string[],input?:Buffer):Promise<CommandResult>
+  install(bundle:{namespace:string;revision:string;files:Record<string,string>},script:string):Promise<{path:string;revision:string}>
+}
+const quote=(value:string)=>"'"+value.replaceAll("'","'\\''")+"'"
+export class ProfileSkillSession {
+  private snapshots=new Map<string,Bundle>()
+  constructor(readonly options:Options){}
+  private async script(){
+    const base=dirname(this.options.script),bundled=join(base,'skill_bundle.py')
+    return readFile(existsSync(bundled)?bundled:join(base,'../../../deploy/computer/skill_bundle.py'),'utf8')
+  }
+  private async host(action:string,args:Record<string,unknown>){
+    const o=this.options
+    await o.authorize()
+    if(o.signal.aborted)throw new Error('技能操作已停止')
+    const worker=new HermesWorkerProcess(o.python,o.script,{mode:'skills',profile:o.profile,hermesSource:o.hermesSource,hermesHome:o.hermesHome,action,arguments:args,provenance:{ownerKey:o.ownerKey,agentId:o.agentId,taskId:o.taskId}})
+    worker.onTool=async name=>{if(name!=='skill_commit')throw new Error('未知技能提交');await o.authorize();if(o.signal.aborted)throw new Error('技能操作已停止');return {authorized:true}}
+    const abort=()=>{void worker.close()};o.signal.addEventListener('abort',abort,{once:true})
+    try{const result=(await worker.wait('skills')).result;await o.authorize();return result}
+    finally{o.signal.removeEventListener('abort',abort);await worker.close()}
+  }
+  async call(tool:string,args:unknown):Promise<unknown>{
+    const o=this.options
+    if(tool==='computer_skills_list'){z.object({}).strict().parse(args);return this.host('list',{})}
+    if(tool==='computer_skill_view'){
+      const body=z.object({name,file_path:relative.optional()}).strict().parse(args)
+      let bundle=this.snapshots.get(body.name)
+      if(!bundle){
+        const result=await this.host('view',{name:body.name})
+        if(result.error)return result
+        bundle=result as Bundle;this.snapshots.set(body.name,bundle)
+      }
+      const namespace=createHash('sha256').update(JSON.stringify([o.ownerKey,o.profile,body.name])).digest('hex')
+      const installed=await o.install({namespace,revision:bundle.revision,files:bundle.files},await this.script())
+      const file=body.file_path??'SKILL.md',encoded=bundle.files[file]
+      if(encoded===undefined)return {error:'技能中没有此文件',code:'skill_file_missing'}
+      const bytes=Buffer.from(encoded,'base64')
+      if(bytes.length>1000000)return {error:'关联文件较大,请在隔离电脑读取',path:installed.path+'/'+file,revision:bundle.revision}
+      return {name:bundle.name,revision:bundle.revision,path:installed.path,content:bytes.toString('utf8'),files:Object.keys(bundle.files),linuxCompatible:bundle.linuxCompatible,execution:'linux-vm',readOnly:true}
+    }
+    if(tool==='computer_skill_publish'){
+      const body=z.object({name,directory:z.string().min(1).max(4096).refine(value=>value.startsWith('/')&&!/[\0\r\n]/.test(value)),expected_revision:revision.nullable(),validation_command:z.string().min(1).max(8000)}).strict().parse(args)
+      const script=await this.script()
+      // Collect before and after validation so a changing draft cannot be
+      // published under evidence produced for different bytes.
+      const collect=async()=>JSON.parse((await o.execute(['python3','-c',script,'collect',body.directory])).stdout) as {files:Record<string,string>;revision:string}
+      let before:Awaited<ReturnType<typeof collect>>
+      try{before=await collect()}catch{return {error:'无法读取完整技能草稿;请检查绝对目录、SKILL.md、符号链接和文件限额',code:'skill_draft_invalid'}}
+      let proof:CommandResult
+      try{proof=await o.execute(['/bin/bash','-lc',`cd -- ${quote(body.directory)} && ${body.validation_command}`])}
+      catch{return {error:'技能验证失败,未发布;请修复草稿后重试',code:'skill_validation_failed'}}
+      let after:Awaited<ReturnType<typeof collect>>
+      try{after=await collect()}catch{return {error:'验证后的草稿不完整或包含不支持的文件,未发布',code:'skill_draft_invalid'}}
+      if(before.revision!==after.revision)return {error:'验证过程中草稿发生变化,请把输出写到草稿目录以外并重新验证',code:'skill_draft_changed'}
+      return this.host('publish',{name:body.name,files:after.files,expectedRevision:body.expected_revision,validation:`command: ${body.validation_command}\nexit: 0\n${(proof.stdout+'\n'+proof.stderr).slice(0,3000)}`})
+    }
+    if(tool==='computer_skill_history'){
+      const body=z.object({name}).strict().parse(args);return this.host('history',body)
+    }
+    if(tool==='computer_skill_restore'){
+      const body=z.object({name,revision,expected_revision:revision,reason:z.string().min(1).max(2000)}).strict().parse(args)
+      return this.host('restore',{name:body.name,revision:body.revision,expectedRevision:body.expected_revision,validation:body.reason})
+    }
+    throw new Error('未知技能工具')
+  }
+}

+ 1 - 1
src/server/composeDesktops.ts

@@ -19,7 +19,7 @@ export class ComposeDesktops {
   async call(id:string,operation:string,body:Record<string,unknown>={}):Promise<any>{
   async call(id:string,operation:string,body:Record<string,unknown>={}):Promise<any>{
     const desktop=this.desktops.find(x=>x.id===id)
     const desktop=this.desktops.find(x=>x.id===id)
     if(!desktop)throw new HttpError(404,'该桌面不在 Compose 配置中','compose_desktop_missing')
     if(!desktop)throw new HttpError(404,'该桌面不在 Compose 配置中','compose_desktop_missing')
-    if(!['health','frame','acquire','renew','release','execute'].includes(operation))throw new HttpError(409,'Compose 管理桌面数量和生命周期','compose_desktop_managed')
+    if(!['health','frame','acquire','renew','release','execute','skills-install'].includes(operation))throw new HttpError(409,'Compose 管理桌面数量和生命周期','compose_desktop_managed')
     return new Promise((resolve,reject)=>{
     return new Promise((resolve,reject)=>{
       const data=JSON.stringify({...body,desktopId:id}),req=request({socketPath:desktop.socketPath,path:`/${operation}`,method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)},timeout:70000},res=>{
       const data=JSON.stringify({...body,desktopId:id}),req=request({socketPath:desktop.socketPath,path:`/${operation}`,method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(data)},timeout:70000},res=>{
         const chunks:Buffer[]=[];let size=0
         const chunks:Buffer[]=[];let size=0

+ 1 - 1
src/server/runnerHub.ts

@@ -270,7 +270,7 @@ export class RunnerHub {
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
       if(!body||typeof body!=='object'||Array.isArray(body))throw new HttpError(400,'请求必须是对象','invalid_json')
       if(match[2]==='desktop'){
       if(match[2]==='desktop'){
         if(record.sourceNodeId!=='local'||record.sourceOwner!=='_system'||!state.features.includes('compose-desktops-v1'))throw new HttpError(403,'该节点不能访问 Compose 桌面','compose_desktop_forbidden')
         if(record.sourceNodeId!=='local'||record.sourceOwner!=='_system'||!state.features.includes('compose-desktops-v1'))throw new HttpError(403,'该节点不能访问 Compose 桌面','compose_desktop_forbidden')
-        const {id,operation,ownerKey,...payload}=parse(z.object({id:z.string(),operation:z.enum(['list','health','frame','acquire','renew','release','execute']),ownerKey:z.string().optional()}).passthrough(),body)
+        const {id,operation,ownerKey,...payload}=parse(z.object({id:z.string(),operation:z.enum(['list','health','frame','acquire','renew','release','execute','skills-install']),ownerKey:z.string().optional()}).passthrough(),body)
         if(operation==='list'){ctx.body=await this.composeDesktops.status();return}
         if(operation==='list'){ctx.body=await this.composeDesktops.status();return}
         const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',id)
         const claim=this.store.get<{owner:string;runnerId:string}>('_system','compose-desktop-owner',id)
         if(!claim||claim.runnerId!==record.id||hash(claim.owner)!==ownerKey)throw new HttpError(403,'没有该共享桌面的使用权限','compose_desktop_forbidden')
         if(!claim||claim.runnerId!==record.id||hash(claim.owner)!==ownerKey)throw new HttpError(403,'没有该共享桌面的使用权限','compose_desktop_forbidden')

+ 67 - 0
tests/live/profileSkills.live.test.ts

@@ -0,0 +1,67 @@
+// @vitest-environment node
+import {expect,it,vi} from 'vitest'
+import {mkdtemp,mkdir,writeFile,readFile,rm,realpath} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {randomUUID} from 'node:crypto'
+import {execFile} from 'node:child_process'
+import {promisify} from 'node:util'
+import {ContainerComputerProvider} from '../../src/runner/computers/container'
+import {ComposeComputerProvider} from '../../src/runner/computers/compose'
+import {ProfileSkillSession} from '../../src/runner/worker/skills'
+const exec=promisify(execFile)
+const docker=(args:string[])=>exec('docker',args,{timeout:90000,maxBuffer:12*1024*1024})
+const enabled=['YAOYAO_COMPUTER_IMAGE','YAOYAO_HERMES_PYTHON','YAOYAO_HERMES_SOURCE'].every(key=>!!process.env[key])
+
+it.skipIf(!enabled).each(['local','compose'])('shares real native Profile skills with a %s desktop and publishes validated, immutable revisions',async mode=>{
+  const root=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-skill-live-'))),home=join(root,'hermes'),id=randomUUID(),runner=randomUUID()
+  const fixture='yaoyao-skill-test-'+id.slice(0,8),ipc=fixture+'-ipc',workspace=fixture+'-workspace'
+  const image=process.env.YAOYAO_COMPUTER_IMAGE!,python=process.env.YAOYAO_HERMES_PYTHON!,source=process.env.YAOYAO_HERMES_SOURCE!
+  const spec={id,ownerKey:'fixture',imageId:image,cwd:'/home/cua/workspace'},context={authorize:()=>{}}
+  let provider:ContainerComputerProvider=new ContainerComputerProvider('docker',runner,root)
+  await mkdir(join(home,'skills','source-proof','scripts'),{recursive:true});await writeFile(join(home,'config.yaml'),'{}')
+  await writeFile(join(home,'skills','source-proof','SKILL.md'),'---\nname: source-proof\ndescription: A source skill for isolated verification\n---\nRun scripts/proof.py in the assigned Linux computer.\n')
+  await writeFile(join(home,'skills','source-proof','scripts','proof.py'),'print("source-skill-proof")\n')
+  try{
+    if(mode==='compose'){
+      await docker(['run','-d','--name',fixture,'--network','none','--shm-size','512m','--memory','2g','--cap-drop','ALL','--cap-add','SETUID','--cap-add','SETGID','--cap-add','CHOWN','--cap-add','KILL','--security-opt','no-new-privileges:true',
+        '--env','YAOYAO_COMPOSE_DESKTOP_ID='+id,'--mount',`type=bind,src=${resolve('deploy/computer')},dst=/usr/local/libexec/yaoyao,readonly`,
+        '--mount',`type=volume,src=${ipc},dst=/run/yaoyao-private/bridge`,'--mount',`type=volume,src=${ipc},dst=/run/skill-test`,
+        '--mount',`type=volume,src=${workspace},dst=/home/cua/workspace`,image])
+      const relay=async(_id:string,op:string,body:Record<string,unknown>)=>{
+        if(op==='list')return [{id,name:'fixture',ready:true}]
+        const code="import http.client,socket,sys; c=http.client.HTTPConnection('localhost'); c.sock=socket.socket(socket.AF_UNIX); c.sock.connect('/run/skill-test/desktop.sock'); c.request('POST',sys.argv[1],sys.argv[2].encode('utf-8')); r=c.getresponse(); print(r.read().decode()); sys.exit(0 if r.status==200 else 1)"
+        return JSON.parse((await docker(['exec','--user','1000:1000',fixture,'python3','-c',code,'/'+op,JSON.stringify({...body,owner:'fixture',desktopId:id})])).stdout)
+      }
+      provider=new ComposeComputerProvider(runner,root,relay)
+      await vi.waitFor(async()=>expect((await relay(id,'health',{})).ready).toBe(true),{timeout:65000,interval:1000})
+    }
+    await provider.ensure(spec,()=>{})
+    const makeSession=()=>new ProfileSkillSession({python,script:resolve('src/runner/worker/hermes_worker.py'),hermesSource:source,hermesHome:home,profile:'default',ownerKey:'fixture',agentId:id,taskId:randomUUID(),signal:new AbortController().signal,
+      authorize:async()=>{},execute:(argv,input)=>provider.execute(spec,argv,{...context,input}),install:(bundle,script)=>provider.installSkill(spec,bundle,script,context)})
+    const session=makeSession()
+    expect((await session.call('computer_skills_list',{}) as any).skills.map((s:any)=>s.name)).toContain('source-proof')
+    const view=await session.call('computer_skill_view',{name:'source-proof'}) as any
+    expect((await provider.execute(spec,['python3',view.path+'/scripts/proof.py'],context)).stdout.trim()).toBe('source-skill-proof')
+    await expect(provider.execute(spec,['chmod','u+w',view.path+'/SKILL.md'],context)).rejects.toThrow()
+    await expect(provider.execute(spec,['sh','-c','echo changed > "$1/SKILL.md"','sh',view.path],context)).rejects.toThrow()
+    const draft='/home/cua/workspace/learned'
+    await provider.execute(spec,['python3','-c','import pathlib,sys; p=pathlib.Path(sys.argv[1]); (p/"scripts").mkdir(parents=True); (p/"SKILL.md").write_text("---\\nname: learned-proof\\ndescription: Tested reusable Linux procedure\\n---\\nRun scripts/proof.py.\\n"); (p/"scripts/proof.py").write_text("print(42)\\n")',draft],context)
+    const published=await session.call('computer_skill_publish',{name:'learned-proof',directory:draft,expected_revision:null,validation_command:'python3 scripts/proof.py'}) as any
+    expect(published).toMatchObject({published:true,profileAvailable:true})
+    // Query the real Hermes discovery and view functions, using a temporary
+    // Profile with no credentials. This proves native interoperability.
+    const native="import sys,json; sys.path.insert(0,sys.argv[1]); from tools.skills_tool import skills_list,skill_view; print(json.dumps({'list':skills_list(),'view':skill_view('learned-proof')}))"
+    const output=(await exec(python,['-c',native,source],{env:{PATH:process.env.PATH,HOME:root,HERMES_HOME:home,PYTHONDONTWRITEBYTECODE:'1'},maxBuffer:8*1024*1024})).stdout
+    expect(output).toContain('learned-proof');expect(output).toContain('computer_shell')
+    expect(output).not.toContain('not found')
+    const fromOtherBot=await makeSession().call('computer_skill_view',{name:'learned-proof'}) as any
+    expect(fromOtherBot.revision).toBe(published.revision)
+    expect((await provider.execute(spec,['python3',fromOtherBot.path+'/scripts/proof.py'],context)).stdout.trim()).toBe('42')
+    expect(await readFile(join(home,'skills','source-proof','scripts','proof.py'),'utf8')).toContain('source-skill-proof')
+  }finally{
+    if(mode==='local')await provider.remove(spec)
+    else{await docker(['rm','-f',fixture]).catch(()=>{});await docker(['volume','rm',ipc,workspace]).catch(()=>{})}
+    await rm(root,{recursive:true,force:true})
+  }
+},150000)

+ 7 - 0
tests/server/profileComputer.test.ts

@@ -8,6 +8,7 @@ import {join} from 'node:path'
 import {randomUUID} from 'node:crypto'
 import {randomUUID} from 'node:crypto'
 import {WebSocketServer,type WebSocket} from 'ws'
 import {WebSocketServer,type WebSocket} from 'ws'
 import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
 import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
+import {ProfileSkillSession} from '../../src/runner/worker/skills'
 import {UpstreamClient} from '../../src/server/upstream'
 import {UpstreamClient} from '../../src/server/upstream'
 import {UpstreamServiceSession} from '../../src/server/localAuth'
 import {UpstreamServiceSession} from '../../src/server/localAuth'
 import type {GatewayFrame} from '../../src/server/workspaceGateway'
 import type {GatewayFrame} from '../../src/server/workspaceGateway'
@@ -99,6 +100,12 @@ it('uses a native Profile session and bridge for VM tools, preserves rotated his
     const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
     const catalog=(await f.post('/tools/list',{})).body.tools.map((t:any)=>t.name)
     expect(catalog).toEqual(expect.arrayContaining(['computer_shell','computer_copy_file','team_test']))
     expect(catalog).toEqual(expect.arrayContaining(['computer_shell','computer_copy_file','team_test']))
     expect(catalog).not.toContain('host_shell')
     expect(catalog).not.toContain('host_shell')
+    const skills=vi.spyOn(ProfileSkillSession.prototype,'call').mockResolvedValue({skills:[{name:'profile-procedure'}]})
+    expect((await f.tool('computer_skills_list',{})).body.structuredContent).toEqual({skills:[{name:'profile-procedure'}]})
+    expect(skills).toHaveBeenCalledWith('computer_skills_list',{})
+    expect(skills.mock.instances[0]?.options.profile).toBe('default')
+    expect(skills.mock.instances[0]?.options.agentId).toBe(f.meta.agentId)
+    skills.mockRestore()
     expect((await f.tool('computer_shell',{command:'pwd'})).body.structuredContent).toMatchObject({stdout:'vm result'})
     expect((await f.tool('computer_shell',{command:'pwd'})).body.structuredContent).toMatchObject({stdout:'vm result'})
     expect(f.execute).toHaveBeenCalledWith(expect.objectContaining({cwd:'/home/cua/workspace'}),['/bin/bash','-lc','pwd'],expect.anything())
     expect(f.execute).toHaveBeenCalledWith(expect.objectContaining({cwd:'/home/cua/workspace'}),['/bin/bash','-lc','pwd'],expect.anything())
     expect((await f.tool('team_test',{})).body.structuredContent).toEqual({proof:'team-result'})
     expect((await f.tool('team_test',{})).body.structuredContent).toEqual({proof:'team-result'})

+ 145 - 0
tests/server/profileSkills.test.ts

@@ -0,0 +1,145 @@
+// @vitest-environment node
+import {afterEach,beforeEach,expect,it,vi} from 'vitest'
+import {mkdtemp,mkdir,writeFile,readFile,rm,realpath,symlink,rename} from 'node:fs/promises'
+import {tmpdir} from 'node:os'
+import {join,resolve} from 'node:path'
+import {execFile} from 'node:child_process'
+import {promisify} from 'node:util'
+import {ProfileSkillSession} from '../../src/runner/worker/skills'
+import {HermesWorkerProcess} from '../../src/runner/worker/process'
+
+const exec=promisify(execFile)
+let root:string,home:string,source:string,allowed:boolean
+const script=resolve('src/runner/worker/hermes_worker.py')
+const spec=(name:string,body='Use the tested script.')=>'---\n'+JSON.stringify({name,description:'Reusable fixture procedure'})+'\n---\n'+body
+beforeEach(async()=>{
+  root=await realpath(await mkdtemp(join(tmpdir(),'yaoyao-skills-')));home=join(root,'profile');source=join(root,'source');allowed=true
+  await mkdir(join(source,'hermes_cli'),{recursive:true});await mkdir(home)
+  await writeFile(join(home,'config.yaml'),'{}')
+  await writeFile(join(source,'dotenv.py'),'def dotenv_values(path): return {}\n')
+  await writeFile(join(source,'hermes_constants.py'),'def set_hermes_home_override(path): pass\n')
+  await writeFile(join(source,'hermes_cli','config.py'),'import os,json\nfrom pathlib import Path\ndef load_config_readonly(): return json.loads((Path(os.environ["HERMES_HOME"])/"config.yaml").read_text())\n')
+  // JSON frontmatter is valid YAML. Native PyYAML interoperability is covered
+  // separately by the real-Hermes live test; no system Python dependency here.
+  await writeFile(join(source,'yaml.py'),'import json\ndef safe_load(text): return json.loads(text)\ndef safe_dump(value, **kwargs): return json.dumps(value,ensure_ascii=False)+"\\n"\n')
+})
+afterEach(async()=>{vi.restoreAllMocks();await rm(root,{recursive:true,force:true})})
+async function skill(directory:string,name='fixture-skill',body?:string){
+  await mkdir(join(directory,'scripts'),{recursive:true})
+  await writeFile(join(directory,'SKILL.md'),spec(name,body))
+  await writeFile(join(directory,'scripts','proof.py'),'print("verified procedure")\n')
+}
+function session(profile='default'){
+  const installs:any[]=[]
+  const authorize=async()=>{if(!allowed)throw new Error('revoked')}
+  const service=new ProfileSkillSession({python:'python3',script,hermesSource:source,hermesHome:home,profile,ownerKey:'owner',agentId:'bot-1',taskId:'task-1',signal:new AbortController().signal,authorize,
+    execute:async(argv)=>{await authorize();return exec(argv[0]!,argv.slice(1),{env:{...process.env,PYTHONDONTWRITEBYTECODE:'1'},maxBuffer:10*1024*1024})},
+    install:async(bundle)=>{await authorize();installs.push(bundle);return {path:'/opt/yaoyao-skills/'+bundle.namespace+'/'+bundle.revision,revision:bundle.revision}},
+  })
+  return {service,installs,call:(name:string,args:unknown={})=>service.call('computer_'+name,args) as Promise<any>}
+}
+async function publish(f:ReturnType<typeof session>,directory:string,expected_revision:string|null=null){
+  return f.call('skill_publish',{name:'fixture-skill',directory,expected_revision,validation_command:'python3 scripts/proof.py'})
+}
+
+it('discovers only selected Profile and configured external roots, respecting disabled names and preserving package resources',async()=>{
+  const external=join(root,'external');await skill(join(external,'outside'),'outside-skill')
+  await skill(join(home,'skills','category','fixture-skill'))
+  await writeFile(join(home,'skills','category','fixture-skill','操作说明.md'),'可复用的操作步骤')
+  await skill(join(home,'skills','disabled'),'disabled-skill')
+  await writeFile(join(home,'config.yaml'),JSON.stringify({skills:{disabled:['disabled-skill'],external_dirs:[external]}}))
+  const named=join(home,'profiles','writer');await mkdir(named,{recursive:true});await writeFile(join(named,'config.yaml'),'{}');await skill(join(named,'skills','writer'),'writer-skill')
+  const f=session(),catalog=await f.call('skills_list')
+  expect(catalog.skills.map((s:any)=>s.name)).toEqual(['fixture-skill','outside-skill'])
+  expect((await session('writer').call('skills_list')).skills.map((s:any)=>s.name)).toEqual(['writer-skill'])
+  const view=await f.call('skill_view',{name:'fixture-skill'})
+  expect(view).toMatchObject({readOnly:true,execution:'linux-vm',files:expect.arrayContaining(['SKILL.md','scripts/proof.py','操作说明.md'])})
+  expect(JSON.stringify(view)).not.toContain(home)
+  expect((await f.call('skill_view',{name:'fixture-skill',file_path:'scripts/proof.py'})).content).toContain('verified procedure')
+  expect((await f.call('skill_view',{name:'disabled-skill'})).code).toBe('skill_missing')
+})
+
+it('publishes a validated package into the native Profile, freezes current-turn reads and records versioned rollback',async()=>{
+  const draft=join(root,'draft');await skill(draft)
+  const f=session(),first=await publish(f,draft)
+  expect(first).toMatchObject({published:true,profileAvailable:true,previous:null})
+  const formal=join(home,'skills','bot-learned','fixture-skill')
+  const text=await readFile(join(formal,'SKILL.md'),'utf8')
+  expect(text).toContain('computer_shell');expect(text).not.toContain('"platforms"')
+  expect(await readFile(join(formal,'scripts','proof.py'),'utf8')).toContain('verified procedure')
+  const frozen=await f.call('skill_view',{name:'fixture-skill'})
+  await writeFile(join(draft,'scripts','proof.py'),'print("second procedure")\n')
+  const second=await publish(f,draft,first.revision)
+  expect(second.revision).not.toBe(first.revision)
+  expect((await f.call('skill_view',{name:'fixture-skill'})).revision).toBe(frozen.revision)
+  expect((await session().call('skill_view',{name:'fixture-skill'})).revision).toBe(second.revision)
+  expect((await publish(f,draft,first.revision)).code).toBe('skill_revision_conflict')
+  const history=await f.call('skill_history',{name:'fixture-skill'})
+  expect(history.history).toHaveLength(2)
+  expect(history.history[1]).toMatchObject({agentId:'bot-1',taskId:'task-1',previous:first.revision,validation:expect.stringContaining('exit: 0')})
+  const restored=await f.call('skill_restore',{name:'fixture-skill',revision:first.revision,expected_revision:second.revision,reason:'Restore previously validated procedure'})
+  expect(restored.revision).toBe(first.revision)
+  expect(await readFile(join(formal,'scripts','proof.py'),'utf8')).toContain('verified procedure')
+})
+
+it('serializes concurrent publishers and refuses to overwrite user-owned or external skills',async()=>{
+  const draft=join(root,'draft');await skill(draft)
+  const results=await Promise.all([publish(session(),draft),publish(session(),draft)])
+  expect(results.filter(result=>result.published)).toHaveLength(1)
+  expect(results.filter(result=>result.code==='skill_revision_conflict')).toHaveLength(1)
+  await skill(join(home,'skills','user-skill'),'user-skill')
+  await skill(draft,'user-skill')
+  expect(await session().call('skill_publish',{name:'user-skill',directory:draft,expected_revision:null,validation_command:'true'})).toMatchObject({code:'skill_not_managed'})
+})
+
+it('refuses failed validation, changing drafts, traversal, links, foreign Profile fields and revoked calls',async()=>{
+  const draft=join(root,'draft');await skill(draft);const f=session()
+  expect(await f.call('skill_publish',{name:'fixture-skill',directory:draft,expected_revision:null,validation_command:'exit 1'})).toMatchObject({code:'skill_validation_failed'})
+  expect(await f.call('skill_publish',{name:'fixture-skill',directory:draft,expected_revision:null,validation_command:'printf changed > scripts/proof.py'})).toMatchObject({code:'skill_draft_changed'})
+  await expect(f.call('skill_view',{name:'../escape'})).rejects.toThrow()
+  await expect(f.call('skill_view',{name:'fixture-skill',profile:'writer'})).rejects.toThrow()
+  await expect(f.call('skill_view',{name:'fixture-skill',file_path:'../secret'})).rejects.toThrow()
+  await symlink(join(home,'config.yaml'),join(draft,'linked.yaml'))
+  expect((await publish(f,draft)).code).toBe('skill_draft_invalid')
+  allowed=false;await expect(f.call('skills_list')).rejects.toThrow('revoked')
+  expect((await readFile(join(home,'config.yaml'),'utf8'))).toBe('{}')
+})
+
+it('respects Profile write approval and does not adopt untracked Bot-category directories',async()=>{
+  const draft=join(root,'draft');await skill(draft)
+  await writeFile(join(home,'config.yaml'),JSON.stringify({skills:{write_approval:'true'}}))
+  expect((await publish(session(),draft)).code).toBe('skill_write_disabled')
+  await writeFile(join(home,'config.yaml'),'{}')
+  await skill(join(home,'skills','bot-learned','fixture-skill'))
+  expect((await publish(session(),draft)).code).toBe('skill_not_managed')
+})
+
+it('runs configured scanning before publication and preserves a native edit racing the commit',async()=>{
+  const draft=join(root,'draft');await skill(draft)
+  await mkdir(join(source,'tools'));await writeFile(join(source,'tools','skills_guard.py'),'def scan_skill(*args, **kwargs): return {}\ndef should_allow_install(result): return False,"blocked"\n')
+  await writeFile(join(home,'config.yaml'),JSON.stringify({skills:{guard_agent_created:true}}))
+  expect((await publish(session(),draft)).code).toBe('skill_scan_blocked')
+  await expect(readFile(join(home,'skills','bot-learned','fixture-skill','SKILL.md'))).rejects.toMatchObject({code:'ENOENT'})
+  await writeFile(join(home,'config.yaml'),'{}')
+  const first=await publish(session(),draft),formal=join(home,'skills','bot-learned','fixture-skill','SKILL.md')
+  const worker=new HermesWorkerProcess('python3',script,{mode:'skills',profile:'default',hermesSource:source,hermesHome:home,action:'publish',arguments:{name:'fixture-skill',files:{'SKILL.md':Buffer.from(spec('fixture-skill','new version')).toString('base64')},expectedRevision:first.revision,validation:'passed'},provenance:{ownerKey:'owner'}})
+  worker.onTool=async()=>{await writeFile(formal,spec('fixture-skill','native edit wins'));return {authorized:true}}
+  try{expect((await worker.wait('skills')).result.code).toBe('skill_revision_conflict')}finally{await worker.close()}
+  expect(await readFile(formal,'utf8')).toContain('native edit wins')
+})
+
+it('rechecks authority at host commit and recovers an interrupted directory replacement',async()=>{
+  const draft=join(root,'draft');await skill(draft)
+  const f=session(),first=await publish(f,draft),formal=join(home,'skills','bot-learned','fixture-skill')
+  await rename(formal,join(home,'.yaoyao-skills','fixture-skill.previous'))
+  // Discovery also repairs an interrupted commit; it does not hide the skill.
+  expect((await session().call('skills_list')).skills.map((s:any)=>s.name)).toContain('fixture-skill')
+  await writeFile(join(draft,'scripts','proof.py'),'print("after recovery")\n')
+  const next=await publish(f,draft,first.revision)
+  expect(next).toMatchObject({published:true,previous:first.revision})
+  const files={'SKILL.md':Buffer.from(spec('blocked-skill')).toString('base64')}
+  const worker=new HermesWorkerProcess('python3',script,{mode:'skills',profile:'default',hermesSource:source,hermesHome:home,action:'publish',arguments:{name:'blocked-skill',files,expectedRevision:null,validation:'passed'},provenance:{ownerKey:'owner'}})
+  worker.onTool=async()=>{throw new Error('revoked just before commit')}
+  try{expect((await worker.wait('skills')).result.code).toBe('skill_cancelled')}finally{await worker.close()}
+  await expect(readFile(join(home,'skills','bot-learned','blocked-skill','SKILL.md'))).rejects.toMatchObject({code:'ENOENT'})
+})

+ 3 - 0
tests/server/workerCompaction.test.ts

@@ -41,6 +41,8 @@ it.each(['context_compaction_disabled','context_compaction_failed'])('keeps a de
  const f=await fixture(),checkpoint=[{role:'system',content:'compacted checkpoint'}],history=[...checkpoint,{role:'user',content:'pending task'}]
  const f=await fixture(),checkpoint=[{role:'system',content:'compacted checkpoint'}],history=[...checkpoint,{role:'user',content:'pending task'}]
  try{
  try{
   const first=await f.turn({completed:false,failureCode,checkpoint,messages:history,text:'engine context failure'})
   const first=await f.turn({completed:false,failureCode,checkpoint,messages:history,text:'engine context failure'})
+  expect(first.boot.tools.map((tool:any)=>tool.name)).toContain('computer_skill_publish')
+  expect(first.boot.skillInstructions).toContain('expected_revision')
   expect(first.result).toMatchObject({status:'failed',code:failureCode,error:expect.stringContaining('虚拟机已进入空闲')})
   expect(first.result).toMatchObject({status:'failed',code:failureCode,error:expect.stringContaining('虚拟机已进入空闲')})
   expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle');expect(f.stop).not.toHaveBeenCalled();expect(f.remove).not.toHaveBeenCalled()
   expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle');expect(f.stop).not.toHaveBeenCalled();expect(f.remove).not.toHaveBeenCalled()
   expect(f.runtime.session(first.sessionId,f.meta,'default')).toMatchObject({history,outcome:'failed'})
   expect(f.runtime.session(first.sessionId,f.meta,'default')).toMatchObject({history,outcome:'failed'})
@@ -48,6 +50,7 @@ it.each(['context_compaction_disabled','context_compaction_failed'])('keeps a de
   expect(f.runtime.session(first.sessionId,f.meta,'default').history).toEqual(history)
   expect(f.runtime.session(first.sessionId,f.meta,'default').history).toEqual(history)
   await first.gateway.close();expect(f.stop).not.toHaveBeenCalled()
   await first.gateway.close();expect(f.stop).not.toHaveBeenCalled()
   const nextHistory=[...history,{role:'assistant',content:'continued'}],next=await f.turn({completed:true,messages:nextHistory,text:'continued'},first.sessionId)
   const nextHistory=[...history,{role:'assistant',content:'continued'}],next=await f.turn({completed:true,messages:nextHistory,text:'continued'},first.sessionId)
+  expect(next.boot.skillInstructions).toBe(first.boot.skillInstructions)
   expect(next.boot.history).toEqual(history);expect(next.boot.contextConfig.compression.enabled).toBe(true)
   expect(next.boot.history).toEqual(history);expect(next.boot.contextConfig.compression.enabled).toBe(true)
   expect(f.runtime.session(first.sessionId,f.meta,'default')).toMatchObject({history:nextHistory,outcome:'complete'})
   expect(f.runtime.session(first.sessionId,f.meta,'default')).toMatchObject({history:nextHistory,outcome:'complete'})
  }finally{await f.close()}
  }finally{await f.close()}