From 69e76912874e5cc3e6c77311369202433f54b6e4 Mon Sep 17 00:00:00 2001 From: Haitao Pan Date: Tue, 26 May 2026 12:58:56 +0800 Subject: [PATCH] chore: align AI agent runtime playbooks --- deploy_QMD.yml | 9 + deploy_agent_hermes.yml | 9 + deploy_agent_skills.yml | 9 - deploy_xworkmate_bridge_vhosts.yml | 14 +- docs/yitu-it-series-r2-assets.md | 205 +++++++++++++++ examples/yitu-it-series-r2.env.example | 15 ++ host_vars/cn-xworkmate-bridge.svc.plus.yml | 14 + inventory.ini | 4 + roles/agent_skills/README.md | 38 ++- roles/agent_skills/defaults/main.yml | 15 +- roles/agent_skills/tasks/main.yml | 134 ++++++++-- roles/ai_agent_runtime/README.md | 17 +- roles/ai_agent_runtime/defaults/main.yml | 6 +- roles/ai_agent_runtime/tasks/browser.yml | 63 ++++- roles/ai_agent_runtime/tasks/main.yml | 3 + roles/ai_agent_runtime/tasks/verify.yml | 8 + .../vhosts/acp_server_codex/tasks/config.yml | 2 + .../vhosts/acp_server_hermes/tasks/config.yml | 3 + .../vhosts/gateway_openclaw/defaults/main.yml | 84 +++++- .../templates/openclaw.json.j2 | 12 +- roles/vhosts/nodejs/tasks/main.yml | 3 + roles/vhosts/qmd/defaults/main.yml | 26 ++ roles/vhosts/qmd/tasks/main.yml | 139 ++++++++++ roles/vhosts/qmd/templates/index.yml.j2 | 12 + .../qmd/templates/qmd-mcp.user.service.j2 | 18 ++ roles/vhosts/qmd/templates/qmd.env.j2 | 3 + .../xfce_xrdp_minimal/defaults/main.yml | 1 + .../xfce_xrdp_minimal/tasks/browser.yml | 21 +- .../vhosts/xfce_xrdp_minimal/tasks/config.yml | 6 +- .../vhosts/xworkmate_bridge/defaults/main.yml | 1 + .../templates/xworkmate-bridge.service.j2 | 6 +- scripts/sync-yitu-it-series-r2.sh | 243 ++++++++++++++++++ setup-ai-agent-runtime.yml | 13 +- setup-ai-agent-skills.yml | 9 + vars/cloudflare_svc_plus_dns.yml | 1 + xfce_xrdp_minimal.yaml | 12 - 36 files changed, 1086 insertions(+), 92 deletions(-) create mode 100644 deploy_QMD.yml create mode 100644 deploy_agent_hermes.yml delete mode 100644 deploy_agent_skills.yml create mode 100644 docs/yitu-it-series-r2-assets.md create mode 100644 examples/yitu-it-series-r2.env.example create mode 100644 host_vars/cn-xworkmate-bridge.svc.plus.yml create mode 100644 roles/vhosts/qmd/defaults/main.yml create mode 100644 roles/vhosts/qmd/tasks/main.yml create mode 100644 roles/vhosts/qmd/templates/index.yml.j2 create mode 100644 roles/vhosts/qmd/templates/qmd-mcp.user.service.j2 create mode 100644 roles/vhosts/qmd/templates/qmd.env.j2 create mode 100755 scripts/sync-yitu-it-series-r2.sh create mode 100644 setup-ai-agent-skills.yml delete mode 100644 xfce_xrdp_minimal.yaml diff --git a/deploy_QMD.yml b/deploy_QMD.yml new file mode 100644 index 0000000..e93b98a --- /dev/null +++ b/deploy_QMD.yml @@ -0,0 +1,9 @@ +--- +- name: Deploy QMD extended memory + hosts: "{{ qmd_hosts | default('all') }}" + become: true + gather_facts: true + roles: + - role: roles/vhosts/qmd/ + tags: [qmd] + diff --git a/deploy_agent_hermes.yml b/deploy_agent_hermes.yml new file mode 100644 index 0000000..b7751ab --- /dev/null +++ b/deploy_agent_hermes.yml @@ -0,0 +1,9 @@ +--- +- name: Deploy Hermes ACP agent adapter + hosts: "{{ acp_hermes_hosts | default('all') }}" + become: true + gather_facts: true + roles: + - role: roles/vhosts/acp_server_hermes/ + tags: [acp_hermes, hermes] + diff --git a/deploy_agent_skills.yml b/deploy_agent_skills.yml deleted file mode 100644 index 13c2264..0000000 --- a/deploy_agent_skills.yml +++ /dev/null @@ -1,9 +0,0 @@ ---- -- name: Deploy shared agent skills - hosts: all - become: true - gather_facts: true - roles: - - role: roles/agent_skills/ - when: agent_skills_enabled | default(true) | bool - tags: [agent_skills] diff --git a/deploy_xworkmate_bridge_vhosts.yml b/deploy_xworkmate_bridge_vhosts.yml index 1b0172e..ffa7600 100644 --- a/deploy_xworkmate_bridge_vhosts.yml +++ b/deploy_xworkmate_bridge_vhosts.yml @@ -1,8 +1,4 @@ --- -- import_playbook: deploy_agent_skills.yml - vars: - agent_skills_enabled: "{{ xworkmate_bridge_agent_skills_enabled | default(false) }}" - - name: Ensure minimal XFCE XRDP desktop baseline hosts: "{{ xworkmate_bridge_hosts | default('all') }}" become: true @@ -11,9 +7,15 @@ - role: roles/vhosts/xfce_xrdp_minimal/ tags: [xfce, xfce_xrdp_minimal] -- import_playbook: setup-ai-agent-runtime.yml +- import_playbook: setup-ai-agent-skills.yml vars: - ai_agent_runtime_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" + ai_agent_runtime_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) or xworkmate_bridge_agent_skills_enabled | default(false) }}" + ai_agent_runtime_nodejs_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" + ai_agent_runtime_python_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" + ai_agent_runtime_browser_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" + ai_agent_runtime_docs_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" + ai_agent_runtime_fonts_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" + ai_agent_runtime_verify_enabled: "{{ xworkmate_bridge_ai_agent_runtime_enabled | default(false) }}" ai_agent_runtime_skills_enabled: "{{ xworkmate_bridge_agent_skills_enabled | default(false) }}" - name: Deploy ACP vhosts through xworkmate bridge diff --git a/docs/yitu-it-series-r2-assets.md b/docs/yitu-it-series-r2-assets.md new file mode 100644 index 0000000..fba933d --- /dev/null +++ b/docs/yitu-it-series-r2-assets.md @@ -0,0 +1,205 @@ +# yitu-it-series R2 assets + +This runbook migrates the local Google Drive `自媒体` directory to Cloudflare R2 for the Docusaurus AI Native knowledge base. + +## Architecture + +```text +GitHub -> Docusaurus -> Cloudflare Pages -> ebook.svc.plus + +Google Drive local folder + -> rclone + -> Cloudflare R2 bucket: yitu-it-series + -> R2 custom domain: img.svc.plus + -> Docusaurus Markdown image URLs +``` + +## Source and target + +```text +Local source: +/Users/shenlan/Library/CloudStorage/GoogleDrive-haitaopanhq@gmail.com/我的云端硬盘/自媒体 + +R2 bucket: +yitu-it-series + +Public asset domain: +https://img.svc.plus +``` + +## Recommended object layout + +```text +yitu-it-series/ +├── covers/ +├── xiaohongshu/ +├── observability/ +├── storage/ +├── networking/ +├── ai-native/ +├── security/ +├── platform-engineering/ +└── ebook-assets/ +``` + +Use stable, semantic paths for published content: + +```text +covers/season-1/single-machine-to-platform-cover-v1.png +security/least-privilege/root-to-rootless-v1.png +ai-native/agentic-infra/ai-native-platform-v1.png +ebook-assets/diagrams/cloud-native-to-ai-native-v1.png +``` + +Prefer versioned object names instead of overwriting an already published image. This keeps Cloudflare CDN behavior predictable and preserves old articles. + +## Cloudflare API token + +Create two token scopes if possible: + +```text +Bootstrap token: +- Account: Cloudflare R2: Edit +- Zone: DNS: Edit, Zone: Read for svc.plus +- Used only for bucket/custom-domain setup + +Long-running R2 S3 token: +- R2 Object Read & Write +- Scope limited to bucket yitu-it-series +- Used by rclone sync +``` + +Required environment variables: + +```bash +export CF_ACCOUNT_ID="..." +export CF_ZONE_ID="..." +export CLOUDFLARE_API_TOKEN="..." +export R2_ACCESS_KEY_ID="..." +export R2_SECRET_ACCESS_KEY="..." +``` + +## Commands + +From the playbooks directory: + +```bash +cd /Users/shenlan/workspaces/cloud-neutral-toolkit/playbooks +chmod +x scripts/sync-yitu-it-series-r2.sh + +scripts/sync-yitu-it-series-r2.sh doctor +scripts/sync-yitu-it-series-r2.sh create-bucket +scripts/sync-yitu-it-series-r2.sh configure-rclone +scripts/sync-yitu-it-series-r2.sh dry-run +scripts/sync-yitu-it-series-r2.sh copy +scripts/sync-yitu-it-series-r2.sh check +scripts/sync-yitu-it-series-r2.sh tree +scripts/sync-yitu-it-series-r2.sh configure-custom-domain +``` + +Use `copy` for the first production migration when preserving all historical remote files matters. Use `sync` for steady-state mirroring after the source layout is stable. + +## Performance profile + +Default large AI image profile: + +```bash +export RCLONE_TRANSFERS=16 +export RCLONE_CHECKERS=32 +export RCLONE_S3_UPLOAD_CUTOFF=128M +export RCLONE_S3_CHUNK_SIZE=128M +``` + +Many small images: + +```bash +export RCLONE_TRANSFERS=32 +export RCLONE_CHECKERS=64 +``` + +Large source files such as PSD/video: + +```bash +export RCLONE_TRANSFERS=4 +export RCLONE_CHECKERS=16 +export RCLONE_S3_UPLOAD_CUTOFF=256M +export RCLONE_S3_CHUNK_SIZE=256M +``` + +## Incremental sync + +Install a macOS launchd sync job: + +```bash +cd /Users/shenlan/workspaces/cloud-neutral-toolkit/playbooks +scripts/sync-yitu-it-series-r2.sh install-launchd +launchctl list | grep yitu-it-series +``` + +Remove it: + +```bash +scripts/sync-yitu-it-series-r2.sh uninstall-launchd +``` + +## R2 custom domain + +Target: + +```text +img.svc.plus -> R2 bucket yitu-it-series +``` + +The script calls the Cloudflare R2 custom domain API: + +```bash +scripts/sync-yitu-it-series-r2.sh configure-custom-domain +``` + +Recommended Cloudflare cache rule: + +```text +If hostname equals img.svc.plus: +- Cache eligible +- Edge TTL: 30 days or longer +- Browser TTL: 7-30 days, or respect origin +``` + +## Docusaurus references + +Markdown: + +```md +![AI Native 基础设施演进](https://img.svc.plus/ai-native/ai-native-infra-cover-v1.png) +![最小权限演进](https://img.svc.plus/security/least-privilege-cover-v1.png) +``` + +MDX: + +```mdx +Platform Engineering Roadmap +``` + +Front matter: + +```md +--- +title: AI Native 基础设施演进 +description: 从云原生到 AI Native 的平台工程知识库 +image: https://img.svc.plus/covers/ai-native-infra-cover-v1.png +--- +``` + +## AI Native knowledge-base practices + +- Keep Docusaurus focused on Markdown, MDX, navigation, SEO, and search. +- Keep heavy generated images and ebook assets in R2. +- Reference published assets with absolute `https://img.svc.plus/...` URLs. +- Keep object names immutable after publication; publish revisions with `-v2`, `-v3`. +- Run `rclone check` before replacing local Markdown image references. +- Keep raw generation artifacts separate from article-ready assets when possible. +- Use topic directories that match the ebook taxonomy so future RAG/vector indexing can attach image context to chapters. diff --git a/examples/yitu-it-series-r2.env.example b/examples/yitu-it-series-r2.env.example new file mode 100644 index 0000000..dcb08f3 --- /dev/null +++ b/examples/yitu-it-series-r2.env.example @@ -0,0 +1,15 @@ +CF_ACCOUNT_ID= +CF_ZONE_ID= +CLOUDFLARE_API_TOKEN= +R2_ACCESS_KEY_ID= +R2_SECRET_ACCESS_KEY= + +R2_BUCKET=yitu-it-series +R2_REMOTE=cloudflare-r2 +R2_CUSTOM_DOMAIN=img.svc.plus +LOCAL_SRC=/Users/shenlan/Library/CloudStorage/GoogleDrive-haitaopanhq@gmail.com/我的云端硬盘/自媒体 + +RCLONE_TRANSFERS=16 +RCLONE_CHECKERS=32 +RCLONE_S3_UPLOAD_CUTOFF=128M +RCLONE_S3_CHUNK_SIZE=128M diff --git a/host_vars/cn-xworkmate-bridge.svc.plus.yml b/host_vars/cn-xworkmate-bridge.svc.plus.yml new file mode 100644 index 0000000..4079986 --- /dev/null +++ b/host_vars/cn-xworkmate-bridge.svc.plus.yml @@ -0,0 +1,14 @@ +--- +xworkmate_bridge_domain: cn-xworkmate-bridge.svc.plus +xworkmate_bridge_public_base_url: https://cn-xworkmate-bridge.svc.plus +xworkmate_bridge_service_domain: cn-xworkmate-bridge.svc.plus +xworkmate_bridge_service_public_base_url: https://cn-xworkmate-bridge.svc.plus +xworkmate_bridge_binary_path: /usr/local/bin/xworkmate-bridge +xworkmate_bridge_service_user: root +xworkmate_bridge_service_group: root +xworkmate_bridge_service_home: /root +xworkmate_bridge_required_services: [] +xworkmate_bridge_required_listeners: + - host: 127.0.0.1 + port: "8787" + name: bridge diff --git a/inventory.ini b/inventory.ini index 68b4b90..c6aee1b 100644 --- a/inventory.ini +++ b/inventory.ini @@ -7,6 +7,10 @@ cn-front.svc.plus ansible_host=47.120.61.35 ansible_user=root ansible_ssh_user=r # services: cn-homepage.svc.plus cn-homepage.svc.plus ansible_host=47.120.61.35 ansible_user=root ansible_ssh_user=root +[cn_xworkmate_bridge_host] +# services: cn-xworkmate-bridge.svc.plus +cn-xworkmate-bridge.svc.plus ansible_host=47.120.61.35 ansible_user=root ansible_ssh_user=root service_domains=cn-xworkmate-bridge.svc.plus + [global_homepage_host] # services: global-homepage.svc.plus global-homepage.svc.plus ansible_host=46.250.251.132 ansible_user=root ansible_ssh_user=root diff --git a/roles/agent_skills/README.md b/roles/agent_skills/README.md index eab3406..e678970 100644 --- a/roles/agent_skills/README.md +++ b/roles/agent_skills/README.md @@ -1,18 +1,31 @@ # Agent Skills -Synchronizes the controller user's `~/.agents/skills/` directory to an Ubuntu -runtime user's canonical skills directory, then exposes the same directory to -agent-specific skill locations. +Synchronizes controller skill sources to an Ubuntu runtime user's canonical +skills directory, then exposes the same directory to agent-specific skill +locations. Default source and target: -- local source: `~/.agents/skills/` +- local marketplace source: `~/.agents/skills/` +- local repository source: `../xworkspace-core-skills/skills/` - remote canonical path: `/home/ubuntu/.agents/skills/` - default agent targets: `codex`, `gemini`, `opencode`, `hermers`, `openclaw` +The repository source is categorized by capability domain, for example +`video-production/`, `image-production/`, `animation/`, and `workspace-core/`. +The role syncs those categories as-is, then creates root-level symlinks for +nested skills so runtimes that scan one directory level can still discover them. +Set `agent_skills_xworkspace_core_enabled=false` to use only the marketplace +source, or `agent_skills_remote_flatten_nested_skills=false` to disable root +symlink materialization. + The role keeps one remote source of truth and links each agent's skills entry to -that canonical directory. Existing non-symlink target directories are rejected by -default to avoid silently deleting agent-owned content. Set +that canonical directory where the online runtime already uses links. Existing +non-symlink target directories are rejected by default to avoid silently deleting +agent-owned content. The live `ubuntu` Codex runtime on +`xworkmate-bridge.svc.plus` keeps `/home/ubuntu/.codex/skills` as a real +directory, so it is preserved by default through +`agent_skills_preserve_existing_target_dirs`. Set `agent_skills_replace_existing_target_dirs=true` only when those target directories should be replaced. @@ -42,6 +55,11 @@ already present locally. Set skills when neither installer is available; the role still fails later if a required skill cannot be resolved. +Required-skill checks search both the marketplace source and the categorized +repository source recursively. Auto-install still writes only to +`~/.agents/skills/`; repository-owned skills should be changed in +`xworkspace-core-skills`. + After install, optional local quality gates run for each resolved skill when the command exists: @@ -58,16 +76,20 @@ Default sync excludes local runtime artifacts such as `.venv/`, `__pycache__/`, `.pyc`, and `.DS_Store`; skills should ship source, scripts, templates, and references rather than controller-local virtual environments. +The sync defaults to overlay mode (`agent_skills_delete_removed=false`) so it +does not remove skills that already exist on the live runtime catalog. Enable +deletion only for controlled rebuilds of `/home/ubuntu/.agents/skills/`. + Example: ```bash -ansible-playbook -i inventory.ini -l jp-xhttp-contabo.svc.plus deploy_agent_skills.yml +ansible-playbook -i inventory.ini -l jp-xhttp-contabo.svc.plus setup-ai-agent-skills.yml --tags agent_skills ``` Bootstrap-only example that keeps the existing local source strict but skips quality gate failures from newly installed marketplace skills: ```bash -ansible-playbook -i inventory.ini -l jp-xhttp-contabo.svc.plus deploy_agent_skills.yml \ +ansible-playbook -i inventory.ini -l jp-xhttp-contabo.svc.plus setup-ai-agent-skills.yml --tags agent_skills \ -e agent_skills_quality_gate_fail_on_error=false ``` diff --git a/roles/agent_skills/defaults/main.yml b/roles/agent_skills/defaults/main.yml index 09448b0..bdb4bd9 100644 --- a/roles/agent_skills/defaults/main.yml +++ b/roles/agent_skills/defaults/main.yml @@ -4,13 +4,19 @@ agent_skills_group: "{{ agent_skills_user }}" agent_skills_home: "/home/{{ agent_skills_user }}" agent_skills_local_source_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.agents/skills" +agent_skills_xworkspace_core_enabled: true +agent_skills_xworkspace_core_required: true +agent_skills_xworkspace_core_source_dir: "{{ playbook_dir | dirname }}/xworkspace-core-skills/skills" agent_skills_remote_dir: "{{ agent_skills_home }}/.agents/skills" agent_skills_local_source_create: true -agent_skills_delete_removed: true +agent_skills_delete_removed: false agent_skills_rsync_compress: false agent_skills_rsync_timeout: 120 agent_skills_install_rsync: true agent_skills_replace_existing_target_dirs: false +agent_skills_preserve_existing_target_dirs: + - "{{ agent_skills_home }}/.codex/skills" +agent_skills_remote_flatten_nested_skills: true agent_skills_auto_install_enabled: true agent_skills_auto_install_fail_on_missing_installer: true agent_skills_quality_gate_enabled: true @@ -31,8 +37,10 @@ agent_skills_rsync_excludes: - .venv/ - __pycache__/ - "*.pyc" + - "*/__pycache__/" + - "*/.DS_Store" agent_skills_rsync_extra_opts: - - "--delete-excluded" + - "--protocol=29" - "--out-format=<>%i" agent_skills_typical_scenario_skills: @@ -121,9 +129,6 @@ agent_skills_targets: paths: - "{{ agent_skills_home }}/.opencode/skills" - "{{ agent_skills_home }}/.config/opencode/skills" - - name: hermers - paths: - - "{{ agent_skills_home }}/.hermers/skills" - name: openclaw paths: - "{{ agent_skills_home }}/.openclaw/skills" diff --git a/roles/agent_skills/tasks/main.yml b/roles/agent_skills/tasks/main.yml index b7f6642..00e69ed 100644 --- a/roles/agent_skills/tasks/main.yml +++ b/roles/agent_skills/tasks/main.yml @@ -6,9 +6,10 @@ - agent_skills_group | length > 0 - agent_skills_home | length > 0 - agent_skills_local_source_dir | length > 0 + - (not agent_skills_xworkspace_core_enabled | bool) or agent_skills_xworkspace_core_source_dir | length > 0 - agent_skills_remote_dir | length > 0 - agent_skills_targets | length > 0 - fail_msg: "agent_skills_user, home, source, remote dir, and targets must be set." + fail_msg: "agent_skills_user, home, source dirs, remote dir, and targets must be set." - name: Build required agent skills list ansible.builtin.set_fact: @@ -21,6 +22,7 @@ mode: "0755" delegate_to: localhost become: false + check_mode: false when: - agent_skills_local_source_create | bool - agent_skills_auto_install_enabled | bool @@ -38,14 +40,52 @@ - agent_skills_local_source.stat.isdir | default(false) fail_msg: "Local skills source directory does not exist: {{ agent_skills_local_source_dir }}" +- name: Inspect xworkspace core skills source directory + ansible.builtin.stat: + path: "{{ agent_skills_xworkspace_core_source_dir }}" + register: agent_skills_xworkspace_core_source + delegate_to: localhost + become: false + when: agent_skills_xworkspace_core_enabled | bool + +- name: Assert xworkspace core skills source directory exists + ansible.builtin.assert: + that: + - agent_skills_xworkspace_core_source.stat.isdir | default(false) + fail_msg: "xworkspace core skills source directory does not exist: {{ agent_skills_xworkspace_core_source_dir }}" + when: + - agent_skills_xworkspace_core_enabled | bool + - agent_skills_xworkspace_core_required | bool + +- name: Build effective agent skills source directories + ansible.builtin.set_fact: + agent_skills_effective_source_dirs: >- + {{ + [agent_skills_local_source_dir] + + ( + ( + agent_skills_xworkspace_core_enabled | bool + and agent_skills_xworkspace_core_source.stat.isdir | default(false) + ) + | ternary([agent_skills_xworkspace_core_source_dir], []) + ) + }} + - name: Inspect required local scenario skills ansible.builtin.shell: | set -eu - for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do - if [ -f "{{ agent_skills_local_source_dir }}/$candidate/SKILL.md" ]; then - printf '%s\n' "{{ agent_skills_local_source_dir }}/$candidate" - exit 0 - fi + for source_dir in {{ agent_skills_effective_source_dirs | map('quote') | join(' ') }}; do + for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do + if [ -f "$source_dir/$candidate/SKILL.md" ]; then + printf '%s\n' "$source_dir/$candidate" + exit 0 + fi + match="$(find "$source_dir" -type f -path "*/$candidate/SKILL.md" -print -quit)" + if [ -n "$match" ]; then + dirname "$match" + exit 0 + fi + done done exit 1 args: @@ -58,6 +98,7 @@ label: "{{ item.name }}" delegate_to: localhost become: false + check_mode: false - name: Build missing scenario skills list ansible.builtin.set_fact: @@ -116,11 +157,18 @@ - name: Reinspect required local scenario skills after auto install ansible.builtin.shell: | set -eu - for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do - if [ -f "{{ agent_skills_local_source_dir }}/$candidate/SKILL.md" ]; then - printf '%s\n' "{{ agent_skills_local_source_dir }}/$candidate" - exit 0 - fi + for source_dir in {{ agent_skills_effective_source_dirs | map('quote') | join(' ') }}; do + for candidate in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do + if [ -f "$source_dir/$candidate/SKILL.md" ]; then + printf '%s\n' "$source_dir/$candidate" + exit 0 + fi + match="$(find "$source_dir" -type f -path "*/$candidate/SKILL.md" -print -quit)" + if [ -n "$match" ]; then + dirname "$match" + exit 0 + fi + done done exit 1 args: @@ -134,6 +182,7 @@ delegate_to: localhost become: false when: agent_skills_auto_install_enabled | bool + check_mode: false - name: Build unresolved scenario skills list ansible.builtin.set_fact: @@ -199,6 +248,7 @@ when: - agent_skills_quality_gate_enabled | bool - agent_skills_resolved_local_paths | length > 0 + check_mode: false - name: Detect local top-level symlink skills ansible.builtin.find: @@ -255,10 +305,15 @@ '--partial', '--timeout=' ~ (agent_skills_rsync_timeout | string) ] + + (['--dry-run'] if ansible_check_mode else []) + (['-z'] if (agent_skills_rsync_compress | bool) else []) - + (['--delete'] if (agent_skills_delete_removed | bool) else []) + + (['--delete'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else []) + + (['--delete-excluded'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else []) + ( - (agent_skills_rsync_excludes + agent_skills_local_symlink_excludes) + ( + agent_skills_rsync_excludes + + ((agent_skills_source_index == 0) | ternary(agent_skills_local_symlink_excludes, [])) + ) | map('regex_replace', '^(.*)$', '--exclude=\1') | list ) @@ -266,7 +321,7 @@ + [ '-e', 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null', - agent_skills_local_source_dir ~ '/', + item ~ '/', ( ansible_user | default(ansible_ssh_user) | default('root') ) ~ '@' ~ ( @@ -276,6 +331,11 @@ }} register: agent_skills_rsync_result changed_when: "'<>' in agent_skills_rsync_result.stdout" + check_mode: false + loop: "{{ agent_skills_effective_source_dirs }}" + loop_control: + index_var: agent_skills_source_index + label: "{{ item }}" delegate_to: localhost become: false @@ -287,6 +347,47 @@ group: "{{ agent_skills_group }}" recurse: true +- name: Link nested categorized skills at canonical root + ansible.builtin.shell: | + set -eu + changed=0 + while IFS= read -r skill_manifest; do + skill_dir="$(dirname "$skill_manifest")" + skill_name="$(basename "$skill_dir")" + link_path={{ agent_skills_remote_dir | quote }}/"$skill_name" + if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then + continue + fi + current_target="" + if [ -L "$link_path" ]; then + current_target="$(readlink "$link_path")" + fi + if [ "$current_target" != "$skill_dir" ]; then + if [ "{{ ansible_check_mode | ternary('true', 'false') }}" != "true" ]; then + ln -sfn "$skill_dir" "$link_path" + fi + changed=1 + fi + done < <(find {{ agent_skills_remote_dir | quote }} -mindepth 3 -name SKILL.md -type f -print) + if [ "$changed" = "1" ]; then + echo "<>linked nested skills" + fi + args: + executable: /bin/bash + register: agent_skills_flatten_result + changed_when: "'<>' in agent_skills_flatten_result.stdout" + check_mode: false + when: agent_skills_remote_flatten_nested_skills | bool + +- name: Set canonical agent skills ownership after nested links + ansible.builtin.file: + path: "{{ agent_skills_remote_dir }}" + state: directory + owner: "{{ agent_skills_user }}" + group: "{{ agent_skills_group }}" + recurse: true + when: agent_skills_remote_flatten_nested_skills | bool + - name: Flatten agent skills target paths ansible.builtin.set_fact: agent_skills_target_paths: "{{ agent_skills_targets | subelements('paths') | map('last') | list }}" @@ -307,6 +408,7 @@ when: - item.stat.exists | default(false) - not item.stat.islnk | default(false) + - item.item not in agent_skills_preserve_existing_target_dirs - not agent_skills_replace_existing_target_dirs | bool - name: Replace existing non-link target directories when enabled @@ -317,6 +419,7 @@ when: - item.stat.exists | default(false) - not item.stat.islnk | default(false) + - item.item not in agent_skills_preserve_existing_target_dirs - agent_skills_replace_existing_target_dirs | bool - name: Build agent skills target parent paths @@ -345,10 +448,9 @@ src: "{{ agent_skills_remote_dir }}" dest: "{{ item }}" state: link - owner: "{{ agent_skills_user }}" - group: "{{ agent_skills_group }}" force: true loop: "{{ agent_skills_target_paths }}" + when: item not in agent_skills_preserve_existing_target_dirs - name: Verify canonical skill manifests are present ansible.builtin.find: diff --git a/roles/ai_agent_runtime/README.md b/roles/ai_agent_runtime/README.md index c9f7d19..b4e5767 100644 --- a/roles/ai_agent_runtime/README.md +++ b/roles/ai_agent_runtime/README.md @@ -6,25 +6,32 @@ role entrypoint. The role installs: - base tools: `curl`, `wget`, `git`, `jq`, `rsync`, `unzip` - Node.js runtime for Playwright-based agents - Python 3 toolchain for scripts and helpers -- system `chromium` browser +- existing system browser, preferring the live `/usr/local/bin/chromium` wrapper + or Google Chrome before installing browser packages - `pandoc` + XeLaTeX PDF toolchain - Chinese fonts for document rendering -- shared agent skills via `roles/agent_skills` +- shared agent skills via `roles/agent_skills`, including the categorized + `../xworkspace-core-skills/skills/` repository source by default Design constraints: - system packages are the primary source of truth -- Playwright uses system `chromium` instead of downloading browsers +- Playwright uses the resolved system browser instead of downloading browsers - Chinese PDF rendering is treated as a runtime requirement, not an optional add-on Default Playwright environment: - `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` - `PLAYWRIGHT_BROWSERS_PATH=0` -- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/bin/chromium` +- `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/usr/local/bin/chromium` when that live + wrapper exists Example: ```bash -ansible-playbook -i inventory.ini -l jp-xhttp-contabo.svc.plus setup-ai-agent-runtime.yml +ansible-playbook -i inventory.ini -l jp-xhttp-contabo.svc.plus setup-ai-agent-skills.yml ``` + +`setup-ai-agent-skills.yml` runs `roles/ai_agent_runtime`, which installs system +dependencies and syncs the current Skill catalog through the embedded +`roles/agent_skills` step in one pass. diff --git a/roles/ai_agent_runtime/defaults/main.yml b/roles/ai_agent_runtime/defaults/main.yml index f729ae0..b05518f 100644 --- a/roles/ai_agent_runtime/defaults/main.yml +++ b/roles/ai_agent_runtime/defaults/main.yml @@ -9,7 +9,7 @@ ai_agent_runtime_base_packages: - wget ai_agent_runtime_nodejs_enabled: true -ai_agent_runtime_nodejs_version: "22.x" +ai_agent_runtime_nodejs_version: "24.x" ai_agent_runtime_install_yarn: true ai_agent_runtime_yarn_version: "" ai_agent_runtime_npm_global_packages: [] @@ -29,8 +29,8 @@ ai_agent_runtime_python_packages: ai_agent_runtime_browser_enabled: true ai_agent_runtime_browser_packages: - - chromium -ai_agent_runtime_browser_executable: /usr/bin/chromium + - google-chrome-stable +ai_agent_runtime_browser_executable: /usr/local/bin/chromium ai_agent_runtime_docs_enabled: true ai_agent_runtime_doc_packages: diff --git a/roles/ai_agent_runtime/tasks/browser.yml b/roles/ai_agent_runtime/tasks/browser.yml index cfeec66..2a1738c 100644 --- a/roles/ai_agent_runtime/tasks/browser.yml +++ b/roles/ai_agent_runtime/tasks/browser.yml @@ -1,26 +1,18 @@ --- -- name: Install AI runtime browser packages - ansible.builtin.apt: - name: "{{ ai_agent_runtime_browser_packages }}" - state: present - update_cache: true - install_recommends: false - environment: - DEBIAN_FRONTEND: noninteractive - APT_LISTCHANGES_FRONTEND: none - become: true - -- name: Resolve Chromium executable +- name: Resolve existing Chromium executable ansible.builtin.shell: | set -eu for candidate in \ "{{ ai_agent_runtime_browser_executable }}" \ + /usr/local/bin/chromium \ + /usr/local/bin/chromium-browser \ + /usr/bin/google-chrome \ + /usr/bin/google-chrome-stable \ chromium \ chromium-browser \ google-chrome \ google-chrome-stable \ /usr/bin/chromium \ - /usr/local/bin/chromium \ /snap/bin/chromium; do if command -v "$candidate" >/dev/null 2>&1; then command -v "$candidate" @@ -36,6 +28,51 @@ executable: /bin/sh register: ai_agent_runtime_browser_resolve changed_when: false + failed_when: false + check_mode: false + +- name: Install AI runtime browser packages when no browser exists + ansible.builtin.apt: + name: "{{ ai_agent_runtime_browser_packages }}" + state: present + update_cache: true + install_recommends: false + environment: + DEBIAN_FRONTEND: noninteractive + APT_LISTCHANGES_FRONTEND: none + become: true + when: ai_agent_runtime_browser_resolve.rc != 0 + +- name: Resolve Chromium executable + ansible.builtin.shell: | + set -eu + for candidate in \ + "{{ ai_agent_runtime_browser_executable }}" \ + /usr/local/bin/chromium \ + /usr/local/bin/chromium-browser \ + /usr/bin/google-chrome \ + /usr/bin/google-chrome-stable \ + chromium \ + chromium-browser \ + google-chrome \ + google-chrome-stable \ + /usr/bin/chromium \ + /snap/bin/chromium; do + if command -v "$candidate" >/dev/null 2>&1; then + command -v "$candidate" + exit 0 + fi + if [ -x "$candidate" ]; then + printf '%s\n' "$candidate" + exit 0 + fi + done + exit 1 + args: + executable: /bin/sh + register: ai_agent_runtime_browser_resolve + changed_when: false + check_mode: false - name: Set resolved Chromium executable ansible.builtin.set_fact: diff --git a/roles/ai_agent_runtime/tasks/main.yml b/roles/ai_agent_runtime/tasks/main.yml index 2cfc2fc..1a3a576 100644 --- a/roles/ai_agent_runtime/tasks/main.yml +++ b/roles/ai_agent_runtime/tasks/main.yml @@ -39,7 +39,10 @@ - name: Configure shared agent skills ansible.builtin.include_role: name: "{{ ai_agent_runtime_skills_role_name }}" + apply: + tags: agent_skills when: ai_agent_runtime_skills_enabled | bool + tags: [agent_skills] - name: Verify AI agent runtime ansible.builtin.include_tasks: verify.yml diff --git a/roles/ai_agent_runtime/tasks/verify.yml b/roles/ai_agent_runtime/tasks/verify.yml index a471b25..27e1c93 100644 --- a/roles/ai_agent_runtime/tasks/verify.yml +++ b/roles/ai_agent_runtime/tasks/verify.yml @@ -3,48 +3,56 @@ ansible.builtin.command: node --version register: ai_agent_runtime_node_version changed_when: false + check_mode: false when: ai_agent_runtime_nodejs_enabled | bool - name: Check npm version ansible.builtin.command: npm --version register: ai_agent_runtime_npm_version changed_when: false + check_mode: false when: ai_agent_runtime_nodejs_enabled | bool - name: Check python version ansible.builtin.command: python3 --version register: ai_agent_runtime_python_version changed_when: false + check_mode: false when: ai_agent_runtime_python_enabled | bool - name: Check pip version ansible.builtin.command: pip3 --version register: ai_agent_runtime_pip_version changed_when: false + check_mode: false when: ai_agent_runtime_python_enabled | bool - name: Check chromium version ansible.builtin.command: "{{ ai_agent_runtime_browser_resolved_executable | default(ai_agent_runtime_browser_executable) }} --version" register: ai_agent_runtime_chromium_version changed_when: false + check_mode: false when: ai_agent_runtime_browser_enabled | bool - name: Check pandoc version ansible.builtin.command: pandoc --version register: ai_agent_runtime_pandoc_version changed_when: false + check_mode: false when: ai_agent_runtime_docs_enabled | bool - name: Check xelatex version ansible.builtin.command: xelatex --version register: ai_agent_runtime_xelatex_version changed_when: false + check_mode: false when: ai_agent_runtime_docs_enabled | bool - name: Check Chinese font inventory ansible.builtin.command: fc-list :lang=zh family register: ai_agent_runtime_chinese_fonts changed_when: false + check_mode: false when: - ai_agent_runtime_fonts_enabled | bool - ai_agent_runtime_verify_chinese_fonts | bool diff --git a/roles/vhosts/acp_server_codex/tasks/config.yml b/roles/vhosts/acp_server_codex/tasks/config.yml index 3d88cd5..93167b4 100644 --- a/roles/vhosts/acp_server_codex/tasks/config.yml +++ b/roles/vhosts/acp_server_codex/tasks/config.yml @@ -25,6 +25,7 @@ register: acp_codex_bridge_binary_attrs changed_when: false failed_when: false + check_mode: false - name: Remove immutable flag from Codex bridge binary when present ansible.builtin.command: @@ -74,6 +75,7 @@ register: acp_codex_service_attrs changed_when: false failed_when: false + check_mode: false - name: Remove immutable flag from Codex ACP systemd service when present ansible.builtin.command: diff --git a/roles/vhosts/acp_server_hermes/tasks/config.yml b/roles/vhosts/acp_server_hermes/tasks/config.yml index fdd2223..1d0dff2 100644 --- a/roles/vhosts/acp_server_hermes/tasks/config.yml +++ b/roles/vhosts/acp_server_hermes/tasks/config.yml @@ -26,6 +26,7 @@ register: acp_hermes_bridge_binary_attrs changed_when: false failed_when: false + check_mode: false - name: Remove immutable flag from Hermes bridge binary when present ansible.builtin.command: @@ -58,6 +59,7 @@ register: acp_hermes_service_attrs changed_when: false failed_when: false + check_mode: false - name: Remove immutable flag from Hermes ACP systemd service when present ansible.builtin.command: @@ -80,6 +82,7 @@ changed_when: false failed_when: false no_log: true + check_mode: false - name: Resolve Hermes ACP auth token ansible.builtin.set_fact: diff --git a/roles/vhosts/gateway_openclaw/defaults/main.yml b/roles/vhosts/gateway_openclaw/defaults/main.yml index 5524a23..cad8a35 100644 --- a/roles/vhosts/gateway_openclaw/defaults/main.yml +++ b/roles/vhosts/gateway_openclaw/defaults/main.yml @@ -44,7 +44,17 @@ gateway_openclaw_acp_max_concurrent_sessions: 2 gateway_openclaw_acp_backend: acpx gateway_openclaw_acp_default_agent: codex gateway_openclaw_codex_app_server_url: ws://127.0.0.1:9001 -gateway_openclaw_default_model: deepseek/deepseek-v4-flash +gateway_openclaw_default_model: + primary: nvidia/nemotron-3-super-120b-a12b + fallbacks: + - nvidia/minimaxai/minimax-m2.5 + - nvidia/z-ai/glm5 +gateway_openclaw_default_models: + nvidia/nemotron-3-super-120b-a12b: {} + nvidia/minimaxai/minimax-m2.5: {} + nvidia/z-ai/glm5: {} + openai-codex/gpt-5.5: {} +gateway_openclaw_main_agent_model: nvidia/nemotron-3-super-120b-a12b gateway_openclaw_main_agent_skills: - acp-router @@ -76,3 +86,75 @@ gateway_openclaw_main_agent_skills: - video-translator - web-search - self-improving + - ai-tech-news-video + - it-infra-continuous-png + - it-infra-evolution-video + - product-intro-video + - sound-fx-for-video + - sketch-animation-video + - skylv-hermes-agent-integration + - hermes-agent-integration + - qmd + +gateway_openclaw_mcp_servers: + qmd: + url: http://localhost:8181/mcp + transport: streamable-http + +gateway_openclaw_model_providers: + nvidia: + api: openai-completions + baseUrl: https://integrate.api.nvidia.com/v1 + models: + - id: nvidia/nemotron-3-super-120b-a12b + name: NVIDIA Nemotron 3 Super 120B + input: [text] + contextWindow: 262144 + maxTokens: 8192 + reasoning: false + compat: + requiresStringContent: true + cost: + input: 0 + output: 0 + cacheRead: 0 + cacheWrite: 0 + - id: moonshotai/kimi-k2.5 + name: Kimi K2.5 + input: [text] + contextWindow: 262144 + maxTokens: 8192 + reasoning: false + compat: + requiresStringContent: true + cost: + input: 0 + output: 0 + cacheRead: 0 + cacheWrite: 0 + - id: minimaxai/minimax-m2.5 + name: MiniMax M2.5 + input: [text] + contextWindow: 196608 + maxTokens: 8192 + reasoning: false + compat: + requiresStringContent: true + cost: + input: 0 + output: 0 + cacheRead: 0 + cacheWrite: 0 + - id: z-ai/glm5 + name: GLM-5 + input: [text] + contextWindow: 202752 + maxTokens: 8192 + reasoning: false + compat: + requiresStringContent: true + cost: + input: 0 + output: 0 + cacheRead: 0 + cacheWrite: 0 diff --git a/roles/vhosts/gateway_openclaw/templates/openclaw.json.j2 b/roles/vhosts/gateway_openclaw/templates/openclaw.json.j2 index 420500f..a649140 100644 --- a/roles/vhosts/gateway_openclaw/templates/openclaw.json.j2 +++ b/roles/vhosts/gateway_openclaw/templates/openclaw.json.j2 @@ -26,11 +26,13 @@ "bootstrapMaxChars": 50000, "bootstrapTotalMaxChars": 300000, "model": {{ gateway_openclaw_default_model | to_json }}, + "models": {{ gateway_openclaw_default_models | to_json }}, "thinkingDefault": "low" }, "list": [ { "id": "main", + "model": {{ gateway_openclaw_main_agent_model | to_json }}, "skills": {{ gateway_openclaw_main_agent_skills | unique | list | to_json }} } ] @@ -85,7 +87,7 @@ }, "models": { "mode": "merge", - "providers": {} + "providers": {{ gateway_openclaw_model_providers | to_json }} }, "wizard": { "lastRunAt": "2026-04-19T10:52:37.655Z", @@ -106,6 +108,9 @@ "defaultAgent": {{ gateway_openclaw_acp_default_agent | to_json }}, "maxConcurrentSessions": {{ gateway_openclaw_acp_max_concurrent_sessions | int }} }, + "mcp": { + "servers": {{ gateway_openclaw_mcp_servers | to_json }} + }, "plugins": { "entries": { "nvidia": {"enabled": true}, @@ -119,9 +124,14 @@ "appServer": { "transport": "websocket", "url": {{ gateway_openclaw_codex_app_server_url | to_json }} + }, + "discovery": { + "enabled": true } } }, + "memory-wiki": {"enabled": true}, + "openai": {"enabled": true}, "openclaw-multi-session-plugins": {"enabled": true}, "device-pair": {"enabled": false}, "phone-control": {"enabled": false}, diff --git a/roles/vhosts/nodejs/tasks/main.yml b/roles/vhosts/nodejs/tasks/main.yml index ce411a1..dc3e684 100644 --- a/roles/vhosts/nodejs/tasks/main.yml +++ b/roles/vhosts/nodejs/tasks/main.yml @@ -4,6 +4,7 @@ register: node_version_check changed_when: false failed_when: false + check_mode: false - name: Get Node.js version number set_fact: @@ -83,12 +84,14 @@ register: npm_version_check changed_when: false failed_when: false + check_mode: false - name: Get current Yarn version command: yarn --version register: yarn_version_check changed_when: false failed_when: false + check_mode: false when: install_yarn | default(true) - name: Normalize desired Yarn version diff --git a/roles/vhosts/qmd/defaults/main.yml b/roles/vhosts/qmd/defaults/main.yml new file mode 100644 index 0000000..9d2e764 --- /dev/null +++ b/roles/vhosts/qmd/defaults/main.yml @@ -0,0 +1,26 @@ +--- +qmd_user: ubuntu +qmd_group: "{{ qmd_user }}" +qmd_home: "/home/{{ qmd_user }}" +qmd_binary_path: "{{ qmd_home }}/.bun/bin/qmd" +qmd_config_dir: "{{ qmd_home }}/.config/qmd" +qmd_cache_dir: "{{ qmd_home }}/.cache/qmd" +qmd_config_dir_mode: "0775" +qmd_cache_dir_mode: "0775" +qmd_index_config_path: "{{ qmd_config_dir }}/index.yml" +qmd_index_config_mode: "0664" +qmd_env_path: "{{ qmd_config_dir }}/qmd.env" +qmd_mcp_service_name: qmd-mcp +qmd_mcp_service_unit_path: "{{ qmd_home }}/.config/systemd/user/{{ qmd_mcp_service_name }}.service" +qmd_service_uid: "1000" +qmd_mcp_host: 127.0.0.1 +qmd_mcp_port: 8181 +qmd_mcp_url: "http://localhost:{{ qmd_mcp_port }}/mcp" +qmd_embed_api_base_url: https://integrate.api.nvidia.com/v1 +qmd_embed_model: nvidia/llama-nemotron-embed-1b-v2 +qmd_collections: + openclaw-workspace: + path: "{{ qmd_home }}/.openclaw/workspace" + pattern: "**/*.md" + context: + "": OpenClaw workspace long-term memory, daily notes, user context, local tools notes, generated plans and markdown artifacts. diff --git a/roles/vhosts/qmd/tasks/main.yml b/roles/vhosts/qmd/tasks/main.yml new file mode 100644 index 0000000..86d02d1 --- /dev/null +++ b/roles/vhosts/qmd/tasks/main.yml @@ -0,0 +1,139 @@ +--- +- name: Assert QMD is only supported on Debian family + ansible.builtin.assert: + that: + - ansible_facts.os_family == "Debian" + fail_msg: "roles/vhosts/qmd currently supports Debian-based hosts only." + +- name: Ensure QMD config and cache directories exist + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: "{{ qmd_user }}" + group: "{{ qmd_group }}" + mode: "{{ item.mode }}" + loop: + - path: "{{ qmd_config_dir }}" + mode: "{{ qmd_config_dir_mode }}" + - path: "{{ qmd_cache_dir }}" + mode: "{{ qmd_cache_dir_mode }}" + - path: "{{ qmd_mcp_service_unit_path | dirname }}" + mode: "0755" + +- name: Deploy QMD collection index config + ansible.builtin.template: + src: index.yml.j2 + dest: "{{ qmd_index_config_path }}" + owner: "{{ qmd_user }}" + group: "{{ qmd_group }}" + mode: "{{ qmd_index_config_mode }}" + +- name: Deploy QMD external embedding environment + ansible.builtin.template: + src: qmd.env.j2 + dest: "{{ qmd_env_path }}" + owner: "{{ qmd_user }}" + group: "{{ qmd_group }}" + mode: "0600" + diff: false + +- name: Inspect QMD binary + ansible.builtin.stat: + path: "{{ qmd_binary_path }}" + register: qmd_binary + +- name: Fail when QMD binary is missing or not executable + ansible.builtin.assert: + that: + - qmd_binary.stat.exists | default(false) + - qmd_binary.stat.executable | default(false) + fail_msg: "QMD binary is missing or not executable: {{ qmd_binary_path }}" + +- name: Deploy QMD MCP user systemd unit + ansible.builtin.template: + src: qmd-mcp.user.service.j2 + dest: "{{ qmd_mcp_service_unit_path }}" + owner: "{{ qmd_user }}" + group: "{{ qmd_group }}" + mode: "0644" + register: qmd_mcp_user_service_unit + +- name: Enable QMD service user linger + ansible.builtin.command: + cmd: "loginctl enable-linger {{ qmd_user }}" + creates: "/var/lib/systemd/linger/{{ qmd_user }}" + when: + - not ansible_check_mode + +- name: Ensure QMD service user manager is running + ansible.builtin.systemd: + name: "user@{{ qmd_service_uid }}.service" + state: started + when: + - not ansible_check_mode + +- name: Reload QMD user systemd manager + ansible.builtin.command: + cmd: systemctl --user daemon-reload + environment: + HOME: "{{ qmd_home }}" + XDG_RUNTIME_DIR: "/run/user/{{ qmd_service_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ qmd_service_uid }}/bus" + become: true + become_user: "{{ qmd_user }}" + changed_when: false + when: + - not ansible_check_mode + +- name: Ensure QMD MCP daemon is enabled and running + ansible.builtin.command: + cmd: >- + systemctl --user enable + {{ '--now' if not (qmd_mcp_user_service_unit.changed | default(false)) else '' }} + {{ qmd_mcp_service_name }}.service + environment: + HOME: "{{ qmd_home }}" + XDG_RUNTIME_DIR: "/run/user/{{ qmd_service_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ qmd_service_uid }}/bus" + become: true + become_user: "{{ qmd_user }}" + register: qmd_mcp_service_enable + changed_when: >- + 'Created symlink' in (qmd_mcp_service_enable.stdout | default('')) or + 'Created symlink' in (qmd_mcp_service_enable.stderr | default('')) + when: + - not ansible_check_mode + +- name: Restart QMD MCP daemon after unit changes + ansible.builtin.command: + cmd: "systemctl --user restart {{ qmd_mcp_service_name }}.service" + environment: + HOME: "{{ qmd_home }}" + XDG_RUNTIME_DIR: "/run/user/{{ qmd_service_uid }}" + DBUS_SESSION_BUS_ADDRESS: "unix:path=/run/user/{{ qmd_service_uid }}/bus" + become: true + become_user: "{{ qmd_user }}" + when: + - qmd_mcp_user_service_unit.changed | default(false) + - not ansible_check_mode + +- name: Validate QMD status + ansible.builtin.command: + cmd: "{{ qmd_binary_path }} status" + environment: + HOME: "{{ qmd_home }}" + QMD_EMBED_API_BASE_URL: "{{ qmd_embed_api_base_url }}" + QMD_EMBED_MODEL: "{{ qmd_embed_model }}" + become: true + become_user: "{{ qmd_user }}" + register: qmd_status + changed_when: false + check_mode: false + +- name: Show QMD validation summary + ansible.builtin.debug: + msg: + - "QMD MCP URL: {{ qmd_mcp_url }}" + - "QMD index config: {{ qmd_index_config_path }}" + - "QMD embedding model: {{ qmd_embed_model }}" + - "{{ qmd_status.stdout | default('') }}" diff --git a/roles/vhosts/qmd/templates/index.yml.j2 b/roles/vhosts/qmd/templates/index.yml.j2 new file mode 100644 index 0000000..957b409 --- /dev/null +++ b/roles/vhosts/qmd/templates/index.yml.j2 @@ -0,0 +1,12 @@ +collections: +{% for name, collection in qmd_collections | dictsort %} + {{ name }}: + path: {{ collection.path }} + pattern: {{ collection.pattern | to_json }} +{% if collection.context is defined %} + context: +{% for path, text in collection.context | dictsort %} + {{ path | to_json }}: {{ text }} +{% endfor %} +{% endif %} +{% endfor %} diff --git a/roles/vhosts/qmd/templates/qmd-mcp.user.service.j2 b/roles/vhosts/qmd/templates/qmd-mcp.user.service.j2 new file mode 100644 index 0000000..c017d17 --- /dev/null +++ b/roles/vhosts/qmd/templates/qmd-mcp.user.service.j2 @@ -0,0 +1,18 @@ +[Unit] +Description=QMD MCP daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +WorkingDirectory={{ qmd_home }} +Environment=HOME={{ qmd_home }} +Environment=PATH={{ qmd_home }}/.bun/bin:{{ qmd_home }}/.local/bin:/usr/local/bin:/usr/bin:/bin +EnvironmentFile={{ qmd_env_path }} +ExecStart={{ qmd_binary_path }} mcp --http --port {{ qmd_mcp_port }} +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target + diff --git a/roles/vhosts/qmd/templates/qmd.env.j2 b/roles/vhosts/qmd/templates/qmd.env.j2 new file mode 100644 index 0000000..9dcb5a4 --- /dev/null +++ b/roles/vhosts/qmd/templates/qmd.env.j2 @@ -0,0 +1,3 @@ +QMD_EMBED_API_BASE_URL={{ qmd_embed_api_base_url }} +QMD_EMBED_MODEL={{ qmd_embed_model }} + diff --git a/roles/vhosts/xfce_xrdp_minimal/defaults/main.yml b/roles/vhosts/xfce_xrdp_minimal/defaults/main.yml index 5175c29..0f9d072 100644 --- a/roles/vhosts/xfce_xrdp_minimal/defaults/main.yml +++ b/roles/vhosts/xfce_xrdp_minimal/defaults/main.yml @@ -21,6 +21,7 @@ xfce_manage_user: false xfce_user_groups: [] xfce_user_shell: /bin/bash xfce_user_password_plaintext: "" +xfce_user_update_password: on_create xfce_google_chrome_version: "148.0.7778.167-1" xfce_google_chrome_apt_key_url: "https://dl.google.com/linux/linux_signing_key.pub" diff --git a/roles/vhosts/xfce_xrdp_minimal/tasks/browser.yml b/roles/vhosts/xfce_xrdp_minimal/tasks/browser.yml index 4e5fdf9..3086652 100644 --- a/roles/vhosts/xfce_xrdp_minimal/tasks/browser.yml +++ b/roles/vhosts/xfce_xrdp_minimal/tasks/browser.yml @@ -12,6 +12,8 @@ - snapd.apparmor.service become: true failed_when: false + when: + - not ansible_check_mode - name: Block snap and snap-backed browser transitional packages ansible.builtin.copy: @@ -120,14 +122,16 @@ mode: "0755" become: true -- name: Keep Chromium compatibility commands pointed at Chrome deb - ansible.builtin.file: - src: /usr/local/bin/chromium-xrdp +- name: Keep Chromium compatibility commands disabled + ansible.builtin.copy: + content: | + #!/bin/sh + echo "Chromium is disabled on this host. Use google-chrome instead." >&2 + exit 126 dest: "{{ item }}" - state: link - force: true owner: root group: root + mode: "0755" loop: - /usr/local/bin/chromium - /usr/local/bin/chromium-browser @@ -162,6 +166,8 @@ environment: HOME: "{{ xfce_user_home }}" changed_when: false + when: + - not ansible_check_mode - name: Set xdg default web browser to Google Chrome XRDP desktop entry ansible.builtin.command: "xdg-settings set default-web-browser {{ xfce_google_chrome_desktop_file }}" @@ -170,6 +176,8 @@ environment: HOME: "{{ xfce_user_home }}" changed_when: false + when: + - not ansible_check_mode - name: Set system browser alternatives to Google Chrome deb ansible.builtin.command: "update-alternatives --set {{ item }} /usr/bin/google-chrome-stable" @@ -178,12 +186,15 @@ - gnome-www-browser become: true changed_when: false + when: + - not ansible_check_mode - name: Verify Google Chrome deb browser installation ansible.builtin.command: /usr/local/bin/chromium-xrdp --version register: xfce_google_chrome_version_check changed_when: false become: true + check_mode: false - name: Show Google Chrome deb browser version ansible.builtin.debug: diff --git a/roles/vhosts/xfce_xrdp_minimal/tasks/config.yml b/roles/vhosts/xfce_xrdp_minimal/tasks/config.yml index b74f1bb..d361cb7 100644 --- a/roles/vhosts/xfce_xrdp_minimal/tasks/config.yml +++ b/roles/vhosts/xfce_xrdp_minimal/tasks/config.yml @@ -22,11 +22,13 @@ ansible.builtin.user: name: "{{ xfce_user }}" password: "{{ xfce_user_password_plaintext | password_hash('sha512') }}" - update_password: always + update_password: "{{ xfce_user_update_password }}" password_lock: false become: true no_log: true - when: xfce_manage_user | bool + when: + - xfce_manage_user | bool + - not ansible_check_mode - name: Ensure the desktop user can sudo ansible.builtin.user: diff --git a/roles/vhosts/xworkmate_bridge/defaults/main.yml b/roles/vhosts/xworkmate_bridge/defaults/main.yml index a5b3cad..158f0fd 100644 --- a/roles/vhosts/xworkmate_bridge/defaults/main.yml +++ b/roles/vhosts/xworkmate_bridge/defaults/main.yml @@ -2,6 +2,7 @@ xworkmate_bridge_service_name: xworkmate-bridge xworkmate_bridge_service_user: ubuntu xworkmate_bridge_service_group: ubuntu +xworkmate_bridge_service_home: "/home/{{ xworkmate_bridge_service_user }}" xworkmate_bridge_auth_token: "{{ lookup('ansible.builtin.env', 'BRIDGE_AUTH_TOKEN') | default(lookup('ansible.builtin.env', 'INTERNAL_SERVICE_TOKEN') | default('', true), true) }}" xworkmate_bridge_listen_host: 127.0.0.1 xworkmate_bridge_listen_port: 8787 diff --git a/roles/vhosts/xworkmate_bridge/templates/xworkmate-bridge.service.j2 b/roles/vhosts/xworkmate_bridge/templates/xworkmate-bridge.service.j2 index dfa7301..4dd75f4 100644 --- a/roles/vhosts/xworkmate_bridge/templates/xworkmate-bridge.service.j2 +++ b/roles/vhosts/xworkmate_bridge/templates/xworkmate-bridge.service.j2 @@ -1,7 +1,11 @@ [Unit] Description=XWorkmate bridge control plane +{% if xworkmate_bridge_required_services | length > 0 %} Requires={{ xworkmate_bridge_required_services | join(' ') }} After=network-online.target {{ xworkmate_bridge_required_services | join(' ') }} +{% else %} +After=network-online.target +{% endif %} Wants=network-online.target [Service] @@ -9,7 +13,7 @@ Type=simple User={{ xworkmate_bridge_service_user }} Group={{ xworkmate_bridge_service_group }} WorkingDirectory={{ xworkmate_bridge_base_dir }} -Environment="HOME=/home/{{ xworkmate_bridge_service_user }}" +Environment="HOME={{ xworkmate_bridge_service_home }}" Environment="TERM=xterm-256color" {% for key, value in xworkmate_bridge_service_environment | dictsort %} {% if value | string | trim | length > 0 %} diff --git a/scripts/sync-yitu-it-series-r2.sh b/scripts/sync-yitu-it-series-r2.sh new file mode 100755 index 0000000..f5aca9e --- /dev/null +++ b/scripts/sync-yitu-it-series-r2.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +set -euo pipefail + +LOCAL_SRC_DEFAULT="/Users/shenlan/Library/CloudStorage/GoogleDrive-haitaopanhq@gmail.com/我的云端硬盘/自媒体" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PLAYBOOKS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +LOCAL_SRC="${LOCAL_SRC:-$LOCAL_SRC_DEFAULT}" +R2_BUCKET="${R2_BUCKET:-yitu-it-series}" +R2_REMOTE="${R2_REMOTE:-cloudflare-r2}" +R2_CUSTOM_DOMAIN="${R2_CUSTOM_DOMAIN:-img.svc.plus}" +RCLONE_BIN="${RCLONE_BIN:-rclone}" +WRANGLER_BIN="${WRANGLER_BIN:-npx --yes wrangler@latest}" +LOG_FILE="${LOG_FILE:-$HOME/rclone-yitu-it-series.log}" + +SYNC_FLAGS=( + --progress + --stats 30s + --transfers "${RCLONE_TRANSFERS:-16}" + --checkers "${RCLONE_CHECKERS:-32}" + --fast-list + --s3-upload-cutoff "${RCLONE_S3_UPLOAD_CUTOFF:-128M}" + --s3-chunk-size "${RCLONE_S3_CHUNK_SIZE:-128M}" + --retries "${RCLONE_RETRIES:-8}" + --low-level-retries "${RCLONE_LOW_LEVEL_RETRIES:-20}" + --timeout "${RCLONE_TIMEOUT:-5m}" + --contimeout "${RCLONE_CONTIMEOUT:-30s}" + --exclude ".DS_Store" + --exclude "Icon?" + --exclude "._*" + --exclude ".Spotlight-V100/**" + --exclude ".Trashes/**" + --log-file "$LOG_FILE" + --log-level "${RCLONE_LOG_LEVEL:-INFO}" +) + +usage() { + cat <<'EOF' +Usage: + sync-yitu-it-series-r2.sh doctor + sync-yitu-it-series-r2.sh create-bucket + sync-yitu-it-series-r2.sh configure-rclone + sync-yitu-it-series-r2.sh dry-run + sync-yitu-it-series-r2.sh sync + sync-yitu-it-series-r2.sh copy + sync-yitu-it-series-r2.sh check + sync-yitu-it-series-r2.sh tree + sync-yitu-it-series-r2.sh configure-custom-domain + sync-yitu-it-series-r2.sh install-launchd + sync-yitu-it-series-r2.sh uninstall-launchd + +Required for configure-rclone: + CF_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID + R2_ACCESS_KEY_ID + R2_SECRET_ACCESS_KEY + +Required for create-bucket: + CLOUDFLARE_API_TOKEN or an active Wrangler login + +Required for configure-custom-domain: + CF_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID + CF_ZONE_ID or CLOUDFLARE_ZONE_ID + CLOUDFLARE_API_TOKEN +EOF +} + +require_cmd() { + local cmd="$1" + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Missing command: $cmd" >&2 + exit 1 + fi +} + +account_id() { + printf '%s' "${CF_ACCOUNT_ID:-${CLOUDFLARE_ACCOUNT_ID:-}}" +} + +zone_id() { + printf '%s' "${CF_ZONE_ID:-${CLOUDFLARE_ZONE_ID:-}}" +} + +require_env() { + local name="$1" + if [[ -z "${!name:-}" ]]; then + echo "Missing environment variable: $name" >&2 + exit 1 + fi +} + +require_account_id() { + if [[ -z "$(account_id)" ]]; then + echo "Missing CF_ACCOUNT_ID or CLOUDFLARE_ACCOUNT_ID" >&2 + exit 1 + fi +} + +remote_endpoint() { + require_account_id + printf 'https://%s.r2.cloudflarestorage.com' "$(account_id)" +} + +doctor() { + require_cmd "$RCLONE_BIN" + echo "Local source: $LOCAL_SRC" + test -d "$LOCAL_SRC" + "$RCLONE_BIN" version | sed -n '1,8p' + "$RCLONE_BIN" size "$LOCAL_SRC" \ + --exclude ".DS_Store" \ + --exclude "Icon?" \ + --exclude "._*" + echo + echo "Rclone remotes:" + "$RCLONE_BIN" listremotes || true + echo + echo "Target: ${R2_REMOTE}:${R2_BUCKET}/" +} + +create_bucket() { + require_account_id + if [[ -n "${CLOUDFLARE_API_TOKEN:-}" ]]; then + CLOUDFLARE_ACCOUNT_ID="$(account_id)" CLOUDFLARE_API_TOKEN="$CLOUDFLARE_API_TOKEN" \ + $WRANGLER_BIN r2 bucket create "$R2_BUCKET" + else + CLOUDFLARE_ACCOUNT_ID="$(account_id)" $WRANGLER_BIN r2 bucket create "$R2_BUCKET" + fi +} + +configure_rclone() { + require_account_id + require_env R2_ACCESS_KEY_ID + require_env R2_SECRET_ACCESS_KEY + "$RCLONE_BIN" config create "$R2_REMOTE" s3 \ + provider Cloudflare \ + access_key_id "$R2_ACCESS_KEY_ID" \ + secret_access_key "$R2_SECRET_ACCESS_KEY" \ + endpoint "$(remote_endpoint)" \ + region auto \ + acl private \ + no_check_bucket true + "$RCLONE_BIN" lsd "${R2_REMOTE}:" +} + +sync_dry_run() { + "$RCLONE_BIN" sync "$LOCAL_SRC" "${R2_REMOTE}:${R2_BUCKET}/" --dry-run "${SYNC_FLAGS[@]}" +} + +sync_run() { + "$RCLONE_BIN" sync "$LOCAL_SRC" "${R2_REMOTE}:${R2_BUCKET}/" "${SYNC_FLAGS[@]}" +} + +copy_run() { + "$RCLONE_BIN" copy "$LOCAL_SRC" "${R2_REMOTE}:${R2_BUCKET}/" "${SYNC_FLAGS[@]}" +} + +check_run() { + "$RCLONE_BIN" check "$LOCAL_SRC" "${R2_REMOTE}:${R2_BUCKET}/" \ + --one-way \ + --size-only \ + --exclude ".DS_Store" \ + --exclude "Icon?" \ + --exclude "._*" +} + +tree_run() { + "$RCLONE_BIN" tree "${R2_REMOTE}:${R2_BUCKET}/" --max-depth "${TREE_MAX_DEPTH:-2}" +} + +configure_custom_domain() { + require_account_id + require_env CLOUDFLARE_API_TOKEN + local zid + zid="$(zone_id)" + if [[ -z "$zid" ]]; then + echo "Missing CF_ZONE_ID or CLOUDFLARE_ZONE_ID" >&2 + exit 1 + fi + curl -fsS "https://api.cloudflare.com/client/v4/accounts/$(account_id)/r2/buckets/${R2_BUCKET}/domains/custom" \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \ + -d "{ + \"domain\": \"${R2_CUSTOM_DOMAIN}\", + \"enabled\": true, + \"zoneId\": \"${zid}\", + \"minTLS\": \"1.2\" + }" | jq . +} + +install_launchd() { + local plist="$HOME/Library/LaunchAgents/plus.svc.yitu-it-series.rclone-sync.plist" + mkdir -p "$HOME/Library/LaunchAgents" + cat > "$plist" < + + + + Label + plus.svc.yitu-it-series.rclone-sync + ProgramArguments + + $SCRIPT_DIR/sync-yitu-it-series-r2.sh + sync + + WorkingDirectory + $PLAYBOOKS_DIR + StartInterval + ${LAUNCHD_START_INTERVAL:-1800} + RunAtLoad + + StandardOutPath + $HOME/rclone-yitu-it-series.launchd.out.log + StandardErrorPath + $HOME/rclone-yitu-it-series.launchd.err.log + + +EOF + launchctl unload "$plist" >/dev/null 2>&1 || true + launchctl load "$plist" + launchctl start plus.svc.yitu-it-series.rclone-sync + echo "Installed $plist" +} + +uninstall_launchd() { + local plist="$HOME/Library/LaunchAgents/plus.svc.yitu-it-series.rclone-sync.plist" + launchctl unload "$plist" >/dev/null 2>&1 || true + rm -f "$plist" + echo "Removed $plist" +} + +case "${1:-}" in + doctor) doctor ;; + create-bucket) create_bucket ;; + configure-rclone) configure_rclone ;; + dry-run) sync_dry_run ;; + sync) sync_run ;; + copy) copy_run ;; + check) check_run ;; + tree) tree_run ;; + configure-custom-domain) configure_custom_domain ;; + install-launchd) install_launchd ;; + uninstall-launchd) uninstall_launchd ;; + -h|--help|help|"") usage ;; + *) usage; exit 2 ;; +esac diff --git a/setup-ai-agent-runtime.yml b/setup-ai-agent-runtime.yml index 98b2fb6..98754e7 100644 --- a/setup-ai-agent-runtime.yml +++ b/setup-ai-agent-runtime.yml @@ -1,9 +1,12 @@ --- -- name: Setup AI agent runtime - hosts: all +- name: Setup AI agent runtime desktop + hosts: jp-xhttp-contabo.svc.plus become: true gather_facts: true + vars: + xfce_manage_user: true + xfce_user_password_plaintext: "L@xiaomin1250" + xfce_user_shell: /bin/bash + xfce_enable_ufw: false roles: - - role: roles/ai_agent_runtime/ - when: ai_agent_runtime_enabled | default(true) | bool - tags: [ai_agent_runtime] + - roles/vhosts/xfce_xrdp_minimal/ diff --git a/setup-ai-agent-skills.yml b/setup-ai-agent-skills.yml new file mode 100644 index 0000000..acf866f --- /dev/null +++ b/setup-ai-agent-skills.yml @@ -0,0 +1,9 @@ +--- +- name: Setup AI agent skills + hosts: all + become: true + gather_facts: true + roles: + - role: roles/ai_agent_runtime/ + when: ai_agent_runtime_enabled | default(true) | bool + tags: [ai_agent_runtime] diff --git a/vars/cloudflare_svc_plus_dns.yml b/vars/cloudflare_svc_plus_dns.yml index 911b5bf..8198404 100644 --- a/vars/cloudflare_svc_plus_dns.yml +++ b/vars/cloudflare_svc_plus_dns.yml @@ -1,6 +1,7 @@ --- cloudflare_dns_default_source_hosts: - cn_front_host + - cn_xworkmate_bridge_host - jp_xhttp_contabo_host - tky_proxy_host diff --git a/xfce_xrdp_minimal.yaml b/xfce_xrdp_minimal.yaml deleted file mode 100644 index 8db08d5..0000000 --- a/xfce_xrdp_minimal.yaml +++ /dev/null @@ -1,12 +0,0 @@ ---- -- name: Setup minimal XFCE + XRDP desktop - hosts: jp-xhttp-contabo.svc.plus - become: true - gather_facts: true - vars: - xfce_manage_user: true - xfce_user_password_plaintext: "L@xiaomin1250" - xfce_user_shell: /bin/bash - xfce_enable_ufw: false - roles: - - roles/vhosts/xfce_xrdp_minimal/