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
22 changes: 20 additions & 2 deletions docs/docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ sandbox:
max_ttl_minutes: 1440
default_network_kind: bridge
images_file: images/catalog.yaml
screenshots:
base_url: http://127.0.0.1:8081
listen: 127.0.0.1:8081
dir: screenshots
```

| Field | Contract |
Expand All @@ -53,12 +57,26 @@ images_file: images/catalog.yaml
| `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` | `bridge` (default) or `ovn`. OVN requires an existing central, configured chassis, and physical uplink. |
| `sandbox.default_network_kind` | `ovn` (default) or `bridge`. 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 desktop integration; the screenshot service is not implemented. |
| `screenshots.base_url` | Required absolute HTTP(S) URL reachable from the agent host, without credentials, query, or fragment. Never derived from the request's `Host` header. |
| `screenshots.dir` | Scratch parent directory; defaults to `screenshots` beside the configuration file. Each process locks the parent and clears only its owned child directory. |
| `screenshots.listen` | Separate screenshot listener in STDIO mode; defaults to `127.0.0.1:8081`. HTTP mode instead mounts screenshots beside MCP on `--addr`. |

`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 example above targets STDIO. For `agentcompute http --addr localhost:8080`,
set `screenshots.base_url` to the agent-reachable URL for port 8080 instead.
Configure a reverse proxy consistently if the public URL has a path prefix.
An unreachable URL is a network configuration error; there is no base64 fallback.

Screenshot URLs are bearer capabilities with 128-bit random identifiers. They
do not require the MCP bearer token. Only GET and HEAD are accepted; responses
use `image/png`, `Cache-Control: no-store`, and `X-Content-Type-Options: nosniff`.
Retention is five minutes or sandbox expiry, whichever is earlier. Sandbox
deletion and reaping purge associated screenshots. A PNG may use at most 16 MiB;
the store rejects new publications at 128 MiB rather than evicting live images.

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.
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
go.yaml.in/yaml/v3 v3.0.5
golang.org/x/image v0.31.0
)

require (
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
golang.org/x/crypto v0.0.0-20190426145343-a29dc8fdc734/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/image v0.31.0 h1:mLChjE2MV6g1S7oqbXC0/UcKijjm5fnJLUYKIYrLESA=
golang.org/x/image v0.31.0/go.mod h1:R9ec5Lcp96v9FTF+ajwaH3uGxPH4fKfHHAVbUILxghA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
Expand Down
40 changes: 30 additions & 10 deletions images/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,12 @@ in the restricted `image-build` project, publishes to the existing
`ghcr.io/gilmanlab/agentcompute/<image>` namespace, and fetches each digest
back independently before opening a public catalog PR.

A newly created GHCR package starts private. For this public catalog, its
owner must explicitly make the package public in GitHub's package settings
after the first publication. This change cannot be reversed to private.
Verify anonymous access before merging its catalog entry: authenticated
fetch-back by the publisher does not establish public availability.

The immutable tag hashes definition inputs under `images/`, excluding
`catalog.yaml` and Markdown. An existing release skips assembly and
publication but is still fetched and boot-qualified. Registry or
Expand All @@ -108,6 +114,9 @@ attestations; new private bakes must not be described as carrying that
provenance. Digest verification and boot tests establish different properties.

The server's startup reconciler imports catalog digest references into `image-build`, verifies bytes, smoke-launches the image, and moves the alias only after success. It records the imgoci digest in image properties; the Incus fingerprint is derived, not a stable identity across rebuilds.
Desktop VM qualification waits for X11, the automation user's Driver
service, and a native `list_apps` call through that user's session. It uses
the catalog's CPU and memory defaults and does not require the GitHub runner.

For a verified download without import:

Expand Down Expand Up @@ -217,12 +226,13 @@ Scratch is sampled every 100 ms, so short peaks between samples are missed.

### Desktop Phase 6

Two local desktop builds used fresh work and output directories:
The two local builds and protected publication produced these measurements:

| Build evidence | Download | Compile | Assemble | Peak RSS | Scratch high-water | `incus.tar.xz` | `disk.qcow2` |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| First image | 5.849 s | 31.670 s | 842.017 s | 507,180 KiB | 7,625,043,968 B | 656 B | 744,611,840 B |
| Corrected networkd rebuild | 5.824 s | 31.834 s | 392.512 s | 517,712 KiB | 7,621,808,128 B | 640 B | 745,013,248 B |
| Protected published build | 6.624 s | 33.283 s | 403.364 s | 505,216 KiB | 7,620,071,424 B | 628 B | 742,923,264 B |

Download includes verified retrieval of Go, vendored distrobuilder source,
Ubuntu base, the snapshot CA package, and the full Cua Driver archive. Compile
Expand All @@ -234,18 +244,28 @@ subsequently passed `images/ubuntu-24.04-desktop/smoke.py` in `image-build`:
X11, the graphical user's active Driver service, and 234 native `list_apps`
entries. The smoke removed its own VM and imported image.

Protected bootstrap PR #23 and private bake run 34854323245 completed
successfully. The desktop-aware publisher must be deployed before the
four-image bake. Desktop publication, verified fetch-back, and the public
catalog update remain pending; no desktop GHCR digest is claimed.
The desktop-aware publisher is deployed. Image PR #25 merged as
`e4333f245b4e81c8d7753038f0ddf04a620bd0a2`, and
[protected bake 34872818589](https://github.com/GilmanLab/agentcompute-images/actions/runs/34872818589)
built, boot-qualified, published, and fetched back all four images.
The desktop pipeline took 674.806 s and produced:

```text
ghcr.io/gilmanlab/agentcompute/ubuntu-24.04-desktop@sha256:5dc4e120a79dd06ad6784e69474f0617387f74cb98685af8844170b7165ea8e2
```

[Catalog PR #27](https://github.com/GilmanLab/agentcompute/pull/27) records
this digest and remains unmerged by request. Published artifact hashes and
the independent publisher rollout evidence are in the spike report.

The corrected image passed the complete production-stdio MCP acceptance run
after OVN recovery: private-only client, desktop readiness, native `list_apps`,
PNG URL fetch/decode, one foreground token click changing one editor tab to
two, reboot recovery, and the VNC endpoint reported by `desktop.info`.
The representative program took 23.624 s; the full run took 158.45 s.
The running reaper returned 404 for the original screenshot 27.120 s after
the shortened sandbox expiry. See the spike report for the native background
PNG URL fetch/decode, one foreground token click changing a single document
to two document tabs, reboot recovery, and the VNC endpoint reported by
`desktop.info`. The post-refactor repeat's representative program took
22.764 s; full acceptance took 160.32 s. The running reaper returned 404 for
the original screenshot 26.804 s after the shortened sandbox expiry.
See the spike report for the native background
delivery limitation and [fleet PR #20](https://github.com/GilmanLab/fleet/pull/20)
for the separately recovered stale-CA reconnect storm and active log limits.

Expand Down
2 changes: 1 addition & 1 deletion internal/cli/dependencies_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,6 @@ func testDependencies(t *testing.T) *mcpserver.Dependencies {
require.NoError(t, err)
service, err := compute.New(mocks.NewMockBackend(t), catalog, compute.Options{Host: "lab01"})
require.NoError(t, err)
deps := mcpserver.NewDependencies(service)
deps := mcpserver.NewDependencies(service, nil)
return &deps
}
218 changes: 218 additions & 0 deletions internal/cli/desktop_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
//go:build integration

package cli

import (
"bytes"
"context"
"fmt"
"image"
_ "image/png"
"io"
"net"
"net/http"
"os"
"strings"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/GilmanLab/agentcompute/internal/incus"
)

// TestDesktopAcceptance drives real Driver tools through the production stdio
// MCP server. The representative client never receives an uplink-facing NIC;
// a separate viewer guest verifies the human VNC endpoint and reboot recovery.
func TestDesktopAcceptance(t *testing.T) {
fx := loadOVNFixture(t)
ctx, cancel := context.WithTimeout(t.Context(), 12*time.Minute)
defer cancel()
root, dir := integrationRoot(t), t.TempDir()
binary := buildAgentcompute(ctx, t, root, dir)
config := writeOVNConfig(t, dir, root, fx)
imageName := envOr("AGENTCOMPUTE_TEST_DESKTOP_IMAGE", "ubuntu/24.04/desktop")
name := fmt.Sprintf("desktop-%x", time.Now().UnixNano())
backend, err := incus.New(
ctx,
incus.Options{Remote: fx.Remote, Pool: "data", OVNUplink: fx.Uplink, OVNRanges: fx.Ranges},
)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, backend.Close()) })
t.Cleanup(func() {
cleanup, stop := context.WithTimeout(context.Background(), 2*time.Minute)
defer stop()
if _, err := backend.GetSandbox(cleanup, name); err == nil {
assert.NoError(t, backend.DeleteSandbox(cleanup, name))
}
})
session, _ := startClusterClient(ctx, t, binary, config,
"sandbox.create", "sandbox.extend", "instance.create", "instance.get", "instance.exec",
"instance.wait", "instance.restart", "net.create", "net.attach", "net.forward",
"desktop.info", "desktop.enable", "desktop.call", "desktop.screenshot")

started := time.Now()
representative := integrationExecute(ctx, t, session, fmt.Sprintf(`def main():
name = %q
sandbox.create(name=name, ttl_minutes=10)
net.create(sandbox=name, name="lan", cidr="192.168.50.0/24", nat=False)
net.create(sandbox=name, name="wan", cidr="10.99.0.0/24", nat=True)
instance.create(sandbox=name, name="rtr", image="router", network="lan")
net.attach(sandbox=name, instance="rtr", network="wan")
nat = instance.exec(sandbox=name, name="rtr", command="/opt/router/nat --mode port-restricted --inside eth0 --outside eth1")
if nat["exit_code"] != 0:
fail(nat["stderr"])
instance.create(sandbox=name, name="client", image=%q, network="lan")
waited = instance.wait(sandbox=name, name="client", until="desktop", timeout_seconds=300)
shot = desktop.screenshot(sandbox=name, instance="client")
called = desktop.call(sandbox=name, instance="client", tool="list_apps")
if not called["ok"]:
fail(called["summary"])
return {"router": instance.get(sandbox=name, name="rtr"), "client": instance.get(sandbox=name, name="client"), "screenshot": shot, "apps": json.decode(called["result"]), "waited": waited}
`, name, imageName))
t.Logf("representative desktop program: %s", time.Since(started))
clientNICs := asSlice(t, asMap(t, representative["client"])["nics"])
require.Len(t, clientNICs, 1, "the representative client must remain private-only")
assert.Equal(t, "lan", asMap(t, clientNICs[0])["network"])
apps := asSlice(t, asMap(t, representative["apps"])["apps"])
var editorInstalled bool
for _, app := range apps {
if asMap(t, app)["bundle_id"] == "org.gnome.TextEditor" {
editorInstalled = true
}
}
require.True(t, editorInstalled, "native list_apps must expose the installed editor")
shot := asMap(t, representative["screenshot"])
originalURL := asString(t, shot["url"])
_, dimensions := fetchDesktopPNG(t, originalURL)
assert.Equal(t, jsonInt(t, shot["width"]), int64(dimensions.Width))
assert.Equal(t, jsonInt(t, shot["height"]), int64(dimensions.Height))

interaction := integrationExecute(ctx, t, session, fmt.Sprintf(`def main():
name = %q
launched = desktop.call(sandbox=name, instance="client", tool="launch_app", args=json.encode({"name":"gnome-text-editor"}))
if not launched["ok"]:
fail(launched["summary"])
app = json.decode(launched["result"])
pid = app["pid"]
window = app["windows"][0]["window_id"]
args = json.encode({"pid":pid,"window_id":window,"include_accessibility_tree":True})
before_call = desktop.call(sandbox=name, instance="client", tool="get_window_state", args=args)
if not before_call["ok"]:
fail(before_call["summary"])
before = json.decode(before_call["result"])
token = ""
for element in before["elements"]:
if element.get("label") == "New tab" and "click" in element.get("actions", []):
token = element["element_token"]
break
if not token:
fail("editor snapshot has no actionable New tab element")
clicked = desktop.call(sandbox=name, instance="client", tool="click", args=json.encode({"pid":pid,"window_id":window,"element_token":token,"delivery_mode":"foreground"}))
if not clicked["ok"]:
fail(clicked["summary"])
after_call = desktop.call(sandbox=name, instance="client", tool="get_window_state", args=args)
if not after_call["ok"]:
fail(after_call["summary"])
after = json.decode(after_call["result"])
return {"before":before["tree_markdown"], "after":after["tree_markdown"], "before_url":before_call["screenshot_url"], "after_url":after_call["screenshot_url"], "click":json.decode(clicked["result"]), "scaled":desktop.screenshot(sandbox=name, instance="client", max_dimension=640), "window_scaled":desktop.screenshot(sandbox=name, instance="client", pid=pid, window_id=window, max_dimension=640), "window_width":before["window_bounds"]["width"]}
`, name))
assert.Equal(t, 1, strings.Count(asString(t, interaction["before"]), `tab panel = "New Document"`))
assert.Equal(t, 2, strings.Count(asString(t, interaction["after"]), `tab panel = "New Document"`))
beforePNG, _ := fetchDesktopPNG(t, asString(t, interaction["before_url"]))
afterPNG, _ := fetchDesktopPNG(t, asString(t, interaction["after_url"]))
assert.False(t, bytes.Equal(beforePNG, afterPNG), "the verified second tab must also change the screenshot")
scaled := asMap(t, interaction["scaled"])
_, scaledDimensions := fetchDesktopPNG(t, asString(t, scaled["url"]))
assert.LessOrEqual(t, scaledDimensions.Width, 640)
assert.LessOrEqual(t, scaledDimensions.Height, 640)
windowScaled := asMap(t, interaction["window_scaled"])
_, windowDimensions := fetchDesktopPNG(t, asString(t, windowScaled["url"]))
assert.LessOrEqual(t, max(windowDimensions.Width, windowDimensions.Height), 640)
assert.InDelta(
t,
float64(jsonInt(t, interaction["window_width"]))/float64(windowDimensions.Width),
windowScaled["scale"],
1e-9,
)
t.Logf("token click native outcome (state verified separately): %v", interaction["click"])
if evidence := os.Getenv("AGENTCOMPUTE_TEST_DESKTOP_EVIDENCE"); evidence != "" {
require.NoError(t, os.MkdirAll(evidence, 0o700))
require.NoError(t, os.WriteFile(evidence+"/before.png", beforePNG, 0o600))
require.NoError(t, os.WriteFile(evidence+"/after.png", afterPNG, 0o600))
}

viewer := integrationExecute(ctx, t, session, fmt.Sprintf(`def main():
name = %q
instance.create(sandbox=name, name="viewer", image=%q)
enabled = desktop.enable(sandbox=name, instance="viewer")
forward = net.forward(sandbox=name, network="default", instance="viewer", port=5900, listen_port=5900, protocol="tcp")
before = desktop.info(sandbox=name, instance="viewer")
instance.restart(sandbox=name, name="viewer")
waited = instance.wait(sandbox=name, name="viewer", until="desktop", timeout_seconds=300)
after = desktop.info(sandbox=name, instance="viewer")
return {"enabled":enabled,"forward":forward,"before":before,"after":after,"waited":waited,"private_client":instance.get(sandbox=name, name="client")}
`, name, imageName))
afterInfo := asMap(t, viewer["after"])
assert.True(t, jsonBool(t, asMap(t, viewer["enabled"])["ready"]))
assert.True(t, jsonBool(t, afterInfo["ready"]))
assert.Equal(t, "0.28.1", afterInfo["driver_version"])
assert.Contains(t, asSlice(t, afterInfo["tools"]), "get_window_state")
assert.Len(t, asSlice(t, asMap(t, viewer["private_client"])["nics"]), 1)
forward := asMap(t, viewer["forward"])
vncAddress := asString(t, afterInfo["vnc"])
assert.Equal(
t,
net.JoinHostPort(asString(t, forward["address"]), fmt.Sprint(jsonInt(t, forward["port"]))),
vncAddress,
)
vnc, err := net.DialTimeout("tcp", vncAddress, 10*time.Second)
require.NoError(t, err)
defer vnc.Close()
require.NoError(t, vnc.SetReadDeadline(time.Now().Add(10*time.Second)))
banner := make([]byte, 12)
_, err = io.ReadFull(vnc, banner)
require.NoError(t, err)
assert.Equal(t, "RFB 003.008\n", string(banner))
t.Logf("reboot ready and VNC reachable: %s", vncAddress)

// Shorten the sandbox lifetime after publication. Its first screenshot was
// initially retained for five minutes, so only lifecycle purge can make it
// disappear at the newly shortened expiry.
extended := integrationExecute(ctx, t, session, fmt.Sprintf(`def main():
return sandbox.extend(name=%q, ttl_minutes=1)
`, name))
expires, err := time.Parse(time.RFC3339, asString(t, extended["expires_at"]))
require.NoError(t, err)
httpClient := &http.Client{Timeout: 5 * time.Second}
require.Eventually(t, func() bool {
response, err := httpClient.Get(originalURL)
if err != nil {
return false
}
defer response.Body.Close()
return response.StatusCode == http.StatusNotFound
}, 100*time.Second, time.Second, "expired sandbox screenshot must be purged by the running reaper")
t.Logf("screenshot 404 observed %s after shortened sandbox expiry", time.Since(expires))
}

func fetchDesktopPNG(t *testing.T, url string) ([]byte, image.Config) {
t.Helper()
client := &http.Client{Timeout: 15 * time.Second}
response, err := client.Get(url)
require.NoError(t, err)
defer response.Body.Close()
require.Equal(t, http.StatusOK, response.StatusCode)
assert.Equal(t, "image/png", response.Header.Get("Content-Type"))
assert.Equal(t, "nosniff", response.Header.Get("X-Content-Type-Options"))
assert.Equal(t, "no-store", response.Header.Get("Cache-Control"))
data, err := io.ReadAll(io.LimitReader(response.Body, (16<<20)+1))
require.NoError(t, err)
require.LessOrEqual(t, len(data), 16<<20)
decoded, format, err := image.Decode(bytes.NewReader(data))
require.NoError(t, err)
require.Equal(t, "png", format)
bounds := decoded.Bounds()
return data, image.Config{Width: bounds.Dx(), Height: bounds.Dy()}
}
Loading