Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/server-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,13 @@ jobs:
node patch-adapter.mjs
echo "AGENT_PI_TEST_RUNTIME=$PWD" >> "$GITHUB_ENV"

- name: Install pinned Claude reference runtime
working-directory: server/runtime/acp/claude
run: |
bun install --frozen-lockfile --ignore-scripts
node --test *.test.mjs
echo "AGENT_CLAUDE_TEST_RUNTIME=$PWD" >> "$GITHUB_ENV"

# categorygen's checks (unclassified route, category that isn't control or
# platform, classified route with no handler) only run when the generator
# does, and its only other caller is `make oapi-generate`, which needs the
Expand Down
2 changes: 2 additions & 0 deletions images/chromium-headful/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,8 @@ COPY server/runtime/acp/requirements.txt /opt/kernel-agent/requirements.txt
RUN uv pip install --python /opt/kernel-agent/venv/bin/python -r /opt/kernel-agent/requirements.txt
COPY server/runtime/acp/pi /opt/kernel-agent/pi
RUN cd /opt/kernel-agent/pi && bun install --frozen-lockfile --ignore-scripts && node patch-adapter.mjs
COPY server/runtime/acp/claude /opt/kernel-agent/claude
RUN cd /opt/kernel-agent/claude && bun install --frozen-lockfile --ignore-scripts
COPY server/runtime/acp/catalog.json /opt/kernel-agent/catalog.json

FROM node:22-bullseye-slim AS node-22
Expand Down
2 changes: 2 additions & 0 deletions images/chromium-headless/image/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,8 @@ COPY server/runtime/acp/requirements.txt /opt/kernel-agent/requirements.txt
RUN uv pip install --python /opt/kernel-agent/venv/bin/python -r /opt/kernel-agent/requirements.txt
COPY server/runtime/acp/pi /opt/kernel-agent/pi
RUN cd /opt/kernel-agent/pi && bun install --frozen-lockfile --ignore-scripts && node patch-adapter.mjs
COPY server/runtime/acp/claude /opt/kernel-agent/claude
RUN cd /opt/kernel-agent/claude && bun install --frozen-lockfile --ignore-scripts
COPY server/runtime/acp/catalog.json /opt/kernel-agent/catalog.json

FROM node:22-bullseye-slim AS node-22
Expand Down
8 changes: 5 additions & 3 deletions server/lib/agentproxy/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# ACP agents

The browser images bundle a pinned Pi reference implementation. Kernel manages
The browser images bundle pinned Pi and [Claude](../../runtime/acp/claude/README.md)
reference implementations. The Pi configuration is described below; Claude has its
own native configuration contract. Kernel manages
configuration preparation and connection lifetime; ACP owns conversations.
There is no runtime resource, conversation REST API, prompt journal, automatic
prompt retry, or session-ID translation in the WebSocket proxy.
Expand All @@ -9,7 +11,7 @@ prompt retry, or session-ID translation in the WebSocket proxy.

| Endpoint | Behavior |
| --- | --- |
| `GET /agent/v1/harnesses` | Returns configured harness names, currently `{"configured":["pi"]}` in the packaged images. This does not mean a model credential is configured. |
| `GET /agent/v1/harnesses` | Returns configured harness names, currently `{"configured":["claude","pi"]}` in the packaged images. This does not mean a model credential is configured. |
| `GET /agent/v1/harnesses/pi/config` | Returns desired/effective configuration, revisions, preparation status and an ETag. |
| `PUT /agent/v1/harnesses/pi/config` | Validates, installs and checks the requested configuration, then activates it. Requires `If-Match` from GET. |
| WebSocket `GET /agent/v1/acp?harness=pi` | Starts a connection-owned `acpremote expose` bridge and Pi adapter using the last ready launch definition. |
Expand Down Expand Up @@ -200,7 +202,7 @@ credential bindings and an optional npm
`registry`. The default state directory is `/home/kernel/.agents/pi`.

The original trusted `harnesses` launch catalog remains supported for separately
provisioned agents. Only Pi has a packaged declarative preparer here. The
provisioned agents. Pi and Claude have separate packaged declarative preparers. The
`Preparer` interface and common revision manager are the implementation boundary
for subsequent harnesses; their native configuration support must be explicit.
Gemini's future integration excludes reconnect/discovery/load until its ACP
Expand Down
134 changes: 134 additions & 0 deletions server/lib/agentproxy/claude.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package agentproxy

import (
"context"
"encoding/json"
"errors"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"syscall"
"time"
)

type ClaudeOptions struct {
StateDir string `json:"stateDir"`
RuntimeDir string `json:"runtimeDir"`
Node string `json:"node"`
Credentials map[string]string `json:"credentials"`
}

type ClaudeConfiguration struct {
Launch ClaudeLaunch `json:"launch"`
Shared ClaudeShared `json:"shared"`
}

type ClaudeLaunch struct {
Model string `json:"model"`
Credential string `json:"credential"`
}

type ClaudeShared struct {
Settings ClaudeSettings `json:"settings"`
MCPServers []ManagedMCPServer `json:"mcpServers"`
}

type ClaudeSettings struct {
Language string `json:"language,omitempty"`
AlwaysThinkingEnabled *bool `json:"alwaysThinkingEnabled,omitempty"`
}

var claudeEnvName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)

func (p ClaudeOptions) validate() error {
if !filepath.IsAbs(p.StateDir) || !filepath.IsAbs(p.RuntimeDir) || !filepath.IsAbs(p.Node) {
return errors.New("claude paths must be absolute")
}
for name, source := range p.Credentials {
if name == "" || !claudeEnvName.MatchString(source) || !validEnvName(source) {
return errors.New("invalid claude credential binding")
}
}
return nil
}

func (p ClaudeOptions) validateDesired(c ClaudeConfiguration) error {
if !strings.HasPrefix(c.Launch.Model, "claude-") || len(c.Launch.Model) > 128 || strings.ContainsAny(c.Launch.Model, "\x00\r\n ") {
return errors.New("a native Anthropic claude model ID is required")
}
if len(c.Shared.Settings.Language) > 128 || strings.ContainsAny(c.Shared.Settings.Language, "\x00\r\n") {
return errors.New("invalid claude language setting")
}
bindings, err := validateMCPServers(c.Shared.MCPServers)
if err != nil {
return err
}
for _, s := range c.Shared.MCPServers {
if s.Command == "" {
return errors.New("claude shared MCP currently supports stdio only")
}
for name := range s.EnvBindings {
if !claudeEnvName.MatchString(name) {
return errors.New("invalid MCP environment name")
}
}
}
for _, binding := range append(bindings, c.Launch.Credential) {
source, ok := p.Credentials[binding]
if !ok || os.Getenv(source) == "" {
return errors.New("credential binding is unavailable")
}
}
return nil
}

func (p ClaudeOptions) Prepare(ctx context.Context, dir string, desired json.RawMessage) (Harness, error) {
var c ClaudeConfiguration
if err := json.Unmarshal(desired, &c); err != nil {
return Harness{}, err
}
if err := p.validateDesired(c); err != nil {
return Harness{}, err
}
if err := atomicWrite(filepath.Join(dir, "config.json"), desired); err != nil {
return Harness{}, err
}
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
command := exec.CommandContext(ctx, p.Node, filepath.Join(p.RuntimeDir, "prepare.mjs"), dir)
command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "CLAUDE_CONFIG_DIR=" + dir}
command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) }
command.WaitDelay = time.Second
err := command.Run()
if command.Process != nil {
_ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
}
if err != nil {
return Harness{}, errors.New("claude native preparation or validation failed")
}
refs := map[string]string{c.Launch.Credential: p.Credentials[c.Launch.Credential]}
for _, s := range c.Shared.MCPServers {
for _, binding := range s.EnvBindings {
refs[binding] = p.Credentials[binding]
}
}
bindings, _ := json.Marshal(refs)
inherited := make([]string, 0, len(refs))
for _, source := range refs {
inherited = append(inherited, source)
}
return Harness{
Command: p.Node,
Args: []string{filepath.Join(p.RuntimeDir, "launch.mjs"), filepath.Join(dir, "config.json")},
Cwd: p.StateDir,
Env: map[string]string{
"HOME": filepath.Join(p.StateDir, "home"),
"CLAUDE_CONFIG_DIR": filepath.Join(p.StateDir, "native"),
"KERNEL_CLAUDE_BINDINGS": string(bindings),
},
InheritEnv: inherited,
}, nil
}
61 changes: 61 additions & 0 deletions server/lib/agentproxy/claude_http.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package agentproxy

import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
)

func (h *Handler) claudeConfiguration(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPut {
expected := r.Header.Get("If-Match")
if expected == "" {
http.Error(w, "If-Match revision required", http.StatusPreconditionRequired)
return
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
var desired ClaudeConfiguration
if err := decoder.Decode(&desired); err != nil {
http.Error(w, "invalid claude configuration", http.StatusBadRequest)
return
}
var extra any
if err := decoder.Decode(&extra); err != io.EOF {
http.Error(w, "expected one configuration", http.StatusBadRequest)
return
}
if err := h.config.Claude.validateDesired(desired); err != nil {
http.Error(w, err.Error(), http.StatusUnprocessableEntity)
return
}
if desired.Shared.MCPServers == nil {
desired.Shared.MCPServers = make([]ManagedMCPServer, 0)
}
data, _ := json.Marshal(desired)
ctx, cancel := context.WithCancel(r.Context())
stop := context.AfterFunc(h.ctx, cancel)
defer stop()
defer cancel()
if err := h.claude.apply(ctx, strings.Trim(expected, "\""), data); err != nil {
if errors.Is(err, errConfigurationConflict) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
http.Error(w, "configuration preparation failed; inspect GET configuration status", http.StatusUnprocessableEntity)
return
}
} else if r.Method != http.MethodGet {
w.Header().Set("Allow", "GET, PUT")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
state := h.claude.snapshot()
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("ETag", "\""+state.Revision+"\"")
_ = json.NewEncoder(w).Encode(state)
}
Loading