Compare commits

..

70 Commits

Author SHA1 Message Date
55a05da3bf
feat: add XWorkmate install redirect (#23)
Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
2026-06-29 15:47:04 +08:00
477b52c516
fix(acp_server_opencode): detect opencode CLI at deploy time (portable across Debian/Ubuntu/macOS) (#22)
Stop assuming a fixed opencode path. Probe the real binary with 'command -v'
using the role PATH, then feed the resolved path to both the systemd unit and
the launchd plist (plist now also passes -opencode-bin). Falls back to the
OS-aware default when opencode is not yet installed.

Also remove the dead acp-bridge.service.j2 template: it was not deployed by any
task and referenced two undefined vars (acp_opencode_bridge_disabled_binary_path,
acp_opencode_bridge_opencode_binary_path) — a hardcoding landmine.

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:31:54 +08:00
4364786465
fix(acp_server_opencode): service PATH + bin var + surface adapter crash in validate (#21)
ACP readiness probe returned 000 for the full retry window on
xworkmate-bridge-ubuntu-26 (nothing listening = adapter crash-loop), but the
play aborted at the probe so the real cause never reached the CI log.

- systemd unit: add Environment=PATH ({{ acp_opencode_path }}, parity with the
  launchd plist) so the lazily-spawned opencode/node CLI resolves; replace the
  hardcoded --opencode-bin /usr/bin/opencode with {{ acp_opencode_binary_path }}
  ({{ npm_global_bin }}/opencode), matching the gemini/codex roles and macOS.
- validate.yml: wrap the readiness probe in block/rescue that dumps systemctl
  status + journalctl on failure, so the adapter crash reason is visible.
- fix latent undefined var in the summary (acp_opencode_adapter_http ->
  acp_opencode_adapter_probe), which would have errored once the endpoint came up.

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 15:25:32 +08:00
e953d87f07
ci: add release/* branch source validation workflow (#19)
release/* 仅接受 hotfix/* 或带 cherry-pick/backport 标签的 PR。
详见 iac_modules/docs/tldr-github-branch-model.md

Co-authored-by: Haitao Pan <manbuzhe2009@qq.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:12:33 +08:00
Haitao Pan
d806ba9d3d fix: update litellm mainstream models registration and gateway defaults 2026-06-27 14:49:18 +08:00
a2ce5b9d05 fix(cloudflare): prefer DNS scoped token 2026-06-27 13:48:19 +08:00
19a3c9f72a fix(macos): select architecture Homebrew explicitly 2026-06-27 12:45:34 +08:00
Haitao Pan
5c74feb860 fix(cloudflare_dns): prefer CLOUDFLARE_API_TOKEN over CLOUDFLARE_DNS_API_TOKEN
Align the DNS role's token resolution with the rest of the stack, which
exports the generic CLOUDFLARE_API_TOKEN. The dedicated *_DNS_API_TOKEN now
acts as the fallback, both for play vars and the environment lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-27 11:31:08 +08:00
Haitao Pan
9b59c89d80 fix(console): expose Homebrew Go to macOS API service 2026-06-27 09:18:03 +08:00
Haitao Pan
abee312617 fix(xfce/nodejs): explicit nodejs_version fallback (omit sentinel leaked into repo URL)
Previous default(omit) was wrong: in include_role vars, omit does not fall back
to the role default — it injects the omit placeholder, which rendered as
node_<<Omit>>.x in the NodeSource apt repo URL and failed apt update. Use an
explicit fallback to the nodejs role's documented default (22.22.3). Avoids both
the 2.19 self-reference recursion and the omit-sentinel leak.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:56:36 +08:00
Haitao Pan
cd9a783de7 fix(xworkmate_bridge): align Caddy SSE timeouts with bridge 60min max wait
Caddy /acp* used read/write_timeout 30m while the bridge max gateway wait is
60min, so long tasks had their SSE killed at the edge (ACP_HTTP_CONNECTION_CLOSED)
while OpenClaw kept running. /api*, /artifacts/* and / also lacked flush_interval
and long timeouts, making polling/streaming fragile.

- T1: introduce xworkmate_bridge_acp_stream_timeout (70m = 60min cap + grace),
  acp_dial_timeout, acp_upstream_keepalive; drive /acp* read/write_timeout from it.
- T2: apply flush_interval -1 + the same long timeouts to /api*, /artifacts/*, /.
- Update validate.yml assertions to reference the vars instead of hardcoded 30m.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 10:49:01 +08:00
Haitao Pan
8fcff61855 fix(ai_agent_runtime): resolver must verify browser actually runs, skip disabled stub
The Chromium resolver accepted any candidate that merely existed (command -v /
-x), so it selected xfce's intentionally-disabled /usr/local/bin/chromium stub
(exits 126 "Chromium is disabled, use google-chrome") over the working
google-chrome. The later "Check chromium version" verify then failed rc=126.
Latent on fresh hosts (depends on role ordering vs the stub install) and
deterministic on any re-run. Now require `<candidate> --version` to succeed
before accepting, so the stub is skipped and google-chrome is resolved.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:42:06 +08:00
Haitao Pan
5d00d700ca fix(xfce/nodejs): drop self-referential nodejs_version (Ansible 2.19 recursion)
include_role passed `nodejs_version: "{{ ai_agent_runtime_nodejs_version |
default(nodejs_version) }}"` — a var named nodejs_version whose template
references nodejs_version itself. Ansible 2.19+'s lazy templating detects the
self-reference in the AST and fails the nodejs role's `nodejs_version_major`
set_fact with "Recursive loop detected: maximum recursion depth exceeded".
Use default(omit) so the nodejs role's own default applies when the
ai_agent_runtime override is absent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:34:52 +08:00
Haitao Pan
50dba213ee feat: implement postgresql.svc.plus docker deployment role 2026-06-26 10:00:00 +08:00
Haitao Pan
c62386f30c fix(postgres): own PGDATA by container uid so re-runs don't break access
On re-run, "Ensure compose directories exist" reset the bind-mounted data dir
to root:root 0700. The official postgres image only chowns/initdb's an EMPTY
PGDATA, so a non-empty data dir stayed root-owned while the backend runs as uid
999 -> "could not open file global/pg_filenode.map: Permission denied" (pg_isready
still passes, masking it; ALTER USER / real queries fail).

Split the dir task: compose project dir stays root:root; data dir is created
owned by postgresql_container_uid/gid (default 999), idempotent across re-runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:42:11 +08:00
Haitao Pan
29e60383e3 fix(xfce_browser): allow_downgrade on Chrome install to avoid downgrade hard-fail
When a host's Chrome apt repo already carries a newer build than a pinned version,
apt refuses with "Packages were downgraded and -y was used without
--allow-downgrades". Set allow_downgrade: true so an explicit (older-but-available)
pin installs cleanly. Complements the empty-default fix (e174e8b): default path
installs latest, pinned path now also robust.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:34:35 +08:00
Haitao Pan
e174e8bcfa fix(xfce_browser): stop pinning Chrome build + fix broken availability regex
Deploy failed on ubuntu26 with "no available installation candidate for
google-chrome-stable=149.0.7827.114-1": Google's apt repo only ever carries the
current stable, so any pinned build vanishes within weeks.

Two fixes:
- defaults: xfce_google_chrome_version "" (install latest google-chrome-stable);
  pin is opt-in and now safe (auto-falls back to latest when the pin is gone).
- browser.yml: the madison availability guard used POSIX [[:space:]], which
  Python re does not support, so it never matched ' | ' separators. Replace with
  \s — verified: empty->latest, pinned+available->pin, pinned+gone->latest.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-25 22:14:46 +08:00
Haitao Pan
5aadb4f0dc fix(xfce): fall back when pinned chrome apt version is unavailable 2026-06-25 20:32:47 +08:00
Haitao Pan
c9919284e0 fix(bridge): avoid embedded templates in caddy assertion 2026-06-25 20:26:38 +08:00
Haitao Pan
5984a75643 fix(litellm): provision Python 3.13 via uv when system python >=3.14
litellm's pinned fork requires Python <3.14; Ubuntu 26.04 ships 3.14 with no
3.13/3.12 in apt, so the venv pip install fails ('requires a different Python').
When the bootstrap interpreter is >=3.14, install a standalone Python 3.13 via
uv, rebuild the venv with it, and proceed. Debian 13 (3.13) is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:23:27 +08:00
Haitao Pan
c7bc68a6dc fix(acp_server_opencode): robust curl-retry for ACP endpoint readiness
The uri probe ran 1s after the service (re)start while the adapter still accepts
TCP but doesn't yet answer (read hangs); uri's default 30s timeout + retries/until
did not actually loop on a connection timeout, so it failed after one attempt.
Replace with a curl retry loop (5s per attempt, up to ~30 tries) — the adapter
answers acp.capabilities in ~4ms once ready.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 21:21:37 +08:00
Haitao Pan
609a88ddcf feat(bridge): fail fast when bridge domain is empty/non-FQDN under Caddy exposure
Non-empty pass-through check: xworkmate_bridge_domain feeds /etc/hostname and the
caddy site name; an empty/non-FQDN/127.0.0.1 value yields an invalid Caddyfile.
Assert a valid FQDN when caddy_enabled (public ingress), with a clear remediation
message (set XWORKMATE_BRIDGE_DOMAIN or provide CMDB service_domains).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 20:50:19 +08:00
Haitao Pan
40b7975061 fix(common): install fail2ban via apt on Debian so module_defaults lock_timeout renders
Same class as bridge/litellm: ansible.builtin.package dispatched to apt inherits
the play's templated module_defaults.apt.lock_timeout un-rendered -> int conversion
error -> on-host bootstrap aborts before litellm/qmd. Use apt on Debian, keep
package for non-Debian (yum/dnf doesn't inherit the apt default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 16:05:40 +08:00
Haitao Pan
3709074916 feat(bridge): set host FQDN + caddy site from XWORKMATE_BRIDGE_DOMAIN or CMDB service_domains
- xworkmate_bridge_domain falls back to the first CMDB service_domains entry
  (inventory hostvar / pipeline-injected env) before ai_workspace_public_domain.
- New task sets the host's /etc/hostname (and running hostname) to that FQDN on
  Linux when it's a valid FQDN — never 127.0.0.1/localhost. The caddy site
  (xworkmate-bridge-site.caddy.j2) already uses the same var.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:56:30 +08:00
Haitao Pan
c3a0e40566 fix(bridge,litellm): use apt on Debian so module_defaults lock_timeout renders
The runtime plays set module_defaults.apt.lock_timeout to a templated value.
When a prerequisite task uses ansible.builtin.package (which dispatches to apt
on Debian), that templated default is NOT rendered and the literal
'{{ ai_workspace_apt_lock_timeout | default(900) | int }}' reaches apt ->
'lock_timeout is of type str ... cannot be converted to an int' -> the whole
on-host bootstrap aborts at the xworkmate-bridge prereq, before litellm/qmd
ever deploy (hence they were never up).

Fix: install prereqs via ansible.builtin.apt on Debian/Ubuntu (template renders
like every other apt task); keep ansible.builtin.package for non-Debian Linux
(dispatches to yum/dnf, which doesn't inherit the apt default).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 15:54:09 +08:00
Haitao Pan
c3f3b8ac8e refactor(agent_skills): run on target host, git-clone sources, drop delegate_to localhost
Make the role work identically under both execution models:
- local/pull (curl|bash -> ansible-playbook -c local; localhost == host)
- remote controller (ansible-playbook -i inventory over ssh; tasks run on host)

Changes:
- Remove ALL delegate_to: localhost (the old raw 'command: rsync' detected
  local-vs-remote via ansible_connection, but delegate_to localhost forced it
  to 'local', so the user@host push branch was dead code -> remote runs wrote
  to the controller's /root and failed).
- Acquire xworkspace-core-skills via ansible.builtin.git clone ON THE HOST
  (most universal/cross-platform), instead of requiring a controller-side dir.
- Merge core skills into the canonical dir with ansible.builtin.copy
  (remote_src, host-local) instead of raw rsync; installer adapters install
  directly into the canonical dir on the host.
- Drop rsync-only vars/excludes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:57:49 +08:00
Haitao Pan
2ef144d572 fix(console): serve dashboard/dist via local python http.server (not npm/caddy)
Prebuilt runtime ships only dashboard/dist (no package.json) so npm run
preview ENOENT-crash-loops (254). console is a local-only static backend on
127.0.0.1:17000 (dashboard is a routerless SPA); serve it with python3
-m http.server on both Linux (console.service) and macOS (console.plist) —
no second caddy (avoids clashing with the system caddy on :80; console is
local-only and not proxied by default). Gate the apt caddy install on
caddy_enabled (true on public-IP Linux VPS for the bridge ingress; macOS
installs no caddy).

Verified: debian13 + ubuntu26.04 console.service active serving 17000=200;
macOS python3 serves the same dist locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 09:44:01 +08:00
Haitao Pan
3505ff1c31 fix(ai-workspace): deploy robustness on Debian13/Ubuntu26.04 (py3.13)
- setup-xworkspace-console.yaml:
  - xworkspace_console_user follows ansible_env.USER (was hardcoded ubuntu;
    mismatched home=/root on root connections -> systemd link 'src does not exist')
  - runtime apt task async/poll (xfce4 desktop install dropped the SSH session)
  - api_dir -> bin/ to match prebuilt runtime manifest (apiBinary: bin/xworkspace-api;
    was api/ -> 203/EXEC crash loop)
- roles/ai_agent_runtime/tasks/{main,docs,fonts,browser}.yml: apt lock_timeout
  (texlive/pandoc raced cloud-init/unattended-upgrades for the dpkg lock)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 03:02:43 +08:00
Haitao Pan
a5e19eff60 chore: qmd version bump, macOS container runtime deps, ignore inventory pycache
- roles/vhosts/common: add docker/docker-compose/colima to macOS brew deps
  (headless container runtime for qmd PG memory-bridge tests)
- roles/vhosts/qmd: bump qmd_version
- .gitignore: ignore inventory/__pycache__/

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 21:01:57 +08:00
Haitao Pan
df48cb4f5a feat(inventory): add Terraform CMDB dynamic inventory for ai-workspace
Reads cmdb.json produced by iac_modules vultr-vps/envs/ai-workspace
generate.py and exposes hosts/groups/hostvars to Ansible, linking IaC
provisioning to playbook deploys (terraform_cmdb.py).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 20:57:58 +08:00
Haitao Pan
099a144a9e fix(xworkmate-bridge): define missing xworkmate_bridge_caddy_base_dir
xworkmate_bridge_obsolete_caddy_fragment_paths references
xworkmate_bridge_caddy_base_dir, but the var was never defined, so the
'Inspect deprecated ACP Caddy fragment' task aborted with
'xworkmate_bridge_caddy_base_dir is undefined'. Define it from the global
caddy_config_dir (consistent with the role's other caddy paths), which is
already OS-aware (/etc/caddy on Linux, Homebrew prefix on macOS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:42:20 +08:00
Haitao Pan
f5a5979439 fix(acp-gemini): create runtime dirs so service WorkingDirectory exists
acp-gemini.service sets WorkingDirectory={{ acp_gemini_workdir }} (~/.gemini)
but the role never created it, so systemd failed at step CHDIR (status
200/CHDIR), the adapter never bound 127.0.0.1:8791, and the CORS preflight
validation failed after 30 retries. Mirror the opencode role: pre-create the
home, .gemini workdir, XDG config and state dirs owned by the service user.
Linux/Debian only (guarded != Darwin); macOS uses the launchd path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:31:38 +08:00
Haitao Pan
e5fc29fa8a fix(console): download runtime from deterministic latest-runtime tag
The online runtime download used releases/latest/download, which GitHub
resolves to whichever release holds the 'Latest' flag. The console repo also
publishes offline-ai-workspace-* build releases that take that flag and carry
no console runtime asset -> HTTP 404 on the online/Debian path. Point at the
stable latest-runtime release (published by the console-runtime workflow) and
add a bounded download retry. The env-provided archive path still wins via the
existing when-guard, so offline/bundled installs are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:19:03 +08:00
Haitao Pan
9e81f65a62 fix(openclaw): pull multi-session plugin runtime from deterministic runtime-latest asset
The download used releases/latest/download, which GitHub resolves to the
human-facing v0.1.12 tag (no runtime asset) -> HTTP 404, failing the deploy
on Ubuntu 26.04 (and any platform). Point at the stable runtime-latest
release published by the plugin repo's runtime-release workflow, and add a
bounded retry around the download for transient network errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:03:23 +08:00
e0bfc765bf feat(litellm): make model registration idempotent via fallback to /model/update 2026-06-23 13:43:42 +08:00
4e183d2d44 fix(litellm): resolve os.environ variables locally before registering models to DB 2026-06-23 13:27:09 +08:00
28df3b59d6 feat(openclaw): conditionally render default UI models and providers based on active API keys 2026-06-23 13:09:56 +08:00
a0d59c0af1 feat(openclaw): adopt native provider simulation pointing to litellm gateway 2026-06-23 12:42:04 +08:00
25b8204b7b fix(openclaw): use hyphens for litellm models to prevent provider intercept 2026-06-23 12:21:45 +08:00
6e260a3425 feat(litellm): ensure deepseek-chat and deepseek-reasoner are registered 2026-06-23 12:18:24 +08:00
e7c96675ff feat(litellm): update model registrations and gateway configurations with API key gating 2026-06-23 11:04:21 +08:00
Haitao Pan
01f1499a60 feat(ai-workspace): consume prebuilt console runtime for final deployment
The macOS console API previously ran via `go run .`, which fails under
launchd's minimal PATH (no `go`) and recompiles on every launch. Switch to
the same prebuilt-runtime consumption model the bridge/qmd/litellm runtimes
already use.

The ai-workspace role now does final deployment only (never builds):
- download xworkspace-console-runtime-<os>-<arch>.tar.gz (incl. darwin-arm64)
  from the latest-runtime release, or use an offline-staged archive via
  XWORKSPACE_CONSOLE_RUNTIME_ARCHIVE;
- unpack to a per-user system dir (~/.local/share/xworkspace-console),
  idempotent via a sha256 marker;
- read manifest.json to resolve the prebuilt API binary and assert it is a
  present, executable native binary;
- on macOS, deploy a LaunchAgent that sources portal.env and execs the
  prebuilt binary directly — no go, no Homebrew, no PATH games.

The Go API is pure-Go (no cgo), so CI cross-compiles darwin-arm64 cleanly;
this role only consumes that artifact. Validated end-to-end on darwin-arm64:
packaged binary serves :8788 (200 with token, 401 without) under launchd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 17:04:55 +08:00
Haitao Pan
a5850cfcee fix(acp_server_gemini): revert incompatible adapter command syntax and update args for antigravity-cli 2026-06-22 13:59:52 +08:00
Haitao Pan
2a85be5c9b fix(xworkmate_bridge): remove obsolete IMAGE variable causing undefined errors 2026-06-22 13:55:14 +08:00
Haitao Pan
32e00a8617 fix(litellm,validation): refine model registration and add cross-platform service validation 2026-06-22 13:52:05 +08:00
Haitao Pan
0ac424f00e Merge branch 'xworkspace-portal-dashboard-17000'
# Conflicts:
#	setup-xworkspace-console.yaml
2026-06-22 13:27:37 +08:00
Haitao Pan
1b2aea005a Merge branch 'refactor/upgrade-antigravity-cli'
# Conflicts:
#	roles/vhosts/acp_server_gemini/defaults/main.yml
#	roles/vhosts/acp_server_gemini/templates/gemini.plist.j2
2026-06-22 13:26:30 +08:00
Haitao Pan
93a3067ea4 Merge branch 'codex/openclaw-playbook-concurrency'
# Conflicts:
#	roles/vhosts/gateway_openclaw/templates/openclaw.json.j2
#	roles/vhosts/xworkmate_bridge/defaults/main.yml
2026-06-22 13:25:45 +08:00
Haitao Pan
9926a46f76 fix(litellm): percent-encode DB password in DATABASE_URL
LiteLLM crash-looped on macOS with Prisma `P1013: invalid port number in
database URL`. The shared auth token is generated by `openssl rand -base64`
and can contain '/', '+' or '='; injected raw into the DATABASE_URL
userinfo, a '/' truncates the authority so the port parses as invalid and
proxy startup fails (port 4000 never binds).

Percent-encode the password for the DATABASE_URL only, via an explicit
reserved-set replace chain ('%' first to avoid double-encoding) since
Jinja's urlencode leaves '/' unescaped. The DB user password stays raw in
provision-database and LITELLM_DB_PASSWORD, and the URL form decodes back
to the identical secret (verified round-trip), so authentication is
unchanged. No effect when no DB host is configured.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:56:56 +08:00
Haitao Pan
ef67c61cf7 fix(xfce): skip Linux XFCE/XRDP desktop stack on macOS
The all-in-one flow reached "Update apt cache" in the
xfce_desktop_minimal_runtime role on macOS and failed with
`[Errno 2] No such file or directory: b'update'` (no apt on Darwin).

XFCE + XRDP is a Linux remote-desktop stack and is meaningless on macOS,
which already has a native GUI. Guard both role includes in
setup-xfce-xrdp.yaml with `ansible_os_family != 'Darwin'` so the apt/systemd
tasks never run there. Linux behavior is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:46:31 +08:00
Haitao Pan
6091b9dbcf fix(qmd): pin Homebrew node@24 for build and status on macOS
`qmd status` aborted with ERR_DLOPEN_FAILED — better-sqlite3 was compiled
against NODE_MODULE_VERSION 137 (node@24) but the validate-status task ran
under nvm's Node 20 (NODE_MODULE_VERSION 115), because the user's PATH puts
nvm node ahead of Homebrew and the task pinned no PATH.

Pin `/opt/homebrew/bin` (node@24) ahead of nvm on Darwin for the npm
install, npm build, and validate-status tasks so the native module is
built and loaded against one consistent Node ABI — the same node@24 the
launchd plist already uses. Linux PATH is left unchanged via an
ansible_os_family conditional.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:43:05 +08:00
Haitao Pan
d9033960fd fix(qmd): drop undefined nodejs_version from macOS LaunchAgent PATH
The QMD launchd plist hardcoded an NVM node path
(`~/.nvm/versions/node/{{ nodejs_version }}/bin`), but `nodejs_version` is
never defined in the Homebrew-based macOS deploy, so "Deploy QMD
LaunchAgent" aborted with `AnsibleUndefinedVariable: 'nodejs_version' is
undefined`.

QMD is a bun binary and the Linux user unit already uses
`.bun/bin:.local/bin:...`. Mirror that for the plist PATH and add the
Homebrew prefix (`/opt/homebrew/bin`) for the brew-installed node@24,
removing the nvm/nodejs_version dependency entirely (same remedy as the
console plist in TC-MAC-005).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:32:58 +08:00
Haitao Pan
bbf5260f0d fix(litellm): put venv bin on PATH for prisma generate on macOS
`prisma generate` invokes the `prisma-client-py` generator as a `/bin/sh`
subprocess, which is resolved via PATH. Even though the role calls the
absolute venv `prisma` binary, the generator console script lives in the
same venv bin dir that is not on the default command PATH, so generation
failed with "prisma-client-py: command not found" on macOS.

Add an `environment.PATH` that prepends the venv bin dir (plus Homebrew
prefixes) so the generator subprocess resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:17:12 +08:00
Haitao Pan
ce2070e779 fix(litellm): repair macOS dependency version probe one-liner
The "Inspect installed LiteLLM dependency versions" probe was written as a
multi-line Python program under YAML `>-` folding, which collapses every
newline into a space. The resulting single logical line contained a
`for ... : try: ... except:` block, which is a SyntaxError. With
`failed_when: false` the failure was swallowed, leaving stdout empty, and
the subsequent `set_fact` crashed in `from_json('')` with
"Expecting value: line 1 column 1 (char 0)".

Rewrite the probe as a genuinely single-line program (dict/list
comprehensions over importlib.metadata.distributions(), joined by `;`),
and harden the decision `set_fact` with `default('{}', true)` so an empty
or malformed probe degrades to "install required" instead of aborting the
play.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:16:57 +08:00
f4a30b9e01 fix(litellm): resilient online dependency install
litellm[proxy] pulls large wheels (polars-runtime ~46MB) that break mid-stream over slow/mirrored links with IncompleteRead, failing the deploy. Add pip --retries/--resume-retries (resumes partial downloads) + longer timeout, tunable via litellm_pip_* vars, and upgrade pip in the venv first so --resume-retries (pip>=25.1) exists.
2026-06-22 02:42:51 +00:00
Haitao Pan
6a2f05f435 fix(litellm): skip redundant dependency installs 2026-06-21 22:34:34 +08:00
Haitao Pan
71ebe6444c fix(litellm): isolate runtime in Python 3.13 venv 2026-06-21 21:15:21 +08:00
Haitao Pan
c11f51b4c9 fix(openclaw): allow version-matched acpx plugin 2026-06-21 21:07:21 +08:00
Haitao Pan
f01e0bb15b fix(qmd): provision macOS LaunchAgent 2026-06-21 21:05:59 +08:00
Haitao Pan
09a39e69ee perf(openclaw): avoid unnecessary doctor repairs 2026-06-21 20:54:01 +08:00
Haitao Pan
02667f9e76 Merge remote-tracking branch 'origin/main' 2026-06-21 20:41:41 +08:00
Haitao Pan
65e45a4834 fix(vhosts): make macOS defaults and vault tasks platform aware 2026-06-21 20:41:32 +08:00
Haitao Pan
f231867593 Merge branch 'fix/xworkmate-windows-handler' into HEAD 2026-06-21 20:40:03 +08:00
Haitao Pan
45f6f3af89 refactor(acp_server_gemini): upgrade to use antigravity-cli 2026-06-19 10:42:24 +08:00
51d28b5d8b fix(postgres): install via brew command on macOS, not homebrew module
The community.general.homebrew module auto-detects a brew prefix and can pick a
stale Intel Homebrew at /usr/local that crashes on newer macOS versions
('unknown or unsupported macOS version'). Use a brew command with the Apple
Silicon prefix first on PATH (matching vault/openclaw), plus
HOMEBREW_NO_AUTO_UPDATE, keeping the task idempotent.
2026-06-18 12:55:51 +00:00
b85a80b8f8 fix(vault): resolve admin entity_id via entity-alias (idempotent bootstrap)
Logging in to obtain entity_id becomes MFA-gated once the login enforcement
exists, so re-runs failed with 'missing entityID'. Look up the entity via its
userpass entity-alias (create entity+alias on first run) and drop the now
unused bootstrap token revoke. Idempotent and backward compatible.
2026-06-18 12:39:42 +00:00
a7ad856e05 fix(common): macOS (Darwin) compatibility for baseline
The Base hardening tasks (timedatectl timezone, /etc/hostname, hostname,
/etc/hosts, ssh hardening, fail2ban, file limits, firewall) use become: true
and Linux-only tooling, so they fail on macOS where the deploy is unprivileged
(timedatectl is also absent). Guard the whole Base block with
ansible_os_family != 'Darwin'.

Add a Common | Darwin baseline branch (common_darwin.yml) that installs shared
Homebrew CLI prerequisites (jq) used by helper scripts in other roles, e.g.
vault's init_vault_admin.sh. Packages are listed in common_darwin_brew_packages.
2026-06-18 12:12:17 +00:00
Haitao Pan
1414fe588f production: build dashboard on target with npm run build, serve with npm run preview
- Add xworkspace_console_dashboard_local_src variable for local dashboard path
- Sync dashboard source from controller to target via rsync
- Build dashboard with npm install && npm run build on target
- Serve production build with npm run preview instead of dev
- Copy dist/ and package.json to portal directory for preview server
2026-06-09 19:46:11 +08:00
Haitao Pan
b58a74892c xworkspace-portal.service: use dashboard on port 17000
- Change portal service from python http.server:7000 to npm dev server:17000
- Update chrome app launcher to use port 17000
- Add dashboard source sync and npm install tasks
- Update portal URL and port variables
2026-06-09 19:28:00 +08:00
Haitao Pan
95efae0060 Configure stable OpenClaw concurrency 2026-05-11 11:45:32 +08:00
70 changed files with 2110 additions and 655 deletions

View File

@ -0,0 +1,44 @@
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,4 +1,5 @@
xfce-secrets.yml
inventory/__pycache__/
.playwright-mcp/
.env
.artifacts/

View File

@ -10,6 +10,7 @@
<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,7 +10,9 @@
<string>-c</string>
<string>
export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH"
exec /usr/bin/env npm run preview -- --host 127.0.0.1 --port {{ xworkspace_console_port }}
# 预编译 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"
</string>
</array>
<key>RunAtLoad</key>
@ -18,7 +20,7 @@
<key>KeepAlive</key>
<true/>
<key>WorkingDirectory</key>
<string>{{ xworkspace_console_dashboard_dir }}</string>
<string>{{ xworkspace_console_dashboard_dir }}/dist</string>
<key>StandardOutPath</key>
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/console.log</string>
<key>StandardErrorPath</key>

106
inventory/terraform_cmdb.py Executable file
View File

@ -0,0 +1,106 @@
#!/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,16 +3,19 @@ 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) }}"
agent_skills_local_source_dir: "{{ lookup('ansible.builtin.env', 'HOME') }}/.agents/skills"
# 规范化技能落地目录canonical始终在目标主机上。installer 直接装到这里,
# core 技能 clone 后合并进来。本地/pull 与远程 controller 两种模型行为一致。
agent_skills_remote_dir: "{{ agent_skills_home }}/.agents/skills"
# xworkspace-core-skills 以 git clone 获取(最通用、跨平台、双模型一致),
# 在目标主机上 clone不再依赖 controller 端预置目录。
agent_skills_xworkspace_core_enabled: true
agent_skills_xworkspace_core_required: true
agent_skills_xworkspace_core_source_dir: "{{ playbook_dir | dirname }}/xworkspace-core-skills/skills"
agent_skills_remote_dir: "{{ agent_skills_home }}/.agents/skills"
agent_skills_local_source_create: true
agent_skills_delete_removed: false
agent_skills_rsync_compress: false
agent_skills_rsync_timeout: 120
agent_skills_install_rsync: 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_replace_existing_target_dirs: false
agent_skills_preserve_existing_target_dirs:
- "{{ agent_skills_home }}/.codex/skills"
@ -32,17 +35,6 @@ 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,286 +1,25 @@
---
- name: Validate agent skills sync input
# 设计:全程在「目标主机」上执行——没有任何 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
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, home, source dirs, remote dir, and targets must be set."
fail_msg: "agent_skills_user/group/home, 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 }}"
@ -297,60 +36,187 @@
group: "{{ agent_skills_group }}"
mode: "0755"
- name: Sync local agent skills into canonical directory
ansible.builtin.command:
argv: >-
# --- 源获取:在目标主机 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: >-
{{
[
'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_remote_dir]
+ (
(
agent_skills_rsync_excludes
+ ((agent_skills_source_index == 0) | ternary(agent_skills_local_symlink_excludes, []))
agent_skills_xworkspace_core_enabled | bool
and agent_skills_core_skills_stat.stat.isdir | default(false)
)
| map('regex_replace', '^(.*)$', '--exclude=\1')
| list
| ternary([agent_skills_xworkspace_core_source_dir], [])
)
+ 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 ~ '/'
)
]
}}
register: agent_skills_rsync_result
changed_when: "'<<CHANGED>>' in agent_skills_rsync_result.stdout"
# --- 缺失场景技能:用 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
check_mode: false
loop: "{{ agent_skills_effective_source_dirs }}"
loop: "{{ agent_skills_required_entries }}"
loop_control:
index_var: agent_skills_source_index
label: "{{ item }}"
delegate_to: localhost
become: false
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
- name: Set canonical agent skills ownership
ansible.builtin.file:
@ -360,6 +226,7 @@
group: "{{ agent_skills_group }}"
recurse: true
# --- 把分类嵌套技能在 canonical 根做扁平 symlink主机本地 ------------------
- name: Link nested categorized skills at canonical root
ansible.builtin.shell: |
set -eu
@ -368,13 +235,9 @@
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"
@ -382,9 +245,7 @@
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
@ -401,6 +262,7 @@
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 }}"
@ -415,8 +277,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 if this path should be
replaced with a link to {{ agent_skills_remote_dir }}.
Set agent_skills_replace_existing_target_dirs=true to replace it with a link
to {{ agent_skills_remote_dir }}.
loop: "{{ agent_skills_target_path_stats.results }}"
when:
- item.stat.exists | default(false)
@ -477,11 +339,10 @@
ansible.builtin.assert:
that:
- agent_skills_manifest_files.matched | int > 0
fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }} after sync."
fail_msg: "No SKILL.md files found under {{ agent_skills_remote_dir }}."
- name: Report synced agent skills
ansible.builtin.debug:
msg: >-
Synced {{ agent_skills_manifest_files.matched }} skill manifests into
{{ agent_skills_remote_dir }} and linked {{ agent_skills_target_paths | length }}
agent target directories.
{{ agent_skills_manifest_files.matched }} skill manifests under
{{ agent_skills_remote_dir }}; linked {{ agent_skills_target_paths | length }} agent targets.

View File

@ -14,12 +14,16 @@
google-chrome-stable \
/usr/bin/chromium \
/snap/bin/chromium; do
resolved=""
if command -v "$candidate" >/dev/null 2>&1; then
command -v "$candidate"
exit 0
resolved="$(command -v "$candidate")"
elif [ -x "$candidate" ]; then
resolved="$candidate"
fi
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
# 必须真正可执行:跳过 xfce 安装的 disabled chromium stub退出码 126
# 否则 resolver 会选中它,后续 --version 校验必失败。
if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then
printf '%s\n' "$resolved"
exit 0
fi
done
@ -37,6 +41,8 @@
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
@ -60,12 +66,16 @@
/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
command -v "$candidate"
exit 0
resolved="$(command -v "$candidate")"
elif [ -x "$candidate" ]; then
resolved="$candidate"
fi
if [ -x "$candidate" ]; then
printf '%s\n' "$candidate"
# 必须真正可执行:跳过 xfce 安装的 disabled chromium stub退出码 126
# 否则 resolver 会选中它,后续 --version 校验必失败。
if [ -n "$resolved" ] && "$resolved" --version >/dev/null 2>&1; then
printf '%s\n' "$resolved"
exit 0
fi
done

View File

@ -5,6 +5,8 @@
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,6 +5,8 @@
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,6 +11,8 @@
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:
- Token resolution from Ansible extra vars, with the DNS-scoped token preferred:
- `-e CLOUDFLARE_DNS_API_TOKEN=...`
- `-e CLOUDFLARE_API_TOKEN=...`
- Environment-backed token resolution as fallback:
- Environment-backed token resolution as fallback, with the DNS-scoped token preferred:
- `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_API_TOKEN is valid but lacks DNS edit permission for {{ cloudflare_dns_zone_name }}.
CLOUDFLARE_DNS_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

@ -0,0 +1,16 @@
---
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

@ -0,0 +1,100 @@
-- 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

@ -0,0 +1,88 @@
# 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,5 +1,93 @@
---
# TODO: implement docker deployment tasks
- name: Placeholder task
debug:
msg: "Role placeholder. Implement docker deployment tasks."
- 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') }}"

View File

@ -0,0 +1,45 @@
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 }}/gemini"
acp_gemini_binary_path: "{{ acp_gemini_npm_global_bin }}/antigravity-cli"
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: --experimental-acp
acp_gemini_args: mcp-app-server
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,6 +69,21 @@
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,6 +11,10 @@ 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,6 +79,29 @@
- "{{ 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,28 +17,71 @@
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
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
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.
- 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:
@ -47,6 +90,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

@ -1,27 +0,0 @@
[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,9 +9,10 @@ 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 /usr/bin/opencode --cwd {{ acp_opencode_workdir }}
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 }}
Restart=always
RestartSec=2

View File

@ -17,6 +17,7 @@
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,6 +6,53 @@ 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,5 +1,6 @@
---
dependencies:
- role: roles/vhosts/caddy
- role: roles/agent_skills
- role: roles/vhosts/gateway_openclaw
- role: roles/vhosts/xworkmate_bridge

View File

@ -0,0 +1,42 @@
---
# 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

@ -0,0 +1,156 @@
---
- 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

@ -0,0 +1,6 @@
{{ 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

@ -0,0 +1,29 @@
<?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,8 +22,18 @@ 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,9 +1,21 @@
---
- name: Fail2ban | Install Fail2ban package
# 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)
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,12 +23,11 @@ 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/latest/download/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/download/runtime-latest/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:
- "@openclaw/acpx"
gateway_openclaw_removed_global_plugins: []
gateway_openclaw_extension_dependency_dirs: []
gateway_openclaw_config_path: "{{ gateway_openclaw_home }}/.openclaw/openclaw.json"
gateway_openclaw_workspace_path: "{{ gateway_openclaw_home }}/.openclaw/workspace"
@ -59,22 +58,63 @@ 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_default_model_fallback }}"
- nvidia/nemotron-3-super-120b-a12b
- nvidia/minimaxai/minimax-m2.5
- nvidia/z-ai/glm5
gateway_openclaw_default_models:
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:
"{{ gateway_openclaw_default_model_primary }}": {}
"{{ gateway_openclaw_default_model_fallback }}": {}
nvidia/nemotron-3-super-120b-a12b: {}
nvidia/minimaxai/minimax-m2.5: {}
nvidia/z-ai/glm5: {}
openai/gpt-5.5:
agentRuntime:
id: codex
"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 {})
}}
gateway_openclaw_main_agent_model: "{{ gateway_openclaw_default_model_primary }}"
gateway_openclaw_main_agent_skills:
@ -122,77 +162,97 @@ gateway_openclaw_mcp_servers:
url: http://localhost:8181/mcp
transport: streamable-http
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
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 {})
}}

View File

@ -1,6 +1,23 @@
---
- name: Run OpenClaw doctor (POSIX)
ansible.builtin.command: "{{ gateway_openclaw_binary_path }} doctor --fix --force --non-interactive --yes"
- 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"
environment:
HOME: "{{ gateway_openclaw_home }}"
PATH: "{{ gateway_openclaw_service_path }}"
@ -11,21 +28,22 @@
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
listen: Restart openclaw
- gateway_openclaw_doctor_lint.rc | default(0) != 0
listen: Run OpenClaw doctor
- name: Run OpenClaw doctor (Windows)
ansible.builtin.include_tasks: windows_doctor.yml
when:
- not ansible_check_mode
- ansible_os_family == 'Windows'
listen: Restart openclaw
listen: Run OpenClaw doctor
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
when:
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- ansible_os_family not in ['Darwin', 'Windows']
listen: Restart openclaw
- name: Restart openclaw gateway
@ -47,7 +65,14 @@
become: true
when:
- not ansible_check_mode
- ansible_os_family != 'Darwin'
- 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'
listen: Restart openclaw
- name: Unload openclaw on macOS

View File

@ -1,6 +1,16 @@
---
- name: Execute OpenClaw doctor
community.windows.win_command: "{{ gateway_openclaw_home }}\\.local\\node_modules\\openclaw\\bin\\openclaw.cmd doctor --fix --force --non-interactive --yes"
- 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"
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

@ -0,0 +1,6 @@
---
- 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,6 +68,13 @@
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: >-
@ -89,7 +96,13 @@
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
notify: Restart openclaw
- >-
(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
- name: Ensure OpenClaw extensions directory exists
ansible.builtin.file:
@ -105,6 +118,10 @@
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:
@ -114,8 +131,11 @@
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: Restart openclaw
notify:
- Run OpenClaw doctor
- Restart openclaw
- name: Ensure OpenClaw global plugin npm directory exists
ansible.builtin.file:
@ -125,6 +145,21 @@
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: >-
@ -149,7 +184,12 @@
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'
notify: Restart openclaw
- >-
(((gateway_openclaw_installed_global_plugins.stdout | default('{}') | from_json).dependencies | default({})).get(item.name, {}).get('version', ''))
!= item.version
notify:
- Run OpenClaw doctor
- Restart openclaw
- name: Remove unsupported OpenClaw global plugin packages
ansible.builtin.command:
@ -172,7 +212,9 @@
when:
- gateway_openclaw_removed_global_plugins | length > 0
- not ansible_check_mode
notify: Restart openclaw
notify:
- Run OpenClaw doctor
- Restart openclaw
- name: Reset OpenClaw compile cache after package or plugin changes
ansible.builtin.file:
@ -276,7 +318,9 @@
group: "{{ gateway_openclaw_service_group }}"
mode: "0600"
diff: false
notify: Restart openclaw
notify:
- Run OpenClaw doctor
- Restart openclaw
- name: Inspect OpenClaw package manifest
ansible.builtin.stat:
@ -303,6 +347,8 @@
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
@ -329,6 +375,8 @@
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
@ -420,6 +468,7 @@
(
(gateway_openclaw_plugin_registry.stdout | from_json).plugins
| selectattr('id', 'equalto', 'acpx')
| rejectattr('version', 'equalto', gateway_openclaw_required_version)
| list
| length
) == 0
@ -434,7 +483,7 @@
fail_msg: >-
OpenClaw must run @openclaw/codex {{ gateway_openclaw_required_version }}
plus openclaw-multi-session-plugins {{ gateway_openclaw_required_version }},
and must not keep stale global @openclaw/acpx.
and any OpenClaw-managed acpx plugin must match the gateway version.
when:
- not ansible_check_mode
- ansible_os_family != 'Windows'

View File

@ -1,4 +1,10 @@
---
- 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: >-
@ -12,8 +18,15 @@
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
notify: Restart openclaw
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
- name: Ensure OpenClaw extensions directory exists on Windows
ansible.windows.win_file:
@ -26,13 +39,17 @@
dest: "{{ gateway_openclaw_home }}\.openclaw\extensions"
creates: "{{ gateway_openclaw_home }}\.openclaw\extensions\openclaw-multi-session-plugins"
when: not ansible_check_mode
notify: Restart openclaw
notify:
- Run OpenClaw doctor
- Restart openclaw
- name: Deploy OpenClaw gateway JSON config on Windows
ansible.windows.win_template:
src: openclaw.json.j2
dest: "{{ gateway_openclaw_config_path }}"
notify: Restart openclaw
notify:
- Run OpenClaw doctor
- Restart openclaw
- name: Register OpenClaw gateway as a Windows Service (schtasks)
community.windows.win_command:

View File

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

View File

@ -5,18 +5,51 @@ 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] @ git+' ~ litellm_source_repo ~ '@' ~ litellm_version,
else 'litellm[proxy] @ ' ~ litellm_source_archive_url,
true)
}}
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_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_listen_host: 127.0.0.1
litellm_listen_port: 4000
litellm_config_dir: /etc/litellm
@ -74,8 +107,22 @@ 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 }}@{{ 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_urlencoded | trim }}@{{ 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,17 +13,31 @@ 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
@ -31,7 +45,7 @@ add_model() {
"model_name": "$alias_name",
"litellm_params": {
"model": "$litellm_provider_model",
"api_key": "os.environ/$api_key_env_var",
"api_key": "${!api_key_env_var}",
"api_base": "$api_base"
},
"model_info": {
@ -47,7 +61,7 @@ EOF
"model_name": "$alias_name",
"litellm_params": {
"model": "$litellm_provider_model",
"api_key": "os.environ/$api_key_env_var"
"api_key": "${!api_key_env_var}"
},
"model_info": {
"id": "$alias_name",
@ -63,68 +77,168 @@ 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")
-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 added."
REGISTERED+=("$alias_name")
else
echo "[ERROR] Failed to add model $alias_name. HTTP Code: $http_code"
echo "Response: $response"
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
fi
}
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"
# 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 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"
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 Gemini Models..."
echo "========================================="
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"
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
echo "========================================="
echo "Registering GPT Models..."
echo "========================================="
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"
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
echo "========================================="
echo "Registering Claude Models..."
echo "========================================="
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 "${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
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"
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 "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,7 +1,21 @@
---
# Provision the litellm database and user BEFORE litellm starts.
# Only runs when litellm_database_host is set.
- name: Install LiteLLM prerequisites (Linux)
# 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)
ansible.builtin.package:
name:
- python3
@ -9,7 +23,7 @@
- python3-venv
- python3-psycopg2
state: present
when: ansible_os_family != 'Darwin'
when: ansible_os_family not in ['Darwin', 'Debian', 'Windows']
- name: Install LiteLLM prerequisites (macOS)
# Use brew from PATH (Apple Silicon prefix first) instead of the
@ -103,28 +117,160 @@
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"
extra_args: --break-system-packages
executable: "{{ litellm_pip_executable if litellm_pip_executable | length > 0 else omit }}"
executable: "{{ litellm_pip_executable }}"
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.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
ansible.builtin.command:
cmd: >-
{{ litellm_python_executable }} -c
"import pathlib, litellm.proxy; print(pathlib.Path(litellm.proxy.__file__).parent)"
register: litellm_proxy_package_path
changed_when: false
become: true
@ -136,11 +282,17 @@
litellm_python_site_packages: "{{ (litellm_proxy_package_path.stdout | trim) | dirname | dirname }}"
- name: Generate Prisma Python Client
ansible.builtin.shell: |
export PATH={{ litellm_service_home }}/.local/bin:$PATH
prisma generate
ansible.builtin.command:
cmd: "{{ litellm_prisma_binary_path }} 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,10 +8,9 @@ User={{ litellm_service_user }}
Group={{ litellm_service_group }}
WorkingDirectory={{ litellm_service_home }}
EnvironmentFile={{ litellm_env_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 }}
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 }}
Restart=always
RestartSec=5
StandardOutput=journal

View File

@ -9,7 +9,7 @@
<string>/bin/bash</string>
<string>-c</string>
<string>
export PATH="/opt/homebrew/bin:/usr/local/bin:{{ litellm_service_home }}/.local/bin:$PATH"
export PATH="{{ litellm_venv_dir }}/bin:/opt/homebrew/bin:/usr/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_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
exec "{{ litellm_binary_path }}" --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: "brew uninstall --ignore-dependencies --force node"
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/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: "brew install {{ nodejs_homebrew_formula }}"
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/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: "brew link --force --overwrite {{ nodejs_homebrew_formula }}"
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/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: "brew pin {{ nodejs_homebrew_formula }}"
ansible.builtin.command: "{{ nodejs_homebrew_prefix }}/bin/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,6 +27,12 @@ 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,18 +33,24 @@
no_log: true
when: postgresql_admin_password_file_status.stat.exists
- name: Ensure PostgreSQL compose directories exist
- name: Ensure PostgreSQL compose project directory exists
ansible.builtin.file:
path: "{{ item.path }}"
path: "{{ postgresql_compose_project_dir }}"
state: directory
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- path: "{{ postgresql_compose_project_dir }}"
mode: "0755"
- path: "{{ postgresql_data_dir }}"
mode: "0700"
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"
- name: Render PostgreSQL compose environment
ansible.builtin.copy:

View File

@ -15,6 +15,20 @@
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: "6021ea34ac27ac9b5c9a7d655500544917c801dd"
qmd_version: "236c83a5f38d860fbf56829ba4e188c1fa2ae52b"
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,6 +18,11 @@ 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 {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
ansible.builtin.command: "launchctl unload {{ qmd_launch_agent_path }}"
failed_when: false
changed_when: false
when: ansible_system == 'Darwin'
listen: Restart QMD
- name: Load QMD on macOS
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
ansible.builtin.command: "launchctl load -w {{ qmd_launch_agent_path }}"
changed_when: false
when: ansible_system == 'Darwin'
listen: Restart QMD

View File

@ -1,13 +1,26 @@
---
- name: Create launchd plist template for QMD
- 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
ansible.builtin.template:
src: qmd.plist.j2
dest: "{{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
dest: "{{ qmd_launch_agent_path }}"
owner: "{{ qmd_user }}"
group: "{{ qmd_group }}"
mode: "0644"
notify: Restart QMD
- name: Reload launchd agent for QMD
ansible.builtin.command: "launchctl load -w {{ ansible_env.HOME }}/Library/LaunchAgents/plus.svc.xworkspace.qmd.plist"
- name: Ensure QMD LaunchAgent is loaded
ansible.builtin.command: "launchctl load -w {{ qmd_launch_agent_path }}"
register: launchctl_result
changed_when: false
failed_when: launchctl_result.rc != 0 and 'already loaded' not in launchctl_result.stderr

View File

@ -126,6 +126,13 @@
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:
@ -136,6 +143,8 @@
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:
@ -296,7 +305,11 @@
- 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>plus.svc.xworkspace.qmd</string>
<string>{{ qmd_launch_agent_label }}</string>
<key>ProgramArguments</key>
<array>
<string>/bin/bash</string>
@ -20,15 +20,15 @@
<key>WorkingDirectory</key>
<string>{{ qmd_home }}</string>
<key>StandardOutPath</key>
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/qmd.log</string>
<string>{{ qmd_launch_agent_stdout_path }}</string>
<key>StandardErrorPath</key>
<string>{{ ansible_env.HOME }}/.local/state/xworkspace/qmd.err.log</string>
<string>{{ qmd_launch_agent_stderr_path }}</string>
<key>EnvironmentVariables</key>
<dict>
<key>HOME</key>
<string>{{ qmd_home }}</string>
<key>PATH</key>
<string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:{{ ansible_env.HOME }}/.nvm/versions/node/{{ nodejs_version }}/bin</string>
<string>{{ qmd_home }}/.bun/bin:{{ qmd_home }}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
</dict>
</dict>
</plist>

View File

@ -1,10 +1,36 @@
---
- name: Check required ACP and gateway service status
- name: Check required ACP and gateway service status (Linux)
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
until: xworkmate_bridge_dependency_status.status.ActiveState | default('') == "active"
register: xworkmate_bridge_dependency_status_linux
until: xworkmate_bridge_dependency_status_linux.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,4 +1,14 @@
---
- 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
@ -9,6 +19,12 @@
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,6 +58,7 @@
- "{{ vault_data_dir }}"
when:
- vault_deploy_mode == "standalone"
- ansible_os_family != 'Darwin'
- name: Deploy standalone Vault systemd service
ansible.builtin.copy:
@ -129,6 +130,8 @@
--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: /usr/local/bin/vault
vault_config_dir: /etc/vault.d
vault_data_dir: /opt/vault/data
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' }}"
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,7 +11,10 @@ xfce_packages:
- fonts-noto-cjk
- xserver-xorg-core
xfce_google_chrome_version: "149.0.7827.114-1"
# 不钉具体构建号Google apt 源只保留当前 stable任何固定版本几周后即消失
# 导致 "no available installation candidate"。留空 = 装当前可用的 google-chrome-stable。
# 如确需复现某版本,可设为该 madison 中仍存在的版本串(缺失时自动回退最新)。
xfce_google_chrome_version: ""
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,18 +110,37 @@
when:
- xfce_browser_package == 'google-chrome-stable'
- not xfce_offline_active
- xfce_google_chrome_repo.changed
- 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
- name: Install apt-managed workspace browser
ansible.builtin.apt:
name: >-
{{
'google-chrome-stable=' ~ xfce_google_chrome_version
if xfce_browser_package == 'google-chrome-stable'
else xfce_browser_package
}}
name: "{{ xfce_browser_package_spec | default(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,7 +3,12 @@
ansible.builtin.include_role:
name: roles/vhosts/nodejs
vars:
nodejs_version: "{{ ai_agent_runtime_nodejs_version | default(nodejs_version) }}"
# 不可写成 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) }}"
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: /opt/cloud-neutral/xworkmate-bridge
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_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,9 +23,19 @@ 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: 5
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_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: ""
@ -46,7 +56,14 @@ 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: "{{ lookup('ansible.builtin.env', 'XWORKMATE_BRIDGE_DOMAIN') | default(ai_workspace_public_domain, 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) }}
# When false, disables public Caddy access to XWorkmate Bridge.
xworkmate_bridge_public_access: true
@ -59,6 +76,7 @@ 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,9 +1,46 @@
---
- name: Install xworkmate-bridge prerequisites
# 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)
ansible.builtin.package:
name: "{{ xworkmate_bridge_packages }}"
state: present
when: ansible_os_family != 'Darwin'
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']
- name: Ensure xworkmate-bridge service group exists
ansible.builtin.group:

View File

@ -32,11 +32,14 @@
- "'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"
- "'read_timeout 30m' in xworkmate_bridge_fragment.stdout"
- "'write_timeout 30m' in xworkmate_bridge_fragment.stdout"
- "'keepalive 5m' 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"
- "'/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,28 +11,55 @@
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 }}
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 }}
}
}
}
handle /artifacts/* {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
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 }}
}
}
}
# /acp* 流式超时必须 >= bridge openClawAgentWaitMaxTimeout(60min)否则长任务在入口被掐断T1
handle /acp* {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }} {
flush_interval -1
transport http {
dial_timeout 10s
read_timeout 30m
write_timeout 30m
keepalive 5m
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 }}
}
}
}
handle / {
reverse_proxy {{ xworkmate_bridge_listen_host }}:{{ xworkmate_bridge_listen_port }}
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 }}
}
}
}
log {

View File

@ -9,9 +9,13 @@
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:
@ -23,4 +27,6 @@
xfce_user_groups:
- sudo
- docker
when: xworkspace_console_enable_xrdp | bool
when:
- ansible_os_family != 'Darwin'
- xworkspace_console_enable_xrdp | bool

View File

@ -7,7 +7,10 @@
ansible.builtin.apt:
lock_timeout: "{{ ai_workspace_apt_lock_timeout | default(900) | int }}"
vars:
xworkspace_console_user: ubuntu
# 跟随连接用户,与 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_public_access: false
xworkspace_console_domain: workspace.svc.plus
xworkspace_console_home: "{{ ansible_env.HOME | default('/home/ubuntu') }}"
@ -16,8 +19,10 @@
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"
xworkspace_console_api_dir: "{{ xworkspace_console_repo_dir }}/api"
xworkspace_console_api_binary: "{{ xworkspace_console_repo_dir }}/bin/xworkspace-api"
# 预编译 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_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 }}"
@ -153,10 +158,16 @@
update_cache: true
name: >-
{{
['caddy', 'xfce4', 'python3', 'golang-go']
['xfce4', 'python3', 'golang-go']
+ (['caddy'] if caddy_enabled | default(true) | bool else [])
+ ([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
@ -490,10 +501,14 @@
- name: Download XWorkspace Console runtime release
ansible.builtin.get_url:
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"
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"
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
@ -609,8 +624,11 @@
[Service]
Type=simple
WorkingDirectory={{ xworkspace_console_dashboard_dir }}
ExecStart=/usr/bin/npm run preview -- --host 127.0.0.1 --port {{ xworkspace_console_port }}
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
Restart=always
RestartSec=2

10
test.yml Normal file
View File

@ -0,0 +1,10 @@
- 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