From 9e0108a8f1b2304093ee760adf53681590c716d5 Mon Sep 17 00:00:00 2001 From: Drew Newberry Date: Mon, 27 Jul 2026 11:54:43 -0700 Subject: [PATCH] feat(agents): publish payloads for remote gateways Signed-off-by: Drew Newberry --- .../skills/launch-openshell-gator/SKILL.md | 69 ++++- architecture/sandbox.md | 8 + scripts/agents/README.md | 74 ++++- scripts/agents/gator/README.md | 26 +- scripts/agents/publish.sh | 138 ++++++++++ scripts/agents/publish_test.sh | 71 +++++ scripts/agents/run.sh | 57 ++++ scripts/agents/run_test.sh | 254 ++++++++++++++++++ tasks/test.toml | 12 +- 9 files changed, 694 insertions(+), 15 deletions(-) create mode 100755 scripts/agents/publish.sh create mode 100755 scripts/agents/publish_test.sh create mode 100755 scripts/agents/run_test.sh diff --git a/.agents/skills/launch-openshell-gator/SKILL.md b/.agents/skills/launch-openshell-gator/SKILL.md index 20f902f085..59581a65e0 100644 --- a/.agents/skills/launch-openshell-gator/SKILL.md +++ b/.agents/skills/launch-openshell-gator/SKILL.md @@ -54,6 +54,15 @@ command -v ruby ``` The local `openshell` wrapper may recompile the CLI. If that fails, fix the local build or ask the operator before changing unrelated source. +Remote gateway launches also require Docker with Buildx: + +```bash +command -v docker +docker buildx version +``` + +Authenticate Docker to an operator-selected private OCI repository before the +launch. Do not print registry credentials or credential-helper configuration. ### Step 3: Verify GitHub Auth @@ -132,6 +141,17 @@ sandbox_name="gator-pr-${pr_number}-supervised" For local image contexts passed to `--from`, use an agent-created path such as `mktemp -d`; do not pass raw user-supplied paths without validating that they are expected local Dockerfile contexts. +For remote publication, accept only a repository without whitespace, a tag, or +a digest. A registry port is allowed: + +```bash +publish_repository="" +[[ -n "$publish_repository" ]] || { echo "empty publish repository" >&2; exit 1; } +[[ "$publish_repository" != *[[:space:]]* ]] || { echo "publish repository contains whitespace" >&2; exit 1; } +[[ "$publish_repository" != *@* ]] || { echo "publish repository must not contain a digest" >&2; exit 1; } +[[ "${publish_repository##*/}" != *:* ]] || { echo "publish repository must not contain a tag" >&2; exit 1; } +``` + ## Standard Launches ### Launch A PR Watcher @@ -157,6 +177,43 @@ sandbox_name="gator-pr-${pr_number}-supervised" The launcher builds the gator sandbox image when needed, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, creates the sandbox, and writes a background log under `scripts/agents/gator/logs/`. +### Launch A PR Watcher On A Remote Gateway + +Use `--publish-to` when the selected gateway cannot access the operator's local +Docker context. The registry repository must be private, authenticated on the +host, and pullable by the remote gateway. + +```bash +gateway_name="" +publish_repository="" +pr_number="" +[[ "$gateway_name" =~ ^[A-Za-z0-9_.-]+$ ]] || { echo "invalid gateway name" >&2; exit 1; } +[[ -n "$publish_repository" ]] || { echo "empty publish repository" >&2; exit 1; } +[[ "$publish_repository" != *[[:space:]]* ]] || { echo "publish repository contains whitespace" >&2; exit 1; } +[[ "$publish_repository" != *@* ]] || { echo "publish repository must not contain a digest" >&2; exit 1; } +[[ "${publish_repository##*/}" != *:* ]] || { echo "publish repository must not contain a tag" >&2; exit 1; } +[[ "$pr_number" =~ ^[0-9]+$ ]] || { echo "invalid PR number" >&2; exit 1; } +sandbox_name="gator-pr-${pr_number}-supervised" +[[ "$sandbox_name" =~ ^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$ ]] || { echo "invalid sandbox name" >&2; exit 1; } + +./scripts/agents/run.sh \ + --agent gator \ + --gateway "$gateway_name" \ + --name "$sandbox_name" \ + --publish-to "$publish_repository" \ + --platform linux/amd64 \ + --watch \ + --background \ + "Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}." +``` + +The launcher stages the same immutable payload used for local launches, pushes +it with a content-derived tag, reads the pushed digest from BuildKit metadata, +and creates the sandbox from `repository@sha256:...`. Publication finishes +before gateway settings or providers are changed. The image contains the +rendered operator prompt and injected agent assets, so use an appropriate +registry retention policy. + ### Launch An Issue Or Issue/PR Pair ```bash @@ -342,12 +399,19 @@ Action: load `debug-openshell-cluster` and diagnose the gateway/driver. Do not k ### Image Build Failure -Symptoms: Dockerfile step failure, missing package, incompatible Codex CLI, registry pull failure. +Symptoms: Dockerfile step failure, missing package, incompatible Codex CLI, +Buildx push failure, missing published digest, or registry pull failure. Actions: - Confirm the build context is `scripts/agents/gator/` or the intended temporary `--from` context. - Confirm Docker or the selected gateway runtime can pull `nvcr.io/nvidia/base/ubuntu:noble-20251013`. +- For remote gateways, confirm Docker is authenticated to the publish repository + and the gateway runtime has pull access to it. +- Pass a repository without a tag or digest to `--publish-to`; the launcher owns + the content tag and launches the registry-reported digest. +- If publication fails, fix registry authentication or Buildx before retrying. + The launcher has not changed gateway settings or providers at that point. - For Codex CLI version experiments, adjust a temporary Docker context first. - Do not commit Dockerfile version changes unless the repo should permanently use that version. @@ -386,7 +450,8 @@ When you launch or inspect gator, report: - Log path. - Target issue/PR scope. - Harness and model when relevant. -- Whether image build and sandbox creation succeeded. +- Whether image build, optional publication, and sandbox creation succeeded. +- The digest-pinned image reference for remote launches. - Latest sentinel or heartbeat status. - Any human action needed. diff --git a/architecture/sandbox.md b/architecture/sandbox.md index 4f95e1ef69..84d945e909 100644 --- a/architecture/sandbox.md +++ b/architecture/sandbox.md @@ -34,6 +34,14 @@ only when the set is already empty; any other outcome fails the spawn. sync, config polling, and log push. 6. It launches the agent command as the restricted sandbox user. +Repository-owned agent launchers treat their rendered prompt, skills, subagents, +and runtime as an immutable image payload. A local gateway can build the staged +Docker context directly. For a remote gateway, the launcher can publish the +context to an operator-selected OCI repository and submit the registry-reported +digest to the gateway. Registry authentication and retention remain +operator-owned, and digest pinning ensures the launched payload is the one that +was staged. + ## Isolation Layers OpenShell uses overlapping controls rather than a single sandbox primitive: diff --git a/scripts/agents/README.md b/scripts/agents/README.md index a718eea78b..b127a9f4db 100644 --- a/scripts/agents/README.md +++ b/scripts/agents/README.md @@ -10,6 +10,8 @@ and execution live in `runtime/harnesses//`. ```text scripts/agents/ run.sh # Generic manifest-driven launcher + publish.sh # Agent-agnostic OCI publisher + run_test.sh # Launcher integration tests with isolated mocks runtime/ # Shared in-sandbox runtime entrypoint.sh # Starts the in-sandbox supervisor supervisor.sh # Runs bounded harness cycles in once/watch mode @@ -75,24 +77,27 @@ Manifest paths support these prefixes: manifest-declared subagent variables such as `{{REVIEWER_COMMAND}}`. 9. Build a temporary Docker context that bakes the rendered payload into `/etc/openshell/agent-payload`. -10. Apply manifest-declared gateway settings. -11. Resolve provider profile IDs by scanning `profile_paths` in order. -12. Import each provider profile into the gateway. If an active profile already +10. When `--publish-to` is set, publish that context for the selected platform + and replace the local source with the repository's immutable image digest. +11. Apply manifest-declared gateway settings. +12. Resolve provider profile IDs by scanning `profile_paths` in order. +13. Import each provider profile into the gateway. If an active profile already exists, the launcher keeps going and uses it. -13. Resolve provider credentials from host commands, JSON files, or literal +14. Resolve provider credentials from host commands, JSON files, or literal manifest values. -14. Create or update each provider instance and attach every selected provider +15. Create or update each provider instance and attach every selected provider to the sandbox. -15. Configure and rotate refresh-backed provider credentials when declared by +16. Configure and rotate refresh-backed provider credentials when declared by the manifest. -16. Run `openshell sandbox create` from that temporary Dockerfile source. -17. Inside the sandbox, run `/etc/openshell/agent-payload/runtime/entrypoint.sh`. -18. The runtime entrypoint starts +17. Run `openshell sandbox create` from the temporary Dockerfile source or + published digest. +18. Inside the sandbox, run `/etc/openshell/agent-payload/runtime/entrypoint.sh`. +19. The runtime entrypoint starts `/etc/openshell/agent-payload/runtime/supervisor.sh`. -19. The supervisor invokes +20. The supervisor invokes `/etc/openshell/agent-payload/runtime/harnesses//exec.sh` as a bounded child execution. -20. Harness adapters prepare harness-local auth/config and execute the agent +21. Harness adapters prepare harness-local auth/config and execute the agent prompt headlessly. The payload directory is baked into the image under `/etc/openshell`, which the @@ -101,6 +106,53 @@ subagent definitions, and runtime scripts are agent guts, not workspace state. Agents should write session artifacts, checkouts, temporary files, and future memory records under `/sandbox` or `/tmp` instead. +## Remote Gateways + +A remote gateway cannot build a Dockerfile from the operator's filesystem. Pass +an OCI repository to make the launcher build and push the staged context before +it changes gateway settings or providers: + +```shell +./scripts/agents/run.sh \ + --agent gator \ + --gateway drew-sandbox \ + --publish-to us-west1-docker.pkg.dev/example-project/agents/gator \ + --platform linux/amd64 \ + "Review and monitor PR #2253 through the gator-gate workflow." +``` + +Authenticate Docker to the registry first and use a repository the remote +gateway can pull. `--publish-to` accepts a repository without a tag or digest. +The launcher derives a content tag for the push, reads the pushed digest from +BuildKit metadata, and gives `openshell sandbox create` the digest-pinned image. +This prevents a mutable tag from changing between publication and launch. + +The staged image contains the rendered operator prompt, skills, subagents, and +runtime. Use a private repository with an appropriate access and retention +policy. Set `OPENSHELL_AGENT_PUBLISH_TO` and `OPENSHELL_AGENT_PLATFORM` for +environment-based configuration. The platform defaults to `linux/amd64`. + +Publishing is not gator-specific. `scripts/agents/publish.sh` accepts any +Dockerfile or Docker context and prints only the digest-pinned reference on +standard output: + +```shell +image_ref="$( + ./scripts/agents/publish.sh \ + --from ./path/to/context \ + --publish-to us-west1-docker.pkg.dev/example-project/agents/example \ + --platform linux/amd64 +)" +``` + +This lets other agent launchers and CI workflows reuse the publisher without +depending on gator's manifest, providers, prompt, or runtime. + +If the selected gateway is known to be remote and `--publish-to` is omitted, the +launcher stops with guidance before changing gateway state. If gateway metadata +cannot be read, the existing local-source behavior is preserved and the +OpenShell CLI reports any incompatibility. + ## Runtime Modes Agents can run in `once` or `watch` mode. In `once` mode the supervisor runs one diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 64f6d27b9f..d351644f4f 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -7,7 +7,9 @@ Launch a headless sandbox agent that runs the `gator-gate` skill against OpenShe - `gh` is authenticated on the host and has access to `NVIDIA/OpenShell` and `NVIDIA/OpenShell-Community`. - For `--harness codex`, `codex login` has created `$HOME/.codex/auth.json`. - For `--harness codex`, local Codex auth must include an access token, refresh token, and account ID. -- A local gateway is available when using the default local Dockerfile source. +- A local gateway is available when using the default local Dockerfile source, + or Docker Buildx is authenticated to a private OCI repository that the remote + gateway can pull. ## Usage @@ -21,6 +23,28 @@ Launch a headless sandbox agent that runs the `gator-gate` skill against OpenShe By default the launcher uses `scripts/agents/gator/Dockerfile` as the sandbox source. Local gateways build `scripts/agents/gator/` as the image context, so gator-specific image files such as `policy.yaml` and `bin/gh` stay with the gator agent. The launcher bakes rendered prompts, skills, subagents, and shared runtime files into `/etc/openshell/agent-payload`, so `--from` must point to a local Dockerfile or directory containing a Dockerfile. +For a remote gateway, publish that fully staged context and launch its immutable +digest: + +```shell +./scripts/agents/run.sh \ + --agent gator \ + --gateway drew-sandbox \ + --publish-to us-west1-docker.pkg.dev/example-project/agents/gator \ + --platform linux/amd64 \ + --watch \ + --background \ + "Review and monitor PR #2253 through the gator-gate workflow. Scope this invocation only to PR #2253." +``` + +Configure Docker's registry credential helper before launching. The repository +must not include a tag or digest, and the remote gateway needs pull access. The +launcher uses a content-derived push tag but creates the sandbox from the +registry-reported digest. Because the image includes the rendered operator +prompt and injected agent assets, use a private repository and an appropriate +retention policy. `OPENSHELL_AGENT_PUBLISH_TO` and +`OPENSHELL_AGENT_PLATFORM` are the equivalent environment variables. + Use `--harness codex` to select Codex explicitly. Other harness names are rejected until their support is added to `agent.yaml` and `scripts/agents/runtime/harnesses//`. Agent directories do not carry their own harness implementations; they provide prompt templates and optional skills or subagents for the shared runtime to inject. Use `--codex-bin "$(command -v codex)"` only when the host executable is compatible with the sandbox OS and architecture. diff --git a/scripts/agents/publish.sh b/scripts/agents/publish.sh new file mode 100755 index 0000000000..04463afef6 --- /dev/null +++ b/scripts/agents/publish.sh @@ -0,0 +1,138 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +DOCKER_BIN="${DOCKER_BIN:-docker}" +SOURCE="" +REPOSITORY="" +PLATFORM="linux/amd64" +TEMP_DIR="" + +fail() { + echo "error: $*" >&2 + exit 1 +} + +cleanup() { + if [[ -n "$TEMP_DIR" && -d "$TEMP_DIR" ]]; then + rm -rf "$TEMP_DIR" + fi +} +trap cleanup EXIT + +usage() { + cat <<'EOF' +Usage: scripts/agents/publish.sh --from DOCKERFILE|DIR --publish-to REPOSITORY [options] + +Build and push a Docker context, then print its digest-pinned OCI reference. + +Options: + --from DOCKERFILE|DIR Dockerfile or directory containing Dockerfile + --publish-to REPOSITORY OCI repository without a tag or digest + --platform PLATFORM Published image platform (default: linux/amd64) + -h, --help Show this help +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) + [[ $# -ge 2 ]] || fail "--from requires a value" + SOURCE="$2" + shift 2 + ;; + --publish-to) + [[ $# -ge 2 ]] || fail "--publish-to requires a value" + REPOSITORY="$2" + shift 2 + ;; + --platform) + [[ $# -ge 2 ]] || fail "--platform requires a value" + PLATFORM="$2" + shift 2 + ;; + -h|--help) + usage + exit 0 + ;; + *) + fail "unknown argument: $1" + ;; + esac +done + +[[ -n "$SOURCE" ]] || fail "--from is required" +[[ -n "$REPOSITORY" ]] || fail "--publish-to is required" +[[ "$REPOSITORY" != *[[:space:]]* ]] || fail "--publish-to must not contain whitespace" +[[ "$REPOSITORY" != *@* ]] || fail "--publish-to must be a repository without a digest" +[[ "${REPOSITORY##*/}" != *:* ]] || fail "--publish-to must be a repository without a tag" +[[ -n "$PLATFORM" ]] || fail "--platform requires a non-empty value" +[[ "$PLATFORM" != *[[:space:]]* ]] || fail "--platform must not contain whitespace" +command -v ruby >/dev/null 2>&1 || fail "required command not found: ruby" +command -v "$DOCKER_BIN" >/dev/null 2>&1 || fail "required command not found: $DOCKER_BIN" + +if [[ -d "$SOURCE" ]]; then + CONTEXT="$(cd "$SOURCE" && pwd)" + DOCKERFILE="$CONTEXT/Dockerfile" +elif [[ -f "$SOURCE" ]]; then + DOCKERFILE="$(cd "$(dirname "$SOURCE")" && pwd)/$(basename "$SOURCE")" + CONTEXT="$(dirname "$DOCKERFILE")" +else + fail "--from source does not exist: $SOURCE" +fi +[[ -f "$DOCKERFILE" ]] || fail "Dockerfile not found: $DOCKERFILE" + +FINGERPRINT="$(ruby -rdigest - "$CONTEXT" <<'RUBY' +root = File.expand_path(ARGV.fetch(0)) +digest = Digest::SHA256.new +Dir.glob(File.join(root, "**", "*"), File::FNM_DOTMATCH).sort.each do |path| + relative = path.delete_prefix("#{root}/") + next if relative.empty? || relative.split("/").include?(".") || relative.split("/").include?("..") + + stat = File.lstat(path) + digest.update(relative) + digest.update("\0") + if stat.symlink? + digest.update("symlink\0") + digest.update(File.readlink(path)) + elsif stat.file? + digest.update("file\0") + digest.update(File.binread(path)) + else + next + end + digest.update("\0") +end +puts digest.hexdigest +RUBY +)" +[[ "$FINGERPRINT" =~ ^[0-9a-f]{64}$ ]] || fail "failed to fingerprint image context" +IMAGE_TAG="${REPOSITORY}:payload-${FINGERPRINT:0:16}" + +TEMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent-publish.XXXXXX")" +METADATA_FILE="$TEMP_DIR/build-metadata.json" + +echo "openshell-agent-publisher: Publishing '$IMAGE_TAG' for platform '$PLATFORM'." >&2 +"$DOCKER_BIN" buildx build \ + --platform "$PLATFORM" \ + --push \ + --metadata-file "$METADATA_FILE" \ + --tag "$IMAGE_TAG" \ + --file "$DOCKERFILE" \ + "$CONTEXT" + +if ! PUBLISHED_DIGEST="$(ruby -rjson - "$METADATA_FILE" <<'RUBY' +metadata = JSON.parse(File.read(ARGV.fetch(0))) +digest = metadata["containerimage.digest"] +abort "missing containerimage.digest" unless digest.is_a?(String) +print digest +RUBY +)"; then + fail "published image metadata does not contain containerimage.digest" +fi +[[ "$PUBLISHED_DIGEST" =~ ^sha256:[0-9a-f]{64}$ ]] || fail "published image metadata contains an invalid digest" + +printf '%s@%s\n' "$REPOSITORY" "$PUBLISHED_DIGEST" diff --git a/scripts/agents/publish_test.sh b/scripts/agents/publish_test.sh new file mode 100755 index 0000000000..3e9bc08993 --- /dev/null +++ b/scripts/agents/publish_test.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent-publish-test.XXXXXX")" + +cleanup() { + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" + local expected="$2" + grep -F -- "$expected" "$file" >/dev/null || fail "expected '$expected' in $file" +} + +mkdir -p "$TEST_ROOT/context" "$TEST_ROOT/mocks" +cat >"$TEST_ROOT/context/Dockerfile" <<'DOCKERFILE' +FROM scratch +COPY payload.txt /payload.txt +DOCKERFILE +printf 'decoupled publisher fixture\n' >"$TEST_ROOT/context/payload.txt" + +cat >"$TEST_ROOT/mocks/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%q ' "$@" >>"$MOCK_DOCKER_LOG" +printf '\n' >>"$MOCK_DOCKER_LOG" + +args=("$@") +metadata_file="" +for ((index = 0; index < ${#args[@]}; index++)); do + if [[ "${args[$index]}" == "--metadata-file" ]]; then + metadata_file="${args[$((index + 1))]}" + fi +done +printf '{"containerimage.digest":"sha256:%064d"}\n' 7 >"$metadata_file" +MOCK +chmod +x "$TEST_ROOT/mocks/docker" + +MOCK_DOCKER_LOG="$TEST_ROOT/docker.log" \ + DOCKER_BIN="$TEST_ROOT/mocks/docker" \ + "$SCRIPT_DIR/publish.sh" \ + --from "$TEST_ROOT/context" \ + --publish-to registry.example.com/agents/reusable \ + --platform linux/arm64 >"$TEST_ROOT/reference.txt" + +assert_contains "$TEST_ROOT/reference.txt" "registry.example.com/agents/reusable@sha256:" +assert_contains "$TEST_ROOT/docker.log" "buildx build" +assert_contains "$TEST_ROOT/docker.log" "--platform linux/arm64" +assert_contains "$TEST_ROOT/docker.log" "--push" +assert_contains "$TEST_ROOT/docker.log" "--tag registry.example.com/agents/reusable:payload-" +assert_contains "$TEST_ROOT/docker.log" "context/Dockerfile" + +if DOCKER_BIN="$TEST_ROOT/mocks/docker" "$SCRIPT_DIR/publish.sh" \ + --from "$TEST_ROOT/context" \ + --publish-to registry.example.com/agents/reusable:latest >/dev/null 2>&1; then + fail "publisher unexpectedly accepted a mutable tag" +fi + +echo "publish.sh tests passed" diff --git a/scripts/agents/run.sh b/scripts/agents/run.sh index 78ef359a49..27198a932e 100755 --- a/scripts/agents/run.sh +++ b/scripts/agents/run.sh @@ -9,10 +9,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" OPENSHELL_BIN="${OPENSHELL_BIN:-openshell}" +PUBLISH_BIN="${OPENSHELL_AGENT_PUBLISH_BIN:-$SCRIPT_DIR/publish.sh}" AGENT_ARG="${OPENSHELL_AGENT_DIR:-}" GATEWAY_OVERRIDE="" SANDBOX_NAME_OVERRIDE="" SANDBOX_FROM_OVERRIDE="" +PUBLISH_TO_OVERRIDE="${OPENSHELL_AGENT_PUBLISH_TO:-}" +PUBLISH_PLATFORM_OVERRIDE="${OPENSHELL_AGENT_PLATFORM:-}" HARNESS_OVERRIDE="${GATOR_HARNESS:-}" GITHUB_PROVIDER_OVERRIDE="${GATOR_GITHUB_PROVIDER:-}" CODEX_PROVIDER_OVERRIDE="${GATOR_CODEX_PROVIDER:-}" @@ -35,6 +38,8 @@ Options: --gateway NAME Gateway name to use --name NAME Sandbox name --from DOCKERFILE|DIR Local Dockerfile source for the sandbox image + --publish-to REPOSITORY Build and push the staged image to an OCI repository + --platform PLATFORM Published image platform (default: linux/amd64) --harness NAME Agent harness to run --github-provider NAME Override the github-gator provider instance name --codex-provider NAME Override the codex-gator provider instance name @@ -85,6 +90,16 @@ while [[ $# -gt 0 ]]; do SANDBOX_FROM_OVERRIDE="$2" shift 2 ;; + --publish-to) + [[ $# -ge 2 ]] || fail "--publish-to requires a value" + PUBLISH_TO_OVERRIDE="$2" + shift 2 + ;; + --platform) + [[ $# -ge 2 ]] || fail "--platform requires a value" + PUBLISH_PLATFORM_OVERRIDE="$2" + shift 2 + ;; --harness) [[ $# -ge 2 ]] || fail "--harness requires a value" HARNESS_OVERRIDE="$2" @@ -308,6 +323,19 @@ openshell_cmd() { "$OPENSHELL_BIN" --gateway "$GATEWAY" "$@" } +gateway_is_remote() { + local gateways_json + + if ! gateways_json="$("$OPENSHELL_BIN" gateway list --output json 2>/dev/null)"; then + return 1 + fi + + GATEWAY_NAME="$GATEWAY" ruby -rjson -e ' + gateway = JSON.parse(STDIN.read).find { |item| item["name"] == ENV.fetch("GATEWAY_NAME") } + exit(gateway && gateway["is_remote"] == true ? 0 : 1) + ' <<<"$gateways_json" +} + upsert_provider() { local name="$1" local type="$2" @@ -487,6 +515,8 @@ SANDBOX_FROM="${SANDBOX_FROM_OVERRIDE:-${GATOR_SANDBOX_FROM:-$(resolve_manifest_ RUN_MODE="${RUN_MODE_OVERRIDE:-$RUNTIME_MODE}" POLL_INTERVAL_SECONDS="${POLL_INTERVAL_OVERRIDE:-$RUNTIME_POLL_INTERVAL_SECONDS}" MAX_TRANSIENT_FAILURES="${MAX_TRANSIENT_FAILURES_OVERRIDE:-$RUNTIME_MAX_TRANSIENT_FAILURES}" +PUBLISH_TO="$PUBLISH_TO_OVERRIDE" +PUBLISH_PLATFORM="${PUBLISH_PLATFORM_OVERRIDE:-linux/amd64}" case "$RUN_MODE" in once|watch) ;; @@ -495,6 +525,9 @@ esac [[ "$POLL_INTERVAL_SECONDS" =~ ^[0-9]+$ ]] || fail "--poll-interval must be an integer number of seconds" [[ "$MAX_TRANSIENT_FAILURES" =~ ^[0-9]+$ ]] || fail "max_transient_failures must be an integer" [[ "$POLL_INTERVAL_SECONDS" -gt 0 ]] || fail "--poll-interval must be greater than zero" +if [[ -n "$PUBLISH_PLATFORM_OVERRIDE" && -z "$PUBLISH_TO" ]]; then + fail "--platform requires --publish-to" +fi for ((provider_index = 0; provider_index < PROVIDER_COUNT; provider_index++)); do profile_var="PROVIDER_${provider_index}_PROFILE" @@ -648,8 +681,32 @@ RUBY SANDBOX_FROM="$build_dockerfile" } +publish_immutable_sandbox_source() { + local repository="$1" + local platform="$2" + local published_reference + + log "The published image contains the rendered operator prompt; use a private repository with an appropriate retention policy." + if ! published_reference="$("$PUBLISH_BIN" \ + --from "$SANDBOX_FROM" \ + --publish-to "$repository" \ + --platform "$platform")"; then + fail "failed to publish staged immutable image" + fi + [[ "$published_reference" =~ ^.+@sha256:[0-9a-f]{64}$ ]] || fail "publisher returned an invalid digest-pinned image reference" + SANDBOX_FROM="$published_reference" + log "Published immutable image '$SANDBOX_FROM'." +} + +if [[ -z "$PUBLISH_TO" ]] && gateway_is_remote; then + fail "gateway '$GATEWAY' is remote; use --publish-to REPOSITORY so the launcher can push an immutable image that the gateway can pull" +fi + log "Staging immutable sandbox payload from '$SANDBOX_FROM'." prepare_immutable_sandbox_source "$SANDBOX_FROM" +if [[ -n "$PUBLISH_TO" ]]; then + publish_immutable_sandbox_source "$PUBLISH_TO" "$PUBLISH_PLATFORM" +fi log "Configuring gateway settings." for ((setting_index = 0; setting_index < SETTING_COUNT; setting_index++)); do diff --git a/scripts/agents/run_test.sh b/scripts/agents/run_test.sh new file mode 100755 index 0000000000..3dfa08dfda --- /dev/null +++ b/scripts/agents/run_test.sh @@ -0,0 +1,254 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_ROOT="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent-run-test.XXXXXX")" + +cleanup() { + rm -rf "$TEST_ROOT" +} +trap cleanup EXIT + +fail() { + echo "FAIL: $*" >&2 + exit 1 +} + +assert_contains() { + local file="$1" + local expected="$2" + grep -F -- "$expected" "$file" >/dev/null || fail "expected '$expected' in $file" +} + +assert_not_contains() { + local file="$1" + local unexpected="$2" + if grep -F -- "$unexpected" "$file" >/dev/null; then + fail "did not expect '$unexpected' in $file" + fi +} + +make_fixture() { + local fixture_dir="$1" + mkdir -p "$fixture_dir" + cat >"$fixture_dir/agent.yaml" <<'YAML' +id: fixture +display_name: Fixture Agent +sandbox: + from: agent://. + gateway: fixture-gateway +harness: + default: codex + supported: + codex: + model: fixture-model + reasoning: low +runtime: + mode: once + poll_interval_seconds: 60 + max_transient_failures: 2 +profile_paths: [] +settings: [] +providers: [] +skills: [] +subagents: [] +prompt_template: prompt.md +YAML + cat >"$fixture_dir/Dockerfile" <<'DOCKERFILE' +FROM scratch +USER fixture +DOCKERFILE + cat >"$fixture_dir/prompt.md" <<'PROMPT' +Fixture prompt: {{USER_PROMPT}} +PROMPT +} + +make_mocks() { + local mock_dir="$1" + mkdir -p "$mock_dir" +cat >"$mock_dir/openshell" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%q ' "$@" >>"$MOCK_OPENSHELL_LOG" +printf '\n' >>"$MOCK_OPENSHELL_LOG" +if [[ "$*" == "gateway list --output json" ]]; then + if [[ "${MOCK_GATEWAY_REMOTE:-0}" == "1" ]]; then + printf '[{"name":"fixture-gateway","is_remote":true}]\n' + else + printf '[{"name":"fixture-gateway","is_remote":false}]\n' + fi +fi +MOCK + cat >"$mock_dir/docker" <<'MOCK' +#!/usr/bin/env bash +set -euo pipefail +printf '%q ' "$@" >>"$MOCK_DOCKER_LOG" +printf '\n' >>"$MOCK_DOCKER_LOG" + +metadata_file="" +dockerfile="" +context="" +args=("$@") +for ((index = 0; index < ${#args[@]}; index++)); do + case "${args[$index]}" in + --metadata-file) + metadata_file="${args[$((index + 1))]}" + ;; + --file) + dockerfile="${args[$((index + 1))]}" + ;; + esac +done +context="${args[$((${#args[@]} - 1))]}" + +[[ -f "$dockerfile" ]] +grep -F 'COPY openshell-agent-payload/ /etc/openshell/agent-payload/' "$dockerfile" >/dev/null +grep -F 'Fixture prompt: review PR 2253' "$context/openshell-agent-payload/agent-prompt.md" >/dev/null + +case "${MOCK_DOCKER_MODE:-success}" in + success) + printf '{"containerimage.digest":"sha256:%064d"}\n' 1 >"$metadata_file" + ;; + missing_digest) + printf '{}\n' >"$metadata_file" + ;; + invalid_digest) + printf '{"containerimage.digest":"sha256:not-a-digest"}\n' >"$metadata_file" + ;; + failure) + exit 42 + ;; + *) + exit 2 + ;; +esac +MOCK + chmod +x "$mock_dir/openshell" "$mock_dir/docker" +} + +run_launcher() { + local case_dir="$1" + shift + MOCK_OPENSHELL_LOG="$case_dir/openshell.log" \ + MOCK_DOCKER_LOG="$case_dir/docker.log" \ + OPENSHELL_BIN="$case_dir/mocks/openshell" \ + DOCKER_BIN="$case_dir/mocks/docker" \ + "$SCRIPT_DIR/run.sh" \ + --agent "$case_dir/agent" \ + --gateway fixture-gateway \ + --name fixture-agent \ + "$@" \ + "review PR 2253" +} + +test_publishes_and_launches_by_digest() { + local case_dir="$TEST_ROOT/publish" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + run_launcher "$case_dir" \ + --publish-to registry.example.com/openshell/fixture \ + --platform linux/amd64 + + assert_contains "$case_dir/docker.log" "buildx build" + assert_contains "$case_dir/docker.log" "--platform linux/amd64" + assert_contains "$case_dir/docker.log" "--push" + assert_contains "$case_dir/docker.log" "--metadata-file" + assert_contains "$case_dir/docker.log" "--tag registry.example.com/openshell/fixture:payload-" + assert_contains "$case_dir/openshell.log" "--from registry.example.com/openshell/fixture@sha256:" + assert_not_contains "$case_dir/openshell.log" "fixture:payload-" +} + +test_environment_overrides_publish_defaults() { + local case_dir="$TEST_ROOT/environment" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + OPENSHELL_AGENT_PUBLISH_TO="registry.example.com/openshell/from-env" \ + OPENSHELL_AGENT_PLATFORM="linux/arm64" \ + run_launcher "$case_dir" + + assert_contains "$case_dir/docker.log" "--platform linux/arm64" + assert_contains "$case_dir/openshell.log" "--from registry.example.com/openshell/from-env@sha256:" +} + +test_local_launch_does_not_invoke_docker() { + local case_dir="$TEST_ROOT/local" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + run_launcher "$case_dir" + + [[ ! -e "$case_dir/docker.log" ]] || fail "local launch unexpectedly invoked docker" + assert_contains "$case_dir/openshell.log" "--from /" + assert_contains "$case_dir/openshell.log" "/build-context/Dockerfile" +} + +test_remote_launch_requires_publish_repository() { + local case_dir="$TEST_ROOT/remote-without-publish" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + if MOCK_GATEWAY_REMOTE=1 run_launcher "$case_dir" >"$case_dir/output.log" 2>&1; then + fail "remote launch unexpectedly accepted a local Dockerfile source" + fi + + assert_contains "$case_dir/output.log" "use --publish-to REPOSITORY" + assert_not_contains "$case_dir/openshell.log" "sandbox create" +} + +test_publish_failure_precedes_gateway_changes() { + local case_dir="$TEST_ROOT/publish-failure" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + if MOCK_DOCKER_MODE=failure run_launcher "$case_dir" \ + --publish-to registry.example.com/openshell/fixture; then + fail "launcher unexpectedly succeeded after docker failure" + fi + + [[ ! -e "$case_dir/openshell.log" ]] || fail "gateway was contacted after docker failure" +} + +test_rejects_missing_or_invalid_digest() { + local mode + for mode in missing_digest invalid_digest; do + local case_dir="$TEST_ROOT/$mode" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + if MOCK_DOCKER_MODE="$mode" run_launcher "$case_dir" \ + --publish-to registry.example.com/openshell/fixture; then + fail "launcher unexpectedly accepted $mode" + fi + + [[ ! -e "$case_dir/openshell.log" ]] || fail "gateway was contacted after $mode" + done +} + +test_rejects_invalid_publish_options() { + local case_dir="$TEST_ROOT/invalid-options" + make_fixture "$case_dir/agent" + make_mocks "$case_dir/mocks" + + if run_launcher "$case_dir" --platform linux/amd64; then + fail "--platform unexpectedly succeeded without --publish-to" + fi + if run_launcher "$case_dir" --publish-to registry.example.com/openshell/fixture:latest; then + fail "--publish-to unexpectedly accepted a tag" + fi +} + +test_publishes_and_launches_by_digest +test_environment_overrides_publish_defaults +test_local_launch_does_not_invoke_docker +test_remote_launch_requires_publish_repository +test_publish_failure_precedes_gateway_changes +test_rejects_missing_or_invalid_digest +test_rejects_invalid_publish_options + +echo "run.sh tests passed" diff --git a/tasks/test.toml b/tasks/test.toml index 96dde276ce..f4728b9315 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -5,7 +5,17 @@ [test] description = "Run all tests (Rust + Python)" -depends = ["test:rust", "test:python", "test:sbom", "test:install-sh", "test:build-env", "test:packaging-assets", "test:docs-website"] +depends = ["test:rust", "test:python", "test:sbom", "test:install-sh", "test:build-env", "test:packaging-assets", "test:docs-website", "test:agents"] + +["test:agents"] +description = "Run repository-owned agent launcher and runtime shell tests" +run = [ + "scripts/agents/publish_test.sh", + "scripts/agents/run_test.sh", + "scripts/agents/runtime/supervisor_test.sh", + "scripts/agents/gator/bin/gh_guard_test.sh", +] +hide = true ["test:docs-website"] description = "Test the docs-website sync script"