diff --git a/Makefile b/Makefile index 75127f413..a69748107 100644 --- a/Makefile +++ b/Makefile @@ -190,7 +190,7 @@ check-distribution: PYTHONDONTWRITEBYTECODE=1 python3 deploy/test_install.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/core-distribution-manifest.test.py PYTHONDONTWRITEBYTECODE=1 python3 scripts/publish-core-release.test.py - bash -n deploy/install.sh scripts/build-web.sh scripts/build-core-distribution.sh scripts/build-core-image-context.sh scripts/prepare-release-runtimes.sh + bash -n deploy/install.sh scripts/build-web.sh scripts/build-core-distribution.sh scripts/build-core-image-context.sh scripts/prepare-release-runtimes.sh scripts/build-agent-host-images.sh scripts/qualify-agent-host.sh ./scripts/build-web.sh build-core-distribution: diff --git a/apps/daemon/internal/agent/installation.go b/apps/daemon/internal/agent/installation.go index 2083b8052..6c13bf9d7 100644 --- a/apps/daemon/internal/agent/installation.go +++ b/apps/daemon/internal/agent/installation.go @@ -1,6 +1,15 @@ package agent -import "context" +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "maps" + "os" + "path/filepath" + "slices" +) // Installation is an optional adapter-owned native distribution contract. // It supplies activation paths and readiness checks, never execution or model @@ -12,3 +21,38 @@ type Installation struct { Environment func(directory, node string) map[string]string Check func(context.Context, string, string, []string) error } + +// ManifestEnvironment reads the installation manifest at path, which an +// agent-host image carries, and returns the environment that activates each +// Harness it lists: what the Harness's Installation derives from its +// directory and Node. Discovery reads that environment. The manifest is a +// JSON object with "node", the absolute path of Node, and "harnesses", the +// absolute directory of each installed Harness by agent kind. A kind without +// an Installation in installations is an error. +func ManifestEnvironment(path string, installations ...Installation) (map[string]string, error) { + raw, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("installation manifest: %w", err) + } + var manifest struct { + Node string `json:"node"` + Harnesses map[string]string `json:"harnesses"` + } + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&manifest); err != nil { + return nil, fmt.Errorf("installation manifest %s: %w", path, err) + } + if !filepath.IsAbs(manifest.Node) { + return nil, fmt.Errorf("installation manifest %s: node %q is not an absolute path", path, manifest.Node) + } + env := map[string]string{} + for kind, dir := range manifest.Harnesses { + i := slices.IndexFunc(installations, func(i Installation) bool { return i.AgentKind == kind }) + if i < 0 || !filepath.IsAbs(dir) { + return nil, fmt.Errorf("installation manifest %s: %s at %q is not an installation of a known kind at an absolute path", path, kind, dir) + } + maps.Copy(env, installations[i].Environment(dir, manifest.Node)) + } + return env, nil +} diff --git a/apps/daemon/internal/agent/installation_test.go b/apps/daemon/internal/agent/installation_test.go new file mode 100644 index 000000000..90f5ad0c9 --- /dev/null +++ b/apps/daemon/internal/agent/installation_test.go @@ -0,0 +1,30 @@ +package agent + +import ( + "os" + "path/filepath" + "reflect" + "testing" +) + +func TestManifestEnvironmentActivatesEachListedInstallation(t *testing.T) { + fake := Installation{AgentKind: "fake", Environment: func(dir, node string) map[string]string { + return map[string]string{"FAKE_DIR": dir, "FAKE_NODE": node} + }} + for manifest, want := range map[string]map[string]string{ + `{"node":"/n/node","harnesses":{"fake":"/h/fake"}}`: {"FAKE_DIR": "/h/fake", "FAKE_NODE": "/n/node"}, + `{"node":"/n/node","harnesses":{"other":"/h/other"}}`: nil, + `{"node":"/n/node","harnesses":{"fake":"h/fake"}}`: nil, + `{"node":"node","harnesses":{"fake":"/h/fake"}}`: nil, + `{"node":"/n/node","harnesses":{},"fake_dir":"/h/fake"}`: nil, + } { + path := filepath.Join(t.TempDir(), "harnesses.json") + if err := os.WriteFile(path, []byte(manifest), 0o600); err != nil { + t.Fatal(err) + } + got, err := ManifestEnvironment(path, fake) + if want == nil && err == nil || want != nil && (err != nil || !reflect.DeepEqual(got, want)) { + t.Errorf("ManifestEnvironment(%s) = %v, %v; want %v", manifest, got, err, want) + } + } +} diff --git a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go index 342b55fec..234c2422a 100644 --- a/apps/daemon/internal/agenthostqualify/qualify_linux_test.go +++ b/apps/daemon/internal/agenthostqualify/qualify_linux_test.go @@ -8,7 +8,10 @@ package agenthostqualify import ( "context" "crypto/rand" + "crypto/tls" + "crypto/x509" "encoding/json" + "encoding/pem" "errors" "fmt" "io" @@ -40,41 +43,20 @@ import ( "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" ) -// The rig is two privileged-suite style containers on the host network. The -// sandbox is plain Debian with bash, git, coreutils and ripgrep, running -// oac-sandbox-io against the Link test relay this test serves on loopback; the -// bootstrap reaches it through a shared run directory. The agent host runs this -// test with the pinned Harness installs, in a private cgroup namespace for the -// views' cgroups. Each OAC_QUALIFY_ holds the -// Harness's model and model_provider without api_key, which the test reads -// from OAC_QUALIFY_KEY_FILE. -// -// CGO_ENABLED=0 go build -o $BIN/oac-sandbox-io ./apps/sandboxio/cmd/oac-sandbox-io -// CGO_ENABLED=0 go build -o $BIN/oac-process-shim ./apps/daemon/cmd/oac-process-shim -// CGO_ENABLED=0 go test -c -o $BIN/qualify.test ./apps/daemon/internal/agenthostqualify -// printf 'FROM debian:bookworm-slim\nRUN apt-get update && apt-get install -y --no-install-recommends bash git coreutils ripgrep && mkdir -m 0777 /workspace\n' | -// docker build -t oac-qualify-sandbox - -// RUN=$(mktemp -d) -// docker run -d --rm --network host -v $RUN:/run/qualify:ro -v $BIN/oac-sandbox-io:/usr/local/bin/oac-sandbox-io:ro oac-qualify-sandbox \ -// sh -c 'until [ -s /run/qualify/bootstrap.json ]; do sleep 1; done; exec oac-sandbox-io --bootstrap-file /run/qualify/bootstrap.json' -// docker run --rm --network host --cgroupns=private --cap-add SYS_ADMIN --cap-add NET_ADMIN --device /dev/fuse --security-opt apparmor=unconfined \ -// -v $RUN:/run/qualify -v $BIN:/qualify:ro -v $KEY:/run/model.key:ro -v /etc/ssl/certs/ca-certificates.crt:/etc/oac-ca/ca.pem:ro \ -// -v $CLAUDE_SDK:/opt/claude-sdk:ro -v $CODEX_BIN:/opt/codex:ro -v $MCODE_HARNESS:/opt/mcode-harness:ro \ -// -e OAC_TEST_QUALIFY=1 -e OAC_QUALIFY_KEY_FILE=/run/model.key -e OAC_RUNTIME_HOME=/var/lib/oac \ -// -e OAC_RUNTIME_CLAUDE_SDK_NODE=/usr/local/bin/node -e OAC_RUNTIME_CLAUDE_SDK_ENTRYPOINT=/opt/claude-sdk/dist/main.js \ -// -e OAC_RUNTIME_CODEX_BIN=/opt/codex/codex \ -// -e OAC_RUNTIME_MCODE_NODE=/usr/local/bin/node -e OAC_RUNTIME_MCODE_BIN=/opt/mcode-harness/native/cli.js \ -// -e OAC_RUNTIME_MCODE_WORKSPACE_BRIDGE=/opt/mcode-harness/bridge.mjs -e OAC_RUNTIME_MCODE_AGENTS_API=1 \ -// -e OAC_QUALIFY_CLAUDE_SDK='{"model":"...","model_provider":{"protocol":"anthropic","base_url":"..."}}' \ -// -e OAC_QUALIFY_CODEX='{"model":"...","model_provider":{"protocol":"responses","base_url":"..."}}' \ -// -e OAC_QUALIFY_MCODE='{"model":"...","model_provider":{"protocol":"anthropic","base_url":"...","context_window":200000,"max_output_tokens":8192}}' \ -// node:22.23.1-bookworm-slim /qualify/qualify.test -test.v -test.timeout 30m +// scripts/qualify-agent-host.sh runs this test binary as the agent host in +// the agent-host image, which activates each Harness from the image's +// manifest, and oac-sandbox-io in the sandbox image, both on the host network. +// The test serves the Link test relay over WSS on loopback and hands the +// sandbox its bootstrap and the relay's CA through a shared run directory. +// Each OAC_QUALIFY_ holds the Harness's model and model_provider without +// api_key, which the test reads from OAC_QUALIFY_KEY_FILE. const ( gateEnv = "OAC_TEST_QUALIFY" keyEnv = "OAC_QUALIFY_KEY_FILE" runDir = "/run/qualify" - shim = "/qualify/oac-process-shim" - caDir = "/etc/oac-ca" + shim = "/opt/oac/bin/oac-process-shim" + // caDir holds the agent-host image's roots, one regular PEM file each. + caDir = "/usr/share/ca-certificates/mozilla" // workspace is the sandbox directory every Session works in. workspace = "/workspace" turnLimit = 10 * time.Minute @@ -89,7 +71,7 @@ func TestMain(m *testing.M) { func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { if os.Getenv(gateEnv) != "1" { - t.Skipf("set %s=1 and run the test binary as the agent host of the two-container rig; see the comment above", gateEnv) + t.Skipf("set %s=1 and run the test with scripts/qualify-agent-host.sh", gateEnv) } if err := sessionview.Probe(); err != nil { t.Fatalf("Probe: %v", err) @@ -98,9 +80,11 @@ func TestHarnessSessionsAgainstTheSandbox(t *testing.T) { if err != nil { t.Fatalf("model key: %v", err) } + activate(t) + tunnel(t) sb := startSandbox(t) reg := agent.NewRegistry() - cfg := agenthost.Config{StateDir: t.TempDir(), ViewCgroups: sessionviewtest.CgroupParent(t), UIDs: agenthost.UIDRange{First: 70000, Count: 8}, RelayURL: sb.url, + cfg := agenthost.Config{StateDir: t.TempDir(), ViewCgroups: sessionviewtest.CgroupParent(t), UIDs: agenthost.UIDRange{First: 70000, Count: 8}, RelayURL: sb.url, TLS: sb.tls, RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: reg, Shim: shim, CADir: caDir, Log: slog.New(slog.NewTextHandler(os.Stderr, nil))} sb.auth.AddRuntime(cfg.Credential, cfg.RuntimeID) @@ -146,7 +130,7 @@ func qualify(t *testing.T, h *agenthost.Host, cfg agenthost.Config, sb *sandbox, b := sb.binding() sb.grant(b, cfg.RuntimeID) env := agenthost.Environment{ - Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/root", "LANG": "C.UTF-8"}, + Sandbox: map[string]string{"PATH": "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", "HOME": "/home/runtime", "LANG": "C.UTF-8"}, Tool: map[string]string{"QUALIFY_VALUE": value, "QUALIFY_EXIT": fmt.Sprint(exit)}, } out := make(sender, 256) @@ -269,27 +253,41 @@ func sessionOptions(t *testing.T, raw string, key []byte) map[string]any { type sandbox struct { auth *sandboxlinktest.Authority url string + tls *tls.Config resource sandboxlink.ResourceRef } func startSandbox(t *testing.T) *sandbox { auth := sandboxlinktest.NewAuthority() rl := relay.New(auth) - srv := httptest.NewServer(rl) + srv := httptest.NewUnstartedServer(rl) + srv.TLS = &tls.Config{Certificates: []tls.Certificate{testCA(t)}} + srv.StartTLS() t.Cleanup(srv.Close) t.Cleanup(func() { rl.Close() }) - in := sandboxbootstrap.Input{Version: sandboxbootstrap.Version, LinkURL: "ws://" + strings.TrimPrefix(srv.URL, "http://"), Credential: "serve-" + rand.Text(), + in := sandboxbootstrap.Input{Version: sandboxbootstrap.Version, LinkURL: "wss://" + strings.TrimPrefix(srv.URL, "https://"), Credential: "serve-" + rand.Text(), Resource: sandboxbootstrap.Resource{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), Kind: "allocation", ID: uuid.NewString(), Generation: 1}} raw, err := in.Marshal() if err != nil { t.Fatal(err) } - sb := &sandbox{auth: auth, url: in.LinkURL, resource: in.Resource.Ref()} + roots := x509.NewCertPool() + roots.AddCert(srv.Certificate()) + sb := &sandbox{auth: auth, url: in.LinkURL, tls: &tls.Config{RootCAs: roots}, resource: in.Resource.Ref()} auth.AddServe([]byte(in.Credential), sandboxlink.ServePeer{PeerID: sandboxwire.NewID(), Resource: sb.resource}) + // The sandbox trusts the relay's CA through SSL_CERT_FILE, and reads the + // bootstrap as the image's user. + ca := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: srv.Certificate().Raw}) + if err := os.WriteFile(filepath.Join(runDir, "ca.pem"), ca, 0o644); err != nil { + t.Fatal(err) + } staged := filepath.Join(runDir, ".bootstrap.json") if err := os.WriteFile(staged, raw, 0o600); err != nil { t.Fatal(err) } + if err := os.Chown(staged, sandboxUser, sandboxUser); err != nil { + t.Fatal(err) + } if err := os.Rename(staged, filepath.Join(runDir, "bootstrap.json")); err != nil { t.Fatal(err) } @@ -314,7 +312,7 @@ func (sb *sandbox) files(t *testing.T, cfg agenthost.Config, f func(ctx context. defer cancel() b, attachment := sb.binding(), sandboxwire.NewID() sb.grant(b, cfg.RuntimeID) - link, err := sandboxlink.DialAttach(ctx, sandboxlink.AttachConfig{URL: sb.url, RuntimeID: cfg.RuntimeID, Credential: cfg.Credential}) + link, err := sandboxlink.DialAttach(ctx, sandboxlink.AttachConfig{URL: sb.url, TLS: cfg.TLS, RuntimeID: cfg.RuntimeID, Credential: cfg.Credential}) if err != nil { t.Fatal(err) } diff --git a/apps/daemon/internal/agenthostqualify/rig_linux_test.go b/apps/daemon/internal/agenthostqualify/rig_linux_test.go new file mode 100644 index 000000000..285cb7036 --- /dev/null +++ b/apps/daemon/internal/agenthostqualify/rig_linux_test.go @@ -0,0 +1,206 @@ +//go:build linux + +package agenthostqualify + +import ( + "bufio" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "math/big" + "net" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "syscall" + "testing" + "time" + + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/claudesdk" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/codex" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agent/mcode" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/agenthost" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview" + "github.com/MiniMax-AI/OpenAgentCore/apps/daemon/internal/sessionview/sessionviewtest" + "github.com/MiniMax-AI/OpenAgentCore/internal/sandboxwire" +) + +const ( + // manifest is the agent-host image's record of its Harness installations. + manifest = "/opt/oac/harnesses.json" + // proxyEnv names the HTTP proxy of a host whose only egress it is. + proxyEnv = "OAC_QUALIFY_PROXY" + // sandboxUser is the user the sandbox image runs oac-sandbox-io as. + sandboxUser = 1000 +) + +// TestViewCgroupDelegation checks the cgroup v2 delegation of the agent-host +// container. Open refuses the container's own cgroup, which Docker mounts +// read-only, as unsupported. In a delegated parent, Open ends a view cgroup +// left behind with cgroup.kill and removes it. +func TestViewCgroupDelegation(t *testing.T) { + if os.Getenv(gateEnv) != "1" { + t.Skipf("set %s=1 and run the test with scripts/qualify-agent-host.sh", gateEnv) + } + cfg := agenthost.Config{StateDir: t.TempDir(), ViewCgroups: "/sys/fs/cgroup", UIDs: agenthost.UIDRange{First: 70000, Count: 8}, RelayURL: "ws://127.0.0.1:1", + RuntimeID: sandboxwire.NewID(), Credential: []byte("runtime-credential"), Harnesses: agent.NewRegistry(), Shim: shim, CADir: caDir} + if h, err := agenthost.Open(cfg); err == nil { + h.Close() + t.Fatalf("Open accepted the undelegated %s", cfg.ViewCgroups) + } else if !errors.Is(err, agenthost.ErrUnsupported) || !errors.Is(err, sessionview.ErrCgroup) { + t.Fatalf("Open in the undelegated %s: %v, want ErrUnsupported from ErrCgroup", cfg.ViewCgroups, err) + } else { + t.Logf("without delegation: %v", err) + } + + cfg.StateDir, cfg.ViewCgroups = t.TempDir(), sessionviewtest.CgroupParent(t) + left := filepath.Join(cfg.ViewCgroups, "left") + if err := os.Mkdir(left, 0o755); err != nil { + t.Fatal(err) + } + dir, err := os.Open(left) + if err != nil { + t.Fatal(err) + } + sleep := exec.Command("sleep", "600") + sleep.SysProcAttr = &syscall.SysProcAttr{UseCgroupFD: true, CgroupFD: int(dir.Fd())} + err = sleep.Start() + dir.Close() + if err != nil { + t.Fatal(err) + } + defer sleep.Process.Kill() + h, err := agenthost.Open(cfg) + if err != nil { + t.Fatalf("Open in the delegated %s: %v", cfg.ViewCgroups, err) + } + h.Close() + if err := sleep.Wait(); sleep.ProcessState.Sys().(syscall.WaitStatus).Signal() != syscall.SIGKILL { + t.Errorf("the process left in a view cgroup ended with %v, want SIGKILL from cgroup.kill", err) + } + if _, err := os.Stat(left); !errors.Is(err, fs.ErrNotExist) { + t.Errorf("Open kept the view cgroup left behind: %v", err) + } +} + +// activate gives each Harness's discovery the environment that the agent-host +// image's manifest activates. +func activate(t *testing.T) { + env, err := agent.ManifestEnvironment(manifest, claudesdk.Installation(), codex.Installation(), mcode.Installation()) + if err != nil { + t.Fatal(err) + } + for name, value := range env { + t.Setenv(name, value) + } +} + +// testCA returns a certificate for 127.0.0.1 that is its own CA. The relay +// serves it, and the agent host and the sandbox trust only it. +func testCA(t *testing.T) tls.Certificate { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{SerialNumber: big.NewInt(1), Subject: pkix.Name{CommonName: "OpenAgentCore qualification CA"}, + NotBefore: time.Now().Add(-time.Minute), NotAfter: time.Now().Add(24 * time.Hour), IsCA: true, BasicConstraintsValid: true, + KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature, ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1)}} + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: key} +} + +// tunnel serves the host of each OAC_QUALIFY_ base_url on a loopback +// address that /etc/hosts gives its name, and carries each connection through +// the HTTP proxy in OAC_QUALIFY_PROXY, because the gateway dials model +// providers directly. TLS stays end to end. Without the proxy it does nothing. +func tunnel(t *testing.T) { + raw := os.Getenv(proxyEnv) + if raw == "" { + return + } + proxy, err := url.Parse(raw) + if err != nil || proxy.Scheme != "http" || proxy.Host == "" { + t.Fatalf("%s is not an http:// proxy URL", proxyEnv) + } + hosts, err := os.OpenFile("/etc/hosts", os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer hosts.Close() + served := map[string]bool{} + for _, declaration := range harnesses { + var options struct { + ModelProvider struct { + BaseURL string `json:"base_url"` + } `json:"model_provider"` + } + // sessionOptions reports options that do not decode. + _ = json.Unmarshal([]byte(os.Getenv("OAC_QUALIFY_"+strings.ToUpper(declaration.Info.Kind))), &options) + base, err := url.Parse(options.ModelProvider.BaseURL) + if err != nil || base.Scheme != "https" || served[base.Hostname()] { + continue + } + served[base.Hostname()] = true + port := base.Port() + if port == "" { + port = "443" + } + address := fmt.Sprintf("127.0.0.%d", 76+len(served)) + ln, err := net.Listen("tcp", net.JoinHostPort(address, port)) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { ln.Close() }) + if _, err := fmt.Fprintf(hosts, "%s %s\n", address, base.Hostname()); err != nil { + t.Fatal(err) + } + go forward(ln, proxy.Host, net.JoinHostPort(base.Hostname(), port)) + } +} + +// forward carries each connection to ln through an HTTP CONNECT to target. +func forward(ln net.Listener, proxy, target string) { + for { + c, err := ln.Accept() + if err != nil { + return + } + go func() { + defer c.Close() + p, err := net.Dial("tcp", proxy) + if err != nil { + fmt.Fprintf(os.Stderr, "tunnel to %s: %v\n", target, err) + return + } + defer p.Close() + fmt.Fprintf(p, "CONNECT %s HTTP/1.1\r\nHost: %s\r\n\r\n", target, target) + r := bufio.NewReader(p) + resp, err := http.ReadResponse(r, &http.Request{Method: http.MethodConnect}) + if err != nil || resp.StatusCode != http.StatusOK { + fmt.Fprintf(os.Stderr, "tunnel to %s: CONNECT: %v %v\n", target, err, resp) + return + } + go func() { + io.Copy(p, c) + p.(*net.TCPConn).CloseWrite() + }() + io.Copy(c, r) + }() + } +} diff --git a/contracts/agents-api/harness-onboarding.md b/contracts/agents-api/harness-onboarding.md index b768c1ccb..f901771f7 100644 --- a/contracts/agents-api/harness-onboarding.md +++ b/contracts/agents-api/harness-onboarding.md @@ -248,6 +248,8 @@ Keep provider keys in private operator files, never in commits or logs. Existing An adapter may supply `agent.Installation` from `installation.go` in its own package: registered kind, pinned version, supported platforms, activation environment and a bounded readiness probe. Register it in `cli/native_harness.go` and add its pinned component to the native distribution builder. This optional contract does not change Executor and Turn semantics. The Runtime owns checksums, copying, locks and additive installation; adapters own native layout and probes. Validate installation and execution on each advertised platform. Missing or incompatible native content fails; it never installs itself during a Turn. +The agent-host image uses the same contract. `deploy/distribution/AgentHost.Dockerfile` installs each Harness in its own directory and lists it in the image's manifest, `/opt/oac/harnesses.json`, and `agent.ManifestEnvironment` activates it from there through `Installation.Environment`. A Harness the agent host runs is added there too. + ## Native process ownership The daemon's `clirunner` offers opt-in Unix process-group ownership for adapters whose SDK launches a native child; unsupported hosts reject this mode before launch. Explicit and parent-context cancellation share a TERM grace period (three seconds by default) and a bounded KILL escalation. An internal reaper also cleans remaining group members when the direct process exits, even if a descendant still holds stdout open; during cancellation, surviving descendants keep the remaining grace after the leader exits. The daemon's `stop` command waits up to ten seconds for confirmed shutdown, which covers that grace period and the pipe and owner cleanup after it. @@ -345,6 +347,8 @@ Run the adapter's Turns, cancellation and continuation in a view, then qualify e | `Proxy` | With `ViewProxyEnv`, every local request, such as web fetches, downloads and update checks, goes through the proxy. With `ViewProxyNone`, a request enabling a feature that needs it is rejected. | | `Home` | Native history and configuration stay under `/.oac/home`, and a later Executor in the same Session continues from them. | +`scripts/qualify-agent-host.sh` runs one Turn per Harness through the daemon's dispatch against the [agent-host and sandbox images](../../docs/maintainers.md#runtime-images-and-helpers). The `agenthostqualify` test binary runs as the agent host with the [agent-host container's flags](../../docs/configuration.md#agent-host-container), and the sandbox image serves the sandbox. Each Turn writes a file and reports the output and exit status of a failing command whose values only the sandbox's tool environment holds. The Link runs over WSS with a CA the test generates. The test also checks the cgroup v2 delegation: the container's own read-only cgroup fails with `ErrUnsupported`, and in a delegated directory the agent host ends a cgroup left behind with `cgroup.kill`. Set `OAC_AGENT_HOST_IMAGE` and `OAC_SANDBOX_IMAGE` to the two images, `OAC_QUALIFY_KEY_FILE` to the model key's file and, for each Harness to qualify, `OAC_QUALIFY_CLAUDE_SDK`, `OAC_QUALIFY_CODEX` or `OAC_QUALIFY_MCODE` to its `model` and `model_provider` without `api_key`. The gateway dials model providers directly, so on a host whose only egress is an HTTP proxy, set `OAC_QUALIFY_PROXY` to it and the test tunnels the providers' hosts through it. + ## Native references | Harness | Adapter | Native transport | Runtime guide | diff --git a/contracts/agents-api/zh/harness-onboarding.md b/contracts/agents-api/zh/harness-onboarding.md index cdaca0c29..e5082fe9a 100644 --- a/contracts/agents-api/zh/harness-onboarding.md +++ b/contracts/agents-api/zh/harness-onboarding.md @@ -1,7 +1,7 @@ --- title: "将原生 Harness 添加到 OpenAgentCore" source: contracts/agents-api/harness-onboarding.md -source_hash: b448a3dd208f7ddf5e15a202b794d9e774f034ff3a2ee099e386abc2ebf1b471 +source_hash: 82787672338b502a32b22201c8c82c87c354a73a94e0ae0e5281e9c0a57eed79 --- **Harness** 是一种运行模型和工具循环的原生代理引擎(Codex、Claude Code、MiniMax Code)。**Harness 适配器**将 Runtime 的 Executor 和 Turn 契约转换到该引擎的 SDK 或协议。本文档定义 Runtime–Harness 协议:适配器接口及其生命周期义务、注册、Core 资格认定和验收。[Harness capabilities](harness-capabilities.md) 记录了当前每个 Harness 支持的功能。 @@ -250,6 +250,8 @@ Environment 验收使用 `services/core/tests/official_environment_{templates,se 适配器可以从自身包中的 `installation.go` 提供 `agent.Installation`:已注册 kind、锁定版本、受支持平台、激活环境和有界就绪探测。在 `cli/native_harness.go` 中注册它,并将其锁定组件添加到原生分发构建器。此可选契约不会改变 Executor 和 Turn 语义。Runtime 负责校验和、复制、锁和增量安装;适配器负责原生布局和探测。必须在每个宣称的平台上验证安装和执行。原生内容缺失或不兼容时必须失败;绝不会在 Turn 期间自行安装。 +agent-host 镜像使用同一契约。`deploy/distribution/AgentHost.Dockerfile` 把每个 Harness 安装在各自的目录中并列入镜像的清单 `/opt/oac/harnesses.json`,`agent.ManifestEnvironment` 再通过 `Installation.Environment` 从那里激活它。由 agent host 运行的 Harness 也要添加到这里。 + ## 原生进程所有权 {#native-process-ownership} daemon 的 `clirunner` 为 SDK 会启动原生子进程的适配器提供可选的 Unix 进程组所有权;不支持的主机会在启动前拒绝此模式。显式取消和父上下文取消共享 TERM 宽限期(默认为三秒)以及有界的 KILL 升级过程。当直接进程退出时,内部回收器也会清理进程组的剩余成员,即使某个后代进程仍保持 stdout 打开;在取消过程中,主进程退出后,存活的后代进程仍会保留剩余宽限时间。daemon 的 `stop` 命令最多等待十秒以确认关闭,这涵盖该宽限期以及之后的管道和所有者清理。 @@ -347,6 +349,8 @@ agent host 根据声明推导进程 broker 的映射表:`/.oac/bin/` 在 | `Proxy` | 使用 `ViewProxyEnv` 时,每个本地请求(例如网页抓取、下载和更新检查)都经过代理。使用 `ViewProxyNone` 时,启用需要代理的功能的请求会被拒绝。 | | `Home` | 原生历史和配置保存在 `/.oac/home` 下,同一 Session 中后续的 Executor 从中继续。 | +`scripts/qualify-agent-host.sh` 针对 [agent-host 和沙箱镜像](../../../docs/zh/maintainers.md#runtime-images-and-helpers),通过守护进程的 dispatch 为每个 Harness 运行一个 Turn。`agenthostqualify` 测试二进制以 [agent-host 容器的参数](../../../docs/zh/configuration.md#agent-host-container)作为 agent host 运行,沙箱镜像提供沙箱。每个 Turn 写入一个文件,并报告一个失败命令的输出和退出状态,这两个值只存在于沙箱的工具环境中。Link 通过 WSS 运行,使用测试生成的 CA。测试还会检查 cgroup v2 委派:容器自己的只读 cgroup 以 `ErrUnsupported` 失败;在委派目录中,agent host 用 `cgroup.kill` 结束遗留的 cgroup。将 `OAC_AGENT_HOST_IMAGE` 和 `OAC_SANDBOX_IMAGE` 设为这两个镜像,将 `OAC_QUALIFY_KEY_FILE` 设为模型密钥文件,并为每个要认定的 Harness 将 `OAC_QUALIFY_CLAUDE_SDK`、`OAC_QUALIFY_CODEX` 或 `OAC_QUALIFY_MCODE` 设为其 `model` 和不含 `api_key` 的 `model_provider`。网关直接连接模型提供商,因此在唯一出口是 HTTP 代理的主机上,将 `OAC_QUALIFY_PROXY` 设为该代理,测试会通过它为提供商的主机建立隧道。 + ## 原生参考 {#native-references} | Harness | 适配器 | 原生传输方式 | Runtime 指南 | diff --git a/deploy/distribution/AgentHost.Dockerfile b/deploy/distribution/AgentHost.Dockerfile new file mode 100644 index 000000000..25f01b506 --- /dev/null +++ b/deploy/distribution/AgentHost.Dockerfile @@ -0,0 +1,39 @@ +# The agent-host image and the sandbox image it works in. The context is what +# scripts/build-agent-host-images.sh prepares: the static oac-daemon, +# oac-process-shim and oac-sandbox-io, and the Runtime image builders' Harness +# payloads in codex/, claude/ and mcode/. Both images share the Runtime images' +# base and the sandbox shares their package layer. +FROM node:22.23.1-bookworm-slim@sha256:8607a9064d4a571140998ae9e52a3b3fcf9cff361d04642d5971e6cd76d39e27 AS base +USER root + +# The tools a Runtime image gives a sandbox, served by oac-sandbox-io. The +# caller appends the bootstrap file's path. +FROM base AS sandbox +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git python3 python3-pip ripgrep \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /environment/workspace /workspace /home/runtime +COPY --chmod=0555 oac-sandbox-io /usr/local/bin/ +ENV HOME=/home/runtime +USER 1000:1000 +ENTRYPOINT ["/usr/local/bin/oac-sandbox-io", "--bootstrap-file"] + +# Each Harness in its own directory, laid out as its agent.Installation +# expects. harnesses.json is the only record of where they are; the agent host +# reads it with agent.ManifestEnvironment. The checks are the Runtime images' +# except MiniMax Code's companion check, which needs the tools that run in the +# sandbox here. +FROM base AS agent-host +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* +COPY --chmod=0555 oac-daemon oac-process-shim /opt/oac/bin/ +COPY --chmod=0555 codex/codex codex/codex-code-mode-host /opt/oac/harnesses/codex/bin/ +COPY claude/claude-sdk /opt/oac/harnesses/claude_sdk +COPY mcode/mcode-harness /opt/oac/harnesses/mcode +COPY <`) is the container's own cgroup and is writable, and a new directory in it is the delegated directory. Without that delegation the agent host does not start: it fails with `agenthost.ErrUnsupported`, and nothing falls back. + +Never set `GODEBUG=http2debug` for the agent host. With it, Go's HTTP/2 implementation logs every header it encodes, including the model and MCP credentials the agent host adds. + ## Installation directory The installer creates the installation directory, `~/.oac/core` by default, with mode `0700`. Secret files are `0600`. diff --git a/docs/maintainers.md b/docs/maintainers.md index 18fbde9b7..4918ab538 100644 --- a/docs/maintainers.md +++ b/docs/maintainers.md @@ -64,7 +64,7 @@ make build-agents-runtime docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/.oac}/build/agents-runtime" ``` -The script checks the package version, builds `oac-daemon` for Linux amd64 and prepares a context with only the daemon, the unmodified native executable, its resources and `services/core/deploy/codex/Dockerfile`. +The script checks the package version, builds `oac-daemon` for Linux amd64 and prepares a context with only the daemon, the unmodified `codex` and `codex-code-mode-host` executables, their resources and `services/core/deploy/codex/Dockerfile`. The Runtime image leaves out `codex-code-mode-host`, which only the agent-host image installs. **Claude Code Runtime image.** Node 20 or newer and pnpm are required. @@ -90,6 +90,15 @@ docker build --platform linux/amd64 -t oac-runtime:mcode "${OAC_DEV_HOME:-$HOME/ The distribution combines the three Harness images into one Runtime image (`deploy/distribution/Runtime.Dockerfile`): the MiniMax Code image, which carries the daemon, with the Codex executable and resources and the Claude SDK bundle copied in. It verifies that each image carries the daemon built from the same commit. +**Agent-host and sandbox images.** With the inputs of the three Harness images above: + +```sh +export AGENTS_RUNTIME_CODEX_PACKAGE=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness +bash scripts/build-agent-host-images.sh +``` + +The script runs the three Runtime image builders into one context, adds the static `oac-daemon`, `oac-process-shim` and `oac-sandbox-io`, and builds both targets of `deploy/distribution/AgentHost.Dockerfile` as `OAC_AGENT_HOST_IMAGE` (default `oac-agent-host:dev`) and `OAC_SANDBOX_IMAGE` (default `oac-sandbox:dev`). Further arguments, such as `--label`, go to both `docker build` calls. The agent-host image installs each Harness in its own directory under `/opt/oac/harnesses`, and `/opt/oac/harnesses.json` is the only record of where; the agent host reads it with `agent.ManifestEnvironment`. The sandbox image has the Runtime images' base and packages and no daemon or Harness, and runs `oac-sandbox-io --bootstrap-file ` as UID/GID 1000. [Qualify the view](../contracts/agents-api/harness-onboarding.md#qualify-the-view) runs both; [Agent-host container](./configuration.md#agent-host-container) lists what the agent host needs. Neither CI nor the release builds them. + **E2B helper.** ```sh diff --git a/docs/zh/configuration.md b/docs/zh/configuration.md index 2ef6abe1b..67127c620 100644 --- a/docs/zh/configuration.md +++ b/docs/zh/configuration.md @@ -1,7 +1,7 @@ --- title: "配置参考" source: docs/configuration.md -source_hash: e697e320a2df0c130cb004f5e8bbda50deff6515ef7727bea066fc0d4f688b7d +source_hash: 0172bc723c66dbfa945c76eae575d8560bbd4b75d1a32cab70d1fb7313079032 --- Core 安装的每项设置都恰好只有一个归属位置。共有两类: @@ -120,6 +120,24 @@ Web 的 **System** 页面显示该安装的地址、默认模型和沙箱配置 [Docker 适配器](sandbox-provider.md#docker-adapter)负责容器隔离、卷布局和生命周期行为。 +## Agent-host 容器 {#agent-host-container} + +agent host 在沙箱之外、在每个 Session 自己的视图中运行该 Session 的 Harness(见[在 agent-host 视图中运行](../../contracts/agents-api/zh/harness-onboarding.md#run-in-an-agent-host-view))。它的容器以 root 运行 [agent-host 镜像](maintainers.md#runtime-images-and-helpers),需要 Linux 5.14 或更高版本以及 cgroup v2: + +| 要求 | Docker 参数 | 用途 | +| --- | --- | --- | +| 私有 cgroup 命名空间 | `--cgroupns=private` | 使容器自己的 cgroup 成为容器内 cgroup v2 挂载的根 | +| `CAP_SYS_ADMIN` | `--cap-add SYS_ADMIN` | 该 cgroup v2 挂载、视图的挂载命名空间及其 FUSE world | +| `CAP_NET_ADMIN` | `--cap-add NET_ADMIN` | 每个视图仅含 loopback 的网络 | +| `/dev/fuse` | `--device /dev/fuse` | 视图的 world,即通过 File access 协议提供的沙箱文件 | +| 不使用 AppArmor 配置文件 | `--security-opt apparmor=unconfined` | Docker 默认的 AppArmor 配置文件会拒绝这些挂载 | + +保留 Docker 默认的 seccomp 配置文件:有了 `CAP_SYS_ADMIN`,它允许 `clone3`、`mount` 和 `unshare`。容器不获得 Docker 套接字,也不发布端口。 + +agent host 需要一个委派给它的 cgroup v2 目录。它在该目录中为每个视图启动一个独立的 cgroup,用 `cgroup.kill` 结束视图并删除该 cgroup。启动时,它会结束并删除该目录中的每个 cgroup,因为其中每个 cgroup 都视为视图的 cgroup,所以其他任何东西都不得使用该目录。该目录必须可写,且不能包含 agent host 自己的进程。Docker 以只读方式挂载容器的 cgroup。使用上述参数时,在容器内再次挂载的 cgroup v2(`mount -t cgroup2 cgroup2 `)就是容器自己的 cgroup 且可写,其中新建的目录即为委派目录。没有这项委派时 agent host 不会启动:它以 `agenthost.ErrUnsupported` 失败,且没有任何回退。 + +绝不要为 agent host 设置 `GODEBUG=http2debug`。设置后,Go 的 HTTP/2 实现会记录它编码的每个请求头,包括 agent host 添加的模型和 MCP 凭据。 + ## 安装目录 {#installation-directory} 安装程序会创建安装目录,默认路径为 `~/.oac/core`,权限模式为 `0700`。机密文件为 `0600`。 diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md index afb79b2fa..ca436d3b6 100644 --- a/docs/zh/maintainers.md +++ b/docs/zh/maintainers.md @@ -1,7 +1,7 @@ --- title: "构建并发布 OpenAgentCore" source: docs/maintainers.md -source_hash: cfab5d1ad336c809b2b27bc870b02934f54184f3778e2c5d770a945c6830e097 +source_hash: a7a566c288258949acdded98e14dc14e102faaf453393bcfd95d43a25bef652d --- 本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请使用 [安装指南](getting-started/install.md)。安装器代码遵循的规则见 [部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md) 和 [节点安装器](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。 @@ -66,7 +66,7 @@ make build-agents-runtime docker build --platform linux/amd64 -t oac-runtime:codex "${OAC_DEV_HOME:-$HOME/.oac}/build/agents-runtime" ``` -该脚本会检查软件包版本,为 Linux amd64 构建 `oac-daemon`,并准备一个仅包含守护进程、未修改的原生可执行文件、相关资源和 `services/core/deploy/codex/Dockerfile` 的上下文。 +该脚本会检查软件包版本,为 Linux amd64 构建 `oac-daemon`,并准备一个仅包含守护进程、未修改的 `codex` 和 `codex-code-mode-host` 可执行文件、相关资源和 `services/core/deploy/codex/Dockerfile` 的上下文。Runtime 镜像不包含 `codex-code-mode-host`,只有 agent-host 镜像安装它。 **Claude Code Runtime 镜像。** 必须使用 Node 20 或更高版本以及 pnpm。 @@ -92,6 +92,15 @@ docker build --platform linux/amd64 -t oac-runtime:mcode "${OAC_DEV_HOME:-$HOME/ 分发包将三个 Harness 镜像合并到一个 Runtime 镜像(`deploy/distribution/Runtime.Dockerfile`)中:以携带守护进程的 MiniMax Code 镜像为基础,并复制入 Codex 可执行文件及资源和 Claude SDK 包。构建过程会验证每个镜像都携带由同一提交构建的守护进程。 +**Agent-host 和沙箱镜像。** 使用上述三个 Harness 镜像的输入: + +```sh +export AGENTS_RUNTIME_CODEX_PACKAGE=/absolute/path/to/package MCODE_HARNESS_BUILD_DIR=/absolute/mcode-harness +bash scripts/build-agent-host-images.sh +``` + +该脚本将三个 Runtime 镜像构建器的产物准备到同一个上下文中,加入静态的 `oac-daemon`、`oac-process-shim` 和 `oac-sandbox-io`,并将 `deploy/distribution/AgentHost.Dockerfile` 的两个目标分别构建为 `OAC_AGENT_HOST_IMAGE`(默认 `oac-agent-host:dev`)和 `OAC_SANDBOX_IMAGE`(默认 `oac-sandbox:dev`)。其余参数(例如 `--label`)会传给两次 `docker build`。agent-host 镜像把每个 Harness 安装在 `/opt/oac/harnesses` 下各自的目录中,`/opt/oac/harnesses.json` 是这些位置的唯一记录;agent host 通过 `agent.ManifestEnvironment` 读取它。沙箱镜像具有 Runtime 镜像的基础层和软件包,不含守护进程和 Harness,并以 UID/GID 1000 运行 `oac-sandbox-io --bootstrap-file `。[认定视图资格](../../contracts/agents-api/zh/harness-onboarding.md#qualify-the-view)会运行这两个镜像;[Agent-host 容器](configuration.md#agent-host-container)列出 agent host 的需求。CI 和发布流程都不构建它们。 + **E2B 辅助程序。** ```sh diff --git a/scripts/build-agent-host-images.sh b/scripts/build-agent-host-images.sh new file mode 100755 index 000000000..1d65c3736 --- /dev/null +++ b/scripts/build-agent-host-images.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +set -euo pipefail + +# The images' files must stay readable by Session uids and the sandbox's user. +umask 022 + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${OAC_DEV_HOME:-$HOME/.oac}" +agent_host_image="${OAC_AGENT_HOST_IMAGE:-oac-agent-host:dev}" +sandbox_image="${OAC_SANDBOX_IMAGE:-oac-sandbox:dev}" +[[ "$runtime_root" == /* ]] || { printf 'Expected absolute build directory: %s\n' "$runtime_root" >&2; exit 1; } +mkdir -p "$runtime_root/cache/oac-runtime-builds" +context="$(mktemp -d "$runtime_root/cache/oac-runtime-builds/agent-host.XXXXXX")" +trap 'rm -rf "$context"' EXIT +# The Runtime image builders check their pinned inputs and prepare each Harness's payload. +AGENTS_RUNTIME_BUILD_DIR="$context/codex" bash "$repo_root/scripts/build-agents-runtime.sh" +AGENTS_RUNTIME_BUILD_DIR="$context/claude" bash "$repo_root/scripts/build-claude-runtime.sh" +AGENTS_RUNTIME_BUILD_DIR="$context/mcode" bash "$repo_root/scripts/build-mcode-runtime.sh" +( + cd "$repo_root" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath -o "$context/" \ + ./apps/daemon/cmd/oac-daemon ./apps/daemon/cmd/oac-process-shim ./apps/sandboxio/cmd/oac-sandbox-io +) +# Further arguments, such as --label or --network, go to both builds. +for target in sandbox agent-host; do + image="$sandbox_image" + if [[ "$target" == agent-host ]]; then image="$agent_host_image"; fi + docker build --platform linux/amd64 --target "$target" --tag "$image" \ + --file "$repo_root/deploy/distribution/AgentHost.Dockerfile" "$@" "$context" +done +printf 'Agent-host image: %s\nSandbox image: %s\n' "$agent_host_image" "$sandbox_image" diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh index 4b00f5d21..f3314c206 100755 --- a/scripts/build-agents-runtime.sh +++ b/scripts/build-agents-runtime.sh @@ -18,7 +18,7 @@ package = json.load(open(sys.argv[1])) assert package['name'] == '@openai/codex' and package['version'] == '0.153.4-linux-x64', 'Expected pinned official Linux x64 package' PY native_dir="$package_dir/vendor/x86_64-unknown-linux-musl" -for executable in "$native_dir/bin/codex"; do +for executable in "$native_dir/bin/codex" "$native_dir/bin/codex-code-mode-host"; do test -x "$executable" || { printf 'Missing executable: %s\n' "$executable" >&2; exit 1; } done test -d "$native_dir/codex-resources" @@ -30,7 +30,7 @@ trap 'rm -rf "$context"' EXIT CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ -o "$context/oac-daemon" ./apps/daemon/cmd/oac-daemon ) -cp "$native_dir/bin/codex" "$context/codex" +cp "$native_dir/bin/codex" "$native_dir/bin/codex-code-mode-host" "$context/" cp -R "$native_dir/codex-resources" "$context/codex-resources" cp "$repo_root/services/core/deploy/codex/Dockerfile" "$context/Dockerfile" # Preserve the previous bundle if compilation or validation failed. diff --git a/scripts/ci_plan.py b/scripts/ci_plan.py index 378c5cc7e..b80f12d6b 100644 --- a/scripts/ci_plan.py +++ b/scripts/ci_plan.py @@ -81,6 +81,7 @@ (("scripts/build-claude", "scripts/check-claude", "scripts/build-mcode", "scripts/prepare-release-runtimes.sh"), SCRIPTS, ("harness", "native", "backend", "distribution")), (("scripts/build-agents-runtime.sh",), (".sh",), ("backend", "native", "distribution")), + (("scripts/build-agent-host-images.sh", "scripts/qualify-agent-host.sh"), (".sh",), ("distribution",)), (("scripts/generate-harness-catalog", "scripts/harness-catalog/", "scripts/openapi-split/", "scripts/patch-agents-openapi.py", "scripts/extract-agents-api-upstream.py"), SCRIPTS, JOBS), (("scripts/check-sqlc.py", "scripts/go-test-shard.py"), (".py",), ("backend",)), diff --git a/scripts/qualify-agent-host.sh b/scripts/qualify-agent-host.sh new file mode 100755 index 000000000..32bcd2042 --- /dev/null +++ b/scripts/qualify-agent-host.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Runs the agent-host qualification (apps/daemon/internal/agenthostqualify) +# against the images scripts/build-agent-host-images.sh builds. The test binary +# runs as the agent host in OAC_AGENT_HOST_IMAGE, and OAC_SANDBOX_IMAGE runs +# oac-sandbox-io with the bootstrap the test writes. OAC_QUALIFY_KEY_FILE is the +# model key's file, each OAC_QUALIFY_ the Harness's model options without +# api_key, and OAC_QUALIFY_PROXY an HTTP proxy for a host whose only egress it +# is. Further arguments go to the test binary. + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +agent_host_image="${OAC_AGENT_HOST_IMAGE:?Set OAC_AGENT_HOST_IMAGE to the agent-host image}" +sandbox_image="${OAC_SANDBOX_IMAGE:?Set OAC_SANDBOX_IMAGE to the sandbox image}" +key="${OAC_QUALIFY_KEY_FILE:?Set OAC_QUALIFY_KEY_FILE to the model key file}" +[[ "$key" == /* && -f "$key" ]] || { printf 'Expected the absolute path of the model key file: %s\n' "$key" >&2; exit 1; } + +run="$(mktemp -d)" +label="oac.qualify=$(basename "$run")" +cleanup() { + docker ps -aq --filter "label=$label" | xargs -r docker rm -f >/dev/null + rm -rf "$run" +} +trap cleanup EXIT +# The sandbox's user reads the bootstrap and the relay's CA here. +chmod 0755 "$run" +(cd "$repo_root" && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go test -c -o "$run/qualify.test" ./apps/daemon/internal/agenthostqualify) + +# The agent host needs a private cgroup namespace, CAP_SYS_ADMIN and +# CAP_NET_ADMIN, /dev/fuse and no AppArmor profile; Docker's default seccomp +# profile stays. The host network lets the sandbox reach the test's relay. +docker run --rm --label "$label" --network host \ + --cgroupns=private --cap-add SYS_ADMIN --cap-add NET_ADMIN --device /dev/fuse --security-opt apparmor=unconfined \ + -v "$run:/run/qualify" -v "$key:/run/model.key:ro" \ + -e OAC_TEST_QUALIFY=1 -e OAC_QUALIFY_KEY_FILE=/run/model.key -e OAC_QUALIFY_PROXY \ + -e OAC_QUALIFY_CLAUDE_SDK -e OAC_QUALIFY_CODEX -e OAC_QUALIFY_MCODE \ + --entrypoint /run/qualify/qualify.test "$agent_host_image" -test.v -test.timeout 40m "$@" & +agent_host=$! +while [[ ! -s "$run/bootstrap.json" ]] && kill -0 "$agent_host" 2>/dev/null; do sleep 1; done +sandbox="" +if [[ -s "$run/bootstrap.json" ]]; then + sandbox="$(docker run -d --label "$label" --network host --tmpfs /workspace:uid=1000,gid=1000 \ + -v "$run:/run/qualify:ro" -e SSL_CERT_FILE=/run/qualify/ca.pem "$sandbox_image" /run/qualify/bootstrap.json)" +fi +status=0 +wait "$agent_host" || status=$? +if [[ "$status" != 0 && -n "$sandbox" ]]; then + printf -- '--- sandbox log\n' + docker logs "$sandbox" 2>&1 | tail -20 +fi +exit "$status" diff --git a/services/core/deploy/codex/README.md b/services/core/deploy/codex/README.md index 42ef10922..401c991d2 100644 --- a/services/core/deploy/codex/README.md +++ b/services/core/deploy/codex/README.md @@ -59,7 +59,7 @@ Every other variant stays unclassified. ## Runtime image -[`Dockerfile`](Dockerfile) builds the Codex Runtime image from a prepared context that holds only `oac-daemon`, the unmodified `codex` executable and `codex-resources`. +[`Dockerfile`](Dockerfile) builds the Codex Runtime image from a prepared context that holds only `oac-daemon`, the unmodified `codex` and `codex-code-mode-host` executables and `codex-resources`. The image leaves out `codex-code-mode-host`, which only the agent-host image installs. | Item | Value | | --- | --- |