diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ed3ee204b..8a435c1a9 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -365,6 +365,7 @@ jobs: - skills-init - acp-sandbox-hermes - acp-sandbox-openclaw + - acp-sandbox-claude runs-on: ubuntu-latest services: registry: diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index a0f6bc42e..5d714f4d6 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -25,6 +25,7 @@ jobs: - skills-init - acp-sandbox-hermes - acp-sandbox-openclaw + - acp-sandbox-claude runs-on: ubuntu-latest permissions: contents: read diff --git a/Makefile b/Makefile index 59e532ace..715547634 100644 --- a/Makefile +++ b/Makefile @@ -59,6 +59,7 @@ SKILLS_INIT_IMAGE_NAME ?= skills-init ACP_SANDBOX_BASE_IMAGE_NAME ?= acp-sandbox-base ACP_SANDBOX_HERMES_IMAGE_NAME ?= acp-sandbox-hermes ACP_SANDBOX_OPENCLAW_IMAGE_NAME ?= acp-sandbox-openclaw +ACP_SANDBOX_CLAUDE_IMAGE_NAME ?= acp-sandbox-claude CONTROLLER_IMAGE_TAG ?= $(VERSION) UI_IMAGE_TAG ?= $(VERSION) @@ -82,6 +83,7 @@ SKILLS_INIT_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(SKILLS_INIT_IMAGE_NAME):$ ACP_SANDBOX_BASE_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(ACP_SANDBOX_BASE_IMAGE_NAME):$(ACP_SANDBOX_IMAGE_TAG) ACP_SANDBOX_HERMES_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(ACP_SANDBOX_HERMES_IMAGE_NAME):$(ACP_SANDBOX_IMAGE_TAG) ACP_SANDBOX_OPENCLAW_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(ACP_SANDBOX_OPENCLAW_IMAGE_NAME):$(ACP_SANDBOX_IMAGE_TAG) +ACP_SANDBOX_CLAUDE_IMG ?= $(DOCKER_REGISTRY)/$(DOCKER_REPO)/$(ACP_SANDBOX_CLAUDE_IMAGE_NAME):$(ACP_SANDBOX_IMAGE_TAG) #take from go/go.mod AWK ?= $(shell command -v gawk || command -v awk) @@ -257,6 +259,7 @@ build-img-versions: ## Print the fully-qualified image tags for all components @echo acp-sandbox-base=$(ACP_SANDBOX_BASE_IMG) @echo acp-sandbox-hermes=$(ACP_SANDBOX_HERMES_IMG) @echo acp-sandbox-openclaw=$(ACP_SANDBOX_OPENCLAW_IMG) + @echo acp-sandbox-claude=$(ACP_SANDBOX_CLAUDE_IMG) .PHONY: controller-manifests controller-manifests: ## Regenerate CRD manifests and copy them into the Helm chart @@ -330,8 +333,8 @@ build-skills-init: buildx-create $(DOCKER_PUSH) $(SKILLS_INIT_IMG) .PHONY: build-acp-sandbox -build-acp-sandbox: ## Build and push all ACP sandbox agent images (hermes, openclaw) -build-acp-sandbox: build-acp-sandbox-hermes build-acp-sandbox-openclaw +build-acp-sandbox: ## Build and push all ACP sandbox agent images (hermes, openclaw, claude) +build-acp-sandbox: build-acp-sandbox-hermes build-acp-sandbox-openclaw build-acp-sandbox-claude .PHONY: build-acp-sandbox-base build-acp-sandbox-base: ## Build and push the ACP sandbox base image (acp-shim only, no agent) @@ -351,6 +354,12 @@ build-acp-sandbox-openclaw: buildx-create $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --target openclaw -t $(ACP_SANDBOX_OPENCLAW_IMG) -f docker/acp-sandbox/Dockerfile ./go $(DOCKER_PUSH) $(ACP_SANDBOX_OPENCLAW_IMG) +.PHONY: build-acp-sandbox-claude +build-acp-sandbox-claude: ## Build and push the ACP sandbox Claude image +build-acp-sandbox-claude: buildx-create + $(DOCKER_BUILDER) $(DOCKER_BUILD_ARGS) $(TOOLS_IMAGE_BUILD_ARGS) --target claude -t $(ACP_SANDBOX_CLAUDE_IMG) -f docker/acp-sandbox/Dockerfile ./go + $(DOCKER_PUSH) $(ACP_SANDBOX_CLAUDE_IMG) + .PHONY: push push: ## Push all component images (controller, ui, app, ADKs) push: push-controller push-ui push-app push-kagent-adk push-golang-adk push-golang-adk-full diff --git a/docker/acp-sandbox/Dockerfile b/docker/acp-sandbox/Dockerfile index b99c3acc5..8611a0d55 100644 --- a/docker/acp-sandbox/Dockerfile +++ b/docker/acp-sandbox/Dockerfile @@ -10,6 +10,7 @@ # # docker build -f docker/acp-sandbox/Dockerfile --target hermes go/ # docker build -f docker/acp-sandbox/Dockerfile --target openclaw go/ +# docker build -f docker/acp-sandbox/Dockerfile --target claude go/ # # The contract between base and agent layers is intentionally tiny: # - ENTRYPOINT is acp-shim; it serves ws://0.0.0.0:9000/acp. @@ -132,3 +133,21 @@ COPY --chmod=755 \ USER agent ENTRYPOINT ["/usr/local/bin/openclaw-acp-entrypoint.sh"] CMD [] + +### Agent target: Claude — Anthropic's Claude Agent SDK exposed over ACP via +### the community adapter (@agentclientprotocol/claude-agent-acp, which wraps +### @anthropic-ai/claude-agent-sdk). Built on node-base because the adapter +### requires Node >=22 (trixie apt ships v20). Unlike openclaw it needs NO +### sandbox-local gateway: `claude-agent-acp` is a stdio ACP server, so the +### shim runs it directly as its child. +FROM node-base AS claude +ARG CLAUDE_AGENT_ACP_VERSION=0.58.1 +USER root +RUN npm install -g "@agentclientprotocol/claude-agent-acp@${CLAUDE_AGENT_ACP_VERSION}" +USER agent +# claude-agent-acp holds ACP session state in memory, so (like hermes) the +# child MUST be long-lived — the shim's default reconnect-grace=0 keeps it +# alive across client reconnects. Requires ANTHROPIC_API_KEY injected at +# runtime (credential injection belongs to the harness bootstrap, not this +# image). +CMD ["--", "claude-agent-acp"] diff --git a/docker/acp-sandbox/README.md b/docker/acp-sandbox/README.md index 44851d121..cfb701c0f 100644 --- a/docker/acp-sandbox/README.md +++ b/docker/acp-sandbox/README.md @@ -18,6 +18,7 @@ The [Dockerfile](Dockerfile) defines: | `node` / `node-base` | internal | `base` + Node.js 22 (trixie apt ships v20, below OpenClaw's >=22.19 requirement). | | `hermes` | `--target hermes` | `base` + Hermes installed via pip (`hermes-agent[acp]`). Child command: `hermes acp`. | | `openclaw` | `--target openclaw` | `node-base` + the OpenClaw CLI (`npm install -g openclaw`). Runs a sandbox-local `openclaw gateway` alongside the shim via a small launcher. | +| `claude` | `--target claude` | `node-base` + the Claude Agent ACP adapter (`npm install -g @agentclientprotocol/claude-agent-acp`, wrapping `@anthropic-ai/claude-agent-sdk`). Child command: `claude-agent-acp`. No gateway — `claude-agent-acp` communicates with the shim over stdio; the shim still exposes `ws://0.0.0.0:9000/acp` externally. Requires `ANTHROPIC_API_KEY` at runtime. | The base↔agent contract is intentionally tiny: @@ -44,6 +45,7 @@ From the repo root (build context is `go/`): docker build -f docker/acp-sandbox/Dockerfile --target base -t kagent/acp-sandbox-base go/ docker build -f docker/acp-sandbox/Dockerfile --target hermes -t kagent/acp-sandbox-hermes go/ docker build -f docker/acp-sandbox/Dockerfile --target openclaw -t kagent/acp-sandbox-openclaw go/ +docker build -f docker/acp-sandbox/Dockerfile --target claude -t kagent/acp-sandbox-claude go/ ``` ## Smoke test (no cluster needed) @@ -58,6 +60,19 @@ websocat ws://localhost:9000/acp {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} ``` +The `claude` target additionally needs an Anthropic API key at runtime — pass +`-e ANTHROPIC_API_KEY=sk-...` on `docker run`, then use the same `websocat` / +`initialize` handshake: + +```sh +docker run --rm -p 9000:9000 -e ANTHROPIC_API_KEY=sk-... kagent/acp-sandbox-claude +# then from another shell, speak newline-delimited JSON-RPC over WS: +websocat ws://localhost:9000/acp +{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} +``` + +> **Note:** A successful `initialize` response only verifies the ACP transport layer. It does not confirm that prompts reach the model — see the Claude open item in [Open items](#open-items-tracked-in-the-ep) below. + The shim does not authenticate the WebSocket handshake; in Substrate the actor's ingress is its only reachable surface and the controller proxies to it. @@ -92,3 +107,10 @@ controller. Each file's header comment has the full step-by-step; in short: belongs to the harness bootstrap, not these images. - Whether the shim is baked (this approach) or injected via init container + shared volume. +- Claude target: `@agentclientprotocol/claude-agent-acp` is community-maintained + and was recently renamed from `@zed-industries/claude-code-acp`. The ACP + `initialize` handshake is verified at the pinned version (0.58.1) — it reports + `protocolVersion: 1`, `agentInfo.version: "0.58.1"`, and `authMethods: []` (so + it authenticates from `ANTHROPIC_API_KEY` with no separate `authenticate` + step). Still to verify: a full `session/prompt` round-trip reaches the model, + and the child's in-memory session model holds across bridge reconnects.