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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/server-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,17 @@ 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"

# categorygen's checks (unclassified route, category that isn't control or
# platform, classified route with no handler) only run when the generator
# does, and its only other caller is `make oapi-generate`, which needs the
Expand Down
8 changes: 8 additions & 0 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -68,6 +69,13 @@ export OUTPUT_DIR=/tmp/recordings
./bin/api
```

### Optional ACP agents

The [ACP proxy](lib/agentproxy/README.md) exposes one WebSocket endpoint using
`acpremote expose` and its per-connection process lifecycle. It is disabled by
default and requires separately provisioned bridge and harness executables.
The public declarative installer/configuration API is not implemented yet.

### API Documentation

- **YAML Spec**: `GET /spec.yaml`
Expand Down
15 changes: 15 additions & 0 deletions server/cmd/api/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down
4 changes: 4 additions & 0 deletions server/cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
142 changes: 142 additions & 0 deletions server/lib/agentproxy/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# ACP WebSocket proxy

This is the first integration milestone for an ACP-first agent API: a real
kernel-images route and an existing-client compatibility gate. It deliberately
contains no conversation REST protocol, runtime resource, prompt journal,
idempotency layer, session-ID translation, or delivery replay.

## Implemented surface

- `GET /agent/v1/harnesses` returns configured harness names. This is configuration
discovery, not a claim that a harness/model or every ACP capability is validated.
- WebSocket `GET /agent/v1/acp?harness=pi` starts `acpremote expose` with the trusted
launch definition and proxies ACP messages unchanged.
- Names are `pi`, `codex`, `claude`, and `gemini`. Only provisioned names are enabled.
- `AGENT_CONFIG_PATH` enables the routes. Empty or invalid configuration leaves
them unavailable without breaking the browser's other APIs.

These routes use the existing browser API authentication/routing boundary, like
`/process/{process_id}/attach`; they do not introduce another public token scheme.
Do not expose the image server directly to an untrusted network. Each internal
bridge binds only to loopback and requires a generated bearer token; browser
credentials and client headers are not forwarded to that bridge.

## Lifecycle

For each client connection the server starts a dedicated `acpremote expose`
listener on an OS-assigned loopback port. Opening its upstream WebSocket starts
one harness/adapter subprocess. The listener and subprocess are scoped to that
attachment and are cleaned up when the connection ends. Multiple concurrent
attachments are allowed within `maxConnections`; each may contain multiple ACP
sessions if the selected harness supports them.

A disconnect is termination, not detach. On reconnect, a fresh subprocess may load
an exact persisted native conversation with ACP `session/load` or `session/resume`.
Native storage must survive connection cleanup. No output is retained by the proxy,
and an interrupted tool's side effect is not automatically reconciled or repeated.

The proxy retains no ACP state. It does not intercept initialize, permissions,
model selection, MCP definitions, session IDs, or results/errors. Subprotocol
`acp.v1` is accepted when offered; no subprotocol is required. Frame size is limited
to 1 MiB to match acpremote 1.7.0's default. Downstream ping/pong uses a 20-second
interval and timeout so a dead network connection cannot leave the agent running
indefinitely while the proxy answers upstream pings.

Server shutdown cancels startup and active connections. Bridge cleanup first asks
the CLI to shut down gracefully, then kills the owned process group after at most
six seconds so adapters cannot leave ordinary descendant processes behind. This
is process cleanup, not a security sandbox for programs that deliberately daemonize
outside their process group.

## Trusted launch catalog (bootstrap, not the declarative public API)

Install Python 3.11+ and the pinned transport dependencies in a private environment:

```sh
python3 -m venv /opt/kernel-agent/venv
/opt/kernel-agent/venv/bin/python -m pip install -r server/runtime/acp/requirements.txt
```

The image does not install these dependencies or any harness automatically in this
milestone. An operator must provision the environment and pinned harness/adapter
binaries before enabling the feature. The bridge executable is expected to be
acpremote 1.7.0; startup reads that CLI's loopback readiness banner, not agent output.

Example catalog, assuming the executable and working directory are provisioned:

```json
{
"acpremote": "/opt/kernel-agent/venv/bin/acpremote",
"maxConnections": 8,
"harnesses": {
"gemini": {
"command": "/opt/kernel-agent/gemini/node_modules/.bin/gemini",
"args": ["--acp"],
"cwd": "/workspace",
"env": {"HOME": "/home/kernel"},
"inheritEnv": ["GEMINI_API_KEY"]
}
}
}
```

Set `AGENT_CONFIG_PATH` to this private file and restart the image server. Launch
configuration is read once; editing the file does not hot-reload running processes.
The catalog is limited to 64 KiB. Commands and working directories must be absolute.
It is trusted operator input, not a mechanism for a WebSocket caller to supply a
command, arguments, or environment. Agent stderr is discarded by this first
integration; no transcript or secret-bearing log stream is exposed.

Only PATH, HOME, USER, LANG, TMPDIR, TERM and certificate paths are inherited by
default. Provider credentials must be explicitly named by `inheritEnv`; a missing
or empty required value rejects connection startup. `env` overrides inherited
values and should contain nonsecret settings only. Neither is returned by discovery.

All connections to a harness use the same configured environment and working
directory. Shared configuration, extensions, MCP files and native history are not
copied or deleted per connection. For Pi, explicitly setting `PI_CODING_AGENT_DIR`
selects shared Pi state; project-local discovery remains Pi/adapter behavior.
Clients must supply a working directory meaningful on the remote host. Local IDE
files, terminal callbacks and local stdio MCP servers do not become remotely
available automatically.

## Acceptance checks

The integration suite uses real acpremote processes and TCP WebSockets. Its
stdlib-only ACP peer is deterministic: no provider credentials or model calls.

```sh
python3 -m venv /tmp/acp-test
/tmp/acp-test/bin/python -m pip install -r runtime/acp/requirements.txt
AGENT_PROXY_TEST_ACPREMOTE=/tmp/acp-test/bin/acpremote \
go test -race -v ./lib/agentproxy ./lib/wsproxy
```

Run from `server/`. CI installs the pinned bridge and sets the environment variable;
without it, the external-process cases explicitly skip rather than claim a pass.

The gates cover independent concurrent processes, multiple native sessions per
connection, connection admission, termination during an in-flight prompt, exact-ID
history load after reconnect, permissions, opaque content/metadata, server shutdown,
message limits, and unresponsive clients. A separate case drives the unmodified
`acpremote mirror` CLI using the official ACP Python SDK. No Kernel-specific client
messages or headers are needed.

ACP UI 0.1.16 at commit `cd9c3cb464a4b321bff652101953a64c07473e31` was also tested
locally without source changes: initialization, session creation, streamed output,
permission approval, disconnect, and exact-session restoration in a fresh process.
This was a local WebSocket test with the deterministic peer, not a TLS/gateway or
real-model test. The UI check is not yet part of CI.

## Remaining work

The public declarative configuration GET/PUT API, transactional preparation,
harness/extension installers, and model-provider secret bindings remain a separate
milestone. The bootstrap catalog is not their replacement. They should produce
trusted launch definitions while making shared filesystem effects explicit.

Each harness still needs pinned native installation, capability/MCP/media tests,
permission and native restoration checks, and shared-configuration verification.
Successful fixture/client interoperability is not a blanket harness support claim.
Platform-level routing/authentication and packaged-image tests with the feature
enabled are also required before public availability.
128 changes: 128 additions & 0 deletions server/lib/agentproxy/bridge.go
Original file line number Diff line number Diff line change
@@ -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
})
}
Loading
Loading