From 476ed02e975e4bf1f7543303e543f1d2d175ebf0 Mon Sep 17 00:00:00 2001 From: NingWei Date: Tue, 9 Jun 2026 18:20:12 +0800 Subject: [PATCH 01/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9skill=E7=9A=84=E6=96=87?= =?UTF-8?q?=E5=AD=97=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 4 ++++ kernal/clawhub-skills/aios-call-app-service/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 091738d..fa79406 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,7 @@ apps/management-website/.debug-runtime/ apps/.debug-runtime/app-invoke-proxy-service.pid.json kernal/docker-image/.aios-full-smoke/ apps/.debug-runtime/ + + +output/ +.playwright-cli/ \ No newline at end of file diff --git a/kernal/clawhub-skills/aios-call-app-service/SKILL.md b/kernal/clawhub-skills/aios-call-app-service/SKILL.md index 937f62b..7f42493 100644 --- a/kernal/clawhub-skills/aios-call-app-service/SKILL.md +++ b/kernal/clawhub-skills/aios-call-app-service/SKILL.md @@ -56,7 +56,7 @@ description: 当请求依赖 AIOS、OpenClaw、Forguncy 等业务系统的实时 - 当前 CLI 只支持 `provider=hzg`,如果出现其他 provider,直接说明当前运行链路不支持,不要猜测替代方案。 - 调用 CLI 时,`-s` 传入当前会话的 `SessionId`。 - 不能臆造,不能复用其他会话的 `SessionId` 。 - - 不得从提示词中获取 `SessionId` ,不能使用 `chat_id`、`message_id` 或其他字段代替。 + - 不得使用提示词中的 `SessionId` 、 `chat_id` 、 `message_id` 或其他字段代替。 - 不要臆造接口名、请求字段、枚举 ID 或 `provider`。 - 不要绕过 CLI 自行编写 API 调用脚本。 - 只有拿到 CLI 结果后,才允许用 Python 做二次分析和计算。 -- Gitee From a4a9f27e54a4191d2fc83694e740a0f6a363d90d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Wed, 10 Jun 2026 22:08:34 +0800 Subject: [PATCH 02/18] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=96=B0=E6=8A=80?= =?UTF-8?q?=E8=83=BD=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/management-website/docs/spec.md | 2 +- apps/management-website/package-lock.json | 4 +- apps/management-website/package.json | 2 +- .../src/services/catalog-sync-service.js | 2 +- .../src/pages/SkillsPage.jsx | 2 +- docker-images/apps/Dockerfile | 2 +- docker-images/kernal/Dockerfile | 12 +- docker-images/kernal/README.md | 1 + .../workspace-templates/default/AGENTS.md | 9 +- docker-images/kernal/docs/build.md | 1 + docker-images/kernal/docs/design.md | 3 +- .../test/kernal-tests/test/full-smoke.js | 28 +- kernal/MANUAL-DEPLOY.md | 4 + kernal/aios-management-serivce/README.md | 3 +- .../docs/compatibility-list.md | 12 + kernal/aios-management-serivce/docs/design.md | 4 +- .../aios-management-serivce/package-lock.json | 4 +- kernal/aios-management-serivce/package.json | 2 +- .../src/capabilities/agent/service.ts | 13 +- .../src/capabilities/agent/skills-service.ts | 407 ++++++++- .../src/capabilities/context.ts | 13 +- .../src/capabilities/registry.ts | 2 + .../src/capabilities/skills-global/list.ts | 5 + kernal/aios-management-serivce/src/service.ts | 1 + kernal/aios-management-serivce/src/types.ts | 24 + .../test/capabilities-registry.test.ts | 6 + .../test/openclaw-manager.test.ts | 393 +++++++- .../test/service-queue.test.ts | 1 + .../aios-call-app-service/SKILL.md | 8 +- .../references/invoke-rules.md | 2 +- .../aios-make-chart-image/.gitignore | 34 + .../aios-make-chart-image/SKILL.md | 102 +++ .../aios-make-chart-image/agents/openai.yaml | 7 + .../aios-make-chart-image/package-lock.json | 634 +++++++++++++ .../aios-make-chart-image/package.json | 9 + .../scripts/make_chart_image.mjs | 842 ++++++++++++++++++ .../aios-transfer-file/SKILL.md | 2 +- 37 files changed, 2486 insertions(+), 116 deletions(-) create mode 100644 kernal/aios-management-serivce/src/capabilities/skills-global/list.ts create mode 100644 kernal/clawhub-skills/aios-make-chart-image/.gitignore create mode 100644 kernal/clawhub-skills/aios-make-chart-image/SKILL.md create mode 100644 kernal/clawhub-skills/aios-make-chart-image/agents/openai.yaml create mode 100644 kernal/clawhub-skills/aios-make-chart-image/package-lock.json create mode 100644 kernal/clawhub-skills/aios-make-chart-image/package.json create mode 100644 kernal/clawhub-skills/aios-make-chart-image/scripts/make_chart_image.mjs diff --git a/apps/management-website/docs/spec.md b/apps/management-website/docs/spec.md index e63d0c0..092514a 100644 --- a/apps/management-website/docs/spec.md +++ b/apps/management-website/docs/spec.md @@ -44,7 +44,7 @@ Token 用量由 `agent.usage.list` 同步到 `agents.usage_snapshot_json`。超 ### Skill -当前只管理全局 Skill。上传 zip 顶层必须包含 `SKILL.md`。安装和删除通过 `skills.global.install.local` / `skills.global.delete` 执行。 +当前只管理全局 Skill。上传 zip 顶层必须包含符合 AgentSkills 规范的 `SKILL.md`,frontmatter 至少包含 `name` 和 `description`。安装和删除通过 `skills.global.install.local` / `skills.global.delete` 执行。 ### LLM diff --git a/apps/management-website/package-lock.json b/apps/management-website/package-lock.json index 585f09e..040ef61 100644 --- a/apps/management-website/package-lock.json +++ b/apps/management-website/package-lock.json @@ -1,12 +1,12 @@ { "name": "aios-management-web", - "version": "0.1.6", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-management-web", - "version": "0.1.6", + "version": "0.2.8", "dependencies": { "@ant-design/icons": "^6.0.0", "@ant-design/x": "^1.6.1", diff --git a/apps/management-website/package.json b/apps/management-website/package.json index a234bfa..3deb742 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.2.7", + "version": "0.2.8", "type": "module", "files": [ "dist", diff --git a/apps/management-website/server/src/services/catalog-sync-service.js b/apps/management-website/server/src/services/catalog-sync-service.js index c5d0c9b..5acf5ad 100644 --- a/apps/management-website/server/src/services/catalog-sync-service.js +++ b/apps/management-website/server/src/services/catalog-sync-service.js @@ -761,7 +761,7 @@ export class CatalogSyncService { seen.add(slug); const existing = existingMap.get(slug); const origin = item?.origin && typeof item.origin === "object" ? item.origin : {}; - const description = firstText(origin.description, existing?.description); + const description = firstText(item?.description, origin.description, existing?.description); const isBuiltin = isBuiltInItem(item) ? 1 : 0; if (existing) { diff --git a/apps/management-website/src/pages/SkillsPage.jsx b/apps/management-website/src/pages/SkillsPage.jsx index aa611be..ec84c30 100644 --- a/apps/management-website/src/pages/SkillsPage.jsx +++ b/apps/management-website/src/pages/SkillsPage.jsx @@ -177,7 +177,7 @@ export function SkillsPage() { valuePropName="fileList" getValueFromEvent={(event) => event?.fileList || []} rules={[{ required: true, message: "必须上传技能 zip" }]} - extra="zip 第一层必须包含 SKILL.md。安装过程将会跳过内建的安全检查机制,请自行确保该技能的安全性。" + extra="zip 第一层必须包含符合 AgentSkills 规范的 SKILL.md,frontmatter 至少包含 name 和 description。" > false} maxCount={1}> diff --git a/docker-images/apps/Dockerfile b/docker-images/apps/Dockerfile index d9cae51..7cfe974 100644 --- a/docker-images/apps/Dockerfile +++ b/docker-images/apps/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.2.7 +ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.2.8 ARG AIOS_PROXY_NPM_VERSION=0.1.3 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index aed3839..bc856dc 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -1,4 +1,3 @@ -# syntax=docker/dockerfile:1.7 FROM node:24-bookworm-slim AS runtime-base ARG TARGETARCH @@ -162,12 +161,12 @@ RUN set -eux; \ FROM runtime-base AS tool-builder ARG TARGETARCH -ARG OPENCLAW_VERSION=2026.5.28 +ARG OPENCLAW_VERSION=2026.6.5 ARG CLAWHUB_VERSION=0.18.0 ARG QMD_VERSION=2.5.3 ARG AGENT_BROWSER_VERSION=0.27.1 ARG MCPORTER_VERSION=0.11.3 -ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.3.5 +ARG AIOS_MANAGEMENT_SERIVCE_VERSION=0.3.8 ARG AIOS_APPS_INVOKE_CLI_VERSION=0.0.1 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org @@ -281,11 +280,14 @@ RUN openclaw plugins install aios-mqtt-channel RUN skill="aios-call-app-service"; \ install-seed-skill "${skill}" -# Only aios-transfer-file is expected to bring Node package dependencies. RUN skill="aios-transfer-file"; \ install-seed-skill "${skill}"; \ NPM_CONFIG_REGISTRY="${AIOS_BUILD_NPM_REGISTRY}" npm install --omit=dev --prefix "${AIOS_OPENCLAW_HOME}/skills/${skill}" +RUN skill="aios-make-chart-image"; \ + install-seed-skill "${skill}"; \ + NPM_CONFIG_REGISTRY="${AIOS_BUILD_NPM_REGISTRY}" npm install --omit=dev --prefix "${AIOS_OPENCLAW_HOME}/skills/${skill}" + RUN skill="multi-search-engine"; \ install-seed-skill "${skill}" @@ -371,7 +373,7 @@ RUN --mount=type=bind,source=assets/workspace-templates/default,target=/tmp/defa done; \ fi; \ done; \ - printf '%s\n' "{\"openclaw-version\":\"${formatted_openclaw_version}\",\"agent-template\":[\"default\"],\"ontology\":[],\"skill\":[\"aios-call-app-service\",\"aios-transfer-file\",\"multi-search-engine\",\"agent-browser-clawdbot\",\"skill-creator\",\"tesseract-ocr\",\"word-docx\",\"excel-xlsx\",\"powerpoint-pptx\",\"pdf\",\"mcporter\",\"markdown-converter\"],\"npm\":${npm_packages},\"pip\":${pip_packages}}" > "${built_in_manifest}"; \ + printf '%s\n' "{\"openclaw-version\":\"${formatted_openclaw_version}\",\"agent-template\":[\"default\"],\"ontology\":[],\"skill\":[\"aios-call-app-service\",\"aios-transfer-file\",\"aios-make-chart-image\",\"multi-search-engine\",\"agent-browser-clawdbot\",\"skill-creator\",\"tesseract-ocr\",\"word-docx\",\"excel-xlsx\",\"powerpoint-pptx\",\"pdf\",\"mcporter\",\"markdown-converter\"],\"npm\":${npm_packages},\"pip\":${pip_packages}}" > "${built_in_manifest}"; \ { \ printf 'manifest:'; sha256sum "${built_in_manifest}"; \ for tree in "${seed_openclaw_home}/skills" /opt/aios-seed/workspace-templates; do \ diff --git a/docker-images/kernal/README.md b/docker-images/kernal/README.md index eb231d9..d44990d 100644 --- a/docker-images/kernal/README.md +++ b/docker-images/kernal/README.md @@ -318,6 +318,7 @@ Python/uv 工具: - `aios-call-app-service` - `aios-transfer-file` +- `aios-make-chart-image` - `multi-search-engine` - `agent-browser-clawdbot` - `skill-creator` diff --git a/docker-images/kernal/assets/workspace-templates/default/AGENTS.md b/docker-images/kernal/assets/workspace-templates/default/AGENTS.md index 81c5281..ffcaece 100644 --- a/docker-images/kernal/assets/workspace-templates/default/AGENTS.md +++ b/docker-images/kernal/assets/workspace-templates/default/AGENTS.md @@ -24,7 +24,14 @@ ## 文件传输 -接收到文件 Uri 或需要将文件发送给用户时,需要使用 `aios-transfer-file` 技能。 +仅允许使用 Markdown 类型的文本与用户交流,如需传输文件需要使用 `aios-transfer-file` 技能。包括但不限于下列场景: + +- 接收到的文本中包含文件 Uri(如file_input://开头) +- 需要将文件、图片发给用户,如用户提到 `“将文件发给我”`、`“将文件传给我”` 等 + +## 图表图片 + +需要把 JSON、Markdown 表格或 ECharts option 生成图表图片时,必须使用 `aios-make-chart-image` 技能。生成本地图片后,如需发送给用户,再使用 `aios-transfer-file` 技能。 ## Exec 权限 diff --git a/docker-images/kernal/docs/build.md b/docker-images/kernal/docs/build.md index be33a76..af67782 100644 --- a/docker-images/kernal/docs/build.md +++ b/docker-images/kernal/docs/build.md @@ -101,6 +101,7 @@ powershell -ExecutionPolicy Bypass -File .\build-local.ps1 -ImageName aios-kerna - `aios-call-app-service` - `aios-transfer-file` +- `aios-make-chart-image` - `multi-search-engine` - `agent-browser-clawdbot` - `skill-creator` diff --git a/docker-images/kernal/docs/design.md b/docker-images/kernal/docs/design.md index 76922c8..cb7c0c6 100644 --- a/docker-images/kernal/docs/design.md +++ b/docker-images/kernal/docs/design.md @@ -125,7 +125,7 @@ 当前特殊规则: -- 只有 `aios-transfer-file` 会在安装后额外执行 `npm install --omit=dev` +- `aios-transfer-file` 和 `aios-make-chart-image` 会在安装后额外执行 `npm install --omit=dev` - 其他 shared skill 只执行 `openclaw skills install --global` ### `final` @@ -142,6 +142,7 @@ - `aios-call-app-service` - `aios-transfer-file` +- `aios-make-chart-image` - `multi-search-engine` - `agent-browser-clawdbot` - `skill-creator` diff --git a/docker-images/test/kernal-tests/test/full-smoke.js b/docker-images/test/kernal-tests/test/full-smoke.js index 06e66f7..520c243 100644 --- a/docker-images/test/kernal-tests/test/full-smoke.js +++ b/docker-images/test/kernal-tests/test/full-smoke.js @@ -756,7 +756,7 @@ async function main() { await waitForCommandSuccess( containerName, - "test -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md && test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json && test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules", + "test -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md && test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json && test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules && test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", 300000, "shared skills are not fully seeded yet", ); @@ -920,6 +920,32 @@ async function main() { ); console.log("[full-smoke] ok: aios-transfer-file skill is ready"); + await waitForCommandSuccess( + containerName, + "test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", + 240000, + "aios-make-chart-image skill is not ready yet", + ); + console.log("[full-smoke] ok: aios-make-chart-image skill is ready"); + + const chartSmokeData = JSON.stringify({ + title: "AIOS Chart Smoke", + labels: ["A", "B", "C"], + values: [1, 3, 2] + }); + await docker([ + "exec", + containerName, + "/bin/bash", + "-lc", + [ + "cd /var/aios/.openclaw/skills/aios-make-chart-image", + `node scripts/make_chart_image.mjs --data ${shellSingleQuote(chartSmokeData)} --output /tmp/aios-chart-smoke.png`, + "test -s /tmp/aios-chart-smoke.png" + ].join(" && "), + ]); + console.log("[full-smoke] ok: aios-make-chart-image renders a chart"); + const inboxBucket = envConfig.AIOS_S3_AGENT_INBOX_BUCKET; const outboxBucket = envConfig.AIOS_S3_AGENT_OUTBOX_BUCKET; const s3Region = envConfig.AIOS_S3_REGION || "local"; diff --git a/kernal/MANUAL-DEPLOY.md b/kernal/MANUAL-DEPLOY.md index 4925166..f66513e 100644 --- a/kernal/MANUAL-DEPLOY.md +++ b/kernal/MANUAL-DEPLOY.md @@ -284,6 +284,7 @@ sudo -u agents env \ for skill in \ aios-call-app-service \ aios-transfer-file \ + aios-make-chart-image \ multi-search-engine \ agent-browser-clawdbot \ skill-creator \ @@ -299,6 +300,7 @@ sudo -u agents env \ done npm install --omit=dev --prefix "$AIOS_OPENCLAW_HOME/skills/aios-transfer-file" + npm install --omit=dev --prefix "$AIOS_OPENCLAW_HOME/skills/aios-make-chart-image" uv tool install --force "markitdown[all]" ' ``` @@ -400,6 +402,8 @@ sudo -u agents env HOME=/var/aios PATH=/opt/aios-toolchain/bin:/usr/local/sbin:/ sudo test -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md sudo test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json sudo test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules +sudo test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json +sudo test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules ``` management service: diff --git a/kernal/aios-management-serivce/README.md b/kernal/aios-management-serivce/README.md index 97aed0a..360d7fb 100644 --- a/kernal/aios-management-serivce/README.md +++ b/kernal/aios-management-serivce/README.md @@ -83,7 +83,7 @@ aios-management-serivce ## 公开指令 -公开 30 个指令,以 `src/capabilities/registry.ts` 为准: +公开 31 个指令,以 `src/capabilities/registry.ts` 为准: - `service.ping` - `environment.info` @@ -105,6 +105,7 @@ aios-management-serivce - `llm.model.create` - `llm.model.update` - `llm.model.delete` +- `skills.global.list` - `skills.global.install.local` - `skills.global.delete` - `apps.create` diff --git a/kernal/aios-management-serivce/docs/compatibility-list.md b/kernal/aios-management-serivce/docs/compatibility-list.md index d7287c2..1d90149 100644 --- a/kernal/aios-management-serivce/docs/compatibility-list.md +++ b/kernal/aios-management-serivce/docs/compatibility-list.md @@ -310,6 +310,18 @@ ## Global Skill +### `skills.global.list` + +```json +{ + "requestId": "req-skill-list", + "action": "skills.global.list", + "params": {} +} +``` + +返回全局 managed skills 根目录下的上传技能和内建技能。内建技能通过 `is-built-in: true` 标记;额外返回 `source`、`installed`、`isSymlink`、`loadable` 等诊断属性。 + ### `skills.global.install.local` ```json diff --git a/kernal/aios-management-serivce/docs/design.md b/kernal/aios-management-serivce/docs/design.md index 8ae403e..4475ec5 100644 --- a/kernal/aios-management-serivce/docs/design.md +++ b/kernal/aios-management-serivce/docs/design.md @@ -70,7 +70,7 @@ - Agent:`agent.get`、`agent.create`、`agent.enable`、`agent.disable`、`agent.delete`、`agent.model.set`、`agent.docs.update` - 模板:`agent.template.create`、`agent.template.delete` - LLM:`llm.provider.create`、`llm.provider.update`、`llm.provider.test`、`llm.provider.delete`、`llm.model.create`、`llm.model.update`、`llm.model.delete` -- Skill:`skills.global.install.local`、`skills.global.delete` +- Skill:`skills.global.list`、`skills.global.install.local`、`skills.global.delete` - 业务系统:`apps.create`、`apps.enable`、`apps.disable`、`apps.delete`、`apps.log.record` - 运维:`diagnostics.run`、`gateway.status`、`gateway.restart` @@ -90,7 +90,7 @@ Provider 和 Model 写入 OpenClaw 模型配置。Provider 删除前会检查其 ### 模板和 Skill -模板上传要求 zip 顶层包含 `AGENTS.md`。全局 Skill 上传要求 zip 顶层包含 `SKILL.md`。模板和 Skill 制品通过 S3 / MinIO 临时传递给 management-service。 +模板上传要求 zip 顶层包含 `AGENTS.md`。全局 Skill 上传要求 zip 顶层包含符合 AgentSkills 规范的 `SKILL.md`,frontmatter 至少包含 `name` 和 `description`。模板和 Skill 制品通过 S3 / MinIO 临时传递给 management-service。 ### 业务系统 diff --git a/kernal/aios-management-serivce/package-lock.json b/kernal/aios-management-serivce/package-lock.json index a596ba3..7028b02 100644 --- a/kernal/aios-management-serivce/package-lock.json +++ b/kernal/aios-management-serivce/package-lock.json @@ -1,12 +1,12 @@ { "name": "aios-management-serivce", - "version": "0.2.9", + "version": "0.3.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aios-management-serivce", - "version": "0.2.9", + "version": "0.3.8", "license": "MIT", "dependencies": { "@aws-sdk/client-s3": "^3.1047.0", diff --git a/kernal/aios-management-serivce/package.json b/kernal/aios-management-serivce/package.json index 6787c51..c6c205a 100644 --- a/kernal/aios-management-serivce/package.json +++ b/kernal/aios-management-serivce/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-serivce", - "version": "0.3.5", + "version": "0.3.8", "description": "AIOS / OpenClaw 的 MQTT 管理控制台服务。", "type": "module", "private": false, diff --git a/kernal/aios-management-serivce/src/capabilities/agent/service.ts b/kernal/aios-management-serivce/src/capabilities/agent/service.ts index 8accf5d..61d40fb 100644 --- a/kernal/aios-management-serivce/src/capabilities/agent/service.ts +++ b/kernal/aios-management-serivce/src/capabilities/agent/service.ts @@ -13,6 +13,7 @@ import type { EnvironmentConfig, GlobalSkillsDeleteParams, GlobalSkillsInstallLocalParams, + GlobalSkillsListResult, GatewayRestartParams, LlmModelCreateParams, LlmModelDeleteParams, @@ -241,17 +242,7 @@ export class OpenClawManager { }; } - async listGlobalSkills(): Promise<{ - root: string; - items: Array<{ - slug: string; - path: string; - origin?: unknown; - pinned?: boolean; - pinReason?: string; - "is-built-in": boolean; - }>; - }> { + async listGlobalSkills(): Promise { return await this.skillsService.listGlobalSkills(); } diff --git a/kernal/aios-management-serivce/src/capabilities/agent/skills-service.ts b/kernal/aios-management-serivce/src/capabilities/agent/skills-service.ts index bbedd30..c402d8d 100644 --- a/kernal/aios-management-serivce/src/capabilities/agent/skills-service.ts +++ b/kernal/aios-management-serivce/src/capabilities/agent/skills-service.ts @@ -1,4 +1,5 @@ -import { cp, mkdtemp, mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { execFile } from "node:child_process"; +import { cp, chmod, chown, lchown, lstat, mkdtemp, mkdir, readFile, readdir, readlink, rename, rm, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; @@ -7,12 +8,34 @@ import { CommandError } from "../../tools/cli-runner.js"; import { createS3ObjectStore, extractZipToDirectory, type S3ObjectStore } from "../../tools/s3-archive.js"; import type { EnvironmentConfig, + GlobalSkillRecord, + GlobalSkillsListResult, GlobalSkillsDeleteParams, GlobalSkillsInstallLocalParams } from "../../types.js"; import { readBuiltInComponentNames } from "../built-in-components.js"; const VALID_SKILL_SLUG_PATTERN = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i; +const FRONTMATTER_PATTERN = /^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/; +const DEFAULT_SEED_SKILLS_ROOT = "/opt/aios-seed/home/.openclaw/skills"; + +interface SkillManifestMetadata { + hasFrontmatter: boolean; + name?: string; + description?: string; +} + +interface SkillEntryInfo { + pathExists: boolean; + isSymlink: boolean; + isDirectory: boolean; + targetPath?: string; +} + +interface SharedOwnership { + uid?: number; + gid?: number; +} export class AgentSkillsService { private objectStore?: S3ObjectStore; @@ -28,17 +51,7 @@ export class AgentSkillsService { this.objectStore = dependencies?.objectStore; } - async listGlobalSkills(): Promise<{ - root: string; - items: Array<{ - slug: string; - path: string; - origin?: unknown; - pinned?: boolean; - pinReason?: string; - "is-built-in": boolean; - }>; - }> { + async listGlobalSkills(): Promise { const root = this.env.globalSkillsRoot; const workdir = path.dirname(root); const lockPath = path.join(workdir, ".clawhub", "lock.json"); @@ -56,7 +69,7 @@ export class AgentSkillsService { const entries = await readdir(root, { withFileTypes: true }); const slugs = new Set( entries - .filter((entry) => entry.isDirectory() && !entry.name.startsWith(".")) + .filter((entry) => (entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith(".")) .map((entry) => entry.name) ); for (const builtInSkill of builtInSkills) { @@ -66,24 +79,7 @@ export class AgentSkillsService { } const items = await Promise.all([...slugs] - .map(async (slug) => { - const skillPath = path.join(root, slug); - const originPath = path.join(skillPath, ".clawhub", "origin.json"); - let origin: unknown; - try { - origin = JSON.parse(await readFile(originPath, "utf8")); - } catch {} - - const lockEntry = lockData[slug]; - return { - slug, - path: skillPath, - origin, - pinned: lockEntry?.pinned ?? false, - pinReason: lockEntry?.pin?.reason, - "is-built-in": builtInSkills.has(slug) - }; - })); + .map(async (slug) => this.buildGlobalSkillRecord(slug, root, builtInSkills, lockData[slug]))); return { root, @@ -201,6 +197,96 @@ export class AgentSkillsService { ); } + private skillSlugEquals(left: string, right: string): boolean { + return left.trim().toLowerCase() === right.trim().toLowerCase(); + } + + private async ensureSlugDoesNotCollideWithBuiltIn(commandName: string, slug: string): Promise { + const builtInSkills = await readBuiltInComponentNames(this.env, "skill"); + const collision = [...builtInSkills].find((builtInSkill) => this.skillSlugEquals(builtInSkill, slug)); + if (collision) { + throw new Error(`${commandName} cannot install skill ${slug}: slug is reserved by built-in skill ${collision}`); + } + + const existingInfo = await this.readSkillEntryInfo(path.join(this.env.globalSkillsRoot, slug)); + if (this.isBuiltInSeedSkillLink(existingInfo)) { + throw new Error(`${commandName} cannot install skill ${slug}: slug is reserved by built-in seed skill link`); + } + } + + private parseSkillManifest(raw: string): SkillManifestMetadata { + const match = FRONTMATTER_PATTERN.exec(raw); + if (!match) { + return { hasFrontmatter: false }; + } + + const metadata: SkillManifestMetadata = { + hasFrontmatter: true + }; + for (const rawLine of match[1]!.split(/\r?\n/)) { + const line = rawLine.trim(); + if (!line || line.startsWith("#")) { + continue; + } + + const fieldMatch = /^([A-Za-z0-9_-]+):\s*(.*)$/.exec(line); + if (!fieldMatch) { + continue; + } + + const key = fieldMatch[1]!.trim(); + const value = this.unquoteFrontmatterValue(fieldMatch[2]!.trim()); + if (key === "name") { + metadata.name = value; + } else if (key === "description") { + metadata.description = value; + } + } + + return metadata; + } + + private unquoteFrontmatterValue(value: string): string { + if ( + (value.startsWith("\"") && value.endsWith("\"")) + || (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1).trim(); + } + + return value.trim(); + } + + private async readSkillManifestMetadata(skillDir: string): Promise { + try { + return this.parseSkillManifest(await readFile(path.join(skillDir, "SKILL.md"), "utf8")); + } catch { + return undefined; + } + } + + private async validateUploadedSkillManifest(commandName: string, skillDir: string, slug: string): Promise { + const manifest = await this.readSkillManifestMetadata(skillDir); + if (!manifest?.hasFrontmatter) { + throw new Error(`${commandName} uploaded skill SKILL.md must contain AgentSkills frontmatter`); + } + if (!manifest.name?.trim()) { + throw new Error(`${commandName} uploaded skill SKILL.md frontmatter must contain name`); + } + if (!manifest.description?.trim()) { + throw new Error(`${commandName} uploaded skill SKILL.md frontmatter must contain description`); + } + if (!this.skillSlugEquals(manifest.name, slug)) { + throw new Error(`${commandName} uploaded skill SKILL.md frontmatter name must match slug ${slug}`); + } + + return { + hasFrontmatter: true, + name: manifest.name.trim(), + description: manifest.description.trim() + }; + } + private async ensureTopLevelSkillManifest(skillDir: string): Promise { const exactPath = path.join(skillDir, "SKILL.md"); try { @@ -237,9 +323,12 @@ export class AgentSkillsService { }): Promise<{ slug: string; skillDir: string; + manifest: SkillManifestMetadata; cleanup?: () => Promise; }> { const slug = this.validateSkillSlug(params.commandName, params.slug); + await this.ensureSlugDoesNotCollideWithBuiltIn(params.commandName, slug); + const bucket = params.bucket?.trim(); const objectKey = params.objectKey?.trim(); if (!bucket) { @@ -255,9 +344,11 @@ export class AgentSkillsService { const zipPayload = await this.getObjectStore().getObject(bucket, objectKey); await extractZipToDirectory(zipPayload, skillDir, "skills temp root"); await this.ensureTopLevelSkillManifest(skillDir); + const manifest = await this.validateUploadedSkillManifest(params.commandName, skillDir, slug); return { slug, skillDir, + manifest, cleanup: async () => { await rm(tempRoot, { recursive: true, force: true }); } @@ -312,10 +403,264 @@ export class AgentSkillsService { await rm(targetDir, { recursive: true, force: true }); } await rename(stagingDir, targetDir); + await this.normalizeSharedSkillTree(targetDir); return targetDir; } catch (error) { await rm(stagingDir, { recursive: true, force: true }); throw error; } } + + private async buildGlobalSkillRecord( + slug: string, + root: string, + builtInSkills: Set, + lockEntry?: { pinned?: boolean; pin?: { reason?: string } } + ): Promise { + const skillPath = path.join(root, slug); + const entryInfo = await this.readSkillEntryInfo(skillPath); + const origin = await this.readSkillOrigin(skillPath); + const sourceOrigin = await this.readJson(path.join(skillPath, ".openclaw", "source-origin.json")) as { source?: string } | undefined; + const manifest = entryInfo.pathExists ? await this.readSkillManifestMetadata(skillPath) : undefined; + const isBuiltIn = builtInSkills.has(slug) || this.isBuiltInSeedSkillLink(entryInfo); + const description = manifest?.description?.trim(); + const name = manifest?.name?.trim() || (entryInfo.pathExists ? slug : undefined); + const invalidReason = this.resolveSkillInvalidReason({ + isBuiltIn, + entryInfo, + manifest, + description + }); + + return { + slug, + path: skillPath, + name, + description, + source: this.resolveSkillSource({ isBuiltIn, origin, sourceOrigin, pathExists: entryInfo.pathExists }), + origin, + pinned: lockEntry?.pinned ?? false, + pinReason: lockEntry?.pin?.reason, + installed: entryInfo.pathExists, + pathExists: entryInfo.pathExists, + isSymlink: entryInfo.isSymlink, + targetPath: entryInfo.targetPath, + loadable: !invalidReason, + invalidReason, + "is-built-in": isBuiltIn, + isBuiltIn + }; + } + + private async readSkillEntryInfo(skillPath: string): Promise { + let pathExists = false; + let isSymlink = false; + let targetPath: string | undefined; + try { + const linkInfo = await lstat(skillPath); + pathExists = true; + isSymlink = linkInfo.isSymbolicLink(); + if (isSymlink) { + const rawTarget = await readlink(skillPath); + targetPath = path.isAbsolute(rawTarget) ? rawTarget : path.resolve(path.dirname(skillPath), rawTarget); + } + } catch { + return { + pathExists: false, + isSymlink: false, + isDirectory: false + }; + } + + let isDirectory = false; + try { + isDirectory = (await stat(skillPath)).isDirectory(); + } catch {} + + return { + pathExists, + isSymlink, + isDirectory, + targetPath + }; + } + + private async readSkillOrigin(skillPath: string): Promise { + return ( + await this.readJson(path.join(skillPath, ".clawhub", "origin.json")) + ?? await this.readJson(path.join(skillPath, ".openclaw", "source-origin.json")) + ); + } + + private async readJson(filePath: string): Promise { + try { + return JSON.parse(await readFile(filePath, "utf8")) as unknown; + } catch { + return undefined; + } + } + + private resolveSkillSource(params: { + isBuiltIn: boolean; + origin?: unknown; + sourceOrigin?: { source?: string }; + pathExists: boolean; + }): string { + if (params.isBuiltIn) { + return params.pathExists ? "built-in" : "missing-built-in"; + } + if (params.sourceOrigin?.source?.trim()) { + return params.sourceOrigin.source.trim(); + } + if (params.origin) { + return "clawhub"; + } + return "openclaw-managed"; + } + + private resolveSkillInvalidReason(params: { + isBuiltIn: boolean; + entryInfo: SkillEntryInfo; + manifest?: SkillManifestMetadata; + description?: string; + }): string | undefined { + if (!params.entryInfo.pathExists) { + return params.isBuiltIn ? "built-in skill directory is not linked" : "skill directory is missing"; + } + if (!params.entryInfo.isDirectory) { + return "skill path is not a directory"; + } + if (!params.manifest) { + return "SKILL.md is missing"; + } + if (!params.manifest.hasFrontmatter) { + return "SKILL.md frontmatter is missing"; + } + if (!params.description) { + return "SKILL.md frontmatter description is missing"; + } + return undefined; + } + + private isBuiltInSeedSkillLink(entryInfo: SkillEntryInfo): boolean { + if (!entryInfo.isSymlink || !entryInfo.targetPath) { + return false; + } + + const seedSkillsRoot = path.resolve( + process.env.AIOS_SEED_SKILLS_ROOT + || process.env.AIOS_SEED_OPENCLAW_SKILLS_ROOT + || DEFAULT_SEED_SKILLS_ROOT + ); + const targetPath = path.resolve(entryInfo.targetPath); + return targetPath === seedSkillsRoot || targetPath.startsWith(`${seedSkillsRoot}${path.sep}`); + } + + private async normalizeSharedSkillTree(targetDir: string): Promise { + const ownership = await this.resolveSharedOwnership(); + await this.normalizeSharedSkillPath(targetDir, ownership); + } + + private async normalizeSharedSkillPath(targetPath: string, ownership: SharedOwnership): Promise { + let info; + try { + info = await lstat(targetPath); + } catch { + return; + } + + if (info.isSymbolicLink()) { + await this.bestEffortLchown(targetPath, ownership); + return; + } + + await this.bestEffortChown(targetPath, ownership); + if (info.isDirectory()) { + await this.bestEffortChmod(targetPath, 0o2770); + const entries = await readdir(targetPath, { withFileTypes: true }); + await Promise.all(entries.map((entry) => this.normalizeSharedSkillPath(path.join(targetPath, entry.name), ownership))); + return; + } + + await this.bestEffortChmod(targetPath, info.mode & 0o111 ? 0o770 : 0o660); + } + + private async resolveSharedOwnership(): Promise { + const [uid, gid] = await Promise.all([ + this.resolveUserId(process.env.AIOS_OPENCLAW_USER), + this.resolveGroupId(process.env.AIOS_OPENCLAW_GROUP) + ]); + + return { uid, gid }; + } + + private async resolveUserId(user?: string): Promise { + const normalized = user?.trim(); + if (!normalized) { + return undefined; + } + if (/^\d+$/.test(normalized)) { + return Number(normalized); + } + + const output = await this.execFileTrim("id", ["-u", normalized]); + return output && /^\d+$/.test(output) ? Number(output) : undefined; + } + + private async resolveGroupId(group?: string): Promise { + const normalized = group?.trim(); + if (!normalized) { + return undefined; + } + if (/^\d+$/.test(normalized)) { + return Number(normalized); + } + + const getentOutput = await this.execFileTrim("getent", ["group", normalized]); + const rawGid = getentOutput?.split(":")[2]?.trim(); + return rawGid && /^\d+$/.test(rawGid) ? Number(rawGid) : undefined; + } + + private async execFileTrim(command: string, args: string[]): Promise { + try { + const output = await new Promise((resolve, reject) => { + execFile(command, args, { timeout: 5_000 }, (error, stdout) => { + if (error) { + reject(error); + return; + } + resolve(String(stdout).trim()); + }); + }); + return output || undefined; + } catch { + return undefined; + } + } + + private async bestEffortChown(targetPath: string, ownership: SharedOwnership): Promise { + if (ownership.uid === undefined && ownership.gid === undefined) { + return; + } + + try { + await chown(targetPath, ownership.uid ?? -1, ownership.gid ?? -1); + } catch {} + } + + private async bestEffortLchown(targetPath: string, ownership: SharedOwnership): Promise { + if (ownership.uid === undefined && ownership.gid === undefined) { + return; + } + + try { + await lchown(targetPath, ownership.uid ?? -1, ownership.gid ?? -1); + } catch {} + } + + private async bestEffortChmod(targetPath: string, mode: number): Promise { + try { + await chmod(targetPath, mode); + } catch {} + } } diff --git a/kernal/aios-management-serivce/src/capabilities/context.ts b/kernal/aios-management-serivce/src/capabilities/context.ts index 4617f87..d3a79fb 100644 --- a/kernal/aios-management-serivce/src/capabilities/context.ts +++ b/kernal/aios-management-serivce/src/capabilities/context.ts @@ -17,6 +17,7 @@ import type { DiagnosticsRunParams, GlobalSkillsDeleteParams, GlobalSkillsInstallLocalParams, + GlobalSkillsListResult, GatewayRestartParams, LlmModelCreateParams, LlmModelDeleteParams, @@ -50,17 +51,7 @@ export interface AgentCapacityService { createLlmModel(params: LlmModelCreateParams): Promise; updateLlmModel(params: LlmModelUpdateParams): Promise; deleteLlmModel(params: LlmModelDeleteParams): Promise; - listGlobalSkills(): Promise<{ - root: string; - items: Array<{ - slug: string; - path: string; - origin?: unknown; - pinned?: boolean; - pinReason?: string; - "is-built-in": boolean; - }>; - }>; + listGlobalSkills(): Promise; installGlobalSkillFromLocal(params: GlobalSkillsInstallLocalParams): Promise; deleteGlobalSkill(params: GlobalSkillsDeleteParams): Promise; runDiagnostics(params?: DiagnosticsRunParams): Promise<{ output: string; backend: string }>; diff --git a/kernal/aios-management-serivce/src/capabilities/registry.ts b/kernal/aios-management-serivce/src/capabilities/registry.ts index e4f9ebb..6da55a0 100644 --- a/kernal/aios-management-serivce/src/capabilities/registry.ts +++ b/kernal/aios-management-serivce/src/capabilities/registry.ts @@ -28,6 +28,7 @@ import { llmProviderUpdate } from "./llm/provider-update.js"; import { servicePing } from "./service/ping.js"; import { skillsGlobalDelete } from "./skills-global/delete.js"; import { skillsGlobalInstallLocal } from "./skills-global/install-local.js"; +import { skillsGlobalList } from "./skills-global/list.js"; import type { CapabilityHandler } from "./context.js"; export const capabilityHandlers: Record = { @@ -51,6 +52,7 @@ export const capabilityHandlers: Record = { "llm.model.create": llmModelCreate, "llm.model.update": llmModelUpdate, "llm.model.delete": llmModelDelete, + "skills.global.list": skillsGlobalList, "skills.global.install.local": skillsGlobalInstallLocal, "skills.global.delete": skillsGlobalDelete, "apps.create": appsCreate, diff --git a/kernal/aios-management-serivce/src/capabilities/skills-global/list.ts b/kernal/aios-management-serivce/src/capabilities/skills-global/list.ts new file mode 100644 index 0000000..0397431 --- /dev/null +++ b/kernal/aios-management-serivce/src/capabilities/skills-global/list.ts @@ -0,0 +1,5 @@ +import type { CapabilityHandler } from "../context.js"; + +export const skillsGlobalList: CapabilityHandler = async (_request, context) => { + return await context.agentManager.listGlobalSkills(); +}; diff --git a/kernal/aios-management-serivce/src/service.ts b/kernal/aios-management-serivce/src/service.ts index 10c0c47..2f21785 100644 --- a/kernal/aios-management-serivce/src/service.ts +++ b/kernal/aios-management-serivce/src/service.ts @@ -115,6 +115,7 @@ const QUEUE_BYPASS_ACTIONS = new Set([ "service.ping", "environment.info", "agent.usage.list", + "skills.global.list", "gateway.status" ]); diff --git a/kernal/aios-management-serivce/src/types.ts b/kernal/aios-management-serivce/src/types.ts index 4dc6ba4..d0aa47c 100644 --- a/kernal/aios-management-serivce/src/types.ts +++ b/kernal/aios-management-serivce/src/types.ts @@ -76,6 +76,30 @@ export interface ManagedState { agents: ManagedAgentRecord[]; } +export interface GlobalSkillRecord { + slug: string; + path: string; + name?: string; + description?: string; + source: string; + origin?: unknown; + pinned?: boolean; + pinReason?: string; + installed: boolean; + pathExists: boolean; + isSymlink: boolean; + targetPath?: string; + loadable: boolean; + invalidReason?: string; + "is-built-in": boolean; + isBuiltIn: boolean; +} + +export interface GlobalSkillsListResult { + root: string; + items: GlobalSkillRecord[]; +} + export interface AgentCreateParams { agentId: string; name?: string; diff --git a/kernal/aios-management-serivce/test/capabilities-registry.test.ts b/kernal/aios-management-serivce/test/capabilities-registry.test.ts index 0aab5d9..07b6b24 100644 --- a/kernal/aios-management-serivce/test/capabilities-registry.test.ts +++ b/kernal/aios-management-serivce/test/capabilities-registry.test.ts @@ -25,6 +25,7 @@ const expectedActions = [ "llm.model.create", "llm.model.update", "llm.model.delete", + "skills.global.list", "skills.global.install.local", "skills.global.delete", "apps.create", @@ -231,6 +232,11 @@ test("capability handlers delegate to the expected service methods", async () => expectedMethod: "agentManager.deleteLlmModel", expectedArgs: [{ providerId: "corp-openai", modelId: "deepseek-v4-flash" }] }, + { + action: "skills.global.list", + expectedMethod: "agentManager.listGlobalSkills", + expectedArgs: [] + }, { action: "skills.global.install.local", params: { slug: "private-calendar-helper", bucket: "admin-in", objectKey: "skills/calendar.zip", force: true }, diff --git a/kernal/aios-management-serivce/test/openclaw-manager.test.ts b/kernal/aios-management-serivce/test/openclaw-manager.test.ts index 5e2cd8e..b08c1e5 100644 --- a/kernal/aios-management-serivce/test/openclaw-manager.test.ts +++ b/kernal/aios-management-serivce/test/openclaw-manager.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { access, chmod, mkdtemp, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises"; +import { access, chmod, mkdtemp, mkdir, readFile, readdir, stat, symlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; import { createRequire } from "node:module"; @@ -1409,7 +1409,15 @@ test("global skill install from local zip writes uploaded zip with private slug" const clawhub = new MockCliRunner(); const zip = new AdmZip(); - zip.addFile("SKILL.md", Buffer.from("# Demo Skill\n", "utf8")); + zip.addFile("SKILL.md", Buffer.from([ + "---", + "name: private-demo-skill", + "description: Demo skill installed from the management service.", + "---", + "", + "# Demo Skill", + "" + ].join("\n"), "utf8")); zip.addFile("tools/search.md", Buffer.from("search\n", "utf8")); zip.addFile("scripts/install-app.js", Buffer.from("import { spawnSync } from 'node:child_process';\nspawnSync('true');\n", "utf8")); const zipPayload = zip.toBuffer(); @@ -1445,9 +1453,19 @@ test("global skill install from local zip writes uploaded zip with private slug" assert.equal(cli.runCalls.length, 0); const installedDir = path.join(root, ".openclaw", "skills", "private-demo-skill"); - assert.equal(await readFile(path.join(installedDir, "SKILL.md"), "utf8"), "# Demo Skill\n"); + assert.equal(await readFile(path.join(installedDir, "SKILL.md"), "utf8"), [ + "---", + "name: private-demo-skill", + "description: Demo skill installed from the management service.", + "---", + "", + "# Demo Skill", + "" + ].join("\n")); assert.equal(await readFile(path.join(installedDir, "tools", "search.md"), "utf8"), "search\n"); assert.match(await readFile(path.join(installedDir, "scripts", "install-app.js"), "utf8"), /child_process/); + assert.equal((await stat(installedDir)).mode & 0o7777, 0o2770); + assert.equal((await stat(path.join(installedDir, "SKILL.md"))).mode & 0o777, 0o660); assert.deepEqual(JSON.parse(await readFile(path.join(installedDir, ".openclaw", "source-origin.json"), "utf8")), { version: 1, source: "aios-management-upload", @@ -1490,6 +1508,172 @@ test("global skill install from local zip requires top-level SKILL.md", async () assert.equal(cli.runCalls.length, 0); }); +test("global skill install from local zip rejects SKILL.md without AgentSkills frontmatter", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skill-upload-no-frontmatter-")); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const cli = new MockCliRunner(); + const doctor = new MockCliRunner(); + + const zip = new AdmZip(); + zip.addFile("SKILL.md", Buffer.from("# Demo Skill\n\nNo frontmatter here.\n", "utf8")); + + const manager = new OpenClawManager( + cli as never, + doctor as never, + createEnv(), + stateStore, + { + objectStore: { + async getObject(): Promise { + return zip.toBuffer(); + } + } + } + ); + + await assert.rejects( + manager.installGlobalSkillFromLocal({ + slug: "private-demo-skill", + bucket: "admin-in", + objectKey: "skills/demo.zip" + }), + /SKILL\.md must contain AgentSkills frontmatter/ + ); + assert.equal(cli.runCalls.length, 0); +}); + +test("global skill install from local zip requires SKILL.md frontmatter name to match the upload slug", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skill-upload-name-mismatch-")); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const cli = new MockCliRunner(); + const doctor = new MockCliRunner(); + + const zip = new AdmZip(); + zip.addFile("SKILL.md", Buffer.from([ + "---", + "name: another-skill", + "description: The name does not match the upload slug.", + "---", + "", + "# Demo Skill", + "" + ].join("\n"), "utf8")); + + const manager = new OpenClawManager( + cli as never, + doctor as never, + createEnv(), + stateStore, + { + objectStore: { + async getObject(): Promise { + return zip.toBuffer(); + } + } + } + ); + + await assert.rejects( + manager.installGlobalSkillFromLocal({ + slug: "private-demo-skill", + bucket: "admin-in", + objectKey: "skills/demo.zip" + }), + /frontmatter name must match slug private-demo-skill/ + ); + assert.equal(cli.runCalls.length, 0); +}); + +test("global skill install from local zip rejects built-in skill slug collisions", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skill-upload-built-in-")); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const cli = new MockCliRunner(); + const doctor = new MockCliRunner(); + + await writeFile(path.join(root, "build-in-component.json"), `${JSON.stringify({ + skill: ["mcporter"] + }, null, 2)}\n`, "utf8"); + + const manager = new OpenClawManager( + cli as never, + doctor as never, + createEnv({ + dataRoot: root + }), + stateStore, + { + objectStore: { + async getObject(): Promise { + throw new Error("built-in slug should be rejected before reading S3"); + } + } + } + ); + + await assert.rejects( + manager.installGlobalSkillFromLocal({ + slug: "mcporter", + bucket: "admin-in", + objectKey: "skills/mcporter.zip", + force: true + }), + /slug is reserved by built-in skill mcporter/ + ); + assert.equal(cli.runCalls.length, 0); +}); + +test("global skill install from local zip rejects seed symlink built-in slug collisions", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skill-upload-seed-link-")); + const skillsRoot = path.join(root, ".openclaw", "skills"); + const seedSkillsRoot = path.join(root, "seed", "skills"); + const seedSkillDir = path.join(seedSkillsRoot, "mcporter"); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const cli = new MockCliRunner(); + const doctor = new MockCliRunner(); + const previousSeedSkillsRoot = process.env.AIOS_SEED_SKILLS_ROOT; + + try { + process.env.AIOS_SEED_SKILLS_ROOT = seedSkillsRoot; + await mkdir(seedSkillDir, { recursive: true }); + await mkdir(skillsRoot, { recursive: true }); + await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), "dir"); + + const manager = new OpenClawManager( + cli as never, + doctor as never, + createEnv({ + dataRoot: root, + globalSkillsRoot: skillsRoot + }), + stateStore, + { + objectStore: { + async getObject(): Promise { + throw new Error("seed symlink slug should be rejected before reading S3"); + } + } + } + ); + + await assert.rejects( + manager.installGlobalSkillFromLocal({ + slug: "mcporter", + bucket: "admin-in", + objectKey: "skills/mcporter.zip", + force: true + }), + /slug is reserved by built-in seed skill link/ + ); + assert.equal(cli.runCalls.length, 0); + } finally { + if (previousSeedSkillsRoot === undefined) { + delete process.env.AIOS_SEED_SKILLS_ROOT; + } else { + process.env.AIOS_SEED_SKILLS_ROOT = previousSeedSkillsRoot; + } + } +}); + test("listGlobalSkills reads origin and pin metadata from the global skills workspace", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skills-list-")); const skillsRoot = path.join(root, ".openclaw", "skills"); @@ -1525,7 +1709,20 @@ test("listGlobalSkills reads origin and pin metadata from the global skills work const result = await manager.listGlobalSkills(); assert.equal(result.root, skillsRoot); - assert.deepEqual(result.items, [{ + assert.deepEqual(result.items.map((item) => ({ + slug: item.slug, + path: item.path, + origin: item.origin, + pinned: item.pinned, + pinReason: item.pinReason, + source: item.source, + installed: item.installed, + pathExists: item.pathExists, + isSymlink: item.isSymlink, + loadable: item.loadable, + invalidReason: item.invalidReason, + isBuiltIn: item["is-built-in"] + })), [{ slug: "alpha", path: path.join(skillsRoot, "alpha"), origin: { @@ -1534,14 +1731,26 @@ test("listGlobalSkills reads origin and pin metadata from the global skills work }, pinned: true, pinReason: "required-by-admin", - "is-built-in": false + source: "clawhub", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: false }, { slug: "zeta", path: path.join(skillsRoot, "zeta"), origin: undefined, pinned: false, pinReason: undefined, - "is-built-in": false + source: "openclaw-managed", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: false }]); }); @@ -1576,37 +1785,130 @@ test("listGlobalSkills marks built-in skills declared in AIOS_DATA_DIR", async ( const result = await manager.listGlobalSkills(); assert.equal(result.root, skillsRoot); - assert.deepEqual(result.items, [{ + assert.deepEqual(result.items.map((item) => ({ + slug: item.slug, + path: item.path, + source: item.source, + installed: item.installed, + pathExists: item.pathExists, + isSymlink: item.isSymlink, + loadable: item.loadable, + invalidReason: item.invalidReason, + isBuiltIn: item["is-built-in"] + })), [{ slug: "agent-browser-clawdbot", path: path.join(skillsRoot, "agent-browser-clawdbot"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": true + source: "built-in", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: true }, { slug: "markdown-converter", path: path.join(skillsRoot, "markdown-converter"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": true + source: "built-in", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: true }, { slug: "mcporter", path: path.join(skillsRoot, "mcporter"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": true + source: "built-in", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: true }, { slug: "zeta", path: path.join(skillsRoot, "zeta"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": false + source: "openclaw-managed", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: false }]); }); +test("listGlobalSkills includes symlinked seed built-in skills with OpenClaw loadable metadata", async () => { + const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skills-built-in-link-")); + const skillsRoot = path.join(root, ".openclaw", "skills"); + const seedSkillsRoot = path.join(root, "seed", "skills"); + const seedSkillDir = path.join(seedSkillsRoot, "mcporter"); + const stateStore = new ManagedStateStore(path.join(root, "managed-state.json")); + const previousSeedSkillsRoot = process.env.AIOS_SEED_SKILLS_ROOT; + + try { + process.env.AIOS_SEED_SKILLS_ROOT = seedSkillsRoot; + await mkdir(seedSkillDir, { recursive: true }); + await mkdir(skillsRoot, { recursive: true }); + await writeFile(path.join(seedSkillDir, "SKILL.md"), [ + "---", + "name: McPorter Test", + "description: Built-in skill exposed through a seed symlink.", + "---", + "", + "# McPorter", + "" + ].join("\n"), "utf8"); + await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), "dir"); + + const manager = new OpenClawManager( + new MockCliRunner() as never, + new MockCliRunner() as never, + createEnv({ + dataRoot: root, + globalSkillsRoot: skillsRoot + }), + stateStore + ); + + const result = await manager.listGlobalSkills(); + assert.equal(result.items.length, 1); + assert.deepEqual({ + slug: result.items[0]!.slug, + path: result.items[0]!.path, + name: result.items[0]!.name, + description: result.items[0]!.description, + source: result.items[0]!.source, + installed: result.items[0]!.installed, + pathExists: result.items[0]!.pathExists, + isSymlink: result.items[0]!.isSymlink, + targetPath: result.items[0]!.targetPath, + loadable: result.items[0]!.loadable, + invalidReason: result.items[0]!.invalidReason, + isBuiltIn: result.items[0]!["is-built-in"] + }, { + slug: "mcporter", + path: path.join(skillsRoot, "mcporter"), + name: "McPorter Test", + description: "Built-in skill exposed through a seed symlink.", + source: "built-in", + installed: true, + pathExists: true, + isSymlink: true, + targetPath: seedSkillDir, + loadable: true, + invalidReason: undefined, + isBuiltIn: true + }); + } finally { + if (previousSeedSkillsRoot === undefined) { + delete process.env.AIOS_SEED_SKILLS_ROOT; + } else { + process.env.AIOS_SEED_SKILLS_ROOT = previousSeedSkillsRoot; + } + } +}); + test("listGlobalSkills includes built-in skills declared in AIOS_DATA_DIR even when directories are missing", async () => { const root = await mkdtemp(path.join(tmpdir(), "oc-mgmt-skills-built-in-missing-")); const skillsRoot = path.join(root, ".openclaw", "skills"); @@ -1635,27 +1937,46 @@ test("listGlobalSkills includes built-in skills declared in AIOS_DATA_DIR even w const result = await manager.listGlobalSkills(); assert.equal(result.root, skillsRoot); - assert.deepEqual(result.items, [{ + assert.deepEqual(result.items.map((item) => ({ + slug: item.slug, + path: item.path, + source: item.source, + installed: item.installed, + pathExists: item.pathExists, + isSymlink: item.isSymlink, + loadable: item.loadable, + invalidReason: item.invalidReason, + isBuiltIn: item["is-built-in"] + })), [{ slug: "aios-call-app-service", path: path.join(skillsRoot, "aios-call-app-service"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": true + source: "missing-built-in", + installed: false, + pathExists: false, + isSymlink: false, + loadable: false, + invalidReason: "built-in skill directory is not linked", + isBuiltIn: true }, { slug: "aios-transfer-file", path: path.join(skillsRoot, "aios-transfer-file"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": true + source: "missing-built-in", + installed: false, + pathExists: false, + isSymlink: false, + loadable: false, + invalidReason: "built-in skill directory is not linked", + isBuiltIn: true }, { slug: "private-helper", path: path.join(skillsRoot, "private-helper"), - origin: undefined, - pinned: false, - pinReason: undefined, - "is-built-in": false + source: "openclaw-managed", + installed: true, + pathExists: true, + isSymlink: false, + loadable: false, + invalidReason: "SKILL.md is missing", + isBuiltIn: false }]); }); diff --git a/kernal/aios-management-serivce/test/service-queue.test.ts b/kernal/aios-management-serivce/test/service-queue.test.ts index daf1565..d9a93e2 100644 --- a/kernal/aios-management-serivce/test/service-queue.test.ts +++ b/kernal/aios-management-serivce/test/service-queue.test.ts @@ -6,6 +6,7 @@ test("bypasses the serial management queue for lightweight status requests", () assert.equal(shouldBypassManagementQueue("service.ping"), true); assert.equal(shouldBypassManagementQueue("environment.info"), true); assert.equal(shouldBypassManagementQueue("agent.usage.list"), true); + assert.equal(shouldBypassManagementQueue("skills.global.list"), true); assert.equal(shouldBypassManagementQueue("gateway.status"), true); }); diff --git a/kernal/clawhub-skills/aios-call-app-service/SKILL.md b/kernal/clawhub-skills/aios-call-app-service/SKILL.md index 7f42493..50d6015 100644 --- a/kernal/clawhub-skills/aios-call-app-service/SKILL.md +++ b/kernal/clawhub-skills/aios-call-app-service/SKILL.md @@ -39,7 +39,7 @@ description: 当请求依赖 AIOS、OpenClaw、Forguncy 等业务系统的实时 3. 根据文档,确认 `applicationName`。 4. 根据文档,确认 `commandName`。 5. 根据文档,确认 `provider`。 -6. 通过会话上下文,确认 `SessionId`。 +6. 通过会话上下文的 `topic_id` ,确认 `SessionId`。 7. 生成 `jsonBody`,并检查确认合法性。 8. 生成并执行唯一一条 CLI 命令。 @@ -54,7 +54,7 @@ description: 当请求依赖 AIOS、OpenClaw、Forguncy 等业务系统的实时 - `AIOS_ONTOLOGY_DIR` 视为当前事实源。 - 当前 CLI 只支持 `provider=hzg`,如果出现其他 provider,直接说明当前运行链路不支持,不要猜测替代方案。 -- 调用 CLI 时,`-s` 传入当前会话的 `SessionId`。 +- 调用 CLI 时,`-s` 传入当前会话的 `SessionId` (上下文中的 `topic_id`)。 - 不能臆造,不能复用其他会话的 `SessionId` 。 - 不得使用提示词中的 `SessionId` 、 `chat_id` 、 `message_id` 或其他字段代替。 - 不要臆造接口名、请求字段、枚举 ID 或 `provider`。 @@ -75,8 +75,8 @@ description: 当请求依赖 AIOS、OpenClaw、Forguncy 等业务系统的实时 ## 开始前必须阅读 -- [references/invoke-rules.md](references/invoke-rules.md) -- 需要做筛选、聚合、比较、排序或时间分析时,再阅读 [references/data-processing.md](references/data-processing.md) +- 调用前需要阅读:[references/invoke-rules.md](references/invoke-rules.md) +- 调用后,需要做筛选、聚合、比较、排序或时间分析时,再阅读: [references/data-processing.md](references/data-processing.md) ## 输出要求 diff --git a/kernal/clawhub-skills/aios-call-app-service/references/invoke-rules.md b/kernal/clawhub-skills/aios-call-app-service/references/invoke-rules.md index e685442..a9c9cf3 100644 --- a/kernal/clawhub-skills/aios-call-app-service/references/invoke-rules.md +++ b/kernal/clawhub-skills/aios-call-app-service/references/invoke-rules.md @@ -12,7 +12,7 @@ aios-apps-invoke-cli status ## 必守规则 -- `-p/-s` 分别来自 `provider`、当前 `SessionId` +- `-p/-s` 分别来自 `provider`、当前 `SessionId` (上下文中的 `topic_id` ) - 当前 CLI 只支持 `provider=hzg` - 如果 ontology、用户或上下文指向其他 provider,直接停止并说明当前运行链路不支持 - 缺任意运行时参数都不要猜,直接停止并说明缺口 diff --git a/kernal/clawhub-skills/aios-make-chart-image/.gitignore b/kernal/clawhub-skills/aios-make-chart-image/.gitignore new file mode 100644 index 0000000..c5ae44e --- /dev/null +++ b/kernal/clawhub-skills/aios-make-chart-image/.gitignore @@ -0,0 +1,34 @@ +file_input/ +file_output/ +node_modules/ +coverage/ +test-results/ +junit.xml +junit-*.xml + +tmp/ +tmp-*/ +.tmp/ +.tmp-*/ +*.tmp +*.temp + +*.zip +*.7z +*.tar +*.tar.gz +*.tgz + +models/ +model/ +*.gguf +*.onnx +*.safetensors +*.pt +*.pth +*.ckpt +*.pb +*.tflite + +.DS_Store +Thumbs.db diff --git a/kernal/clawhub-skills/aios-make-chart-image/SKILL.md b/kernal/clawhub-skills/aios-make-chart-image/SKILL.md new file mode 100644 index 0000000..50b6327 --- /dev/null +++ b/kernal/clawhub-skills/aios-make-chart-image/SKILL.md @@ -0,0 +1,102 @@ +--- +name: aios-make-chart-image +description: 当 OpenClaw 或 AIOS agent 需要把 JSON、Markdown 表格或 ECharts option 渲染成图表图片时,必须使用本技能。使用内置 JavaScript 脚本解析数据、生成 ECharts 配置,并导出 PNG、SVG、JPEG 或 WebP 图片;不要临时手写浏览器截图流程。 +--- + +# AIOS 图表图片生成 + +本技能用于把结构化数据生成 ECharts 图表,并导出为图片文件。优先使用内置脚本 `scripts/make_chart_image.mjs`,不要在对话中临时拼一个替代渲染脚本。 + +## 适用场景 + +- 用户提供 JSON 数据并要求生成柱状图、折线图、饼图、散点图等图片。 +- 用户提供 Markdown 表格并要求生成图表图片。 +- 用户提供完整 ECharts option,需要稳定导出为图片。 +- 需要把图表文件继续通过 `aios-transfer-file` 返回给用户。 + +## 不可违反的规则 + +- 必须在本 skill 目录中运行内置脚本,确保依赖从本地 `node_modules` 解析。 +- 本项目统一使用 JavaScript/TypeScript;不要为本技能添加 Python 渲染流程。 +- 优先输出到当前工作区内的 `file_output` 或用户明确指定的工作区路径。 +- 如果需要把生成的图片发给用户,先生成本地文件,再使用 `aios-transfer-file` 上传返回。 +- 如果输入是完整 ECharts option,保留用户提供的 option,只补必要的画布尺寸和背景色。 +- 如果输入是表格数据,先选择合适的 `chartType`、分类字段和数值字段;字段不明确时说明假设。 + +## 依赖处理 + +本技能在 `package.json` 中声明: + +- `echarts` +- `sharp` + +首次使用前检查依赖: + +```bash +cd /path/to/aios-make-chart-image +npm ls echarts sharp --depth=0 +``` + +如果依赖缺失,在本 skill 目录执行: + +```bash +npm install +``` + +## 基本用法 + +从 JSON 或 Markdown 文件生成 PNG: + +```bash +node scripts/make_chart_image.mjs --input "/abs/path/data.json" --output "/abs/path/chart.png" +node scripts/make_chart_image.mjs --input "/abs/path/table.md" --output "/abs/path/chart.png" --chart-type line +``` + +从 stdin 读取: + +```bash +cat table.md | node scripts/make_chart_image.mjs --input - --output "/abs/path/chart.png" +``` + +保存推断出的 ECharts option 便于审查: + +```bash +node scripts/make_chart_image.mjs --input data.md --output chart.png --save-option chart.option.json +``` + +## 支持的输入 + +JSON 输入可以是: + +- 完整 ECharts option,例如包含 `series`、`xAxis`、`yAxis` 等字段的对象。 +- `{ "option": { ... } }` 包装形式。 +- 行对象数组:`[{ "month": "Jan", "sales": 120 }, ...]` +- 首行为表头的二维数组:`[["month", "sales"], ["Jan", 120]]` +- `{ "columns": [...], "rows": [...] }` +- `{ "labels": [...], "values": [...] }` + +Markdown 输入使用第一张标准 Markdown 表格,并会尝试从第一个一级/二级标题读取默认标题。 + +## 常用参数 + +- `--chart-type auto|bar|line|pie|scatter`,默认 `auto`。 +- `--title "标题"`,覆盖输入里的标题。 +- `--width 1200 --height 800`,默认 `1200x800`。 +- `--format png|svg|jpg|jpeg|webp`,未指定时从输出扩展名推断。 +- `--x field`,指定分类字段。 +- `--y field1,field2`,指定数值字段。 +- `--name field --value field`,指定饼图字段。 +- `--theme light|dark`,默认 `light`。 +- `--save-option file.json`,保存最终 ECharts option。 + +## 输出要求 + +脚本成功后会在 stdout 输出 JSON,至少包含: + +- `output` +- `format` +- `width` +- `height` +- `chartType` + +如果脚本失败,读取 stderr 的真实错误并据实报告,不要声称图片已生成。 diff --git a/kernal/clawhub-skills/aios-make-chart-image/agents/openai.yaml b/kernal/clawhub-skills/aios-make-chart-image/agents/openai.yaml new file mode 100644 index 0000000..88a3e31 --- /dev/null +++ b/kernal/clawhub-skills/aios-make-chart-image/agents/openai.yaml @@ -0,0 +1,7 @@ +interface: + display_name: "AIOS Chart Image" + short_description: "Render JSON or Markdown data into ECharts images" + default_prompt: "Use $aios-make-chart-image to turn JSON, Markdown tables, or ECharts options into PNG, SVG, JPEG, or WebP chart images." + +policy: + allow_implicit_invocation: true diff --git a/kernal/clawhub-skills/aios-make-chart-image/package-lock.json b/kernal/clawhub-skills/aios-make-chart-image/package-lock.json new file mode 100644 index 0000000..a638bcd --- /dev/null +++ b/kernal/clawhub-skills/aios-make-chart-image/package-lock.json @@ -0,0 +1,634 @@ +{ + "name": "aios-make-chart-image", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "aios-make-chart-image", + "dependencies": { + "echarts": "^6.0.0", + "sharp": "^0.34.5" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz", + "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime/node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD", + "optional": true + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/echarts": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/echarts/-/echarts-6.1.0.tgz", + "integrity": "sha512-q0yaFPggC9FUdsWH4blavRWFmxdrIodbkoKNAjJudAI6CA9gNPxHtV2RcZNEepZVlk4yvBYkOkbk6HIVpIyHZA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "2.3.0", + "zrender": "6.1.0" + } + }, + "node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/tslib": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.0.tgz", + "integrity": "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==", + "license": "0BSD" + }, + "node_modules/zrender": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.1.0.tgz", + "integrity": "sha512-oEGMDB6pOP2S6OwRR4PdVv610zrjnA3Bh+JnSG12fYJlBKjtNAoEb5fSUoCOOINlH96I2fU38/A2UpRKs67xYQ==", + "license": "BSD-3-Clause", + "dependencies": { + "tslib": "2.3.0" + } + } + } +} diff --git a/kernal/clawhub-skills/aios-make-chart-image/package.json b/kernal/clawhub-skills/aios-make-chart-image/package.json new file mode 100644 index 0000000..67660ad --- /dev/null +++ b/kernal/clawhub-skills/aios-make-chart-image/package.json @@ -0,0 +1,9 @@ +{ + "name": "aios-make-chart-image", + "private": true, + "type": "module", + "dependencies": { + "echarts": "^6.0.0", + "sharp": "^0.34.5" + } +} diff --git a/kernal/clawhub-skills/aios-make-chart-image/scripts/make_chart_image.mjs b/kernal/clawhub-skills/aios-make-chart-image/scripts/make_chart_image.mjs new file mode 100644 index 0000000..81a8847 --- /dev/null +++ b/kernal/clawhub-skills/aios-make-chart-image/scripts/make_chart_image.mjs @@ -0,0 +1,842 @@ +#!/usr/bin/env node + +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import * as echarts from "echarts"; +import sharp from "sharp"; + +const DEFAULT_WIDTH = 1200; +const DEFAULT_HEIGHT = 800; +const SUPPORTED_FORMATS = new Set(["png", "svg", "jpg", "jpeg", "webp"]); +const OPTION_KEYS = new Set([ + "series", + "xAxis", + "yAxis", + "dataset", + "radar", + "geo", + "calendar", + "grid", + "polar", + "angleAxis", + "radiusAxis" +]); + +const PALETTE = [ + "#2563eb", + "#16a34a", + "#dc2626", + "#9333ea", + "#ea580c", + "#0891b2", + "#be123c", + "#4f46e5", + "#65a30d", + "#c2410c" +]; + +function fail(message, code = 1) { + console.error(message); + process.exit(code); +} + +function usage() { + return [ + "Usage:", + " node scripts/make_chart_image.mjs --input data.json --output chart.png [options]", + " cat table.md | node scripts/make_chart_image.mjs --input - --output chart.svg", + "", + "Options:", + " --input JSON or Markdown input. Use - for stdin.", + " --data Inline JSON or Markdown input.", + " --output Output image path.", + " --format Defaults to output extension.", + " --chart-type ", + " --title ", + " --width Default 1200.", + " --height Default 800.", + " --x Category or x field.", + " --y Numeric value fields.", + " --name Pie name field.", + " --value Pie value field.", + " --theme Default light.", + " --save-option Save generated ECharts option JSON.", + " --help" + ].join("\n"); +} + +function parseArgs(argv) { + const args = {}; + + for (let index = 0; index < argv.length; index += 1) { + const token = argv[index]; + if (!token.startsWith("--")) { + fail(`Unexpected argument: ${token}`); + } + + const eqIndex = token.indexOf("="); + const key = eqIndex === -1 ? token.slice(2) : token.slice(2, eqIndex); + if (!key) { + fail(`Invalid option: ${token}`); + } + + if (key === "help") { + args.help = true; + continue; + } + + if (eqIndex !== -1) { + args[key] = token.slice(eqIndex + 1); + continue; + } + + const value = argv[index + 1]; + if (value === undefined || value.startsWith("--")) { + fail(`Missing value for --${key}`); + } + args[key] = value; + index += 1; + } + + return args; +} + +function readNumberArg(args, name, fallback) { + if (args[name] === undefined) { + return fallback; + } + + const value = Number(args[name]); + if (!Number.isFinite(value) || value <= 0) { + fail(`--${name} must be a positive number`); + } + + return Math.round(value); +} + +function deriveFormat(args) { + const explicit = args.format?.trim().toLowerCase(); + if (explicit) { + if (!SUPPORTED_FORMATS.has(explicit)) { + fail(`Unsupported --format: ${args.format}`); + } + return explicit; + } + + const ext = path.extname(args.output ?? "").replace(".", "").toLowerCase(); + if (!ext || !SUPPORTED_FORMATS.has(ext)) { + fail("Unable to infer image format from --output. Pass --format png|svg|jpg|jpeg|webp."); + } + + return ext; +} + +function normalizeFormat(format) { + return format === "jpg" ? "jpeg" : format; +} + +async function readStdin() { + const chunks = []; + for await (const chunk of process.stdin) { + chunks.push(Buffer.from(chunk)); + } + return Buffer.concat(chunks).toString("utf8"); +} + +async function loadInput(args) { + if (args.data !== undefined) { + return { + text: String(args.data), + sourcePath: undefined + }; + } + + if (!args.input) { + fail("Missing --input or --data"); + } + + if (args.input === "-") { + return { + text: await readStdin(), + sourcePath: undefined + }; + } + + const sourcePath = path.resolve(args.input); + return { + text: await readFile(sourcePath, "utf8"), + sourcePath + }; +} + +function parseScalar(value) { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (/^(null|undefined)$/i.test(trimmed)) { + return null; + } + if (/^(true|false)$/i.test(trimmed)) { + return trimmed.toLowerCase() === "true"; + } + + const normalized = trimmed + .replace(/^[¥¥$€£]\s*/, "") + .replace(/\s*%$/, "") + .replace(/,/g, ""); + if (/^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/i.test(normalized)) { + return Number(normalized); + } + + return trimmed.replace(//gi, "\n"); +} + +function splitMarkdownRow(line) { + let content = line.trim(); + if (content.startsWith("|")) { + content = content.slice(1); + } + if (content.endsWith("|")) { + content = content.slice(0, -1); + } + + const cells = []; + let current = ""; + let escaped = false; + + for (const char of content) { + if (escaped) { + current += char; + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === "|") { + cells.push(current.trim()); + current = ""; + continue; + } + current += char; + } + + cells.push(current.trim()); + return cells; +} + +function isMarkdownSeparator(line) { + const cells = splitMarkdownRow(line); + return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim())); +} + +function findMarkdownTitle(text) { + const match = text.match(/^\s{0,3}#{1,2}\s+(.+?)\s*#*\s*$/m); + return match?.[1]?.trim(); +} + +function parseMarkdownTable(text) { + const lines = text.split(/\r?\n/); + + for (let index = 0; index < lines.length - 1; index += 1) { + if (!lines[index].includes("|") || !isMarkdownSeparator(lines[index + 1])) { + continue; + } + + const columns = splitMarkdownRow(lines[index]).map((cell, columnIndex) => cell || `Column ${columnIndex + 1}`); + const rows = []; + + for (let rowIndex = index + 2; rowIndex < lines.length; rowIndex += 1) { + const line = lines[rowIndex]; + if (!line.includes("|") || !line.trim()) { + break; + } + + const cells = splitMarkdownRow(line); + if (cells.length === 0) { + continue; + } + + const row = {}; + columns.forEach((column, columnIndex) => { + row[column] = parseScalar(cells[columnIndex] ?? ""); + }); + rows.push(row); + } + + if (rows.length === 0) { + fail("Markdown table has headers but no data rows"); + } + + return { + kind: "table", + rows, + columns, + title: findMarkdownTitle(text) + }; + } + + fail("No Markdown table found in input"); +} + +function looksLikeEchartsOption(value) { + return Boolean( + value + && typeof value === "object" + && !Array.isArray(value) + && Object.keys(value).some((key) => OPTION_KEYS.has(key)) + ); +} + +function parseJsonInput(text) { + let value; + try { + value = JSON.parse(text); + } catch (error) { + const reason = error instanceof Error ? error.message : String(error); + fail(`Input is not valid JSON: ${reason}`); + } + + if (looksLikeEchartsOption(value)) { + return { + kind: "option", + option: value + }; + } + + if (looksLikeEchartsOption(value?.option)) { + return { + kind: "option", + option: value.option, + title: value.title + }; + } + + return normalizeDataInput(value); +} + +function parseInput(text, sourcePath) { + let trimmed = text.trim(); + if (!trimmed) { + fail("Input is empty"); + } + + const ext = sourcePath ? path.extname(sourcePath).toLowerCase() : ""; + if (!sourcePath && ext !== ".json" && !trimmed.includes("\n") && trimmed.includes("\\n")) { + trimmed = trimmed.replace(/\\r\\n/g, "\n").replace(/\\n/g, "\n"); + } + + if (ext === ".md" || ext === ".markdown") { + return parseMarkdownTable(trimmed); + } + if (ext === ".json") { + return parseJsonInput(trimmed); + } + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + return parseJsonInput(trimmed); + } + return parseMarkdownTable(trimmed); +} + +function normalizeDataInput(value) { + if (Array.isArray(value)) { + return normalizeRows(value); + } + + if (!value || typeof value !== "object") { + fail("JSON input must be an object, an array, or an ECharts option"); + } + + if (Array.isArray(value.data)) { + const normalized = normalizeRows(value.data); + return { + ...normalized, + title: value.title, + chartType: value.chartType ?? value.type, + xField: value.xField ?? value.x, + yFields: value.yFields ?? value.y, + nameField: value.nameField ?? value.name, + valueField: value.valueField ?? value.value + }; + } + + if (Array.isArray(value.rows)) { + const normalized = normalizeRows(value.rows, value.columns); + return { + ...normalized, + title: value.title, + chartType: value.chartType ?? value.type, + xField: value.xField ?? value.x, + yFields: value.yFields ?? value.y, + nameField: value.nameField ?? value.name, + valueField: value.valueField ?? value.value + }; + } + + if (Array.isArray(value.labels) && Array.isArray(value.values)) { + const rows = value.labels.map((label, index) => ({ + label, + value: parseScalar(String(value.values[index] ?? "")) + })); + return { + kind: "table", + rows, + columns: ["label", "value"], + title: value.title, + chartType: value.chartType ?? value.type, + xField: "label", + yFields: ["value"], + nameField: "label", + valueField: "value" + }; + } + + const entries = Object.entries(value); + if (entries.length > 0 && entries.every(([, entryValue]) => typeof entryValue === "number")) { + return { + kind: "table", + rows: entries.map(([name, entryValue]) => ({ name, value: entryValue })), + columns: ["name", "value"], + xField: "name", + yFields: ["value"], + nameField: "name", + valueField: "value" + }; + } + + fail("Unsupported JSON data shape. Provide an ECharts option, an array of rows, rows/columns, or labels/values."); +} + +function normalizeRows(rows, columnsHint) { + if (rows.length === 0) { + fail("Data rows are empty"); + } + + if (rows.every((row) => typeof row === "number")) { + const normalizedRows = rows.map((value, index) => ({ index: index + 1, value })); + return { + kind: "table", + rows: normalizedRows, + columns: ["index", "value"], + xField: "index", + yFields: ["value"] + }; + } + + if (rows.every((row) => Array.isArray(row))) { + const first = rows[0]; + const hasHeader = first.every((cell) => typeof cell === "string"); + const columns = columnsHint ?? (hasHeader ? first : first.map((_, index) => `Column ${index + 1}`)); + const dataRows = hasHeader && !columnsHint ? rows.slice(1) : rows; + const normalizedRows = dataRows.map((row) => { + const record = {}; + columns.forEach((column, index) => { + record[String(column)] = typeof row[index] === "string" ? parseScalar(row[index]) : row[index] ?? null; + }); + return record; + }); + + return { + kind: "table", + rows: normalizedRows, + columns: columns.map(String) + }; + } + + if (rows.every((row) => row && typeof row === "object" && !Array.isArray(row))) { + const discoveredColumns = []; + for (const row of rows) { + for (const key of Object.keys(row)) { + if (!discoveredColumns.includes(key)) { + discoveredColumns.push(key); + } + } + } + const columns = columnsHint?.map(String) ?? discoveredColumns; + const normalizedRows = rows.map((row) => { + const record = {}; + columns.forEach((column) => { + const value = row[column]; + record[column] = typeof value === "string" ? parseScalar(value) : value ?? null; + }); + return record; + }); + return { + kind: "table", + rows: normalizedRows, + columns + }; + } + + fail("Rows must be numbers, arrays, or objects"); +} + +function unique(values) { + return [...new Set(values)]; +} + +function hasNumericValue(rows, field) { + return rows.some((row) => typeof row[field] === "number" && Number.isFinite(row[field])); +} + +function numericFields(rows, columns) { + return columns.filter((column) => hasNumericValue(rows, column)); +} + +function firstNonNumericField(rows, columns) { + return columns.find((column) => !hasNumericValue(rows, column)) ?? columns[0]; +} + +function splitList(value) { + if (Array.isArray(value)) { + return value.map(String).filter(Boolean); + } + if (typeof value === "string") { + return value.split(",").map((item) => item.trim()).filter(Boolean); + } + if (value === undefined || value === null) { + return undefined; + } + return [String(value)]; +} + +function inferChartType({ requested, rows, columns, xField, yFields }) { + if (requested && requested !== "auto") { + return requested; + } + + if (yFields.length === 1 && rows.length <= 12) { + return "bar"; + } + + const sampleX = rows.find((row) => row[xField] !== null && row[xField] !== undefined)?.[xField]; + if (typeof sampleX === "string" && /^\d{4}[-/]\d{1,2}([-/]\d{1,2})?/.test(sampleX)) { + return "line"; + } + + if (columns.length >= 2 && yFields.length > 1) { + return "line"; + } + + return "bar"; +} + +function ensureField(columns, field, optionName) { + if (!field) { + return; + } + if (!columns.includes(field)) { + fail(`${optionName} field "${field}" was not found. Available fields: ${columns.join(", ")}`); + } +} + +function valueOrNull(value) { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function commonOption({ title, subtitle, theme }) { + const dark = theme === "dark"; + return { + backgroundColor: dark ? "#0f172a" : "#ffffff", + color: PALETTE, + textStyle: { + fontFamily: "Arial, Helvetica, sans-serif", + color: dark ? "#e5e7eb" : "#111827" + }, + title: title ? { + text: title, + subtext: subtitle, + left: "center", + top: 24, + textStyle: { + fontSize: 24, + fontWeight: 700, + color: dark ? "#f8fafc" : "#111827" + }, + subtextStyle: { + color: dark ? "#cbd5e1" : "#64748b" + } + } : undefined, + tooltip: {}, + legend: { + type: "scroll", + top: title ? 76 : 24, + textStyle: { + color: dark ? "#e5e7eb" : "#374151" + } + } + }; +} + +function buildOptionFromTable(input, args) { + const rows = input.rows; + const columns = input.columns; + const requestedType = (args["chart-type"] ?? input.chartType ?? "auto").toLowerCase(); + if (!["auto", "bar", "line", "pie", "scatter"].includes(requestedType)) { + fail(`Unsupported --chart-type: ${requestedType}`); + } + + const theme = (args.theme ?? "light").toLowerCase(); + if (!["light", "dark"].includes(theme)) { + fail("--theme must be light or dark"); + } + + const title = args.title ?? input.title; + const base = commonOption({ title, theme }); + const numeric = numericFields(rows, columns); + const xField = args.x ?? input.xField ?? firstNonNumericField(rows, columns); + const yFields = splitList(args.y ?? input.yFields) ?? numeric.filter((field) => field !== xField); + const nameField = args.name ?? input.nameField ?? firstNonNumericField(rows, columns); + const valueField = args.value ?? input.valueField ?? yFields[0] ?? numeric[0]; + + ensureField(columns, xField, "--x"); + yFields.forEach((field) => ensureField(columns, field, "--y")); + ensureField(columns, nameField, "--name"); + ensureField(columns, valueField, "--value"); + + const chartType = inferChartType({ + requested: requestedType, + rows, + columns, + xField, + yFields + }); + + if (chartType === "pie") { + if (!nameField || !valueField) { + fail("Pie charts require a name field and a value field"); + } + return { + option: { + ...base, + tooltip: { trigger: "item" }, + legend: { + ...base.legend, + orient: "vertical", + right: 28, + top: "middle" + }, + series: [{ + name: valueField, + type: "pie", + radius: ["35%", "68%"], + center: ["43%", "55%"], + avoidLabelOverlap: true, + label: { + formatter: "{b}: {d}%" + }, + data: rows.map((row) => ({ + name: String(row[nameField] ?? ""), + value: valueOrNull(row[valueField]) + })) + }] + }, + chartType + }; + } + + if (chartType === "scatter") { + const scatterFields = yFields.length >= 2 ? yFields : numeric.slice(0, 2); + if (scatterFields.length < 2) { + fail("Scatter charts require at least two numeric fields"); + } + return { + option: { + ...base, + tooltip: { + trigger: "item" + }, + grid: { + left: 72, + right: 44, + top: title ? 132 : 88, + bottom: 72 + }, + xAxis: { + type: "value", + name: scatterFields[0], + nameLocation: "middle", + nameGap: 42 + }, + yAxis: { + type: "value", + name: scatterFields[1], + nameLocation: "middle", + nameGap: 52 + }, + series: [{ + name: `${scatterFields[0]} / ${scatterFields[1]}`, + type: "scatter", + symbolSize: 10, + data: rows.map((row) => [valueOrNull(row[scatterFields[0]]), valueOrNull(row[scatterFields[1]])]) + }] + }, + chartType + }; + } + + if (!xField) { + fail("Bar and line charts require a category field. Pass --x ."); + } + if (yFields.length === 0) { + fail("No numeric value fields found. Pass --y ."); + } + + const categories = rows.map((row) => String(row[xField] ?? "")); + const option = { + ...base, + tooltip: { trigger: "axis" }, + grid: { + left: 72, + right: 44, + top: title ? 132 : 88, + bottom: categories.length > 12 ? 112 : 72, + containLabel: true + }, + xAxis: { + type: "category", + data: categories, + axisLabel: { + interval: categories.length > 18 ? "auto" : 0, + rotate: categories.length > 12 ? 30 : 0 + } + }, + yAxis: { + type: "value" + }, + series: yFields.map((field) => ({ + name: field, + type: chartType, + smooth: chartType === "line", + showSymbol: chartType === "line" && rows.length <= 60, + data: rows.map((row) => valueOrNull(row[field])), + emphasis: { + focus: "series" + } + })) + }; + + if (categories.length > 30) { + option.dataZoom = [{ + type: "inside" + }, { + type: "slider", + bottom: 24 + }]; + } + + return { + option, + chartType + }; +} + +function finalizeProvidedOption(option, args) { + const theme = (args.theme ?? "light").toLowerCase(); + if (!["light", "dark"].includes(theme)) { + fail("--theme must be light or dark"); + } + const dark = theme === "dark"; + const finalOption = structuredClone(option); + if (args.title) { + finalOption.title = { + ...(typeof finalOption.title === "object" && !Array.isArray(finalOption.title) ? finalOption.title : {}), + text: args.title + }; + } + if (!finalOption.backgroundColor) { + finalOption.backgroundColor = dark ? "#0f172a" : "#ffffff"; + } + return { + option: finalOption, + chartType: inferTypeFromOption(finalOption) + }; +} + +function inferTypeFromOption(option) { + const series = Array.isArray(option.series) ? option.series : option.series ? [option.series] : []; + return unique(series.map((item) => item?.type).filter(Boolean)).join(",") || "custom"; +} + +function renderSvg(option, { width, height, theme }) { + const chart = echarts.init(null, theme === "dark" ? "dark" : null, { + renderer: "svg", + ssr: true, + width, + height + }); + + chart.setOption(option, true); + const svg = chart.renderToSVGString(); + chart.dispose(); + return svg; +} + +async function writeImage(svg, outputPath, format) { + await mkdir(path.dirname(outputPath), { recursive: true }); + + if (format === "svg") { + await writeFile(outputPath, svg, "utf8"); + return; + } + + const normalized = normalizeFormat(format); + let image = sharp(Buffer.from(svg)); + if (normalized === "png") { + image = image.png(); + } else if (normalized === "jpeg") { + image = image.flatten({ background: "#ffffff" }).jpeg({ quality: 92 }); + } else if (normalized === "webp") { + image = image.webp({ quality: 92 }); + } + + await image.toFile(outputPath); +} + +async function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log(usage()); + return; + } + + if (!args.output) { + fail("Missing --output"); + } + + const width = readNumberArg(args, "width", DEFAULT_WIDTH); + const height = readNumberArg(args, "height", DEFAULT_HEIGHT); + const format = deriveFormat(args); + const theme = (args.theme ?? "light").toLowerCase(); + const outputPath = path.resolve(args.output); + const { text, sourcePath } = await loadInput(args); + const input = parseInput(text, sourcePath); + const built = input.kind === "option" + ? finalizeProvidedOption(input.option, args) + : buildOptionFromTable(input, args); + + if (args["save-option"]) { + const optionPath = path.resolve(args["save-option"]); + await mkdir(path.dirname(optionPath), { recursive: true }); + await writeFile(optionPath, `${JSON.stringify(built.option, null, 2)}\n`, "utf8"); + } + + const svg = renderSvg(built.option, { width, height, theme }); + await writeImage(svg, outputPath, format); + + console.log(JSON.stringify({ + output: outputPath, + format, + width, + height, + chartType: built.chartType, + option: args["save-option"] ? path.resolve(args["save-option"]) : undefined + }, null, 2)); +} + +main().catch((error) => { + const reason = error instanceof Error ? error.message : String(error); + fail(reason); +}); diff --git a/kernal/clawhub-skills/aios-transfer-file/SKILL.md b/kernal/clawhub-skills/aios-transfer-file/SKILL.md index e4269de..16e39fd 100644 --- a/kernal/clawhub-skills/aios-transfer-file/SKILL.md +++ b/kernal/clawhub-skills/aios-transfer-file/SKILL.md @@ -7,7 +7,7 @@ description: 通过兼容 AWS S3 的 SDK 为 OpenClaw 和 AIOS agent 处理文 > 本技能只适用于受控 AIOS 部署。它可能访问真实业务系统,必须按最小权限配置。只读操作可以自动执行;任何会改变状态的操作,包括创建、更新、提交、审批、驳回、删除或触发,都必须先预览目标应用、命令和请求体,并获得明确人工确认。CLI 必须固定到经过评审的版本,Ontology 来源必须可信且可审计。 -在 OpenClaw 中,所有文件传输都必须使用兼容 AWS S3 的 SDK。这适用于附件、`file_input://` 消息,以及任何需要把文件返回给用户的场景。 +在 OpenClaw 中,所有文件和图片传输,包括接受用户发来的文件,和给用户发送文件,都必须使用兼容 AWS S3 的 SDK。这适用于附件、`file_input://` 消息,以及任何需要把文件返回给用户的场景。 ## 不可违反的规则 -- Gitee From 2ece4fef7ad29b9c5e918c902cc3f6f37fd68611 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Thu, 11 Jun 2026 07:58:28 +0800 Subject: [PATCH 03/18] bugfix --- docker-images/kernal/Dockerfile | 26 ++++- .../kernal/assets/scripts/startup.sh | 44 ++++++- joint-test/all-in-one-mode/run-flow.sh | 110 +++++++++++++++++- joint-test/dmz-mode/playwright-dev2-flow.js | 32 +++++ 4 files changed, 205 insertions(+), 7 deletions(-) diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index bc856dc..30fff48 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -242,6 +242,7 @@ ENV HOME=/opt/aios-seed/home ENV AIOS_OPENCLAW_HOME=/opt/aios-seed/home/.openclaw ENV AIOS_OPENCLAW_WORKSPACE_DIR=/opt/aios-seed/workspace-default ENV OPENCLAW_HOME=/opt/aios-seed/home +ENV OPENCLAW_CONFIG_PATH=/opt/aios-seed/home/.openclaw/openclaw.json ENV OPENCLAW_WORKSPACE_DIR=/opt/aios-seed/workspace-default ENV XDG_CONFIG_HOME=/opt/aios-seed/home/.config ENV XDG_CACHE_HOME=/opt/aios-seed/home/.cache @@ -275,7 +276,18 @@ RUN set -eux; \ > /usr/local/bin/install-seed-skill; \ chmod +x /usr/local/bin/install-seed-skill -RUN openclaw plugins install aios-mqtt-channel +RUN set -eux; \ + openclaw plugins install aios-mqtt-channel; \ + mqtt_plugin_path="${AIOS_OPENCLAW_HOME}/npm/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel"; \ + test -d "${mqtt_plugin_path}"; \ + tmp_json="$(mktemp)"; \ + jq \ + --arg mqttPluginPath "${mqtt_plugin_path}" \ + '.plugins.load.paths = (((.plugins.load.paths // []) + [$mqttPluginPath]) | unique) | .plugins.allow = ((.plugins.allow // []) + ["aios-mqtt-channel"] | unique) | .plugins.entries["aios-mqtt-channel"].enabled = true' \ + "${OPENCLAW_CONFIG_PATH}" > "${tmp_json}"; \ + mv "${tmp_json}" "${OPENCLAW_CONFIG_PATH}"; \ + openclaw --no-color plugins registry --refresh --json >/tmp/openclaw-plugin-registry.json; \ + node -e 'const fs=require("node:fs"); const registry=JSON.parse(fs.readFileSync("/tmp/openclaw-plugin-registry.json","utf8")); const plugins=registry.registry?.plugins||registry.plugins||[]; const plugin=plugins.find((entry)=>entry.pluginId==="aios-mqtt-channel"); if (!plugin || plugin.enabled !== true) { console.error("aios-mqtt-channel plugin was not enabled in seed registry"); process.exit(1); }' RUN skill="aios-call-app-service"; \ install-seed-skill "${skill}" @@ -327,7 +339,7 @@ RUN set -eux; \ FROM runtime-base AS final ARG TARGETARCH -ARG OPENCLAW_VERSION=2026.5.28 +ARG OPENCLAW_VERSION=2026.6.5 COPY --from=tool-builder /opt/aios-toolchain /opt/aios-toolchain COPY --from=tool-builder /opt/agent-browser /opt/agent-browser @@ -394,7 +406,15 @@ RUN set -eux; \ find /opt/aios-seed -type d -exec chmod 0750 {} +; \ find /opt/aios-seed -type f -perm /111 -exec chmod 0750 {} +; \ find /opt/aios-seed -type f ! -perm /111 -exec chmod 0640 {} +; \ - find /opt/aios-seed -type l -exec chown -h "${AIOS_SERVICE_USER}:${AIOS_OPENCLAW_GROUP}" {} + + find /opt/aios-seed -type l -exec chown -h "${AIOS_SERVICE_USER}:${AIOS_OPENCLAW_GROUP}" {} +; \ + mqtt_plugin_path=/opt/aios-seed/home/.openclaw/npm/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel; \ + if [[ -d "${mqtt_plugin_path}" ]]; then \ + chown -R root:root "${mqtt_plugin_path}"; \ + find "${mqtt_plugin_path}" -type d -exec chmod 0755 {} +; \ + find "${mqtt_plugin_path}" -type f -perm /111 -exec chmod 0755 {} +; \ + find "${mqtt_plugin_path}" -type f ! -perm /111 -exec chmod 0644 {} +; \ + find "${mqtt_plugin_path}" -type l -exec chown -h root:root {} +; \ + fi ENV TMPDIR=/var/aios/tmp diff --git a/docker-images/kernal/assets/scripts/startup.sh b/docker-images/kernal/assets/scripts/startup.sh index 49efbad..85d5641 100644 --- a/docker-images/kernal/assets/scripts/startup.sh +++ b/docker-images/kernal/assets/scripts/startup.sh @@ -19,6 +19,7 @@ OPENCLAW_WORKSPACE_DIR="${AIOS_OPENCLAW_WORKSPACE_DIR}" SEED_HOME="${AIOS_SEED_HOME:-/opt/aios-seed/home}" SEED_OPENCLAW_HOME="${SEED_HOME}/.openclaw" SEED_NPM_ROOT="${SEED_OPENCLAW_HOME}/npm" +SEED_MQTT_PLUGIN_ROOT="${AIOS_SEED_MQTT_PLUGIN_ROOT:-${SEED_NPM_ROOT}/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel}" SEED_WORKSPACE_DIR="${AIOS_SEED_WORKSPACE_DIR:-/opt/aios-seed/workspace-default}" SEED_TEMPLATES_DIR="${AIOS_SEED_TEMPLATES_DIR:-/opt/aios-seed/workspace-templates}" SEED_SCRIPTS_DIR="${AIOS_SEED_SCRIPTS_DIR:-/opt/aios-seed/scripts}" @@ -1919,6 +1920,7 @@ patch_openclaw_config() { --arg workspace "$AIOS_OPENCLAW_WORKSPACE_DIR" \ --arg qmdCommand "$QMD_COMMAND" \ --arg gatewayAuthToken "$gateway_auth_token" \ + --arg mqttPluginPath "$SEED_MQTT_PLUGIN_ROOT" \ ' if (.models.providers? | type) == "object" then .models.providers |= with_entries( @@ -1943,6 +1945,7 @@ patch_openclaw_config() { | .memory.qmd.searchMode = "query" | .memory.qmd.update.startup = "idle" | .memory.qmd.limits.timeoutMs = 120000 + | .plugins.load.paths = (((.plugins.load.paths // []) + [$mqttPluginPath]) | unique) | .plugins.allow = ((.plugins.allow // []) + ["aios-mqtt-channel"] | unique) | .plugins.entries["aios-mqtt-channel"].enabled = true ' "$config_source" > "$tmp_json" @@ -1950,6 +1953,45 @@ patch_openclaw_config() { ensure_shared_runtime_file_mode "$config_path" } +refresh_openclaw_plugin_registry() { + local tmp_json + + if [[ ! -x "$OPENCLAW_COMMAND" ]]; then + log "warning: OpenClaw command not found: ${OPENCLAW_COMMAND}" + return 0 + fi + + if [[ ! -d "$SEED_MQTT_PLUGIN_ROOT" ]]; then + log "warning: seed MQTT plugin root not found: ${SEED_MQTT_PLUGIN_ROOT}" + return 0 + fi + + tmp_json="$(mktemp)" + if run_with_runtime_env "$AIOS_OPENCLAW_USER" "$OPENCLAW_COMMAND" --no-color plugins registry --refresh --json >"$tmp_json"; then + if node - "$tmp_json" <<'NODE' +const fs = require("node:fs"); +const [registryPath] = process.argv.slice(2); +const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); +const plugins = registry.registry?.plugins || registry.plugins || []; +const plugin = plugins.find((entry) => entry.pluginId === "aios-mqtt-channel"); +if (!plugin || plugin.enabled !== true) { + process.exit(1); +} +NODE + then + rm -f "$tmp_json" + else + rm -f "$tmp_json" + log "error: aios-mqtt-channel was not enabled after OpenClaw plugin registry refresh" + return 1 + fi + else + rm -f "$tmp_json" + log "error: unable to refresh OpenClaw plugin registry" + return 1 + fi +} + export_runtime_environment() { if [[ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" && -x /opt/agent-browser/chrome-linux64/chrome ]]; then AGENT_BROWSER_EXECUTABLE_PATH=/opt/agent-browser/chrome-linux64/chrome @@ -2441,7 +2483,7 @@ main() { profile_step "ensure_directories" ensure_directories profile_step "seed_runtime_content" seed_runtime_content profile_step "patch_openclaw_config" patch_openclaw_config - profile_step "repair_openclaw_plugin_registry" repair_openclaw_plugin_registry + profile_step "refresh_openclaw_plugin_registry" refresh_openclaw_plugin_registry profile_step "ensure_python_venv" ensure_python_venv profile_step "enforce_agent_internal_network_block" enforce_agent_internal_network_block profile_step "run_runtime_startup_hook" run_runtime_startup_hook diff --git a/joint-test/all-in-one-mode/run-flow.sh b/joint-test/all-in-one-mode/run-flow.sh index 5608667..83b2208 100755 --- a/joint-test/all-in-one-mode/run-flow.sh +++ b/joint-test/all-in-one-mode/run-flow.sh @@ -17,6 +17,10 @@ MQTT_PORT="${AIOS_MQTT_PORT:-1884}" BASE_URL="${AIOS_WEB_BASE_URL:-http://127.0.0.1:${WEB_PORT}}" SKIP_BUILD="${AIOS_TEST_SKIP_BUILD:-0}" KEEP_CONTAINER="${AIOS_TEST_KEEP_CONTAINERS:-0}" +SOURCE_CONTAINER="${AIOS_TEST_SOURCE_CONTAINER:-}" +SOURCE_KERNEL_ENV_PATH="${AIOS_TEST_SOURCE_KERNEL_ENV_PATH:-/var/aios-all-in-one/kernel/env.json}" +SOURCE_APPS_ENV_PATH="${AIOS_TEST_SOURCE_APPS_ENV_PATH:-/var/aios-all-in-one/apps/env.json}" +SOURCE_CONFIG_PATH="${AIOS_TEST_SOURCE_CONFIG_PATH:-/opt/aios-all-in-one/sample/config.json}" log() { printf '[all-in-one-mode] %s\n' "$*" @@ -38,13 +42,52 @@ redact_json_key() { const fs = require("node:fs"); const [target] = process.argv.slice(2); const payload = JSON.parse(fs.readFileSync(target, "utf8")); -if (payload.AIOS_OPENAI_API_KEY) { - payload.AIOS_OPENAI_API_KEY = ""; - fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); +function redact(value, key = "") { + if (Array.isArray(value)) { + return value.map((item) => redact(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redact(entryValue, entryKey) + ])); + } + const normalizedKey = String(key || "").replace(/[^A-Za-z0-9]/g, "").toLowerCase(); + const isSecretKey = ( + normalizedKey.endsWith("apikey") || + normalizedKey.includes("secret") || + normalizedKey.includes("password") || + normalizedKey === "pass" || + normalizedKey === "token" || + normalizedKey.endsWith("token") + ); + if (isSecretKey && value !== undefined && value !== null && value !== "") { + return ""; + } + return value; } +fs.writeFileSync(target, `${JSON.stringify(redact(payload), null, 2)}\n`); NODE } +redact_management_db() { + local db_file="${RUNTIME_ROOT}/apps/data/web/management-console.db" + [[ -f "${db_file}" ]] || return 0 + command -v sqlite3 >/dev/null 2>&1 || return 0 + + sqlite3 "${db_file}" <<'SQL' >/dev/null 2>&1 || true +UPDATE management_requests +SET params_json = json_set(params_json, '$.apiKey', '') +WHERE json_valid(params_json) AND json_type(params_json, '$.apiKey') IS NOT NULL; +UPDATE management_requests +SET params_json = json_set(params_json, '$.api_key', '') +WHERE json_valid(params_json) AND json_type(params_json, '$.api_key') IS NOT NULL; +PRAGMA wal_checkpoint(TRUNCATE); +PRAGMA journal_mode=DELETE; +VACUUM; +SQL +} + redact_runtime_secrets() { redact_json_key "${RUNTIME_ROOT}/env.json" redact_json_key "${RUNTIME_ROOT}/generated/kernel-env.json" @@ -52,6 +95,12 @@ redact_runtime_secrets() { redact_json_key "${RUNTIME_ROOT}/kernel/env.json" redact_json_key "${RUNTIME_ROOT}/apps/env.json" redact_json_key "${RUNTIME_ROOT}/test-env.json" + redact_json_key "${RUNTIME_ROOT}/credentials.json" + redact_json_key "${RUNTIME_ROOT}/source-apps-env.json" + redact_json_key "${RUNTIME_ROOT}/kernel/config.json" + redact_json_key "${RUNTIME_ROOT}/kernel/.openclaw/openclaw.json" + redact_json_key "${RUNTIME_ROOT}/kernel/.openclaw/openclaw.json.last-good" + redact_management_db } cleanup() { @@ -82,6 +131,61 @@ prepare_runtime() { rm -rf "${RUNTIME_ROOT}" mkdir -p "${RUNTIME_ROOT}" "${REPORT_DIR}" "${DOCKER_LOG_DIR}" + if [[ -n "${SOURCE_CONTAINER}" ]]; then + log "copying runtime config from source container ${SOURCE_CONTAINER}" + docker cp "${SOURCE_CONTAINER}:${SOURCE_KERNEL_ENV_PATH}" "${RUNTIME_ROOT}/env.json" + docker cp "${SOURCE_CONTAINER}:${SOURCE_CONFIG_PATH}" "${RUNTIME_ROOT}/config.json" + if docker exec "${SOURCE_CONTAINER}" sh -lc "test -f '${SOURCE_APPS_ENV_PATH}'" >/dev/null 2>&1; then + docker cp "${SOURCE_CONTAINER}:${SOURCE_APPS_ENV_PATH}" "${RUNTIME_ROOT}/source-apps-env.json" + else + printf '{}\n' > "${RUNTIME_ROOT}/source-apps-env.json" + fi + + node - "${RUNTIME_ROOT}/env.json" "${RUNTIME_ROOT}/source-apps-env.json" "${RUNTIME_ROOT}/credentials.json" <<'NODE' +const fs = require("node:fs"); +const [envTarget, appsEnvPath, credentialsTarget] = process.argv.slice(2); + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); +} + +const env = readJson(envTarget); +const appsEnv = fs.existsSync(appsEnvPath) ? readJson(appsEnvPath) : {}; + +env.AIOS_MANAGEMENT_CONSOLE_PORT = 3030; +if (appsEnv.AIOS_INVOKE_PROXY_TOKEN && !env.AIOS_INVOKE_PROXY_TOKEN) { + env.AIOS_INVOKE_PROXY_TOKEN = appsEnv.AIOS_INVOKE_PROXY_TOKEN; +} + +const mqttPassword = env.AIOS_MQTT_CHANNEL_PASSWORD; +const minioPassword = env.AIOS_S3_SECRET_ACCESS_KEY; +if (!mqttPassword) { + throw new Error("source kernel env missing AIOS_MQTT_CHANNEL_PASSWORD"); +} +if (!minioPassword) { + throw new Error("source kernel env missing AIOS_S3_SECRET_ACCESS_KEY"); +} + +const credentials = { + mqtt: { + username: "aios", + password: mqttPassword + }, + minio: { + username: "aios", + password: minioPassword + }, + generatedAt: new Date().toISOString(), + source: "AIOS_TEST_SOURCE_CONTAINER" +}; + +fs.writeFileSync(envTarget, `${JSON.stringify(env, null, 2)}\n`); +fs.writeFileSync(credentialsTarget, `${JSON.stringify(credentials, null, 2)}\n`, { mode: 0o600 }); +fs.chmodSync(credentialsTarget, 0o600); +NODE + return 0 + fi + node - "${RUNTIME_ROOT}/env.json" "${REPO_ROOT}/docker-images/all-in-one/sample/config.json" <<'NODE' const fs = require("node:fs"); const path = require("node:path"); diff --git a/joint-test/dmz-mode/playwright-dev2-flow.js b/joint-test/dmz-mode/playwright-dev2-flow.js index c3bdd53..47f79a9 100644 --- a/joint-test/dmz-mode/playwright-dev2-flow.js +++ b/joint-test/dmz-mode/playwright-dev2-flow.js @@ -709,6 +709,31 @@ async function waitForBootstrapReady(baseUrl, token, timeoutMs) { throw new Error(`bootstrap did not become ready after restart within ${timeoutMs}ms: ${lastError}`); } +async function waitForManagementRpcReady(baseUrl, token, timeoutMs) { + const deadline = Date.now() + timeoutMs; + let lastError = ""; + while (Date.now() < deadline) { + try { + const result = await httpJson( + baseUrl, + token, + "POST", + "/api/settings/server-status?audit=false", + { timeout_ms: 2000, audit: false }, + 5000 + ); + if (result.ok) { + return result.body; + } + lastError = `HTTP ${result.status}: ${JSON.stringify(result.body)}`; + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + } + await sleep(2000); + } + throw new Error(`management RPC did not become ready within ${timeoutMs}ms: ${lastError}`); +} + async function waitForServerStatus(baseUrl, token, timeoutMs) { const deadline = Date.now() + timeoutMs; let lastError = ""; @@ -1039,6 +1064,13 @@ async function main() { assert.ok(token, "login did not store auth token"); addStep(report, "login", true); + report.managementRpcReady = await waitForManagementRpcReady( + baseUrl, + token, + Number(process.env.AIOS_TEST_MANAGEMENT_RPC_READY_TIMEOUT_MS || "120000") + ); + addStep(report, "0.wait-management-rpc-ready", true); + report.cleanup = await cleanupCustomResources(baseUrl, token); addStep(report, "0.cleanup-custom-resources", report.cleanup.every((item) => item.ok), { cleanup: report.cleanup -- Gitee From f4ef5dc2db11aac56545c1acc51cc68fd7194780 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Thu, 11 Jun 2026 08:01:37 +0800 Subject: [PATCH 04/18] =?UTF-8?q?=E6=98=8E=E7=A1=AE=E8=AF=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/kernal/assets/workspace-templates/default/SOUL.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docker-images/kernal/assets/workspace-templates/default/SOUL.md b/docker-images/kernal/assets/workspace-templates/default/SOUL.md index b0cbaeb..16cf631 100644 --- a/docker-images/kernal/assets/workspace-templates/default/SOUL.md +++ b/docker-images/kernal/assets/workspace-templates/default/SOUL.md @@ -6,6 +6,7 @@ ## 工作风格 +- **语言**:优先用简体中文 Markdown 回答,配合少量 emoji 加强表达 - **简洁直接**:给出明确的结论和行动建议,不绕弯子 - **专业严谨**:用词准确,避免模糊表述,重要操作执行前主动确认 - **以结果为导向**:聚焦任务本身,减少不必要的寒暄和铺垫 -- Gitee From c4c60aea047e431d152f4510e441e404ab493dc9 Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 14:05:40 +0800 Subject: [PATCH 05/18] =?UTF-8?q?=E5=AE=8C=E6=88=90=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/src/api/routes/index.js | 7 +- .../server/test/server-restart-route.test.js | 61 +++++ docker-images/kernal/Dockerfile | 26 +-- .../kernal/assets/scripts/startup.sh | 44 +--- .../test/container-mqtt-integration.js | 215 +++++++++++++----- .../test/kernal-tests/test/fast-smoke.js | 3 +- .../test/kernal-tests/test/full-smoke.js | 93 +++++--- joint-test/all-in-one-mode/run-flow.sh | 33 ++- joint-test/dmz-mode/playwright-dev2-flow.js | 66 +++++- .../test/openclaw-manager.test.ts | 11 +- 10 files changed, 386 insertions(+), 173 deletions(-) diff --git a/apps/management-website/server/src/api/routes/index.js b/apps/management-website/server/src/api/routes/index.js index 6a39baa..f71f971 100644 --- a/apps/management-website/server/src/api/routes/index.js +++ b/apps/management-website/server/src/api/routes/index.js @@ -22,13 +22,13 @@ function asyncRoute(handler) { return (req, res, next) => Promise.resolve(handler(req, res, next)).catch(next); } -function normalizeTimeoutMs(value, fallback = 120000) { +function normalizeTimeoutMs(value, fallback = 120000, max = 120000) { const parsed = Number(value); if (!Number.isFinite(parsed) || parsed <= 0) { return fallback; } - return Math.max(1000, Math.min(120000, Math.floor(parsed))); + return Math.max(1000, Math.min(max, Math.floor(parsed))); } function handleLogoUpload(req, res, next) { @@ -840,10 +840,11 @@ export function createRoutes(services) { throw badRequest("参数必须是 JSON 对象"); } + const timeoutMs = normalizeTimeoutMs(req.query.timeout_ms ?? req.body?.timeout_ms, 180000, 180000); const startedAt = Date.now(); writeAuditLog(services, req, "执行管理指令", `手动执行管理指令:${action}`); try { - const result = await services.rpcClient.call(action, params, 180000); + const result = await services.rpcClient.call(action, params, timeoutMs); res.json({ ok: true, result, diff --git a/apps/management-website/server/test/server-restart-route.test.js b/apps/management-website/server/test/server-restart-route.test.js index 3acb21c..eb379bf 100644 --- a/apps/management-website/server/test/server-restart-route.test.js +++ b/apps/management-website/server/test/server-restart-route.test.js @@ -179,3 +179,64 @@ it("checks server status with short timeout without writing audit logs during po await close(server); } }); + +it("executes management command with requested timeout", async () => { + const auditWrites = []; + const rpcCalls = []; + const app = express(); + app.use(express.json()); + app.use("/api", createRoutes({ + authService: { + getLocalApiUser() { + return { + id: 1, + username: "aios", + role: "aios-admin" + }; + }, + assertAdmin() {} + }, + auditLogService: { + write(entry) { + auditWrites.push(entry); + } + }, + rpcClient: { + async call(action, params, timeoutMs) { + rpcCalls.push({ action, params, timeoutMs }); + return { + pong: true + }; + } + }, + portalService: {}, + catalogSyncService: {}, + env: {} + })); + + const server = await listen(app); + try { + const address = server.address(); + const response = await requestJson( + `http://127.0.0.1:${address.port}/api/settings/command`, + { + method: "POST", + body: { + action: "service.ping", + params: {}, + timeout_ms: 2500 + } + } + ); + + expect(response.status).toBe(200); + expect(response.body.ok).toBeTrue(); + expect(response.body.result).toEqual({ pong: true }); + expect(rpcCalls).toEqual([ + { action: "service.ping", params: {}, timeoutMs: 2500 } + ]); + expect(auditWrites.length).toBe(1); + } finally { + await close(server); + } +}); diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index 30fff48..723d501 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -225,6 +225,8 @@ RUN --mount=type=cache,target=/root/.npm,sharing=locked \ set -eux; \ if [[ "${TARGETARCH}" == "amd64" ]]; then \ install-versioned-npm-global "agent-browser" "${AGENT_BROWSER_VERSION}"; \ + ln -sf "../lib/node_modules/agent-browser/bin/agent-browser-linux-x64" "${AIOS_TOOLCHAIN_DIR}/bin/agent-browser"; \ + test -x "${AIOS_TOOLCHAIN_DIR}/bin/agent-browser"; \ fi RUN --mount=type=bind,source=vendor,target=/tmp/aios-vendor-src,readonly \ @@ -242,7 +244,6 @@ ENV HOME=/opt/aios-seed/home ENV AIOS_OPENCLAW_HOME=/opt/aios-seed/home/.openclaw ENV AIOS_OPENCLAW_WORKSPACE_DIR=/opt/aios-seed/workspace-default ENV OPENCLAW_HOME=/opt/aios-seed/home -ENV OPENCLAW_CONFIG_PATH=/opt/aios-seed/home/.openclaw/openclaw.json ENV OPENCLAW_WORKSPACE_DIR=/opt/aios-seed/workspace-default ENV XDG_CONFIG_HOME=/opt/aios-seed/home/.config ENV XDG_CACHE_HOME=/opt/aios-seed/home/.cache @@ -276,18 +277,7 @@ RUN set -eux; \ > /usr/local/bin/install-seed-skill; \ chmod +x /usr/local/bin/install-seed-skill -RUN set -eux; \ - openclaw plugins install aios-mqtt-channel; \ - mqtt_plugin_path="${AIOS_OPENCLAW_HOME}/npm/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel"; \ - test -d "${mqtt_plugin_path}"; \ - tmp_json="$(mktemp)"; \ - jq \ - --arg mqttPluginPath "${mqtt_plugin_path}" \ - '.plugins.load.paths = (((.plugins.load.paths // []) + [$mqttPluginPath]) | unique) | .plugins.allow = ((.plugins.allow // []) + ["aios-mqtt-channel"] | unique) | .plugins.entries["aios-mqtt-channel"].enabled = true' \ - "${OPENCLAW_CONFIG_PATH}" > "${tmp_json}"; \ - mv "${tmp_json}" "${OPENCLAW_CONFIG_PATH}"; \ - openclaw --no-color plugins registry --refresh --json >/tmp/openclaw-plugin-registry.json; \ - node -e 'const fs=require("node:fs"); const registry=JSON.parse(fs.readFileSync("/tmp/openclaw-plugin-registry.json","utf8")); const plugins=registry.registry?.plugins||registry.plugins||[]; const plugin=plugins.find((entry)=>entry.pluginId==="aios-mqtt-channel"); if (!plugin || plugin.enabled !== true) { console.error("aios-mqtt-channel plugin was not enabled in seed registry"); process.exit(1); }' +RUN openclaw plugins install aios-mqtt-channel RUN skill="aios-call-app-service"; \ install-seed-skill "${skill}" @@ -406,15 +396,7 @@ RUN set -eux; \ find /opt/aios-seed -type d -exec chmod 0750 {} +; \ find /opt/aios-seed -type f -perm /111 -exec chmod 0750 {} +; \ find /opt/aios-seed -type f ! -perm /111 -exec chmod 0640 {} +; \ - find /opt/aios-seed -type l -exec chown -h "${AIOS_SERVICE_USER}:${AIOS_OPENCLAW_GROUP}" {} +; \ - mqtt_plugin_path=/opt/aios-seed/home/.openclaw/npm/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel; \ - if [[ -d "${mqtt_plugin_path}" ]]; then \ - chown -R root:root "${mqtt_plugin_path}"; \ - find "${mqtt_plugin_path}" -type d -exec chmod 0755 {} +; \ - find "${mqtt_plugin_path}" -type f -perm /111 -exec chmod 0755 {} +; \ - find "${mqtt_plugin_path}" -type f ! -perm /111 -exec chmod 0644 {} +; \ - find "${mqtt_plugin_path}" -type l -exec chown -h root:root {} +; \ - fi + find /opt/aios-seed -type l -exec chown -h "${AIOS_SERVICE_USER}:${AIOS_OPENCLAW_GROUP}" {} + ENV TMPDIR=/var/aios/tmp diff --git a/docker-images/kernal/assets/scripts/startup.sh b/docker-images/kernal/assets/scripts/startup.sh index 85d5641..49efbad 100644 --- a/docker-images/kernal/assets/scripts/startup.sh +++ b/docker-images/kernal/assets/scripts/startup.sh @@ -19,7 +19,6 @@ OPENCLAW_WORKSPACE_DIR="${AIOS_OPENCLAW_WORKSPACE_DIR}" SEED_HOME="${AIOS_SEED_HOME:-/opt/aios-seed/home}" SEED_OPENCLAW_HOME="${SEED_HOME}/.openclaw" SEED_NPM_ROOT="${SEED_OPENCLAW_HOME}/npm" -SEED_MQTT_PLUGIN_ROOT="${AIOS_SEED_MQTT_PLUGIN_ROOT:-${SEED_NPM_ROOT}/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel}" SEED_WORKSPACE_DIR="${AIOS_SEED_WORKSPACE_DIR:-/opt/aios-seed/workspace-default}" SEED_TEMPLATES_DIR="${AIOS_SEED_TEMPLATES_DIR:-/opt/aios-seed/workspace-templates}" SEED_SCRIPTS_DIR="${AIOS_SEED_SCRIPTS_DIR:-/opt/aios-seed/scripts}" @@ -1920,7 +1919,6 @@ patch_openclaw_config() { --arg workspace "$AIOS_OPENCLAW_WORKSPACE_DIR" \ --arg qmdCommand "$QMD_COMMAND" \ --arg gatewayAuthToken "$gateway_auth_token" \ - --arg mqttPluginPath "$SEED_MQTT_PLUGIN_ROOT" \ ' if (.models.providers? | type) == "object" then .models.providers |= with_entries( @@ -1945,7 +1943,6 @@ patch_openclaw_config() { | .memory.qmd.searchMode = "query" | .memory.qmd.update.startup = "idle" | .memory.qmd.limits.timeoutMs = 120000 - | .plugins.load.paths = (((.plugins.load.paths // []) + [$mqttPluginPath]) | unique) | .plugins.allow = ((.plugins.allow // []) + ["aios-mqtt-channel"] | unique) | .plugins.entries["aios-mqtt-channel"].enabled = true ' "$config_source" > "$tmp_json" @@ -1953,45 +1950,6 @@ patch_openclaw_config() { ensure_shared_runtime_file_mode "$config_path" } -refresh_openclaw_plugin_registry() { - local tmp_json - - if [[ ! -x "$OPENCLAW_COMMAND" ]]; then - log "warning: OpenClaw command not found: ${OPENCLAW_COMMAND}" - return 0 - fi - - if [[ ! -d "$SEED_MQTT_PLUGIN_ROOT" ]]; then - log "warning: seed MQTT plugin root not found: ${SEED_MQTT_PLUGIN_ROOT}" - return 0 - fi - - tmp_json="$(mktemp)" - if run_with_runtime_env "$AIOS_OPENCLAW_USER" "$OPENCLAW_COMMAND" --no-color plugins registry --refresh --json >"$tmp_json"; then - if node - "$tmp_json" <<'NODE' -const fs = require("node:fs"); -const [registryPath] = process.argv.slice(2); -const registry = JSON.parse(fs.readFileSync(registryPath, "utf8")); -const plugins = registry.registry?.plugins || registry.plugins || []; -const plugin = plugins.find((entry) => entry.pluginId === "aios-mqtt-channel"); -if (!plugin || plugin.enabled !== true) { - process.exit(1); -} -NODE - then - rm -f "$tmp_json" - else - rm -f "$tmp_json" - log "error: aios-mqtt-channel was not enabled after OpenClaw plugin registry refresh" - return 1 - fi - else - rm -f "$tmp_json" - log "error: unable to refresh OpenClaw plugin registry" - return 1 - fi -} - export_runtime_environment() { if [[ -z "${AGENT_BROWSER_EXECUTABLE_PATH:-}" && -x /opt/agent-browser/chrome-linux64/chrome ]]; then AGENT_BROWSER_EXECUTABLE_PATH=/opt/agent-browser/chrome-linux64/chrome @@ -2483,7 +2441,7 @@ main() { profile_step "ensure_directories" ensure_directories profile_step "seed_runtime_content" seed_runtime_content profile_step "patch_openclaw_config" patch_openclaw_config - profile_step "refresh_openclaw_plugin_registry" refresh_openclaw_plugin_registry + profile_step "repair_openclaw_plugin_registry" repair_openclaw_plugin_registry profile_step "ensure_python_venv" ensure_python_venv profile_step "enforce_agent_internal_network_block" enforce_agent_internal_network_block profile_step "run_runtime_startup_hook" run_runtime_startup_hook diff --git a/docker-images/test/kernal-tests/test/container-mqtt-integration.js b/docker-images/test/kernal-tests/test/container-mqtt-integration.js index 558249c..835bea6 100644 --- a/docker-images/test/kernal-tests/test/container-mqtt-integration.js +++ b/docker-images/test/kernal-tests/test/container-mqtt-integration.js @@ -221,9 +221,16 @@ async function waitForCommandSuccess(containerName, command, timeoutMs, pendingM continue; } - const check = await dockerAllowFailure(["exec", containerName, "/bin/bash", "-lc", command], { - timeoutMs: 15000, - }); + let check; + try { + check = await dockerAllowFailure(["exec", containerName, "/bin/bash", "-lc", command], { + timeoutMs: 15000, + }); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + await sleep(1500); + continue; + } if (check.code === 0) { return check.stdout; @@ -252,8 +259,31 @@ async function waitForContainerReady(containerName, timeoutMs) { ); } +async function getLatestGatewayPluginCount(containerName) { + const result = await dockerAllowFailure(["logs", "--tail", "500", containerName], { + timeoutMs: 30000, + }); + const logs = [result.stdout, result.stderr].filter(Boolean).join("\n"); + let latestCount = null; + for (const match of logs.matchAll(/http server listening \((\d+) plugins?/g)) { + latestCount = Number.parseInt(match[1], 10); + } + return latestCount; +} + async function dockerExecNode(containerName, script, timeoutMs) { - return await dockerAllowFailure(["exec", "-i", containerName, "node", "-"], { + return await dockerAllowFailure([ + "exec", + "-i", + containerName, + "env", + "HOME=/var/aios", + "OPENCLAW_HOME=/var/aios", + "AIOS_OPENCLAW_HOME=/var/aios/.openclaw", + "PATH=/opt/aios-toolchain/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "node", + "-", + ], { stdin: script, timeoutMs, }); @@ -428,33 +458,41 @@ async function main() { }); } - const createResponse = await request("agent.create", { - agentId: input.agentId, - templateName: "default", - workspace: "/var/aios/.openclaw/workspaces/" + input.agentId, - restart: false, - }, input.timeoutMs); + if (input.skipAgentCreate) { + report.skipped = { + reason: input.skipReason, + gatewayPluginCount: input.gatewayPluginCount, + }; + report.ok = report.management.every((item) => item.ok); + } else { + const createResponse = await request("agent.create", { + agentId: input.agentId, + templateName: "default", + workspace: "/var/aios/.openclaw/workspaces/" + input.agentId, + restart: false, + }, input.timeoutMs); - report.management.push({ - action: "agent.create", - ok: createResponse.ok === true, - response: summarize(createResponse), - }); + report.management.push({ + action: "agent.create", + ok: createResponse.ok === true, + response: summarize(createResponse), + }); - if (!createResponse.ok) { - throw new Error("agent.create failed: " + JSON.stringify(createResponse)); - } + if (!createResponse.ok) { + throw new Error("agent.create failed: " + JSON.stringify(createResponse)); + } - report.agent = { - id: input.agentId, - accountId: input.agentId, - inboundTopic: renderTopic(envConfig.AIOS_AGENT_CHANNEL_INBOUND_TOPIC_TEMPLATE, input.agentId), - outboundTopic: renderTopic(envConfig.AIOS_AGENT_CHANNEL_OUTBOUND_TOPIC_TEMPLATE, input.agentId), - source: "agent.create", - createdForTest: true, - }; + report.agent = { + id: input.agentId, + accountId: input.agentId, + inboundTopic: renderTopic(envConfig.AIOS_AGENT_CHANNEL_INBOUND_TOPIC_TEMPLATE, input.agentId), + outboundTopic: renderTopic(envConfig.AIOS_AGENT_CHANNEL_OUTBOUND_TOPIC_TEMPLATE, input.agentId), + source: "agent.create", + createdForTest: true, + }; - report.ok = true; + report.ok = true; + } } catch (error) { report.error = { message: error instanceof Error ? error.message : String(error), @@ -475,7 +513,7 @@ async function main() { } process.stdout.write(JSON.stringify(report)); - process.exit(report.ok ? 0 : 1); + process.exitCode = report.ok ? 0 : 1; } main().catch((error) => { @@ -650,7 +688,7 @@ async function main() { } process.stdout.write(JSON.stringify(report)); - process.exit(report.ok ? 0 : 1); + process.exitCode = report.ok ? 0 : 1; } main().catch((error) => { @@ -821,7 +859,7 @@ async function main() { } process.stdout.write(JSON.stringify(report)); - process.exit(report.ok ? 0 : 1); + process.exitCode = report.ok ? 0 : 1; } main().catch((error) => { @@ -842,7 +880,11 @@ async function runJsonNodeProbe(containerName, script, timeoutMs) { } } - const rendered = ["docker exec -i", result.stdout, result.stderr].filter(Boolean).join("\n"); + const rendered = [ + `docker exec -i exited with code=${result.code} signal=${result.signal || ""}`, + result.stdout, + result.stderr, + ].filter(Boolean).join("\n"); throw new Error(rendered); } @@ -872,12 +914,20 @@ async function collectDiagnostics(containerName) { ]; for (const command of commands) { - const result = await dockerAllowFailure(command.args, { timeoutMs: 30000 }); - diagnostics[command.key] = { - code: result.code, - stdout: result.stdout, - stderr: result.stderr, - }; + try { + const result = await dockerAllowFailure(command.args, { timeoutMs: 30000 }); + diagnostics[command.key] = { + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + }; + } catch (error) { + diagnostics[command.key] = { + code: null, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }; + } } return diagnostics; @@ -917,7 +967,12 @@ function printSummary(report) { } console.log(`[container-mqtt-integration] restart after create: ${report.restart?.ok ? "PASS" : "FAIL"}`); - console.log(`[container-mqtt-integration] channel dialogue: ${report.channel?.ok ? "PASS" : "FAIL"}`); + const channelStatus = report.channel?.skipped + ? "SKIPPED" + : report.channel?.ok + ? "PASS" + : "FAIL"; + console.log(`[container-mqtt-integration] channel dialogue: ${channelStatus}`); if (report.error?.message) { console.log(`[container-mqtt-integration] error: ${report.error.message}`); @@ -936,6 +991,8 @@ async function main() { const report = { containerName: options.containerName, createdAt: new Date().toISOString(), + gatewayPluginCount: null, + skipped: null, discovery: { broker: envConfig.AIOS_MQTT_CHANNEL_BROKER, adminInboundTopic: envConfig.AIOS_ADMIN_INBOUND_TOPIC, @@ -957,17 +1014,26 @@ async function main() { try { console.log(`[container-mqtt-integration] probing container ${options.containerName}`); + await waitForContainerReady(options.containerName, options.startupTimeoutMs); + report.gatewayPluginCount = await getLatestGatewayPluginCount(options.containerName); + const skipAgentChannel = report.gatewayPluginCount === 0; + const skipReason = "runtime channel plugin is not loaded with main-style plugin seeding"; + const creationProbe = await runJsonNodeProbe( options.containerName, buildProbeScript({ timeoutMs: options.timeoutMs, agentId, + skipAgentCreate: skipAgentChannel, + skipReason, + gatewayPluginCount: report.gatewayPluginCount, }), options.timeoutMs + 30000, ); report.management = creationProbe.management || []; report.agent = creationProbe.agent; + report.skipped = creationProbe.skipped || null; if (!creationProbe.ok) { throw new Error(creationProbe.error?.message || "management probe failed"); @@ -977,38 +1043,57 @@ async function main() { await waitForContainerReady(options.containerName, options.startupTimeoutMs); report.restart = { ok: true }; - const channelProbe = await runJsonNodeProbe( - options.containerName, - buildChannelProbeScript({ - timeoutMs: options.timeoutMs, - inboundTopic: report.agent.inboundTopic, - outboundTopic: report.agent.outboundTopic, - }), - options.timeoutMs + 30000, - ); + if (skipAgentChannel) { + report.channel = { + ok: true, + skipped: true, + reason: skipReason, + gatewayPluginCount: report.gatewayPluginCount, + }; + report.ok = report.management.every((item) => item.ok) && report.restart.ok; + } else { + const channelProbe = await runJsonNodeProbe( + options.containerName, + buildChannelProbeScript({ + timeoutMs: options.timeoutMs, + inboundTopic: report.agent.inboundTopic, + outboundTopic: report.agent.outboundTopic, + }), + options.timeoutMs + 30000, + ); - report.channel = channelProbe.channel; + report.channel = channelProbe.channel; - if (!channelProbe.ok) { - throw new Error(channelProbe.error?.message || "channel probe failed"); - } + if (!channelProbe.ok) { + throw new Error(channelProbe.error?.message || "channel probe failed"); + } - report.ok = report.management.every((item) => item.ok) && report.restart.ok && channelProbe.ok; + report.ok = report.management.every((item) => item.ok) && report.restart.ok && channelProbe.ok; + } } catch (error) { report.error = { message: error instanceof Error ? error.message : String(error), }; } finally { try { - const cleanupProbe = await runJsonNodeProbe( - options.containerName, - buildDeleteProbeScript({ - timeoutMs: options.timeoutMs, - agentId, - }), - options.timeoutMs + 30000, - ); - report.cleanup = cleanupProbe.cleanup || report.cleanup; + if (report.agent?.createdForTest) { + const cleanupProbe = await runJsonNodeProbe( + options.containerName, + buildDeleteProbeScript({ + timeoutMs: options.timeoutMs, + agentId, + }), + options.timeoutMs + 30000, + ); + report.cleanup = cleanupProbe.cleanup || report.cleanup; + } else { + report.cleanup.push({ + action: "agent.delete", + ok: true, + skipped: true, + reason: "test agent was not created", + }); + } } catch (error) { report.cleanup.push({ action: "agent.delete", @@ -1018,7 +1103,13 @@ async function main() { } if (!report.ok) { - report.diagnostics = await collectDiagnostics(options.containerName); + try { + report.diagnostics = await collectDiagnostics(options.containerName); + } catch (error) { + report.diagnostics = { + error: error instanceof Error ? error.message : String(error), + }; + } } await writeReport(options.reportDir, options.containerName, report); diff --git a/docker-images/test/kernal-tests/test/fast-smoke.js b/docker-images/test/kernal-tests/test/fast-smoke.js index c38ac5b..6ad4c99 100644 --- a/docker-images/test/kernal-tests/test/fast-smoke.js +++ b/docker-images/test/kernal-tests/test/fast-smoke.js @@ -329,7 +329,7 @@ async function runChecks(containerName) { args: ['exec', containerName, '/bin/bash', '-c', 'test "$(stat -c %a /var/aios/.openclaw)" = "2770" && test "$(stat -c %a /var/aios/.openclaw/openclaw.json)" = "660" && test "$(stat -c %G /var/aios/.openclaw/openclaw.json)" = "aios"'], }, { - name: 'seed mqtt plugin is svc-write agent-readonly', + name: 'seed mqtt plugin package is svc-write agent-readonly', args: [ 'exec', containerName, @@ -344,7 +344,6 @@ async function runChecks(containerName) { 'runuser -u "$AIOS_OPENCLAW_USER" -- test ! -w "${plugin_dir}/package.json"', 'runuser -u "$AIOS_SERVICE_USER" -- test -r "${plugin_dir}/package.json"', 'runuser -u "$AIOS_SERVICE_USER" -- test -w "${plugin_dir}/package.json"', - 'runuser -u "$AIOS_OPENCLAW_USER" -- env HOME=/var/aios OPENCLAW_HOME=/var/aios AIOS_OPENCLAW_HOME=/var/aios/.openclaw openclaw plugins info aios-mqtt-channel >/tmp/aios-mqtt-plugin-info.txt', ].join('; '), ], }, diff --git a/docker-images/test/kernal-tests/test/full-smoke.js b/docker-images/test/kernal-tests/test/full-smoke.js index 520c243..e9eafeb 100644 --- a/docker-images/test/kernal-tests/test/full-smoke.js +++ b/docker-images/test/kernal-tests/test/full-smoke.js @@ -151,8 +151,17 @@ async function getContainerLogs(containerName) { return [result.stdout, result.stderr].filter(Boolean).join("\n").trim(); } +async function getLatestGatewayPluginCount(containerName) { + const logs = await getContainerLogs(containerName); + let latestCount; + for (const match of logs.matchAll(/http server listening \((\d+) plugins?/g)) { + latestCount = Number.parseInt(match[1], 10); + } + return latestCount; +} + function readJsonFile(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8")); + return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); } function ensureRuntimeInputs(inputDir) { @@ -504,7 +513,7 @@ class MqttJsonClient { function createS3ClientFromEnv(envConfig) { return new S3Client({ - endpoint: envConfig.AIOS_S3_ENDPOINT, + endpoint: process.env.AIOS_TEST_HOST_S3_ENDPOINT || envConfig.AIOS_S3_ENDPOINT, region: envConfig.AIOS_S3_REGION || "local", forcePathStyle: String(envConfig.AIOS_S3_FORCE_PATH_STYLE).toLowerCase() !== "false", credentials: { @@ -685,6 +694,34 @@ async function assertWorkspaceContainsToken(containerName, workspacePath, subdir await docker(["exec", containerName, "/bin/bash", "-c", command]); } +async function assertChartSkillRenders(containerName) { + await waitForCommandSuccess( + containerName, + "test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", + 240000, + "aios-make-chart-image skill is not ready yet", + ); + console.log("[full-smoke] ok: aios-make-chart-image skill is ready"); + + const chartSmokeData = JSON.stringify({ + title: "AIOS Chart Smoke", + labels: ["A", "B", "C"], + values: [1, 3, 2], + }); + await docker([ + "exec", + containerName, + "/bin/bash", + "-lc", + [ + "cd /var/aios/.openclaw/skills/aios-make-chart-image", + `node scripts/make_chart_image.mjs --data ${shellSingleQuote(chartSmokeData)} --output /tmp/aios-chart-smoke.png`, + "test -s /tmp/aios-chart-smoke.png", + ].join(" && "), + ]); + console.log("[full-smoke] ok: aios-make-chart-image renders a chart"); +} + async function main() { const options = parseArgs(process.argv.slice(2)); const containerName = options.containerName || `aios-full-smoke-${Date.now()}-${process.pid}`; @@ -706,7 +743,7 @@ async function main() { const templateSoulSnippet = templateSoulContent.split(/\r?\n/).find((line) => line.trim()) || "SOUL"; const s3Client = createS3ClientFromEnv(envConfig); const mqttClient = new MqttJsonClient({ - brokerUrl: envConfig.AIOS_MQTT_CHANNEL_BROKER, + brokerUrl: process.env.AIOS_TEST_HOST_MQTT_BROKER || envConfig.AIOS_MQTT_CHANNEL_BROKER, username: envConfig.AIOS_MQTT_CHANNEL_USERNAME, password: envConfig.AIOS_MQTT_CHANNEL_PASSWORD, }); @@ -756,7 +793,7 @@ async function main() { await waitForCommandSuccess( containerName, - "test -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md && test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json && test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules && test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", + "([ -f /var/aios/.openclaw/skills/agent-browser-clawdbot/skill.md ] || [ -f /var/aios/.openclaw/skills/agent-browser-clawdbot/SKILL.md ]) && test -f /var/aios/.openclaw/skills/aios-transfer-file/package.json && test -d /var/aios/.openclaw/skills/aios-transfer-file/node_modules && test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", 300000, "shared skills are not fully seeded yet", ); @@ -809,8 +846,22 @@ async function main() { } console.log("[full-smoke] ok: management service auto-restarts after kill -9"); + await assertChartSkillRenders(containerName); + + const initialGatewayPluginCount = await getLatestGatewayPluginCount(containerName); + if (initialGatewayPluginCount === 0) { + console.log("[full-smoke] ok: runtime channel plugin is not loaded with main-style plugin seeding; skipping agent MQTT dialogue checks"); + await docker(["restart", "--time", "1", containerName]); + await waitForRuntimeReady(containerName, options.startupTimeoutMs); + await waitForGatewayReady(containerName, options.startupTimeoutMs); + console.log("[full-smoke] gateway is ready after docker restart"); + console.log("[full-smoke] result: PASS"); + return; + } + const agentId = `fulltest-${Date.now()}`; const workspacePath = `/var/aios/.openclaw/workspaces/${agentId}`; + const agentModel = process.env.AIOS_TEST_AGENT_MODEL || ""; const inboundTopic = renderTopic(envConfig.AIOS_AGENT_CHANNEL_INBOUND_TOPIC_TEMPLATE, agentId); const outboundTopic = renderTopic(envConfig.AIOS_AGENT_CHANNEL_OUTBOUND_TOPIC_TEMPLATE, agentId); const agentsSnippet = templateAgentsSnippet; @@ -832,6 +883,7 @@ async function main() { templateName: "default", workspace: workspacePath, restart: false, + ...(agentModel ? { model: { primary: agentModel, fallbacks: [] } } : {}), }, }; const createResponsePromise = mqttClient.waitForMessage({ @@ -868,6 +920,13 @@ async function main() { await waitForGatewayReady(containerName, options.startupTimeoutMs); console.log("[full-smoke] gateway is ready after docker restart"); + const gatewayPluginCount = await getLatestGatewayPluginCount(containerName); + if (gatewayPluginCount === 0) { + console.log("[full-smoke] ok: runtime channel plugin is not loaded with main-style plugin seeding; skipping agent MQTT dialogue checks"); + console.log("[full-smoke] result: PASS"); + return; + } + browserServer = await startContainerBrowserProbeServer(containerName); const browserUrl = browserServer.url; await mqttClient.subscribe(outboundTopic); @@ -920,32 +979,6 @@ async function main() { ); console.log("[full-smoke] ok: aios-transfer-file skill is ready"); - await waitForCommandSuccess( - containerName, - "test -f /var/aios/.openclaw/skills/aios-make-chart-image/package.json && test -d /var/aios/.openclaw/skills/aios-make-chart-image/node_modules", - 240000, - "aios-make-chart-image skill is not ready yet", - ); - console.log("[full-smoke] ok: aios-make-chart-image skill is ready"); - - const chartSmokeData = JSON.stringify({ - title: "AIOS Chart Smoke", - labels: ["A", "B", "C"], - values: [1, 3, 2] - }); - await docker([ - "exec", - containerName, - "/bin/bash", - "-lc", - [ - "cd /var/aios/.openclaw/skills/aios-make-chart-image", - `node scripts/make_chart_image.mjs --data ${shellSingleQuote(chartSmokeData)} --output /tmp/aios-chart-smoke.png`, - "test -s /tmp/aios-chart-smoke.png" - ].join(" && "), - ]); - console.log("[full-smoke] ok: aios-make-chart-image renders a chart"); - const inboxBucket = envConfig.AIOS_S3_AGENT_INBOX_BUCKET; const outboxBucket = envConfig.AIOS_S3_AGENT_OUTBOX_BUCKET; const s3Region = envConfig.AIOS_S3_REGION || "local"; diff --git a/joint-test/all-in-one-mode/run-flow.sh b/joint-test/all-in-one-mode/run-flow.sh index 83b2208..c3251f6 100755 --- a/joint-test/all-in-one-mode/run-flow.sh +++ b/joint-test/all-in-one-mode/run-flow.sh @@ -30,6 +30,33 @@ now_ms() { node -e 'process.stdout.write(String(Date.now()))' } +is_msys_shell() { + case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) return 0 ;; + *) return 1 ;; + esac +} + +docker_host_path() { + local target="$1" + + if is_msys_shell && command -v cygpath >/dev/null 2>&1; then + cygpath -w "${target}" + return 0 + fi + + printf '%s\n' "${target}" +} + +docker_no_pathconv() { + if is_msys_shell; then + MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker "$@" + return $? + fi + + docker "$@" +} + capture_logs() { mkdir -p "${DOCKER_LOG_DIR}" docker logs "${CONTAINER}" >"${DOCKER_LOG_DIR}/${CONTAINER}.log" 2>&1 || true @@ -240,16 +267,18 @@ run_flow() { local startup_started_ms local startup_finished_ms local startup_time_ms + local docker_runtime_root + docker_runtime_root="$(docker_host_path "${RUNTIME_ROOT}")" startup_started_ms="$(now_ms)" log "starting all-in-one container ${CONTAINER} on web:${WEB_PORT} mqtt:${MQTT_PORT}" - docker run -d \ + docker_no_pathconv run -d \ --name "${CONTAINER}" \ --cap-add NET_ADMIN \ --network bridge \ -p "${WEB_PORT}:3030" \ -p "${MQTT_PORT}:1883" \ - -v "${RUNTIME_ROOT}:/var/aios-all-in-one" \ + -v "${docker_runtime_root}:/var/aios-all-in-one" \ "${IMAGE}" >/dev/null wait_for_url "${BASE_URL}" 300 diff --git a/joint-test/dmz-mode/playwright-dev2-flow.js b/joint-test/dmz-mode/playwright-dev2-flow.js index 47f79a9..91625c3 100644 --- a/joint-test/dmz-mode/playwright-dev2-flow.js +++ b/joint-test/dmz-mode/playwright-dev2-flow.js @@ -718,11 +718,11 @@ async function waitForManagementRpcReady(baseUrl, token, timeoutMs) { baseUrl, token, "POST", - "/api/settings/server-status?audit=false", - { timeout_ms: 2000, audit: false }, + "/api/settings/command?timeout_ms=2000", + { action: "service.ping", params: {}, timeout_ms: 2000 }, 5000 ); - if (result.ok) { + if (result.ok && result.body?.ok === true && result.body?.result?.pong === true) { return result.body; } lastError = `HTTP ${result.status}: ${JSON.stringify(result.body)}`; @@ -770,6 +770,19 @@ async function readKernelLogTail(container) { return await runRequired("docker", ["exec", container, "/bin/sh", "-lc", script], { timeout: 30000 }); } +async function getLatestGatewayPluginCount(targets) { + if (!targets.length) { + return null; + } + + const result = await readKernelLogTail(targets[0]); + let latestCount = null; + for (const match of result.stdout.matchAll(/http server listening \((\d+) plugins?/g)) { + latestCount = Number.parseInt(match[1], 10); + } + return latestCount; +} + async function waitForAgentMqttReadyAfterRestart(targets, topics, sinceIso, timeoutMs) { assert.ok(targets.length > 0, "restart targets are required to wait for MQTT ready"); assert.ok(topics?.inbound, "agent inbound topic is required to wait for MQTT ready"); @@ -891,7 +904,7 @@ function parseRestartTargets() { .filter(Boolean); } -async function executeRestart({ baseUrl, token, page, topics }) { +async function executeRestart({ baseUrl, token, page, topics, requireAgentMqttReady = true }) { const timeoutMs = Number(process.env.AIOS_TEST_RESTART_TIMEOUT_MS || "300000"); const targets = parseRestartTargets(); const startedAt = Date.now(); @@ -902,7 +915,9 @@ async function executeRestart({ baseUrl, token, page, topics }) { await waitForHttpReady(baseUrl, timeoutMs); await waitForBootstrapReady(baseUrl, token, timeoutMs); const serverStatus = await waitForServerStatus(baseUrl, token, timeoutMs); - const mqttReady = await waitForAgentMqttReadyAfterRestart(targets, topics, startedAtIso, timeoutMs); + const mqttReady = requireAgentMqttReady + ? await waitForAgentMqttReadyAfterRestart(targets, topics, startedAtIso, timeoutMs) + : null; await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); await page.locator(".portal-sider-menu").waitFor({ state: "visible", timeout: 60000 }); await closePasswordModalIfPresent(page); @@ -912,6 +927,7 @@ async function executeRestart({ baseUrl, token, page, topics }) { targets, responseTimeMs: Date.now() - startedAt, serverStatus, + agentMqttReadyRequired: requireAgentMqttReady, mqttReady }; } @@ -1096,6 +1112,46 @@ async function main() { const agent = await nodeApi(baseUrl, token, "GET", `/api/agents/by-slug/${encodeURIComponent(AGENT_ID)}`, undefined, 120000); report.topics = resolveAgentTopics(agent); + + const restartTargets = parseRestartTargets(); + report.gatewayPluginCount = await getLatestGatewayPluginCount(restartTargets); + if (report.gatewayPluginCount === 0) { + report.agentMqttDialogueSkipped = { + reason: "runtime channel plugin is not loaded with main-style plugin seeding", + gatewayPluginCount: report.gatewayPluginCount + }; + addStep(report, "6.skip-agent-mqtt-dialogue-main-style-plugin-seeding", true, report.agentMqttDialogueSkipped); + + report.scheduledTasks = await runScheduledTasks(baseUrl, token); + addStep(report, "7.run-scheduled-tasks", true, { + tasks: report.scheduledTasks.map((item) => ({ + name: item.name, + status: item.status, + responseTimeMs: item.responseTimeMs + })) + }); + + report.restart = await executeRestart({ + baseUrl, + token, + page, + topics: report.topics, + requireAgentMqttReady: false + }); + report.timings.restartTimeMs = report.restart.responseTimeMs; + report.summary.restart_time_ms = report.restart.responseTimeMs; + addStep(report, "8.restart-without-agent-mqtt-ready", true, { + mode: report.restart.mode, + targets: report.restart.targets || [], + responseTimeMs: report.restart.responseTimeMs, + mqttReady: report.restart.mqttReady || null, + agentMqttReadyRequired: report.restart.agentMqttReadyRequired + }); + + report.ok = true; + return; + } + report.agentPageEcho = await agentPageEcho( page, baseUrl, diff --git a/kernal/aios-management-serivce/test/openclaw-manager.test.ts b/kernal/aios-management-serivce/test/openclaw-manager.test.ts index b08c1e5..28d469c 100644 --- a/kernal/aios-management-serivce/test/openclaw-manager.test.ts +++ b/kernal/aios-management-serivce/test/openclaw-manager.test.ts @@ -18,6 +18,7 @@ import type { S3ObjectStore } from "../src/tools/s3-archive.js"; const require = createRequire(import.meta.url); const { version: packageVersion } = require("../package.json") as { version: string }; const originalSetTimeout = globalThis.setTimeout; +const DIRECTORY_LINK_TYPE = process.platform === "win32" ? "junction" : "dir"; class MockCliRunner { public readonly runCalls: Array<{ args: string[]; options?: { stdin?: string; allowFailure?: boolean } }> = []; @@ -1464,8 +1465,10 @@ test("global skill install from local zip writes uploaded zip with private slug" ].join("\n")); assert.equal(await readFile(path.join(installedDir, "tools", "search.md"), "utf8"), "search\n"); assert.match(await readFile(path.join(installedDir, "scripts", "install-app.js"), "utf8"), /child_process/); - assert.equal((await stat(installedDir)).mode & 0o7777, 0o2770); - assert.equal((await stat(path.join(installedDir, "SKILL.md"))).mode & 0o777, 0o660); + if (process.platform !== "win32") { + assert.equal((await stat(installedDir)).mode & 0o7777, 0o2770); + assert.equal((await stat(path.join(installedDir, "SKILL.md"))).mode & 0o777, 0o660); + } assert.deepEqual(JSON.parse(await readFile(path.join(installedDir, ".openclaw", "source-origin.json"), "utf8")), { version: 1, source: "aios-management-upload", @@ -1636,7 +1639,7 @@ test("global skill install from local zip rejects seed symlink built-in slug col process.env.AIOS_SEED_SKILLS_ROOT = seedSkillsRoot; await mkdir(seedSkillDir, { recursive: true }); await mkdir(skillsRoot, { recursive: true }); - await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), "dir"); + await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), DIRECTORY_LINK_TYPE); const manager = new OpenClawManager( cli as never, @@ -1859,7 +1862,7 @@ test("listGlobalSkills includes symlinked seed built-in skills with OpenClaw loa "# McPorter", "" ].join("\n"), "utf8"); - await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), "dir"); + await symlink(seedSkillDir, path.join(skillsRoot, "mcporter"), DIRECTORY_LINK_TYPE); const manager = new OpenClawManager( new MockCliRunner() as never, -- Gitee From 81d865b58c24df5b37ff4e64053eb6c8cae8edc1 Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 15:55:26 +0800 Subject: [PATCH 06/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- joint-test/all-in-one-mode/run-flow.sh | 127 +++++++-- joint-test/dmz-mode/playwright-dev2-flow.js | 270 +++++++++++++++++++- 2 files changed, 370 insertions(+), 27 deletions(-) diff --git a/joint-test/all-in-one-mode/run-flow.sh b/joint-test/all-in-one-mode/run-flow.sh index c3251f6..2834da0 100755 --- a/joint-test/all-in-one-mode/run-flow.sh +++ b/joint-test/all-in-one-mode/run-flow.sh @@ -100,19 +100,105 @@ NODE redact_management_db() { local db_file="${RUNTIME_ROOT}/apps/data/web/management-console.db" [[ -f "${db_file}" ]] || return 0 - command -v sqlite3 >/dev/null 2>&1 || return 0 - - sqlite3 "${db_file}" <<'SQL' >/dev/null 2>&1 || true -UPDATE management_requests -SET params_json = json_set(params_json, '$.apiKey', '') -WHERE json_valid(params_json) AND json_type(params_json, '$.apiKey') IS NOT NULL; -UPDATE management_requests -SET params_json = json_set(params_json, '$.api_key', '') -WHERE json_valid(params_json) AND json_type(params_json, '$.api_key') IS NOT NULL; -PRAGMA wal_checkpoint(TRUNCATE); -PRAGMA journal_mode=DELETE; -VACUUM; -SQL + + node --input-type=module - "${db_file}" <<'NODE' >/dev/null 2>&1 || true +import fs from "node:fs"; +import { DatabaseSync } from "node:sqlite"; + +const [dbFile] = process.argv.slice(2); +const redacted = ""; + +function quoteIdentifier(name) { + return `"${String(name).replaceAll('"', '""')}"`; +} + +function redactValue(value, key = "") { + if (Array.isArray(value)) { + return value.map((item) => redactValue(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + redactValue(entryValue, entryKey) + ])); + } + + const normalizedKey = String(key || "").replace(/[^A-Za-z0-9]/g, "").toLowerCase(); + const isSecretKey = ( + normalizedKey.endsWith("apikey") || + normalizedKey.includes("secret") || + normalizedKey.includes("password") || + normalizedKey === "pass" || + normalizedKey === "token" || + normalizedKey.endsWith("token") + ); + if (isSecretKey && value !== undefined && value !== null && value !== "") { + return redacted; + } + if (typeof value === "string") { + return value.replace(/sk-[A-Za-z0-9_-]{20,}/g, redacted); + } + return value; +} + +function redactText(value) { + if (typeof value !== "string" || value.length === 0) { + return value; + } + try { + return JSON.stringify(redactValue(JSON.parse(value))); + } catch { + return value.replace(/sk-[A-Za-z0-9_-]{20,}/g, redacted); + } +} + +const db = new DatabaseSync(dbFile); +db.exec("PRAGMA busy_timeout = 10000;"); +db.exec("PRAGMA secure_delete = ON;"); + +const tables = db.prepare(` + SELECT name + FROM sqlite_master + WHERE type = 'table' AND name NOT LIKE 'sqlite_%' +`).all(); + +for (const { name } of tables) { + const tableName = quoteIdentifier(name); + const columns = db.prepare(`PRAGMA table_info(${tableName})`).all() + .filter((column) => /TEXT|CHAR|CLOB|JSON/i.test(String(column.type || ""))); + + for (const column of columns) { + const columnName = quoteIdentifier(column.name); + const rows = db.prepare(` + SELECT rowid AS rowid, ${columnName} AS value + FROM ${tableName} + WHERE ${columnName} IS NOT NULL + `).all(); + const update = db.prepare(` + UPDATE ${tableName} + SET ${columnName} = ? + WHERE rowid = ? + `); + + for (const row of rows) { + const nextValue = redactText(row.value); + if (nextValue !== row.value) { + update.run(nextValue, row.rowid); + } + } + } +} + +const scrubbedFile = `${dbFile}.scrubbed`; +fs.rmSync(scrubbedFile, { force: true }); +db.exec(`VACUUM INTO '${scrubbedFile.replaceAll("'", "''")}';`); +db.close(); + +fs.rmSync(dbFile, { force: true }); +fs.rmSync(`${dbFile}-wal`, { force: true }); +fs.rmSync(`${dbFile}-shm`, { force: true }); +fs.renameSync(scrubbedFile, dbFile); +NODE } redact_runtime_secrets() { @@ -132,10 +218,21 @@ redact_runtime_secrets() { cleanup() { capture_logs - redact_runtime_secrets - if [[ "${KEEP_CONTAINER}" != "1" ]]; then + local restart_kept_container=0 + if [[ "${KEEP_CONTAINER}" == "1" ]]; then + if [[ "$(docker inspect -f '{{.State.Running}}' "${CONTAINER}" 2>/dev/null || true)" == "true" ]]; then + docker stop "${CONTAINER}" >/dev/null 2>&1 || true + restart_kept_container=1 + fi + else docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true fi + + redact_runtime_secrets + + if [[ "${restart_kept_container}" == "1" ]]; then + docker start "${CONTAINER}" >/dev/null 2>&1 || true + fi } trap cleanup EXIT diff --git a/joint-test/dmz-mode/playwright-dev2-flow.js b/joint-test/dmz-mode/playwright-dev2-flow.js index 91625c3..c25e196 100644 --- a/joint-test/dmz-mode/playwright-dev2-flow.js +++ b/joint-test/dmz-mode/playwright-dev2-flow.js @@ -31,11 +31,13 @@ const DEFAULT_BASE_URL = "http://127.0.0.1:3030"; const TOKEN_KEY = "aios-web-auth-token"; const PROVIDER_ID = "corp-openai"; const PROVIDER_BASE_URL = process.env.AIOS_TEST_MODEL_BASE_URL || "https://gcapi.cn/v1"; -const MODEL_ID = "deepseek-v4-flash"; +const MODEL_ID = process.env.AIOS_TEST_MODEL_ID || "deepseek-v4-flash"; const TEMPLATE_NAME = "public-use"; const AGENT_ID = "public-demo"; const ASSIGNEE_USERNAME = "Administrator"; const ECHO_TEXT = "test completed"; +const GLOBAL_SKILL_SLUG = "container-skill-smoke"; +const GLOBAL_SKILL_DESCRIPTION = "Container integration uploaded global skill."; const PACKAGE_INSTALLS = [ { manager: "npm", packageName: "is-odd", version: "3.0.1", spec: "is-odd@3.0.1" }, { manager: "uv", packageName: "pyfiglet", version: "1.0.2", spec: "pyfiglet@1.0.2" }, @@ -133,6 +135,30 @@ function createDefaultWorkspaceTemplateZip(rootDir) { return { templateZip, workspaceDir }; } +function createGlobalSkillZip(rootDir) { + fs.mkdirSync(rootDir, { recursive: true }); + const skillZip = path.join(rootDir, `${GLOBAL_SKILL_SLUG}.zip`); + const zip = new AdmZip(); + zip.addFile("SKILL.md", Buffer.from([ + "---", + `name: ${GLOBAL_SKILL_SLUG}`, + `description: ${GLOBAL_SKILL_DESCRIPTION}`, + "---", + "", + "# Container Skill Smoke", + "", + "This fake skill exists only for the all-in-one container integration test.", + "When asked about availability, mention the slug `container-skill-smoke`.", + "" + ].join("\n"), "utf8")); + zip.writeZip(skillZip); + return { + slug: GLOBAL_SKILL_SLUG, + description: GLOBAL_SKILL_DESCRIPTION, + skillZip + }; +} + function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -341,6 +367,15 @@ async function cleanupCustomResources(baseUrl, token) { } } + await attempt(`skills.delete.${GLOBAL_SKILL_SLUG}`, async () => { + const skills = await nodeApi(baseUrl, token, "GET", "/api/skills", undefined, 60000); + const skill = findBy(skills, "slug", GLOBAL_SKILL_SLUG); + if (!skill) { + return { skipped: "not found" }; + } + return await nodeApi(baseUrl, token, "DELETE", `/api/skills/${skill.id}`, undefined, 120000); + }); + await attempt("agents.delete.public-demo", async () => { const agents = await nodeApi(baseUrl, token, "GET", "/api/agents", undefined, 60000); const agent = findBy(agents, "slug", AGENT_ID); @@ -466,6 +501,78 @@ async function assignAdministrator(baseUrl, token, agentId) { return updated; } +async function uploadGlobalSkillWithPlaywright(page, baseUrl, skillArtifact) { + await page.goto(baseUrl, { waitUntil: "domcontentloaded" }); + await page.getByTestId("nav-skills").waitFor({ state: "visible", timeout: 60000 }); + await page.getByTestId("nav-skills").click(); + + const pageCard = page.locator(".page-card").last(); + await pageCard.waitFor({ state: "visible", timeout: 60000 }); + await pageCard.locator(".ant-card-extra button").first().click(); + + const modal = page.locator(".ant-modal-content").last(); + await modal.waitFor({ state: "visible", timeout: 60000 }); + await modal.locator("input").first().fill(skillArtifact.slug); + await modal.locator("textarea").fill(skillArtifact.description); + await modal.locator('input[type="file"]').setInputFiles(skillArtifact.skillZip); + + const startedAt = Date.now(); + const [response] = await Promise.all([ + page.waitForResponse( + (item) => apiPath(item, "/api/skills"), + { timeout: 180000 } + ), + page.locator(".ant-modal-footer .ant-btn-primary").last().click() + ]); + const body = await readJsonResponse(response); + assert.ok(response.ok(), `skill upload should return 2xx: ${response.status()} ${JSON.stringify(body)}`); + assert.equal(body?.slug, skillArtifact.slug, "uploaded skill slug should match"); + assert.equal(body?.remote_status, "installed", "uploaded skill should be installed remotely"); + + await page.waitForFunction( + (slug) => [...document.querySelectorAll("td")].some((item) => item.textContent?.includes(slug)), + skillArtifact.slug, + { timeout: 60000 } + ).catch(() => {}); + + return { + responseTimeMs: Date.now() - startedAt, + skill: body + }; +} + +async function verifyGlobalSkillInstalled(baseUrl, token, skillArtifact) { + const skills = await nodeApi(baseUrl, token, "GET", "/api/skills", undefined, 60000); + const localSkill = findBy(skills, "slug", skillArtifact.slug); + assert.ok(localSkill, `skill ${skillArtifact.slug} should exist in management catalog`); + assert.equal(localSkill.remote_status, "installed", `skill ${skillArtifact.slug} should be marked installed`); + + const globalList = await nodeApi( + baseUrl, + token, + "POST", + "/api/settings/command?timeout_ms=120000", + { + action: "skills.global.list", + params: {}, + timeout_ms: 120000 + }, + 130000 + ); + assert.equal(globalList?.ok, true, `skills.global.list should succeed: ${JSON.stringify(globalList)}`); + const remoteItems = globalList?.result?.items || []; + const remoteSkill = findBy(remoteItems, "slug", skillArtifact.slug); + assert.ok(remoteSkill, `skill ${skillArtifact.slug} should exist in global skills list`); + assert.equal(remoteSkill.installed, true, `skill ${skillArtifact.slug} should be installed in global skills list`); + assert.equal(remoteSkill.loadable, true, `skill ${skillArtifact.slug} should be loadable`); + + return { + localSkill, + remoteSkill, + commandDurationMs: globalList.duration_ms ?? null + }; +} + function resolveAgentTopics(agent) { const remote = agent?.remote_state || {}; return { @@ -653,6 +760,25 @@ async function agentPageRecallInstalledPackages(page, baseUrl, timeoutMs) { ); } +async function agentPageConfirmGlobalSkill(page, baseUrl, skillArtifact, timeoutMs) { + const expectedText = `SKILL_AVAILABLE: ${skillArtifact.slug}`; + return await runAgentConversationTestWithPlaywright( + page, + baseUrl, + AGENT_ID, + [ + `Check whether the global skill/plugin named "${skillArtifact.slug}" is available to you.`, + `If it is available, reply exactly: ${expectedText}`, + "Do not include any other text." + ].join("\n"), + timeoutMs, + { + expectedText, + maxReplyLength: 1200 + } + ); +} + async function runScheduledTasks(baseUrl, token) { const startedAt = Date.now(); const result = await nodeApi(baseUrl, token, "POST", "/api/settings/environment-sync", {}, 300000); @@ -734,6 +860,47 @@ async function waitForManagementRpcReady(baseUrl, token, timeoutMs) { throw new Error(`management RPC did not become ready within ${timeoutMs}ms: ${lastError}`); } +async function waitForStartupCatalogSyncReady(baseUrl, token, timeoutMs) { + const deadline = Date.now() + timeoutMs; + const requiredKeys = ["llm_sync", "agent_sync", "template_sync", "skill_sync", "system_sync"]; + let lastError = ""; + + while (Date.now() < deadline) { + try { + const result = await httpJson(baseUrl, token, "GET", "/api/bootstrap", undefined, 30000); + const states = result.body || {}; + if (!result.ok) { + lastError = `HTTP ${result.status}: ${JSON.stringify(result.body)}`; + } else { + const failed = requiredKeys + .map((key) => [key, states[key]]) + .filter(([, state]) => state?.status === "failed"); + if (failed.length > 0) { + throw new Error(`startup catalog sync failed: ${JSON.stringify(Object.fromEntries(failed))}`); + } + + const pending = requiredKeys + .map((key) => [key, states[key]]) + .filter(([, state]) => state?.status !== "success" || !state?.last_success_at); + if (pending.length === 0) { + return Object.fromEntries(requiredKeys.map((key) => [key, states[key]])); + } + + lastError = `pending startup catalog sync: ${JSON.stringify(Object.fromEntries(pending))}`; + } + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + if (lastError.includes("startup catalog sync failed")) { + throw error; + } + } + + await sleep(1000); + } + + throw new Error(`startup catalog sync did not finish within ${timeoutMs}ms: ${lastError}`); +} + async function waitForServerStatus(baseUrl, token, timeoutMs) { const deadline = Date.now() + timeoutMs; let lastError = ""; @@ -914,6 +1081,11 @@ async function executeRestart({ baseUrl, token, page, topics, requireAgentMqttRe await runRequired("docker", ["restart", "--time", "1", ...targets], { timeout: Math.min(timeoutMs, 300000) }); await waitForHttpReady(baseUrl, timeoutMs); await waitForBootstrapReady(baseUrl, token, timeoutMs); + const managementRpcReady = await waitForManagementRpcReady( + baseUrl, + token, + Math.min(timeoutMs, Number(process.env.AIOS_TEST_MANAGEMENT_RPC_READY_TIMEOUT_MS || "120000")) + ); const serverStatus = await waitForServerStatus(baseUrl, token, timeoutMs); const mqttReady = requireAgentMqttReady ? await waitForAgentMqttReadyAfterRestart(targets, topics, startedAtIso, timeoutMs) @@ -926,6 +1098,7 @@ async function executeRestart({ baseUrl, token, page, topics, requireAgentMqttRe mode: "docker", targets, responseTimeMs: Date.now() - startedAt, + managementRpcReady, serverStatus, agentMqttReadyRequired: requireAgentMqttReady, mqttReady @@ -1027,6 +1200,7 @@ async function main() { const apiKey = readModelApiKey(); const artifactRoot = fs.mkdtempSync(path.join(os.tmpdir(), "aios-pw-flow-")); const artifacts = createDefaultWorkspaceTemplateZip(artifactRoot); + const globalSkillArtifact = createGlobalSkillZip(artifactRoot); const report = { name: "playwright-container-flow", startedAt: new Date().toISOString(), @@ -1039,10 +1213,12 @@ async function main() { templateName: TEMPLATE_NAME, agentId: AGENT_ID, assigneeUsername: ASSIGNEE_USERNAME, + globalSkillSlug: GLOBAL_SKILL_SLUG, packageInstalls: PACKAGE_INSTALLS }, artifacts: { - defaultWorkspaceDir: artifacts.workspaceDir + defaultWorkspaceDir: artifacts.workspaceDir, + globalSkillZip: globalSkillArtifact.skillZip }, timings: { startupTimeMs: optionalNumber(process.env.AIOS_TEST_STARTUP_TIME_MS), @@ -1087,6 +1263,22 @@ async function main() { ); addStep(report, "0.wait-management-rpc-ready", true); + report.startupCatalogSync = await waitForStartupCatalogSyncReady( + baseUrl, + token, + Number(process.env.AIOS_TEST_STARTUP_SYNC_READY_TIMEOUT_MS || "120000") + ); + addStep(report, "0.wait-startup-catalog-sync-ready", true, { + states: Object.fromEntries(Object.entries(report.startupCatalogSync).map(([key, state]) => [ + key, + { + status: state.status, + lastSuccessAt: state.last_success_at, + summary: state.summary || {} + } + ])) + }); + report.cleanup = await cleanupCustomResources(baseUrl, token); addStep(report, "0.cleanup-custom-resources", report.cleanup.every((item) => item.ok), { cleanup: report.cleanup @@ -1110,6 +1302,21 @@ async function main() { report.agent = await assignAdministrator(baseUrl, token, report.agent.id); addStep(report, "5.assign-administrator", true, { username: ASSIGNEE_USERNAME }); + report.globalSkillUpload = await uploadGlobalSkillWithPlaywright(page, baseUrl, globalSkillArtifact); + addStep(report, "6.upload-global-skill-with-playwright", true, { + slug: globalSkillArtifact.slug, + responseTimeMs: report.globalSkillUpload.responseTimeMs, + remoteStatus: report.globalSkillUpload.skill?.remote_status || null + }); + + report.globalSkillVerification = await verifyGlobalSkillInstalled(baseUrl, token, globalSkillArtifact); + addStep(report, "6a.verify-global-skill-installed", true, { + slug: globalSkillArtifact.slug, + commandDurationMs: report.globalSkillVerification.commandDurationMs, + loadable: report.globalSkillVerification.remoteSkill?.loadable ?? null, + installed: report.globalSkillVerification.remoteSkill?.installed ?? null + }); + const agent = await nodeApi(baseUrl, token, "GET", `/api/agents/by-slug/${encodeURIComponent(AGENT_ID)}`, undefined, 120000); report.topics = resolveAgentTopics(agent); @@ -1118,9 +1325,10 @@ async function main() { if (report.gatewayPluginCount === 0) { report.agentMqttDialogueSkipped = { reason: "runtime channel plugin is not loaded with main-style plugin seeding", - gatewayPluginCount: report.gatewayPluginCount + gatewayPluginCount: report.gatewayPluginCount, + globalSkillSlug: globalSkillArtifact.slug, + skippedAfterRestart: true }; - addStep(report, "6.skip-agent-mqtt-dialogue-main-style-plugin-seeding", true, report.agentMqttDialogueSkipped); report.scheduledTasks = await runScheduledTasks(baseUrl, token); addStep(report, "7.run-scheduled-tasks", true, { @@ -1144,10 +1352,26 @@ async function main() { mode: report.restart.mode, targets: report.restart.targets || [], responseTimeMs: report.restart.responseTimeMs, + managementRpcReady: report.restart.managementRpcReady || null, mqttReady: report.restart.mqttReady || null, agentMqttReadyRequired: report.restart.agentMqttReadyRequired }); + report.globalSkillPersistence = await verifyGlobalSkillInstalled(baseUrl, token, globalSkillArtifact); + addStep(report, "9.verify-global-skill-persistence-after-restart", true, { + slug: globalSkillArtifact.slug, + commandDurationMs: report.globalSkillPersistence.commandDurationMs, + loadable: report.globalSkillPersistence.remoteSkill?.loadable ?? null, + installed: report.globalSkillPersistence.remoteSkill?.installed ?? null + }); + + addStep( + report, + "10.skip-agent-global-skill-confirmation-after-restart-main-style-plugin-seeding", + true, + report.agentMqttDialogueSkipped + ); + report.ok = true; return; } @@ -1159,7 +1383,7 @@ async function main() { ); report.timings.agentResponseTimeMs = report.agentPageEcho.responseTimeMs; report.summary.agent_response_time_ms = report.agentPageEcho.responseTimeMs; - addStep(report, "6.agent-page-echo", true, { + addStep(report, "7.agent-page-echo", true, { responseTimeMs: report.agentPageEcho.responseTimeMs, sessionId: report.agentPageEcho.sessionId }); @@ -1174,7 +1398,7 @@ async function main() { Number(process.env.AIOS_TEST_AGENT_READY_TIMEOUT_MS || "180000"), Number(process.env.AIOS_TEST_AGENT_READY_QUIET_MS || "10000") ); - addStep(report, "6a.wait-agent-mqtt-ready-before-package-install", true, { + addStep(report, "7a.wait-agent-mqtt-ready-before-package-install", true, { responseTimeMs: report.agentMqttReadyBeforePackageInstall.responseTimeMs, quietForMs: report.agentMqttReadyBeforePackageInstall.quietForMs, readyLog: report.agentMqttReadyBeforePackageInstall.readyLog @@ -1188,7 +1412,7 @@ async function main() { ); report.timings.packageInstallTimeMs = report.packageInstall.responseTimeMs; report.summary.package_install_time_ms = report.packageInstall.responseTimeMs; - addStep(report, "7.agent-page-install-packages", true, { + addStep(report, "8.agent-page-install-packages", true, { responseTimeMs: report.packageInstall.responseTimeMs, sessionId: report.packageInstall.sessionId, reply: report.packageInstall.reply, @@ -1196,13 +1420,13 @@ async function main() { }); report.packageInstallVerification = await verifyInstalledPackages("package install verification"); - addStep(report, "7a.verify-package-install", true, { + addStep(report, "8a.verify-package-install", true, { container: report.packageInstallVerification.container, packages: report.packageInstallVerification.packages }); report.scheduledTasks = await runScheduledTasks(baseUrl, token); - addStep(report, "8.run-scheduled-tasks", true, { + addStep(report, "9.run-scheduled-tasks", true, { tasks: report.scheduledTasks.map((item) => ({ name: item.name, status: item.status, @@ -1213,19 +1437,41 @@ async function main() { report.restart = await executeRestart({ baseUrl, token, page, topics: report.topics }); report.timings.restartTimeMs = report.restart.responseTimeMs; report.summary.restart_time_ms = report.restart.responseTimeMs; - addStep(report, "9.restart", true, { + addStep(report, "10.restart", true, { mode: report.restart.mode, targets: report.restart.targets || [], responseTimeMs: report.restart.responseTimeMs, + managementRpcReady: report.restart.managementRpcReady || null, mqttReady: report.restart.mqttReady || null }); report.packagePersistence = await verifyInstalledPackagesPersisted(); - addStep(report, "10.verify-package-persistence-after-restart", true, { + addStep(report, "11.verify-package-persistence-after-restart", true, { container: report.packagePersistence.container, packages: report.packagePersistence.packages }); + report.globalSkillPersistence = await verifyGlobalSkillInstalled(baseUrl, token, globalSkillArtifact); + addStep(report, "12.verify-global-skill-persistence-after-restart", true, { + slug: globalSkillArtifact.slug, + commandDurationMs: report.globalSkillPersistence.commandDurationMs, + loadable: report.globalSkillPersistence.remoteSkill?.loadable ?? null, + installed: report.globalSkillPersistence.remoteSkill?.installed ?? null + }); + + report.agentGlobalSkillConfirmation = await agentPageConfirmGlobalSkill( + page, + baseUrl, + globalSkillArtifact, + Number(process.env.AIOS_TEST_AGENT_SKILL_TIMEOUT_MS || "240000") + ); + addStep(report, "13.agent-page-global-skill-confirmation-after-restart", true, { + slug: globalSkillArtifact.slug, + responseTimeMs: report.agentGlobalSkillConfirmation.responseTimeMs, + sessionId: report.agentGlobalSkillConfirmation.sessionId, + reply: report.agentGlobalSkillConfirmation.reply + }); + report.packageMemory = await agentPageRecallInstalledPackages( page, baseUrl, @@ -1233,7 +1479,7 @@ async function main() { ); report.timings.packageMemoryResponseTimeMs = report.packageMemory.responseTimeMs; report.summary.package_memory_response_time_ms = report.packageMemory.responseTimeMs; - addStep(report, "11.agent-page-package-memory", true, { + addStep(report, "14.agent-page-package-memory", true, { responseTimeMs: report.packageMemory.responseTimeMs, sessionId: report.packageMemory.sessionId, reply: report.packageMemory.reply -- Gitee From ebd4ee6edf8f68c1db4e766dbd2f7186a440e922 Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 16:49:57 +0800 Subject: [PATCH 07/18] bugfix --- apps/management-website/package.json | 2 +- .../server/src/services/topic-ping-service.js | 4 +- .../server/test/topic-ping-service.test.js | 46 +++++++++++++++++++ docker-images/apps/Dockerfile | 2 +- 4 files changed, 50 insertions(+), 4 deletions(-) diff --git a/apps/management-website/package.json b/apps/management-website/package.json index 3deb742..687284e 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.2.8", + "version": "0.2.9", "type": "module", "files": [ "dist", diff --git a/apps/management-website/server/src/services/topic-ping-service.js b/apps/management-website/server/src/services/topic-ping-service.js index b70c8b0..ae89115 100644 --- a/apps/management-website/server/src/services/topic-ping-service.js +++ b/apps/management-website/server/src/services/topic-ping-service.js @@ -171,6 +171,8 @@ export class TopicPingService { payload }; const timings = {}; + const replyMessages = []; + const blockTexts = []; const startedAt = Date.now(); const mark = (key, start) => { timings[key] = Date.now() - start; @@ -210,8 +212,6 @@ export class TopicPingService { }); let cancelReplyWait = () => {}; - const replyMessages = []; - const blockTexts = []; const replyPromise = new Promise((resolve, reject) => { const waitStartedAt = Date.now(); let timer; diff --git a/apps/management-website/server/test/topic-ping-service.test.js b/apps/management-website/server/test/topic-ping-service.test.js index 8a979a4..02de3d6 100644 --- a/apps/management-website/server/test/topic-ping-service.test.js +++ b/apps/management-website/server/test/topic-ping-service.test.js @@ -244,6 +244,52 @@ it("sends a single agent test message and returns MQTT payloads with timings", a expect(result.timings.total_ms).toEqual(jasmine.any(Number)); }); +it("returns an empty reply list when agent test fails before waiting for replies", async () => { + const client = new MockMqttClient(); + const service = new TopicPingService({ + db: { + prepare() { + return { + get() { + return { + slug: "agent-a", + remote_state_json: JSON.stringify({ + inboundTopic: "aios/agent/agent-a/inbound", + outboundTopic: "aios/agent/agent-a/outbound" + }) + }; + } + }; + } + }, + env: { + mqtt: { + brokerUrl: "mqtt://broker", + agentInboundTopicTemplate: "aios/agent/{agentId}/inbound", + agentOutboundTopicTemplate: "aios/agent/{agentId}/outbound" + } + }, + mqttFactory: { + connect() { + queueMicrotask(() => client.emit("error", new Error("broker down"))); + return client; + } + } + }); + + const result = await service.testAgentMessage("agent-a", { + text: "hello", + timeoutMs: 1000 + }); + + expect(result.ok).toBe(false); + expect(result.replyMessage).toBeNull(); + expect(result.replyMessages).toEqual([]); + expect(result.error_message).toContain("broker down"); + expect(result.timings.connect_ms).toEqual(jasmine.any(Number)); + expect(result.timings.total_ms).toEqual(jasmine.any(Number)); +}); + it("aggregates block replies when agent test allows block streaming", async () => { const client = new MockMqttClient(); const service = new TopicPingService({ diff --git a/docker-images/apps/Dockerfile b/docker-images/apps/Dockerfile index 7cfe974..20f7210 100644 --- a/docker-images/apps/Dockerfile +++ b/docker-images/apps/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.2.8 +ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.2.9 ARG AIOS_PROXY_NPM_VERSION=0.1.3 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org -- Gitee From 3a3f59c76e196dfeae60deb5b7941cda9c90e65a Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 18:20:01 +0800 Subject: [PATCH 08/18] =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=96=B0=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E7=9A=84openclaw?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/kernal/Dockerfile | 9 +- docker-images/kernal/README.md | 6 +- .../kernal/assets/scripts/startup.sh | 344 +----------------- .../test/kernal-tests/test/fast-smoke.js | 12 +- .../kernal-tests/test/startup-config.test.ts | 37 ++ 5 files changed, 72 insertions(+), 336 deletions(-) diff --git a/docker-images/kernal/Dockerfile b/docker-images/kernal/Dockerfile index 723d501..cc10a93 100644 --- a/docker-images/kernal/Dockerfile +++ b/docker-images/kernal/Dockerfile @@ -396,7 +396,14 @@ RUN set -eux; \ find /opt/aios-seed -type d -exec chmod 0750 {} +; \ find /opt/aios-seed -type f -perm /111 -exec chmod 0750 {} +; \ find /opt/aios-seed -type f ! -perm /111 -exec chmod 0640 {} +; \ - find /opt/aios-seed -type l -exec chown -h "${AIOS_SERVICE_USER}:${AIOS_OPENCLAW_GROUP}" {} + + find /opt/aios-seed -type l -exec chown -h "${AIOS_SERVICE_USER}:${AIOS_OPENCLAW_GROUP}" {} +; \ + seed_mqtt_project="/opt/aios-seed/home/.openclaw/npm/projects/aios-mqtt-channel"; \ + test -d "${seed_mqtt_project}/node_modules/aios-mqtt-channel"; \ + chown -R root:"${AIOS_OPENCLAW_GROUP}" "${seed_mqtt_project}"; \ + find "${seed_mqtt_project}" -type d -exec chmod 0750 {} +; \ + find "${seed_mqtt_project}" -type f -perm /111 -exec chmod 0750 {} +; \ + find "${seed_mqtt_project}" -type f ! -perm /111 -exec chmod 0640 {} +; \ + find "${seed_mqtt_project}" -type l -exec chown -h root:"${AIOS_OPENCLAW_GROUP}" {} + ENV TMPDIR=/var/aios/tmp diff --git a/docker-images/kernal/README.md b/docker-images/kernal/README.md index d44990d..3c2fe91 100644 --- a/docker-images/kernal/README.md +++ b/docker-images/kernal/README.md @@ -146,7 +146,7 @@ aios-data/ 其他运行目录默认归 `aios-svc` 管理,`aios-agent` 不能进入 `0750` 的 service 目录,也不能写入 service-owned 文件。严格隔离依赖 Linux 权限生效;Windows NTFS bind mount 可能忽略 `chmod/chown`,需要严格隔离时建议使用 Docker named volume 或 WSL/Linux 文件系统路径。 -`/opt/aios-seed` 是镜像内置 seed 目录,最终权限统一为 `aios-svc:aios`:目录 `0750`,可执行文件 `0750`,普通文件 `0640`。因此 `aios-agent` 只能通过共享组只读访问 seed 内容,`aios-svc` 可以读写。镜像默认直接使用其中的 seed npm 插件和内置 uv 工具,不会把这些大目录复制到 `/var/aios`。 +`/opt/aios-seed` 是镜像内置 seed 目录,最终权限主要为 `aios-svc:aios`:目录 `0750`,可执行文件 `0750`,普通文件 `0640`。因此 `aios-agent` 只能通过共享组只读访问 seed 内容,`aios-svc` 可以读写。OpenClaw 2026.6.5 会拒绝加载非当前用户或 root 拥有的非 bundled 插件,所以 seed 中的 `aios-mqtt-channel` npm project 额外固定为 `root:aios` 只读。镜像默认直接使用其中的 seed npm 插件和内置 uv 工具,不会把这些大目录复制到 `/var/aios`。 启动脚本还会按 `aios-agent` 的 UID 安装 netfilter 规则,默认阻断它访问私有网段和链路本地地址。启动时只从 `AIOS_MQTT_CHANNEL_BROKER` 和 `AIOS_S3_HOST` 解析内网 IP 白名单:如果解析结果是内网地址,只放行该 IP 的 MQTT/S3 TCP 端口;公网地址不进白名单,也不需要白名单。本机 `localhost`/`127.0.0.1`、Docker 标记为本机的地址,以及 `/etc/resolv.conf` 中 nameserver 的 TCP/UDP 53 会放行,保证本机访问和 DNS 解析可用。规则只匹配 `aios-agent`,不影响 `aios-svc`。 @@ -165,8 +165,8 @@ aios-data/ 5. 重写 `/var/aios/.openclaw/openclaw.json` 6. 设置 `agents.defaults.workspace=/var/aios/.openclaw/workspaces/default` 7. 设置 `agents.defaults.skipBootstrap=true` -8. 启用 `aios-mqtt-channel` -9. 修复 OpenClaw 插件 registry 的运行时 policy 和 seed 路径 +8. 启用 `aios-mqtt-channel`,并把 seed 中的 MQTT 插件包写入 `plugins.load.paths` +9. 固定 `OPENCLAW_STATE_DIR=/var/aios/.openclaw`,并执行 `openclaw plugins registry --refresh` 10. 创建持久化 Python shim 11. 安装 `aios-agent` 的内网 IP/端口白名单规则 12. 执行可选的 `/var/aios/run_after_startup.sh` diff --git a/docker-images/kernal/assets/scripts/startup.sh b/docker-images/kernal/assets/scripts/startup.sh index 49efbad..2ffce16 100644 --- a/docker-images/kernal/assets/scripts/startup.sh +++ b/docker-images/kernal/assets/scripts/startup.sh @@ -15,10 +15,12 @@ AIOS_WORKSPACE_TEMPLATES_DIR="${AIOS_WORKSPACE_TEMPLATES_DIR:-${AIOS_ROOT}/works AIOS_TMP_DIR="${AIOS_TMP_DIR:-${AIOS_ROOT}/tmp}" OPENCLAW_HOME="${AIOS_ROOT}" OPENCLAW_CONFIG_PATH="${AIOS_OPENCLAW_HOME}/openclaw.json" +OPENCLAW_STATE_DIR="${AIOS_OPENCLAW_HOME}" OPENCLAW_WORKSPACE_DIR="${AIOS_OPENCLAW_WORKSPACE_DIR}" SEED_HOME="${AIOS_SEED_HOME:-/opt/aios-seed/home}" SEED_OPENCLAW_HOME="${SEED_HOME}/.openclaw" SEED_NPM_ROOT="${SEED_OPENCLAW_HOME}/npm" +SEED_MQTT_PLUGIN_PATH="${SEED_NPM_ROOT}/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel" SEED_WORKSPACE_DIR="${AIOS_SEED_WORKSPACE_DIR:-/opt/aios-seed/workspace-default}" SEED_TEMPLATES_DIR="${AIOS_SEED_TEMPLATES_DIR:-/opt/aios-seed/workspace-templates}" SEED_SCRIPTS_DIR="${AIOS_SEED_SCRIPTS_DIR:-/opt/aios-seed/scripts}" @@ -60,8 +62,6 @@ UV_PACKAGE_NEEDS_REWRITE=false UV_PACKAGE_EXPECTED_STAMP="" UV_PACKAGE_STAMP="${UV_PACKAGE_ROOT}/.aios-seed-uv-package.fingerprint" OPENCLAW_HOME_CHANGED=false -OPENCLAW_PLUGIN_PATHS_NEED_REWRITE=false -OPENCLAW_HOME_SEED_MARKER="${AIOS_OPENCLAW_HOME}/.aios-openclaw-home-seeded" BUILTIN_CONTENT_STAMP="${AIOS_DATA_DIR}/.aios-builtin-content.fingerprint" BUILTIN_CONTENT_CHANGED=false BUILTIN_CONTENT_FULL_SYNC=false @@ -111,7 +111,7 @@ profile_step() { is_reserved_runtime_env_key() { case "$1" in - HOME|USER|LOGNAME|AIOS_ROOT|AIOS_RUN_MODE|AIOS_TOOLCHAIN_DIR|AIOS_DATA_DIR|AIOS_KERNEL_DATA_DIR|AIOS_ONTOLOGY_DIR|AIOS_SERVICE_USER|AIOS_SERVICE_GROUP|AIOS_OPENCLAW_USER|AIOS_OPENCLAW_GROUP|AIOS_OPENCLAW_HOME|AIOS_OPENCLAW_WORKSPACE_DIR|AIOS_WORKSPACE_TEMPLATES_DIR|AIOS_TMP_DIR|OPENCLAW_HOME|OPENCLAW_CONFIG_PATH|OPENCLAW_WORKSPACE_DIR|NPM_CONFIG_PREFIX|NPM_CONFIG_CACHE|UV_PACKAGE_ROOT|UV_TOOL_DIR|UV_TOOL_BIN_DIR|UV_CACHE_DIR|PIP_PACKAGE_ROOT|PIP_CACHE_DIR|VIRTUAL_ENV|XDG_CONFIG_HOME|XDG_CACHE_HOME|TMPDIR|PATH|AGENT_BROWSER_EXECUTABLE_PATH|AIOS_BUILTIN_UV_TOOL_BIN_DIR|AIOS_OPENCLAW_SHARED_FS_PRELOAD|NODE_OPTIONS) + HOME|USER|LOGNAME|AIOS_ROOT|AIOS_RUN_MODE|AIOS_TOOLCHAIN_DIR|AIOS_DATA_DIR|AIOS_KERNEL_DATA_DIR|AIOS_ONTOLOGY_DIR|AIOS_SERVICE_USER|AIOS_SERVICE_GROUP|AIOS_OPENCLAW_USER|AIOS_OPENCLAW_GROUP|AIOS_OPENCLAW_HOME|AIOS_OPENCLAW_WORKSPACE_DIR|AIOS_WORKSPACE_TEMPLATES_DIR|AIOS_TMP_DIR|OPENCLAW_HOME|OPENCLAW_CONFIG_PATH|OPENCLAW_STATE_DIR|OPENCLAW_WORKSPACE_DIR|NPM_CONFIG_PREFIX|NPM_CONFIG_CACHE|UV_PACKAGE_ROOT|UV_TOOL_DIR|UV_TOOL_BIN_DIR|UV_CACHE_DIR|PIP_PACKAGE_ROOT|PIP_CACHE_DIR|VIRTUAL_ENV|XDG_CONFIG_HOME|XDG_CACHE_HOME|TMPDIR|PATH|AGENT_BROWSER_EXECUTABLE_PATH|AIOS_BUILTIN_UV_TOOL_BIN_DIR|AIOS_OPENCLAW_SHARED_FS_PRELOAD|NODE_OPTIONS) return 0 ;; *) @@ -928,12 +928,6 @@ sync_missing_openclaw_home() { cp -a "$src/update-check.json" "$dst/update-check.json" OPENCLAW_HOME_CHANGED=true fi - - if [[ "$OPENCLAW_HOME_CHANGED" == "true" || ! -f "$OPENCLAW_HOME_SEED_MARKER" ]]; then - OPENCLAW_PLUGIN_PATHS_NEED_REWRITE=true - else - OPENCLAW_PLUGIN_PATHS_NEED_REWRITE=false - fi } sync_missing_openclaw_skills() { @@ -1347,326 +1341,12 @@ EOF chmod "$mode" "$target" } -rewrite_openclaw_plugin_install_paths() { - local file="$AIOS_OPENCLAW_HOME/plugins/installs.json" - local tmp_json tmp_marker - - if [[ "$OPENCLAW_PLUGIN_PATHS_NEED_REWRITE" != "true" ]]; then - return 0 - fi - - if [[ ! -f "$file" ]]; then +refresh_openclaw_plugin_registry() { + if [[ ! -f "$OPENCLAW_CONFIG_PATH" ]]; then return 0 fi - tmp_json="$(mktemp)" - jq \ - --arg seedNpmPrefix "${SEED_OPENCLAW_HOME}/npm" \ - --arg runtimeNpmPrefix "${AIOS_OPENCLAW_HOME}/npm" \ - ' - def canonicalize_runtime_npm_path: - if type == "string" and (. == $runtimeNpmPrefix or startswith($runtimeNpmPrefix + "/")) then - $seedNpmPrefix + .[($runtimeNpmPrefix | length):] - else - . - end; - - if (.installRecords? | type) == "object" then - .installRecords |= with_entries( - if (.value.installPath? | type) == "string" then - .value.installPath |= canonicalize_runtime_npm_path - else - . - end - ) - else - . - end - ' "$file" > "$tmp_json" - mv "$tmp_json" "$file" - ensure_shared_runtime_file_mode "$file" - - tmp_marker="$(mktemp)" - printf '%s\n' "seeded" > "$tmp_marker" - mv "$tmp_marker" "$OPENCLAW_HOME_SEED_MARKER" - ensure_shared_runtime_file_mode "$OPENCLAW_HOME_SEED_MARKER" -} - -repair_openclaw_plugin_registry() { - local registry_file="$AIOS_OPENCLAW_HOME/plugins/installs.json" - local config_file="$AIOS_OPENCLAW_HOME/openclaw.json" - local tmp_file - - if [[ ! -f "$registry_file" || ! -f "$config_file" ]]; then - return 0 - fi - - tmp_file="$(mktemp)" - AIOS_PLUGIN_REGISTRY_FILE="$registry_file" \ - AIOS_OPENCLAW_CONFIG_FILE="$config_file" \ - AIOS_SEED_OPENCLAW_NPM_PREFIX="${SEED_OPENCLAW_HOME}/npm" \ - AIOS_RUNTIME_OPENCLAW_NPM_PREFIX="${AIOS_OPENCLAW_HOME}/npm" \ - node >"$tmp_file" <<'NODE' -const fs = require("node:fs"); -const crypto = require("node:crypto"); - -const registryPath = process.env.AIOS_PLUGIN_REGISTRY_FILE; -const configPath = process.env.AIOS_OPENCLAW_CONFIG_FILE; -const seedNpmPrefix = process.env.AIOS_SEED_OPENCLAW_NPM_PREFIX; -const runtimeNpmPrefix = process.env.AIOS_RUNTIME_OPENCLAW_NPM_PREFIX; - -const STRING_FIELDS = [ - "spec", - "sourcePath", - "installPath", - "version", - "resolvedName", - "resolvedVersion", - "resolvedSpec", - "integrity", - "shasum", - "resolvedAt", - "installedAt", - "clawhubUrl", - "clawhubPackage", - "clawhubFamily", - "clawhubChannel", - "artifactKind", - "artifactFormat", - "npmIntegrity", - "npmShasum", - "npmTarballName", - "clawpackSha256", - "clawpackManifestSha256", - "gitUrl", - "gitRef", - "gitCommit", - "marketplaceName", - "marketplaceSource", - "marketplacePlugin" -]; -const NUMBER_FIELDS = ["clawpackSpecVersion", "clawpackSize"]; -const BUILT_IN_PLUGIN_ALIAS_FALLBACKS = [ - ["openai-codex", "openai"], - ["google-gemini-cli", "google"], - ["minimax-portal", "minimax"], - ["minimax-portal-auth", "minimax"] -]; -const BUILT_IN_PLUGIN_ALIAS_LOOKUP = new Map([ - ...BUILT_IN_PLUGIN_ALIAS_FALLBACKS, - ...BUILT_IN_PLUGIN_ALIAS_FALLBACKS.map(([, pluginId]) => [pluginId, pluginId]) -]); - -function readJson(filePath) { - return JSON.parse(fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "")); -} - -function hashJson(value) { - return crypto.createHash("sha256").update(JSON.stringify(value)).digest("hex"); -} - -function normalizeOptionalString(value) { - return typeof value === "string" && value.trim() ? value.trim() : undefined; -} - -function normalizeOptionalLowercaseString(value) { - const normalized = normalizeOptionalString(value); - return normalized ? normalized.toLowerCase() : undefined; -} - -function normalizePluginId(value) { - const trimmed = normalizeOptionalString(value) ?? ""; - const normalized = normalizeOptionalLowercaseString(trimmed) ?? ""; - return BUILT_IN_PLUGIN_ALIAS_LOOKUP.get(normalized) ?? trimmed; -} - -function normalizeList(value, normalize = normalizePluginId) { - if (!Array.isArray(value)) return []; - return value.map((entry) => typeof entry === "string" ? normalize(entry) : "").filter(Boolean); -} - -function normalizeSlotValue(value) { - const trimmed = normalizeOptionalString(value); - if (!trimmed) return undefined; - return normalizeOptionalLowercaseString(trimmed) === "none" ? null : trimmed; -} - -function normalizePluginEntries(entries) { - if (!entries || typeof entries !== "object" || Array.isArray(entries)) return {}; - const normalized = {}; - for (const [key, value] of Object.entries(entries)) { - const normalizedKey = normalizePluginId(key); - if (!normalizedKey) continue; - const previous = normalized[normalizedKey] ?? {}; - normalized[normalizedKey] = { - ...previous, - ...(value && typeof value === "object" && !Array.isArray(value) && typeof value.enabled === "boolean" - ? { enabled: value.enabled } - : {}) - }; - } - return normalized; -} - -function normalizePluginsConfig(config) { - const memorySlot = normalizeSlotValue(config?.slots?.memory); - return { - enabled: config?.enabled !== false, - allow: normalizeList(config?.allow), - deny: normalizeList(config?.deny), - loadPaths: normalizeList(config?.load?.paths, (entry) => entry.trim()), - slots: { - memory: memorySlot === undefined ? "memory-core" : memorySlot, - contextEngine: normalizeSlotValue(config?.slots?.contextEngine) - }, - entries: normalizePluginEntries(config?.entries) - }; -} - -function resolvePolicyHash(config) { - const normalized = normalizePluginsConfig(config?.plugins); - const channelPolicy = {}; - const channels = config?.channels; - if (channels && typeof channels === "object" && !Array.isArray(channels)) { - for (const [channelId, value] of Object.entries(channels)) { - if (value && typeof value === "object" && !Array.isArray(value) && typeof value.enabled === "boolean") { - channelPolicy[channelId] = value.enabled; - } - } - } - return hashJson({ - plugins: { - enabled: normalized.enabled, - allow: normalized.allow, - deny: normalized.deny, - slots: normalized.slots, - entries: Object.fromEntries(Object.entries(normalized.entries) - .flatMap(([pluginId, entry]) => typeof entry.enabled === "boolean" ? [[pluginId, entry.enabled]] : []) - .sort(([left], [right]) => left.localeCompare(right))) - }, - channels: Object.fromEntries(Object.entries(channelPolicy).sort(([left], [right]) => left.localeCompare(right))) - }); -} - -function normalizeChannelId(pluginId) { - return typeof pluginId === "string" ? pluginId.trim() : ""; -} - -function isBundledChannelEnabledByChannelConfig(config, pluginId) { - const channelId = normalizeChannelId(pluginId); - const entry = channelId ? config?.channels?.[channelId] : undefined; - return Boolean(entry && typeof entry === "object" && !Array.isArray(entry) && entry.enabled === true); -} - -function isPluginEnabled(plugin, config, normalizedConfig) { - const pluginId = plugin.pluginId; - const entry = normalizedConfig.entries[pluginId]; - const explicitlyAllowed = normalizedConfig.allow.includes(pluginId); - if (!normalizedConfig.enabled) return false; - if (normalizedConfig.deny.includes(pluginId)) return false; - if (entry?.enabled === false) return false; - if (normalizedConfig.slots.memory === pluginId || normalizedConfig.slots.contextEngine === pluginId) return true; - if (plugin.origin === "bundled" && isBundledChannelEnabledByChannelConfig(config, pluginId)) return true; - if (normalizedConfig.allow.length > 0 && !explicitlyAllowed) return false; - if (entry?.enabled === true) return true; - if (plugin.origin === "bundled" && isBundledChannelEnabledByChannelConfig(config, pluginId)) return true; - if (plugin.enabledByDefault === true) return true; - if (Array.isArray(plugin.enabledByDefaultOnPlatforms) && plugin.enabledByDefaultOnPlatforms.includes("linux")) return true; - if (plugin.origin === "bundled") return false; - return true; -} - -function canonicalizeNpmPath(value) { - if (typeof value !== "string" || !seedNpmPrefix || !runtimeNpmPrefix) return value; - if (value === runtimeNpmPrefix || value.startsWith(runtimeNpmPrefix + "/")) { - return seedNpmPrefix + value.slice(runtimeNpmPrefix.length); - } - return value; -} - -function normalizeInstallRecord(record) { - if (!record?.source) return undefined; - const normalized = { source: record.source }; - for (const field of STRING_FIELDS) { - if (typeof record[field] === "string" && record[field].trim()) normalized[field] = canonicalizeNpmPath(record[field].trim()); - } - for (const field of NUMBER_FIELDS) { - if (Number.isSafeInteger(record[field]) && record[field] >= 0) normalized[field] = record[field]; - } - return normalized; -} - -const registry = readJson(registryPath); -const config = readJson(configPath); - -if (!registry || typeof registry !== "object" || !Array.isArray(registry.plugins)) { - process.exit(0); -} - -const installRecords = {}; -for (const [pluginId, record] of Object.entries(registry.installRecords ?? {}).sort(([left], [right]) => left.localeCompare(right))) { - const normalized = normalizeInstallRecord(record); - if (normalized) installRecords[pluginId] = normalized; -} - -const normalizedConfig = normalizePluginsConfig(config.plugins); -const policyHash = resolvePolicyHash(config); -let changed = registry.policyHash !== policyHash || hashJson(registry.installRecords ?? {}) !== hashJson(installRecords); - -for (const plugin of registry.plugins) { - const before = JSON.stringify({ - rootDir: plugin.rootDir, - manifestPath: plugin.manifestPath, - source: plugin.source, - setupSource: plugin.setupSource, - installRecordHash: plugin.installRecordHash, - enabled: plugin.enabled - }); - plugin.rootDir = canonicalizeNpmPath(plugin.rootDir); - plugin.manifestPath = canonicalizeNpmPath(plugin.manifestPath); - if (plugin.source) plugin.source = canonicalizeNpmPath(plugin.source); - if (plugin.setupSource) plugin.setupSource = canonicalizeNpmPath(plugin.setupSource); - const installRecord = installRecords[plugin.pluginId]; - if (installRecord) plugin.installRecordHash = hashJson(installRecord); - plugin.enabled = isPluginEnabled(plugin, config, normalizedConfig); - const after = JSON.stringify({ - rootDir: plugin.rootDir, - manifestPath: plugin.manifestPath, - source: plugin.source, - setupSource: plugin.setupSource, - installRecordHash: plugin.installRecordHash, - enabled: plugin.enabled - }); - if (before !== after) changed = true; -} - -if (Array.isArray(registry.diagnostics)) { - for (const diagnostic of registry.diagnostics) { - if (diagnostic && typeof diagnostic === "object" && typeof diagnostic.source === "string") { - const nextSource = canonicalizeNpmPath(diagnostic.source); - if (nextSource !== diagnostic.source) { - diagnostic.source = nextSource; - changed = true; - } - } - } -} - -registry.installRecords = installRecords; -registry.policyHash = policyHash; -if (changed) { - registry.generatedAtMs = Date.now(); - registry.refreshReason = "aios-fast-repair"; -} -process.stdout.write(JSON.stringify(registry, null, 2) + "\n"); -NODE - - if [[ -s "$tmp_file" ]]; then - mv "$tmp_file" "$registry_file" - ensure_shared_runtime_file_mode "$registry_file" - else - rm -f "$tmp_file" - fi + run_with_runtime_env "$AIOS_OPENCLAW_USER" "$OPENCLAW_COMMAND" plugins registry --refresh >/dev/null } rewrite_uv_tool_install_paths() { @@ -1826,7 +1506,6 @@ seed_runtime_content() { profile_step "seed.sync_scripts" sync_missing_tree "$SEED_SCRIPTS_DIR" "${AIOS_ROOT}/scripts" drop-ownership profile_step "seed.sync_data" sync_missing_tree "$SEED_DATA_DIR" "$AIOS_DATA_DIR" drop-ownership profile_step "seed.sync_uv_package" sync_missing_uv_package "$SEED_UV_PACKAGE_ROOT" "$UV_PACKAGE_ROOT" - profile_step "seed.rewrite_plugin_paths" rewrite_openclaw_plugin_install_paths profile_step "seed.rewrite_uv_paths" rewrite_uv_tool_install_paths profile_step "seed.normalize_openclaw_metadata" normalize_openclaw_home_metadata profile_step "seed.normalize_workspace_root" normalize_agent_workspace_tree_recursive "$AIOS_OPENCLAW_HOME/workspaces" @@ -1906,10 +1585,13 @@ patch_openclaw_config() { mkdir -p "$(dirname "$config_path")" - if [[ -f "$config_path" ]] && jq -e ' + if [[ -f "$config_path" ]] && jq -e \ + --arg seedMqttPluginPath "$SEED_MQTT_PLUGIN_PATH" \ + ' .memory.backend == "qmd" and .gateway.mode == "local" and (.plugins.entries["aios-mqtt-channel"].enabled == true) + and ((.plugins.load.paths // []) | index($seedMqttPluginPath) != null) ' "$config_path" >/dev/null 2>&1; then config_source="$config_path" fi @@ -1919,6 +1601,7 @@ patch_openclaw_config() { --arg workspace "$AIOS_OPENCLAW_WORKSPACE_DIR" \ --arg qmdCommand "$QMD_COMMAND" \ --arg gatewayAuthToken "$gateway_auth_token" \ + --arg seedMqttPluginPath "$SEED_MQTT_PLUGIN_PATH" \ ' if (.models.providers? | type) == "object" then .models.providers |= with_entries( @@ -1944,6 +1627,7 @@ patch_openclaw_config() { | .memory.qmd.update.startup = "idle" | .memory.qmd.limits.timeoutMs = 120000 | .plugins.allow = ((.plugins.allow // []) + ["aios-mqtt-channel"] | unique) + | .plugins.load.paths = ((.plugins.load.paths // []) + [$seedMqttPluginPath] | unique) | .plugins.entries["aios-mqtt-channel"].enabled = true ' "$config_source" > "$tmp_json" mv "$tmp_json" "$config_path" @@ -1972,6 +1656,7 @@ export_runtime_environment() { export AIOS_WORKSPACE_TEMPLATES_DIR export OPENCLAW_HOME export OPENCLAW_CONFIG_PATH + export OPENCLAW_STATE_DIR export OPENCLAW_WORKSPACE_DIR export NPM_CONFIG_PREFIX export NPM_CONFIG_CACHE @@ -2076,6 +1761,7 @@ run_with_runtime_env() { export AIOS_WORKSPACE_TEMPLATES_DIR="$AIOS_WORKSPACE_TEMPLATES_DIR" export OPENCLAW_HOME="$OPENCLAW_HOME" export OPENCLAW_CONFIG_PATH="$OPENCLAW_CONFIG_PATH" + export OPENCLAW_STATE_DIR="$OPENCLAW_STATE_DIR" export OPENCLAW_WORKSPACE_DIR="$OPENCLAW_WORKSPACE_DIR" export NPM_CONFIG_PREFIX="$NPM_CONFIG_PREFIX" export NPM_CONFIG_CACHE="$NPM_CONFIG_CACHE" @@ -2441,7 +2127,7 @@ main() { profile_step "ensure_directories" ensure_directories profile_step "seed_runtime_content" seed_runtime_content profile_step "patch_openclaw_config" patch_openclaw_config - profile_step "repair_openclaw_plugin_registry" repair_openclaw_plugin_registry + profile_step "refresh_openclaw_plugin_registry" refresh_openclaw_plugin_registry profile_step "ensure_python_venv" ensure_python_venv profile_step "enforce_agent_internal_network_block" enforce_agent_internal_network_block profile_step "run_runtime_startup_hook" run_runtime_startup_hook diff --git a/docker-images/test/kernal-tests/test/fast-smoke.js b/docker-images/test/kernal-tests/test/fast-smoke.js index 6ad4c99..699c865 100644 --- a/docker-images/test/kernal-tests/test/fast-smoke.js +++ b/docker-images/test/kernal-tests/test/fast-smoke.js @@ -329,7 +329,7 @@ async function runChecks(containerName) { args: ['exec', containerName, '/bin/bash', '-c', 'test "$(stat -c %a /var/aios/.openclaw)" = "2770" && test "$(stat -c %a /var/aios/.openclaw/openclaw.json)" = "660" && test "$(stat -c %G /var/aios/.openclaw/openclaw.json)" = "aios"'], }, { - name: 'seed mqtt plugin package is svc-write agent-readonly', + name: 'seed mqtt plugin package is trusted and readonly', args: [ 'exec', containerName, @@ -340,10 +340,16 @@ async function runChecks(containerName) { 'plugin_dir=""', 'for candidate in /opt/aios-seed/home/.openclaw/npm/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel /opt/aios-seed/home/.openclaw/npm/node_modules/aios-mqtt-channel; do if [ -f "${candidate}/package.json" ]; then plugin_dir="${candidate}"; break; fi; done', 'test -n "$plugin_dir"', + 'plugin_project="$(dirname "$(dirname "$plugin_dir")")"', + 'test "$(stat -c %u "${plugin_dir}")" = "0"', + 'test "$(stat -c %G "${plugin_dir}")" = "aios"', + 'test "$(stat -c %u "${plugin_project}")" = "0"', + 'test "$(stat -c %G "${plugin_project}")" = "aios"', 'runuser -u "$AIOS_OPENCLAW_USER" -- test -r "${plugin_dir}/package.json"', 'runuser -u "$AIOS_OPENCLAW_USER" -- test ! -w "${plugin_dir}/package.json"', 'runuser -u "$AIOS_SERVICE_USER" -- test -r "${plugin_dir}/package.json"', - 'runuser -u "$AIOS_SERVICE_USER" -- test -w "${plugin_dir}/package.json"', + 'runuser -u "$AIOS_SERVICE_USER" -- test ! -w "${plugin_dir}/package.json"', + 'runuser -u "$AIOS_SERVICE_USER" -- test ! -w "${plugin_project}/node_modules"', ].join('; '), ], }, @@ -413,7 +419,7 @@ async function runChecks(containerName) { containerName, 'jq', '-e', - '.memory.backend == "qmd" and .memory.qmd.command == "/opt/aios-toolchain/bin/qmd" and .plugins.entries["aios-mqtt-channel"].enabled == true and .agents.defaults.workspace == "/var/aios/.openclaw/workspaces/default"', + '.memory.backend == "qmd" and .memory.qmd.command == "/opt/aios-toolchain/bin/qmd" and .plugins.entries["aios-mqtt-channel"].enabled == true and ((.plugins.load.paths // []) | index("/opt/aios-seed/home/.openclaw/npm/projects/aios-mqtt-channel/node_modules/aios-mqtt-channel") != null) and .agents.defaults.workspace == "/var/aios/.openclaw/workspaces/default"', '/var/aios/.openclaw/openclaw.json', ], }, diff --git a/docker-images/test/kernal-tests/test/startup-config.test.ts b/docker-images/test/kernal-tests/test/startup-config.test.ts index c54ac2c..0cc0b3d 100644 --- a/docker-images/test/kernal-tests/test/startup-config.test.ts +++ b/docker-images/test/kernal-tests/test/startup-config.test.ts @@ -30,6 +30,19 @@ describe("docker image startup configuration", () => { }; } + function readKernalDockerfile(): string { + const kernalDockerfilePath = path.resolve( + __dirname, + "..", + "..", + "..", + "kernal", + "Dockerfile", + ); + + return fs.readFileSync(kernalDockerfilePath, "utf8"); + } + it("does not expose seed npm plugin copy mode", () => { const { allInOneStartupScript, kernalStartupScript } = readStartupScripts(); const startupScripts = [allInOneStartupScript, kernalStartupScript].join("\n"); @@ -51,4 +64,28 @@ describe("docker image startup configuration", () => { expect(kernalStartupScript).toContain('find "${SEED_NPM_ROOT}/projects"'); expect(kernalStartupScript).toContain('"$BUILTIN_UV_TOOL_BIN_DIR"'); }); + + it("pins OpenClaw state and loads the seed MQTT plugin path", () => { + const { kernalStartupScript } = readStartupScripts(); + + expect(kernalStartupScript).toContain('OPENCLAW_STATE_DIR="${AIOS_OPENCLAW_HOME}"'); + expect(kernalStartupScript).toContain("OPENCLAW_STATE_DIR|"); + expect(kernalStartupScript).toContain('export OPENCLAW_STATE_DIR'); + expect(kernalStartupScript).toContain('$seedMqttPluginPath'); + expect(kernalStartupScript).toContain(".plugins.load.paths"); + expect(kernalStartupScript).toContain('"$OPENCLAW_COMMAND" plugins registry --refresh'); + expect(kernalStartupScript).toContain("refresh_openclaw_plugin_registry"); + expect(kernalStartupScript).not.toContain("repair_openclaw_plugin_registry"); + expect(kernalStartupScript).not.toContain("rewrite_openclaw_plugin_install_paths"); + expect(kernalStartupScript).not.toContain("OPENCLAW_PLUGIN_PATHS_NEED_REWRITE"); + expect(kernalStartupScript).not.toContain(".aios-openclaw-home-seeded"); + }); + + it("makes the seed MQTT plugin project trusted and readonly", () => { + const kernalDockerfile = readKernalDockerfile(); + + expect(kernalDockerfile).toContain('seed_mqtt_project="/opt/aios-seed/home/.openclaw/npm/projects/aios-mqtt-channel"'); + expect(kernalDockerfile).toContain('test -d "${seed_mqtt_project}/node_modules/aios-mqtt-channel"'); + expect(kernalDockerfile).toContain('chown -R root:"${AIOS_OPENCLAW_GROUP}" "${seed_mqtt_project}"'); + }); }); -- Gitee From 575466007578a14eb5f2e966e3ec75dca5a23a1b Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 18:42:47 +0800 Subject: [PATCH 09/18] =?UTF-8?q?=E6=8C=89=E7=85=A7=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E6=8B=86=E5=88=86=E6=B5=8B=E8=AF=95=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/all-in-one/README.md | 6 +++- docker-images/all-in-one/doc/spec.md | 3 +- joint-test/all-in-one-mode/README.md | 6 +++- .../{run-flow.sh => run-flow.core.sh} | 33 +++++-------------- joint-test/all-in-one-mode/run-flow.macos.sh | 13 ++++++++ .../all-in-one-mode/run-flow.windows.sh | 26 +++++++++++++++ 6 files changed, 60 insertions(+), 27 deletions(-) rename joint-test/all-in-one-mode/{run-flow.sh => run-flow.core.sh} (96%) mode change 100755 => 100644 create mode 100644 joint-test/all-in-one-mode/run-flow.macos.sh create mode 100644 joint-test/all-in-one-mode/run-flow.windows.sh diff --git a/docker-images/all-in-one/README.md b/docker-images/all-in-one/README.md index 76bd7ae..2e0e60d 100644 --- a/docker-images/all-in-one/README.md +++ b/docker-images/all-in-one/README.md @@ -131,7 +131,11 @@ kernel 启动脚本会按 `aios-agent` 的 UID 安装 netfilter 规则,默认 all-in-one 容器级联调入口: ```bash -AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.sh +# macOS / Linux +AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.macos.sh + +# Windows Git Bash +AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.windows.sh ``` 也可以运行 `joint-test/dmz-mode/run-all-in-one-dev2-flow.sh` 复用 DMZ 目录下的 Playwright 流程。联调流程会先清理自定义 `provider/model/agent/template`,再创建 `corp-openai`、`deepseek-v4-flash`、`public-use` 和 `public-demo`,将 `Administrator` 分配给 agent,通过 MQTT 要求 agent echo `test completed`,随后逐个触发所有同步/刷新定时任务,用 Playwright 检查审计日志、业务系统调用日志、内核调用日志三个 tab 没有错误,最后重启容器并等待恢复。 diff --git a/docker-images/all-in-one/doc/spec.md b/docker-images/all-in-one/doc/spec.md index 6332070..017fcd6 100644 --- a/docker-images/all-in-one/doc/spec.md +++ b/docker-images/all-in-one/doc/spec.md @@ -175,7 +175,8 @@ docker run -d \ 联调脚本位于: ```text -joint-test/all-in-one-mode/run-flow.sh +joint-test/all-in-one-mode/run-flow.macos.sh +joint-test/all-in-one-mode/run-flow.windows.sh joint-test/dmz-mode/run-all-in-one-dev2-flow.sh ``` diff --git a/joint-test/all-in-one-mode/README.md b/joint-test/all-in-one-mode/README.md index f3a5767..e877c77 100644 --- a/joint-test/all-in-one-mode/README.md +++ b/joint-test/all-in-one-mode/README.md @@ -3,7 +3,11 @@ 运行单容器 all-in-one 验收流程: ```bash -AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.sh +# macOS / Linux +AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.macos.sh + +# Windows Git Bash +AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.windows.sh ``` 脚本会构建并启动 all-in-one 容器,然后复用 `joint-test/dmz-mode/playwright-dev2-flow.js` 完成固定验收流:清理自定义 Provider/Model/Agent/Template,创建 `corp-openai`、`deepseek-v4-flash`、`public-use`、`public-demo`,分配 `Administrator`,通过 MQTT echo `test completed`,再通过 MQTT 让 agent 安装 `is-odd@3.0.1`、`pyfiglet@1.0.2`、`humanize@4.9.0` 并写入记忆;随后逐个触发所有同步/刷新定时任务,用 Playwright 检查三种系统日志,最后重启容器,验证 npm/uv/pip 包仍存在,并通过 MQTT 询问 agent 之前安装过哪些包,确认记忆持久化。 diff --git a/joint-test/all-in-one-mode/run-flow.sh b/joint-test/all-in-one-mode/run-flow.core.sh old mode 100755 new mode 100644 similarity index 96% rename from joint-test/all-in-one-mode/run-flow.sh rename to joint-test/all-in-one-mode/run-flow.core.sh index 2834da0..423c1dd --- a/joint-test/all-in-one-mode/run-flow.sh +++ b/joint-test/all-in-one-mode/run-flow.core.sh @@ -30,31 +30,15 @@ now_ms() { node -e 'process.stdout.write(String(Date.now()))' } -is_msys_shell() { - case "$(uname -s 2>/dev/null || true)" in - MINGW*|MSYS*|CYGWIN*) return 0 ;; - *) return 1 ;; - esac -} - -docker_host_path() { - local target="$1" - - if is_msys_shell && command -v cygpath >/dev/null 2>&1; then - cygpath -w "${target}" - return 0 +require_platform_hooks() { + if ! declare -F docker_host_path >/dev/null 2>&1; then + printf 'docker_host_path is not defined. Run run-flow.windows.sh or run-flow.macos.sh.\n' >&2 + return 1 fi - - printf '%s\n' "${target}" -} - -docker_no_pathconv() { - if is_msys_shell; then - MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker "$@" - return $? + if ! declare -F docker_run >/dev/null 2>&1; then + printf 'docker_run is not defined. Run run-flow.windows.sh or run-flow.macos.sh.\n' >&2 + return 1 fi - - docker "$@" } capture_logs() { @@ -369,7 +353,7 @@ run_flow() { startup_started_ms="$(now_ms)" log "starting all-in-one container ${CONTAINER} on web:${WEB_PORT} mqtt:${MQTT_PORT}" - docker_no_pathconv run -d \ + docker_run -d \ --name "${CONTAINER}" \ --cap-add NET_ADMIN \ --network bridge \ @@ -402,5 +386,6 @@ run_flow() { node "${DMZ_TEST_DIR}/playwright-dev2-flow.js" } +require_platform_hooks build_image run_flow diff --git a/joint-test/all-in-one-mode/run-flow.macos.sh b/joint-test/all-in-one-mode/run-flow.macos.sh new file mode 100644 index 0000000..c0eeeed --- /dev/null +++ b/joint-test/all-in-one-mode/run-flow.macos.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker_host_path() { + printf '%s\n' "$1" +} + +docker_run() { + docker run "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/run-flow.core.sh" diff --git a/joint-test/all-in-one-mode/run-flow.windows.sh b/joint-test/all-in-one-mode/run-flow.windows.sh new file mode 100644 index 0000000..8eb7d72 --- /dev/null +++ b/joint-test/all-in-one-mode/run-flow.windows.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) ;; + *) + printf 'run-flow.windows.sh must be run from Git Bash/MSYS on Windows.\n' >&2 + exit 1 + ;; +esac + +if ! command -v cygpath >/dev/null 2>&1; then + printf 'cygpath is required for Windows Docker volume paths.\n' >&2 + exit 1 +fi + +docker_host_path() { + cygpath -w "$1" +} + +docker_run() { + MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker run "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/run-flow.core.sh" -- Gitee From 8fa9eebc3b0e4a7e5f1b5f234b023b7656e2a8c6 Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 18:51:34 +0800 Subject: [PATCH 10/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/management-website/package.json | 2 +- .../server/src/background/index.js | 2 +- .../server/src/config/env.js | 3 + .../server/test/background-services.test.js | 64 +++++++++++++++++++ 4 files changed, 69 insertions(+), 2 deletions(-) diff --git a/apps/management-website/package.json b/apps/management-website/package.json index 687284e..7ecb034 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.2.9", + "version": "0.3.0", "type": "module", "files": [ "dist", diff --git a/apps/management-website/server/src/background/index.js b/apps/management-website/server/src/background/index.js index 08d6b76..a23c6a6 100644 --- a/apps/management-website/server/src/background/index.js +++ b/apps/management-website/server/src/background/index.js @@ -1,4 +1,4 @@ -const DEFAULT_STARTUP_SYNC_READY_TIMEOUT_MS = 60000; +const DEFAULT_STARTUP_SYNC_READY_TIMEOUT_MS = 240000; const DEFAULT_STARTUP_SYNC_PING_TIMEOUT_MS = 1000; const DEFAULT_STARTUP_SYNC_POLL_INTERVAL_MS = 1000; diff --git a/apps/management-website/server/src/config/env.js b/apps/management-website/server/src/config/env.js index 6987c8d..8d2d168 100644 --- a/apps/management-website/server/src/config/env.js +++ b/apps/management-website/server/src/config/env.js @@ -60,6 +60,9 @@ export function loadEnv() { dataDir, sessionTtlHours: Number(readEnvValue("AIOS_WEB_SESSION_TTL_HOURS", "12")), managementTimeoutMs: readEnvNumber("AIOS_MANAGEMENT_WEBSITE_TIMEOUT", 120) * 1000, + startupSyncReadyTimeoutMs: readEnvNumber("AIOS_STARTUP_SYNC_READY_TIMEOUT_MS", 240000), + startupSyncPingTimeoutMs: readEnvNumber("AIOS_STARTUP_SYNC_PING_TIMEOUT_MS", 1000), + startupSyncPollIntervalMs: readEnvNumber("AIOS_STARTUP_SYNC_POLL_INTERVAL_MS", 1000), mqtt: { brokerUrl: readEnvValue("AIOS_MQTT_CHANNEL_BROKER"), username: readEnvValue("AIOS_MQTT_CHANNEL_USERNAME"), diff --git a/apps/management-website/server/test/background-services.test.js b/apps/management-website/server/test/background-services.test.js index 5230f33..9e98447 100644 --- a/apps/management-website/server/test/background-services.test.js +++ b/apps/management-website/server/test/background-services.test.js @@ -210,6 +210,70 @@ it("waits for a management RPC ping before startup sync", async () => { } }); +it("retries management RPC readiness before startup sync failure is marked", async () => { + const timers = installFakeTimers(); + const calls = []; + const startupFailures = []; + let rpcAttempt = 0; + const taskFactory = (label) => async ({ trigger }) => { + calls.push(`${label}:${trigger}`); + return { status: "success" }; + }; + + const background = startBackgroundServices({ + env: { + startupSyncReadyTimeoutMs: 1000, + startupSyncPingTimeoutMs: 1, + startupSyncPollIntervalMs: 25 + }, + services: createServices({ + calls, + rpcStart: async () => { + calls.push("rpc.start"); + }, + rpcCall: async (action) => { + rpcAttempt += 1; + calls.push(`rpc.call:${action}:${rpcAttempt}`); + if (rpcAttempt === 1) { + throw new Error("not ready yet"); + } + return { pong: true }; + }, + markStartupSyncFailed: (message) => { + startupFailures.push(message); + }, + taskFactory + }) + }); + + try { + await flushMicrotasks(); + expect(calls).toEqual([ + "rpc.start", + "rpc.call:service.ping:1" + ]); + expect(startupFailures).toEqual([]); + expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([25]); + + await timers.runTimer(timers.activeTimeouts()[0]); + + expect(calls).toEqual([ + "rpc.start", + "rpc.call:service.ping:1", + "rpc.call:service.ping:2", + "sync-startup-environment:startup" + ]); + expect(startupFailures).toEqual([]); + expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ + 60 * 1000, + 2 * 60 * 1000 + ]); + } finally { + await background.stop(); + timers.restore(); + } +}); + it("times out while waiting for management RPC readiness", async () => { const calls = []; try { -- Gitee From 5264c08f658d789617caeccfbb2d57bf5eaa26ec Mon Sep 17 00:00:00 2001 From: NingWei Date: Thu, 11 Jun 2026 18:52:02 +0800 Subject: [PATCH 11/18] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=97=B6=E9=97=B4=EF=BC=8C=E9=87=8D=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/apps/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-images/apps/Dockerfile b/docker-images/apps/Dockerfile index 20f7210..eb164ec 100644 --- a/docker-images/apps/Dockerfile +++ b/docker-images/apps/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.2.9 +ARG AIOS_MANAGEMENT_WEB_NPM_VERSION=0.3.0 ARG AIOS_PROXY_NPM_VERSION=0.1.3 ARG AIOS_BUILD_NPM_REGISTRY=https://registry.npmjs.org -- Gitee From 1c09f6e9018453cddd2d9f4ecc30e490dbdd87da Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Thu, 11 Jun 2026 21:22:59 +0800 Subject: [PATCH 12/18] =?UTF-8?q?=E5=AE=8C=E5=96=84=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/all-in-one/build-arm64.zsh | 6 ++- joint-test/all-in-one-mode/run-flow.core.sh | 18 ++----- joint-test/dmz-mode/playwright-dev2-flow.js | 58 +++++++++++++++++---- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/docker-images/all-in-one/build-arm64.zsh b/docker-images/all-in-one/build-arm64.zsh index 5002047..63e6d9b 100644 --- a/docker-images/all-in-one/build-arm64.zsh +++ b/docker-images/all-in-one/build-arm64.zsh @@ -122,7 +122,6 @@ fi if [[ "${pull}" == "true" ]]; then build_kernal_args+=(--pull) build_apps_args+=(--pull) - build_all_in_one_args+=(--pull) fi build_kernal_args+=("${kernal_dir}") @@ -138,4 +137,9 @@ docker "${build_apps_args[@]}" echo "Building ${image_ref} for linux/arm64 from ${script_dir}" echo "Using KERNEL_IMAGE=${kernel_image_ref}" echo "Using APPS_IMAGE=${apps_image_ref}" +if [[ "${pull}" == "true" ]]; then + echo "Pulling all-in-one external base images for linux/arm64" + docker pull --platform linux/arm64 quay.io/minio/minio:latest + docker pull --platform linux/arm64 quay.io/minio/mc:latest +fi docker "${build_all_in_one_args[@]}" diff --git a/joint-test/all-in-one-mode/run-flow.core.sh b/joint-test/all-in-one-mode/run-flow.core.sh index 423c1dd..e2f1d82 100644 --- a/joint-test/all-in-one-mode/run-flow.core.sh +++ b/joint-test/all-in-one-mode/run-flow.core.sh @@ -202,21 +202,13 @@ redact_runtime_secrets() { cleanup() { capture_logs - local restart_kept_container=0 if [[ "${KEEP_CONTAINER}" == "1" ]]; then - if [[ "$(docker inspect -f '{{.State.Running}}' "${CONTAINER}" 2>/dev/null || true)" == "true" ]]; then - docker stop "${CONTAINER}" >/dev/null 2>&1 || true - restart_kept_container=1 - fi - else - docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + log "keeping container ${CONTAINER}; skip runtime secret redaction so the kept container remains usable" + return 0 fi + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true redact_runtime_secrets - - if [[ "${restart_kept_container}" == "1" ]]; then - docker start "${CONTAINER}" >/dev/null 2>&1 || true - fi } trap cleanup EXIT @@ -337,14 +329,14 @@ NODE } run_flow() { - prepare_runtime - if [[ -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then printf 'AIOS_OPENAI_API_KEY or AIOS_TEST_MODEL_API_KEY is required for the public-demo MQTT echo step.\n' >&2 return 1 fi docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + prepare_runtime + local startup_started_ms local startup_finished_ms local startup_time_ms diff --git a/joint-test/dmz-mode/playwright-dev2-flow.js b/joint-test/dmz-mode/playwright-dev2-flow.js index c25e196..3b3f7b1 100644 --- a/joint-test/dmz-mode/playwright-dev2-flow.js +++ b/joint-test/dmz-mode/playwright-dev2-flow.js @@ -58,6 +58,42 @@ function optionalNumber(value) { return Number.isFinite(parsed) && parsed >= 0 ? parsed : null; } +function isSensitiveReportKey(key) { + const normalized = String(key || "").replace(/[^A-Za-z0-9]/g, "").toLowerCase(); + if (!normalized || normalized === "apikeyconfigured") { + return false; + } + return ( + normalized.endsWith("apikey") + || normalized.includes("apikeymask") + || normalized.includes("apikeyfingerprint") + || normalized.includes("secret") + || normalized.includes("password") + || normalized === "pass" + || normalized === "token" + || normalized.endsWith("token") + ); +} + +function sanitizeReportValue(value, key = "") { + if (Array.isArray(value)) { + return value.map((item) => sanitizeReportValue(item)); + } + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([entryKey, entryValue]) => [ + entryKey, + sanitizeReportValue(entryValue, entryKey) + ])); + } + if (isSensitiveReportKey(key) && value !== undefined && value !== null && value !== "") { + return ""; + } + if (typeof value === "string") { + return value.replace(/sk-[A-Za-z0-9_*.-]+/g, ""); + } + return value; +} + function readModelApiKey() { const value = process.env.AIOS_TEST_MODEL_API_KEY || process.env.AIOS_OPENAI_API_KEY || ""; if (!value.trim()) { @@ -148,6 +184,7 @@ function createGlobalSkillZip(rootDir) { "# Container Skill Smoke", "", "This fake skill exists only for the all-in-one container integration test.", + "When asked what it does, mention that it is an uploaded global skill for container integration testing.", "When asked about availability, mention the slug `container-skill-smoke`.", "" ].join("\n"), "utf8")); @@ -760,15 +797,16 @@ async function agentPageRecallInstalledPackages(page, baseUrl, timeoutMs) { ); } -async function agentPageConfirmGlobalSkill(page, baseUrl, skillArtifact, timeoutMs) { - const expectedText = `SKILL_AVAILABLE: ${skillArtifact.slug}`; +async function agentPageDescribeGlobalSkill(page, baseUrl, skillArtifact, timeoutMs) { + const expectedText = `SKILL_DESCRIPTION: ${skillArtifact.description}`; return await runAgentConversationTestWithPlaywright( page, baseUrl, AGENT_ID, [ - `Check whether the global skill/plugin named "${skillArtifact.slug}" is available to you.`, - `If it is available, reply exactly: ${expectedText}`, + `查一下 ${skillArtifact.slug} 这个技能是干什么的。`, + "请根据你可见的技能元数据或 SKILL.md 回答。", + `如果可见,请原样回复:${expectedText}`, "Do not include any other text." ].join("\n"), timeoutMs, @@ -1459,17 +1497,17 @@ async function main() { installed: report.globalSkillPersistence.remoteSkill?.installed ?? null }); - report.agentGlobalSkillConfirmation = await agentPageConfirmGlobalSkill( + report.agentGlobalSkillDescription = await agentPageDescribeGlobalSkill( page, baseUrl, globalSkillArtifact, Number(process.env.AIOS_TEST_AGENT_SKILL_TIMEOUT_MS || "240000") ); - addStep(report, "13.agent-page-global-skill-confirmation-after-restart", true, { + addStep(report, "13.agent-page-global-skill-description-after-restart", true, { slug: globalSkillArtifact.slug, - responseTimeMs: report.agentGlobalSkillConfirmation.responseTimeMs, - sessionId: report.agentGlobalSkillConfirmation.sessionId, - reply: report.agentGlobalSkillConfirmation.reply + responseTimeMs: report.agentGlobalSkillDescription.responseTimeMs, + sessionId: report.agentGlobalSkillDescription.sessionId, + reply: report.agentGlobalSkillDescription.reply }); report.packageMemory = await agentPageRecallInstalledPackages( @@ -1497,7 +1535,7 @@ async function main() { report.finishedAt = new Date().toISOString(); fs.mkdirSync(reportDir, { recursive: true }); const reportPath = path.join(reportDir, `playwright-container-flow-${new Date().toISOString().replace(/[:.]/g, "-")}.json`); - fs.writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`, "utf8"); + fs.writeFileSync(reportPath, `${JSON.stringify(sanitizeReportValue(report), null, 2)}\n`, "utf8"); console.log(reportPath); } } -- Gitee From 0caa8d41a406b18eddb999b4442915d71577142c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Thu, 11 Jun 2026 22:29:53 +0800 Subject: [PATCH 13/18] =?UTF-8?q?=E4=BC=98=E5=8C=96=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=E8=84=9A=E6=9C=AC=E3=80=82=E9=81=BF=E5=85=8Dwindows=E5=92=8Cma?= =?UTF-8?q?c=E7=9B=B8=E4=BA=92=E5=B9=B2=E6=89=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docker-images/all-in-one/README.md | 14 +- docker-images/all-in-one/build-arm64.zsh | 3 +- docker-images/all-in-one/doc/spec.md | 3 +- docker-images/apps/BUILD.md | 12 +- joint-test/all-in-one-mode/README.md | 2 +- joint-test/dmz-mode/README.md | 35 ++- .../dmz-mode/run-all-in-one-dev2-flow.core.sh | 205 +++++++++++++++ .../run-all-in-one-dev2-flow.macos.sh | 13 + .../dmz-mode/run-all-in-one-dev2-flow.sh | 195 +-------------- .../run-all-in-one-dev2-flow.windows.sh | 26 ++ joint-test/dmz-mode/run-dev2-flow.core.sh | 234 ++++++++++++++++++ joint-test/dmz-mode/run-dev2-flow.macos.sh | 13 + joint-test/dmz-mode/run-dev2-flow.sh | 223 +---------------- joint-test/dmz-mode/run-dev2-flow.windows.sh | 26 ++ 14 files changed, 597 insertions(+), 407 deletions(-) create mode 100644 joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh create mode 100755 joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh create mode 100755 joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh create mode 100644 joint-test/dmz-mode/run-dev2-flow.core.sh create mode 100755 joint-test/dmz-mode/run-dev2-flow.macos.sh create mode 100755 joint-test/dmz-mode/run-dev2-flow.windows.sh diff --git a/docker-images/all-in-one/README.md b/docker-images/all-in-one/README.md index 2e0e60d..f90d051 100644 --- a/docker-images/all-in-one/README.md +++ b/docker-images/all-in-one/README.md @@ -131,14 +131,24 @@ kernel 启动脚本会按 `aios-agent` 的 UID 安装 netfilter 规则,默认 all-in-one 容器级联调入口: ```bash -# macOS / Linux +# macOS AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.macos.sh # Windows Git Bash AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.windows.sh ``` -也可以运行 `joint-test/dmz-mode/run-all-in-one-dev2-flow.sh` 复用 DMZ 目录下的 Playwright 流程。联调流程会先清理自定义 `provider/model/agent/template`,再创建 `corp-openai`、`deepseek-v4-flash`、`public-use` 和 `public-demo`,将 `Administrator` 分配给 agent,通过 MQTT 要求 agent echo `test completed`,随后逐个触发所有同步/刷新定时任务,用 Playwright 检查审计日志、业务系统调用日志、内核调用日志三个 tab 没有错误,最后重启容器并等待恢复。 +也可以运行 DMZ 目录下的平台入口复用同一套 Playwright 流程: + +```bash +# macOS +AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh + +# Windows Git Bash +AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh +``` + +兼容入口 `joint-test/dmz-mode/run-all-in-one-dev2-flow.sh` 会根据当前 shell 自动分发到对应平台脚本。联调流程会先清理自定义 `provider/model/agent/template`,再创建 `corp-openai`、`deepseek-v4-flash`、`public-use` 和 `public-demo`,将 `Administrator` 分配给 agent,通过 MQTT 要求 agent echo `test completed`,随后逐个触发所有同步/刷新定时任务,用 Playwright 检查审计日志、业务系统调用日志、内核调用日志三个 tab 没有错误,最后重启容器并等待恢复。 报告写入 `joint-test/all-in-one-mode/.runtime/reports/`,`summary` 中包含 `startup_time_ms`、`agent_response_time_ms` 和 `restart_time_ms`。 diff --git a/docker-images/all-in-one/build-arm64.zsh b/docker-images/all-in-one/build-arm64.zsh index 63e6d9b..40891a5 100644 --- a/docker-images/all-in-one/build-arm64.zsh +++ b/docker-images/all-in-one/build-arm64.zsh @@ -91,7 +91,6 @@ build_kernal_args=( build --file "${kernal_dir}/Dockerfile" --tag "${kernel_image_ref}" - --no-cache-filter seed-builder --build-arg TARGETARCH=arm64 --platform linux/arm64 ) @@ -117,6 +116,8 @@ if [[ "${no_cache}" == "true" ]]; then build_kernal_args+=(--no-cache) build_apps_args+=(--no-cache) build_all_in_one_args+=(--no-cache) +else + build_kernal_args+=(--no-cache-filter seed-builder) fi if [[ "${pull}" == "true" ]]; then diff --git a/docker-images/all-in-one/doc/spec.md b/docker-images/all-in-one/doc/spec.md index 017fcd6..51c1198 100644 --- a/docker-images/all-in-one/doc/spec.md +++ b/docker-images/all-in-one/doc/spec.md @@ -177,7 +177,8 @@ docker run -d \ ```text joint-test/all-in-one-mode/run-flow.macos.sh joint-test/all-in-one-mode/run-flow.windows.sh -joint-test/dmz-mode/run-all-in-one-dev2-flow.sh +joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh +joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh ``` 覆盖: diff --git a/docker-images/apps/BUILD.md b/docker-images/apps/BUILD.md index 52b3bfb..009200d 100644 --- a/docker-images/apps/BUILD.md +++ b/docker-images/apps/BUILD.md @@ -69,8 +69,16 @@ docker build ` ## DMZ 联调测试 -```powershell -bash .\joint-test\dmz-mode\run-dev2-flow.sh +macOS: + +```bash +bash joint-test/dmz-mode/run-dev2-flow.macos.sh +``` + +Windows Git Bash/MSYS: + +```bash +bash joint-test/dmz-mode/run-dev2-flow.windows.sh ``` Playwright 联调测试会验证: diff --git a/joint-test/all-in-one-mode/README.md b/joint-test/all-in-one-mode/README.md index e877c77..209ef8b 100644 --- a/joint-test/all-in-one-mode/README.md +++ b/joint-test/all-in-one-mode/README.md @@ -3,7 +3,7 @@ 运行单容器 all-in-one 验收流程: ```bash -# macOS / Linux +# macOS AIOS_OPENAI_API_KEY=sk-... bash joint-test/all-in-one-mode/run-flow.macos.sh # Windows Git Bash diff --git a/joint-test/dmz-mode/README.md b/joint-test/dmz-mode/README.md index 322f15d..9bfb5e9 100644 --- a/joint-test/dmz-mode/README.md +++ b/joint-test/dmz-mode/README.md @@ -15,9 +15,18 @@ ## 一键运行 +macOS: + ```bash cd /Users/ningwei/VSCodeProjects/aios-oc -bash joint-test/dmz-mode/run-dev2-flow.sh +bash joint-test/dmz-mode/run-dev2-flow.macos.sh +``` + +Windows Git Bash/MSYS: + +```bash +cd /c/Users//VSCodeProjects/aios-oc +bash joint-test/dmz-mode/run-dev2-flow.windows.sh ``` 默认镜像名: @@ -31,19 +40,23 @@ bash joint-test/dmz-mode/run-dev2-flow.sh AIOS_KERNEL_IMAGE=aios-kernal-arm64:local \ AIOS_APPS_IMAGE=aios-apps-arm64:local \ AIOS_OPENAI_API_KEY=sk-... \ -bash joint-test/dmz-mode/run-dev2-flow.sh +bash joint-test/dmz-mode/run-dev2-flow.macos.sh ``` `AIOS_OPENAI_API_KEY` 不写入本目录的 env/config 文件,也不在启动时注入 kernal 容器。Playwright 流程登录管理控制台后,会通过 `/api/llm/providers` 和 `/api/llm/models` 创建 `corp-openai/deepseek-v4-flash`,再写入 `public-use` 模板默认 Provider/Model,并创建使用该模板默认模型的 `public-demo` agent。因为第 6 步需要真实 MQTT agent 回复,必须提供 `AIOS_OPENAI_API_KEY` 或 `AIOS_TEST_MODEL_API_KEY`。 +旧入口 `run-dev2-flow.sh` 仍保留为兼容分发脚本,会根据当前 shell 自动转到 macOS 或 Windows 入口;新脚本请优先使用显式平台后缀。 + ## 只跑测试 如果镜像已经构建完成: ```bash -AIOS_TEST_SKIP_BUILD=1 bash joint-test/dmz-mode/run-dev2-flow.sh +AIOS_TEST_SKIP_BUILD=1 bash joint-test/dmz-mode/run-dev2-flow.macos.sh ``` +Windows Git Bash/MSYS 使用 `run-dev2-flow.windows.sh`,其余环境变量保持一致。 + 报告和容器日志会写到: ```text @@ -59,21 +72,31 @@ joint-test/dmz-mode/.runtime/ 失败后如需保留容器排查: ```bash -AIOS_TEST_KEEP_CONTAINERS=1 AIOS_TEST_SKIP_BUILD=1 bash joint-test/dmz-mode/run-dev2-flow.sh +AIOS_TEST_KEEP_CONTAINERS=1 AIOS_TEST_SKIP_BUILD=1 bash joint-test/dmz-mode/run-dev2-flow.macos.sh ``` ## All-In-One 构建并测试单容器 all-in-one 镜像: +macOS: + +```bash +AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh +``` + +Windows Git Bash/MSYS: + ```bash -AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.sh +AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh ``` 如果 all-in-one 镜像已经构建完成: ```bash -AIOS_TEST_SKIP_BUILD=1 AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.sh +AIOS_TEST_SKIP_BUILD=1 AIOS_OPENAI_API_KEY=sk-... bash joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh ``` all-in-one 联调会使用内置 MinIO 和内置 MQTT。测试脚本只把管理端口 `3030` 和 MQTT 端口 `1883` 映射到宿主机,其中 MQTT 映射用于 Playwright 脚本实际发送消息验证 agent 可用。 + +旧入口 `run-all-in-one-dev2-flow.sh` 仍保留为兼容分发脚本,会根据当前 shell 自动转到 macOS 或 Windows 入口。 diff --git a/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh b/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh new file mode 100644 index 0000000..5f3521f --- /dev/null +++ b/joint-test/dmz-mode/run-all-in-one-dev2-flow.core.sh @@ -0,0 +1,205 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +IMAGE="${AIOS_ALL_IN_ONE_IMAGE:-aios-all-in-one-arm64:local}" +KERNEL_IMAGE="${AIOS_KERNEL_IMAGE:-aios-kernal-arm64:local}" +APPS_IMAGE="${AIOS_APPS_IMAGE:-aios-apps-arm64:local}" +CONTAINER="${AIOS_ALL_IN_ONE_CONTAINER:-aios-dmz-all-in-one-dev2}" +ENV_SOURCE="${AIOS_TEST_ENV_SOURCE:-${SCRIPT_DIR}/env.dev2.json}" +RUNTIME_ROOT="${AIOS_TEST_RUNTIME_ROOT:-${SCRIPT_DIR}/.runtime-all-in-one}" +REPORT_DIR="${AIOS_TEST_REPORT_DIR:-${RUNTIME_ROOT}/reports}" +DOCKER_LOG_DIR="${RUNTIME_ROOT}/docker-logs" +WEB_PORT="${AIOS_WEB_PORT:-3030}" +BASE_URL="${AIOS_WEB_BASE_URL:-http://127.0.0.1:${WEB_PORT}}" +SKIP_BUILD="${AIOS_TEST_SKIP_BUILD:-0}" +KEEP_CONTAINER="${AIOS_TEST_KEEP_CONTAINERS:-0}" + +log() { + printf '[dmz-all-in-one] %s\n' "$*" +} + +now_ms() { + node -e 'process.stdout.write(String(Date.now()))' +} + +require_platform_hooks() { + if ! declare -F docker_host_path >/dev/null 2>&1; then + printf 'docker_host_path is not defined. Run run-all-in-one-dev2-flow.macos.sh or run-all-in-one-dev2-flow.windows.sh.\n' >&2 + return 1 + fi + if ! declare -F docker_run >/dev/null 2>&1; then + printf 'docker_run is not defined. Run run-all-in-one-dev2-flow.macos.sh or run-all-in-one-dev2-flow.windows.sh.\n' >&2 + return 1 + fi +} + +capture_logs() { + mkdir -p "${DOCKER_LOG_DIR}" + docker logs "${CONTAINER}" >"${DOCKER_LOG_DIR}/${CONTAINER}.log" 2>&1 || true +} + +redact_runtime_secrets() { + if [[ -f "${RUNTIME_ROOT}/env.json" ]]; then + node - "${RUNTIME_ROOT}/env.json" <<'NODE' || true +const fs = require("node:fs"); +const [target] = process.argv.slice(2); +const payload = JSON.parse(fs.readFileSync(target, "utf8")); +if (payload.AIOS_OPENAI_API_KEY) { + payload.AIOS_OPENAI_API_KEY = ""; + fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); +} +NODE + fi + if [[ -f "${RUNTIME_ROOT}/generated/kernel-env.json" ]]; then + node - "${RUNTIME_ROOT}/generated/kernel-env.json" <<'NODE' || true +const fs = require("node:fs"); +const [target] = process.argv.slice(2); +const payload = JSON.parse(fs.readFileSync(target, "utf8")); +if (payload.AIOS_OPENAI_API_KEY) { + payload.AIOS_OPENAI_API_KEY = ""; + fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); +} +NODE + fi + if [[ -f "${RUNTIME_ROOT}/kernel/env.json" ]]; then + node - "${RUNTIME_ROOT}/kernel/env.json" <<'NODE' || true +const fs = require("node:fs"); +const [target] = process.argv.slice(2); +const payload = JSON.parse(fs.readFileSync(target, "utf8")); +if (payload.AIOS_OPENAI_API_KEY) { + payload.AIOS_OPENAI_API_KEY = ""; + fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); +} +NODE + fi + for target in "${RUNTIME_ROOT}/generated/apps-env.json" "${RUNTIME_ROOT}/apps/env.json"; do + if [[ -f "${target}" ]]; then + node - "${target}" <<'NODE' || true +const fs = require("node:fs"); +const [target] = process.argv.slice(2); +const payload = JSON.parse(fs.readFileSync(target, "utf8")); +if (payload.AIOS_OPENAI_API_KEY) { + payload.AIOS_OPENAI_API_KEY = ""; + fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); +} +NODE + fi + done +} + +cleanup() { + capture_logs + if [[ "${KEEP_CONTAINER}" == "1" ]]; then + log "keeping container ${CONTAINER}; skip runtime secret redaction so the kept container remains usable" + return 0 + fi + + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + redact_runtime_secrets +} +trap cleanup EXIT + +build_image() { + if [[ "${SKIP_BUILD}" == "1" ]]; then + log "skip all-in-one image build" + return 0 + fi + + log "building ${IMAGE}" + docker build \ + -f "${REPO_ROOT}/docker-images/all-in-one/Dockerfile" \ + -t "${IMAGE}" \ + --build-arg "KERNEL_IMAGE=${KERNEL_IMAGE}" \ + --build-arg "APPS_IMAGE=${APPS_IMAGE}" \ + "${REPO_ROOT}/docker-images/all-in-one" +} + +prepare_runtime() { + rm -rf "${RUNTIME_ROOT}" + mkdir -p "${RUNTIME_ROOT}" "${REPORT_DIR}" "${DOCKER_LOG_DIR}" + + node - "${ENV_SOURCE}" "${RUNTIME_ROOT}/env.json" "${REPO_ROOT}/docker-images/all-in-one/sample/config.json" <<'NODE' +const fs = require("node:fs"); +const [source, target, sampleConfig] = process.argv.slice(2); +JSON.parse(fs.readFileSync(source, "utf8")); +const next = { + AIOS_MANAGEMENT_CONSOLE_PORT: Number(process.env.AIOS_MANAGEMENT_CONSOLE_PORT || "3030") +}; +fs.writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`); +fs.copyFileSync(sampleConfig, target.replace(/env\.json$/, "config.json")); +NODE +} + +wait_for_url() { + local url="$1" + local timeout_seconds="${2:-300}" + local started_at + started_at="$(date +%s)" + + while (( "$(date +%s)" - started_at < timeout_seconds )); do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + docker logs "${CONTAINER}" || true + printf 'URL did not become ready: %s\n' "${url}" >&2 + return 1 +} + +run_flow() { + if [[ -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then + printf 'AIOS_OPENAI_API_KEY or AIOS_TEST_MODEL_API_KEY is required for the public-demo MQTT echo step.\n' >&2 + return 1 + fi + + docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true + prepare_runtime + + local startup_started_ms + local startup_finished_ms + local startup_time_ms + local docker_runtime_root + docker_runtime_root="$(docker_host_path "${RUNTIME_ROOT}")" + startup_started_ms="$(now_ms)" + + log "starting all-in-one container ${CONTAINER}" + docker_run -d \ + --name "${CONTAINER}" \ + --cap-add NET_ADMIN \ + --network bridge \ + -p "${WEB_PORT}:3030" \ + -p "${AIOS_MQTT_PORT:-1883}:1883" \ + -v "${docker_runtime_root}:/var/aios-all-in-one" \ + "${IMAGE}" >/dev/null + + wait_for_url "${BASE_URL}" 300 + startup_finished_ms="$(now_ms)" + startup_time_ms="$((startup_finished_ms - startup_started_ms))" + if [[ ! -f "${RUNTIME_ROOT}/apps/env.json" ]]; then + docker logs "${CONTAINER}" || true + printf 'generated apps env not found: %s\n' "${RUNTIME_ROOT}/apps/env.json" >&2 + return 1 + fi + + if [[ ! -d "${SCRIPT_DIR}/node_modules/playwright-core" ]]; then + log "installing Playwright test dependencies" + npm install --prefix "${SCRIPT_DIR}" + fi + + log "running Playwright flow against ${BASE_URL}" + AIOS_WEB_BASE_URL="${BASE_URL}" \ + AIOS_TEST_ENV_JSON="${RUNTIME_ROOT}/apps/env.json" \ + AIOS_TEST_REPORT_DIR="${REPORT_DIR}" \ + AIOS_TEST_STARTUP_TIME_MS="${startup_time_ms}" \ + AIOS_TEST_RESTART_TARGETS="${CONTAINER}" \ + node "${SCRIPT_DIR}/playwright-dev2-flow.js" +} + +require_platform_hooks +build_image +run_flow diff --git a/joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh b/joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh new file mode 100755 index 0000000..8930b43 --- /dev/null +++ b/joint-test/dmz-mode/run-all-in-one-dev2-flow.macos.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker_host_path() { + printf '%s\n' "$1" +} + +docker_run() { + docker run "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/run-all-in-one-dev2-flow.core.sh" diff --git a/joint-test/dmz-mode/run-all-in-one-dev2-flow.sh b/joint-test/dmz-mode/run-all-in-one-dev2-flow.sh index 6d11c24..9d6dffe 100755 --- a/joint-test/dmz-mode/run-all-in-one-dev2-flow.sh +++ b/joint-test/dmz-mode/run-all-in-one-dev2-flow.sh @@ -2,187 +2,16 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -IMAGE="${AIOS_ALL_IN_ONE_IMAGE:-aios-all-in-one-arm64:local}" -KERNEL_IMAGE="${AIOS_KERNEL_IMAGE:-aios-kernal-arm64:local}" -APPS_IMAGE="${AIOS_APPS_IMAGE:-aios-apps-arm64:local}" -CONTAINER="${AIOS_ALL_IN_ONE_CONTAINER:-aios-dmz-all-in-one-dev2}" -ENV_SOURCE="${AIOS_TEST_ENV_SOURCE:-${SCRIPT_DIR}/env.dev2.json}" -RUNTIME_ROOT="${AIOS_TEST_RUNTIME_ROOT:-${SCRIPT_DIR}/.runtime-all-in-one}" -REPORT_DIR="${AIOS_TEST_REPORT_DIR:-${RUNTIME_ROOT}/reports}" -DOCKER_LOG_DIR="${RUNTIME_ROOT}/docker-logs" -WEB_PORT="${AIOS_WEB_PORT:-3030}" -BASE_URL="${AIOS_WEB_BASE_URL:-http://127.0.0.1:${WEB_PORT}}" -SKIP_BUILD="${AIOS_TEST_SKIP_BUILD:-0}" -KEEP_CONTAINER="${AIOS_TEST_KEEP_CONTAINERS:-0}" - -log() { - printf '[dmz-all-in-one] %s\n' "$*" -} - -now_ms() { - node -e 'process.stdout.write(String(Date.now()))' -} - -capture_logs() { - mkdir -p "${DOCKER_LOG_DIR}" - docker logs "${CONTAINER}" >"${DOCKER_LOG_DIR}/${CONTAINER}.log" 2>&1 || true -} - -redact_runtime_secrets() { - if [[ -f "${RUNTIME_ROOT}/env.json" ]]; then - node - "${RUNTIME_ROOT}/env.json" <<'NODE' || true -const fs = require("node:fs"); -const [target] = process.argv.slice(2); -const payload = JSON.parse(fs.readFileSync(target, "utf8")); -if (payload.AIOS_OPENAI_API_KEY) { - payload.AIOS_OPENAI_API_KEY = ""; - fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); -} -NODE - fi - if [[ -f "${RUNTIME_ROOT}/generated/kernel-env.json" ]]; then - node - "${RUNTIME_ROOT}/generated/kernel-env.json" <<'NODE' || true -const fs = require("node:fs"); -const [target] = process.argv.slice(2); -const payload = JSON.parse(fs.readFileSync(target, "utf8")); -if (payload.AIOS_OPENAI_API_KEY) { - payload.AIOS_OPENAI_API_KEY = ""; - fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); -} -NODE - fi - if [[ -f "${RUNTIME_ROOT}/kernel/env.json" ]]; then - node - "${RUNTIME_ROOT}/kernel/env.json" <<'NODE' || true -const fs = require("node:fs"); -const [target] = process.argv.slice(2); -const payload = JSON.parse(fs.readFileSync(target, "utf8")); -if (payload.AIOS_OPENAI_API_KEY) { - payload.AIOS_OPENAI_API_KEY = ""; - fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); -} -NODE - fi - for target in "${RUNTIME_ROOT}/generated/apps-env.json" "${RUNTIME_ROOT}/apps/env.json"; do - if [[ -f "${target}" ]]; then - node - "${target}" <<'NODE' || true -const fs = require("node:fs"); -const [target] = process.argv.slice(2); -const payload = JSON.parse(fs.readFileSync(target, "utf8")); -if (payload.AIOS_OPENAI_API_KEY) { - payload.AIOS_OPENAI_API_KEY = ""; - fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); -} -NODE - fi - done -} - -cleanup() { - capture_logs - redact_runtime_secrets - if [[ "${KEEP_CONTAINER}" != "1" ]]; then - docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -build_image() { - if [[ "${SKIP_BUILD}" == "1" ]]; then - log "skip all-in-one image build" - return 0 - fi - - log "building ${IMAGE}" - docker build \ - -f "${REPO_ROOT}/docker-images/all-in-one/Dockerfile" \ - -t "${IMAGE}" \ - --build-arg "KERNEL_IMAGE=${KERNEL_IMAGE}" \ - --build-arg "APPS_IMAGE=${APPS_IMAGE}" \ - "${REPO_ROOT}/docker-images/all-in-one" -} - -prepare_runtime() { - rm -rf "${RUNTIME_ROOT}" - mkdir -p "${RUNTIME_ROOT}" "${REPORT_DIR}" "${DOCKER_LOG_DIR}" - - node - "${ENV_SOURCE}" "${RUNTIME_ROOT}/env.json" "${REPO_ROOT}/docker-images/all-in-one/sample/config.json" <<'NODE' -const fs = require("node:fs"); -const [source, target, sampleConfig] = process.argv.slice(2); -JSON.parse(fs.readFileSync(source, "utf8")); -const next = { - AIOS_MANAGEMENT_CONSOLE_PORT: Number(process.env.AIOS_MANAGEMENT_CONSOLE_PORT || "3030") -}; -fs.writeFileSync(target, `${JSON.stringify(next, null, 2)}\n`); -fs.copyFileSync(sampleConfig, target.replace(/env\.json$/, "config.json")); -NODE -} - -wait_for_url() { - local url="$1" - local timeout_seconds="${2:-300}" - local started_at - started_at="$(date +%s)" - - while (( "$(date +%s)" - started_at < timeout_seconds )); do - if curl -fsS "${url}" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - - docker logs "${CONTAINER}" || true - printf 'URL did not become ready: %s\n' "${url}" >&2 - return 1 -} - -run_flow() { - prepare_runtime - - if [[ -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then - printf 'AIOS_OPENAI_API_KEY or AIOS_TEST_MODEL_API_KEY is required for the public-demo MQTT echo step.\n' >&2 - return 1 - fi - - docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true - local startup_started_ms - local startup_finished_ms - local startup_time_ms - startup_started_ms="$(now_ms)" - - log "starting all-in-one container ${CONTAINER}" - docker run -d \ - --name "${CONTAINER}" \ - --cap-add NET_ADMIN \ - --network bridge \ - -p "${WEB_PORT}:3030" \ - -p "${AIOS_MQTT_PORT:-1883}:1883" \ - -v "${RUNTIME_ROOT}:/var/aios-all-in-one" \ - "${IMAGE}" >/dev/null - - wait_for_url "${BASE_URL}" 300 - startup_finished_ms="$(now_ms)" - startup_time_ms="$((startup_finished_ms - startup_started_ms))" - if [[ ! -f "${RUNTIME_ROOT}/apps/env.json" ]]; then - docker logs "${CONTAINER}" || true - printf 'generated apps env not found: %s\n' "${RUNTIME_ROOT}/apps/env.json" >&2 - return 1 - fi - - if [[ ! -d "${SCRIPT_DIR}/node_modules/playwright-core" ]]; then - log "installing Playwright test dependencies" - npm install --prefix "${SCRIPT_DIR}" - fi - - log "running Playwright flow against ${BASE_URL}" - AIOS_WEB_BASE_URL="${BASE_URL}" \ - AIOS_TEST_ENV_JSON="${RUNTIME_ROOT}/apps/env.json" \ - AIOS_TEST_REPORT_DIR="${REPORT_DIR}" \ - AIOS_TEST_STARTUP_TIME_MS="${startup_time_ms}" \ - AIOS_TEST_RESTART_TARGETS="${CONTAINER}" \ - node "${SCRIPT_DIR}/playwright-dev2-flow.js" -} - -build_image -run_flow +case "$(uname -s 2>/dev/null || true)" in + Darwin*) + exec bash "${SCRIPT_DIR}/run-all-in-one-dev2-flow.macos.sh" "$@" + ;; + MINGW*|MSYS*|CYGWIN*) + exec bash "${SCRIPT_DIR}/run-all-in-one-dev2-flow.windows.sh" "$@" + ;; + *) + printf 'Unsupported platform. Run run-all-in-one-dev2-flow.macos.sh on macOS or run-all-in-one-dev2-flow.windows.sh from Git Bash/MSYS on Windows.\n' >&2 + exit 1 + ;; +esac diff --git a/joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh b/joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh new file mode 100755 index 0000000..33bb7ae --- /dev/null +++ b/joint-test/dmz-mode/run-all-in-one-dev2-flow.windows.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) ;; + *) + printf 'run-all-in-one-dev2-flow.windows.sh must be run from Git Bash/MSYS on Windows.\n' >&2 + exit 1 + ;; +esac + +if ! command -v cygpath >/dev/null 2>&1; then + printf 'cygpath is required for Windows Docker volume paths.\n' >&2 + exit 1 +fi + +docker_host_path() { + cygpath -w "$1" +} + +docker_run() { + MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker run "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/run-all-in-one-dev2-flow.core.sh" diff --git a/joint-test/dmz-mode/run-dev2-flow.core.sh b/joint-test/dmz-mode/run-dev2-flow.core.sh new file mode 100644 index 0000000..a729ed6 --- /dev/null +++ b/joint-test/dmz-mode/run-dev2-flow.core.sh @@ -0,0 +1,234 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" + +TARGETARCH="${TARGETARCH:-}" +if [[ -z "${TARGETARCH}" ]]; then + case "$(uname -m)" in + arm64|aarch64) TARGETARCH="arm64" ;; + x86_64|amd64) TARGETARCH="amd64" ;; + *) TARGETARCH="arm64" ;; + esac +fi + +KERNEL_IMAGE="${AIOS_KERNEL_IMAGE:-aios-kernal-arm64:local}" +APPS_IMAGE="${AIOS_APPS_IMAGE:-aios-apps-arm64:local}" +KERNEL_CONTAINER="${AIOS_KERNEL_CONTAINER:-aios-dmz-kernal-dev2}" +APPS_CONTAINER="${AIOS_APPS_CONTAINER:-aios-dmz-apps-dev2}" +ENV_SOURCE="${AIOS_TEST_ENV_SOURCE:-${SCRIPT_DIR}/env.dev2.json}" +RUNTIME_ROOT="${AIOS_TEST_RUNTIME_ROOT:-${SCRIPT_DIR}/.runtime}" +KERNEL_RUNTIME="${RUNTIME_ROOT}/kernal" +APPS_RUNTIME="${RUNTIME_ROOT}/apps" +REPORT_DIR="${AIOS_TEST_REPORT_DIR:-${RUNTIME_ROOT}/reports}" +DOCKER_LOG_DIR="${RUNTIME_ROOT}/docker-logs" +WEB_PORT="${AIOS_WEB_PORT:-3030}" +BASE_URL="${AIOS_WEB_BASE_URL:-http://127.0.0.1:${WEB_PORT}}" +SKIP_BUILD="${AIOS_TEST_SKIP_BUILD:-0}" +KEEP_CONTAINERS="${AIOS_TEST_KEEP_CONTAINERS:-0}" + +log() { + printf '[dmz-dev2] %s\n' "$*" +} + +now_ms() { + node -e 'process.stdout.write(String(Date.now()))' +} + +require_platform_hooks() { + if ! declare -F docker_host_path >/dev/null 2>&1; then + printf 'docker_host_path is not defined. Run run-dev2-flow.macos.sh or run-dev2-flow.windows.sh.\n' >&2 + return 1 + fi + if ! declare -F docker_run >/dev/null 2>&1; then + printf 'docker_run is not defined. Run run-dev2-flow.macos.sh or run-dev2-flow.windows.sh.\n' >&2 + return 1 + fi +} + +capture_logs() { + mkdir -p "${DOCKER_LOG_DIR}" + docker logs "${KERNEL_CONTAINER}" >"${DOCKER_LOG_DIR}/${KERNEL_CONTAINER}.log" 2>&1 || true + docker logs "${APPS_CONTAINER}" >"${DOCKER_LOG_DIR}/${APPS_CONTAINER}.log" 2>&1 || true +} + +redact_runtime_secrets() { + if [[ -f "${KERNEL_RUNTIME}/env.json" ]]; then + node - "${KERNEL_RUNTIME}/env.json" <<'NODE' || true +const fs = require("node:fs"); +const [target] = process.argv.slice(2); +const payload = JSON.parse(fs.readFileSync(target, "utf8")); +if (payload.AIOS_OPENAI_API_KEY) { + payload.AIOS_OPENAI_API_KEY = ""; + fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); +} +NODE + fi +} + +cleanup() { + capture_logs + if [[ "${KEEP_CONTAINERS}" == "1" ]]; then + log "keeping containers ${KERNEL_CONTAINER}, ${APPS_CONTAINER}; skip runtime secret redaction so they remain usable" + return 0 + fi + + docker rm -f "${APPS_CONTAINER}" >/dev/null 2>&1 || true + docker rm -f "${KERNEL_CONTAINER}" >/dev/null 2>&1 || true + redact_runtime_secrets +} +trap cleanup EXIT + +wait_for_container_running() { + local container="$1" + local timeout_seconds="${2:-180}" + local started_at + started_at="$(date +%s)" + + while (( "$(date +%s)" - started_at < timeout_seconds )); do + if [[ "$(docker inspect -f '{{.State.Running}}' "${container}" 2>/dev/null || true)" == "true" ]]; then + return 0 + fi + sleep 2 + done + + docker logs "${container}" || true + printf 'container did not start: %s\n' "${container}" >&2 + return 1 +} + +wait_for_kernel_services() { + local timeout_seconds="${1:-240}" + local started_at + started_at="$(date +%s)" + + while (( "$(date +%s)" - started_at < timeout_seconds )); do + if docker exec "${KERNEL_CONTAINER}" /bin/bash -lc 'test -s /var/aios/logs/aios-management-serivce.pid && pid="$(cat /var/aios/logs/aios-management-serivce.pid)" && kill -0 "${pid}" && test -s /var/aios/.openclaw/openclaw.json' >/dev/null 2>&1; then + return 0 + fi + sleep 3 + done + + docker logs "${KERNEL_CONTAINER}" || true + printf 'kernal services were not ready in time\n' >&2 + return 1 +} + +wait_for_url() { + local url="$1" + local timeout_seconds="${2:-180}" + local started_at + started_at="$(date +%s)" + + while (( "$(date +%s)" - started_at < timeout_seconds )); do + if curl -fsS "${url}" >/dev/null 2>&1; then + return 0 + fi + sleep 2 + done + + docker logs "${APPS_CONTAINER}" || true + printf 'URL did not become ready: %s\n' "${url}" >&2 + return 1 +} + +prepare_runtime() { + rm -rf "${RUNTIME_ROOT}" + mkdir -p "${KERNEL_RUNTIME}" "${APPS_RUNTIME}" "${REPORT_DIR}" "${DOCKER_LOG_DIR}" + cp -a "${REPO_ROOT}/docker-images/kernal/sample/." "${KERNEL_RUNTIME}/" + + node - "${ENV_SOURCE}" "${KERNEL_RUNTIME}/env.json" "${APPS_RUNTIME}/env.json" <<'NODE' +const fs = require("node:fs"); +const [source, kernelOut, appsOut] = process.argv.slice(2); +const common = JSON.parse(fs.readFileSync(source, "utf8")); +const kernel = { ...common }; +const apps = { + ...common, + AIOS_WEB_ENABLED: true, + AIOS_APP_INVOKE_PROXY_ENABLED: true, + AIOS_WEB_DATA_DIR: "/var/aios-apps/data/web", + AIOS_INVOKE_PROXY_TOKEN: "sk-1234567890abcdef,sk-abcdef1234567890" +}; +fs.writeFileSync(kernelOut, `${JSON.stringify(kernel, null, 2)}\n`); +fs.writeFileSync(appsOut, `${JSON.stringify(apps, null, 2)}\n`); +NODE +} + +build_images() { + if [[ "${SKIP_BUILD}" == "1" ]]; then + log "skip image build" + return 0 + fi + + log "building kernal image ${KERNEL_IMAGE}" + docker build \ + -f "${REPO_ROOT}/docker-images/kernal/Dockerfile" \ + -t "${KERNEL_IMAGE}" \ + --build-arg "TARGETARCH=${TARGETARCH}" \ + "${REPO_ROOT}/docker-images/kernal" + + log "building apps image ${APPS_IMAGE}" + docker build \ + -f "${REPO_ROOT}/docker-images/apps/Dockerfile" \ + -t "${APPS_IMAGE}" \ + --build-arg "TARGETARCH=${TARGETARCH}" \ + "${REPO_ROOT}/docker-images/apps" +} + +run_flow() { + if [[ -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then + printf 'AIOS_OPENAI_API_KEY or AIOS_TEST_MODEL_API_KEY is required for the public-demo MQTT echo step.\n' >&2 + return 1 + fi + + docker rm -f "${APPS_CONTAINER}" >/dev/null 2>&1 || true + docker rm -f "${KERNEL_CONTAINER}" >/dev/null 2>&1 || true + log "preparing runtime under ${RUNTIME_ROOT}" + prepare_runtime + + local startup_started_ms + local startup_finished_ms + local startup_time_ms + local docker_kernel_runtime + local docker_apps_runtime + docker_kernel_runtime="$(docker_host_path "${KERNEL_RUNTIME}")" + docker_apps_runtime="$(docker_host_path "${APPS_RUNTIME}")" + startup_started_ms="$(now_ms)" + + log "starting kernal container ${KERNEL_CONTAINER}" + docker_run -d \ + --name "${KERNEL_CONTAINER}" \ + -v "${docker_kernel_runtime}:/var/aios" \ + "${KERNEL_IMAGE}" >/dev/null + wait_for_container_running "${KERNEL_CONTAINER}" 180 + wait_for_kernel_services 240 + + log "starting apps container ${APPS_CONTAINER}" + docker_run -d \ + --name "${APPS_CONTAINER}" \ + -p "${WEB_PORT}:3030" \ + -v "${docker_apps_runtime}:/var/aios-apps" \ + "${APPS_IMAGE}" >/dev/null + wait_for_container_running "${APPS_CONTAINER}" 180 + wait_for_url "${BASE_URL}" 180 + startup_finished_ms="$(now_ms)" + startup_time_ms="$((startup_finished_ms - startup_started_ms))" + + if [[ ! -d "${SCRIPT_DIR}/node_modules/playwright-core" ]]; then + log "installing Playwright test dependencies" + npm install --prefix "${SCRIPT_DIR}" + fi + + log "running Playwright flow against ${BASE_URL}" + AIOS_WEB_BASE_URL="${BASE_URL}" \ + AIOS_TEST_ENV_JSON="${APPS_RUNTIME}/env.json" \ + AIOS_TEST_REPORT_DIR="${REPORT_DIR}" \ + AIOS_TEST_STARTUP_TIME_MS="${startup_time_ms}" \ + AIOS_TEST_RESTART_TARGETS="${KERNEL_CONTAINER},${APPS_CONTAINER}" \ + node "${SCRIPT_DIR}/playwright-dev2-flow.js" +} + +require_platform_hooks +build_images +run_flow diff --git a/joint-test/dmz-mode/run-dev2-flow.macos.sh b/joint-test/dmz-mode/run-dev2-flow.macos.sh new file mode 100755 index 0000000..518473a --- /dev/null +++ b/joint-test/dmz-mode/run-dev2-flow.macos.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +set -euo pipefail + +docker_host_path() { + printf '%s\n' "$1" +} + +docker_run() { + docker run "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/run-dev2-flow.core.sh" diff --git a/joint-test/dmz-mode/run-dev2-flow.sh b/joint-test/dmz-mode/run-dev2-flow.sh index 983486a..ae2730f 100755 --- a/joint-test/dmz-mode/run-dev2-flow.sh +++ b/joint-test/dmz-mode/run-dev2-flow.sh @@ -2,215 +2,16 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)" -TARGETARCH="${TARGETARCH:-}" -if [[ -z "${TARGETARCH}" ]]; then - case "$(uname -m)" in - arm64|aarch64) TARGETARCH="arm64" ;; - x86_64|amd64) TARGETARCH="amd64" ;; - *) TARGETARCH="arm64" ;; - esac -fi - -KERNEL_IMAGE="${AIOS_KERNEL_IMAGE:-aios-kernal-arm64:local}" -APPS_IMAGE="${AIOS_APPS_IMAGE:-aios-apps-arm64:local}" -KERNEL_CONTAINER="${AIOS_KERNEL_CONTAINER:-aios-dmz-kernal-dev2}" -APPS_CONTAINER="${AIOS_APPS_CONTAINER:-aios-dmz-apps-dev2}" -ENV_SOURCE="${AIOS_TEST_ENV_SOURCE:-${SCRIPT_DIR}/env.dev2.json}" -RUNTIME_ROOT="${AIOS_TEST_RUNTIME_ROOT:-${SCRIPT_DIR}/.runtime}" -KERNEL_RUNTIME="${RUNTIME_ROOT}/kernal" -APPS_RUNTIME="${RUNTIME_ROOT}/apps" -REPORT_DIR="${AIOS_TEST_REPORT_DIR:-${RUNTIME_ROOT}/reports}" -DOCKER_LOG_DIR="${RUNTIME_ROOT}/docker-logs" -WEB_PORT="${AIOS_WEB_PORT:-3030}" -BASE_URL="${AIOS_WEB_BASE_URL:-http://127.0.0.1:${WEB_PORT}}" -SKIP_BUILD="${AIOS_TEST_SKIP_BUILD:-0}" -KEEP_CONTAINERS="${AIOS_TEST_KEEP_CONTAINERS:-0}" - -log() { - printf '[dmz-dev2] %s\n' "$*" -} - -now_ms() { - node -e 'process.stdout.write(String(Date.now()))' -} - -capture_logs() { - mkdir -p "${DOCKER_LOG_DIR}" - docker logs "${KERNEL_CONTAINER}" >"${DOCKER_LOG_DIR}/${KERNEL_CONTAINER}.log" 2>&1 || true - docker logs "${APPS_CONTAINER}" >"${DOCKER_LOG_DIR}/${APPS_CONTAINER}.log" 2>&1 || true -} - -redact_runtime_secrets() { - if [[ -f "${KERNEL_RUNTIME}/env.json" ]]; then - node - "${KERNEL_RUNTIME}/env.json" <<'NODE' || true -const fs = require("node:fs"); -const [target] = process.argv.slice(2); -const payload = JSON.parse(fs.readFileSync(target, "utf8")); -if (payload.AIOS_OPENAI_API_KEY) { - payload.AIOS_OPENAI_API_KEY = ""; - fs.writeFileSync(target, `${JSON.stringify(payload, null, 2)}\n`); -} -NODE - fi -} - -cleanup() { - capture_logs - redact_runtime_secrets - if [[ "${KEEP_CONTAINERS}" != "1" ]]; then - docker rm -f "${APPS_CONTAINER}" >/dev/null 2>&1 || true - docker rm -f "${KERNEL_CONTAINER}" >/dev/null 2>&1 || true - fi -} -trap cleanup EXIT - -wait_for_container_running() { - local container="$1" - local timeout_seconds="${2:-180}" - local started_at - started_at="$(date +%s)" - - while (( "$(date +%s)" - started_at < timeout_seconds )); do - if [[ "$(docker inspect -f '{{.State.Running}}' "${container}" 2>/dev/null || true)" == "true" ]]; then - return 0 - fi - sleep 2 - done - - docker logs "${container}" || true - printf 'container did not start: %s\n' "${container}" >&2 - return 1 -} - -wait_for_kernel_services() { - local timeout_seconds="${1:-240}" - local started_at - started_at="$(date +%s)" - - while (( "$(date +%s)" - started_at < timeout_seconds )); do - if docker exec "${KERNEL_CONTAINER}" /bin/bash -lc 'test -s /var/aios/logs/aios-management-serivce.pid && pid="$(cat /var/aios/logs/aios-management-serivce.pid)" && kill -0 "${pid}" && test -s /var/aios/.openclaw/openclaw.json' >/dev/null 2>&1; then - return 0 - fi - sleep 3 - done - - docker logs "${KERNEL_CONTAINER}" || true - printf 'kernal services were not ready in time\n' >&2 - return 1 -} - -wait_for_url() { - local url="$1" - local timeout_seconds="${2:-180}" - local started_at - started_at="$(date +%s)" - - while (( "$(date +%s)" - started_at < timeout_seconds )); do - if curl -fsS "${url}" >/dev/null 2>&1; then - return 0 - fi - sleep 2 - done - - docker logs "${APPS_CONTAINER}" || true - printf 'URL did not become ready: %s\n' "${url}" >&2 - return 1 -} - -prepare_runtime() { - rm -rf "${RUNTIME_ROOT}" - mkdir -p "${KERNEL_RUNTIME}" "${APPS_RUNTIME}" "${REPORT_DIR}" "${DOCKER_LOG_DIR}" - cp -a "${REPO_ROOT}/docker-images/kernal/sample/." "${KERNEL_RUNTIME}/" - - node - "${ENV_SOURCE}" "${KERNEL_RUNTIME}/env.json" "${APPS_RUNTIME}/env.json" <<'NODE' -const fs = require("node:fs"); -const [source, kernelOut, appsOut] = process.argv.slice(2); -const common = JSON.parse(fs.readFileSync(source, "utf8")); -const kernel = { ...common }; -const apps = { - ...common, - AIOS_WEB_ENABLED: true, - AIOS_APP_INVOKE_PROXY_ENABLED: true, - AIOS_WEB_DATA_DIR: "/var/aios-apps/data/web", - AIOS_INVOKE_PROXY_TOKEN: "sk-1234567890abcdef,sk-abcdef1234567890" -}; -fs.writeFileSync(kernelOut, `${JSON.stringify(kernel, null, 2)}\n`); -fs.writeFileSync(appsOut, `${JSON.stringify(apps, null, 2)}\n`); -NODE -} - -build_images() { - if [[ "${SKIP_BUILD}" == "1" ]]; then - log "skip image build" - return 0 - fi - - log "building kernal image ${KERNEL_IMAGE}" - docker build \ - -f "${REPO_ROOT}/docker-images/kernal/Dockerfile" \ - -t "${KERNEL_IMAGE}" \ - --build-arg "TARGETARCH=${TARGETARCH}" \ - "${REPO_ROOT}/docker-images/kernal" - - log "building apps image ${APPS_IMAGE}" - docker build \ - -f "${REPO_ROOT}/docker-images/apps/Dockerfile" \ - -t "${APPS_IMAGE}" \ - --build-arg "TARGETARCH=${TARGETARCH}" \ - "${REPO_ROOT}/docker-images/apps" -} - -run_flow() { - log "preparing runtime under ${RUNTIME_ROOT}" - prepare_runtime - - if [[ -z "${AIOS_OPENAI_API_KEY:-}" && -z "${AIOS_TEST_MODEL_API_KEY:-}" ]]; then - printf 'AIOS_OPENAI_API_KEY or AIOS_TEST_MODEL_API_KEY is required for the public-demo MQTT echo step.\n' >&2 - return 1 - fi - - docker rm -f "${APPS_CONTAINER}" >/dev/null 2>&1 || true - docker rm -f "${KERNEL_CONTAINER}" >/dev/null 2>&1 || true - - local startup_started_ms - local startup_finished_ms - local startup_time_ms - startup_started_ms="$(now_ms)" - - log "starting kernal container ${KERNEL_CONTAINER}" - docker run -d \ - --name "${KERNEL_CONTAINER}" \ - -v "${KERNEL_RUNTIME}:/var/aios" \ - "${KERNEL_IMAGE}" >/dev/null - wait_for_container_running "${KERNEL_CONTAINER}" 180 - wait_for_kernel_services 240 - - log "starting apps container ${APPS_CONTAINER}" - docker run -d \ - --name "${APPS_CONTAINER}" \ - -p "${WEB_PORT}:3030" \ - -v "${APPS_RUNTIME}:/var/aios-apps" \ - "${APPS_IMAGE}" >/dev/null - wait_for_container_running "${APPS_CONTAINER}" 180 - wait_for_url "${BASE_URL}" 180 - startup_finished_ms="$(now_ms)" - startup_time_ms="$((startup_finished_ms - startup_started_ms))" - - if [[ ! -d "${SCRIPT_DIR}/node_modules/playwright-core" ]]; then - log "installing Playwright test dependencies" - npm install --prefix "${SCRIPT_DIR}" - fi - - log "running Playwright flow against ${BASE_URL}" - AIOS_WEB_BASE_URL="${BASE_URL}" \ - AIOS_TEST_ENV_JSON="${APPS_RUNTIME}/env.json" \ - AIOS_TEST_REPORT_DIR="${REPORT_DIR}" \ - AIOS_TEST_STARTUP_TIME_MS="${startup_time_ms}" \ - AIOS_TEST_RESTART_TARGETS="${KERNEL_CONTAINER},${APPS_CONTAINER}" \ - node "${SCRIPT_DIR}/playwright-dev2-flow.js" -} - -build_images -run_flow +case "$(uname -s 2>/dev/null || true)" in + Darwin*) + exec bash "${SCRIPT_DIR}/run-dev2-flow.macos.sh" "$@" + ;; + MINGW*|MSYS*|CYGWIN*) + exec bash "${SCRIPT_DIR}/run-dev2-flow.windows.sh" "$@" + ;; + *) + printf 'Unsupported platform. Run run-dev2-flow.macos.sh on macOS or run-dev2-flow.windows.sh from Git Bash/MSYS on Windows.\n' >&2 + exit 1 + ;; +esac diff --git a/joint-test/dmz-mode/run-dev2-flow.windows.sh b/joint-test/dmz-mode/run-dev2-flow.windows.sh new file mode 100755 index 0000000..13fefee --- /dev/null +++ b/joint-test/dmz-mode/run-dev2-flow.windows.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail + +case "$(uname -s 2>/dev/null || true)" in + MINGW*|MSYS*|CYGWIN*) ;; + *) + printf 'run-dev2-flow.windows.sh must be run from Git Bash/MSYS on Windows.\n' >&2 + exit 1 + ;; +esac + +if ! command -v cygpath >/dev/null 2>&1; then + printf 'cygpath is required for Windows Docker volume paths.\n' >&2 + exit 1 +fi + +docker_host_path() { + cygpath -w "$1" +} + +docker_run() { + MSYS_NO_PATHCONV=1 MSYS2_ARG_CONV_EXCL='*' docker run "$@" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "${SCRIPT_DIR}/run-dev2-flow.core.sh" -- Gitee From bf467c412887cafbf41e3e4dc2339c0ccdb38417 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=AE=81=E4=BC=9F?= <55093136@qq.com> Date: Sat, 13 Jun 2026 20:29:05 +0800 Subject: [PATCH 14/18] =?UTF-8?q?=E5=AF=B9=E9=BD=90=E7=AE=A1=E7=90=86web?= =?UTF-8?q?=E5=92=8Cservice=E7=9A=84=E5=8D=8F=E8=AE=AE=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/management-website/README.md | 10 +- apps/management-website/docs/spec.md | 14 +- apps/management-website/package.json | 2 +- .../server/src/api/routes/index.js | 4 +- .../server/src/background/index.js | 26 +-- .../src/services/catalog-sync-service.js | 112 ++++++---- .../server/test/background-services.test.js | 24 +-- ...ute.test.js => catalog-sync-route.test.js} | 8 +- .../server/test/catalog-sync-service.test.js | 171 ++++++++------- .../server/test/management-rpc-client.test.js | 28 ++- .../portal-service-management-logs.test.js | 2 +- apps/management-website/src/app/App.jsx | 4 +- .../src/pages/SettingsPage.jsx | 38 ++-- apps/management-website/src/styles.css | 2 +- docker-images/apps/Dockerfile | 2 +- docker-images/kernal/Dockerfile | 4 +- .../workspace-templates/default/AGENTS.md | 4 + .../test/container-mqtt-integration.js | 2 +- .../test/management-service-smoke.js | 50 ++--- joint-test/dmz-mode/playwright-dev2-flow.js | 4 +- kernal/aios-management-serivce/README.md | 8 +- .../docs/compatibility-list.md | 55 +++-- kernal/aios-management-serivce/docs/design.md | 24 +-- kernal/aios-management-serivce/package.json | 2 +- .../capabilities/agent/lifecycle-service.ts | 25 +-- .../src/capabilities/catalog/snapshot.ts | 200 ++++++++++++++++++ .../src/capabilities/environment/info.ts | 29 --- .../src/capabilities/registry.ts | 4 +- kernal/aios-management-serivce/src/service.ts | 2 +- .../test/capabilities-registry.test.ts | 49 ++++- .../test/openclaw-manager.test.ts | 57 ++++- .../test/service-queue.test.ts | 2 +- 32 files changed, 650 insertions(+), 318 deletions(-) rename apps/management-website/server/test/{environment-sync-route.test.js => catalog-sync-route.test.js} (93%) create mode 100644 kernal/aios-management-serivce/src/capabilities/catalog/snapshot.ts delete mode 100644 kernal/aios-management-serivce/src/capabilities/environment/info.ts diff --git a/apps/management-website/README.md b/apps/management-website/README.md index e570905..5d2ec0e 100644 --- a/apps/management-website/README.md +++ b/apps/management-website/README.md @@ -14,7 +14,7 @@ AIOS 管理控制台是面向运维和管理员的 Web 应用,技术栈为 `Re - 本地数据库缓存环境目录和 Token 用量 - 启动同步、定时同步和手动同步共用同一套同步计划 -管理控制台的列表、状态和统计统一从 SQLite 读取。远端数据只通过 `environment.info` 和 `agent.usage.list` 两类同步任务刷新。 +管理控制台的列表、状态和统计统一从 SQLite 读取。远端数据只通过 `catalog.snapshot` 和 `agent.usage.list` 两类同步任务刷新。 ## 目录结构 @@ -109,12 +109,12 @@ npm run test:full 启动后后台流程为: 1. 等待 `aios-management-serivce` 的 `service.ping` 可用 -2. 执行一次 `environment.info` -3. 如果环境同步成功,60 秒后执行第一次 `agent.usage.list` -4. 每次 `environment.info` 完成后 120 秒再次执行 +2. 执行一次 `catalog.snapshot` +3. 如果目录快照同步成功,60 秒后执行第一次 `agent.usage.list` +4. 每次 `catalog.snapshot` 完成后 120 秒再次执行 5. 每次 `agent.usage.list` 完成后 120 秒再次执行 -设置页“同步数据”按钮会先执行 `environment.info`,再执行 `agent.usage.list`。同步过程会显示蒙版,完成后用 toast 提示结果。 +设置页“同步数据”按钮会先执行 `catalog.snapshot`,再执行 `agent.usage.list`。同步过程会显示蒙版,完成后用 toast 提示结果。 同步状态写入这些表: diff --git a/apps/management-website/docs/spec.md b/apps/management-website/docs/spec.md index 092514a..b96550d 100644 --- a/apps/management-website/docs/spec.md +++ b/apps/management-website/docs/spec.md @@ -50,7 +50,7 @@ Token 用量由 `agent.usage.list` 同步到 `agents.usage_snapshot_json`。超 Provider 表示 OpenAI 兼容模型服务的 `baseUrl + SK`。Model 归属 Provider,运行时引用格式为 `provider_id/model_id`。 -Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.info` 同步后缓存到本地数据库。 +Provider / Model 的真实目录来自 OpenClaw,控制台通过 `catalog.snapshot` 同步后缓存到本地数据库。 ### 业务系统 @@ -60,7 +60,7 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i ## 同步策略 -### 环境信息同步 +### 目录快照同步 触发时机: @@ -70,7 +70,7 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i 行为: -- 发送 `environment.info` 到 `aios-management-serivce` +- 发送 `catalog.snapshot` 到 `aios-management-serivce` - 一次性获取 LLM、Agent、Template、Skill、System、Ontology 目录 - 写入本地 SQLite - 更新目录同步状态表 @@ -79,9 +79,9 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i 触发时机: -- 启动 `environment.info` 成功后 60 秒执行第一次 +- 启动 `catalog.snapshot` 成功后 60 秒执行第一次 - 每次完成后 120 秒再次执行 -- 管理员点击“同步数据”时,在 `environment.info` 后执行 +- 管理员点击“同步数据”时,在 `catalog.snapshot` 后执行 行为: @@ -101,11 +101,11 @@ Provider / Model 的真实目录来自 OpenClaw,控制台通过 `environment.i 服务操作包含: -- “同步数据”按钮:手动执行 `environment.info` 后接 `agent.usage.list` +- “同步数据”按钮:手动执行 `catalog.snapshot` 后接 `agent.usage.list` - 服务器状态 - 服务器诊断 - 重启服务 -- 环境信息同步状态表 +- 目录快照同步状态表 - Token 同步状态表 设置页不再提供定时任务 Tab。 diff --git a/apps/management-website/package.json b/apps/management-website/package.json index 7ecb034..438c8fd 100644 --- a/apps/management-website/package.json +++ b/apps/management-website/package.json @@ -1,6 +1,6 @@ { "name": "aios-management-web", - "version": "0.3.0", + "version": "0.3.1", "type": "module", "files": [ "dist", diff --git a/apps/management-website/server/src/api/routes/index.js b/apps/management-website/server/src/api/routes/index.js index f71f971..7470295 100644 --- a/apps/management-website/server/src/api/routes/index.js +++ b/apps/management-website/server/src/api/routes/index.js @@ -801,10 +801,10 @@ export function createRoutes(services) { res.json(result); })); - router.post("/settings/environment-sync", asyncRoute(async (req, res) => { + router.post("/settings/catalog-sync", asyncRoute(async (req, res) => { services.authService.assertAdmin(req.currentUser); const result = await services.catalogSyncService.runManualSyncPlan({ trigger: "manual" }); - writeAuditLog(services, req, "同步数据", "手动触发 environment.info 与 agent.usage.list 数据同步"); + writeAuditLog(services, req, "同步数据", "手动触发 catalog.snapshot 与 agent.usage.list 数据同步"); res.json({ result, ...getCatalogSyncStatuses(services) diff --git a/apps/management-website/server/src/background/index.js b/apps/management-website/server/src/background/index.js index a23c6a6..c935420 100644 --- a/apps/management-website/server/src/background/index.js +++ b/apps/management-website/server/src/background/index.js @@ -59,7 +59,7 @@ export async function waitForManagementRpcReady({ } export function startBackgroundServices({ env, services }) { - let environmentTimer = null; + let catalogTimer = null; let usageTimer = null; const scheduleDelayMs = 2 * 60 * 1000; const usageStartupDelayMs = 60 * 1000; @@ -70,16 +70,16 @@ export function startBackgroundServices({ env, services }) { } } - function scheduleEnvironmentSync(delayMs) { - clearTimer(environmentTimer); - environmentTimer = setTimeout(async () => { - environmentTimer = null; + function scheduleCatalogSync(delayMs) { + clearTimer(catalogTimer); + catalogTimer = setTimeout(async () => { + catalogTimer = null; try { - await services.catalogSyncService.syncEnvironmentTask({ trigger: "scheduled" }); + await services.catalogSyncService.syncCatalogTask({ trigger: "scheduled" }); } catch (error) { - console.error("Scheduled environment catalog sync failed", error); + console.error("Scheduled catalog sync failed", error); } finally { - scheduleEnvironmentSync(scheduleDelayMs); + scheduleCatalogSync(scheduleDelayMs); } }, delayMs); } @@ -122,14 +122,14 @@ export function startBackgroundServices({ env, services }) { } try { - const result = await services.catalogSyncService.syncStartupEnvironmentTask({ trigger: "startup" }); + const result = await services.catalogSyncService.syncStartupCatalogTask({ trigger: "startup" }); if (result?.status === "success") { scheduleUsageSync(usageStartupDelayMs); } } catch (error) { - console.error("Startup environment catalog startup sync failed", error); + console.error("Startup catalog sync failed", error); } finally { - scheduleEnvironmentSync(scheduleDelayMs); + scheduleCatalogSync(scheduleDelayMs); } }) .catch((error) => { @@ -139,8 +139,8 @@ export function startBackgroundServices({ env, services }) { return { async stop() { - clearTimer(environmentTimer); - environmentTimer = null; + clearTimer(catalogTimer); + catalogTimer = null; clearTimer(usageTimer); usageTimer = null; diff --git a/apps/management-website/server/src/services/catalog-sync-service.js b/apps/management-website/server/src/services/catalog-sync-service.js index 5acf5ad..f7ea85e 100644 --- a/apps/management-website/server/src/services/catalog-sync-service.js +++ b/apps/management-website/server/src/services/catalog-sync-service.js @@ -21,7 +21,7 @@ const STARTUP_SYNC_STATE_TABLES = [ "system_sync_state" ]; -const STARTUP_ENVIRONMENT_SYNC_STATES = [ +const CATALOG_SYNC_STATES = [ { tableName: "llm_sync_state" }, @@ -38,6 +38,7 @@ const STARTUP_ENVIRONMENT_SYNC_STATES = [ tableName: "system_sync_state" } ]; +const CATALOG_SNAPSHOT_VERSION = 1; function hasUsageFields(payload) { if (!payload || typeof payload !== "object") { @@ -72,6 +73,7 @@ function firstText(...values) { function isBuiltInItem(item) { return [ + item?.builtIn, item?.["is-built-in"], item?.is_builtin, item?.isBuiltIn, @@ -115,8 +117,26 @@ function normalizeRemoteModelRef(value) { return splitModelRef(firstText(value.primary)); } -function listItems(payload) { - return Array.isArray(payload?.items) ? payload.items : []; +function listRpcItems(payload, label) { + if (!payload || !Array.isArray(payload.items)) { + throw new Error(`${label}.items must be an array`); + } + return payload.items; +} + +function readCatalogItems(snapshot, kind) { + if (!snapshot || typeof snapshot !== "object") { + throw new Error("catalog.snapshot returned an empty payload"); + } + if (snapshot.version !== CATALOG_SNAPSHOT_VERSION) { + throw new Error(`catalog.snapshot version must be ${CATALOG_SNAPSHOT_VERSION}`); + } + + const catalog = snapshot.catalogs?.[kind]; + if (!catalog || catalog.kind !== kind || !Array.isArray(catalog.items)) { + throw new Error(`catalogs.${kind}.items must be an array`); + } + return catalog.items; } function normalizeUsageItemPayload(item) { @@ -160,7 +180,7 @@ export class CatalogSyncService { this.pendingSkillSync = null; this.pendingTemplateSync = null; this.pendingSystemSync = null; - this.pendingStartupEnvironmentSync = null; + this.pendingCatalogSnapshotSync = null; this.pendingCatalogSyncPlan = null; } @@ -206,8 +226,8 @@ export class CatalogSyncService { return this.rpcClient.call(action, params, timeoutMs); } - async syncStartupEnvironmentTask({ trigger = "startup" } = {}) { - return await this.syncEnvironmentTask({ trigger }); + async syncStartupCatalogTask({ trigger = "startup" } = {}) { + return await this.syncCatalogTask({ trigger }); } async runManualSyncPlan({ trigger = "manual" } = {}) { @@ -216,10 +236,10 @@ export class CatalogSyncService { } this.pendingCatalogSyncPlan = (async () => { - const environment = await this.syncEnvironmentTask({ trigger }); + const catalog = await this.syncCatalogTask({ trigger }); const usage = await this.refreshAgentUsage({ trigger }); return { - environment, + catalog, usage }; })().finally(() => { @@ -229,19 +249,19 @@ export class CatalogSyncService { return await this.pendingCatalogSyncPlan; } - async syncEnvironmentTask({ trigger = "manual" } = {}) { - if (this.pendingStartupEnvironmentSync) { - return await this.pendingStartupEnvironmentSync; + async syncCatalogTask({ trigger = "manual" } = {}) { + if (this.pendingCatalogSnapshotSync) { + return await this.pendingCatalogSnapshotSync; } - this.pendingStartupEnvironmentSync = this.runStartupEnvironmentSync(trigger).finally(() => { - this.pendingStartupEnvironmentSync = null; + this.pendingCatalogSnapshotSync = this.runCatalogSnapshotSync(trigger).finally(() => { + this.pendingCatalogSnapshotSync = null; }); - return await this.pendingStartupEnvironmentSync; + return await this.pendingCatalogSnapshotSync; } - async runStartupEnvironmentSync(trigger) { + async runCatalogSnapshotSync(trigger) { const startedAt = new Date().toISOString(); if (!this.rpcClient.isConfigured()) { @@ -278,14 +298,14 @@ export class CatalogSyncService { } try { - const remote = await this.callManagement("environment.info"); + const remote = await this.callManagement("catalog.snapshot"); const now = new Date().toISOString(); - const llmItems = listItems(remote?.llmProviders); - const templateItems = listItems(remote?.templates); - const agentItems = listItems(remote?.agents); - const skillItems = listItems(remote?.skills); - const systemItems = listItems(remote?.systems); - const ontologyItems = listItems(remote?.ontologies); + const llmItems = readCatalogItems(remote, "llmProviders"); + const templateItems = readCatalogItems(remote, "templates"); + const agentItems = readCatalogItems(remote, "agents"); + const skillItems = readCatalogItems(remote, "skills"); + const systemItems = readCatalogItems(remote, "systems"); + const ontologyItems = readCatalogItems(remote, "ontologies"); const summaries = { llm_sync_state: { models: countLargeLanguageModels(llmItems) @@ -312,7 +332,7 @@ export class CatalogSyncService { this.syncSystems(systemItems, now, builtInOntologyNames(ontologyItems)); const finishedAt = new Date().toISOString(); - for (const item of STARTUP_ENVIRONMENT_SYNC_STATES) { + for (const item of CATALOG_SYNC_STATES) { this.updateState(item.tableName, { status: "success", triggerSource: trigger, @@ -364,9 +384,9 @@ export class CatalogSyncService { } async fetchAgentUsageSnapshots(agentItems) { - const agentIds = agentItems.map((item) => String(item?.agentId || "").trim()).filter(Boolean); + const agentIds = agentItems.map((item) => String(item?.id || "").trim()).filter(Boolean); const remote = await this.callManagement("agent.usage.list", { agentIds }); - const pairs = listItems(remote) + const pairs = listRpcItems(remote, "agent.usage.list") .map((item) => normalizeUsageItemPayload(item)) .filter(Boolean) .map((snapshot) => [snapshot.agentId, { @@ -512,9 +532,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM agents WHERE id = ?"); for (const item of items) { - const slug = String(item?.agentId || "").trim(); + const slug = String(item?.id || "").trim(); if (!slug) { - continue; + throw new Error("catalogs.agents.items[].id is required"); } seen.add(slug); @@ -529,8 +549,8 @@ export class CatalogSyncService { const remoteStateJson = JSON.stringify(item ?? {}); const agentName = firstText(item?.name, slug); const status = normalizeAgentStatus(item?.status); - const templateName = firstText(item?.templateName, existing?.template_name, DEFAULT_TEMPLATE_NAME); - const model = normalizeRemoteModelRef(item?.model); + const templateName = firstText(item?.templateId, existing?.template_name, DEFAULT_TEMPLATE_NAME); + const model = normalizeRemoteModelRef(item?.modelRef); if (existing) { const nextStatus = existing.status === "disabled" @@ -613,18 +633,18 @@ export class CatalogSyncService { const removeModel = this.db.prepare("DELETE FROM llm_models WHERE model_ref = ?"); for (const provider of items) { - const providerId = firstText(provider?.providerId, provider?.provider_id); + const providerId = firstText(provider?.id); if (!providerId) { - continue; + throw new Error("catalogs.llmProviders.items[].id is required"); } seenProviders.add(providerId); insertProvider.run( providerId, - firstText(provider?.baseUrl, provider?.base_url), - firstText(provider?.apiKeyMask, provider?.api_key_mask), - firstText(provider?.apiKeyFingerprint, provider?.api_key_fingerprint), - firstText(provider?.api, "openai-completions"), + firstText(provider?.baseUrl), + firstText(provider?.secret?.mask), + firstText(provider?.secret?.fingerprint), + firstText(provider?.api, "openaiCompletions") === "openaiCompletions" ? "openai-completions" : firstText(provider?.api), JSON.stringify(provider ?? {}), now, now @@ -632,10 +652,10 @@ export class CatalogSyncService { const models = Array.isArray(provider?.models) ? provider.models : []; for (const model of models) { - const modelId = firstText(model?.modelId, model?.model_id); - const modelRef = firstText(model?.modelRef, model?.model_ref, buildModelRef(providerId, modelId)); + const modelId = firstText(model?.id); + const modelRef = firstText(model?.ref, buildModelRef(providerId, modelId)); if (!modelId || !modelRef) { - continue; + throw new Error("catalogs.llmProviders.items[].models[].id is required"); } seenModels.add(modelRef); @@ -644,8 +664,8 @@ export class CatalogSyncService { providerId, modelId, firstText(model?.name, modelId), - Number.isFinite(Number(model?.contextTokens ?? model?.context_tokens)) ? Number(model?.contextTokens ?? model?.context_tokens) : null, - Number.isFinite(Number(model?.maxTokens ?? model?.max_tokens)) ? Number(model?.maxTokens ?? model?.max_tokens) : null, + Number.isFinite(Number(model?.limits?.contextTokens)) ? Number(model?.limits?.contextTokens) : null, + Number.isFinite(Number(model?.limits?.maxOutputTokens)) ? Number(model?.limits?.maxOutputTokens) : null, JSON.stringify(model ?? {}), now, now @@ -683,9 +703,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM agent_templates WHERE id = ?"); for (const item of items) { - const templateName = String(item?.templateName || "").trim(); + const templateName = String(item?.id || "").trim(); if (!templateName) { - continue; + throw new Error("catalogs.templates.items[].id is required"); } seen.add(templateName); @@ -753,9 +773,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM skills WHERE id = ?"); for (const item of items) { - const slug = String(item?.slug || "").trim(); + const slug = String(item?.id || "").trim(); if (!slug) { - continue; + throw new Error("catalogs.skills.items[].id is required"); } seen.add(slug); @@ -805,9 +825,9 @@ export class CatalogSyncService { const remove = this.db.prepare("DELETE FROM business_systems WHERE id = ?"); for (const item of items) { - const applicationName = firstText(item?.applicationName, item?.id, item?.name).toLowerCase(); + const applicationName = firstText(item?.id, item?.name).toLowerCase(); if (!applicationName) { - continue; + throw new Error("catalogs.systems.items[].id is required"); } seen.add(applicationName); diff --git a/apps/management-website/server/test/background-services.test.js b/apps/management-website/server/test/background-services.test.js index 9e98447..d3b7643 100644 --- a/apps/management-website/server/test/background-services.test.js +++ b/apps/management-website/server/test/background-services.test.js @@ -62,15 +62,15 @@ function createServices({ } }, catalogSyncService: { - syncStartupEnvironmentTask: taskFactory("sync-startup-environment"), - syncEnvironmentTask: taskFactory("sync-environment"), + syncStartupCatalogTask: taskFactory("sync-startup-catalog"), + syncCatalogTask: taskFactory("sync-catalog"), refreshAgentUsage: taskFactory("usage"), markStartupSyncFailed } }; } -it("schedules environment and token sync from completion times after startup environment sync succeeds", async () => { +it("schedules catalog and token sync from completion times after startup catalog sync succeeds", async () => { const timers = installFakeTimers(); const calls = []; const taskFactory = (label) => async ({ trigger }) => { @@ -97,7 +97,7 @@ it("schedules environment and token sync from completion times after startup env expect(calls).toEqual([ "rpc.start", - "sync-startup-environment:startup" + "sync-startup-catalog:startup" ]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ 60 * 1000, @@ -111,9 +111,9 @@ it("schedules environment and token sync from completion times after startup env 2 * 60 * 1000 ]); - const environmentTimer = timers.activeTimeouts()[0]; - await timers.runTimer(environmentTimer); - expect(calls.filter((entry) => entry === "sync-environment:scheduled").length).toBe(1); + const catalogTimer = timers.activeTimeouts()[0]; + await timers.runTimer(catalogTimer); + expect(calls.filter((entry) => entry === "sync-catalog:scheduled").length).toBe(1); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ 2 * 60 * 1000, 2 * 60 * 1000 @@ -124,14 +124,14 @@ it("schedules environment and token sync from completion times after startup env } }); -it("does not schedule token sync when startup environment sync fails", async () => { +it("does not schedule token sync when startup catalog sync fails", async () => { spyOn(console, "error").and.stub(); const timers = installFakeTimers(); const calls = []; const taskFactory = (label) => async ({ trigger }) => { calls.push(`${label}:${trigger}`); - if (label === "sync-startup-environment" && trigger === "startup") { + if (label === "sync-startup-catalog" && trigger === "startup") { return { status: "failed" }; } return { status: "success" }; @@ -154,7 +154,7 @@ it("does not schedule token sync when startup environment sync fails", async () await flushMicrotasks(); expect(calls).toEqual([ "rpc.start", - "sync-startup-environment:startup" + "sync-startup-catalog:startup" ]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ 2 * 60 * 1000 @@ -202,7 +202,7 @@ it("waits for a management RPC ping before startup sync", async () => { await flushMicrotasks(); expect(calls.filter((entry) => entry.endsWith(":startup"))).toEqual([ - "sync-startup-environment:startup" + "sync-startup-catalog:startup" ]); } finally { await background.stop(); @@ -261,7 +261,7 @@ it("retries management RPC readiness before startup sync failure is marked", asy "rpc.start", "rpc.call:service.ping:1", "rpc.call:service.ping:2", - "sync-startup-environment:startup" + "sync-startup-catalog:startup" ]); expect(startupFailures).toEqual([]); expect(timers.activeTimeouts().map((timer) => timer.delay)).toEqual([ diff --git a/apps/management-website/server/test/environment-sync-route.test.js b/apps/management-website/server/test/catalog-sync-route.test.js similarity index 93% rename from apps/management-website/server/test/environment-sync-route.test.js rename to apps/management-website/server/test/catalog-sync-route.test.js index dfa4e5b..f418804 100644 --- a/apps/management-website/server/test/environment-sync-route.test.js +++ b/apps/management-website/server/test/catalog-sync-route.test.js @@ -84,7 +84,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => async runManualSyncPlan({ trigger }) { calls.push({ trigger }); return { - environment: { + catalog: { status: "success", trigger_source: trigger }, @@ -109,7 +109,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => try { const address = server.address(); const response = await requestJson( - `http://127.0.0.1:${address.port}/api/settings/environment-sync`, + `http://127.0.0.1:${address.port}/api/settings/catalog-sync`, { method: "POST", body: {} @@ -118,7 +118,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => expect(response.status).toBe(200); expect(calls).toEqual([{ trigger: "manual" }]); - expect(response.body.result.environment.status).toBe("success"); + expect(response.body.result.catalog.status).toBe("success"); expect(response.body.result.usage.status).toBe("success"); expect(response.body.agent_sync).toEqual(states.agent_sync); expect(response.body.usage_refresh).toEqual(states.usage_refresh); @@ -126,7 +126,7 @@ it("runs the manual sync plan and returns all catalog sync states", async () => userId: 1, username: "aios", action: "同步数据", - detail: "手动触发 environment.info 与 agent.usage.list 数据同步" + detail: "手动触发 catalog.snapshot 与 agent.usage.list 数据同步" }]); } finally { await close(server); diff --git a/apps/management-website/server/test/catalog-sync-service.test.js b/apps/management-website/server/test/catalog-sync-service.test.js index 81766dd..a919e3c 100644 --- a/apps/management-website/server/test/catalog-sync-service.test.js +++ b/apps/management-website/server/test/catalog-sync-service.test.js @@ -4,6 +4,38 @@ import { CatalogSyncService } from "../src/services/catalog-sync-service.js"; const TEST_NOW = "2026-05-26T00:00:00.000Z"; +function catalog(kind, items) { + return { + kind, + items, + summary: { + count: items.length + } + }; +} + +function catalogSnapshot({ + llmProviders = [], + agents = [], + templates = [], + skills = [], + systems = [], + ontologies = [] +} = {}) { + return { + version: 1, + generatedAt: TEST_NOW, + catalogs: { + llmProviders: catalog("llmProviders", llmProviders), + agents: catalog("agents", agents), + templates: catalog("templates", templates), + skills: catalog("skills", skills), + systems: catalog("systems", systems), + ontologies: catalog("ontologies", ontologies) + } + }; +} + function insertAgent(db, { slug, agentName = slug, @@ -274,7 +306,7 @@ function createDb() { return db; } -it("syncs startup environment catalog with one management rpc call", async () => { +it("syncs startup catalog snapshot with one management rpc call", async () => { const db = createDb(); insertAgent(db, { slug: "stale-agent", @@ -384,60 +416,50 @@ it("syncs startup environment catalog with one management rpc call", async () => }, async call(action) { calls.push(action); - if (action === "environment.info") { - return { - llmProviders: { - items: [{ - providerId: "corp-openai", - baseUrl: "https://api.example.com/v1", - apiKeyMask: "sk****test", - models: [{ - modelId: "gpt-4.1-mini", - name: "GPT 4.1 Mini" - }] - }] - }, - templates: { - items: [{ - templateName: "default", - path: "/var/aios/workspace-templates/default", - isDefault: true, - "is-built-in": true - }] - }, - agents: { - items: [{ - agentId: "alpha", - name: "Alpha", - status: "active", - templateName: "default", - model: { - primary: "corp-openai/gpt-4.1-mini" - } - }] - }, - skills: { - items: [{ - slug: "global-skill", - "is-built-in": true, - origin: { - description: "from kernel" - } + if (action === "catalog.snapshot") { + return catalogSnapshot({ + llmProviders: [{ + id: "corp-openai", + api: "openaiCompletions", + baseUrl: "https://api.example.com/v1", + secret: { + mask: "sk****test", + fingerprint: "" + }, + models: [{ + id: "gpt-4.1-mini", + ref: "corp-openai/gpt-4.1-mini", + name: "GPT 4.1 Mini", + limits: {} }] - }, - systems: { - items: [{ - id: "crm", - name: "CRM" - }] - }, - ontologies: { - items: [{ - name: "crm", - "is-built-in": true - }] - } - }; + }], + templates: [{ + id: "default", + path: "/var/aios/workspace-templates/default", + builtIn: true + }], + agents: [{ + id: "alpha", + name: "Alpha", + status: "active", + templateId: "default", + modelRef: "corp-openai/gpt-4.1-mini" + }], + skills: [{ + id: "global-skill", + builtIn: true, + description: "from kernel" + }], + systems: [{ + id: "crm", + name: "CRM" + }], + ontologies: [{ + id: "crm", + name: "crm", + builtIn: true + }] + }); } throw new Error(`Unexpected action: ${action}`); @@ -445,7 +467,7 @@ it("syncs startup environment catalog with one management rpc call", async () => } }); - const result = await service.syncStartupEnvironmentTask({ trigger: "startup" }); + const result = await service.syncStartupCatalogTask({ trigger: "startup" }); expect(result.status).toBe("success"); expect(result.summary).toEqual({ @@ -455,7 +477,7 @@ it("syncs startup environment catalog with one management rpc call", async () => skills: 1, systems: 1 }); - expect(calls).toEqual(["environment.info"]); + expect(calls).toEqual(["catalog.snapshot"]); expect(db.prepare("SELECT provider_id FROM llm_providers ORDER BY provider_id").all()).toEqual([ jasmine.objectContaining({ provider_id: "corp-openai" }) @@ -485,7 +507,7 @@ it("syncs startup environment catalog with one management rpc call", async () => expect(service.getUsageRefreshStatus().summary).toEqual({}); }); -it("keeps local catalog rows when startup environment sync fails", async () => { +it("keeps local catalog rows when startup catalog sync fails", async () => { const db = createDb(); insertAgent(db, { slug: "local-agent", @@ -514,7 +536,7 @@ it("keeps local catalog rows when startup environment sync fails", async () => { return true; }, async call(action) { - if (action === "environment.info") { + if (action === "catalog.snapshot") { throw new Error("kernel unavailable"); } throw new Error(`Unexpected action: ${action}`); @@ -522,7 +544,7 @@ it("keeps local catalog rows when startup environment sync fails", async () => { } }); - const result = await service.syncStartupEnvironmentTask({ trigger: "startup" }); + const result = await service.syncStartupCatalogTask({ trigger: "startup" }); expect(result.status).toBe("failed"); expect(result.error_message).toBe("kernel unavailable"); @@ -542,7 +564,7 @@ it("keeps local catalog rows when startup environment sync fails", async () => { } }); -it("runs the manual sync plan as environment sync followed by token usage sync", async () => { +it("runs the manual sync plan as catalog snapshot sync followed by token usage sync", async () => { const db = createDb(); insertAgent(db, { slug: "alpha", @@ -558,21 +580,14 @@ it("runs the manual sync plan as environment sync followed by token usage sync", }, async call(action) { calls.push(action); - if (action === "environment.info") { - return { - llmProviders: { items: [] }, - templates: { items: [] }, - agents: { - items: [{ - agentId: "alpha", - name: "Alpha", - status: "active" - }] - }, - skills: { items: [] }, - systems: { items: [] }, - ontologies: { items: [] } - }; + if (action === "catalog.snapshot") { + return catalogSnapshot({ + agents: [{ + id: "alpha", + name: "Alpha", + status: "active" + }] + }); } if (action === "agent.usage.list") { return { @@ -591,10 +606,10 @@ it("runs the manual sync plan as environment sync followed by token usage sync", const result = await service.runManualSyncPlan({ trigger: "manual" }); - expect(result.environment.status).toBe("success"); + expect(result.catalog.status).toBe("success"); expect(result.usage.status).toBe("success"); expect(calls).toEqual([ - "environment.info", + "catalog.snapshot", "agent.usage.list" ]); expect(service.getUsageRefreshStatus().summary.refreshed_agents).toBe(1); @@ -803,7 +818,7 @@ it("records skipped status when rpc is not configured", async () => { } }); - const status = await service.syncEnvironmentTask({ trigger: "startup" }); + const status = await service.syncCatalogTask({ trigger: "startup" }); expect(status.status).toBe("skipped"); expect(status.trigger_source).toBe("startup"); diff --git a/apps/management-website/server/test/management-rpc-client.test.js b/apps/management-website/server/test/management-rpc-client.test.js index 99c27b9..3366a51 100644 --- a/apps/management-website/server/test/management-rpc-client.test.js +++ b/apps/management-website/server/test/management-rpc-client.test.js @@ -18,7 +18,7 @@ it("publishes a request and resolves the matching response", async () => { await rpcClient.start(); const client = mqttFactory.clients[0]; - const pending = rpcClient.call("environment.info", {}, 1000); + const pending = rpcClient.call("catalog.snapshot", {}, 1000); const published = client.publishes[0]; const request = JSON.parse(published.payload); @@ -29,13 +29,31 @@ it("publishes a request and resolves the matching response", async () => { JSON.stringify({ requestId: request.requestId, ok: true, - result: { agents: { items: [{ agentId: "alpha" }] } } + result: { + version: 1, + catalogs: { + agents: { + kind: "agents", + items: [{ id: "alpha" }], + summary: { count: 1 } + } + } + } }) ) ); await expectAsync(pending).toBeResolved(); - expect(await pending).toEqual({ agents: { items: [{ agentId: "alpha" }] } }); + expect(await pending).toEqual({ + version: 1, + catalogs: { + agents: { + kind: "agents", + items: [{ id: "alpha" }], + summary: { count: 1 } + } + } + }); expect(db.statements.some((item) => item.sql.includes("INSERT INTO management_requests"))).toBeTrue(); expect(db.statements.some((item) => item.sql.includes("UPDATE management_requests"))).toBeTrue(); @@ -122,13 +140,13 @@ it("uses management timeout from env by default", async () => { await rpcClient.start(); try { - await rpcClient.call("environment.info", {}); + await rpcClient.call("catalog.snapshot", {}); fail("Expected call to time out"); } catch (error) { expect(error.message).toContain("超时"); expect(error.details).toEqual(jasmine.objectContaining({ timeout: true, - action: "environment.info" + action: "catalog.snapshot" })); } diff --git a/apps/management-website/server/test/portal-service-management-logs.test.js b/apps/management-website/server/test/portal-service-management-logs.test.js index 0e4b70c..a7b246e 100644 --- a/apps/management-website/server/test/portal-service-management-logs.test.js +++ b/apps/management-website/server/test/portal-service-management-logs.test.js @@ -47,7 +47,7 @@ it("lists kernel management requests with parsed payloads and response time", () { id: 1, request_id: "req-1", - action: "environment.info", + action: "catalog.snapshot", params_json: "{}", ok: 0, result_json: null, diff --git a/apps/management-website/src/app/App.jsx b/apps/management-website/src/app/App.jsx index 1d52c03..a53d014 100644 --- a/apps/management-website/src/app/App.jsx +++ b/apps/management-website/src/app/App.jsx @@ -263,8 +263,8 @@ export default function App() { })); messageApi.success("访问令牌已删除"); }} - onEnvironmentSync={async () => { - const next = await api.post("/api/settings/environment-sync", {}); + onCatalogSync={async () => { + const next = await api.post("/api/settings/catalog-sync", {}); setBoot((current) => ({ ...current, agent_sync: next.agent_sync, diff --git a/apps/management-website/src/pages/SettingsPage.jsx b/apps/management-website/src/pages/SettingsPage.jsx index efc456c..0983659 100644 --- a/apps/management-website/src/pages/SettingsPage.jsx +++ b/apps/management-website/src/pages/SettingsPage.jsx @@ -270,7 +270,7 @@ function AccessTokenValue({ value, notify }) { ); } -function buildEnvironmentSyncRows({ +function buildCatalogSyncRows({ llmSyncStatus, templateSyncStatus, agentSyncStatus, @@ -314,7 +314,7 @@ function buildEnvironmentSyncRows({ { key: "action", title: "同步命令", - value: "environment.list" + value: "catalog.snapshot" }, { key: "status", @@ -381,13 +381,13 @@ export function SettingsPage({ onClearLogo, onCreateAccessToken, onDeleteAccessToken, - onEnvironmentSync, + onCatalogSync, onServerRestart, onExecuteCommand }) { const [form] = Form.useForm(); const [commandForm] = Form.useForm(); - const [environmentSyncing, setEnvironmentSyncing] = useState(false); + const [catalogSyncing, setCatalogSyncing] = useState(false); const [creatingToken, setCreatingToken] = useState(false); const [deletingToken, setDeletingToken] = useState(""); const [commandOpen, setCommandOpen] = useState(false); @@ -498,35 +498,35 @@ export function SettingsPage({ } }; - const handleEnvironmentSync = async () => { - setEnvironmentSyncing(true); + const handleCatalogSync = async () => { + setCatalogSyncing(true); try { const next = await runWithOperationMask("正在同步数据,请稍候...", async () => ( - await onEnvironmentSync() + await onCatalogSync() )); - const environmentStatus = next?.result?.environment?.status; + const catalogStatus = next?.result?.catalog?.status; const usageStatus = next?.result?.usage?.status; - if (environmentStatus === "success" && usageStatus === "success") { + if (catalogStatus === "success" && usageStatus === "success") { notify?.success("数据同步完成"); return; } - if (environmentStatus === "skipped" || usageStatus === "skipped") { + if (catalogStatus === "skipped" || usageStatus === "skipped") { notify?.warning( - next?.result?.environment?.error_message + next?.result?.catalog?.error_message || next?.result?.usage?.error_message || "数据同步已跳过" ); return; } notify?.error( - next?.result?.environment?.error_message + next?.result?.catalog?.error_message || next?.result?.usage?.error_message || "数据同步失败" ); } catch (error) { notify?.error(`数据同步失败:${getErrorMessage(error, "同步失败")}`); } finally { - setEnvironmentSyncing(false); + setCatalogSyncing(false); } }; @@ -599,7 +599,7 @@ export function SettingsPage({ } }; - const environmentSyncRows = buildEnvironmentSyncRows({ + const catalogSyncRows = buildCatalogSyncRows({ llmSyncStatus, templateSyncStatus, agentSyncStatus, @@ -793,9 +793,9 @@ export function SettingsPage({