diff --git a/.dockerignore b/.dockerignore index 4d874f06c..7e060319d 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,5 @@ node_modules +**/node_modules .git .gitignore .env diff --git a/.github/workflows/server-test.yaml b/.github/workflows/server-test.yaml index b3bc43e60..6517315c7 100644 --- a/.github/workflows/server-test.yaml +++ b/.github/workflows/server-test.yaml @@ -41,6 +41,29 @@ jobs: with: node-version: 22 + - name: Set up Python for ACP interoperability tests + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install pinned ACP bridge and client + run: | + python -m venv "$RUNNER_TEMP/acp" + "$RUNNER_TEMP/acp/bin/python" -m pip install -r server/runtime/acp/requirements.txt + echo "AGENT_PROXY_TEST_ACPREMOTE=$RUNNER_TEMP/acp/bin/acpremote" >> "$GITHUB_ENV" + + - name: Set up Bun for pinned Pi packages + uses: oven-sh/setup-bun@v2 + with: + bun-version: "1.4.0" + + - name: Install pinned Pi reference runtime + working-directory: server/runtime/acp/pi + run: | + bun install --frozen-lockfile --ignore-scripts + node patch-adapter.mjs + echo "AGENT_PI_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 76c0338cb..57db1582c 100644 --- a/images/chromium-headful/Dockerfile +++ b/images/chromium-headful/Dockerfile @@ -162,6 +162,19 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX EOT FROM ghcr.io/kernel/neko/base:3.0.8-v1.6.0 AS neko +FROM ghcr.io/astral-sh/uv:0.8.14 AS agent-uv +FROM oven/bun:1.4.0 AS agent-bun +FROM node:22-bullseye-slim AS agent-runtime +COPY --from=agent-uv /uv /usr/local/bin/uv +COPY --from=agent-bun /usr/local/bin/bun /usr/local/bin/bun +ENV UV_PYTHON_INSTALL_DIR=/opt/kernel-agent/python +RUN uv python install 3.12.11 && uv venv --python 3.12.11 /opt/kernel-agent/venv +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/catalog.json /opt/kernel-agent/catalog.json + FROM node:22-bullseye-slim AS node-22 FROM docker.io/ubuntu:22.04 @@ -397,4 +410,8 @@ RUN useradd -m -s /bin/bash kernel COPY shared/envoy/bake-certs.sh /usr/local/bin/bake-certs.sh RUN chmod +x /usr/local/bin/bake-certs.sh && /usr/local/bin/bake-certs.sh && rm /usr/local/bin/bake-certs.sh +COPY --from=agent-runtime /opt/kernel-agent /opt/kernel-agent +COPY --from=agent-bun /usr/local/bin/bun /usr/local/bin/bun +ENV AGENT_CONFIG_PATH=/opt/kernel-agent/catalog.json + ENTRYPOINT [ "/wrapper" ] diff --git a/images/chromium-headless/image/Dockerfile b/images/chromium-headless/image/Dockerfile index bf3180642..6cb69c77f 100644 --- a/images/chromium-headless/image/Dockerfile +++ b/images/chromium-headless/image/Dockerfile @@ -109,6 +109,19 @@ RUN --mount=type=cache,target=/tmp/cache/ffmpeg,sharing=locked,id=$CACHEIDPREFIX rm -rf /tmp/ffmpeg* EOT +FROM ghcr.io/astral-sh/uv:0.8.14 AS agent-uv +FROM oven/bun:1.4.0 AS agent-bun +FROM node:22-bullseye-slim AS agent-runtime +COPY --from=agent-uv /uv /usr/local/bin/uv +COPY --from=agent-bun /usr/local/bin/bun /usr/local/bin/bun +ENV UV_PYTHON_INSTALL_DIR=/opt/kernel-agent/python +RUN uv python install 3.12.11 && uv venv --python 3.12.11 /opt/kernel-agent/venv +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/catalog.json /opt/kernel-agent/catalog.json + FROM node:22-bullseye-slim AS node-22 FROM docker.io/ubuntu:22.04 @@ -285,4 +298,8 @@ RUN esbuild /tmp/playwright-daemon.ts \ --external:esbuild \ && rm /tmp/playwright-daemon.ts /tmp/page-target-id-cache.ts /tmp/webmcp.ts +COPY --from=agent-runtime /opt/kernel-agent /opt/kernel-agent +COPY --from=agent-bun /usr/local/bin/bun /usr/local/bin/bun +ENV AGENT_CONFIG_PATH=/opt/kernel-agent/catalog.json + ENTRYPOINT [ "/wrapper" ] diff --git a/server/README.md b/server/README.md index cc80cb414..968661ac9 100644 --- a/server/README.md +++ b/server/README.md @@ -57,6 +57,7 @@ Configure the server using environment variables: | `MAX_SIZE_MB` | `500` | Default maximum file size (MB) | | `OUTPUT_DIR` | `.` | Directory to save recordings | | `FFMPEG_PATH` | `ffmpeg` | Path to the ffmpeg binary | +| `AGENT_CONFIG_PATH` | empty | Trusted launch catalog enabling the optional ACP WebSocket proxy | #### Example Configuration @@ -68,6 +69,14 @@ export OUTPUT_DIR=/tmp/recordings ./bin/api ``` +### Optional ACP agents + +The [ACP API](lib/agentproxy/README.md) exposes one WebSocket endpoint using +`acpremote expose` and its per-connection process lifecycle. Browser images bundle +a pinned Pi runtime and declarative configuration GET/PUT endpoints. Configure a +provider credential binding before connecting. An empty `AGENT_CONFIG_PATH` +disables these routes when running the server separately. + ### API Documentation - **YAML Spec**: `GET /spec.yaml` diff --git a/server/cmd/api/main.go b/server/cmd/api/main.go index b3384c6f5..08d770a64 100644 --- a/server/cmd/api/main.go +++ b/server/cmd/api/main.go @@ -27,6 +27,7 @@ import ( serverpkg "github.com/kernel/kernel-images/server" "github.com/kernel/kernel-images/server/cmd/api/api" "github.com/kernel/kernel-images/server/cmd/config" + "github.com/kernel/kernel-images/server/lib/agentproxy" "github.com/kernel/kernel-images/server/lib/chromedriverproxy" "github.com/kernel/kernel-images/server/lib/devtoolsproxy" "github.com/kernel/kernel-images/server/lib/events" @@ -285,6 +286,20 @@ func main() { apiService.HandleProcessAttachWS(w, r, id, wsRegistry) }) + if config.AgentConfigPath != "" { + agentConfig, err := agentproxy.Load(config.AgentConfigPath) + if err != nil { + slogger.Error("agent endpoints disabled: invalid launch catalog", "err", err) + } else { + agents, err := agentproxy.New(ctx, agentConfig, slogger, wsRegistry) + if err != nil { + slogger.Error("agent endpoints disabled", "err", err) + } else { + r.Handle("/agent/v1/*", agents) + } + } + } + // Serve extension files for Chrome policy-installed extensions // This allows Chrome to download .crx and update.xml files via HTTP extensionsDir := "/home/kernel/extensions" diff --git a/server/cmd/config/config.go b/server/cmd/config/config.go index 70cce9142..bf7ea152e 100644 --- a/server/cmd/config/config.go +++ b/server/cmd/config/config.go @@ -15,6 +15,9 @@ type Config struct { // Server configuration Port int `envconfig:"PORT" default:"10001"` + // Optional trusted launch catalog. Empty leaves the ACP endpoints disabled. + AgentConfigPath string `envconfig:"AGENT_CONFIG_PATH" default:""` + // Port for the Prometheus metrics endpoint. Served on a separate // listener so scrapes bypass the scale-to-zero middleware and the // external API surface. @@ -89,6 +92,7 @@ func (c *Config) LogValue() slog.Value { } return slog.GroupValue( slog.Int("port", c.Port), + slog.String("agent_config_path", c.AgentConfigPath), slog.Int("metrics_port", c.MetricsPort), slog.Int("frame_rate", c.FrameRate), slog.Int("display_num", c.DisplayNum), diff --git a/server/lib/agentproxy/README.md b/server/lib/agentproxy/README.md new file mode 100644 index 000000000..34951f586 --- /dev/null +++ b/server/lib/agentproxy/README.md @@ -0,0 +1,243 @@ +# ACP agents + +The browser images bundle a pinned Pi reference implementation. 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. + +## API + +| 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/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. | + +The routes use the existing browser API routing/authentication boundary, like +`/process/{process_id}/attach`. Do not expose the image server directly to an +untrusted network. Internal bridge listeners bind to loopback and use independent +random bearer tokens; client headers are not forwarded to them. + +### Configuration example + +Inject `OPENROUTER_API_KEY` into the image environment, then GET the configuration +and use its ETag in PUT. The initial ETag is `"0"`. + +```http +PUT /agent/v1/harnesses/pi/config +Content-Type: application/json +If-Match: "0" + +{ + "launch": { + "provider": "openrouter", + "model": "z-ai/glm-5.3", + "thinking": "low", + "credential": "openrouter" + }, + "shared": { + "extensions": [], + "mcpServers": [] + } +} +``` + +The response, also returned by GET, has this shape: + +```json +{ + "revision": "", + "status": "ready", + "desired": {"launch": {}, "shared": {}}, + "effectiveRevision": "", + "effective": {"launch": {}, "shared": {}} +} +``` + +The configuration objects above are abbreviated. `status` is `unconfigured`, +`preparing`, `ready`, or `failed`. Before first preparation, desired/effective are +null. Failed preparation retains the desired request and a safe `error` message, +while effective configuration and existing connections retain the last ready +revision. GET remains available during preparation. Effective means the last +configuration prepared by this API, not an audit of out-of-band filesystem edits. + +PUT is synchronous, with preparation bounded to three minutes. Concurrent or stale +writes return 409; missing If-Match returns 428; malformed JSON returns 400; invalid +configuration or failed preparation returns 422. After failure, GET obtains the +new desired revision for the next If-Match. Responses never contain credential +values. The request limit is 1 MiB. + +A ready revision means software/configuration preparation succeeded, not that a +provider key has been authenticated or a remote MCP service is reachable. + +### Launch settings and credential bindings + +`provider` supports `openrouter`, `openai`, `anthropic`, and `google`. `model` is the +native Pi model ID. `thinking` is one of `off`, `minimal`, `low`, `medium`, `high`, +or `xhigh`; the pinned adapter does not expose `max`, and individual models may +support only a subset. ACP model/mode +controls can subsequently change a session's selection. + +`credential` names an operator-defined environment binding, not a secret value. +The packaged catalog binds `openrouter` to `OPENROUTER_API_KEY`, `openai` to +`OPENAI_API_KEY`, `anthropic` to `ANTHROPIC_API_KEY`, and `google` to +`GEMINI_API_KEY`. Only credentials referenced by the configuration are inherited +by the launched agent. Missing/empty bindings reject preparation. GET returns the +binding names supplied by the caller, not their environment implementation. + +### Extensions + +`shared.extensions` accepts at most 32 exact-version npm references, for example +`npm:my-pi-extension@1.2.3`. Ranges, floating tags, git sources and local paths are +not supported in this first contract. Packages are installed with Bun and +lifecycle scripts disabled. Only extension resources are enabled from packages; +package skills, prompt templates and themes are excluded. + +Preparation imports/initializes extensions in a separate preparation directory +without inheriting provider credentials. Managed MCP server startup is disabled. An extension import/startup +error prevents activation. Extensions requiring install scripts are unsupported. +Extensions are executable, trusted code with browser filesystem access: atomic +activation does not roll back arbitrary side effects performed by extension code. + +Project-local executable settings/resources are not automatically trusted. The +managed runtime passes Pi's `--no-approve`; normal native context-file behavior +remains available. + +### Shared MCP configuration + +At most 32 uniquely named servers are accepted. A stdio definition uses an +absolute remote command: + +```json +{ + "name": "docs", + "command": "/usr/local/bin/docs-mcp", + "args": ["--read-only"], + "envBindings": {"DOCS_API_KEY": "docs-token"} +} +``` + +An HTTP definition can bind request headers: + +```json +{ + "name": "docs", + "url": "https://example.com/mcp", + "transport": "http", + "headerBindings": { + "Authorization": {"credential": "docs-token", "prefix": "Bearer "} + } +} +``` + +`docs-token` must be declared in the operator catalog's credential bindings. +`transport` supports `http` (streamable HTTP) and `sse`. Embedded URL credentials, +query parameters and fragments are rejected; use bindings for secrets. Interactive +MCP OAuth is disabled in the managed extension; use explicit credential bindings. + +Standard ACP `mcpServers` on `session/new` and fresh-process `session/load` are +also supported. They **override shared defaults by server name** for that native +Pi session. They are kept in process memory rather than written into shared +configuration; clients must supply them again when loading. Other shared servers +remain available. Stdio MCP processes receive SDK platform-default environment +variables plus explicitly supplied bindings/values, not the entire provider +environment. The managed MCP extension uses an isolated explicit config, so +ambient `.mcp.json` and imported host MCP settings do not participate. + +Pi-acp 0.0.33 otherwise accepts and stores MCP definitions without passing them to +Pi. `runtime/acp/pi/patch-adapter.mjs` adds per-session environment handoff for +create/load and advertises the implemented HTTP/SSE support. It is a guarded, +version-specific adapter patch, not an interception layer in the Go proxy. + +## Lifetime and shared state + +Each connection starts its own loopback expose listener and agent process tree. +Multiple connections and multiple native sessions per connection are supported. +Disconnect terminates the owned processes; it does not delete saved sessions. +Unresponsive clients are detected with a 20-second ping interval and pong timeout. +Shutdown first requests graceful bridge cleanup, then escalates to process-group +termination after six seconds. This is cleanup, not a sandbox against deliberately +daemonized processes. WebSocket `acp.v1` is optional and the message limit is 1 MiB. + +Configuration activation atomically switches `current` to a prepared revision. +New connections use the new launch definition; existing connections are not killed. +All Pi processes in the browser share managed configuration and native session +storage. Native history and the adapter's ID mapping remain outside the revision +being replaced. Successful revisions are retained for existing processes; +failed preparation directories are removed. There is no automatic GC of successful +revisions yet, so repeated large extension installations consume disk space. + +Shared files can be observed dynamically by existing processes; there is no promise +of complete revision isolation. New credentials require a fresh connection, and +extensions/MCP services may require a fresh native session to reload. Native writes +and direct filesystem edits are not reconciled back into the API's desired state. +Saved sessions survive connection and API-process restarts in the same browser, +not deletion/replacement of the browser itself. + +The reconnect contract is `initialize`, authenticate if needed, `session/list`, +then `session/load` with the discovered exact ID. Load replays native history. +`session/resume` is optional; acpremote 1.7.0 mirror currently rejects that method +on its local router. Neither path guarantees interrupted-turn continuation, +lossless event replay or exactly-once tool side effects. + +`cwd` and stdio MCP commands refer to the browser's filesystem. Client-local paths +and terminals do not become remotely available automatically. Use the process/file +APIs to provision an existing remote working directory. + +## Pins and operator configuration + +The runtime pins Pi **0.83.0**, pi-acp **0.0.33** with the MCP patch, +pi-mcp-adapter **2.32.1**, acpremote **1.7.0**, ACP Python SDK **0.11.0**, +websockets **15.0.1**, Python **3.12.11**, and Bun **1.4.0**. The runtime's Bun lockfile +pins its JavaScript dependency resolution. User extension dependencies are resolved +and recorded separately in each prepared revision. + +Images set `AGENT_CONFIG_PATH=/opt/kernel-agent/catalog.json`. An empty variable +still disables the agent routes. Operators can replace the catalog to configure +`pi.stateDir`, `pi.runtimeDir`, absolute `pi.node`/`pi.bun` executable paths, +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 +`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 +implementation satisfies that protocol gate. + +## Validation + +From `server/`: + +```sh +cd runtime/acp/pi +bun install --frozen-lockfile --ignore-scripts +node patch-adapter.mjs +cd ../../.. +AGENT_PI_TEST_RUNTIME="$PWD/runtime/acp/pi" \ +AGENT_PROXY_TEST_ACPREMOTE=/path/to/acp-venv/bin/acpremote \ + go test -race ./lib/agentproxy ./lib/wsproxy +``` + +CI installs the pinned runtimes. Tests cover transactional activation, failure +retention, restart recovery, optimistic concurrency, private files and responses, +real npm-compatible fixture package installation/validation, plus the existing +bridge/official-client lifecycle and content gates. Without the corresponding +environment variables, external-runtime tests explicitly skip. + +The opt-in real-provider test requires a **fresh disposable image**, injected +`OPENROUTER_API_KEY`, and a Python runner with `runtime/acp/requirements.txt`: + +```sh +AGENT_API_URL=http://127.0.0.1:10001 \ + /path/to/acp-venv/bin/python lib/agentproxy/testdata/pi_gate.py +``` + +It refuses to overwrite an existing configuration. It exercises independent +connections, shared and session-overridden stdio MCP tools, updates while connected, +failed preparation, process cleanup, and fresh-process discovery/load/history plus +real-model recall. This paid-provider gate is not run automatically in CI. +HTTP/SSE MCP interoperability and media/model behavior beyond these tests require +additional validation; packaged headless-image testing is not a platform gateway +or TLS/authentication integration test. diff --git a/server/lib/agentproxy/bridge.go b/server/lib/agentproxy/bridge.go new file mode 100644 index 000000000..4fad2c1ad --- /dev/null +++ b/server/lib/agentproxy/bridge.go @@ -0,0 +1,128 @@ +package agentproxy + +import ( + "bufio" + "context" + "crypto/rand" + "encoding/hex" + "errors" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "strconv" + "strings" + "sync" + "syscall" + "time" +) + +const bridgeTokenEnv = "KERNEL_ACP_BRIDGE_TOKEN" + +// Each attachment gets its own expose listener/process group. No connection or +// child process is retained for reattachment after the downstream closes. +type bridge struct { + url string + headers http.Header + command *exec.Cmd + done chan struct{} + once sync.Once +} + +func startBridge(ctx context.Context, executable string, harness Harness) (*bridge, error) { + if err := ctx.Err(); err != nil { + return nil, err + } + secret := make([]byte, 32) + if _, err := rand.Read(secret); err != nil { + return nil, err + } + token := hex.EncodeToString(secret) + env, err := harness.environment(token) + if err != nil { + return nil, err + } + args := []string{"expose", "--host", "127.0.0.1", "--port", "0", "--cwd", harness.Cwd, "--stderr-mode", "discard", "--token-env", bridgeTokenEnv, "--", harness.Command} + args = append(args, harness.Args...) + command := exec.Command(executable, args...) + command.Env = env + command.Dir = harness.Cwd + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + // Keep our reader separate from exec.Wait's pipe cleanup. + reader, writer, err := os.Pipe() + if err != nil { + return nil, err + } + command.Stderr = writer + if err := command.Start(); err != nil { + reader.Close() + writer.Close() + return nil, errors.New("could not start ACP bridge") + } + writer.Close() + b := &bridge{command: command, done: make(chan struct{}), headers: http.Header{"Authorization": {"Bearer " + token}}} + go func() { + _ = command.Wait() + close(b.done) + }() + ready := make(chan string, 1) + go func() { + defer reader.Close() + defer close(ready) + scanner := bufio.NewScanner(reader) + for scanner.Scan() { + if address := bridgeAddress(scanner.Text()); address != "" { + ready <- address + // Never forward agent/bridge stderr into request logs or responses. + _, _ = io.Copy(io.Discard, reader) + return + } + } + }() + startup, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + select { + case address := <-ready: + if address != "" { + b.url = address + return b, nil + } + case <-b.done: + case <-startup.Done(): + } + b.close() + return nil, errors.New("ACP bridge did not become ready") +} + +// acpremote 1.7.0 reports its OS-selected port on stderr. Restrict the advertised +// address to the listener we requested; never proxy an arbitrary URL from output. +func bridgeAddress(line string) string { + address, ok := strings.CutPrefix(line, "Serving ACP WebSocket at ") + if !ok { + return "" + } + u, err := url.Parse(address) + if err != nil || u.Scheme != "ws" || u.Hostname() != "127.0.0.1" || u.Path != "/acp/ws" || u.User != nil || u.RawQuery != "" || u.Fragment != "" { + return "" + } + port, err := strconv.Atoi(u.Port()) + if err != nil || port < 1 || port > 65535 { + return "" + } + return address +} + +func (b *bridge) close() { + b.once.Do(func() { + // The CLI handles SIGINT by closing its server and awaiting child cleanup. + _ = b.command.Process.Signal(os.Interrupt) + select { + case <-b.done: + case <-time.After(6 * time.Second): + } + // Terminate remaining descendants if an adapter outlives its bridge. + _ = syscall.Kill(-b.command.Process.Pid, syscall.SIGKILL) + <-b.done + }) +} diff --git a/server/lib/agentproxy/config.go b/server/lib/agentproxy/config.go new file mode 100644 index 000000000..40d3ebf64 --- /dev/null +++ b/server/lib/agentproxy/config.go @@ -0,0 +1,126 @@ +// Package agentproxy exposes ACP WebSockets without interpreting ACP messages. +package agentproxy + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +// Config is trusted, operator-provisioned launch configuration. It is not a +// public command-execution API. Harness-specific installers will produce it. +type Config struct { + ACPRemote string `json:"acpremote"` + MaxConnections int `json:"maxConnections"` + Harnesses map[string]Harness `json:"harnesses"` + Pi *PiOptions `json:"pi,omitempty"` +} + +type Harness struct { + Command string `json:"command"` + Args []string `json:"args"` + Cwd string `json:"cwd"` + Env map[string]string `json:"env"` + InheritEnv []string `json:"inheritEnv"` +} + +func Load(path string) (Config, error) { + file, err := os.Open(path) + if err != nil { + return Config{}, err + } + defer file.Close() + data, err := io.ReadAll(io.LimitReader(file, (64<<10)+1)) + if err != nil { + return Config{}, err + } + if len(data) > 64<<10 { + return Config{}, errors.New("agent configuration exceeds 64 KiB") + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var config Config + if err := decoder.Decode(&config); err != nil { + return Config{}, errors.New("invalid agent configuration") + } + var extra any + if err := decoder.Decode(&extra); err != io.EOF { + return Config{}, errors.New("expected one agent configuration") + } + if err := config.validate(); err != nil { + return Config{}, err + } + return config, nil +} + +func (c Config) validate() error { + if !filepath.IsAbs(c.ACPRemote) { + return errors.New("acpremote must be an absolute executable path") + } + if c.MaxConnections < 1 || c.MaxConnections > 64 { + return errors.New("maxConnections must be between 1 and 64") + } + if c.Pi != nil { + if err := c.Pi.validate(); err != nil { + return err + } + } + if len(c.Harnesses) == 0 && c.Pi == nil { + return errors.New("at least one harness is required") + } + for name, harness := range c.Harnesses { + switch name { + case "pi", "codex", "claude", "gemini": + default: + return fmt.Errorf("unknown harness %q", name) + } + if !filepath.IsAbs(harness.Command) || !filepath.IsAbs(harness.Cwd) { + return fmt.Errorf("%s requires absolute command and cwd paths", name) + } + for name, value := range harness.Env { + if !validEnvName(name) || strings.ContainsRune(value, '\x00') { + return errors.New("invalid harness environment") + } + } + for _, name := range harness.InheritEnv { + if !validEnvName(name) { + return errors.New("invalid inherited environment name") + } + } + } + return nil +} + +func validEnvName(name string) bool { + return name != "" && !strings.ContainsAny(name, "=\x00") && name != bridgeTokenEnv +} + +func (h Harness) environment(token string) ([]string, error) { + values := make(map[string]string) + for _, name := range []string{"PATH", "HOME", "USER", "LANG", "TMPDIR", "TERM", "SSL_CERT_FILE", "SSL_CERT_DIR"} { + if value, ok := os.LookupEnv(name); ok { + values[name] = value + } + } + for _, name := range h.InheritEnv { + value, ok := os.LookupEnv(name) + if !ok || value == "" { + return nil, errors.New("required harness environment is unavailable") + } + values[name] = value + } + for name, value := range h.Env { + values[name] = value + } + values[bridgeTokenEnv] = token + env := make([]string, 0, len(values)) + for name, value := range values { + env = append(env, name+"="+value) + } + return env, nil +} diff --git a/server/lib/agentproxy/config_test.go b/server/lib/agentproxy/config_test.go new file mode 100644 index 000000000..a7190346f --- /dev/null +++ b/server/lib/agentproxy/config_test.go @@ -0,0 +1,121 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadConfig(t *testing.T) { + valid := `{"acpremote":"/opt/acp/bin/acpremote","maxConnections":8,"harnesses":{"pi":{"command":"/opt/pi-acp","cwd":"/workspace","args":[],"env":{},"inheritEnv":[]}}}` + for _, test := range []struct { + name string + data string + ok bool + }{ + {"valid", valid, true}, + {"unknown field", strings.Replace(valid, `"maxConnections":8`, `"typo":8`, 1), false}, + {"extra JSON", valid + `{}`, false}, + {"oversized", valid + strings.Repeat(" ", 64<<10), false}, + {"relative executable", strings.Replace(valid, "/opt/pi-acp", "pi-acp", 1), false}, + {"relative cwd", strings.Replace(valid, "/workspace", "workspace", 1), false}, + {"unknown harness", strings.Replace(valid, `"pi":`, `"other":`, 1), false}, + {"no connection bound", strings.Replace(valid, `"maxConnections":8`, `"maxConnections":0`, 1), false}, + {"reserved environment", strings.Replace(valid, `"env":{}`, `"env":{"KERNEL_ACP_BRIDGE_TOKEN":"override"}`, 1), false}, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := os.WriteFile(path, []byte(test.data), 0600); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if (err == nil) != test.ok { + t.Fatalf("load: %v", err) + } + }) + } +} + +func TestEnvironmentIsExplicit(t *testing.T) { + t.Setenv("AGENT_PROXY_TEST_SECRET", "do-not-inherit") + t.Setenv("AGENT_PROXY_TEST_ALLOWED", "inherited") + h := Harness{InheritEnv: []string{"AGENT_PROXY_TEST_ALLOWED"}, Env: map[string]string{"HOME": "/shared/pi"}} + env, err := h.environment("internal-token") + if err != nil { + t.Fatal(err) + } + joined := strings.Join(env, "\n") + if strings.Contains(joined, "do-not-inherit") || !strings.Contains(joined, "AGENT_PROXY_TEST_ALLOWED=inherited") || !strings.Contains(joined, "HOME=/shared/pi") { + t.Fatal("environment inheritance or override is incorrect") + } + t.Setenv("AGENT_PROXY_TEST_ALLOWED", "") + if _, err := h.environment("internal-token"); err == nil { + t.Fatal("empty required environment accepted") + } +} + +func TestBridgeAddress(t *testing.T) { + for _, address := range []string{"ws://127.0.0.1:1234/acp/ws", "ws://example.com:1234/acp/ws", "ws://127.0.0.1:0/acp/ws", "ws://127.0.0.1:70000/acp/ws", "ws://127.0.0.1:1234/acp/ws?secret=x", "ws://user@127.0.0.1:1234/acp/ws", "ws://127.0.0.1:1234/other"} { + got := bridgeAddress("Serving ACP WebSocket at " + address) + if (got != "") != (address == "ws://127.0.0.1:1234/acp/ws") { + t.Fatalf("unexpected address accepted: %q", got) + } + } +} + +func TestHTTPAdmissionAndDiscovery(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + config := Config{ACPRemote: "/nonexistent/acpremote", MaxConnections: 1, Harnesses: map[string]Harness{ + "pi": {Command: "/nonexistent/pi", Cwd: t.TempDir(), Env: map[string]string{"PRIVATE_KEY": "secret-value"}}, + }} + h, err := New(ctx, config, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + t.Fatal(err) + } + response := httptest.NewRecorder() + h.ServeHTTP(response, httptest.NewRequest(http.MethodGet, "/agent/v1/harnesses", nil)) + var discovery struct { + Configured []string `json:"configured"` + } + if err := json.Unmarshal(response.Body.Bytes(), &discovery); err != nil || len(discovery.Configured) != 1 || discovery.Configured[0] != "pi" { + t.Fatal("invalid configured harness list") + } + if strings.Contains(response.Body.String(), "secret-value") { + t.Fatal("discovery leaked launch environment") + } + for _, test := range []struct { + path string + code int + }{ + {"/agent/v1/acp?harness=unknown", 404}, + {"/agent/v1/acp?harness=pi", 400}, + {"/agent/v1/commands", 404}, + } { + response := httptest.NewRecorder() + h.ServeHTTP(response, httptest.NewRequest(http.MethodGet, test.path, nil)) + if response.Code != test.code { + t.Fatalf("%s: %d", test.path, response.Code) + } + } + request := httptest.NewRequest(http.MethodGet, "/agent/v1/acp?harness=pi", nil) + request.Header.Set("Upgrade", "websocket") + response = httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != 503 || len(h.slots) != 0 { + t.Fatal("failed startup did not release admission slot") + } + cancel() + response = httptest.NewRecorder() + h.ServeHTTP(response, request) + if response.Code != 503 { + t.Fatal("shutdown accepted a new connection") + } +} diff --git a/server/lib/agentproxy/configuration_http.go b/server/lib/agentproxy/configuration_http.go new file mode 100644 index 000000000..67d5fcced --- /dev/null +++ b/server/lib/agentproxy/configuration_http.go @@ -0,0 +1,65 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" +) + +func (h *Handler) piConfiguration(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 PiConfiguration + if err := decoder.Decode(&desired); err != nil { + http.Error(w, "invalid pi 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.Pi.validateDesired(desired); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + // Normalize empty arrays so configuration responses never serialize them as null. + if desired.Shared.Extensions == nil { + desired.Shared.Extensions = make([]string, 0) + } + 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.pi.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.pi.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/configuration_http_test.go b/server/lib/agentproxy/configuration_http_test.go new file mode 100644 index 000000000..cb70c4c72 --- /dev/null +++ b/server/lib/agentproxy/configuration_http_test.go @@ -0,0 +1,61 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestConfigurationHTTP(t *testing.T) { + t.Setenv("PI_HTTP_TEST_KEY", "private-provider-key") + options := &PiOptions{StateDir: t.TempDir(), RuntimeDir: "/runtime/pi", Node: "/bin/node", Bun: "/bin/bun", Credentials: map[string]string{"provider": "PI_HTTP_TEST_KEY"}} + h, err := New(context.Background(), Config{ACPRemote: "/bin/acpremote", MaxConnections: 1, Pi: options}, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + t.Fatal(err) + } + h.pi.preparer = prepareFunc(func(_ context.Context, dir string, _ json.RawMessage) (Harness, error) { + return Harness{Command: "/bin/true", Cwd: dir, Env: map[string]string{"HIDDEN": "private-provider-key"}}, nil + }) + path := "/agent/v1/harnesses/pi/config" + request := func(method, 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 + } + initial := request(http.MethodGet, "", "") + if initial.Code != 200 || initial.Header().Get("ETag") != `"0"` { + t.Fatal(initial) + } + body := `{"launch":{"provider":"openrouter","model":"z-ai/glm-5.3","thinking":"low","credential":"provider"},"shared":{}}` + if w := request(http.MethodPut, body, ""); w.Code != 428 { + t.Fatal(w.Code) + } + if w := request(http.MethodPut, strings.Replace(body, `"shared":{}`, `"typo":{}`, 1), `"0"`); w.Code != 400 { + t.Fatal(w.Code) + } + ready := request(http.MethodPut, body, `"0"`) + if ready.Code != 200 || !strings.Contains(ready.Body.String(), `"status":"ready"`) { + t.Fatal(ready.Body.String()) + } + if strings.Contains(ready.Body.String(), "private-provider-key") || strings.Contains(ready.Body.String(), "PI_HTTP_TEST_KEY") { + t.Fatal("configuration response leaked credentials or binding implementation") + } + if !strings.Contains(ready.Body.String(), `"extensions":[]`) { + t.Fatal("extensions serialized as null") + } + if w := request(http.MethodPut, body, `"0"`); w.Code != 409 { + t.Fatal("stale revision accepted") + } + if w := request(http.MethodDelete, "", ""); w.Code != 405 { + t.Fatal(w.Code) + } +} diff --git a/server/lib/agentproxy/disconnect_test.go b/server/lib/agentproxy/disconnect_test.go new file mode 100644 index 000000000..fdf6432cf --- /dev/null +++ b/server/lib/agentproxy/disconnect_test.go @@ -0,0 +1,28 @@ +package agentproxy + +import ( + "syscall" + "testing" +) + +func TestAbruptDisconnect(t *testing.T) { + for _, prompt := range []string{"block", "permission"} { + t.Run(prompt, func(t *testing.T) { + handler, address, dir, _ := fixture(t, 1) + conn := connect(t, address) + pid := initialize(t, conn) + session := newSession(t, conn, 2, dir) + send(t, conn, map[string]any{"jsonrpc": "2.0", "id": 3, "method": "session/prompt", "params": textPrompt(session, prompt)}) + message := read(t, conn) + expected := "session/update" + if prompt == "permission" { + expected = "session/request_permission" + } + if message.Method != expected { + t.Fatalf("agent did not reach %s", prompt) + } + conn.CloseNow() // No WebSocket close handshake or ACP cancellation. + eventually(t, func() bool { return syscall.Kill(pid, 0) == syscall.ESRCH && len(handler.slots) == 0 }) + }) + } +} diff --git a/server/lib/agentproxy/handler.go b/server/lib/agentproxy/handler.go new file mode 100644 index 000000000..d294cda22 --- /dev/null +++ b/server/lib/agentproxy/handler.go @@ -0,0 +1,117 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "sort" + "strings" + "time" + + "github.com/coder/websocket" + "github.com/kernel/kernel-images/server/lib/wsdrain" + "github.com/kernel/kernel-images/server/lib/wsproxy" +) + +type Handler struct { + ctx context.Context + config Config + logger *slog.Logger + registry *wsdrain.Registry + slots chan struct{} + pi *configurationManager +} + +func New(ctx context.Context, config Config, logger *slog.Logger, registry *wsdrain.Registry) (*Handler, error) { + if err := config.validate(); err != nil { + return nil, err + } + h := &Handler{ctx: ctx, config: config, logger: logger, registry: registry, slots: make(chan struct{}, config.MaxConnections)} + if config.Pi != nil { + var err error + h.pi, err = newConfigurationManager(config.Pi.StateDir, *config.Pi) + if err != nil { + return nil, err + } + } + return h, nil +} + +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/agent/v1/harnesses": + names := make([]string, 0, len(h.config.Harnesses)) + if h.pi != nil { + names = append(names, "pi") + } + for name := range h.config.Harnesses { + if name == "pi" && h.pi != nil { + continue + } + names = append(names, name) + } + sort.Strings(names) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(struct { + Configured []string `json:"configured"` + }{names}) + case r.URL.Path == "/agent/v1/harnesses/pi/config" && h.pi != nil: + h.piConfiguration(w, r) + case r.Method == http.MethodGet && r.URL.Path == "/agent/v1/acp": + h.connect(w, r) + default: + http.NotFound(w, r) + } +} + +func (h *Handler) connect(w http.ResponseWriter, r *http.Request) { + name := r.URL.Query().Get("harness") + harness, ok := h.config.Harnesses[name] + if name == "pi" && h.pi != nil { + harness, ok = h.pi.preparedLaunch() + if !ok { + http.Error(w, "pi configuration is not ready", http.StatusConflict) + return + } + } + if !ok { + http.Error(w, "harness is not configured", http.StatusNotFound) + return + } + if !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") { + http.Error(w, "WebSocket upgrade required", http.StatusBadRequest) + return + } + ctx, cancel := context.WithCancel(r.Context()) + stop := context.AfterFunc(h.ctx, cancel) + defer stop() + defer cancel() + if h.ctx.Err() != nil { + http.Error(w, "agent service is shutting down", http.StatusServiceUnavailable) + return + } + select { + case h.slots <- struct{}{}: + defer func() { <-h.slots }() + default: + http.Error(w, "agent connection limit reached", http.StatusTooManyRequests) + return + } + b, err := startBridge(ctx, h.config.ACPRemote, harness) + if err != nil { + h.logger.Warn("ACP bridge startup failed", "harness", name, "err", err) + http.Error(w, "ACP bridge unavailable", http.StatusServiceUnavailable) + return + } + defer b.close() + wsproxy.Proxy(w, r.WithContext(ctx), b.url, wsproxy.ProxyOptions{ + // Same browser-scoped authentication boundary as the process attach API. + AcceptOptions: &websocket.AcceptOptions{OriginPatterns: []string{"*"}, Subprotocols: []string{"acp.v1"}}, + DialOptions: &websocket.DialOptions{HTTPHeader: b.headers}, + Logger: h.logger, + Registry: h.registry, + ReadLimit: 1 << 20, // acpremote 1.7.0's default message limit + PingInterval: 20 * time.Second, + }) +} diff --git a/server/lib/agentproxy/managed.go b/server/lib/agentproxy/managed.go new file mode 100644 index 000000000..679b23e91 --- /dev/null +++ b/server/lib/agentproxy/managed.go @@ -0,0 +1,220 @@ +package agentproxy + +import ( + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sync" +) + +// Preparer materializes an immutable revision without changing the active one. +// It must not write shared session data or inherit provider credentials. +type Preparer interface { + Prepare(context.Context, string, json.RawMessage) (Harness, error) +} + +type Configuration struct { + Revision string `json:"revision"` + Status string `json:"status"` + Desired json.RawMessage `json:"desired"` + EffectiveRevision string `json:"effectiveRevision"` + Effective json.RawMessage `json:"effective"` + Error string `json:"error,omitempty"` +} + +type preparedRevision struct { + Configuration json.RawMessage `json:"configuration"` + Launch Harness `json:"launch"` +} + +type configurationManager struct { + mu sync.RWMutex + preparing sync.Mutex + root string + preparer Preparer + state Configuration + launch *Harness +} + +var errConfigurationConflict = errors.New("configuration changed or preparation already in progress") + +func newConfigurationManager(root string, preparer Preparer) (*configurationManager, error) { + if !filepath.IsAbs(root) { + return nil, errors.New("configuration directory must be absolute") + } + if err := os.MkdirAll(filepath.Join(root, "revisions"), 0700); err != nil { + return nil, err + } + m := &configurationManager{root: root, preparer: preparer, state: Configuration{Revision: "0", Status: "unconfigured", Desired: json.RawMessage("null"), Effective: json.RawMessage("null")}} + data, err := os.ReadFile(filepath.Join(root, "desired.json")) + if err == nil { + if err = json.Unmarshal(data, &m.state); err != nil { + return nil, err + } + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + data, err = os.ReadFile(filepath.Join(root, "current", "prepared.json")) + if err == nil { + var revision preparedRevision + if err = json.Unmarshal(data, &revision); err != nil { + return nil, err + } + target, err := os.Readlink(filepath.Join(root, "current")) + if err != nil { + return nil, err + } + m.state.EffectiveRevision = filepath.Base(target) + m.state.Effective = revision.Configuration + m.launch = &revision.Launch + } else if !errors.Is(err, os.ErrNotExist) { + return nil, err + } + if m.state.Revision == m.state.EffectiveRevision { + m.state.Status = "ready" + m.state.Error = "" + } else if m.state.Status == "preparing" { + m.state.Status = "failed" + m.state.Error = "preparation interrupted; last ready revision retained" + } + return m, nil +} + +func (m *configurationManager) snapshot() Configuration { + m.mu.RLock() + defer m.mu.RUnlock() + s := m.state + s.Desired = append(json.RawMessage(nil), s.Desired...) + s.Effective = append(json.RawMessage(nil), s.Effective...) + return s +} + +func (m *configurationManager) preparedLaunch() (Harness, bool) { + m.mu.RLock() + defer m.mu.RUnlock() + if m.launch == nil { + return Harness{}, false + } + return *m.launch, true +} + +func (m *configurationManager) apply(ctx context.Context, expected string, desired json.RawMessage) error { + if !m.preparing.TryLock() { + return errConfigurationConflict + } + defer m.preparing.Unlock() + m.mu.Lock() + if expected != m.state.Revision { + m.mu.Unlock() + return errConfigurationConflict + } + token := make([]byte, 16) + if _, err := rand.Read(token); err != nil { + m.mu.Unlock() + return err + } + revision := hex.EncodeToString(token) + previous := m.state + m.state.Revision = revision + m.state.Desired = append(json.RawMessage(nil), desired...) + m.state.Status = "preparing" + m.state.Error = "" + if err := m.persist(); err != nil { + m.state = previous + m.mu.Unlock() + return err + } + m.mu.Unlock() + dir := filepath.Join(m.root, "revisions", revision) + launch, err := m.prepare(ctx, dir, desired) + if err != nil { + _ = os.RemoveAll(dir) + } + m.mu.Lock() + defer m.mu.Unlock() + if err != nil { + m.state.Status = "failed" + m.state.Error = "configuration preparation failed; last ready revision retained" + _ = m.persist() + return err + } + // The symlink is the commit point for both launch metadata and shared settings. + temporary := filepath.Join(m.root, "next-"+revision) + if err = os.Symlink(filepath.Join("revisions", revision), temporary); err == nil { + err = os.Rename(temporary, filepath.Join(m.root, "current")) + } + if err != nil { + os.Remove(temporary) + m.state.Status = "failed" + m.state.Error = "configuration activation failed" + _ = m.persist() + return err + } + m.launch = &launch + m.state.Status = "ready" + m.state.Error = "" + m.state.EffectiveRevision = revision + m.state.Effective = append(json.RawMessage(nil), desired...) + // current is already committed. A status-write failure must not report a + // rejected update; restart reconstructs ready state from the same pointer. + _ = m.persist() + return nil +} + +func (m *configurationManager) prepare(ctx context.Context, dir string, desired json.RawMessage) (Harness, error) { + if err := os.Mkdir(dir, 0700); err != nil { + return Harness{}, err + } + launch, err := m.preparer.Prepare(ctx, dir, desired) + if err != nil { + return Harness{}, err + } + if err = ctx.Err(); err != nil { + return Harness{}, err + } + check := Config{ACPRemote: "/validated-by-owner", MaxConnections: 1, Harnesses: map[string]Harness{"pi": launch}} + if err = check.validate(); err != nil { + return Harness{}, fmt.Errorf("invalid prepared launch: %w", err) + } + data, err := json.Marshal(preparedRevision{Configuration: desired, Launch: launch}) + if err != nil { + return Harness{}, err + } + if err = atomicWrite(filepath.Join(dir, "prepared.json"), data); err != nil { + return Harness{}, err + } + return launch, nil +} + +// Caller holds mu; desired state is separate from the atomic active-revision pointer. +func (m *configurationManager) persist() error { + data, err := json.Marshal(m.state) + if err != nil { + return err + } + return atomicWrite(filepath.Join(m.root, "desired.json"), data) +} + +func atomicWrite(path string, data []byte) error { + file, err := os.CreateTemp(filepath.Dir(path), ".config-") + if err != nil { + return err + } + defer os.Remove(file.Name()) + if _, err = file.Write(data); err == nil { + err = file.Sync() + } + closeErr := file.Close() + if err != nil { + return err + } + if closeErr != nil { + return closeErr + } + return os.Rename(file.Name(), path) +} diff --git a/server/lib/agentproxy/managed_test.go b/server/lib/agentproxy/managed_test.go new file mode 100644 index 000000000..3269b957a --- /dev/null +++ b/server/lib/agentproxy/managed_test.go @@ -0,0 +1,115 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +type prepareFunc func(context.Context, string, json.RawMessage) (Harness, error) + +func (f prepareFunc) Prepare(ctx context.Context, dir string, data json.RawMessage) (Harness, error) { + return f(ctx, dir, data) +} + +func TestConfigurationCommitAndRecovery(t *testing.T) { + root := t.TempDir() + prepare := prepareFunc(func(_ context.Context, dir string, data json.RawMessage) (Harness, error) { + if string(data) == `{"fail":true}` { + return Harness{}, errors.New("installation failed") + } + return Harness{Command: "/bin/true", Cwd: dir}, nil + }) + m, err := newConfigurationManager(root, prepare) + if err != nil { + t.Fatal(err) + } + if _, ok := m.preparedLaunch(); ok { + t.Fatal("unprepared launch available") + } + if err = m.apply(context.Background(), "0", json.RawMessage(`{"version":1}`)); err != nil { + t.Fatal(err) + } + ready := m.snapshot() + if ready.Status != "ready" || ready.Revision != ready.EffectiveRevision { + t.Fatal(ready) + } + if err = m.apply(context.Background(), "0", json.RawMessage(`{}`)); !errors.Is(err, errConfigurationConflict) { + t.Fatal("stale write accepted") + } + if err = m.apply(context.Background(), ready.Revision, json.RawMessage(`{"fail":true}`)); err == nil { + t.Fatal("failed preparation succeeded") + } + failed := m.snapshot() + if failed.Status != "failed" || failed.EffectiveRevision != ready.Revision || string(failed.Effective) != `{"version":1}` { + t.Fatal(failed) + } + restored, err := newConfigurationManager(root, prepare) + if err != nil { + t.Fatal(err) + } + if restored.snapshot().EffectiveRevision != ready.Revision { + t.Fatal("restart lost last ready revision") + } + // A crash after the commit point but before the status write is recovered from current. + restored.state.Status = "preparing" + restored.state.Revision = ready.Revision + if err = restored.persist(); err != nil { + t.Fatal(err) + } + restored, err = newConfigurationManager(root, prepare) + if err != nil { + t.Fatal(err) + } + if restored.snapshot().Status != "ready" { + t.Fatal("committed revision not recovered") + } +} + +func TestPreparationIsExclusiveAndCancellable(t *testing.T) { + started := make(chan struct{}) + finish := make(chan error, 1) + m, err := newConfigurationManager(t.TempDir(), prepareFunc(func(ctx context.Context, _ string, _ json.RawMessage) (Harness, error) { + close(started) + <-ctx.Done() + return Harness{}, ctx.Err() + })) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go func() { finish <- m.apply(ctx, "0", json.RawMessage(`{}`)) }() + <-started + state := m.snapshot() + if state.Status != "preparing" { + t.Fatal(state) + } + if err = m.apply(ctx, state.Revision, json.RawMessage(`{}`)); !errors.Is(err, errConfigurationConflict) { + t.Fatal("concurrent preparation accepted") + } + cancel() + if err = <-finish; !errors.Is(err, context.Canceled) { + t.Fatal(err) + } + if _, ok := m.preparedLaunch(); ok { + t.Fatal("cancelled revision activated") + } +} + +func TestConfigurationFilesArePrivate(t *testing.T) { + path := filepath.Join(t.TempDir(), "config.json") + if err := atomicWrite(path, []byte(`{}`)); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0600 { + t.Fatal(info.Mode()) + } +} diff --git a/server/lib/agentproxy/mcp.go b/server/lib/agentproxy/mcp.go new file mode 100644 index 000000000..7884e9fd6 --- /dev/null +++ b/server/lib/agentproxy/mcp.go @@ -0,0 +1,77 @@ +package agentproxy + +import ( + "errors" + "net/url" + "path/filepath" + "regexp" + "strings" +) + +type CredentialHeader struct { + Credential string `json:"credential"` + Prefix string `json:"prefix,omitempty"` +} +type ManagedMCPServer struct { + Name string `json:"name"` + Command string `json:"command,omitempty"` + Args []string `json:"args,omitempty"` + EnvBindings map[string]string `json:"envBindings,omitempty"` + URL string `json:"url,omitempty"` + Transport string `json:"transport,omitempty"` + HeaderBindings map[string]CredentialHeader `json:"headerBindings,omitempty"` +} + +var headerName = regexp.MustCompile("^[!#$%&'*+.^_`|~0-9A-Za-z-]+$") + +func validateMCPServers(servers []ManagedMCPServer) ([]string, error) { + if len(servers) > 32 { + return nil, errors.New("at most 32 MCP servers are supported") + } + bindings := make([]string, 0) + names := make(map[string]bool) + for _, server := range servers { + if server.Name == "" || names[server.Name] { + return nil, errors.New("MCP server names must be nonempty and unique") + } + names[server.Name] = true + if err := validateMCPTransport(server); err != nil { + return nil, err + } + for _, arg := range server.Args { + if strings.ContainsRune(arg, '\x00') { + return nil, errors.New("invalid MCP argument") + } + } + for name, binding := range server.EnvBindings { + if !validEnvName(name) { + return nil, errors.New("invalid MCP environment name") + } + bindings = append(bindings, binding) + } + for name, header := range server.HeaderBindings { + if !headerName.MatchString(name) || strings.ContainsAny(header.Prefix, "\r\n\x00") { + return nil, errors.New("invalid MCP header") + } + bindings = append(bindings, header.Credential) + } + } + return bindings, nil +} + +func validateMCPTransport(server ManagedMCPServer) error { + if server.Command != "" { + if !filepath.IsAbs(server.Command) || server.URL != "" || server.Transport != "" || len(server.HeaderBindings) != 0 { + return errors.New("invalid stdio MCP server") + } + return nil + } + u, err := url.Parse(server.URL) + if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || len(server.Args) != 0 || len(server.EnvBindings) != 0 { + return errors.New("MCP URL must be HTTP(S), without embedded credentials or query parameters") + } + if server.Transport != "http" && server.Transport != "sse" { + return errors.New("MCP transport must be http or sse") + } + return nil +} diff --git a/server/lib/agentproxy/mcp_test.go b/server/lib/agentproxy/mcp_test.go new file mode 100644 index 000000000..76616146e --- /dev/null +++ b/server/lib/agentproxy/mcp_test.go @@ -0,0 +1,32 @@ +package agentproxy + +import "testing" + +func TestManagedMCPValidation(t *testing.T) { + for _, test := range []struct { + name string + server ManagedMCPServer + valid bool + }{ + {"stdio", ManagedMCPServer{Name: "docs", Command: "/bin/server", EnvBindings: map[string]string{"TOKEN": "docs"}}, true}, + {"http", ManagedMCPServer{Name: "docs", URL: "https://example.com/mcp", Transport: "http", HeaderBindings: map[string]CredentialHeader{"Authorization": {Credential: "docs", Prefix: "Bearer "}}}, true}, + {"sse", ManagedMCPServer{Name: "docs", URL: "https://example.com/sse", Transport: "sse"}, true}, + {"relative command", ManagedMCPServer{Name: "docs", Command: "server"}, false}, + {"mixed transports", ManagedMCPServer{Name: "docs", Command: "/bin/server", URL: "https://example.com"}, false}, + {"URL credential", ManagedMCPServer{Name: "docs", URL: "https://user:password@example.com", Transport: "http"}, false}, + {"URL query", ManagedMCPServer{Name: "docs", URL: "https://example.com?token=x", Transport: "http"}, false}, + {"header injection", ManagedMCPServer{Name: "docs", URL: "https://example.com", Transport: "http", HeaderBindings: map[string]CredentialHeader{"Bad Header": {Credential: "docs"}}}, false}, + {"NUL argument", ManagedMCPServer{Name: "docs", Command: "/bin/server", Args: []string{"\x00"}}, false}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := validateMCPServers([]ManagedMCPServer{test.server}) + if (err == nil) != test.valid { + t.Fatal(err) + } + }) + } + server := ManagedMCPServer{Name: "docs", Command: "/bin/server"} + if _, err := validateMCPServers([]ManagedMCPServer{server, server}); err == nil { + t.Fatal("duplicate name accepted") + } +} diff --git a/server/lib/agentproxy/pi.go b/server/lib/agentproxy/pi.go new file mode 100644 index 000000000..1325d3500 --- /dev/null +++ b/server/lib/agentproxy/pi.go @@ -0,0 +1,129 @@ +package agentproxy + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "syscall" + "time" +) + +type PiOptions struct { + StateDir string `json:"stateDir"` + RuntimeDir string `json:"runtimeDir"` + Node string `json:"node"` + Bun string `json:"bun"` + Credentials map[string]string `json:"credentials"` + Registry string `json:"registry,omitempty"` +} + +type PiConfiguration struct { + Launch PiLaunch `json:"launch"` + Shared PiShared `json:"shared"` +} +type PiLaunch struct { + Provider string `json:"provider"` + Model string `json:"model"` + Thinking string `json:"thinking"` + Credential string `json:"credential"` +} +type PiShared struct { + Extensions []string `json:"extensions"` + MCPServers []ManagedMCPServer `json:"mcpServers"` +} + +var pinnedPackage = regexp.MustCompile(`^npm:(@[-a-z0-9_.]+/)?[a-z0-9][-a-z0-9_.]*@[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$`) +var providerEnv = map[string]string{"openrouter": "OPENROUTER_API_KEY", "openai": "OPENAI_API_KEY", "anthropic": "ANTHROPIC_API_KEY", "google": "GEMINI_API_KEY"} + +func (p PiOptions) validate() error { + if !filepath.IsAbs(p.StateDir) || !filepath.IsAbs(p.RuntimeDir) || !filepath.IsAbs(p.Node) || !filepath.IsAbs(p.Bun) { + return errors.New("pi paths must be absolute") + } + for name, source := range p.Credentials { + if name == "" || !validEnvName(source) { + return errors.New("invalid credential binding") + } + } + return nil +} +func (p PiOptions) validateDesired(c PiConfiguration) error { + if providerEnv[c.Launch.Provider] == "" || strings.TrimSpace(c.Launch.Model) == "" || len(c.Launch.Model) > 512 || strings.HasPrefix(c.Launch.Model, "-") || strings.ContainsAny(c.Launch.Model, "\x00\r\n") { + return errors.New("supported provider and model are required") + } + switch c.Launch.Thinking { + case "off", "minimal", "low", "medium", "high", "xhigh": + default: + return errors.New("invalid thinking level") + } + if len(c.Shared.Extensions) > 32 { + return errors.New("at most 32 extensions are supported") + } + for _, source := range c.Shared.Extensions { + if !pinnedPackage.MatchString(source) { + return errors.New("extensions must be exact-version npm package references") + } + } + bindings, err := validateMCPServers(c.Shared.MCPServers) + if err != nil { + return err + } + 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 PiOptions) Prepare(ctx context.Context, dir string, desired json.RawMessage) (Harness, error) { + var c PiConfiguration + 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, p.StateDir) + command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + command.Cancel = func() error { return syscall.Kill(-command.Process.Pid, syscall.SIGKILL) } + command.WaitDelay = time.Second + registry := p.Registry + if registry == "" { + registry = "https://registry.npmjs.org" + } + command.Env = []string{"PATH=" + os.Getenv("PATH"), "HOME=" + dir, "PI_SKIP_VERSION_CHECK=1", "KERNEL_PI_REGISTRY=" + registry, "KERNEL_PI_BUN=" + p.Bun} + err := command.Run() + if command.Process != nil { + // Extension validation must not leave preparation-time descendants running. + _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL) + } + if err != nil { + return Harness{}, errors.New("pi package preparation or validation failed") + } + refs := map[string]string{c.Launch.Credential: p.Credentials[c.Launch.Credential]} + for _, s := range c.Shared.MCPServers { + for _, b := range s.EnvBindings { + refs[b] = p.Credentials[b] + } + for _, h := range s.HeaderBindings { + refs[h.Credential] = p.Credentials[h.Credential] + } + } + 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"), "PI_CODING_AGENT_DIR": filepath.Join(p.StateDir, "current", "agent"), "KERNEL_PI_STATE": p.StateDir, "KERNEL_PI_BINDINGS": string(bindings), "KERNEL_PI_PROVIDER_ENV": providerEnv[c.Launch.Provider], "KERNEL_PI_PROVIDER_SOURCE": p.Credentials[c.Launch.Credential]}, InheritEnv: inherited}, nil +} diff --git a/server/lib/agentproxy/pi_test.go b/server/lib/agentproxy/pi_test.go new file mode 100644 index 000000000..ea9f32ad0 --- /dev/null +++ b/server/lib/agentproxy/pi_test.go @@ -0,0 +1,128 @@ +package agentproxy + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPiConfigurationValidation(t *testing.T) { + t.Setenv("PI_TEST_KEY", "not-a-real-key") + p := PiOptions{Credentials: map[string]string{"provider": "PI_TEST_KEY"}} + valid := PiConfiguration{Launch: PiLaunch{Provider: "openrouter", Model: "z-ai/glm-5.3", Thinking: "low", Credential: "provider"}} + if err := p.validateDesired(valid); err != nil { + t.Fatal(err) + } + for _, source := range []string{"npm:extension", "npm:extension@latest", "npm:extension@^1.2.3", "/tmp/extension", "npm:extension@1.0.0 --evil"} { + c := valid + c.Shared.Extensions = []string{source} + if err := p.validateDesired(c); err == nil { + t.Fatalf("accepted %s", source) + } + } + valid.Shared.Extensions = []string{"npm:@example/extension@1.2.3"} + if err := p.validateDesired(valid); err != nil { + t.Fatal(err) + } + valid.Launch.Credential = "unknown" + if err := p.validateDesired(valid); err == nil { + t.Fatal("unknown credential accepted") + } +} + +func extensionArchive(t *testing.T, broken bool) []byte { + t.Helper() + var output bytes.Buffer + gz := gzip.NewWriter(&output) + archive := tar.NewWriter(gz) + source := `export default function(pi) { pi.registerCommand("fixture-command", { description: "fixture", handler: async () => {} }); }` + manifest := `{"name":"test-pi-extension","version":"1.0.0","type":"module","pi":{"extensions":["index.js"]}}` + if broken { + source = `export default function(pi) { throw new Error("fixture extension is broken"); }` + manifest = strings.Replace(manifest, "1.0.0", "1.0.1", 1) + } + for name, data := range map[string]string{"package/package.json": manifest, "package/index.js": source} { + if err := archive.WriteHeader(&tar.Header{Name: name, Mode: 0600, Size: int64(len(data))}); err != nil { + t.Fatal(err) + } + if _, err := io.WriteString(archive, data); err != nil { + t.Fatal(err) + } + } + if err := archive.Close(); err != nil { + t.Fatal(err) + } + if err := gz.Close(); err != nil { + t.Fatal(err) + } + return output.Bytes() +} + +func TestPiPreparesPackagesAndRetainsLastReady(t *testing.T) { + runtime := os.Getenv("AGENT_PI_TEST_RUNTIME") + if runtime == "" { + t.Skip("set AGENT_PI_TEST_RUNTIME to the installed pinned pi runtime") + } + node, err := exec.LookPath("node") + if err != nil { + t.Fatal(err) + } + bun, err := exec.LookPath("bun") + if err != nil { + t.Fatal(err) + } + // Preparation must use the configured executables, not PATH lookup. + t.Setenv("PATH", "") + good, bad := extensionArchive(t, false), extensionArchive(t, true) + var registry *httptest.Server + registry = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/test-pi-extension": + _ = json.NewEncoder(w).Encode(map[string]any{"name": "test-pi-extension", "versions": map[string]any{ + "1.0.0": map[string]any{"name": "test-pi-extension", "version": "1.0.0", "dist": map[string]string{"tarball": registry.URL + "/good.tgz"}}, + "1.0.1": map[string]any{"name": "test-pi-extension", "version": "1.0.1", "dist": map[string]string{"tarball": registry.URL + "/bad.tgz"}}, + }}) + case "/good.tgz": + _, _ = w.Write(good) + case "/bad.tgz": + _, _ = w.Write(bad) + default: + http.NotFound(w, r) + } + })) + defer registry.Close() + t.Setenv("PI_TEST_KEY", "not-a-real-key") + p := PiOptions{StateDir: t.TempDir(), RuntimeDir: runtime, Node: node, Bun: bun, Registry: registry.URL, Credentials: map[string]string{"provider": "PI_TEST_KEY"}} + m, err := newConfigurationManager(p.StateDir, p) + if err != nil { + t.Fatal(err) + } + c := PiConfiguration{Launch: PiLaunch{Provider: "openrouter", Model: "z-ai/glm-5.3", Thinking: "low", Credential: "provider"}, Shared: PiShared{Extensions: []string{"npm:test-pi-extension@1.0.0"}, MCPServers: make([]ManagedMCPServer, 0)}} + data, _ := json.Marshal(c) + if err = m.apply(context.Background(), "0", data); err != nil { + t.Fatal(err) + } + ready := m.snapshot() + contents, err := os.ReadFile(filepath.Join(p.StateDir, "current", "node_modules", "test-pi-extension", "index.js")) + if err != nil || !strings.Contains(string(contents), "fixture-command") { + t.Fatal("extension package was not materialized", err) + } + c.Shared.Extensions = []string{"npm:test-pi-extension@1.0.1"} + data, _ = json.Marshal(c) + if err = m.apply(context.Background(), ready.Revision, data); err == nil { + t.Fatal("broken extension activated") + } + if state := m.snapshot(); state.Status != "failed" || state.EffectiveRevision != ready.Revision { + t.Fatal(state) + } +} diff --git a/server/lib/agentproxy/pi_wrapper_test.go b/server/lib/agentproxy/pi_wrapper_test.go new file mode 100644 index 000000000..44925931f --- /dev/null +++ b/server/lib/agentproxy/pi_wrapper_test.go @@ -0,0 +1,42 @@ +package agentproxy + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +func TestPiWrapperUsesConfiguredNodeWithoutPATH(t *testing.T) { + wrapper, err := filepath.Abs("../../runtime/acp/pi/pi-command") + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(wrapper) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm()&0111 == 0 { + t.Fatal("Pi wrapper is not executable") + } + dir := t.TempDir() + node := filepath.Join(dir, "configured-node") + output := filepath.Join(dir, "args") + if err = os.WriteFile(node, []byte("#!/bin/sh\nprintf '%s\\n' \"$@\" > \"$OUTPUT\"\n"), 0700); err != nil { + t.Fatal(err) + } + command := exec.Command(wrapper, "--mode", "rpc") + command.Env = []string{"PATH=", "KERNEL_PI_NODE=" + node, "OUTPUT=" + output} + if data, err := command.CombinedOutput(); err != nil { + t.Fatalf("wrapper failed: %v: %s", err, data) + } + data, err := os.ReadFile(output) + if err != nil { + t.Fatal(err) + } + want := filepath.Join(filepath.Dir(wrapper), "pi-command.mjs") + "\n--mode\nrpc\n" + if string(data) != want { + t.Fatalf("arguments changed: %s", strings.TrimSpace(string(data))) + } +} diff --git a/server/lib/agentproxy/proxy_test.go b/server/lib/agentproxy/proxy_test.go new file mode 100644 index 000000000..0306a52fc --- /dev/null +++ b/server/lib/agentproxy/proxy_test.go @@ -0,0 +1,262 @@ +package agentproxy + +import ( + "bytes" + "context" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/coder/websocket" +) + +type rpcMessage struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` + Result json.RawMessage `json:"result"` + Error json.RawMessage `json:"error"` +} + +func fixture(t *testing.T, limit int) (*Handler, string, string, context.CancelFunc) { + t.Helper() + remote := os.Getenv("AGENT_PROXY_TEST_ACPREMOTE") + if remote == "" { + t.Skip("set AGENT_PROXY_TEST_ACPREMOTE to the pinned acpremote venv executable") + } + peer, err := filepath.Abs("testdata/agent.py") + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + config := Config{ACPRemote: remote, MaxConnections: limit, Harnesses: map[string]Harness{ + "pi": {Command: filepath.Join(filepath.Dir(remote), "python"), Args: []string{peer}, Cwd: dir, Env: map[string]string{"AGENT_PROXY_TEST_DIR": dir}}, + }} + ctx, cancel := context.WithCancel(context.Background()) + handler, err := New(ctx, config, slog.New(slog.NewTextHandler(io.Discard, nil)), nil) + if err != nil { + cancel() + t.Fatal(err) + } + server := httptest.NewServer(handler) + t.Cleanup(func() { cancel(); server.Close(); eventually(t, func() bool { return len(handler.slots) == 0 }) }) + return handler, "ws" + strings.TrimPrefix(server.URL, "http") + "/agent/v1/acp?harness=pi", dir, cancel +} + +func connect(t *testing.T, address string) *websocket.Conn { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, address, &websocket.DialOptions{Subprotocols: []string{"acp.v1"}}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.CloseNow() }) + if conn.Subprotocol() != "acp.v1" { + t.Fatal("client subprotocol not negotiated") + } + return conn +} + +func send(t *testing.T, conn *websocket.Conn, value any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // ACP UI sends newline-terminated JSON in its WebSocket text frames. + if err := conn.Write(ctx, websocket.MessageText, append(data, '\n')); err != nil { + t.Fatal(err) + } +} + +func read(t *testing.T, conn *websocket.Conn) rpcMessage { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, data, err := conn.Read(ctx) + if err != nil { + t.Fatal(err) + } + var message rpcMessage + if err := json.Unmarshal(data, &message); err != nil { + t.Fatal(err) + } + if len(message.Error) != 0 { + t.Fatalf("RPC error: %s", message.Error) + } + return message +} + +func call(t *testing.T, conn *websocket.Conn, id int, method string, params any) (json.RawMessage, []json.RawMessage) { + t.Helper() + send(t, conn, map[string]any{"jsonrpc": "2.0", "id": id, "method": method, "params": params}) + return result(t, conn, id) +} + +func result(t *testing.T, conn *websocket.Conn, id int) (json.RawMessage, []json.RawMessage) { + t.Helper() + var updates []json.RawMessage + for { + message := read(t, conn) + if string(message.ID) == strconv.Itoa(id) { + return message.Result, updates + } + if message.Method != "session/update" { + t.Fatalf("unexpected message: %+v", message) + } + updates = append(updates, message.Params) + } +} + +func initialize(t *testing.T, conn *websocket.Conn) int { + t.Helper() + data, _ := call(t, conn, 1, "initialize", map[string]any{"protocolVersion": 1, "clientCapabilities": map[string]any{}}) + var initialized struct { + Meta struct { + PID int `json:"pid"` + } `json:"_meta"` + } + if err := json.Unmarshal(data, &initialized); err != nil || initialized.Meta.PID == 0 { + t.Fatalf("initialize failed: %s", data) + } + return initialized.Meta.PID +} + +func newSession(t *testing.T, conn *websocket.Conn, id int, dir string) string { + t.Helper() + data, _ := call(t, conn, id, "session/new", map[string]any{"cwd": dir, "mcpServers": []any{}}) + var session struct { + ID string `json:"sessionId"` + } + if err := json.Unmarshal(data, &session); err != nil || session.ID == "" { + t.Fatalf("new session failed: %s", data) + } + return session.ID +} + +func textPrompt(sessionID, text string) map[string]any { + return map[string]any{"sessionId": sessionID, "prompt": []map[string]string{{"type": "text", "text": text}}} +} + +func eventually(t *testing.T, condition func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !condition() { + if time.Now().After(deadline) { + t.Fatal("condition timed out") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestConnectionLifecycle(t *testing.T) { + handler, address, dir, _ := fixture(t, 2) + first := connect(t, address) + firstPID := initialize(t, first) + firstSession := newSession(t, first, 2, dir) + if other := newSession(t, first, 3, dir); other == firstSession { + t.Fatal("ACP sessions were restricted to one per connection") + } + _, updates := call(t, first, 4, "session/prompt", textPrompt(firstSession, "remember this")) + if len(updates) != 1 || !bytes.Contains(updates[0], []byte("remember this")) { + t.Fatal("missing output") + } + second := connect(t, address) + secondPID := initialize(t, second) + if firstPID == secondPID { + t.Fatal("connections shared an agent process") + } + secondSession := newSession(t, second, 2, dir) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if conn, response, err := websocket.Dial(ctx, address, nil); err == nil { + conn.CloseNow() + t.Fatal("connection limit not enforced") + } else if response == nil || response.StatusCode != http.StatusTooManyRequests { + t.Fatalf("expected 429, got %+v: %v", response, err) + } + // Disconnect an in-flight turn: the agent must terminate, not detach. + send(t, first, map[string]any{"jsonrpc": "2.0", "id": 5, "method": "session/prompt", "params": textPrompt(firstSession, "block")}) + if message := read(t, first); message.Method != "session/update" { + t.Fatal("blocking turn did not start") + } + first.Close(websocket.StatusNormalClosure, "") + eventually(t, func() bool { return syscall.Kill(firstPID, 0) == syscall.ESRCH && len(handler.slots) == 1 }) + call(t, second, 3, "session/prompt", textPrompt(secondSession, "still alive")) + reconnected := connect(t, address) + if pid := initialize(t, reconnected); pid == firstPID || pid == secondPID { + t.Fatal("reconnect did not start a fresh agent") + } + _, restored := call(t, reconnected, 2, "session/load", map[string]any{"sessionId": firstSession, "cwd": dir, "mcpServers": []any{}}) + if len(restored) != 1 || !bytes.Contains(restored[0], []byte("remember this")) { + t.Fatal("exact native session history was not restored") + } + second.Close(websocket.StatusNormalClosure, "") + reconnected.Close(websocket.StatusNormalClosure, "") +} + +func TestACPContentAndPermissions(t *testing.T) { + _, address, dir, _ := fixture(t, 1) + conn := connect(t, address) + initialize(t, conn) + session := newSession(t, conn, 2, dir) + content := json.RawMessage(`[{"type":"image","data":"AAEC","mimeType":"image/png","_meta":{"n":9007199254740993}},{"type":"resource_link","uri":"file:///source","name":"source"}]`) + completion, updates := call(t, conn, 3, "session/prompt", map[string]any{"sessionId": session, "prompt": content}) + if len(updates) != 2 || !bytes.Contains(updates[0], []byte("9007199254740993")) || !bytes.Contains(completion, []byte("preserved")) { + t.Fatal("opaque ACP content or completion metadata changed") + } + send(t, conn, map[string]any{"jsonrpc": "2.0", "id": 4, "method": "session/prompt", "params": textPrompt(session, "permission")}) + permission := read(t, conn) + if permission.Method != "session/request_permission" || string(permission.ID) != `"approval"` { + t.Fatalf("permission request changed: %+v", permission) + } + send(t, conn, map[string]any{"jsonrpc": "2.0", "id": permission.ID, "result": map[string]any{"outcome": map[string]string{"outcome": "selected", "optionId": "allow"}}}) + _, updates = result(t, conn, 4) + if len(updates) != 1 || !bytes.Contains(updates[0], []byte("selected")) { + t.Fatal("permission response did not reach agent") + } + conn.Close(websocket.StatusNormalClosure, "") +} + +func TestShutdownClosesAgent(t *testing.T) { + handler, address, _, stop := fixture(t, 1) + conn := connect(t, address) + pid := initialize(t, conn) + stop() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, _, err := conn.Read(ctx); err == nil { + t.Fatal("shutdown did not close connection") + } + eventually(t, func() bool { return syscall.Kill(pid, 0) == syscall.ESRCH && len(handler.slots) == 0 }) +} + +func TestExistingACPMirrorClient(t *testing.T) { + _, address, dir, _ := fixture(t, 1) + remote := os.Getenv("AGENT_PROXY_TEST_ACPREMOTE") + client, err := filepath.Abs("testdata/client.py") + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + command := exec.CommandContext(ctx, filepath.Join(filepath.Dir(remote), "python"), client, remote, address, dir) + output, err := command.CombinedOutput() + if err != nil || !bytes.Contains(output, []byte(`"ok": true`)) { + t.Fatalf("existing client failed: %v\n%s", err, output) + } + t.Log(strings.TrimSpace(string(output))) +} diff --git a/server/lib/agentproxy/testdata/agent.py b/server/lib/agentproxy/testdata/agent.py new file mode 100644 index 000000000..fcebe1472 --- /dev/null +++ b/server/lib/agentproxy/testdata/agent.py @@ -0,0 +1,81 @@ +"""Deterministic ACP stdio peer; no model calls or provider credentials.""" +import json +import os +from pathlib import Path +import sys +import uuid + +root = Path(os.environ["AGENT_PROXY_TEST_DIR"]) +pending = None + + +def send(value): + print(json.dumps(value), flush=True) + + +def reply(request_id, result): + send({"jsonrpc": "2.0", "id": request_id, "result": result}) + + +def update(session_id, content): + send({"jsonrpc": "2.0", "method": "session/update", "params": { + "sessionId": session_id, + "update": {"sessionUpdate": "agent_message_chunk", "content": content}, + }}) + + +for line in sys.stdin: + if not line.strip(): + continue + message = json.loads(line) + method = message.get("method") + params = message.get("params", {}) + request_id = message.get("id") + if method == "initialize": + reply(request_id, {"protocolVersion": 1, "agentInfo": {"name": "test-peer", "version": "1"}, + "agentCapabilities": {"loadSession": True, + "promptCapabilities": {"image": True, "audio": True, "embeddedContext": True}, + "sessionCapabilities": {"list": {}, "resume": {}}}, + "authMethods": [], "_meta": {"pid": os.getpid()}}) + elif method == "session/new": + session_id = str(uuid.uuid4()) + (root / (session_id + ".json")).write_text(json.dumps([])) + reply(request_id, {"sessionId": session_id}) + elif method in ("session/load", "session/resume"): + session_id = params["sessionId"] + if str(uuid.UUID(session_id)) != session_id: + raise ValueError("invalid fixture session ID") + history = json.loads((root / (session_id + ".json")).read_text()) + if method == "session/load": + for content in history: + update(session_id, content) + reply(request_id, {}) + elif method == "session/list": + reply(request_id, {"sessions": [{"sessionId": path.stem, "cwd": str(root)} for path in root.glob("*.json")]}) + elif method == "session/prompt": + session_id = params["sessionId"] + content = params["prompt"] + text = content[0].get("text", "") + if text == "permission": + pending = (request_id, session_id) + send({"jsonrpc": "2.0", "id": "approval", "method": "session/request_permission", "params": { + "sessionId": session_id, "toolCall": {"toolCallId": "tool-1", "title": "checkpoint", "status": "pending"}, + "options": [{"optionId": "allow", "kind": "allow_once", "name": "Allow"}], + }}) + elif text == "block": + pending = (request_id, session_id) + update(session_id, {"type": "text", "text": "started"}) + else: + (root / (session_id + ".json")).write_text(json.dumps(content)) + for block in content: + update(session_id, block) + reply(request_id, {"stopReason": "end_turn", "_meta": {"preserved": True}}) + elif method == "session/cancel" and pending: + reply(pending[0], {"stopReason": "cancelled"}) + pending = None + elif method is None and request_id == "approval" and pending: + update(pending[1], {"type": "text", "text": message["result"]["outcome"]["outcome"]}) + reply(pending[0], {"stopReason": "end_turn"}) + pending = None + elif method and request_id is not None: + send({"jsonrpc": "2.0", "id": request_id, "error": {"code": -32601, "message": "not implemented"}}) diff --git a/server/lib/agentproxy/testdata/client.py b/server/lib/agentproxy/testdata/client.py new file mode 100644 index 000000000..e79b25014 --- /dev/null +++ b/server/lib/agentproxy/testdata/client.py @@ -0,0 +1,65 @@ +"""Exercise the unmodified acpremote mirror with the official ACP Python client.""" +import asyncio +import json +import sys + +from acp import connect_to_agent, text_block +from acp.schema import ClientCapabilities, RequestPermissionResponse + + +class Client: + def __init__(self): + self.texts = asyncio.Queue() + self.permissions = 0 + + def on_connect(self, connection): + pass + + async def session_update(self, session_id, update, **kwargs): + content = getattr(update, "content", None) + if getattr(content, "type", None) == "text": + self.texts.put_nowait(content.text) + + async def request_permission(self, options, session_id, tool_call, **kwargs): + self.permissions += 1 + return RequestPermissionResponse.model_validate({ + "outcome": {"outcome": "selected", "optionId": options[0].option_id} + }) + + async def ext_notification(self, method, params): + pass + + +async def main(): + process = await asyncio.create_subprocess_exec( + sys.argv[1], "mirror", sys.argv[2], + stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, + ) + client = Client() + connection = connect_to_agent(client, process.stdin, process.stdout) + try: + initialized = await connection.initialize(protocol_version=1, client_capabilities=ClientCapabilities()) + assert initialized.protocol_version == 1 + first = await connection.new_session(cwd=sys.argv[3], mcp_servers=[]) + second = await connection.new_session(cwd=sys.argv[3], mcp_servers=[]) + assert first.session_id != second.session_id + result = await connection.prompt(session_id=first.session_id, prompt=[text_block("mirror works")]) + assert result.stop_reason == "end_turn" + assert await client.texts.get() == "mirror works" + await connection.prompt(session_id=second.session_id, prompt=[text_block("permission")]) + assert client.permissions == 1 + assert await client.texts.get() == "selected" + await connection.load_session(session_id=first.session_id, cwd=sys.argv[3], mcp_servers=[]) + assert await client.texts.get() == "mirror works" + print(json.dumps({"ok": True, "client": "ACP Python SDK via acpremote mirror", "sessions": 2, "permissions": 1})) + finally: + await connection.close() + process.stdin.close() + try: + await asyncio.wait_for(process.wait(), 5) + except TimeoutError: + process.kill() + await process.wait() + + +asyncio.run(asyncio.wait_for(main(), 20)) diff --git a/server/lib/agentproxy/testdata/mcp_checkpoint.py b/server/lib/agentproxy/testdata/mcp_checkpoint.py new file mode 100644 index 000000000..c5cfc8ac8 --- /dev/null +++ b/server/lib/agentproxy/testdata/mcp_checkpoint.py @@ -0,0 +1,45 @@ +import sys, json + +marker = sys.argv[1] +for line in sys.stdin: + try: + r = json.loads(line) + except ValueError: + continue + if "id" not in r: + continue + method = r["method"] + if method == "initialize": + result = { + "protocolVersion": r["params"]["protocolVersion"], + "capabilities": {"tools": {}}, + "serverInfo": {"name": "checkpoint", "version": "1"}, + } + elif method == "tools/list": + result = { + "tools": [ + { + "name": "checkpoint", + "description": "Return the checkpoint marker.", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + } + elif method == "tools/call": + open(sys.argv[2], "a").write(marker + "\n") + result = {"content": [{"type": "text", "text": marker}]} + elif method == "ping": + result = {} + else: + print( + json.dumps( + { + "jsonrpc": "2.0", + "id": r["id"], + "error": {"code": -32601, "message": "unknown method"}, + } + ), + flush=True, + ) + continue + print(json.dumps({"jsonrpc": "2.0", "id": r["id"], "result": result}), flush=True) diff --git a/server/lib/agentproxy/testdata/pi_gate.py b/server/lib/agentproxy/testdata/pi_gate.py new file mode 100644 index 000000000..954850a15 --- /dev/null +++ b/server/lib/agentproxy/testdata/pi_gate.py @@ -0,0 +1,277 @@ +"""Opt-in real-provider gate. Use a fresh disposable image with OPENROUTER_API_KEY. +Requires the pinned ACP requirements in the test runner's Python environment. +""" + +import asyncio, base64, json, os, pathlib, tempfile, urllib.request, urllib.error, uuid +import websockets + +os.umask(0o077) +base = os.environ["AGENT_API_URL"].rstrip("/") +assert base.startswith(("http://", "https://")), "AGENT_API_URL must be HTTP(S)" +root = pathlib.Path(tempfile.mkdtemp(prefix="pi-gate-evidence-")) +workspace = "/tmp/pi-gate-" + uuid.uuid4().hex + + +def http(method, path, data=None, revision=None): + headers = {"Content-Type": "application/json"} + if revision: + headers["If-Match"] = '"' + revision + '"' + request = urllib.request.Request( + base + path, + data=json.dumps(data).encode() if data is not None else None, + method=method, + headers=headers, + ) + try: + response = urllib.request.urlopen(request, timeout=200) + return response.status, json.loads(response.read()) + except urllib.error.HTTPError as error: + return error.code, error.read().decode() + + +def pids(): + code = """import pathlib,json +pids=[] +for path in pathlib.Path('/proc').iterdir(): + if not path.name.isdigit():continue + try:args=(path/'cmdline').read_bytes().split(bytes([0])) + except (FileNotFoundError,ProcessLookupError,PermissionError):continue + if b'/opt/kernel-agent/pi/node_modules/pi-acp/dist/index.js' in args:pids.append(path.name) +print(json.dumps(pids)) +""" + status, result = http( + "POST", + "/process/exec", + {"command": "/opt/kernel-agent/venv/bin/python", "args": ["-c", code]}, + ) + assert status == 200 and result["exit_code"] == 0, result + return set(json.loads(base64.b64decode(result["stdout_b64"]))) + + +config_path = "/agent/v1/harnesses/pi/config" +status, initial = http("GET", config_path) +assert status == 200 +assert initial["status"] == "unconfigured", ( + "Use a fresh disposable browser; refusing to replace existing configuration." +) +source = base64.b64encode( + pathlib.Path(__file__).with_name("mcp_checkpoint.py").read_bytes() +).decode() +code = ( + "import pathlib,base64; p=pathlib.Path('" + + workspace + + "'); p.mkdir(); (p/'mcp.py').write_bytes(base64.b64decode('" + + source + + "'))" +) +assert ( + http( + "POST", + "/process/exec", + {"command": "/opt/kernel-agent/venv/bin/python", "args": ["-c", code]}, + )[0] + == 200 +) +shared = { + "name": "checkpoint", + "command": "/opt/kernel-agent/venv/bin/python", + "args": [workspace + "/mcp.py", "shared", workspace + "/calls"], +} +desired = { + "launch": { + "provider": "openrouter", + "model": "z-ai/glm-5.3", + "thinking": "low", + "credential": "openrouter", + }, + "shared": {"extensions": [], "mcpServers": [shared]}, +} +status, ready = http("PUT", config_path, desired, initial["revision"]) +assert status == 200, (status, ready) +print("configuration ready", ready["revision"], flush=True) + + +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=pi", max_size=1 << 20 + ) + try: + await self.call( + "initialize", {"protocolVersion": 1, "clientCapabilities": {}} + ) + except BaseException: + await self.ws.close() + raise + return self + + async def close(self): + await self.ws.close() + await asyncio.sleep(2) + + 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 = [] + while True: + message = json.loads(await asyncio.wait_for(self.ws.recv(), 150)) + 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): + _, updates = await self.call( + "session/prompt", + {"sessionId": sid, "prompt": [{"type": "text", "text": text}]}, + ) + 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 main(): + clients = [] + summary = {} + try: + first = await Client().open() + clients.append(first) + session, _ = await first.call( + "session/new", {"cwd": workspace, "mcpServers": []} + ) + sid = session["sessionId"] + old = pids() + assert old + marker = "cobalt-" + uuid.uuid4().hex[:8] + text = await first.prompt( + sid, + "Remember this checkpoint: " + + marker + + ". Reply only with the checkpoint. Do not use tools.", + ) + assert marker in text, text + 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]} + ) + assert other["sessionId"] != sid + assert len(pids()) > len(old) + 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 + 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 + updated = json.loads(json.dumps(desired)) + updated["shared"]["mcpServers"][0]["args"][1] = "updated-shared" + active = pids() + code, latest = await asyncio.to_thread( + http, "PUT", config_path, updated, ready["revision"] + ) + assert code == 200, (code, latest) + assert pids() == active, "configuration update restarted active connections" + broken = json.loads(json.dumps(updated)) + broken["shared"]["extensions"] = [ + "npm:pi-reference-nonexistent-test-package@0.0.1" + ] + code, error = await asyncio.to_thread( + http, "PUT", config_path, broken, latest["revision"] + ) + assert code == 422, (code, error) + code, failed = http("GET", config_path) + assert ( + failed["status"] == "failed" + and failed["effectiveRevision"] == latest["revision"] + ) + await first.close() + assert not old.intersection(pids()), (old, pids()) + text = await second.prompt( + other["sessionId"], "Reply only with OK. Do not use tools." + ) + assert "OK" in text + reconnected = await Client().open() + clients.append(reconnected) + listed, _ = await reconnected.call("session/list", {"cwd": workspace}) + assert any(s["sessionId"] == sid for s in listed["sessions"]) + _, history = await reconnected.call( + "session/load", {"sessionId": sid, "cwd": workspace, "mcpServers": []} + ) + assert marker in json.dumps(history) + text = await reconnected.prompt( + sid, + "What was the checkpoint word in our first exchange? Reply only with it. Do not use tools.", + ) + assert marker in text, text + text = await reconnected.prompt( + sid, + "Call the checkpoint MCP tool once and return its output. Do not use other tools.", + ) + assert "updated-shared" in text, text + summary = { + "configurationUpdateKeepsConnections": True, + "updatedSharedMCP": True, + "pass": True, + "independentConnections": True, + "failedPreparationRetainsReady": True, + "disconnectKillsAdapter": True, + "freshListLoadHistoryAndRecall": True, + "sharedMCP": True, + "sessionMCPOverride": True, + "sessionId": sid, + } + finally: + for c in clients: + await c.close() + assert not pids(), pids() + (root / "summary.json").write_text(json.dumps(summary, indent=2)) + (root / "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:", root) diff --git a/server/lib/wsproxy/proxy_options_test.go b/server/lib/wsproxy/proxy_options_test.go new file mode 100644 index 000000000..9bc895d10 --- /dev/null +++ b/server/lib/wsproxy/proxy_options_test.go @@ -0,0 +1,73 @@ +package wsproxy + +import ( + "context" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/coder/websocket" +) + +func proxyWithOptions(t *testing.T, opts ProxyOptions) (*websocket.Conn, <-chan struct{}) { + t.Helper() + closed := make(chan struct{}) + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer close(closed) + defer conn.CloseNow() + for { + kind, data, err := conn.Read(r.Context()) + if err != nil { + return + } + if err := conn.Write(r.Context(), kind, data); err != nil { + return + } + } + })) + t.Cleanup(upstream.Close) + opts.Logger = slog.New(slog.NewTextHandler(io.Discard, nil)) + proxy := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + Proxy(w, r, "ws"+strings.TrimPrefix(upstream.URL, "http"), opts) + })) + t.Cleanup(proxy.Close) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, "ws"+strings.TrimPrefix(proxy.URL, "http"), nil) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { conn.CloseNow() }) + return conn, closed +} + +func TestProxyReadLimit(t *testing.T) { + conn, _ := proxyWithOptions(t, ProxyOptions{ReadLimit: 64}) + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if err := conn.Write(ctx, websocket.MessageText, []byte(strings.Repeat("x", 128))); err != nil { + t.Fatal(err) + } + if _, _, err := conn.Read(ctx); err == nil { + t.Fatal("oversized frame was forwarded") + } +} + +func TestProxyPingDetectsUnresponsiveClient(t *testing.T) { + _, closed := proxyWithOptions(t, ProxyOptions{PingInterval: 20 * time.Millisecond}) + // Without a client read loop, pings are not answered. The proxy must close + // the upstream too instead of keeping the remote agent alive indefinitely. + select { + case <-closed: + case <-time.After(3 * time.Second): + t.Fatal("unresponsive client did not close upstream") + } +} diff --git a/server/lib/wsproxy/wsproxy.go b/server/lib/wsproxy/wsproxy.go index 65ad81f46..aa867412d 100644 --- a/server/lib/wsproxy/wsproxy.go +++ b/server/lib/wsproxy/wsproxy.go @@ -38,6 +38,11 @@ type ProxyOptions struct { Logger *slog.Logger Transform MessageTransform Observe Observer + // ReadLimit overrides the default 100 MiB limit on both connections. + ReadLimit int64 + // PingInterval enables downstream liveness checks with the same pong timeout. + // Zero leaves keepalive behavior unchanged. + PingInterval time.Duration // Registry, when set, tracks the accepted client connection so it is // closed with a Going Away frame on server shutdown. Registry *wsdrain.Registry @@ -140,7 +145,11 @@ func Proxy(w http.ResponseWriter, r *http.Request, upstreamURL string, opts Prox logger.Error("websocket accept failed", slog.String("err", err.Error())) return } - clientConn.SetReadLimit(100 * 1024 * 1024) + readLimit := opts.ReadLimit + if readLimit <= 0 { + readLimit = 100 * 1024 * 1024 + } + clientConn.SetReadLimit(readLimit) untrack := opts.Registry.Track(clientConn) defer untrack() @@ -151,10 +160,16 @@ func Proxy(w http.ResponseWriter, r *http.Request, upstreamURL string, opts Prox clientConn.Close(websocket.StatusInternalError, "failed to connect to upstream") return } - upstreamConn.SetReadLimit(100 * 1024 * 1024) + upstreamConn.SetReadLimit(readLimit) logger.Debug("proxying websocket", slog.String("url", upstreamURL)) + if opts.PingInterval > 0 { + pingCtx, cancel := context.WithCancel(r.Context()) + defer cancel() + go pingClient(pingCtx, clientConn, opts.PingInterval) + } + var once sync.Once cleanup := func(_ PumpExitCause) { once.Do(func() { @@ -165,3 +180,22 @@ func Proxy(w http.ResponseWriter, r *http.Request, upstreamURL string, opts Prox Pump(r.Context(), clientConn, upstreamConn, cleanup, logger, opts.Transform, opts.Observe) } + +func pingClient(ctx context.Context, client *websocket.Conn, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + pingCtx, cancel := context.WithTimeout(ctx, interval) + err := client.Ping(pingCtx) + cancel() + if err != nil { + client.CloseNow() + return + } + } + } +} diff --git a/server/runtime/acp/catalog.json b/server/runtime/acp/catalog.json new file mode 100644 index 000000000..eb9d6fe72 --- /dev/null +++ b/server/runtime/acp/catalog.json @@ -0,0 +1,17 @@ +{ + "acpremote": "/opt/kernel-agent/venv/bin/acpremote", + "maxConnections": 8, + "harnesses": {}, + "pi": { + "stateDir": "/home/kernel/.agents/pi", + "runtimeDir": "/opt/kernel-agent/pi", + "node": "/usr/local/bin/node", + "bun": "/usr/local/bin/bun", + "credentials": { + "openrouter": "OPENROUTER_API_KEY", + "openai": "OPENAI_API_KEY", + "anthropic": "ANTHROPIC_API_KEY", + "google": "GEMINI_API_KEY" + } + } +} diff --git a/server/runtime/acp/pi/bun.lock b/server/runtime/acp/pi/bun.lock new file mode 100644 index 000000000..17346aa25 --- /dev/null +++ b/server/runtime/acp/pi/bun.lock @@ -0,0 +1,544 @@ +{ + "lockfileVersion": 2, + "configVersion": 1, + "workspaces": { + "": { + "dependencies": { + "@earendil-works/pi-coding-agent": "0.83.0", + "pi-acp": "0.0.33", + "pi-mcp-adapter": "2.32.1", + }, + }, + }, + "packages": { + "@agentclientprotocol/sdk": ["@agentclientprotocol/sdk@0.26.0", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-ialrcI+RzKOYe+fw+TfpyTdRmEoqIkXLlwbTi6XgaXXfdhNcdod7TmE1VsTnG3yTlox8TMTSMQgWbLLbz3r86Q=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="], + + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.977.9", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@aws-sdk/xml-builder": "^3.972.40", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.33.3", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.72", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.15", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-login": "^3.972.77", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.77", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.82", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.70", "@aws-sdk/credential-provider-http": "^3.972.72", "@aws-sdk/credential-provider-ini": "^3.973.15", "@aws-sdk/credential-provider-process": "^3.972.70", "@aws-sdk/credential-provider-sso": "^3.973.14", "@aws-sdk/credential-provider-web-identity": "^3.972.76", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/credential-provider-imds": "^4.4.16", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.70", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.14", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/token-providers": "3.1116.0", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.76", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA=="], + + "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.34", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw=="], + + "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.29", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ=="], + + "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.52", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.44", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/signature-v4-multi-region": "^3.996.46", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/fetch-http-handler": "^5.7.2", "@smithy/node-http-handler": "^4.11.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw=="], + + "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.46", "", { "dependencies": { "@aws-sdk/types": "^3.974.5", "@smithy/signature-v4": "^5.6.12", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.974.5", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.10", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.40", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], + + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + + "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.83.0", "", { "dependencies": { "@earendil-works/pi-ai": "^0.83.0", "diff": "8.0.4", "ignore": "7.0.5", "typebox": "1.3.7", "yaml": "2.9.0" } }, "sha512-RorGp9OH5l3ElpuC5a5ZQ2eWcchZGXflXRzVGkV99y3y6tT+LLNyxoYIdVKvTKWEObwhExeQbTH0fI2tE4iX4g=="], + + "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.83.0", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.3.7" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-m3IZD4g3er0V8TC9+Vpgw/sjTKqcJlkcIBy/JvsgRubuuik3tAVzyugUg4rVrShIkkOT69mEd34NEqKUIsl6JQ=="], + + "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.83.0", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.83.0", "@earendil-works/pi-ai": "^0.83.0", "@earendil-works/pi-tui": "^0.83.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.3.7", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-uYhF+FsZxogoSX/AxBcUdiY+ZklubwaXyAoEGA2eQwsHcyEAhUYIKh/WLXe/a8+k8eTCmxb+ZN2Zo9mzQtzbWw=="], + + "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.83.0", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-IoYrb0rORjELmEpNtoCA/U8je3KopMkRAVJRdSzvXRvgb+Huo1gNh8Q5CSZvNOiYtDxJdj2tYZZHZ4B3+IN3hA=="], + + "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="], + + "@hono/node-server": ["@hono/node-server@2.1.1", "", { "peerDependencies": { "hono": "^4" } }, "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg=="], + + "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="], + + "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="], + + "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="], + + "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="], + + "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="], + + "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="], + + "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="], + + "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="], + + "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="], + + "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="], + + "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="], + + "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="], + + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], + + "@modelcontextprotocol/ext-apps": ["@modelcontextprotocol/ext-apps@1.7.5", "", { "dependencies": { "@standard-schema/spec": "^1.1.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["react", "react-dom"] }, "sha512-TjPH2S2y5UEGKhmI6+XGFuqfqOV4ppe1x6DA3txnUaEWkgtA4G5vo14jGKFZmegdkZ1H4QMLyujLvoU1BEdnAg=="], + + "@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=="], + + "@napi-rs/keyring": ["@napi-rs/keyring@1.3.0", "", { "optionalDependencies": { "@napi-rs/keyring-darwin-arm64": "1.3.0", "@napi-rs/keyring-darwin-x64": "1.3.0", "@napi-rs/keyring-freebsd-x64": "1.3.0", "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", "@napi-rs/keyring-linux-arm64-musl": "1.3.0", "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-gnu": "1.3.0", "@napi-rs/keyring-linux-x64-musl": "1.3.0", "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", "@napi-rs/keyring-win32-x64-msvc": "1.3.0" } }, "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA=="], + + "@napi-rs/keyring-darwin-arm64": ["@napi-rs/keyring-darwin-arm64@1.3.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ=="], + + "@napi-rs/keyring-darwin-x64": ["@napi-rs/keyring-darwin-x64@1.3.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw=="], + + "@napi-rs/keyring-freebsd-x64": ["@napi-rs/keyring-freebsd-x64@1.3.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw=="], + + "@napi-rs/keyring-linux-arm-gnueabihf": ["@napi-rs/keyring-linux-arm-gnueabihf@1.3.0", "", { "os": "linux", "cpu": "arm" }, "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ=="], + + "@napi-rs/keyring-linux-arm64-gnu": ["@napi-rs/keyring-linux-arm64-gnu@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg=="], + + "@napi-rs/keyring-linux-arm64-musl": ["@napi-rs/keyring-linux-arm64-musl@1.3.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw=="], + + "@napi-rs/keyring-linux-riscv64-gnu": ["@napi-rs/keyring-linux-riscv64-gnu@1.3.0", "", { "os": "linux", "cpu": "none" }, "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ=="], + + "@napi-rs/keyring-linux-x64-gnu": ["@napi-rs/keyring-linux-x64-gnu@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A=="], + + "@napi-rs/keyring-linux-x64-musl": ["@napi-rs/keyring-linux-x64-musl@1.3.0", "", { "os": "linux", "cpu": "x64" }, "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw=="], + + "@napi-rs/keyring-win32-arm64-msvc": ["@napi-rs/keyring-win32-arm64-msvc@1.3.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw=="], + + "@napi-rs/keyring-win32-ia32-msvc": ["@napi-rs/keyring-win32-ia32-msvc@1.3.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ=="], + + "@napi-rs/keyring-win32-x64-msvc": ["@napi-rs/keyring-win32-x64-msvc@1.3.0", "", { "os": "win32", "cpu": "x64" }, "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg=="], + + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="], + + "@pkgr/core": ["@pkgr/core@0.1.2", "", {}, "sha512-fdDH1LSGfZdTH2sxdpVMw31BanV28K/Gry0cVFxaNP77neJSkd82mM8ErPNYs9e+0O7SdHBLTDzDgwUuy18RnQ=="], + + "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], + + "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="], + + "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="], + + "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="], + + "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="], + + "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="], + + "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="], + + "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="], + + "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="], + + "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="], + + "@smithy/core": ["@smithy/core@3.33.3", "", { "dependencies": { "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.5.2", "", { "dependencies": { "@smithy/core": "^3.33.2", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.8.0", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.7.3", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow=="], + + "@smithy/types": ["@smithy/types@4.18.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + + "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], + + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], + + "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=="], + + "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="], + + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + + "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="], + + "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=="], + + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + + "brace-expansion": ["brace-expansion@5.0.9", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg=="], + + "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="], + + "bundle-name": ["bundle-name@4.1.0", "", { "dependencies": { "run-applescript": "^7.0.0" } }, "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q=="], + + "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=="], + + "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], + + "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=="], + + "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="], + + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + + "default-browser": ["default-browser@5.5.1", "", { "dependencies": { "bundle-name": "^4.1.0", "default-browser-id": "^5.0.0" } }, "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw=="], + + "default-browser-id": ["default-browser-id@5.0.1", "", {}, "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q=="], + + "define-lazy-prop": ["define-lazy-prop@3.0.0", "", {}, "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg=="], + + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + + "diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="], + + "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=="], + + "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="], + + "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=="], + + "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-uri": ["fast-uri@3.1.7", "", {}, "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg=="], + + "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="], + + "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=="], + + "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="], + + "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=="], + + "gaxios": ["gaxios@7.3.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ=="], + + "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="], + + "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="], + + "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=="], + + "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="], + + "google-auth-library": ["google-auth-library@10.9.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw=="], + + "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "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=="], + + "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="], + + "hono": ["hono@4.13.7", "", {}, "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ=="], + + "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="], + + "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=="], + + "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], + + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + + "iconv-lite": ["iconv-lite@0.7.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ=="], + + "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "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-docker": ["is-docker@3.0.0", "", { "bin": { "is-docker": "cli.js" } }, "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ=="], + + "is-inside-container": ["is-inside-container@1.0.0", "", { "dependencies": { "is-docker": "^3.0.0" }, "bin": { "is-inside-container": "cli.js" } }, "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA=="], + + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + + "is-wsl": ["is-wsl@3.1.1", "", { "dependencies": { "is-inside-container": "^1.0.0" } }, "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="], + + "jose": ["jose@6.2.12", "", {}, "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw=="], + + "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="], + + "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=="], + + "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="], + + "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + + "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="], + + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="], + + "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=="], + + "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="], + + "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + + "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=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], + + "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=="], + + "open": ["open@10.2.0", "", { "dependencies": { "default-browser": "^5.2.1", "define-lazy-prop": "^3.0.0", "is-inside-container": "^1.0.0", "wsl-utils": "^0.1.0" } }, "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA=="], + + "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="], + + "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="], + + "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], + + "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="], + + "path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + + "pi-acp": ["pi-acp@0.0.33", "", { "dependencies": { "@agentclientprotocol/sdk": "^0.26.0", "zod": "^3.25.0" }, "bin": { "pi-acp": "dist/index.js" } }, "sha512-vX9kY1tK14E72G4dBAx+RGCk/k7XPjTHls6dLUxA8WSkBav6B6JHuSBv3eusp50LCR/GTRsR2kIKsG0Z5jANzw=="], + + "pi-mcp-adapter": ["pi-mcp-adapter@2.32.1", "", { "dependencies": { "@modelcontextprotocol/client": "2.0.0", "@modelcontextprotocol/core": "2.0.0", "@modelcontextprotocol/ext-apps": "^1.2.2", "@napi-rs/keyring": "^1.3.0", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "cross-spawn": "^7.0.6", "open": "^10.2.0", "recheck": "^4.5.0", "smol-toml": "^1.6.1", "strip-json-comments": "^5.0.3", "zod": "^3.25.0 || ^4.0.0" }, "peerDependencies": { "@earendil-works/pi-ai": "^0.84.1", "@earendil-works/pi-tui": "*", "typebox": "*" }, "optionalPeers": ["@earendil-works/pi-ai", "@earendil-works/pi-tui", "typebox"], "bin": { "pi-mcp-adapter": "cli.js" } }, "sha512-GNLYa2U9T5ZqIhZmhx/RTenEjfakJTelq/z6Q+At5SIxyuYvrvobriDEVsnx+lqetVDizUCudTWLfdZytQu0rg=="], + + "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], + + "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="], + + "protobufjs": ["protobufjs@7.6.6", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg=="], + + "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=="], + + "recheck": ["recheck@4.5.0", "", { "dependencies": { "synckit": "0.9.2" }, "optionalDependencies": { "recheck-jar": "4.5.0", "recheck-linux-x64": "4.5.0", "recheck-macos-arm64": "4.5.0", "recheck-macos-x64": "4.5.0", "recheck-windows-x64": "4.5.0" } }, "sha512-kPnbOV6Zfx9a25AZ++28fI1q78L/UVRQmmuazwVRPfiiqpMs+WbOU69Shx820XgfKWfak0JH75PUvZMFtRGSsw=="], + + "recheck-jar": ["recheck-jar@4.5.0", "", {}, "sha512-Ad7oCQmY8cQLzd3QVNXjzZ+S6MbImGhR4AaW2yiGzteOfMV45522rt6nSzFyt8p3mCEaMcm/4MoZrMSxUcCbrA=="], + + "recheck-linux-x64": ["recheck-linux-x64@4.5.0", "", { "os": "linux", "cpu": "x64" }, "sha512-52kXsR/v+IbGIKYYFZfSZcgse/Ci9IA2HnuzrtvRRcfODkcUGe4n72ESQ8nOPwrdHFg9i4j9/YyPh1HWWgpJ6A=="], + + "recheck-macos-arm64": ["recheck-macos-arm64@4.5.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qIyK3dRuLkORQvv0b59fZZRXweSmjjWaoA4K8Kgifz0anMBH4pqsDV6plBlgjcRmW9yC12wErIRzifREaKnk2w=="], + + "recheck-macos-x64": ["recheck-macos-x64@4.5.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-1wp/eiLxcjC/Ex4wurlrS/LGzt8IiF4TiK5sEjldu4HVAKdNCnnmsS9a5vFpfcikDz4ZuZlLlTi1VbQTxHlwZg=="], + + "recheck-windows-x64": ["recheck-windows-x64@4.5.0", "", { "os": "win32", "cpu": "x64" }, "sha512-ekBKwAp0oKkMULn5zgmHEYLwSJfkfb95AbTtbDkQazNkqYw9PRD/mVyFUR6Ff2IeRyZI0gxy+N2AKBISWydhug=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + + "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], + + "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=="], + + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], + + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], + + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + + "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="], + + "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=="], + + "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], + + "smol-toml": ["smol-toml@1.8.0", "", {}, "sha512-kCZr2V3ch9i00x8zXRhjUNVcjG9ijES5dDudkXvUVCT5QlJNQWElSJdZqyPemffHoLNUYwOcou0Fy+ojN0uHSQ=="], + + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + + "strip-json-comments": ["strip-json-comments@5.0.3", "", {}, "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw=="], + + "synckit": ["synckit@0.9.2", "", { "dependencies": { "@pkgr/core": "^0.1.0", "tslib": "^2.6.2" } }, "sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw=="], + + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + + "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "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=="], + + "typebox": ["typebox@1.3.7", "", {}, "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg=="], + + "undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="], + + "undici-types": ["undici-types@8.9.0", "", {}, "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg=="], + + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + + "vary": ["vary@1.1.2", "", {}, "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg=="], + + "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="], + + "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=="], + + "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + + "wsl-utils": ["wsl-utils@0.1.0", "", { "dependencies": { "is-wsl": "^3.1.0" } }, "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw=="], + + "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], + + "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + + "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="], + + "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1116.0", "", { "dependencies": { "@aws-sdk/core": "^3.977.9", "@aws-sdk/nested-clients": "^3.997.44", "@aws-sdk/types": "^3.974.5", "@smithy/core": "^3.33.3", "@smithy/types": "^4.17.2", "tslib": "^2.6.2" } }, "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q=="], + + "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.12.1", "", { "dependencies": { "@smithy/core": "^3.33.3", "@smithy/types": "^4.18.0", "tslib": "^2.6.2" } }, "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw=="], + + "@mistralai/mistralai/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@modelcontextprotocol/client/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@modelcontextprotocol/core/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "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=="], + + "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="], + + "pi-mcp-adapter/zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], + + "type-is/content-type": ["content-type@2.1.0", "", {}, "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag=="], + } +} diff --git a/server/runtime/acp/pi/launch.mjs b/server/runtime/acp/pi/launch.mjs new file mode 100644 index 000000000..d76e245cc --- /dev/null +++ b/server/runtime/acp/pi/launch.mjs @@ -0,0 +1,18 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { run } from "./process.mjs"; + +const config = JSON.parse(readFileSync(process.argv[2], "utf8")); +process.env.KERNEL_PI_LAUNCH = JSON.stringify(config.launch); +process.env.KERNEL_PI_NODE = process.execPath; +process.env[process.env.KERNEL_PI_PROVIDER_ENV] = + process.env[process.env.KERNEL_PI_PROVIDER_SOURCE]; +process.env.PI_ACP_PI_COMMAND = fileURLToPath( + new URL("./pi-command", import.meta.url), +); +process.env.PI_SKIP_VERSION_CHECK = "1"; +run(process.execPath, [ + fileURLToPath( + new URL("./node_modules/pi-acp/dist/index.js", import.meta.url), + ), +]); diff --git a/server/runtime/acp/pi/mcp.ts b/server/runtime/acp/pi/mcp.ts new file mode 100644 index 000000000..72f457c4b --- /dev/null +++ b/server/runtime/acp/pi/mcp.ts @@ -0,0 +1,105 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { createMcpAdapter, type ServerEntry } from "pi-mcp-adapter"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; + +type SharedServer = { + name: string; + command?: string; + args?: string[]; + envBindings?: Record; + url?: string; + transport?: "http" | "sse"; + headerBindings?: Record; +}; +type SessionServer = { + name: string; + command?: string; + args?: string[]; + env?: { name: string; value: string }[]; + url?: string; + type?: "http" | "sse"; + headers?: { name: string; value: string }[]; +}; + +export default function (pi: ExtensionAPI) { + const preparing = process.env.KERNEL_PI_PREPARING === "1"; + const state = process.env.KERNEL_PI_STATE; + if (!preparing && !state) throw new Error("managed Pi state is unavailable"); + const config: { shared: { mcpServers: SharedServer[] } } = preparing + ? { shared: { mcpServers: [] } } + : JSON.parse(readFileSync(join(state!, "current/config.json"), "utf8")); + const bindings: Record = JSON.parse( + process.env.KERNEL_PI_BINDINGS ?? "{}", + ); + const credential = (name: string) => { + const value = process.env[bindings[name]]; + if (!value) throw new Error("MCP credential binding is unavailable"); + return value; + }; + const servers: Record = Object.create(null); + for (const server of config.shared.mcpServers) { + servers[server.name] = { + ...(server.command + ? { + command: server.command, + args: server.args ?? [], + env: Object.fromEntries( + Object.entries(server.envBindings ?? {}).map(([key, binding]) => [ + key, + credential(binding), + ]), + ), + } + : { + url: server.url, + headers: Object.fromEntries( + Object.entries(server.headerBindings ?? {}).map( + ([key, binding]) => [ + key, + (binding.prefix ?? "") + credential(binding.credential), + ], + ), + ), + httpTransport: + server.transport === "sse" ? "sse" : "streamable-http", + }), + lifecycle: "eager", + directTools: true, + literalEnv: true, + inheritEnv: false, + auth: false, + oauth: false, + }; + } + // Session definitions override shared defaults by name, without writing files. + const sessionServers: SessionServer[] = preparing + ? [] + : JSON.parse(process.env.KERNEL_PI_SESSION_MCP ?? "[]"); + for (const server of sessionServers) { + servers[server.name] = { + ...(server.command + ? { + command: server.command, + args: server.args ?? [], + env: Object.fromEntries( + (server.env ?? []).map(({ name, value }) => [name, value]), + ), + } + : { + url: server.url, + headers: Object.fromEntries( + (server.headers ?? []).map(({ name, value }) => [name, value]), + ), + httpTransport: server.type === "sse" ? "sse" : "streamable-http", + }), + lifecycle: "eager", + directTools: true, + literalEnv: true, + inheritEnv: false, + auth: false, + oauth: false, + }; + } + return createMcpAdapter({ config: { mcpServers: servers } })(pi); +} diff --git a/server/runtime/acp/pi/package.json b/server/runtime/acp/pi/package.json new file mode 100644 index 000000000..9bbb56b27 --- /dev/null +++ b/server/runtime/acp/pi/package.json @@ -0,0 +1,9 @@ +{ + "private": true, + "type": "module", + "dependencies": { + "@earendil-works/pi-coding-agent": "0.83.0", + "pi-acp": "0.0.33", + "pi-mcp-adapter": "2.32.1" + } +} diff --git a/server/runtime/acp/pi/patch-adapter.mjs b/server/runtime/acp/pi/patch-adapter.mjs new file mode 100644 index 000000000..646a74d9d --- /dev/null +++ b/server/runtime/acp/pi/patch-adapter.mjs @@ -0,0 +1,34 @@ +import { readFileSync, writeFileSync, renameSync, statSync } from "node:fs"; + +// pi-acp 0.0.33 stores MCP definitions but does not pass them to Pi. Keep this +// version-specific adapter patch outside the protocol-transparent Go proxy. +const path = new URL("./node_modules/pi-acp/dist/index.js", import.meta.url); +let source = readFileSync(path, "utf8"); +const replacements = [ + [ + 'cwd: params.cwd,\n stdio: "pipe",\n env: process.env,', + 'cwd: params.cwd,\n stdio: "pipe",\n env: { ...process.env, KERNEL_PI_SESSION_MCP: JSON.stringify(params.mcpServers ?? []) },', + ], + [ + "piCommand: params.piCommand\n", + "piCommand: params.piCommand,\n mcpServers: params.mcpServers\n", + ], + [ + "piCommand: process.env.PI_ACP_PI_COMMAND\n });", + "piCommand: process.env.PI_ACP_PI_COMMAND,\n mcpServers: opts?.mcpServers ?? []\n });", + ], + [ + "mcpCapabilities: { http: false, sse: false }", + "mcpCapabilities: { http: true, sse: true }", + ], +]; +for (const [before, after] of replacements) { + if (source.split(after).length === 2) continue; + if (source.split(before).length !== 2) + throw new Error("pinned pi-acp patch no longer matches"); + source = source.replace(before, after); +} +// Replace the inode: Bun can hardlink installed files to its package cache. +const temporary = new URL(path.href + `.${process.pid}.tmp`); +writeFileSync(temporary, source, { mode: statSync(path).mode & 0o777 }); +renameSync(temporary, path); diff --git a/server/runtime/acp/pi/pi-command b/server/runtime/acp/pi/pi-command new file mode 100755 index 000000000..d542fab51 --- /dev/null +++ b/server/runtime/acp/pi/pi-command @@ -0,0 +1,2 @@ +#!/bin/sh +exec "${KERNEL_PI_NODE:?KERNEL_PI_NODE is required}" "${0%/*}/pi-command.mjs" "$@" diff --git a/server/runtime/acp/pi/pi-command.mjs b/server/runtime/acp/pi/pi-command.mjs new file mode 100644 index 000000000..7424cb5ef --- /dev/null +++ b/server/runtime/acp/pi/pi-command.mjs @@ -0,0 +1,20 @@ +import { fileURLToPath } from "node:url"; +import { run } from "./process.mjs"; + +const { provider, model, thinking } = JSON.parse(process.env.KERNEL_PI_LAUNCH); +run(process.execPath, [ + fileURLToPath( + new URL( + "./node_modules/@earendil-works/pi-coding-agent/dist/cli.js", + import.meta.url, + ), + ), + "--provider", + provider, + "--model", + model, + "--thinking", + thinking, + "--no-approve", + ...process.argv.slice(2), +]); diff --git a/server/runtime/acp/pi/prepare.mjs b/server/runtime/acp/pi/prepare.mjs new file mode 100644 index 000000000..f510f83ae --- /dev/null +++ b/server/runtime/acp/pi/prepare.mjs @@ -0,0 +1,136 @@ +import { mkdir, readFile, writeFile, symlink } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const [revision, state] = process.argv.slice(2); +const config = JSON.parse( + await readFile(join(revision, "config.json"), "utf8"), +); +const runtime = fileURLToPath(new URL(".", import.meta.url)); +await mkdir(join(state, "home"), { recursive: true, mode: 0o700 }); +await mkdir(join(state, "sessions"), { recursive: true, mode: 0o700 }); +await mkdir(join(revision, "agent"), { mode: 0o700 }); +await symlink(join(state, "sessions"), join(revision, "agent/sessions")); +const dependencies = Object.create(null); +for (const source of config.shared.extensions) { + const spec = source.slice(4), + index = spec.lastIndexOf("@"); + const name = spec.slice(0, index); + if (name in dependencies) throw new Error("duplicate extension package"); + dependencies[name] = spec.slice(index + 1); +} +await writeFile( + join(revision, "package.json"), + JSON.stringify({ private: true, dependencies }), + { mode: 0o600 }, +); +if (Object.keys(dependencies).length) { + await new Promise((resolve, reject) => { + const child = spawn( + process.env.KERNEL_PI_BUN, + [ + "install", + "--ignore-scripts", + "--no-progress", + "--registry", + process.env.KERNEL_PI_REGISTRY, + ], + { cwd: revision, stdio: "ignore" }, + ); + child.on("error", reject); + child.on("exit", (code) => + code === 0 + ? resolve() + : reject(new Error("extension installation failed")), + ); + }); +} +const settings = { + quietStartup: true, + enableInstallTelemetry: false, + enableAnalytics: false, + defaultProjectTrust: "never", + extensions: [join(runtime, "mcp.ts")], + packages: Object.keys(dependencies).map((name) => ({ + source: join(revision, "node_modules", name), + skills: [], + prompts: [], + themes: [], + })), +}; +await writeFile( + join(revision, "agent/settings.json"), + JSON.stringify(settings), + { mode: 0o600 }, +); +// Import and initialize installed extensions without provider calls or MCP startup. +// Pi reports broken extensions on stderr but can otherwise continue successfully. +await new Promise((resolve, reject) => { + const probe = spawn( + process.execPath, + [ + join(runtime, "node_modules/@earendil-works/pi-coding-agent/dist/cli.js"), + "--mode", + "rpc", + "--session-dir", + join(revision, "validation-sessions"), + "--no-approve", + "--no-skills", + "--no-prompt-templates", + "--no-context-files", + ], + { + cwd: revision, + env: { + ...process.env, + PI_CODING_AGENT_DIR: join(revision, "agent"), + PI_OFFLINE: "1", + KERNEL_PI_PREPARING: "1", + }, + stdio: ["pipe", "pipe", "pipe"], + }, + ); + let output = "", + stderr = "", + complete = false; + const timer = setTimeout(() => { + probe.kill("SIGKILL"); + reject(new Error("extension validation timed out")); + }, 20000); + probe.on("error", (error) => { + clearTimeout(timer); + reject(error); + }); + probe.stderr.on("data", (data) => { + stderr += data; + if (stderr.length > 65536) probe.kill("SIGKILL"); + }); + probe.stdout.on("data", (data) => { + output += data; + if (output.length > 65536) probe.kill("SIGKILL"); + let index; + while ((index = output.indexOf("\n")) >= 0) { + const line = output.slice(0, index); + output = output.slice(index + 1); + try { + const response = JSON.parse(line); + if (response.id === "prepare" && response.success === true) { + complete = true; + probe.stdin.end(); + } + } catch { + /* The Pi CLI can print a startup prelude before RPC output. */ + } + } + }); + probe.on("exit", () => { + clearTimeout(timer); + if (complete && !stderr.trim()) resolve(); + else reject(new Error("extension validation failed")); + }); + probe.stdin.on("error", () => {}); + probe.stdin.write( + JSON.stringify({ id: "prepare", type: "get_state" }) + "\n", + ); +}); diff --git a/server/runtime/acp/pi/process.mjs b/server/runtime/acp/pi/process.mjs new file mode 100644 index 000000000..36e3e8b3b --- /dev/null +++ b/server/runtime/acp/pi/process.mjs @@ -0,0 +1,9 @@ +import { spawn } from "node:child_process"; + +export function run(command, args, env = process.env) { + const child = spawn(command, args, { env, stdio: "inherit" }); + for (const signal of ["SIGTERM", "SIGINT"]) + process.on(signal, () => child.kill(signal)); + child.on("error", () => process.exit(1)); + child.on("exit", (code) => process.exit(code ?? 1)); +} diff --git a/server/runtime/acp/requirements.txt b/server/runtime/acp/requirements.txt new file mode 100644 index 000000000..ea3ef8a56 --- /dev/null +++ b/server/runtime/acp/requirements.txt @@ -0,0 +1,3 @@ +acpremote==1.7.0 +agent-client-protocol==0.11.0 +websockets==15.0.1