Skip to content
Merged
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
22 changes: 17 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ The server exposes exactly three MCP tools:
- `describe_api` returns the exact input and output shape for one capability.
- `execute` runs a Starlark program and returns the value from its zero-argument `main()` function.

Slice 1 provides 13 capabilities: sandbox create/list/get/extend/delete, image listing, instance create/list/get/delete/exec, and network create/attach. Sandboxes are restricted Incus projects with persisted TTLs and a default NAT bridge. The startup/30-second reaper deletes expired sandboxes.
Capabilities cover sandbox TTLs; instance lifecycle, exec, files, snapshots, and sandbox-local image publishing; and network creation, NICs, peering, ACLs, forwards, and Linux link impairment. Sandboxes are restricted Incus projects. OVN provides cross-member networking; member-local bridges remain available. The startup/30-second reaper retries expired sandboxes when their backend becomes available.

## Local bootstrap

Expand All @@ -33,15 +33,16 @@ Create `agentcompute.yaml` beside `images/`:
```yaml
incus:
remote: nas01
host: lab01
pool: data
sandbox:
default_ttl_minutes: 240
max_ttl_minutes: 1440
default_network_kind: bridge
default_network_kind: ovn
images_file: images/catalog.yaml
```

OVN requires the fleet-managed central, chassis TLS configuration, and physical uplink. To use the bridge fallback, set `default_network_kind: bridge` and configure `incus.host`.

Pass its path with `--config` or `AGENTCOMPUTE_CONFIG`. YAML and TOML are strict: unknown keys fail startup. Catalog and certificate paths resolve relative to the configuration file.

Run the local STDIO transport:
Expand Down Expand Up @@ -92,9 +93,13 @@ def main():
return result
```

Instances use the sandbox's persisted member. An explicit different `host` is rejected. Bridges have opaque Incus names; capabilities expose only logical names such as `default` and `lan`. Each member's bridge is a separate L2 domain, not a cross-member network.
OVN instances may use an explicit online `host`. Without one, placement prefers the most free RAM, then the lowest one-minute load, then member name; automatic placement excludes manual/group-only schedulers. Bridge instances remain on the sandbox's persisted member and reject a different host. Bridge names are opaque; capabilities expose logical names such as `default` and `lan`.

An OVN network with `nat=false` is isolated: it has no direct outside path and consumes no external address. Attach a router instance or use `net.peer` for reachability. Isolated networks reject external forwards. Operator-management and OOB denies are immutable, including against broader user allow rules.

Exec retains 64 KiB per stream while draining the rest. `stdout_truncated` and `stderr_truncated` report overflow. An exec-only timeout returns `timed_out=true`; caller cancellation remains an error. `user` accepts a numeric UID or `root`. OVN and macOS are not available in this slice.
Restoring a snapshot stages a copy before deleting the current instance, then recreates it under the same agent-visible name and ownership. The Incus identity and NIC MAC can change, so the DHCP lease can change too; the original instance's snapshots are consumed. Low-level Incus access remains blocked.

Exec retains 64 KiB per stream while draining the rest. `stdout_truncated` and `stderr_truncated` report overflow. An exec-only timeout returns `timed_out=true`; caller cancellation remains an error. `user` accepts a numeric UID or `root`. macOS is not available.

Only `main()`'s final converted value is returned. Intermediate capability results remain inside the worker and do not enter the model's context.

Expand Down Expand Up @@ -190,6 +195,13 @@ AGENTCOMPUTE_TEST_REMOTE=nas01 AGENTCOMPUTE_TEST_HOST=lab01 \
go test -tags integration ./internal/cli -run TestClusterLifecycle -count=1 -v
```

Run the cross-member OVN acceptance lane against the durable fleet fabric:

```sh
AGENTCOMPUTE_TEST_REMOTE=nas01 AGENTCOMPUTE_TEST_MEMBERS=lab01,lab03 \
go test -tags integration ./internal/cli -run '^TestOVNAcceptance$' -count=1 -v -timeout 15m
```

`root:smoke` and release jobs use offline artifact startup checks, without cluster credentials. Go tests exercise the real CodeMode worker and both MCP transports. Run `.github/scripts/mcp_smoke.py -- bin/agentcompute stdio --config agentcompute.yaml` for read-only live discovery and execution. The combined cluster acceptance program and results are retained under `spikes/`.

CI runs the same aggregate check with:
Expand Down
10 changes: 6 additions & 4 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,17 +49,19 @@ images_file: images/catalog.yaml
| `incus.url` | Explicit daemon URL instead of a named remote. |
| `incus.client_cert`, `incus.client_key` | PEM file paths for the explicit-URL client identity. |
| `incus.server_cert` | Optional PEM file path for a pinned server certificate. Otherwise normal CA verification applies. |
| `incus.host` | Required member for newly created bridge-backed sandboxes. Placement is persisted in project metadata. |
| `incus.host` | Required when the default network kind is `bridge`; that member is persisted for the sandbox. OVN supports explicit per-instance hosts or automatic placement. |
| `incus.pool` | Required storage pool for instance root disks. |
| `sandbox.default_ttl_minutes` | Positive creation default, 240 minutes if omitted. |
| `sandbox.max_ttl_minutes` | Positive upper bound, 1440 minutes if omitted; must be at least the default. |
| `sandbox.default_network_kind` | Only `bridge` is available. |
| `sandbox.default_network_kind` | `bridge` (default) or `ovn`. OVN requires an existing central, configured chassis, and physical uplink. |
| `images_file` | Schema-version-1 catalog path; defaults to `images/catalog.yaml`. |
| `screenshots.dir`, `screenshots.base_url` | Accepted schema fields reserved for the desktop slice; no screenshot service in slice 1. |
| `screenshots.dir`, `screenshots.base_url` | Accepted schema fields reserved for desktop integration; the screenshot service is not implemented. |

`sandbox.extend` replaces the expiry with **now + TTL**, rather than adding time to the old expiry. Explicit deletion first expires the project so a partial failure is retried by the reaper. The reaper scans at startup and every 30 seconds.

The default bridge enables IPv4 DHCP and NAT. `net.create` without L3 options creates a bare bridge. Physical network names are opaque `ac` plus eight lowercase hex characters; `user.agentcompute.sandbox`, `.name`, and `.version` metadata resolve their logical names. Pending creations also reserve the physical name on the project for cleanup.
In OVN mode, sandbox projects own their logical networks and NICs use managed networks only. Networks span members. A `nat=false` network is isolated, has no external allocation or direct outside path, and requires peering or a dual-NIC router for reachability. External forwards require NAT-enabled networks. Automatic instance placement prefers free RAM, then one-minute load, then member name; explicit online hosts take precedence.

In bridge mode, the sandbox's default bridge enables IPv4 DHCP and NAT. Additional bridge networks require `kind="bridge"`. Physical network names are opaque `ac` plus eight lowercase hex characters; `user.agentcompute.sandbox`, `.name`, and `.version` metadata resolve their logical names. Pending creations also reserve the physical name on the project for cleanup.

All members receive bridge definitions before activation, but each member has a separate L2/dnsmasq/NAT instance. All guests in a bridge-backed sandbox must use its persisted member. These bridges are not OVN networks.

Expand Down
6 changes: 4 additions & 2 deletions docs/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ incus:
images_file: images/catalog.yaml
```

This example uses the member-local bridge fallback. For cross-member networking, set `sandbox.default_network_kind` to `ovn`; `incus.host` can then be omitted. OVN requires a provisioned central, chassis configuration, and physical uplink.

The identity must manage sandbox projects and bridges in the default project. The image-build-only CI certificate is insufficient. See [Configuration](configuration.md) for explicit-URL credentials and TTL settings.

## Connect over STDIO
Expand All @@ -41,7 +43,7 @@ Configure an MCP client with absolute paths:
}
```

Startup reconciles the image catalog. The client sees exactly `search_api`, `describe_api`, and `execute`; the 13 compute capabilities live behind those tools. STDIO sends JSON-RPC to stdout and diagnostics to stderr.
Startup reconciles the image catalog. The client sees exactly `search_api`, `describe_api`, and `execute`; compute capabilities live behind those tools. STDIO sends JSON-RPC to stdout and diagnostics to stderr.

## Discover the capabilities

Expand Down Expand Up @@ -72,7 +74,7 @@ def main():

The result contains the command's exit code, stdout, stderr, timeout flag, and per-stream truncation flags. Only the final value returned by `main()` enters the successful MCP result.

An omitted sandbox name is generated. Its default bridge provides DHCP and NAT on the configured member. All its guests stay on that persisted member. If execution fails before explicit deletion, use `sandbox.list` to find the sandbox; its persisted TTL also survives a server restart and is enforced by the reaper.
An omitted sandbox name is generated. With this bridge configuration, its default network provides DHCP and NAT on the configured member, and all guests stay on that member. If execution fails before explicit deletion, use `sandbox.list` to find the sandbox; its persisted TTL survives a server restart and is enforced by the reaper.

## Use HTTP

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ description: A CodeMode-native Model Context Protocol server.

`agentcompute` is a [CodeMode](https://github.com/meigma/codemode) [Model Context Protocol](https://modelcontextprotocol.io) server. You register typed Go capabilities; an agent uses the fixed `search_api`, `describe_api`, and `execute` MCP tools to discover and compose them in bounded Starlark programs.

Slice 1 manages time-limited Incus container sandboxes: 13 capabilities cover sandbox lifecycle, curated images, instances and bounded exec, and bridge networks. State survives process restarts in Incus project metadata.
Agentcompute manages time-limited Incus sandboxes with cross-member OVN networking or member-local bridges. Capabilities cover instance lifecycle, bounded exec, files, snapshots, image publishing, peering, ACLs, forwards, and link impairment. State survives process restarts in Incus project metadata.

## Documentation

Expand Down
12 changes: 11 additions & 1 deletion images/catalog.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,20 @@ images:
kinds:
- container
kind: container
reference: ghcr.io/gilmanlab/agentcompute/router@sha256:7d0877799aafc04c8a3ce321bfd586969f97c65e91665941455eeb160555df39
reference: ghcr.io/gilmanlab/agentcompute/router@sha256:47cc9f058575d398736f45cd696f5613a24af73188df0a154c6284cc3e091960
cpus: 1
memory_mb: 512
disk_gb: 2
- name: ubuntu/24.04
os: ubuntu
version: '24.04'
kinds:
- container
kind: container
reference: images:ubuntu/24.04
cpus: 1
memory_mb: 1024
disk_gb: 4
- name: runner
os: ubuntu
version: '24.04'
Expand Down
120 changes: 116 additions & 4 deletions internal/cli/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ func TestClusterLifecycle(t *testing.T) {
require.NoError(t, err, "%s", output)
config := filepath.Join(dir, "config.yaml")
text := fmt.Sprintf(
"incus:\n remote: %q\n host: %q\n pool: data\nimages_file: %q\n",
"incus:\n remote: %q\n host: %q\n pool: data\nsandbox:\n default_network_kind: bridge\nimages_file: %q\n",
remote,
host,
filepath.Join(root, "images", "catalog.yaml"),
Expand All @@ -58,6 +58,7 @@ func TestClusterLifecycle(t *testing.T) {
`)
t.Logf("MCP sandbox creation: %s", time.Since(createStarted))
name := sandbox["name"].(string)
require.Equal(t, "bridge", sandbox["network"].(map[string]any)["kind"])
expires := sandbox["expires_at"].(string)
t.Cleanup(func() {
cleanup, stop := context.WithTimeout(context.Background(), time.Minute)
Expand All @@ -78,6 +79,14 @@ func TestClusterLifecycle(t *testing.T) {
observedNICs := created["observed"].(map[string]any)["nics"].([]any)
require.Len(t, observedNICs, 1)
assert.Equal(t, "default", observedNICs[0].(map[string]any)["network"])
mixed, err := first.CallTool(ctx, &mcp.CallToolParams{
Name: "execute",
Arguments: map[string]any{"source": fmt.Sprintf(`def main():
return net.create(sandbox=%q, name="ovn-mixed", kind="ovn")
`, name)},
})
require.NoError(t, err)
require.True(t, mixed.IsError, "OVN networks must not mix into a bridge sandbox")
duplicate, err := first.CallTool(ctx, &mcp.CallToolParams{
Name: "execute",
Arguments: map[string]any{"source": fmt.Sprintf(`def main():
Expand Down Expand Up @@ -156,15 +165,36 @@ func TestClusterLifecycle(t *testing.T) {
}
}

func startClusterClient(ctx context.Context, t *testing.T, binary, config string) (*mcp.ClientSession, *exec.Cmd) {
func startClusterClient(
ctx context.Context,
t *testing.T,
binary, config string,
names ...string,
) (*mcp.ClientSession, *exec.Cmd) {
t.Helper()
if len(names) == 0 {
names = []string{
"sandbox.create",
"sandbox.list",
"instance.create",
"instance.get",
"instance.exec",
"net.attach",
}
}
command := exec.CommandContext(ctx, binary, "stdio", "--config", config)
command.Stderr = os.Stderr
client := mcp.NewClient(&mcp.Implementation{Name: "integration", Version: "1"}, nil)
session, err := client.Connect(ctx, &mcp.CommandTransport{Command: command, TerminateDuration: time.Second}, nil)
require.NoError(t, err)
t.Cleanup(func() { _ = session.Close() })
for _, name := range []string{"sandbox.create", "sandbox.list", "instance.create", "instance.get", "instance.exec", "net.attach"} {
discoverCapabilities(ctx, t, session, names)
return session, command
}

func discoverCapabilities(ctx context.Context, t *testing.T, session *mcp.ClientSession, names []string) {
t.Helper()
for _, name := range names {
for _, tool := range []struct{ name, key string }{{"search_api", "query"}, {"describe_api", "name"}} {
result, err := session.CallTool(
ctx,
Expand All @@ -174,7 +204,6 @@ func startClusterClient(ctx context.Context, t *testing.T, binary, config string
require.False(t, result.IsError, "%v", result.Content)
}
}
return session, command
}

func integrationExecute(ctx context.Context, t *testing.T, session *mcp.ClientSession, source string) map[string]any {
Expand All @@ -193,3 +222,86 @@ func integrationExecute(ctx context.Context, t *testing.T, session *mcp.ClientSe
require.NoError(t, json.Unmarshal(data, &envelope))
return envelope.Result
}

func integrationExecuteError(ctx context.Context, t *testing.T, session *mcp.ClientSession, source string) {
t.Helper()
result, err := session.CallTool(
ctx,
&mcp.CallToolParams{Name: "execute", Arguments: map[string]any{"source": source}},
)
require.NoError(t, err)
require.True(t, result.IsError, "expected an agent-facing error, content: %v", result.Content)
}

func requireTestRemote(t *testing.T) string {
t.Helper()
remote := os.Getenv("AGENTCOMPUTE_TEST_REMOTE")
if remote == "" {
t.Skip("set AGENTCOMPUTE_TEST_REMOTE to opt into disposable cluster resources")
}
return remote
}

func integrationRoot(t *testing.T) string {
t.Helper()
root, err := filepath.Abs(filepath.Join("..", ".."))
require.NoError(t, err)
return root
}

func buildAgentcompute(ctx context.Context, t *testing.T, root, dir string) string {
t.Helper()
binary := filepath.Join(dir, "agentcompute")
build := exec.CommandContext(ctx, "go", "build", "-o", binary, "./cmd/agentcompute")
build.Dir = root
output, err := build.CombinedOutput()
require.NoError(t, err, "%s", output)
return binary
}

func asMap(t *testing.T, value any) map[string]any {
t.Helper()
out, ok := value.(map[string]any)
require.True(t, ok, "expected object, got %T", value)
return out
}

func asSlice(t *testing.T, value any) []any {
t.Helper()
out, ok := value.([]any)
require.True(t, ok, "expected list, got %T", value)
return out
}

func asString(t *testing.T, value any) string {
t.Helper()
out, ok := value.(string)
require.True(t, ok, "expected string, got %T", value)
return out
}

func jsonInt(t *testing.T, value any) int64 {
t.Helper()
switch n := value.(type) {
case int64:
return n
case float64:
return int64(n)
case json.Number:
parsed, err := n.Int64()
require.NoError(t, err)
return parsed
case int:
return int64(n)
default:
t.Fatalf("expected integer, got %T", value)
return 0
}
}

func jsonBool(t *testing.T, value any) bool {
t.Helper()
out, ok := value.(bool)
require.True(t, ok, "expected bool, got %T", value)
return out
}
Loading