diff --git a/CLAUDE.md b/CLAUDE.md index 6e8a83e2..27118c95 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,18 +2,19 @@ ## Project Overview -OpenClaw Orchestrator (Claworc) manages multiple OpenClaw instances in Kubernetes or Docker. +OpenClaw Orchestrator (Claworc) manages multiple AI agent instances in Kubernetes or Docker — OpenClaw by +default, plus Hermes, NanoClaw, or any custom image implementing the agent shim contract (`docs/shim.md`). Each instance runs in its own container/pod and allows users easy access to a Chromium browser & terminal for collaboration with the agent. The project consists of the following components: * Control Plane (Golang backend and React frontend) with dashboard, VNC client for Chromium, Terminal, Logs and other useful stuff. -* Agent image with OpenClaw installed. It is compatible with both ARM64 and AMD64 architectures. +* Agent images (`claworc/openclaw`, `claworc/hermes`, `claworc/nanoclaw`, plus a copy-me template). Compatible with both ARM64 and AMD64 architectures. * Helm chart for deployment to Kubernetes. ## Repository Structure -- `agent/` - Base docker image with OpenClaw instance (`claworc/openclaw`) and images with various browsers `claworc/-browser` +- `agent/` - Agent docker images (`agent/openclaw`, `agent/hermes`, `agent/nanoclaw`, `agent/template`) and browser images `claworc/-browser` - `control-plane/` - Main application (Go backend + React frontend) - `main.go` - Entry point, Chi router, embedded SPA serving - `internal/` - Go packages (config, database, handlers, middleware, orchestrator, sshproxy, sshterminal) @@ -21,7 +22,6 @@ The project consists of the following components: - `Dockerfile` - Multi-stage build (Node frontend + Go backend) - `helm/` - Helm chart for deploying the dashboard to Kubernetes - `website/` - Landing page for claworc.com -- `website_docs/` - End-user documentation powered by Mintlify. It is automatically deployed to claworc.com/docs - `docs/` - Detailed internal specs (architecture, API, data model, UI, features) ## Architecture @@ -36,6 +36,16 @@ health at `/health`. Logs are streamed via SSE. WebSocket proxying for chat and **LLM Gateway**: Proxy for LLM requests that replaces virtual keys with real, globally configured API tokens. It records statistics in a separate SQLite database. See`docs/virtual-keys.md`. +**Agent Shim** (`internal/agentshim/`): The universal interface between the control plane and the AI agent +running inside an instance container (OpenClaw, Hermes, NanoClaw, custom). All agent-specific knowledge — +chat protocol, config paths, LLM provider config, restart — lives behind the `Client`/`Session` interfaces. +Two adapters: `shimexec/` speaks the exec-based shim contract (`docs/shim.md`, scripts at `/opt/claworc/shim/` +inside the image, invoked over SSH), and `openclawnative/` drives pre-shim OpenClaw images via their gateway +WebSocket + CLI. The factory prefers the shim when the image ships it and falls back to native for legacy +OpenClaw images. Chat, webhooks, config editing, and virtual-key routing all go through this layer. The +agent-type registry (`registry.go`) drives per-type defaults and UI capability gating. Layering is strict: +handlers → agentshim → sshproxy (transport) → orchestrator (containers). + **Orchestrator** (`internal/orchestrator/`): Thin abstraction over the underlying container runtime (Kubernetes or Docker). Its job is generic container primitives only — instance lifecycle, exec, file streaming, SSH address, resource updates, image updates, volume cloning. It does NOT own browser-pod, diff --git a/Makefile b/Makefile index ec263fc5..95650ecd 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,8 @@ include .env.development export AGENT_IMAGE := claworc/openclaw +HERMES_IMAGE := claworc/hermes +NANOCLAW_IMAGE := claworc/nanoclaw STABLE_IMAGE := glukw/claworc-stable STABLE_MIRROR_IMAGE := claworc/openclaw-stable STABLE_VERSION_URL := https://isitstable.com/api/v1/openclaw/latest-stable @@ -51,7 +53,9 @@ agent-base-china: agent-build: @echo "Building images locally (agent + browser variants)..." - docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) -t $(AGENT_IMAGE):$(TAG) -f agent/instance/Dockerfile --load agent/instance/ + docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) -t $(AGENT_IMAGE):$(TAG) -f agent/openclaw/Dockerfile --load agent/openclaw/ + docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) -t $(HERMES_IMAGE):$(TAG) -f agent/hermes/Dockerfile --load agent/hermes/ + docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) -t $(NANOCLAW_IMAGE):$(TAG) -f agent/nanoclaw/Dockerfile --load agent/nanoclaw/ docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) --build-arg BASE_IMAGE=$(BROWSER_BASE_IMAGE):$(TAG) -t $(BROWSER_CHROMIUM_IMAGE):$(TAG) -f agent/browser/Dockerfile.chromium --load agent/browser/ docker buildx build --platform linux/amd64 $(CACHE_ARGS) --build-arg BASE_IMAGE=$(BROWSER_BASE_IMAGE):$(TAG) -t $(BROWSER_CHROME_IMAGE):$(TAG) -f agent/browser/Dockerfile.chrome --load agent/browser/ docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) --build-arg BASE_IMAGE=$(BROWSER_BASE_IMAGE):$(TAG) -t $(BROWSER_BRAVE_IMAGE):$(TAG) -f agent/browser/Dockerfile.brave --load agent/browser/ @@ -62,11 +66,28 @@ agent-test: AGENT_CHROME_TEST_IMAGE=$(BROWSER_CHROME_IMAGE):$(TAG) \ AGENT_BRAVE_TEST_IMAGE=$(BROWSER_BRAVE_IMAGE):$(TAG) \ npm run test + @echo "Running shim conformance selftest against $(HERMES_IMAGE):$(TAG)..." + docker run --rm --entrypoint sh $(HERMES_IMAGE):$(TAG) -c 'sh /opt/claworc/shim/shim-selftest /opt/claworc/shim' + @echo "Running shim conformance selftest against $(NANOCLAW_IMAGE):$(TAG)..." + # NanoClaw's shim needs its s6 services (svc-agent supervisor) running, so + # boot the image, wait for health, then exec the selftest. The LLM proxy + # URL points at an unreachable port on purpose: the chat check then fails + # fast inside the agent (connection refused) instead of hanging on auth, + # and the turn still ends cleanly per contract. + docker rm -f claworc-nanoclaw-selftest >/dev/null 2>&1 || true + docker run -d --name claworc-nanoclaw-selftest \ + -e CLAWORC_INITIAL_LLM_CONFIG='{"proxy_url":"http://127.0.0.1:40001","style":"anthropic","default_model":"anthropic/claude-sonnet-4-5","providers":[{"key":"anthropic","api_key":"claworc-vk-ci","api_type":"anthropic-messages"}]}' \ + $(NANOCLAW_IMAGE):$(TAG) + sh -c 'for i in $$(seq 1 30); do docker exec claworc-nanoclaw-selftest /opt/claworc/shim/health >/dev/null 2>&1 && exit 0; sleep 2; done; echo "nanoclaw health never became ready" >&2; docker logs claworc-nanoclaw-selftest; docker rm -f claworc-nanoclaw-selftest; exit 1' + docker exec claworc-nanoclaw-selftest sh /opt/claworc/shim/shim-selftest /opt/claworc/shim; \ + rc=$$?; docker rm -f claworc-nanoclaw-selftest >/dev/null 2>&1; exit $$rc agent-push: @echo "Pushing all agent + browser images in parallel..." - docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) -t $(AGENT_IMAGE):$(TAG) -f agent/instance/Dockerfile --push agent/instance/ & \ + docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) -t $(AGENT_IMAGE):$(TAG) -f agent/openclaw/Dockerfile --push agent/openclaw/ & \ + docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) -t $(HERMES_IMAGE):$(TAG) -f agent/hermes/Dockerfile --push agent/hermes/ & \ + docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) -t $(NANOCLAW_IMAGE):$(TAG) -f agent/nanoclaw/Dockerfile --push agent/nanoclaw/ & \ docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) --build-arg BASE_IMAGE=$(BROWSER_BASE_IMAGE):$(TAG) -t $(BROWSER_CHROMIUM_IMAGE):$(TAG) -f agent/browser/Dockerfile.chromium --push agent/browser/ & \ docker buildx build --platform linux/amd64 $(CACHE_ARGS) --build-arg BASE_IMAGE=$(BROWSER_BASE_IMAGE):$(TAG) -t $(BROWSER_CHROME_IMAGE):$(TAG) -f agent/browser/Dockerfile.chrome --push agent/browser/ & \ docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) --build-arg BASE_IMAGE=$(BROWSER_BASE_IMAGE):$(TAG) -t $(BROWSER_BRAVE_IMAGE):$(TAG) -f agent/browser/Dockerfile.brave --push agent/browser/ & \ @@ -86,7 +107,7 @@ agent-stable: -t $(STABLE_IMAGE):$(OPENCLAW_VERSION) \ -t $(STABLE_MIRROR_IMAGE):$(TAG) \ -t $(STABLE_MIRROR_IMAGE):$(OPENCLAW_VERSION) \ - -f agent/instance/Dockerfile --push agent/instance/ + -f agent/openclaw/Dockerfile --push agent/openclaw/ # CI variant: build single-arch first and run the OpenClaw test suite against # the pinned image, only push multi-arch if tests pass. @@ -97,7 +118,7 @@ agent-stable-ci: @echo "Building+loading $(STABLE_IMAGE):test (openclaw@$(OPENCLAW_VERSION))..." docker buildx build --platform linux/$(NATIVE_ARCH) $(CACHE_ARGS) \ --build-arg OPENCLAW_VERSION=$(OPENCLAW_VERSION) \ - -t $(STABLE_IMAGE):test -f agent/instance/Dockerfile --load agent/instance/ + -t $(STABLE_IMAGE):test -f agent/openclaw/Dockerfile --load agent/openclaw/ cd agent/tests && AGENT_INSTANCE_TEST_IMAGE=$(STABLE_IMAGE):test npm run test -- openclaw.test.ts @echo "Pushing multi-arch $(STABLE_IMAGE) + $(STABLE_MIRROR_IMAGE) :$(TAG) and :$(OPENCLAW_VERSION)..." docker buildx build --platform $(PLATFORMS) $(CACHE_ARGS) \ @@ -106,7 +127,7 @@ agent-stable-ci: -t $(STABLE_IMAGE):$(OPENCLAW_VERSION) \ -t $(STABLE_MIRROR_IMAGE):$(TAG) \ -t $(STABLE_MIRROR_IMAGE):$(OPENCLAW_VERSION) \ - -f agent/instance/Dockerfile --push agent/instance/ + -f agent/openclaw/Dockerfile --push agent/openclaw/ AGENT_CONTAINER := claworc-agent-exec AGENT_SSH_PORT := 2222 @@ -180,11 +201,11 @@ dev: CLAWORC_AUTH_DISABLED=true CLAWORC_LLM_RESPONSE_LOG=$(CURDIR)/llm-responses.log CLAWORC_ALLOWED_HOST_MOUNTS=/tmp,~/ goreman -set-ports=false start ssh-integration-test: - docker build -f agent/instance/Dockerfile -t claworc-agent:local agent/instance/ + docker build -f agent/openclaw/Dockerfile -t claworc-agent:local agent/openclaw/ cd control-plane && go test -tags docker_integration -v -timeout 300s ./internal/sshproxy/ -run TestIntegration ssh-file-integration-test: - docker build -f agent/instance/Dockerfile -t claworc-agent:local agent/instance/ + docker build -f agent/openclaw/Dockerfile -t claworc-agent:local agent/openclaw/ cd agent/tests && npm run test:ssh -- --testPathPattern file.test test-integration-backend: diff --git a/agent/README.md b/agent/README.md index b4004a24..0d9a54b8 100644 --- a/agent/README.md +++ b/agent/README.md @@ -19,7 +19,7 @@ All services are managed by s6-overlay: | Service | Port | Description | |----------------|-------|--------------------------------| | sshd | 22 | SSH server for remote access | -| svc-openclaw | 18789 | OpenClaw gateway | +| svc-agent | 18789 | OpenClaw gateway | | svc-xvnc | 5900 | TigerVNC X server | | svc-novnc | 3000 | noVNC websockify bridge | | svc-desktop | - | Openbox + Chromium | diff --git a/agent/instance/.dockerignore b/agent/hermes/.dockerignore similarity index 100% rename from agent/instance/.dockerignore rename to agent/hermes/.dockerignore diff --git a/agent/hermes/Dockerfile b/agent/hermes/Dockerfile new file mode 100644 index 00000000..5f0626cc --- /dev/null +++ b/agent/hermes/Dockerfile @@ -0,0 +1,113 @@ +# Claworc Hermes agent image (claworc/hermes). +# +# Hermes Agent by Nous Research (https://github.com/NousResearch/hermes-agent) +# behind the Claworc Agent Shim Contract v1 (docs/shim.md): debian-slim, +# s6-overlay as PID 1, the hardened sshd, and a shim at /opt/claworc/shim +# that drives Hermes' non-interactive CLI (`hermes chat -q ... -Q`) with +# native resume-by-session-id persistence. +# +# Lean by design: no VNC/browser (those live in the claworc/-browser +# images) and no Hermes messaging-gateway daemon — each chat turn spawns the +# CLI, so there is no svc-agent service and the shim's restart verb is a +# contract-legal no-op. + +FROM debian:bookworm-slim + +ARG S6_OVERLAY_VERSION=3.2.0.2 +ARG TARGETARCH +# Pinned Hermes release tag (or any git ref) from NousResearch/hermes-agent. +ARG HERMES_VERSION=v2026.8.3 + +# Create claworc user (UID 1000) — all agent state stays owned by it. +RUN useradd -m -u 1000 -s /bin/bash claworc + +# System packages: sshd (the contract's only hard runtime dependency), +# python3 + venv for Hermes (bookworm ships 3.11, inside Hermes' +# requires-python >=3.11,<3.14), git (Hermes install + its git/worktree +# tooling), ripgrep (Hermes' file-search tools), jq/procps for the shim +# and selftest. +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils \ + openssh-server \ + python3 python3-venv \ + git ripgrep jq procps && \ + rm -rf /var/lib/apt/lists/* + +RUN S6_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \ + curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" \ + | tar -C / -Jxpf - && \ + curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" \ + | tar -C / -Jxpf - + +# uv — the package manager Hermes' own installer uses. Multi-arch static +# binaries copied from the pinned upstream image. +COPY --from=ghcr.io/astral-sh/uv:0.11.6 /uv /uvx /usr/local/bin/ + +# --------------------------------------------------------------------------- +# Hermes install, pinned to HERMES_VERSION. +# +# Mirrors the official managed layout (full checkout + editable install + +# venv OUTSIDE the source tree — see the hermes-agent README's contributor +# notes): repo-relative data (locales/, skills/, prompts) resolves from the +# checkout, and the venv can't be clobbered by agent file operations. +# Base dependencies only (exact-pinned upstream); the heavy `[all]` extra +# (voice, messaging platforms) is deliberately skipped — Hermes lazy-installs +# optional backends via uv at runtime when a user enables them. +# --------------------------------------------------------------------------- +RUN git clone --depth 1 --branch "${HERMES_VERSION}" \ + https://github.com/NousResearch/hermes-agent /opt/hermes/hermes-agent && \ + uv venv --python /usr/bin/python3 /opt/hermes/venv && \ + VIRTUAL_ENV=/opt/hermes/venv uv pip install -e /opt/hermes/hermes-agent && \ + ln -s /opt/hermes/venv/bin/hermes /usr/local/bin/hermes && \ + printf '%s\n' "${HERMES_VERSION}" > /opt/hermes/VERSION && \ + /opt/hermes/venv/bin/python -m compileall -q /opt/hermes/hermes-agent || true + +# Sanity check: the pinned install must answer --version (the shim's health +# verb runs the same probe at runtime). +RUN /opt/hermes/venv/bin/hermes --version + +# Baked ~/.hermes skeleton: minimal config.yaml (with the claworc-managed +# model block configure-llm rewrites) + empty .env. Hermes needs no +# interactive onboarding when model.provider/base_url/api_key are present in +# config.yaml (hermes_cli/main.py:_has_any_provider_configured). Seeded onto +# the PVC by the init-agent-seed oneshot (or lazily by the shim verbs). +COPY skeleton/ /opt/hermes-skeleton/.hermes/ +RUN chown -R claworc:claworc /opt/hermes-skeleton && \ + chmod 0644 /opt/hermes-skeleton/.hermes/config.yaml /opt/hermes-skeleton/.hermes/.env + +COPY rootfs/ / + +# Claworc agent shim (docs/shim.md): the universal exec-based interface the +# control plane invokes over SSH. Verbs must be 0755; agent.txt/agent.svg are +# static identity files read over SFTP. +COPY shim/ /opt/claworc/shim/ +RUN chmod 0755 /opt/claworc/shim/meta \ + /opt/claworc/shim/health \ + /opt/claworc/shim/chat-send \ + /opt/claworc/shim/chat-abort \ + /opt/claworc/shim/session-reset \ + /opt/claworc/shim/config-get \ + /opt/claworc/shim/config-set \ + /opt/claworc/shim/configure-llm \ + /opt/claworc/shim/restart \ + /opt/claworc/shim/shim-selftest \ + /opt/claworc/shim/lib/ensure-seed.sh && \ + chmod 0644 /opt/claworc/shim/agent.txt /opt/claworc/shim/agent.svg + +RUN chmod +x /etc/s6-overlay/s6-rc.d/init-setup/up \ + /etc/s6-overlay/s6-rc.d/init-agent-seed/up \ + /etc/s6-overlay/scripts/init-setup.sh \ + /etc/s6-overlay/scripts/init-agent-seed.sh \ + /etc/s6-overlay/s6-rc.d/svc-sshd/run + +# Pre-create the agent log dir (init-setup.sh recreates it at boot anyway). +RUN mkdir -p /var/log/claworc && chown claworc:claworc /var/log/claworc + +# Reduce SUID surface. Stripping su's SUID bit is safe: the shim invokes it +# as root (SSH exec), and root does not need SUID to switch users. +RUN chmod u-s /usr/bin/su /usr/bin/mount /usr/bin/umount /usr/bin/newgrp \ + /usr/bin/chsh /usr/bin/chfn /usr/bin/gpasswd /usr/bin/chage \ + /usr/lib/openssh/ssh-keysign 2>/dev/null || true + +ENTRYPOINT ["/init"] diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/dependencies.d/init-setup b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/dependencies.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/dependencies.d/init-setup rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/dependencies.d/init-setup diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/type b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/type similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/type rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/type diff --git a/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/up b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/up new file mode 100644 index 00000000..6fce7335 --- /dev/null +++ b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/init-agent-seed.sh diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/type b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/type rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/dependencies.d/init-setup b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/dependencies.d/init-setup rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run old mode 100644 new mode 100755 similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/type b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/type rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/dependencies.d/init-openclaw-seed b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-agent-seed similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/dependencies.d/init-openclaw-seed rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-agent-seed diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/dependencies.d/init-setup b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/dependencies.d/init-setup rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd b/agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd rename to agent/hermes/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd diff --git a/agent/hermes/rootfs/etc/s6-overlay/scripts/init-agent-seed.sh b/agent/hermes/rootfs/etc/s6-overlay/scripts/init-agent-seed.sh new file mode 100755 index 00000000..bb3e68b7 --- /dev/null +++ b/agent/hermes/rootfs/etc/s6-overlay/scripts/init-agent-seed.sh @@ -0,0 +1,28 @@ +#!/bin/bash +# Seeds /home/claworc/.hermes from the image-baked skeleton on first boot, +# then applies the initial LLM routing passed by the control plane. +# +# The Dockerfile bakes a minimal ~/.hermes tree (config.yaml with the +# claworc-managed model block + an empty .env) into /opt/hermes-skeleton. +# This oneshot copies it onto the (possibly-empty) PVC mounted at +# /home/claworc. Idempotent: a no-op when /home/claworc/.hermes already +# exists (PVC carried state from a previous boot). +# +# The heavy lifting is shared with the shim verbs via ensure-seed.sh so the +# selftest / verbs also work in containers that never ran the s6 boot +# sequence (e.g. `docker run --entrypoint sh`). + +set -e + +/opt/claworc/shim/lib/ensure-seed.sh + +# --------------------------------------------------------------------------- +# First-boot LLM routing: the control plane passes the configure-llm routing +# document in CLAWORC_INITIAL_LLM_CONFIG (docs/shim.md). Apply it through the +# shim's own verb so boot and reconfiguration share one code path. +# --------------------------------------------------------------------------- +if [ -n "${CLAWORC_INITIAL_LLM_CONFIG:-}" ]; then + if ! printf '%s' "$CLAWORC_INITIAL_LLM_CONFIG" | /opt/claworc/shim/configure-llm; then + echo "configure-llm failed; continuing boot without initial LLM routing" >&2 + fi +fi diff --git a/agent/hermes/rootfs/etc/s6-overlay/scripts/init-setup.sh b/agent/hermes/rootfs/etc/s6-overlay/scripts/init-setup.sh new file mode 100755 index 00000000..354bd00b --- /dev/null +++ b/agent/hermes/rootfs/etc/s6-overlay/scripts/init-setup.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# Runs once at container boot (s6-rc oneshot). Two jobs: +# 1. Prepare /var/log/claworc and the claworc user's HOME. +# 2. Snapshot PID 1's env to /etc/environment and +# /etc/profile.d/claworc-env.sh so SSH sessions — which go through +# PAM and do NOT inherit sshd's env — see the vars passed to +# `docker run -e` / the Kubernetes pod spec. The shim verbs are exec'd +# over SSH, so this is what delivers CLAWORC_AGENT_TOKEN, +# CLAWORC_LLM_PROXY_URL, etc. to them. + +set -e + +# SSH host keys: regenerate per-pod so every container has unique keys. +if command -v ssh-keygen >/dev/null 2>&1; then + ssh-keygen -A >/dev/null 2>&1 || true +fi + +# --------------------------------------------------------------------------- +# Filesystem + user home +# --------------------------------------------------------------------------- +mkdir -p /var/log/claworc +chmod 755 /var/log/claworc +touch /var/log/claworc/agent.log +chown claworc:claworc /var/log/claworc/agent.log + +test -f /home/claworc/.bashrc || cp -a /etc/skel/. /home/claworc/ +# Hermes runs each chat turn from this directory (AGENTS.md, file tools, +# git operations all resolve from here). Declared as meta.workspace_dir. +mkdir -p /home/claworc/hermes-workspace +# Shim persistent state (Claworc session-key -> Hermes session-id map) +# lives on the instance PVC. +mkdir -p /home/claworc/.claworc/shim +chown -R claworc:claworc /home/claworc + +# Ephemeral shim runtime state (chat PIDs for chat-abort). +mkdir -p /run/claworc/shim +chmod 755 /run/claworc /run/claworc/shim + +# --------------------------------------------------------------------------- +# Propagate PID 1 env to PAM and bash login shells +# --------------------------------------------------------------------------- +exclude='^(PATH|HOME|HOSTNAME|TERM|PWD|OLDPWD|SHLVL|SHELL|LOGNAME|USER|MAIL|_)=' + +: > /etc/environment +printenv | grep -vE "$exclude" | while IFS='=' read -r key value; do + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '%s="%s"\n' "$key" "$escaped" >> /etc/environment +done +chmod 644 /etc/environment + +{ + echo '# Generated by init-setup.sh at container boot. Do not edit.' + printenv | grep -vE "$exclude" | while IFS='=' read -r key value; do + printf 'export %s=%q\n' "$key" "$value" + done +} > /etc/profile.d/claworc-env.sh +chmod 644 /etc/profile.d/claworc-env.sh diff --git a/agent/hermes/rootfs/etc/ssh/sshd_config.d/claworc.conf b/agent/hermes/rootfs/etc/ssh/sshd_config.d/claworc.conf new file mode 100644 index 00000000..d22476c2 --- /dev/null +++ b/agent/hermes/rootfs/etc/ssh/sshd_config.d/claworc.conf @@ -0,0 +1,33 @@ +# Claworc SSH Server Hardened Configuration +# Applied via the sshd_config.d/ include mechanism. SSH is the contract's +# only hard runtime dependency — the control plane execs the shim verbs, +# streams files, and opens tunnels over this connection. + +# Network +Port 22 +ListenAddress 0.0.0.0 + +# Authentication +PubkeyAuthentication yes +PasswordAuthentication no +PermitEmptyPasswords no +PermitRootLogin prohibit-password +MaxAuthTries 3 +StrictModes yes +LoginGraceTime 30 + +# Connection limits +MaxStartups 10:30:60 + +# Forwarding restrictions +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding yes +# 127.0.0.1:40001 is the Claworc LLM proxy listener: the control plane +# installs a remote port forward on it so the agent's LLM traffic (routed +# there by configure-llm) reaches the gateway with virtual-key auth. +PermitListen 127.0.0.1:40001 + +# Logging +SyslogFacility AUTH +LogLevel INFO diff --git a/agent/hermes/shim/agent.svg b/agent/hermes/shim/agent.svg new file mode 100644 index 00000000..39691792 --- /dev/null +++ b/agent/hermes/shim/agent.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + diff --git a/agent/hermes/shim/agent.txt b/agent/hermes/shim/agent.txt new file mode 100644 index 00000000..ef1e77d7 --- /dev/null +++ b/agent/hermes/shim/agent.txt @@ -0,0 +1 @@ +Hermes diff --git a/agent/hermes/shim/chat-abort b/agent/hermes/shim/chat-abort new file mode 100755 index 00000000..6016c16f --- /dev/null +++ b/agent/hermes/shim/chat-abort @@ -0,0 +1,28 @@ +#!/bin/sh +# chat-abort --session — best-effort abort of the in-flight turn: +# SIGTERM the running chat-send wrapper (it kills the hermes child, emits +# end/aborted, and exits 0). Exit 0 also when nothing was running +# (docs/shim.md). +set -eu + +RUN_DIR=${CLAWORC_SHIM_RUN_DIR:-/run/claworc/shim} + +SESSION="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } + +SESSION_FILE=$(printf %s "$SESSION" | tr -c 'A-Za-z0-9._-' '_') +PIDFILE="$RUN_DIR/chat-$SESSION_FILE.pid" + +if [ -f "$PIDFILE" ]; then + PID=$(cat "$PIDFILE" 2>/dev/null || true) + if [ -n "$PID" ]; then + kill -TERM "$PID" 2>/dev/null || true + fi +fi +exit 0 diff --git a/agent/hermes/shim/chat-send b/agent/hermes/shim/chat-send new file mode 100755 index 00000000..a6bb366a --- /dev/null +++ b/agent/hermes/shim/chat-send @@ -0,0 +1,332 @@ +#!/usr/bin/env python3 +"""chat-send --session [--turn ] — one Hermes chat turn as Claworc +shim JSONL events (docs/shim.md). Python 3 stdlib only. + +Hermes invocation (verified against NousResearch/hermes-agent v2026.8.x): + + hermes chat -q -Q --source tool --no-restore-cwd [--resume ] + + - `-q` runs a single non-interactive query; `-Q` (quiet) keeps stdout + machine-readable: ONLY the final response is printed to stdout, while + `session_id: ` is printed to stderr on exit (cli.py single-query + path). Exit code is 0/1. + - `--resume ` restores the session's conversation history from the + Hermes SQLite store (~/.hermes) — native session persistence. The shim + maps the opaque Claworc session key to the Hermes session id under + STATE_DIR/hermes-sessions/.id and resumes it on subsequent turns. + - `--source tool` keeps Claworc-driven sessions out of the user's own + `hermes sessions list`; `--no-restore-cwd` pins the turn to the Claworc + workspace dir instead of the session's recorded cwd. + - HERMES_YOLO_MODE=1 / HERMES_ACCEPT_HOOKS=1 mirror what `hermes -z` + (oneshot) sets: non-interactive runs cannot answer approval prompts. + +Events: cumulative assistant snapshots (message_id m1, throttled >= 150 ms) +as Hermes' stdout arrives, then exactly one `end`. SIGTERM/INT/HUP or +chat-abort (SIGTERM via the pidfile) => end/aborted. The shim exits 0 +whenever an end event was emitted, including error ends. +""" +import json +import os +import pwd +import re +import shlex +import signal +import subprocess +import sys +import tempfile +import threading +import time + +HERMES_BIN = "/opt/hermes/venv/bin/hermes" +WORKSPACE = "/home/claworc/hermes-workspace" +HERMES_HOME = "/home/claworc/.hermes" +AGENT_LOG = "/var/log/claworc/agent.log" +SNAPSHOT_INTERVAL = 0.15 + +STATE_DIR = os.environ.get("CLAWORC_SHIM_STATE_DIR", "/home/claworc/.claworc/shim") +RUN_DIR = os.environ.get("CLAWORC_SHIM_RUN_DIR", "/run/claworc/shim") + +_abort = threading.Event() + + +def emit(obj): + # Compact separators: consumers (and the conformance selftest) grep for + # '"event":"end"' — keep the wire format free of gratuitous whitespace. + sys.stdout.write(json.dumps(obj, ensure_ascii=False, separators=(",", ":")) + "\n") + sys.stdout.flush() + + +def claworc_ids(): + try: + pw = pwd.getpwnam("claworc") + return pw.pw_uid, pw.pw_gid + except KeyError: + return None, None + + +def chown_claworc(path): + if os.geteuid() != 0: + return + uid, gid = claworc_ids() + if uid is not None: + try: + os.chown(path, uid, gid) + except OSError: + pass + + +def log_line(msg): + try: + with open(AGENT_LOG, "a", encoding="utf-8") as f: + f.write(time.strftime("[%Y-%m-%dT%H:%M:%S%z] ") + "[chat-send] " + msg + "\n") + except OSError: + pass + + +def sanitize_key(key): + return re.sub(r"[^A-Za-z0-9._-]", "_", key) + + +def build_command(message, resume_id): + """Argv that runs one hermes turn as the claworc user.""" + # `--query=` (the = form) so a message that starts with `-` cannot + # be mistaken for an option by argparse. + argv = [ + HERMES_BIN, "chat", + f"--query={message}", + "-Q", + "--source", "tool", + "--no-restore-cwd", + ] + if resume_id: + argv += ["--resume", resume_id] + + env_prefix = [ + "env", + "HOME=/home/claworc", + "USER=claworc", + "LOGNAME=claworc", + f"HERMES_HOME={HERMES_HOME}", + "HERMES_YOLO_MODE=1", + "HERMES_ACCEPT_HOOKS=1", + "PATH=/opt/hermes/venv/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + ] + if os.geteuid() == 0: + if os.access("/command/s6-setuidgid", os.X_OK): + return ["/command/s6-setuidgid", "claworc"] + env_prefix + argv + # Fallback when s6 is unavailable: su with a quoted command line. + quoted = " ".join(shlex.quote(a) for a in (env_prefix + argv)) + return ["su", "claworc", "-s", "/bin/sh", "-c", quoted] + return env_prefix + argv + + +def run_turn(message, resume_id, snapshot_cb): + """Run one hermes invocation. Returns (rc, stdout_text, stderr_text). + rc is None when aborted.""" + proc = subprocess.Popen( + build_command(message, resume_id), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + stdin=subprocess.DEVNULL, + cwd=WORKSPACE if os.path.isdir(WORKSPACE) else "/home/claworc", + start_new_session=True, + ) + + out_chunks, err_chunks = [], [] + lock = threading.Lock() + + def reader(stream, sink): + while True: + chunk = stream.read(4096) + if not chunk: + break + with lock: + sink.append(chunk) + stream.close() + + t_out = threading.Thread(target=reader, args=(proc.stdout, out_chunks), daemon=True) + t_err = threading.Thread(target=reader, args=(proc.stderr, err_chunks), daemon=True) + t_out.start() + t_err.start() + + last_emit = 0.0 + last_len = 0 + while proc.poll() is None: + if _abort.is_set(): + try: + os.killpg(proc.pid, signal.SIGTERM) + except OSError: + proc.terminate() + try: + proc.wait(timeout=5) + except subprocess.TimeoutExpired: + try: + os.killpg(proc.pid, signal.SIGKILL) + except OSError: + proc.kill() + proc.wait() + break + now = time.monotonic() + with lock: + cur = b"".join(out_chunks) + if len(cur) > last_len and now - last_emit >= SNAPSHOT_INTERVAL: + snapshot_cb(cur.decode("utf-8", "replace")) + last_len = len(cur) + last_emit = now + time.sleep(0.05) + + t_out.join(timeout=5) + t_err.join(timeout=5) + stdout_text = b"".join(out_chunks).decode("utf-8", "replace") + stderr_text = b"".join(err_chunks).decode("utf-8", "replace") + rc = None if _abort.is_set() else proc.poll() + return rc, stdout_text, stderr_text + + +def main(): + session = "" + turn = "" + args = sys.argv[1:] + while args: + a = args.pop(0) + if a == "--session" and args: + session = args.pop(0) + elif a == "--turn" and args: + turn = args.pop(0) + else: + print(f"unknown argument: {a}", file=sys.stderr) + sys.exit(2) + if not session: + print("--session is required", file=sys.stderr) + sys.exit(2) + if not turn: + turn = f"t-{os.getpid()}" + + if not os.access(HERMES_BIN, os.X_OK): + print(f"hermes CLI missing at {HERMES_BIN}", file=sys.stderr) + sys.exit(4) + + # Lazily seed ~/.hermes / create runtime dirs when s6 has not run. + if not os.path.isdir(HERMES_HOME): + subprocess.run(["/opt/claworc/shim/lib/ensure-seed.sh"], check=False) + for d in (RUN_DIR, os.path.join(STATE_DIR, "hermes-sessions")): + os.makedirs(d, exist_ok=True) + chown_claworc(STATE_DIR) + chown_claworc(os.path.join(STATE_DIR, "hermes-sessions")) + if not os.path.isdir(WORKSPACE): + try: + os.makedirs(WORKSPACE, exist_ok=True) + chown_claworc(WORKSPACE) + except OSError: + pass + + # The raw user message, stdin until EOF. Argv cannot carry NUL bytes. + message = sys.stdin.buffer.read().decode("utf-8", "replace").replace("\x00", "") + if not message.strip(): + # An empty --query would drop hermes into interactive mode and hang. + emit({"v": 1, "event": "start", "session": session, "turn": turn}) + emit({"v": 1, "event": "error", "turn": turn, "code": "empty_message", + "text": "empty user message", "fatal": True}) + emit({"v": 1, "event": "end", "turn": turn, + "stop_reason": "error", "text": ""}) + sys.exit(0) + + key = sanitize_key(session) + pidfile = os.path.join(RUN_DIR, f"chat-{key}.pid") + mapfile = os.path.join(STATE_DIR, "hermes-sessions", f"{key}.id") + + resume_id = "" + try: + with open(mapfile, encoding="utf-8") as f: + resume_id = f.read().strip() + except OSError: + pass + + try: + with open(pidfile, "w", encoding="utf-8") as f: + f.write(str(os.getpid())) + except OSError: + pidfile = None + + def on_signal(signum, frame): + _abort.set() + + for s in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): + signal.signal(s, on_signal) + + def cleanup(): + if pidfile: + try: + os.unlink(pidfile) + except OSError: + pass + + emit({"v": 1, "event": "start", "session": session, "turn": turn}) + + def snapshot(text): + emit({"v": 1, "event": "assistant", "turn": turn, + "message_id": "m1", "text": text.rstrip("\n")}) + + rc, out, err = run_turn(message, resume_id, snapshot) + + # Self-heal a stale mapping: the Hermes session store may have been + # wiped (fresh PVC, deleted state.db). Retry once with a fresh session. + if rc not in (0, None) and resume_id and "Session not found" in err: + log_line(f"session={session} stale hermes session {resume_id!r}; retrying fresh") + try: + os.unlink(mapfile) + except OSError: + pass + resume_id = "" + rc, out, err = run_turn(message, resume_id, snapshot) + + # Persist the Claworc-key -> Hermes-session-id mapping. The single-query + # path prints `session_id: ` to stderr on every exit (including + # SIGTERM aborts), so history up to an abort stays resumable. + m = None + for m in re.finditer(r"^session_id:\s*(\S+)\s*$", err, re.MULTILINE): + pass + if m: + hermes_id = m.group(1) + if hermes_id != resume_id: + try: + fd, tmp = tempfile.mkstemp(dir=os.path.dirname(mapfile), prefix=".id.") + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(hermes_id + "\n") + chown_claworc(tmp) + os.replace(tmp, mapfile) + except OSError: + pass + + text = out.rstrip("\n") + + if rc is None: + emit({"v": 1, "event": "end", "turn": turn, + "stop_reason": "aborted", "text": text}) + log_line(f"session={session} turn={turn} aborted") + cleanup() + sys.exit(0) + + if text: + snapshot(text) + + if rc != 0: + tail = err.strip().splitlines() + detail = tail[-1][:300] if tail else f"hermes exited with status {rc}" + emit({"v": 1, "event": "error", "turn": turn, "code": "agent_failed", + "text": detail, "fatal": True}) + emit({"v": 1, "event": "end", "turn": turn, + "stop_reason": "error", "text": text}) + log_line(f"session={session} turn={turn} error rc={rc}: {detail}") + cleanup() + sys.exit(0) + + emit({"v": 1, "event": "end", "turn": turn, + "stop_reason": "complete", "text": text}) + log_line(f"session={session} turn={turn} complete ({len(text)} chars)") + cleanup() + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/agent/hermes/shim/config-get b/agent/hermes/shim/config-get new file mode 100755 index 00000000..9f442ac0 --- /dev/null +++ b/agent/hermes/shim/config-get @@ -0,0 +1,27 @@ +#!/bin/sh +# config-get [--id ] — raw config file bytes on stdout (docs/shim.md). +# ids: "config" -> ~/.hermes/config.yaml (default), "env" -> ~/.hermes/.env +set -eu + +ID=config +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done + +case "$ID" in + config) FILE=/home/claworc/.hermes/config.yaml ;; + env) FILE=/home/claworc/.hermes/.env ;; + *) echo "unknown config file id: $ID" >&2; exit 2 ;; +esac + +# Lazily seed ~/.hermes when the s6 boot sequence has not run. +[ -d /home/claworc/.hermes ] || /opt/claworc/shim/lib/ensure-seed.sh || true + +if [ ! -f "$FILE" ]; then + echo "config file not found: $FILE" >&2 + exit 1 +fi +exec cat "$FILE" diff --git a/agent/hermes/shim/config-set b/agent/hermes/shim/config-set new file mode 100755 index 00000000..02687e72 --- /dev/null +++ b/agent/hermes/shim/config-set @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""config-set [--id ] — replace a declared Hermes config file with +stdin bytes (docs/shim.md). + +ids: "config" -> ~/.hermes/config.yaml (default), "env" -> ~/.hermes/.env + +Validation (exit 6 + {"error": ...} on stdout when invalid): + - config.yaml: must parse as YAML. Validated with the PyYAML inside the + Hermes venv (the shim itself is stdlib-only); skipped when the venv is + unavailable. + - .env: loose dotenv check — every non-blank, non-comment line must look + like KEY=VALUE (an optional `export ` prefix is allowed). + +Writes atomically (tmp + rename) and keeps the file claworc-owned. Never +restarts the agent — the control plane owns that (and these files declare +restart_required: false anyway; each chat turn re-reads them). +""" +import json +import os +import pwd +import subprocess +import sys +import tempfile + +FILES = { + "config": "/home/claworc/.hermes/config.yaml", + "env": "/home/claworc/.hermes/.env", +} +VENV_PY = "/opt/hermes/venv/bin/python" + + +def fail_validation(msg): + print(json.dumps({"error": msg})) + sys.exit(6) + + +def main(): + args = sys.argv[1:] + file_id = "config" + while args: + a = args.pop(0) + if a == "--id": + if not args: + print("--id requires a value", file=sys.stderr) + sys.exit(2) + file_id = args.pop(0) + else: + print(f"unknown argument: {a}", file=sys.stderr) + sys.exit(2) + + path = FILES.get(file_id) + if path is None: + print(f"unknown config file id: {file_id}", file=sys.stderr) + sys.exit(2) + + # Lazily seed ~/.hermes when the s6 boot sequence has not run. + if not os.path.isdir("/home/claworc/.hermes"): + subprocess.run(["/opt/claworc/shim/lib/ensure-seed.sh"], check=False) + + data = sys.stdin.buffer.read() + + if file_id == "config": + if os.access(VENV_PY, os.X_OK): + probe = subprocess.run( + [VENV_PY, "-c", "import sys,yaml; yaml.safe_load(sys.stdin.buffer.read())"], + input=data, + capture_output=True, + ) + if probe.returncode != 0: + err = probe.stderr.decode("utf-8", "replace").strip().splitlines() + fail_validation("invalid YAML: " + (err[-1] if err else "parse error")) + else: # .env — loose dotenv syntax + try: + text = data.decode("utf-8") + except UnicodeDecodeError as e: + fail_validation(f"invalid UTF-8: {e}") + for i, line in enumerate(text.splitlines(), 1): + s = line.strip() + if not s or s.startswith("#"): + continue + if s.startswith("export "): + s = s[len("export "):].lstrip() + key, sep, _ = s.partition("=") + if not sep or not key.strip() or any(c.isspace() for c in key.strip()): + fail_validation(f"line {i} is not KEY=VALUE: {line.strip()[:80]}") + + d = os.path.dirname(path) + os.makedirs(d, exist_ok=True) + fd, tmp = tempfile.mkstemp(dir=d, prefix=".claworc-config.") + try: + with os.fdopen(fd, "wb") as f: + f.write(data) + os.chmod(tmp, 0o644) + if os.geteuid() == 0: + try: + pw = pwd.getpwnam("claworc") + os.chown(tmp, pw.pw_uid, pw.pw_gid) + except (KeyError, OSError): + pass + os.replace(tmp, path) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/agent/hermes/shim/configure-llm b/agent/hermes/shim/configure-llm new file mode 100755 index 00000000..49582bbc --- /dev/null +++ b/agent/hermes/shim/configure-llm @@ -0,0 +1,144 @@ +#!/usr/bin/env python3 +"""configure-llm — route Hermes' LLM traffic through the Claworc LLM proxy +(docs/shim.md). Reads the generic routing document on stdin and rewrites the +fully managed block in ~/.hermes/config.yaml — replace, never append twice — +so the verb is idempotent. + +Hermes fact-check (hermes_cli/runtime_provider.py, hermes_cli/config.py, +verified against NousResearch/hermes-agent v2026.8.x): + - ~/.hermes/config.yaml `model:` section is the single source of truth for + a custom OpenAI-compatible endpoint: + model.provider: "custom" -> plain OpenAI-compatible endpoint + model.base_url -> trusted for bare "custom" (loopback or + when provider is already "custom") + model.api_key -> read via `for k in ("api_key", "api")` + model.default -> default model id, passed through as-is + - OPENAI_API_KEY / OPENAI_BASE_URL env vars are host-gated or ignored for + custom endpoints, so the config file is the correct (and only reliable) + place to put the proxy routing. + +The managed block is a complete top-level `model:` mapping between the +`# BEGIN claworc-managed` / `# END claworc-managed` markers. The image-baked +skeleton config.yaml contains exactly one such block, so replacing it keeps +the YAML free of duplicate keys. Values are emitted with json.dumps (JSON +strings are valid YAML double-quoted scalars). + +Exit 6 when the routing cannot be expressed (e.g. style "anthropic": Hermes +would need api_mode plumbing this shim does not manage; meta declares +llm.styles ["openai"] accordingly). +""" +import json +import os +import pwd +import subprocess +import sys +import tempfile + +CONFIG = "/home/claworc/.hermes/config.yaml" +BEGIN = "# BEGIN claworc-managed" +END = "# END claworc-managed" + + +def fail_validation(msg): + print(json.dumps({"error": msg})) + sys.exit(6) + + +def main(): + if len(sys.argv) > 1: + print(f"unknown argument: {sys.argv[1]}", file=sys.stderr) + sys.exit(2) + + try: + doc = json.load(sys.stdin) + except Exception as e: + fail_validation(f"invalid JSON routing document: {e}") + if not isinstance(doc, dict): + fail_validation("routing document must be a JSON object") + + style = doc.get("style") or "openai" + if style != "openai": + fail_validation(f"unsupported llm style {style!r}: this image routes Hermes " + "through an OpenAI-compatible endpoint only") + + proxy_url = doc.get("proxy_url") or "" + providers = doc.get("providers") or [] + if not isinstance(providers, list): + fail_validation("providers must be an array") + if providers and not proxy_url: + fail_validation("proxy_url is required when providers are present") + + default_model = doc.get("default_model") or "" + api_key = "" + if providers: + first = providers[0] + if not isinstance(first, dict): + fail_validation("providers entries must be objects") + api_key = first.get("api_key") or "" + + block = [ + BEGIN, + "# Managed by the Claworc shim configure-llm verb - do not edit inside this block.", + "# Routes all Hermes LLM traffic to the Claworc LLM proxy with a virtual key.", + "model:", + " provider: \"custom\"", + f" base_url: {json.dumps(proxy_url)}", + f" api_key: {json.dumps(api_key)}", + f" default: {json.dumps(default_model)}", + END, + ] + + # Lazily seed ~/.hermes when the s6 boot sequence has not run. + if not os.path.isdir(os.path.dirname(CONFIG)): + subprocess.run(["/opt/claworc/shim/lib/ensure-seed.sh"], check=False) + os.makedirs(os.path.dirname(CONFIG), exist_ok=True) + + try: + with open(CONFIG, encoding="utf-8") as f: + lines = f.read().splitlines() + except FileNotFoundError: + lines = [] + + # Replace the existing managed block in place; append the block when absent. + out, i, replaced = [], 0, False + while i < len(lines): + if lines[i].strip() == BEGIN: + j = i + 1 + while j < len(lines) and lines[j].strip() != END: + j += 1 + out.extend(block) + replaced = True + i = j + 1 # skip END (or run off the end for an unterminated block) + else: + out.append(lines[i]) + i += 1 + if not replaced: + if out and out[-1].strip(): + out.append("") + out.extend(block) + + content = "\n".join(out) + "\n" + d = os.path.dirname(os.path.abspath(CONFIG)) + fd, tmp = tempfile.mkstemp(dir=d, prefix=".config.yaml.") + try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.chmod(tmp, 0o644) + if os.geteuid() == 0: + try: + pw = pwd.getpwnam("claworc") + os.chown(tmp, pw.pw_uid, pw.pw_gid) + except (KeyError, OSError): + pass + os.replace(tmp, CONFIG) + except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/agent/hermes/shim/health b/agent/hermes/shim/health new file mode 100755 index 00000000..0c143654 --- /dev/null +++ b/agent/hermes/shim/health @@ -0,0 +1,46 @@ +#!/bin/sh +# health — exit 0 when a chat turn can run, 4 while booting, 1 when broken +# (docs/shim.md). Hermes has no daemon in this image (each chat-send spawns +# `hermes chat -q`), so "healthy" means the pinned Hermes install answers +# `hermes --version` — a fast pre-config code path in hermes_cli/main.py. +set -u + +HERMES_BIN=/opt/hermes/venv/bin/hermes + +if [ ! -x "$HERMES_BIN" ]; then + echo "hermes CLI missing at $HERMES_BIN" >&2 + exit 1 +fi + +# Lazily seed ~/.hermes when the s6 boot sequence has not run (selftest / +# exec'd containers). A missing-but-seedable home is "booting", a failed +# seed is broken. +if [ ! -d /home/claworc/.hermes ]; then + if ! /opt/claworc/shim/lib/ensure-seed.sh; then + echo "hermes home missing and seed failed" >&2 + exit 4 + fi +fi + +run_as_claworc() { + if [ "$(id -u)" = "0" ] && [ -x /command/s6-setuidgid ]; then + /command/s6-setuidgid claworc env HOME=/home/claworc HERMES_HOME=/home/claworc/.hermes "$@" + else + env HOME=/home/claworc HERMES_HOME=/home/claworc/.hermes "$@" + fi +} + +if command -v timeout >/dev/null 2>&1; then + OUT=$(run_as_claworc timeout 30 "$HERMES_BIN" --version 2>&1) || { + echo "hermes --version failed: $(printf %s "$OUT" | head -c 200)" >&2 + exit 1 + } +else + OUT=$(run_as_claworc "$HERMES_BIN" --version 2>&1) || { + echo "hermes --version failed: $(printf %s "$OUT" | head -c 200)" >&2 + exit 1 + } +fi + +printf '{"status":"ok"}\n' +exit 0 diff --git a/agent/hermes/shim/lib/ensure-seed.sh b/agent/hermes/shim/lib/ensure-seed.sh new file mode 100755 index 00000000..5d4a2b3a --- /dev/null +++ b/agent/hermes/shim/lib/ensure-seed.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# ensure-seed.sh — idempotently materialize /home/claworc/.hermes from the +# image-baked skeleton at /opt/hermes-skeleton/.hermes. +# +# Called from the init-agent-seed s6 oneshot at boot AND lazily from the shim +# verbs (health, chat-send, config-get/-set, configure-llm), so the shim also +# works in containers started without the s6 boot sequence (conformance +# selftest, `docker run --entrypoint sh`, CI). +set -eu + +SKELETON=/opt/hermes-skeleton/.hermes +TARGET=/home/claworc/.hermes + +if [ -d "$TARGET" ]; then + exit 0 +fi + +if [ ! -d "$SKELETON" ]; then + echo "ensure-seed: skeleton missing at $SKELETON" >&2 + exit 1 +fi + +echo "ensure-seed: seeding $TARGET from $SKELETON" >&2 +mkdir -p "$TARGET" +cp -a "$SKELETON"/. "$TARGET"/ +chown -R claworc:claworc "$TARGET" 2>/dev/null || true diff --git a/agent/hermes/shim/meta b/agent/hermes/shim/meta new file mode 100755 index 00000000..a302205e --- /dev/null +++ b/agent/hermes/shim/meta @@ -0,0 +1,32 @@ +#!/bin/sh +# meta — Claworc agent shim capability probe (docs/shim.md, contract v1). +# +# Static JSON except for the Hermes version, which is baked into +# /opt/hermes/VERSION at image build time (running the agent must not be a +# prerequisite for meta). +# +# session_persistence is "native": chat-send resumes Hermes sessions by ID +# (`hermes chat -q ... -Q --resume `); the Claworc session key -> +# Hermes session id map lives in /home/claworc/.claworc/shim/hermes-sessions/. +set -eu + +VERSION="" +[ -f /opt/hermes/VERSION ] && VERSION=$(head -n1 /opt/hermes/VERSION | tr -d '"\\') + +cat < — clear conversation history for the key by +# dropping its Claworc-key -> Hermes-session-id mapping; the next chat-send +# starts a fresh Hermes session. Idempotent (docs/shim.md). +# +# The old Hermes session rows stay in ~/.hermes SQLite (harmless — they are +# simply never resumed again), which also preserves Hermes' own cross-session +# recall features. +set -eu + +STATE_DIR=${CLAWORC_SHIM_STATE_DIR:-/home/claworc/.claworc/shim} + +SESSION="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } + +SESSION_FILE=$(printf %s "$SESSION" | tr -c 'A-Za-z0-9._-' '_') +rm -f "$STATE_DIR/hermes-sessions/$SESSION_FILE.id" +exit 0 diff --git a/agent/hermes/shim/shim-selftest b/agent/hermes/shim/shim-selftest new file mode 100755 index 00000000..e02c7fab --- /dev/null +++ b/agent/hermes/shim/shim-selftest @@ -0,0 +1,301 @@ +#!/bin/sh +# shim-selftest — conformance check for the Claworc Agent Shim Contract v1 +# (docs/shim.md). Runnable inside any agent image, or against a shim +# directory in the repo: +# +# shim-selftest [shim-dir] # default: /opt/claworc/shim +# docker run --rm my-agent-image /opt/claworc/shim/shim-selftest +# +# Exercises: identity files, meta (JSON + required fields + verb presence per +# capability), health exit codes, chat-send JSONL well-formedness, the config +# round-trip, and configure-llm idempotency. Exits non-zero with a per-check +# report when anything fails. +# +# NOTE: the config and configure-llm checks MUTATE agent configuration +# (config-set writes the current bytes back; configure-llm applies a sample +# routing document twice). Run in a throwaway container/CI, or pass +# --skip-mutating. +set -u + +SHIM_DIR=/opt/claworc/shim +SKIP_MUTATING=0 +for arg in "$@"; do + case "$arg" in + --skip-mutating) SKIP_MUTATING=1 ;; + -*) echo "usage: shim-selftest [--skip-mutating] [shim-dir]" >&2; exit 2 ;; + *) SHIM_DIR=$arg ;; + esac +done + +PASS=0 +FAIL=0 +SKIP=0 +pass() { PASS=$((PASS + 1)); printf '[PASS] %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); printf '[FAIL] %s\n' "$1"; } +skip() { SKIP=$((SKIP + 1)); printf '[SKIP] %s\n' "$1"; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# --- JSON helpers (jq preferred, python3 fallback) -------------------------- +if command -v jq >/dev/null 2>&1; then + JSON_TOOL=jq +elif command -v python3 >/dev/null 2>&1; then + JSON_TOOL=python3 +else + echo "shim-selftest needs jq or python3 for JSON validation" >&2 + exit 2 +fi + +json_valid() { # json_valid + if [ "$JSON_TOOL" = jq ]; then jq -e . "$1" >/dev/null 2>&1 + else python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$1" >/dev/null 2>&1 + fi +} + +json_query() { # json_query — prints result or "None" + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +try: + print(eval(sys.argv[2], {"d": d})) +except Exception: + print("None") +' "$1" "$2" 2>/dev/null +} + +if [ "$JSON_TOOL" = jq ] && ! command -v python3 >/dev/null 2>&1; then + # json_query needs python3; degrade to jq-only equivalents where used. + json_query() { + case "$2" in + 'd["contract"]') jq -r '.contract' "$1" 2>/dev/null ;; + '"chat" in d.get("capabilities", [])') jq -r '.capabilities | contains(["chat"])' "$1" 2>/dev/null | sed 's/true/True/; s/false/False/' ;; + 'isinstance(d.get("capabilities"), list)') jq -r '.capabilities | type == "array"' "$1" 2>/dev/null | sed 's/true/True/; s/false/False/' ;; + '",".join(d.get("capabilities", []))') jq -r '.capabilities | join(",")' "$1" 2>/dev/null ;; + 'd.get("llm", {}).get("styles", ["openai"])[0]') jq -r '.llm.styles[0] // "openai"' "$1" 2>/dev/null ;; + *) echo "None" ;; + esac + } +fi + +run_timeout() { # run_timeout + if command -v timeout >/dev/null 2>&1; then + t=$1; shift + timeout "$t" "$@" + else + shift + "$@" + fi +} + +# --- 1. identity files ------------------------------------------------------ +if [ -s "$SHIM_DIR/agent.txt" ] && \ + [ "$(wc -l < "$SHIM_DIR/agent.txt" | tr -d ' ')" -le 1 ] && \ + [ -n "$(head -n1 "$SHIM_DIR/agent.txt")" ]; then + pass "agent.txt: present, single line ($(head -n1 "$SHIM_DIR/agent.txt"))" +else + fail "agent.txt: missing, empty, or multi-line" +fi +if [ -s "$SHIM_DIR/agent.svg" ]; then + pass "agent.svg: present" +else + fail "agent.svg: missing or empty" +fi + +# --- 2. meta ---------------------------------------------------------------- +META="$WORK/meta.json" +if [ ! -x "$SHIM_DIR/meta" ]; then + fail "meta: not executable" + echo "shim-selftest: cannot continue without meta" + exit 1 +fi +if run_timeout 30 "$SHIM_DIR/meta" > "$META" 2> "$WORK/meta.err"; then + pass "meta: exit 0" +else + fail "meta: exited non-zero ($(head -c 200 "$WORK/meta.err"))" +fi +if json_valid "$META"; then + pass "meta: stdout is valid JSON" +else + fail "meta: stdout is not valid JSON" +fi +CONTRACT=$(json_query "$META" 'd["contract"]') +if [ "$CONTRACT" = "1" ]; then + pass "meta: contract=1" +else + fail "meta: contract must be the integer 1, got '$CONTRACT'" +fi +if [ "$(json_query "$META" 'isinstance(d.get("capabilities"), list)')" = "True" ]; then + pass "meta: capabilities is an array" +else + fail "meta: capabilities missing or not an array" +fi +if [ "$(json_query "$META" '"chat" in d.get("capabilities", [])')" = "True" ]; then + pass "meta: capabilities include required \"chat\"" +else + fail "meta: capabilities must include \"chat\"" +fi + +CAPS=$(json_query "$META" '",".join(d.get("capabilities", []))') +[ "$CAPS" = "None" ] && CAPS="" +has_cap() { printf ',%s,' "$CAPS" | grep -q ",$1,"; } + +# Verb executables required by declared capabilities. +check_verb() { # check_verb + if [ -x "$SHIM_DIR/$1" ]; then + pass "verb $1: executable" + elif [ "$2" = 1 ]; then + fail "verb $1: missing/not executable but required ($3)" + else + skip "verb $1: absent (capability not declared)" + fi +} +check_verb health 1 "always required" +check_verb chat-send 1 "chat capability is mandatory" +check_verb chat-abort "$(has_cap chat.abort && echo 1 || echo 0)" "chat.abort declared" +check_verb session-reset "$(has_cap session.reset && echo 1 || echo 0)" "session.reset declared" +check_verb config-get "$(has_cap config && echo 1 || echo 0)" "config declared" +check_verb config-set "$(has_cap config && echo 1 || echo 0)" "config declared" +check_verb configure-llm "$(has_cap configure-llm && echo 1 || echo 0)" "configure-llm declared" +check_verb restart "$(has_cap restart && echo 1 || echo 0)" "restart declared" + +# --- 3. health -------------------------------------------------------------- +HEALTH_RC=0 +run_timeout 30 "$SHIM_DIR/health" > "$WORK/health.out" 2> "$WORK/health.err" || HEALTH_RC=$? +case "$HEALTH_RC" in + 0) pass "health: exit 0 (ready)" ;; + 4) pass "health: exit 4 (agent booting — contract-legal)" ;; + 1|5) pass "health: exit $HEALTH_RC (broken/timeout — contract-legal code)" ;; + *) fail "health: exit $HEALTH_RC is outside the contract's code set {0,1,4,5}" ;; +esac +if [ -s "$WORK/health.out" ] && ! json_valid "$WORK/health.out"; then + fail "health: stdout present but not valid JSON" +fi + +# --- 4. chat-send JSONL ----------------------------------------------------- +if [ "$HEALTH_RC" -ne 0 ]; then + skip "chat-send: agent not ready (health exit $HEALTH_RC)" +else + CHAT_OUT="$WORK/chat.jsonl" + CHAT_RC=0 + printf 'shim-selftest ping: reply with anything.' | \ + run_timeout 180 "$SHIM_DIR/chat-send" --session shim-selftest --turn t-selftest \ + > "$CHAT_OUT" 2> "$WORK/chat.err" || CHAT_RC=$? + if [ "$CHAT_RC" -eq 0 ]; then + pass "chat-send: exit 0" + else + fail "chat-send: exit $CHAT_RC ($(head -c 200 "$WORK/chat.err"))" + fi + if [ ! -s "$CHAT_OUT" ]; then + fail "chat-send: produced no output" + else + BAD_LINES=0 + TOTAL_LINES=0 + while IFS= read -r line; do + [ -n "$line" ] || continue + TOTAL_LINES=$((TOTAL_LINES + 1)) + printf '%s' "$line" > "$WORK/line.json" + json_valid "$WORK/line.json" || BAD_LINES=$((BAD_LINES + 1)) + done < "$CHAT_OUT" + if [ "$BAD_LINES" -eq 0 ]; then + pass "chat-send: all $TOTAL_LINES JSONL lines parse" + else + fail "chat-send: $BAD_LINES of $TOTAL_LINES lines are not valid JSON" + fi + ENDS=$(grep -c '"event":"end"' "$CHAT_OUT" || true) + if [ "$ENDS" = "1" ]; then + pass "chat-send: exactly one end event" + else + fail "chat-send: expected exactly one end event, got $ENDS" + fi + tail -n1 "$CHAT_OUT" > "$WORK/last.json" + if [ "$(json_query "$WORK/last.json" 'd.get("event")')" = "end" ] && \ + [ "$(json_query "$WORK/last.json" 'd.get("stop_reason") in ("complete","aborted","error")')" = "True" ]; then + pass "chat-send: last line is end with a legal stop_reason" + else + fail "chat-send: last line must be the end event with stop_reason complete|aborted|error" + fi + if grep -q '"event":"start"' "$CHAT_OUT"; then + pass "chat-send: start event present" + else + fail "chat-send: no start event emitted" + fi + fi +fi + +# --- 5. config round-trip --------------------------------------------------- +if ! has_cap config; then + skip "config round-trip: config capability not declared" +elif [ "$SKIP_MUTATING" = 1 ]; then + skip "config round-trip: --skip-mutating" +else + RC=0 + "$SHIM_DIR/config-get" > "$WORK/cfg.before" 2> "$WORK/cfg.err" || RC=$? + if [ "$RC" -ne 0 ]; then + fail "config-get: exit $RC ($(head -c 200 "$WORK/cfg.err"))" + else + RC=0 + "$SHIM_DIR/config-set" < "$WORK/cfg.before" > "$WORK/cfgset.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "config-set: exit $RC writing back unmodified config ($(head -c 200 "$WORK/cfgset.out"))" + else + "$SHIM_DIR/config-get" > "$WORK/cfg.after" 2>/dev/null || true + if cmp -s "$WORK/cfg.before" "$WORK/cfg.after"; then + pass "config: get -> set -> get round-trips byte-identical" + else + fail "config: round-trip altered the file" + fi + fi + fi +fi + +# --- 6. configure-llm idempotency ------------------------------------------- +if ! has_cap configure-llm; then + skip "configure-llm: capability not declared" +elif [ "$SKIP_MUTATING" = 1 ]; then + skip "configure-llm: --skip-mutating" +else + STYLE=$(json_query "$META" 'd.get("llm", {}).get("styles", ["openai"])[0]') + [ "$STYLE" = "None" ] && STYLE=openai + cat > "$WORK/routing.json" < "$WORK/llm1.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "configure-llm: first run exit $RC ($(head -c 200 "$WORK/llm1.out"))" + else + pass "configure-llm: first run exit 0" + if has_cap config; then + "$SHIM_DIR/config-get" > "$WORK/llm.state1" 2>/dev/null || true + fi + RC=0 + "$SHIM_DIR/configure-llm" < "$WORK/routing.json" > "$WORK/llm2.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "configure-llm: second run exit $RC (must be idempotent)" + elif has_cap config; then + "$SHIM_DIR/config-get" > "$WORK/llm.state2" 2>/dev/null || true + if cmp -s "$WORK/llm.state1" "$WORK/llm.state2"; then + pass "configure-llm: idempotent (config identical after second run)" + else + fail "configure-llm: second run changed the config again (not idempotent)" + fi + else + pass "configure-llm: second run exit 0 (no config verb to diff state)" + fi + fi +fi + +# --- report ----------------------------------------------------------------- +printf '\nshim-selftest: %d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/agent/hermes/skeleton/config.yaml b/agent/hermes/skeleton/config.yaml new file mode 100644 index 00000000..7e8da9f9 --- /dev/null +++ b/agent/hermes/skeleton/config.yaml @@ -0,0 +1,28 @@ +# Hermes Agent configuration (Claworc image). +# +# This file is seeded to /home/claworc/.hermes/config.yaml on first boot and +# is editable from the Claworc Config tab. Hermes re-reads it on every chat +# turn — no restart needed. +# +# The block between the claworc-managed markers is rewritten by the Claworc +# control plane (configure-llm shim verb) to route all LLM traffic through +# the Claworc LLM proxy with a virtual key. Put your own settings OUTSIDE +# the block; anything inside it will be overwritten. + +# Pre-exec command scanning via the external `tirith` binary is enabled by +# default in Hermes, but the binary is not installed in this lean image and +# the resulting "scanner enabled but not available" warning would leak into +# the machine-readable chat stdout on the first turn. Pattern-based command +# safety checks remain active. +security: + tirith_enabled: false + +# BEGIN claworc-managed +# Managed by the Claworc shim configure-llm verb - do not edit inside this block. +# Routes all Hermes LLM traffic to the Claworc LLM proxy with a virtual key. +model: + provider: "custom" + base_url: "http://127.0.0.1:40001" + api_key: "" + default: "" +# END claworc-managed diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/run b/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/run deleted file mode 100644 index 8e143173..00000000 --- a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/run +++ /dev/null @@ -1,42 +0,0 @@ -#!/command/with-contenv bash - -export HOME=/home/claworc - -# Heavy work (`openclaw doctor --fix`, `openclaw onboard`, TOOLS.md install) -# runs at image build time; init-openclaw-seed (a oneshot that runs before -# this service) copies the baked /opt/openclaw-skeleton/.openclaw onto the -# PVC on first boot. By the time we get here, /home/claworc/.openclaw is -# already populated and the only work left is to apply env-driven config -# overrides and exec the gateway. - -# Always reaffirm gateway settings — these are baked into the skeleton too, -# but writing them here keeps a single source of truth and lets ops change -# them by editing this file without rebuilding the image. -s6-setuidgid claworc openclaw config set gateway.mode local -# Newer openclaw rejects host-alias values (e.g. `localhost`) for gateway.bind -# and demands a bind-mode keyword (lan/loopback/custom/tailnet/auto) instead. -s6-setuidgid claworc openclaw config set gateway.bind loopback -s6-setuidgid claworc openclaw config set gateway.controlUi.dangerouslyDisableDeviceAuth true - -# Set Control UI base path for reverse proxy -if [ -n "$CLAWORC_INSTANCE_ID" ]; then - s6-setuidgid claworc openclaw config set gateway.controlUi.basePath "/openclaw/${CLAWORC_INSTANCE_ID}/" -fi - -# Set token if provided -if [ -n "$OPENCLAW_GATEWAY_TOKEN" ]; then - s6-setuidgid claworc openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" -fi - -# Apply initial model and provider config passed by Claworc at container creation. -# This ensures the gateway starts with providers already configured, preventing -# auth failures on messages that arrive before ConfigureInstance runs via SSH. -if [ -n "$OPENCLAW_INITIAL_MODELS" ]; then - s6-setuidgid claworc openclaw config set agents.defaults.model "$OPENCLAW_INITIAL_MODELS" --json -fi -if [ -n "$OPENCLAW_INITIAL_PROVIDERS" ]; then - s6-setuidgid claworc openclaw config set models.providers "$OPENCLAW_INITIAL_PROVIDERS" --json -fi - -echo "Starting OpenClaw Gateway (foreground) as claworc..." -exec bash -c "tail -f /dev/null | s6-setuidgid claworc /usr/bin/openclaw gateway run >> /var/log/claworc/openclaw.log 2>&1" diff --git a/agent/nanoclaw/Dockerfile b/agent/nanoclaw/Dockerfile new file mode 100644 index 00000000..170065da --- /dev/null +++ b/agent/nanoclaw/Dockerfile @@ -0,0 +1,153 @@ +# Claworc NanoClaw agent image (claworc/nanoclaw). +# +# NanoClaw (https://github.com/nanocoai/nanoclaw) is a lightweight +# OpenClaw alternative built on the Claude Agent SDK. Upstream, a Node host +# process routes channel messages and spawns one Docker container per agent +# session; host and session runner communicate exclusively through a +# per-session SQLite pair (inbound.db / outbound.db). +# +# The Claworc instance container IS the session sandbox — there is no +# Docker-in-Docker here. This image therefore skips the upstream host +# entirely (channels, OneCLI credential gateway, container spawning are all +# host concerns Claworc replaces) and runs the *agent-runner* directly as a +# child process, supervised by /opt/claworc/shim/lib/host.mjs (svc-agent). +# All message IO stays on NanoClaw's documented host<->runner session-DB +# contract; a small build-time patch (patches/0001-session-dir-env.patch) +# makes the runner's fixed /workspace session mount point overridable via +# NANOCLAW_SESSION_DIR so multiple sessions can run as plain processes. +# +# Layout mirrors upstream's agent container where it matters: +# /opt/nanoclaw pinned NanoClaw checkout (patched) +# /app/src -> /opt/nanoclaw/container/agent-runner/src (runner source) +# /app/node_modules bun install of the runner deps +# /pnpm/claude pinned @anthropic-ai/claude-code +# (the SDK hardcodes this path) +# /workspace/agent -> /home/claworc/workspace (agent group dir) +# +# No VNC/browser: browsers live in the on-demand claworc/-browser +# pods, as with the other slim agent images. + +FROM debian:bookworm-slim + +ARG S6_OVERLAY_VERSION=3.2.0.2 +ARG TARGETARCH +# NanoClaw release tag (github.com/nanocoai/nanoclaw). Bump deliberately and +# regenerate patches/ against the new tag. +ARG NANOCLAW_VERSION=v2.1.54 +# Runtime pins matching upstream's container/Dockerfile + cli-tools.json for +# this NanoClaw version. +ARG BUN_VERSION=1.3.12 +ARG CLAUDE_CODE_VERSION=2.1.197 +ARG PNPM_VERSION=10.33.0 + +# Create claworc user (UID 1000) — all agent state stays owned by it. +RUN useradd -m -u 1000 -s /bin/bash claworc && mkdir -p /defaults + +# System packages + Node.js 22 (the Claude Code CLI and its ecosystem need +# node; the agent-runner itself runs under Bun). python3 + jq serve the shim +# selftest and general agent tooling. +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + curl ca-certificates xz-utils gnupg && \ + curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + tzdata locales \ + openssh-server git patch unzip zip \ + python3 jq procps nodejs \ + sudo && \ + echo "en_US.UTF-8 UTF-8" >> /etc/locale.gen && locale-gen && \ + rm -rf /var/lib/apt/lists/* + +RUN echo 'claworc ALL=(ALL) NOPASSWD: /usr/bin/apt-get, /usr/bin/apt' > /etc/sudoers.d/claworc-apt && \ + chmod 0440 /etc/sudoers.d/claworc-apt + +# s6-overlay (PID 1) +RUN S6_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \ + curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" \ + | tar -C / -Jxpf - && \ + curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" \ + | tar -C / -Jxpf - + +# Bun — the agent-runner's runtime AND the shim's scripting runtime +# (bun:sqlite is the same SQLite driver the runner uses, so the shim bridge +# adds zero dependencies of its own). +RUN curl -fsSL https://bun.sh/install | bash -s "bun-v${BUN_VERSION}" && \ + install -m 0755 /root/.bun/bin/bun /usr/local/bin/bun && \ + rm -rf /root/.bun + +# pnpm + the Claude Code CLI at upstream's pinned version. The Claude Agent +# SDK inside NanoClaw hardcodes pathToClaudeCodeExecutable: '/pnpm/claude' +# (container/agent-runner/src/providers/claude.ts), so we reproduce +# upstream's PNPM_HOME=/pnpm global-bin layout exactly. The +# only-built-dependencies opt-in lets claude-code's postinstall download its +# native binary (pnpm skips build scripts by default). +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack enable && corepack prepare pnpm@${PNPM_VERSION} --activate && \ + echo "only-built-dependencies[]=@anthropic-ai/claude-code" > /root/.npmrc && \ + pnpm install -g "@anthropic-ai/claude-code@${CLAUDE_CODE_VERSION}" && \ + test -x /pnpm/claude + +# NanoClaw source at the pinned tag, with the Claworc no-container patch. +COPY patches/ /tmp/claworc-patches/ +RUN mkdir -p /opt/nanoclaw && \ + curl -fsSL "https://github.com/nanocoai/nanoclaw/archive/refs/tags/${NANOCLAW_VERSION}.tar.gz" \ + | tar -xz -C /opt/nanoclaw --strip-components=1 && \ + for p in /tmp/claworc-patches/*.patch; do patch -p1 -d /opt/nanoclaw < "$p"; done && \ + rm -rf /tmp/claworc-patches + +# Agent-runner dependencies (Claude Agent SDK, MCP SDK, zod, cron-parser) +# from upstream's own lockfile, plus the /app layout the runner source +# expects (/app/src is also baked into its memory-hook command strings). +RUN cd /opt/nanoclaw/container/agent-runner && \ + bun install --frozen-lockfile && \ + mkdir -p /app && \ + ln -s /opt/nanoclaw/container/agent-runner/src /app/src && \ + ln -s /opt/nanoclaw/container/agent-runner/node_modules /app/node_modules && \ + ln -s /opt/nanoclaw/container/skills /app/skills && \ + ln -s /opt/nanoclaw/container/CLAUDE.md /app/CLAUDE.md && \ + chown -R claworc:claworc /opt/nanoclaw + +# Upstream group-dir path -> persistent claworc workspace. The runner keeps +# its hardcoded cwd /workspace/agent; the symlink lands it on the PVC. +RUN mkdir -p /workspace && \ + ln -s /home/claworc/workspace /workspace/agent && \ + chown -R claworc:claworc /workspace + +COPY defaults/container.json /defaults/container.json + +COPY rootfs/ / + +# Claworc agent shim (docs/shim.md): the universal exec-based interface the +# control plane invokes over SSH. +COPY shim/ /opt/claworc/shim/ +RUN printf '%s\n' "${NANOCLAW_VERSION#v}" > /opt/claworc/shim/nanoclaw.version && \ + chmod 0755 /opt/claworc/shim/meta \ + /opt/claworc/shim/health \ + /opt/claworc/shim/chat-send \ + /opt/claworc/shim/chat-abort \ + /opt/claworc/shim/session-reset \ + /opt/claworc/shim/config-get \ + /opt/claworc/shim/config-set \ + /opt/claworc/shim/configure-llm \ + /opt/claworc/shim/restart \ + /opt/claworc/shim/shim-selftest && \ + chmod 0644 /opt/claworc/shim/agent.txt /opt/claworc/shim/agent.svg \ + /opt/claworc/shim/nanoclaw.version /opt/claworc/shim/lib/*.mjs + +RUN chmod +x /etc/s6-overlay/s6-rc.d/init-setup/up \ + /etc/s6-overlay/s6-rc.d/init-agent-seed/up \ + /etc/s6-overlay/scripts/init-setup.sh \ + /etc/s6-overlay/scripts/init-agent-seed.sh \ + /etc/s6-overlay/s6-rc.d/svc-agent/run \ + /etc/s6-overlay/s6-rc.d/svc-sshd/run + +RUN mkdir -p /var/log/claworc && chown claworc:claworc /var/log/claworc + +# Reduce SUID surface. Stripping su's SUID bit is safe: the shim invokes it +# as root (SSH exec), and root does not need SUID to switch users. +RUN chmod u-s /usr/bin/su /usr/bin/mount /usr/bin/umount /usr/bin/newgrp \ + /usr/bin/chsh /usr/bin/chfn /usr/bin/gpasswd /usr/bin/chage \ + /usr/lib/openssh/ssh-keysign 2>/dev/null || true + +ENTRYPOINT ["/init"] diff --git a/agent/nanoclaw/README.md b/agent/nanoclaw/README.md new file mode 100644 index 00000000..6a9f809c --- /dev/null +++ b/agent/nanoclaw/README.md @@ -0,0 +1,79 @@ +# Claworc NanoClaw agent image + +`claworc/nanoclaw` — a Claworc-managed agent image for +[NanoClaw](https://github.com/nanocoai/nanoclaw) (pinned via +`ARG NANOCLAW_VERSION`), implementing the +[Claworc Agent Shim Contract v1](../../docs/shim.md). + +## How NanoClaw is run here (no Docker-in-Docker) + +Upstream NanoClaw is two processes: + +* a **host** (Node) that owns channels (WhatsApp/Telegram/…), routing, the + central DB, the OneCLI credential gateway, and spawns **one Docker container + per agent session** (`src/container-runtime.ts` hardcodes `docker`; the + spawn hard-fails without the OneCLI gateway); +* an **agent-runner** (Bun + Claude Agent SDK) inside each session container. + Host and runner communicate *only* through a per-session SQLite pair — + `inbound.db` (host writes / runner reads) and `outbound.db` (runner writes / + host reads: `messages_out`, `processing_ack`, `session_state`). + +A Claworc instance container is already the sandbox, so this image does not +run the upstream host at all (there is no supported non-container executor +and the local `ncl` CLI needs the full host + Docker + OneCLI). Instead the +shim plays the host's role on the documented session-DB contract: + +* `shim/lib/host.mjs` (svc-agent) supervises one **agent-runner child + process** per Claworc session that has pending work, reaping idle ones; +* `chat-send` inserts the user message into the session's `inbound.db`, + streams new `messages_out` chat rows as cumulative assistant snapshots, and + ends the turn when the runner writes a terminal `processing_ack` for the + message (a real marker — `chat_end_detection: "exact"`); +* `configure-llm` stores `ANTHROPIC_BASE_URL`/`ANTHROPIC_AUTH_TOKEN` routing + (NanoClaw's native Claude SDK wiring) in a managed state file injected into + every runner spawn, and writes the default model into NanoClaw's own + `container.json`; +* one small build-time patch (`patches/0001-session-dir-env.patch`) makes the + runner's fixed `/workspace` session mount point overridable via + `NANOCLAW_SESSION_DIR` so several sessions can coexist as plain processes. + The shared group dir stays at upstream's `/workspace/agent`, a symlink to + the persistent `/home/claworc/workspace`. + +Everything else is upstream-faithful: runner source is used unmodified from +the pinned checkout (`/app/src`, the layout its hook commands expect), deps +come from upstream's own `bun.lock`, and the Claude Code CLI is installed at +upstream's pinned version at `/pnpm/claude` (the path the runner hardcodes). + +## Session and state model + +| Path | Contents | +|---|---| +| `/home/claworc/workspace` | NanoClaw agent group dir: `container.json`, `CLAUDE.md`, `memory/`, `conversations/`, working files (PVC) | +| `/home/claworc/.claworc/shim/nanoclaw/sessions//` | per-Claworc-session `inbound.db` / `outbound.db` / `.heartbeat` / `outbox/` (PVC) | +| `/home/claworc/.claworc/shim/nanoclaw/llm.json` | managed configure-llm state (PVC) | +| `/run/claworc/shim/` | supervisor heartbeat + runner/chat pidfiles (ephemeral) | + +`session-reset` kills the session's runner and deletes its session directory +(fresh SDK continuation). Long-term memory under the workspace is agent-level +state shared by all sessions — upstream semantics — and is not touched. + +## Validate + +The image ships the template's `shim-selftest` at +`/opt/claworc/shim/shim-selftest`. Unlike the daemonless template/Hermes +images it needs its s6 services (the svc-agent supervisor) running, so boot +the container first — this is exactly what `make agent-test` does: + +```sh +docker build -t claworc/nanoclaw:test agent/nanoclaw/ +docker run -d --name ncl-test claworc/nanoclaw:test +# wait until: docker exec ncl-test /opt/claworc/shim/health → exit 0 +docker exec ncl-test sh /opt/claworc/shim/shim-selftest /opt/claworc/shim +docker rm -f ncl-test +``` + +Chat checks need an Anthropic-compatible endpoint. Without one the agent +replies with the API error text and the turn still ends cleanly per contract; +the shim caps the Claude Code CLI's retry loop (`CLAUDE_CODE_MAX_RETRIES`, +default 3, override with `CLAWORC_NANOCLAW_MAX_RETRIES`) so that failure +takes seconds, not the CLI's default multi-minute backoff. diff --git a/agent/nanoclaw/defaults/container.json b/agent/nanoclaw/defaults/container.json new file mode 100644 index 00000000..fcce3b31 --- /dev/null +++ b/agent/nanoclaw/defaults/container.json @@ -0,0 +1,6 @@ +{ + "provider": "claude", + "assistantName": "NanoClaw", + "maxMessagesPerPrompt": 10, + "mcpServers": {} +} diff --git a/agent/nanoclaw/patches/0001-session-dir-env.patch b/agent/nanoclaw/patches/0001-session-dir-env.patch new file mode 100644 index 00000000..211be43b --- /dev/null +++ b/agent/nanoclaw/patches/0001-session-dir-env.patch @@ -0,0 +1,100 @@ +CLAWORC PATCH — parametrize the NanoClaw agent-runner session directory +======================================================================= + +Upstream NanoClaw (nanocoai/nanoclaw) runs one Docker container per agent +session; the host bind-mounts each session's directory (inbound.db / +outbound.db / .heartbeat / outbox) at the fixed path /workspace inside the +container, so the agent-runner hardcodes that path. + +The Claworc instance container IS the sandbox — there is no Docker inside it. +The Claworc shim runs the agent-runner as a plain child process, one per +Claworc chat session, so the fixed /workspace mount point is replaced by a +NANOCLAW_SESSION_DIR environment variable (default: /workspace, i.e. behavior +is unchanged for upstream's containerized use). + +Four files are touched, all in container/agent-runner/src/: + db/connection.ts inbound.db / outbound.db / .heartbeat paths + cli/ncl.ts same two DB paths for the in-session `ncl` CLI + mcp-tools/core.ts the per-message outbox directory + index.ts propagate the variable to the nanoclaw MCP subprocess + +The shared agent-group directory (/workspace/agent) is NOT parametrized: the +image provides it as a real path (a symlink to the persistent +/home/claworc/workspace), matching upstream's "one group dir shared by all +sessions" semantics. + +Applied at image build time with: patch -p1 -d /opt/nanoclaw < this-file +Generated against NANOCLAW_VERSION v2.1.54; regenerate when bumping. + +diff --git a/container/agent-runner/src/cli/ncl.ts b/container/agent-runner/src/cli/ncl.ts +index 2025e3b..ac84e1c 100644 +--- a/container/agent-runner/src/cli/ncl.ts ++++ b/container/agent-runner/src/cli/ncl.ts +@@ -31,8 +31,11 @@ type ResponseFrame = + // Paths + // --------------------------------------------------------------------------- + +-const INBOUND_DB = '/workspace/inbound.db'; +-const OUTBOUND_DB = '/workspace/outbound.db'; ++// CLAWORC PATCH: honor the per-session directory override used by the ++// Claworc no-container runner (see db/connection.ts). ++const NCL_SESSION_DIR = process.env.NANOCLAW_SESSION_DIR || '/workspace'; ++const INBOUND_DB = `${NCL_SESSION_DIR}/inbound.db`; ++const OUTBOUND_DB = `${NCL_SESSION_DIR}/outbound.db`; + + // --------------------------------------------------------------------------- + // DB transport +diff --git a/container/agent-runner/src/db/connection.ts b/container/agent-runner/src/db/connection.ts +index 00ce0ee..ad306c2 100644 +--- a/container/agent-runner/src/db/connection.ts ++++ b/container/agent-runner/src/db/connection.ts +@@ -20,9 +20,16 @@ + import { Database } from 'bun:sqlite'; + import fs from 'fs'; + +-const DEFAULT_INBOUND_PATH = '/workspace/inbound.db'; +-const DEFAULT_OUTBOUND_PATH = '/workspace/outbound.db'; +-const DEFAULT_HEARTBEAT_PATH = '/workspace/.heartbeat'; ++// CLAWORC PATCH (see agent/nanoclaw/patches/ in the claworc repo): the ++// Claworc image runs this agent-runner as a plain child process — one per ++// Claworc chat session — instead of one container per session. The session ++// DB pair therefore cannot live at a fixed mount point; the supervisor ++// passes the per-session directory via NANOCLAW_SESSION_DIR. Behavior is ++// unchanged when the variable is unset (containerized default). ++const SESSION_DIR = process.env.NANOCLAW_SESSION_DIR || '/workspace'; ++const DEFAULT_INBOUND_PATH = `${SESSION_DIR}/inbound.db`; ++const DEFAULT_OUTBOUND_PATH = `${SESSION_DIR}/outbound.db`; ++const DEFAULT_HEARTBEAT_PATH = `${SESSION_DIR}/.heartbeat`; + + let _inbound: Database | null = null; + let _outbound: Database | null = null; +diff --git a/container/agent-runner/src/index.ts b/container/agent-runner/src/index.ts +index c85873c..5f39918 100644 +--- a/container/agent-runner/src/index.ts ++++ b/container/agent-runner/src/index.ts +@@ -88,7 +88,9 @@ async function main(): Promise { + nanoclaw: { + command: 'bun', + args: ['run', mcpServerPath], +- env: {}, ++ // CLAWORC PATCH: propagate the per-session directory override so the ++ // MCP server subprocess writes to the same session DB pair. ++ env: process.env.NANOCLAW_SESSION_DIR ? { NANOCLAW_SESSION_DIR: process.env.NANOCLAW_SESSION_DIR } : {}, + }, + }; + +diff --git a/container/agent-runner/src/mcp-tools/core.ts b/container/agent-runner/src/mcp-tools/core.ts +index 61bd557..80bf4eb 100644 +--- a/container/agent-runner/src/mcp-tools/core.ts ++++ b/container/agent-runner/src/mcp-tools/core.ts +@@ -138,7 +138,9 @@ export const sendFile: McpToolDefinition = { + const id = generateId(); + const filename = (args.filename as string) || path.basename(resolvedPath); + +- const outboxDir = path.join('/workspace/outbox', id); ++ // CLAWORC PATCH: outbox lives inside the per-session directory when the ++ // runner is executed without a per-session container mount. ++ const outboxDir = path.join(process.env.NANOCLAW_SESSION_DIR || '/workspace', 'outbox', id); + fs.mkdirSync(outboxDir, { recursive: true }); + fs.copyFileSync(resolvedPath, path.join(outboxDir, filename)); + diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/dependencies.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/dependencies.d/init-setup diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/type similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/type diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/up b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/up new file mode 100644 index 00000000..6fce7335 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-agent-seed/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/init-agent-seed.sh diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type @@ -0,0 +1 @@ +oneshot diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up new file mode 100644 index 00000000..88d2a0d4 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/init-setup.sh diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-homebrew-seed b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-agent-seed similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-homebrew-seed rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-agent-seed diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-setup diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/run b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/run new file mode 100644 index 00000000..b7328be3 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/run @@ -0,0 +1,16 @@ +#!/command/with-contenv bash + +export HOME=/home/claworc + +# svc-agent runs the shim's NanoClaw supervisor (see +# /opt/claworc/shim/lib/host.mjs): it plays the role of upstream NanoClaw's +# host process, spawning one agent-runner child per Claworc chat session +# that has pending work in its session-DB pair, and reaping idle ones. LLM +# routing (ANTHROPIC_BASE_URL / ANTHROPIC_AUTH_TOKEN for the Claworc LLM +# proxy) is read from the configure-llm state file at every runner spawn, so +# `s6-svc -r` (the shim restart verb) picks up config changes and the +# supervisor's own TERM handler recycles all runner children. +# +# Output goes to the contract log path /var/log/claworc/agent.log. +echo "Starting NanoClaw shim supervisor as claworc..." +exec s6-setuidgid claworc /usr/local/bin/bun /opt/claworc/shim/lib/host.mjs >> /var/log/claworc/agent.log 2>&1 diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/type b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/type similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-openclaw/type rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/type diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-openclaw-seed b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-openclaw-seed rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run new file mode 100755 index 00000000..f6be5b7a --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run @@ -0,0 +1,26 @@ +#!/command/with-contenv bash + +# Remove legacy DSA/ECDSA host keys (keep only Ed25519 and RSA) +rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub +rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub + +# Generate host keys if missing (only Ed25519 and RSA will be created +# since we removed DSA/ECDSA above) +ssh-keygen -A + +# Remove any DSA/ECDSA keys that ssh-keygen -A may have regenerated +rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub +rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub + +# Create privilege-separation directory +mkdir -p /run/sshd + +# Ensure /root/.ssh directory exists with correct permissions +mkdir -p /root/.ssh +chmod 700 /root/.ssh + +echo "Starting sshd in foreground..." +# The -e flag sends sshd logs to stderr (in addition to syslog facility +# configured in claworc.conf). We redirect to the log file for SSH-based +# log streaming used by the control plane. +exec /usr/sbin/sshd -D -e >> /var/log/claworc/sshd.log 2>&1 diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-agent-seed similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-agent-seed diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-openclaw b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-openclaw rename to agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-agent b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-agent new file mode 100644 index 00000000..e69de29b diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd b/agent/nanoclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd new file mode 100644 index 00000000..e69de29b diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/scripts/init-agent-seed.sh b/agent/nanoclaw/rootfs/etc/s6-overlay/scripts/init-agent-seed.sh new file mode 100644 index 00000000..769dbe07 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/scripts/init-agent-seed.sh @@ -0,0 +1,32 @@ +#!/bin/bash +# Seeds the NanoClaw agent workspace on the persistent volume on first boot +# (s6-rc oneshot, after init-setup, before svc-agent). Idempotent: only +# writes files that don't exist yet, so agent-accumulated state (memory/, +# conversations/, user edits to container.json) is never clobbered. +# +# The workspace lives at /home/claworc/workspace (the instance PVC); the +# image's /workspace/agent symlink points here so upstream NanoClaw's +# hardcoded /workspace/agent paths (runner cwd, container.json, memory +# scaffold) work unchanged. + +set -e + +WORKSPACE=/home/claworc/workspace + +mkdir -p "$WORKSPACE" + +# Per-agent-group config read by the agent-runner at startup +# (container/agent-runner/src/config.ts in the NanoClaw repo). +if [ ! -f "$WORKSPACE/container.json" ] && [ -f /defaults/container.json ]; then + install -m 0644 /defaults/container.json "$WORKSPACE/container.json" +fi + +# Upstream shared agent doctrine (workspace layout, memory conventions, +# communication rules). Upstream composes this per group; we seed the shared +# base once and let the agent/user evolve it. +if [ ! -f "$WORKSPACE/CLAUDE.md" ] && [ -f /opt/nanoclaw/container/CLAUDE.md ]; then + install -m 0644 /opt/nanoclaw/container/CLAUDE.md "$WORKSPACE/CLAUDE.md" +fi + +chown -R claworc:claworc "$WORKSPACE" +echo "init-agent-seed: NanoClaw workspace ready at $WORKSPACE" diff --git a/agent/nanoclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh b/agent/nanoclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh new file mode 100644 index 00000000..82ff6214 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# Runs once at container boot (s6-rc oneshot). Three jobs: +# 1. Prepare /var/log/claworc and the claworc user's HOME. +# 2. Snapshot PID 1's env to /etc/environment and +# /etc/profile.d/claworc-env.sh so SSH sessions — which go through +# PAM and do NOT inherit sshd's env — see the vars passed to +# `docker run -e` / the Kubernetes pod spec. The shim verbs are exec'd +# over SSH, so this is what delivers CLAWORC_AGENT_TOKEN, +# CLAWORC_LLM_PROXY_URL, etc. to them. +# 3. Apply CLAWORC_INITIAL_LLM_CONFIG through the shim's own configure-llm +# verb so first boot and reconfiguration share one code path. + +set -e + +# SSH host keys: regenerate per-pod so every container has unique keys. +if command -v ssh-keygen >/dev/null 2>&1; then + ssh-keygen -A >/dev/null 2>&1 || true +fi + +# --------------------------------------------------------------------------- +# Filesystem + user home +# --------------------------------------------------------------------------- +mkdir -p /var/log/claworc +chmod 755 /var/log/claworc +touch /var/log/claworc/agent.log +chown claworc:claworc /var/log/claworc/agent.log + +test -f /home/claworc/.bashrc || cp -a /etc/skel/. /home/claworc/ +# The NanoClaw agent workspace (files, long-term memory, container.json). +# /workspace/agent inside the image symlinks here so upstream NanoClaw's +# hardcoded group-dir paths keep working on the persistent volume. +mkdir -p /home/claworc/workspace +# Shim persistent state: per-session NanoClaw DB pairs + LLM routing. +mkdir -p /home/claworc/.claworc/shim/nanoclaw/sessions +chown -R claworc:claworc /home/claworc + +# Ephemeral shim runtime state (chat/runner PIDs, supervisor heartbeat) — +# written by the claworc-owned supervisor and verbs. +mkdir -p /run/claworc/shim +chown -R claworc:claworc /run/claworc +chmod 755 /run/claworc /run/claworc/shim + +# --------------------------------------------------------------------------- +# Propagate PID 1 env to PAM and bash login shells +# --------------------------------------------------------------------------- +exclude='^(PATH|HOME|HOSTNAME|TERM|PWD|OLDPWD|SHLVL|SHELL|LOGNAME|USER|MAIL|_)=' + +: > /etc/environment +printenv | grep -vE "$exclude" | while IFS='=' read -r key value; do + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '%s="%s"\n' "$key" "$escaped" >> /etc/environment +done +chmod 644 /etc/environment + +{ + echo '# Generated by init-setup.sh at container boot. Do not edit.' + printenv | grep -vE "$exclude" | while IFS='=' read -r key value; do + printf 'export %s=%q\n' "$key" "$value" + done +} > /etc/profile.d/claworc-env.sh +chmod 644 /etc/profile.d/claworc-env.sh + +# --------------------------------------------------------------------------- +# First-boot LLM routing (CLAWORC_INITIAL_LLM_CONFIG, docs/shim.md) +# --------------------------------------------------------------------------- +if [ -n "${CLAWORC_INITIAL_LLM_CONFIG:-}" ]; then + if ! printf '%s' "$CLAWORC_INITIAL_LLM_CONFIG" | /opt/claworc/shim/configure-llm; then + echo "configure-llm failed; continuing boot without initial LLM routing" >&2 + fi +fi diff --git a/agent/nanoclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf b/agent/nanoclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf new file mode 100644 index 00000000..d22476c2 --- /dev/null +++ b/agent/nanoclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf @@ -0,0 +1,33 @@ +# Claworc SSH Server Hardened Configuration +# Applied via the sshd_config.d/ include mechanism. SSH is the contract's +# only hard runtime dependency — the control plane execs the shim verbs, +# streams files, and opens tunnels over this connection. + +# Network +Port 22 +ListenAddress 0.0.0.0 + +# Authentication +PubkeyAuthentication yes +PasswordAuthentication no +PermitEmptyPasswords no +PermitRootLogin prohibit-password +MaxAuthTries 3 +StrictModes yes +LoginGraceTime 30 + +# Connection limits +MaxStartups 10:30:60 + +# Forwarding restrictions +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding yes +# 127.0.0.1:40001 is the Claworc LLM proxy listener: the control plane +# installs a remote port forward on it so the agent's LLM traffic (routed +# there by configure-llm) reaches the gateway with virtual-key auth. +PermitListen 127.0.0.1:40001 + +# Logging +SyslogFacility AUTH +LogLevel INFO diff --git a/agent/nanoclaw/shim/agent.svg b/agent/nanoclaw/shim/agent.svg new file mode 100644 index 00000000..83c97068 --- /dev/null +++ b/agent/nanoclaw/shim/agent.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/agent/nanoclaw/shim/agent.txt b/agent/nanoclaw/shim/agent.txt new file mode 100644 index 00000000..ec701f46 --- /dev/null +++ b/agent/nanoclaw/shim/agent.txt @@ -0,0 +1 @@ +NanoClaw diff --git a/agent/nanoclaw/shim/chat-abort b/agent/nanoclaw/shim/chat-abort new file mode 100644 index 00000000..0caa363b --- /dev/null +++ b/agent/nanoclaw/shim/chat-abort @@ -0,0 +1,15 @@ +#!/bin/sh +# chat-abort — Claworc shim verb (docs/shim.md). Thin wrapper: verbs are invoked +# as root over SSH; agent state must stay claworc-owned, so drop privileges +# and exec the Bun implementation in lib/. Bun is used because bun:sqlite is +# the same SQLite driver NanoClaw's own agent-runner uses — no dependency of +# our own. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BUN=${CLAWORC_SHIM_BUN:-/usr/local/bin/bun} + +if [ "$(id -u)" = "0" ] && [ -x /command/s6-setuidgid ]; then + exec env HOME=/home/claworc /command/s6-setuidgid claworc "$BUN" "$SHIM_DIR/lib/chat-abort.mjs" "$@" +fi +exec "$BUN" "$SHIM_DIR/lib/chat-abort.mjs" "$@" diff --git a/agent/nanoclaw/shim/chat-send b/agent/nanoclaw/shim/chat-send new file mode 100644 index 00000000..3a483b57 --- /dev/null +++ b/agent/nanoclaw/shim/chat-send @@ -0,0 +1,15 @@ +#!/bin/sh +# chat-send — Claworc shim verb (docs/shim.md). Thin wrapper: verbs are invoked +# as root over SSH; agent state must stay claworc-owned, so drop privileges +# and exec the Bun implementation in lib/. Bun is used because bun:sqlite is +# the same SQLite driver NanoClaw's own agent-runner uses — no dependency of +# our own. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BUN=${CLAWORC_SHIM_BUN:-/usr/local/bin/bun} + +if [ "$(id -u)" = "0" ] && [ -x /command/s6-setuidgid ]; then + exec env HOME=/home/claworc /command/s6-setuidgid claworc "$BUN" "$SHIM_DIR/lib/chat-send.mjs" "$@" +fi +exec "$BUN" "$SHIM_DIR/lib/chat-send.mjs" "$@" diff --git a/agent/nanoclaw/shim/config-get b/agent/nanoclaw/shim/config-get new file mode 100644 index 00000000..859a8134 --- /dev/null +++ b/agent/nanoclaw/shim/config-get @@ -0,0 +1,28 @@ +#!/bin/sh +# config-get [--id ] — raw config file bytes on stdout (docs/shim.md). +# The single exposed file is NanoClaw's per-agent-group container.json. +set -eu + +CONFIG_FILE=${CLAWORC_NANOCLAW_CONFIG_FILE:-/home/claworc/workspace/container.json} + +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi +if [ ! -f "$CONFIG_FILE" ]; then + # First boot before init-agent-seed / configure-llm ran: serve the baked + # default so the Config tab is never a hard error. + if [ -f /defaults/container.json ]; then + exec cat /defaults/container.json + fi + echo "config file not found: $CONFIG_FILE" >&2 + exit 1 +fi +exec cat "$CONFIG_FILE" diff --git a/agent/nanoclaw/shim/config-set b/agent/nanoclaw/shim/config-set new file mode 100644 index 00000000..27a057d7 --- /dev/null +++ b/agent/nanoclaw/shim/config-set @@ -0,0 +1,44 @@ +#!/bin/sh +# config-set [--id ] — replace container.json with stdin bytes. +# Validates JSON (exit 6 + {"error":...} on stdout when invalid), writes +# atomically, keeps the file claworc-owned, does NOT restart the agent — +# the control plane calls restart afterwards (restart_required: true in meta). +set -eu + +CONFIG_FILE=${CLAWORC_NANOCLAW_CONFIG_FILE:-/home/claworc/workspace/container.json} +BUN=${CLAWORC_SHIM_BUN:-/usr/local/bin/bun} + +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi + +DIR=$(dirname "$CONFIG_FILE") +mkdir -p "$DIR" +TMP="$CONFIG_FILE.shim-tmp.$$" +trap 'rm -f "$TMP"' EXIT + +cat > "$TMP" + +# container.json is JSON.parse'd by the agent-runner at startup — refuse +# content it cannot parse. +if ! ERR=$("$BUN" -e 'const fs=require("fs");try{JSON.parse(fs.readFileSync(process.argv[1],"utf8"))}catch(e){console.error(e.message);process.exit(1)}' "$TMP" 2>&1); then + printf '{"error":"invalid JSON: %s"}\n' "$(printf '%s' "$ERR" | head -c 300 | tr -d '"\\\n')" + exit 6 +fi + +chmod 0644 "$TMP" +mv -f "$TMP" "$CONFIG_FILE" +trap - EXIT +if [ "$(id -u)" = "0" ]; then + chown claworc:claworc "$CONFIG_FILE" 2>/dev/null || true + chown claworc:claworc "$DIR" 2>/dev/null || true +fi +exit 0 diff --git a/agent/nanoclaw/shim/configure-llm b/agent/nanoclaw/shim/configure-llm new file mode 100644 index 00000000..f0a4ab15 --- /dev/null +++ b/agent/nanoclaw/shim/configure-llm @@ -0,0 +1,15 @@ +#!/bin/sh +# configure-llm — Claworc shim verb (docs/shim.md). Thin wrapper: verbs are invoked +# as root over SSH; agent state must stay claworc-owned, so drop privileges +# and exec the Bun implementation in lib/. Bun is used because bun:sqlite is +# the same SQLite driver NanoClaw's own agent-runner uses — no dependency of +# our own. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BUN=${CLAWORC_SHIM_BUN:-/usr/local/bin/bun} + +if [ "$(id -u)" = "0" ] && [ -x /command/s6-setuidgid ]; then + exec env HOME=/home/claworc /command/s6-setuidgid claworc "$BUN" "$SHIM_DIR/lib/configure-llm.mjs" "$@" +fi +exec "$BUN" "$SHIM_DIR/lib/configure-llm.mjs" "$@" diff --git a/agent/nanoclaw/shim/health b/agent/nanoclaw/shim/health new file mode 100644 index 00000000..246f2e3f --- /dev/null +++ b/agent/nanoclaw/shim/health @@ -0,0 +1,51 @@ +#!/bin/sh +# health — exit 0 when the agent can take a chat turn, 4 while booting, +# 1 when broken (docs/shim.md). +# +# "Able to take a turn" for this image means: the svc-agent supervisor +# (shim/lib/host.mjs) is alive and its scan loop is ticking — it touches +# /run/claworc/shim/host.heartbeat every 500ms. A fresh heartbeat implies a +# new inbound message will be picked up and a runner spawned. +set -u + +RUN_DIR=${CLAWORC_SHIM_RUN_DIR:-/run/claworc/shim} +HEARTBEAT="$RUN_DIR/host.heartbeat" +SVC_DIR=/run/service/svc-agent +BUN=${CLAWORC_SHIM_BUN:-/usr/local/bin/bun} + +if [ ! -x "$BUN" ]; then + echo "bun runtime missing at $BUN" >&2 + exit 1 +fi + +# s6 service tree not created yet => container still booting. +if [ ! -d /run/service ]; then + echo "s6 service tree not up yet" >&2 + exit 4 +fi + +if [ ! -d "$SVC_DIR" ]; then + echo "svc-agent service directory missing" >&2 + exit 1 +fi + +if ! /command/s6-svstat -o up "$SVC_DIR" 2>/dev/null | grep -q '^true$'; then + echo "svc-agent service is down" >&2 + exit 1 +fi + +# Service is up: heartbeat mtime decides ready vs still-booting. +if [ -f "$HEARTBEAT" ]; then + NOW=$(date +%s) + MTIME=$(stat -c %Y "$HEARTBEAT" 2>/dev/null || stat -f %m "$HEARTBEAT" 2>/dev/null || echo 0) + AGE=$((NOW - MTIME)) + if [ "$AGE" -le 15 ]; then + printf '{"status":"ok"}\n' + exit 0 + fi + echo "supervisor heartbeat stale (${AGE}s)" >&2 + exit 4 +fi + +echo "supervisor heartbeat not written yet" >&2 +exit 4 diff --git a/agent/nanoclaw/shim/lib/chat-abort.mjs b/agent/nanoclaw/shim/lib/chat-abort.mjs new file mode 100644 index 00000000..c2914614 --- /dev/null +++ b/agent/nanoclaw/shim/lib/chat-abort.mjs @@ -0,0 +1,72 @@ +// chat-abort.mjs — abort the in-flight turn for a session (docs/shim.md). +// +// Best-effort, exit 0 even when nothing was running: +// 1. retire the session's un-acked pending inbound rows so a respawned +// runner won't re-process the aborted turn, +// 2. SIGTERM the session's agent-runner (kills the in-flight SDK query; +// the supervisor lazily respawns on the next real message), +// 3. SIGTERM the running chat-send, which emits end/aborted and exits 0. +import fs from 'node:fs'; +import path from 'node:path'; + +import { + chatPidFile, + openInboundRo, + pidAlive, + readPidFile, + retireInboundMessages, + runnerPidFile, + sessionDir, +} from './shimlib.mjs'; + +let session = ''; +{ + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === '--session') session = args[++i] ?? ''; + else { + process.stderr.write(`unknown argument: ${args[i]}\n`); + process.exit(2); + } + } +} +if (!session) { + process.stderr.write('--session is required\n'); + process.exit(2); +} + +const dir = sessionDir(session); + +// 1. retire pending rows (idempotent; session may not exist at all). +try { + if (fs.existsSync(path.join(dir, 'inbound.db'))) { + const db = openInboundRo(dir); + let ids = []; + try { + ids = db.prepare(`SELECT id FROM messages_in WHERE status = 'pending'`).all().map((r) => r.id); + } finally { + db.close(); + } + retireInboundMessages(dir, ids); + } +} catch (err) { + process.stderr.write(`chat-abort: retire failed: ${err.message}\n`); +} + +// 2. kill the runner. +try { + const pid = readPidFile(runnerPidFile(session)); + if (pidAlive(pid)) process.kill(pid, 'SIGTERM'); +} catch { + /* already gone */ +} + +// 3. signal chat-send (it emits end/aborted itself). +try { + const pid = readPidFile(chatPidFile(session)); + if (pidAlive(pid)) process.kill(pid, 'SIGTERM'); +} catch { + /* already gone */ +} + +process.exit(0); diff --git a/agent/nanoclaw/shim/lib/chat-send.mjs b/agent/nanoclaw/shim/lib/chat-send.mjs new file mode 100644 index 00000000..01d79ec3 --- /dev/null +++ b/agent/nanoclaw/shim/lib/chat-send.mjs @@ -0,0 +1,171 @@ +// chat-send.mjs — bridge one Claworc chat turn onto NanoClaw's session-DB +// message contract (docs/shim.md on the Claworc side, docs/db-session.md on +// the NanoClaw side). +// +// Flow: insert the user message into the session's inbound.db (as the host +// process would), let the svc-agent supervisor wake an agent-runner child, +// then stream every new messages_out chat row as an assistant snapshot and +// finish when the runner writes a terminal processing_ack for our message id. +// The ack is a real end-of-turn marker written by the runner itself, so +// meta declares chat_end_detection "exact". +import fs from 'node:fs'; + +import { + HOST_HEARTBEAT, + ackStatus, + chatPidFile, + emit, + ensureSession, + insertUserMessage, + maxOutboundSeq, + mtimeAgeMs, + outboundRowsAfter, + outboundText, + retireInboundMessages, + runnerPidFile, + pidAlive, + readPidFile, + sleep, +} from './shimlib.mjs'; + +const POLL_MS = 300; +// The svc-agent supervisor notices new work within ~0.5s and a runner boot +// (bun + Claude Agent SDK spawn) takes a few seconds; if no runner has even +// STARTED processing after this long, the service side is broken. +const RUNNER_GRACE_MS = 120_000; +// Absolute safety net so an orphaned chat-send cannot live forever. The +// control plane's own idle timeout (CLAWORC_WEBHOOK_IDLE_TIMEOUT / channel +// teardown) is the real supervisor of turn length. +const HARD_CAP_MS = 60 * 60 * 1000; + +function usage(msg) { + process.stderr.write(msg + '\n'); + process.exit(2); +} + +let session = ''; +let turn = ''; +{ + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === '--session') session = args[++i] ?? ''; + else if (args[i] === '--turn') turn = args[++i] ?? ''; + else usage(`unknown argument: ${args[i]}`); + } +} +if (!session) usage('--session is required'); +if (!turn) turn = `t-${process.pid}-${Date.now().toString(36)}`; + +const message = (await Bun.stdin.text()).toString(); + +let dir; +let messageId = null; +let lastText = ''; +let endedOK = false; + +function endEvent(stopReason, text) { + if (endedOK) return; + endedOK = true; + emit({ v: 1, event: 'end', turn, stop_reason: stopReason, text: text ?? '' }); +} + +function cleanup() { + try { + fs.rmSync(chatPidFile(session), { force: true }); + } catch { + /* ignore */ + } +} + +function onAbortSignal() { + // Retire our inbound row so a respawned runner doesn't re-process the + // aborted turn (we own inbound.db — host-side status is authoritative for + // the runner's pending query). History already streamed stays in the + // session per contract. chat-abort separately SIGTERMs the runner. + try { + if (dir && messageId) retireInboundMessages(dir, [messageId]); + } catch { + /* best effort */ + } + endEvent('aborted', lastText); + cleanup(); + process.exit(0); +} +process.on('SIGTERM', onAbortSignal); +process.on('SIGINT', onAbortSignal); +process.on('SIGHUP', onAbortSignal); + +try { + dir = ensureSession(session); + const baselineSeq = maxOutboundSeq(dir); + + try { + fs.writeFileSync(chatPidFile(session), String(process.pid)); + } catch { + /* chat-abort just won't find us */ + } + + emit({ v: 1, event: 'start', session, turn }); + messageId = insertUserMessage(dir, session, message); + + const startMs = Date.now(); + let lastSeq = baselineSeq; + let sawRunner = false; + let messageCount = 0; + + for (;;) { + await sleep(POLL_MS); + + // Stream new outbound chat rows. Each messages_out row is one complete + // assistant message — a cumulative snapshot keyed by its row id. + for (const row of outboundRowsAfter(dir, lastSeq)) { + lastSeq = row.seq; + const text = outboundText(row); + if (text === null) continue; + messageCount++; + lastText = text; + emit({ v: 1, event: 'assistant', turn, message_id: row.id, text }); + } + + const ack = ackStatus(dir, messageId); + if (ack === 'completed') { + endEvent('complete', lastText); + break; + } + if (ack === 'failed' || ack === 'script-skip:error') { + emit({ v: 1, event: 'error', turn, code: 'agent_failed', text: `agent marked the message ${ack}`, fatal: true }); + endEvent('error', lastText); + break; + } + + const runnerPid = readPidFile(runnerPidFile(session)); + if (pidAlive(runnerPid)) sawRunner = true; + + // Service-side failure detection (all soft-fail as end/error, exit 0): + if (mtimeAgeMs(HOST_HEARTBEAT) > 20_000) { + emit({ v: 1, event: 'error', turn, code: 'agent_service_down', text: 'NanoClaw supervisor (svc-agent) is not running', fatal: true }); + retireInboundMessages(dir, [messageId]); + endEvent('error', lastText); + break; + } + if (!sawRunner && Date.now() - startMs > RUNNER_GRACE_MS && ack === null) { + emit({ v: 1, event: 'error', turn, code: 'runner_not_started', text: 'no agent-runner started for this session in time', fatal: true }); + retireInboundMessages(dir, [messageId]); + endEvent('error', lastText); + break; + } + if (Date.now() - startMs > HARD_CAP_MS) { + emit({ v: 1, event: 'error', turn, code: 'turn_timeout', text: 'turn exceeded the shim hard cap', fatal: true }); + retireInboundMessages(dir, [messageId]); + endEvent('error', lastText); + break; + } + } +} catch (err) { + process.stderr.write(`chat-send: ${err?.stack || err}\n`); + emit({ v: 1, event: 'error', turn, code: 'shim_error', text: String(err?.message || err), fatal: true }); + endEvent('error', lastText); +} + +cleanup(); +process.exit(0); diff --git a/agent/nanoclaw/shim/lib/configure-llm.mjs b/agent/nanoclaw/shim/lib/configure-llm.mjs new file mode 100644 index 00000000..7cf9c31d --- /dev/null +++ b/agent/nanoclaw/shim/lib/configure-llm.mjs @@ -0,0 +1,120 @@ +// configure-llm.mjs — route NanoClaw's LLM traffic through the Claworc LLM +// proxy (docs/shim.md). Reads the generic routing document on stdin. +// +// NanoClaw's agent-runner talks to Anthropic through the Claude Agent SDK, +// which honors ANTHROPIC_BASE_URL + ANTHROPIC_AUTH_TOKEN — the exact wiring +// upstream NanoClaw's own setup uses (src/providers/claude.ts). This shim +// stores the routing state in one fully-managed JSON file +// (/llm.json); the svc-agent supervisor injects the env pair into +// every agent-runner it spawns, and the default model is written into the +// agent's container.json (NanoClaw's real per-group config file). Both +// writes are deterministic full rewrites — idempotent by construction. +// +// Virtual-key selection: the provider whose dialect is Anthropic (api_type +// mentioning "anthropic", else key "anthropic", else the first entry), per +// the declared llm.styles ["anthropic"]. +import fs from 'node:fs'; +import path from 'node:path'; + +import { AGENT_DIR, LLM_CONFIG_PATH, STATE_DIR, jsonError } from './shimlib.mjs'; + +function fail(msg) { + jsonError(msg); + process.exit(6); +} + +let doc; +try { + doc = JSON.parse((await Bun.stdin.text()).toString()); +} catch (err) { + fail(`invalid JSON routing document: ${err.message}`); +} +if (typeof doc !== 'object' || doc === null || Array.isArray(doc)) { + fail('routing document must be a JSON object'); +} + +const style = doc.style || 'anthropic'; +if (style !== 'anthropic') { + fail(`unsupported llm style '${style}': this image only speaks the anthropic dialect`); +} + +const providers = doc.providers ?? []; +if (!Array.isArray(providers)) fail('providers must be an array'); +const proxyUrl = doc.proxy_url || ''; +if (providers.length > 0 && !proxyUrl) fail('proxy_url is required when providers are present'); + +let provider = null; +if (providers.length > 0) { + provider = + providers.find((p) => p && typeof p.api_type === 'string' && p.api_type.includes('anthropic')) || + providers.find((p) => p && p.key === 'anthropic') || + providers[0]; + if (typeof provider !== 'object' || provider === null) fail('providers entries must be objects'); +} + +const defaultModel = doc.default_model || ''; +const fallbackModels = (doc.fallback_models || []).filter((m) => typeof m === 'string' && m); + +fs.mkdirSync(STATE_DIR, { recursive: true }); +fs.mkdirSync(AGENT_DIR, { recursive: true }); + +function writeAtomic(file, content) { + const tmp = `${file}.tmp-${process.pid}`; + fs.writeFileSync(tmp, content, { mode: 0o644 }); + fs.renameSync(tmp, file); +} + +if (!proxyUrl) { + // Routing removed — drop the managed state so runners fall back to + // whatever ambient credentials exist (normally none). Idempotent. + fs.rmSync(LLM_CONFIG_PATH, { force: true }); +} else { + // Fully-managed file: deterministic key order, whole-file rewrite. + writeAtomic( + LLM_CONFIG_PATH, + JSON.stringify( + { + _comment: 'Managed by the Claworc shim configure-llm verb - do not edit.', + style: 'anthropic', + proxy_url: proxyUrl, + api_key: provider?.api_key || '', + default_model: defaultModel, + fallback_models: fallbackModels, + }, + null, + 2, + ) + '\n', + ); +} + +// Default model lands in NanoClaw's own per-group config (container.json, +// read by the agent-runner at startup). Only the managed keys are rewritten; +// user-added keys (mcpServers, assistantName, ...) are preserved. +const containerJsonPath = path.join(AGENT_DIR, 'container.json'); +let cfg = {}; +try { + cfg = JSON.parse(fs.readFileSync(containerJsonPath, 'utf8')); + if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) cfg = {}; +} catch { + // No config yet (first boot runs configure-llm before init-agent-seed): + // start from the image's baked defaults so their non-managed keys + // (maxMessagesPerPrompt, mcpServers) aren't lost. + try { + cfg = JSON.parse(fs.readFileSync('/defaults/container.json', 'utf8')); + if (typeof cfg !== 'object' || cfg === null || Array.isArray(cfg)) cfg = {}; + } catch { + cfg = {}; + } +} +cfg.provider = 'claude'; +if (defaultModel) { + // Model ids are passed through as-is; the Claworc LLM proxy owns the + // mapping from routing-document ids to upstream models. + cfg.model = defaultModel; +} else { + delete cfg.model; +} +if (!cfg.assistantName) cfg.assistantName = 'NanoClaw'; +writeAtomic(containerJsonPath, JSON.stringify(cfg, null, 2) + '\n'); + +process.exit(0); diff --git a/agent/nanoclaw/shim/lib/host.mjs b/agent/nanoclaw/shim/lib/host.mjs new file mode 100644 index 00000000..ab5d36da --- /dev/null +++ b/agent/nanoclaw/shim/lib/host.mjs @@ -0,0 +1,172 @@ +// host.mjs — the shim-side replacement for the NanoClaw host process. +// +// Upstream NanoClaw's host (src/index.ts) does channels + routing + spawning +// one Docker container per session. In a Claworc instance the container IS +// the sandbox, and Claworc itself is the only channel, so this supervisor +// keeps just the one host responsibility that matters here: make sure an +// agent-runner child process is alive for every session that has pending +// work, and reap idle ones. All message IO stays exactly on NanoClaw's +// documented host<->runner contract (the per-session inbound.db/outbound.db +// SQLite pair, docs/db-session.md in the NanoClaw repo). +// +// Run by s6 (svc-agent) as the claworc user; logs to /var/log/claworc/agent.log +// via the service's redirect. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import { + AGENT_DIR, + HOST_HEARTBEAT, + RUN_DIR, + SESSIONS_DIR, + llmEnv, + runnerPidFile, + sessionHasDueWork, + sleep, +} from './shimlib.mjs'; + +const BUN = process.env.CLAWORC_SHIM_BUN || '/usr/local/bin/bun'; +const RUNNER_ENTRY = '/app/src/index.ts'; +const RUNNER_CWD = '/workspace/agent'; // symlink to AGENT_DIR (upstream path) +const TICK_MS = 500; +// Keep a warm runner (and its Claude Agent SDK subprocess) around between +// turns; upstream keeps containers warm and reaps them with a host sweep. +const IDLE_MS = parseInt(process.env.CLAWORC_NANOCLAW_RUNNER_IDLE_MS || '', 10) || 10 * 60 * 1000; + +function log(msg) { + process.stderr.write(`[shim-host] ${new Date().toISOString()} ${msg}\n`); +} + +/** key -> { proc, lastActive } */ +const runners = new Map(); + +function touchHeartbeat() { + const now = new Date(); + try { + fs.utimesSync(HOST_HEARTBEAT, now, now); + } catch { + try { + fs.writeFileSync(HOST_HEARTBEAT, ''); + } catch { + /* /run dir missing — init-setup creates it; retry next tick */ + } + } +} + +function spawnRunner(key, dir) { + const env = { + ...process.env, + ...llmEnv(), + HOME: process.env.HOME || '/home/claworc', + NANOCLAW_SESSION_DIR: dir, + }; + const proc = spawn(BUN, ['run', RUNNER_ENTRY], { + cwd: RUNNER_CWD, + env, + stdio: ['ignore', 'inherit', 'inherit'], + }); + const entry = { proc, lastActive: Date.now() }; + runners.set(key, entry); + try { + fs.writeFileSync(runnerPidFile(key), String(proc.pid)); + } catch { + /* non-fatal: chat-abort just won't find the pid */ + } + log(`runner spawned for session '${key}' (pid ${proc.pid})`); + proc.on('exit', (code, signal) => { + if (runners.get(key) === entry) runners.delete(key); + try { + fs.rmSync(runnerPidFile(key), { force: true }); + } catch { + /* ignore */ + } + log(`runner for session '${key}' exited (code=${code} signal=${signal})`); + }); + proc.on('error', (err) => { + if (runners.get(key) === entry) runners.delete(key); + log(`runner spawn error for session '${key}': ${err.message}`); + }); +} + +function stopRunner(key, reason) { + const entry = runners.get(key); + if (!entry) return; + log(`stopping runner for session '${key}' (${reason})`); + try { + entry.proc.kill('SIGTERM'); + } catch { + /* already gone */ + } +} + +function tick() { + touchHeartbeat(); + + let keys = []; + try { + keys = fs + .readdirSync(SESSIONS_DIR, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name); + } catch { + return; // sessions dir not created yet + } + + for (const key of keys) { + const dir = path.join(SESSIONS_DIR, key); + if (!fs.existsSync(path.join(dir, 'inbound.db'))) continue; + let due = false; + try { + due = sessionHasDueWork(dir); + } catch (err) { + log(`due-check failed for '${key}': ${err.message}`); + continue; + } + const entry = runners.get(key); + if (due) { + if (entry) { + entry.lastActive = Date.now(); + } else { + spawnRunner(key, dir); + } + } else if (entry && Date.now() - entry.lastActive > IDLE_MS) { + stopRunner(key, `idle ${Math.round(IDLE_MS / 1000)}s`); + } + } +} + +async function main() { + fs.mkdirSync(SESSIONS_DIR, { recursive: true }); + fs.mkdirSync(AGENT_DIR, { recursive: true }); + try { + fs.mkdirSync(RUN_DIR, { recursive: true }); + } catch { + /* created by init-setup as root; claworc may not own the parent */ + } + + let shuttingDown = false; + const shutdown = (sig) => { + if (shuttingDown) return; + shuttingDown = true; + log(`received ${sig}, stopping ${runners.size} runner(s)`); + for (const key of runners.keys()) stopRunner(key, 'service shutdown'); + // Give runners a moment to finalize outbound.db writes, then exit; s6 + // escalates to SIGKILL on its own timeout if we hang. + setTimeout(() => process.exit(0), 3000); + }; + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + + log(`supervising NanoClaw agent-runners (sessions: ${SESSIONS_DIR}, idle reap: ${IDLE_MS}ms)`); + while (!shuttingDown) { + try { + tick(); + } catch (err) { + log(`tick error: ${err.message}`); + } + await sleep(TICK_MS); + } +} + +main(); diff --git a/agent/nanoclaw/shim/lib/session-reset.mjs b/agent/nanoclaw/shim/lib/session-reset.mjs new file mode 100644 index 00000000..7c4722f1 --- /dev/null +++ b/agent/nanoclaw/shim/lib/session-reset.mjs @@ -0,0 +1,52 @@ +// session-reset.mjs — clear conversation history for a Claworc session key +// (docs/shim.md). Idempotent. +// +// A NanoClaw session's entire conversational state lives in its session +// directory: the inbound/outbound DB pair, including the SDK continuation id +// in outbound.db's session_state. Killing the runner and deleting the +// directory gives the next chat-send a completely fresh session (equivalent +// to upstream's /clear plus a new DB pair). The shared agent workspace +// (files + long-term memory under /home/claworc/workspace) is intentionally +// NOT touched — that is agent-level state shared by every session, exactly as +// in upstream NanoClaw. +import fs from 'node:fs'; + +import { pidAlive, readPidFile, runnerPidFile, sessionDir, sleep } from './shimlib.mjs'; + +let session = ''; +{ + const args = process.argv.slice(2); + for (let i = 0; i < args.length; i++) { + if (args[i] === '--session') session = args[++i] ?? ''; + else { + process.stderr.write(`unknown argument: ${args[i]}\n`); + process.exit(2); + } + } +} +if (!session) { + process.stderr.write('--session is required\n'); + process.exit(2); +} + +const pid = readPidFile(runnerPidFile(session)); +if (pidAlive(pid)) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + /* gone */ + } + // Give it a moment to release its DB handles before we delete the files. + for (let i = 0; i < 20 && pidAlive(pid); i++) await sleep(100); + if (pidAlive(pid)) { + try { + process.kill(pid, 'SIGKILL'); + } catch { + /* gone */ + } + } +} + +fs.rmSync(sessionDir(session), { recursive: true, force: true }); +fs.rmSync(runnerPidFile(session), { force: true }); +process.exit(0); diff --git a/agent/nanoclaw/shim/lib/shimlib.mjs b/agent/nanoclaw/shim/lib/shimlib.mjs new file mode 100644 index 00000000..1bcd7535 --- /dev/null +++ b/agent/nanoclaw/shim/lib/shimlib.mjs @@ -0,0 +1,344 @@ +// shimlib.mjs — shared helpers for the NanoClaw Claworc shim (docs/shim.md). +// +// Runs under Bun (bun:sqlite is the same SQLite driver the NanoClaw +// agent-runner itself uses — no extra dependency). The shim plays the role of +// the upstream NanoClaw *host* process: it owns inbound.db (writes user +// messages, reads nothing the container owns) and reads outbound.db +// (messages_out + processing_ack, written by the agent-runner). The +// single-writer-per-file rule from NanoClaw's docs/db.md is preserved. +import { Database } from 'bun:sqlite'; +import fs from 'node:fs'; +import path from 'node:path'; + +// Schemas are imported from the pinned NanoClaw source tree so they can never +// drift from the agent-runner's expectations across NANOCLAW_VERSION bumps. +import { INBOUND_SCHEMA, OUTBOUND_SCHEMA } from '/opt/nanoclaw/src/db/schema.ts'; + +export const STATE_DIR = process.env.CLAWORC_SHIM_STATE_DIR || '/home/claworc/.claworc/shim/nanoclaw'; +export const RUN_DIR = process.env.CLAWORC_SHIM_RUN_DIR || '/run/claworc/shim'; +export const SESSIONS_DIR = path.join(STATE_DIR, 'sessions'); +// Real, user-visible agent workspace. /workspace/agent symlinks here so the +// unpatched upstream paths in CLAUDE.md / config.ts keep working. +export const AGENT_DIR = process.env.CLAWORC_NANOCLAW_AGENT_DIR || '/home/claworc/workspace'; +export const LLM_CONFIG_PATH = path.join(STATE_DIR, 'llm.json'); + +// Opaque Claworc session keys become file names — same sanitizer as the +// template shim (tr -c 'A-Za-z0-9._-' '_'). +export function sanitizeKey(key) { + return key.replace(/[^A-Za-z0-9._-]/g, '_'); +} + +export function sessionDir(key) { + return path.join(SESSIONS_DIR, sanitizeKey(key)); +} + +export function runnerPidFile(key) { + return path.join(RUN_DIR, `runner-${sanitizeKey(key)}.pid`); +} + +export function chatPidFile(key) { + return path.join(RUN_DIR, `chat-${sanitizeKey(key)}.pid`); +} + +export const HOST_HEARTBEAT = path.join(RUN_DIR, 'host.heartbeat'); + +export function pidAlive(pid) { + if (!pid || !Number.isFinite(pid)) return false; + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } +} + +export function readPidFile(file) { + try { + return parseInt(fs.readFileSync(file, 'utf8').trim(), 10); + } catch { + return null; + } +} + +export function mtimeAgeMs(file) { + try { + return Date.now() - fs.statSync(file).mtimeMs; + } catch { + return Infinity; + } +} + +// --- session DB pair ------------------------------------------------------- + +/** + * Ensure the session directory exists with both DB files at current schema, + * plus the destination map and routing row the agent-runner expects the host + * to have written. Idempotent (CREATE IF NOT EXISTS / upserts). + */ +export function ensureSession(key) { + const dir = sessionDir(key); + fs.mkdirSync(dir, { recursive: true }); + + const inbound = new Database(path.join(dir, 'inbound.db')); + try { + // NanoClaw's cross-mount invariant (journal DELETE, not WAL) is kept even + // though everything is a local FS here — the runner sets the same pragma. + inbound.exec('PRAGMA journal_mode = DELETE'); + inbound.exec('PRAGMA busy_timeout = 5000'); + inbound.exec(INBOUND_SCHEMA); + // Destination map: one channel called "user" — the Claworc chat surface. + // The runtime system prompt lists it, so the agent addresses replies with + // and mcp send_message(to="user"). + inbound + .prepare( + `INSERT INTO destinations (name, display_name, type, channel_type, platform_id, agent_group_id) + VALUES ('user', 'User', 'channel', 'claworc', $pid, NULL) + ON CONFLICT(name) DO UPDATE SET platform_id = excluded.platform_id`, + ) + .run({ $pid: key }); + inbound + .prepare( + `INSERT INTO session_routing (id, channel_type, platform_id, thread_id) + VALUES (1, 'claworc', $pid, NULL) + ON CONFLICT(id) DO UPDATE SET channel_type = excluded.channel_type, + platform_id = excluded.platform_id, thread_id = excluded.thread_id`, + ) + .run({ $pid: key }); + } finally { + inbound.close(); + } + + const outbound = new Database(path.join(dir, 'outbound.db')); + try { + outbound.exec('PRAGMA journal_mode = DELETE'); + outbound.exec('PRAGMA busy_timeout = 5000'); + outbound.exec(OUTBOUND_SCHEMA); + } finally { + outbound.close(); + } + return dir; +} + +export function openInboundRw(dir) { + const db = new Database(path.join(dir, 'inbound.db')); + db.exec('PRAGMA journal_mode = DELETE'); + db.exec('PRAGMA busy_timeout = 5000'); + return db; +} + +export function openInboundRo(dir) { + const db = new Database(path.join(dir, 'inbound.db'), { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +export function openOutboundRo(dir) { + const db = new Database(path.join(dir, 'outbound.db'), { readonly: true }); + db.exec('PRAGMA busy_timeout = 5000'); + db.exec('PRAGMA mmap_size = 0'); + return db; +} + +/** Host-side even seq (NanoClaw invariant: host even, container odd). */ +export function nextEvenSeq(db) { + const { m } = db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_in').get(); + return m < 2 ? 2 : m + 2 - (m % 2); +} + +export function generateId(prefix = 'msg') { + return `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** Insert one user chat message; returns the row id. */ +export function insertUserMessage(dir, key, text) { + const db = openInboundRw(dir); + try { + const id = generateId(); + db.prepare( + `INSERT INTO messages_in (id, seq, kind, timestamp, status, trigger, platform_id, channel_type, thread_id, content) + VALUES ($id, $seq, 'chat', $ts, 'pending', 1, $pid, 'claworc', NULL, $content)`, + ).run({ + $id: id, + $seq: nextEvenSeq(db), + $ts: new Date().toISOString(), + $pid: key, + $content: JSON.stringify({ sender: 'User', senderId: 'claworc-user', text, isFromMe: false }), + }); + return id; + } finally { + db.close(); + } +} + +/** + * Mark inbound rows terminally handled so a (re)spawned runner never + * re-processes them (used on abort). We own inbound.db, so this is the + * host-legal way to retire rows — the container only reads this file. + */ +export function retireInboundMessages(dir, ids) { + if (!ids.length) return; + const db = openInboundRw(dir); + try { + const stmt = db.prepare(`UPDATE messages_in SET status = 'completed' WHERE id = ?`); + for (const id of ids) stmt.run(id); + } finally { + db.close(); + } +} + +/** processing_ack status for a message id: null | processing | completed | failed | script-skip:error */ +export function ackStatus(dir, id) { + let db; + try { + db = openOutboundRo(dir); + } catch { + return null; + } + try { + const row = db.prepare('SELECT status FROM processing_ack WHERE message_id = ?').get(id); + return row ? row.status : null; + } catch { + return null; + } finally { + db.close(); + } +} + +/** All messages_out rows with seq > afterSeq, ordered. */ +export function outboundRowsAfter(dir, afterSeq) { + let db; + try { + db = openOutboundRo(dir); + } catch { + return []; + } + try { + return db + .prepare('SELECT id, seq, kind, content FROM messages_out WHERE seq > ? ORDER BY seq ASC') + .all(afterSeq); + } catch { + return []; + } finally { + db.close(); + } +} + +export function maxOutboundSeq(dir) { + let db; + try { + db = openOutboundRo(dir); + } catch { + return 0; + } + try { + const { m } = db.prepare('SELECT COALESCE(MAX(seq), 0) AS m FROM messages_out').get(); + return m; + } catch { + return 0; + } finally { + db.close(); + } +} + +/** Extract user-facing text from a messages_out content JSON (chat kinds). */ +export function outboundText(row) { + if (row.kind !== 'chat' && row.kind !== 'chat-sdk') return null; + try { + const c = JSON.parse(row.content); + const t = c.text ?? c.markdown ?? c.fallbackText; + return typeof t === 'string' && t.length > 0 ? t : null; + } catch { + return null; + } +} + +/** + * Sessions with work for the agent: any pending, due, wake-eligible inbound + * row whose processing_ack is not terminal. A row stuck in 'processing' by a + * dead runner counts as due — the respawned runner clears stale acks and + * re-processes it (upstream crash-recovery semantics). + */ +export function sessionHasDueWork(dir) { + let inbound; + try { + inbound = openInboundRo(dir); + } catch { + return false; + } + let pending; + try { + pending = inbound + .prepare( + `SELECT id FROM messages_in + WHERE status = 'pending' AND trigger = 1 + AND (process_after IS NULL OR datetime(process_after) <= datetime('now'))`, + ) + .all(); + } catch { + return false; + } finally { + inbound.close(); + } + if (pending.length === 0) return false; + let outbound; + try { + outbound = openOutboundRo(dir); + } catch { + return true; // no outbound.db yet -> nothing acked -> due + } + try { + const acked = new Set( + outbound + .prepare(`SELECT message_id FROM processing_ack WHERE status IN ('completed', 'failed', 'script-skip:error')`) + .all() + .map((r) => r.message_id), + ); + return pending.some((r) => !acked.has(r.id)); + } catch { + return true; + } finally { + outbound.close(); + } +} + +// --- LLM routing state ----------------------------------------------------- + +/** Read the configure-llm state written by lib/configure-llm.mjs. */ +export function readLlmConfig() { + try { + return JSON.parse(fs.readFileSync(LLM_CONFIG_PATH, 'utf8')); + } catch { + return null; + } +} + +/** Env block for agent-runner children derived from the routing document. */ +export function llmEnv() { + const cfg = readLlmConfig(); + if (!cfg || !cfg.proxy_url) return {}; + return { + // NanoClaw's native Claude wiring: the Agent SDK honors these two. + ANTHROPIC_BASE_URL: cfg.proxy_url, + ANTHROPIC_AUTH_TOKEN: cfg.api_key || 'placeholder', + // Bound the Claude Code CLI's API retry loop. Its default policy retries + // even hard auth/connection failures for several minutes, which turns a + // misconfigured virtual key into a multi-minute silent hang per turn. + // Transient proxy blips still get a few attempts. + CLAUDE_CODE_MAX_RETRIES: process.env.CLAWORC_NANOCLAW_MAX_RETRIES || '3', + }; +} + +// --- chat event emission --------------------------------------------------- + +export function emit(event) { + process.stdout.write(JSON.stringify(event) + '\n'); +} + +export function jsonError(msg) { + process.stdout.write(JSON.stringify({ error: msg }) + '\n'); +} + +export function sleep(ms) { + return new Promise((r) => setTimeout(r, ms)); +} diff --git a/agent/nanoclaw/shim/meta b/agent/nanoclaw/shim/meta new file mode 100644 index 00000000..f84eac49 --- /dev/null +++ b/agent/nanoclaw/shim/meta @@ -0,0 +1,43 @@ +#!/bin/sh +# meta — Claworc agent shim capability probe (docs/shim.md, contract v1). +# Static heredoc; the NanoClaw version is baked into the image at build time +# (agent/nanoclaw/Dockerfile writes /opt/claworc/shim/nanoclaw.version from +# ARG NANOCLAW_VERSION). Must stay fast and must not require the agent to be +# running. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +VERSION="" +[ -s "$SHIM_DIR/nanoclaw.version" ] && VERSION=$(head -n1 "$SHIM_DIR/nanoclaw.version" | tr -cd '0-9A-Za-z.-') + +if [ -n "$VERSION" ]; then + AGENT_JSON=$(printf '{"name":"nanoclaw","version":"%s"}' "$VERSION") +else + AGENT_JSON='{"name":"nanoclaw"}' +fi + +# Notes: +# - config: container.json is NanoClaw's real per-agent-group config file +# (provider, model, assistantName, mcpServers, maxMessagesPerPrompt); the +# agent-runner reads it at startup, hence restart_required. +# - chat_end_detection "exact": end-of-turn is the runner's own terminal +# processing_ack row, not a quiet-period heuristic. +# - session_persistence "native": each Claworc session key maps to its own +# NanoClaw session-DB pair; the Claude Agent SDK continuation id stored in +# its session_state survives runner and instance restarts. +cat <&2 + exit 1 +fi +exec /command/s6-svc -r "$SVC_DIR" diff --git a/agent/nanoclaw/shim/session-reset b/agent/nanoclaw/shim/session-reset new file mode 100644 index 00000000..4df624c2 --- /dev/null +++ b/agent/nanoclaw/shim/session-reset @@ -0,0 +1,15 @@ +#!/bin/sh +# session-reset — Claworc shim verb (docs/shim.md). Thin wrapper: verbs are invoked +# as root over SSH; agent state must stay claworc-owned, so drop privileges +# and exec the Bun implementation in lib/. Bun is used because bun:sqlite is +# the same SQLite driver NanoClaw's own agent-runner uses — no dependency of +# our own. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +BUN=${CLAWORC_SHIM_BUN:-/usr/local/bin/bun} + +if [ "$(id -u)" = "0" ] && [ -x /command/s6-setuidgid ]; then + exec env HOME=/home/claworc /command/s6-setuidgid claworc "$BUN" "$SHIM_DIR/lib/session-reset.mjs" "$@" +fi +exec "$BUN" "$SHIM_DIR/lib/session-reset.mjs" "$@" diff --git a/agent/nanoclaw/shim/shim-selftest b/agent/nanoclaw/shim/shim-selftest new file mode 100755 index 00000000..e02c7fab --- /dev/null +++ b/agent/nanoclaw/shim/shim-selftest @@ -0,0 +1,301 @@ +#!/bin/sh +# shim-selftest — conformance check for the Claworc Agent Shim Contract v1 +# (docs/shim.md). Runnable inside any agent image, or against a shim +# directory in the repo: +# +# shim-selftest [shim-dir] # default: /opt/claworc/shim +# docker run --rm my-agent-image /opt/claworc/shim/shim-selftest +# +# Exercises: identity files, meta (JSON + required fields + verb presence per +# capability), health exit codes, chat-send JSONL well-formedness, the config +# round-trip, and configure-llm idempotency. Exits non-zero with a per-check +# report when anything fails. +# +# NOTE: the config and configure-llm checks MUTATE agent configuration +# (config-set writes the current bytes back; configure-llm applies a sample +# routing document twice). Run in a throwaway container/CI, or pass +# --skip-mutating. +set -u + +SHIM_DIR=/opt/claworc/shim +SKIP_MUTATING=0 +for arg in "$@"; do + case "$arg" in + --skip-mutating) SKIP_MUTATING=1 ;; + -*) echo "usage: shim-selftest [--skip-mutating] [shim-dir]" >&2; exit 2 ;; + *) SHIM_DIR=$arg ;; + esac +done + +PASS=0 +FAIL=0 +SKIP=0 +pass() { PASS=$((PASS + 1)); printf '[PASS] %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); printf '[FAIL] %s\n' "$1"; } +skip() { SKIP=$((SKIP + 1)); printf '[SKIP] %s\n' "$1"; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# --- JSON helpers (jq preferred, python3 fallback) -------------------------- +if command -v jq >/dev/null 2>&1; then + JSON_TOOL=jq +elif command -v python3 >/dev/null 2>&1; then + JSON_TOOL=python3 +else + echo "shim-selftest needs jq or python3 for JSON validation" >&2 + exit 2 +fi + +json_valid() { # json_valid + if [ "$JSON_TOOL" = jq ]; then jq -e . "$1" >/dev/null 2>&1 + else python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$1" >/dev/null 2>&1 + fi +} + +json_query() { # json_query — prints result or "None" + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +try: + print(eval(sys.argv[2], {"d": d})) +except Exception: + print("None") +' "$1" "$2" 2>/dev/null +} + +if [ "$JSON_TOOL" = jq ] && ! command -v python3 >/dev/null 2>&1; then + # json_query needs python3; degrade to jq-only equivalents where used. + json_query() { + case "$2" in + 'd["contract"]') jq -r '.contract' "$1" 2>/dev/null ;; + '"chat" in d.get("capabilities", [])') jq -r '.capabilities | contains(["chat"])' "$1" 2>/dev/null | sed 's/true/True/; s/false/False/' ;; + 'isinstance(d.get("capabilities"), list)') jq -r '.capabilities | type == "array"' "$1" 2>/dev/null | sed 's/true/True/; s/false/False/' ;; + '",".join(d.get("capabilities", []))') jq -r '.capabilities | join(",")' "$1" 2>/dev/null ;; + 'd.get("llm", {}).get("styles", ["openai"])[0]') jq -r '.llm.styles[0] // "openai"' "$1" 2>/dev/null ;; + *) echo "None" ;; + esac + } +fi + +run_timeout() { # run_timeout + if command -v timeout >/dev/null 2>&1; then + t=$1; shift + timeout "$t" "$@" + else + shift + "$@" + fi +} + +# --- 1. identity files ------------------------------------------------------ +if [ -s "$SHIM_DIR/agent.txt" ] && \ + [ "$(wc -l < "$SHIM_DIR/agent.txt" | tr -d ' ')" -le 1 ] && \ + [ -n "$(head -n1 "$SHIM_DIR/agent.txt")" ]; then + pass "agent.txt: present, single line ($(head -n1 "$SHIM_DIR/agent.txt"))" +else + fail "agent.txt: missing, empty, or multi-line" +fi +if [ -s "$SHIM_DIR/agent.svg" ]; then + pass "agent.svg: present" +else + fail "agent.svg: missing or empty" +fi + +# --- 2. meta ---------------------------------------------------------------- +META="$WORK/meta.json" +if [ ! -x "$SHIM_DIR/meta" ]; then + fail "meta: not executable" + echo "shim-selftest: cannot continue without meta" + exit 1 +fi +if run_timeout 30 "$SHIM_DIR/meta" > "$META" 2> "$WORK/meta.err"; then + pass "meta: exit 0" +else + fail "meta: exited non-zero ($(head -c 200 "$WORK/meta.err"))" +fi +if json_valid "$META"; then + pass "meta: stdout is valid JSON" +else + fail "meta: stdout is not valid JSON" +fi +CONTRACT=$(json_query "$META" 'd["contract"]') +if [ "$CONTRACT" = "1" ]; then + pass "meta: contract=1" +else + fail "meta: contract must be the integer 1, got '$CONTRACT'" +fi +if [ "$(json_query "$META" 'isinstance(d.get("capabilities"), list)')" = "True" ]; then + pass "meta: capabilities is an array" +else + fail "meta: capabilities missing or not an array" +fi +if [ "$(json_query "$META" '"chat" in d.get("capabilities", [])')" = "True" ]; then + pass "meta: capabilities include required \"chat\"" +else + fail "meta: capabilities must include \"chat\"" +fi + +CAPS=$(json_query "$META" '",".join(d.get("capabilities", []))') +[ "$CAPS" = "None" ] && CAPS="" +has_cap() { printf ',%s,' "$CAPS" | grep -q ",$1,"; } + +# Verb executables required by declared capabilities. +check_verb() { # check_verb + if [ -x "$SHIM_DIR/$1" ]; then + pass "verb $1: executable" + elif [ "$2" = 1 ]; then + fail "verb $1: missing/not executable but required ($3)" + else + skip "verb $1: absent (capability not declared)" + fi +} +check_verb health 1 "always required" +check_verb chat-send 1 "chat capability is mandatory" +check_verb chat-abort "$(has_cap chat.abort && echo 1 || echo 0)" "chat.abort declared" +check_verb session-reset "$(has_cap session.reset && echo 1 || echo 0)" "session.reset declared" +check_verb config-get "$(has_cap config && echo 1 || echo 0)" "config declared" +check_verb config-set "$(has_cap config && echo 1 || echo 0)" "config declared" +check_verb configure-llm "$(has_cap configure-llm && echo 1 || echo 0)" "configure-llm declared" +check_verb restart "$(has_cap restart && echo 1 || echo 0)" "restart declared" + +# --- 3. health -------------------------------------------------------------- +HEALTH_RC=0 +run_timeout 30 "$SHIM_DIR/health" > "$WORK/health.out" 2> "$WORK/health.err" || HEALTH_RC=$? +case "$HEALTH_RC" in + 0) pass "health: exit 0 (ready)" ;; + 4) pass "health: exit 4 (agent booting — contract-legal)" ;; + 1|5) pass "health: exit $HEALTH_RC (broken/timeout — contract-legal code)" ;; + *) fail "health: exit $HEALTH_RC is outside the contract's code set {0,1,4,5}" ;; +esac +if [ -s "$WORK/health.out" ] && ! json_valid "$WORK/health.out"; then + fail "health: stdout present but not valid JSON" +fi + +# --- 4. chat-send JSONL ----------------------------------------------------- +if [ "$HEALTH_RC" -ne 0 ]; then + skip "chat-send: agent not ready (health exit $HEALTH_RC)" +else + CHAT_OUT="$WORK/chat.jsonl" + CHAT_RC=0 + printf 'shim-selftest ping: reply with anything.' | \ + run_timeout 180 "$SHIM_DIR/chat-send" --session shim-selftest --turn t-selftest \ + > "$CHAT_OUT" 2> "$WORK/chat.err" || CHAT_RC=$? + if [ "$CHAT_RC" -eq 0 ]; then + pass "chat-send: exit 0" + else + fail "chat-send: exit $CHAT_RC ($(head -c 200 "$WORK/chat.err"))" + fi + if [ ! -s "$CHAT_OUT" ]; then + fail "chat-send: produced no output" + else + BAD_LINES=0 + TOTAL_LINES=0 + while IFS= read -r line; do + [ -n "$line" ] || continue + TOTAL_LINES=$((TOTAL_LINES + 1)) + printf '%s' "$line" > "$WORK/line.json" + json_valid "$WORK/line.json" || BAD_LINES=$((BAD_LINES + 1)) + done < "$CHAT_OUT" + if [ "$BAD_LINES" -eq 0 ]; then + pass "chat-send: all $TOTAL_LINES JSONL lines parse" + else + fail "chat-send: $BAD_LINES of $TOTAL_LINES lines are not valid JSON" + fi + ENDS=$(grep -c '"event":"end"' "$CHAT_OUT" || true) + if [ "$ENDS" = "1" ]; then + pass "chat-send: exactly one end event" + else + fail "chat-send: expected exactly one end event, got $ENDS" + fi + tail -n1 "$CHAT_OUT" > "$WORK/last.json" + if [ "$(json_query "$WORK/last.json" 'd.get("event")')" = "end" ] && \ + [ "$(json_query "$WORK/last.json" 'd.get("stop_reason") in ("complete","aborted","error")')" = "True" ]; then + pass "chat-send: last line is end with a legal stop_reason" + else + fail "chat-send: last line must be the end event with stop_reason complete|aborted|error" + fi + if grep -q '"event":"start"' "$CHAT_OUT"; then + pass "chat-send: start event present" + else + fail "chat-send: no start event emitted" + fi + fi +fi + +# --- 5. config round-trip --------------------------------------------------- +if ! has_cap config; then + skip "config round-trip: config capability not declared" +elif [ "$SKIP_MUTATING" = 1 ]; then + skip "config round-trip: --skip-mutating" +else + RC=0 + "$SHIM_DIR/config-get" > "$WORK/cfg.before" 2> "$WORK/cfg.err" || RC=$? + if [ "$RC" -ne 0 ]; then + fail "config-get: exit $RC ($(head -c 200 "$WORK/cfg.err"))" + else + RC=0 + "$SHIM_DIR/config-set" < "$WORK/cfg.before" > "$WORK/cfgset.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "config-set: exit $RC writing back unmodified config ($(head -c 200 "$WORK/cfgset.out"))" + else + "$SHIM_DIR/config-get" > "$WORK/cfg.after" 2>/dev/null || true + if cmp -s "$WORK/cfg.before" "$WORK/cfg.after"; then + pass "config: get -> set -> get round-trips byte-identical" + else + fail "config: round-trip altered the file" + fi + fi + fi +fi + +# --- 6. configure-llm idempotency ------------------------------------------- +if ! has_cap configure-llm; then + skip "configure-llm: capability not declared" +elif [ "$SKIP_MUTATING" = 1 ]; then + skip "configure-llm: --skip-mutating" +else + STYLE=$(json_query "$META" 'd.get("llm", {}).get("styles", ["openai"])[0]') + [ "$STYLE" = "None" ] && STYLE=openai + cat > "$WORK/routing.json" < "$WORK/llm1.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "configure-llm: first run exit $RC ($(head -c 200 "$WORK/llm1.out"))" + else + pass "configure-llm: first run exit 0" + if has_cap config; then + "$SHIM_DIR/config-get" > "$WORK/llm.state1" 2>/dev/null || true + fi + RC=0 + "$SHIM_DIR/configure-llm" < "$WORK/routing.json" > "$WORK/llm2.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "configure-llm: second run exit $RC (must be idempotent)" + elif has_cap config; then + "$SHIM_DIR/config-get" > "$WORK/llm.state2" 2>/dev/null || true + if cmp -s "$WORK/llm.state1" "$WORK/llm.state2"; then + pass "configure-llm: idempotent (config identical after second run)" + else + fail "configure-llm: second run changed the config again (not idempotent)" + fi + else + pass "configure-llm: second run exit 0 (no config verb to diff state)" + fi + fi +fi + +# --- report ----------------------------------------------------------------- +printf '\nshim-selftest: %d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/agent/openclaw/.dockerignore b/agent/openclaw/.dockerignore new file mode 100644 index 00000000..776e92ab --- /dev/null +++ b/agent/openclaw/.dockerignore @@ -0,0 +1,8 @@ +tests/ +*.md +!TOOLS.md +.git/ +.gitignore +.DS_Store +.idea/ +.vscode/ diff --git a/agent/instance/Dockerfile b/agent/openclaw/Dockerfile similarity index 87% rename from agent/instance/Dockerfile rename to agent/openclaw/Dockerfile index 04d118b9..5ffe2e49 100644 --- a/agent/instance/Dockerfile +++ b/agent/openclaw/Dockerfile @@ -81,13 +81,29 @@ COPY TOOLS.md /tmp/TOOLS.md COPY rootfs/ / +# Claworc agent shim (docs/shim.md): the universal exec-based interface the +# control plane invokes over SSH. Verbs must be 0755; agent.txt/agent.svg are +# static identity files read over SFTP. +COPY shim/ /opt/claworc/shim/ +RUN chmod 0755 /opt/claworc/shim/meta \ + /opt/claworc/shim/health \ + /opt/claworc/shim/chat-send \ + /opt/claworc/shim/chat-abort \ + /opt/claworc/shim/session-reset \ + /opt/claworc/shim/config-get \ + /opt/claworc/shim/config-set \ + /opt/claworc/shim/configure-llm \ + /opt/claworc/shim/restart \ + /opt/claworc/shim/lib/gateway-bridge.mjs && \ + chmod 0644 /opt/claworc/shim/agent.txt /opt/claworc/shim/agent.svg + RUN chmod +x /etc/s6-overlay/s6-rc.d/init-setup/up \ /etc/s6-overlay/s6-rc.d/init-openclaw-seed/up \ /etc/s6-overlay/s6-rc.d/init-homebrew-seed/up \ /etc/s6-overlay/scripts/init-setup.sh \ /etc/s6-overlay/scripts/init-openclaw-seed.sh \ /etc/s6-overlay/scripts/init-homebrew-seed.sh \ - /etc/s6-overlay/s6-rc.d/svc-openclaw/run \ + /etc/s6-overlay/s6-rc.d/svc-agent/run \ /etc/s6-overlay/s6-rc.d/svc-sshd/run \ /etc/s6-overlay/s6-rc.d/svc-cron/run @@ -107,7 +123,7 @@ RUN mkdir -p /var/log/claworc && chown claworc:claworc /var/log/claworc # back into /home/claworc/.openclaw on first boot if the PVC is empty. USER claworc # Gateway-* config (gateway.mode, gateway.bind, controlUi.* etc.) is set at -# runtime by svc-openclaw/run, where it can react to env vars and stay +# runtime by svc-agent/run, where it can react to env vars and stay # best-effort; baking those values here would abort the build if openclaw's # validator rejects a value (e.g. legacy `gateway.bind localhost`). Doctor # alone is enough to produce a usable skeleton config. @@ -115,7 +131,7 @@ RUN bash -c '\ set -e; \ mkdir -p /home/claworc/.openclaw; \ openclaw doctor --fix || echo "Doctor finished with exit code $?"; \ - openclaw config set logging.file /var/log/claworc/openclaw.log; \ + openclaw config set logging.file /var/log/claworc/agent.log; \ openclaw config set browser "$(cat /defaults/browser.json)" --json; \ openclaw onboard --non-interactive --accept-risk --mode local || echo "Onboard finished with exit code $?"; \ WORKSPACE=$(openclaw config get agents.defaults.workspace 2>/dev/null | tr -d "\"" | tr -d "'\''"); \ diff --git a/agent/instance/TOOLS.md b/agent/openclaw/TOOLS.md similarity index 100% rename from agent/instance/TOOLS.md rename to agent/openclaw/TOOLS.md diff --git a/agent/instance/browser.json b/agent/openclaw/browser.json similarity index 100% rename from agent/instance/browser.json rename to agent/openclaw/browser.json diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/dependencies.d/init-setup b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/dependencies.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/type b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/type @@ -0,0 +1 @@ +oneshot diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/up b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/up similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/up rename to agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-homebrew-seed/up diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/dependencies.d/init-setup b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/dependencies.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/type b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/type @@ -0,0 +1 @@ +oneshot diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/up b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/up similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/up rename to agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-openclaw-seed/up diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type @@ -0,0 +1 @@ +oneshot diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up new file mode 100644 index 00000000..88d2a0d4 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/init-setup.sh diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-openclaw-seed b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-openclaw-seed new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-setup b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/dependencies.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/run b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/run new file mode 100644 index 00000000..835cd8cd --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/run @@ -0,0 +1,76 @@ +#!/command/with-contenv bash + +export HOME=/home/claworc + +# Heavy work (`openclaw doctor --fix`, `openclaw onboard`, TOOLS.md install) +# runs at image build time; init-openclaw-seed (a oneshot that runs before +# this service) copies the baked /opt/openclaw-skeleton/.openclaw onto the +# PVC on first boot. By the time we get here, /home/claworc/.openclaw is +# already populated and the only work left is to apply env-driven config +# overrides and exec the gateway. + +# Always reaffirm gateway settings — these are baked into the skeleton too, +# but writing them here keeps a single source of truth and lets ops change +# them by editing this file without rebuilding the image. +s6-setuidgid claworc openclaw config set gateway.mode local +# Newer openclaw rejects host-alias values (e.g. `localhost`) for gateway.bind +# and demands a bind-mode keyword (lan/loopback/custom/tailnet/auto) instead. +s6-setuidgid claworc openclaw config set gateway.bind loopback +s6-setuidgid claworc openclaw config set gateway.controlUi.dangerouslyDisableDeviceAuth true + +# Set Control UI base path for reverse proxy +if [ -n "$CLAWORC_INSTANCE_ID" ]; then + s6-setuidgid claworc openclaw config set gateway.controlUi.basePath "/openclaw/${CLAWORC_INSTANCE_ID}/" +fi + +# Set token if provided. The generic CLAWORC_AGENT_TOKEN (shim contract, +# docs/shim.md) is honored when the OpenClaw-specific legacy variable is +# unset. +if [ -z "$OPENCLAW_GATEWAY_TOKEN" ] && [ -n "$CLAWORC_AGENT_TOKEN" ]; then + OPENCLAW_GATEWAY_TOKEN="$CLAWORC_AGENT_TOKEN" +fi +if [ -n "$OPENCLAW_GATEWAY_TOKEN" ]; then + s6-setuidgid claworc openclaw config set gateway.auth.token "$OPENCLAW_GATEWAY_TOKEN" +fi + +# Apply initial model and provider config passed by Claworc at container +# creation, so the gateway starts with providers already configured and +# messages arriving before ConfigureInstance runs via SSH don't hit auth +# failures. The generic CLAWORC_INITIAL_LLM_CONFIG routing document (piped +# into the shim's own configure-llm verb, docs/shim.md) is preferred; the +# legacy OpenClaw-specific variables are only consulted when it is absent. +if [ -n "$CLAWORC_INITIAL_LLM_CONFIG" ]; then + echo "Applying CLAWORC_INITIAL_LLM_CONFIG via /opt/claworc/shim/configure-llm..." + if ! printf '%s' "$CLAWORC_INITIAL_LLM_CONFIG" | /opt/claworc/shim/configure-llm; then + echo "configure-llm failed; continuing boot without initial LLM routing" >&2 + fi +else + if [ -n "$OPENCLAW_INITIAL_MODELS" ]; then + s6-setuidgid claworc openclaw config set agents.defaults.model "$OPENCLAW_INITIAL_MODELS" --json + fi + if [ -n "$OPENCLAW_INITIAL_PROVIDERS" ]; then + s6-setuidgid claworc openclaw config set models.providers "$OPENCLAW_INITIAL_PROVIDERS" --json + fi +fi + +echo "Starting OpenClaw Gateway (foreground) as claworc..." +# The gateway needs an open-but-silent stdin (it exits on stdin EOF), hence +# the `tail -f /dev/null |` prefix. Run the pipeline in the background of a +# small supervising bash that: +# - forwards TERM/INT to its whole process group, so `s6-svc -r` (the +# shim's restart verb) cleanly kills the gateway instead of orphaning it +# with port 18789 held; +# - exits when the gateway exits (e.g. `openclaw gateway stop`), letting s6 +# respawn the service. +# Output goes to the contract log path /var/log/claworc/agent.log; +# init-setup.sh symlinks the legacy openclaw.log name to it. +exec bash -c ' + tail -f /dev/null | s6-setuidgid claworc /usr/bin/openclaw gateway run >> /var/log/claworc/agent.log 2>&1 & + gw=$! + trap "kill -TERM 0 2>/dev/null" TERM INT + wait "$gw" + status=$? + # Reap the tail (and anything else in our group) before exiting. + kill -TERM 0 2>/dev/null + exit "$status" +' diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/type b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent/type @@ -0,0 +1 @@ +longrun diff --git a/agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/run b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/run similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/run rename to agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/run diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/type b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-cron/type @@ -0,0 +1 @@ +longrun diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run new file mode 100644 index 00000000..f6be5b7a --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run @@ -0,0 +1,26 @@ +#!/command/with-contenv bash + +# Remove legacy DSA/ECDSA host keys (keep only Ed25519 and RSA) +rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub +rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub + +# Generate host keys if missing (only Ed25519 and RSA will be created +# since we removed DSA/ECDSA above) +ssh-keygen -A + +# Remove any DSA/ECDSA keys that ssh-keygen -A may have regenerated +rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub +rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub + +# Create privilege-separation directory +mkdir -p /run/sshd + +# Ensure /root/.ssh directory exists with correct permissions +mkdir -p /root/.ssh +chmod 700 /root/.ssh + +echo "Starting sshd in foreground..." +# The -e flag sends sshd logs to stderr (in addition to syslog facility +# configured in claworc.conf). We redirect to the log file for SSH-based +# log streaming used by the control plane. +exec /usr/sbin/sshd -D -e >> /var/log/claworc/sshd.log 2>&1 diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type @@ -0,0 +1 @@ +longrun diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-homebrew-seed b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-homebrew-seed new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-openclaw-seed b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-openclaw-seed new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-agent b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-agent new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-cron new file mode 100644 index 00000000..e69de29b diff --git a/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd b/agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd new file mode 100644 index 00000000..e69de29b diff --git a/agent/instance/rootfs/etc/s6-overlay/scripts/init-homebrew-seed.sh b/agent/openclaw/rootfs/etc/s6-overlay/scripts/init-homebrew-seed.sh similarity index 100% rename from agent/instance/rootfs/etc/s6-overlay/scripts/init-homebrew-seed.sh rename to agent/openclaw/rootfs/etc/s6-overlay/scripts/init-homebrew-seed.sh diff --git a/agent/instance/rootfs/etc/s6-overlay/scripts/init-openclaw-seed.sh b/agent/openclaw/rootfs/etc/s6-overlay/scripts/init-openclaw-seed.sh similarity index 92% rename from agent/instance/rootfs/etc/s6-overlay/scripts/init-openclaw-seed.sh rename to agent/openclaw/rootfs/etc/s6-overlay/scripts/init-openclaw-seed.sh index 4947f538..22f268b4 100644 --- a/agent/instance/rootfs/etc/s6-overlay/scripts/init-openclaw-seed.sh +++ b/agent/openclaw/rootfs/etc/s6-overlay/scripts/init-openclaw-seed.sh @@ -4,7 +4,7 @@ # The agent image runs `openclaw doctor --fix` and `openclaw onboard` at build # time and stashes the resulting tree under /opt/openclaw-skeleton/.openclaw. # This oneshot copies it onto the (possibly-empty) PVC mounted at -# /home/claworc, so svc-openclaw can `exec openclaw gateway run` immediately +# /home/claworc, so svc-agent can `exec openclaw gateway run` immediately # instead of running doctor/onboard on every container start. # # Idempotent: if /home/claworc/.openclaw already exists (e.g. PVC carried diff --git a/agent/instance/rootfs/etc/s6-overlay/scripts/init-setup.sh b/agent/openclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh similarity index 85% rename from agent/instance/rootfs/etc/s6-overlay/scripts/init-setup.sh rename to agent/openclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh index 9f4512e3..5b9afed1 100644 --- a/agent/instance/rootfs/etc/s6-overlay/scripts/init-setup.sh +++ b/agent/openclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh @@ -27,6 +27,18 @@ fi # --------------------------------------------------------------------------- mkdir -p /var/log/claworc chmod 755 /var/log/claworc + +# Agent log convention (docs/shim.md): the agent writes +# /var/log/claworc/agent.log; the legacy openclaw.log name is kept working as +# a symlink because existing control planes tail it. If a previous boot left +# a real openclaw.log behind, fold it into agent.log first. +if [ -f /var/log/claworc/openclaw.log ] && [ ! -L /var/log/claworc/openclaw.log ]; then + cat /var/log/claworc/openclaw.log >> /var/log/claworc/agent.log 2>/dev/null || true + rm -f /var/log/claworc/openclaw.log +fi +touch /var/log/claworc/agent.log +chown claworc:claworc /var/log/claworc/agent.log +ln -sfn agent.log /var/log/claworc/openclaw.log test -f /home/claworc/.bashrc || cp -a /etc/skel/. /home/claworc/ mkdir -p /home/claworc/Downloads diff --git a/agent/instance/rootfs/etc/ssh/sshd_config.d/claworc.conf b/agent/openclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf similarity index 100% rename from agent/instance/rootfs/etc/ssh/sshd_config.d/claworc.conf rename to agent/openclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf diff --git a/agent/openclaw/shim/agent.svg b/agent/openclaw/shim/agent.svg new file mode 100644 index 00000000..ff168c31 --- /dev/null +++ b/agent/openclaw/shim/agent.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/agent/openclaw/shim/agent.txt b/agent/openclaw/shim/agent.txt new file mode 100644 index 00000000..9a5b1392 --- /dev/null +++ b/agent/openclaw/shim/agent.txt @@ -0,0 +1 @@ +OpenClaw diff --git a/agent/openclaw/shim/chat-abort b/agent/openclaw/shim/chat-abort new file mode 100755 index 00000000..c8679999 --- /dev/null +++ b/agent/openclaw/shim/chat-abort @@ -0,0 +1,4 @@ +#!/bin/sh +# chat-abort --session — abort the in-flight turn for the session. +# Exit 0 also when nothing was running (docs/shim.md). +exec /usr/bin/node /opt/claworc/shim/lib/gateway-bridge.mjs abort "$@" diff --git a/agent/openclaw/shim/chat-send b/agent/openclaw/shim/chat-send new file mode 100755 index 00000000..2815c38f --- /dev/null +++ b/agent/openclaw/shim/chat-send @@ -0,0 +1,4 @@ +#!/bin/sh +# chat-send --session [--turn ] — stream one chat turn as JSONL. +# Thin wrapper over the shared gateway bridge (docs/shim.md). +exec /usr/bin/node /opt/claworc/shim/lib/gateway-bridge.mjs send "$@" diff --git a/agent/openclaw/shim/config-get b/agent/openclaw/shim/config-get new file mode 100755 index 00000000..063c5788 --- /dev/null +++ b/agent/openclaw/shim/config-get @@ -0,0 +1,21 @@ +#!/bin/sh +# config-get [--id ] — raw config file bytes on stdout (docs/shim.md). +set -eu + +CONFIG_FILE=/home/claworc/.openclaw/openclaw.json +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi +if [ ! -f "$CONFIG_FILE" ]; then + echo "config file not found: $CONFIG_FILE" >&2 + exit 1 +fi +exec cat "$CONFIG_FILE" diff --git a/agent/openclaw/shim/config-set b/agent/openclaw/shim/config-set new file mode 100755 index 00000000..8267de25 --- /dev/null +++ b/agent/openclaw/shim/config-set @@ -0,0 +1,47 @@ +#!/bin/sh +# config-set [--id ] — replace the config file with stdin bytes. +# Validates JSON (exit 6 + {"error":...} on stdout when invalid), writes +# atomically (tmp + mv) with claworc ownership, does NOT restart the agent — +# the control plane calls the restart verb afterwards (docs/shim.md). +set -eu + +CONFIG_FILE=/home/claworc/.openclaw/openclaw.json +CONFIG_DIR=/home/claworc/.openclaw +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi + +mkdir -p "$CONFIG_DIR" +chown claworc:claworc "$CONFIG_DIR" 2>/dev/null || true + +TMP="$CONFIG_DIR/.openclaw.json.shim-tmp.$$" +trap 'rm -f "$TMP"' EXIT + +cat > "$TMP" + +# Validate JSON; on failure print {"error": ...} on stdout and exit 6. +if ! /usr/bin/node -e ' + const fs = require("fs"); + try { + JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + } catch (e) { + console.log(JSON.stringify({ error: "invalid JSON: " + e.message })); + process.exit(1); + } +' "$TMP"; then + exit 6 +fi + +chown claworc:claworc "$TMP" +chmod 0600 "$TMP" +mv -f "$TMP" "$CONFIG_FILE" +trap - EXIT +exit 0 diff --git a/agent/openclaw/shim/configure-llm b/agent/openclaw/shim/configure-llm new file mode 100755 index 00000000..f47879a5 --- /dev/null +++ b/agent/openclaw/shim/configure-llm @@ -0,0 +1,196 @@ +#!/usr/bin/env node +// configure-llm — routes all OpenClaw LLM traffic through the Claworc LLM +// proxy using virtual keys (docs/shim.md). Reads the generic routing document +// from stdin and applies it via `openclaw config set/unset ... --json` as the +// claworc user. +// +// The translation replicates what the control plane's native OpenClaw adapter +// does in control-plane/internal/handlers/instances.go +// (buildOpenClawProvidersJSON + ConfigureInstance): +// - providers -> models.providers map keyed by provider key with +// {baseUrl: proxy_url, api, apiKey: virtual key, +// models: [...]} — model ids stored WITHOUT the +// "/" prefix (OpenClaw derives the full +// name as /). +// - api_type default -> "openai-completions". +// - codex special case -> providers with api_type "openai-codex-responses" +// declare "openai-responses" to OpenClaw so pi-ai +// skips its client-side JWT decode of apiKey; the +// Claworc gateway translates path/auth/SSE upstream. +// - default_model/fallback_models -> agents.defaults.model {primary,fallbacks}. +// - agents.defaults.models allowlist rebuilt from the full prefixed model +// set; both map paths are `config unset` first because `openclaw config +// set` deep-merges into existing map values (de-selected entries would +// linger otherwise). That unset-then-set cycle is what makes this verb +// idempotent. +// +// Does NOT restart the agent. Exit codes per docs/shim.md (6 = validation). + +"use strict"; + +// CommonJS on purpose: this file has no extension, so explicit ESM syntax +// would depend on node's module-syntax detection heuristics. +const { execFileSync } = require("node:child_process"); +const { createHash } = require("node:crypto"); +const { chownSync, mkdirSync, readFileSync, renameSync, writeFileSync } = require("node:fs"); + +const CONFIG_FILE = "/home/claworc/.openclaw/openclaw.json"; +// Post-apply state cache making repeat runs true no-ops: `openclaw config +// set` bumps a lastTouchedAt timestamp on every invocation, so skipping the +// CLI when nothing changed is the only way re-running the same routing +// document leaves the config byte-identical (the shim-selftest idempotency +// check relies on this). The cached config hash guards against drift: any +// out-of-band config edit invalidates the cache and forces a re-apply. +const STATE_DIR = "/home/claworc/.claworc/shim"; +const STATE_FILE = `${STATE_DIR}/llm-state.json`; + +function configHash() { + try { + return createHash("sha256").update(readFileSync(CONFIG_FILE)).digest("hex"); + } catch { + return ""; + } +} + +const EXIT_INTERNAL = 1; +const EXIT_VALIDATION = 6; + +function validationFail(msg) { + process.stdout.write(`${JSON.stringify({ error: msg })}\n`); + process.exit(EXIT_VALIDATION); +} + +function openclaw(args, { ignoreFailure = false } = {}) { + try { + execFileSync("/command/s6-setuidgid", ["claworc", "/usr/bin/openclaw", ...args], { + env: { ...process.env, HOME: "/home/claworc" }, + stdio: ["ignore", "pipe", "pipe"], + timeout: 60_000, + }); + } catch (err) { + if (ignoreFailure) return; + const stderr = err.stderr ? err.stderr.toString().trim() : ""; + process.stderr.write(`openclaw ${args.slice(0, 3).join(" ")} failed: ${stderr || err.message}\n`); + process.exit(EXIT_INTERNAL); + } +} + +let raw; +try { + raw = readFileSync(0, "utf8"); +} catch (err) { + process.stderr.write(`read stdin: ${err.message}\n`); + process.exit(EXIT_INTERNAL); +} + +let doc; +try { + doc = JSON.parse(raw); +} catch (err) { + validationFail(`invalid JSON routing document: ${err.message}`); +} +if (!doc || typeof doc !== "object" || Array.isArray(doc)) { + validationFail("routing document must be a JSON object"); +} + +const style = typeof doc.style === "string" && doc.style !== "" ? doc.style : "openai"; +if (style !== "openai") { + validationFail(`unsupported llm style ${JSON.stringify(style)}: openclaw speaks the openai dialect only`); +} + +const providers = Array.isArray(doc.providers) ? doc.providers : []; +const defaultModel = typeof doc.default_model === "string" ? doc.default_model : ""; +const fallbackModels = Array.isArray(doc.fallback_models) + ? doc.fallback_models.filter((m) => typeof m === "string" && m !== "") + : []; + +if (providers.length > 0 && (typeof doc.proxy_url !== "string" || doc.proxy_url === "")) { + validationFail("proxy_url is required when providers are present"); +} + +// --- providers -> models.providers ------------------------------------- +const providersMap = {}; +const prefixedProviderModels = []; +for (const p of providers) { + if (!p || typeof p !== "object" || typeof p.key !== "string" || p.key === "") { + validationFail("each provider needs a non-empty string key"); + } + let api = typeof p.api_type === "string" && p.api_type !== "" ? p.api_type : "openai-completions"; + // Codex declares openai-responses to OpenClaw (see instances.go). + if (api === "openai-codex-responses") api = "openai-responses"; + + const models = []; + for (const m of Array.isArray(p.models) ? p.models : []) { + if (!m || typeof m !== "object" || typeof m.id !== "string" || m.id === "") continue; + const prefix = `${p.key}/`; + const id = m.id.startsWith(prefix) ? m.id.slice(prefix.length) : m.id; + const entry = { ...m, id }; + // OpenClaw's provider-model schema requires `name`; routing documents + // (docs/shim.md) may carry id-only entries. + if (typeof entry.name !== "string" || entry.name === "") entry.name = id; + models.push(entry); + prefixedProviderModels.push(`${p.key}/${id}`); + } + + providersMap[p.key] = { + baseUrl: doc.proxy_url, + api, + apiKey: typeof p.api_key === "string" ? p.api_key : "", + models, + }; +} + +// --- desired-state short-circuit ----------------------------------------- +const allowlist = {}; +if (defaultModel !== "") { + for (const name of [defaultModel, ...fallbackModels, ...prefixedProviderModels]) { + allowlist[name] = {}; + } +} +const desired = JSON.stringify({ defaultModel, fallbackModels, allowlist, providersMap }); +try { + const state = JSON.parse(readFileSync(STATE_FILE, "utf8")); + if (state.desired === desired && state.configHash !== "" && state.configHash === configHash()) { + process.stderr.write("routing document already applied and config unchanged; nothing to do\n"); + process.exit(0); + } +} catch { + /* no valid state cache — apply below */ +} + +// --- default_model/fallback_models -> agents.defaults.model + allowlist --- +if (defaultModel !== "") { + const modelConfig = { primary: defaultModel, fallbacks: fallbackModels }; + openclaw(["config", "set", "agents.defaults.model", JSON.stringify(modelConfig), "--json"]); + + // Allowlist restricting the UI dropdown to configured models. Rebuilt from + // scratch: unset first (deep-merge guard, `openclaw config set` merges into + // existing map values), then set the full prefixed set. + openclaw(["config", "unset", "agents.defaults.models"], { ignoreFailure: true }); + openclaw(["config", "set", "agents.defaults.models", JSON.stringify(allowlist), "--json"]); +} + +if (Object.keys(providersMap).length > 0) { + // Clear the providers map first so de-selected providers are removed + // instead of being deep-merged with the previous config. + openclaw(["config", "unset", "models.providers"], { ignoreFailure: true }); + openclaw(["config", "set", "models.providers", JSON.stringify(providersMap), "--json"]); +} + +// Record what was applied (and the resulting config fingerprint) so the next +// identical run is a no-op. +try { + mkdirSync(STATE_DIR, { recursive: true }); + const tmp = `${STATE_FILE}.tmp.${process.pid}`; + writeFileSync(tmp, JSON.stringify({ desired, configHash: configHash() })); + try { + chownSync(tmp, 1000, 1000); // claworc:claworc + } catch { + /* not root — ownership already correct */ + } + renameSync(tmp, STATE_FILE); +} catch (err) { + process.stderr.write(`warning: could not persist llm-state cache: ${err.message}\n`); +} + +process.exit(0); diff --git a/agent/openclaw/shim/health b/agent/openclaw/shim/health new file mode 100755 index 00000000..ac4212c3 --- /dev/null +++ b/agent/openclaw/shim/health @@ -0,0 +1,33 @@ +#!/bin/sh +# health — exit 0 when the agent can take a chat turn, 4 while booting, +# 1 when broken (docs/shim.md). +set -u + +SVC_DIR=/run/service/svc-agent +GATEWAY_PORT=${OPENCLAW_GATEWAY_PORT:-18789} + +# s6-svscan has not created the service tree yet => still booting. +if [ ! -d /run/service ]; then + echo "s6 service tree not up yet" >&2 + exit 4 +fi + +if [ ! -d "$SVC_DIR" ]; then + echo "svc-agent service directory missing" >&2 + exit 1 +fi + +if ! /command/s6-svstat -o up "$SVC_DIR" 2>/dev/null | grep -q '^true$'; then + echo "svc-agent service is down" >&2 + exit 1 +fi + +# Service is up — the gateway WS port must accept connections before chat can +# work. /dev/tcp needs bash (this image always has it). +if bash -c "exec 3<>/dev/tcp/127.0.0.1/${GATEWAY_PORT}" 2>/dev/null; then + printf '{"status":"ok"}\n' + exit 0 +fi + +echo "gateway port ${GATEWAY_PORT} not accepting connections yet" >&2 +exit 4 diff --git a/agent/openclaw/shim/lib/gateway-bridge.mjs b/agent/openclaw/shim/lib/gateway-bridge.mjs new file mode 100755 index 00000000..5da68c4e --- /dev/null +++ b/agent/openclaw/shim/lib/gateway-bridge.mjs @@ -0,0 +1,473 @@ +#!/usr/bin/env node +// gateway-bridge.mjs — shared implementation behind the chat-send, chat-abort +// and session-reset shim verbs (see docs/shim.md for the contract). +// +// Speaks the OpenClaw local gateway WebSocket protocol on +// ws://127.0.0.1:18789/gateway and translates gateway event frames into the +// normalized Claworc chat JSONL on stdout. +// +// The connect handshake replicates the control plane's Go client +// (control-plane/internal/sshproxy/gateway_dialer.go) frame-for-frame: +// 1. token as ?token= query parameter + Origin header, +// 2. read one challenge frame, +// 3. send a `connect` req (minProtocol 3, maxProtocol 4, role operator, +// scopes ["operator.admin"], auth.token), +// 4. wait for the res frame, skipping event frames; ok=false => auth failure. +// +// Usage: gateway-bridge.mjs --session [--turn ] +// Node >= 22 only (relies on the built-in WebSocket global); no npm deps. + +import { randomUUID } from "node:crypto"; +import { readFileSync } from "node:fs"; +import net from "node:net"; +import process from "node:process"; + +const GATEWAY_PORT = Number(process.env.OPENCLAW_GATEWAY_PORT || 18789); +const GATEWAY_ORIGIN = `http://127.0.0.1:${GATEWAY_PORT}`; +const CONNECT_TIMEOUT_MS = 10_000; +// Idle gap tolerated between gateway frames during a chat turn. Re-armed on +// every frame, so an actively streaming agent is never cut off. +const IDLE_TIMEOUT_MS = Number(process.env.CLAWORC_SHIM_CHAT_IDLE_MS || 300_000); +// Minimum interval between assistant snapshot lines (contract: >= 150 ms). +const SNAPSHOT_THROTTLE_MS = 150; + +// Contract exit codes (docs/shim.md). +const EXIT_OK = 0; +const EXIT_INTERNAL = 1; +const EXIT_USAGE = 2; +const EXIT_NOT_READY = 4; +const EXIT_TIMEOUT = 5; + +function die(code, msg) { + if (msg) process.stderr.write(`${msg}\n`); + process.exit(code); +} + +function resolveToken() { + if (process.env.OPENCLAW_GATEWAY_TOKEN) return process.env.OPENCLAW_GATEWAY_TOKEN; + if (process.env.CLAWORC_AGENT_TOKEN) return process.env.CLAWORC_AGENT_TOKEN; + // Fallback: read the token straight out of the agent config so the verbs + // work even in exec contexts that did not inherit the container env. + try { + const cfg = JSON.parse(readFileSync("/home/claworc/.openclaw/openclaw.json", "utf8")); + const t = cfg?.gateway?.auth?.token; + if (typeof t === "string" && t !== "") return t; + } catch { + /* config missing/unreadable — proceed tokenless */ + } + return ""; +} + +function parseArgs(argv) { + const cmd = argv[0]; + if (!["send", "abort", "reset"].includes(cmd)) { + die(EXIT_USAGE, `usage: gateway-bridge.mjs --session [--turn ]`); + } + let session = ""; + let turn = ""; + for (let i = 1; i < argv.length; i++) { + switch (argv[i]) { + case "--session": + session = argv[++i] ?? ""; + break; + case "--turn": + turn = argv[++i] ?? ""; + break; + default: + die(EXIT_USAGE, `unknown argument: ${argv[i]}`); + } + } + if (!session) die(EXIT_USAGE, "--session is required"); + if (!turn) turn = `t-${randomUUID().slice(0, 8)}`; + return { cmd, session, turn }; +} + +// Quick TCP probe so "agent still booting" (exit 4) is distinguishable from +// genuine dial/handshake failures (exit 1). +function probePort(port, timeoutMs = 3000) { + return new Promise((resolve) => { + const sock = net.connect({ host: "127.0.0.1", port }); + const done = (up) => { + sock.removeAllListeners(); + sock.destroy(); + resolve(up); + }; + sock.setTimeout(timeoutMs, () => done(false)); + sock.once("connect", () => done(true)); + sock.once("error", () => done(false)); + }); +} + +class Gateway { + constructor(ws) { + this.ws = ws; + this.queue = []; + this.waiter = null; // {resolve} of a pending next() + this.closed = false; + ws.addEventListener("message", (ev) => { + let frame; + try { + frame = JSON.parse(typeof ev.data === "string" ? ev.data : String(ev.data)); + } catch { + return; // ignore non-JSON frames + } + this.push(frame); + }); + ws.addEventListener("close", () => { + this.closed = true; + this.push(null); + }); + ws.addEventListener("error", () => { + this.closed = true; + this.push(null); + }); + } + + push(item) { + if (this.waiter) { + const w = this.waiter; + this.waiter = null; + w.resolve(item); + } else { + this.queue.push(item); + } + } + + // Resolves with the next parsed frame, null when the socket closed, or + // rejects with a TimeoutError after timeoutMs of silence. + next(timeoutMs) { + if (this.queue.length > 0) return Promise.resolve(this.queue.shift()); + if (this.closed) return Promise.resolve(null); + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + if (this.waiter && this.waiter.resolve === wrapped) this.waiter = null; + const err = new Error(`no gateway frame for ${timeoutMs}ms`); + err.timeout = true; + reject(err); + }, Math.max(1, timeoutMs)); + const wrapped = (item) => { + clearTimeout(timer); + resolve(item); + }; + this.waiter = { resolve: wrapped }; + }); + } + + send(frame) { + this.ws.send(JSON.stringify(frame)); + } + + close() { + try { + this.ws.close(); + } catch { + /* already closed */ + } + } +} + +async function dialGateway(token) { + if (typeof WebSocket === "undefined") { + throw new Error("global WebSocket unavailable — node >= 22 required"); + } + let url = `ws://127.0.0.1:${GATEWAY_PORT}/gateway`; + if (token) url += `?token=${encodeURIComponent(token)}`; + + let ws; + try { + // Node's undici WebSocket accepts a non-standard `headers` option; the + // gateway expects a loopback Origin (mirrors gateway_dialer.go). + ws = new WebSocket(url, { headers: { Origin: GATEWAY_ORIGIN } }); + } catch { + ws = new WebSocket(url); + } + + await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const err = new Error("timed out opening gateway websocket"); + err.timeout = true; + reject(err); + }, CONNECT_TIMEOUT_MS); + ws.addEventListener("open", () => { + clearTimeout(timer); + resolve(); + }, { once: true }); + ws.addEventListener("error", () => { + clearTimeout(timer); + reject(new Error("gateway websocket dial failed")); + }, { once: true }); + }); + + const gw = new Gateway(ws); + + // Phase 1: the gateway sends a connect.challenge frame first. + await gw.next(CONNECT_TIMEOUT_MS); + + // Phase 2: connect request — same frame shape as gateway_dialer.go. + gw.send({ + type: "req", + id: `connect-${Date.now()}`, + method: "connect", + params: { + minProtocol: 3, + maxProtocol: 4, + // The gateway validates client.id against an allowlist of known + // clients, so this must stay byte-identical to the control plane's + // dialer ("claworc-shim" gets rejected with "invalid connect params"). + client: { + id: "openclaw-control-ui", + version: "1.0.0", + platform: "linux", + mode: "webchat", + }, + role: "operator", + scopes: ["operator.admin"], + auth: { token }, + }, + }); + + // Phase 3: wait for the hello-ok response, skipping event frames. + const deadline = Date.now() + CONNECT_TIMEOUT_MS; + for (;;) { + const frame = await gw.next(Math.max(1, deadline - Date.now())); + if (frame === null) throw new Error("gateway closed during handshake"); + if (frame.type === "event") continue; + if (frame.type === "res") { + if (frame.ok !== true) { + const msg = frame?.error?.message || "gateway auth failed"; + const err = new Error(msg); + err.handshake = true; + throw err; + } + return gw; + } + } +} + +// --------------------------------------------------------------------------- +// send +// --------------------------------------------------------------------------- + +async function cmdSend(session, turn) { + const message = readFileSync(0, "utf8"); // stdin until EOF + + if (!(await probePort(GATEWAY_PORT))) { + die(EXIT_NOT_READY, `gateway port ${GATEWAY_PORT} is not accepting connections (agent still booting?)`); + } + + let gw; + try { + gw = await dialGateway(resolveToken()); + } catch (err) { + die(err.timeout ? EXIT_TIMEOUT : EXIT_INTERNAL, `gateway handshake failed: ${err.message}`); + } + + const out = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`); + + let started = false; + let ended = false; + let lastText = ""; // last assistant snapshot — becomes end.text + + const ensureStart = () => { + if (!started) { + started = true; + out({ v: 1, event: "start", session, turn }); + } + }; + + // Assistant snapshot throttling (>=150ms apart, flushed on message + // boundaries, tool events, and end). + let pending = null; // {messageId, text} + let lastEmit = 0; + let flushTimer = null; + const flushPending = () => { + if (flushTimer) { + clearTimeout(flushTimer); + flushTimer = null; + } + if (!pending) return; + ensureStart(); + out({ v: 1, event: "assistant", turn, message_id: pending.messageId, text: pending.text }); + lastEmit = Date.now(); + pending = null; + }; + const snapshot = (messageId, text) => { + lastText = text; + if (pending && pending.messageId !== messageId) flushPending(); + pending = { messageId, text }; + const wait = SNAPSHOT_THROTTLE_MS - (Date.now() - lastEmit); + if (wait <= 0) flushPending(); + else if (!flushTimer) flushTimer = setTimeout(flushPending, wait); + }; + + const finish = (stopReason) => { + if (ended) return; + ended = true; + flushPending(); + ensureStart(); + out({ v: 1, event: "end", turn, stop_reason: stopReason, text: lastText }); + gw.close(); + process.exit(EXIT_OK); + }; + + // Abort semantics: SIGTERM (or SSH channel teardown) aborts the in-flight + // turn, emits end/aborted, exits 0. + const onAbortSignal = () => { + try { + gw.send({ + type: "req", + id: `abort-${Date.now()}`, + method: "chat.abort", + params: { sessionKey: session }, + }); + } catch { + /* best-effort */ + } + finish("aborted"); + }; + process.on("SIGTERM", onAbortSignal); + process.on("SIGINT", onAbortSignal); + process.on("SIGHUP", onAbortSignal); + + const reqId = `chat-${Date.now()}`; + gw.send({ + type: "req", + id: reqId, + method: "chat.send", + params: { + sessionKey: session, + message, + idempotencyKey: randomUUID(), + }, + }); + + for (;;) { + let frame; + try { + frame = await gw.next(IDLE_TIMEOUT_MS); + } catch (err) { + if (err.timeout) { + out({ v: 1, event: "error", turn, code: "idle_timeout", text: `no gateway events for ${IDLE_TIMEOUT_MS}ms`, fatal: true }); + finish("error"); + } + throw err; + } + if (frame === null) { + // Socket closed without a lifecycle end. + out({ v: 1, event: "error", turn, code: "gateway_closed", text: "gateway connection closed mid-turn", fatal: true }); + finish("error"); + } + + if (frame.type === "res") { + if (frame.id === reqId && frame.ok === false) { + const msg = frame?.error?.message || "chat.send rejected"; + const code = frame?.error?.code || "gateway_error"; + out({ v: 1, event: "error", turn, code: String(code), text: String(msg), fatal: true }); + finish("error"); + } + continue; // ok-acks carry no chat content + } + if (frame.type !== "event") continue; + const payload = frame.payload; + if (!payload || typeof payload !== "object") continue; + const data = payload.data && typeof payload.data === "object" ? payload.data : {}; + + switch (payload.stream) { + case "assistant": { + // OpenClaw assistant events carry the CUMULATIVE snapshot in + // data.text — exactly what the contract's assistant.text wants. + if (typeof data.text === "string" && data.text !== "") { + const messageId = String(payload.runId ?? data.runId ?? "m1"); + snapshot(messageId, data.text); + } + break; + } + case "tool": { + flushPending(); // keep assistant/tool ordering + ensureStart(); + const ev = { v: 1, event: "tool", turn, name: "tool", detail: data }; + if (typeof data.name === "string" && data.name !== "") ev.name = data.name; + else if (typeof data.tool === "string" && data.tool !== "") ev.name = data.tool; + if (typeof data.phase === "string" && data.phase !== "") ev.phase = data.phase; + out(ev); + break; + } + case "lifecycle": { + const phase = typeof data.phase === "string" ? data.phase : ""; + if (phase === "start") ensureStart(); + else if (phase === "end") finish("complete"); + break; + } + default: + break; // unknown streams are ignored (forward compatibility) + } + } +} + +// --------------------------------------------------------------------------- +// abort / reset +// --------------------------------------------------------------------------- + +async function sendSimpleRequest(method, params, reqPrefix) { + const gw = await dialGateway(resolveToken()); + const reqId = `${reqPrefix}-${Date.now()}`; + gw.send({ type: "req", id: reqId, method, params }); + const deadline = Date.now() + CONNECT_TIMEOUT_MS; + for (;;) { + const frame = await gw.next(Math.max(1, deadline - Date.now())); + if (frame === null) return null; + if (frame.type === "res" && frame.id === reqId) { + gw.close(); + return frame; + } + } +} + +async function cmdAbort(session) { + // "Exit 0 also when nothing was running" — a gateway that is not even + // listening trivially has no turn in flight. + if (!(await probePort(GATEWAY_PORT))) { + process.stderr.write("gateway not listening; nothing to abort\n"); + process.exit(EXIT_OK); + } + try { + const res = await sendSimpleRequest("chat.abort", { sessionKey: session }, "abort"); + if (res && res.ok === false) { + process.stderr.write(`chat.abort: ${res?.error?.message || "rejected"} (treated as no-op)\n`); + } + } catch (err) { + process.stderr.write(`chat.abort best-effort failed: ${err.message}\n`); + } + process.exit(EXIT_OK); +} + +async function cmdReset(session) { + if (!(await probePort(GATEWAY_PORT))) { + die(EXIT_NOT_READY, `gateway port ${GATEWAY_PORT} is not accepting connections (agent still booting?)`); + } + let res; + try { + // Frame shape mirrors the control plane chat proxy (handlers/chat.go): + // method sessions.reset, params {key: }. + res = await sendSimpleRequest("sessions.reset", { key: session }, "reset"); + } catch (err) { + die(err.timeout ? EXIT_TIMEOUT : EXIT_INTERNAL, `sessions.reset failed: ${err.message}`); + } + if (res && res.ok === false) { + const msg = String(res?.error?.message || "rejected"); + // Resetting a session that does not exist yet is a success (idempotency). + if (/not found|unknown|no such|missing/i.test(msg)) process.exit(EXIT_OK); + die(EXIT_INTERNAL, `sessions.reset rejected: ${msg}`); + } + process.exit(EXIT_OK); +} + +// --------------------------------------------------------------------------- + +const { cmd, session, turn } = parseArgs(process.argv.slice(2)); + +const run = { send: () => cmdSend(session, turn), abort: () => cmdAbort(session), reset: () => cmdReset(session) }[cmd]; + +run().catch((err) => { + die(err.timeout ? EXIT_TIMEOUT : EXIT_INTERNAL, `gateway-bridge ${cmd}: ${err.message}`); +}); diff --git a/agent/openclaw/shim/meta b/agent/openclaw/shim/meta new file mode 100755 index 00000000..6212fad2 --- /dev/null +++ b/agent/openclaw/shim/meta @@ -0,0 +1,47 @@ +#!/bin/sh +# meta — Claworc agent shim capability/version probe (docs/shim.md, contract v1). +# Prints one JSON object on stdout. Must stay fast: the control plane runs this +# with a short timeout on every SSH (re)connect, so the openclaw version probe +# is bounded and cached under /run. +set -eu + +VERSION_CACHE=/run/claworc/shim/agent-version +mkdir -p /run/claworc/shim 2>/dev/null || true + +AGENT_VERSION="" +if [ -s "$VERSION_CACHE" ]; then + AGENT_VERSION=$(cat "$VERSION_CACHE") +else + # `openclaw --version` is a node CLI start (~1s); bound it and cache the + # result for subsequent probes. Sanitize to a version-looking token so the + # value is safe to splice into JSON. + AGENT_VERSION=$(timeout 10 env HOME=/home/claworc /command/s6-setuidgid claworc \ + /usr/bin/openclaw --version 2>/dev/null \ + | grep -oE '[0-9]+\.[0-9]+\.[0-9]+([0-9A-Za-z.-]*)?' | head -n1 || true) + if [ -n "$AGENT_VERSION" ]; then + printf '%s' "$AGENT_VERSION" > "$VERSION_CACHE" 2>/dev/null || true + fi +fi + +if [ -n "$AGENT_VERSION" ]; then + AGENT_JSON=$(printf '{"name":"openclaw","version":"%s"}' "$AGENT_VERSION") +else + AGENT_JSON='{"name":"openclaw"}' +fi + +cat <&2 + exit 1 +fi +exec /command/s6-svc -r "$SVC_DIR" diff --git a/agent/openclaw/shim/session-reset b/agent/openclaw/shim/session-reset new file mode 100755 index 00000000..37108b5d --- /dev/null +++ b/agent/openclaw/shim/session-reset @@ -0,0 +1,4 @@ +#!/bin/sh +# session-reset --session — clear conversation history for the key. +# Idempotent (docs/shim.md). +exec /usr/bin/node /opt/claworc/shim/lib/gateway-bridge.mjs reset "$@" diff --git a/agent/template/Dockerfile b/agent/template/Dockerfile new file mode 100644 index 00000000..cd13bd4d --- /dev/null +++ b/agent/template/Dockerfile @@ -0,0 +1,69 @@ +# Claworc custom-agent template image. +# +# Minimal skeleton every Claworc-managed agent image needs: debian-slim, +# s6-overlay as PID 1, a hardened sshd (SSH is the contract's only hard +# runtime dependency), and a complete pure-shell implementation of the +# Claworc Agent Shim Contract v1 at /opt/claworc/shim (see docs/shim.md). +# +# Copy this directory, install your agent below, point shim/agent.env's +# CHAT_CMD at it, and adjust shim/meta. Validate with: +# docker build -t my-agent agent/template/ +# docker run --rm my-agent /opt/claworc/shim/shim-selftest + +FROM debian:bookworm-slim + +ARG S6_OVERLAY_VERSION=3.2.0.2 +ARG TARGETARCH + +# Create claworc user (UID 1000) — all agent state stays owned by it. +RUN useradd -m -u 1000 -s /bin/bash claworc + +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \ + ca-certificates curl xz-utils \ + openssh-server \ + python3 jq procps && \ + rm -rf /var/lib/apt/lists/* + +RUN S6_ARCH=$([ "$TARGETARCH" = "arm64" ] && echo "aarch64" || echo "x86_64") && \ + curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz" \ + | tar -C / -Jxpf - && \ + curl -fsSL "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}.tar.xz" \ + | tar -C / -Jxpf - + +# --------------------------------------------------------------------------- +# Install YOUR agent here, e.g.: +# RUN npm install -g my-agent # or pip install / curl a binary ... +# Then set CHAT_CMD in shim/agent.env and update shim/meta accordingly. +# Agents with a daemon should add an s6 longrun service named `svc-agent` +# (see agent/openclaw/rootfs/etc/s6-overlay/s6-rc.d/svc-agent) and switch +# shim/restart to `s6-svc -r /run/service/svc-agent`. +# --------------------------------------------------------------------------- + +COPY rootfs/ / +COPY shim/ /opt/claworc/shim/ + +RUN chmod 0755 /opt/claworc/shim/meta \ + /opt/claworc/shim/health \ + /opt/claworc/shim/chat-send \ + /opt/claworc/shim/chat-abort \ + /opt/claworc/shim/session-reset \ + /opt/claworc/shim/config-get \ + /opt/claworc/shim/config-set \ + /opt/claworc/shim/configure-llm \ + /opt/claworc/shim/restart \ + /opt/claworc/shim/shim-selftest && \ + chmod 0644 /opt/claworc/shim/agent.txt \ + /opt/claworc/shim/agent.svg \ + /opt/claworc/shim/agent.env && \ + chmod +x /etc/s6-overlay/s6-rc.d/init-setup/up \ + /etc/s6-overlay/scripts/init-setup.sh \ + /etc/s6-overlay/s6-rc.d/svc-sshd/run + +# Reduce SUID surface. Stripping su's SUID bit is safe: the shim invokes it +# as root (SSH exec), and root does not need SUID to switch users. +RUN chmod u-s /usr/bin/su /usr/bin/mount /usr/bin/umount /usr/bin/newgrp \ + /usr/bin/chsh /usr/bin/chfn /usr/bin/gpasswd /usr/bin/chage \ + /usr/lib/openssh/ssh-keysign 2>/dev/null || true + +ENTRYPOINT ["/init"] diff --git a/agent/template/README.md b/agent/template/README.md new file mode 100644 index 00000000..63abcb5a --- /dev/null +++ b/agent/template/README.md @@ -0,0 +1,70 @@ +# Claworc Custom Agent Template + +Copy-me skeleton for building a custom agent image that Claworc can manage — +chat, config editing, LLM virtual-key routing, health checks, and webhooks all +work through the [Claworc Agent Shim Contract](../../docs/shim.md) implemented +here as small POSIX shell scripts. + +## What's inside + +``` +Dockerfile debian-slim + s6-overlay + hardened sshd (no agent) +rootfs/ + etc/ssh/sshd_config.d/ hardened sshd config (SSH is the only hard runtime dep) + etc/s6-overlay/ init-setup oneshot (env propagation, /var/log/claworc, + first-boot CLAWORC_INITIAL_LLM_CONFIG) + svc-sshd +shim/ installed at /opt/claworc/shim + agent.txt agent.svg static identity (name + square logo), read over SFTP + agent.env your agent's config: CHAT_CMD + managed LLM block + meta capability probe (static JSON heredoc) + health 0 ready / 4 booting / 1 broken + chat-send CHAT_CMD wrapper -> normalized chat JSONL + chat-abort best-effort SIGTERM of the running chat-send + session-reset removes the per-session transcript + config-get / config-set raw agent.env bytes (config-set validates + tmp/mv) + configure-llm rewrites the managed LLM block in agent.env + restart no-op (no daemon); swap for s6-svc -r if you add one + shim-selftest contract conformance check — run it in CI +``` + +## Make it yours + +1. **Install your agent** in the marked section of the `Dockerfile`. +2. **Point `shim/agent.env`'s `CHAT_CMD`** at your agent's CLI. The contract: + it reads the user message on stdin and writes the reply to stdout. The + default `cat` is an echo agent so the image passes `shim-selftest` as-is. +3. **Update `shim/meta`**: agent name/version, and trim `capabilities` to what + you actually support (`chat` is mandatory; drop `configure-llm` etc. if + they don't apply — the Claworc UI hides those features). +4. **LLM routing**: `configure-llm` writes `OPENAI_BASE_URL`/`OPENAI_API_KEY` + (or the Anthropic pair) plus generic `CLAWORC_LLM_*` variables into the + managed block of `agent.env`, pointing at the Claworc LLM proxy with a + virtual key. Make your `CHAT_CMD` honor those variables, or adapt the verb + to your agent's native config format (keep it idempotent: rewrite a fully + managed section, never append). +5. **Streaming (optional)**: the stock `chat-send` emits a single cumulative + assistant snapshot when `CHAT_CMD` finishes. If your agent streams, emit + snapshot lines as output accumulates — `assistant.text` is always the full + text so far, never a delta. +6. **Session history (optional)**: the template declares + `session_persistence: "none"` — each turn is fresh. Feed the transcript + kept in `/home/claworc/.claworc/shim/sessions/` back into your agent and + declare `"emulated"` if you want multi-turn memory. + +## Validate + +```sh +docker build -t my-agent . +docker run --rm my-agent /opt/claworc/shim/shim-selftest +``` + +`shim-selftest` checks the identity files, `meta` JSON, `health` exit codes, +`chat-send` JSONL well-formedness (every line parses, exactly one `end` +event, last), the config round-trip, and `configure-llm` idempotency, and +exits non-zero with a per-check report on any failure. The config and +configure-llm checks mutate agent configuration — run in a throwaway +container, or pass `--skip-mutating`. It can also run against a shim +directory outside a container: `shim/shim-selftest ./shim`. + +For a full-featured reference implementation (daemon agent, gateway bridge, +native sessions), see `agent/openclaw/`. diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type new file mode 100644 index 00000000..bdd22a18 --- /dev/null +++ b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/init-setup/type @@ -0,0 +1 @@ +oneshot diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up new file mode 100755 index 00000000..88d2a0d4 --- /dev/null +++ b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/init-setup/up @@ -0,0 +1 @@ +/command/with-contenv /etc/s6-overlay/scripts/init-setup.sh diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/dependencies.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run new file mode 100755 index 00000000..f6be5b7a --- /dev/null +++ b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/run @@ -0,0 +1,26 @@ +#!/command/with-contenv bash + +# Remove legacy DSA/ECDSA host keys (keep only Ed25519 and RSA) +rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub +rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub + +# Generate host keys if missing (only Ed25519 and RSA will be created +# since we removed DSA/ECDSA above) +ssh-keygen -A + +# Remove any DSA/ECDSA keys that ssh-keygen -A may have regenerated +rm -f /etc/ssh/ssh_host_dsa_key /etc/ssh/ssh_host_dsa_key.pub +rm -f /etc/ssh/ssh_host_ecdsa_key /etc/ssh/ssh_host_ecdsa_key.pub + +# Create privilege-separation directory +mkdir -p /run/sshd + +# Ensure /root/.ssh directory exists with correct permissions +mkdir -p /root/.ssh +chmod 700 /root/.ssh + +echo "Starting sshd in foreground..." +# The -e flag sends sshd logs to stderr (in addition to syslog facility +# configured in claworc.conf). We redirect to the log file for SSH-based +# log streaming used by the control plane. +exec /usr/sbin/sshd -D -e >> /var/log/claworc/sshd.log 2>&1 diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type new file mode 100644 index 00000000..5883cff0 --- /dev/null +++ b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/svc-sshd/type @@ -0,0 +1 @@ +longrun diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/init-setup new file mode 100644 index 00000000..e69de29b diff --git a/agent/template/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd b/agent/template/rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/svc-sshd new file mode 100644 index 00000000..e69de29b diff --git a/agent/template/rootfs/etc/s6-overlay/scripts/init-setup.sh b/agent/template/rootfs/etc/s6-overlay/scripts/init-setup.sh new file mode 100755 index 00000000..951cf8f1 --- /dev/null +++ b/agent/template/rootfs/etc/s6-overlay/scripts/init-setup.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Runs once at container boot (s6-rc oneshot). Two jobs: +# 1. Prepare /var/log/claworc and the claworc user's HOME. +# 2. Snapshot PID 1's env to /etc/environment and +# /etc/profile.d/claworc-env.sh so SSH sessions — which go through +# PAM and do NOT inherit sshd's env — see the vars passed to +# `docker run -e` / the Kubernetes pod spec. The shim verbs are exec'd +# over SSH, so this is what delivers CLAWORC_AGENT_TOKEN, +# CLAWORC_LLM_PROXY_URL, etc. to them. + +set -e + +# SSH host keys: regenerate per-pod so every container has unique keys. +if command -v ssh-keygen >/dev/null 2>&1; then + ssh-keygen -A >/dev/null 2>&1 || true +fi + +# --------------------------------------------------------------------------- +# Filesystem + user home +# --------------------------------------------------------------------------- +mkdir -p /var/log/claworc +chmod 755 /var/log/claworc +touch /var/log/claworc/agent.log +chown claworc:claworc /var/log/claworc/agent.log + +test -f /home/claworc/.bashrc || cp -a /etc/skel/. /home/claworc/ +mkdir -p /home/claworc/workspace +# Shim persistent state (session transcripts) lives on the instance PVC. +mkdir -p /home/claworc/.claworc/shim +chown -R claworc:claworc /home/claworc + +# Ephemeral shim runtime state (chat PIDs for chat-abort). +mkdir -p /run/claworc/shim +chmod 755 /run/claworc /run/claworc/shim + +# --------------------------------------------------------------------------- +# Propagate PID 1 env to PAM and bash login shells +# --------------------------------------------------------------------------- +exclude='^(PATH|HOME|HOSTNAME|TERM|PWD|OLDPWD|SHLVL|SHELL|LOGNAME|USER|MAIL|_)=' + +: > /etc/environment +printenv | grep -vE "$exclude" | while IFS='=' read -r key value; do + escaped="${value//\\/\\\\}" + escaped="${escaped//\"/\\\"}" + printf '%s="%s"\n' "$key" "$escaped" >> /etc/environment +done +chmod 644 /etc/environment + +{ + echo '# Generated by init-setup.sh at container boot. Do not edit.' + printenv | grep -vE "$exclude" | while IFS='=' read -r key value; do + printf 'export %s=%q\n' "$key" "$value" + done +} > /etc/profile.d/claworc-env.sh +chmod 644 /etc/profile.d/claworc-env.sh + +# --------------------------------------------------------------------------- +# First-boot LLM routing: the control plane passes the configure-llm routing +# document in CLAWORC_INITIAL_LLM_CONFIG (docs/shim.md). Apply it through the +# shim's own verb so boot and reconfiguration share one code path. +# --------------------------------------------------------------------------- +if [ -n "${CLAWORC_INITIAL_LLM_CONFIG:-}" ]; then + if ! printf '%s' "$CLAWORC_INITIAL_LLM_CONFIG" | /opt/claworc/shim/configure-llm; then + echo "configure-llm failed; continuing boot without initial LLM routing" >&2 + fi +fi diff --git a/agent/template/rootfs/etc/ssh/sshd_config.d/claworc.conf b/agent/template/rootfs/etc/ssh/sshd_config.d/claworc.conf new file mode 100644 index 00000000..d22476c2 --- /dev/null +++ b/agent/template/rootfs/etc/ssh/sshd_config.d/claworc.conf @@ -0,0 +1,33 @@ +# Claworc SSH Server Hardened Configuration +# Applied via the sshd_config.d/ include mechanism. SSH is the contract's +# only hard runtime dependency — the control plane execs the shim verbs, +# streams files, and opens tunnels over this connection. + +# Network +Port 22 +ListenAddress 0.0.0.0 + +# Authentication +PubkeyAuthentication yes +PasswordAuthentication no +PermitEmptyPasswords no +PermitRootLogin prohibit-password +MaxAuthTries 3 +StrictModes yes +LoginGraceTime 30 + +# Connection limits +MaxStartups 10:30:60 + +# Forwarding restrictions +X11Forwarding no +AllowAgentForwarding no +AllowTcpForwarding yes +# 127.0.0.1:40001 is the Claworc LLM proxy listener: the control plane +# installs a remote port forward on it so the agent's LLM traffic (routed +# there by configure-llm) reaches the gateway with virtual-key auth. +PermitListen 127.0.0.1:40001 + +# Logging +SyslogFacility AUTH +LogLevel INFO diff --git a/agent/template/shim/agent.env b/agent/template/shim/agent.env new file mode 100644 index 00000000..87f02310 --- /dev/null +++ b/agent/template/shim/agent.env @@ -0,0 +1,20 @@ +# Claworc template agent configuration. Sourced by the shim verbs +# (chat-send, health) and exposed in the Claworc Config tab via +# config-get / config-set. +# +# CHAT_CMD is the heart of the template: a command that reads the user +# message on stdin and writes the agent's reply to stdout. Replace `cat` +# (an echo agent, useful for shim-selftest) with your agent's CLI, e.g.: +# CHAT_CMD="my-agent chat --stdin" +CHAT_CMD="cat" + +# >>> claworc-llm >>> +# Managed by the Claworc shim configure-llm verb - do not edit inside this block. +CLAWORC_LLM_PROXY_URL=http://127.0.0.1:40001 +CLAWORC_LLM_STYLE=openai +CLAWORC_LLM_API_KEY=claworc-vk-selftest +CLAWORC_LLM_DEFAULT_MODEL=anthropic/claude-sonnet-4-5 +CLAWORC_LLM_FALLBACK_MODELS='' +OPENAI_BASE_URL=http://127.0.0.1:40001 +OPENAI_API_KEY=claworc-vk-selftest +# <<< claworc-llm <<< diff --git a/agent/template/shim/agent.svg b/agent/template/shim/agent.svg new file mode 100644 index 00000000..5db7fd4d --- /dev/null +++ b/agent/template/shim/agent.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/agent/template/shim/agent.txt b/agent/template/shim/agent.txt new file mode 100644 index 00000000..d5fa97ef --- /dev/null +++ b/agent/template/shim/agent.txt @@ -0,0 +1 @@ +Custom Agent diff --git a/agent/template/shim/chat-abort b/agent/template/shim/chat-abort new file mode 100755 index 00000000..12c41d1d --- /dev/null +++ b/agent/template/shim/chat-abort @@ -0,0 +1,27 @@ +#!/bin/sh +# chat-abort --session — best-effort abort of the in-flight turn: +# SIGTERM the running chat-send (it emits end/aborted and exits 0). +# Exit 0 also when nothing was running (docs/shim.md). +set -eu + +RUN_DIR=${CLAWORC_SHIM_RUN_DIR:-/run/claworc/shim} + +SESSION="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } + +SESSION_FILE=$(printf %s "$SESSION" | tr -c 'A-Za-z0-9._-' '_') +PIDFILE="$RUN_DIR/chat-$SESSION_FILE.pid" + +if [ -f "$PIDFILE" ]; then + PID=$(cat "$PIDFILE" 2>/dev/null || true) + if [ -n "$PID" ]; then + kill -TERM "$PID" 2>/dev/null || true + fi +fi +exit 0 diff --git a/agent/template/shim/chat-send b/agent/template/shim/chat-send new file mode 100755 index 00000000..6734cac2 --- /dev/null +++ b/agent/template/shim/chat-send @@ -0,0 +1,97 @@ +#!/bin/sh +# chat-send --session [--turn ] — the CHAT_CMD wrapper pattern from +# docs/shim.md, extended with a PID file (for chat-abort), SIGTERM abort +# handling, and a per-session transcript (for session-reset). +# +# CHAT_CMD (from agent.env) reads the user message on stdin and writes the +# reply to stdout. The reply is emitted as a single cumulative assistant +# snapshot followed by the mandatory end event. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE=${CLAWORC_SHIM_ENV_FILE:-$SHIM_DIR/agent.env} +STATE_DIR=${CLAWORC_SHIM_STATE_DIR:-/home/claworc/.claworc/shim} +RUN_DIR=${CLAWORC_SHIM_RUN_DIR:-/run/claworc/shim} + +SESSION="" +TURN="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + --turn) TURN="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } +[ -n "$TURN" ] || TURN="t-$$" + +# shellcheck disable=SC1090 +[ -f "$ENV_FILE" ] && . "$ENV_FILE" +[ -n "${CHAT_CMD:-}" ] || { echo "CHAT_CMD is not set in $ENV_FILE" >&2; exit 4; } + +json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; } + +SESSION_JSON=$(printf %s "$SESSION" | json_escape) +TURN_JSON=$(printf %s "$TURN" | json_escape) +# Session keys are opaque; sanitize before using them as file names. +SESSION_FILE=$(printf %s "$SESSION" | tr -c 'A-Za-z0-9._-' '_') + +mkdir -p "$RUN_DIR" "$STATE_DIR/sessions" 2>/dev/null || true +PIDFILE="$RUN_DIR/chat-$SESSION_FILE.pid" +TRANSCRIPT="$STATE_DIR/sessions/$SESSION_FILE.log" + +MSG_FILE=$(mktemp) +REPLY_FILE=$(mktemp) +cleanup() { rm -f "$MSG_FILE" "$REPLY_FILE" "$PIDFILE"; } + +cat > "$MSG_FILE" # the raw user message, stdin until EOF +printf '%s\n' "$$" > "$PIDFILE" 2>/dev/null || true + +printf '{"v":1,"event":"start","session":%s,"turn":%s}\n' "$SESSION_JSON" "$TURN_JSON" + +# Drop to the claworc user when running as root (SSH is the auth boundary; +# agent state must stay claworc-owned). +if [ "$(id -u)" = "0" ]; then + su claworc -s /bin/sh -c "$CHAT_CMD" < "$MSG_FILE" > "$REPLY_FILE" & +else + /bin/sh -c "$CHAT_CMD" < "$MSG_FILE" > "$REPLY_FILE" & +fi +CHILD=$! + +on_abort() { + kill -TERM "$CHILD" 2>/dev/null || true + printf '{"v":1,"event":"end","turn":%s,"stop_reason":"aborted","text":""}\n' "$TURN_JSON" + cleanup + exit 0 +} +trap on_abort TERM INT HUP + +STATUS=0 +wait "$CHILD" || STATUS=$? +trap - TERM INT HUP + +if [ "$STATUS" -ne 0 ]; then + printf '{"v":1,"event":"error","turn":%s,"code":"agent_failed","text":"CHAT_CMD exited with status %s","fatal":true}\n' "$TURN_JSON" "$STATUS" + printf '{"v":1,"event":"end","turn":%s,"stop_reason":"error","text":""}\n' "$TURN_JSON" + cleanup + exit 0 +fi + +REPLY_JSON=$(json_escape < "$REPLY_FILE") + +# Append to the per-session transcript so session-reset has real state to +# clear. (The template declares session_persistence "none": CHAT_CMD only +# sees the current message. Replay the transcript yourself for "emulated".) +{ + printf '>>> user (%s)\n' "$TURN" + cat "$MSG_FILE" + printf '\n<<< assistant (%s)\n' "$TURN" + cat "$REPLY_FILE" + printf '\n' +} >> "$TRANSCRIPT" 2>/dev/null || true +chown claworc:claworc "$TRANSCRIPT" 2>/dev/null || true + +printf '{"v":1,"event":"assistant","turn":%s,"message_id":"m1","text":%s}\n' "$TURN_JSON" "$REPLY_JSON" +printf '{"v":1,"event":"end","turn":%s,"stop_reason":"complete","text":%s}\n' "$TURN_JSON" "$REPLY_JSON" +cleanup +exit 0 diff --git a/agent/template/shim/config-get b/agent/template/shim/config-get new file mode 100755 index 00000000..45c565b7 --- /dev/null +++ b/agent/template/shim/config-get @@ -0,0 +1,23 @@ +#!/bin/sh +# config-get [--id ] — raw config file bytes on stdout (docs/shim.md). +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE=${CLAWORC_SHIM_ENV_FILE:-$SHIM_DIR/agent.env} + +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi +if [ ! -f "$ENV_FILE" ]; then + echo "config file not found: $ENV_FILE" >&2 + exit 1 +fi +exec cat "$ENV_FILE" diff --git a/agent/template/shim/config-set b/agent/template/shim/config-set new file mode 100755 index 00000000..f2e1a23e --- /dev/null +++ b/agent/template/shim/config-set @@ -0,0 +1,36 @@ +#!/bin/sh +# config-set [--id ] — replace agent.env with stdin bytes. +# Validates shell syntax (exit 6 + {"error":...} on stdout when invalid), +# writes atomically (tmp + mv), does NOT restart the agent (docs/shim.md). +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE=${CLAWORC_SHIM_ENV_FILE:-$SHIM_DIR/agent.env} + +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi + +TMP="$ENV_FILE.shim-tmp.$$" +trap 'rm -f "$TMP"' EXIT + +cat > "$TMP" + +# agent.env is sourced by the verbs — refuse content sh cannot parse. +if ! ERR=$(sh -n "$TMP" 2>&1); then + python3 -c 'import json,sys; print(json.dumps({"error": "invalid shell syntax: " + sys.argv[1]}))' "$ERR" + exit 6 +fi + +chmod 0644 "$TMP" +mv -f "$TMP" "$ENV_FILE" +trap - EXIT +exit 0 diff --git a/agent/template/shim/configure-llm b/agent/template/shim/configure-llm new file mode 100755 index 00000000..b02df667 --- /dev/null +++ b/agent/template/shim/configure-llm @@ -0,0 +1,133 @@ +#!/bin/sh +# configure-llm — routes the agent's LLM traffic through the Claworc LLM +# proxy (docs/shim.md). Reads the generic routing document from stdin and +# rewrites the fully managed block in agent.env — never appends — so the +# verb is idempotent. +# +# The block exports generic CLAWORC_LLM_* variables plus the conventional +# provider env vars for the declared style (OPENAI_BASE_URL/OPENAI_API_KEY or +# ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY), pointing at the proxy with the first +# provider's virtual key. Adapt the block contents to whatever your CHAT_CMD +# agent actually reads. Exit 6 when the routing cannot be expressed. +set -eu + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE=${CLAWORC_SHIM_ENV_FILE:-$SHIM_DIR/agent.env} +export ENV_FILE + +# The heredoc below occupies python's stdin with the program text, so hand +# the real stdin (the routing document) over on fd 3. +exec 3<&0 + +python3 - <<'PY' +import json +import os +import shlex +import sys +import tempfile + +BEGIN = "# >>> claworc-llm >>>" +END = "# <<< claworc-llm <<<" + +env_file = os.environ["ENV_FILE"] + + +def validation_fail(msg): + print(json.dumps({"error": msg})) + sys.exit(6) + + +try: + with os.fdopen(3, "r", encoding="utf-8") as routing_stdin: + doc = json.load(routing_stdin) +except Exception as e: # noqa: BLE001 + validation_fail(f"invalid JSON routing document: {e}") +if not isinstance(doc, dict): + validation_fail("routing document must be a JSON object") + +style = doc.get("style") or "openai" +if style not in ("openai", "anthropic"): + validation_fail(f"unsupported llm style {style!r}") + +proxy_url = doc.get("proxy_url") or "" +providers = doc.get("providers") or [] +if not isinstance(providers, list): + validation_fail("providers must be an array") +if providers and not proxy_url: + validation_fail("proxy_url is required when providers are present") + +default_model = doc.get("default_model") or "" +fallbacks = [m for m in (doc.get("fallback_models") or []) if isinstance(m, str) and m] +api_key = "" +if providers: + first = providers[0] + if not isinstance(first, dict): + validation_fail("providers entries must be objects") + api_key = first.get("api_key") or "" + +block = [ + BEGIN, + "# Managed by the Claworc shim configure-llm verb - do not edit inside this block.", + f"CLAWORC_LLM_PROXY_URL={shlex.quote(proxy_url)}", + f"CLAWORC_LLM_STYLE={shlex.quote(style)}", + f"CLAWORC_LLM_API_KEY={shlex.quote(api_key)}", + f"CLAWORC_LLM_DEFAULT_MODEL={shlex.quote(default_model)}", + f"CLAWORC_LLM_FALLBACK_MODELS={shlex.quote(','.join(fallbacks))}", +] +if style == "openai": + block += [ + f"OPENAI_BASE_URL={shlex.quote(proxy_url)}", + f"OPENAI_API_KEY={shlex.quote(api_key)}", + ] +else: + block += [ + f"ANTHROPIC_BASE_URL={shlex.quote(proxy_url)}", + f"ANTHROPIC_API_KEY={shlex.quote(api_key)}", + ] +block.append(END) + +try: + with open(env_file, encoding="utf-8") as f: + lines = f.read().splitlines() +except FileNotFoundError: + lines = [] + +# Replace the existing managed block in place; append the block when absent. +out, i, replaced = [], 0, False +while i < len(lines): + if lines[i].strip() == BEGIN: + j = i + 1 + while j < len(lines) and lines[j].strip() != END: + j += 1 + out.extend(block) + replaced = True + i = j + 1 # skip END (or run off the end for an unterminated block) + else: + out.append(lines[i]) + i += 1 +if not replaced: + if out and out[-1].strip(): + out.append("") + out.extend(block) + +content = "\n".join(out) + "\n" +d = os.path.dirname(os.path.abspath(env_file)) +fd, tmp = tempfile.mkstemp(dir=d, prefix=".agent.env.") +try: + with os.fdopen(fd, "w", encoding="utf-8") as f: + f.write(content) + os.chmod(tmp, 0o644) + os.replace(tmp, env_file) +except BaseException: + try: + os.unlink(tmp) + except OSError: + pass + raise +PY + +# Keep agent state claworc-owned when invoked as root. +if [ "$(id -u)" = "0" ]; then + chown claworc:claworc "$ENV_FILE" 2>/dev/null || true +fi +exit 0 diff --git a/agent/template/shim/health b/agent/template/shim/health new file mode 100755 index 00000000..814e55c9 --- /dev/null +++ b/agent/template/shim/health @@ -0,0 +1,30 @@ +#!/bin/sh +# health — exit 0 when the agent can take a chat turn, 4 while booting, +# 1 when broken (docs/shim.md). The template has no daemon; "healthy" means +# the configured CHAT_CMD executable resolves. +set -u + +SHIM_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +ENV_FILE=${CLAWORC_SHIM_ENV_FILE:-$SHIM_DIR/agent.env} + +if [ ! -f "$ENV_FILE" ]; then + echo "agent.env missing at $ENV_FILE" >&2 + exit 1 +fi +# shellcheck disable=SC1090 +. "$ENV_FILE" + +if [ -z "${CHAT_CMD:-}" ]; then + echo "CHAT_CMD is not set in $ENV_FILE" >&2 + exit 1 +fi + +# First word of CHAT_CMD must be an executable on PATH (or an absolute path). +set -- $CHAT_CMD +if ! command -v "$1" >/dev/null 2>&1; then + echo "CHAT_CMD executable not found: $1" >&2 + exit 1 +fi + +printf '{"status":"ok"}\n' +exit 0 diff --git a/agent/template/shim/meta b/agent/template/shim/meta new file mode 100755 index 00000000..d7dafaca --- /dev/null +++ b/agent/template/shim/meta @@ -0,0 +1,21 @@ +#!/bin/sh +# meta — Claworc agent shim capability probe (docs/shim.md, contract v1). +# Pure shell, no jq: the JSON is a static heredoc. Keep it in sync with the +# verbs you actually implement — `capabilities` gates the Claworc UI. +set -eu + +cat <<'EOF' +{ + "contract": 1, + "shim_version": "0.1.0", + "agent": {"name": "template"}, + "capabilities": ["chat", "chat.abort", "session.reset", "config", "configure-llm", "restart"], + "config_files": [ + {"id": "main", "path": "/opt/claworc/shim/agent.env", "language": "shell", "label": "agent.env", "restart_required": false} + ], + "workspace_dir": "/home/claworc/workspace", + "log_files": [{"path": "/var/log/claworc/agent.log", "label": "Agent"}], + "llm": {"styles": ["openai"]}, + "session_persistence": "none" +} +EOF diff --git a/agent/template/shim/restart b/agent/template/shim/restart new file mode 100755 index 00000000..60558619 --- /dev/null +++ b/agent/template/shim/restart @@ -0,0 +1,7 @@ +#!/bin/sh +# restart — restart the agent service (docs/shim.md). The template has no +# long-running agent daemon (CHAT_CMD is spawned per turn), so this is a +# no-op success. If you add an s6 service for your agent (conventionally +# `svc-agent`), replace this with: +# exec /command/s6-svc -r /run/service/svc-agent +exit 0 diff --git a/agent/template/shim/session-reset b/agent/template/shim/session-reset new file mode 100755 index 00000000..c0222450 --- /dev/null +++ b/agent/template/shim/session-reset @@ -0,0 +1,20 @@ +#!/bin/sh +# session-reset --session — clear conversation history for the key by +# removing its transcript; the next chat-send starts fresh. Idempotent +# (docs/shim.md). +set -eu + +STATE_DIR=${CLAWORC_SHIM_STATE_DIR:-/home/claworc/.claworc/shim} + +SESSION="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } + +SESSION_FILE=$(printf %s "$SESSION" | tr -c 'A-Za-z0-9._-' '_') +rm -f "$STATE_DIR/sessions/$SESSION_FILE.log" +exit 0 diff --git a/agent/template/shim/shim-selftest b/agent/template/shim/shim-selftest new file mode 100755 index 00000000..e02c7fab --- /dev/null +++ b/agent/template/shim/shim-selftest @@ -0,0 +1,301 @@ +#!/bin/sh +# shim-selftest — conformance check for the Claworc Agent Shim Contract v1 +# (docs/shim.md). Runnable inside any agent image, or against a shim +# directory in the repo: +# +# shim-selftest [shim-dir] # default: /opt/claworc/shim +# docker run --rm my-agent-image /opt/claworc/shim/shim-selftest +# +# Exercises: identity files, meta (JSON + required fields + verb presence per +# capability), health exit codes, chat-send JSONL well-formedness, the config +# round-trip, and configure-llm idempotency. Exits non-zero with a per-check +# report when anything fails. +# +# NOTE: the config and configure-llm checks MUTATE agent configuration +# (config-set writes the current bytes back; configure-llm applies a sample +# routing document twice). Run in a throwaway container/CI, or pass +# --skip-mutating. +set -u + +SHIM_DIR=/opt/claworc/shim +SKIP_MUTATING=0 +for arg in "$@"; do + case "$arg" in + --skip-mutating) SKIP_MUTATING=1 ;; + -*) echo "usage: shim-selftest [--skip-mutating] [shim-dir]" >&2; exit 2 ;; + *) SHIM_DIR=$arg ;; + esac +done + +PASS=0 +FAIL=0 +SKIP=0 +pass() { PASS=$((PASS + 1)); printf '[PASS] %s\n' "$1"; } +fail() { FAIL=$((FAIL + 1)); printf '[FAIL] %s\n' "$1"; } +skip() { SKIP=$((SKIP + 1)); printf '[SKIP] %s\n' "$1"; } + +WORK=$(mktemp -d) +trap 'rm -rf "$WORK"' EXIT + +# --- JSON helpers (jq preferred, python3 fallback) -------------------------- +if command -v jq >/dev/null 2>&1; then + JSON_TOOL=jq +elif command -v python3 >/dev/null 2>&1; then + JSON_TOOL=python3 +else + echo "shim-selftest needs jq or python3 for JSON validation" >&2 + exit 2 +fi + +json_valid() { # json_valid + if [ "$JSON_TOOL" = jq ]; then jq -e . "$1" >/dev/null 2>&1 + else python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$1" >/dev/null 2>&1 + fi +} + +json_query() { # json_query — prints result or "None" + python3 -c ' +import json, sys +d = json.load(open(sys.argv[1])) +try: + print(eval(sys.argv[2], {"d": d})) +except Exception: + print("None") +' "$1" "$2" 2>/dev/null +} + +if [ "$JSON_TOOL" = jq ] && ! command -v python3 >/dev/null 2>&1; then + # json_query needs python3; degrade to jq-only equivalents where used. + json_query() { + case "$2" in + 'd["contract"]') jq -r '.contract' "$1" 2>/dev/null ;; + '"chat" in d.get("capabilities", [])') jq -r '.capabilities | contains(["chat"])' "$1" 2>/dev/null | sed 's/true/True/; s/false/False/' ;; + 'isinstance(d.get("capabilities"), list)') jq -r '.capabilities | type == "array"' "$1" 2>/dev/null | sed 's/true/True/; s/false/False/' ;; + '",".join(d.get("capabilities", []))') jq -r '.capabilities | join(",")' "$1" 2>/dev/null ;; + 'd.get("llm", {}).get("styles", ["openai"])[0]') jq -r '.llm.styles[0] // "openai"' "$1" 2>/dev/null ;; + *) echo "None" ;; + esac + } +fi + +run_timeout() { # run_timeout + if command -v timeout >/dev/null 2>&1; then + t=$1; shift + timeout "$t" "$@" + else + shift + "$@" + fi +} + +# --- 1. identity files ------------------------------------------------------ +if [ -s "$SHIM_DIR/agent.txt" ] && \ + [ "$(wc -l < "$SHIM_DIR/agent.txt" | tr -d ' ')" -le 1 ] && \ + [ -n "$(head -n1 "$SHIM_DIR/agent.txt")" ]; then + pass "agent.txt: present, single line ($(head -n1 "$SHIM_DIR/agent.txt"))" +else + fail "agent.txt: missing, empty, or multi-line" +fi +if [ -s "$SHIM_DIR/agent.svg" ]; then + pass "agent.svg: present" +else + fail "agent.svg: missing or empty" +fi + +# --- 2. meta ---------------------------------------------------------------- +META="$WORK/meta.json" +if [ ! -x "$SHIM_DIR/meta" ]; then + fail "meta: not executable" + echo "shim-selftest: cannot continue without meta" + exit 1 +fi +if run_timeout 30 "$SHIM_DIR/meta" > "$META" 2> "$WORK/meta.err"; then + pass "meta: exit 0" +else + fail "meta: exited non-zero ($(head -c 200 "$WORK/meta.err"))" +fi +if json_valid "$META"; then + pass "meta: stdout is valid JSON" +else + fail "meta: stdout is not valid JSON" +fi +CONTRACT=$(json_query "$META" 'd["contract"]') +if [ "$CONTRACT" = "1" ]; then + pass "meta: contract=1" +else + fail "meta: contract must be the integer 1, got '$CONTRACT'" +fi +if [ "$(json_query "$META" 'isinstance(d.get("capabilities"), list)')" = "True" ]; then + pass "meta: capabilities is an array" +else + fail "meta: capabilities missing or not an array" +fi +if [ "$(json_query "$META" '"chat" in d.get("capabilities", [])')" = "True" ]; then + pass "meta: capabilities include required \"chat\"" +else + fail "meta: capabilities must include \"chat\"" +fi + +CAPS=$(json_query "$META" '",".join(d.get("capabilities", []))') +[ "$CAPS" = "None" ] && CAPS="" +has_cap() { printf ',%s,' "$CAPS" | grep -q ",$1,"; } + +# Verb executables required by declared capabilities. +check_verb() { # check_verb + if [ -x "$SHIM_DIR/$1" ]; then + pass "verb $1: executable" + elif [ "$2" = 1 ]; then + fail "verb $1: missing/not executable but required ($3)" + else + skip "verb $1: absent (capability not declared)" + fi +} +check_verb health 1 "always required" +check_verb chat-send 1 "chat capability is mandatory" +check_verb chat-abort "$(has_cap chat.abort && echo 1 || echo 0)" "chat.abort declared" +check_verb session-reset "$(has_cap session.reset && echo 1 || echo 0)" "session.reset declared" +check_verb config-get "$(has_cap config && echo 1 || echo 0)" "config declared" +check_verb config-set "$(has_cap config && echo 1 || echo 0)" "config declared" +check_verb configure-llm "$(has_cap configure-llm && echo 1 || echo 0)" "configure-llm declared" +check_verb restart "$(has_cap restart && echo 1 || echo 0)" "restart declared" + +# --- 3. health -------------------------------------------------------------- +HEALTH_RC=0 +run_timeout 30 "$SHIM_DIR/health" > "$WORK/health.out" 2> "$WORK/health.err" || HEALTH_RC=$? +case "$HEALTH_RC" in + 0) pass "health: exit 0 (ready)" ;; + 4) pass "health: exit 4 (agent booting — contract-legal)" ;; + 1|5) pass "health: exit $HEALTH_RC (broken/timeout — contract-legal code)" ;; + *) fail "health: exit $HEALTH_RC is outside the contract's code set {0,1,4,5}" ;; +esac +if [ -s "$WORK/health.out" ] && ! json_valid "$WORK/health.out"; then + fail "health: stdout present but not valid JSON" +fi + +# --- 4. chat-send JSONL ----------------------------------------------------- +if [ "$HEALTH_RC" -ne 0 ]; then + skip "chat-send: agent not ready (health exit $HEALTH_RC)" +else + CHAT_OUT="$WORK/chat.jsonl" + CHAT_RC=0 + printf 'shim-selftest ping: reply with anything.' | \ + run_timeout 180 "$SHIM_DIR/chat-send" --session shim-selftest --turn t-selftest \ + > "$CHAT_OUT" 2> "$WORK/chat.err" || CHAT_RC=$? + if [ "$CHAT_RC" -eq 0 ]; then + pass "chat-send: exit 0" + else + fail "chat-send: exit $CHAT_RC ($(head -c 200 "$WORK/chat.err"))" + fi + if [ ! -s "$CHAT_OUT" ]; then + fail "chat-send: produced no output" + else + BAD_LINES=0 + TOTAL_LINES=0 + while IFS= read -r line; do + [ -n "$line" ] || continue + TOTAL_LINES=$((TOTAL_LINES + 1)) + printf '%s' "$line" > "$WORK/line.json" + json_valid "$WORK/line.json" || BAD_LINES=$((BAD_LINES + 1)) + done < "$CHAT_OUT" + if [ "$BAD_LINES" -eq 0 ]; then + pass "chat-send: all $TOTAL_LINES JSONL lines parse" + else + fail "chat-send: $BAD_LINES of $TOTAL_LINES lines are not valid JSON" + fi + ENDS=$(grep -c '"event":"end"' "$CHAT_OUT" || true) + if [ "$ENDS" = "1" ]; then + pass "chat-send: exactly one end event" + else + fail "chat-send: expected exactly one end event, got $ENDS" + fi + tail -n1 "$CHAT_OUT" > "$WORK/last.json" + if [ "$(json_query "$WORK/last.json" 'd.get("event")')" = "end" ] && \ + [ "$(json_query "$WORK/last.json" 'd.get("stop_reason") in ("complete","aborted","error")')" = "True" ]; then + pass "chat-send: last line is end with a legal stop_reason" + else + fail "chat-send: last line must be the end event with stop_reason complete|aborted|error" + fi + if grep -q '"event":"start"' "$CHAT_OUT"; then + pass "chat-send: start event present" + else + fail "chat-send: no start event emitted" + fi + fi +fi + +# --- 5. config round-trip --------------------------------------------------- +if ! has_cap config; then + skip "config round-trip: config capability not declared" +elif [ "$SKIP_MUTATING" = 1 ]; then + skip "config round-trip: --skip-mutating" +else + RC=0 + "$SHIM_DIR/config-get" > "$WORK/cfg.before" 2> "$WORK/cfg.err" || RC=$? + if [ "$RC" -ne 0 ]; then + fail "config-get: exit $RC ($(head -c 200 "$WORK/cfg.err"))" + else + RC=0 + "$SHIM_DIR/config-set" < "$WORK/cfg.before" > "$WORK/cfgset.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "config-set: exit $RC writing back unmodified config ($(head -c 200 "$WORK/cfgset.out"))" + else + "$SHIM_DIR/config-get" > "$WORK/cfg.after" 2>/dev/null || true + if cmp -s "$WORK/cfg.before" "$WORK/cfg.after"; then + pass "config: get -> set -> get round-trips byte-identical" + else + fail "config: round-trip altered the file" + fi + fi + fi +fi + +# --- 6. configure-llm idempotency ------------------------------------------- +if ! has_cap configure-llm; then + skip "configure-llm: capability not declared" +elif [ "$SKIP_MUTATING" = 1 ]; then + skip "configure-llm: --skip-mutating" +else + STYLE=$(json_query "$META" 'd.get("llm", {}).get("styles", ["openai"])[0]') + [ "$STYLE" = "None" ] && STYLE=openai + cat > "$WORK/routing.json" < "$WORK/llm1.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "configure-llm: first run exit $RC ($(head -c 200 "$WORK/llm1.out"))" + else + pass "configure-llm: first run exit 0" + if has_cap config; then + "$SHIM_DIR/config-get" > "$WORK/llm.state1" 2>/dev/null || true + fi + RC=0 + "$SHIM_DIR/configure-llm" < "$WORK/routing.json" > "$WORK/llm2.out" 2>&1 || RC=$? + if [ "$RC" -ne 0 ]; then + fail "configure-llm: second run exit $RC (must be idempotent)" + elif has_cap config; then + "$SHIM_DIR/config-get" > "$WORK/llm.state2" 2>/dev/null || true + if cmp -s "$WORK/llm.state1" "$WORK/llm.state2"; then + pass "configure-llm: idempotent (config identical after second run)" + else + fail "configure-llm: second run changed the config again (not idempotent)" + fi + else + pass "configure-llm: second run exit 0 (no config verb to diff state)" + fi + fi +fi + +# --- report ----------------------------------------------------------------- +printf '\nshim-selftest: %d passed, %d failed, %d skipped\n' "$PASS" "$FAIL" "$SKIP" +[ "$FAIL" -eq 0 ] || exit 1 +exit 0 diff --git a/agent/tests/openclaw.test.ts b/agent/tests/openclaw.test.ts index 3f32b16c..11baeabe 100644 --- a/agent/tests/openclaw.test.ts +++ b/agent/tests/openclaw.test.ts @@ -20,7 +20,7 @@ function structureOf(obj: any): any { describe.skipIf(!container)("agent image", { timeout: 300_000 }, () => { // Wait for openclaw gateway to be ready. - // The svc-openclaw run script executes `openclaw doctor --fix` followed by + // The svc-agent run script executes `openclaw doctor --fix` followed by // several `openclaw config set` commands before starting the gateway — each // spawns Node.js under QEMU emulation, which is very slow with concurrent // containers. By the time browser.test.ts finishes, the gateway is usually ready. @@ -168,7 +168,7 @@ describe.skipIf(!container)("agent image", { timeout: 300_000 }, () => { // native addon (libvips) used by openclaw's image pipeline (Telegram, // screenshots). Upstream openclaw lazy-imports sharp but no longer // declares it in package.json, so the Dockerfile installs it explicitly - // (see `npm install --no-save sharp` in agent/instance/Dockerfile, plus + // (see `npm install --no-save sharp` in agent/openclaw/Dockerfile, plus // the libvips42 apt package). describe("sharp image dependency (issue #127)", () => { const cdOpenclaw = 'cd "$(npm root -g)/openclaw"'; diff --git a/control-plane/frontend/src/app/App.tsx b/control-plane/frontend/src/app/App.tsx index 4ffd3f3f..c3030685 100644 --- a/control-plane/frontend/src/app/App.tsx +++ b/control-plane/frontend/src/app/App.tsx @@ -17,7 +17,6 @@ import ChatPopupPage from "./pages/ChatPopupPage"; import SkillsPage from "./pages/SkillsPage"; import BackupsPage from "./pages/BackupsPage"; import SharedFoldersPage from "./pages/SharedFoldersPage"; -import KanbanPage from "./pages/KanbanPage"; import { useAuth } from "@common/contexts/AuthContext"; import { checkSetupRequired } from "@common/api/auth"; @@ -112,7 +111,6 @@ export default function App() { /> } /> } /> - } /> } /> { setEditedConfig(null); toast.custom( - createElement(AppToast, { title: "OpenClaw settings saved", status: "success", toastId }), + createElement(AppToast, { title: "Agent settings saved", status: "success", toastId }), { id: toastId, duration: 3000 }, ); }, @@ -557,11 +557,15 @@ export default function AgentDetailPage() { ); }; + // The Config tab is hidden when the agent type declares no config + // capability (agent_capabilities is only present on the detail response; + // undefined means "unknown" and keeps the tab visible). + const hasConfigCapability = instance.agent_capabilities?.config !== false; const tabs: { key: Tab; label: string }[] = [ { key: "chat", label: "Chat" }, { key: "terminal", label: "Terminal" }, { key: "files", label: "Files" }, - { key: "config", label: "Config" }, + ...(hasConfigCapability ? [{ key: "config", label: "Config" } as { key: Tab; label: string }] : []), { key: "logs", label: "Logs" }, { key: "settings", label: "Settings" }, ]; @@ -576,6 +580,11 @@ export default function AgentDetailPage() {

{instance.display_name}

+ {instance.agent_display_name && ( + + {instance.agent_display_name} + + )} )} - {activeTab === "config" && ( + {activeTab === "config" && hasConfigCapability && (
{instance.status !== "running" ? ( @@ -1327,12 +1336,13 @@ export default function AgentDetailPage() { value={currentConfig} onChange={(v) => setEditedConfig(v ?? "{}")} height="100%" + language={configData?.language || "json"} />
- Saving will restart the openclaw-gateway service. + Saving will restart the agent service.
- - {instance.display_name} - +
+ + {instance.display_name} + + {instance.agent_display_name && ( + + {instance.agent_display_name} + + )} +
= { VNC: "Browser", CDP: "Browser CDP", - Gateway: "OpenClaw", + Gateway: "Agent Gateway", LLMProxy: "API Gateway", }; diff --git a/control-plane/frontend/src/common/components/Sidebar.tsx b/control-plane/frontend/src/common/components/Sidebar.tsx index a5a316d8..191bbc7b 100644 --- a/control-plane/frontend/src/common/components/Sidebar.tsx +++ b/control-plane/frontend/src/common/components/Sidebar.tsx @@ -10,7 +10,6 @@ import { BookOpen, HardDrive, FolderOpen, - Trello, ShieldCheck, UsersRound, } from "lucide-react"; @@ -111,12 +110,6 @@ export default function Sidebar() { )} - - - - Kanban - - diff --git a/control-plane/frontend/src/common/hooks/useAgentTypes.ts b/control-plane/frontend/src/common/hooks/useAgentTypes.ts new file mode 100644 index 00000000..c476c2a3 --- /dev/null +++ b/control-plane/frontend/src/common/hooks/useAgentTypes.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchAgentTypes } from "@common/api/agentTypes"; + +/** Static agent-type registry (with resolved default images). Rarely changes. */ +export function useAgentTypes() { + return useQuery({ + queryKey: ["agent-types"], + queryFn: fetchAgentTypes, + staleTime: 5 * 60 * 1000, + }); +} diff --git a/control-plane/frontend/src/common/hooks/useChat.ts b/control-plane/frontend/src/common/hooks/useChat.ts index c133f236..6c54465f 100644 --- a/control-plane/frontend/src/common/hooks/useChat.ts +++ b/control-plane/frontend/src/common/hooks/useChat.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import type { ChatMessage, ConnectionState, GatewayFrame } from "@common/types/chat"; +import type { ChatFrame, ChatMessage, ConnectionState } from "@common/types/chat"; let msgCounter = 0; function nextId(): string { @@ -10,22 +10,6 @@ const BACKOFF_INITIAL = 1000; const BACKOFF_MAX = 30000; const MAX_RETRIES = 5; -/** Extract text from a gateway chat message content field (array of blocks or string). */ -function extractText(content: unknown): string | undefined { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - const parts: string[] = []; - for (const block of content) { - if (typeof block === "string") parts.push(block); - else if (block && typeof block === "object" && typeof (block as any).text === "string") { - parts.push((block as any).text); - } - } - return parts.length > 0 ? parts.join("") : undefined; - } - return undefined; -} - export function useChat(instanceId: number, enabled: boolean, initialMessages?: ChatMessage[]) { const [messages, setMessages] = useState(initialMessages ?? []); const [connectionState, setConnectionState] = @@ -37,10 +21,10 @@ export function useChat(instanceId: number, enabled: boolean, initialMessages?: const reconnectTimerRef = useRef | null>(null); const stableTimerRef = useRef | null>(null); const enabledRef = useRef(enabled); - // Track the current streaming run so we can update the message in-place - const streamingRunRef = useRef<{ runId: string; msgId: string } | null>(null); - // Track completed run IDs so chat snapshots arriving after lifecycle end don't create duplicates - const completedRunsRef = useRef>(new Set()); + // Track the current streaming assistant message so we can update it in-place + const streamingRef = useRef<{ messageId: string; msgId: string } | null>(null); + // Track completed message IDs so stray snapshots arriving after `end` don't create duplicates + const completedMessagesRef = useRef>(new Set()); useEffect(() => { enabledRef.current = enabled; @@ -93,156 +77,93 @@ export function useChat(instanceId: number, enabled: boolean, initialMessages?: }; ws.onmessage = (event) => { - let frame: GatewayFrame; + let frame: ChatFrame; try { frame = JSON.parse(event.data); } catch { return; } + if (!frame || typeof frame !== "object") return; + + // Backend handshake frame + if ("type" in frame && frame.type === "connected") { + setConnectionState("connected"); + // Only reset retries after connection is stable for 5s + // This prevents infinite reconnect loops when connections drop immediately + if (stableTimerRef.current) clearTimeout(stableTimerRef.current); + stableTimerRef.current = setTimeout(() => { + retriesRef.current = 0; + backoffRef.current = BACKOFF_INITIAL; + }, 5000); + // Only add message if last message isn't already "Connected to Agent" + setMessages((prev) => { + const last = prev[prev.length - 1]; + if (last?.role === "system" && last.content === "Connected to Agent") { + return prev; + } + return [...prev, { id: nextId(), role: "system", content: "Connected to Agent", timestamp: Date.now() }]; + }); + return; + } - switch (frame.type) { - case "connected": - setConnectionState("connected"); - // Only reset retries after connection is stable for 5s - // This prevents infinite reconnect loops when connections drop immediately - if (stableTimerRef.current) clearTimeout(stableTimerRef.current); - stableTimerRef.current = setTimeout(() => { - retriesRef.current = 0; - backoffRef.current = BACKOFF_INITIAL; - }, 5000); - // Only add message if last message isn't already "Connected to Gateway" - setMessages((prev) => { - const last = prev[prev.length - 1]; - if (last?.role === "system" && last.content === "Connected to Gateway") { - return prev; - } - return [...prev, { id: nextId(), role: "system", content: "Connected to Gateway", timestamp: Date.now() }]; - }); - break; + // Normalized shim events (forwarded verbatim by the backend) + if (!("event" in frame)) return; - case "chat": - setMessages((prev) => [ - ...prev, - { - id: nextId(), - role: frame.role, - content: frame.content, - timestamp: Date.now(), - }, - ]); + switch (frame.event) { + case "start": + setThinkingLabel("Thinking..."); break; - case "agent": { - const eventName = frame.event; - if (eventName === "thinking") { - setThinkingLabel("Thinking..."); - } else if (eventName === "tool_use") { - setThinkingLabel("Working..."); + case "assistant": { + const messageId = frame.message_id; + const text = frame.text; + if (!messageId || typeof text !== "string") break; + + setThinkingLabel(null); + + // Skip stray snapshots for messages already finalized by `end` + if (completedMessagesRef.current.has(messageId)) break; + + const current = streamingRef.current; + if (current && current.messageId === messageId) { + // Cumulative snapshot — replace the streaming message's content in-place + setMessages((prev) => + prev.map((m) => + m.id === current.msgId ? { ...m, content: text } : m, + ), + ); + } else { + // New message_id — finalize any previous streaming message and create a new one + if (current) completedMessagesRef.current.add(current.messageId); + const msgId = nextId(); + streamingRef.current = { messageId, msgId }; + setMessages((prev) => [ + ...prev, + { id: msgId, role: "agent", content: text, timestamp: Date.now() }, + ]); } break; } - case "error": - addSystemMessage(`Error: ${frame.message}`); + case "tool": + setThinkingLabel("Working..."); break; - // Raw gateway event frames (forwarded as-is from the gateway) - case "event": { - const ev = frame.event; - const payload = frame.payload as Record | undefined; - if (!payload) break; - - // Skip heartbeat ticks, presence, and health events - if (ev === "tick" || ev === "presence" || ev === "health") break; - - if (ev === "agent") { - const stream = payload.stream as string | undefined; - const data = payload.data as Record | undefined; - const runId = payload.runId as string | undefined; - - if (stream === "assistant" && data && runId) { - const text = data.text as string | undefined; - if (!text) break; - - setThinkingLabel(null); - - const current = streamingRunRef.current; - if (current && current.runId === runId) { - // Update existing streaming message in-place - setMessages((prev) => - prev.map((m) => - m.id === current.msgId ? { ...m, content: text } : m, - ), - ); - } else { - // New run — create a new agent message - const msgId = nextId(); - streamingRunRef.current = { runId, msgId }; - setMessages((prev) => [ - ...prev, - { id: msgId, role: "agent", content: text, timestamp: Date.now() }, - ]); - } - } else if (stream === "lifecycle") { - const phase = (data as any)?.phase as string | undefined; - if (phase === "start") { - setThinkingLabel("Thinking..."); - } else if (phase === "end") { - setThinkingLabel(null); - const cur = streamingRunRef.current; - if (cur) completedRunsRef.current.add(cur.runId); - streamingRunRef.current = null; - } - } - break; - } - - if (ev === "chat") { - // Chat events are periodic snapshots — use them as fallback - // if we missed the agent stream events - const msg = payload.message as Record | undefined; - if (!msg) break; - const runId = payload.runId as string | undefined; - const text = extractText(msg.content); - if (!text) break; - const role = msg.role === "user" ? "user" as const : "agent" as const; - - // Skip if this run was already completed via agent stream events - if (runId && completedRunsRef.current.has(runId)) break; - - const current = streamingRunRef.current; - if (current && runId && current.runId === runId) { - // Already tracking this run via agent events — update with snapshot - setMessages((prev) => - prev.map((m) => - m.id === current.msgId ? { ...m, content: text } : m, - ), - ); - } else if (!current || (runId && current.runId !== runId)) { - // Not tracking — create a new message - setThinkingLabel(null); - const msgId = nextId(); - if (runId) { - streamingRunRef.current = { runId, msgId }; - } - setMessages((prev) => [ - ...prev, - { id: msgId, role, content: text, timestamp: Date.now() }, - ]); - } - break; - } + case "error": + addSystemMessage(`Error: ${frame.text ?? frame.code ?? "unknown"}`); + break; + case "end": { + setThinkingLabel(null); + const current = streamingRef.current; + if (current) completedMessagesRef.current.add(current.messageId); + streamingRef.current = null; break; } - // Raw gateway response frames (ack for chat.send etc.) - case "res": { - if (!frame.ok && frame.error) { - addSystemMessage(`Error: ${frame.error.message ?? frame.error.code ?? "unknown"}`); - } + // Unknown event types MUST be ignored (forward compatibility) + default: break; - } } }; @@ -320,7 +241,7 @@ export function useChat(instanceId: number, enabled: boolean, initialMessages?: const stopResponse = useCallback(() => { sendCommand("/stop"); setThinkingLabel(null); - streamingRunRef.current = null; + streamingRef.current = null; }, [sendCommand]); /** Abort current run, reset session, and clear local history */ @@ -328,8 +249,8 @@ export function useChat(instanceId: number, enabled: boolean, initialMessages?: sendCommand("/stop"); sendCommand("/new"); setThinkingLabel(null); - streamingRunRef.current = null; - completedRunsRef.current.clear(); + streamingRef.current = null; + completedMessagesRef.current.clear(); setMessages([]); }, [sendCommand]); diff --git a/control-plane/frontend/src/common/types/chat.ts b/control-plane/frontend/src/common/types/chat.ts index 47ec4f6f..763bb445 100644 --- a/control-plane/frontend/src/common/types/chat.ts +++ b/control-plane/frontend/src/common/types/chat.ts @@ -7,49 +7,64 @@ export interface ChatMessage { timestamp: number; } -/** Frames received from the Gateway (via backend proxy) */ -export interface GatewayConnectedFrame { +/** Handshake frame sent by the backend chat proxy right after the WebSocket opens */ +export interface ConnectedFrame { type: "connected"; } -export interface GatewayChatFrame { - type: "chat"; - role: "agent" | "user"; - content: string; +/** + * Normalized agent shim chat events (see docs/shim.md, contract v1), + * forwarded verbatim by the backend after the "connected" handshake. + */ +export interface ShimStartEvent { + v: number; + event: "start"; + session?: string; + turn?: string; } -export interface GatewayAgentFrame { - type: "agent"; - event: string; - data?: unknown; +export interface ShimAssistantEvent { + v: number; + event: "assistant"; + turn?: string; + message_id: string; + /** CUMULATIVE snapshot of the full message text so far (not a delta) */ + text: string; } -export interface GatewayErrorFrame { - type: "error"; - message: string; +export interface ShimToolEvent { + v: number; + event: "tool"; + turn?: string; + name?: string; + phase?: "start" | "result" | string; + detail?: Record; } -/** Raw gateway event frame (forwarded as-is from the gateway) */ -export interface GatewayEventFrame { - type: "event"; - event: string; - payload?: Record; - seq?: number; +export interface ShimErrorEvent { + v: number; + event: "error"; + turn?: string; + code?: string; + text?: string; + fatal?: boolean; } -/** Raw gateway response frame (ack for chat.send etc.) */ -export interface GatewayResponseFrame { - type: "res"; - id: string; - ok: boolean; - payload?: Record; - error?: { code?: string; message?: string }; +export interface ShimEndEvent { + v: number; + event: "end"; + turn?: string; + stop_reason?: "complete" | "aborted" | "error"; + /** Final text of the last assistant message */ + text?: string; } -export type GatewayFrame = - | GatewayConnectedFrame - | GatewayChatFrame - | GatewayAgentFrame - | GatewayErrorFrame - | GatewayEventFrame - | GatewayResponseFrame; +export type ShimChatEvent = + | ShimStartEvent + | ShimAssistantEvent + | ShimToolEvent + | ShimErrorEvent + | ShimEndEvent; + +/** Frames received over the instance chat WebSocket */ +export type ChatFrame = ConnectedFrame | ShimChatEvent; diff --git a/control-plane/frontend/src/common/types/instance.ts b/control-plane/frontend/src/common/types/instance.ts index 0ec889ea..78e3da6f 100644 --- a/control-plane/frontend/src/common/types/instance.ts +++ b/control-plane/frontend/src/common/types/instance.ts @@ -4,10 +4,29 @@ export interface InstanceModels { extra: string[]; } +export interface AgentCapabilities { + chat: boolean; + chat_abort: boolean; + session_reset: boolean; + config: boolean; + configure_llm: boolean; + restart: boolean; + control_ui: boolean; + skills: boolean; +} + export interface Instance { id: number; name: string; display_name: string; + /** Effective agent type ("openclaw" for pre-shim rows). */ + agent_type: string; + /** Registry display name for agent_type (e.g. "OpenClaw"). */ + agent_display_name: string; + /** Whether this agent type serves a web control UI. */ + has_control_ui: boolean; + /** Static registry capabilities. Only present on the detail response. */ + agent_capabilities?: AgentCapabilities; status: "creating" | "running" | "restarting" | "stopping" | "stopped" | "error"; status_message?: string; cpu_request: string; @@ -72,6 +91,8 @@ export type InstanceDetail = Instance; export interface InstanceCreatePayload { display_name: string; + /** Agent type ("openclaw" | "hermes" | "nanoclaw" | "custom"); defaults to "openclaw". */ + agent_type?: string; cpu_request?: string; cpu_limit?: string; memory_request?: string; @@ -180,9 +201,12 @@ export interface LLMProvider { export interface InstanceConfig { config: string; + /** Monaco editor language for the config file (json | yaml | toml | ini | shell | plaintext). */ + language?: string; } export interface InstanceConfigUpdate { config: string; restarted: boolean; + language?: string; } diff --git a/control-plane/frontend/src/common/types/settings.ts b/control-plane/frontend/src/common/types/settings.ts index cbdc2ee0..4cb8086f 100644 --- a/control-plane/frontend/src/common/types/settings.ts +++ b/control-plane/frontend/src/common/types/settings.ts @@ -9,6 +9,8 @@ export interface Settings { default_models: string[]; default_container_image: string; default_agent_image: string; + /** Per-agent-type default images for non-OpenClaw types (hermes/nanoclaw/custom). */ + default_agent_images: Record; default_browser_image: string; default_vnc_resolution: string; default_cpu_request: string; @@ -44,6 +46,9 @@ export interface SettingsUpdatePayload { default_models?: string[]; brave_api_key?: string; default_container_image?: string; + default_agent_image?: string; + /** Per-agent-type default images for non-OpenClaw types. */ + default_agent_images?: Record; default_vnc_resolution?: string; default_cpu_request?: string; default_cpu_limit?: string; diff --git a/control-plane/internal/agentshim/factory.go b/control-plane/internal/agentshim/factory.go new file mode 100644 index 00000000..07efad1b --- /dev/null +++ b/control-plane/internal/agentshim/factory.go @@ -0,0 +1,214 @@ +package agentshim + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/gluk-w/claworc/control-plane/internal/database" + "github.com/gluk-w/claworc/control-plane/internal/utils" + gossh "golang.org/x/crypto/ssh" +) + +// InstanceDeps bundles the per-instance transport-level dependencies an +// adapter needs. Everything here is resolved lazily so that operations which +// don't need a given dependency (e.g. config reads don't need the gateway +// tunnel) never pay for — or fail on — its resolution. +type InstanceDeps struct { + // Instance is the database record snapshot taken by the factory. + Instance database.Instance + // GatewayToken is the decrypted intra-container agent auth token + // (may be empty). + GatewayToken string + // TunnelPort resolves the local port of an active SSH tunnel for the + // given service type (e.g. "gateway"). + TunnelPort func(service string) (int, error) + // SSHClient resolves an established SSH client to the instance. + SSHClient func(ctx context.Context) (*gossh.Client, error) +} + +// Constructor builds an adapter Client from instance dependencies. +type Constructor func(deps InstanceDeps) Client + +var adapters = map[string]Constructor{} + +// RegisterAdapter registers an adapter constructor for an agent type. +// Adapters call this from init(); the map is not mutated afterwards. +func RegisterAdapter(agentType string, ctor Constructor) { + adapters[agentType] = ctor +} + +// ShimAdapterType is the registry key of the exec-based shim adapter +// (internal/agentshim/shimexec), the standard path for every agent image +// implementing docs/shim.md. +const ShimAdapterType = "shimexec" + +// shimProbePath is the file whose executability marks a shim-capable image. +const shimProbePath = "/opt/claworc/shim/meta" + +// shimProbeTTL bounds how long a probe result is trusted. Shim presence only +// changes with image content, but a same-tag image can be re-pulled with +// different content, so results self-heal on a short TTL. +const shimProbeTTL = 5 * time.Minute + +type shimProbeEntry struct { + image string + hasShim bool + at time.Time +} + +// Factory builds agent Clients for instances. Its function fields are the +// wiring seams: production code points them at the handlers' tunnel manager +// and SSH manager; tests inject stubs. +type Factory struct { + // TunnelPort resolves the local port of an active SSH tunnel for an + // instance and service type (e.g. "gateway"). + TunnelPort func(instanceID uint, service string) (int, error) + // SSHClient resolves an established SSH client for an instance, + // honoring its source-IP restrictions. + SSHClient func(ctx context.Context, inst database.Instance) (*gossh.Client, error) + // ProbeShim reports whether the instance's image ships the exec shim + // contract. Nil selects the default SSH probe (test -x on shimProbePath). + // Tests inject stubs here. + ProbeShim func(ctx context.Context, deps InstanceDeps) (bool, error) + + // probeCache caches shim-probe results per instance ID, keyed to the + // image string and bounded by shimProbeTTL. + probeCache sync.Map // uint -> shimProbeEntry +} + +// ForInstance loads the instance record, decrypts its gateway token, and +// returns the agent Client for its agent type. Today every image is OpenClaw, +// so the type switch has a single arm; a shimexec adapter for images +// implementing the exec-based shim contract (docs/shim.md) plugs in here +// later. +func (f *Factory) ForInstance(ctx context.Context, instanceID uint) (Client, error) { + var inst database.Instance + if err := database.DB.First(&inst, instanceID).Error; err != nil { + return nil, fmt.Errorf("instance %d not found: %w", instanceID, err) + } + + var token string + if inst.GatewayToken != "" { + if tok, err := utils.Decrypt(inst.GatewayToken); err == nil && tok != "" { + token = tok + } + } + + deps := InstanceDeps{ + Instance: inst, + GatewayToken: token, + TunnelPort: func(service string) (int, error) { + if f.TunnelPort == nil { + return 0, fmt.Errorf("agentshim factory: no tunnel port resolver configured") + } + return f.TunnelPort(instanceID, service) + }, + SSHClient: func(ctx context.Context) (*gossh.Client, error) { + if f.SSHClient == nil { + return nil, fmt.Errorf("agentshim factory: no SSH client resolver configured") + } + return f.SSHClient(ctx, inst) + }, + } + + // Agent-type dispatch. Non-OpenClaw types always go through the exec + // shim contract. OpenClaw prefers the shim when the image ships it and + // falls back to the native gateway adapter for pre-shim images — the + // backward-compatibility guarantee: legacy deployments keep working + // without any image rebuild. + adapterKey := ShimAdapterType + if inst.EffectiveAgentType() == "openclaw" && !f.imageHasShim(ctx, deps) { + adapterKey = "openclaw" + } + + ctor, ok := adapters[adapterKey] + if !ok { + return nil, fmt.Errorf("no agent adapter registered for type %q", adapterKey) + } + return ctor(deps), nil +} + +// InvalidateShimProbe drops the cached shim-probe result for an instance. +// Call after image updates so the next ForInstance re-probes immediately. +func (f *Factory) InvalidateShimProbe(instanceID uint) { + f.probeCache.Delete(instanceID) +} + +// imageHasShim reports whether the instance's image ships the exec shim, +// caching the answer per (instance, image) with a TTL. Probe failures are +// fail-open to the legacy adapter and are not cached. +func (f *Factory) imageHasShim(ctx context.Context, deps InstanceDeps) bool { + id := deps.Instance.ID + image := deps.Instance.ContainerImage + if e, ok := f.probeCache.Load(id); ok { + entry := e.(shimProbeEntry) + if entry.image == image && time.Since(entry.at) < shimProbeTTL { + return entry.hasShim + } + } + + probe := f.ProbeShim + if probe == nil { + probe = defaultShimProbe + } + hasShim, err := probe(ctx, deps) + if err != nil { + return false + } + f.probeCache.Store(id, shimProbeEntry{image: image, hasShim: hasShim, at: time.Now()}) + return hasShim +} + +// defaultShimProbe checks shim presence with a single cheap exec over the +// instance's SSH connection. +func defaultShimProbe(ctx context.Context, deps InstanceDeps) (bool, error) { + client, err := deps.SSHClient(ctx) + if err != nil { + return false, err + } + sess, err := client.NewSession() + if err != nil { + return false, err + } + defer sess.Close() + + done := make(chan error, 1) + go func() { done <- sess.Run("test -x " + shimProbePath) }() + select { + case err := <-done: + // Exit status 1 means "no shim", not a probe failure. + if err == nil { + return true, nil + } + var exitErr *gossh.ExitError + if errors.As(err, &exitErr) { + return false, nil + } + return false, err + case <-time.After(5 * time.Second): + return false, fmt.Errorf("shim probe timed out") + case <-ctx.Done(): + return false, ctx.Err() + } +} + +// defaultFactory is set once at package wiring time (handlers init) and read +// afterwards; see SetDefaultFactory. +var defaultFactory *Factory + +// SetDefaultFactory installs the process-wide factory. Called once from the +// handlers package's wiring before any request is served. +func SetDefaultFactory(f *Factory) { defaultFactory = f } + +// DefaultFactory returns the process-wide factory. It always returns a +// non-nil Factory; an unwired factory yields descriptive errors from its +// resolvers rather than nil-pointer panics. +func DefaultFactory() *Factory { + if defaultFactory == nil { + return &Factory{} + } + return defaultFactory +} diff --git a/control-plane/internal/agentshim/factory_test.go b/control-plane/internal/agentshim/factory_test.go new file mode 100644 index 00000000..852118c8 --- /dev/null +++ b/control-plane/internal/agentshim/factory_test.go @@ -0,0 +1,207 @@ +package agentshim_test + +import ( + "context" + "fmt" + "testing" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + _ "github.com/gluk-w/claworc/control-plane/internal/agentshim/openclawnative" + _ "github.com/gluk-w/claworc/control-plane/internal/agentshim/shimexec" + "github.com/gluk-w/claworc/control-plane/internal/database" + gossh "golang.org/x/crypto/ssh" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +func setupTestDB(t *testing.T) { + t.Helper() + dsn := fmt.Sprintf("file:agentshim_%s_%p?mode=memory&cache=shared", t.Name(), t) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := db.AutoMigrate(&database.Instance{}); err != nil { + t.Fatalf("automigrate: %v", err) + } + database.DB = db +} + +func TestFactory_ForInstance(t *testing.T) { + setupTestDB(t) + inst := database.Instance{Name: "bot-shim-test", DisplayName: "Shim Test", Status: "running"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatalf("create instance: %v", err) + } + + tunnelCalls := 0 + f := &agentshim.Factory{ + TunnelPort: func(instanceID uint, service string) (int, error) { + tunnelCalls++ + if instanceID != inst.ID { + t.Errorf("tunnel port asked for instance %d, want %d", instanceID, inst.ID) + } + if service != "gateway" { + t.Errorf("tunnel port asked for service %q, want gateway", service) + } + return 0, fmt.Errorf("no tunnel in test") + }, + SSHClient: func(ctx context.Context, i database.Instance) (*gossh.Client, error) { + return nil, fmt.Errorf("no ssh in test") + }, + } + + client, err := f.ForInstance(context.Background(), inst.ID) + if err != nil { + t.Fatalf("ForInstance: %v", err) + } + if client.Type() != "openclaw" { + t.Fatalf("client type = %q, want openclaw", client.Type()) + } + + // Deps are resolved lazily and routed to the factory's seams. + if err := client.Health(context.Background()); err == nil { + t.Fatal("Health should fail without a tunnel") + } + if tunnelCalls != 1 { + t.Fatalf("tunnel resolver called %d times, want 1", tunnelCalls) + } + + caps, err := client.Capabilities(context.Background()) + if err != nil { + t.Fatalf("Capabilities: %v", err) + } + if !caps.Chat { + t.Fatal("openclaw client must report chat capability") + } +} + +func TestFactory_Dispatch(t *testing.T) { + setupTestDB(t) + + newFactory := func(probe func(context.Context, agentshim.InstanceDeps) (bool, error)) *agentshim.Factory { + return &agentshim.Factory{ + SSHClient: func(ctx context.Context, i database.Instance) (*gossh.Client, error) { + return nil, fmt.Errorf("no ssh in test") + }, + ProbeShim: probe, + } + } + + t.Run("openclaw without shim falls back to native", func(t *testing.T) { + inst := database.Instance{Name: "bot-native", DisplayName: "native", AgentType: "openclaw"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatal(err) + } + f := newFactory(func(context.Context, agentshim.InstanceDeps) (bool, error) { return false, nil }) + client, err := f.ForInstance(context.Background(), inst.ID) + if err != nil { + t.Fatal(err) + } + if client.Type() != "openclaw" { + t.Fatalf("client type = %q, want openclaw", client.Type()) + } + }) + + t.Run("openclaw with shim prefers shimexec", func(t *testing.T) { + inst := database.Instance{Name: "bot-shimmy", DisplayName: "shimmy", AgentType: "openclaw"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatal(err) + } + f := newFactory(func(context.Context, agentshim.InstanceDeps) (bool, error) { return true, nil }) + client, err := f.ForInstance(context.Background(), inst.ID) + if err != nil { + t.Fatal(err) + } + if client.Type() != agentshim.ShimAdapterType { + t.Fatalf("client type = %q, want %q", client.Type(), agentshim.ShimAdapterType) + } + }) + + t.Run("non-openclaw types always use shimexec without probing", func(t *testing.T) { + inst := database.Instance{Name: "bot-hermes", DisplayName: "hermes", AgentType: "hermes"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatal(err) + } + f := newFactory(func(context.Context, agentshim.InstanceDeps) (bool, error) { + t.Fatal("probe must not run for non-openclaw types") + return false, nil + }) + client, err := f.ForInstance(context.Background(), inst.ID) + if err != nil { + t.Fatal(err) + } + if client.Type() != agentshim.ShimAdapterType { + t.Fatalf("client type = %q, want %q", client.Type(), agentshim.ShimAdapterType) + } + }) + + t.Run("probe result is cached until invalidated", func(t *testing.T) { + inst := database.Instance{Name: "bot-cache", DisplayName: "cache", AgentType: "openclaw"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatal(err) + } + probes := 0 + f := newFactory(func(context.Context, agentshim.InstanceDeps) (bool, error) { + probes++ + return true, nil + }) + for i := 0; i < 3; i++ { + if _, err := f.ForInstance(context.Background(), inst.ID); err != nil { + t.Fatal(err) + } + } + if probes != 1 { + t.Fatalf("probe ran %d times, want 1 (cached)", probes) + } + f.InvalidateShimProbe(inst.ID) + if _, err := f.ForInstance(context.Background(), inst.ID); err != nil { + t.Fatal(err) + } + if probes != 2 { + t.Fatalf("probe ran %d times after invalidation, want 2", probes) + } + }) + + t.Run("probe errors fail open to native", func(t *testing.T) { + inst := database.Instance{Name: "bot-probe-err", DisplayName: "probe-err", AgentType: "openclaw"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatal(err) + } + probes := 0 + f := newFactory(func(context.Context, agentshim.InstanceDeps) (bool, error) { + probes++ + return false, fmt.Errorf("ssh down") + }) + client, err := f.ForInstance(context.Background(), inst.ID) + if err != nil { + t.Fatal(err) + } + if client.Type() != "openclaw" { + t.Fatalf("client type = %q, want openclaw fallback", client.Type()) + } + // Errors are not cached — the next call probes again. + if _, err := f.ForInstance(context.Background(), inst.ID); err != nil { + t.Fatal(err) + } + if probes != 2 { + t.Fatalf("probe ran %d times, want 2 (errors uncached)", probes) + } + }) +} + +func TestFactory_ForInstance_NotFound(t *testing.T) { + setupTestDB(t) + if _, err := (&agentshim.Factory{}).ForInstance(context.Background(), 9999); err == nil { + t.Fatal("expected error for missing instance") + } +} + +func TestDefaultFactory_NeverNil(t *testing.T) { + if agentshim.DefaultFactory() == nil { + t.Fatal("DefaultFactory returned nil") + } +} diff --git a/control-plane/internal/agentshim/openclawnative/client.go b/control-plane/internal/agentshim/openclawnative/client.go new file mode 100644 index 00000000..a4a4af56 --- /dev/null +++ b/control-plane/internal/agentshim/openclawnative/client.go @@ -0,0 +1,338 @@ +// Package openclawnative implements the agentshim Client/Session interfaces +// for pre-shim OpenClaw images using OpenClaw's native machinery: the gateway +// WebSocket protocol for chat, the `openclaw` CLI over SSH for config/LLM +// routing, and direct SFTP file access for the config file. +package openclawnative + +import ( + "context" + "encoding/json" + "fmt" + "log" + "net" + "time" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/llmgateway" + "github.com/gluk-w/claworc/control-plane/internal/sshproxy" + gossh "golang.org/x/crypto/ssh" +) + +// Type is the agent type identifier for the native OpenClaw adapter. +const Type = "openclaw" + +// ConfigPath is the OpenClaw config file location inside the instance. +const ConfigPath = "/home/claworc/.openclaw/openclaw.json" + +// configFileID is the ID of the single config file OpenClaw exposes. +const configFileID = "main" + +func init() { + agentshim.RegisterAdapter(Type, func(deps agentshim.InstanceDeps) agentshim.Client { + return New(deps) + }) +} + +// Client is the native OpenClaw adapter. +type Client struct { + deps agentshim.InstanceDeps + // exec, when non-nil, overrides SSH-resolved CLI execution. Used by + // callers that already hold an established connection (instance + // create/clone flows) and by tests. + exec sshproxy.Instance +} + +var _ agentshim.Client = (*Client)(nil) + +// New builds a Client from factory-resolved instance dependencies. +func New(deps agentshim.InstanceDeps) *Client { return &Client{deps: deps} } + +// NewWithExec builds a Client whose CLI verbs run over an already-established +// sshproxy.Instance. Only exec-backed operations (ConfigureLLM, Restart) are +// usable on such a client; chat and config file access need factory deps. +func NewWithExec(exec sshproxy.Instance) *Client { return &Client{exec: exec} } + +// Type implements agentshim.Client. +func (c *Client) Type() string { return Type } + +// Capabilities implements agentshim.Client. OpenClaw's capabilities are +// static — the adapter is built into the control plane, no probe needed. +func (c *Client) Capabilities(_ context.Context) (agentshim.Capabilities, error) { + return agentshim.Capabilities{ + Chat: true, + ChatAbort: true, + SessionReset: true, + Config: true, + ConfigureLLM: true, + Restart: true, + ControlUI: true, + Skills: true, + ConfigFiles: []agentshim.ConfigFile{{ + ID: configFileID, + Path: ConfigPath, + Language: "json", + Label: "openclaw.json", + RestartRequired: true, + }}, + WorkspaceDir: "/home/claworc/.openclaw/workspace", + SkillsDir: "/home/claworc/.openclaw/skills", + LogFiles: []agentshim.LogFile{{Path: "/var/log/claworc/openclaw.log", Label: "OpenClaw"}}, + LLMStyles: []string{"openai"}, + SessionPersistence: "native", + }, nil +} + +// Health implements agentshim.Client: a cheap dial-ability check of the +// gateway tunnel. The tunnel only exists while SSH is up, and the gateway +// only listens while OpenClaw runs, so a successful TCP connect is a good +// readiness signal without the cost of a full WebSocket handshake. +func (c *Client) Health(_ context.Context) error { + port, err := c.tunnelPort() + if err != nil { + return &agentshim.TransportError{Err: err} + } + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 5*time.Second) + if err != nil { + return fmt.Errorf("gateway not reachable: %w", err) + } + conn.Close() + return nil +} + +// GetConfig implements agentshim.Client: reads openclaw.json over SFTP. +func (c *Client) GetConfig(ctx context.Context, fileID string) (string, string, error) { + if err := checkFileID(fileID); err != nil { + return "", "", err + } + client, err := c.sshClient(ctx) + if err != nil { + return "", "", &agentshim.TransportError{Err: err} + } + content, err := sshproxy.ReadFile(client, ConfigPath) + if err != nil { + return "", "", fmt.Errorf("read %s: %w", ConfigPath, err) + } + return string(content), "json", nil +} + +// SetConfig implements agentshim.Client: writes openclaw.json over SFTP. +// The agent is NOT restarted here; callers invoke Restart afterwards +// (the file declares RestartRequired). +func (c *Client) SetConfig(ctx context.Context, fileID, content string) error { + if err := checkFileID(fileID); err != nil { + return err + } + client, err := c.sshClient(ctx) + if err != nil { + return &agentshim.TransportError{Err: err} + } + if err := sshproxy.WriteFile(client, ConfigPath, []byte(content)); err != nil { + return fmt.Errorf("write %s: %w", ConfigPath, err) + } + return nil +} + +// Restart implements agentshim.Client: `openclaw gateway stop` — s6 +// supervises the gateway and restarts it immediately with fresh config. +func (c *Client) Restart(ctx context.Context) error { + inst, err := c.execInstance(ctx) + if err != nil { + return err + } + if _, stderr, code, err := inst.ExecOpenclaw(ctx, "gateway", "stop"); err != nil || code != 0 { + return fmt.Errorf("restart gateway: %v %s", err, stderr) + } + return nil +} + +// ConfigureLLM implements agentshim.Client. It translates the generic +// routing document into OpenClaw's native config via `openclaw config +// set/unset --json` and restarts the gateway: +// +// - agents.defaults.model — primary + fallbacks +// - agents.defaults.models — allowlist restricting the UI model dropdown +// - models.providers — providers pointing baseUrl at the LLM proxy +// with virtual keys +// +// Error semantics mirror the historical ConfigureInstance behavior: a +// transport-level exec failure aborts (and is returned); a non-zero exit from +// a single config step is logged and the remaining steps still run, so a bad +// model name can never leave providers unconfigured. +func (c *Client) ConfigureLLM(ctx context.Context, routing agentshim.LLMRouting) error { + inst, err := c.execInstance(ctx) + if err != nil { + return err + } + + name := c.deps.Instance.Name + + models := routing.Models() + if len(models) > 0 { + modelJSON := BuildModelsJSON(routing) + _, stderr, code, err := inst.ExecOpenclaw(ctx, "config", "set", "agents.defaults.model", modelJSON, "--json") + if err != nil { + return fmt.Errorf("set agents.defaults.model: %w", err) + } + if code != 0 { + log.Printf("[openclawnative] %s: set agents.defaults.model failed: %s", name, stderr) + // continue — providers must still be configured even if model config failed + } + + // Set the models allowlist to restrict the UI dropdown to only + // configured models. `openclaw config set` deep-merges into existing + // map values, so a previously-selected model that the admin + // de-selected would linger — clear the path before writing. + modelsMap := make(map[string]interface{}, len(models)) + for _, m := range models { + modelsMap[m] = map[string]interface{}{} + } + modelsMapJSON, err := json.Marshal(modelsMap) + if err != nil { + log.Printf("[openclawnative] %s: marshal models allowlist: %v", name, err) + } else { + _, _, _, _ = inst.ExecOpenclaw(ctx, "config", "unset", "agents.defaults.models") + _, stderr, code, err := inst.ExecOpenclaw(ctx, "config", "set", "agents.defaults.models", string(modelsMapJSON), "--json") + if err != nil { + log.Printf("[openclawnative] %s: set models allowlist: %v", name, err) + } else if code != 0 { + log.Printf("[openclawnative] %s: set models allowlist failed: %s", name, stderr) + } + } + } + + if len(routing.Providers) > 0 && routing.ProxyURL != "" { + providersJSON, err := BuildProvidersJSON(routing) + if err != nil { + log.Printf("[openclawnative] %s: marshal providers: %v", name, err) + } else if providersJSON != "" { + // Clear the providers map first so de-selected providers are + // removed instead of being deep-merged with the previous config. + _, _, _, _ = inst.ExecOpenclaw(ctx, "config", "unset", "models.providers") + stdout, stderr, code, err := inst.ExecOpenclaw(ctx, "config", "set", "models.providers", providersJSON, "--json") + if err != nil { + log.Printf("[openclawnative] %s: set providers: %v", name, err) + } else if code != 0 { + log.Printf("[openclawnative] %s: set providers failed: stdout=%q stderr=%q", name, stdout, stderr) + } + } + } + + // Restart the gateway so it picks up new env vars and config. + stdout, stderr, code, err := inst.ExecOpenclaw(ctx, "gateway", "stop") + if err != nil { + return fmt.Errorf("restart gateway: %w", err) + } + if code != 0 { + return fmt.Errorf("restart gateway: stdout=%q stderr=%q", stdout, stderr) + } + return nil +} + +// providerCfg is the JSON shape expected by OpenClaw's models.providers config. +type providerCfg struct { + BaseURL string `json:"baseUrl"` + API string `json:"api"` + APIKey string `json:"apiKey"` + Models []modelCfg `json:"models"` +} + +type modelCfg struct { + ID string `json:"id"` +} + +// BuildProvidersJSON translates the routing document into OpenClaw's +// models.providers JSON. Returns "" when there is nothing to configure. +// Exported because the instance-create path also embeds this JSON in the +// OPENCLAW_INITIAL_PROVIDERS boot env var. +func BuildProvidersJSON(routing agentshim.LLMRouting) (string, error) { + if len(routing.Providers) == 0 || routing.ProxyURL == "" { + return "", nil + } + providers := make(map[string]providerCfg, len(routing.Providers)) + for _, p := range routing.Providers { + apiType := p.APIType + if apiType == "" { + apiType = "openai-completions" + } + // Codex declares openai-responses to OpenClaw so pi-ai skips its + // client-side JWT decode of apiKey. The gateway translates + // path/auth/SSE upstream. The routing document keeps the codex + // api type for gateway routing. + if apiType == llmgateway.APITypeOpenAICodexResponses { + apiType = "openai-responses" + } + models := make([]modelCfg, 0, len(p.Models)) + for _, m := range p.Models { + models = append(models, modelCfg{ID: m.ID}) + } + providers[p.Key] = providerCfg{ + BaseURL: routing.ProxyURL, + API: apiType, + APIKey: p.APIKey, + Models: models, + } + } + b, err := json.Marshal(providers) + if err != nil { + return "", err + } + return string(b), nil +} + +// BuildModelsJSON translates the routing document into OpenClaw's +// agents.defaults.model JSON ({"primary": ..., "fallbacks": [...]}). +// Returns "" when no default model is set. Exported because the +// instance-create path also embeds this JSON in the OPENCLAW_INITIAL_MODELS +// boot env var. +func BuildModelsJSON(routing agentshim.LLMRouting) string { + if routing.DefaultModel == "" { + return "" + } + fallbacks := routing.FallbackModels + if fallbacks == nil { + fallbacks = []string{} + } + b, err := json.Marshal(map[string]interface{}{ + "primary": routing.DefaultModel, + "fallbacks": fallbacks, + }) + if err != nil { + return "" + } + return string(b) +} + +func checkFileID(fileID string) error { + if fileID != "" && fileID != configFileID { + return fmt.Errorf("unknown config file %q", fileID) + } + return nil +} + +func (c *Client) tunnelPort() (int, error) { + if c.deps.TunnelPort == nil { + return 0, fmt.Errorf("openclawnative: no tunnel port resolver") + } + return c.deps.TunnelPort("gateway") +} + +func (c *Client) sshClient(ctx context.Context) (*gossh.Client, error) { + if c.deps.SSHClient == nil { + return nil, fmt.Errorf("openclawnative: no SSH client resolver") + } + return c.deps.SSHClient(ctx) +} + +func (c *Client) execInstance(ctx context.Context) (sshproxy.Instance, error) { + if c.exec != nil { + return c.exec, nil + } + if c.deps.SSHClient == nil { + return nil, fmt.Errorf("openclawnative: no SSH client resolver") + } + client, err := c.deps.SSHClient(ctx) + if err != nil { + return nil, &agentshim.TransportError{Err: err} + } + return sshproxy.NewSSHInstance(client), nil +} diff --git a/control-plane/internal/agentshim/openclawnative/client_test.go b/control-plane/internal/agentshim/openclawnative/client_test.go new file mode 100644 index 00000000..ed433f5f --- /dev/null +++ b/control-plane/internal/agentshim/openclawnative/client_test.go @@ -0,0 +1,221 @@ +package openclawnative + +import ( + "context" + "encoding/json" + "strings" + "sync" + "testing" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" +) + +// mockExec records ExecOpenclaw calls. +type mockExec struct { + mu sync.Mutex + calls [][]string +} + +func (m *mockExec) ExecOpenclaw(_ context.Context, args ...string) (string, string, int, error) { + m.mu.Lock() + defer m.mu.Unlock() + m.calls = append(m.calls, args) + return "", "", 0, nil +} + +func TestConfigureLLM_CallSequence(t *testing.T) { + exec := &mockExec{} + routing := agentshim.LLMRouting{ + ProxyURL: "http://127.0.0.1:40001", + Style: "openai", + DefaultModel: "anthropic/claude-sonnet-4-5", + FallbackModels: []string{"openai/gpt-5"}, + Providers: []agentshim.ProviderRoute{ + {Key: "anthropic", APIKey: "vk-1", APIType: "anthropic-messages", + Models: []agentshim.ModelRef{{ID: "claude-sonnet-4-5", Default: true}}}, + }, + } + if err := NewWithExec(exec).ConfigureLLM(context.Background(), routing); err != nil { + t.Fatalf("ConfigureLLM: %v", err) + } + + want := [][]string{ + {"config", "set", "agents.defaults.model"}, + {"config", "unset", "agents.defaults.models"}, + {"config", "set", "agents.defaults.models"}, + {"config", "unset", "models.providers"}, + {"config", "set", "models.providers"}, + {"gateway", "stop"}, + } + if len(exec.calls) != len(want) { + t.Fatalf("got %d calls, want %d: %v", len(exec.calls), len(want), exec.calls) + } + for i, w := range want { + for j, arg := range w { + if exec.calls[i][j] != arg { + t.Errorf("call %d = %v, want prefix %v", i, exec.calls[i], w) + break + } + } + } + + // agents.defaults.model payload: primary + fallbacks + var modelCfg map[string]any + if err := json.Unmarshal([]byte(exec.calls[0][3]), &modelCfg); err != nil { + t.Fatalf("model config not JSON: %v", err) + } + if modelCfg["primary"] != "anthropic/claude-sonnet-4-5" { + t.Errorf("primary = %v", modelCfg["primary"]) + } + fallbacks, _ := modelCfg["fallbacks"].([]any) + if len(fallbacks) != 1 || fallbacks[0] != "openai/gpt-5" { + t.Errorf("fallbacks = %v", modelCfg["fallbacks"]) + } + + // allowlist contains both models + allowlist := exec.calls[2][3] + if !strings.Contains(allowlist, "anthropic/claude-sonnet-4-5") || !strings.Contains(allowlist, "openai/gpt-5") { + t.Errorf("allowlist = %s", allowlist) + } +} + +func TestBuildProvidersJSON(t *testing.T) { + routing := agentshim.LLMRouting{ + ProxyURL: "http://127.0.0.1:40001", + Providers: []agentshim.ProviderRoute{ + {Key: "anthropic", APIKey: "vk-abc", APIType: "anthropic-messages", + Models: []agentshim.ModelRef{{ID: "claude-sonnet-4-5"}}}, + {Key: "custom", APIKey: "vk-def"}, // no api type → default; no models → [] + }, + } + out, err := BuildProvidersJSON(routing) + if err != nil { + t.Fatalf("BuildProvidersJSON: %v", err) + } + var providers map[string]map[string]any + if err := json.Unmarshal([]byte(out), &providers); err != nil { + t.Fatalf("output not JSON: %v", err) + } + + anth := providers["anthropic"] + if anth["baseUrl"] != "http://127.0.0.1:40001" { + t.Errorf("baseUrl = %v", anth["baseUrl"]) + } + if anth["api"] != "anthropic-messages" { + t.Errorf("api = %v", anth["api"]) + } + if anth["apiKey"] != "vk-abc" { + t.Errorf("apiKey = %v", anth["apiKey"]) + } + + custom := providers["custom"] + if custom["api"] != "openai-completions" { + t.Errorf("default api = %v, want openai-completions", custom["api"]) + } + if models, ok := custom["models"].([]any); !ok || len(models) != 0 { + t.Errorf("empty models must marshal as []: %v", custom["models"]) + } +} + +// TestBuildProvidersJSON_CodexDeclaresOpenAIResponses guards the codex +// special case: the openai-codex-responses api type is declared to OpenClaw +// as openai-responses so pi-ai skips its client-side JWT decode of apiKey. +func TestBuildProvidersJSON_CodexDeclaresOpenAIResponses(t *testing.T) { + routing := agentshim.LLMRouting{ + ProxyURL: "http://127.0.0.1:40001", + Providers: []agentshim.ProviderRoute{ + {Key: "codex", APIKey: "vk-x", APIType: "openai-codex-responses"}, + }, + } + out, err := BuildProvidersJSON(routing) + if err != nil { + t.Fatalf("BuildProvidersJSON: %v", err) + } + var providers map[string]map[string]any + if err := json.Unmarshal([]byte(out), &providers); err != nil { + t.Fatalf("output not JSON: %v", err) + } + if providers["codex"]["api"] != "openai-responses" { + t.Errorf("codex api = %v, want openai-responses", providers["codex"]["api"]) + } +} + +func TestBuildProvidersJSON_Empty(t *testing.T) { + if out, _ := BuildProvidersJSON(agentshim.LLMRouting{ProxyURL: "http://x"}); out != "" { + t.Errorf("no providers must yield empty string, got %q", out) + } + if out, _ := BuildProvidersJSON(agentshim.LLMRouting{ + Providers: []agentshim.ProviderRoute{{Key: "a"}}, + }); out != "" { + t.Errorf("no proxy URL must yield empty string, got %q", out) + } +} + +func TestBuildModelsJSON(t *testing.T) { + if out := BuildModelsJSON(agentshim.LLMRouting{}); out != "" { + t.Errorf("no default model must yield empty string, got %q", out) + } + out := BuildModelsJSON(agentshim.LLMRouting{DefaultModel: "m1"}) + if out != `{"fallbacks":[],"primary":"m1"}` { + t.Errorf("BuildModelsJSON = %s", out) + } +} + +func TestCapabilities(t *testing.T) { + caps, err := New(agentshim.InstanceDeps{}).Capabilities(context.Background()) + if err != nil { + t.Fatalf("Capabilities: %v", err) + } + if !caps.Chat || !caps.ChatAbort || !caps.SessionReset || !caps.Config || + !caps.ConfigureLLM || !caps.Restart || !caps.ControlUI || !caps.Skills { + t.Errorf("capability flags = %+v", caps) + } + file := caps.FindConfigFile("") + if file == nil || file.Path != ConfigPath || file.Language != "json" || !file.RestartRequired { + t.Errorf("config file = %+v", file) + } + if caps.SessionPersistence != "native" { + t.Errorf("session persistence = %q", caps.SessionPersistence) + } + if len(caps.LLMStyles) != 1 || caps.LLMStyles[0] != "openai" { + t.Errorf("llm styles = %v", caps.LLMStyles) + } +} + +// TestEventWireFormat pins the JSON the browser receives to the docs/shim.md +// JSONL schema. +func TestEventWireFormat(t *testing.T) { + s := newSession(nil, "browser") + s.translate(gwFrame("assistant", "r1", map[string]any{"text": "hi"})) + + cases := []struct { + frame []byte + want string + }{ + { + gwFrame("lifecycle", "r1", map[string]any{"phase": "start"}), + `{"v":1,"event":"start","session":"browser","turn":"r1"}`, + }, + { + gwFrame("assistant", "r1", map[string]any{"text": "hi there"}), + `{"v":1,"event":"assistant","turn":"r1","message_id":"r1","text":"hi there"}`, + }, + { + gwFrame("lifecycle", "r1", map[string]any{"phase": "end"}), + `{"v":1,"event":"end","turn":"r1","text":"hi there","stop_reason":"complete"}`, + }, + } + for _, c := range cases { + ev, ok := s.translate(c.frame) + if !ok { + t.Fatalf("frame %s not translated", c.frame) + } + b, err := json.Marshal(ev) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if string(b) != c.want { + t.Errorf("wire JSON = %s\n want %s", b, c.want) + } + } +} diff --git a/control-plane/internal/agentshim/openclawnative/session.go b/control-plane/internal/agentshim/openclawnative/session.go new file mode 100644 index 00000000..b6242a22 --- /dev/null +++ b/control-plane/internal/agentshim/openclawnative/session.go @@ -0,0 +1,238 @@ +package openclawnative + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/coder/websocket" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/sshproxy" + "github.com/google/uuid" +) + +// OpenSession implements agentshim.Client: it dials the OpenClaw gateway +// over the instance's SSH tunnel, completes the connect handshake, and +// returns a Session speaking the normalized chat event schema. +func (c *Client) OpenSession(ctx context.Context, sessionKey string) (agentshim.Session, error) { + port, err := c.tunnelPort() + if err != nil { + return nil, &agentshim.TransportError{Err: err} + } + conn, err := sshproxy.DialGateway(ctx, port, c.deps.GatewayToken) + if err != nil { + return nil, err + } + return newSession(conn, sessionKey), nil +} + +// session speaks the OpenClaw gateway WebSocket protocol on one side and the +// normalized agentshim event schema on the other. +type session struct { + conn *websocket.Conn + key string + + mu sync.Mutex + reqCounter int + // lastText tracks the latest cumulative assistant snapshot per gateway + // runId, so the synthesized "end" event can carry the final text (OpenClaw + // lifecycle/end frames don't repeat it). lastAnyText is the fallback when + // the end frame's runId doesn't match any assistant frame's. + lastText map[string]string + lastAnyText string +} + +var _ agentshim.Session = (*session)(nil) + +func newSession(conn *websocket.Conn, key string) *session { + return &session{conn: conn, key: key, lastText: make(map[string]string)} +} + +func (s *session) nextID(prefix string) string { + s.mu.Lock() + s.reqCounter++ + n := s.reqCounter + s.mu.Unlock() + return fmt.Sprintf("%s-%d", prefix, n) +} + +func (s *session) writeFrame(ctx context.Context, frame map[string]any) error { + b, err := json.Marshal(frame) + if err != nil { + return err + } + return s.conn.Write(ctx, websocket.MessageText, b) +} + +// Send implements agentshim.Session via a chat.send gateway frame. +func (s *session) Send(ctx context.Context, message string) error { + return s.writeFrame(ctx, map[string]any{ + "type": "req", + "id": s.nextID("chat"), + "method": "chat.send", + "params": map[string]any{ + "sessionKey": s.key, + "message": message, + "idempotencyKey": uuid.New().String(), + }, + }) +} + +// Abort implements agentshim.Session via a chat.abort gateway frame. +func (s *session) Abort(ctx context.Context) error { + return s.writeFrame(ctx, map[string]any{ + "type": "req", + "id": s.nextID("abort"), + "method": "chat.abort", + "params": map[string]any{ + "sessionKey": s.key, + }, + }) +} + +// Reset implements agentshim.Session via a sessions.reset gateway frame. +func (s *session) Reset(ctx context.Context) error { + return s.writeFrame(ctx, map[string]any{ + "type": "req", + "id": s.nextID("reset"), + "method": "sessions.reset", + "params": map[string]any{ + "key": s.key, + }, + }) +} + +// Recv implements agentshim.Session: it reads gateway frames, skipping the +// ones that don't translate (tick/presence/health, successful res acks), and +// returns the next normalized event. +func (s *session) Recv(ctx context.Context) (agentshim.Event, error) { + for { + _, data, err := s.conn.Read(ctx) + if err != nil { + return agentshim.Event{}, err + } + if ev, ok := s.translate(data); ok { + return ev, nil + } + } +} + +// Close implements agentshim.Session. +func (s *session) Close() error { + return s.conn.CloseNow() +} + +// translate converts one raw OpenClaw gateway frame into a normalized event. +// The second return value is false for frames that must be skipped. +// +// Mapping: +// +// payload.stream=="lifecycle" data.phase=="start" → start +// payload.stream=="assistant" data.text → assistant (Text is the +// CUMULATIVE SNAPSHOT for that runId, which doubles as message_id/turn) +// payload.stream=="tool" → tool, Detail = raw data +// payload.stream=="lifecycle" data.phase=="end" → end, stop_reason +// "complete", Text = last assistant snapshot for that run +// type=="res" ok==false → error (non-fatal) +// tick / presence / health / res-ok → skipped +func (s *session) translate(raw []byte) (agentshim.Event, bool) { + var msg map[string]any + if err := json.Unmarshal(raw, &msg); err != nil { + return agentshim.Event{}, false + } + + switch msg["type"] { + case "res": + if ok, _ := msg["ok"].(bool); ok { + return agentshim.Event{}, false + } + text := "gateway request failed" + if errObj, _ := msg["error"].(map[string]any); errObj != nil { + if m, _ := errObj["message"].(string); m != "" { + text = m + } + } + return agentshim.Event{ + V: 1, + Kind: agentshim.EventError, + Text: text, + Fatal: false, + }, true + + case "event": + payload, _ := msg["payload"].(map[string]any) + if payload == nil { + return agentshim.Event{}, false + } + stream, _ := payload["stream"].(string) + runID, _ := payload["runId"].(string) + data, _ := payload["data"].(map[string]any) + + switch stream { + case "assistant": + text, _ := data["text"].(string) + if text != "" { + s.mu.Lock() + s.lastText[runID] = text + s.lastAnyText = text + s.mu.Unlock() + } + return agentshim.Event{ + V: 1, + Kind: agentshim.EventAssistant, + Turn: runID, + MessageID: runID, + Text: text, + }, true + + case "tool": + var detail json.RawMessage + if data != nil { + if b, err := json.Marshal(data); err == nil { + detail = b + } + } + name, _ := data["name"].(string) + phase, _ := data["phase"].(string) + return agentshim.Event{ + V: 1, + Kind: agentshim.EventTool, + Turn: runID, + Name: name, + Phase: phase, + Detail: detail, + }, true + + case "lifecycle": + phase, _ := data["phase"].(string) + switch phase { + case "start": + return agentshim.Event{ + V: 1, + Kind: agentshim.EventStart, + Session: s.key, + Turn: runID, + }, true + case "end": + s.mu.Lock() + text := s.lastText[runID] + if text == "" { + text = s.lastAnyText + } + s.mu.Unlock() + return agentshim.Event{ + V: 1, + Kind: agentshim.EventEnd, + Turn: runID, + StopReason: agentshim.StopComplete, + Text: text, + }, true + } + return agentshim.Event{}, false + } + // tick, presence, health, and any unknown stream: skip. + return agentshim.Event{}, false + } + return agentshim.Event{}, false +} diff --git a/control-plane/internal/agentshim/openclawnative/session_test.go b/control-plane/internal/agentshim/openclawnative/session_test.go new file mode 100644 index 00000000..b9f66c53 --- /dev/null +++ b/control-plane/internal/agentshim/openclawnative/session_test.go @@ -0,0 +1,362 @@ +package openclawnative + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" +) + +// gwFrame builds a raw OpenClaw gateway event frame. +func gwFrame(stream, runID string, data map[string]any) []byte { + payload := map[string]any{"stream": stream, "data": data} + if runID != "" { + payload["runId"] = runID + } + b, _ := json.Marshal(map[string]any{"type": "event", "payload": payload}) + return b +} + +func resFrame(ok bool, errMsg string) []byte { + frame := map[string]any{"type": "res", "ok": ok} + if errMsg != "" { + frame["error"] = map[string]any{"message": errMsg} + } + b, _ := json.Marshal(frame) + return b +} + +// --- translate golden tests (no transport involved) --- + +// TestTranslate_GoldenSequence feeds a recorded OpenClaw gateway frame +// sequence through the translator and asserts the exact normalized event +// sequence, guarding the cumulative-text and lifecycle-end semantics. +func TestTranslate_GoldenSequence(t *testing.T) { + s := newSession(nil, "browser") + + frames := [][]byte{ + gwFrame("lifecycle", "r1", map[string]any{"phase": "start"}), + gwFrame("health", "", map[string]any{"ok": true}), // skipped + resFrame(true, ""), // ack: skipped + gwFrame("assistant", "r1", map[string]any{"text": "Looking into it"}), // snapshot 1 + gwFrame("assistant", "r1", map[string]any{"text": "Looking into it. Done."}), // snapshot 2 (cumulative) + gwFrame("tool", "r1", map[string]any{"name": "exec", "phase": "start", "command": "ls /tmp"}), + gwFrame("tick", "", nil), // skipped + resFrame(false, "rate limited"), // res error → non-fatal error event + gwFrame("presence", "", nil), // skipped + gwFrame("lifecycle", "r1", map[string]any{"phase": "end"}), + } + + var got []agentshim.Event + for _, f := range frames { + if ev, ok := s.translate(f); ok { + got = append(got, ev) + } + } + + want := []agentshim.Event{ + {V: 1, Kind: "start", Session: "browser", Turn: "r1"}, + {V: 1, Kind: "assistant", Turn: "r1", MessageID: "r1", Text: "Looking into it"}, + {V: 1, Kind: "assistant", Turn: "r1", MessageID: "r1", Text: "Looking into it. Done."}, + {V: 1, Kind: "tool", Turn: "r1", Name: "exec", Phase: "start"}, + {V: 1, Kind: "error", Text: "rate limited"}, + {V: 1, Kind: "end", Turn: "r1", StopReason: "complete", Text: "Looking into it. Done."}, + } + + if len(got) != len(want) { + t.Fatalf("got %d events, want %d: %+v", len(got), len(want), got) + } + for i := range want { + g := got[i] + // Compare Detail separately (JSON object key order is irrelevant). + detail := g.Detail + g.Detail = nil + if !reflect.DeepEqual(g, want[i]) { + t.Errorf("event %d = %+v, want %+v", i, g, want[i]) + } + if want[i].Kind == "tool" { + var d map[string]any + if err := json.Unmarshal(detail, &d); err != nil { + t.Fatalf("tool detail not JSON: %v", err) + } + if d["command"] != "ls /tmp" { + t.Errorf("tool detail = %v, want command 'ls /tmp'", d) + } + } + } +} + +// TestTranslate_EndTextFallsBackAcrossRuns: when the end frame's runId does +// not match the assistant frames' (or both are absent), end.text still +// carries the last assistant snapshot. +func TestTranslate_EndTextFallsBackAcrossRuns(t *testing.T) { + s := newSession(nil, "k") + + if _, ok := s.translate(gwFrame("assistant", "run-a", map[string]any{"text": "the reply"})); !ok { + t.Fatal("assistant frame not translated") + } + ev, ok := s.translate(gwFrame("lifecycle", "run-b", map[string]any{"phase": "end"})) + if !ok { + t.Fatal("end frame not translated") + } + if ev.Text != "the reply" { + t.Fatalf("end.Text = %q, want fallback to last snapshot", ev.Text) + } +} + +// TestTranslate_MultipleMessageIDsPerTurn: each snapshot replaces only its +// own message; end carries the LAST assistant snapshot. +func TestTranslate_LastSnapshotPerRunWins(t *testing.T) { + s := newSession(nil, "k") + s.translate(gwFrame("assistant", "r9", map[string]any{"text": "one"})) + s.translate(gwFrame("assistant", "r9", map[string]any{"text": "one two"})) + s.translate(gwFrame("assistant", "r9", map[string]any{"text": "one two three"})) + ev, _ := s.translate(gwFrame("lifecycle", "r9", map[string]any{"phase": "end"})) + if ev.Text != "one two three" { + t.Fatalf("end.Text = %q, want final cumulative snapshot", ev.Text) + } +} + +// TestTranslate_SkipsUnknownAndMalformed: unknown streams, malformed JSON, +// and successful res acks are all skipped. +func TestTranslate_SkipsUnknownAndMalformed(t *testing.T) { + s := newSession(nil, "k") + for _, raw := range [][]byte{ + []byte("not json"), + []byte(`{"type":"event"}`), + []byte(`{"type":"event","payload":{"stream":"weird-new-stream"}}`), + resFrame(true, ""), + gwFrame("lifecycle", "r1", map[string]any{"phase": "compacting"}), + } { + if ev, ok := s.translate(raw); ok { + t.Errorf("frame %s translated to %+v, want skip", raw, ev) + } + } +} + +func TestTranslate_ResErrorDefaultsMessage(t *testing.T) { + s := newSession(nil, "k") + ev, ok := s.translate([]byte(`{"type":"res","ok":false}`)) + if !ok { + t.Fatal("res error not translated") + } + if ev.Kind != agentshim.EventError || ev.Fatal { + t.Fatalf("event = %+v, want non-fatal error", ev) + } + if ev.Text == "" { + t.Fatal("error event has empty text") + } +} + +// --- end-to-end session tests over a fake gateway WebSocket --- + +// fakeGateway runs a minimal OpenClaw gateway: it completes the DialGateway +// handshake, then hands the connection to serve. +func fakeGateway(t *testing.T, serve func(ctx context.Context, conn *websocket.Conn)) (port int, cleanup func()) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) + if err != nil { + t.Logf("ws accept: %v", err) + return + } + defer conn.CloseNow() + ctx := r.Context() + + // Phase 1: connect.challenge + challenge, _ := json.Marshal(map[string]any{"type": "event", "payload": map[string]any{"stream": "connect.challenge"}}) + if err := conn.Write(ctx, websocket.MessageText, challenge); err != nil { + return + } + // Phase 2: read connect frame (discard) + if _, _, err := conn.Read(ctx); err != nil { + return + } + // Phase 3: hello-ok + helloOK, _ := json.Marshal(map[string]any{"type": "res", "ok": true}) + if err := conn.Write(ctx, websocket.MessageText, helloOK); err != nil { + return + } + serve(ctx, conn) + })) + addr := srv.Listener.Addr().String() + portStr := addr[strings.LastIndex(addr, ":")+1:] + p, err := strconv.Atoi(portStr) + if err != nil { + t.Fatalf("parse port %q: %v", portStr, err) + } + return p, srv.Close +} + +func testClient(port int) *Client { + return New(agentshim.InstanceDeps{ + TunnelPort: func(service string) (int, error) { return port, nil }, + }) +} + +// readReq reads the next req frame from the session side of the gateway. +func readReq(ctx context.Context, t *testing.T, conn *websocket.Conn) map[string]any { + t.Helper() + _, data, err := conn.Read(ctx) + if err != nil { + t.Logf("gateway read: %v", err) + return nil + } + var frame map[string]any + if err := json.Unmarshal(data, &frame); err != nil { + t.Logf("gateway unmarshal: %v", err) + return nil + } + return frame +} + +// TestSession_SendAbortResetFrames asserts the exact gateway frames the +// session verbs produce. +func TestSession_SendAbortResetFrames(t *testing.T) { + frames := make(chan map[string]any, 3) + port, cleanup := fakeGateway(t, func(ctx context.Context, conn *websocket.Conn) { + for i := 0; i < 3; i++ { + f := readReq(ctx, t, conn) + if f == nil { + return + } + frames <- f + } + }) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sess, err := testClient(port).OpenSession(ctx, "claworc-webhook-my-task") + if err != nil { + t.Fatalf("OpenSession: %v", err) + } + defer sess.Close() + + if err := sess.Send(ctx, "hello"); err != nil { + t.Fatalf("Send: %v", err) + } + if err := sess.Abort(ctx); err != nil { + t.Fatalf("Abort: %v", err) + } + if err := sess.Reset(ctx); err != nil { + t.Fatalf("Reset: %v", err) + } + + send := <-frames + if send["method"] != "chat.send" { + t.Fatalf("frame 1 method = %v, want chat.send", send["method"]) + } + params, _ := send["params"].(map[string]any) + if params["sessionKey"] != "claworc-webhook-my-task" { + t.Errorf("sessionKey = %v", params["sessionKey"]) + } + if params["message"] != "hello" { + t.Errorf("message = %v", params["message"]) + } + if ik, _ := params["idempotencyKey"].(string); ik == "" { + t.Error("idempotencyKey is empty") + } + + abort := <-frames + if abort["method"] != "chat.abort" { + t.Fatalf("frame 2 method = %v, want chat.abort", abort["method"]) + } + if p, _ := abort["params"].(map[string]any); p["sessionKey"] != "claworc-webhook-my-task" { + t.Errorf("abort sessionKey = %v", p["sessionKey"]) + } + + reset := <-frames + if reset["method"] != "sessions.reset" { + t.Fatalf("frame 3 method = %v, want sessions.reset", reset["method"]) + } + if p, _ := reset["params"].(map[string]any); p["key"] != "claworc-webhook-my-task" { + t.Errorf("reset key = %v", p["key"]) + } +} + +// TestSession_RecvStream drives a full chat turn through a fake gateway and +// asserts the normalized event stream the session yields, including frame +// skipping over the wire. +func TestSession_RecvStream(t *testing.T) { + port, cleanup := fakeGateway(t, func(ctx context.Context, conn *websocket.Conn) { + if f := readReq(ctx, t, conn); f == nil { + return + } + for _, frame := range [][]byte{ + resFrame(true, ""), // chat.send ack: skipped + gwFrame("lifecycle", "r1", map[string]any{"phase": "start"}), + gwFrame("tick", "", nil), + gwFrame("assistant", "r1", map[string]any{"text": "chunk"}), + gwFrame("assistant", "r1", map[string]any{"text": "chunk chunk"}), + gwFrame("lifecycle", "r1", map[string]any{"phase": "end"}), + } { + if err := conn.Write(ctx, websocket.MessageText, frame); err != nil { + return + } + } + <-ctx.Done() + }) + defer cleanup() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + sess, err := testClient(port).OpenSession(ctx, "browser") + if err != nil { + t.Fatalf("OpenSession: %v", err) + } + defer sess.Close() + + if err := sess.Send(ctx, "do the thing"); err != nil { + t.Fatalf("Send: %v", err) + } + + var kinds []string + var lastEnd agentshim.Event + for { + ev, err := sess.Recv(ctx) + if err != nil { + t.Fatalf("Recv: %v (got kinds %v)", err, kinds) + } + kinds = append(kinds, ev.Kind) + if ev.Kind == agentshim.EventEnd { + lastEnd = ev + break + } + } + + wantKinds := []string{"start", "assistant", "assistant", "end"} + if !reflect.DeepEqual(kinds, wantKinds) { + t.Fatalf("kinds = %v, want %v", kinds, wantKinds) + } + if lastEnd.StopReason != agentshim.StopComplete { + t.Errorf("end stop_reason = %q, want complete", lastEnd.StopReason) + } + if lastEnd.Text != "chunk chunk" { + t.Errorf("end text = %q, want final cumulative snapshot", lastEnd.Text) + } +} + +// TestOpenSession_NoTunnel: a missing tunnel is a transport error. +func TestOpenSession_NoTunnel(t *testing.T) { + c := New(agentshim.InstanceDeps{}) + _, err := c.OpenSession(context.Background(), "browser") + if err == nil { + t.Fatal("expected error") + } + var te *agentshim.TransportError + if !errors.As(err, &te) { + t.Fatalf("error %v is not a TransportError", err) + } +} diff --git a/control-plane/internal/agentshim/registry.go b/control-plane/internal/agentshim/registry.go new file mode 100644 index 00000000..22a1a7f9 --- /dev/null +++ b/control-plane/internal/agentshim/registry.go @@ -0,0 +1,155 @@ +package agentshim + +import ( + "encoding/json" + "fmt" +) + +// Agent type identifiers known to the control plane. "openclaw" matches +// openclawnative.Type and models.AgentTypeOpenClaw (kept as literals here to +// avoid an import cycle with the adapter package). +const ( + TypeOpenClaw = "openclaw" + TypeHermes = "hermes" + TypeNanoClaw = "nanoclaw" + TypeCustom = "custom" +) + +// Setting keys used for per-type default image resolution. +const ( + // SettingDefaultAgentImage is the legacy single-image setting; it remains + // the default image source for the OpenClaw agent type. + SettingDefaultAgentImage = "default_agent_image" + // SettingDefaultAgentImages is a JSON map {agentType: image} holding the + // default images of every non-OpenClaw agent type. + SettingDefaultAgentImages = "default_agent_images" +) + +// RegistryEntry describes one agent type the control plane can manage. The +// registry is static: it captures what is known about a type before any +// container exists (display name, conservative capabilities, log path). +// Live capability probing — once shim-capable images land — refines, never +// replaces, this data. +type RegistryEntry struct { + // Type is the identifier stored in Instance.AgentType. + Type string + // DisplayName is the human-readable agent name shown in the UI. + DisplayName string + // HasControlUI reports whether the agent serves its own web control UI + // that the control plane reverse-proxies (/openclaw/{id}/*). + HasControlUI bool + // StaticCapabilities are the capabilities assumed for instances of this + // type without probing the container. OpenClaw's are exact (the adapter + // is built in); other types are conservative. + StaticCapabilities Capabilities + // LogPath is the primary agent log file inside the container. + LogPath string +} + +// registryEntries is the ordered static registry. Order is the UI display +// order: the incumbent first, then alphabetical, custom last. +var registryEntries = []RegistryEntry{ + { + Type: TypeOpenClaw, + DisplayName: "OpenClaw", + HasControlUI: true, + StaticCapabilities: Capabilities{ + Chat: true, + ChatAbort: true, + SessionReset: true, + Config: true, + ConfigureLLM: true, + Restart: true, + ControlUI: true, + Skills: true, + LLMStyles: []string{"openai"}, + SessionPersistence: "native", + }, + LogPath: "/var/log/claworc/openclaw.log", + }, + { + Type: TypeHermes, + DisplayName: "Hermes", + StaticCapabilities: Capabilities{ + Chat: true, + Config: true, + ConfigureLLM: true, + Restart: true, + }, + LogPath: "/var/log/claworc/agent.log", + }, + { + Type: TypeNanoClaw, + DisplayName: "NanoClaw", + StaticCapabilities: Capabilities{ + Chat: true, + ConfigureLLM: true, + Restart: true, + }, + LogPath: "/var/log/claworc/agent.log", + }, + { + Type: TypeCustom, + DisplayName: "Custom", + StaticCapabilities: Capabilities{ + Chat: true, + Config: true, + ConfigureLLM: true, + Restart: true, + }, + LogPath: "/var/log/claworc/agent.log", + }, +} + +// Types returns the ordered list of registered agent types. +func Types() []RegistryEntry { + out := make([]RegistryEntry, len(registryEntries)) + copy(out, registryEntries) + return out +} + +// Get returns the registry entry for an agent type. The empty string resolves +// to OpenClaw, mirroring Instance.EffectiveAgentType. +func Get(agentType string) (RegistryEntry, bool) { + if agentType == "" { + agentType = TypeOpenClaw + } + for _, e := range registryEntries { + if e.Type == agentType { + return e, true + } + } + return RegistryEntry{}, false +} + +// Validate returns an error when agentType is not a registered agent type. +// The empty string is valid (it means OpenClaw). +func Validate(agentType string) error { + if _, ok := Get(agentType); !ok { + return fmt.Errorf("unknown agent type %q (valid: openclaw, hermes, nanoclaw, custom)", agentType) + } + return nil +} + +// DefaultImage resolves the configured default container image for an agent +// type. OpenClaw reads the legacy default_agent_image setting; every other +// type reads its key out of the default_agent_images JSON map. getSetting is +// injected (normally database.GetSetting) so this stays testable without a +// DB. Returns "" when nothing is configured. +func DefaultImage(agentType string, getSetting func(key string) (string, error)) string { + if agentType == "" || agentType == TypeOpenClaw { + if v, err := getSetting(SettingDefaultAgentImage); err == nil { + return v + } + return "" + } + raw, err := getSetting(SettingDefaultAgentImages) + if err != nil || raw == "" { + return "" + } + var images map[string]string + if err := json.Unmarshal([]byte(raw), &images); err != nil { + return "" + } + return images[agentType] +} diff --git a/control-plane/internal/agentshim/registry_test.go b/control-plane/internal/agentshim/registry_test.go new file mode 100644 index 00000000..46d2be99 --- /dev/null +++ b/control-plane/internal/agentshim/registry_test.go @@ -0,0 +1,168 @@ +package agentshim_test + +import ( + "context" + "fmt" + "testing" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/agentshim/openclawnative" +) + +func TestRegistry_TypesOrdered(t *testing.T) { + t.Parallel() + entries := agentshim.Types() + want := []string{"openclaw", "hermes", "nanoclaw", "custom"} + if len(entries) != len(want) { + t.Fatalf("Types() returned %d entries, want %d", len(entries), len(want)) + } + for i, w := range want { + if entries[i].Type != w { + t.Errorf("Types()[%d].Type = %q, want %q", i, entries[i].Type, w) + } + if entries[i].DisplayName == "" { + t.Errorf("Types()[%d] (%s) has empty DisplayName", i, entries[i].Type) + } + if entries[i].LogPath == "" { + t.Errorf("Types()[%d] (%s) has empty LogPath", i, entries[i].Type) + } + if !entries[i].StaticCapabilities.Chat { + t.Errorf("Types()[%d] (%s) must declare the chat capability", i, entries[i].Type) + } + } +} + +func TestRegistry_Get(t *testing.T) { + t.Parallel() + // Empty string resolves to OpenClaw, mirroring EffectiveAgentType. + entry, ok := agentshim.Get("") + if !ok || entry.Type != agentshim.TypeOpenClaw { + t.Fatalf("Get(\"\") = (%v, %v), want the openclaw entry", entry.Type, ok) + } + if _, ok := agentshim.Get("hermes"); !ok { + t.Error("Get(hermes) not found") + } + if _, ok := agentshim.Get("bogus"); ok { + t.Error("Get(bogus) unexpectedly found") + } +} + +func TestRegistry_Validate(t *testing.T) { + t.Parallel() + for _, valid := range []string{"", "openclaw", "hermes", "nanoclaw", "custom"} { + if err := agentshim.Validate(valid); err != nil { + t.Errorf("Validate(%q) = %v, want nil", valid, err) + } + } + if err := agentshim.Validate("skynet"); err == nil { + t.Error("Validate(skynet) = nil, want error") + } +} + +// TestRegistry_OpenClawMatchesNativeAdapter pins the static registry entry to +// the built-in OpenClaw adapter's own capability report so they cannot drift. +func TestRegistry_OpenClawMatchesNativeAdapter(t *testing.T) { + t.Parallel() + entry, _ := agentshim.Get(agentshim.TypeOpenClaw) + if entry.Type != openclawnative.Type { + t.Fatalf("registry openclaw type %q != adapter type %q", entry.Type, openclawnative.Type) + } + if !entry.HasControlUI { + t.Error("openclaw entry must declare HasControlUI") + } + + live, err := openclawnative.New(agentshim.InstanceDeps{}).Capabilities(context.Background()) + if err != nil { + t.Fatalf("adapter capabilities: %v", err) + } + got := entry.StaticCapabilities + for _, c := range []struct { + name string + static, live bool + }{ + {"Chat", got.Chat, live.Chat}, + {"ChatAbort", got.ChatAbort, live.ChatAbort}, + {"SessionReset", got.SessionReset, live.SessionReset}, + {"Config", got.Config, live.Config}, + {"ConfigureLLM", got.ConfigureLLM, live.ConfigureLLM}, + {"Restart", got.Restart, live.Restart}, + {"ControlUI", got.ControlUI, live.ControlUI}, + {"Skills", got.Skills, live.Skills}, + } { + if c.static != c.live { + t.Errorf("registry openclaw %s = %v, adapter reports %v", c.name, c.static, c.live) + } + } +} + +func TestRegistry_ConservativeCapabilities(t *testing.T) { + t.Parallel() + tests := []struct { + agentType string + wantConfig bool + }{ + {"hermes", true}, + {"nanoclaw", false}, + {"custom", true}, + } + for _, tt := range tests { + entry, ok := agentshim.Get(tt.agentType) + if !ok { + t.Fatalf("Get(%s) not found", tt.agentType) + } + caps := entry.StaticCapabilities + if !caps.Chat || !caps.ConfigureLLM || !caps.Restart { + t.Errorf("%s: chat/configure-llm/restart must all be true, got %+v", tt.agentType, caps) + } + if caps.Config != tt.wantConfig { + t.Errorf("%s: Config = %v, want %v", tt.agentType, caps.Config, tt.wantConfig) + } + if entry.HasControlUI { + t.Errorf("%s: only openclaw serves a control UI", tt.agentType) + } + if caps.ControlUI || caps.ChatAbort || caps.SessionReset || caps.Skills { + t.Errorf("%s: conservative entry declares optional capabilities it cannot guarantee: %+v", tt.agentType, caps) + } + } +} + +func TestRegistry_DefaultImage(t *testing.T) { + t.Parallel() + settings := map[string]string{ + "default_agent_image": "claworc/openclaw:latest", + "default_agent_images": `{"hermes":"claworc/hermes:latest","nanoclaw":"claworc/nanoclaw:latest","custom":""}`, + } + getSetting := func(key string) (string, error) { + v, ok := settings[key] + if !ok { + return "", fmt.Errorf("setting %s not found", key) + } + return v, nil + } + + tests := []struct { + agentType string + want string + }{ + {"", "claworc/openclaw:latest"}, + {"openclaw", "claworc/openclaw:latest"}, + {"hermes", "claworc/hermes:latest"}, + {"nanoclaw", "claworc/nanoclaw:latest"}, + {"custom", ""}, + {"unknown", ""}, + } + for _, tt := range tests { + if got := agentshim.DefaultImage(tt.agentType, getSetting); got != tt.want { + t.Errorf("DefaultImage(%q) = %q, want %q", tt.agentType, got, tt.want) + } + } + + // Missing settings resolve to "" rather than erroring. + empty := func(string) (string, error) { return "", fmt.Errorf("no settings") } + if got := agentshim.DefaultImage("openclaw", empty); got != "" { + t.Errorf("DefaultImage with no settings = %q, want empty", got) + } + if got := agentshim.DefaultImage("hermes", empty); got != "" { + t.Errorf("DefaultImage with no settings = %q, want empty", got) + } +} diff --git a/control-plane/internal/agentshim/shim.go b/control-plane/internal/agentshim/shim.go new file mode 100644 index 00000000..d9af419b --- /dev/null +++ b/control-plane/internal/agentshim/shim.go @@ -0,0 +1,199 @@ +// Package agentshim is the universal interface between the Claworc control +// plane and the AI agent running inside an instance container. All +// agent-specific knowledge (protocols, config paths, CLI invocations) lives +// behind the Client/Session interfaces defined here; handlers talk only to +// this package, which in turn uses internal/sshproxy for transport. +// +// Layering (see docs/shim.md): +// +// handlers / frontend +// └── internal/agentshim (Client/Session interfaces + adapters) +// └── internal/sshproxy (SSH exec / SFTP / tunnels — transport only) +// └── internal/orchestrator (container lifecycle only) +package agentshim + +import ( + "context" + "encoding/json" +) + +// ConfigFile describes one agent config file exposed in the Config tab. +type ConfigFile struct { + ID string `json:"id"` + Path string `json:"path"` + Language string `json:"language"` // json | yaml | toml | ini | shell | plaintext + Label string `json:"label"` + RestartRequired bool `json:"restart_required"` +} + +// LogFile describes one agent log file surfaced by log streaming. +type LogFile struct { + Path string `json:"path"` + Label string `json:"label"` +} + +// Capabilities describes what the agent behind a Client supports. It mirrors +// the `meta` document of the shim contract (docs/shim.md). +type Capabilities struct { + Chat bool + ChatAbort bool + SessionReset bool + Config bool + ConfigureLLM bool + Restart bool + ControlUI bool + Skills bool + + ConfigFiles []ConfigFile + WorkspaceDir string + SkillsDir string + LogFiles []LogFile + LLMStyles []string // "openai" and/or "anthropic" + SessionPersistence string // native | emulated | none +} + +// FindConfigFile returns the config file with the given ID, or the first +// config file when id is empty. Returns nil when no file matches. +func (c Capabilities) FindConfigFile(id string) *ConfigFile { + if len(c.ConfigFiles) == 0 { + return nil + } + if id == "" { + return &c.ConfigFiles[0] + } + for i := range c.ConfigFiles { + if c.ConfigFiles[i].ID == id { + return &c.ConfigFiles[i] + } + } + return nil +} + +// Client is the agent-agnostic handle for one instance's agent. It exposes +// every operation the control plane performs against an agent: chat sessions, +// config editing, LLM routing, restart, and health. +type Client interface { + // Type identifies the adapter, e.g. "openclaw". + Type() string + // Capabilities reports what this agent supports. + Capabilities(ctx context.Context) (Capabilities, error) + // Health returns nil when the agent can take a chat turn. + Health(ctx context.Context) error + // GetConfig reads the config file identified by fileID ("" selects the + // first declared file) and returns its content and editor language. + GetConfig(ctx context.Context, fileID string) (content, language string, err error) + // SetConfig replaces the config file's content. It does NOT restart the + // agent — callers invoke Restart when the file declares RestartRequired. + SetConfig(ctx context.Context, fileID, content string) error + // Restart restarts the agent service. + Restart(ctx context.Context) error + // ConfigureLLM routes the agent's LLM traffic per the routing document. + ConfigureLLM(ctx context.Context, routing LLMRouting) error + // OpenSession opens a chat session for the opaque Claworc-chosen session + // key (e.g. "browser", "claworc-webhook-"). + OpenSession(ctx context.Context, sessionKey string) (Session, error) +} + +// Session is one open chat channel to the agent. Sessions are not safe for +// concurrent Recv calls; Send/Abort/Reset may be called from another +// goroutine while Recv is blocked (matching websocket semantics). +type Session interface { + // Send delivers one user message to the agent. + Send(ctx context.Context, message string) error + // Recv blocks until the next normalized chat event is available. + Recv(ctx context.Context) (Event, error) + // Abort aborts the in-flight turn, if any. + Abort(ctx context.Context) error + // Reset clears the session's conversation history. + Reset(ctx context.Context) error + // Close tears down the session's underlying transport. + Close() error +} + +// Event kinds, per the chat event JSONL schema in docs/shim.md. +const ( + EventStart = "start" + EventAssistant = "assistant" + EventTool = "tool" + EventError = "error" + EventEnd = "end" +) + +// Stop reasons for EventEnd. +const ( + StopComplete = "complete" + StopAborted = "aborted" + StopError = "error" +) + +// Event is one normalized chat event, matching the JSONL schema in +// docs/shim.md exactly. Serialized verbatim, it IS the browser chat protocol. +// +// IMPORTANT: Text on "assistant" events is a CUMULATIVE SNAPSHOT of the +// message identified by MessageID — the full text of that message so far, NOT +// a delta. Consumers must replace, never append. A turn may contain multiple +// MessageIDs (text → tool calls → more text); each snapshot replaces only its +// own message. Text on "end" events carries the final text of the last +// assistant message so one-shot consumers (webhooks) can ignore everything +// else. +type Event struct { + V int `json:"v"` + Kind string `json:"event"` // start | assistant | tool | error | end + Session string `json:"session,omitempty"` + Turn string `json:"turn,omitempty"` + MessageID string `json:"message_id,omitempty"` + Text string `json:"text,omitempty"` + Name string `json:"name,omitempty"` // tool events + Phase string `json:"phase,omitempty"` // tool events: start | result + Code string `json:"code,omitempty"` // error events + StopReason string `json:"stop_reason,omitempty"` + Fatal bool `json:"fatal,omitempty"` + Detail json.RawMessage `json:"detail,omitempty"` +} + +// ModelRef references one model served by a provider route. +type ModelRef struct { + ID string `json:"id"` + Default bool `json:"default,omitempty"` +} + +// ProviderRoute routes one provider's traffic through the LLM proxy using a +// virtual key. +type ProviderRoute struct { + Key string `json:"key"` + APIKey string `json:"api_key"` + APIType string `json:"api_type,omitempty"` + Models []ModelRef `json:"models"` +} + +// LLMRouting mirrors the configure-llm routing document (docs/shim.md): it +// describes how to route all of the agent's LLM traffic through the Claworc +// LLM proxy using virtual keys. +type LLMRouting struct { + ProxyURL string `json:"proxy_url"` + Style string `json:"style"` // openai | anthropic + DefaultModel string `json:"default_model"` + FallbackModels []string `json:"fallback_models"` + Providers []ProviderRoute `json:"providers"` +} + +// Models returns the effective ordered model list: DefaultModel followed by +// FallbackModels. Empty when no default model is set. +func (r LLMRouting) Models() []string { + if r.DefaultModel == "" { + return nil + } + models := make([]string, 0, 1+len(r.FallbackModels)) + models = append(models, r.DefaultModel) + models = append(models, r.FallbackModels...) + return models +} + +// TransportError wraps failures to reach the instance (SSH connection, +// tunnel lookup) as opposed to agent-level failures, so handlers can +// distinguish "cannot reach the container" (502) from "agent operation +// failed" (503/500). +type TransportError struct{ Err error } + +func (e *TransportError) Error() string { return "transport: " + e.Err.Error() } +func (e *TransportError) Unwrap() error { return e.Err } diff --git a/control-plane/internal/agentshim/shimexec/client.go b/control-plane/internal/agentshim/shimexec/client.go new file mode 100644 index 00000000..487bd18a --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/client.go @@ -0,0 +1,338 @@ +package shimexec + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "strings" + "sync" + "time" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + gossh "golang.org/x/crypto/ssh" +) + +// AgentInfo is the agent identity block of the shim meta document. +type AgentInfo struct { + Name string `json:"name"` + Version string `json:"version,omitempty"` +} + +// LLMMeta is the llm block of the shim meta document. +type LLMMeta struct { + Styles []string `json:"styles"` +} + +// Meta is the wire document printed by the `meta` verb (docs/shim.md). +// Unknown fields are ignored per the contract's forward-compatibility rule. +type Meta struct { + Contract int `json:"contract"` + ShimVersion string `json:"shim_version"` + Agent AgentInfo `json:"agent"` + Capabilities []string `json:"capabilities"` + ConfigFiles []agentshim.ConfigFile `json:"config_files"` + WorkspaceDir string `json:"workspace_dir"` + SkillsDir string `json:"skills_dir"` + LogFiles []agentshim.LogFile `json:"log_files"` + LLM LLMMeta `json:"llm"` + SessionPersistence string `json:"session_persistence"` + ChatEndDetection string `json:"chat_end_detection,omitempty"` +} + +// Has reports whether the meta document declares the given capability string. +func (m *Meta) Has(capability string) bool { + for _, c := range m.Capabilities { + if c == capability { + return true + } + } + return false +} + +// capabilities translates the meta document's capability strings into the +// adapter-agnostic Capabilities struct. +func (m *Meta) capabilities() agentshim.Capabilities { + return agentshim.Capabilities{ + Chat: m.Has("chat"), + ChatAbort: m.Has("chat.abort"), + SessionReset: m.Has("session.reset"), + Config: m.Has("config"), + ConfigureLLM: m.Has("configure-llm"), + Restart: m.Has("restart"), + ControlUI: m.Has("control-ui"), + Skills: m.Has("skills"), + + ConfigFiles: m.ConfigFiles, + WorkspaceDir: m.WorkspaceDir, + SkillsDir: m.SkillsDir, + LogFiles: m.LogFiles, + LLMStyles: m.LLM.Styles, + SessionPersistence: m.SessionPersistence, + } +} + +// Client implements agentshim.Client by invoking the shim verbs under +// /opt/claworc/shim through a Runner. The parsed meta document is fetched +// once and cached; InvalidateCache forces a re-probe (e.g. after an image +// update). +type Client struct { + runner Runner + + // AbortGrace is how long Session.Abort waits for the in-flight + // chat-send to exit after chat-abort before hard-terminating its + // transport. Defaults to 5s. + AbortGrace time.Duration + + mu sync.Mutex + cached *Meta +} + +var _ agentshim.Client = (*Client)(nil) + +// New builds a Client on top of the given Runner. +func New(runner Runner) *Client { + return &Client{runner: runner, AbortGrace: 5 * time.Second} +} + +// NewFromSSH builds a Client whose verbs run over SSH connections resolved +// by the given function (typically a closure over the sshproxy manager). +func NewFromSSH(resolve func(ctx context.Context) (*gossh.Client, error)) *Client { + return New(NewSSHRunner(resolve)) +} + +// Type implements agentshim.Client. +func (c *Client) Type() string { return Type } + +// run executes one shim verb, returning its stdout after mapping the +// contract exit codes to errors. +func (c *Client) run(ctx context.Context, stdin io.Reader, verb string, args ...string) (string, error) { + argv := append([]string{verbPath(verb)}, args...) + var out bytes.Buffer + tail := newTailBuffer(stderrTailCap) + code, err := c.runner.Run(ctx, argv, stdin, &out, tail) + if err != nil { + return out.String(), fmt.Errorf("%s: %w", verb, err) + } + if err := mapExit(verb, code, out.Bytes(), tail.String()); err != nil { + return out.String(), err + } + return out.String(), nil +} + +// Meta returns the parsed (and validated) shim meta document, fetching it on +// first use. The result is a copy; mutating it does not affect the cache. +func (c *Client) Meta(ctx context.Context) (Meta, error) { + m, err := c.getMeta(ctx) + if err != nil { + return Meta{}, err + } + return *m, nil +} + +// getMeta returns the cached meta document, probing the shim when absent. +// The mutex is held across the probe so concurrent callers share one fetch. +func (c *Client) getMeta(ctx context.Context) (*Meta, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.cached != nil { + return c.cached, nil + } + stdout, err := c.run(ctx, nil, "meta") + if err != nil { + return nil, err + } + m, err := parseMeta([]byte(stdout)) + if err != nil { + return nil, err + } + c.cached = m + return m, nil +} + +// parseMeta decodes and validates a shim meta document. +func parseMeta(raw []byte) (*Meta, error) { + var m Meta + if err := json.Unmarshal(raw, &m); err != nil { + return nil, fmt.Errorf("meta: invalid JSON: %w", err) + } + if m.Contract != SupportedContract { + return nil, fmt.Errorf("meta: unsupported shim contract %d (control plane supports %d)", m.Contract, SupportedContract) + } + if !m.Has("chat") { + return nil, fmt.Errorf("meta: shim does not declare the required %q capability", "chat") + } + return &m, nil +} + +// InvalidateCache drops the cached meta document so the next call re-probes +// the shim (used after image updates and SSH reconnects). +func (c *Client) InvalidateCache() { + c.mu.Lock() + c.cached = nil + c.mu.Unlock() +} + +// Capabilities implements agentshim.Client. +func (c *Client) Capabilities(ctx context.Context) (agentshim.Capabilities, error) { + m, err := c.getMeta(ctx) + if err != nil { + return agentshim.Capabilities{}, err + } + return m.capabilities(), nil +} + +// Health implements agentshim.Client: `health` exit 0 → nil, 4 → ErrBooting, +// anything else → an error carrying the shim's diagnostics. +func (c *Client) Health(ctx context.Context) error { + _, err := c.run(ctx, nil, "health") + return err +} + +// Identity reads the static identity files (agent.txt, agent.svg). The name +// is the first line of agent.txt, trimmed. A missing or unreadable agent.svg +// is tolerated (nil bytes) — the UI falls back to a generic icon. +func (c *Client) Identity(ctx context.Context) (name string, svg []byte, err error) { + txt, err := c.runner.ReadFile(ctx, ShimDir+"/agent.txt") + if err != nil { + return "", nil, fmt.Errorf("read agent.txt: %w", err) + } + name, _, _ = strings.Cut(string(txt), "\n") + name = strings.TrimSpace(name) + svg, svgErr := c.runner.ReadFile(ctx, ShimDir+"/agent.svg") + if svgErr != nil { + svg = nil + } + return name, svg, nil +} + +// GetConfig implements agentshim.Client: `config-get --id `, with the +// editor language taken from the meta config_files entry. +func (c *Client) GetConfig(ctx context.Context, fileID string) (string, string, error) { + cf, err := c.findConfigFile(ctx, fileID) + if err != nil { + return "", "", err + } + content, err := c.run(ctx, nil, "config-get", "--id", cf.ID) + if err != nil { + return "", "", err + } + return content, cf.Language, nil +} + +// SetConfig implements agentshim.Client: `config-set --id ` with the new +// content on stdin. A validation failure (exit 6) surfaces as +// *ValidationError carrying the shim's {"error":...} message. The agent is +// NOT restarted here (contract: config-set must not restart; callers invoke +// Restart when the file declares restart_required). +func (c *Client) SetConfig(ctx context.Context, fileID, content string) error { + cf, err := c.findConfigFile(ctx, fileID) + if err != nil { + return err + } + _, err = c.run(ctx, strings.NewReader(content), "config-set", "--id", cf.ID) + return err +} + +// findConfigFile resolves fileID ("" selects the first declared file) +// against the meta document's config_files. +func (c *Client) findConfigFile(ctx context.Context, fileID string) (*agentshim.ConfigFile, error) { + m, err := c.getMeta(ctx) + if err != nil { + return nil, err + } + cf := m.capabilities().FindConfigFile(fileID) + if cf == nil { + if fileID == "" { + return nil, fmt.Errorf("agent declares no config files") + } + return nil, fmt.Errorf("unknown config file %q", fileID) + } + return cf, nil +} + +// Restart implements agentshim.Client: the `restart` verb. +func (c *Client) Restart(ctx context.Context) error { + _, err := c.run(ctx, nil, "restart") + return err +} + +// llmWire mirrors the configure-llm routing document exactly as specified in +// docs/shim.md. agentshim.LLMRouting's JSON tags happen to match, but we +// marshal through this local wire struct so the on-the-wire shape is pinned +// to the contract (nil slices become [], model entries carry only "id") +// independently of future changes to the shared types. +type llmWire struct { + ProxyURL string `json:"proxy_url"` + Style string `json:"style"` + DefaultModel string `json:"default_model"` + FallbackModels []string `json:"fallback_models"` + Providers []llmProviderWire `json:"providers"` +} + +type llmProviderWire struct { + Key string `json:"key"` + APIKey string `json:"api_key"` + // api_type is not part of the documented routing schema; it is included + // (omitempty) because shims MUST ignore unknown fields and dialect-aware + // shims need it to distinguish e.g. codex-style providers. + APIType string `json:"api_type,omitempty"` + Models []llmModelWire `json:"models"` +} + +type llmModelWire struct { + ID string `json:"id"` +} + +func buildLLMWire(routing agentshim.LLMRouting) llmWire { + w := llmWire{ + ProxyURL: routing.ProxyURL, + Style: routing.Style, + DefaultModel: routing.DefaultModel, + FallbackModels: routing.FallbackModels, + Providers: make([]llmProviderWire, 0, len(routing.Providers)), + } + if w.FallbackModels == nil { + w.FallbackModels = []string{} + } + for _, p := range routing.Providers { + models := make([]llmModelWire, 0, len(p.Models)) + for _, m := range p.Models { + models = append(models, llmModelWire{ID: m.ID}) + } + w.Providers = append(w.Providers, llmProviderWire{ + Key: p.Key, + APIKey: p.APIKey, + APIType: p.APIType, + Models: models, + }) + } + return w +} + +// ConfigureLLM implements agentshim.Client: pipes the generic routing +// document into the `configure-llm` verb. Exit 6 (routing not expressible) +// surfaces as *ValidationError. +func (c *Client) ConfigureLLM(ctx context.Context, routing agentshim.LLMRouting) error { + doc, err := json.Marshal(buildLLMWire(routing)) + if err != nil { + return fmt.Errorf("configure-llm: marshal routing: %w", err) + } + _, err = c.run(ctx, strings.NewReader(string(doc)), "configure-llm") + return err +} + +// OpenSession implements agentshim.Client. It validates the shim probe (meta +// must parse and declare chat) and returns a Session whose turns each run +// one streaming `chat-send` exec. +func (c *Client) OpenSession(ctx context.Context, sessionKey string) (agentshim.Session, error) { + caps, err := c.Capabilities(ctx) + if err != nil { + return nil, err + } + if !caps.Chat { + return nil, fmt.Errorf("open session: %w", ErrUnsupported) + } + return newSession(c, sessionKey), nil +} diff --git a/control-plane/internal/agentshim/shimexec/client_test.go b/control-plane/internal/agentshim/shimexec/client_test.go new file mode 100644 index 00000000..73755f09 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/client_test.go @@ -0,0 +1,410 @@ +package shimexec + +import ( + "context" + "encoding/json" + "errors" + "io" + "os" + "path" + "strings" + "sync" + "testing" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" +) + +// --- scripted fake Runner --- + +type fakeResp struct { + stdout string + stderr string + code int + err error +} + +type fakeCall struct { + argv []string + stdin string +} + +// fakeRunner is a Runner test double scripted with canned exit codes and +// output per verb (keyed by the basename of argv[0]). +type fakeRunner struct { + mu sync.Mutex + responses map[string]fakeResp + files map[string][]byte + calls []fakeCall +} + +func (f *fakeRunner) record(argv []string, stdin io.Reader) fakeResp { + var in []byte + if stdin != nil { + in, _ = io.ReadAll(stdin) + } + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, fakeCall{argv: argv, stdin: string(in)}) + return f.responses[path.Base(argv[0])] +} + +func (f *fakeRunner) Run(_ context.Context, argv []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + resp := f.record(argv, stdin) + if stdout != nil { + io.WriteString(stdout, resp.stdout) + } + if stderr != nil { + io.WriteString(stderr, resp.stderr) + } + return resp.code, resp.err +} + +func (f *fakeRunner) Start(_ context.Context, argv []string, stdin io.Reader) (StreamHandle, error) { + resp := f.record(argv, stdin) + if resp.err != nil { + return nil, resp.err + } + return &fakeStream{r: strings.NewReader(resp.stdout), tail: resp.stderr, code: resp.code}, nil +} + +func (f *fakeRunner) ReadFile(_ context.Context, p string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + b, ok := f.files[p] + if !ok { + return nil, os.ErrNotExist + } + return b, nil +} + +func (f *fakeRunner) verbCalls(verb string) int { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, c := range f.calls { + if path.Base(c.argv[0]) == verb { + n++ + } + } + return n +} + +func (f *fakeRunner) lastStdin(verb string) string { + f.mu.Lock() + defer f.mu.Unlock() + for i := len(f.calls) - 1; i >= 0; i-- { + if path.Base(f.calls[i].argv[0]) == verb { + return f.calls[i].stdin + } + } + return "" +} + +type fakeStream struct { + r *strings.Reader + tail string + code int +} + +func (h *fakeStream) Stdout() io.Reader { return h.r } +func (h *fakeStream) StderrTail() string { return h.tail } +func (h *fakeStream) Terminate() error { return nil } +func (h *fakeStream) Wait() (int, error) { return h.code, nil } + +const validMetaDoc = `{ + "contract": 1, + "shim_version": "0.1.0", + "agent": {"name": "fakeagent", "version": "9.9.9"}, + "capabilities": ["chat", "chat.abort", "session.reset", "config", "configure-llm", "restart", "control-ui", "skills"], + "config_files": [ + {"id": "main", "path": "/etc/agent.json", "language": "json", "label": "agent.json", "restart_required": true} + ], + "workspace_dir": "/home/claworc/workspace", + "skills_dir": "/home/claworc/skills", + "log_files": [{"path": "/var/log/claworc/agent.log", "label": "Agent"}], + "llm": {"styles": ["openai", "anthropic"]}, + "session_persistence": "native", + "unknown_future_field": 42 +}` + +func newFakeClient(responses map[string]fakeResp) (*Client, *fakeRunner) { + fr := &fakeRunner{responses: responses} + return New(fr), fr +} + +func TestMetaParsingAndCapabilities(t *testing.T) { + c, fr := newFakeClient(map[string]fakeResp{"meta": {stdout: validMetaDoc}}) + caps, err := c.Capabilities(context.Background()) + if err != nil { + t.Fatalf("Capabilities: %v", err) + } + if !caps.Chat || !caps.ChatAbort || !caps.SessionReset || !caps.Config || + !caps.ConfigureLLM || !caps.Restart || !caps.ControlUI || !caps.Skills { + t.Errorf("capability flags not all set: %+v", caps) + } + if len(caps.ConfigFiles) != 1 || caps.ConfigFiles[0].ID != "main" || + caps.ConfigFiles[0].Language != "json" || !caps.ConfigFiles[0].RestartRequired { + t.Errorf("config files: %+v", caps.ConfigFiles) + } + if caps.WorkspaceDir != "/home/claworc/workspace" || caps.SkillsDir != "/home/claworc/skills" { + t.Errorf("dirs: %q %q", caps.WorkspaceDir, caps.SkillsDir) + } + if len(caps.LLMStyles) != 2 || caps.LLMStyles[0] != "openai" { + t.Errorf("llm styles: %v", caps.LLMStyles) + } + if caps.SessionPersistence != "native" { + t.Errorf("session persistence: %q", caps.SessionPersistence) + } + + m, err := c.Meta(context.Background()) + if err != nil { + t.Fatalf("Meta: %v", err) + } + if m.Agent.Name != "fakeagent" || m.Agent.Version != "9.9.9" || m.ShimVersion != "0.1.0" { + t.Errorf("meta identity: %+v", m) + } + + // Cached: three reads, one probe. + if _, err := c.Capabilities(context.Background()); err != nil { + t.Fatal(err) + } + if got := fr.verbCalls("meta"); got != 1 { + t.Errorf("meta probes = %d, want 1 (cached)", got) + } + c.InvalidateCache() + if _, err := c.Capabilities(context.Background()); err != nil { + t.Fatal(err) + } + if got := fr.verbCalls("meta"); got != 2 { + t.Errorf("meta probes after invalidate = %d, want 2", got) + } +} + +func TestMetaValidation(t *testing.T) { + cases := []struct { + name, doc, wantSub string + }{ + {"bad contract", `{"contract": 2, "capabilities": ["chat"]}`, "unsupported shim contract"}, + {"missing chat", `{"contract": 1, "capabilities": ["config"]}`, `required "chat" capability`}, + {"invalid json", `{nope`, "invalid JSON"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{"meta": {stdout: tc.doc}}) + _, err := c.Capabilities(context.Background()) + if err == nil || !strings.Contains(err.Error(), tc.wantSub) { + t.Errorf("err = %v, want substring %q", err, tc.wantSub) + } + }) + } +} + +func TestMetaUnsupportedVerb(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{"meta": {code: 127, stderr: "not found"}}) + if _, err := c.Capabilities(context.Background()); err == nil { + t.Fatal("want error for exit 127 meta") + } +} + +func TestExitCodeMapping(t *testing.T) { + t.Run("3 unsupported", func(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{"restart": {code: ExitUnsupported}}) + err := c.Restart(context.Background()) + if !errors.Is(err, ErrUnsupported) { + t.Errorf("err = %v, want ErrUnsupported", err) + } + }) + t.Run("4 booting", func(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{"health": {code: ExitNotReady}}) + err := c.Health(context.Background()) + if !errors.Is(err, ErrBooting) { + t.Errorf("err = %v, want ErrBooting", err) + } + }) + t.Run("6 validation with payload", func(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{ + "meta": {stdout: validMetaDoc}, + "config-set": {code: ExitValidation, stdout: `{"error":"bad json at line 3"}`}, + }) + err := c.SetConfig(context.Background(), "main", "{}") + var ve *ValidationError + if !errors.As(err, &ve) { + t.Fatalf("err = %v, want *ValidationError", err) + } + if ve.Message != "bad json at line 3" { + t.Errorf("message = %q", ve.Message) + } + }) + t.Run("generic failure carries stderr tail", func(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{"health": {code: 1, stderr: "gateway crashed hard"}}) + err := c.Health(context.Background()) + if err == nil || !strings.Contains(err.Error(), "gateway crashed hard") || !strings.Contains(err.Error(), "exit 1") { + t.Errorf("err = %v", err) + } + }) + t.Run("stderr tail capped", func(t *testing.T) { + big := strings.Repeat("x", 10*stderrTailCap) + "TAIL-END" + c, _ := newFakeClient(map[string]fakeResp{"health": {code: 1, stderr: big}}) + err := c.Health(context.Background()) + if err == nil { + t.Fatal("want error") + } + if len(err.Error()) > stderrTailCap+100 { + t.Errorf("error not capped: %d bytes", len(err.Error())) + } + if !strings.Contains(err.Error(), "TAIL-END") { + t.Errorf("tail should keep the end of stderr: %v", err.Error()[:80]) + } + }) + t.Run("health ok", func(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{"health": {stdout: `{"status":"ok"}`}}) + if err := c.Health(context.Background()); err != nil { + t.Errorf("err = %v, want nil", err) + } + }) +} + +func TestGetConfig(t *testing.T) { + c, fr := newFakeClient(map[string]fakeResp{ + "meta": {stdout: validMetaDoc}, + "config-get": {stdout: `{"model": "x"}`}, + }) + content, lang, err := c.GetConfig(context.Background(), "") + if err != nil { + t.Fatalf("GetConfig: %v", err) + } + if content != `{"model": "x"}` || lang != "json" { + t.Errorf("content=%q lang=%q", content, lang) + } + // "" resolves to the first declared file's id. + fr.mu.Lock() + last := fr.calls[len(fr.calls)-1].argv + fr.mu.Unlock() + if want := []string{ShimDir + "/config-get", "--id", "main"}; strings.Join(last, " ") != strings.Join(want, " ") { + t.Errorf("argv = %v", last) + } + + if _, _, err := c.GetConfig(context.Background(), "nope"); err == nil || !strings.Contains(err.Error(), `unknown config file "nope"`) { + t.Errorf("unknown id err = %v", err) + } +} + +func TestSetConfigSendsContentOnStdin(t *testing.T) { + c, fr := newFakeClient(map[string]fakeResp{ + "meta": {stdout: validMetaDoc}, + "config-set": {}, + }) + if err := c.SetConfig(context.Background(), "main", "new content"); err != nil { + t.Fatalf("SetConfig: %v", err) + } + if got := fr.lastStdin("config-set"); got != "new content" { + t.Errorf("stdin = %q", got) + } +} + +func TestConfigureLLMWireFormat(t *testing.T) { + c, fr := newFakeClient(map[string]fakeResp{"configure-llm": {}}) + routing := agentshim.LLMRouting{ + ProxyURL: "http://127.0.0.1:40001", + Style: "openai", + DefaultModel: "anthropic/claude-sonnet-4-5", + // FallbackModels deliberately nil: must marshal as []. + Providers: []agentshim.ProviderRoute{{ + Key: "anthropic", + APIKey: "claworc-vk-abc123", + Models: []agentshim.ModelRef{{ID: "anthropic/claude-sonnet-4-5", Default: true}}, + }}, + } + if err := c.ConfigureLLM(context.Background(), routing); err != nil { + t.Fatalf("ConfigureLLM: %v", err) + } + raw := fr.lastStdin("configure-llm") + + var doc map[string]any + if err := json.Unmarshal([]byte(raw), &doc); err != nil { + t.Fatalf("routing doc not JSON: %v (%s)", err, raw) + } + if doc["proxy_url"] != "http://127.0.0.1:40001" || doc["style"] != "openai" || + doc["default_model"] != "anthropic/claude-sonnet-4-5" { + t.Errorf("doc = %v", doc) + } + fb, ok := doc["fallback_models"].([]any) + if !ok || len(fb) != 0 { + t.Errorf("fallback_models = %#v, want []", doc["fallback_models"]) + } + providers := doc["providers"].([]any) + p0 := providers[0].(map[string]any) + if p0["key"] != "anthropic" || p0["api_key"] != "claworc-vk-abc123" { + t.Errorf("provider = %v", p0) + } + m0 := p0["models"].([]any)[0].(map[string]any) + if m0["id"] != "anthropic/claude-sonnet-4-5" { + t.Errorf("model = %v", m0) + } + // The documented wire schema carries only "id" per model. + if _, has := m0["default"]; has { + t.Errorf("model entry should carry only id: %v", m0) + } +} + +func TestConfigureLLMValidationFailure(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{ + "configure-llm": {code: ExitValidation, stdout: `{"error":"routing not expressible"}`}, + }) + err := c.ConfigureLLM(context.Background(), agentshim.LLMRouting{}) + var ve *ValidationError + if !errors.As(err, &ve) || ve.Message != "routing not expressible" { + t.Errorf("err = %v", err) + } +} + +func TestIdentity(t *testing.T) { + t.Run("full", func(t *testing.T) { + fr := &fakeRunner{files: map[string][]byte{ + ShimDir + "/agent.txt": []byte(" OpenClaw \nextra junk\n"), + ShimDir + "/agent.svg": []byte(""), + }} + name, svg, err := New(fr).Identity(context.Background()) + if err != nil { + t.Fatalf("Identity: %v", err) + } + if name != "OpenClaw" { + t.Errorf("name = %q", name) + } + if string(svg) != "" { + t.Errorf("svg = %q", svg) + } + }) + t.Run("missing svg tolerated", func(t *testing.T) { + fr := &fakeRunner{files: map[string][]byte{ + ShimDir + "/agent.txt": []byte("Hermes\n"), + }} + name, svg, err := New(fr).Identity(context.Background()) + if err != nil || name != "Hermes" || svg != nil { + t.Errorf("name=%q svg=%v err=%v", name, svg, err) + } + }) + t.Run("missing txt fails", func(t *testing.T) { + fr := &fakeRunner{files: map[string][]byte{}} + if _, _, err := New(fr).Identity(context.Background()); err == nil { + t.Error("want error for missing agent.txt") + } + }) +} + +func TestOpenSessionRequiresChat(t *testing.T) { + c, _ := newFakeClient(map[string]fakeResp{ + "meta": {stdout: `{"contract":1,"capabilities":["config"]}`}, + }) + if _, err := c.OpenSession(context.Background(), "browser"); err == nil { + t.Fatal("want error when meta lacks chat capability") + } +} + +func TestTailBuffer(t *testing.T) { + tb := newTailBuffer(8) + io.WriteString(tb, "0123456789abcdef") + if got := tb.String(); got != "89abcdef" { + t.Errorf("tail = %q", got) + } +} diff --git a/control-plane/internal/agentshim/shimexec/local_test.go b/control-plane/internal/agentshim/shimexec/local_test.go new file mode 100644 index 00000000..72976044 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/local_test.go @@ -0,0 +1,415 @@ +package shimexec + +// Local-exec conformance harness: drives the adapter (Client + session) +// against real shell scripts in testdata/shim through the LocalRunner, so +// the exact code paths used over SSH — argv building, JSONL parsing, exit +// code mapping, abort/terminate semantics — are exercised end to end. + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" +) + +func requireSh(t *testing.T) { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("shim scripts need a POSIX sh") + } + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } +} + +func shimDir(t *testing.T, rel string) string { + t.Helper() + dir, err := filepath.Abs(rel) + if err != nil { + t.Fatal(err) + } + entries, err := os.ReadDir(dir) + if err != nil { + t.Skipf("shim dir %s not available: %v", dir, err) + } + // Ensure the verbs are executable regardless of how the tree was + // checked out. + for _, e := range entries { + if e.IsDir() || strings.Contains(e.Name(), ".") { + continue + } + _ = os.Chmod(filepath.Join(dir, e.Name()), 0o755) + } + return dir +} + +func newLocalClient(t *testing.T, env ...string) *Client { + t.Helper() + requireSh(t) + return New(&LocalRunner{Dir: shimDir(t, filepath.Join("testdata", "shim")), Env: env}) +} + +func recvEvent(t *testing.T, s agentshim.Session) agentshim.Event { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + ev, err := s.Recv(ctx) + if err != nil { + t.Fatalf("Recv: %v", err) + } + return ev +} + +func openLocalSession(t *testing.T, c *Client, key string) agentshim.Session { + t.Helper() + sess, err := c.OpenSession(context.Background(), key) + if err != nil { + t.Fatalf("OpenSession: %v", err) + } + t.Cleanup(func() { sess.Close() }) + return sess +} + +func TestLocalMetaCapabilities(t *testing.T) { + c := newLocalClient(t) + caps, err := c.Capabilities(context.Background()) + if err != nil { + t.Fatalf("Capabilities: %v", err) + } + if !caps.Chat || !caps.ChatAbort || !caps.SessionReset || !caps.Config || !caps.ConfigureLLM || !caps.Restart { + t.Errorf("caps = %+v", caps) + } + if caps.ControlUI || caps.Skills { + t.Errorf("control-ui/skills should be off: %+v", caps) + } + if len(caps.ConfigFiles) != 1 || caps.ConfigFiles[0].Language != "ini" { + t.Errorf("config files = %+v", caps.ConfigFiles) + } + if caps.SessionPersistence != "emulated" { + t.Errorf("persistence = %q", caps.SessionPersistence) + } + m, err := c.Meta(context.Background()) + if err != nil { + t.Fatal(err) + } + if m.Agent.Name != "fakeagent" || m.Agent.Version != "1.2.3" { + t.Errorf("agent = %+v", m.Agent) + } +} + +func TestLocalHealth(t *testing.T) { + if err := newLocalClient(t).Health(context.Background()); err != nil { + t.Errorf("healthy: %v", err) + } + if err := newLocalClient(t, "HEALTH_EXIT=4").Health(context.Background()); !errors.Is(err, ErrBooting) { + t.Errorf("booting err = %v", err) + } + err := newLocalClient(t, "HEALTH_EXIT=1").Health(context.Background()) + if err == nil || !strings.Contains(err.Error(), "health detail on stderr") { + t.Errorf("broken err = %v", err) + } +} + +func TestLocalIdentity(t *testing.T) { + name, svg, err := newLocalClient(t).Identity(context.Background()) + if err != nil { + t.Fatalf("Identity: %v", err) + } + if name != "Fake Agent" { + t.Errorf("name = %q", name) + } + if !strings.Contains(string(svg), " 5*time.Second { + t.Errorf("Close took %s", elapsed) + } +} diff --git a/control-plane/internal/agentshim/shimexec/localrunner.go b/control-plane/internal/agentshim/shimexec/localrunner.go new file mode 100644 index 00000000..1b46457d --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/localrunner.go @@ -0,0 +1,182 @@ +package shimexec + +import ( + "context" + "errors" + "io" + "os" + "os/exec" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// LocalRunner implements Runner via os/exec against a directory of shim +// scripts on the local filesystem. It exists for the conformance harness: +// the same adapter code that drives a remote shim over SSH is exercised +// against real scripts (the package testdata set and agent/template/shim) +// without needing a container or SSH server. Verb paths under ShimDir are +// remapped into Dir; identity files are read from Dir too. +type LocalRunner struct { + // Dir is the local directory containing the shim verbs. + Dir string + // Env is extra KEY=VALUE entries appended to the inherited environment + // (e.g. CLAWORC_SHIM_ENV_FILE overrides for the template scripts). + Env []string +} + +var _ Runner = (*LocalRunner)(nil) + +// mapPath rewrites contract paths (/opt/claworc/shim/...) into Dir. +func (r *LocalRunner) mapPath(p string) string { + if rest, ok := strings.CutPrefix(p, ShimDir+"/"); ok { + return filepath.Join(r.Dir, rest) + } + if !filepath.IsAbs(p) { + return filepath.Join(r.Dir, p) + } + return p +} + +func (r *LocalRunner) command(argv []string) (*exec.Cmd, error) { + if len(argv) == 0 { + return nil, errors.New("shimexec: empty argv") + } + cmd := exec.Command(r.mapPath(argv[0]), argv[1:]...) + cmd.Env = append(os.Environ(), r.Env...) + return cmd, nil +} + +// exitCodeOf maps an os/exec Wait error to (exit code, transport error). +func exitCodeOf(werr error) (int, error) { + if werr == nil { + return 0, nil + } + var exitErr *exec.ExitError + if errors.As(werr, &exitErr) { + return exitErr.ExitCode(), nil + } + return -1, werr +} + +// Run implements Runner. +func (r *LocalRunner) Run(ctx context.Context, argv []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + cmd, err := r.command(argv) + if err != nil { + return -1, err + } + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + + // Bind to ctx manually: cancellation delivers SIGTERM (the contract's + // abort signal) with a SIGKILL escalation, which exec.CommandContext's + // default hard-kill would not provide. + if err := cmd.Start(); err != nil { + return -1, err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case <-ctx.Done(): + _ = cmd.Process.Signal(syscall.SIGTERM) + select { + case <-done: + case <-time.After(3 * time.Second): + _ = cmd.Process.Kill() + <-done + } + return -1, ctx.Err() + case werr := <-done: + return exitCodeOf(werr) + } +} + +// Start implements Runner: it launches a streaming command whose lifetime is +// bound to ctx. Stdout is bridged through an io.Pipe (closed only after Wait +// returns) so readers always observe EOF exactly at process exit. +func (r *LocalRunner) Start(ctx context.Context, argv []string, stdin io.Reader) (StreamHandle, error) { + cmd, err := r.command(argv) + if err != nil { + return nil, err + } + pr, pw := io.Pipe() + tail := newTailBuffer(stderrTailCap) + cmd.Stdin = stdin + cmd.Stdout = pw + cmd.Stderr = tail + + if err := cmd.Start(); err != nil { + pw.Close() + return nil, err + } + h := &localStream{cmd: cmd, pr: pr, tail: tail, done: make(chan struct{})} + go func() { + werr := cmd.Wait() + h.mu.Lock() + h.code, h.err = exitCodeOf(werr) + h.mu.Unlock() + pw.Close() + close(h.done) + }() + go func() { + select { + case <-ctx.Done(): + _ = h.Terminate() + case <-h.done: + } + }() + return h, nil +} + +// ReadFile implements Runner from the local filesystem. +func (r *LocalRunner) ReadFile(_ context.Context, path string) ([]byte, error) { + return os.ReadFile(r.mapPath(path)) +} + +// localStream is the StreamHandle for one local streaming command. +type localStream struct { + cmd *exec.Cmd + pr *io.PipeReader + tail *tailBuffer + + done chan struct{} + + mu sync.Mutex + code int + err error + + termOnce sync.Once +} + +func (h *localStream) Stdout() io.Reader { return h.pr } +func (h *localStream) StderrTail() string { return h.tail.String() } + +// Terminate delivers SIGTERM (the contract's abort signal) and escalates to +// SIGKILL if the process is still alive shortly after. +func (h *localStream) Terminate() error { + h.termOnce.Do(func() { + if h.cmd.Process != nil { + _ = h.cmd.Process.Signal(syscall.SIGTERM) + } + go func() { + select { + case <-h.done: + case <-time.After(3 * time.Second): + if h.cmd.Process != nil { + _ = h.cmd.Process.Kill() + } + } + }() + }) + return nil +} + +func (h *localStream) Wait() (int, error) { + <-h.done + h.mu.Lock() + defer h.mu.Unlock() + return h.code, h.err +} diff --git a/control-plane/internal/agentshim/shimexec/register.go b/control-plane/internal/agentshim/shimexec/register.go new file mode 100644 index 00000000..518e3568 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/register.go @@ -0,0 +1,9 @@ +package shimexec + +import "github.com/gluk-w/claworc/control-plane/internal/agentshim" + +func init() { + agentshim.RegisterAdapter(agentshim.ShimAdapterType, func(deps agentshim.InstanceDeps) agentshim.Client { + return NewFromSSH(deps.SSHClient) + }) +} diff --git a/control-plane/internal/agentshim/shimexec/session.go b/control-plane/internal/agentshim/shimexec/session.go new file mode 100644 index 00000000..214f3abf --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/session.go @@ -0,0 +1,281 @@ +package shimexec + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log" + "strings" + "sync" + "time" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" +) + +// maxEventLine bounds a single JSONL event line (cumulative assistant +// snapshots can be large). Matches the browser websocket read limit. +const maxEventLine = 4 * 1024 * 1024 + +// sendQueueDepth bounds messages queued behind an in-flight turn. Sends +// beyond it block until the queue drains (or the Send ctx is done). +const sendQueueDepth = 16 + +// eventBufferDepth bounds events buffered between the turn reader and Recv. +// A full buffer applies backpressure to the chat-send stdout reader. +const eventBufferDepth = 256 + +// session implements agentshim.Session over per-turn `chat-send` streaming +// execs. +// +// Concurrency model: +// - Send enqueues the message onto sendCh; a single manager goroutine +// (loop) drains the queue and runs exactly one chat-send exec at a time, +// so a message sent while a turn is in flight is queued, not rejected — +// the /stop + new-message flow relies on this. +// - The exec's lifetime is bound to the session's own context (derived +// from context.Background), NOT the Send ctx: chat.go's relay ctx may +// cancel independently of the turn. +// - Recv drains the events channel; Abort/Reset run their verbs through +// the Runner directly and are safe to call while Recv blocks. +// - Close cancels the session context (terminating any in-flight exec via +// the Runner's ctx binding) and makes Recv/Send return ErrSessionClosed. +type session struct { + c *Client + key string + + ctx context.Context + cancel context.CancelFunc + + sendCh chan string + events chan agentshim.Event + + closeOnce sync.Once + + mu sync.Mutex + inflight StreamHandle +} + +var _ agentshim.Session = (*session)(nil) + +func newSession(c *Client, key string) *session { + ctx, cancel := context.WithCancel(context.Background()) + s := &session{ + c: c, + key: key, + ctx: ctx, + cancel: cancel, + sendCh: make(chan string, sendQueueDepth), + events: make(chan agentshim.Event, eventBufferDepth), + } + go s.loop() + return s +} + +// Send implements agentshim.Session: it queues the message for the manager +// goroutine. Only one chat-send exec runs at a time; queued messages start +// after the in-flight turn ends (or is aborted). +func (s *session) Send(ctx context.Context, message string) error { + // Checked first: a buffered sendCh could otherwise win the select below + // even after Close. + if s.ctx.Err() != nil { + return ErrSessionClosed + } + select { + case s.sendCh <- message: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-s.ctx.Done(): + return ErrSessionClosed + } +} + +// Recv implements agentshim.Session: it blocks for the next event across +// all turns. Buffered events are still delivered after Close; once drained, +// Recv returns ErrSessionClosed. +func (s *session) Recv(ctx context.Context) (agentshim.Event, error) { + // Prefer already-buffered events over the closed/cancelled signals. + select { + case ev := <-s.events: + return ev, nil + default: + } + select { + case ev := <-s.events: + return ev, nil + case <-ctx.Done(): + return agentshim.Event{}, ctx.Err() + case <-s.ctx.Done(): + return agentshim.Event{}, ErrSessionClosed + } +} + +// Abort implements agentshim.Session: `chat-abort --session `. The +// in-flight chat-send then emits end/aborted and exits on its own; as a +// safety net, if the streaming exec is still alive after AbortGrace it is +// hard-terminated (which synthesizes an error end). +func (s *session) Abort(ctx context.Context) error { + _, err := s.c.run(ctx, nil, "chat-abort", "--session", s.key) + + if h := s.currentHandle(); h != nil { + grace := s.c.AbortGrace + if grace <= 0 { + grace = 5 * time.Second + } + go func() { + t := time.NewTimer(grace) + defer t.Stop() + select { + case <-t.C: + if s.currentHandle() == h { + log.Printf("[shimexec] session %s: chat-send still running %s after abort, terminating", s.key, grace) + _ = h.Terminate() + } + case <-s.ctx.Done(): + } + }() + } + return err +} + +// Reset implements agentshim.Session: `session-reset --session `. +func (s *session) Reset(ctx context.Context) error { + _, err := s.c.run(ctx, nil, "session-reset", "--session", s.key) + return err +} + +// Close implements agentshim.Session: it terminates any in-flight exec and +// releases resources. Recv returns ErrSessionClosed once buffered events are +// drained; Send fails immediately. +func (s *session) Close() error { + s.closeOnce.Do(func() { + s.cancel() + if h := s.currentHandle(); h != nil { + _ = h.Terminate() + } + }) + return nil +} + +func (s *session) currentHandle() StreamHandle { + s.mu.Lock() + defer s.mu.Unlock() + return s.inflight +} + +func (s *session) setHandle(h StreamHandle) { + s.mu.Lock() + s.inflight = h + s.mu.Unlock() +} + +// emit delivers one event to Recv, blocking (backpressure) unless the +// session is closed. +func (s *session) emit(ev agentshim.Event) { + select { + case s.events <- ev: + case <-s.ctx.Done(): + } +} + +// loop is the session manager goroutine: it serializes turns. +func (s *session) loop() { + for { + select { + case <-s.ctx.Done(): + return + case msg := <-s.sendCh: + s.runTurn(msg) + } + } +} + +// runTurn spawns one `chat-send` streaming exec for a queued message, +// forwards its JSONL events, and synthesizes an error end when the exec dies +// without emitting one. +func (s *session) runTurn(message string) { + argv := []string{verbPath("chat-send"), "--session", s.key} + h, err := s.c.runner.Start(s.ctx, argv, strings.NewReader(message)) + if err != nil { + s.emit(agentshim.Event{ + V: 1, Kind: agentshim.EventError, Code: "shim_exec_failed", + Text: "chat-send: " + err.Error(), Fatal: true, + }) + s.emit(agentshim.Event{V: 1, Kind: agentshim.EventEnd, StopReason: agentshim.StopError}) + return + } + s.setHandle(h) + defer s.setHandle(nil) + + var ( + sawEnd bool + lastTurn string + ) + sc := bufio.NewScanner(h.Stdout()) + sc.Buffer(make([]byte, 64*1024), maxEventLine) + for sc.Scan() { + line := bytes.TrimSpace(sc.Bytes()) + if len(line) == 0 { + continue + } + var ev agentshim.Event + if err := json.Unmarshal(line, &ev); err != nil { + log.Printf("[shimexec] session %s: skipping malformed event line: %v", s.key, err) + continue + } + switch ev.Kind { + case agentshim.EventStart, agentshim.EventAssistant, agentshim.EventTool, + agentshim.EventError, agentshim.EventEnd: + default: + // Unknown event kinds MUST be ignored (contract v1 forward compat). + continue + } + if ev.Turn != "" { + lastTurn = ev.Turn + } + s.emit(ev) + if ev.Kind == agentshim.EventEnd { + sawEnd = true + break + } + } + if serr := sc.Err(); serr != nil { + log.Printf("[shimexec] session %s: chat-send stdout read error: %v", s.key, serr) + } + if sawEnd { + // end is contractually the last line; drain any trailing output in + // the background so a misbehaving shim cannot stall Wait on a full + // stdout pipe. + go func() { _, _ = io.Copy(io.Discard, h.Stdout()) }() + } + code, werr := h.Wait() + + if sawEnd { + if code != 0 || werr != nil { + log.Printf("[shimexec] session %s: chat-send exited code=%d err=%v after end event", s.key, code, werr) + } + return + } + + // The exec ended without emitting an end event: the shim/transport + // itself failed. Synthesize a fatal error plus an error end so consumers + // always see a terminated turn. + text := fmt.Sprintf("chat-send exited (code %d) without an end event", code) + if werr != nil { + text = fmt.Sprintf("chat-send failed: %v", werr) + } + if detail := strings.TrimSpace(h.StderrTail()); detail != "" { + text += ": " + capString(detail, stderrTailCap) + } + s.emit(agentshim.Event{ + V: 1, Kind: agentshim.EventError, Turn: lastTurn, + Code: "shim_exec_failed", Text: text, Fatal: true, + }) + s.emit(agentshim.Event{ + V: 1, Kind: agentshim.EventEnd, Turn: lastTurn, + StopReason: agentshim.StopError, + }) +} diff --git a/control-plane/internal/agentshim/shimexec/shimexec.go b/control-plane/internal/agentshim/shimexec/shimexec.go new file mode 100644 index 00000000..a337469f --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/shimexec.go @@ -0,0 +1,171 @@ +// Package shimexec implements the agentshim Client/Session interfaces for +// images that ship the exec-based agent shim contract (docs/shim.md): the +// control plane invokes well-known executables under /opt/claworc/shim over +// the instance's SSH connection. All transport goes through the small Runner +// interface so tests can drive the adapter against local scripts or fakes. +package shimexec + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "strings" + "sync" +) + +// Type is the adapter identifier for the exec-based shim adapter. +const Type = "shimexec" + +// ShimDir is where the shim contract mandates verbs and identity files live +// inside the instance (docs/shim.md, "Image layout"). +const ShimDir = "/opt/claworc/shim" + +// SupportedContract is the shim contract version this adapter implements. +const SupportedContract = 1 + +// Shim contract exit codes (docs/shim.md, "Common conventions"). +const ( + ExitOK = 0 + ExitInternal = 1 + ExitUsage = 2 + ExitUnsupported = 3 + ExitNotReady = 4 + ExitTimeout = 5 + ExitValidation = 6 +) + +// stderrTailCap bounds how much captured stderr is carried into error +// messages and synthesized chat error events. +const stderrTailCap = 2048 + +// ErrUnsupported reports a verb/capability the agent's shim does not +// implement (contract exit code 3). +var ErrUnsupported = errors.New("capability unsupported by agent shim") + +// ErrBooting reports that the agent is not ready yet (contract exit code 4). +var ErrBooting = errors.New("agent not ready (booting)") + +// ErrSessionClosed is returned by Session methods after Close. +var ErrSessionClosed = errors.New("shimexec: session closed") + +// ValidationError carries the {"error":"..."} payload a verb printed on +// stdout when exiting with the validation-failure code 6. +type ValidationError struct{ Message string } + +func (e *ValidationError) Error() string { return "validation failed: " + e.Message } + +// Runner executes shim verbs on the instance. The production implementation +// runs over an established SSH connection (NewSSHRunner); tests substitute a +// LocalRunner (os/exec against a directory of scripts) or a scripted fake. +type Runner interface { + // Run executes argv on the instance, wiring stdin/stdout/stderr, and + // returns the process exit code. A non-nil error means the transport + // itself failed (the exit code is then meaningless). + Run(ctx context.Context, argv []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) + // Start begins a streaming command (chat-send) and returns a handle to + // its stdio. The command's lifetime is bound to ctx: cancellation + // terminates it with SIGTERM semantics. + Start(ctx context.Context, argv []string, stdin io.Reader) (StreamHandle, error) + // ReadFile reads a remote file (agent.txt / agent.svg identity files). + ReadFile(ctx context.Context, path string) ([]byte, error) +} + +// StreamHandle is a running streaming command started by Runner.Start. +type StreamHandle interface { + // Stdout streams the command's stdout. It reaches EOF when the command + // exits (or is terminated). + Stdout() io.Reader + // StderrTail returns the tail (up to ~2KB) of stderr captured so far. + StderrTail() string + // Terminate signals the command with SIGTERM semantics (best-effort) + // and tears down its transport. Safe to call multiple times. + Terminate() error + // Wait blocks until the command exits and returns its exit code. A + // non-nil error means the transport failed before an exit status was + // observed. + Wait() (int, error) +} + +// verbPath returns the absolute path of a shim verb executable. +func verbPath(verb string) string { return ShimDir + "/" + verb } + +// mapExit translates a shim verb's exit code into a Go error per the +// contract's exit-code table. +func mapExit(verb string, code int, stdout []byte, stderrTail string) error { + switch code { + case ExitOK: + return nil + case ExitUnsupported: + return fmt.Errorf("%s: %w", verb, ErrUnsupported) + case ExitNotReady: + return fmt.Errorf("%s: %w", verb, ErrBooting) + case ExitTimeout: + return fmt.Errorf("%s: timed out waiting on the agent: %s", verb, tailDetail(stdout, stderrTail)) + case ExitValidation: + return &ValidationError{Message: validationMessage(stdout, stderrTail)} + default: + return fmt.Errorf("%s: exit %d: %s", verb, code, tailDetail(stdout, stderrTail)) + } +} + +// validationMessage extracts the message from the {"error":"..."} document a +// verb prints on stdout when exiting 6, falling back to raw output. +func validationMessage(stdout []byte, stderrTail string) string { + var doc struct { + Error string `json:"error"` + } + if err := json.Unmarshal(stdout, &doc); err == nil && doc.Error != "" { + return doc.Error + } + if msg := strings.TrimSpace(string(stdout)); msg != "" { + return capString(msg, stderrTailCap) + } + if msg := strings.TrimSpace(stderrTail); msg != "" { + return msg + } + return "invalid payload" +} + +// tailDetail picks the most useful diagnostic text for a generic failure: +// the stderr tail when present, else a capped stdout excerpt. +func tailDetail(stdout []byte, stderrTail string) string { + if d := strings.TrimSpace(stderrTail); d != "" { + return d + } + return capString(strings.TrimSpace(string(stdout)), stderrTailCap) +} + +func capString(s string, max int) string { + if len(s) <= max { + return s + } + return s[len(s)-max:] +} + +// tailBuffer is an io.Writer keeping only the last max bytes written. +// Safe for concurrent use. +type tailBuffer struct { + mu sync.Mutex + max int + buf []byte +} + +func newTailBuffer(max int) *tailBuffer { return &tailBuffer{max: max} } + +func (t *tailBuffer) Write(p []byte) (int, error) { + t.mu.Lock() + defer t.mu.Unlock() + t.buf = append(t.buf, p...) + if len(t.buf) > t.max { + t.buf = append([]byte(nil), t.buf[len(t.buf)-t.max:]...) + } + return len(p), nil +} + +func (t *tailBuffer) String() string { + t.mu.Lock() + defer t.mu.Unlock() + return string(t.buf) +} diff --git a/control-plane/internal/agentshim/shimexec/sshrunner.go b/control-plane/internal/agentshim/shimexec/sshrunner.go new file mode 100644 index 00000000..d1e66c86 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/sshrunner.go @@ -0,0 +1,206 @@ +package shimexec + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "strings" + "sync" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/sshproxy" + gossh "golang.org/x/crypto/ssh" +) + +// SSHRunner is the production Runner: each Run/Start opens one exec channel +// on an established SSH connection resolved lazily per call (connections are +// managed and re-established by sshproxy; resolving late means a reconnect +// between verbs is picked up transparently). +// +// Verbs run as the SSH user (root — SSH is the authentication boundary, +// exactly like the terminal and file browser; shims themselves drop to the +// claworc user, per docs/shim.md). +type SSHRunner struct { + resolve func(ctx context.Context) (*gossh.Client, error) +} + +var _ Runner = (*SSHRunner)(nil) + +// NewSSHRunner builds a Runner over SSH connections resolved by the given +// function (typically a closure over the sshproxy connection manager). +func NewSSHRunner(resolve func(ctx context.Context) (*gossh.Client, error)) *SSHRunner { + return &SSHRunner{resolve: resolve} +} + +// shellJoin quotes each argv element and joins them into one command line +// for the remote shell. +func shellJoin(argv []string) string { + parts := make([]string, len(argv)) + for i, a := range argv { + parts[i] = sshproxy.ShellQuote(a) + } + return strings.Join(parts, " ") +} + +// newSSHSession resolves a connection and opens one exec session on it. +// Failures at this layer are transport failures. +func (r *SSHRunner) newSSHSession(ctx context.Context) (*gossh.Session, error) { + if r.resolve == nil { + return nil, &agentshim.TransportError{Err: errors.New("shimexec: no SSH client resolver")} + } + client, err := r.resolve(ctx) + if err != nil { + return nil, &agentshim.TransportError{Err: err} + } + sess, err := client.NewSession() + if err != nil { + return nil, &agentshim.TransportError{Err: err} + } + return sess, nil +} + +// mapWaitErr translates ssh Session.Wait errors into (exit code, transport +// error). A missing exit status (channel torn down, e.g. by Terminate) maps +// to code -1 with no transport error so callers treat it as an abnormal exit +// rather than an unreachable instance. +func mapWaitErr(werr error) (int, error) { + if werr == nil { + return 0, nil + } + var exitErr *gossh.ExitError + if errors.As(werr, &exitErr) { + return exitErr.ExitStatus(), nil + } + var missing *gossh.ExitMissingError + if errors.As(werr, &missing) { + return -1, nil + } + return -1, &agentshim.TransportError{Err: werr} +} + +// Run implements Runner over one SSH exec channel. +func (r *SSHRunner) Run(ctx context.Context, argv []string, stdin io.Reader, stdout, stderr io.Writer) (int, error) { + sess, err := r.newSSHSession(ctx) + if err != nil { + return -1, err + } + defer sess.Close() + + sess.Stdin = stdin + sess.Stdout = stdout + sess.Stderr = stderr + + if err := sess.Start(shellJoin(argv)); err != nil { + return -1, &agentshim.TransportError{Err: err} + } + + done := make(chan error, 1) + go func() { done <- sess.Wait() }() + + select { + case <-ctx.Done(): + // Best-effort SIGTERM (not all sshds honor signal requests), then + // tear the channel down — the contract treats channel teardown as + // abort. + _ = sess.Signal(gossh.SIGTERM) + _ = sess.Close() + <-done + return -1, ctx.Err() + case werr := <-done: + return mapWaitErr(werr) + } +} + +// Start implements Runner: it begins a streaming exec (chat-send) whose +// lifetime is bound to ctx. +func (r *SSHRunner) Start(ctx context.Context, argv []string, stdin io.Reader) (StreamHandle, error) { + sess, err := r.newSSHSession(ctx) + if err != nil { + return nil, err + } + + stdout, err := sess.StdoutPipe() + if err != nil { + sess.Close() + return nil, &agentshim.TransportError{Err: err} + } + tail := newTailBuffer(stderrTailCap) + sess.Stderr = tail + sess.Stdin = stdin + + if err := sess.Start(shellJoin(argv)); err != nil { + sess.Close() + return nil, &agentshim.TransportError{Err: err} + } + + h := &sshStream{sess: sess, stdout: stdout, tail: tail, done: make(chan struct{})} + go func() { + werr := sess.Wait() + h.mu.Lock() + h.code, h.err = mapWaitErr(werr) + h.mu.Unlock() + close(h.done) + sess.Close() + }() + go func() { + select { + case <-ctx.Done(): + _ = h.Terminate() + case <-h.done: + } + }() + return h, nil +} + +// ReadFile implements Runner by cat-ing the remote path over an exec +// channel (same transport sshproxy's file helpers use). +func (r *SSHRunner) ReadFile(ctx context.Context, path string) ([]byte, error) { + var out bytes.Buffer + tail := newTailBuffer(stderrTailCap) + code, err := r.Run(ctx, []string{"cat", path}, nil, &out, tail) + if err != nil { + return nil, err + } + if code != 0 { + return nil, fmt.Errorf("read %s: exit %d: %s", path, code, strings.TrimSpace(tail.String())) + } + return out.Bytes(), nil +} + +// sshStream is the StreamHandle for one streaming SSH exec. +type sshStream struct { + sess *gossh.Session + stdout io.Reader + tail *tailBuffer + + done chan struct{} + + mu sync.Mutex + code int + err error + + termOnce sync.Once +} + +func (h *sshStream) Stdout() io.Reader { return h.stdout } +func (h *sshStream) StderrTail() string { return h.tail.String() } + +// Terminate sends a best-effort SIGTERM and tears the exec channel down. +// Channel teardown is the contract's documented abort path ("on SIGTERM (or +// when the SSH channel is torn down), the shim SHOULD abort"). +func (h *sshStream) Terminate() error { + h.termOnce.Do(func() { + _ = h.sess.Signal(gossh.SIGTERM) + _ = h.sess.Close() + }) + return nil +} + +func (h *sshStream) Wait() (int, error) { + <-h.done + h.mu.Lock() + defer h.mu.Unlock() + return h.code, h.err +} diff --git a/control-plane/internal/agentshim/shimexec/template_test.go b/control-plane/internal/agentshim/shimexec/template_test.go new file mode 100644 index 00000000..231307e8 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/template_test.go @@ -0,0 +1,270 @@ +package shimexec + +// Conformance run against the REAL copy-me shim implementation shipped in +// agent/template/shim. The template's verbs honor CLAWORC_SHIM_ENV_FILE / +// CLAWORC_SHIM_STATE_DIR / CLAWORC_SHIM_RUN_DIR overrides, so every verb is +// driven against temp files without a container and without touching the +// repo copies. Verbs whose implementation shells out to python3 (chat-send's +// JSON escaping, configure-llm, config-set's validation-error path) are +// skipped when python3 is absent. + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" +) + +func templateDir(t *testing.T) string { + t.Helper() + requireSh(t) + return shimDir(t, filepath.Join("..", "..", "..", "..", "agent", "template", "shim")) +} + +func requirePython3(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("python3"); err != nil { + t.Skip("python3 not available (template verb needs it)") + } +} + +// templateEnvFile writes a temp agent.env and returns its path. +func templateEnvFile(t *testing.T, chatCmd string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "agent.env") + if err := os.WriteFile(p, []byte("CHAT_CMD="+chatCmd+"\n"), 0o644); err != nil { + t.Fatal(err) + } + return p +} + +func newTemplateClient(t *testing.T, env ...string) *Client { + t.Helper() + return New(&LocalRunner{Dir: templateDir(t), Env: env}) +} + +func TestTemplateMetaAndIdentity(t *testing.T) { + c := newTemplateClient(t) + caps, err := c.Capabilities(context.Background()) + if err != nil { + t.Fatalf("Capabilities: %v", err) + } + if !caps.Chat || !caps.ChatAbort || !caps.SessionReset || !caps.Config || !caps.ConfigureLLM || !caps.Restart { + t.Errorf("caps = %+v", caps) + } + if len(caps.ConfigFiles) != 1 || caps.ConfigFiles[0].Language != "shell" || caps.ConfigFiles[0].RestartRequired { + t.Errorf("config files = %+v", caps.ConfigFiles) + } + if caps.SessionPersistence != "none" { + t.Errorf("persistence = %q", caps.SessionPersistence) + } + + name, svg, err := c.Identity(context.Background()) + if err != nil { + t.Fatalf("Identity: %v", err) + } + if name != "Custom Agent" { + t.Errorf("name = %q", name) + } + if !strings.Contains(string(svg), ">> claworc-llm >>>"); got != 1 { + t.Errorf("managed block appears %d times, want 1 (idempotency): %q", got, content) + } +} + +func TestTemplateRestart(t *testing.T) { + if err := newTemplateClient(t).Restart(context.Background()); err != nil { + t.Errorf("Restart: %v", err) + } +} + +func TestTemplateHealthTimeBudget(t *testing.T) { + // meta + health are probed on every SSH reconnect; keep them snappy. + c := newTemplateClient(t, "CLAWORC_SHIM_ENV_FILE="+templateEnvFile(t, `"cat"`)) + start := time.Now() + if _, err := c.Capabilities(context.Background()); err != nil { + t.Fatal(err) + } + if err := c.Health(context.Background()); err != nil { + t.Fatal(err) + } + if elapsed := time.Since(start); elapsed > 5*time.Second { + t.Errorf("probe took %s", elapsed) + } +} diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/agent.svg b/control-plane/internal/agentshim/shimexec/testdata/shim/agent.svg new file mode 100644 index 00000000..7a011616 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/agent.svg @@ -0,0 +1,4 @@ + + + + diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/agent.txt b/control-plane/internal/agentshim/shimexec/testdata/shim/agent.txt new file mode 100644 index 00000000..d9253298 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/agent.txt @@ -0,0 +1 @@ +Fake Agent diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/chat-abort b/control-plane/internal/agentshim/shimexec/testdata/shim/chat-abort new file mode 100755 index 00000000..6e2bf42f --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/chat-abort @@ -0,0 +1,26 @@ +#!/bin/sh +# Conformance-harness chat-abort verb: SIGTERM the chat-send recorded in the +# pid file. ABORT_NOOP=1 makes it do nothing (exercises the adapter's +# safety-kill grace path). Exit 0 also when nothing is running. +set -u + +SESSION="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } + +[ -n "${ABORT_NOOP:-}" ] && exit 0 + +RUN_DIR="${CLAWORC_SHIM_RUN_DIR:-/tmp}" +PIDFILE="$RUN_DIR/chat-$SESSION.pid" +if [ -f "$PIDFILE" ]; then + PID=$(cat "$PIDFILE" 2>/dev/null || true) + if [ -n "$PID" ]; then + kill -TERM "$PID" 2>/dev/null || true + fi +fi +exit 0 diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/chat-send b/control-plane/internal/agentshim/shimexec/testdata/shim/chat-send new file mode 100755 index 00000000..9cb709a2 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/chat-send @@ -0,0 +1,73 @@ +#!/bin/sh +# Conformance-harness chat-send verb. CHAT_MODE selects the scenario: +# echo (default) — start, partial + full cumulative assistant snapshots +# (plus a malformed line and an unknown event kind that +# consumers must skip), end/complete. CHAT_DELAY seconds +# between the snapshots. +# fail — start, then exit 1 with stderr and NO end event +# (consumers must synthesize error + end). +# hang — start, write a pid file, sleep; SIGTERM (from +# chat-abort or Terminate) emits end/aborted and exits 0. +# stubborn — start, sleep with no TERM trap and no pid file: only a +# hard Terminate of the exec ends it (no end event). +set -u + +SESSION="" +TURN="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + --turn) TURN="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } +[ -n "$TURN" ] || TURN="t-$$" + +MSG=$(cat) +MODE="${CHAT_MODE:-echo}" +RUN_DIR="${CLAWORC_SHIM_RUN_DIR:-/tmp}" +mkdir -p "$RUN_DIR" 2>/dev/null || true + +# The pid file must exist before the start event is observable: chat-abort +# may run the moment the consumer sees "start". +if [ "$MODE" = "hang" ]; then + printf '%s\n' "$$" > "$RUN_DIR/chat-$SESSION.pid" +fi + +printf '{"v":1,"event":"start","session":"%s","turn":"%s"}\n' "$SESSION" "$TURN" + +case "$MODE" in + echo) + printf '{"v":1,"event":"assistant","turn":"%s","message_id":"m1","text":"echo:"}\n' "$TURN" + printf 'this is not a json event line\n' + printf '{"v":1,"event":"weird-future-kind","turn":"%s"}\n' "$TURN" + printf '{"v":1,"event":"tool","turn":"%s","name":"exec","phase":"start","detail":{"command":"true"}}\n' "$TURN" + [ "${CHAT_DELAY:-0}" != "0" ] && sleep "$CHAT_DELAY" + printf '{"v":1,"event":"assistant","turn":"%s","message_id":"m1","text":"echo: %s"}\n' "$TURN" "$MSG" + printf '{"v":1,"event":"end","turn":"%s","stop_reason":"complete","text":"echo: %s"}\n' "$TURN" "$MSG" + exit 0 + ;; + fail) + echo "boom: agent exploded" >&2 + exit 1 + ;; + hang) + sleep 60 >/dev/null 2>&1 & + CHILD=$! + # shellcheck disable=SC2064 + trap "kill $CHILD 2>/dev/null; printf '{\"v\":1,\"event\":\"end\",\"turn\":\"%s\",\"stop_reason\":\"aborted\",\"text\":\"\"}\n' '$TURN'; rm -f '$RUN_DIR/chat-$SESSION.pid'; exit 0" TERM INT + wait "$CHILD" + rm -f "$RUN_DIR/chat-$SESSION.pid" + exit 0 + ;; + stubborn) + sleep 60 >/dev/null 2>&1 & + wait $! + exit 0 + ;; + *) + echo "unknown CHAT_MODE: $MODE" >&2 + exit 1 + ;; +esac diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/config-get b/control-plane/internal/agentshim/shimexec/testdata/shim/config-get new file mode 100755 index 00000000..36c720eb --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/config-get @@ -0,0 +1,16 @@ +#!/bin/sh +# Conformance-harness config-get verb: prints the file at TEST_CONFIG_FILE. +set -eu + +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi +exec cat "${TEST_CONFIG_FILE:?TEST_CONFIG_FILE must be set}" diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/config-set b/control-plane/internal/agentshim/shimexec/testdata/shim/config-set new file mode 100755 index 00000000..daa54655 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/config-set @@ -0,0 +1,27 @@ +#!/bin/sh +# Conformance-harness config-set verb: writes stdin to TEST_CONFIG_FILE. +# Content containing the INVALID marker fails validation: exit 6 with the +# contract's {"error":...} document on stdout. +set -eu + +ID=main +while [ $# -gt 0 ]; do + case "$1" in + --id) ID="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +if [ "$ID" != "main" ]; then + echo "unknown config file id: $ID" >&2 + exit 2 +fi + +TMP="${TEST_CONFIG_FILE:?TEST_CONFIG_FILE must be set}.tmp.$$" +cat > "$TMP" +if grep -q INVALID "$TMP"; then + rm -f "$TMP" + printf '{"error":"config contains the INVALID marker"}\n' + exit 6 +fi +mv -f "$TMP" "$TEST_CONFIG_FILE" +exit 0 diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/configure-llm b/control-plane/internal/agentshim/shimexec/testdata/shim/configure-llm new file mode 100755 index 00000000..16feb407 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/configure-llm @@ -0,0 +1,6 @@ +#!/bin/sh +# Conformance-harness configure-llm verb: captures the routing document into +# TEST_LLM_FILE (full overwrite — trivially idempotent). +set -eu +cat > "${TEST_LLM_FILE:?TEST_LLM_FILE must be set}" +exit 0 diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/health b/control-plane/internal/agentshim/shimexec/testdata/shim/health new file mode 100755 index 00000000..7fb1fca5 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/health @@ -0,0 +1,6 @@ +#!/bin/sh +# Conformance-harness health verb: exit code driven by HEALTH_EXIT. +if [ "${HEALTH_EXIT:-0}" -ne 0 ]; then + echo "health detail on stderr" >&2 +fi +exit "${HEALTH_EXIT:-0}" diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/meta b/control-plane/internal/agentshim/shimexec/testdata/shim/meta new file mode 100755 index 00000000..4d26b9c4 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/meta @@ -0,0 +1,20 @@ +#!/bin/sh +# Conformance-harness meta verb: prints a static, valid contract-v1 document. +set -eu +cat <<'EOF' +{ + "contract": 1, + "shim_version": "0.0.1-test", + "agent": {"name": "fakeagent", "version": "1.2.3"}, + "capabilities": ["chat", "chat.abort", "session.reset", "config", "configure-llm", "restart"], + "config_files": [ + {"id": "main", "path": "/tmp/fake-agent.conf", "language": "ini", "label": "agent.conf", "restart_required": true} + ], + "workspace_dir": "/home/claworc/workspace", + "skills_dir": "/home/claworc/skills", + "log_files": [{"path": "/var/log/claworc/agent.log", "label": "Agent"}], + "llm": {"styles": ["openai"]}, + "session_persistence": "emulated", + "some_future_field": {"ignored": true} +} +EOF diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/restart b/control-plane/internal/agentshim/shimexec/testdata/shim/restart new file mode 100755 index 00000000..0842b600 --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/restart @@ -0,0 +1,5 @@ +#!/bin/sh +# Conformance-harness restart verb: records the restart in a marker file. +set -eu +touch "${TEST_RESTART_MARKER:?TEST_RESTART_MARKER must be set}" +exit 0 diff --git a/control-plane/internal/agentshim/shimexec/testdata/shim/session-reset b/control-plane/internal/agentshim/shimexec/testdata/shim/session-reset new file mode 100755 index 00000000..3d80e72f --- /dev/null +++ b/control-plane/internal/agentshim/shimexec/testdata/shim/session-reset @@ -0,0 +1,18 @@ +#!/bin/sh +# Conformance-harness session-reset verb: records the reset in a log the +# test asserts on. Idempotent. +set -eu + +SESSION="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } + +STATE_DIR="${CLAWORC_SHIM_STATE_DIR:-/tmp}" +mkdir -p "$STATE_DIR" +printf '%s\n' "$SESSION" >> "$STATE_DIR/reset.log" +exit 0 diff --git a/control-plane/internal/backup/backup_test.go b/control-plane/internal/backup/backup_test.go index f4da79d1..fbca1476 100644 --- a/control-plane/internal/backup/backup_test.go +++ b/control-plane/internal/backup/backup_test.go @@ -38,7 +38,6 @@ func (m *mockOrch) GetInstanceStatus(_ context.Context, _ string) (string, error return "running", nil } func (m *mockOrch) GetInstanceImageInfo(_ context.Context, _ string) (string, error) { return "", nil } -func (m *mockOrch) UpdateInstanceConfig(_ context.Context, _, _ string) error { return nil } func (m *mockOrch) CloneVolumes(_ context.Context, _, _ string) error { return nil } func (m *mockOrch) ConfigureSSHAccess(_ context.Context, _ uint, _ string) error { return nil } func (m *mockOrch) GetSSHAddress(_ context.Context, _ uint) (string, int, error) { return "", 0, nil } @@ -63,9 +62,9 @@ func (m *mockOrch) StreamExecInInstance(ctx context.Context, name string, cmd [] func (m *mockOrch) UpdatePlacementConfig(_ context.Context, _ string, _ orchestrator.UpdatePlacementParams) error { return nil } -func (m *mockOrch) DeleteSharedVolume(_ context.Context, _ uint) error { return nil } -func (m *mockOrch) CloneVolume(_ context.Context, _, _ string) error { return nil } -func (m *mockOrch) VolumeNameFor(name, suffix string) string { return name + "-" + suffix } +func (m *mockOrch) DeleteSharedVolume(_ context.Context, _ uint) error { return nil } +func (m *mockOrch) CloneVolume(_ context.Context, _, _ string) error { return nil } +func (m *mockOrch) VolumeNameFor(name, suffix string) string { return name + "-" + suffix } func (m *mockOrch) Apply(_ context.Context, _ orchestrator.WorkloadSpec) error { return nil } func (m *mockOrch) DeleteWorkload(_ context.Context, _ orchestrator.WorkloadSpec) error { return nil } func (m *mockOrch) EnsureSSHAccess(_ context.Context, _, _ string) error { return nil } diff --git a/control-plane/internal/database/database.go b/control-plane/internal/database/database.go index 1628bd9c..617e5a38 100644 --- a/control-plane/internal/database/database.go +++ b/control-plane/internal/database/database.go @@ -161,7 +161,12 @@ func seedDefaults() error { // On-demand browser pod defaults. New instances created from now on use // the slim agent image; the browser variant is launched lazily as a // separate pod/container by the configured provider. - "default_agent_image": "claworc/openclaw:latest", + "default_agent_image": "claworc/openclaw:latest", + // Per-agent-type default images for non-OpenClaw agents. OpenClaw keeps + // the legacy default_agent_image key above. JSON map agent type → image; + // "custom" is intentionally empty (custom images must be specified per + // instance or configured here by the admin). + "default_agent_images": `{"hermes":"claworc/hermes:latest","nanoclaw":"claworc/nanoclaw:latest","custom":""}`, "default_browser_image": "claworc/chromium-browser:latest", "default_browser_provider": "auto", "default_browser_idle_minutes": "15", diff --git a/control-plane/internal/database/migrations/migration_00012_backfill_instance_agent_type.go b/control-plane/internal/database/migrations/migration_00012_backfill_instance_agent_type.go new file mode 100644 index 00000000..0315afa8 --- /dev/null +++ b/control-plane/internal/database/migrations/migration_00012_backfill_instance_agent_type.go @@ -0,0 +1,34 @@ +package migrations + +import ( + "context" + "database/sql" + "fmt" + + "github.com/pressly/goose/v3" + "gorm.io/gorm" + + "github.com/gluk-w/claworc/control-plane/internal/database/models" +) + +// 00012_backfill_instance_agent_type: populates Instance.AgentType for rows +// that pre-date the column. AutoMigrateAll creates the column on every boot; +// this migration walks rows where agent_type IS NULL OR ” and stamps the +// only agent type that existed before the universal agent shim: "openclaw". +// Idempotent: re-runs match zero rows. +func init() { + register(&goose.Migration{ + Version: 12, + Source: "00012_backfill_instance_agent_type.go", + UpFnContext: func(ctx context.Context, tx *sql.Tx) error { + return WithMigrator(ctx, tx, func(m gorm.Migrator, gdb *gorm.DB) error { + return gdb.Model(&models.Instance{}). + Where("agent_type IS NULL OR agent_type = ''"). + Update("agent_type", models.AgentTypeOpenClaw).Error + }) + }, + DownFnContext: func(ctx context.Context, tx *sql.Tx) error { + return fmt.Errorf("backfill_instance_agent_type migration is not reversible") + }, + }) +} diff --git a/control-plane/internal/database/models.go b/control-plane/internal/database/models.go index ac84b8f8..31860e63 100644 --- a/control-plane/internal/database/models.go +++ b/control-plane/internal/database/models.go @@ -37,6 +37,10 @@ type ( WebhookLog = models.WebhookLog ) +// AgentTypeOpenClaw re-exports the implicit agent type of pre-shim rows +// (empty Instance.AgentType). See Instance.EffectiveAgentType. +const AgentTypeOpenClaw = models.AgentTypeOpenClaw + // Helper re-exports keep `database.ParseTeamIDs(...)` etc. working for // existing callers. The implementations live in the models package. diff --git a/control-plane/internal/database/models/models.go b/control-plane/internal/database/models/models.go index f808ca8d..ff3af410 100644 --- a/control-plane/internal/database/models/models.go +++ b/control-plane/internal/database/models/models.go @@ -28,6 +28,21 @@ func (i *Instance) BeforeCreate(_ *gorm.DB) error { return nil } +// AgentTypeOpenClaw is the agent type every pre-shim instance runs. It is +// the implicit value of an empty Instance.AgentType (see EffectiveAgentType). +const AgentTypeOpenClaw = "openclaw" + +// EffectiveAgentType returns the instance's agent type, treating the empty +// string as "openclaw". Mirrors the IsLegacyEmbedded philosophy: rows that +// pre-date the column store "" and must keep behaving exactly as before the +// upgrade. All code MUST read this accessor, never the raw AgentType field. +func (i *Instance) EffectiveAgentType() string { + if i.AgentType == "" { + return AgentTypeOpenClaw + } + return i.AgentType +} + // IsLegacyEmbedded reports whether the given container image refers to the // legacy combined agent+browser image. Legacy instances run Chromium and VNC // inside the same container as OpenClaw and use the agent's reverse VNC tunnel @@ -60,9 +75,15 @@ type Instance struct { // UUID is a stable, non-enumerable identifier used in webhook URLs and // any other surface that should not leak the sequential ID. Auto-filled // by BeforeCreate; backfilled for pre-existing rows by migration 00007. - UUID string `gorm:"uniqueIndex" json:"uuid"` - Name string `gorm:"uniqueIndex;not null" json:"name"` - DisplayName string `gorm:"not null" json:"display_name"` + UUID string `gorm:"uniqueIndex" json:"uuid"` + Name string `gorm:"uniqueIndex;not null" json:"name"` + DisplayName string `gorm:"not null" json:"display_name"` + // AgentType identifies which agent implementation the instance's image + // runs ("openclaw", "hermes", "nanoclaw", "custom" — see + // agentshim.Types()). Empty means "openclaw" (pre-shim rows); always read + // via EffectiveAgentType(), never the raw field. Backfilled to "openclaw" + // for pre-existing rows by migration 00012. + AgentType string `gorm:"default:''" json:"agent_type"` Status string `gorm:"not null;default:creating" json:"status"` CPURequest string `gorm:"default:500m" json:"cpu_request"` CPULimit string `gorm:"default:2000m" json:"cpu_limit"` diff --git a/control-plane/internal/database/models_test.go b/control-plane/internal/database/models_test.go index 085332ec..00af305c 100644 --- a/control-plane/internal/database/models_test.go +++ b/control-plane/internal/database/models_test.go @@ -131,3 +131,23 @@ func TestParseEncodeSharedFolderInstanceIDs_Roundtrip(t *testing.T) { } } } + +func TestEffectiveAgentType(t *testing.T) { + t.Parallel() + cases := []struct { + agentType string + want string + }{ + {"", AgentTypeOpenClaw}, // pre-shim rows store "" and mean openclaw + {"openclaw", "openclaw"}, + {"hermes", "hermes"}, + {"nanoclaw", "nanoclaw"}, + {"custom", "custom"}, + } + for _, tc := range cases { + inst := Instance{AgentType: tc.agentType} + if got := inst.EffectiveAgentType(); got != tc.want { + t.Errorf("EffectiveAgentType(%q) = %q, want %q", tc.agentType, got, tc.want) + } + } +} diff --git a/control-plane/internal/handlers/agentshim.go b/control-plane/internal/handlers/agentshim.go new file mode 100644 index 00000000..af2528a5 --- /dev/null +++ b/control-plane/internal/handlers/agentshim.go @@ -0,0 +1,41 @@ +package handlers + +import ( + "context" + "fmt" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + // Register the native OpenClaw adapter and the exec-shim adapter with + // the agentshim factory. + _ "github.com/gluk-w/claworc/control-plane/internal/agentshim/openclawnative" + _ "github.com/gluk-w/claworc/control-plane/internal/agentshim/shimexec" + "github.com/gluk-w/claworc/control-plane/internal/database" + "github.com/gluk-w/claworc/control-plane/internal/orchestrator" + gossh "golang.org/x/crypto/ssh" +) + +// init wires the process-wide agentshim factory to the handlers' transport +// managers. The closures read SSHMgr/TunnelMgr at call time (they are +// assigned by main after startup), mirroring how getTunnelPort works. +func init() { + agentshim.SetDefaultFactory(&agentshim.Factory{ + TunnelPort: func(instanceID uint, service string) (int, error) { + return getTunnelPort(instanceID, service) + }, + SSHClient: shimSSHClient, + }) +} + +// shimSSHClient resolves an established SSH connection for an instance, +// honoring its source-IP restrictions — the same path config handlers used +// before the shim extraction. +func shimSSHClient(ctx context.Context, inst database.Instance) (*gossh.Client, error) { + if SSHMgr == nil { + return nil, fmt.Errorf("SSH manager not initialized") + } + orch := orchestrator.Get() + if orch == nil { + return nil, fmt.Errorf("no orchestrator available") + } + return SSHMgr.EnsureConnectedWithIPCheck(ctx, inst.ID, orch, inst.AllowedSourceIPs) +} diff --git a/control-plane/internal/handlers/agenttypes.go b/control-plane/internal/handlers/agenttypes.go new file mode 100644 index 00000000..49047a7c --- /dev/null +++ b/control-plane/internal/handlers/agenttypes.go @@ -0,0 +1,58 @@ +package handlers + +import ( + "net/http" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/database" +) + +// agentTypeResponse is one entry of GET /api/v1/agent-types. +type agentTypeResponse struct { + Type string `json:"type"` + DisplayName string `json:"display_name"` + DefaultImage string `json:"default_image"` + HasControlUI bool `json:"has_control_ui"` +} + +// agentCapabilitiesResponse serializes the static registry capabilities for +// API responses (single-instance GET). +type agentCapabilitiesResponse struct { + Chat bool `json:"chat"` + ChatAbort bool `json:"chat_abort"` + SessionReset bool `json:"session_reset"` + Config bool `json:"config"` + ConfigureLLM bool `json:"configure_llm"` + Restart bool `json:"restart"` + ControlUI bool `json:"control_ui"` + Skills bool `json:"skills"` +} + +func toAgentCapabilitiesResponse(c agentshim.Capabilities) *agentCapabilitiesResponse { + return &agentCapabilitiesResponse{ + Chat: c.Chat, + ChatAbort: c.ChatAbort, + SessionReset: c.SessionReset, + Config: c.Config, + ConfigureLLM: c.ConfigureLLM, + Restart: c.Restart, + ControlUI: c.ControlUI, + Skills: c.Skills, + } +} + +// ListAgentTypes returns the static agent-type registry with each type's +// configured default image resolved from settings. +func ListAgentTypes(w http.ResponseWriter, r *http.Request) { + entries := agentshim.Types() + out := make([]agentTypeResponse, 0, len(entries)) + for _, e := range entries { + out = append(out, agentTypeResponse{ + Type: e.Type, + DisplayName: e.DisplayName, + DefaultImage: agentshim.DefaultImage(e.Type, database.GetSetting), + HasControlUI: e.HasControlUI, + }) + } + writeJSON(w, http.StatusOK, out) +} diff --git a/control-plane/internal/handlers/agenttypes_test.go b/control-plane/internal/handlers/agenttypes_test.go new file mode 100644 index 00000000..c716e3c9 --- /dev/null +++ b/control-plane/internal/handlers/agenttypes_test.go @@ -0,0 +1,296 @@ +package handlers + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/config" + "github.com/gluk-w/claworc/control-plane/internal/database" + "github.com/gluk-w/claworc/control-plane/internal/orchestrator" + "github.com/gluk-w/claworc/control-plane/internal/sshproxy" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +// setupAgentTypesTestDB opens an in-memory DB with every table the create +// path touches (teams, providers, gateway keys) plus the seed settings the +// image-resolution logic reads. +func setupAgentTypesTestDB(t *testing.T) { + t.Helper() + dsn := fmt.Sprintf("file:agenttypes_%s_%p?mode=memory&cache=shared", t.Name(), t) + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{ + Logger: logger.Default.LogMode(logger.Silent), + }) + if err != nil { + t.Fatalf("open test db: %v", err) + } + if err := db.AutoMigrate( + &database.Instance{}, &database.Setting{}, &database.User{}, + &database.UserInstance{}, &database.Team{}, &database.TeamMember{}, + &database.LLMProvider{}, &database.LLMGatewayKey{}, + ); err != nil { + t.Fatalf("auto-migrate: %v", err) + } + // Note: database.DB is intentionally left set after the test — the async + // provisioning goroutine spawned by CreateInstance reads the global and + // would panic on nil during cleanup. + database.DB = db + + database.SetSetting("default_agent_image", "claworc/openclaw:latest") + database.SetSetting("default_agent_images", + `{"hermes":"claworc/hermes:latest","nanoclaw":"claworc/nanoclaw:latest","custom":""}`) + database.SetSetting("default_models", `[]`) + + if err := db.Create(&database.Team{Name: "Default Team"}).Error; err != nil { + t.Fatalf("create team: %v", err) + } +} + +func TestListAgentTypes(t *testing.T) { + setupAgentTypesTestDB(t) + + w := httptest.NewRecorder() + ListAgentTypes(w, httptest.NewRequest("GET", "/api/v1/agent-types", nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", w.Code, w.Body.String()) + } + + var got []map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if len(got) != 4 { + t.Fatalf("got %d agent types, want 4", len(got)) + } + byType := map[string]map[string]interface{}{} + for _, e := range got { + byType[e["type"].(string)] = e + } + if img := byType["openclaw"]["default_image"]; img != "claworc/openclaw:latest" { + t.Errorf("openclaw default_image = %v", img) + } + if img := byType["hermes"]["default_image"]; img != "claworc/hermes:latest" { + t.Errorf("hermes default_image = %v", img) + } + if hc := byType["openclaw"]["has_control_ui"]; hc != true { + t.Errorf("openclaw has_control_ui = %v, want true", hc) + } + if hc := byType["hermes"]["has_control_ui"]; hc != false { + t.Errorf("hermes has_control_ui = %v, want false", hc) + } +} + +func TestCreateInstance_RejectsUnknownAgentType(t *testing.T) { + setupAgentTypesTestDB(t) + + body := bytes.NewBufferString(`{"display_name":"Bad Type","team_id":1,"agent_type":"skynet"}`) + req := httptest.NewRequest("POST", "/api/v1/instances", body) + w := httptest.NewRecorder() + + CreateInstance(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body: %s)", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "unknown agent type") { + t.Errorf("body %q should mention unknown agent type", w.Body.String()) + } +} + +func TestCreateInstance_StoresAgentTypeAndDefaultImage(t *testing.T) { + setupAgentTypesTestDB(t) + + mock := &mockOrchestrator{} + orchestrator.Set(mock) + defer orchestrator.Set(nil) + // A live (but unconnected) SSH manager so the async provisioning + // goroutine blocks harmlessly instead of panicking on a nil manager. + // Deliberately NOT reset to nil on exit (package convention — the + // goroutine may still reference it). + SSHMgr = sshproxy.NewSSHManager(nil, "") + + body := bytes.NewBufferString(`{"display_name":"Hermes One","team_id":1,"agent_type":"hermes"}`) + req := httptest.NewRequest("POST", "/api/v1/instances", body) + w := httptest.NewRecorder() + + CreateInstance(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body: %s)", w.Code, w.Body.String()) + } + + var resp map[string]interface{} + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if resp["agent_type"] != "hermes" { + t.Errorf("response agent_type = %v, want hermes", resp["agent_type"]) + } + if resp["agent_display_name"] != "Hermes" { + t.Errorf("response agent_display_name = %v, want Hermes", resp["agent_display_name"]) + } + if resp["has_control_ui"] != false { + t.Errorf("response has_control_ui = %v, want false", resp["has_control_ui"]) + } + + var inst database.Instance + if err := database.DB.Where("name = ?", "bot-hermes-one").First(&inst).Error; err != nil { + t.Fatalf("load created instance: %v", err) + } + if inst.AgentType != "hermes" { + t.Errorf("stored AgentType = %q, want hermes", inst.AgentType) + } + if inst.ContainerImage != "claworc/hermes:latest" { + t.Errorf("stored ContainerImage = %q, want the hermes default", inst.ContainerImage) + } +} + +func TestCreateInstance_DefaultsToOpenClaw(t *testing.T) { + setupAgentTypesTestDB(t) + + mock := &mockOrchestrator{} + orchestrator.Set(mock) + defer orchestrator.Set(nil) + SSHMgr = sshproxy.NewSSHManager(nil, "") + + body := bytes.NewBufferString(`{"display_name":"Plain","team_id":1}`) + req := httptest.NewRequest("POST", "/api/v1/instances", body) + w := httptest.NewRecorder() + + CreateInstance(w, req) + + if w.Code != http.StatusCreated { + t.Fatalf("status = %d, want 201 (body: %s)", w.Code, w.Body.String()) + } + var inst database.Instance + if err := database.DB.Where("name = ?", "bot-plain").First(&inst).Error; err != nil { + t.Fatalf("load created instance: %v", err) + } + if inst.AgentType != agentshim.TypeOpenClaw { + t.Errorf("stored AgentType = %q, want openclaw", inst.AgentType) + } + if inst.ContainerImage != "claworc/openclaw:latest" { + t.Errorf("stored ContainerImage = %q, want the openclaw default", inst.ContainerImage) + } +} + +func TestApplyReservedAgentEnv_OpenClaw(t *testing.T) { + setupAgentTypesTestDB(t) + database.SetSetting("default_models", `["anthropic/claude-sonnet-4-5"]`) + + prevPort := config.Cfg.LLMGatewayPort + config.Cfg.LLMGatewayPort = 40001 + defer func() { config.Cfg.LLMGatewayPort = prevPort }() + + inst := database.Instance{Name: "bot-env", DisplayName: "Env", AgentType: "openclaw"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatalf("create instance: %v", err) + } + + envVars := map[string]string{} + applyReservedAgentEnv(envVars, inst, "tok-123") + + if envVars["CLAWORC_INSTANCE_ID"] != fmt.Sprintf("%d", inst.ID) { + t.Errorf("CLAWORC_INSTANCE_ID = %q", envVars["CLAWORC_INSTANCE_ID"]) + } + if envVars["CLAWORC_AGENT_TOKEN"] != "tok-123" { + t.Errorf("CLAWORC_AGENT_TOKEN = %q, want tok-123", envVars["CLAWORC_AGENT_TOKEN"]) + } + if envVars["CLAWORC_LLM_PROXY_URL"] != "http://127.0.0.1:40001" { + t.Errorf("CLAWORC_LLM_PROXY_URL = %q", envVars["CLAWORC_LLM_PROXY_URL"]) + } + var routing agentshim.LLMRouting + if err := json.Unmarshal([]byte(envVars["CLAWORC_INITIAL_LLM_CONFIG"]), &routing); err != nil { + t.Fatalf("CLAWORC_INITIAL_LLM_CONFIG is not valid LLMRouting JSON: %v", err) + } + if routing.DefaultModel != "anthropic/claude-sonnet-4-5" { + t.Errorf("routing default model = %q", routing.DefaultModel) + } + if routing.ProxyURL != "http://127.0.0.1:40001" { + t.Errorf("routing proxy url = %q", routing.ProxyURL) + } + + // OpenClaw keeps its legacy variables. + if envVars["OPENCLAW_GATEWAY_TOKEN"] != "tok-123" { + t.Errorf("OPENCLAW_GATEWAY_TOKEN = %q, want tok-123", envVars["OPENCLAW_GATEWAY_TOKEN"]) + } + if envVars["OPENCLAW_INITIAL_MODELS"] == "" { + t.Error("OPENCLAW_INITIAL_MODELS missing for openclaw type") + } +} + +func TestApplyReservedAgentEnv_NonOpenClawSkipsLegacyVars(t *testing.T) { + setupAgentTypesTestDB(t) + database.SetSetting("default_models", `["anthropic/claude-sonnet-4-5"]`) + + prevPort := config.Cfg.LLMGatewayPort + config.Cfg.LLMGatewayPort = 40001 + defer func() { config.Cfg.LLMGatewayPort = prevPort }() + + inst := database.Instance{Name: "bot-hermes-env", DisplayName: "HermesEnv", AgentType: "hermes"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatalf("create instance: %v", err) + } + + envVars := map[string]string{} + applyReservedAgentEnv(envVars, inst, "tok-456") + + for _, want := range []string{"CLAWORC_INSTANCE_ID", "CLAWORC_AGENT_TOKEN", "CLAWORC_LLM_PROXY_URL", "CLAWORC_INITIAL_LLM_CONFIG"} { + if envVars[want] == "" { + t.Errorf("%s missing for hermes type", want) + } + } + for _, banned := range []string{"OPENCLAW_GATEWAY_TOKEN", "OPENCLAW_INITIAL_MODELS", "OPENCLAW_INITIAL_PROVIDERS"} { + if _, ok := envVars[banned]; ok { + t.Errorf("%s must not be injected for non-openclaw types", banned) + } + } +} + +func TestControlProxy_NoControlUIType(t *testing.T) { + setupAgentTypesTestDB(t) + + inst := database.Instance{Name: "bot-no-ui", DisplayName: "No UI", Status: "running", AgentType: "hermes"} + if err := database.DB.Create(&inst).Error; err != nil { + t.Fatalf("create instance: %v", err) + } + user := createTestUser(t, "admin") + + req := buildRequest(t, "GET", fmt.Sprintf("/openclaw/%d/", inst.ID), user, + map[string]string{"id": fmt.Sprintf("%d", inst.ID), "*": ""}) + w := httptest.NewRecorder() + + ControlProxy(w, req) + + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body: %s)", w.Code, w.Body.String()) + } + if !strings.Contains(w.Body.String(), "does not provide a web control UI") { + t.Errorf("body %q should explain the missing control UI", w.Body.String()) + } +} + +func TestReservedEnvVarNames_IncludeShimContractVars(t *testing.T) { + t.Parallel() + want := []string{ + "OPENCLAW_GATEWAY_TOKEN", "CLAWORC_INSTANCE_ID", + "OPENCLAW_INITIAL_MODELS", "OPENCLAW_INITIAL_PROVIDERS", + "CLAWORC_AGENT_TOKEN", "CLAWORC_INITIAL_LLM_CONFIG", "CLAWORC_LLM_PROXY_URL", + } + have := map[string]bool{} + for _, n := range ReservedEnvVarNames { + have[n] = true + } + for _, n := range want { + if !have[n] { + t.Errorf("ReservedEnvVarNames missing %s", n) + } + } +} diff --git a/control-plane/internal/handlers/chat.go b/control-plane/internal/handlers/chat.go index b052d087..c216f1f1 100644 --- a/control-plane/internal/handlers/chat.go +++ b/control-plane/internal/handlers/chat.go @@ -3,23 +3,26 @@ package handlers import ( "context" "encoding/json" - "fmt" + "errors" "log" "net/http" "strconv" "strings" "github.com/coder/websocket" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" "github.com/gluk-w/claworc/control-plane/internal/database" "github.com/gluk-w/claworc/control-plane/internal/middleware" - "github.com/gluk-w/claworc/control-plane/internal/sshproxy" - "github.com/gluk-w/claworc/control-plane/internal/utils" "github.com/go-chi/chi/v5" - "github.com/google/uuid" ) const chatSessionKey = "browser" +// ChatProxy relays the browser chat WebSocket to the instance's agent via +// the agentshim. Browser→server frames are unchanged ({type:"chat", +// content}); server→browser frames are a {"type":"connected"} handshake +// followed by normalized agentshim events serialized verbatim — the shim +// event schema (docs/shim.md) is the browser chat protocol. func ChatProxy(w http.ResponseWriter, r *http.Request) { id, err := strconv.Atoi(chi.URLParam(r, "id")) if err != nil { @@ -51,29 +54,26 @@ func ChatProxy(w http.ResponseWriter, r *http.Request) { return } - // Get gateway tunnel port - port, err := getTunnelPort(uint(id), "gateway") + client, err := agentshim.DefaultFactory().ForInstance(ctx, uint(id)) if err != nil { - log.Printf("[chat] No gateway tunnel for instance %d: %v", id, err) + log.Printf("[chat] No agent client for instance %d: %v", id, err) clientConn.Close(4500, truncate(err.Error(), 120)) return } - // Decrypt gateway token - var gatewayToken string - if inst.GatewayToken != "" { - if tok, err := utils.Decrypt(inst.GatewayToken); err == nil && tok != "" { - gatewayToken = tok - } - } - - gwConn, err := sshproxy.DialGateway(ctx, port, gatewayToken) + sess, err := client.OpenSession(ctx, chatSessionKey) if err != nil { - log.Printf("[chat] Gateway dial/handshake failed for %s: %v", inst.Name, err) + var te *agentshim.TransportError + if errors.As(err, &te) { + log.Printf("[chat] No gateway tunnel for instance %d: %v", id, err) + clientConn.Close(4500, truncate(err.Error(), 120)) + return + } + log.Printf("[chat] Session open failed for %s: %v", inst.Name, err) clientConn.Close(4502, truncate(err.Error(), 120)) return } - defer gwConn.CloseNow() + defer sess.Close() clientConn.SetReadLimit(4 * 1024 * 1024) @@ -81,13 +81,10 @@ func ChatProxy(w http.ResponseWriter, r *http.Request) { connectedMsg, _ := json.Marshal(map[string]string{"type": "connected"}) clientConn.Write(ctx, websocket.MessageText, connectedMsg) - // Bidirectional relay with message translation relayCtx, relayCancel := context.WithCancel(ctx) defer relayCancel() - var reqCounter int - - // Browser → Gateway (translate chat messages to gateway protocol) + // Browser → Agent (translate chat frames to session verbs) go func() { defer relayCancel() for { @@ -110,68 +107,41 @@ func ChatProxy(w http.ResponseWriter, r *http.Request) { continue } - // Translate to gateway protocol - reqCounter++ - var gwFrame map[string]interface{} - - trimmedContent := strings.TrimSpace(content) - if trimmedContent == "/new" || trimmedContent == "/reset" { - gwFrame = map[string]interface{}{ - "type": "req", - "id": fmt.Sprintf("reset-%d", reqCounter), - "method": "sessions.reset", - "params": map[string]interface{}{ - "key": chatSessionKey, - }, - } - } else if trimmedContent == "/stop" { - gwFrame = map[string]interface{}{ - "type": "req", - "id": fmt.Sprintf("abort-%d", reqCounter), - "method": "chat.abort", - "params": map[string]interface{}{ - "sessionKey": chatSessionKey, - }, - } - } else { - gwFrame = map[string]interface{}{ - "type": "req", - "id": fmt.Sprintf("chat-%d", reqCounter), - "method": "chat.send", - "params": map[string]interface{}{ - "sessionKey": chatSessionKey, - "message": content, - "idempotencyKey": uuid.New().String(), - }, - } + var verbErr error + switch strings.TrimSpace(content) { + case "/new", "/reset": + verbErr = sess.Reset(relayCtx) + case "/stop": + verbErr = sess.Abort(relayCtx) + default: + verbErr = sess.Send(relayCtx, content) } - - gwJSON, _ := json.Marshal(gwFrame) - log.Printf("[chat] Browser→Gateway: %s", string(gwJSON)) - if err := gwConn.Write(relayCtx, websocket.MessageText, gwJSON); err != nil { + if verbErr != nil { return } } }() - // Gateway → Browser (forward all frames, log for debugging) + // Agent → Browser (normalized events, serialized verbatim) func() { defer relayCancel() for { - msgType, data, err := gwConn.Read(relayCtx) + ev, err := sess.Recv(relayCtx) if err != nil { - log.Printf("[chat] Gateway read error: %v", err) + log.Printf("[chat] Session read error: %v", err) return } - log.Printf("[chat] Gateway→Browser: %s", string(data)) - if err := clientConn.Write(relayCtx, msgType, data); err != nil { + evJSON, err := json.Marshal(ev) + if err != nil { + continue + } + if err := clientConn.Write(relayCtx, websocket.MessageText, evJSON); err != nil { return } } }() clientConn.Close(websocket.StatusNormalClosure, "") - gwConn.Close(websocket.StatusNormalClosure, "") } func truncate(s string, maxLen int) string { diff --git a/control-plane/internal/handlers/configure_test.go b/control-plane/internal/handlers/configure_test.go index 5b0a3dd8..5268a015 100644 --- a/control-plane/internal/handlers/configure_test.go +++ b/control-plane/internal/handlers/configure_test.go @@ -54,7 +54,6 @@ func (mockOps) RestartInstance(_ context.Context, _ string, _ orchestrator.Creat } func (mockOps) GetInstanceStatus(_ context.Context, _ string) (string, error) { return "running", nil } func (mockOps) GetInstanceImageInfo(_ context.Context, _ string) (string, error) { return "", nil } -func (mockOps) UpdateInstanceConfig(_ context.Context, _ string, _ string) error { return nil } func (mockOps) CloneVolumes(_ context.Context, _, _ string) error { return nil } func (mockOps) ConfigureSSHAccess(_ context.Context, _ uint, _ string) error { return nil } func (mockOps) GetSSHAddress(_ context.Context, _ uint) (string, int, error) { return "", 0, nil } @@ -76,10 +75,10 @@ func (mockOps) StreamExecInInstance(_ context.Context, _ string, _ []string, _ i func (mockOps) UpdatePlacementConfig(_ context.Context, _ string, _ orchestrator.UpdatePlacementParams) error { return nil } -func (mockOps) DeleteSharedVolume(_ context.Context, _ uint) error { return nil } -func (mockOps) CloneVolume(_ context.Context, _, _ string) error { return nil } -func (mockOps) VolumeNameFor(name, suffix string) string { return name + "-" + suffix } -func (mockOps) Apply(_ context.Context, _ orchestrator.WorkloadSpec) error { return nil } +func (mockOps) DeleteSharedVolume(_ context.Context, _ uint) error { return nil } +func (mockOps) CloneVolume(_ context.Context, _, _ string) error { return nil } +func (mockOps) VolumeNameFor(name, suffix string) string { return name + "-" + suffix } +func (mockOps) Apply(_ context.Context, _ orchestrator.WorkloadSpec) error { return nil } func (mockOps) DeleteWorkload(_ context.Context, _ orchestrator.WorkloadSpec) error { return nil } diff --git a/control-plane/internal/handlers/control.go b/control-plane/internal/handlers/control.go index 4926cf1a..501987c6 100644 --- a/control-plane/internal/handlers/control.go +++ b/control-plane/internal/handlers/control.go @@ -7,6 +7,7 @@ import ( "strconv" "strings" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" "github.com/gluk-w/claworc/control-plane/internal/database" "github.com/gluk-w/claworc/control-plane/internal/middleware" "github.com/gluk-w/claworc/control-plane/internal/utils" @@ -68,6 +69,19 @@ func ControlProxy(w http.ResponseWriter, r *http.Request) { return } + // Only agent types that ship a web control UI (per the static registry) + // can be proxied. For everything else this route is a clean 404 rather + // than an eternally-spinning "connecting" page. + var inst database.Instance + if err := database.DB.First(&inst, id).Error; err != nil { + writeError(w, http.StatusNotFound, "Instance not found") + return + } + if entry, ok := agentshim.Get(inst.EffectiveAgentType()); !ok || !entry.HasControlUI { + writeError(w, http.StatusNotFound, "This agent type does not provide a web control UI.") + return + } + info, err := getTunnelPortInfo(uint(id), "gateway") if err != nil { // WebSocket clients can't display HTML — return plain error @@ -81,8 +95,7 @@ func ControlProxy(w http.ResponseWriter, r *http.Request) { // Look up gateway token so we can inject it into upstream WebSocket requests var gatewayToken string - var inst database.Instance - if err := database.DB.First(&inst, id).Error; err == nil && inst.GatewayToken != "" { + if inst.GatewayToken != "" { if tok, err := utils.Decrypt(inst.GatewayToken); err == nil && tok != "" { gatewayToken = tok } diff --git a/control-plane/internal/handlers/envvars.go b/control-plane/internal/handlers/envvars.go index 4ef741ab..b682445c 100644 --- a/control-plane/internal/handlers/envvars.go +++ b/control-plane/internal/handlers/envvars.go @@ -20,6 +20,11 @@ var ReservedEnvVarNames = []string{ "CLAWORC_INSTANCE_ID", "OPENCLAW_INITIAL_MODELS", "OPENCLAW_INITIAL_PROVIDERS", + // Universal agent shim contract variables (docs/shim.md), injected for + // every agent type at container create/restart. + "CLAWORC_AGENT_TOKEN", + "CLAWORC_INITIAL_LLM_CONFIG", + "CLAWORC_LLM_PROXY_URL", } var envVarNameRegex = regexp.MustCompile(`^[A-Z_][A-Z0-9_]*$`) @@ -89,7 +94,6 @@ func decryptEnvVars(encrypted map[string]string) map[string]string { return out } - // LoadGlobalEnvVars reads default_env_vars from the settings table and returns // the decrypted {KEY: value} map. Errors loading the setting are swallowed — // env vars are optional enrichment, not critical infrastructure. diff --git a/control-plane/internal/handlers/instances.go b/control-plane/internal/handlers/instances.go index dfa14e25..a951938f 100644 --- a/control-plane/internal/handlers/instances.go +++ b/control-plane/internal/handlers/instances.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "net/http" @@ -14,6 +15,8 @@ import ( "sync" "time" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" + "github.com/gluk-w/claworc/control-plane/internal/agentshim/openclawnative" "github.com/gluk-w/claworc/control-plane/internal/analytics" "github.com/gluk-w/claworc/control-plane/internal/config" "github.com/gluk-w/claworc/control-plane/internal/database" @@ -103,6 +106,7 @@ type modelsConfig struct { type instanceCreateRequest struct { DisplayName string `json:"display_name"` + AgentType string `json:"agent_type"` // "" defaults to "openclaw"; validated via agentshim registry CPURequest string `json:"cpu_request"` CPULimit string `json:"cpu_limit"` MemoryRequest string `json:"memory_request"` @@ -143,54 +147,64 @@ type modelsResponse struct { } type instanceResponse struct { - ID uint `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Status string `json:"status"` - CPURequest string `json:"cpu_request"` - CPULimit string `json:"cpu_limit"` - MemoryRequest string `json:"memory_request"` - MemoryLimit string `json:"memory_limit"` - StorageHomebrew string `json:"storage_homebrew"` - StorageHome string `json:"storage_home"` - HasBraveOverride bool `json:"has_brave_override"` - Models *modelsResponse `json:"models"` - DefaultModel string `json:"default_model"` - ContainerImage *string `json:"container_image"` - HasImageOverride bool `json:"has_image_override"` - VNCResolution *string `json:"vnc_resolution"` - HasResolutionOverride bool `json:"has_resolution_override"` - Timezone *string `json:"timezone"` - HasTimezoneOverride bool `json:"has_timezone_override"` - UserAgent *string `json:"user_agent"` - HasUserAgentOverride bool `json:"has_user_agent_override"` - EnvVars map[string]string `json:"env_vars"` - HasEnvOverride bool `json:"has_env_override"` - RequiresRestart bool `json:"requires_restart,omitempty"` - Restarting bool `json:"restarting,omitempty"` - LiveImageInfo *string `json:"live_image_info,omitempty"` - StatusMessage string `json:"status_message,omitempty"` - AllowedSourceIPs string `json:"allowed_source_ips"` - EnabledProviders []uint `json:"enabled_providers"` - InstanceProviders []providerResp `json:"instance_providers"` - ControlURL string `json:"control_url"` - GatewayToken string `json:"gateway_token"` - SortOrder int `json:"sort_order"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - IsLegacyEmbedded bool `json:"is_legacy_embedded"` - BrowserProvider string `json:"browser_provider,omitempty"` - BrowserImage string `json:"browser_image,omitempty"` - BrowserIdleMinutes *int `json:"browser_idle_minutes,omitempty"` - BrowserStorage string `json:"browser_storage,omitempty"` - BrowserActive bool `json:"browser_active"` - TeamID uint `json:"team_id"` - PodAnnotations map[string]string `json:"pod_annotations"` - NodeSelector map[string]string `json:"node_selector"` - Tolerations []orchestrator.Toleration `json:"tolerations"` - Affinity string `json:"affinity"` - ServiceAccountAnnotations map[string]string `json:"service_account_annotations"` - Ports []orchestrator.PortSpec `json:"ports"` + ID uint `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + // AgentType is the effective agent type ("openclaw" for pre-shim rows). + AgentType string `json:"agent_type"` + // AgentDisplayName is the registry display name for AgentType. + AgentDisplayName string `json:"agent_display_name"` + // HasControlUI reports whether this agent type serves a web control UI. + HasControlUI bool `json:"has_control_ui"` + // AgentCapabilities carries the static registry capabilities. Only set on + // the single-instance GET response — list responses stay cheap (no + // per-instance capability payloads, and never live SSH probing). + AgentCapabilities *agentCapabilitiesResponse `json:"agent_capabilities,omitempty"` + Status string `json:"status"` + CPURequest string `json:"cpu_request"` + CPULimit string `json:"cpu_limit"` + MemoryRequest string `json:"memory_request"` + MemoryLimit string `json:"memory_limit"` + StorageHomebrew string `json:"storage_homebrew"` + StorageHome string `json:"storage_home"` + HasBraveOverride bool `json:"has_brave_override"` + Models *modelsResponse `json:"models"` + DefaultModel string `json:"default_model"` + ContainerImage *string `json:"container_image"` + HasImageOverride bool `json:"has_image_override"` + VNCResolution *string `json:"vnc_resolution"` + HasResolutionOverride bool `json:"has_resolution_override"` + Timezone *string `json:"timezone"` + HasTimezoneOverride bool `json:"has_timezone_override"` + UserAgent *string `json:"user_agent"` + HasUserAgentOverride bool `json:"has_user_agent_override"` + EnvVars map[string]string `json:"env_vars"` + HasEnvOverride bool `json:"has_env_override"` + RequiresRestart bool `json:"requires_restart,omitempty"` + Restarting bool `json:"restarting,omitempty"` + LiveImageInfo *string `json:"live_image_info,omitempty"` + StatusMessage string `json:"status_message,omitempty"` + AllowedSourceIPs string `json:"allowed_source_ips"` + EnabledProviders []uint `json:"enabled_providers"` + InstanceProviders []providerResp `json:"instance_providers"` + ControlURL string `json:"control_url"` + GatewayToken string `json:"gateway_token"` + SortOrder int `json:"sort_order"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + IsLegacyEmbedded bool `json:"is_legacy_embedded"` + BrowserProvider string `json:"browser_provider,omitempty"` + BrowserImage string `json:"browser_image,omitempty"` + BrowserIdleMinutes *int `json:"browser_idle_minutes,omitempty"` + BrowserStorage string `json:"browser_storage,omitempty"` + BrowserActive bool `json:"browser_active"` + TeamID uint `json:"team_id"` + PodAnnotations map[string]string `json:"pod_annotations"` + NodeSelector map[string]string `json:"node_selector"` + Tolerations []orchestrator.Toleration `json:"tolerations"` + Affinity string `json:"affinity"` + ServiceAccountAnnotations map[string]string `json:"service_account_annotations"` + Ports []orchestrator.PortSpec `json:"ports"` } func generateName(displayName string) string { @@ -266,19 +280,26 @@ type GatewayProvider struct { CatalogKey string // non-empty for catalog-backed providers (e.g. "openai", "anthropic") } -// openclawProviderCfg is the JSON shape expected by OpenClaw's models.providers config. -type openclawProviderCfg struct { - BaseURL string `json:"baseUrl"` - API string `json:"api"` - APIKey string `json:"apiKey"` - Models []database.ProviderModel `json:"models"` -} +// buildLLMRouting translates the resolved effective models + gateway +// providers into the agent-agnostic LLM routing document (docs/shim.md). +// Catalog providers are filtered to only the selected models; agent-specific +// config shapes (e.g. OpenClaw's models.providers JSON) are produced from +// this document by the agent adapter (see agentshim/openclawnative). +func buildLLMRouting(models []string, gatewayProviders map[string]GatewayProvider, gatewayPort int) agentshim.LLMRouting { + routing := agentshim.LLMRouting{ + Style: "openai", + FallbackModels: []string{}, + } + if gatewayPort > 0 { + routing.ProxyURL = fmt.Sprintf("http://127.0.0.1:%d", gatewayPort) + } + if len(models) > 0 { + routing.DefaultModel = models[0] + routing.FallbackModels = models[1:] + } -// buildOpenClawProvidersJSON builds the models.providers JSON for OpenClaw config. -// It filters catalog providers to only the selected models. -func buildOpenClawProvidersJSON(models []string, gatewayProviders map[string]GatewayProvider, gatewayPort int) (string, error) { - if len(gatewayProviders) == 0 || gatewayPort <= 0 { - return "", nil + if len(gatewayProviders) == 0 || routing.ProxyURL == "" { + return routing } effectiveSet := make(map[string]struct{}, len(models)) @@ -286,12 +307,7 @@ func buildOpenClawProvidersJSON(models []string, gatewayProviders map[string]Gat effectiveSet[m] = struct{}{} } - providers := make(map[string]openclawProviderCfg, len(gatewayProviders)) for providerKey, gp := range gatewayProviders { - apiType := gp.APIType - if apiType == "" { - apiType = "openai-completions" - } var gpModels []database.ProviderModel if gp.CatalogKey != "" { var allModels []database.ProviderModel @@ -308,29 +324,21 @@ func buildOpenClawProvidersJSON(models []string, gatewayProviders map[string]Gat } else if len(gp.Models) > 0 { gpModels = gp.Models } - if gpModels == nil { - gpModels = []database.ProviderModel{} - } - // Codex declares openai-responses to OpenClaw so pi-ai skips its - // client-side JWT decode of apiKey. The gateway translates path/auth/SSE - // upstream. The DB record keeps the codex apiType for gateway routing. - declaredAPI := apiType - if declaredAPI == llmgateway.APITypeOpenAICodexResponses { - declaredAPI = "openai-responses" + refs := make([]agentshim.ModelRef, 0, len(gpModels)) + for _, m := range gpModels { + refs = append(refs, agentshim.ModelRef{ + ID: m.ID, + Default: providerKey+"/"+m.ID == routing.DefaultModel, + }) } - providers[providerKey] = openclawProviderCfg{ - BaseURL: fmt.Sprintf("http://127.0.0.1:%d", gatewayPort), - API: declaredAPI, + routing.Providers = append(routing.Providers, agentshim.ProviderRoute{ + Key: providerKey, APIKey: gp.Key, - Models: gpModels, - } - } - - b, err := json.Marshal(providers) - if err != nil { - return "", err + APIType: gp.APIType, + Models: refs, + }) } - return string(b), nil + return routing } // resolveGatewayProviders builds the providerKey→GatewayProvider map for an instance's enabled @@ -512,10 +520,19 @@ func instanceToResponse(inst database.Instance, status string) instanceResponse ports = []orchestrator.PortSpec{} } + agentType := inst.EffectiveAgentType() + agentEntry, agentKnown := agentshim.Get(agentType) + if !agentKnown { + agentEntry.DisplayName = agentType // surface the raw type rather than "" + } + return instanceResponse{ ID: inst.ID, Name: inst.Name, DisplayName: inst.DisplayName, + AgentType: agentType, + AgentDisplayName: agentEntry.DisplayName, + HasControlUI: agentEntry.HasControlUI, Status: status, StatusMessage: getStatusMessage(inst.ID), CPURequest: inst.CPURequest, @@ -728,6 +745,57 @@ func instancePlacementParams(inst database.Instance) instancePlacementFields { return f } +// applyReservedAgentEnv injects the reserved system env vars for the +// instance's agent type into envVars. Applied after user env vars so they can +// never be shadowed (see ReservedEnvVarNames). +// +// Every agent type gets the shim-contract variables (docs/shim.md): +// CLAWORC_INSTANCE_ID, CLAWORC_AGENT_TOKEN (the same secret that has always +// served as the OpenClaw gateway token), CLAWORC_LLM_PROXY_URL, and +// CLAWORC_INITIAL_LLM_CONFIG (the agentshim.LLMRouting JSON applied by the +// image's configure-llm at first boot). The OpenClaw type additionally keeps +// the legacy OPENCLAW_GATEWAY_TOKEN / OPENCLAW_INITIAL_MODELS / +// OPENCLAW_INITIAL_PROVIDERS variables for backward compatibility. +func applyReservedAgentEnv(envVars map[string]string, inst database.Instance, agentTokenPlain string) { + envVars["CLAWORC_INSTANCE_ID"] = fmt.Sprintf("%d", inst.ID) + if agentTokenPlain != "" { + envVars["CLAWORC_AGENT_TOKEN"] = agentTokenPlain + } + if config.Cfg.LLMGatewayPort > 0 { + envVars["CLAWORC_LLM_PROXY_URL"] = fmt.Sprintf("http://127.0.0.1:%d", config.Cfg.LLMGatewayPort) + } + + routing := buildLLMRouting(resolveInstanceModels(inst), resolveGatewayProviders(inst), config.Cfg.LLMGatewayPort) + if b, err := json.Marshal(routing); err == nil { + envVars["CLAWORC_INITIAL_LLM_CONFIG"] = string(b) + } + + if inst.EffectiveAgentType() == agentshim.TypeOpenClaw { + if agentTokenPlain != "" { + envVars["OPENCLAW_GATEWAY_TOKEN"] = agentTokenPlain + } + if modelsJSON := openclawnative.BuildModelsJSON(routing); modelsJSON != "" { + envVars["OPENCLAW_INITIAL_MODELS"] = modelsJSON + } + if providersJSON, _ := openclawnative.BuildProvidersJSON(routing); providersJSON != "" { + envVars["OPENCLAW_INITIAL_PROVIDERS"] = providersJSON + } + } +} + +// decryptedGatewayToken returns the instance's decrypted agent auth token, +// or "" when unset/undecryptable. +func decryptedGatewayToken(inst database.Instance) string { + if inst.GatewayToken == "" { + return "" + } + plain, err := utils.Decrypt(inst.GatewayToken) + if err != nil { + return "" + } + return plain +} + // buildCreateParams constructs orchestrator.CreateParams from a database Instance. func buildCreateParams(inst database.Instance) orchestrator.CreateParams { envVars := map[string]string{} @@ -736,12 +804,7 @@ func buildCreateParams(inst database.Instance) orchestrator.CreateParams { MergeUserEnvVars(envVars, LoadGlobalEnvVars(), LoadInstanceEnvVars(inst)) // System env vars — applied last so they cannot be shadowed - if inst.GatewayToken != "" { - if plain, err := utils.Decrypt(inst.GatewayToken); err == nil { - envVars["OPENCLAW_GATEWAY_TOKEN"] = plain - } - } - envVars["CLAWORC_INSTANCE_ID"] = fmt.Sprintf("%d", inst.ID) + applyReservedAgentEnv(envVars, inst, decryptedGatewayToken(inst)) placement := instancePlacementParams(inst) @@ -890,6 +953,15 @@ func CreateInstance(w http.ResponseWriter, r *http.Request) { return } + // Agent type: default "openclaw", validated against the static registry. + if body.AgentType == "" { + body.AgentType = agentshim.TypeOpenClaw + } + if err := agentshim.Validate(body.AgentType); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + // Target team must be specified. Non-admins must be a manager of it. caller := middleware.GetUser(r) var teamID uint @@ -975,15 +1047,13 @@ func CreateInstance(w http.ResponseWriter, r *http.Request) { if body.ContainerImage != nil { containerImage = *body.ContainerImage } - // Populate the new slim agent image as the default for newly-created - // instances. Existing rows with empty ContainerImage keep resolving via + // Image resolution: explicit image → the selected agent type's default + // (openclaw → legacy default_agent_image; others → default_agent_images + // map). Existing rows with empty ContainerImage keep resolving via // default_container_image (which seeds to the legacy combined image), but - // any instance created from now on opts into the on-demand browser-pod - // layout unless the caller passes an explicit container_image override. + // any instance created from now on stores the resolved default explicitly. if containerImage == "" { - if def, err := database.GetSetting("default_agent_image"); err == nil && def != "" { - containerImage = def - } + containerImage = agentshim.DefaultImage(body.AgentType, database.GetSetting) } var vncResolution string if body.VNCResolution != nil { @@ -1076,6 +1146,7 @@ func CreateInstance(w http.ResponseWriter, r *http.Request) { inst := database.Instance{ Name: name, DisplayName: body.DisplayName, + AgentType: body.AgentType, Status: "creating", CPURequest: body.CPURequest, CPULimit: body.CPULimit, @@ -1134,21 +1205,6 @@ func CreateInstance(w http.ResponseWriter, r *http.Request) { models := resolveInstanceModels(inst) gatewayProviders := resolveGatewayProviders(inst) - // Build initial OpenClaw config env vars so the gateway starts with providers already configured - initialModelsJSON := "" - if len(models) > 0 { - modelConfig := map[string]interface{}{"primary": models[0]} - if len(models) > 1 { - modelConfig["fallbacks"] = models[1:] - } else { - modelConfig["fallbacks"] = []string{} - } - if b, err := json.Marshal(modelConfig); err == nil { - initialModelsJSON = string(b) - } - } - initialProvidersJSON, _ := buildOpenClawProvidersJSON(models, gatewayProviders, config.Cfg.LLMGatewayPort) - // Launch container creation asynchronously (image pull can take minutes) startInstanceTask(taskmanager.TaskInstanceCreate, inst.ID, callerID(r), inst.DisplayName, fmt.Sprintf("Creating instance %s", inst.DisplayName), @@ -1166,17 +1222,10 @@ func CreateInstance(w http.ResponseWriter, r *http.Request) { // Applied first so reserved system names below can never be shadowed. MergeUserEnvVars(envVars, LoadGlobalEnvVars(), LoadInstanceEnvVars(inst)) - // System env vars — reserved, always win over user values - if gatewayTokenPlain != "" { - envVars["OPENCLAW_GATEWAY_TOKEN"] = gatewayTokenPlain - } - envVars["CLAWORC_INSTANCE_ID"] = fmt.Sprintf("%d", inst.ID) - if initialModelsJSON != "" { - envVars["OPENCLAW_INITIAL_MODELS"] = initialModelsJSON - } - if initialProvidersJSON != "" { - envVars["OPENCLAW_INITIAL_PROVIDERS"] = initialProvidersJSON - } + // System env vars — reserved, always win over user values. Includes + // the initial LLM routing config so the agent boots with providers + // already configured (no race with early messages). + applyReservedAgentEnv(envVars, inst, gatewayTokenPlain) // inst was just Create()'d above with PodAnnotations/NodeSelector/ // Tolerations/Affinity/ServiceAccountAnnotations/Ports already @@ -1282,6 +1331,10 @@ func GetInstance(w http.ResponseWriter, r *http.Request) { } status := resolveStatus(&inst, orchStatus) resp := instanceToResponse(inst, status) + // Static registry capabilities — detail responses only; never live-probed. + if entry, ok := agentshim.Get(inst.EffectiveAgentType()); ok { + resp.AgentCapabilities = toAgentCapabilitiesResponse(entry.StaticCapabilities) + } if orch != nil { if info, err := orch.GetInstanceImageInfo(r.Context(), inst.Name); err == nil && info != "" { resp.LiveImageInfo = &info @@ -1291,6 +1344,7 @@ func GetInstance(w http.ResponseWriter, r *http.Request) { } type instanceUpdateRequest struct { + AgentType *string `json:"agent_type"` // validated via agentshim registry BraveAPIKey *string `json:"brave_api_key"` Models *modelsConfig `json:"models"` DefaultModel *string `json:"default_model"` @@ -1369,6 +1423,16 @@ func UpdateInstance(w http.ResponseWriter, r *http.Request) { inst.TeamID = newTeamID } + // Update agent type (validated against the static registry). Takes effect + // on the next restart/image update — the running container is untouched. + if body.AgentType != nil && *body.AgentType != "" { + if err := agentshim.Validate(*body.AgentType); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + database.DB.Model(&inst).Update("agent_type", *body.AgentType) + } + // Update Brave API key if body.BraveAPIKey != nil { if *body.BraveAPIKey != "" { @@ -1795,14 +1859,9 @@ func UpdateInstanceImage(w http.ResponseWriter, r *http.Request) { effectiveTimezone := getEffectiveTimezone(inst) effectiveUserAgent := getEffectiveUserAgent(inst) - // Decrypt gateway token for env vars + // Reserved system env vars (shim-contract + OpenClaw legacy set) envVars := map[string]string{} - if inst.GatewayToken != "" { - if plain, err := utils.Decrypt(inst.GatewayToken); err == nil { - envVars["OPENCLAW_GATEWAY_TOKEN"] = plain - } - } - envVars["CLAWORC_INSTANCE_ID"] = fmt.Sprintf("%d", inst.ID) + applyReservedAgentEnv(envVars, inst, decryptedGatewayToken(inst)) instID := inst.ID instName := inst.Name @@ -2064,20 +2123,25 @@ func GetInstanceConfig(w http.ResponseWriter, r *http.Request) { return } - client, err := SSHMgr.EnsureConnectedWithIPCheck(r.Context(), inst.ID, orch, inst.AllowedSourceIPs) + client, err := agentshim.DefaultFactory().ForInstance(r.Context(), inst.ID) if err != nil { - log.Printf("Failed to get SSH connection for instance %d: %v", inst.ID, err) - writeError(w, http.StatusBadGateway, fmt.Sprintf("SSH connection failed: %v", err)) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve agent client: %v", err)) return } - content, err := sshproxy.ReadFile(client, orchestrator.PathOpenClawConfig) + content, language, err := client.GetConfig(r.Context(), "") if err != nil { + var te *agentshim.TransportError + if errors.As(err, &te) { + log.Printf("Failed to get SSH connection for instance %d: %v", inst.ID, err) + writeError(w, http.StatusBadGateway, fmt.Sprintf("SSH connection failed: %v", te.Err)) + return + } writeError(w, http.StatusServiceUnavailable, "Instance must be running to read config") return } - writeJSON(w, http.StatusOK, map[string]string{"config": string(content)}) + writeJSON(w, http.StatusOK, map[string]string{"config": content, "language": language}) } func UpdateInstanceConfig(w http.ResponseWriter, r *http.Request) { @@ -2123,26 +2187,43 @@ func UpdateInstanceConfig(w http.ResponseWriter, r *http.Request) { return } - client, err := SSHMgr.EnsureConnectedWithIPCheck(r.Context(), inst.ID, orch, inst.AllowedSourceIPs) + client, err := agentshim.DefaultFactory().ForInstance(r.Context(), inst.ID) if err != nil { - log.Printf("Failed to get SSH connection for instance %d: %v", inst.ID, err) - writeError(w, http.StatusBadGateway, fmt.Sprintf("SSH connection failed: %v", err)) + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to resolve agent client: %v", err)) return } - if err := sshproxy.WriteFile(client, orchestrator.PathOpenClawConfig, []byte(body.Config)); err != nil { + if err := client.SetConfig(r.Context(), "", body.Config); err != nil { + var te *agentshim.TransportError + if errors.As(err, &te) { + log.Printf("Failed to get SSH connection for instance %d: %v", inst.ID, err) + writeError(w, http.StatusBadGateway, fmt.Sprintf("SSH connection failed: %v", te.Err)) + return + } writeError(w, http.StatusInternalServerError, fmt.Sprintf("Failed to write config: %v", err)) return } - instanceConn := sshproxy.NewSSHInstance(client) - if _, stderr, code, err := instanceConn.ExecOpenclaw(r.Context(), "gateway", "stop"); err != nil || code != 0 { - log.Printf("Failed to restart gateway for instance %d: %v %s", inst.ID, err, stderr) + // Restart the agent when the config file declares it (best-effort, + // matching historical behavior). + restarted := false + language := "" + if caps, capErr := client.Capabilities(r.Context()); capErr == nil { + if file := caps.FindConfigFile(""); file != nil { + language = file.Language + if file.RestartRequired { + if rerr := client.Restart(r.Context()); rerr != nil { + log.Printf("Failed to restart agent for instance %d: %v", inst.ID, rerr) + } + restarted = true + } + } } writeJSON(w, http.StatusOK, map[string]interface{}{ "config": body.Config, - "restarted": true, + "restarted": restarted, + "language": language, }) } @@ -2189,6 +2270,7 @@ func CloneInstance(w http.ResponseWriter, r *http.Request) { inst := database.Instance{ Name: cloneName, DisplayName: cloneDisplayName, + AgentType: src.AgentType, Status: "creating", CPURequest: src.CPURequest, CPULimit: src.CPULimit, @@ -2275,10 +2357,7 @@ func CloneInstance(w http.ResponseWriter, r *http.Request) { effectiveUserAgent := getEffectiveUserAgent(inst) envVars := map[string]string{} - if gatewayTokenPlain != "" { - envVars["OPENCLAW_GATEWAY_TOKEN"] = gatewayTokenPlain - } - envVars["CLAWORC_INSTANCE_ID"] = fmt.Sprintf("%d", inst.ID) + applyReservedAgentEnv(envVars, inst, gatewayTokenPlain) placement := instancePlacementParams(inst) @@ -2467,11 +2546,13 @@ func ReorderInstances(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } -// ConfigureInstance sets the model configuration and gateway providers on a running instance -// via openclaw CLI over SSH through inst. +// ConfigureInstance sets the model configuration and gateway providers on a +// running instance. It builds the agent-agnostic LLM routing document and +// hands it to the OpenClaw adapter's ConfigureLLM, which applies it via the +// openclaw CLI over SSH through inst. // -// gatewayProviders (optional) maps provider key → gateway auth key for configuring -// models.providers in OpenClaw to route through the internal LLM gateway. +// gatewayProviders (optional) maps provider key → gateway auth key for routing +// the agent's LLM traffic through the internal LLM gateway. // gatewayPort is the port the LLM gateway listens on (typically 40001). func ConfigureInstance(ctx context.Context, ops orchestrator.ContainerOrchestrator, inst sshproxy.Instance, name string, models []string, gatewayProviders map[string]GatewayProvider, gatewayPort int) { if len(models) == 0 && len(gatewayProviders) == 0 { @@ -2484,80 +2565,10 @@ func ConfigureInstance(ctx context.Context, ops orchestrator.ContainerOrchestrat return } - // Set model config via openclaw config set - if len(models) > 0 { - modelConfig := map[string]interface{}{ - "primary": models[0], - } - if len(models) > 1 { - modelConfig["fallbacks"] = models[1:] - } else { - modelConfig["fallbacks"] = []string{} - } - modelJSON, err := json.Marshal(modelConfig) - if err != nil { - log.Printf("Error marshaling model config for %s: %v", utils.SanitizeForLog(name), err) - return - } - _, stderr, code, err := inst.ExecOpenclaw(ctx, "config", "set", "agents.defaults.model", string(modelJSON), "--json") - if err != nil { - log.Printf("Error setting model config for %s: %v", utils.SanitizeForLog(name), err) - return - } - if code != 0 { - log.Printf("Failed to set model config for %s: %s", utils.SanitizeForLog(name), utils.SanitizeForLog(stderr)) - // continue — providers must still be configured even if model config failed - } - - // Set models allowlist to restrict the UI dropdown to only configured models - modelsMap := make(map[string]interface{}, len(models)) - for _, m := range models { - modelsMap[m] = map[string]interface{}{} - } - modelsMapJSON, err := json.Marshal(modelsMap) - if err != nil { - log.Printf("Error marshaling models allowlist for %s: %v", utils.SanitizeForLog(name), err) - } else { - // `openclaw config set` deep-merges into existing map values, so a - // previously-selected model that the admin de-selected would linger. - // Clear the path before writing the new allowlist. - _, _, _, _ = inst.ExecOpenclaw(ctx, "config", "unset", "agents.defaults.models") - _, stderr, code, err := inst.ExecOpenclaw(ctx, "config", "set", "agents.defaults.models", string(modelsMapJSON), "--json") - if err != nil { - log.Printf("Error setting models allowlist for %s: %v", utils.SanitizeForLog(name), err) - } else if code != 0 { - log.Printf("Failed to set models allowlist for %s: %s", utils.SanitizeForLog(name), utils.SanitizeForLog(stderr)) - } - } - } - - // Set gateway providers via openclaw CLI. - if len(gatewayProviders) > 0 && gatewayPort > 0 { - providersJSON, err := buildOpenClawProvidersJSON(models, gatewayProviders, gatewayPort) - if err != nil { - log.Printf("Error marshaling gateway providers for %s: %v", utils.SanitizeForLog(name), err) - } else if providersJSON != "" { - // Clear the providers map first so de-selected providers are removed - // instead of being deep-merged with the previous config. - _, _, _, _ = inst.ExecOpenclaw(ctx, "config", "unset", "models.providers") - stdout, stderr, code, err := inst.ExecOpenclaw(ctx, "config", "set", "models.providers", providersJSON, "--json") - if err != nil { - log.Printf("Error setting gateway providers for %s: %v", utils.SanitizeForLog(name), err) - } else if code != 0 { - log.Printf("Failed to set gateway providers for %s: stdout=%q stderr=%q", - utils.SanitizeForLog(name), utils.SanitizeForLog(stdout), utils.SanitizeForLog(stderr)) - } - } - } - - // Restart gateway so it picks up new env vars and config - stdout, stderr, code, err := inst.ExecOpenclaw(ctx, "gateway", "stop") - if err != nil { - log.Printf("Error restarting gateway for %s: %v", utils.SanitizeForLog(name), err) - return - } - if code != 0 { - log.Printf("Failed to restart gateway for %s: stdout=%q stderr=%q", utils.SanitizeForLog(name), utils.SanitizeForLog(stdout), utils.SanitizeForLog(stderr)) + routing := buildLLMRouting(models, gatewayProviders, gatewayPort) + client := openclawnative.NewWithExec(inst) + if err := client.ConfigureLLM(ctx, routing); err != nil { + log.Printf("Failed to configure models/providers for %s: %v", utils.SanitizeForLog(name), err) return } log.Printf("Models and providers configured for %s", utils.SanitizeForLog(name)) diff --git a/control-plane/internal/handlers/logs.go b/control-plane/internal/handlers/logs.go index aacb6285..079ce6ef 100644 --- a/control-plane/internal/handlers/logs.go +++ b/control-plane/internal/handlers/logs.go @@ -37,6 +37,12 @@ func StreamLogs(w http.ResponseWriter, r *http.Request) { if logType == "" { logType = sshproxy.LogTypeOpenClaw } + // "agent" is an alias for the primary agent log; normalize it to + // "openclaw" so per-instance custom log-path overrides keyed "openclaw" + // keep applying. Both names resolve to the same file. + if logType == sshproxy.LogTypeAgent { + logType = sshproxy.LogTypeOpenClaw + } var inst database.Instance if err := database.DB.First(&inst, id).Error; err != nil { diff --git a/control-plane/internal/handlers/settings.go b/control-plane/internal/handlers/settings.go index b1cf70ce..4cc75003 100644 --- a/control-plane/internal/handlers/settings.go +++ b/control-plane/internal/handlers/settings.go @@ -96,6 +96,19 @@ func settingsToResponse(raw map[string]string) map[string]interface{} { // edit flow needs the live value to diff against. result["default_env_vars"] = EnvVarsForResponse(raw["default_env_vars"]) + // Per-agent-type default images (non-OpenClaw types; OpenClaw keeps the + // plain default_agent_image setting above). + { + var m map[string]string + if raw["default_agent_images"] != "" { + json.Unmarshal([]byte(raw["default_agent_images"]), &m) + } + if m == nil { + m = map[string]string{} + } + result["default_agent_images"] = m + } + // Global pod placement settings for _, k := range []string{"default_pod_annotations", "default_node_selector", "default_service_account_annotations"} { var m map[string]string @@ -216,10 +229,11 @@ func UpdateSettings(w http.ResponseWriter, r *http.Request) { } } - // Handle pod placement + service/port settings (stored as JSON strings) + // Handle pod placement + service/port settings and the per-agent-type + // default image map (stored as JSON strings) for _, key := range []string{ "default_pod_annotations", "default_node_selector", "default_tolerations", - "default_service_account_annotations", "default_ports", + "default_service_account_annotations", "default_ports", "default_agent_images", } { if v, ok := raw[key]; ok { b, err := json.Marshal(v) @@ -237,7 +251,7 @@ func UpdateSettings(w http.ResponseWriter, r *http.Request) { continue } if key == "default_pod_annotations" || key == "default_node_selector" || key == "default_tolerations" || - key == "default_service_account_annotations" || key == "default_ports" { + key == "default_service_account_annotations" || key == "default_ports" || key == "default_agent_images" { continue // handled above } // installation_id is read-only; never accept it on update. diff --git a/control-plane/internal/handlers/ssh_test.go b/control-plane/internal/handlers/ssh_test.go index 35834004..75c88bcc 100644 --- a/control-plane/internal/handlers/ssh_test.go +++ b/control-plane/internal/handlers/ssh_test.go @@ -185,8 +185,7 @@ func (m *mockOrchestrator) GetInstanceStatus(_ context.Context, _ string) (strin func (m *mockOrchestrator) GetInstanceImageInfo(_ context.Context, _ string) (string, error) { return "", nil } -func (m *mockOrchestrator) UpdateInstanceConfig(_ context.Context, _, _ string) error { return nil } -func (m *mockOrchestrator) CloneVolumes(_ context.Context, _, _ string) error { return nil } +func (m *mockOrchestrator) CloneVolumes(_ context.Context, _, _ string) error { return nil } func (m *mockOrchestrator) ConfigureSSHAccess(_ context.Context, _ uint, _ string) error { return m.configureErr } @@ -218,8 +217,8 @@ func (m *mockOrchestrator) UpdatePlacementConfig(_ context.Context, _ string, _ return nil } func (m *mockOrchestrator) DeleteSharedVolume(_ context.Context, _ uint) error { return nil } -func (m *mockOrchestrator) CloneVolume(_ context.Context, _, _ string) error { return nil } -func (m *mockOrchestrator) VolumeNameFor(name, suffix string) string { return name + "-" + suffix } +func (m *mockOrchestrator) CloneVolume(_ context.Context, _, _ string) error { return nil } +func (m *mockOrchestrator) VolumeNameFor(name, suffix string) string { return name + "-" + suffix } func (m *mockOrchestrator) Apply(_ context.Context, _ orchestrator.WorkloadSpec) error { return nil } diff --git a/control-plane/internal/handlers/webhook_bridge.go b/control-plane/internal/handlers/webhook_bridge.go index 9d68d94d..09ae0081 100644 --- a/control-plane/internal/handlers/webhook_bridge.go +++ b/control-plane/internal/handlers/webhook_bridge.go @@ -2,45 +2,47 @@ package handlers import ( "context" - "encoding/json" "fmt" "log" "path/filepath" "strings" "time" - "github.com/coder/websocket" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" "github.com/gluk-w/claworc/control-plane/internal/config" "github.com/gluk-w/claworc/control-plane/internal/database" - "github.com/gluk-w/claworc/control-plane/internal/sshproxy" "github.com/gluk-w/claworc/control-plane/internal/utils" ) const webhookSessionPrefix = "claworc-webhook-" -// webhookGetTunnelPort is the getTunnelPort call used by RunWebhookBridge. -// Replaced in tests to inject a local fake-gateway port. -var webhookGetTunnelPort = getTunnelPort +// webhookOpenSession opens the agent chat session RunWebhookBridge streams +// from. Replaced in tests to inject a fake session. +var webhookOpenSession = func(ctx context.Context, instanceID uint, sessionKey string) (agentshim.Session, error) { + client, err := agentshim.DefaultFactory().ForInstance(ctx, instanceID) + if err != nil { + return nil, err + } + return client.OpenSession(ctx, sessionKey) +} // WebhookAttachment is a single file delivered alongside a webhook // request. The bridge writes Content into the instance at -// /tmp/webhooks// before sending chat.send. +// /tmp/webhooks// before sending the chat message. type WebhookAttachment struct { Filename string Content []byte } -// RunWebhookBridge dials the OpenClaw gateway for the given instance, -// uploads any attachments into /tmp/webhooks//, sends a -// single chat.send frame using the claworc-webhook- key -// (so webhook sessions are identifiable in OpenClaw's session list), -// and reads gateway events until the lifecycle/end frame arrives. -// Returns the final cumulative assistant text. +// RunWebhookBridge opens an agent chat session for the given instance, +// uploads any attachments into /tmp/webhooks//, sends a single +// message using the claworc-webhook- key (so webhook sessions +// are identifiable in the agent's session list), and reads normalized chat +// events until the "end" event arrives. Returns the final cumulative +// assistant text. // -// This mirrors the moderator runner's gateway loop (see -// internal/moderator/runner.go) but synchronously blocks the HTTP caller -// instead of streaming into Kanban comments. The supplied ctx is the -// HTTP request context — its cancellation (client disconnect) or deadline +// This synchronously blocks the HTTP caller. The supplied ctx is the HTTP +// request context — its cancellation (client disconnect) or deadline // (client HTTP timeout) terminates the call. func RunWebhookBridge(ctx context.Context, instanceID uint, sessionName, message string, attachments []WebhookAttachment) (reply string, err error) { if sessionName == "" { @@ -81,48 +83,20 @@ func RunWebhookBridge(ctx context.Context, instanceID uint, sessionName, message finalMessage = b.String() } - port, err := webhookGetTunnelPort(instanceID, "gateway") - if err != nil { - return "", fmt.Errorf("no gateway tunnel: %w", err) - } - - var gatewayToken string - if inst.GatewayToken != "" { - if tok, derr := utils.Decrypt(inst.GatewayToken); derr == nil && tok != "" { - gatewayToken = tok - } - } - dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second) - gwConn, err := sshproxy.DialGateway(dialCtx, port, gatewayToken) + sess, err := webhookOpenSession(dialCtx, instanceID, webhookSessionPrefix+sessionName) cancel() if err != nil { return "", fmt.Errorf("dial gateway: %w", err) } - defer gwConn.CloseNow() + defer sess.Close() - ocSessionKey := webhookSessionPrefix + sessionName - requestID := fmt.Sprintf("webhook-%d", time.Now().UnixNano()) - sendFrame := map[string]any{ - "type": "req", - "id": requestID, - "method": "chat.send", - "params": map[string]any{ - "sessionKey": ocSessionKey, - "message": finalMessage, - "idempotencyKey": ocSessionKey + "-" + requestID, - }, - } - sendJSON, err := json.Marshal(sendFrame) - if err != nil { - return "", fmt.Errorf("marshal chat.send: %w", err) - } - if err := gwConn.Write(ctx, websocket.MessageText, sendJSON); err != nil { - return "", fmt.Errorf("send chat.send: %w", err) + if err := sess.Send(ctx, finalMessage); err != nil { + return "", fmt.Errorf("send chat message: %w", err) } // Idle (activity-based) deadline: each read is bounded by idle, and the - // timer re-arms on every frame received. An agent that keeps streaming + // timer re-arms on every event received. An agent that keeps streaming // events is never cut off; only a genuine stall (no events for idle) trips. idle := config.Cfg.WebhookIdleTimeout if idle <= 0 { @@ -132,49 +106,32 @@ func RunWebhookBridge(ctx context.Context, instanceID uint, sessionName, message var assistantText string for { readCtx, cancel := context.WithTimeout(ctx, idle) - _, data, err := gwConn.Read(readCtx) + ev, err := sess.Recv(readCtx) cancel() if err != nil { // Parent ctx cancelled => client disconnected or its own deadline. if ctx.Err() != nil { return "", ctx.Err() } - // Per-read deadline fired => OpenClaw produced no events for idle. + // Per-read deadline fired => the agent produced no events for idle. if readCtx.Err() == context.DeadlineExceeded { - return "", fmt.Errorf("openclaw idle timeout: no events for %s", idle) + return "", fmt.Errorf("agent idle timeout: no events for %s", idle) } return "", fmt.Errorf("gateway read: %w", err) } - var msg map[string]any - if err := json.Unmarshal(data, &msg); err != nil { - continue - } - if msg["type"] != "event" { - continue - } - payload, _ := msg["payload"].(map[string]any) - if payload == nil { - continue - } - stream, _ := payload["stream"].(string) - eventData, _ := payload["data"].(map[string]any) - switch stream { - case "assistant": - // OpenClaw assistant events carry the cumulative snapshot in - // data.text. The latest snapshot is the final reply. - if eventData != nil { - if text, _ := eventData["text"].(string); text != "" { - assistantText = text - } + switch ev.Kind { + case agentshim.EventAssistant: + // Assistant events carry a cumulative snapshot; the latest + // snapshot is the final reply. + if ev.Text != "" { + assistantText = ev.Text } - case "lifecycle": - if eventData != nil { - phase, _ := eventData["phase"].(string) - if phase == "end" { - log.Printf("[webhook-bridge] instance=%d session=%s done bytes=%d", instanceID, utils.SanitizeForLog(sessionName), len(assistantText)) - return assistantText, nil - } + case agentshim.EventEnd: + if ev.Text != "" { + assistantText = ev.Text } + log.Printf("[webhook-bridge] instance=%d session=%s done bytes=%d", instanceID, utils.SanitizeForLog(sessionName), len(assistantText)) + return assistantText, nil } } } diff --git a/control-plane/internal/handlers/webhook_bridge_test.go b/control-plane/internal/handlers/webhook_bridge_test.go index a3c93bc4..2debb636 100644 --- a/control-plane/internal/handlers/webhook_bridge_test.go +++ b/control-plane/internal/handlers/webhook_bridge_test.go @@ -2,129 +2,94 @@ package handlers import ( "context" - "encoding/json" "errors" - "net/http" - "net/http/httptest" "strconv" "strings" + "sync" "testing" "time" - "github.com/coder/websocket" + "github.com/gluk-w/claworc/control-plane/internal/agentshim" "github.com/gluk-w/claworc/control-plane/internal/config" "github.com/gluk-w/claworc/control-plane/internal/database" ) -// fakeGatewayFunc runs a minimal OpenClaw gateway over WebSocket. It completes -// the DialGateway handshake, reads the chat.send frame, and then hands control -// to afterSend, which decides what events (if any) to stream back. The gateway's -// request context is passed through so afterSend can observe client disconnect. -func fakeGatewayFunc(t *testing.T, afterSend func(ctx context.Context, conn *websocket.Conn, params map[string]any)) *httptest.Server { - t.Helper() - return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true}) - if err != nil { - t.Logf("ws accept: %v", err) - return - } - defer conn.CloseNow() - ctx := r.Context() +// scriptStep is one Recv result of a fakeSession: after delay, ev is +// returned. Steps past the end of the script block until ctx is done +// (simulating a silent agent). +type scriptStep struct { + delay time.Duration + ev agentshim.Event +} - // Phase 1: send connect.challenge - challenge, _ := json.Marshal(map[string]any{"type": "event", "payload": map[string]any{"stream": "connect.challenge"}}) - if err := conn.Write(ctx, websocket.MessageText, challenge); err != nil { - t.Logf("write challenge: %v", err) - return - } +// fakeSession is an in-memory agentshim.Session: Send records messages, +// Recv replays a script of events. +type fakeSession struct { + mu sync.Mutex + idx int + script []scriptStep + sent []string + closed bool +} - // Phase 2: read connect frame (discard) - if _, _, err := conn.Read(ctx); err != nil { - t.Logf("read connect: %v", err) - return - } +func (f *fakeSession) Send(_ context.Context, message string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.sent = append(f.sent, message) + return nil +} - // Phase 3: send hello-ok - helloOK, _ := json.Marshal(map[string]any{"type": "res", "ok": true}) - if err := conn.Write(ctx, websocket.MessageText, helloOK); err != nil { - t.Logf("write hello-ok: %v", err) - return - } +func (f *fakeSession) Recv(ctx context.Context) (agentshim.Event, error) { + f.mu.Lock() + if f.idx >= len(f.script) { + f.mu.Unlock() + // Deliberate silence: block until the caller's deadline fires. + <-ctx.Done() + return agentshim.Event{}, ctx.Err() + } + step := f.script[f.idx] + f.idx++ + f.mu.Unlock() - // Phase 4: read chat.send frame - _, data, err := conn.Read(ctx) - if err != nil { - t.Logf("read chat.send: %v", err) - return - } - var frame map[string]any - if err := json.Unmarshal(data, &frame); err != nil { - t.Logf("unmarshal frame: %v", err) - return + if step.delay > 0 { + select { + case <-ctx.Done(): + return agentshim.Event{}, ctx.Err() + case <-time.After(step.delay): } - params, _ := frame["params"].(map[string]any) - - afterSend(ctx, conn, params) - })) + } + return step.ev, nil } -// fakeGateway is the default gateway: it captures the chat.send params and -// immediately sends a lifecycle/end event so RunWebhookBridge returns. -func fakeGateway(t *testing.T) (srv *httptest.Server, paramsCh <-chan map[string]any) { - t.Helper() - ch := make(chan map[string]any, 1) - srv = fakeGatewayFunc(t, func(ctx context.Context, conn *websocket.Conn, params map[string]any) { - ch <- params - conn.Write(ctx, websocket.MessageText, lifecycleEndEvent()) //nolint:errcheck - }) - return srv, ch +func (f *fakeSession) Abort(context.Context) error { return nil } +func (f *fakeSession) Reset(context.Context) error { return nil } +func (f *fakeSession) Close() error { + f.mu.Lock() + defer f.mu.Unlock() + f.closed = true + return nil } -// assistantEvent builds an OpenClaw assistant event carrying a cumulative -// snapshot in data.text. -func assistantEvent(text string) []byte { - b, _ := json.Marshal(map[string]any{ - "type": "event", - "payload": map[string]any{ - "stream": "assistant", - "data": map[string]any{"text": text}, - }, - }) - return b +func assistantSnapshot(text string) agentshim.Event { + return agentshim.Event{V: 1, Kind: agentshim.EventAssistant, Text: text} } -// lifecycleEndEvent builds the lifecycle/end event that signals completion. -func lifecycleEndEvent() []byte { - b, _ := json.Marshal(map[string]any{ - "type": "event", - "payload": map[string]any{ - "stream": "lifecycle", - "data": map[string]any{"phase": "end"}, - }, - }) - return b +func endEvent(text string) agentshim.Event { + return agentshim.Event{V: 1, Kind: agentshim.EventEnd, StopReason: agentshim.StopComplete, Text: text} } -// gatewayPort extracts the listening port from a fake gateway test server. -func gatewayPort(t *testing.T, srv *httptest.Server) int { +// pointSessionTo overrides webhookOpenSession to hand out sess and records +// the session key the bridge requested. +func pointSessionTo(t *testing.T, sess agentshim.Session) (sessionKey *string) { t.Helper() - addr := srv.Listener.Addr().String() - portStr := addr[strings.LastIndex(addr, ":")+1:] - port, err := strconv.Atoi(portStr) - if err != nil { - t.Fatalf("parse port %q: %v", portStr, err) + var key string + orig := webhookOpenSession + webhookOpenSession = func(_ context.Context, _ uint, sessionKey string) (agentshim.Session, error) { + key = sessionKey + return sess, nil } - return port -} - -// pointTunnelTo overrides webhookGetTunnelPort to resolve to srv's port for the -// duration of the test. -func pointTunnelTo(t *testing.T, srv *httptest.Server) { - t.Helper() - port := gatewayPort(t, srv) - orig := webhookGetTunnelPort - webhookGetTunnelPort = func(_ uint, _ string) (int, error) { return port, nil } - t.Cleanup(func() { webhookGetTunnelPort = orig }) + t.Cleanup(func() { webhookOpenSession = orig }) + return &key } // setBridgeIdleTimeout sets the webhook idle timeout for the test and restores @@ -156,101 +121,64 @@ func newBridgeInstance(t *testing.T, uuid string) database.Instance { func TestRunWebhookBridge_SessionKeyHasPrefix(t *testing.T) { setupTestDB(t) - if err := database.DB.AutoMigrate(&database.WebhookApiKey{}, &database.WebhookLog{}); err != nil { - t.Fatalf("automigrate: %v", err) - } + inst := newBridgeInstance(t, "bridge-prefix-test") - inst := database.Instance{ - UUID: "bridge-prefix-test", - Name: "bot-bridge-prefix-test", - DisplayName: "Bridge Prefix Test", - Status: "running", - } - if err := database.DB.Create(&inst).Error; err != nil { - t.Fatalf("create instance: %v", err) - } - - srv, paramsCh := fakeGateway(t) - defer srv.Close() - - // Derive the port from the test server URL. - addr := srv.Listener.Addr().String() - portStr := addr[strings.LastIndex(addr, ":")+1:] - port, err := strconv.Atoi(portStr) - if err != nil { - t.Fatalf("parse port %q: %v", portStr, err) - } - - origGetTunnelPort := webhookGetTunnelPort - webhookGetTunnelPort = func(_ uint, _ string) (int, error) { return port, nil } - t.Cleanup(func() { webhookGetTunnelPort = origGetTunnelPort }) + sess := &fakeSession{script: []scriptStep{{ev: endEvent("")}}} + key := pointSessionTo(t, sess) _, bridgeErr := RunWebhookBridge(context.Background(), inst.ID, "my-task", "hello", nil) if bridgeErr != nil { t.Fatalf("RunWebhookBridge: %v", bridgeErr) } - params := <-paramsCh - sessionKey, _ := params["sessionKey"].(string) - if sessionKey != "claworc-webhook-my-task" { - t.Fatalf("sessionKey = %q, want %q", sessionKey, "claworc-webhook-my-task") + if *key != "claworc-webhook-my-task" { + t.Fatalf("sessionKey = %q, want %q", *key, "claworc-webhook-my-task") } - idempotencyKey, _ := params["idempotencyKey"].(string) - if !strings.HasPrefix(idempotencyKey, "claworc-webhook-my-task-") { - t.Fatalf("idempotencyKey = %q, want prefix %q", idempotencyKey, "claworc-webhook-my-task-") + if len(sess.sent) != 1 || sess.sent[0] != "hello" { + t.Fatalf("sent = %v, want exactly [hello]", sess.sent) + } + if !sess.closed { + t.Fatal("session was not closed") } } -// TestRunWebhookBridge_IdleTimeout: a gateway that sends one event then goes +// TestRunWebhookBridge_IdleTimeout: a session that yields one event then goes // silent must trip the idle timeout rather than blocking forever. func TestRunWebhookBridge_IdleTimeout(t *testing.T) { setupTestDB(t) inst := newBridgeInstance(t, "bridge-idle-test") setBridgeIdleTimeout(t, 200*time.Millisecond) - srv := fakeGatewayFunc(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { - // One event, then deliberate silence (never sends lifecycle/end). - conn.Write(ctx, websocket.MessageText, assistantEvent("working...")) //nolint:errcheck - <-ctx.Done() - }) - defer srv.Close() - pointTunnelTo(t, srv) + // One assistant event, then deliberate silence (never sends "end"). + sess := &fakeSession{script: []scriptStep{{ev: assistantSnapshot("working...")}}} + pointSessionTo(t, sess) _, err := RunWebhookBridge(context.Background(), inst.ID, "idle-task", "hello", nil) if err == nil { t.Fatalf("expected idle timeout error, got nil") } - if !strings.Contains(err.Error(), "openclaw idle timeout") { + if !strings.Contains(err.Error(), "agent idle timeout") { t.Fatalf("error = %q, want idle timeout", err.Error()) } } -// TestRunWebhookBridge_HeartbeatKeepsAlive: a gateway streaming events at an +// TestRunWebhookBridge_HeartbeatKeepsAlive: a session streaming events at an // interval shorter than the idle window — but for far longer than that window -// in total — must NOT be cut off, proving the deadline re-arms per frame. +// in total — must NOT be cut off, proving the deadline re-arms per event. func TestRunWebhookBridge_HeartbeatKeepsAlive(t *testing.T) { setupTestDB(t) inst := newBridgeInstance(t, "bridge-heartbeat-test") setBridgeIdleTimeout(t, 200*time.Millisecond) - srv := fakeGatewayFunc(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { - // 12 events @ 50ms = ~600ms total, well past the 200ms idle window, - // but each gap (50ms) stays under it. Last snapshot is the reply. - for i := 0; i < 12; i++ { - text := "chunk-" + strconv.Itoa(i) - if err := conn.Write(ctx, websocket.MessageText, assistantEvent(text)); err != nil { - return - } - select { - case <-ctx.Done(): - return - case <-time.After(50 * time.Millisecond): - } - } - conn.Write(ctx, websocket.MessageText, lifecycleEndEvent()) //nolint:errcheck - }) - defer srv.Close() - pointTunnelTo(t, srv) + // 12 events @ 50ms = ~600ms total, well past the 200ms idle window, + // but each gap (50ms) stays under it. Last snapshot is the reply. + var script []scriptStep + for i := 0; i < 12; i++ { + script = append(script, scriptStep{delay: 50 * time.Millisecond, ev: assistantSnapshot("chunk-" + strconv.Itoa(i))}) + } + script = append(script, scriptStep{ev: endEvent("")}) + sess := &fakeSession{script: script} + pointSessionTo(t, sess) reply, err := RunWebhookBridge(context.Background(), inst.ID, "hb-task", "hello", nil) if err != nil { @@ -261,6 +189,27 @@ func TestRunWebhookBridge_HeartbeatKeepsAlive(t *testing.T) { } } +// TestRunWebhookBridge_EndTextWins: when the end event carries text (the +// normalized schema's end.text), it is the authoritative reply. +func TestRunWebhookBridge_EndTextWins(t *testing.T) { + setupTestDB(t) + inst := newBridgeInstance(t, "bridge-endtext-test") + + sess := &fakeSession{script: []scriptStep{ + {ev: assistantSnapshot("partial")}, + {ev: endEvent("final answer")}, + }} + pointSessionTo(t, sess) + + reply, err := RunWebhookBridge(context.Background(), inst.ID, "end-task", "hello", nil) + if err != nil { + t.Fatalf("RunWebhookBridge: %v", err) + } + if reply != "final answer" { + t.Fatalf("reply = %q, want %q", reply, "final answer") + } +} + // TestRunWebhookBridge_ClientDisconnect: cancelling the request context mid- // stream returns context.Canceled, not the idle-timeout error. func TestRunWebhookBridge_ClientDisconnect(t *testing.T) { @@ -268,17 +217,9 @@ func TestRunWebhookBridge_ClientDisconnect(t *testing.T) { inst := newBridgeInstance(t, "bridge-disconnect-test") setBridgeIdleTimeout(t, 5*time.Second) // generous, so idle never fires first - started := make(chan struct{}, 1) - srv := fakeGatewayFunc(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { - conn.Write(ctx, websocket.MessageText, assistantEvent("working...")) //nolint:errcheck - select { - case started <- struct{}{}: - default: - } - <-ctx.Done() - }) - defer srv.Close() - pointTunnelTo(t, srv) + // One assistant event, then silence until the caller cancels. + sess := &fakeSession{script: []scriptStep{{ev: assistantSnapshot("working...")}}} + pointSessionTo(t, sess) ctx, cancel := context.WithCancel(context.Background()) defer cancel() @@ -291,7 +232,17 @@ func TestRunWebhookBridge_ClientDisconnect(t *testing.T) { resCh <- result{err} }() - <-started + // Wait until the first event has been consumed, then cancel. + deadline := time.Now().Add(2 * time.Second) + for { + sess.mu.Lock() + consumed := sess.idx >= 1 + sess.mu.Unlock() + if consumed || time.Now().After(deadline) { + break + } + time.Sleep(10 * time.Millisecond) + } cancel() select { diff --git a/control-plane/internal/orchestrator/common.go b/control-plane/internal/orchestrator/common.go index 040cc329..d64475f9 100644 --- a/control-plane/internal/orchestrator/common.go +++ b/control-plane/internal/orchestrator/common.go @@ -4,12 +4,8 @@ import ( "context" "encoding/base64" "fmt" - - "github.com/gluk-w/claworc/control-plane/internal/sshproxy" ) -const PathOpenClawConfig = "/home/claworc/.openclaw/openclaw.json" - // ExecFunc matches the ExecInInstance method signature. type ExecFunc func(ctx context.Context, name string, cmd []string) (string, string, int, error) @@ -36,25 +32,3 @@ func configureSSHAccess(ctx context.Context, execFn ExecFunc, name string, publi return nil } - -func updateInstanceConfig(ctx context.Context, execFn ExecFunc, factory sshproxy.InstanceFactory, name string, configJSON string) error { - // Write config file via exec (not an openclaw CLI call) - b64 := base64.StdEncoding.EncodeToString([]byte(configJSON)) - cmd := []string{"sh", "-c", fmt.Sprintf("echo '%s' | base64 -d > %s", b64, PathOpenClawConfig)} - _, stderr, code, err := execFn(ctx, name, cmd) - if err != nil { - return fmt.Errorf("write config: %w", err) - } - if code != 0 { - return fmt.Errorf("write config: %s", stderr) - } - - inst, err := factory(ctx, name) - if err != nil { - return fmt.Errorf("get instance connection: %w", err) - } - if _, stderr, code, err := inst.ExecOpenclaw(ctx, "gateway", "stop"); err != nil || code != 0 { - return fmt.Errorf("restart gateway: %v %s", err, stderr) - } - return nil -} diff --git a/control-plane/internal/orchestrator/docker.go b/control-plane/internal/orchestrator/docker.go index 70af88d7..dd6c541b 100644 --- a/control-plane/internal/orchestrator/docker.go +++ b/control-plane/internal/orchestrator/docker.go @@ -697,10 +697,6 @@ type dockerStatsJSON struct { } `json:"memory_stats"` } -func (d *DockerOrchestrator) UpdateInstanceConfig(ctx context.Context, name string, configJSON string) error { - return updateInstanceConfig(ctx, d.ExecInInstance, d.InstanceFactory, name, configJSON) -} - func stripDockerLogHeaders(data []byte) string { // Docker multiplexed log format: [stream_type(1)][0(3)][size(4)][payload] // If the data starts with a valid header byte (0, 1, or 2), try to strip diff --git a/control-plane/internal/orchestrator/kubernetes.go b/control-plane/internal/orchestrator/kubernetes.go index b6d69ea9..9b7ef178 100644 --- a/control-plane/internal/orchestrator/kubernetes.go +++ b/control-plane/internal/orchestrator/kubernetes.go @@ -673,10 +673,6 @@ func (k *KubernetesOrchestrator) GetContainerStats(ctx context.Context, name str }, nil } -func (k *KubernetesOrchestrator) UpdateInstanceConfig(ctx context.Context, name string, configJSON string) error { - return updateInstanceConfig(ctx, k.ExecInInstance, k.InstanceFactory, name, configJSON) -} - func (k *KubernetesOrchestrator) ExecInInstance(ctx context.Context, name string, cmd []string) (string, string, int, error) { podName, err := k.getPodName(ctx, name) if err != nil { diff --git a/control-plane/internal/orchestrator/orchestrator.go b/control-plane/internal/orchestrator/orchestrator.go index 02f2a814..a5d88725 100644 --- a/control-plane/internal/orchestrator/orchestrator.go +++ b/control-plane/internal/orchestrator/orchestrator.go @@ -22,9 +22,6 @@ type ContainerOrchestrator interface { GetInstanceStatus(ctx context.Context, name string) (string, error) GetInstanceImageInfo(ctx context.Context, name string) (string, error) - // Config - UpdateInstanceConfig(ctx context.Context, name string, configJSON string) error - // Resources UpdateResources(ctx context.Context, name string, params UpdateResourcesParams) error diff --git a/control-plane/internal/sshproxy/logs.go b/control-plane/internal/sshproxy/logs.go index 13e61e92..e2137c35 100644 --- a/control-plane/internal/sshproxy/logs.go +++ b/control-plane/internal/sshproxy/logs.go @@ -66,14 +66,19 @@ type LogType string const ( LogTypeOpenClaw LogType = "openclaw" - LogTypeSSHD LogType = "sshd" - LogTypeSystem LogType = "system" - LogTypeAuth LogType = "auth" + // LogTypeAgent is the agent-agnostic alias for the primary agent log. + // It resolves to the same path as LogTypeOpenClaw; both names are + // accepted by the log-streaming API. + LogTypeAgent LogType = "agent" + LogTypeSSHD LogType = "sshd" + LogTypeSystem LogType = "system" + LogTypeAuth LogType = "auth" ) // DefaultLogPaths maps each LogType to its default file path on the agent. var DefaultLogPaths = map[LogType]string{ LogTypeOpenClaw: LogPathOpenClaw, + LogTypeAgent: LogPathOpenClaw, // alias — same file as "openclaw" LogTypeSSHD: LogPathSSHD, LogTypeSystem: LogPathSyslog, LogTypeAuth: LogPathAuth, diff --git a/control-plane/internal/sshproxy/tunnel.go b/control-plane/internal/sshproxy/tunnel.go index 7393616a..b6709bf5 100644 --- a/control-plane/internal/sshproxy/tunnel.go +++ b/control-plane/internal/sshproxy/tunnel.go @@ -112,6 +112,12 @@ type TunnelManager struct { // spawning a new session. Returning false means the browser pod is not // running — a normal idle state for on-demand CDP, not a failure. cdpHealthProbe CDPHealthProbe + + // gatewayTunnelPredicate, when set, lets the reconciler ask "should this + // instance get the OpenClaw gateway reverse tunnel?" — the tunnel is + // meaningless for non-OpenClaw agent types. nil means every instance gets + // one (backward compatible). + gatewayTunnelPredicate GatewayTunnelPredicate } // CDPDialProvider is the hook used by the reconciler to discover non-legacy @@ -123,6 +129,11 @@ type CDPDialProvider func(ctx context.Context, instanceID uint) (DialFunc, bool) // from the tunnel health checker. type CDPHealthProbe func(ctx context.Context, instanceID uint) bool +// GatewayTunnelPredicate reports whether an instance should get the OpenClaw +// gateway reverse tunnel (true for the "openclaw" agent type). Keeps this +// package free of agent knowledge — the caller injects the type lookup. +type GatewayTunnelPredicate func(instanceID uint) bool + // NewTunnelManager creates a new TunnelManager that uses the given SSHManager // for obtaining SSH connections to instances. func NewTunnelManager(sshMgr *SSHManager) *TunnelManager { @@ -162,6 +173,15 @@ func (tm *TunnelManager) SetCDPHealthProbe(p CDPHealthProbe) { tm.mu.Unlock() } +// SetGatewayTunnelPredicate installs the hook used by StartTunnelsForInstance +// to decide whether an instance gets the OpenClaw gateway reverse tunnel. +// Pass nil to revert to the default behaviour (every instance gets one). +func (tm *TunnelManager) SetGatewayTunnelPredicate(p GatewayTunnelPredicate) { + tm.mu.Lock() + tm.gatewayTunnelPredicate = p + tm.mu.Unlock() +} + // CreateAgentListenerTunnel makes the SSH server (agent) listen on agentPort. // Connections from inside the agent to that port are forwarded to localAddr on the control plane. // Uses ssh.Client.Listen() — different from reverse tunnels which use local listeners. @@ -414,10 +434,16 @@ func (tm *TunnelManager) StartTunnelsForInstance(ctx context.Context, instanceID } } - // Create Gateway tunnel - _, err = tm.CreateTunnelForGateway(ctx, instanceID, 0) - if err != nil { - log.Printf("Failed to create Gateway tunnel for instance %d: %v", instanceID, err) + // Create Gateway tunnel — only for instances whose agent type actually + // serves the OpenClaw gateway (predicate injected by main; nil = all). + tm.mu.RLock() + gatewayPredicate := tm.gatewayTunnelPredicate + tm.mu.RUnlock() + if gatewayPredicate == nil || gatewayPredicate(instanceID) { + _, err = tm.CreateTunnelForGateway(ctx, instanceID, 0) + if err != nil { + log.Printf("Failed to create Gateway tunnel for instance %d: %v", instanceID, err) + } } // Create LLM proxy agent-listener tunnel if gateway is configured diff --git a/control-plane/main.go b/control-plane/main.go index fe8238c2..0b099cf3 100644 --- a/control-plane/main.go +++ b/control-plane/main.go @@ -84,6 +84,16 @@ func main() { handlers.SSHMgr = sshMgr tunnelMgr := sshproxy.NewTunnelManager(sshMgr) handlers.TunnelMgr = tunnelMgr + // The OpenClaw gateway WS tunnel only makes sense for the "openclaw" + // agent type; other agents don't run the gateway. The LLM gateway + // agent-listener tunnel stays for ALL types (not gated here). + tunnelMgr.SetGatewayTunnelPredicate(func(instanceID uint) bool { + var inst database.Instance + if err := database.DB.First(&inst, instanceID).Error; err != nil { + return true // fail open — behave as before when the row is unreadable + } + return inst.EffectiveAgentType() == database.AgentTypeOpenClaw + }) log.Printf("SSH manager initialized (public key: %d bytes)", len(sshPublicKey)) // Init SSH audit logger @@ -354,6 +364,9 @@ func main() { // Teams: list available to the caller (admin: all, others: own). r.Get("/teams", handlers.ListTeams) + // Agent types (static registry + resolved default images) + r.Get("/agent-types", handlers.ListAgentTypes) + // Instances (ListInstances filters by role internally) r.Get("/instances", handlers.ListInstances) r.Put("/instances/reorder", handlers.ReorderInstances) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 437d53eb..d977a72d 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -112,14 +112,14 @@ into user-visible places inside the container: - **S6 services** — every `run` script uses `#!/command/with-contenv bash`, which re-exports vars captured by s6-overlay's `/init` into - `/run/s6/container_environment/`. Services like `svc-openclaw` and + `/run/s6/container_environment/`. Services like `svc-agent` and `svc-desktop` therefore see the vars directly. - **`docker exec` / `kubectl exec`** — inherit PID 1's environ from the container runtime. - **SSH sessions** — do **not** inherit sshd's environ. sshd runs the user through PAM and `login.defs`, which build a fresh env. To cover this path, the init-setup oneshot - (`agent/instance/rootfs/etc/s6-overlay/scripts/init-setup.sh`) snapshots PID + (`agent/openclaw/rootfs/etc/s6-overlay/scripts/init-setup.sh`) snapshots PID 1's env into two files at boot: - `/etc/environment` — read by `pam_env.so`, present in `/etc/pam.d/sshd`, `cron`, `login`, and `su`. diff --git a/docs/ondemand-browser.md b/docs/ondemand-browser.md index 154e89da..68b3a3d5 100644 --- a/docs/ondemand-browser.md +++ b/docs/ondemand-browser.md @@ -57,7 +57,7 @@ Clean image lineup; legacy combined-image source is deleted from `agent/`. New images, all under the `glukw/claworc-*` namespace: -- `claworc/openclaw:latest` — slim agent. s6 services: `init-setup`, `svc-sshd`, `svc-openclaw`, `svc-cron`. No Xvfb/VNC/openbox/browser. +- `claworc/openclaw:latest` — slim agent. s6 services: `init-setup`, `svc-sshd`, `svc-agent`, `svc-cron`. No Xvfb/VNC/openbox/browser. - `claworc/base-browser:latest` — Xvfb, TigerVNC, noVNC, openbox, stealth-extension. **No sshd.** s6 services: `init-setup`, `svc-xvnc`, `svc-novnc`, `svc-desktop`. Container port `9222` (CDP) bound to `0.0.0.0` (cluster-reachable, NetworkPolicy-restricted); container ports `3000` (noVNC) and `5900` (raw VNC) similarly. - `claworc/chromium-browser:latest` / `claworc/chrome-browser:latest` / `claworc/brave-browser:latest` — derive from `base-browser` and install the respective browser. The `svc-desktop` script keeps today's flags except `--remote-debugging-address` is removed (Chromium binds to `0.0.0.0:9222` so the cluster Service can reach it; access control is at the Service + NetworkPolicy layer). @@ -83,7 +83,7 @@ Legacy instances continue using a single `-home` PVC where chrome-data liv Reuse the existing `TunnelTypeAgentListener` pattern. -**Agent change** — `agent/instance/rootfs/etc/ssh/sshd_config.d/claworc.conf`: +**Agent change** — `agent/openclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf`: ``` PermitListen 127.0.0.1:9222 127.0.0.1:40001 ``` @@ -94,7 +94,7 @@ PermitListen 127.0.0.1:9222 127.0.0.1:40001 - `cdpUrl: "http://127.0.0.1:9222"` (unchanged) - `attachOnly: true` (unchanged) -This is the existing browser section of OpenClaw's config that `svc-openclaw` already initialises at startup; no new file. +This is the existing browser section of OpenClaw's config that `svc-agent` already initialises at startup; no new file. **Control-plane changes** — `control-plane/internal/sshproxy/tunnel.go`: - Generalise `agentListenerLoop` to accept a `dial DialFunc` (`func(context.Context) (io.ReadWriteCloser, error)`). LLM-proxy callsites untouched. @@ -255,14 +255,14 @@ Delete (legacy combined-image sources; published images stay in registry): - The combined s6 service set under `rootfs/etc/s6-overlay/s6-rc.d/user/contents.d/` that listed all services together. Add: -- `agent/instance/Dockerfile` — slim agent (sshd, OpenClaw, cron). Uses the `agent.bundle` s6 set. +- `agent/openclaw/Dockerfile` — slim agent (sshd, OpenClaw, cron). Uses the `agent.bundle` s6 set. - `agent/browser/Dockerfile.base` — Xvfb/TigerVNC/noVNC/openbox/stealth-extension base. Uses the `browser.bundle` s6 set. - `agent/browser/Dockerfile.chromium`, `agent/browser/Dockerfile.chrome`, `agent/browser/Dockerfile.brave`. - New s6 bundles `agent.bundle` and `browser.bundle`. Edit: - `rootfs/etc/ssh/sshd_config.d/claworc.conf` — add `127.0.0.1:9222` to `PermitListen` (this stays in the agent image only; the browser image has no sshd). -- The OpenClaw `browser` config seed delivered by `svc-openclaw` — bump `remoteCdpTimeoutMs` and `remoteCdpHandshakeTimeoutMs` to `65000`. Same mechanism that exists today; no new file. +- The OpenClaw `browser` config seed delivered by `svc-agent` — bump `remoteCdpTimeoutMs` and `remoteCdpHandshakeTimeoutMs` to `65000`. Same mechanism that exists today; no new file. - Browser variant `svc-desktop` script — drop `--remote-debugging-address=127.0.0.1` so Chromium binds to all interfaces (cluster-internal only via Service + NetworkPolicy). **Control plane (`/Users/stan/claworc/control-plane/`):** @@ -346,5 +346,5 @@ CDP is not authenticated by Chromium itself — relying on cluster networking is - `/Users/stan/claworc/control-plane/internal/handlers/desktop.go` - `/Users/stan/claworc/control-plane/internal/handlers/instances.go` - `/Users/stan/claworc/control-plane/internal/taskmanager/taskmanager.go` -- `/Users/stan/claworc/agent/instance/rootfs/etc/ssh/sshd_config.d/claworc.conf` +- `/Users/stan/claworc/agent/openclaw/rootfs/etc/ssh/sshd_config.d/claworc.conf` - `/Users/stan/claworc/helm/templates/networkpolicy.yaml` diff --git a/docs/shim.md b/docs/shim.md new file mode 100644 index 00000000..9d1b7f3a --- /dev/null +++ b/docs/shim.md @@ -0,0 +1,316 @@ +# Claworc Agent Shim Contract (v1) + +The agent shim is the universal interface between the Claworc control plane and the AI +agent running inside an instance container. Any image that implements this contract can +be managed by Claworc — chat, webhooks, config editing, LLM virtual-key routing, and +health checks all go through the shim. OpenClaw, Hermes, NanoClaw, and custom images each +ship their own shim implementation. + +The contract is **exec-based**: the control plane invokes well-known executables inside +the container over the instance's SSH connection. There is no required daemon, port, or +wire protocol beyond SSH (which every Claworc image already runs). Most verbs are +implementable as small shell scripts; images are free to use any language. + +Layering (control-plane side): + +``` +handlers / frontend + └── internal/agentshim (Client/Session interfaces + adapters — all agent knowledge) + └── internal/sshproxy (SSH exec / SFTP / tunnels — transport only) + └── internal/orchestrator (container lifecycle only — no agent knowledge) +``` + +## Image layout + +``` +/opt/claworc/shim/ +├── agent.txt # single-line agent display name, e.g. "OpenClaw" +├── agent.svg # square logo, shown in the instance list and detail header +├── meta # executable verbs (any language, shebang OK, mode 0755) +├── health +├── chat-send +├── chat-abort +├── session-reset +├── config-get +├── config-set +├── configure-llm +├── restart +└── lib/ # optional shared helpers, not invoked by the control plane +``` + +- Verbs are invoked **as root** (SSH is the authentication boundary, exactly like the + terminal and file browser). Shims MUST drop to the `claworc` user for anything that + touches agent state (`s6-setuidgid claworc`, `su - claworc -c`, …) so files stay + claworc-owned. +- `agent.txt` and `agent.svg` are static files read over the SSH channel — they must + not require the agent to be running. +- Shim persistent state (session maps, transcripts) lives in + `/home/claworc/.claworc/shim/` (on the instance PVC, survives restarts). Ephemeral + runtime state (PIDs) lives in `/run/claworc/shim/`. + +## Common conventions for all verbs + +- Structured output is UTF-8 JSON on **stdout**. Human-readable diagnostics go to + **stderr** (surfaced in control-plane error messages). +- **Exit codes**: + + | Code | Meaning | + |------|---------| + | 0 | success | + | 1 | internal failure | + | 2 | usage error (bad arguments) | + | 3 | verb/capability unsupported by this agent | + | 4 | agent not ready (still booting) | + | 5 | timed out waiting on the agent | + | 6 | validation failed (bad config / payload); stdout carries `{"error":"..."}` | + +- `configure-llm`, `config-set`, `session-reset`, and `restart` MUST be idempotent. +- Consumers MUST ignore unknown JSON fields and unknown event types (forward + compatibility within contract v1). Breaking changes bump the `contract` integer. + +## Verbs + +### `meta` + +Capability and version probe. No arguments, no stdin. Prints one JSON object: + +```json +{ + "contract": 1, + "shim_version": "0.1.0", + "agent": {"name": "openclaw", "version": "2.7.1"}, + "capabilities": ["chat", "chat.abort", "session.reset", "config", "configure-llm", "restart", "control-ui"], + "config_files": [ + {"id": "main", "path": "/home/claworc/.openclaw/openclaw.json", + "language": "json", "label": "openclaw.json", "restart_required": true} + ], + "workspace_dir": "/home/claworc/.openclaw/workspace", + "skills_dir": "/home/claworc/.openclaw/skills", + "log_files": [{"path": "/var/log/claworc/agent.log", "label": "Agent"}], + "llm": {"styles": ["openai"]}, + "session_persistence": "native" +} +``` + +Field notes: + +- `contract` (required): integer contract version this shim implements. The control + plane rejects versions outside its supported range. +- `capabilities` (required): gates features in the UI. `chat` is **required** — images + without it fail validation. Optional: `chat.abort`, `session.reset`, `config`, + `configure-llm`, `restart`, `control-ui` (agent serves its own web UI that Claworc + reverse-proxies), `skills` (supports Claworc skills sync into `skills_dir`). +- `config_files`: files exposed in the Config tab. `language` drives editor syntax + highlighting (`json`, `yaml`, `toml`, `ini`, `shell`, `plaintext`). Empty array (or + no `config` capability) hides the Config tab. `restart_required: true` makes the + control plane call `restart` after `config-set`. +- `llm.styles`: which API dialect(s) the agent will use when calling the LLM proxy — + `openai` and/or `anthropic`. The control plane verifies its gateway supports the + declared style. +- `skills_dir`: directory Claworc syncs skills into (only meaningful with the + `skills` capability). +- `session_persistence`: `native` (agent resumes sessions by key), `emulated` (shim + replays transcripts), or `none` (each turn is fresh). Optional; consumers treat an + absent field as `native`. +- `chat_end_detection` (optional): `exact` (default) or `heuristic` — declare + `heuristic` when end-of-turn is inferred (e.g. quiet-period detection), so the UI can + soften "done" indicators. + +### `health` + +No arguments. Exit `0` when the agent can take a chat turn, `4` while booting, `1` when +broken. Optionally prints `{"status":"ok","detail":"..."}`. + +### `chat-send --session [--turn ]` + +The core verb. Sends one user message to the agent and streams the agent's response. + +- **stdin**: the raw UTF-8 user message, read until EOF. Attachments are not in-band — + the control plane uploads files via SFTP beforehand and references them in the message + text. +- **stdout**: JSONL — one event object per line (schema below), terminated by exactly + one `end` event, then exit `0`. +- `--session `: opaque Claworc-chosen session key (e.g. `browser`, + `claworc-webhook-`). The shim maps it to agent-native sessions and MUST + preserve conversation history across turns for the same key (unless + `session_persistence` is `none`). +- `--turn `: optional caller-supplied turn id echoed in events; the shim generates + one when absent. +- **Abort semantics**: the primary abort path is the `chat-abort` verb — the running + `chat-send` then emits `end` with `"stop_reason":"aborted"` and exits 0. The shim + SHOULD also handle SIGTERM/HUP the same way (note: many sshds do not deliver signal + requests; SSH channel teardown is the delivery mechanism the control plane relies + on, so shims must tolerate being killed without emitting `end`). History up to the + abort stays in the session. +- Exit `0` iff an `end` event was emitted (including aborted/error ends). A non-zero + exit means the shim/transport itself failed; the control plane surfaces the last + `error` event or a stderr tail. + +#### Chat event schema (JSONL) + +```jsonl +{"v":1,"event":"start","session":"browser","turn":"t-9f2c"} +{"v":1,"event":"assistant","turn":"t-9f2c","message_id":"m1","text":"Looking into it"} +{"v":1,"event":"assistant","turn":"t-9f2c","message_id":"m1","text":"Looking into it now. I'll check the file."} +{"v":1,"event":"tool","turn":"t-9f2c","name":"exec","phase":"start","detail":{"command":"ls /tmp"}} +{"v":1,"event":"tool","turn":"t-9f2c","name":"exec","phase":"result","detail":{"exit":0}} +{"v":1,"event":"assistant","turn":"t-9f2c","message_id":"m2","text":"Done. Two files found."} +{"v":1,"event":"error","turn":"t-9f2c","code":"provider_rate_limit","text":"rate limited","fatal":false} +{"v":1,"event":"end","turn":"t-9f2c","stop_reason":"complete","text":"Done. Two files found."} +``` + +Rules: + +- **`assistant.text` is a CUMULATIVE SNAPSHOT** of the message identified by + `message_id` — the full text so far, not a delta. Snapshots are self-healing over a + buffered pipe: a dropped or coalesced line costs latency, never correctness. Agents + that natively stream deltas accumulate them in the shim (two lines of code); the + reverse (snapshot→delta) would require diffing. A turn may contain multiple + `message_id`s (text → tool calls → more text); each snapshot replaces only its own + message. +- Shims SHOULD throttle snapshots (≥150 ms apart, or on message boundaries) to bound + output size on long responses. +- `end` is required, exactly once, last. `stop_reason` ∈ `complete | aborted | error`. + `end.text` carries the final text of the last assistant message so one-shot consumers + (webhooks) can ignore everything else. Consumers stop reading at `end` and discard + any output after it. +- `tool` events are optional; `detail` is free-form JSON. +- `error` with `"fatal":false` is informational; a fatal error should be followed by + `end` with `stop_reason:"error"`. +- Unknown event types MUST be ignored by consumers. + +The control plane forwards these events (verbatim JSON) to the browser chat UI over its +WebSocket, prefixed by a `{"type":"connected"}` handshake frame — the shim event schema +is also the browser chat protocol. + +### `chat-abort --session ` + +Aborts the in-flight turn for the session (the running `chat-send` emits `end/aborted` +and exits). Exit `0` also when nothing was running. + +### `session-reset --session ` + +Clears conversation history for the key; the next `chat-send` starts fresh. Backs the +`/new` and `/reset` chat commands. + +### `config-get [--id ]` / `config-set [--id ]` + +Raw config file bytes on stdout (`config-get`) / stdin (`config-set`). `--id` selects an +entry from `meta.config_files` (defaults to the first). `config-set` MAY validate and +exit `6` with `{"error":"..."}` on stdout; it MUST NOT restart the agent — the control +plane calls `restart` afterwards when the file declares `restart_required`. + +### `configure-llm` + +Routes all of the agent's LLM traffic through the Claworc LLM proxy using virtual keys. +stdin is the generic routing document: + +```json +{ + "proxy_url": "http://127.0.0.1:40001", + "style": "openai", + "default_model": "anthropic/claude-sonnet-4-5", + "fallback_models": [], + "providers": [ + {"key": "anthropic", "api_key": "claworc-vk-abc123", "api_type": "anthropic-messages", + "models": [{"id": "anthropic/claude-sonnet-4-5"}]} + ] +} +``` + +`providers[].api_type` is optional metadata for dialect-aware shims (e.g. OpenClaw's +provider `api` field); shims MUST ignore fields they don't understand. Model entries +carry only `id`; the default model is `default_model`, never a per-model flag. + +The shim rewrites the agent's native model/provider configuration so requests go to +`proxy_url` authenticated by the virtual key(s). MUST be idempotent — rewrite a fully +managed section, never append. Exit `6` if the routing cannot be expressed. + +This verb is also invoked **by the image itself at boot**: when the +`CLAWORC_INITIAL_LLM_CONFIG` environment variable is set, the image's startup script +pipes its value into its own `configure-llm` before starting the agent service. + +### `restart` + +Restarts the agent service (`s6-svc -r /run/service/svc-agent` or equivalent). Exit `0` +when the restart was accepted; a no-op exit `0` is fine for agents with no daemon. + +## Environment variables (set by the control plane) + +| Variable | Purpose | +|---|---| +| `CLAWORC_INSTANCE_ID` | Instance identifier (existing) | +| `CLAWORC_AGENT_TOKEN` | Secret for intra-container agent auth (e.g. OpenClaw maps it to its gateway token) | +| `CLAWORC_INITIAL_LLM_CONFIG` | `configure-llm` JSON document applied at first boot | +| `CLAWORC_LLM_PROXY_URL` | LLM proxy URL, normally `http://127.0.0.1:40001` | + +These names are reserved (users cannot override them). For OpenClaw images the legacy +`OPENCLAW_GATEWAY_TOKEN`, `OPENCLAW_INITIAL_MODELS`, and `OPENCLAW_INITIAL_PROVIDERS` +variables remain reserved and are still injected for backward compatibility. + +## Service & filesystem conventions + +- s6-overlay services: `svc-sshd` (required — the contract's only hard runtime + dependency), `svc-agent` (the agent daemon, if any), `init-agent-seed` (oneshot + first-boot seeding of `/home/claworc` from a baked skeleton). +- Primary agent log at `/var/log/claworc/agent.log` (declared in `meta.log_files`; + Claworc's log streaming tails `/var/log/claworc/`). +- Persistent agent state under `/home/claworc` (the instance PVC). + +## Probe, validation, and degraded mode + +On every SSH (re)connect — and after image updates — the control plane: + +1. reads `/opt/claworc/shim/agent.txt` and `agent.svg` over the SSH channel, +2. runs `/opt/claworc/shim/meta` with a short timeout. + +Outcomes: + +- **shim mode** — meta parses, `contract` supported, `chat` capability present. The + identity and meta document are cached on the instance record. +- **legacy-openclaw** — no shim, but the `openclaw` CLI exists: the control plane falls + back to its built-in native OpenClaw adapter (pre-shim images keep working). +- **shim-missing / shim-incompatible** — chat, config, and webhooks are disabled with + an explanatory banner; terminal, file browser, logs, and VNC remain fully functional. + +## Minimal shell reference implementation + +A bare-bones custom image can implement chat with a wrapper around any CLI agent +(`CHAT_CMD` reads the message on stdin and writes the reply to stdout): + +```sh +#!/bin/sh +# /opt/claworc/shim/chat-send — minimal single-snapshot implementation +set -eu +SESSION=""; TURN="" +while [ $# -gt 0 ]; do + case "$1" in + --session) SESSION="$2"; shift 2 ;; + --turn) TURN="$2"; shift 2 ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ -n "$SESSION" ] || { echo "--session is required" >&2; exit 2; } +[ -n "$TURN" ] || TURN="t-$$" + +json_escape() { python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))'; } + +printf '{"v":1,"event":"start","session":%s,"turn":%s}\n' \ + "$(printf %s "$SESSION" | json_escape)" "$(printf %s "$TURN" | json_escape)" + +REPLY=$(su - claworc -c "$CHAT_CMD" 2>/dev/null) || { + printf '{"v":1,"event":"end","turn":%s,"stop_reason":"error","text":""}\n' \ + "$(printf %s "$TURN" | json_escape)" + exit 0 +} +T=$(printf %s "$REPLY" | json_escape) +TU=$(printf %s "$TURN" | json_escape) +printf '{"v":1,"event":"assistant","turn":%s,"message_id":"m1","text":%s}\n' "$TU" "$T" +printf '{"v":1,"event":"end","turn":%s,"stop_reason":"complete","text":%s}\n' "$TU" "$T" +``` + +`meta`, `health`, `config-get`/`config-set`, and `restart` are each a few lines of +shell (`cat` a JSON heredoc, `pgrep`, `cat`/`tee` the config path, `s6-svc -r`). The +`agent/template/` directory ships a complete copy-me implementation plus +`shim-selftest`, a conformance script that exercises every verb and validates the JSONL +output; run it in CI for every agent image.