From c69acb401954130ad7db03010e183937f41c8cb6 Mon Sep 17 00:00:00 2001 From: saladday <1203511142@qq.com> Date: Fri, 18 Sep 2026 07:17:19 +0800 Subject: [PATCH] feat(agents-api): provision isolated Docker runtimes --- CONTRIBUTING.md | 10 + Makefile | 4 + go.mod | 20 ++ go.sum | 27 +++ scripts/build-agents-runtime.sh | 43 ++++ services/agents-api/deploy/codex/Dockerfile | 24 ++ services/agents-api/deploy/codex/README.md | 43 ++++ .../internal/sandbox/docker/bootstrap.go | 62 +++++ .../internal/sandbox/docker/command.go | 66 ++++++ .../internal/sandbox/docker/provider.go | 213 ++++++++++++++++++ .../internal/sandbox/docker/provider_test.go | 171 ++++++++++++++ .../agents-api/internal/sandbox/provider.go | 49 ++++ 12 files changed, 732 insertions(+) create mode 100755 scripts/build-agents-runtime.sh create mode 100644 services/agents-api/deploy/codex/Dockerfile create mode 100644 services/agents-api/internal/sandbox/docker/bootstrap.go create mode 100644 services/agents-api/internal/sandbox/docker/command.go create mode 100644 services/agents-api/internal/sandbox/docker/provider.go create mode 100644 services/agents-api/internal/sandbox/docker/provider_test.go create mode 100644 services/agents-api/internal/sandbox/provider.go diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d7c536e3..9a77e53b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -236,6 +236,16 @@ RunCommand is for initialization only. Daily execution and Files use Runtime and native or bounded local capabilities. Docker's lack of a native renewable lease does not remove service-owned hosted expiry and cleanup requirements. +The independent Docker Provider consumes an immutable Runtime image and retains +one caller-owned allocation reference through partial creation and cleanup. Persist +that reference before Create and serialize its lifecycle; resolve a lost response +with observed state, without rewriting bootstrap credentials or replaying startup. +Provider state is compute state, not public Environment readiness. Named Runtime +volumes need explicit owned cleanup after container removal. Initialization command +timeouts can leave processes alive and require allocation cleanup before reuse. +The [managed Runtime build and ownership contract](services/agents-api/deploy/codex/README.md#managed-runtime-image-and-docker-adapter) +does not itself enable public hosted admission or supply durable expiry. + Qualify the actual Docker/native sandbox before default cutover: real model execution, file access, owned cancellation, restart with retained native history and files, and rejection when required history is missing. Generated code and file diff --git a/Makefile b/Makefile index 0597e078..91c1c4f6 100644 --- a/Makefile +++ b/Makefile @@ -387,3 +387,7 @@ check-agents-harness: check-agents-harness-native: ./scripts/build-agents-harness.sh check + +.PHONY: build-agents-runtime +build-agents-runtime: + ./scripts/build-agents-runtime.sh diff --git a/go.mod b/go.mod index c3c8f682..342d6601 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 github.com/bwmarrin/discordgo v0.29.0 + github.com/containerd/errdefs v1.0.0 github.com/go-chi/chi/v5 v5.3.2 github.com/go-chi/httprate v0.16.0 github.com/golang-jwt/jwt/v5 v5.3.1 @@ -13,6 +14,8 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.10.0 github.com/larksuite/oapi-sdk-go/v3 v3.10.0 + github.com/moby/moby/api v1.56.0 + github.com/moby/moby/client v0.6.0 github.com/openai/openai-go/v3 v3.61.0 github.com/pressly/goose/v3 v3.27.3 github.com/robfig/cron/v3 v3.0.1 @@ -24,10 +27,27 @@ require ( ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/felixge/httpsnoop v1.1.0 // indirect + github.com/go-logr/logr v1.4.4 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/tidwall/gjson v1.19.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.1 // indirect github.com/tidwall/sjson v1.2.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect ) require ( diff --git a/go.sum b/go.sum index f9d23c02..b352edfe 100644 --- a/go.sum +++ b/go.sum @@ -1,21 +1,36 @@ github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3 h1:mFFeD2k8QE+pYd1uFik2XG85+vilL5uj9P5mHQTM1Xk= github.com/aliyun/alibabacloud-oss-go-sdk-v2 v1.5.3/go.mod h1:FTzydeQVmR24FI0D6XWUOMKckjXehM/jgMn1xC+DA9M= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/felixge/httpsnoop v1.1.0 h1:3YtUj32ZZkqZtt3sZZsClsymw/QDuVfpNhoA31zeORc= +github.com/felixge/httpsnoop v1.1.0/go.mod h1:Zqxgdd+1Rkcz8euOqdr7lqgCRJztwr5hp9vDSi5UZCE= github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY= github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto= github.com/go-chi/httprate v0.16.0 h1:8V5DH9j6pSK6UQoBsTpvMyFxycqaKEIToyPKzHJjUa8= github.com/go-chi/httprate v0.16.0/go.mod h1:A8lo+qRhk+s9LiuP5saS7XCGDXRXMcrueq0NfIuCa/I= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -62,12 +77,22 @@ github.com/mattn/go-isatty v0.0.23 h1:cYwCQTQf3HB6xUC+BtyCLZNr7IzbOmoZbmssVNzSyi github.com/mattn/go-isatty v0.0.23/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/moby/api v1.56.0 h1:GQzua3NA599ASSIICx0iFgiJeO9YkdDARvQsm23ZZuQ= +github.com/moby/moby/api v1.56.0/go.mod h1:sZ+THbVWkjOmBPPfbnzdD/G1LuIexWhqlSHHPTDQ1Uk= +github.com/moby/moby/client v0.6.0 h1:AJjEB21QPbXSXjDsZorFBoDZPhMrfbpaPLgSMAW9Bgs= +github.com/moby/moby/client v0.6.0/go.mod h1:OCo00wNRyA3m4lmJ228W3JbyCN4ZNNYjpOXiJydBdcQ= github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/openai/openai-go/v3 v3.61.0 h1:nMLuGFdKBF0sB3qFVNwE8kpBpenVN1ucYRoKcx7E1u0= github.com/openai/openai-go/v3 v3.61.0/go.mod h1:ufI1+K+t0ijRB3gk8eztiw1crcDpsBuxRQL4sbLIrts= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pressly/goose/v3 v3.27.3 h1:pIglVHjw99r4e/hDHHwbl9vfOsDMqUokfkXo6+n/RxA= @@ -111,6 +136,8 @@ github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= diff --git a/scripts/build-agents-runtime.sh b/scripts/build-agents-runtime.sh new file mode 100755 index 00000000..00b38647 --- /dev/null +++ b/scripts/build-agents-runtime.sh @@ -0,0 +1,43 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +runtime_root="${PARSAR_HOME:-$HOME/.parsar}" +output_dir="${AGENTS_RUNTIME_BUILD_DIR:-$runtime_root/build/agents-runtime}" +# Extract the official @openai/codex@0.153.4-linux-x64 npm package here. +package_dir="${AGENTS_RUNTIME_CODEX_PACKAGE:?Set AGENTS_RUNTIME_CODEX_PACKAGE to the extracted pinned platform package}" +helpers_dir="${AGENTS_EXECUTOR_BUILD_DIR:-$runtime_root/build/agents-executor}" +for directory in "$runtime_root" "$output_dir" "$package_dir" "$helpers_dir"; do + if [[ "$directory" != /* ]]; then + printf 'Runtime build directories must be absolute: %s\n' "$directory" >&2 + exit 1 + fi +done +python3 - "$package_dir/package.json" <<'PY' +import json, sys +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" "$helpers_dir/agents-api-codex-directory" "$helpers_dir/agents-api-codex-write"; do + test -x "$executable" || { printf 'Missing executable: %s\n' "$executable" >&2; exit 1; } +done +test -d "$native_dir/codex-resources" +mkdir -p "$runtime_root/cache/agents-runtime-builds" +context="$(mktemp -d "$runtime_root/cache/agents-runtime-builds/bundle.XXXXXX")" +trap 'rm -rf "$context"' EXIT +( + cd "$repo_root" + CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -mod=readonly -trimpath \ + -o "$context/parsar-daemon" ./apps/parsar-daemon/cmd/parsar-daemon +) +cp "$helpers_dir/agents-api-codex-directory" "$helpers_dir/agents-api-codex-write" "$context/" +cp "$native_dir/bin/codex" "$context/codex" +cp -R "$native_dir/codex-resources" "$context/codex-resources" +cp "$repo_root/services/agents-api/deploy/codex/requirements.toml" "$context/requirements.toml" +cp "$repo_root/services/agents-api/deploy/codex/Dockerfile" "$context/Dockerfile" +# Preserve the previous bundle if compilation or validation failed. +mkdir -p "$output_dir" +cp -R "$context/." "$output_dir/" +printf 'Runtime image context: %s\n' "$output_dir" +printf 'Build with: docker build --platform linux/amd64 -t agents-runtime:local %q\n' "$output_dir" diff --git a/services/agents-api/deploy/codex/Dockerfile b/services/agents-api/deploy/codex/Dockerfile new file mode 100644 index 00000000..1c49232f --- /dev/null +++ b/services/agents-api/deploy/codex/Dockerfile @@ -0,0 +1,24 @@ +# Build context is a prepared binary bundle, never the product checkout. +# Native package and resources must both be Codex 0.153.4 (linux/amd64). +FROM debian:bookworm-slim@sha256:a0982977ea1cf754c15281f48a7d5957cd14039a2a4f2aca9f23d74d226003d0 +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates bash git python3 nodejs npm ripgrep \ + && rm -rf /var/lib/apt/lists/* \ + && mkdir -p /environment /home/runtime \ + && groupadd --gid 1000 runtime \ + && useradd --uid 1000 --gid 1000 --home-dir /home/runtime --no-create-home runtime +COPY --chmod=0555 parsar-daemon agents-api-codex-directory agents-api-codex-write codex /usr/local/bin/ +COPY codex-resources /usr/local/codex-resources +COPY requirements.toml /etc/codex/requirements.toml +RUN chmod 0555 /etc/codex && chmod 0444 /etc/codex/requirements.toml +ENV HOME=/home/runtime PARSAR_HOME=/home/runtime/.parsar \ + PARSAR_CODEX_BIN=/usr/local/bin/codex PARSAR_CODEX_PERMISSION_PROFILE=managed-workspace \ + PARSAR_RUNTIME_WORKSPACE=/environment/workspace \ + PARSAR_RUNTIME_DIRECTORY_HELPER=/usr/local/bin/agents-api-codex-directory \ + PARSAR_RUNTIME_WRITE_HELPER=/usr/local/bin/agents-api-codex-write \ + PARSAR_RUNTIME_STAGING=/environment/staging +USER 1000:1000 +RUN test -r /etc/codex/requirements.toml && test "$(codex --version)" = "codex-cli 0.153.4" +WORKDIR /environment/workspace +ENTRYPOINT ["/usr/local/bin/parsar-daemon"] +CMD ["connect", "--profile", "default"] diff --git a/services/agents-api/deploy/codex/README.md b/services/agents-api/deploy/codex/README.md index 3e6e1ee0..1e3cbb50 100644 --- a/services/agents-api/deploy/codex/README.md +++ b/services/agents-api/deploy/codex/README.md @@ -70,3 +70,46 @@ Native tools retain their narrower filesystem policy. Qualify direct reads, symlink and process-root aliases, attempted staging modification, real uploaded bytes consumed by Codex, cancellation and retained-history restart before using this writer profile for public admission. Configuration alone is not that proof. + +## Managed Runtime image and Docker adapter + +Build the existing Rust filesystem helpers with `make build-agents-executor`, and +extract the official npm package `@openai/codex@0.153.4-linux-x64` beneath +`~/.parsar/`. Set `AGENTS_RUNTIME_CODEX_PACKAGE` to its extracted `package` directory +and run `scripts/build-agents-runtime.sh`. It builds the existing daemon and +prepares a binary-only Docker context at `~/.parsar/build/agents-runtime`; build +that context with the printed Docker command. This initial image is Linux amd64. +The package includes the unmodified native executable and matching resources. +It does not contain the product server, product CLI, credentials or workspace data. + +The service's `internal/sandbox` interface has five operations. Its Docker adapter +uses the official Moby Go client and an operator-selected immutable image digest, +network, installation UUID and the contents of this directory's `seccomp.json`. +The Runtime authenticates outward through the ordinary daemon bootstrap path; +`CoreURL` includes the existing `/api/v1` gateway prefix. The image's default +entrypoint is the same daemon connect command used by a user-managed Runtime. +No socket, host home or product configuration is mounted inside the Runtime. + +The caller persists a fresh allocation UUID with the authorized tenant and +Environment before Create, and serializes lifecycle operations for that allocation. +Create returns the reference even on failure. Duplicate allocation creation does +not rewrite credentials or restart the container. After a lost response, inspect +the allocation and reconcile its actual state; do not blindly replay Create. +This adapter does not supply durable Core reconciliation or public hosted Session +admission. Those are required before switching the default hosted path. + +Two labelled named volumes retain native state and the workspace/staging pair. +The trusted daemon auth profile is copied with restrictive permissions before +startup; it does not enter image layers, environment variables, labels or arguments. +GetInfo describes observed compute state, not daemon or native readiness. Docker +has no renewable provider lease: Renew verifies the allocation, while future Core +lifecycle integration must own expiry. Kill verifies allocation ownership, removes +the container, explicitly removes its named volumes and confirms absence. Keep the +reference and retry cleanup when an operation fails; an HTTP timeout is not proof +that a resource disappeared. Never use broad container or volume pruning. + +RunCommand is for trusted initialization, using an explicit context deadline, +argument vector and nonroot user. It preserves nonzero status and limits each +output stream to1MiB. Disconnecting an exec stream does not stop the command: +an unconfirmed result requires allocation cleanup before reuse. Routine agent +execution, cancellation and Files continue through Core/daemon/Runtime. diff --git a/services/agents-api/internal/sandbox/docker/bootstrap.go b/services/agents-api/internal/sandbox/docker/bootstrap.go new file mode 100644 index 00000000..e6eb824b --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/bootstrap.go @@ -0,0 +1,62 @@ +package docker + +import ( + "archive/tar" + "bytes" + "context" + "encoding/json" + "io" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/moby/moby/client" +) + +type entry struct { + name string + content []byte + directory bool +} + +func (p *Provider) bootstrap(ctx context.Context, id string, b sandbox.Bootstrap) error { + // The existing daemon auth profile is the shared managed/user-managed boundary. + // This file is injected before the untrusted workspace or harness can run. + auth, e := json.Marshal(struct { + ServerURL string `json:"server_url"` + RuntimeID string `json:"runtime_id"` + Credential string `json:"runner_credential"` + }{b.CoreURL, b.DeviceID, b.Credential}) + if e != nil { + return e + } + if e = p.copy(ctx, id, "/home", []entry{ + {name: "runtime", directory: true}, {name: "runtime/.parsar", directory: true}, + {name: "runtime/.parsar/parsar-daemon", directory: true}, + {name: "runtime/.parsar/parsar-daemon/default", directory: true}, + {name: "runtime/.parsar/parsar-daemon/default/auth.json", content: auth}, + }); e != nil { + return e + } + return p.copy(ctx, id, "/environment", []entry{{name: "workspace", directory: true}, {name: "staging", directory: true}}) +} +func (p *Provider) copy(ctx context.Context, id, path string, entries []entry) error { + var content bytes.Buffer + writer := tar.NewWriter(&content) + for _, file := range entries { + header := &tar.Header{Name: file.name, Uid: 1000, Gid: 1000, Mode: 0600, Size: int64(len(file.content)), Typeflag: tar.TypeReg} + if file.directory { + header.Mode = 0700 + header.Typeflag = tar.TypeDir + } + if e := writer.WriteHeader(header); e != nil { + return e + } + if _, e := writer.Write(file.content); e != nil { + return e + } + } + if e := writer.Close(); e != nil { + return e + } + _, e := p.client.CopyToContainer(ctx, id, client.CopyToContainerOptions{DestinationPath: path, Content: io.Reader(&content), CopyUIDGID: true}) + return e +} diff --git a/services/agents-api/internal/sandbox/docker/command.go b/services/agents-api/internal/sandbox/docker/command.go new file mode 100644 index 00000000..5b20cb5c --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/command.go @@ -0,0 +1,66 @@ +package docker + +import ( + "bytes" + "context" + "errors" + "path" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/moby/moby/api/pkg/stdcopy" + "github.com/moby/moby/client" +) + +// RunCommand is for trusted initialization only. Closing an exec attachment does +// not kill its process. On any uncertain outcome, the caller must reclaim the +// allocation with Kill instead of starting the daemon or replaying the command. +func (p *Provider) RunCommand(ctx context.Context, r sandbox.Reference, command sandbox.Command) (sandbox.CommandResult, error) { + var result sandbox.CommandResult + if len(command.Args) == 0 || (command.Directory != "" && !path.IsAbs(command.Directory)) { + return result, sandbox.ErrInvalid + } + c, e := p.inspect(ctx, r) + if e != nil { + return result, e + } + if _, ok := ctx.Deadline(); !ok { + return result, sandbox.ErrInvalid + } + exec, e := p.client.ExecCreate(ctx, c.Container.ID, client.ExecCreateOptions{User: "1000:1000", Cmd: command.Args, WorkingDir: command.Directory, AttachStdout: true, AttachStderr: true}) + if e != nil { + return result, e + } + attached, e := p.client.ExecAttach(ctx, exec.ID, client.ExecAttachOptions{}) + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + defer attached.Close() + stdout, stderr := &boundedBuffer{}, &boundedBuffer{} + done := make(chan error, 1) + go func() { _, e := stdcopy.StdCopy(stdout, stderr, attached.Reader); done <- e }() + select { + case e = <-done: + case <-ctx.Done(): + attached.Close() + <-done + e = ctx.Err() + } + if e != nil { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + status, e := p.client.ExecInspect(ctx, exec.ID, client.ExecInspectOptions{}) + if e != nil || status.Running { + return result, errors.Join(sandbox.ErrCommandUnconfirmed, e) + } + return sandbox.CommandResult{Stdout: stdout.String(), Stderr: stderr.String(), ExitCode: status.ExitCode}, nil +} + +type boundedBuffer struct{ bytes.Buffer } + +func (b *boundedBuffer) Write(data []byte) (int, error) { + const max = 1024 * 1024 + if b.Len()+len(data) > max { + return 0, errors.New("initialization output exceeds 1 MiB") + } + return b.Buffer.Write(data) +} diff --git a/services/agents-api/internal/sandbox/docker/provider.go b/services/agents-api/internal/sandbox/docker/provider.go new file mode 100644 index 00000000..bb5ee7e5 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/provider.go @@ -0,0 +1,213 @@ +// Package docker is a thin SDK adapter for the dedicated, colocated Runtime. +package docker + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "strings" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/api/types/container" + "github.com/moby/moby/api/types/mount" + "github.com/moby/moby/client" +) + +const labelPrefix = "io.parsar.agents-api." + +// Config is trusted operator configuration, never public Session input. The +// immutable image contains the qualified native profile and all Runtime binaries. +// Seccomp is JSON content, not a path on the Docker host. Network must provide +// trusted daemon/model connectivity; native tool network policy is in the image. +type Config struct { + InstallationID, Image, Network, Seccomp string + ExtraHosts []string +} +type Provider struct { + client *client.Client + config Config +} + +var _ sandbox.Provider = (*Provider)(nil) + +func New(c *client.Client, config Config) (*Provider, error) { + if c == nil || !validID(config.InstallationID) || (!strings.HasPrefix(config.Image, "sha256:") && !strings.Contains(config.Image, "@sha256:")) || config.Seccomp == "" || config.Network == "" || config.Network == "host" || strings.HasPrefix(config.Network, "container:") { + return nil, sandbox.ErrInvalid + } + return &Provider{client: c, config: config}, nil +} +func validID(v string) bool { + u, e := uuid.Parse(v) + return e == nil && u != uuid.Nil && u.String() == v +} +func validReference(r sandbox.Reference) bool { + return validID(r.TenantID) && validID(r.EnvironmentID) && validID(r.AllocationID) +} +func (p *Provider) name(r sandbox.Reference) string { + h := sha256.Sum256([]byte(p.config.InstallationID + ":" + r.TenantID + ":" + r.EnvironmentID + ":" + r.AllocationID)) + return "agents-runtime-" + hex.EncodeToString(h[:16]) +} +func (p *Provider) labels(r sandbox.Reference) map[string]string { + return map[string]string{ + labelPrefix + "installation": p.config.InstallationID, labelPrefix + "tenant": r.TenantID, labelPrefix + "environment": r.EnvironmentID, labelPrefix + "allocation": r.AllocationID, + } +} +func (p *Provider) owns(labels map[string]string, r sandbox.Reference) bool { + for k, v := range p.labels(r) { + if labels[k] != v { + return false + } + } + return true +} +func (p *Provider) inspect(ctx context.Context, r sandbox.Reference) (client.ContainerInspectResult, error) { + if !validReference(r) { + return client.ContainerInspectResult{}, sandbox.ErrInvalid + } + v, e := p.client.ContainerInspect(ctx, p.name(r), client.ContainerInspectOptions{}) + if errdefs.IsNotFound(e) { + return v, sandbox.ErrNotFound + } + if e != nil { + return v, e + } + if v.Container.Config == nil || !p.owns(v.Container.Config.Labels, r) { + return v, sandbox.ErrOwnership + } + return v, nil +} +func (p *Provider) GetInfo(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + info := sandbox.Info{Reference: r} + v, e := p.inspect(ctx, r) + if e != nil { + return info, e + } + info.ProviderID = v.Container.ID + info.State = string(v.Container.State.Status) + return info, nil +} + +// Renew observes Docker state. Docker has no lease; service-owned expiry must be +// managed durably by Core. Never synthesize an expiry or revive a stopped Runtime. +func (p *Provider) Renew(ctx context.Context, r sandbox.Reference) (sandbox.Info, error) { + return p.GetInfo(ctx, r) +} + +func (p *Provider) Create(ctx context.Context, b sandbox.Bootstrap) (sandbox.Info, error) { + info := sandbox.Info{Reference: b.Reference} + u, e := url.Parse(b.CoreURL) + if !validReference(b.Reference) || !validID(b.SessionID) || !validID(b.DeviceID) || e != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.TrimSpace(b.Credential) == "" { + return info, sandbox.ErrInvalid + } + if existing, e := p.GetInfo(ctx, b.Reference); e == nil { + return existing, sandbox.ErrExists + } else if !errors.Is(e, sandbox.ErrNotFound) { + return info, e + } + name := p.name(b.Reference) + // Retained volumes without a container are partial or lost state, not an + // invitation to overwrite native history with a new bootstrap identity. + for _, suffix := range []string{"-home", "-environment"} { + v, err := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) + if err == nil { + if !p.owns(v.Volume.Labels, b.Reference) { + return info, sandbox.ErrOwnership + } + return info, sandbox.ErrExists + } + if !errdefs.IsNotFound(err) { + return info, err + } + } + for _, suffix := range []string{"-home", "-environment"} { + v, e := p.client.VolumeCreate(ctx, client.VolumeCreateOptions{Name: name + suffix, Labels: p.labels(b.Reference)}) + if e != nil { + return info, e + } + if !p.owns(v.Volume.Labels, b.Reference) { + return info, sandbox.ErrOwnership + } + } + limit := int64(128) + v, e := p.client.ContainerCreate(ctx, client.ContainerCreateOptions{Name: name, Image: p.config.Image, + Config: &container.Config{User: "1000:1000", WorkingDir: "/environment/workspace", Labels: p.labels(b.Reference), Env: []string{"PARSAR_RUNTIME_ENVIRONMENT_ID=" + b.EnvironmentID, "PARSAR_RUNTIME_SESSION_ID=" + b.SessionID}}, + HostConfig: &container.HostConfig{ReadonlyRootfs: true, CapDrop: []string{"ALL"}, SecurityOpt: []string{"no-new-privileges", "seccomp=" + p.config.Seccomp, "apparmor=unconfined"}, NetworkMode: container.NetworkMode(p.config.Network), ExtraHosts: p.config.ExtraHosts, + Resources: container.Resources{PidsLimit: &limit, Memory: 2 * 1024 * 1024 * 1024, NanoCPUs: 2 * 1000000000}, Tmpfs: map[string]string{"/tmp": "rw,nosuid,nodev,size=128m"}, + Mounts: []mount.Mount{{Type: mount.TypeVolume, Source: name + "-home", Target: "/home"}, {Type: mount.TypeVolume, Source: name + "-environment", Target: "/environment"}}, + }, + }) + if errdefs.IsConflict(e) { + return info, sandbox.ErrExists + } + if e != nil { + return info, e + } + info.ProviderID = v.ID + // Any failure returns the retained allocation reference. The caller must Kill + // it, including on a lost acknowledgement. Never erase uncertain owner state. + if e = p.bootstrap(ctx, v.ID, b); e != nil { + return info, fmt.Errorf("runtime bootstrap: %w", e) + } + if _, e = p.client.ContainerStart(ctx, v.ID, client.ContainerStartOptions{}); e != nil { + return info, e + } + return p.GetInfo(ctx, b.Reference) +} + +// Kill is idempotent only for absence, not for errors or foreign ownership. It +// checks all resources before removing any and confirms removal of named volumes. +func (p *Provider) Kill(ctx context.Context, r sandbox.Reference) error { + if !validReference(r) { + return sandbox.ErrInvalid + } + c, e := p.inspect(ctx, r) + if e != nil && !errors.Is(e, sandbox.ErrNotFound) { + return e + } + present := e == nil + name := p.name(r) + volumes := []string{} + for _, suffix := range []string{"-home", "-environment"} { + v, e := p.client.VolumeInspect(ctx, name+suffix, client.VolumeInspectOptions{}) + if errdefs.IsNotFound(e) { + continue + } + if e != nil { + return e + } + if !p.owns(v.Volume.Labels, r) { + return sandbox.ErrOwnership + } + volumes = append(volumes, name+suffix) + } + if present { + if _, e = p.client.ContainerRemove(ctx, c.Container.ID, client.ContainerRemoveOptions{Force: true}); e != nil && !errdefs.IsNotFound(e) { + return e + } + } + for _, v := range volumes { + if _, e = p.client.VolumeRemove(ctx, v, client.VolumeRemoveOptions{}); e != nil && !errdefs.IsNotFound(e) { + return e + } + } + if _, e = p.inspect(ctx, r); !errors.Is(e, sandbox.ErrNotFound) { + if e == nil { + return errors.New("container removal unconfirmed") + } + return e + } + for _, v := range volumes { + if _, e = p.client.VolumeInspect(ctx, v, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + if e == nil { + return errors.New("volume removal unconfirmed") + } + return e + } + } + return nil +} diff --git a/services/agents-api/internal/sandbox/docker/provider_test.go b/services/agents-api/internal/sandbox/docker/provider_test.go new file mode 100644 index 00000000..ef171315 --- /dev/null +++ b/services/agents-api/internal/sandbox/docker/provider_test.go @@ -0,0 +1,171 @@ +package docker + +import ( + "context" + "encoding/json" + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/sandbox" + "github.com/containerd/errdefs" + "github.com/google/uuid" + "github.com/moby/moby/client" +) + +func TestProviderRejectsUnsafeOperatorConfiguration(t *testing.T) { + c, e := client.New() + if e != nil { + t.Fatal(e) + } + defer c.Close() + base := Config{InstallationID: uuid.NewString(), Image: "test@sha256:" + strings.Repeat("a", 64), Network: "bridge", Seccomp: `{}`} + for _, change := range []func(*Config){func(c *Config) { c.Image = "mutable:latest" }, func(c *Config) { c.InstallationID = "" }, func(c *Config) { c.Network = "host" }, func(c *Config) { c.Network = "container:other" }, func(c *Config) { c.Seccomp = "" }} { + v := base + change(&v) + if _, e := New(c, v); !errors.Is(e, sandbox.ErrInvalid) { + t.Fatalf("accepted invalid configuration: %v", e) + } + } +} + +// This optional Docker mechanism test uses a pinned fixture image whose entrypoint +// is sleep. It is not native/model acceptance; the real Runtime has separate checks. +func TestDockerProviderLifecycle(t *testing.T) { + image := os.Getenv("AGENTS_RUNTIME_DOCKER_TEST_IMAGE") + if image == "" { + t.Skip("explicit Docker fixture image required") + } + seccomp, e := os.ReadFile("../../../deploy/codex/seccomp.json") + if e != nil { + t.Fatal(e) + } + c, e := client.New(client.FromEnv) + if e != nil { + t.Fatal(e) + } + defer c.Close() + p, e := New(c, Config{InstallationID: uuid.NewString(), Image: image, Network: "bridge", Seccomp: string(seccomp)}) + if e != nil { + t.Fatal(e) + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + bootstrap := func() sandbox.Bootstrap { + return sandbox.Bootstrap{Reference: sandbox.Reference{TenantID: uuid.NewString(), EnvironmentID: uuid.NewString(), AllocationID: uuid.NewString()}, SessionID: uuid.NewString(), DeviceID: uuid.NewString(), CoreURL: "http://core.invalid/api/v1", Credential: "synthetic-test-credential"} + } + b := bootstrap() + t.Cleanup(func() { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if e := p.Kill(ctx, b.Reference); e != nil { + t.Error(e) + } + }) + info, e := p.Create(ctx, b) + if e != nil { + t.Fatal(e) + } + if info.State != "running" || info.ProviderID == "" { + t.Fatalf("bad compute observation: %+v", info) + } + inspected, e := p.inspect(ctx, b.Reference) + if e != nil { + t.Fatal(e) + } + if strings.Contains(string(inspected.Raw), b.Credential) || inspected.Container.Config.User != "1000:1000" || !inspected.Container.HostConfig.ReadonlyRootfs || inspected.Container.HostConfig.Privileged { + t.Fatal("unsafe Docker configuration") + } + changed := b + changed.Credential = "must-not-replace-existing" + if _, e = p.Create(ctx, changed); !errors.Is(e, sandbox.ErrExists) { + t.Fatalf("duplicate not rejected: %v", e) + } + r, e := p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/home/runtime/.parsar/parsar-daemon/default/auth.json"}}) + if e != nil { + t.Fatal(e) + } + var auth map[string]string + if json.Unmarshal([]byte(r.Stdout), &auth) != nil || auth["runner_credential"] != b.Credential || auth["runtime_id"] != b.DeviceID { + t.Fatal("bootstrap changed or malformed") + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "printf retained > /environment/workspace/history; printf failed >&2; exit 7"}}) + if e != nil || r.ExitCode != 7 || r.Stderr != "failed" { + t.Fatalf("lost command status: %+v %v", r, e) + } + wrong := b.Reference + wrong.TenantID = uuid.NewString() + if _, e = p.GetInfo(ctx, wrong); !errors.Is(e, sandbox.ErrNotFound) { + t.Fatal("foreign allocation visible") + } + if e = p.Kill(ctx, wrong); e != nil { + t.Fatal(e) + } + if _, e = p.Renew(ctx, b.Reference); e != nil { + t.Fatal("wrong tenant removed the owner") + } + timeout := 1 + if _, e = c.ContainerRestart(ctx, info.ProviderID, client.ContainerRestartOptions{Timeout: &timeout}); e != nil { + t.Fatal(e) + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"cat", "/environment/workspace/history"}}) + if e != nil || r.Stdout != "retained" { + t.Fatal("restart lost workspace") + } + r, e = p.RunCommand(ctx, b.Reference, sandbox.Command{Args: []string{"sh", "-c", "touch /cannot-write-root"}}) + if e != nil || r.ExitCode == 0 { + t.Fatal("root filesystem writable") + } + // Container loss must not trigger credential overwrite or state replacement. + if _, e = c.ContainerRemove(ctx, info.ProviderID, client.ContainerRemoveOptions{Force: true}); e != nil { + t.Fatal(e) + } + if _, e = p.Create(ctx, b); !errors.Is(e, sandbox.ErrExists) { + t.Fatalf("retained volumes reused: %v", e) + } + if e = p.Kill(ctx, b.Reference); e != nil { + t.Fatal(e) + } + for _, suffix := range []string{"-home", "-environment"} { + if _, e = c.VolumeInspect(ctx, p.name(b.Reference)+suffix, client.VolumeInspectOptions{}); !errdefs.IsNotFound(e) { + t.Fatal("named volume remains") + } + } + // A colliding resource with different ownership cannot be deleted, including + // when its container is absent after a partial creation. + foreign := bootstrap() + volume := p.name(foreign.Reference) + "-home" + if _, e = c.VolumeCreate(ctx, client.VolumeCreateOptions{Name: volume, Labels: map[string]string{"fixture": "foreign"}}); e != nil { + t.Fatal(e) + } + t.Cleanup(func() { + _, e := c.VolumeRemove(context.Background(), volume, client.VolumeRemoveOptions{}) + if e != nil { + t.Error(e) + } + }) + if e = p.Kill(ctx, foreign.Reference); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("foreign volume cleanup accepted") + } + if _, e = p.Create(ctx, foreign); !errors.Is(e, sandbox.ErrOwnership) { + t.Fatal("foreign volume bootstrap accepted") + } + // Closing initialization output is not process termination. Require explicit + // reclamation, without returning partial output as a successful command. + next := bootstrap() + defer p.Kill(context.Background(), next.Reference) + if _, e = p.Create(ctx, next); e != nil { + t.Fatal(e) + } + short, stop := context.WithTimeout(ctx, 100*time.Millisecond) + _, e = p.RunCommand(short, next.Reference, sandbox.Command{Args: []string{"sleep", "30"}}) + stop() + if !errors.Is(e, sandbox.ErrCommandUnconfirmed) { + t.Fatalf("timeout classified as certain: %v", e) + } + if e = p.Kill(ctx, next.Reference); e != nil { + t.Fatal(e) + } +} diff --git a/services/agents-api/internal/sandbox/provider.go b/services/agents-api/internal/sandbox/provider.go new file mode 100644 index 00000000..02325510 --- /dev/null +++ b/services/agents-api/internal/sandbox/provider.go @@ -0,0 +1,49 @@ +// Package sandbox manages compute for an already authorized Environment. +// It does not schedule Turns or implement routine execution and Files operations. +package sandbox + +import ( + "context" + "errors" +) + +var ( + ErrInvalid = errors.New("invalid sandbox configuration") + ErrOwnership = errors.New("sandbox ownership mismatch") + ErrExists = errors.New("sandbox allocation already exists") + ErrNotFound = errors.New("sandbox allocation not found") + ErrCommandUnconfirmed = errors.New("initialization command outcome unconfirmed; reclaim allocation before reuse") +) + +// Reference must be persisted by the caller before Create. AllocationID is a fresh +// UUID for one attempt, not the Environment ID. Serialize lifecycle operations for +// an allocation; a lost Create response is resolved with GetInfo, never by replay. +type Reference struct{ TenantID, EnvironmentID, AllocationID string } + +type Bootstrap struct { + Reference + SessionID, DeviceID, CoreURL, Credential string +} + +// Info describes compute only. Running does not establish daemon authentication, +// native preparation, Environment readiness or a renewable provider lease. +type Info struct { + Reference + ProviderID, State string +} +type Command struct { + Args []string + Directory string +} +type CommandResult struct { + Stdout, Stderr string + ExitCode int +} + +type Provider interface { + Create(context.Context, Bootstrap) (Info, error) + GetInfo(context.Context, Reference) (Info, error) + Renew(context.Context, Reference) (Info, error) + Kill(context.Context, Reference) error + RunCommand(context.Context, Reference, Command) (CommandResult, error) +}