From 77d625cc865c2effb0d4cc0f6b340c1a2028d317 Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:34:35 +0000 Subject: [PATCH 1/2] Add a packaged Claude ACP reference harness --- .github/workflows/server-test.yaml | 7 + images/chromium-headful/Dockerfile | 2 + images/chromium-headless/image/Dockerfile | 2 + server/lib/agentproxy/README.md | 8 +- server/lib/agentproxy/claude.go | 134 ++++++++++ server/lib/agentproxy/claude_http.go | 61 +++++ server/lib/agentproxy/claude_test.go | 199 +++++++++++++++ server/lib/agentproxy/config.go | 8 +- server/lib/agentproxy/handler.go | 23 ++ server/lib/agentproxy/testdata/claude_gate.py | 212 ++++++++++++++++ server/runtime/acp/catalog.json | 6 + server/runtime/acp/claude/README.md | 154 ++++++++++++ server/runtime/acp/claude/bun.lock | 238 ++++++++++++++++++ server/runtime/acp/claude/launch.mjs | 46 ++++ server/runtime/acp/claude/mcp-command.mjs | 24 ++ server/runtime/acp/claude/native-command | 2 + server/runtime/acp/claude/native.mjs | 38 +++ server/runtime/acp/claude/package.json | 9 + server/runtime/acp/claude/prepare.mjs | 33 +++ server/runtime/acp/claude/runtime.test.mjs | 113 +++++++++ 20 files changed, 1315 insertions(+), 4 deletions(-) create mode 100644 server/lib/agentproxy/claude.go create mode 100644 server/lib/agentproxy/claude_http.go create mode 100644 server/lib/agentproxy/claude_test.go create mode 100644 server/lib/agentproxy/testdata/claude_gate.py create mode 100644 server/runtime/acp/claude/README.md create mode 100644 server/runtime/acp/claude/bun.lock create mode 100644 server/runtime/acp/claude/launch.mjs create mode 100644 server/runtime/acp/claude/mcp-command.mjs create mode 100755 server/runtime/acp/claude/native-command create mode 100644 server/runtime/acp/claude/native.mjs create mode 100644 server/runtime/acp/claude/package.json create mode 100644 server/runtime/acp/claude/prepare.mjs create mode 100644 server/runtime/acp/claude/runtime.test.mjs diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index 6517315c..e3666a86 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -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 diff --git a/images/chromium-headful/Dockerfile b/images/chromium-headful/Dockerfile index 57db1582..51436710 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -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 diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index 6cb69c77..ea822a0d 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -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 diff --git a/server/lib/agentproxy/README.md b/server/lib/agentproxy/README.md index 34951f58..7f37d5c6 100644 --- a/server/lib/agentproxy/README.md +++ b/server/lib/agentproxy/README.md @@ -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. @@ -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. | @@ -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 diff --git a/server/lib/agentproxy/claude.go b/server/lib/agentproxy/claude.go new file mode 100644 index 00000000..005426ce --- /dev/null +++ b/server/lib/agentproxy/claude.go @@ -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 +} diff --git a/server/lib/agentproxy/claude_http.go b/server/lib/agentproxy/claude_http.go new file mode 100644 index 00000000..0b771cbf --- /dev/null +++ b/server/lib/agentproxy/claude_http.go @@ -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) +} diff --git a/server/lib/agentproxy/claude_test.go b/server/lib/agentproxy/claude_test.go new file mode 100644 index 00000000..6d15d6d1 --- /dev/null +++ b/server/lib/agentproxy/claude_test.go @@ -0,0 +1,199 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestClaudeConfigurationHTTP(t *testing.T) { + t.Setenv("CLAUDE_TEST_KEY", "private-provider-key") + p := &ClaudeOptions{StateDir: t.TempDir(), RuntimeDir: "/runtime/claude", Node: "/bin/node", Credentials: map[string]string{"provider": "CLAUDE_TEST_KEY"}} + h, err := New(context.Background(), Config{ACPRemote: "/bin/acpremote", MaxConnections: 1, Claude: p}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + t.Fatal(err) + } + h.claude.preparer = prepareFunc(func(_ context.Context, dir string, _ json.RawMessage) (Harness, error) { + return Harness{Command: "/bin/true", Cwd: dir}, nil + }) + request := func(method, path, body, etag string) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + if etag != "" { + r.Header.Set("If-Match", etag) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w + } + path := "/agent/v1/harnesses/claude/config" + if w := request("GET", "/agent/v1/harnesses", "", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"claude"`) { + t.Fatal(w) + } + if w := request("GET", "/agent/v1/acp?harness=claude", "", ""); w.Code != 409 { + t.Fatal(w) + } + if w := request("GET", path, "", ""); w.Header().Get("ETag") != `"0"` { + t.Fatal(w) + } + body := `{"launch":{"model":"claude-haiku-4-5-20251001","credential":"provider"},"shared":{}}` + for _, tc := range []struct { + body, etag string + code int + }{ + {body, "", 428}, {strings.Replace(body, `"shared":{}`, `"shared":{"extensions":[]}`, 1), `"0"`, 400}, + {strings.Replace(body, "provider", "missing", 1), `"0"`, 422}, {body + " {}", `"0"`, 400}, + } { + if w := request("PUT", path, tc.body, tc.etag); w.Code != tc.code { + t.Fatal(w.Code, w.Body.String()) + } + } + ready := request("PUT", path, body, `"0"`) + if ready.Code != 200 || !strings.Contains(ready.Body.String(), `"mcpServers":[]`) { + t.Fatal(ready) + } + if w := request("PUT", path, body, `"0"`); w.Code != 409 { + t.Fatal(w) + } + h.claude.preparer = prepareFunc(func(context.Context, string, json.RawMessage) (Harness, error) { + return Harness{}, errors.New("private-provider-key") + }) + if w := request("PUT", path, body, ready.Header().Get("ETag")); w.Code != 422 || strings.Contains(w.Body.String(), "private-provider-key") { + t.Fatal(w) + } + state := request("GET", path, "", "") + if !strings.Contains(state.Body.String(), `"status":"failed"`) || strings.Contains(state.Body.String(), "private-provider-key") || strings.Contains(state.Body.String(), "CLAUDE_TEST_KEY") { + t.Fatal(state) + } + if _, ok := h.claude.preparedLaunch(); !ok { + t.Fatal("failed preparation removed last ready launch") + } + if w := request(http.MethodDelete, path, "", ""); w.Code != 405 { + t.Fatal(w) + } +} + +func TestClaudePreparesNativeSettings(t *testing.T) { + runtime := os.Getenv("AGENT_CLAUDE_TEST_RUNTIME") + if runtime == "" { + t.Skip("set AGENT_CLAUDE_TEST_RUNTIME to installed Claude runtime") + } + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_TEST_KEY", "not-a-real-key") + p := ClaudeOptions{StateDir: t.TempDir(), RuntimeDir: runtime, Node: node, Credentials: map[string]string{"provider": "CLAUDE_TEST_KEY"}} + m, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + data := json.RawMessage(`{"launch":{"model":"claude-haiku-4-5-20251001","credential":"provider"},"shared":{"settings":{"language":"English","alwaysThinkingEnabled":false},"mcpServers":[]}}`) + if err = m.apply(context.Background(), "0", data); err != nil { + t.Fatal(err) + } + ready := m.snapshot() + launch, _ := m.preparedLaunch() + if launch.Env["CLAUDE_CONFIG_DIR"] != filepath.Join(p.StateDir, "native") { + t.Fatal(launch) + } + if _, err = os.Stat(filepath.Join(p.StateDir, "native")); !errors.Is(err, os.ErrNotExist) { + t.Fatal("preparation wrote native state") + } + for _, name := range []string{"settings.json", "config.json", "prepared.json"} { + path := filepath.Join(p.StateDir, "current", name) + info, err := os.Stat(path) + if err != nil || info.Mode().Perm() != 0600 { + t.Fatal(name, err) + } + contents, _ := os.ReadFile(path) + if strings.Contains(string(contents), "not-a-real-key") { + t.Fatal("persisted secret") + } + } + p.Node = "/bin/false" + m.preparer = p + if err = m.apply(context.Background(), ready.Revision, data); err == nil { + t.Fatal("broken runtime accepted") + } + recovered, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + if state := recovered.snapshot(); state.Status != "failed" || state.EffectiveRevision != ready.Revision || state.Revision == ready.Revision { + t.Fatal(state) + } + entries, _ := os.ReadDir(filepath.Join(p.StateDir, "revisions")) + if len(entries) != 1 { + t.Fatal("failed preparation directory retained") + } +} + +func TestClaudeNativeInitializeAndList(t *testing.T) { + runtime, remote := os.Getenv("AGENT_CLAUDE_TEST_RUNTIME"), os.Getenv("AGENT_PROXY_TEST_ACPREMOTE") + if runtime == "" || remote == "" { + t.Skip("set AGENT_CLAUDE_TEST_RUNTIME and AGENT_PROXY_TEST_ACPREMOTE for native ACP verification") + } + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + t.Setenv("CLAUDE_TEST_KEY", "fixture-not-a-provider-key") + p := &ClaudeOptions{StateDir: t.TempDir(), RuntimeDir: runtime, Node: node, Credentials: map[string]string{"provider": "CLAUDE_TEST_KEY"}} + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + h, err := New(ctx, Config{ACPRemote: remote, MaxConnections: 2, Claude: p}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + t.Fatal(err) + } + data := json.RawMessage(`{"launch":{"model":"claude-haiku-4-5-20251001","credential":"provider"},"shared":{"settings":{},"mcpServers":[]}}`) + if err := h.claude.apply(ctx, "0", data); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(h) + t.Cleanup(func() { cancel(); server.Close() }) + for range 2 { + conn := connect(t, "ws"+strings.TrimPrefix(server.URL, "http")+"/agent/v1/acp?harness=claude") + response, _ := call(t, conn, 1, "initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}}) + var initialized struct { + AgentInfo struct { + Version string `json:"version"` + } `json:"agentInfo"` + } + if err := json.Unmarshal(response, &initialized); err != nil || initialized.AgentInfo.Version != "0.75.1" { + t.Fatalf("unexpected initialize: %s", response) + } + response, _ = call(t, conn, 2, "session/list", map[string]any{}) + if !strings.Contains(string(response), `"sessions":[]`) { + t.Fatalf("unexpected session list: %s", response) + } + conn.CloseNow() + eventually(t, func() bool { return len(h.slots) == 0 }) + } +} + +func TestClaudeRejectsUnsupportedConfiguration(t *testing.T) { + t.Setenv("CLAUDE_TEST_KEY", "fixture") + p := ClaudeOptions{Credentials: map[string]string{"provider": "CLAUDE_TEST_KEY"}} + c := ClaudeConfiguration{Launch: ClaudeLaunch{Model: "claude-haiku-4-5-20251001", Credential: "provider"}} + if err := p.validateDesired(c); err != nil { + t.Fatal(err) + } + c.Shared.MCPServers = []ManagedMCPServer{{Name: "http", URL: "https://example.com/mcp", Transport: "http"}} + if err := p.validateDesired(c); err == nil { + t.Fatal("unvalidated HTTP MCP accepted") + } + c.Shared.MCPServers = nil + c.Launch.Model = "bedrock-model" + if err := p.validateDesired(c); err == nil { + t.Fatal("unsupported provider accepted") + } +} diff --git a/server/lib/agentproxy/config.go b/server/lib/agentproxy/config.go index 40d3ebf6..4c8953eb 100644 --- a/server/lib/agentproxy/config.go +++ b/server/lib/agentproxy/config.go @@ -19,6 +19,7 @@ type Config struct { MaxConnections int `json:"maxConnections"` Harnesses map[string]Harness `json:"harnesses"` Pi *PiOptions `json:"pi,omitempty"` + Claude *ClaudeOptions `json:"claude,omitempty"` } type Harness struct { @@ -70,7 +71,12 @@ func (c Config) validate() error { return err } } - if len(c.Harnesses) == 0 && c.Pi == nil { + if c.Claude != nil { + if err := c.Claude.validate(); err != nil { + return err + } + } + if len(c.Harnesses) == 0 && c.Pi == nil && c.Claude == nil { return errors.New("at least one harness is required") } for name, harness := range c.Harnesses { diff --git a/server/lib/agentproxy/handler.go b/server/lib/agentproxy/handler.go index d294cda2..85212ddf 100644 --- a/server/lib/agentproxy/handler.go +++ b/server/lib/agentproxy/handler.go @@ -21,6 +21,7 @@ type Handler struct { registry *wsdrain.Registry slots chan struct{} pi *configurationManager + claude *configurationManager } func New(ctx context.Context, config Config, logger *slog.Logger, registry *wsdrain.Registry) (*Handler, error) { @@ -35,6 +36,13 @@ func New(ctx context.Context, config Config, logger *slog.Logger, registry *wsdr return nil, err } } + if config.Claude != nil { + var err error + h.claude, err = newConfigurationManager(config.Claude.StateDir, *config.Claude) + if err != nil { + return nil, err + } + } return h, nil } @@ -45,7 +53,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { if h.pi != nil { names = append(names, "pi") } + if h.claude != nil { + names = append(names, "claude") + } for name := range h.config.Harnesses { + if name == "claude" && h.claude != nil { + continue + } if name == "pi" && h.pi != nil { continue } @@ -58,6 +72,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { }{names}) case r.URL.Path == "/agent/v1/harnesses/pi/config" && h.pi != nil: h.piConfiguration(w, r) + case r.URL.Path == "/agent/v1/harnesses/claude/config" && h.claude != nil: + h.claudeConfiguration(w, r) case r.Method == http.MethodGet && r.URL.Path == "/agent/v1/acp": h.connect(w, r) default: @@ -75,6 +91,13 @@ func (h *Handler) connect(w http.ResponseWriter, r *http.Request) { return } } + if name == "claude" && h.claude != nil { + harness, ok = h.claude.preparedLaunch() + if !ok { + http.Error(w, "claude configuration is not ready", http.StatusConflict) + return + } + } if !ok { http.Error(w, "harness is not configured", http.StatusNotFound) return diff --git a/server/lib/agentproxy/testdata/claude_gate.py b/server/lib/agentproxy/testdata/claude_gate.py new file mode 100644 index 00000000..099832d4 --- /dev/null +++ b/server/lib/agentproxy/testdata/claude_gate.py @@ -0,0 +1,212 @@ +"""Bounded real-provider gate for a fresh packaged image with ANTHROPIC_API_KEY. +Run with AGENT_API_URL and the pinned runtime/acp/requirements.txt environment. +Native SDK sessions have a $0.15 budget and at most three turns per prompt. +""" +import asyncio +import base64 +import json +import os +from pathlib import Path +import tempfile +import urllib.error +import urllib.request +import uuid + +import websockets + +os.umask(0o077) +base = os.environ["AGENT_API_URL"].rstrip("/") +workspace = "/tmp/claude-gate-" + uuid.uuid4().hex +config_path = "/agent/v1/harnesses/claude/config" +python = "/opt/kernel-agent/venv/bin/python" +evidence = Path(tempfile.mkdtemp(prefix="claude-gate-")) + + +def http(method, path, data=None, revision=None): + headers = {"Content-Type": "application/json"} + if revision is not None: + headers["If-Match"] = '"' + revision + '"' + request = urllib.request.Request(base + path, method=method, headers=headers, + data=json.dumps(data).encode() if data is not None else None) + try: + with urllib.request.urlopen(request, timeout=200) as response: + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + + +def execute(code): + status, result = http("POST", "/process/exec", {"command": python, "args": ["-c", code]}) + assert status == 200 and result["exit_code"] == 0, "remote fixture command failed" + return base64.b64decode(result["stdout_b64"]).decode() + + +def pids(): + # Record the entire connection-owned process groups, not just the adapter. + return set(json.loads(execute('''import os,pathlib,json +processes={} +for path in pathlib.Path('/proc').iterdir(): + if not path.name.isdigit():continue + try: + args=(path/'cmdline').read_bytes().split(bytes([0])) + processes[int(path.name)]=(os.getpgid(int(path.name)),args) + except (FileNotFoundError,ProcessLookupError,PermissionError):continue +groups={pgid for pgid,args in processes.values() if b'/opt/kernel-agent/claude/node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js' in args} +print(json.dumps([pid for pid,(pgid,args) in processes.items() if pgid in groups])) +'''))) + + +class Client: + def __init__(self): + self.seq = 0 + self.events = [] + + async def open(self): + self.ws = await websockets.connect("ws" + base[4:] + "/agent/v1/acp?harness=claude", max_size=1 << 20) + try: + result, _ = await self.call("initialize", {"protocolVersion": 1, "clientCapabilities": {}}) + assert result["agentInfo"]["version"] == "0.75.1" + assert result["agentCapabilities"]["loadSession"] + except BaseException: + await self.ws.close() + raise + return self + + async def close(self): + await self.ws.close() + + async def call(self, method, params): + self.seq += 1 + await self.ws.send(json.dumps({"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params})) + updates = [] + async with asyncio.timeout(120): + while True: + message = json.loads(await self.ws.recv()) + self.events.append(message) + if message.get("method") == "session/request_permission": + option = next(o for o in message["params"]["options"] if o["kind"] == "allow_once") + await self.ws.send(json.dumps({"jsonrpc": "2.0", "id": message["id"], "result": { + "outcome": {"outcome": "selected", "optionId": option["optionId"]}}})) + elif message.get("id") == self.seq and ("result" in message or "error" in message): + assert "error" not in message, message + return message["result"], updates + else: + updates.append(message) + + async def prompt(self, sid, text): + result, updates = await self.call("session/prompt", {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}) + assert result["stopReason"] == "end_turn", result + return "".join(u.get("params", {}).get("update", {}).get("content", {}).get("text", "") + for u in updates if u.get("params", {}).get("update", {}).get("sessionUpdate") == "agent_message_chunk") + + +async def wait_gone(owned): + for _ in range(12): + states = json.loads(execute( + "import pathlib,json\nresult={}\n" + f"for pid in {sorted(owned)!r}:\n" + " try:result[pid]=pathlib.Path('/proc',str(pid),'stat').read_text().rsplit(')',1)[1].split()[0]\n" + " except FileNotFoundError:pass\nprint(json.dumps(result))" + )) + if all(state == "Z" for state in states.values()): + return len(states) + await asyncio.sleep(1) + raise AssertionError("disconnect left connection-owned processes") + + +async def main(): + status, initial = http("GET", config_path) + assert status == 200 and initial["status"] == "unconfigured", "use a fresh disposable image" + isolation_check = b"import os,sys\nif sys.argv[1] != 'session':\n assert 'ANTHROPIC_API_KEY' not in os.environ\n assert not any(k.startswith('KERNEL_CLAUDE_SECRET_') for k in os.environ)\n" + source = base64.b64encode(isolation_check + Path(__file__).with_name("mcp_checkpoint.py").read_bytes()).decode() + execute(f"import pathlib,base64; p=pathlib.Path({workspace!r}); p.mkdir(); (p/'mcp.py').write_bytes(base64.b64decode({source!r}))") + shared = {"name": "checkpoint", "command": python, "args": [workspace + "/mcp.py", "shared", workspace + "/calls"]} + desired = {"launch": {"model": "claude-haiku-4-5-20251001", "credential": "anthropic"}, + "shared": {"settings": {"language": "English", "alwaysThinkingEnabled": False}, "mcpServers": [shared]}} + status, ready = http("PUT", config_path, desired, initial["revision"]) + assert status == 200, (status, ready) + options = {"systemPrompt": "Follow each user instruction exactly. Keep replies short. Remember conversation details. Use MCP tools only when requested.", + "claudeCode": {"options": {"maxBudgetUsd": 0.15, "maxTurns": 3, "tools": [], "env": {"ENABLE_TOOL_SEARCH": "false"}}}} + clients = [] + summary = {"pass": False} + try: + first = await Client().open() + clients.append(first) + session, _ = await first.call("session/new", {"cwd": workspace, "mcpServers": [], "_meta": options}) + sid = session["sessionId"] + marker = "cobalt-" + uuid.uuid4().hex[:8] + text = await first.prompt(sid, "Remember the checkpoint " + marker + ". Reply only with the checkpoint. Do not use tools.") + assert marker in text, text + print("initial turn passed", flush=True) + text = await first.prompt(sid, "Call the checkpoint MCP tool once and return its output. Do not use other tools.") + assert "shared" in text, text + owned = pids() + assert owned + second = await Client().open() + clients.append(second) + custom = dict(shared, args=[workspace + "/mcp.py", "session", workspace + "/calls"], env=[]) + other, _ = await second.call("session/new", {"cwd": workspace, "mcpServers": [custom], "_meta": options}) + assert other["sessionId"] != sid and len(pids()) > len(owned) + text = await second.prompt(other["sessionId"], "Call the checkpoint MCP tool once and return its output. Do not use other tools.") + assert "session" in text, text + updated = json.loads(json.dumps(desired)) + updated["shared"]["mcpServers"][0]["args"][1] = "updated-shared" + active = pids() + status, latest = await asyncio.to_thread(http, "PUT", config_path, updated, ready["revision"]) + assert status == 200 and active == pids(), "activation changed existing connections" + assert http("PUT", config_path, desired, ready["revision"])[0] == 409 + broken = json.loads(json.dumps(updated)) + broken["shared"]["mcpServers"][0]["command"] = workspace + "/missing-command" + status, _ = await asyncio.to_thread(http, "PUT", config_path, broken, latest["revision"]) + assert status == 422 + status, failed = http("GET", config_path) + assert status == 200 and failed["status"] == "failed" + assert failed["effectiveRevision"] == latest["revision"] and failed["desired"] == broken + assert active == pids(), "failed preparation changed existing connections" + await first.close() + zombies = await wait_gone(owned) + text = await second.prompt(other["sessionId"], "Reply only OK. Do not use tools.") + assert "OK" in text, text + fresh = await Client().open() + clients.append(fresh) + cursor = None + seen = set() + listed = [] + while True: + params = {"cwd": workspace} + if cursor is not None: + params["cursor"] = cursor + page, _ = await fresh.call("session/list", params) + listed.extend(page["sessions"]) + cursor = page.get("nextCursor") + if not cursor: + break + assert cursor not in seen + seen.add(cursor) + assert any(s["sessionId"] == sid for s in listed) + loaded, history = await fresh.call("session/load", {"sessionId": sid, "cwd": workspace, "mcpServers": [], "_meta": options}) + assert marker in json.dumps(history), "native history not replayed" + original_model = next(o["currentValue"] for o in session["configOptions"] if o["id"] == "model") + loaded_model = next(o["currentValue"] for o in loaded["configOptions"] if o["id"] == "model") + assert loaded_model == original_model == "haiku", loaded_model + text = await fresh.prompt(sid, "What was the checkpoint in our first exchange? Reply only with it. Do not use tools.") + assert marker in text, text + text = await fresh.prompt(sid, "Call the checkpoint MCP tool once and return its output. Do not use other tools.") + assert "updated-shared" in text, text + summary = {"pass": True, "sessionId": sid, "initialTurn": True, "sharedMCP": True, + "sessionMCPOverride": True, "independentProcessTrees": True, "disconnectCleanup": True, + "activationKeepsConnections": True, "staleWriteRejected": True, "failedPreparationRetainsReady": True, + "freshListLoadHistoryAndRecall": True, "modelRestored": True, + "listPages": len(seen) + 1, "updatedMCPOnLoad": True, "unreapedZombiesAfterFirstDisconnect": zombies} + finally: + all_owned = pids() + for client in clients: + await client.close() + summary["unreapedZombiesAfterFinalDisconnect"] = await wait_gone(all_owned) + (evidence / "summary.json").write_text(json.dumps(summary, indent=2)) + (evidence / "events.json").write_text(json.dumps([c.events for c in clients], indent=2)) + print(json.dumps(summary), flush=True) + + +asyncio.run(main()) +print("evidence:", evidence) diff --git a/server/runtime/acp/catalog.json b/server/runtime/acp/catalog.json index eb9d6fe7..8148e52e 100644 --- a/server/runtime/acp/catalog.json +++ b/server/runtime/acp/catalog.json @@ -2,6 +2,12 @@ "acpremote": "/opt/kernel-agent/venv/bin/acpremote", "maxConnections": 8, "harnesses": {}, + "claude": { + "stateDir": "/home/kernel/.agents/claude", + "runtimeDir": "/opt/kernel-agent/claude", + "node": "/usr/local/bin/node", + "credentials": { "anthropic": "ANTHROPIC_API_KEY" } + }, "pi": { "stateDir": "/home/kernel/.agents/pi", "runtimeDir": "/opt/kernel-agent/pi", diff --git a/server/runtime/acp/claude/README.md b/server/runtime/acp/claude/README.md new file mode 100644 index 00000000..58e75deb --- /dev/null +++ b/server/runtime/acp/claude/README.md @@ -0,0 +1,154 @@ +# Claude ACP reference + +The packaged `claude` harness uses the same Preparer, revision manager and +connection-owned ACP transport as Pi. It does not adapt Pi extensions or parse +ACP conversation messages. Both browser images install this runtime. + +## Configure + +Inject `ANTHROPIC_API_KEY`, GET `/agent/v1/harnesses/claude/config`, then PUT +with the returned ETag in `If-Match`: + +```json +{ + "launch": { + "model": "claude-haiku-4-5-20251001", + "credential": "anthropic" + }, + "shared": { + "settings": {"language": "English", "alwaysThinkingEnabled": false}, + "mcpServers": [] + } +} +``` + +Connect to `/agent/v1/acp?harness=claude`. ACP owns initialize/authenticate, +sessions, prompts, permissions, cancellation, history and model controls. The +pinned adapter exposes model selection through `configOptions` (Haiku's native +selection ID is `haiku`), not a legacy `models` response. Native adapter extensions +remain native; Kernel adds no conversation protocol or prompt retry. + +`launch.credential` names an operator binding, never a key value. The packaged +catalog maps `anthropic` to `ANTHROPIC_API_KEY`; operators can replace this mapping. +The launcher copies only referenced credentials into its private environment and +sets the native `ANTHROPIC_API_KEY` and `ANTHROPIC_MODEL`. Ambient gateway URLs, +OAuth tokens and third-party-provider switches are not inherited. Direct Anthropic +API-key authentication is validated; OAuth, gateway auth, Bedrock, Vertex and other +providers are not managed configuration options. + +Desired/effective revisions, failure retention, optimistic concurrency and response +status codes are the same as the [shared contract](../../../lib/agentproxy/README.md). +Unknown fields, including Pi `extensions`, `provider` and `thinking`, are rejected. +Configuration responses and revision files contain binding names, never resolved +credential values. Preparation checks the pinned native binary, native settings +resolution and executable shared MCP paths without provider credentials or MCP +startup. Ready does not mean provider authentication or MCP connectivity succeeded. + +## Native shared settings and MCP + +`shared.settings` supports native `language` and `alwaysThinkingEnabled`. These +settings are validated with the pinned SDK resolver and passed to the native CLI +as a revision-local `--settings` file. Arbitrary environment settings, hooks, +plugins, skills, commands, marketplaces and Pi npm extensions are **unsupported** +by this preparer. No extension installation is implied by accepting a revision. + +Shared MCP supports stdio only, using the shared server schema: + +```json +{ + "name": "docs", + "command": "/usr/local/bin/docs-mcp", + "args": ["--read-only"], + "envBindings": {"DOCS_API_KEY": "docs-token"} +} +``` + +Declare `docs-token` in the operator catalog's `claude.credentials` map. Shared +stdio children receive platform environment variables and explicit bindings, not +provider keys or the entire adapter environment. Binding values never enter the +native CLI argument list or revision files. Shared HTTP/SSE and header bindings +are rejected pending interoperability validation. + +`native-command` is a native executable launcher, selected through the upstream +adapter's `CLAUDE_CODE_EXECUTABLE` setting. It merges shared MCP defaults underneath +the SDK's ACP-supplied `--mcp-config` by server name and invokes the pinned native +binary. It never reads ACP messages. Shared settings and MCP are selected from the +connection's immutable revision. New native sessions within that connection retain +that revision; a new connection sees the new effective revision. Ambient MCP is +excluded with `--strict-mcp-config`, and ambient CLI settings sources are disabled. +The adapter's own native SettingsManager still reads native user/project metadata; +this launcher is not a sandbox or a replacement for native trust/permissions. + +ACP `session/new` and `session/load` stdio MCP overrides are validated. Supply them +again when loading; other shared server defaults remain. ACP-supplied stdio servers +retain upstream native environment inheritance, so they are trusted executable +code with access to the agent's credentials. The shared-binding isolation guarantee +does not extend to arbitrary native client configuration. Upstream advertises +HTTP/SSE and other extensions; those remain unvalidated here, not Kernel-managed +features. + +## Native state and reconnect + +`CLAUDE_CONFIG_DIR=/native` and `HOME=/home` are created on launch, +not during preparation. Native transcripts, auth and other native mutable state +stay outside replaceable revisions. They persist across connection/API restarts +inside the same browser, not browser deletion. Each connection gets independent +adapter/native/MCP processes. Activation does not restart existing connections. + +Reconnect with initialize (authenticate if required), `session/list`, then +`session/load` using the exact discovered original ID. Consume `nextCursor` until +absent: 0.75.1 currently returns all sessions in a single terminal page and does not +implement multi-page cursor handling. Load replays history and restores model state; +subsequent prompts can recall prior content without the client supplying it again. +`session/resume` is optional and is not part of this gate. Interrupted-turn +continuation and exactly-once tool side effects are not promised. + +Disconnect terminates connection-owned processes through the shared bridge cleanup. +Packaged-image validation observed terminated native children retained as zombies +by the image wrapper; no live owned processes remained. Child reaping is a shared +image lifecycle limitation, not solved in this adapter. + +## Pins and verification + +- `@agentclientprotocol/claude-agent-acp` **0.75.1** (unpatched). +- `@anthropic-ai/claude-agent-sdk` **0.3.257**, including its exact-version platform + native binaries; `@agentclientprotocol/sdk` **1.4.0**. +- `bun.lock` records transitive dependencies; installs use `--frozen-lockfile + --ignore-scripts` and retain optional native dependencies. +- Uses the images' Node 22 (requires Node >=22.15 for Linux `process.execve`), Bun + **1.4.0**, and shared ACP bridge/client pins documented in the shared README. + +```sh +cd server/runtime/acp/claude +bun install --frozen-lockfile --ignore-scripts +node --test *.test.mjs +cd ../../.. +AGENT_CLAUDE_TEST_RUNTIME="$PWD/runtime/acp/claude" \ + go test -race ./lib/agentproxy ./lib/wsproxy +``` + +The native runtime test skips explicitly without `AGENT_CLAUDE_TEST_RUNTIME`. +Unit coverage includes HTTP validation, credential-safe responses, stale writes, +failed preparation/restart retention, private revision files, no preparation-time +native-state mutation, native argument merging and shared MCP environment isolation. + +The opt-in paid gate requires a **fresh disposable packaged image**, injected +`ANTHROPIC_API_KEY`, and a runner with `server/runtime/acp/requirements.txt` installed: + +```sh +AGENT_API_URL=http://127.0.0.1:10001 \ + /path/to/acp-venv/bin/python server/lib/agentproxy/testdata/claude_gate.py +``` + +It uses Haiku 4.5 with a $0.15/session SDK budget, three-turn-per-prompt limit and +120-second request timeout; it never retries prompts. It checks a real initial +turn, shared/overridden MCP, independent processes, activation and failed-preparation +retention, disconnect termination, fresh exact-ID discovery/load/history, native +model state and model recall. Raw events stay in a private local evidence directory. + +Locally validated: all non-e2e Go tests under race, `go vet ./...`, native JS tests, +both image runtime-stage builds, and the full headless image real-provider gate. +The full headful image, ARM64, interactive OAuth, media, remote HTTP/SSE MCP, +`session/resume`, cancellation during a tool side effect and platform gateway/TLS +authentication integration were not exercised. CI is filtered to PRs against main; +a stacked PR against another branch does not automatically run that workflow. diff --git a/server/runtime/acp/claude/bun.lock b/server/runtime/acp/claude/bun.lock new file mode 100644 index 00000000..b3768a9c --- /dev/null +++ b/server/runtime/acp/claude/bun.lock @@ -0,0 +1,238 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "@agentclientprotocol/claude-agent-acp": "0.75.1", + "@agentclientprotocol/sdk": "1.4.0", + "@anthropic-ai/claude-agent-sdk": "0.3.257", + }, + }, + }, + "packages": { + "@agentclientprotocol/claude-agent-acp": ["@agentclientprotocol/claude-agent-acp@0.75.1", "", { "dependencies": { "@agentclientprotocol/sdk": "1.4.0", "@anthropic-ai/claude-agent-sdk": "0.3.257", "zod": "^4.0.0" }, "bin": { "claude-agent-acp": "dist/index.js" } }, "sha512-Un6I4BRkhpCFS3I7kr5C/lkAm8Nc3VuGZU2YQ3xIpJAIxV94iWO0Q2CH2QABxMERpONRu4Le6XC9V+5PImQZ2A=="], + + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@1.4.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg=="], + + "@anthropic-ai/claude-agent-sdk": ["@anthropic-ai/claude-agent-sdk@0.3.257", "", { "optionalDependencies": { "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.257", "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.257", "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.257", "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.257", "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.257" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", "@modelcontextprotocol/sdk": "^1.29.0", "zod": "^4.0.0" } }, "sha512-Se55zXv48IYLg/WzoXzpbPLcq86suwDSbRUoNb69l4dkovorqS/47Xuy7MUo/gPNwwcPB4a+aqbXbshU33dcdQ=="], + + "@anthropic-ai/claude-agent-sdk-darwin-arm64": ["@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.257", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ITjFPYB8riu9tbxbrWArokiZ/90w/NDrYbtEvyr3ScilVtu1iupkComxkhvbUxoyT4JRDpKsdw/ZOfwSl/pkpA=="], + + "@anthropic-ai/claude-agent-sdk-darwin-x64": ["@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.257", "", { "os": "darwin", "cpu": "x64" }, "sha512-0s7QoLRnopbMvCqVpgCnvBWg4UNxPUybMZTknkn3HLBMvUjUdHs1QXb77c/yrT1WAPqDTw1Ju+H0esXwWa8/Kg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64": ["@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.257", "", { "os": "linux", "cpu": "arm64" }, "sha512-38tv1s1CaIE6CyEBmJpfKtvwPzrasxPiRT079BEs5aaiLPQ+pmSGF0+Lwy7ot9i6xqr7v5/91TVo5JXHI8Pkpg=="], + + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": ["@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.257", "", { "os": "linux", "cpu": "arm64" }, "sha512-t2WHfKjY4Jjgzfk0lbBUt6EVPQe+wXvXvRmYnK7ICfzZ1jS8CrpDajwBQErKFSMiUqGAutZy3vwDwGziW32cpw=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64": ["@anthropic-ai/claude-agent-sdk-linux-x64@0.3.257", "", { "os": "linux", "cpu": "x64" }, "sha512-0FRyIwV4jEJErdDDoYMC0v9lzuFNz5y0lK2340H5fP02eNXsP3U0htKW/bfRP8Ppei+xc4QUZgdCI6rVzkhXGg=="], + + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": ["@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.257", "", { "os": "linux", "cpu": "x64" }, "sha512-qSlgAUEpj2JAA+YqMD222iv2W3x8xBDSYwdoaOx20VbaJRjFq8feViI7kcr1C6wVN+ApX4Rxl7YX6gekiBkA2g=="], + + "@anthropic-ai/claude-agent-sdk-win32-arm64": ["@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.257", "", { "os": "win32", "cpu": "arm64" }, "sha512-7iEwy14lSqaAWvNR3KucBusQs5+hG6uoliYWZ2M2FyaCk5PHYymVf9mrRPtWhaYfNwum/YY13acYIIvnAbz0kA=="], + + "@anthropic-ai/claude-agent-sdk-win32-x64": ["@anthropic-ai/claude-agent-sdk-win32-x64@0.3.257", "", { "os": "win32", "cpu": "x64" }, "sha512-NW0zMjHXFBdu2TcjT9Zo5o/1tJaDy6A7v4Gt/vLVJgaipV/RzAbyRGXZzH37hswyiKrSLGaSoYYP1vUfncbkUQ=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.124.0", "", { "dependencies": { "json-schema-to-ts": "^3.1.1", "standardwebhooks": "^1.0.0" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-cN5O8i9UVxHeOQAzj/XjshWXG8KiibJDw9OGpH2Z/eR3n/RBxdoLxDJOcfqAJWvjaMDFfHTBADU04hWRJVkDyA=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.30.0", "", { "dependencies": { "@hono/node-server": "^1.19.9 || ^2.0.5", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA=="], + + "@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], + + "ajv-formats": ["ajv-formats@3.0.1", "", { "dependencies": { "ajv": "^8.0.0" } }, "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ=="], + + "body-parser": ["body-parser@2.3.0", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^2.0.0", "debug": "^4.4.3", "http-errors": "^2.0.1", "iconv-lite": "^0.7.2", "on-finished": "^2.4.1", "qs": "^6.15.2", "raw-body": "^3.0.2", "type-is": "^2.1.0" } }, "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw=="], + + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "call-bound": ["call-bound@1.0.4", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "get-intrinsic": "^1.3.0" } }, "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg=="], + + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + + "cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], + + "encodeurl": ["encodeurl@2.0.0", "", {}, "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.2", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw=="], + + "escape-html": ["escape-html@1.0.3", "", {}, "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow=="], + + "etag": ["etag@1.8.1", "", {}, "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg=="], + + "eventsource": ["eventsource@3.0.7", "", { "dependencies": { "eventsource-parser": "^3.0.1" } }, "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA=="], + + "eventsource-parser": ["eventsource-parser@3.1.1", "", {}, "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ=="], + + "express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + + "express-rate-limit": ["express-rate-limit@8.7.0", "", { "dependencies": { "debug": "^4.4.3", "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="], + + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + + "finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "forwarded": ["forwarded@0.2.0", "", {}, "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow=="], + + "fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="], + + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], + + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ip-address": ["ip-address@10.7.0", "", {}, "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA=="], + + "ipaddr.js": ["ipaddr.js@1.9.1", "", {}, "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="], + + "json-schema-traverse": ["json-schema-traverse@1.0.0", "", {}, "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug=="], + + "json-schema-typed": ["json-schema-typed@8.0.2", "", {}, "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + + "media-typer": ["media-typer@1.1.1", "", {}, "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ=="], + + "merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], + + "mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "negotiator": ["negotiator@1.1.0", "", { "dependencies": { "content-type": "^2.1.0" } }, "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-inspect": ["object-inspect@1.13.4", "", {}, "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew=="], + + "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], + + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + + "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="], + + "range-parser": ["range-parser@1.3.0", "", {}, "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw=="], + + "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "side-channel": ["side-channel@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4", "side-channel-list": "^1.0.1", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ=="], + + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], + + "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], + + "side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="], + + "standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + + "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "body-parser/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "negotiator/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + } +} diff --git a/server/runtime/acp/claude/launch.mjs b/server/runtime/acp/claude/launch.mjs new file mode 100644 index 00000000..c3aeea50 --- /dev/null +++ b/server/runtime/acp/claude/launch.mjs @@ -0,0 +1,46 @@ +import { readFileSync, mkdirSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { claudeCliPath } from "@agentclientprotocol/claude-agent-acp/dist/acp-agent.js"; + +const configPath = process.argv[2]; +const config = JSON.parse(readFileSync(configPath, "utf8")); +const bindings = JSON.parse(process.env.KERNEL_CLAUDE_BINDINGS); +const env = {}; +for (const name of [ + "PATH", + "HOME", + "USER", + "LANG", + "TMPDIR", + "TERM", + "SSL_CERT_FILE", + "SSL_CERT_DIR", + "CLAUDE_CONFIG_DIR", +]) { + if (process.env[name] !== undefined) env[name] = process.env[name]; +} +const aliases = Object.create(null); +for (const [binding, source] of Object.entries(bindings)) { + const alias = `KERNEL_CLAUDE_SECRET_${Object.keys(aliases).length}`; + if (!process.env[source]) + throw new Error("credential binding is unavailable"); + aliases[binding] = alias; + env[alias] = process.env[source]; +} +env.ANTHROPIC_API_KEY = env[aliases[config.launch.credential]]; +env.ANTHROPIC_MODEL = config.launch.model; +env.CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC = "1"; +env.KERNEL_CLAUDE_BINDINGS = JSON.stringify(aliases); +env.KERNEL_CLAUDE_CONFIG = configPath; +env.KERNEL_CLAUDE_NATIVE = await claudeCliPath(); +env.KERNEL_CLAUDE_NODE = process.execPath; +const runtime = dirname(fileURLToPath(import.meta.url)); +env.CLAUDE_CODE_EXECUTABLE = join(runtime, "native-command"); +mkdirSync(env.HOME, { recursive: true, mode: 0o700 }); +mkdirSync(env.CLAUDE_CONFIG_DIR, { recursive: true, mode: 0o700 }); +const adapter = join( + runtime, + "node_modules/@agentclientprotocol/claude-agent-acp/dist/index.js", +); +process.execve(process.execPath, [process.execPath, adapter], env); diff --git a/server/runtime/acp/claude/mcp-command.mjs b/server/runtime/acp/claude/mcp-command.mjs new file mode 100644 index 00000000..6bd14049 --- /dev/null +++ b/server/runtime/acp/claude/mcp-command.mjs @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs"; + +const [configPath, name] = process.argv.slice(2); +const config = JSON.parse(readFileSync(configPath, "utf8")); +const server = config.shared.mcpServers.find((server) => server.name === name); +const bindings = JSON.parse(process.env.KERNEL_CLAUDE_BINDINGS); +const env = {}; +for (const name of [ + "PATH", + "HOME", + "USER", + "LANG", + "TMPDIR", + "SSL_CERT_FILE", + "SSL_CERT_DIR", +]) { + if (process.env[name] !== undefined) env[name] = process.env[name]; +} +for (const [name, binding] of Object.entries(server.envBindings ?? {})) { + const value = process.env[bindings[binding]]; + if (!value) throw new Error("MCP credential binding is unavailable"); + env[name] = value; +} +process.execve(server.command, [server.command, ...(server.args ?? [])], env); diff --git a/server/runtime/acp/claude/native-command b/server/runtime/acp/claude/native-command new file mode 100755 index 00000000..4c98a35c --- /dev/null +++ b/server/runtime/acp/claude/native-command @@ -0,0 +1,2 @@ +#!/bin/sh +exec "$KERNEL_CLAUDE_NODE" "$(dirname "$0")/native.mjs" "$@" diff --git a/server/runtime/acp/claude/native.mjs b/server/runtime/acp/claude/native.mjs new file mode 100644 index 00000000..bf180ab2 --- /dev/null +++ b/server/runtime/acp/claude/native.mjs @@ -0,0 +1,38 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const args = process.argv.slice(2); +// Only augment SDK query launches, not native auth, version or status commands. +if (args.includes("--input-format")) { + const configPath = process.env.KERNEL_CLAUDE_CONFIG; + const config = JSON.parse(readFileSync(configPath, "utf8")); + const runtime = dirname(fileURLToPath(import.meta.url)); + const servers = Object.create(null); + for (const server of config.shared.mcpServers) { + servers[server.name] = { + type: "stdio", + command: process.execPath, + args: [join(runtime, "mcp-command.mjs"), configPath, server.name], + }; + } + // The SDK sends its ACP servers as one JSON --mcp-config argument. Merge + // defaults underneath them; leave session names and conversation data alone. + const index = args.indexOf("--mcp-config"); + if (index !== -1) { + const supplied = JSON.parse(args[index + 1]); + args[index + 1] = JSON.stringify({ + ...supplied, + mcpServers: { ...servers, ...supplied.mcpServers }, + }); + } else if (Object.keys(servers).length) { + args.push("--mcp-config", JSON.stringify({ mcpServers: servers })); + } + args.push("--settings", join(dirname(configPath), "settings.json")); + args.push("--setting-sources", "", "--strict-mcp-config"); +} +process.execve( + process.env.KERNEL_CLAUDE_NATIVE, + [process.env.KERNEL_CLAUDE_NATIVE, ...args], + process.env, +); diff --git a/server/runtime/acp/claude/package.json b/server/runtime/acp/claude/package.json new file mode 100644 index 00000000..c87b2e11 --- /dev/null +++ b/server/runtime/acp/claude/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@agentclientprotocol/claude-agent-acp": "0.75.1", + "@agentclientprotocol/sdk": "1.4.0", + "@anthropic-ai/claude-agent-sdk": "0.3.257" + } +} diff --git a/server/runtime/acp/claude/prepare.mjs b/server/runtime/acp/claude/prepare.mjs new file mode 100644 index 00000000..f95210e9 --- /dev/null +++ b/server/runtime/acp/claude/prepare.mjs @@ -0,0 +1,33 @@ +import { access, readFile, stat, writeFile } from "node:fs/promises"; +import { constants } from "node:fs"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { resolveSettings } from "@anthropic-ai/claude-agent-sdk"; +import { claudeCliPath } from "@agentclientprotocol/claude-agent-acp/dist/acp-agent.js"; + +const [revision] = process.argv.slice(2); +const config = JSON.parse( + await readFile(join(revision, "config.json"), "utf8"), +); +await writeFile( + join(revision, "settings.json"), + JSON.stringify(config.shared.settings), + { mode: 0o600 }, +); +const { effective } = await resolveSettings({ + cwd: revision, + settingSources: ["user"], +}); +for (const [name, value] of Object.entries(config.shared.settings)) { + if (effective[name] !== value) + throw new Error("native settings validation failed"); +} +for (const server of config.shared.mcpServers) { + await access(server.command, constants.X_OK); + if (!(await stat(server.command)).isFile()) + throw new Error("MCP command must be an executable file"); +} +execFileSync(await claudeCliPath(), ["--version"], { + stdio: "ignore", + timeout: 20000, +}); diff --git a/server/runtime/acp/claude/runtime.test.mjs b/server/runtime/acp/claude/runtime.test.mjs new file mode 100644 index 00000000..a7dd04ac --- /dev/null +++ b/server/runtime/acp/claude/runtime.test.mjs @@ -0,0 +1,113 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawnSync } from "node:child_process"; +import test from "node:test"; + +const runtime = dirname(fileURLToPath(import.meta.url)); + +function fixture(t) { + const root = mkdtempSync(join(tmpdir(), "claude-runtime-")); + t.after(() => rmSync(root, { recursive: true, force: true })); + const capture = join(root, "capture"); + writeFileSync( + capture, + `#!${process.execPath}\nconsole.log(JSON.stringify({args:process.argv.slice(2),env:process.env}));`, + { mode: 0o700 }, + ); + const config = join(root, "config.json"); + writeFileSync( + config, + JSON.stringify({ + shared: { + mcpServers: [ + { + name: "docs", + command: capture, + args: ["fixture"], + envBindings: { DOCS_KEY: "docs-token" }, + }, + { name: "other", command: capture, args: [] }, + ], + }, + }), + ); + const env = { + PATH: "/usr/bin:/bin", + HOME: root, + KERNEL_CLAUDE_CONFIG: config, + KERNEL_CLAUDE_NATIVE: capture, + KERNEL_CLAUDE_BINDINGS: '{"docs-token":"KERNEL_CLAUDE_SECRET_0"}', + KERNEL_CLAUDE_SECRET_0: "mcp-fixture", + ANTHROPIC_API_KEY: "provider-fixture", + UNRELATED_SECRET: "unrelated", + }; + return { root, capture, config, env }; +} + +function run(script, args, env) { + const result = spawnSync(process.execPath, [join(runtime, script), ...args], { + env, + encoding: "utf8", + }); + assert.equal(result.status, 0, result.stderr); + return JSON.parse(result.stdout); +} + +test("native flags merge shared MCP underneath ACP names and disable ambient MCP", (t) => { + const { root, env } = fixture(t); + const custom = { type: "stdio", command: "/bin/custom", args: [] }; + const { args } = run( + "native.mjs", + [ + "--input-format", + "stream-json", + "--mcp-config", + JSON.stringify({ mcpServers: { docs: custom } }), + ], + env, + ); + const mcp = JSON.parse(args[args.indexOf("--mcp-config") + 1]); + assert.deepEqual(mcp.mcpServers.docs, custom); + assert.equal(mcp.mcpServers.other.command, process.execPath); + assert.equal(mcp.mcpServers.other.args[0], join(runtime, "mcp-command.mjs")); + assert.equal( + args[args.indexOf("--settings") + 1], + join(root, "settings.json"), + ); + assert.equal(args[args.indexOf("--setting-sources") + 1], ""); + assert.ok(args.includes("--strict-mcp-config")); + assert.ok(!JSON.stringify(args).includes("mcp-fixture")); + assert.ok(!JSON.stringify(args).includes("provider-fixture")); +}); + +test("native version/auth commands retain their original arguments", (t) => { + const { env } = fixture(t); + for (const args of [["--version"], ["auth", "status"]]) { + assert.deepEqual(run("native.mjs", args, env).args, args); + } +}); + +test("shared stdio receives only platform environment and explicit bindings", (t) => { + const { config, env } = fixture(t); + const result = run("mcp-command.mjs", [config, "docs"], env); + assert.deepEqual(result.args, ["fixture"]); + assert.equal(result.env.DOCS_KEY, "mcp-fixture"); + for (const name of [ + "ANTHROPIC_API_KEY", + "UNRELATED_SECRET", + "KERNEL_CLAUDE_SECRET_0", + "KERNEL_CLAUDE_BINDINGS", + ]) { + assert.equal(result.env[name], undefined); + } + delete env.KERNEL_CLAUDE_SECRET_0; + const failed = spawnSync( + process.execPath, + [join(runtime, "mcp-command.mjs"), config, "docs"], + { env }, + ); + assert.notEqual(failed.status, 0); +}); From d18d55a3ea70423da6b3b35e0a43461a605ce7ae Mon Sep 17 00:00:00 2001 From: rgarcia <72655+rgarcia@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:47:34 +0000 Subject: [PATCH 2/2] Preserve native session settings alongside shared defaults --- server/runtime/acp/claude/README.md | 16 +++++++--- server/runtime/acp/claude/launch.mjs | 2 +- server/runtime/acp/claude/mcp-command.mjs | 2 +- server/runtime/acp/claude/native.mjs | 13 +++++++- server/runtime/acp/claude/runtime.test.mjs | 37 +++++++++++++++++++++- 5 files changed, 62 insertions(+), 8 deletions(-) diff --git a/server/runtime/acp/claude/README.md b/server/runtime/acp/claude/README.md index 58e75deb..21833ec4 100644 --- a/server/runtime/acp/claude/README.md +++ b/server/runtime/acp/claude/README.md @@ -48,7 +48,10 @@ startup. Ready does not mean provider authentication or MCP connectivity succeed `shared.settings` supports native `language` and `alwaysThinkingEnabled`. These settings are validated with the pinned SDK resolver and passed to the native CLI -as a revision-local `--settings` file. Arbitrary environment settings, hooks, +as a revision-local `--settings` file. If the SDK already supplies `--settings`, +shared defaults are merged underneath those native session settings into one CLI +argument, preserving native model restrictions and provider-routing settings. +Arbitrary environment settings, hooks, plugins, skills, commands, marketplaces and Pi npm extensions are **unsupported** by this preparer. No extension installation is implied by accepting a revision. @@ -78,6 +81,11 @@ that revision; a new connection sees the new effective revision. Ambient MCP is excluded with `--strict-mcp-config`, and ambient CLI settings sources are disabled. The adapter's own native SettingsManager still reads native user/project metadata; this launcher is not a sandbox or a replacement for native trust/permissions. +These revision guarantees describe the managed launch path, not a security boundary +against native client overrides: `_meta.claudeCode.options.env` can replace wrapper +paths, binding metadata or `CLAUDE_CONFIG_DIR`. Clients with access to this browser +can also modify its files or read process environments through the process API. +Such out-of-band/native overrides are not reconciled into desired/effective state. ACP `session/new` and `session/load` stdio MCP overrides are validated. Supply them again when loading; other shared server defaults remain. ACP-supplied stdio servers @@ -112,9 +120,9 @@ image lifecycle limitation, not solved in this adapter. - `@agentclientprotocol/claude-agent-acp` **0.75.1** (unpatched). - `@anthropic-ai/claude-agent-sdk` **0.3.257**, including its exact-version platform - native binaries; `@agentclientprotocol/sdk` **1.4.0**. -- `bun.lock` records transitive dependencies; installs use `--frozen-lockfile - --ignore-scripts` and retain optional native dependencies. + native binaries (CLI reports **2.1.257**); `@agentclientprotocol/sdk` **1.4.0**. +- `bun.lock` records transitive dependencies; installs use + `--frozen-lockfile --ignore-scripts` and retain optional native dependencies. - Uses the images' Node 22 (requires Node >=22.15 for Linux `process.execve`), Bun **1.4.0**, and shared ACP bridge/client pins documented in the shared README. diff --git a/server/runtime/acp/claude/launch.mjs b/server/runtime/acp/claude/launch.mjs index c3aeea50..110c205c 100644 --- a/server/runtime/acp/claude/launch.mjs +++ b/server/runtime/acp/claude/launch.mjs @@ -6,7 +6,7 @@ import { claudeCliPath } from "@agentclientprotocol/claude-agent-acp/dist/acp-ag const configPath = process.argv[2]; const config = JSON.parse(readFileSync(configPath, "utf8")); const bindings = JSON.parse(process.env.KERNEL_CLAUDE_BINDINGS); -const env = {}; +const env = Object.create(null); for (const name of [ "PATH", "HOME", diff --git a/server/runtime/acp/claude/mcp-command.mjs b/server/runtime/acp/claude/mcp-command.mjs index 6bd14049..cd278a9a 100644 --- a/server/runtime/acp/claude/mcp-command.mjs +++ b/server/runtime/acp/claude/mcp-command.mjs @@ -4,7 +4,7 @@ const [configPath, name] = process.argv.slice(2); const config = JSON.parse(readFileSync(configPath, "utf8")); const server = config.shared.mcpServers.find((server) => server.name === name); const bindings = JSON.parse(process.env.KERNEL_CLAUDE_BINDINGS); -const env = {}; +const env = Object.create(null); for (const name of [ "PATH", "HOME", diff --git a/server/runtime/acp/claude/native.mjs b/server/runtime/acp/claude/native.mjs index bf180ab2..f8a14719 100644 --- a/server/runtime/acp/claude/native.mjs +++ b/server/runtime/acp/claude/native.mjs @@ -28,7 +28,18 @@ if (args.includes("--input-format")) { } else if (Object.keys(servers).length) { args.push("--mcp-config", JSON.stringify({ mcpServers: servers })); } - args.push("--settings", join(dirname(configPath), "settings.json")); + const settingsPath = join(dirname(configPath), "settings.json"); + const settingsIndex = args.indexOf("--settings"); + if (settingsIndex !== -1) { + const value = args[settingsIndex + 1]; + const supplied = JSON.parse( + value.trimStart().startsWith("{") ? value : readFileSync(value, "utf8"), + ); + const shared = JSON.parse(readFileSync(settingsPath, "utf8")); + args[settingsIndex + 1] = JSON.stringify({ ...shared, ...supplied }); + } else { + args.push("--settings", settingsPath); + } args.push("--setting-sources", "", "--strict-mcp-config"); } process.execve( diff --git a/server/runtime/acp/claude/runtime.test.mjs b/server/runtime/acp/claude/runtime.test.mjs index a7dd04ac..e9756e72 100644 --- a/server/runtime/acp/claude/runtime.test.mjs +++ b/server/runtime/acp/claude/runtime.test.mjs @@ -17,6 +17,10 @@ function fixture(t) { `#!${process.execPath}\nconsole.log(JSON.stringify({args:process.argv.slice(2),env:process.env}));`, { mode: 0o700 }, ); + writeFileSync( + join(root, "settings.json"), + JSON.stringify({ language: "English", alwaysThinkingEnabled: false }), + ); const config = join(root, "config.json"); writeFileSync( config, @@ -27,7 +31,7 @@ function fixture(t) { name: "docs", command: capture, args: ["fixture"], - envBindings: { DOCS_KEY: "docs-token" }, + envBindings: { DOCS_KEY: "docs-token", ["__proto__"]: "docs-token" }, }, { name: "other", command: capture, args: [] }, ], @@ -83,6 +87,36 @@ test("native flags merge shared MCP underneath ACP names and disable ambient MCP assert.ok(!JSON.stringify(args).includes("provider-fixture")); }); +test("shared settings merge underneath native SDK settings without duplicate flags", (t) => { + const { root, env } = fixture(t); + const settings = { + language: "Spanish", + env: { ANTHROPIC_BASE_URL: "https://example.test" }, + availableModels: ["haiku"], + }; + const settingsPath = join(root, "session-settings.json"); + writeFileSync(settingsPath, JSON.stringify(settings)); + for (const supplied of [JSON.stringify(settings), settingsPath]) { + const { args } = run( + "native.mjs", + [ + "--input-format", + "stream-json", + "--setting-sources=user,project,local", + "--settings", + supplied, + ], + env, + ); + assert.equal(args.filter((arg) => arg === "--settings").length, 1); + assert.deepEqual(JSON.parse(args[args.indexOf("--settings") + 1]), { + alwaysThinkingEnabled: false, + ...settings, + }); + assert.equal(args[args.lastIndexOf("--setting-sources") + 1], ""); + } +}); + test("native version/auth commands retain their original arguments", (t) => { const { env } = fixture(t); for (const args of [["--version"], ["auth", "status"]]) { @@ -95,6 +129,7 @@ test("shared stdio receives only platform environment and explicit bindings", (t const result = run("mcp-command.mjs", [config, "docs"], env); assert.deepEqual(result.args, ["fixture"]); assert.equal(result.env.DOCS_KEY, "mcp-fixture"); + assert.equal(result.env.__proto__, "mcp-fixture"); for (const name of [ "ANTHROPIC_API_KEY", "UNRELATED_SECRET",