Compare commits

..

No commits in common. "main" and "fix/xworkmate-windows-handler" have entirely different histories.

70 changed files with 655 additions and 2110 deletions

View File

@ -1,44 +0,0 @@
name: Validate Release PR
# release/* 分支的发布策略门禁:仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
# 详见 iac_modules/docs/tldr-github-branch-model.md
on:
pull_request_target:
types: [opened, synchronize, reopened, labeled, unlabeled]
permissions:
contents: read
pull-requests: read
jobs:
validate-release-source:
runs-on: ubuntu-latest
if: startsWith(github.base_ref, 'release/')
steps:
- name: Check PR source branch
run: |
SRC="${{ github.head_ref }}"
TGT="${{ github.base_ref }}"
LABELS="${{ join(github.event.pull_request.labels.*.name, ',') }}"
echo "🔍 Validating PR into release branch"
echo " source: $SRC"
echo " target: $TGT"
echo " labels: $LABELS"
if [[ "$SRC" =~ ^hotfix/ ]]; then
echo "✅ Allowed: hotfix/* branch"
exit 0
fi
if [[ "$LABELS" =~ (^|,)(cherry-pick|backport)(,|$) ]]; then
echo "✅ Allowed: cherry-pick/backport labeled PR"
exit 0
fi
echo "❌ Rejected."
echo "release/* 仅接受:"
echo " - 来自 hotfix/* 的 PR"
echo " - 带 cherry-pick 或 backport 标签的 PR已验证 feature 的 backport/cherry-pick"
echo "禁止从 main / develop / feature/* 直接合并到 release/*。"
exit 1

1
.gitignore vendored
View File

@ -1,5 +1,4 @@
xfce-secrets.yml
inventory/__pycache__/
.playwright-mcp/
.env
.artifacts/

View File

@ -10,7 +10,6 @@
<string>-c</string>
<string>
source "{{ xworkspace_console_config_dir }}/portal.env"
export PATH="/opt/homebrew/bin:/usr/local/bin:$PATH"
exec {{ xworkspace_console_api_exec }}
</string>
</array>

View File

@ -10,9 +10,7 @@
<string>-c</string>
<string>
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
# 预编译 runtime 只发 dashboard/dist无 package.json且 dashboard 是
# 无客户端路由的单页应用,故用 python3 静态伺服 dist 即可macOS 无 caddy
exec /usr/bin/env python3 -m http.server {{ xworkspace_console_port }} --bind 127.0.0.1 --directory "{{ xworkspace_console_dashboard_dir }}/dist"
exec /usr/bin/env npm run preview -- --host 127.0.0.1 --port {{ xworkspace_console_port }}
</string>
</array>
<key>RunAtLoad</key>
@ -20,7 +18,7 @@
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>{{ xworkspace_console_dashboard_dir }}/dist</string>
<string>{{ xworkspace_console_dashboard_dir }}</string>
<key>StandardOutPath</key>
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/console.log</string>
<key>StandardErrorPath</key>

View File

@ -1,106 +0,0 @@
#!/usr/bin/env python3
"""Ansible 动态 inventory —— 数据源为 Terraform 导出的 CMDB。
IAC 联动方式
iac_modules/terraform-hcl-standard/vultr-vps/envs/ai-workspace/ generate.py
`terraform apply` YAML 静态字段与 terraform 运行时输出合并写出
cmdb.json结构化主机事实本脚本把它翻译成 Ansible 动态 inventory
于是 IaC 一变更重跑 `generate.py inventory`inventory 就跟着变
取数优先级
1. 环境变量 AI_WORKSPACE_CMDB_JSON 指向的文件
2. 环境变量 AI_WORKSPACE_TF_DIR或默认 env 目录下的 cmdb.json
用法
ansible-inventory -i inventory/terraform_cmdb.py --list
ansible all -i inventory/terraform_cmdb.py -m ping
"""
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
# playbooks/inventory -> 仓库根 -> terraform env
REPO_ROOT = os.path.abspath(os.path.join(HERE, "..", ".."))
DEFAULT_TF_DIR = os.path.join(
REPO_ROOT,
"iac_modules",
"terraform-hcl-standard",
"vultr-vps",
"envs",
"ai-workspace",
)
def _from_explicit_file():
path = os.environ.get("AI_WORKSPACE_CMDB_JSON")
if path and os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
return None
def _from_default_file(tf_dir):
path = os.path.join(tf_dir, "cmdb.json")
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
return json.load(fh)
return None
def load_cmdb():
tf_dir = os.environ.get("AI_WORKSPACE_TF_DIR", DEFAULT_TF_DIR)
for loader in (
_from_explicit_file,
lambda: _from_default_file(tf_dir),
):
data = loader()
if data:
return data
return {}
def build_inventory(cmdb):
inv = {"_meta": {"hostvars": {}}}
groups = {}
for name, host in cmdb.items():
hostvars = {
"ansible_host": host.get("ip"),
"ansible_user": host.get("ansible_user", "root"),
# 云主机 IP 常被回收,放宽 host key 校验避免撞到旧 known_hosts
"ansible_ssh_common_args": (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"
),
}
# CMDB 其余字段一并暴露给 playbook 使用
hostvars.update(host.get("host_vars", {}))
hostvars["cmdb_instance_id"] = host.get("instance_id")
hostvars["cmdb_os_id"] = host.get("os_id")
hostvars["cmdb_tags"] = host.get("tags", [])
inv["_meta"]["hostvars"][name] = hostvars
for group in host.get("groups", []) or ["ungrouped"]:
groups.setdefault(group, {"hosts": []})["hosts"].append(name)
inv.update(groups)
inv["all"] = {"children": sorted(list(groups.keys()) + ["ungrouped"])}
return inv
def main():
args = sys.argv[1:]
cmdb = load_cmdb()
if "--host" in args:
# hostvars 已在 _meta 里,单主机查询返回空对象即可
print(json.dumps({}))
return
# 默认与 --list 行为一致
print(json.dumps(build_inventory(cmdb), indent=2))
if __name__ == "__main__":
main()

View File

@ -3,19 +3,16 @@ agent_skills_user: "{{ ansible_env.USER | default('ubuntu') }}"
agent_skills_group: "{{ 'staff' if ansible_os_family == 'Darwin' else agent_skills_user }}"
agent_skills_home: "{{ ansible_env.HOME | default('/home/' + agent_skills_user) }}"
# 规范化技能落地目录canonical始终在目标主机上。installer 直接装到这里,
# core 技能 clone 后合并进来。本地/pull 与远程 controller 两种模型行为一致。
agent_skills_remote_dir: "{{ agent_skills_home }}/.agents/skills"
# xworkspace-core-skills 以 git clone 获取(最通用、跨平台、双模型一致),
# 在目标主机上 clone不再依赖 controller 端预置目录。
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_repo_url: "https://github.com/ai-workspace-lab/xworkspace-core-skills.git"
agent_skills_xworkspace_core_version: "main"
agent_skills_xworkspace_core_clone_dir: "{{ agent_skills_home }}/.local/src/xworkspace-core-skills"
agent_skills_xworkspace_core_source_dir: "{{ agent_skills_xworkspace_core_clone_dir }}/skills"
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: 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"
@ -35,6 +32,17 @@ agent_skills_quality_gate_commands:
argv_prefix:
- self-improving
- inspect
agent_skills_rsync_excludes:
- .DS_Store
- .venv/
- __pycache__/
- "*.pyc"
- "*/__pycache__/"
- "*/.DS_Store"
agent_skills_rsync_extra_opts:
- "--protocol=29"
- "--out-format=<<CHANGED>>%i"
agent_skills_typical_scenario_skills:
- name: pptx
scenario_groups: [local-document-artifacts]

View File

@ -1,25 +1,286 @@
---
# 设计:全程在「目标主机」上执行——没有任何 delegate_to: localhost。
# 因此两种执行模型行为完全一致:
# - 本地/pullcurl|bash → ansible-playbook -c locallocalhost 即主机)
# - 远程 controlleransible-playbook -i <inventory> over ssh任务在主机上跑
# 源以 git clone 获取(最通用、跨平台),不再依赖 controller 端预置目录,
# 合并用 ansible.builtin.copy无裸 rsync、无本地钉死
- name: Validate agent skills input
- name: Validate agent skills sync input
ansible.builtin.assert:
that:
- agent_skills_user | length > 0
- 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/group/home, 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:
agent_skills_required_entries: "{{ agent_skills_typical_scenario_skills + agent_skills_extra_required_skills }}"
- name: Ensure local agent skills source directory exists for auto install
ansible.builtin.file:
path: "{{ agent_skills_local_source_dir }}"
state: directory
mode: "0755"
delegate_to: localhost
become: false
check_mode: false
when:
- agent_skills_local_source_create | bool
- agent_skills_auto_install_enabled | bool
- name: Inspect local agent skills source directory
ansible.builtin.stat:
path: "{{ agent_skills_local_source_dir }}"
register: agent_skills_local_source
delegate_to: localhost
become: false
- name: Assert local agent skills source directory exists
ansible.builtin.assert:
that:
- 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 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:
executable: /bin/bash
register: agent_skills_local_skill_presence
changed_when: false
failed_when: false
loop: "{{ agent_skills_required_entries }}"
loop_control:
label: "{{ item.name }}"
delegate_to: localhost
become: false
check_mode: false
- name: Build missing scenario skills list
ansible.builtin.set_fact:
agent_skills_missing_entries: >-
{{
agent_skills_local_skill_presence.results
| selectattr('rc', 'ne', 0)
| map(attribute='item')
| list
}}
- name: Install missing scenario skills from local installer adapters
ansible.builtin.shell: |
set -eu
skill_name={{ item.name | quote }}
target_dir={{ agent_skills_local_source_dir | quote }}
target_parent="$(dirname "$target_dir")"
target_base="$(basename "$target_dir")"
install_rc=1
if command -v clawhub >/dev/null 2>&1; then
for install_name in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
if clawhub --workdir "$target_parent" --dir "$target_base" --no-input install \
{{ (item.install_force | default(false) | bool) | ternary('--force', '') }} "$install_name"; then
install_rc=0
break
fi
done
exit "$install_rc"
elif command -v find-skills >/dev/null 2>&1; then
for install_name in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
if find-skills install "$install_name" --target "$target_dir"; then
install_rc=0
break
fi
done
exit "$install_rc"
elif [ "{{ agent_skills_auto_install_fail_on_missing_installer | bool | ternary('true', 'false') }}" = "true" ]; then
echo "Missing installer for $skill_name. Install clawhub or find-skills, or preseed $target_dir/$skill_name." >&2
exit 127
else
echo "Skipped missing skill $skill_name because no installer adapter is available." >&2
fi
args:
executable: /bin/bash
loop: "{{ agent_skills_missing_entries }}"
loop_control:
label: "{{ item.name }}"
register: agent_skills_install_result
changed_when: agent_skills_install_result.rc == 0
delegate_to: localhost
become: false
when:
- agent_skills_auto_install_enabled | bool
- agent_skills_missing_entries | length > 0
- name: Reinspect required local scenario skills after auto install
ansible.builtin.shell: |
set -eu
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:
executable: /bin/bash
register: agent_skills_local_skill_presence_after_install
changed_when: false
failed_when: false
loop: "{{ agent_skills_required_entries }}"
loop_control:
label: "{{ item.name }}"
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:
agent_skills_unresolved_entries: >-
{{
(
agent_skills_auto_install_enabled | bool
)
| ternary(
agent_skills_local_skill_presence_after_install.results | default([]),
agent_skills_local_skill_presence.results | default([])
)
| selectattr('rc', 'ne', 0)
| map(attribute='item.name')
| list
}}
- name: Assert required scenario skills are available locally
ansible.builtin.assert:
that:
- agent_skills_unresolved_entries | length == 0
fail_msg: >-
Required scenario skills are still missing from {{ agent_skills_local_source_dir }}:
{{ agent_skills_unresolved_entries | join(', ') }}.
- name: Build resolved local scenario skill paths
ansible.builtin.set_fact:
agent_skills_resolved_local_paths: >-
{{
(
(agent_skills_auto_install_enabled | bool)
| ternary(
agent_skills_local_skill_presence_after_install.results | default([]),
agent_skills_local_skill_presence.results | default([])
)
)
| selectattr('rc', 'eq', 0)
| map(attribute='stdout')
| list
| unique
}}
- name: Run optional scenario skill quality gates
ansible.builtin.shell: |
set -eu
skill_path={{ item.0 | quote }}
gate_name={{ item.1.name | quote }}
if command -v "$gate_name" >/dev/null 2>&1; then
{{ item.1.argv_prefix | map('quote') | join(' ') }} "$skill_path"
else
echo "Skipped missing quality gate: $gate_name"
fi
args:
executable: /bin/bash
register: agent_skills_quality_gate_results
changed_when: false
failed_when: agent_skills_quality_gate_fail_on_error | bool and agent_skills_quality_gate_results.rc != 0
loop: "{{ agent_skills_resolved_local_paths | product(agent_skills_quality_gate_commands) | list }}"
loop_control:
label: "{{ item.1.name }} {{ item.0 | basename }}"
delegate_to: localhost
become: false
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:
paths: "{{ agent_skills_local_source_dir }}"
file_type: link
recurse: false
register: agent_skills_local_symlinks
delegate_to: localhost
become: false
- name: Build rsync excludes for local symlink skills
ansible.builtin.set_fact:
agent_skills_local_symlink_excludes: >-
{{
agent_skills_local_symlinks.files
| map(attribute='path')
| map('basename')
| list
}}
- name: Install rsync for agent skills sync
ansible.builtin.apt:
name: rsync
state: present
update_cache: true
environment:
DEBIAN_FRONTEND: noninteractive
APT_LISTCHANGES_FRONTEND: none
when:
- agent_skills_install_rsync | bool
- ansible_os_family != 'Darwin'
- name: Ensure agent skills owner home exists
ansible.builtin.file:
path: "{{ agent_skills_home }}"
@ -36,187 +297,60 @@
group: "{{ agent_skills_group }}"
mode: "0755"
# --- 源获取:在目标主机 git clone最通用 ---------------------------------
- name: Ensure core skills checkout parent exists
ansible.builtin.file:
path: "{{ agent_skills_xworkspace_core_clone_dir | dirname }}"
state: directory
owner: "{{ agent_skills_user }}"
group: "{{ agent_skills_group }}"
mode: "0755"
when: agent_skills_xworkspace_core_enabled | bool
- name: Clone/update xworkspace core skills on the target host
ansible.builtin.git:
repo: "{{ agent_skills_xworkspace_core_repo_url }}"
dest: "{{ agent_skills_xworkspace_core_clone_dir }}"
version: "{{ agent_skills_xworkspace_core_version }}"
depth: 1
force: true
become_user: "{{ agent_skills_user }}"
register: agent_skills_core_clone
when: agent_skills_xworkspace_core_enabled | bool
- name: Inspect core skills directory
ansible.builtin.stat:
path: "{{ agent_skills_xworkspace_core_source_dir }}"
register: agent_skills_core_skills_stat
when: agent_skills_xworkspace_core_enabled | bool
- name: Require core skills directory when enabled and required
ansible.builtin.assert:
that:
- agent_skills_core_skills_stat.stat.isdir | default(false)
fail_msg: "core skills dir missing after clone: {{ agent_skills_xworkspace_core_source_dir }}"
when:
- agent_skills_xworkspace_core_enabled | bool
- agent_skills_xworkspace_core_required | bool
- name: Build skill search dirs (canonical + core checkout)
ansible.builtin.set_fact:
agent_skills_search_dirs: >-
- name: Sync local agent skills into canonical directory
ansible.builtin.command:
argv: >-
{{
[agent_skills_remote_dir]
[
'rsync',
'-a',
'--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 and agent_skills_source_index == 0) else [])
+ (['--delete-excluded'] if (agent_skills_delete_removed | bool and agent_skills_source_index == 0) else [])
+ (
(
agent_skills_xworkspace_core_enabled | bool
and agent_skills_core_skills_stat.stat.isdir | default(false)
agent_skills_rsync_excludes
+ ((agent_skills_source_index == 0) | ternary(agent_skills_local_symlink_excludes, []))
)
| ternary([agent_skills_xworkspace_core_source_dir], [])
| map('regex_replace', '^(.*)$', '--exclude=\1')
| list
)
+ agent_skills_rsync_extra_opts
+ (
((ansible_connection | default('ssh')) == 'local')
| ternary(
[],
['-e', 'ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null']
)
)
+ [
item ~ '/',
(
((ansible_connection | default('ssh')) == 'local')
)
| ternary(
agent_skills_remote_dir ~ '/',
(
ansible_user | default(ansible_ssh_user) | default('root')
) ~ '@' ~ (
ansible_host | default(inventory_hostname)
) ~ ':' ~ agent_skills_remote_dir ~ '/'
)
]
}}
# --- 缺失场景技能:用 installer 适配器装到 canonical主机本地 --------------
- name: Inspect required scenario skills presence
ansible.builtin.shell: |
set -eu
for d in {{ agent_skills_search_dirs | map('quote') | join(' ') }}; do
for c in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
if [ -f "$d/$c/SKILL.md" ]; then printf '%s\n' "$d/$c"; exit 0; fi
m="$(find "$d" -type f -path "*/$c/SKILL.md" -print -quit 2>/dev/null || true)"
if [ -n "$m" ]; then dirname "$m"; exit 0; fi
done
done
exit 1
args:
executable: /bin/bash
register: agent_skills_presence
changed_when: false
failed_when: false
register: agent_skills_rsync_result
changed_when: "'<<CHANGED>>' in agent_skills_rsync_result.stdout"
check_mode: false
loop: "{{ agent_skills_required_entries }}"
loop: "{{ agent_skills_effective_source_dirs }}"
loop_control:
label: "{{ item.name }}"
- name: Build missing scenario skills list
ansible.builtin.set_fact:
agent_skills_missing_entries: >-
{{ agent_skills_presence.results | selectattr('rc', 'ne', 0) | map(attribute='item') | list }}
- name: Install missing scenario skills via installer adapters (clawhub/find-skills)
ansible.builtin.shell: |
set -eu
skill={{ item.name | quote }}
target_dir={{ agent_skills_remote_dir | quote }}
parent="$(dirname "$target_dir")"; base="$(basename "$target_dir")"
rc=1
if command -v clawhub >/dev/null 2>&1; then
for n in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
if clawhub --workdir "$parent" --dir "$base" --no-input install {{ (item.install_force | default(false) | bool) | ternary('--force', '') }} "$n"; then rc=0; break; fi
done
exit "$rc"
elif command -v find-skills >/dev/null 2>&1; then
for n in {{ ([item.install_name | default(item.name)] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
if find-skills install "$n" --target "$target_dir"; then rc=0; break; fi
done
exit "$rc"
elif [ "{{ agent_skills_auto_install_fail_on_missing_installer | bool | ternary('true', 'false') }}" = "true" ]; then
echo "No installer (clawhub/find-skills) for $skill; preseed $target_dir/$skill." >&2
exit 127
else
echo "Skipped missing $skill (no installer adapter)." >&2
fi
args:
executable: /bin/bash
become_user: "{{ agent_skills_user }}"
register: agent_skills_install_result
changed_when: agent_skills_install_result.rc == 0
loop: "{{ agent_skills_missing_entries }}"
loop_control:
label: "{{ item.name }}"
when:
- agent_skills_auto_install_enabled | bool
- agent_skills_missing_entries | length > 0
# --- 合并 core 技能到 canonical主机本地 copy无 rsync、无 delegate --------
- name: Merge core skills into canonical directory
ansible.builtin.copy:
src: "{{ agent_skills_xworkspace_core_source_dir }}/"
dest: "{{ agent_skills_remote_dir }}/"
remote_src: true
owner: "{{ agent_skills_user }}"
group: "{{ agent_skills_group }}"
mode: preserve
when:
- agent_skills_xworkspace_core_enabled | bool
- agent_skills_core_skills_stat.stat.isdir | default(false)
- name: Re-inspect required scenario skills in canonical dir
ansible.builtin.shell: |
set -eu
d={{ agent_skills_remote_dir | quote }}
for c in {{ ([item.name] + (item.aliases | default([]))) | unique | map('quote') | join(' ') }}; do
if [ -f "$d/$c/SKILL.md" ]; then printf '%s\n' "$d/$c"; exit 0; fi
m="$(find "$d" -type f -path "*/$c/SKILL.md" -print -quit 2>/dev/null || true)"
if [ -n "$m" ]; then dirname "$m"; exit 0; fi
done
exit 1
args:
executable: /bin/bash
register: agent_skills_presence_final
changed_when: false
failed_when: false
check_mode: false
loop: "{{ agent_skills_required_entries }}"
loop_control:
label: "{{ item.name }}"
- name: Assert required scenario skills are available
ansible.builtin.assert:
that:
- (agent_skills_presence_final.results | selectattr('rc', 'ne', 0) | list | length) == 0
fail_msg: >-
Required scenario skills still missing under {{ agent_skills_remote_dir }}:
{{ agent_skills_presence_final.results | selectattr('rc', 'ne', 0) | map(attribute='item.name') | join(', ') }}.
- name: Build resolved skill paths
ansible.builtin.set_fact:
agent_skills_resolved_paths: >-
{{ agent_skills_presence_final.results | selectattr('rc', 'eq', 0) | map(attribute='stdout') | list | unique }}
- name: Run optional scenario skill quality gates
ansible.builtin.shell: |
set -eu
skill_path={{ item.0 | quote }}
gate_name={{ item.1.name | quote }}
if command -v "$gate_name" >/dev/null 2>&1; then
{{ item.1.argv_prefix | map('quote') | join(' ') }} "$skill_path"
else
echo "Skipped missing quality gate: $gate_name"
fi
args:
executable: /bin/bash
become_user: "{{ agent_skills_user }}"
register: agent_skills_quality_gate_results
changed_when: false
failed_when: agent_skills_quality_gate_fail_on_error | bool and agent_skills_quality_gate_results.rc != 0
loop: "{{ agent_skills_resolved_paths | product(agent_skills_quality_gate_commands) | list }}"
loop_control:
label: "{{ item.1.name }} {{ item.0 | basename }}"
when:
- agent_skills_quality_gate_enabled | bool
- agent_skills_resolved_paths | length > 0
check_mode: false
index_var: agent_skills_source_index
label: "{{ item }}"
delegate_to: localhost
become: false
- name: Set canonical agent skills ownership
ansible.builtin.file:
@ -226,7 +360,6 @@
group: "{{ agent_skills_group }}"
recurse: true
# --- 把分类嵌套技能在 canonical 根做扁平 symlink主机本地 ------------------
- name: Link nested categorized skills at canonical root
ansible.builtin.shell: |
set -eu
@ -235,9 +368,13 @@
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
if [ -e "$link_path" ] && [ ! -L "$link_path" ]; then
continue
fi
current_target=""
if [ -L "$link_path" ]; then current_target="$(readlink "$link_path")"; fi
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"
@ -245,7 +382,9 @@
changed=1
fi
done < <(find {{ agent_skills_remote_dir | quote }} -mindepth 3 -name SKILL.md -type f -print)
if [ "$changed" = "1" ]; then echo "<<CHANGED>>linked nested skills"; fi
if [ "$changed" = "1" ]; then
echo "<<CHANGED>>linked nested skills"
fi
args:
executable: /bin/bash
register: agent_skills_flatten_result
@ -262,7 +401,6 @@
recurse: true
when: agent_skills_remote_flatten_nested_skills | bool
# --- 把各 Agent 的 skills 目录 symlink 到 canonical ---------------------------
- name: Flatten agent skills target paths
ansible.builtin.set_fact:
agent_skills_target_paths: "{{ agent_skills_targets | subelements('paths') | map('last') | list }}"
@ -277,8 +415,8 @@
ansible.builtin.fail:
msg: >-
Agent skills target already exists and is not a symlink: {{ item.item }}.
Set agent_skills_replace_existing_target_dirs=true to replace it with a link
to {{ agent_skills_remote_dir }}.
Set agent_skills_replace_existing_target_dirs=true if this path should be
replaced with a link to {{ agent_skills_remote_dir }}.
loop: "{{ agent_skills_target_path_stats.results }}"
when:
- item.stat.exists | default(false)
@ -339,10 +477,11 @@
ansible.builtin.assert:
that:
- agent_skills_manifest_files.matched | int > 0
fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }}."
fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }} after sync."
- name: Report synced agent skills
ansible.builtin.debug:
msg: >-
{{ agent_skills_manifest_files.matched }} skill manifests under
{{ agent_skills_remote_dir }}; linked {{ agent_skills_target_paths | length }} agent targets.
Synced {{ agent_skills_manifest_files.matched }} skill manifests into
{{ agent_skills_remote_dir }} and linked {{ agent_skills_target_paths | length }}
agent target directories.

View File

@ -14,16 +14,12 @@
google-chrome-stable \
/usr/bin/chromium \
/snap/bin/chromium; do
resolved=""
if command -v "$candidate" >/dev/null 2>&1; then
resolved="$(command -v "$candidate")"
elif [ -x "$candidate" ]; then
resolved="$candidate"
command -v "$candidate"
exit 0
fi
# 必须真正可执行:跳过 xfce 安装的 disabled chromium stub退出码 126
# 否则 resolver 会选中它,后续 --version 校验必失败。
if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then
printf '%s\n' "$resolved"
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
exit 0
fi
done
@ -41,8 +37,6 @@
state: present
update_cache: true
install_recommends: false
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
environment:
DEBIAN_FRONTEND: noninteractive
APT_LISTCHANGES_FRONTEND: none
@ -66,16 +60,12 @@
/usr/bin/chromium \
/snap/bin/chromium \
"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; do
resolved=""
if command -v "$candidate" >/dev/null 2>&1; then
resolved="$(command -v "$candidate")"
elif [ -x "$candidate" ]; then
resolved="$candidate"
command -v "$candidate"
exit 0
fi
# 必须真正可执行:跳过 xfce 安装的 disabled chromium stub退出码 126
# 否则 resolver 会选中它,后续 --version 校验必失败。
if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then
printf '%s\n' "$resolved"
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
exit 0
fi
done

View File

@ -5,8 +5,6 @@
state: present
update_cache: true
install_recommends: false
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
environment:
DEBIAN_FRONTEND: noninteractive
APT_LISTCHANGES_FRONTEND: none

View File

@ -5,8 +5,6 @@
state: present
update_cache: true
install_recommends: false
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
environment:
DEBIAN_FRONTEND: noninteractive
APT_LISTCHANGES_FRONTEND: none

View File

@ -11,8 +11,6 @@
state: present
update_cache: true
install_recommends: false
# 等 dpkg 前端锁,避免与 cloud-init/unattended-upgrades 抢锁而立即失败
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
environment:
DEBIAN_FRONTEND: noninteractive
APT_LISTCHANGES_FRONTEND: none

View File

@ -6,10 +6,10 @@ Reusable Ansible role for creating and updating Cloudflare DNS records in the `s
- Zone lookup by name, or direct `cloudflare_dns_zone_id`
- Create/update/delete of managed DNS records
- Token resolution from Ansible extra vars, with the DNS-scoped token preferred:
- Token resolution from Ansible extra vars:
- `-e CLOUDFLARE_DNS_API_TOKEN=...`
- `-e CLOUDFLARE_API_TOKEN=...`
- Environment-backed token resolution as fallback, with the DNS-scoped token preferred:
- Environment-backed token resolution as fallback:
- `CLOUDFLARE_DNS_API_TOKEN`
- `CLOUDFLARE_API_TOKEN`

View File

@ -78,7 +78,7 @@
- "'#zone:read' in (cloudflare_dns_zone_lookup.json.result[0].permissions | default([]))"
- "'#dns_records:edit' in (cloudflare_dns_zone_lookup.json.result[0].permissions | default([]))"
fail_msg: >-
CLOUDFLARE_DNS_API_TOKEN is valid but lacks DNS edit permission for {{ cloudflare_dns_zone_name }}.
CLOUDFLARE_API_TOKEN is valid but lacks DNS edit permission for {{ cloudflare_dns_zone_name }}.
Current permissions: {{ cloudflare_dns_zone_lookup.json.result[0].permissions | default([]) }}.
Required: Zone read + DNS edit on the svc.plus zone.
when:

View File

@ -1,16 +0,0 @@
---
postgresql_image: "ghcr.io/x-evor/images/postgresql:17"
postgresql_compose_project_dir: "{{ '/opt/ai-workspace/postgres' if ansible_os_family != 'Darwin' else lookup('env', 'HOME') + '/ai-workspace-postgres' }}"
postgresql_compose_project_name: "ai-workspace-postgres"
postgresql_compose_file: "{{ postgresql_compose_project_dir }}/docker-compose.yml"
postgresql_compose_env_file: "{{ postgresql_compose_project_dir }}/.env"
postgresql_data_dir: "{{ postgresql_compose_project_dir }}/data"
postgresql_admin_user: postgres
postgresql_admin_password: "changeme"
postgresql_database: postgres
postgresql_port: 5432
postgresql_local_port: 15432
postgresql_container_uid: "999"
postgresql_container_gid: "999"

View File

@ -1,100 +0,0 @@
-- PostgreSQL initialization script
-- This script runs automatically on first container startup
-- Create extensions
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_jieba;
CREATE EXTENSION IF NOT EXISTS pgmq;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create a sample database for testing
CREATE DATABASE appdb;
-- Connect to the new database
\c appdb
-- Recreate extensions in the new database
CREATE EXTENSION IF NOT EXISTS vector;
CREATE EXTENSION IF NOT EXISTS pg_jieba;
CREATE EXTENSION IF NOT EXISTS pgmq;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create a sample schema
CREATE SCHEMA IF NOT EXISTS app;
-- Sample table with vector embeddings
CREATE TABLE IF NOT EXISTS app.documents (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- OpenAI ada-002 dimension
metadata JSONB,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Create indexes
CREATE INDEX IF NOT EXISTS idx_documents_embedding ON app.documents
USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
CREATE INDEX IF NOT EXISTS idx_documents_metadata ON app.documents
USING gin (metadata);
CREATE INDEX IF NOT EXISTS idx_documents_content ON app.documents
USING gin (to_tsvector('english', content));
-- Sample table for node management
CREATE TABLE IF NOT EXISTS app.nodes (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT NOT NULL,
location TEXT NOT NULL,
address TEXT NOT NULL,
port INTEGER NOT NULL DEFAULT 443,
server_name TEXT,
protocols JSONB NOT NULL DEFAULT '[]'::jsonb,
available BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
-- Index for available nodes
CREATE INDEX IF NOT EXISTS idx_nodes_available ON app.nodes (available);
-- Sample table with Chinese full-text search
CREATE TABLE IF NOT EXISTS app.articles_zh (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
title TEXT NOT NULL,
content TEXT NOT NULL,
tags TEXT[],
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_articles_zh_content ON app.articles_zh
USING gin (to_tsvector('jiebacfg', content));
-- Sample key-value store using hstore
CREATE TABLE IF NOT EXISTS app.sessions (
session_id TEXT PRIMARY KEY,
data hstore NOT NULL,
expires_at TIMESTAMP NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_sessions_expires ON app.sessions (expires_at);
-- Create a message queue
SELECT pgmq.create('task_queue');
SELECT pgmq.create('notification_queue');
-- Grant permissions (adjust as needed)
-- GRANT ALL PRIVILEGES ON SCHEMA app TO your_app_user;
-- GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA app TO your_app_user;
COMMENT ON DATABASE appdb IS 'Application database with vector search, full-text search, and message queue capabilities';
COMMENT ON SCHEMA app IS 'Main application schema';
COMMENT ON TABLE app.documents IS 'Documents with vector embeddings for semantic search';
COMMENT ON TABLE app.articles_zh IS 'Chinese articles with jieba tokenization';
COMMENT ON TABLE app.sessions IS 'Session storage using hstore';

View File

@ -1,88 +0,0 @@
# PostgreSQL Configuration
# Optimized for application workloads with vector search and full-text search
# Connection Settings
listen_addresses = '*'
port = 5432
max_connections = 100
superuser_reserved_connections = 3
# Memory Settings
shared_buffers = 256MB
effective_cache_size = 1GB
maintenance_work_mem = 64MB
work_mem = 16MB
# Write-Ahead Log
wal_buffers = 16MB
min_wal_size = 1GB
max_wal_size = 4GB
checkpoint_completion_target = 0.9
wal_compression = on
# Query Tuning
random_page_cost = 1.1 # Lower for SSD
effective_io_concurrency = 200
default_statistics_target = 100
# Logging
log_destination = 'stderr'
logging_collector = on
log_directory = 'log'
log_filename = 'postgresql-%Y-%m-%d_%H%M%S.log'
log_rotation_age = 1d
log_rotation_size = 100MB
log_line_prefix = '%t [%p]: [%l-1] user=%u,db=%d,app=%a,client=%h '
log_timezone = 'UTC'
# What to Log
log_checkpoints = on
log_connections = on
log_disconnections = on
log_duration = off
log_lock_waits = on
log_statement = 'none'
log_temp_files = 0
# Slow Query Logging
log_min_duration_statement = 1000 # Log queries slower than 1 second
# Locale and Formatting
datestyle = 'iso, mdy'
timezone = 'UTC'
lc_messages = 'en_US.utf8'
lc_monetary = 'en_US.utf8'
lc_numeric = 'en_US.utf8'
lc_time = 'en_US.utf8'
default_text_search_config = 'pg_catalog.english'
# Extension-specific settings
# pgvector settings
# No specific configuration needed, but ensure shared_buffers is adequate
# pg_jieba settings
# Default configuration is usually sufficient
# Full-text search
# Increase work_mem if doing complex text searches
# work_mem = 32MB # Uncomment if needed
# Performance for JSONB
# GIN indexes benefit from larger maintenance_work_mem during creation
# Connection Pooling (if using PgBouncer)
# Consider lowering max_connections and using PgBouncer
# Security
# ssl = on
# ssl_cert_file = '/path/to/server.crt'
# ssl_key_file = '/path/to/server.key'
# ssl_ca_file = '/path/to/ca.crt'
# Uncomment for production SSL/TLS
# ssl_prefer_server_ciphers = on
# ssl_ciphers = 'HIGH:MEDIUM:+3DES:!aNULL'
# Client Authentication
# Edit pg_hba.conf for detailed access control

View File

@ -1,93 +1,5 @@
---
- name: Ensure Homebrew Docker and Colima are installed (macOS)
ansible.builtin.command: brew install colima docker docker-compose
environment:
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
HOMEBREW_NO_AUTO_UPDATE: "1"
register: brew_install
changed_when: >-
'already installed' not in (brew_install.stderr | default(''))
and 'already installed' not in (brew_install.stdout | default(''))
when: ansible_os_family == 'Darwin'
- name: Ensure Colima is started (macOS)
ansible.builtin.command: colima start
environment:
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
register: colima_start
changed_when: "'already running' not in colima_start.stdout and 'already running' not in colima_start.stderr"
when: ansible_os_family == 'Darwin'
- name: Ensure PostgreSQL compose project directory exists
ansible.builtin.file:
path: "{{ postgresql_compose_project_dir }}"
state: directory
mode: "0755"
- name: Ensure PostgreSQL init-scripts directory exists
ansible.builtin.file:
path: "{{ postgresql_compose_project_dir }}/init-scripts"
state: directory
mode: "0755"
- name: Ensure PostgreSQL data directory exists
ansible.builtin.file:
path: "{{ postgresql_data_dir }}"
state: directory
mode: "0700"
owner: "{{ postgresql_container_uid }}"
group: "{{ postgresql_container_gid }}"
# macOS/Colima usually handles volume mounts with current user, but uid 999 is standard for postgres container
ignore_errors: "{{ ansible_os_family == 'Darwin' }}"
- name: Render PostgreSQL compose environment file
ansible.builtin.copy:
dest: "{{ postgresql_compose_env_file }}"
mode: "0600"
content: |
POSTGRES_DB={{ postgresql_database }}
POSTGRES_USER={{ postgresql_admin_user }}
POSTGRES_PASSWORD={{ postgresql_admin_password }}
no_log: true
- name: Render PostgreSQL compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: "{{ postgresql_compose_file }}"
mode: "0644"
- name: Copy postgresql.conf
ansible.builtin.copy:
src: postgresql.conf
dest: "{{ postgresql_compose_project_dir }}/postgresql.conf"
mode: "0644"
- name: Copy init-extensions script
ansible.builtin.copy:
src: init-scripts/01-init-extensions.sql
dest: "{{ postgresql_compose_project_dir }}/init-scripts/01-init-extensions.sql"
mode: "0644"
- name: Start PostgreSQL compose service
ansible.builtin.command:
cmd: "docker compose -f {{ postgresql_compose_file }} -p {{ postgresql_compose_project_name }} up -d --remove-orphans"
chdir: "{{ postgresql_compose_project_dir }}"
register: postgresql_compose_up
changed_when: >-
'Started' in (postgresql_compose_up.stdout | default('')) or
'Created' in (postgresql_compose_up.stdout | default('')) or
'Recreated' in (postgresql_compose_up.stdout | default('')) or
'Pulled' in (postgresql_compose_up.stdout | default(''))
environment:
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
- name: Validate PostgreSQL compose service
ansible.builtin.command:
cmd: "docker exec {{ postgresql_compose_project_name }} pg_isready -U {{ postgresql_admin_user }} -d {{ postgresql_database }}"
register: postgresql_compose_ready
retries: 12
delay: 5
until: postgresql_compose_ready.rc == 0
changed_when: false
environment:
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH | default('/usr/bin:/bin') }}"
# TODO: implement docker deployment tasks
- name: Placeholder task
debug:
msg: "Role placeholder. Implement docker deployment tasks."

View File

@ -1,45 +0,0 @@
services:
postgres:
image: "{{ postgresql_image }}"
container_name: "{{ postgresql_compose_project_name }}"
restart: unless-stopped
env_file:
- "{{ postgresql_compose_env_file }}"
# PostgreSQL 只监听 localhost,通过 stunnel 提供外部访问
# 不直接暴露端口,确保所有连接都经过 TLS 加密
expose:
- "5432"
ports:
- "{{ postgresql_local_port }}:5432"
- "{{ postgresql_port }}:5432"
volumes:
- "{{ postgresql_data_dir }}:/var/lib/postgresql/data"
- ./init-scripts:/docker-entrypoint-initdb.d:ro
- ./postgresql.conf:/etc/postgresql/postgresql.conf:ro
healthcheck:
test: [ "CMD-SHELL", "pg_isready -U {{ postgresql_admin_user }} -h 127.0.0.1" ]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- postgres_network
# Resource limits (adjust based on your needs)
deploy:
resources:
limits:
cpus: '2'
memory: 2G
reservations:
cpus: '1'
memory: 1G
networks:
postgres_network:
driver: bridge

View File

@ -8,9 +8,9 @@ acp_gemini_xdg_config_home: "{{ acp_gemini_home }}/.config"
acp_gemini_xdg_state_home: "{{ acp_gemini_home }}/.local/state"
acp_gemini_config_dir: "{{ acp_gemini_home }}/.gemini"
acp_gemini_npm_global_bin: "{{ acp_gemini_home + '/.local/bin' if ansible_os_family == 'Darwin' else '/usr/bin' }}"
acp_gemini_binary_path: "{{ acp_gemini_npm_global_bin }}/antigravity-cli"
acp_gemini_binary_path: "{{ acp_gemini_npm_global_bin }}/gemini"
acp_gemini_path: "{{ acp_gemini_npm_global_bin }}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin"
acp_gemini_args: mcp-app-server
acp_gemini_args: --experimental-acp
acp_gemini_bridge_local_source_dir: "{{ playbook_dir }}/../xworkmate-bridge"
acp_gemini_bridge_local_build_dir: "{{ playbook_dir }}/.artifacts/acp_gemini"
acp_gemini_bridge_local_binary_path: "{{ acp_gemini_bridge_local_build_dir }}/xworkmate-go-core"

View File

@ -69,21 +69,6 @@
changed_when: true
become: true
- name: Ensure Gemini ACP runtime directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ acp_gemini_service_user }}"
group: "{{ acp_gemini_service_group }}"
mode: "0755"
loop:
- "{{ acp_gemini_home }}"
- "{{ acp_gemini_workdir }}"
- "{{ acp_gemini_xdg_config_home }}"
- "{{ acp_gemini_xdg_state_home }}"
become: true
when: ansible_os_family != 'Darwin'
- name: Deploy Gemini ACP systemd service
ansible.builtin.command:
cmd: lsattr "/etc/systemd/system/{{ acp_gemini_service_name }}.service"

View File

@ -19,7 +19,7 @@ Environment=GEMINI_ADAPTER_ALLOWED_ORIGINS={{ acp_gemini_allowed_origins | join(
Environment={{ key }}={{ value }}
{% endif %}
{% endfor %}
ExecStart={{ acp_gemini_bridge_binary_path }} adapter gemini -listen {{ acp_gemini_listen_host }}:{{ acp_gemini_listen_port }} -gemini-bin {{ acp_gemini_binary_path }} -gemini-args "{{ acp_gemini_args }}"
ExecStart={{ acp_gemini_bridge_binary_path }} adapter gemini --listen {{ acp_gemini_listen_host }}:{{ acp_gemini_listen_port }} --gemini-bin {{ acp_gemini_binary_path }} --gemini-args "{{ acp_gemini_args }}"
Restart=always
RestartSec=2

View File

@ -11,10 +11,6 @@ acp_opencode_workdir: "{{ ansible_env.HOME | default('/home/' + acp_opencode_ser
# user-level npm global bin lives under ~/.local/bin; include Homebrew + system.
acp_opencode_npm_global_bin: "{{ acp_opencode_home + '/.local/bin' if ansible_os_family == 'Darwin' else '/usr/bin' }}"
acp_opencode_path: "{{ acp_opencode_npm_global_bin }}:/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/local/sbin:/usr/bin:/bin"
# OpenCode CLI binary the adapter spawns lazily (mirrors gemini/codex which use
# {{ acp_X_npm_global_bin }}/<cli>). Was hardcoded to /usr/bin/opencode in the
# unit template; use the resolved npm global bin so macOS (~/.local/bin) works too.
acp_opencode_binary_path: "{{ acp_opencode_npm_global_bin }}/opencode"
acp_opencode_listen_host: 127.0.0.1
acp_opencode_listen_port: 38992
acp_opencode_packages: []

View File

@ -79,29 +79,6 @@
- "{{ acp_opencode_home }}/.local"
- "{{ acp_opencode_workdir }}"
# Resolve the OpenCode CLI at deploy time instead of assuming a fixed path.
# npm installs it wherever the active node prefix points (NodeSource -> /usr/bin
# on Debian/Ubuntu, ~/.local/bin or Homebrew on macOS), so probe the real path
# with the role PATH and fall back to the OS-aware default when not yet present.
- name: Resolve OpenCode CLI binary path
ansible.builtin.shell: |
set -eu
export PATH="{{ acp_opencode_path }}:${PATH}"
command -v opencode || true
args:
executable: /bin/bash
register: acp_opencode_resolved_bin
changed_when: false
- name: Use resolved OpenCode CLI binary path when present
ansible.builtin.set_fact:
acp_opencode_binary_path: "{{ acp_opencode_resolved_bin.stdout_lines[0] | trim }}"
when: acp_opencode_resolved_bin.stdout | default('') | trim | length > 0
- name: Report effective OpenCode CLI binary path
ansible.builtin.debug:
msg: "OpenCode CLI binary resolved to: {{ acp_opencode_binary_path }}"
- name: Deploy Caddy main file
ansible.builtin.template:
src: Caddyfile.j2

View File

@ -17,71 +17,28 @@
changed_when: false
failed_when: false
# 用 curl 重试循环替代 uri服务刚 (重)启时 adapter 会先 accept TCP 但短时间内不
# 应答(读挂起),而 uri 默认 30s 超时 + retries/until 在连接超时上不可靠循环(实测
# 仅试一次即失败)。每次 5s 上限、真重试给冷启动足够时间adapter 就绪后 ~4ms 回 200
# 包在 block/rescue探针失败时把 systemctl status + journalctl 打到 CI 日志,
# 否则 play 在此中止,看不到 adapter 进程真正的崩溃原因(如 last code 000
- name: Validate OpenCode local ACP endpoint
block:
- name: Validate OpenCode local ACP endpoint (readiness retry)
ansible.builtin.shell: |
set -eu
url="http://{{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }}/acp/rpc"
body='{"jsonrpc":"2.0","id":1,"method":"acp.capabilities","params":{}}'
code=""
for i in $(seq 1 30); do
code="$(curl -s -m 5 -o /dev/null -w '%{http_code}' -X POST "$url" \
-H 'Content-Type: application/json' -d "$body" 2>/dev/null || true)"
if [ "$code" = "200" ]; then
echo "OpenCode ACP endpoint ready after ${i} attempt(s)"
exit 0
fi
sleep 2
done
echo "OpenCode ACP endpoint ${url} not ready after retries (last code: ${code:-none})" >&2
exit 1
args:
executable: /bin/bash
changed_when: false
register: acp_opencode_adapter_probe
rescue:
- name: Capture OpenCode ACP service status on failure
ansible.builtin.command: systemctl status "{{ acp_opencode_service_name }}" --no-pager --full
register: acp_opencode_status_fail
changed_when: false
failed_when: false
when: ansible_os_family != 'Darwin'
- name: Capture recent OpenCode ACP service logs on failure
ansible.builtin.command: journalctl -u "{{ acp_opencode_service_name }}" -n 80 --no-pager
register: acp_opencode_journal_fail
changed_when: false
failed_when: false
when: ansible_os_family != 'Darwin'
- name: Show OpenCode ACP failure diagnostics
ansible.builtin.debug:
msg:
- "Probe stderr: {{ acp_opencode_adapter_probe.stderr | default('N/A') }}"
- "Listeners: {{ acp_opencode_ss.stdout | default('N/A') }}"
- "Service status: {{ acp_opencode_status_fail.stdout | default('N/A') }}"
- "Recent logs: {{ acp_opencode_journal_fail.stdout | default('N/A') }}"
- name: Fail after emitting OpenCode ACP diagnostics
ansible.builtin.fail:
msg: >-
OpenCode ACP endpoint
{{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} did not
become ready. See the diagnostics above (service status + journal) for
the adapter crash cause.
ansible.builtin.uri:
url: "http://{{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }}/acp/rpc"
method: POST
body_format: json
body:
jsonrpc: "2.0"
id: 1
method: acp.capabilities
params: {}
return_content: true
status_code: 200
register: acp_opencode_adapter_http
retries: 30
delay: 2
until: acp_opencode_adapter_http.status == 200
- name: Show OpenCode ACP status
ansible.builtin.command: systemctl status "{{ acp_opencode_service_name }}" --no-pager
register: acp_opencode_status
changed_when: false
failed_when: false
when: ansible_os_family != 'Darwin'
- name: Show OpenCode ACP validation summary
ansible.builtin.debug:
@ -90,6 +47,6 @@
- "Preferred WebSocket endpoint: {{ acp_opencode_public_base_url }}/acp"
- "Compatibility HTTP RPC endpoint: {{ acp_opencode_public_base_url }}/acp/rpc"
- "OpenCode ACP adapter listener: {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }}"
- "Readiness probe: {{ acp_opencode_adapter_probe.stdout | default('N/A') }}"
- "Service: {{ acp_opencode_status.stdout | default('N/A') }}"
- "Socket: {{ acp_opencode_ss.stdout | default('N/A') }}"
- "Adapter capabilities HTTP: {{ acp_opencode_adapter_http.content | default('N/A') }}"

View File

@ -0,0 +1,27 @@
[Unit]
Description=XWorkmate OpenCode ACP bridge server
After=network-online.target {{ acp_opencode_service_name }}.service
Wants=network-online.target
[Service]
Type=simple
User={{ acp_opencode_service_user }}
Group={{ acp_opencode_service_group }}
WorkingDirectory={{ acp_opencode_workdir }}
Environment=HOME={{ acp_opencode_home }}
Environment=TERM=xterm-256color
Environment=ACP_LISTEN_ADDR={{ acp_opencode_bridge_listen_host }}:{{ acp_opencode_bridge_listen_port }}
Environment=ACP_ALLOWED_ORIGINS={{ acp_opencode_bridge_allowed_origins | join(',') }}
{% if acp_opencode_auth_token | trim | length > 0 %}
Environment=ACP_AUTH_TOKEN={{ acp_opencode_auth_token }}
{% endif %}
Environment=ACP_CODEX_BIN={{ acp_opencode_bridge_disabled_binary_path }}
Environment=ACP_CLAUDE_BIN={{ acp_opencode_bridge_disabled_binary_path }}
Environment=ACP_GEMINI_BIN={{ acp_opencode_bridge_disabled_binary_path }}
Environment=ACP_OPENCODE_BIN={{ acp_opencode_bridge_opencode_binary_path }}
ExecStart={{ acp_opencode_bridge_binary_path }} serve --listen {{ acp_opencode_bridge_listen_host }}:{{ acp_opencode_bridge_listen_port }}
Restart=always
RestartSec=2
[Install]
WantedBy=multi-user.target

View File

@ -9,10 +9,9 @@ User={{ acp_opencode_service_user }}
Group={{ acp_opencode_service_group }}
WorkingDirectory={{ acp_opencode_workdir }}
Environment=HOME={{ acp_opencode_home }}
Environment=PATH={{ acp_opencode_path }}
Environment=TERM=xterm-256color
Environment=OPENCODE_ADAPTER_ALLOWED_ORIGINS={{ acp_opencode_bridge_allowed_origins | join(',') }}
ExecStart={{ acp_opencode_bridge_binary_path }} adapter opencode --listen {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} --opencode-bin {{ acp_opencode_binary_path }} --cwd {{ acp_opencode_workdir }}
ExecStart={{ acp_opencode_bridge_binary_path }} adapter opencode --listen {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} --opencode-bin /usr/bin/opencode --cwd {{ acp_opencode_workdir }}
Restart=always
RestartSec=2

View File

@ -17,7 +17,6 @@
exec "{{ acp_opencode_bridge_binary_path }}" adapter opencode \
-listen {{ acp_opencode_listen_host }}:{{ acp_opencode_listen_port }} \
-opencode-bin "{{ acp_opencode_binary_path }}" \
-cwd "{{ acp_opencode_workdir }}"
</string>
</array>

View File

@ -6,53 +6,6 @@ migrate_litellm_db: "litellm"
migrate_litellm_db_user: "litellm"
migrate_litellm_db_host: "127.0.0.1"
# Public bootstrap redirects
ai_workspace_caddy_base_dir: "{{ caddy_config_dir | default('/etc/caddy') }}"
ai_workspace_caddy_conf_dir: "{{ ai_workspace_caddy_base_dir }}/conf.d"
ai_workspace_caddyfile_path: "{{ ai_workspace_caddy_base_dir }}/Caddyfile"
ai_workspace_caddy_fragment_path: "{{ ai_workspace_caddy_conf_dir }}/install.svc.plus.caddy"
ai_workspace_public_domain: "install.svc.plus"
ai_workspace_install_script_url: "https://raw.githubusercontent.com/ai-workspace-lab/xworkspace-console/main/scripts/setup-ai-workspace-all-in-one.sh"
ai_workspace_xworkmate_install_script_url: "https://raw.githubusercontent.com/ai-workspace-lab/xworkmate-app/main/scripts/install-xworkmate-app.sh"
ai_workspace_manage_caddy: true
# Migration paths
openclaw_data_dir: "~/.openclaw"
xworkspace_state_dir: "~/.local/state/xworkspace"
# =============================================================================
# XWorkspace Console runtime — final deployment (consumption only)
#
# The console runtime binary (Go API) and dashboard dist are cross-compiled and
# published by CI:
# ai-workspace-lab/xworkspace-console
# .github/workflows/offline-package-xworkspace-console-runtime.yaml
# as xworkspace-console-runtime-<os>-<arch>.tar.gz (incl. darwin-arm64). This
# role NEVER builds from source; it only downloads/stages the prebuilt tarball,
# unpacks it to a per-user system dir, and deploys the launchd service that
# execs the prebuilt API binary recorded in the package manifest.
# =============================================================================
ai_workspace_console_deploy_enabled: true
ai_workspace_console_runtime_os: "{{ ansible_system | lower }}"
ai_workspace_console_runtime_arch: "{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}"
# Stable moving release maintained by the CI publish job (matches the
# latest-runtime convention used by the bridge/qmd/litellm runtimes).
ai_workspace_console_runtime_release_tag: "latest-runtime"
ai_workspace_console_runtime_release_base: "https://github.com/ai-workspace-lab/xworkspace-console/releases/download/{{ ai_workspace_console_runtime_release_tag }}"
ai_workspace_console_runtime_asset: "xworkspace-console-runtime-{{ ai_workspace_console_runtime_os }}-{{ ai_workspace_console_runtime_arch }}.tar.gz"
ai_workspace_console_runtime_url: "{{ ai_workspace_console_runtime_release_base }}/{{ ai_workspace_console_runtime_asset }}"
# Offline/air-gapped override: a locally-staged runtime tarball. When set it is
# used verbatim and no download happens (offline package path).
ai_workspace_console_runtime_archive: "{{ lookup('ansible.builtin.env', 'XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE') | default('', true) }}"
# The tarball carries a top-level xworkspace-console/ dir, so it is extracted
# into the parent and lands at <parent>/xworkspace-console.
ai_workspace_console_install_parent: "{{ ansible_env.HOME }}/.local/share"
ai_workspace_console_install_dir: "{{ ai_workspace_console_install_parent }}/xworkspace-console"
ai_workspace_console_runtime_marker: "{{ ai_workspace_console_install_dir }}/.runtime-archive-sha256"
ai_workspace_console_manifest_path: "{{ ai_workspace_console_install_dir }}/manifest.json"
# Token env file produced by the console play; the API sources it for auth.
ai_workspace_console_config_dir: "{{ ansible_env.HOME }}/.config/ai-workspace"
ai_workspace_console_portal_env: "{{ ai_workspace_console_config_dir }}/portal.env"
ai_workspace_console_log_dir: "{{ ansible_env.HOME }}/.local/state/xworkspace"
ai_workspace_console_api_label: "plus.svc.xworkspace.api"
ai_workspace_console_api_port: 8788

View File

@ -1,6 +1,5 @@
---
dependencies:
- role: roles/vhosts/caddy
- role: roles/agent_skills
- role: roles/vhosts/gateway_openclaw
- role: roles/vhosts/xworkmate_bridge

View File

@ -1,42 +0,0 @@
---
# macOS final deployment of the console API: run the prebuilt arm64 binary
# from the unpacked runtime via a user LaunchAgent. The binary is self-contained
# (pure Go, no cgo), so it needs neither `go` nor any Homebrew tooling at runtime
# — this avoids the launchd minimal-PATH problem that broke the `go run .` path.
- name: Ensure XWorkspace Console runtime directories exist (macOS)
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ ai_workspace_console_config_dir }}"
- "{{ ai_workspace_console_log_dir }}"
- "{{ ansible_env.HOME }}/Library/LaunchAgents"
- name: Deploy XWorkspace Console API LaunchAgent (macOS)
ansible.builtin.template:
src: xworkspace-api.plist.j2
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/{{ ai_workspace_console_api_label }}.plist"
mode: "0644"
register: ai_workspace_console_api_plist
- name: Restart XWorkspace Console API LaunchAgent on change (macOS)
ansible.builtin.command: "launchctl stop {{ ai_workspace_console_api_label }}"
when: ai_workspace_console_api_plist.changed
changed_when: false
failed_when: false
- name: Load XWorkspace Console API LaunchAgent (macOS)
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/{{ ai_workspace_console_api_label }}.plist"
register: ai_workspace_console_api_load
changed_when: false
failed_when: >-
ai_workspace_console_api_load.rc != 0
and 'already loaded' not in ai_workspace_console_api_load.stderr
- name: Start XWorkspace Console API LaunchAgent on change (macOS)
ansible.builtin.command: "launchctl start {{ ai_workspace_console_api_label }}"
when: ai_workspace_console_api_plist.changed
changed_when: false
failed_when: false

View File

@ -1,156 +0,0 @@
---
- name: Ensure AI Workspace Caddy fragment directory exists
ansible.builtin.file:
path: "{{ ai_workspace_caddy_conf_dir }}"
state: directory
mode: "0755"
when: ai_workspace_manage_caddy | bool
- name: Render install.svc.plus redirect fragment
ansible.builtin.template:
src: Caddyfile.j2
dest: "{{ ai_workspace_caddy_fragment_path }}"
mode: "0644"
register: ai_workspace_caddy_fragment
when: ai_workspace_manage_caddy | bool
- name: Validate Caddy configuration
ansible.builtin.command: >-
caddy validate --config {{ ai_workspace_caddyfile_path }}
changed_when: false
when:
- ai_workspace_manage_caddy | bool
- ai_workspace_caddy_fragment.changed
- name: Reload Caddy after updating install redirects
ansible.builtin.service:
name: caddy
state: reloaded
when:
- ai_workspace_manage_caddy | bool
- ai_workspace_caddy_fragment.changed
# =============================================================================
# Final deployment of the prebuilt XWorkspace Console runtime.
#
# The runtime binary is built in CI and published as
# xworkspace-console-runtime-<os>-<arch>.tar.gz (incl. darwin-arm64). This role
# is consumption-only: download/stage -> unpack to a per-user system dir ->
# read the package manifest -> exec the prebuilt API binary via launchd. It
# never compiles from source and never runs `go`.
# =============================================================================
- name: Resolve XWorkspace Console runtime source
ansible.builtin.set_fact:
ai_workspace_console_runtime_archive_resolved: >-
{{ ai_workspace_console_runtime_archive
if (ai_workspace_console_runtime_archive | length > 0)
else '/tmp/xworkspace-console-runtime.tar.gz' }}
when: ai_workspace_console_deploy_enabled | bool
- name: Ensure XWorkspace Console install parent exists
ansible.builtin.file:
path: "{{ ai_workspace_console_install_parent }}"
state: directory
mode: "0755"
when: ai_workspace_console_deploy_enabled | bool
- name: Download XWorkspace Console runtime release
ansible.builtin.get_url:
url: "{{ ai_workspace_console_runtime_url }}"
dest: "{{ ai_workspace_console_runtime_archive_resolved }}"
mode: "0644"
force: true
# Only fetch from the network when an offline archive was not supplied.
when:
- ai_workspace_console_deploy_enabled | bool
- ai_workspace_console_runtime_archive | length == 0
- name: Stat XWorkspace Console runtime archive
ansible.builtin.stat:
path: "{{ ai_workspace_console_runtime_archive_resolved }}"
checksum_algorithm: sha256
register: ai_workspace_console_runtime_archive_stat
when: ai_workspace_console_deploy_enabled | bool
- name: Require a valid XWorkspace Console runtime archive
ansible.builtin.assert:
that:
- ai_workspace_console_runtime_archive_stat.stat.exists | default(false)
fail_msg: >-
No XWorkspace Console runtime archive at
{{ ai_workspace_console_runtime_archive_resolved }}.
Set XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE (offline) or ensure
{{ ai_workspace_console_runtime_url }} is reachable.
when: ai_workspace_console_deploy_enabled | bool
- name: Read installed XWorkspace Console runtime marker
ansible.builtin.slurp:
path: "{{ ai_workspace_console_runtime_marker }}"
register: ai_workspace_console_runtime_marker_content
failed_when: false
when: ai_workspace_console_deploy_enabled | bool
- name: Install (unpack) XWorkspace Console runtime
ansible.builtin.unarchive:
src: "{{ ai_workspace_console_runtime_archive_resolved }}"
dest: "{{ ai_workspace_console_install_parent }}"
remote_src: true
mode: "0755"
# Re-extract only when the package checksum changed or the binary is missing,
# so repeat runs are idempotent and do not thrash the service.
when:
- ai_workspace_console_deploy_enabled | bool
- >-
(ai_workspace_console_runtime_marker_content.content | default('') | b64decode | trim)
!= (ai_workspace_console_runtime_archive_stat.stat.checksum | default(''))
or not (ai_workspace_console_manifest_path is file)
- name: Read XWorkspace Console runtime manifest
ansible.builtin.slurp:
path: "{{ ai_workspace_console_manifest_path }}"
register: ai_workspace_console_manifest_raw
when: ai_workspace_console_deploy_enabled | bool
- name: Resolve XWorkspace Console API binary from manifest
ansible.builtin.set_fact:
ai_workspace_console_manifest: "{{ ai_workspace_console_manifest_raw.content | b64decode | from_json }}"
when: ai_workspace_console_deploy_enabled | bool
- name: Set XWorkspace Console API binary path
ansible.builtin.set_fact:
ai_workspace_console_api_binary: "{{ ai_workspace_console_install_dir }}/{{ ai_workspace_console_manifest.apiBinary }}"
when: ai_workspace_console_deploy_enabled | bool
- name: Stat XWorkspace Console API binary
ansible.builtin.stat:
path: "{{ ai_workspace_console_api_binary }}"
register: ai_workspace_console_api_binary_stat
when: ai_workspace_console_deploy_enabled | bool
- name: Require an executable XWorkspace Console API binary
ansible.builtin.assert:
that:
- ai_workspace_console_api_binary_stat.stat.exists | default(false)
- ai_workspace_console_api_binary_stat.stat.executable | default(false)
fail_msg: >-
Prebuilt API binary missing or not executable:
{{ ai_workspace_console_api_binary }} (manifest os/arch:
{{ ai_workspace_console_manifest.os | default('?') }}/{{ ai_workspace_console_manifest.arch | default('?') }}).
when: ai_workspace_console_deploy_enabled | bool
- name: Record installed XWorkspace Console runtime marker
ansible.builtin.copy:
dest: "{{ ai_workspace_console_runtime_marker }}"
content: "{{ ai_workspace_console_runtime_archive_stat.stat.checksum }}\n"
mode: "0644"
when:
- ai_workspace_console_deploy_enabled | bool
- ai_workspace_console_runtime_archive_stat.stat.exists | default(false)
# --- macOS service: exec the prebuilt binary directly (no go, no PATH games) ---
- name: Deploy XWorkspace Console API on macOS
ansible.builtin.import_tasks: macos.yml
when:
- ai_workspace_console_deploy_enabled | bool
- ansible_os_family == 'Darwin'

View File

@ -1,6 +0,0 @@
{{ ai_workspace_public_domain }} {
redir /ai-workspace {{ ai_workspace_install_script_url }} 302
redir /ai-workspace/latest {{ ai_workspace_install_script_url }} 302
redir /xworkmate-app {{ ai_workspace_xworkmate_install_script_url }} 302
redir /xworkmate-app/latest {{ ai_workspace_xworkmate_install_script_url }} 302
}

View File

@ -1,29 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{ ai_workspace_console_api_label }}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
<string>-c</string>
<string>
set -a
[ -f "{{ ai_workspace_console_portal_env }}" ] &amp;&amp; . "{{ ai_workspace_console_portal_env }}"
set +a
exec "{{ ai_workspace_console_api_binary }}"
</string>
</array>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>{{ ai_workspace_console_install_dir }}</string>
<key>StandardOutPath</key>
<string>{{ ai_workspace_console_log_dir }}/api.log</string>
<key>StandardErrorPath</key>
<string>{{ ai_workspace_console_log_dir }}/api.err.log</string>
</dict>
</plist>

View File

@ -22,18 +22,8 @@ enable_common: true
# macOS (Darwin) baseline: shared Homebrew CLI prerequisites used by helper
# scripts across roles (e.g. jq is required by vault's init_vault_admin.sh).
# macOS ships curl/base64 already; jq is not present by default.
#
# docker/docker-compose/colima provide a headless container runtime on macOS
# (colima runs the Docker daemon in a lightweight VM; the docker formula is the
# CLI only, no Docker Desktop). Needed for container workloads such as the qmd
# PostgreSQL memory-bridge integration tests (test/pg-compose.yml). Each formula
# installs a /opt/homebrew/bin/<name> binary, so the task's `creates` check stays
# idempotent. After install, start the runtime once with `colima start`.
common_darwin_brew_packages:
- jq
- docker
- docker-compose
- colima
common_firewall:
enabled: true

View File

@ -1,21 +1,9 @@
---
# Debian/Ubuntu 走 aptplay 的 module_defaults.apt.lock_timeout(模板值)经
# ansible.builtin.package 间接派发到 apt 时不渲染 → lock_timeout 收到字面 "{{ ... }}"
# 报 int 转换失败(与 xworkmate_bridge/litellm 同类修复)。
- name: Fail2ban | Install Fail2ban package (Debian/Ubuntu via apt)
ansible.builtin.apt:
name: fail2ban
state: present
update_cache: true
become: true
when: ansible_os_family == 'Debian'
- name: Fail2ban | Install Fail2ban package (non-Debian)
- name: Fail2ban | Install Fail2ban package
ansible.builtin.package:
name: fail2ban
state: present
become: true
when: ansible_os_family != 'Debian'
- name: Fail2ban | Deploy jail.local configuration
ansible.builtin.copy:

View File

@ -23,11 +23,12 @@ gateway_openclaw_install_dir: "{{ gateway_openclaw_home }}/.local/lib/node_modul
gateway_openclaw_required_version: "2026.6.1"
gateway_openclaw_npm_package_spec: "openclaw@{{ gateway_openclaw_required_version }}"
gateway_openclaw_global_npm_dir: "{{ gateway_openclaw_home }}/.openclaw/npm"
gateway_openclaw_multi_session_plugin_archive_url: "https://github.com/ai-workspace-lab/openclaw-multi-session-plugins/releases/download/runtime-latest/openclaw-multi-session-plugins-runtime-all.tar.gz"
gateway_openclaw_multi_session_plugin_archive_url: "https://github.com/ai-workspace-lab/openclaw-multi-session-plugins/releases/latest/download/openclaw-multi-session-plugins-runtime-all.tar.gz"
gateway_openclaw_required_global_plugins:
- name: "@openclaw/codex"
version: "{{ gateway_openclaw_required_version }}"
gateway_openclaw_removed_global_plugins: []
gateway_openclaw_removed_global_plugins:
- "@openclaw/acpx"
gateway_openclaw_extension_dependency_dirs: []
gateway_openclaw_config_path: "{{ gateway_openclaw_home }}/.openclaw/openclaw.json"
gateway_openclaw_workspace_path: "{{ gateway_openclaw_home }}/.openclaw/workspace"
@ -58,63 +59,22 @@ gateway_openclaw_acp_default_agent: codex
gateway_openclaw_codex_app_server_url: ws://127.0.0.1:9001
gateway_openclaw_default_model_primary: "deepseek/deepseek-v4-flash"
gateway_openclaw_default_model_fallback: "deepseek/deepseek-v4-pro"
gateway_openclaw_fallbacks_deepseek:
- "{{ gateway_openclaw_default_model_fallback }}"
- "deepseek/deepseek-chat"
- "deepseek/deepseek-reasoner"
gateway_openclaw_fallbacks_nvidia:
- "nvidia/deepseek-v4-flash"
- "nvidia/deepseek-v4-pro"
- "nvidia/glm-5.1"
- "nvidia/minimax-m3"
- "nvidia/qwen3.5"
- "nvidia/kimi-k2.6"
gateway_openclaw_fallbacks_ollama:
- "ollama/deepseek-v4-flash"
- "ollama/deepseek-v4-pro"
- "ollama/glm-5.2"
- "ollama/minimax-m3"
- "ollama/qwen3.5"
- "ollama/kimi-k2.7-code"
gateway_openclaw_default_model:
primary: "{{ gateway_openclaw_default_model_primary }}"
fallbacks: >-
{{
([]
+ (gateway_openclaw_fallbacks_deepseek if lookup('ansible.builtin.env', 'DEEPSEEK_API_KEY') else [])
+ (gateway_openclaw_fallbacks_nvidia if lookup('ansible.builtin.env', 'NVIDIA_API_KEY') else [])
+ (gateway_openclaw_fallbacks_ollama if lookup('ansible.builtin.env', 'OLLAMA_API_KEY') else []))
| unique | list
}}
gateway_openclaw_default_models_deepseek:
fallbacks:
- "{{ gateway_openclaw_default_model_fallback }}"
- nvidia/nemotron-3-super-120b-a12b
- nvidia/minimaxai/minimax-m2.5
- nvidia/z-ai/glm5
gateway_openclaw_default_models:
"{{ gateway_openclaw_default_model_primary }}": {}
"{{ gateway_openclaw_default_model_fallback }}": {}
"deepseek/deepseek-chat": {}
"deepseek/deepseek-reasoner": {}
gateway_openclaw_default_models_nvidia:
"nvidia/deepseek-v4-flash": {}
"nvidia/deepseek-v4-pro": {}
"nvidia/glm-5.1": {}
"nvidia/minimax-m3": {}
"nvidia/qwen3.5": {}
"nvidia/kimi-k2.6": {}
gateway_openclaw_default_models_ollama:
"ollama/deepseek-v4-flash": {}
"ollama/deepseek-v4-pro": {}
"ollama/glm-5.2": {}
"ollama/minimax-m3": {}
"ollama/qwen3.5": {}
"ollama/kimi-k2.7-code": {}
gateway_openclaw_default_models: >-
{{
{ 'openai/gpt-5.5': { 'agentRuntime': { 'id': 'codex' } } }
| combine(gateway_openclaw_default_models_deepseek if lookup('ansible.builtin.env', 'DEEPSEEK_API_KEY') else {})
| combine(gateway_openclaw_default_models_nvidia if lookup('ansible.builtin.env', 'NVIDIA_API_KEY') else {})
| combine(gateway_openclaw_default_models_ollama if lookup('ansible.builtin.env', 'OLLAMA_API_KEY') else {})
}}
nvidia/nemotron-3-super-120b-a12b: {}
nvidia/minimaxai/minimax-m2.5: {}
nvidia/z-ai/glm5: {}
openai/gpt-5.5:
agentRuntime:
id: codex
gateway_openclaw_main_agent_model: "{{ gateway_openclaw_default_model_primary }}"
gateway_openclaw_main_agent_skills:
@ -162,97 +122,77 @@ gateway_openclaw_mcp_servers:
url: http://localhost:8181/mcp
transport: streamable-http
gateway_openclaw_provider_deepseek:
api: openai-completions
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
apiKey: "{{ ai_workspace_auth_token }}"
models:
- id: "{{ gateway_openclaw_default_model_primary }}"
name: DeepSeek V4 Flash
input: [text]
contextWindow: 128000
maxTokens: 8192
reasoning: false
- id: "{{ gateway_openclaw_default_model_fallback }}"
name: DeepSeek V4 Pro
input: [text]
contextWindow: 128000
maxTokens: 8192
reasoning: true
- id: "deepseek/deepseek-chat"
name: DeepSeek V3 Chat
input: [text]
contextWindow: 64000
- id: "deepseek/deepseek-reasoner"
name: DeepSeek R1 Reasoner
input: [text]
contextWindow: 64000
reasoning: true
gateway_openclaw_provider_nvidia:
api: openai-completions
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
apiKey: "{{ ai_workspace_auth_token }}"
models:
- id: "nvidia/deepseek-v4-flash"
name: NVIDIA DeepSeek V4 Flash
input: [text]
contextWindow: 128000
- id: "nvidia/deepseek-v4-pro"
name: NVIDIA DeepSeek V4 Pro
input: [text]
contextWindow: 128000
- id: "nvidia/glm-5.2"
name: NVIDIA GLM 5.2
input: [text]
contextWindow: 128000
- id: "nvidia/minimax-m3"
name: NVIDIA MiniMax M3
input: [text]
contextWindow: 128000
- id: "nvidia/qwen3.5"
name: NVIDIA Qwen 3.5
input: [text]
contextWindow: 128000
- id: "nvidia/kimi-k2.7-code"
name: NVIDIA Kimi K2.7 Code
input: [text]
contextWindow: 128000
gateway_openclaw_provider_ollama:
api: openai-completions
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
apiKey: "{{ ai_workspace_auth_token }}"
models:
- id: "ollama/deepseek-v4-flash"
name: Ollama DeepSeek V4 Flash
input: [text]
contextWindow: 128000
- id: "ollama/deepseek-v4-pro"
name: Ollama DeepSeek V4 Pro
input: [text]
contextWindow: 128000
- id: "ollama/glm-5.2"
name: Ollama GLM 5.2
input: [text]
contextWindow: 128000
- id: "ollama/minimax-m3"
name: Ollama MiniMax M3
input: [text]
contextWindow: 128000
- id: "ollama/qwen3.5"
name: Ollama Qwen 3.5
input: [text]
contextWindow: 128000
- id: "ollama/kimi-k2.7-code"
name: Ollama Kimi K2.7 Code
input: [text]
contextWindow: 128000
gateway_openclaw_model_providers: >-
{{
{}
| combine({'deepseek': gateway_openclaw_provider_deepseek} if lookup('ansible.builtin.env', 'DEEPSEEK_API_KEY') else {})
| combine({'nvidia': gateway_openclaw_provider_nvidia} if lookup('ansible.builtin.env', 'NVIDIA_API_KEY') else {})
| combine({'ollama': gateway_openclaw_provider_ollama} if lookup('ansible.builtin.env', 'OLLAMA_API_KEY') else {})
}}
gateway_openclaw_model_providers:
litellm:
api: openai-completions
baseUrl: "http://127.0.0.1:{{ litellm_listen_port | default(4000) }}/v1"
apiKey: "{{ ai_workspace_auth_token }}"
models:
- id: "{{ gateway_openclaw_default_model_primary }}"
name: DeepSeek V4 Flash
input: [text]
contextWindow: 128000
maxTokens: 8192
reasoning: false
- id: "{{ gateway_openclaw_default_model_fallback }}"
name: DeepSeek V4 Pro
input: [text]
contextWindow: 128000
maxTokens: 8192
reasoning: true
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

View File

@ -1,23 +1,6 @@
---
- name: Check OpenClaw health (POSIX)
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --lint --severity-min error"
environment:
HOME: "{{ gateway_openclaw_home }}"
PATH: "{{ gateway_openclaw_service_path }}"
OPENCLAW_NO_RESPAWN: "1"
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
become: true
become_user: "{{ gateway_openclaw_service_user }}"
register: gateway_openclaw_doctor_lint
changed_when: false
failed_when: false
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
listen: Run OpenClaw doctor
- name: Repair OpenClaw health findings (POSIX)
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --repair --non-interactive --yes"
- name: Run OpenClaw doctor (POSIX)
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --fix --force --non-interactive --yes"
environment:
HOME: "{{ gateway_openclaw_home }}"
PATH: "{{ gateway_openclaw_service_path }}"
@ -28,22 +11,21 @@
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
- gateway_openclaw_doctor_lint.rc | default(0) != 0
listen: Run OpenClaw doctor
listen: Restart openclaw
- name: Run OpenClaw doctor (Windows)
ansible.builtin.include_tasks: windows_doctor.yml
when:
- not ansible_check_mode
- ansible_os_family == 'Windows'
listen: Run OpenClaw doctor
listen: Restart openclaw
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
when:
- not ansible_check_mode
- ansible_os_family not in ['Darwin', 'Windows']
- ansible_os_family != 'Darwin'
listen: Restart openclaw
- name: Restart openclaw gateway
@ -65,14 +47,7 @@
become: true
when:
- not ansible_check_mode
- ansible_os_family not in ['Darwin', 'Windows']
listen: Restart openclaw
- name: Restart OpenClaw gateway on Windows
ansible.builtin.include_tasks: windows_restart.yml
when:
- not ansible_check_mode
- ansible_os_family == 'Windows'
- ansible_os_family != 'Darwin'
listen: Restart openclaw
- name: Unload openclaw on macOS

View File

@ -1,16 +1,6 @@
---
- name: Check OpenClaw health on Windows
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --lint --severity-min error"
- name: Execute OpenClaw doctor
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --fix --force --non-interactive --yes"
environment:
OPENCLAW_NO_RESPAWN: "1"
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
register: gateway_openclaw_doctor_lint_win
changed_when: false
failed_when: false
- name: Repair OpenClaw health findings on Windows
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --repair --non-interactive --yes"
environment:
OPENCLAW_NO_RESPAWN: "1"
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
when: gateway_openclaw_doctor_lint_win.rc | default(0) != 0

View File

@ -1,6 +0,0 @@
---
- name: Restart OpenClaw gateway on Windows
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd gateway restart"
environment:
OPENCLAW_NO_RESPAWN: "1"
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"

View File

@ -68,13 +68,6 @@
group: "{{ gateway_openclaw_service_group }}"
mode: "0700"
- name: Inspect installed OpenClaw gateway package version
ansible.builtin.slurp:
path: "{{ gateway_openclaw_install_dir }}/package.json"
register: gateway_openclaw_installed_package_manifest
failed_when: false
when: ansible_os_family != 'Windows'
- name: Install required OpenClaw gateway package version
ansible.builtin.command:
cmd: >-
@ -96,13 +89,7 @@
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
- >-
(gateway_openclaw_installed_package_manifest.content | default('') | length == 0)
or
((gateway_openclaw_installed_package_manifest.content | b64decode | from_json).version | default('') != gateway_openclaw_required_version)
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Ensure OpenClaw extensions directory exists
ansible.builtin.file:
@ -118,10 +105,6 @@
url: "{{ gateway_openclaw_multi_session_plugin_archive_url }}"
dest: "/tmp/openclaw-multi-session-plugins.tar.gz"
mode: "0644"
register: gateway_openclaw_multi_session_plugin_download
until: gateway_openclaw_multi_session_plugin_download is succeeded
retries: 3
delay: 5
- name: Extract OpenClaw Multi-Session Plugins
ansible.builtin.unarchive:
@ -131,11 +114,8 @@
owner: "{{ gateway_openclaw_service_user }}"
group: "{{ gateway_openclaw_service_group }}"
mode: "0755"
creates: "{{ gateway_openclaw_home }}/.openclaw/extensions/openclaw-multi-session-plugins"
become: "{{ ansible_os_family != 'Darwin' }}"
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Ensure OpenClaw global plugin npm directory exists
ansible.builtin.file:
@ -145,21 +125,6 @@
group: "{{ gateway_openclaw_service_group }}"
mode: "0700"
- name: Inspect installed OpenClaw global plugin versions
ansible.builtin.command:
cmd: npm list --depth=0 --json --prefix "{{ gateway_openclaw_global_npm_dir }}"
environment:
HOME: "{{ gateway_openclaw_home }}"
PATH: "{{ gateway_openclaw_service_path }}"
become: true
become_user: "{{ gateway_openclaw_service_user }}"
register: gateway_openclaw_installed_global_plugins
changed_when: false
failed_when: false
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
- name: Install required OpenClaw global plugin versions
ansible.builtin.command:
cmd: >-
@ -184,12 +149,7 @@
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
- >-
(((gateway_openclaw_installed_global_plugins.stdout | default('{}') | from_json).dependencies | default({})).get(item.name, {}).get('version', ''))
!= item.version
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Remove unsupported OpenClaw global plugin packages
ansible.builtin.command:
@ -212,9 +172,7 @@
when:
- gateway_openclaw_removed_global_plugins | length > 0
- not ansible_check_mode
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Reset OpenClaw compile cache after package or plugin changes
ansible.builtin.file:
@ -318,9 +276,7 @@
group: "{{ gateway_openclaw_service_group }}"
mode: "0600"
diff: false
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Inspect OpenClaw package manifest
ansible.builtin.stat:
@ -347,8 +303,6 @@
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
become: true
become_user: "{{ gateway_openclaw_service_user }}"
args:
creates: "{{ gateway_openclaw_install_dir }}/node_modules"
register: gateway_openclaw_package_deps
changed_when: >-
'added ' in (gateway_openclaw_package_deps.stdout | default('')) or
@ -375,8 +329,6 @@
NODE_COMPILE_CACHE: "{{ gateway_openclaw_compile_cache_dir }}"
become: true
become_user: "{{ gateway_openclaw_service_user }}"
args:
creates: "{{ item.item }}/node_modules"
register: gateway_openclaw_extension_deps
changed_when: >-
'added ' in (gateway_openclaw_extension_deps.stdout | default('')) or
@ -468,7 +420,6 @@
(
(gateway_openclaw_plugin_registry.stdout | from_json).plugins
| selectattr('id', 'equalto', 'acpx')
| rejectattr('version', 'equalto', gateway_openclaw_required_version)
| list
| length
) == 0
@ -483,7 +434,7 @@
fail_msg: >-
OpenClaw must run @openclaw/codex {{ gateway_openclaw_required_version }}
plus openclaw-multi-session-plugins {{ gateway_openclaw_required_version }},
and any OpenClaw-managed acpx plugin must match the gateway version.
and must not keep stale global @openclaw/acpx.
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'

View File

@ -1,10 +1,4 @@
---
- name: Inspect installed OpenClaw gateway package version on Windows
ansible.windows.win_slurp:
src: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\package.json"
register: gateway_openclaw_installed_package_manifest_win
failed_when: false
- name: Install required OpenClaw gateway package version on Windows
community.windows.win_command:
cmd: >-
@ -18,15 +12,8 @@
changed_when: >-
'added ' in (gateway_openclaw_package_install_win.stdout | default('')) or
'updated ' in (gateway_openclaw_package_install_win.stdout | default(''))
when:
- not ansible_check_mode
- >-
(gateway_openclaw_installed_package_manifest_win.content | default('') | length == 0)
or
((gateway_openclaw_installed_package_manifest_win.content | b64decode | from_json).version | default('') != gateway_openclaw_required_version)
notify:
- Run OpenClaw doctor
- Restart openclaw
when: not ansible_check_mode
notify: Restart openclaw
- name: Ensure OpenClaw extensions directory exists on Windows
ansible.windows.win_file:
@ -39,17 +26,13 @@
dest: "{{ gateway_openclaw_home }}\.openclaw\extensions"
creates: "{{ gateway_openclaw_home }}\.openclaw\extensions\openclaw-multi-session-plugins"
when: not ansible_check_mode
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Deploy OpenClaw gateway JSON config on Windows
ansible.windows.win_template:
src: openclaw.json.j2
dest: "{{ gateway_openclaw_config_path }}"
notify:
- Run OpenClaw doctor
- Restart openclaw
notify: Restart openclaw
- name: Register OpenClaw gateway as a Windows Service (schtasks)
community.windows.win_command:

View File

@ -131,12 +131,7 @@
},
"memory-wiki": {"enabled": true},
"openai": {"enabled": true},
"openclaw-multi-session-plugins": {
"enabled": true,
"hooks": {
"allowConversationAccess": true
}
},
"openclaw-multi-session-plugins": {"enabled": true},
"device-pair": {"enabled": false},
"phone-control": {"enabled": false},
"talk-voice": {"enabled": false}

View File

@ -5,51 +5,18 @@ litellm_service_group: "{{ 'staff' if ansible_os_family == 'Darwin' else (ansibl
litellm_service_home: "{{ ansible_env.HOME | default('/home/' + litellm_service_user) }}"
litellm_source_repo: "https://github.com/ai-workspace-services/litellm.git"
litellm_version: "3ad385a8a46988b6a81fe6c0bc22ef58685baa58"
litellm_source_archive_url: "https://github.com/ai-workspace-services/litellm/archive/{{ litellm_version }}.zip"
litellm_debian_11_compat_version: "1.74.9"
litellm_default_bootstrap_python_executable: >-
{{
'/opt/homebrew/bin/python3.13'
if ansible_os_family == 'Darwin'
else ('C:/Python313/python.exe' if ansible_os_family == 'Windows' else '/usr/bin/python3')
}}
litellm_default_pip_cache_dir: >-
{{
lookup('ansible.builtin.env', 'LOCALAPPDATA')
| default(litellm_service_home ~ '/AppData/Local/pip/Cache', true)
if ansible_os_family == 'Windows'
else (litellm_service_home ~ '/Library/Caches/pip' if ansible_os_family == 'Darwin' else litellm_service_home ~ '/.cache/pip')
}}
litellm_package_spec: >-
{{
lookup('ansible.builtin.env', 'LITELLM_PACKAGE_SPEC')
| default(
'litellm[proxy]==' ~ litellm_debian_11_compat_version
if ansible_facts.distribution == 'Debian' and ansible_facts.distribution_major_version == '11'
else 'litellm[proxy] @ ' ~ litellm_source_archive_url,
else 'litellm[proxy] @ git+' ~ litellm_source_repo ~ '@' ~ litellm_version,
true)
}}
litellm_bootstrap_python_executable: >-
{{
lookup('ansible.builtin.env', 'LITELLM_PYTHON_EXECUTABLE')
| default(litellm_default_bootstrap_python_executable, true)
}}
litellm_venv_dir: "{{ litellm_service_home }}/.local/share/litellm/venv"
litellm_pip_cache_dir: >-
{{
lookup('ansible.builtin.env', 'LITELLM_PIP_CACHE_DIR')
| default(litellm_default_pip_cache_dir, true)
}}
litellm_install_marker_file: "{{ litellm_venv_dir }}/.install-spec"
litellm_python_executable: "{{ litellm_venv_dir }}/bin/python"
litellm_pip_executable: "{{ litellm_venv_dir }}/bin/pip"
# Network resilience for the (large) online dependency install. Resume-retries
# requires pip >= 25.1, which the role guarantees by upgrading pip in the venv.
litellm_pip_retries: 5
litellm_pip_resume_retries: 5
litellm_pip_timeout: 180
litellm_binary_path: "{{ litellm_venv_dir }}/bin/litellm"
litellm_prisma_binary_path: "{{ litellm_venv_dir }}/bin/prisma"
litellm_python_executable: "{{ lookup('ansible.builtin.env', 'LITELLM_PYTHON_EXECUTABLE') | default('python3', true) }}"
litellm_pip_executable: "{{ lookup('ansible.builtin.env', 'LITELLM_PIP_EXECUTABLE') | default('', true) }}"
litellm_listen_host: 127.0.0.1
litellm_listen_port: 4000
litellm_config_dir: /etc/litellm
@ -107,22 +74,8 @@ litellm_database_password: "{{ lookup('ansible.builtin.env', 'LITELLM_DATABASE_P
litellm_database_admin_user: "{{ lookup('ansible.builtin.env', 'LITELLM_DATABASE_ADMIN_USER') | default('postgres', true) }}"
litellm_database_admin_password: "{{ lookup('ansible.builtin.env', 'LITELLM_DATABASE_ADMIN_PASSWORD') | default('', true) }}"
# Percent-encode the password for use inside the DATABASE_URL userinfo. The
# shared auth token is `openssl rand -base64`, which can contain '/', '+' and
# '=' — a raw '/' truncates the URL authority and Prisma aborts with
# "P1013: invalid port number in database URL". Jinja's `urlencode` leaves '/'
# safe, so encode the reserved set explicitly ('%' first to avoid double
# encoding). The actual DB user password stays raw (provision-database and
# LITELLM_DB_PASSWORD use it verbatim); only the URL form is encoded so the
# client decodes back to the same raw secret.
litellm_database_password_urlencoded: >-
{{ litellm_database_password
| replace('%', '%25') | replace('/', '%2F') | replace('+', '%2B')
| replace('=', '%3D') | replace('@', '%40') | replace(':', '%3A')
| replace('?', '%3F') | replace('#', '%23') | replace(' ', '%20') }}
# Build DATABASE_URL from components (used in litellm.env)
litellm_database_url: "{% if litellm_database_host | trim | length > 0 %}postgresql://{{ litellm_database_user }}:{{ litellm_database_password_urlencoded | trim }}@{{ litellm_database_host }}:{{ litellm_database_port }}/{{ litellm_database_name }}?sslmode={{ litellm_database_sslmode }}{% else %}{% endif %}"
litellm_database_url: "{% if litellm_database_host | trim | length > 0 %}postgresql://{{ litellm_database_user }}:{{ litellm_database_password }}@{{ litellm_database_host }}:{{ litellm_database_port }}/{{ litellm_database_name }}?sslmode={{ litellm_database_sslmode }}{% else %}{% endif %}"
# Models are now dynamically managed via DB/UI or user-provided config

View File

@ -13,31 +13,17 @@ if [ -z "$LITELLM_TOKEN" ]; then
exit 1
fi
if [ -z "${DEEPSEEK_API_KEY:-}" ] && [ -z "${NVIDIA_API_KEY:-}" ] && [ -z "${OLLAMA_API_KEY:-}" ]; then
echo "[INFO] DEEPSEEK_API_KEY, NVIDIA_API_KEY, and OLLAMA_API_KEY are empty. Manual configuration mode."
exit 0
fi
echo "[INFO] Using LiteLLM URL: $LITELLM_URL"
# Aliases successfully registered, collected for the post-registration probe.
REGISTERED=()
# Function to add a model
add_model() {
local alias_name="$1"
local litellm_provider_model="$2"
local api_key_env_var="$3"
local api_base="${4:-}"
# Skip registration when the backing API key was not provided (empty env var).
if [ -z "${!api_key_env_var:-}" ]; then
echo "[SKIP] $alias_name: $api_key_env_var is empty; not registering."
return 0
fi
echo "Adding model: $alias_name -> $litellm_provider_model"
local payload
if [ -n "$api_base" ]; then
payload=$(cat <<EOF
@ -45,7 +31,7 @@ add_model() {
"model_name": "$alias_name",
"litellm_params": {
"model": "$litellm_provider_model",
"api_key": "${!api_key_env_var}",
"api_key": "os.environ/$api_key_env_var",
"api_base": "$api_base"
},
"model_info": {
@ -61,7 +47,7 @@ EOF
"model_name": "$alias_name",
"litellm_params": {
"model": "$litellm_provider_model",
"api_key": "${!api_key_env_var}"
"api_key": "os.environ/$api_key_env_var"
},
"model_info": {
"id": "$alias_name",
@ -77,168 +63,68 @@ EOF
response=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$LITELLM_URL/model/new" \
-H "Authorization: Bearer $LITELLM_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload") || true
-d "$payload")
http_code=$(echo "$response" | grep -Eo 'HTTP_CODE:[0-9]{3}' | cut -d':' -f2 || echo "000")
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
echo "[SUCCESS] Model $alias_name added."
REGISTERED+=("$alias_name")
else
echo "[INFO] Model $alias_name failed to add via /model/new (HTTP $http_code), attempting /model/update..."
response=$(curl -s -w "\nHTTP_CODE:%{http_code}" -X POST "$LITELLM_URL/model/update" \
-H "Authorization: Bearer $LITELLM_TOKEN" \
-H "Content-Type: application/json" \
-d "$payload") || true
http_code=$(echo "$response" | grep -Eo 'HTTP_CODE:[0-9]{3}' | cut -d':' -f2 || echo "000")
if [ "$http_code" = "200" ] || [ "$http_code" = "201" ]; then
echo "[SUCCESS] Model $alias_name updated."
REGISTERED+=("$alias_name")
else
echo "[ERROR] Failed to add/update model $alias_name. HTTP Code: $http_code"
echo "Response: $response"
fi
echo "[ERROR] Failed to add model $alias_name. HTTP Code: $http_code"
echo "Response: $response"
fi
}
# Probe a single registered alias by sending a real 1-token completion through
# LiteLLM. Registration (presence in /v1/models) only proves the row exists in
# the DB; it does NOT prove the upstream model id / api_base / entitlement are
# valid. This is the only check that proves an alias is actually callable.
# Echoes "PASS" / "FAIL <http> <reason>" and returns 0 only on PASS.
probe_model() {
local alias_name="$1"
local body http_code msg
body=$(curl -s -m 60 -w "\nHTTP_CODE:%{http_code}" \
-X POST "$LITELLM_URL/v1/chat/completions" \
-H "Authorization: Bearer $LITELLM_TOKEN" \
-H "Content-Type: application/json" \
-d "{\"model\":\"$alias_name\",\"messages\":[{\"role\":\"user\",\"content\":\"ping\"}],\"max_tokens\":1}") || true
http_code=$(echo "$body" | grep -Eo 'HTTP_CODE:[0-9]{3}' | cut -d':' -f2 || echo "000")
if [ "$http_code" = "200" ]; then
echo "PASS"
return 0
fi
# Pull a short reason out of the error for the report. Prefer the upstream
# provider message (e.g. "this model requires a subscription") over
# LiteLLM's verbose fallback-wrapper text, then cap the length.
local flat
flat=$(echo "$body" | sed 's/HTTP_CODE:[0-9]*//' | tr '\n' ' ')
msg=$(echo "$flat" | grep -Eo "'error': '[^']*'" | head -1 | sed "s/'error': '//; s/'$//")
[ -z "$msg" ] && msg=$(echo "$flat" | grep -Eo '"message":"[^"]*"' | head -1 | cut -d'"' -f4)
[ -z "$msg" ] && msg="$flat"
echo "FAIL $http_code $(echo "${msg:-unknown}" | cut -c1-90)"
return 1
}
echo "========================================="
echo "Registering DeepSeek Models..."
echo "========================================="
add_model "deepseek-chat" "deepseek/deepseek-chat" "DEEPSEEK_API_KEY"
add_model "deepseek-reasoner" "deepseek/deepseek-reasoner" "DEEPSEEK_API_KEY"
add_model "deepseek-v4-flash" "deepseek/deepseek-v4-flash" "DEEPSEEK_API_KEY"
add_model "deepseek-v4-pro" "deepseek/deepseek-v4-pro" "DEEPSEEK_API_KEY"
if [ -n "${DEEPSEEK_API_KEY:-}" ]; then
echo "========================================="
echo "Registering DeepSeek Models..."
echo "========================================="
add_model "deepseek/deepseek-v4-flash" "deepseek/deepseek-v4-flash" "DEEPSEEK_API_KEY"
add_model "deepseek/deepseek-v4-pro" "deepseek/deepseek-v4-pro" "DEEPSEEK_API_KEY"
add_model "deepseek/deepseek-chat" "deepseek/deepseek-chat" "DEEPSEEK_API_KEY"
add_model "deepseek/deepseek-reasoner" "deepseek/deepseek-reasoner" "DEEPSEEK_API_KEY"
fi
if [ -n "${NVIDIA_API_KEY:-}" ]; then
echo "========================================="
echo "Registering NVIDIA Build Models..."
echo "========================================="
# NVIDIA NIM model ids are vendor-namespaced (deepseek-ai/..., minimaxai/...,
# qwen/..., z-ai/..., moonshotai/...); bare names 404 on the upstream router.
# Every alias below maps to a model that EXISTS in the live GET /v1/models
# catalog. NVIDIA serves glm-5.1 and kimi-k2.6 (no 5.2 / k2.7), so the
# aliases are named for the real versions rather than lying about them.
NVIDIA_API_BASE="${NVIDIA_API_BASE:-https://integrate.api.nvidia.com/v1}"
add_model "nvidia/deepseek-v4-flash" "openai/deepseek-ai/deepseek-v4-flash" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
add_model "nvidia/deepseek-v4-pro" "openai/deepseek-ai/deepseek-v4-pro" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
add_model "nvidia/glm-5.1" "openai/z-ai/glm-5.1" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
add_model "nvidia/minimax-m3" "openai/minimaxai/minimax-m3" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
add_model "nvidia/qwen3.5" "openai/qwen/qwen3.5-397b-a17b" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
add_model "nvidia/kimi-k2.6" "openai/moonshotai/kimi-k2.6" "NVIDIA_API_KEY" "$NVIDIA_API_BASE"
fi
echo "========================================="
echo "Registering NVIDIA Build Models..."
echo "========================================="
# For NVIDIA NIM models, you can use openai format with custom base, or nvidia_nim/ provider
add_model "nvidia/deepseek-r1" "openai/deepseek-ai/deepseek-r1" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
add_model "nvidia/minimax-text-01" "openai/minimax/minimax-text-01" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
add_model "nvidia/glm-4" "openai/thudm/glm-4-9b-chat" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
add_model "nvidia/glm-5" "openai/thudm/glm-5" "NVIDIA_API_KEY" "https://integrate.api.nvidia.com/v1"
echo "========================================="
echo "Registering Gemini Models..."
echo "========================================="
if [ -n "${GEMINI_API_KEY:-}" ]; then
add_model "gemini-2.5-pro" "gemini/gemini-2.5-pro" "GEMINI_API_KEY"
add_model "gemini-2.5-flash" "gemini/gemini-2.5-flash" "GEMINI_API_KEY"
add_model "gemini-1.5-pro" "gemini/gemini-1.5-pro" "GEMINI_API_KEY"
fi
add_model "gemini-2.5-pro" "gemini/gemini-2.5-pro" "GEMINI_API_KEY"
add_model "gemini-2.5-flash" "gemini/gemini-2.5-flash" "GEMINI_API_KEY"
add_model "gemini-1.5-pro" "gemini/gemini-1.5-pro" "GEMINI_API_KEY"
echo "========================================="
echo "Registering GPT Models..."
echo "========================================="
if [ -n "${OPENAI_API_KEY:-}" ]; then
add_model "gpt-5.5" "openai/gpt-5.5" "OPENAI_API_KEY"
add_model "gpt-5.4" "openai/gpt-5.4" "OPENAI_API_KEY"
add_model "gpt-5.4-mini" "openai/gpt-5.4-mini" "OPENAI_API_KEY"
fi
add_model "gpt-5.5" "openai/gpt-5.5" "OPENAI_API_KEY"
add_model "gpt-5.4" "openai/gpt-5.4" "OPENAI_API_KEY"
add_model "gpt-5.4-mini" "openai/gpt-5.4-mini" "OPENAI_API_KEY"
echo "========================================="
echo "Registering Claude Models..."
echo "========================================="
if [ -n "${ANTHROPIC_API_KEY:-}" ]; then
add_model "claude-3.5-sonnet" "anthropic/claude-3-5-sonnet-20241022" "ANTHROPIC_API_KEY"
add_model "claude-3.5-haiku" "anthropic/claude-3-5-haiku-20241022" "ANTHROPIC_API_KEY"
add_model "claude-3-opus" "anthropic/claude-3-opus-20240229" "ANTHROPIC_API_KEY"
fi
add_model "claude-3.5-sonnet" "anthropic/claude-3-5-sonnet-20241022" "ANTHROPIC_API_KEY"
add_model "claude-3.5-haiku" "anthropic/claude-3-5-haiku-20241022" "ANTHROPIC_API_KEY"
add_model "claude-3-opus" "anthropic/claude-3-opus-20240229" "ANTHROPIC_API_KEY"
if [ -n "${OLLAMA_API_KEY:-}" ]; then
echo "========================================="
echo "Registering OLLAMA Cloud Models..."
echo "========================================="
OLLAMA_API_BASE="${OLLAMA_API_BASE:-https://api.ollama.cloud/v1}"
# Ollama Cloud model ids carry a tag (":cloud" for the hosted big models),
# per https://ollama.com/search. The bare names below resolve to a local
# pull that the cloud endpoint does not have -> 404 "model not found".
# NOTE: the :cloud models require an Ollama paid subscription; without one
# the upstream returns 403. The verification pass at the end will surface
# this clearly (a 403/404 here is an upstream entitlement issue, not a
# config bug).
add_model "ollama/deepseek-v4-flash" "openai/deepseek-v4-flash:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
add_model "ollama/deepseek-v4-pro" "openai/deepseek-v4-pro:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
add_model "ollama/glm-5.2" "openai/glm-5.2:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
add_model "ollama/minimax-m3" "openai/minimax-m3:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
add_model "ollama/qwen3.5" "openai/qwen3.5:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
add_model "ollama/kimi-k2.7-code" "openai/kimi-k2.7-code:cloud" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
fi
echo "========================================="
echo "Registering Zhipu (GLM) using OLLAMA_API_KEY..."
echo "========================================="
add_model "glm-4" "openai/glm-4" "OLLAMA_API_KEY" "https://open.bigmodel.cn/api/paas/v4"
add_model "glm-5" "openai/glm-5" "OLLAMA_API_KEY" "https://open.bigmodel.cn/api/paas/v4"
echo "========================================="
echo "Registering OLLAMA Cloud Models..."
echo "========================================="
# Assuming OLLAMA API is exposed via a cloud endpoint or an OpenAI proxy
OLLAMA_API_BASE="${OLLAMA_API_BASE:-https://api.ollama.cloud/v1}"
add_model "ollama-llama3" "openai/llama3" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
add_model "ollama-qwen" "openai/qwen" "OLLAMA_API_KEY" "$OLLAMA_API_BASE"
echo "All models requested have been registered."
echo "You can check them at $LITELLM_URL/ui/?page=models"
# =============================================================================
# Verification pass: prove callability, not mere presence in /v1/models.
# Sends a real 1-token completion through LiteLLM for every registered alias and
# prints a PASS/FAIL health table. Controlled by REGISTER_MODELS_VERIFY (default
# on); set REGISTER_MODELS_VERIFY=0 to skip. A FAIL here is the real signal that
# a fallback link is unhealthy even though it shows up in /v1/models.
# =============================================================================
if [ "${REGISTER_MODELS_VERIFY:-1}" != "0" ] && [ "${#REGISTERED[@]}" -gt 0 ]; then
echo "========================================="
echo "Verifying callability (1-token live probe per alias)..."
echo "========================================="
pass_count=0
fail_count=0
fail_list=()
for alias_name in "${REGISTERED[@]}"; do
# `|| true` keeps the non-zero FAIL return from tripping `set -e`.
result="$(probe_model "$alias_name" || true)"
if [ "$result" = "PASS" ]; then
printf ' [PASS] %s\n' "$alias_name"
pass_count=$((pass_count + 1))
else
printf ' [FAIL] %-28s %s\n' "$alias_name" "${result#FAIL }"
fail_count=$((fail_count + 1))
fail_list+=("$alias_name")
fi
done
echo "-----------------------------------------"
echo "Callable: $pass_count Unhealthy: $fail_count (of ${#REGISTERED[@]} registered)"
if [ "$fail_count" -gt 0 ]; then
echo "Unhealthy aliases (registered but NOT callable): ${fail_list[*]}"
echo "These appear in /v1/models but fail a real call — check upstream"
echo "model id, api_base, and account entitlement (e.g. 403 = subscription)."
fi
fi

View File

@ -1,21 +1,7 @@
---
# Provision the litellm database and user BEFORE litellm starts.
# Only runs when litellm_database_host is set.
# Debian/Ubuntu 显式走 aptplay 的 module_defaults.apt.lock_timeout(模板值)只有在
# apt 任务上才渲染;经 package 间接派发到 apt 时模板不渲染 → lock_timeout 收到字面
# "{{ ... }}" 报 int 转换失败(见 xworkmate_bridge 同类修复)。
- name: Install LiteLLM prerequisites (Debian/Ubuntu via apt)
ansible.builtin.apt:
name:
- python3
- python3-pip
- python3-venv
- python3-psycopg2
state: present
update_cache: true
when: ansible_os_family == 'Debian'
- name: Install LiteLLM prerequisites (non-Debian Linux)
- name: Install LiteLLM prerequisites (Linux)
ansible.builtin.package:
name:
- python3
@ -23,7 +9,7 @@
- python3-venv
- python3-psycopg2
state: present
when: ansible_os_family not in ['Darwin', 'Debian', 'Windows']
when: ansible_os_family != 'Darwin'
- name: Install LiteLLM prerequisites (macOS)
# Use brew from PATH (Apple Silicon prefix first) instead of the
@ -117,160 +103,28 @@
mode: "0600"
notify: Restart litellm
# litellm 的 pinned fork 要求 Python <3.14。当系统解释器 >=3.14(如 Ubuntu 26.04 的
# 3.14,且 apt 无 3.13/3.12)时,用 uv 装独立 Python 3.13 并改用它建 venv否则
# pip 会报 "requires a different Python: 3.14 not in '<3.14,>=3.10'"。
- name: Detect litellm bootstrap python version
ansible.builtin.command: >-
{{ litellm_bootstrap_python_executable }} -c
'import sys; print("%d.%d" % sys.version_info[:2])'
register: litellm_bootstrap_py_ver
changed_when: false
failed_when: false
- name: Provision compatible Python 3.13 via uv (system python >=3.14, litellm needs <3.14)
when:
- ansible_os_family not in ['Darwin', 'Windows']
- (litellm_bootstrap_py_ver.stdout | default('0.0', true) | trim) is version('3.14', '>=')
become: true
become_user: "{{ litellm_service_user }}"
block:
- name: Install uv for litellm python provisioning
ansible.builtin.shell: |
set -eu
if [ ! -x "{{ litellm_service_home }}/.local/bin/uv" ] && ! command -v uv >/dev/null 2>&1; then
curl -LsSf https://astral.sh/uv/install.sh \
| env UV_INSTALL_DIR="{{ litellm_service_home }}/.local/bin" sh
fi
args:
executable: /bin/bash
changed_when: true
- name: Install Python 3.13 via uv
ansible.builtin.command: "{{ litellm_service_home }}/.local/bin/uv python install 3.13"
changed_when: true
- name: Resolve uv-managed Python 3.13 path
ansible.builtin.command: "{{ litellm_service_home }}/.local/bin/uv python find 3.13"
register: litellm_uv_python313
changed_when: false
- name: Use uv Python 3.13 as litellm bootstrap interpreter
ansible.builtin.set_fact:
litellm_bootstrap_python_executable: "{{ litellm_uv_python313.stdout | trim }}"
- name: Drop incompatible existing litellm venv (rebuild with Python 3.13)
ansible.builtin.file:
path: "{{ litellm_venv_dir }}"
state: absent
- name: Create isolated LiteLLM Python environment
ansible.builtin.command:
cmd: "{{ litellm_bootstrap_python_executable }} -m venv {{ litellm_venv_dir }}"
creates: "{{ litellm_python_executable }}"
become: true
become_user: "{{ litellm_service_user }}"
# A venv bootstrapped by ensurepip can ship a pip older than 25.1, which lacks
# `--resume-retries`. Upgrade pip first so the resilient download flags used by
# the dependency install below are always available.
- name: Ensure recent pip in the LiteLLM environment
ansible.builtin.pip:
name: pip
state: latest
executable: "{{ litellm_pip_executable }}"
environment:
PIP_CACHE_DIR: "{{ litellm_pip_cache_dir }}"
PIP_DEFAULT_TIMEOUT: "120"
become: true
become_user: "{{ litellm_service_user }}"
- name: Inspect installed LiteLLM dependency marker
ansible.builtin.stat:
path: "{{ litellm_install_marker_file }}"
register: litellm_install_marker
become: true
become_user: "{{ litellm_service_user }}"
- name: Read installed LiteLLM dependency marker
ansible.builtin.command:
cmd: "cat {{ litellm_install_marker_file }}"
register: litellm_install_marker_content
changed_when: false
failed_when: false
become: true
become_user: "{{ litellm_service_user }}"
when: litellm_install_marker.stat.exists
# The probe must stay a valid single-line Python program: the `>-` folding
# collapses every newline into a space, so a `for ... : try: ... except:` block
# would become one illegal logical line, crash with SyntaxError, and (with
# failed_when:false) leave stdout empty -> the from_json below then explodes.
# Build the version map from importlib.metadata.distributions() with dict/list
# comprehensions, which are valid single statements joined by semicolons.
- name: Inspect installed LiteLLM dependency versions
ansible.builtin.command:
cmd: >-
{{ litellm_python_executable }} -c
"import importlib.metadata as m, json;
dists = {d.metadata['Name'].lower(): d.version for d in m.distributions()};
print(json.dumps({p: dists.get(p, 'missing') for p in ['litellm', 'prisma', 'psycopg2-binary']}))"
register: litellm_dependency_versions
changed_when: false
failed_when: false
become: true
become_user: "{{ litellm_service_user }}"
- name: Decide whether LiteLLM dependencies need installation
ansible.builtin.set_fact:
# default('{}', true) also substitutes when stdout is an empty string (not
# just undefined), so a failed/empty probe degrades to "install required"
# instead of crashing from_json with "Expecting value: line 1 column 1".
litellm_dependency_install_required: >-
{{
not litellm_install_marker.stat.exists
or (litellm_install_marker_content.stdout | default('') | trim != litellm_package_spec)
or (litellm_dependency_versions.stdout | default('{}', true) | from_json).litellm == 'missing'
or (litellm_dependency_versions.stdout | default('{}', true) | from_json).prisma == 'missing'
or (litellm_dependency_versions.stdout | default('{}', true) | from_json)['psycopg2-binary'] == 'missing'
}}
- name: Ensure LiteLLM and DB dependencies are installed
ansible.builtin.pip:
name:
- "{{ litellm_package_spec }}"
- "prisma"
- "psycopg2-binary"
executable: "{{ litellm_pip_executable }}"
extra_args: --break-system-packages
executable: "{{ litellm_pip_executable if litellm_pip_executable | length > 0 else omit }}"
state: present
# litellm[proxy] pulls large wheels (e.g. polars-runtime ~46MB) that often
# break mid-stream on slow/mirrored links with IncompleteRead. --retries
# reconnects and --resume-retries continues a partial download instead of
# restarting it, so a flaky connection no longer fails the whole deploy.
extra_args: "--retries {{ litellm_pip_retries }} --resume-retries {{ litellm_pip_resume_retries }}"
environment:
PIP_CACHE_DIR: "{{ litellm_pip_cache_dir }}"
PIP_DEFAULT_TIMEOUT: "{{ litellm_pip_timeout }}"
become: true
become_user: "{{ litellm_service_user }}"
when: litellm_dependency_install_required | bool
- name: Record installed LiteLLM dependency spec
ansible.builtin.copy:
dest: "{{ litellm_install_marker_file }}"
content: "{{ litellm_package_spec }}\n"
owner: "{{ litellm_service_user }}"
group: "{{ litellm_service_group }}"
mode: "0644"
become: true
become_user: "{{ litellm_service_user }}"
when: litellm_dependency_install_required | bool
- name: Resolve LiteLLM Python site-packages path
ansible.builtin.command:
cmd: >-
{{ litellm_python_executable }} -c
"import pathlib, litellm.proxy; print(pathlib.Path(litellm.proxy.__file__).parent)"
ansible.builtin.shell: |
{{ litellm_python_executable | quote }} - <<'PY'
import glob
import os
paths = glob.glob(os.path.expanduser("~/.local/lib/python*/site-packages/litellm/proxy"))
if not paths:
raise SystemExit("litellm proxy package path not found")
print(sorted(paths)[-1])
PY
register: litellm_proxy_package_path
changed_when: false
become: true
@ -282,17 +136,11 @@
litellm_python_site_packages: "{{ (litellm_proxy_package_path.stdout | trim) | dirname | dirname }}"
- name: Generate Prisma Python Client
ansible.builtin.command:
cmd: "{{ litellm_prisma_binary_path }} generate"
ansible.builtin.shell: |
export PATH={{ litellm_service_home }}/.local/bin:$PATH
prisma generate
args:
chdir: "{{ litellm_proxy_dir }}"
# `prisma generate` shells out to the `prisma-client-py` generator, which is a
# console script installed into the venv's bin dir. The default command PATH
# does not include the venv, so the absolute prisma binary still fails with
# "prisma-client-py: command not found". Put the venv bin dir on PATH so the
# generator subprocess is resolvable.
environment:
PATH: "{{ litellm_venv_dir }}/bin:/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH }}"
become: true
become_user: "{{ litellm_service_user }}"
changed_when: false

View File

@ -8,9 +8,10 @@ User={{ litellm_service_user }}
Group={{ litellm_service_group }}
WorkingDirectory={{ litellm_service_home }}
EnvironmentFile={{ litellm_env_file }}
Environment=PYTHONPATH={{ litellm_python_site_packages }}
Environment=PATH={{ litellm_venv_dir }}/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart={{ litellm_binary_path }} --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config {{ litellm_config_file }}
Environment=PYTHONPATH={{ litellm_python_site_packages | default(litellm_service_home ~ '/.local/lib/python3.12/site-packages') }}
Environment=PYTHONUSERBASE={{ litellm_service_home }}/.local
Environment=PATH={{ litellm_service_home }}/.local/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
ExecStart={{ litellm_service_home }}/.local/bin/litellm --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config {{ litellm_config_file }}
Restart=always
RestartSec=5
StandardOutput=journal

View File

@ -9,7 +9,7 @@
<string>/bin/bash</string>
<string>-c</string>
<string>
export PATH="{{ litellm_venv_dir }}/bin:/opt/homebrew/bin:/usr/local/bin:$PATH"
export PATH="/opt/homebrew/bin:/usr/local/bin:{{ litellm_service_home }}/.local/bin:$PATH"
export DATABASE_URL="{{ litellm_database_url }}"
export LITELLM_MASTER_KEY="{{ litellm_master_key }}"
export LITELLM_SALT_KEY="{{ litellm_salt_key }}"
@ -22,7 +22,7 @@
export GEMINI_API_KEY="{{ litellm_gemini_api_key }}"
export ANTHROPIC_API_KEY="{{ litellm_anthropic_api_key }}"
exec "{{ litellm_binary_path }}" --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config "{{ litellm_config_file }}" --use_prisma_db_push
exec "{{ litellm_proxy_dir | default(litellm_service_home ~ '/.local/lib/python3.13/site-packages/litellm/proxy') }}/../../../../bin/litellm" --host {{ litellm_listen_host }} --port {{ litellm_listen_port }} --config "{{ litellm_config_file }}" --use_prisma_db_push
</string>
</array>
<key>RunAtLoad</key>

View File

@ -5,7 +5,7 @@
nodejs_homebrew_formula: "node@{{ nodejs_version_major | default(22) }}"
- name: Ensure unversioned Homebrew node formula is absent
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew uninstall --ignore-dependencies --force node"
ansible.builtin.command: "brew uninstall --ignore-dependencies --force node"
register: nodejs_brew_uninstall
changed_when: "'uninstalled' in (nodejs_brew_uninstall.stdout | lower) or 'uninstalled' in (nodejs_brew_uninstall.stderr | lower)"
failed_when: nodejs_brew_uninstall.rc != 0 and 'no such keg' not in (nodejs_brew_uninstall.stdout | lower) and 'not installed' not in (nodejs_brew_uninstall.stderr | lower)
@ -17,7 +17,7 @@
HOMEBREW_BOTTLE_DOMAIN: "{{ lookup('ansible.builtin.env', 'HOMEBREW_BOTTLE_DOMAIN') | default('https://mirrors.ustc.edu.cn/homebrew-bottles', true) }}"
- name: Ensure Homebrew {{ nodejs_homebrew_formula }} formula is installed
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew install {{ nodejs_homebrew_formula }}"
ansible.builtin.command: "brew install {{ nodejs_homebrew_formula }}"
register: nodejs_brew_install
changed_when: "'already installed' not in (nodejs_brew_install.stdout | lower) and 'already installed' not in (nodejs_brew_install.stderr | lower)"
failed_when: nodejs_brew_install.rc != 0 and 'already installed' not in (nodejs_brew_install.stdout | lower) and 'already installed' not in (nodejs_brew_install.stderr | lower)
@ -29,7 +29,7 @@
HOMEBREW_BOTTLE_DOMAIN: "{{ lookup('ansible.builtin.env', 'HOMEBREW_BOTTLE_DOMAIN') | default('https://mirrors.ustc.edu.cn/homebrew-bottles', true) }}"
- name: Ensure {{ nodejs_homebrew_formula }} is linked as the default node
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew link --force --overwrite {{ nodejs_homebrew_formula }}"
ansible.builtin.command: "brew link --force --overwrite {{ nodejs_homebrew_formula }}"
register: nodejs_brew_link
changed_when: "'linking' in (nodejs_brew_link.stdout | lower)"
failed_when: nodejs_brew_link.rc != 0 and 'already linked' not in (nodejs_brew_link.stdout | lower) and 'already linked' not in (nodejs_brew_link.stderr | lower)
@ -44,7 +44,7 @@
create: true
- name: Pin {{ nodejs_homebrew_formula }} to prevent automatic upgrades
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/brew pin {{ nodejs_homebrew_formula }}"
ansible.builtin.command: "brew pin {{ nodejs_homebrew_formula }}"
register: nodejs_brew_pin
changed_when: "'pinned' in (nodejs_brew_pin.stdout | lower)"
failed_when: nodejs_brew_pin.rc != 0 and 'already pinned' not in (nodejs_brew_pin.stdout | lower) and 'already pinned' not in (nodejs_brew_pin.stderr | lower)

View File

@ -27,12 +27,6 @@ postgresql_compose_project_name: ai-workspace-postgres
postgresql_image: "postgres:17.7"
postgresql_container_name: ai-workspace-postgres
postgresql_data_dir: "{{ postgresql_compose_project_dir }}/data"
# 官方 postgres 镜像内 postgres 用户的 uid/gid17.x 为 999。数据目录必须归
# 该 uid否则容器内 postgres 进程无法进入自己的 PGDATAglobal/pg_filenode.map
# Permission denied。首次 initdb 由 entrypoint chown但非空 PGDATA 不再 chown
# 故 ansible 须把目录直接建成该 uid避免重跑时被重置回 root。
postgresql_container_uid: "999"
postgresql_container_gid: "999"
postgresql_database: postgres
postgresql_admin_user: postgres
postgresql_admin_password_file: /root/.ai_workspace_postgres_password

View File

@ -33,24 +33,18 @@
no_log: true
when: postgresql_admin_password_file_status.stat.exists
- name: Ensure PostgreSQL compose project directory exists
- name: Ensure PostgreSQL compose directories exist
ansible.builtin.file:
path: "{{ postgresql_compose_project_dir }}"
path: "{{ item.path }}"
state: directory
owner: root
group: root
mode: "0755"
# 数据目录归容器内 postgres uid默认 999而非 root否则重跑时这个 file
# 任务会把 PGDATA 顶层重置回 root:root 0700而非空 PGDATA 不再被 entrypoint
# chown导致 uid 999 无法进入 → "could not open file ... Permission denied"。
- name: Ensure PostgreSQL data directory exists (owned by container postgres uid)
ansible.builtin.file:
path: "{{ postgresql_data_dir }}"
state: directory
owner: "{{ postgresql_container_uid }}"
group: "{{ postgresql_container_gid }}"
mode: "0700"
mode: "{{ item.mode }}"
loop:
- path: "{{ postgresql_compose_project_dir }}"
mode: "0755"
- path: "{{ postgresql_data_dir }}"
mode: "0700"
- name: Render PostgreSQL compose environment
ansible.builtin.copy:

View File

@ -15,20 +15,6 @@
and 'already installed' not in (postgresql_brew_install.stdout | default(''))
failed_when: postgresql_brew_install.rc != 0
- name: Stop conflicting PostgreSQL versions
ansible.builtin.shell: |
conflicting_services=$(brew services list | awk '/^postgresql(@[0-9]+)?/ && $2 == "started" && $1 != "postgresql@16" {print $1}')
if [ -n "$conflicting_services" ]; then
for svc in $conflicting_services; do
brew services stop "$svc"
done
echo "Stopped: $conflicting_services"
fi
environment:
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH }}"
register: stop_conflicting_pg
changed_when: "'Stopped:' in stop_conflicting_pg.stdout"
- name: Start PostgreSQL via Homebrew Services
ansible.builtin.command: brew services start postgresql@16
register: brew_services_output

View File

@ -3,7 +3,7 @@ qmd_user: "{{ ansible_env.USER | default('ubuntu') }}"
qmd_group: "{{ 'staff' if ansible_os_family == 'Darwin' else (ansible_env.USER | default('ubuntu')) }}"
qmd_home: "{{ ansible_env.HOME | default('/home/' + qmd_user) }}"
qmd_source_repo: "https://github.com/ai-workspace-services/qmd.git"
qmd_version: "236c83a5f38d860fbf56829ba4e188c1fa2ae52b"
qmd_version: "6021ea34ac27ac9b5c9a7d655500544917c801dd"
qmd_source_dir: "{{ qmd_home }}/.local/src/qmd"
qmd_runtime_archive: "{{ lookup('ansible.builtin.env', 'QMD_RUNTIME_ARCHIVE') | default('', true) }}"
qmd_runtime_marker: "{{ qmd_source_dir }}/.runtime-archive-sha256"
@ -18,11 +18,6 @@ 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_launch_agent_label: plus.svc.xworkspace.qmd
qmd_launch_agent_path: "{{ qmd_home }}/Library/LaunchAgents/{{ qmd_launch_agent_label }}.plist"
qmd_launch_agent_log_dir: "{{ qmd_home }}/.local/state/xworkspace"
qmd_launch_agent_stdout_path: "{{ qmd_launch_agent_log_dir }}/qmd.log"
qmd_launch_agent_stderr_path: "{{ qmd_launch_agent_log_dir }}/qmd.err.log"
qmd_service_uid: ""
qmd_mcp_host: 127.0.0.1
qmd_mcp_port: 8181

View File

@ -10,14 +10,14 @@
listen: Restart QMD
- name: Unload QMD on macOS
ansible.builtin.command: "launchctl unload {{ qmd_launch_agent_path }}"
ansible.builtin.command: "launchctl unload {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
failed_when: false
changed_when: false
when: ansible_system == 'Darwin'
listen: Restart QMD
- name: Load QMD on macOS
ansible.builtin.command: "launchctl load -w {{ qmd_launch_agent_path }}"
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
changed_when: false
when: ansible_system == 'Darwin'
listen: Restart QMD

View File

@ -1,26 +1,13 @@
---
- name: Ensure QMD launchd directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0755"
loop:
- "{{ qmd_launch_agent_path | dirname }}"
- "{{ qmd_launch_agent_log_dir }}"
- name: Deploy QMD LaunchAgent
- name: Create launchd plist template for QMD
ansible.builtin.template:
src: qmd.plist.j2
dest: "{{ qmd_launch_agent_path }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
mode: "0644"
notify: Restart QMD
- name: Ensure QMD LaunchAgent is loaded
ansible.builtin.command: "launchctl load -w {{ qmd_launch_agent_path }}"
- name: Reload launchd agent for QMD
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
register: launchctl_result
changed_when: false
failed_when: launchctl_result.rc != 0 and 'already loaded' not in launchctl_result.stderr

View File

@ -126,13 +126,6 @@
ansible.builtin.command:
cmd: npm install
chdir: "{{ qmd_source_dir }}"
# Pin Homebrew node@24 ahead of any nvm/other node on Darwin so the native
# better-sqlite3 module is compiled against the same Node ABI that the
# launchd service and `qmd status` run with. Otherwise a host with nvm node
# first builds the module for the wrong NODE_MODULE_VERSION and qmd aborts
# with ERR_DLOPEN_FAILED. Linux PATH is left untouched.
environment:
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
become: true
become_user: "{{ qmd_user }}"
when:
@ -143,8 +136,6 @@
ansible.builtin.command:
cmd: npm run build
chdir: "{{ qmd_source_dir }}"
environment:
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
become: true
become_user: "{{ qmd_user }}"
when:
@ -305,11 +296,7 @@
- name: Validate QMD status
ansible.builtin.command:
cmd: "{{ qmd_binary_path }} status"
# qmd's /bin/sh wrapper invokes `node`; on Darwin pin Homebrew node@24 first
# so it matches the ABI better-sqlite3 was built with (see npm tasks above),
# instead of falling through to an nvm node and failing ERR_DLOPEN_FAILED.
environment:
PATH: "{{ '/opt/homebrew/bin:/usr/local/bin:' if ansible_os_family == 'Darwin' else '' }}{{ ansible_env.PATH }}"
HOME: "{{ qmd_home }}"
QMD_EMBED_API_BASE_URL: "{{ qmd_embed_api_base_url }}"
QMD_EMBED_MODEL: "{{ qmd_embed_model }}"

View File

@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>{{ qmd_launch_agent_label }}</string>
<string>plus.svc.xworkspace.qmd</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
@ -20,15 +20,15 @@
<key>WorkingDirectory</key>
<string>{{ qmd_home }}</string>
<key>StandardOutPath</key>
<string>{{ qmd_launch_agent_stdout_path }}</string>
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/qmd.log</string>
<key>StandardErrorPath</key>
<string>{{ qmd_launch_agent_stderr_path }}</string>
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/qmd.err.log</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{{ qmd_home }}</string>
<key>PATH</key>
<string>{{ qmd_home }}/.bun/bin:{{ qmd_home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:{{ ansible_env.HOME }}/.nvm/versions/node/{{ nodejs_version }}/bin</string>
</dict>
</dict>
</plist>

View File

@ -1,36 +1,10 @@
---
- name: Check required ACP and gateway service status (Linux)
- name: Check required ACP and gateway service status
ansible.builtin.systemd:
name: "{{ item }}"
loop: "{{ xworkmate_bridge_required_services | default(['acp-codex.service', 'acp-opencode.service', 'acp-gemini.service', 'acp-hermes.service']) }}"
register: xworkmate_bridge_dependency_status_linux
until: xworkmate_bridge_dependency_status_linux.status.ActiveState | default('') == "active"
register: xworkmate_bridge_dependency_status
until: xworkmate_bridge_dependency_status.status.ActiveState | default('') == "active"
retries: 12
delay: 5
ignore_errors: true
when: ansible_os_family not in ['Darwin', 'Windows']
- name: Check required ACP and gateway service status (macOS)
ansible.builtin.command: "launchctl list plus.svc.xworkspace.{{ item | regex_replace('\\.service$', '') | replace('-', '.') }}"
loop: "{{ xworkmate_bridge_required_services | default(['acp-codex.service', 'acp-opencode.service', 'acp-gemini.service', 'acp-hermes.service']) }}"
register: xworkmate_bridge_dependency_status_macos
until: xworkmate_bridge_dependency_status_macos.rc == 0 and ('\"PID\"' in xworkmate_bridge_dependency_status_macos.stdout)
retries: 12
delay: 5
ignore_errors: true
changed_when: false
when: ansible_os_family == 'Darwin'
- name: Check required ACP and gateway service status (Windows)
ansible.windows.win_service_info:
name: "{{ item | regex_replace('\\.service$', '') }}"
loop: "{{ xworkmate_bridge_required_services | default(['acp-codex.service', 'acp-opencode.service', 'acp-gemini.service', 'acp-hermes.service']) }}"
register: xworkmate_bridge_dependency_status_windows
until: >
xworkmate_bridge_dependency_status_windows.exists and
(xworkmate_bridge_dependency_status_windows.services | length > 0) and
(xworkmate_bridge_dependency_status_windows.services[0].state == 'running')
retries: 12
delay: 5
ignore_errors: true
when: ansible_os_family == 'Windows'

View File

@ -1,14 +1,4 @@
---
- name: Ensure macOS Vault directories exist
ansible.builtin.file:
path: "{{ item }}"
state: directory
mode: "0755"
loop:
- "{{ vault_config_dir }}"
- "{{ vault_data_dir }}"
- "{{ ansible_env.HOME }}/.local/state/xworkspace"
- name: Install HashiCorp Tap
ansible.builtin.command: brew tap hashicorp/tap
changed_when: false
@ -19,12 +9,6 @@
creates: /opt/homebrew/bin/vault
changed_when: true
- name: Install jq via Homebrew (required by Vault admin bootstrap)
ansible.builtin.command: brew install jq
args:
creates: /opt/homebrew/bin/jq
changed_when: true
- name: Create symlink for Vault binary to match Linux path
ansible.builtin.file:
src: /opt/homebrew/bin/vault

View File

@ -58,7 +58,6 @@
- "{{ vault_data_dir }}"
when:
- vault_deploy_mode == "standalone"
- ansible_os_family != 'Darwin'
- name: Deploy standalone Vault systemd service
ansible.builtin.copy:
@ -130,8 +129,6 @@
--root-token {{ vault_server_root_access_token | quote }}
--output-dir {{ vault_admin_output_dir | quote }}
--ui-url {{ vault_admin_ui_url | quote }}
environment:
PATH: "/opt/homebrew/bin:/usr/local/bin:{{ ansible_env.PATH }}"
no_log: true
when:
- not ansible_check_mode

View File

@ -6,9 +6,9 @@ vault_deploy_mode: "{{ lookup('ansible.builtin.env', 'VAULT_DEPLOY_MODE') | defa
vault_version: "{{ lookup('ansible.builtin.env', 'VAULT_VERSION') | default('1.21.4', true) }}"
vault_listen_addr: 127.0.0.1:8200
vault_service_name: vault
vault_binary_path: "{{ '/opt/homebrew/bin/vault' if ansible_os_family == 'Darwin' else '/usr/local/bin/vault' }}"
vault_config_dir: "{{ (ansible_env.HOME ~ '/Library/Application Support/vault') if ansible_os_family == 'Darwin' else '/etc/vault.d' }}"
vault_data_dir: "{{ (ansible_env.HOME ~ '/Library/Application Support/vault/data') if ansible_os_family == 'Darwin' else '/opt/vault/data' }}"
vault_binary_path: /usr/local/bin/vault
vault_config_dir: /etc/vault.d
vault_data_dir: /opt/vault/data
ai_workspace_auth_token: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_AUTH_TOKEN') | default('', true) }}"
vault_server_root_access_token: "{{ lookup('ansible.builtin.env', 'VAULT_SERVER_ROOT_ACCESS_TOKEN') | default(lookup('ansible.builtin.env', 'VAULT_TOKEN') | default(ai_workspace_auth_token, true), true) }}"
vault_admin_init_enabled: "{{ (vault_server_root_access_token | trim | length > 0) and (vault_admin_password | trim | length > 0) }}"

View File

@ -11,10 +11,7 @@ xfce_packages:
- fonts-noto-cjk
- xserver-xorg-core
# 不钉具体构建号Google apt 源只保留当前 stable任何固定版本几周后即消失
# 导致 "no available installation candidate"。留空 = 装当前可用的 google-chrome-stable。
# 如确需复现某版本,可设为该 madison 中仍存在的版本串(缺失时自动回退最新)。
xfce_google_chrome_version: ""
xfce_google_chrome_version: "149.0.7827.114-1"
xfce_google_chrome_apt_key_url: "https://dl.google.com/linux/linux_signing_key.pub"
xfce_google_chrome_apt_keyring: "/etc/apt/keyrings/google-linux-signing-key.gpg"
xfce_google_chrome_apt_source: "deb [arch=amd64 signed-by={{ xfce_google_chrome_apt_keyring }}] https://dl.google.com/linux/chrome/deb/ stable main"

View File

@ -110,37 +110,18 @@
when:
- xfce_browser_package == 'google-chrome-stable'
- not xfce_offline_active
- name: Inspect available Google Chrome apt versions
ansible.builtin.command: apt-cache madison google-chrome-stable
changed_when: false
register: xfce_google_chrome_versions
when:
- xfce_browser_package == 'google-chrome-stable'
- xfce_google_chrome_version | length > 0
- name: Select Google Chrome package spec
ansible.builtin.set_fact:
xfce_browser_package_spec: >-
{{
'google-chrome-stable=' ~ xfce_google_chrome_version
if (
xfce_browser_package == 'google-chrome-stable'
and (xfce_google_chrome_version | length) > 0
and (xfce_google_chrome_versions.stdout | default('') is search('\\|\\s*' ~ (xfce_google_chrome_version | regex_escape) ~ '\\s*\\|'))
)
else xfce_browser_package
}}
when: xfce_browser_package | length > 0
- xfce_google_chrome_repo.changed
- name: Install apt-managed workspace browser
ansible.builtin.apt:
name: "{{ xfce_browser_package_spec | default(xfce_browser_package) }}"
name: >-
{{
'google-chrome-stable=' ~ xfce_google_chrome_version
if xfce_browser_package == 'google-chrome-stable'
else xfce_browser_package
}}
state: present
install_recommends: false
# Chrome 源只留当前 stable若显式钉到一个比候选更旧但仍在源内的版本
# 安装会被 apt 视为降级而拒绝。允许降级,避免该路径硬失败。
allow_downgrade: true
environment:
DEBIAN_FRONTEND: noninteractive
APT_LISTCHANGES_FRONTEND: none

View File

@ -3,12 +3,7 @@
ansible.builtin.include_role:
name: roles/vhosts/nodejs
vars:
# 不可写成 default(nodejs_version):传入名为 nodejs_version 的变量里再引用
# nodejs_version 形成自引用Ansible 2.19+ 惰性模板判定递归 (Recursive loop
# detected) 而失败。也不可用 default(omit)include_role 的 vars 里 omit 不会
# 回退到角色默认,而是把 omit 占位符字面塞进去,导致 node_<<Omit>>.x 仓库地址。
# 用显式回退到 nodejs 角色的文档默认值。
nodejs_version: "{{ ai_agent_runtime_nodejs_version | default('22.22.3', true) }}"
nodejs_version: "{{ ai_agent_runtime_nodejs_version | default(nodejs_version) }}"
when: xfce_desktop_install_nodejs_runtime | bool
- name: Install Playwright browser runtime

View File

@ -9,7 +9,7 @@ xworkmate_bridge_review_auth_token: "{{ lookup('ansible.builtin.env', 'BRIDGE_RE
xworkmate_bridge_listen_host: 127.0.0.1
xworkmate_bridge_listen_port: 8787
xworkmate_bridge_listen_addr: "{{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}"
xworkmate_bridge_base_dir: "{{ (ansible_env.HOME ~ '/Library/Application Support/cloud-neutral/xworkmate-bridge') if ansible_os_family == 'Darwin' else '/opt/cloud-neutral/xworkmate-bridge' }}"
xworkmate_bridge_base_dir: /opt/cloud-neutral/xworkmate-bridge
xworkmate_bridge_config_file: "{{ xworkmate_bridge_base_dir }}/config.yaml"
xworkmate_bridge_binary_path: /usr/local/bin/xworkmate-go-core
xworkmate_bridge_systemd_unit_path: "/etc/systemd/system/{{ xworkmate_bridge_service_name }}.service"
@ -23,19 +23,9 @@ xworkmate_bridge_service_environment:
BRIDGE_AUTH_TOKEN: "{{ xworkmate_bridge_effective_auth_token | default(xworkmate_bridge_auth_token) }}"
BRIDGE_REVIEW_AUTH_TOKEN: "{{ xworkmate_bridge_effective_review_auth_token | default(xworkmate_bridge_review_auth_token) }}"
BRIDGE_CONFIG_PATH: "{{ xworkmate_bridge_config_file }}"
XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_ACTIVE: "{{ xworkmate_bridge_openclaw_gateway_max_active }}"
XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_MAX_QUEUED: "{{ xworkmate_bridge_openclaw_gateway_max_queued }}"
XWORKMATE_BRIDGE_OPENCLAW_GATEWAY_QUEUE_TIMEOUT: "{{ xworkmate_bridge_openclaw_gateway_queue_timeout }}"
xworkmate_bridge_openclaw_gateway_max_active: 2
xworkmate_bridge_openclaw_gateway_max_active: 5
xworkmate_bridge_openclaw_gateway_max_queued: 20
xworkmate_bridge_openclaw_gateway_queue_timeout: 10m
# Caddy reverse_proxy 流式SSE超时。必须 >= bridge openClawAgentWaitMaxTimeout(60min)
# 才不会在长任务执行到一半时把 SSE 从入口掐断(表现为 ACP_HTTP_CONNECTION_CLOSED
# 而 OpenClaw gateway 仍在后台跑)。来源常量见 xworkmate-bridge/internal/acp/orchestrator.go:32。
# 取 70m = 60min 上限 + 10min 余量(HTTP margin + keepalive 抖动)。改这里即同时驱动 read/write_timeout。
xworkmate_bridge_acp_stream_timeout: 70m
xworkmate_bridge_acp_dial_timeout: 10s
xworkmate_bridge_acp_upstream_keepalive: 5m
xworkmate_bridge_distributed_topology: ""
xworkmate_bridge_distributed_local_node_id: ""
xworkmate_bridge_distributed_task_forward_peer_id: ""
@ -56,14 +46,7 @@ deploy_acp_hermes: true
# Unified domain settings
ai_workspace_public_domain: "{{ lookup('ansible.builtin.env', 'SERVER_DOMAIN') | default(lookup('ansible.builtin.env', 'ACP_BRIDGE_DOMAIN') | default(lookup('ansible.builtin.env', 'BRIDGE_DOMAIN') | default('xworkmate-bridge.svc.plus', true), true), true) }}"
# 域名优先级XWORKMATE_BRIDGE_DOMAIN(envoperator 指定) > CMDB service_domains
# 首个域名(inventory hostvaron-host 模型由流水线作为该 env 传入) > ai_workspace_public_domain。
# 用作 xworkmate-bridge.caddy 站点名与 /etc/hostname绝不为空/127.0.0.1。
xworkmate_bridge_domain: >-
{{ lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_DOMAIN')
| default((service_domains | default('', true) | string | split(',')
| map('trim') | reject('equalto', '') | list | first | default('', true)), true)
| default(ai_workspace_public_domain, true) }}
xworkmate_bridge_domain: "{{ lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_DOMAIN') | default(ai_workspace_public_domain, true) }}"
# When false, disables public Caddy access to XWorkmate Bridge.
xworkmate_bridge_public_access: true
@ -76,7 +59,6 @@ xworkmate_bridge_validation_validate_certs: true
xworkmate_bridge_validation_origin: https://xworkmate.svc.plus
# Caddy configuration paths
xworkmate_bridge_caddy_base_dir: "{{ caddy_config_dir }}"
xworkmate_bridge_caddyfile_path: "{{ caddy_config_dir }}/Caddyfile"
xworkmate_bridge_caddy_conf_dir: "{{ caddy_config_dir }}/conf.d"
xworkmate_bridge_service_caddy_fragment_path: "{{ caddy_config_dir }}/conf.d/xworkmate-bridge.caddy"

View File

@ -1,46 +1,9 @@
---
# Debian/Ubuntu 显式走 aptplay 的 module_defaults.apt.lock_timeout(模板值)只有
# 在 ansible.builtin.apt 任务上才会被正确渲染;经 ansible.builtin.package 间接派发到
# apt 时该模板不渲染,会把字面 "{{ ... }}" 当成 lock_timeout 传入而报 int 转换失败。
- name: Install xworkmate-bridge prerequisites (Debian/Ubuntu via apt)
ansible.builtin.apt:
name: "{{ xworkmate_bridge_packages }}"
state: present
update_cache: true
when: ansible_os_family == 'Debian'
# 非 Debian Linux 仍用通用 package(派发到 yum/dnf不继承 apt 的 lock_timeout 默认)
- name: Install xworkmate-bridge prerequisites (non-Debian Linux)
- name: Install xworkmate-bridge prerequisites
ansible.builtin.package:
name: "{{ xworkmate_bridge_packages }}"
state: present
when: ansible_os_family not in ['Darwin', 'Debian', 'Windows']
# 非空传递检查bridge 域名喂给 /etc/hostname 与 caddy 站点名;空/非 FQDN/127.0.0.1
# 会渲染出无效 Caddyfile。公网暴露(caddy_enabled)时必须是合法 FQDN缺失即抛错。
- name: Assert bridge domain is a non-empty FQDN when exposed via Caddy
ansible.builtin.assert:
that:
- xworkmate_bridge_domain | default('') | trim | length > 0
- "'.' in xworkmate_bridge_domain"
- xworkmate_bridge_domain not in ['127.0.0.1', 'localhost']
fail_msg: >-
xworkmate_bridge_domain 必须是非空 FQDN用于 /etc/hostname 与
/etc/caddy/conf.d/xworkmate-bridge 站点名)。请设置 XWORKMATE_BRIDGE_DOMAIN
或在 CMDB/inventory 提供 service_domains当前解析为
"{{ xworkmate_bridge_domain | default('') }}")。
when: caddy_enabled | default(true) | bool
# 把目标主机 hostname 设为 bridge 域名(= XWORKMATE_BRIDGE_DOMAIN否则 CMDB
# service_domains)。仅 Linux、且为合法 FQDN 时设置;绝不取 127.0.0.1/localhost。
- name: Set host FQDN from xworkmate-bridge domain
ansible.builtin.hostname:
name: "{{ xworkmate_bridge_domain }}"
when:
- ansible_os_family not in ['Darwin', 'Windows']
- xworkmate_bridge_domain | default('') | trim | length > 0
- "'.' in xworkmate_bridge_domain"
- xworkmate_bridge_domain not in ['127.0.0.1', 'localhost']
when: ansible_os_family != 'Darwin'
- name: Ensure xworkmate-bridge service group exists
ansible.builtin.group:

View File

@ -32,14 +32,11 @@
- "'handle /acp*' in xworkmate_bridge_fragment.stdout"
- "'handle /api*' in xworkmate_bridge_fragment.stdout"
- "'handle /artifacts/*' in xworkmate_bridge_fragment.stdout"
- >-
'reverse_proxy ' ~ xworkmate_bridge_listen_host ~ ':' ~ xworkmate_bridge_listen_port
in xworkmate_bridge_fragment.stdout
- "'reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}' in xworkmate_bridge_fragment.stdout"
- "'flush_interval -1' in xworkmate_bridge_fragment.stdout"
# 流式超时与 bridge openClawAgentWaitMaxTimeout(60min) 对齐,由 xworkmate_bridge_acp_stream_timeout 驱动T1/T2
- "'read_timeout ' ~ xworkmate_bridge_acp_stream_timeout in xworkmate_bridge_fragment.stdout"
- "'write_timeout ' ~ xworkmate_bridge_acp_stream_timeout in xworkmate_bridge_fragment.stdout"
- "'keepalive ' ~ xworkmate_bridge_acp_upstream_keepalive in xworkmate_bridge_fragment.stdout"
- "'read_timeout 30m' in xworkmate_bridge_fragment.stdout"
- "'write_timeout 30m' in xworkmate_bridge_fragment.stdout"
- "'keepalive 5m' in xworkmate_bridge_fragment.stdout"
- "'/gateway/openclaw' not in xworkmate_bridge_fragment.stdout"
- "'/acp-server' not in xworkmate_bridge_fragment.stdout"
- "'127.0.0.1:18789' not in xworkmate_bridge_fragment.stdout"

View File

@ -11,55 +11,28 @@
respond `{"jsonrpc":"2.0","error":{"code":-32001,"message":"unauthorized"},"type":"res","ok":false}` 401
}
# /api* 承载 tasks.get 轮询与(部分)流式响应:与 /acp* 用同样的流式 + 长超时配置,
# 避免落到 Caddy 默认短超时把轮询/流式打断T2
handle /api* {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
flush_interval -1
transport http {
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
}
}
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
}
handle /artifacts/* {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
flush_interval -1
transport http {
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
}
}
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
}
# /acp* 流式超时必须 >= bridge openClawAgentWaitMaxTimeout(60min)否则长任务在入口被掐断T1
handle /acp* {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
flush_interval -1
transport http {
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
dial_timeout 10s
read_timeout 30m
write_timeout 30m
keepalive 5m
}
}
}
handle / {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
flush_interval -1
transport http {
dial_timeout {{ xworkmate_bridge_acp_dial_timeout }}
read_timeout {{ xworkmate_bridge_acp_stream_timeout }}
write_timeout {{ xworkmate_bridge_acp_stream_timeout }}
keepalive {{ xworkmate_bridge_acp_upstream_keepalive }}
}
}
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
}
log {

View File

@ -9,13 +9,9 @@
vars:
xworkspace_console_enable_xrdp: false
tasks:
# XFCE/XRDP is a Linux remote-desktop stack (apt-based, systemd) and is not
# applicable to macOS, which already has a native GUI. Skip the whole stack
# on Darwin so the apt-driven tasks never run there.
- name: Include XFCE desktop runtime role
ansible.builtin.include_role:
name: roles/vhosts/xfce_desktop_minimal_runtime
when: ansible_os_family != 'Darwin'
- name: Include XRDP server role when enabled
ansible.builtin.include_role:
@ -27,6 +23,4 @@
xfce_user_groups:
- sudo
- docker
when:
- ansible_os_family != 'Darwin'
- xworkspace_console_enable_xrdp | bool
when: xworkspace_console_enable_xrdp | bool

View File

@ -7,10 +7,7 @@
ansible.builtin.apt:
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
vars:
# 跟随连接用户,与 xworkspace_console_home(ansible_env.HOME) 保持一致:
# 以 root 连接时 user=root/home=/root避免 become_user=ubuntu 去 link /root
# 下的 unit 文件而报 "src does not exist"root 家目录 700ubuntu 无法进入)。
xworkspace_console_user: "{{ ansible_env.USER | default('ubuntu') }}"
xworkspace_console_user: ubuntu
xworkspace_console_public_access: false
xworkspace_console_domain: workspace.svc.plus
xworkspace_console_home: "{{ ansible_env.HOME | default('/home/ubuntu') }}"
@ -19,10 +16,8 @@
xworkspace_console_runtime_archive: "{{ lookup('ansible.builtin.env', 'XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE') | default('', true) }}"
ai_workspace_prebuilt_components_required: "{{ lookup('ansible.builtin.env', 'AI_WORKSPACE_PREBUILT_COMPONENTS_REQUIRED') | default('false', true) | bool }}"
xworkspace_console_dashboard_dir: "{{ xworkspace_console_repo_dir }}/dashboard"
# 预编译 runtime tar 的 manifest.json 记 apiBinary: bin/xworkspace-api
# 二进制落在 bin/(非源码布局的 api/)。对齐之,否则服务 203/EXEC 崩溃重启。
xworkspace_console_api_dir: "{{ xworkspace_console_repo_dir }}/bin"
xworkspace_console_api_binary: "{{ xworkspace_console_api_dir }}/xworkspace-api"
xworkspace_console_api_dir: "{{ xworkspace_console_repo_dir }}/api"
xworkspace_console_api_binary: "{{ xworkspace_console_repo_dir }}/bin/xworkspace-api"
xworkspace_console_runtime_marker: "{{ xworkspace_console_repo_dir }}/.runtime-archive-sha256"
xworkspace_console_api_working_dir: "{{ xworkspace_console_repo_dir }}"
xworkspace_console_api_exec: "{{ xworkspace_console_api_binary }}"
@ -158,16 +153,10 @@
update_cache: true
name: >-
{{
['xfce4', 'python3', 'golang-go']
+ (['caddy'] if caddy_enabled | default(true) | bool else [])
['caddy', 'xfce4', 'python3', 'golang-go']
+ ([xworkspace_console_browser_package] if xworkspace_console_browser_package | length > 0 else [])
}}
state: present
# xfce4 元包会拉入整套桌面,安装期间偶发重置网络/拖长,导致前台 SSH 会话
# 掉线 → ansible 误判 UNREACHABLE实际包已在主机装完。改异步执行 + 轮询,
# 让安装在主机后台跑、ansible 重连轮询,掉线也不影响。
async: "{{ ai_workspace_runtime_apt_async | default(1800) | int }}"
poll: 15
when: ansible_os_family != 'Darwin'
- name: Ensure ttyd binary target directory exists
@ -501,14 +490,10 @@
- name: Download XWorkspace Console runtime release
ansible.builtin.get_url:
url: "https://github.com/ai-workspace-lab/xworkspace-console/releases/download/latest-runtime/xworkspace-console-runtime-{{ ansible_system | lower }}-{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}.tar.gz"
url: "https://github.com/ai-workspace-lab/xworkspace-console/releases/latest/download/xworkspace-console-runtime-{{ ansible_system | lower }}-{{ 'amd64' if ansible_architecture in ['x86_64', 'amd64'] else 'arm64' }}.tar.gz"
dest: "/tmp/xworkspace-console-runtime.tar.gz"
mode: "0644"
force: true
register: xworkspace_console_runtime_download
until: xworkspace_console_runtime_download is succeeded
retries: 3
delay: 5
when: xworkspace_console_runtime_archive | length == 0
- name: Set runtime archive path
@ -624,11 +609,8 @@
[Service]
Type=simple
WorkingDirectory={{ xworkspace_console_dashboard_dir }}/dist
# console 只是 17000 上的静态后端dashboard 为无路由单页 dist由系统
# caddy 经 /etc/caddy/conf.d/ 反代对外。用 python3 静态伺服即可,跨 Linux/
# macOS 统一、不再起第二个 caddy避免与系统 caddy 抢 :80
ExecStart=/usr/bin/env python3 -m http.server {{ xworkspace_console_port }} --bind 127.0.0.1 --directory {{ xworkspace_console_dashboard_dir }}/dist
WorkingDirectory={{ xworkspace_console_dashboard_dir }}
ExecStart=/usr/bin/npm run preview -- --host 127.0.0.1 --port {{ xworkspace_console_port }}
Restart=always
RestartSec=2

View File

@ -1,10 +0,0 @@
- hosts: localhost
tasks:
- name: test
command: npm -v
environment:
PATH: "/Users/shenlan/.local/bin:/Users/shenlan/.npm-global/bin:/Users/shenlan/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
become: true
become_user: shenlan
register: out
- debug: var=out.stdout