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
+
+
+```
+
+MDX:
+
+```mdx
+
+```
+
+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/