From 161b43a241cb8a5212b26a87f79a54e7fd9d8033 Mon Sep 17 00:00:00 2001 From: John Myers Date: Tue, 20 Oct 2026 03:17:03 -0700 Subject: [PATCH 1/2] refactor(gator): separate runtime assignments from tooling images (WIP) Signed-off-by: John Myers --- .../skills/launch-openshell-gator/SKILL.md | 2 + scripts/agents/gator/README.md | 8 ++- scripts/agents/gator/agent.yaml | 2 +- .../gator/bin/review_feedback_ledger_test.sh | 2 +- scripts/agents/run.sh | 17 +++--- scripts/agents/run_test.sh | 52 +++++++++++++++++++ .../runtime/harnesses/codex/exec_test.sh | 22 ++++---- scripts/agents/runtime/supervisor.sh | 28 +++++++++- scripts/agents/runtime/supervisor_test.sh | 47 +++++++++++++++++ 9 files changed, 159 insertions(+), 21 deletions(-) create mode 100644 scripts/agents/run_test.sh diff --git a/.agents/skills/launch-openshell-gator/SKILL.md b/.agents/skills/launch-openshell-gator/SKILL.md index 85553a4f01..4d59c8b4b4 100644 --- a/.agents/skills/launch-openshell-gator/SKILL.md +++ b/.agents/skills/launch-openshell-gator/SKILL.md @@ -82,6 +82,8 @@ If this fails, run the local Codex login flow outside the gator launch. If Codex The launcher passes the non-secret host account ID as literal `CODEX_ACCOUNT_ID` for Codex's local workspace routing. The harness must not use the opaque `CODEX_AUTH_ACCOUNT_ID` credential placeholder for that comparison. Access tokens remain placeholders, and refresh material remains gateway-only. +The image contains task-independent tooling and a prompt template, not the run's PR assignment or test authorization. The launcher supplies those instructions at runtime through `OPENSHELL_AGENT_TASK` in the canonical process command. The supervisor captures the assembled prompt in memory and supplies it on stdin for every cycle. Task text is non-secret and may appear in runtime command metadata; never put credentials in the task. Task-only changes reuse the same tooling build inputs, although the launcher still builds/tags an image per invocation. + ### Step 5: Verify Gateway Is Registered And Alive Use the target gateway from the operator request or current session context. Do not assume a gateway name. If the operator did not specify one, list registered gateways and ask before launching when the correct target is ambiguous. diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index 350c0dd595..ee86dff5b0 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -20,7 +20,11 @@ Launch a headless sandbox agent that runs the `gator-gate` skill against OpenShe "Run gator on PR 1536 and keep watching until it closes or merges." ``` -By default the launcher uses `scripts/agents/gator/Dockerfile` as the sandbox image source. It builds `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`, then passes the resulting image reference to `openshell sandbox create`. +By default the launcher uses `scripts/agents/gator/Dockerfile` as the sandbox image source. It builds `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 a task-independent prompt template, skills, subagents, and shared runtime files into `/etc/openshell/agent-payload`, then passes the resulting image reference to `openshell sandbox create`. + +The PR assignment, operator test authorization, harness selection, watch mode, and polling interval are runtime inputs, not rendered image contents. The launcher supplies the non-secret assignment as `OPENSHELL_AGENT_TASK` in the canonical process command. The supervisor assembles the prompt once in memory and sends it to each harness cycle on stdin; cycles do not read task instructions from an agent-writable file. Assignment text is literal data, so shell syntax and template tokens inside it are not evaluated. Do not put credentials in assignments; use providers. Runtime command metadata can contain the assignment. This separates reusable tooling from per-run instructions; it does not make prompt instructions a security boundary. + +The launcher still builds and tags an image for each invocation, but task-only or supervision-setting changes reuse identical tooling build inputs. Publishing and selecting reusable images is a separate launcher capability. The launcher queries the selected gateway and builds with its Docker or Podman compute driver. If `CONTAINER_ENGINE` is set, it must match that driver. Other @@ -75,4 +79,6 @@ bash scripts/agents/gator/bin/gh_guard_test.sh bash scripts/agents/gator/bin/review_feedback_ledger_test.sh bash scripts/agents/gator/bin/resolve_gator_review_threads_test.sh bash scripts/agents/runtime/harnesses/codex/exec_test.sh +bash scripts/agents/run_test.sh +bash scripts/agents/runtime/supervisor_test.sh ``` diff --git a/scripts/agents/gator/agent.yaml b/scripts/agents/gator/agent.yaml index b1e7cc1e8a..7a4128a05d 100644 --- a/scripts/agents/gator/agent.yaml +++ b/scripts/agents/gator/agent.yaml @@ -2,7 +2,7 @@ # SPDX-License-Identifier: Apache-2.0 id: gator -payload_version: 12 +payload_version: 13 display_name: Gator Gate Agent description: Validate and monitor OpenShell GitHub issues and pull requests through the gator state machine. diff --git a/scripts/agents/gator/bin/review_feedback_ledger_test.sh b/scripts/agents/gator/bin/review_feedback_ledger_test.sh index c97f5b941c..e222bd329a 100755 --- a/scripts/agents/gator/bin/review_feedback_ledger_test.sh +++ b/scripts/agents/gator/bin/review_feedback_ledger_test.sh @@ -406,7 +406,7 @@ rg -q 'COPY bin/validate-review-findings /usr/local/bin/validate-review-findings "$GATOR_DIR/Dockerfile" ruby -ryaml -e ' manifest = YAML.load_file(ARGV.fetch(0)) - abort unless manifest.fetch("payload_version") == 12 + abort unless manifest.fetch("payload_version") == 13 resource = manifest.fetch("resources").find { |entry| entry.fetch("id") == "gator-review-findings-schema" } diff --git a/scripts/agents/run.sh b/scripts/agents/run.sh index 2275aa69a3..51df721f7b 100755 --- a/scripts/agents/run.sh +++ b/scripts/agents/run.sh @@ -580,8 +580,10 @@ done PROMPT_TEMPLATE_PATH="$(resolve_manifest_path "$PROMPT_TEMPLATE")" [[ -f "$PROMPT_TEMPLATE_PATH" ]] || fail "missing prompt template: $PROMPT_TEMPLATE_PATH" -ruby -ryaml -rshellwords - "$PROMPT_TEMPLATE_PATH" "$PAYLOAD_DIR/agent-prompt.md" "$MANIFEST_FILE" "$PAYLOAD_IMAGE_DIR" "$HARNESS" "$RUN_MODE" "$POLL_INTERVAL_SECONDS" "$USER_PROMPT" <<'RUBY' -template_path, output_path, manifest_path, payload_image_dir, harness, run_mode, poll_interval_seconds, user_prompt = ARGV +# Compile tooling references only. Task, harness, and supervision settings are +# launch inputs, never image contents (including Docker build context/layers). +ruby -ryaml -rshellwords - "$PROMPT_TEMPLATE_PATH" "$PAYLOAD_DIR/agent-prompt.md" "$MANIFEST_FILE" "$PAYLOAD_IMAGE_DIR" <<'RUBY' +template_path, output_path, manifest_path, payload_image_dir = ARGV manifest = YAML.load_file(manifest_path) || {} subagents = manifest.fetch("subagents", []) @@ -590,12 +592,9 @@ subagent_command = lambda do |id| end values = { - "HARNESS" => harness, - "RUN_MODE" => run_mode, - "POLL_INTERVAL_SECONDS" => poll_interval_seconds, "PAYLOAD_VERSION" => manifest.fetch("payload_version", 1).to_s, - "USER_PROMPT" => user_prompt, } +runtime_variables = %w[HARNESS RUN_MODE POLL_INTERVAL_SECONDS USER_PROMPT] subagents.each do |subagent| next unless subagent.key?("expose_as") @@ -604,14 +603,15 @@ subagents.each do |subagent| unless expose_as.match?(/\A[A-Z][A-Z0-9_]*\z/) abort "invalid subagent expose_as '#{expose_as}': expected [A-Z][A-Z0-9_]*" end - abort "prompt variable '#{expose_as}' is already defined" if values.key?(expose_as) + abort "prompt variable '#{expose_as}' is already defined" if values.key?(expose_as) || runtime_variables.include?(expose_as) values[expose_as] = subagent_command.call(subagent.fetch("id")) end template = File.read(template_path) rendered = template.gsub(/\{\{([A-Z0-9_]+)\}\}/) do - values.fetch(Regexp.last_match(1)) + key = Regexp.last_match(1) + runtime_variables.include?(key) ? Regexp.last_match(0) : values.fetch(key) end File.write(output_path, rendered) RUBY @@ -801,6 +801,7 @@ HARNESS_ENV_ARGS=( "OPENSHELL_AGENT_POLL_INTERVAL_SECONDS=$POLL_INTERVAL_SECONDS" "OPENSHELL_AGENT_MAX_TRANSIENT_FAILURES=$MAX_TRANSIENT_FAILURES" "OPENSHELL_AGENT_PAYLOAD_VERSION=$AGENT_PAYLOAD_VERSION" + "OPENSHELL_AGENT_TASK=$USER_PROMPT" ) case "$HARNESS" in diff --git a/scripts/agents/run_test.sh b/scripts/agents/run_test.sh new file mode 100644 index 0000000000..ca0fe11400 --- /dev/null +++ b/scripts/agents/run_test.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" + +# Stop at the build boundary. No image build, gateway writes, provider imports, +# credential reads, or sandbox launches are allowed in this test. +cat > "$tmp/bin/openshell" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +[[ "$*" == *'gateway info --output json' ]] || exit 90 +printf '%s\n' '{"compute_drivers":[{"name":"docker"}]}' +SH +cat > "$tmp/bin/docker" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [[ "$1" == --version ]]; then echo 'Docker test stub'; exit 0; fi +[[ "$1 $2" == 'buildx build' ]] || exit 91 +cp -R "${!#}" "$CAPTURE_CONTEXT" +exit 77 +SH +chmod +x "$tmp/bin/openshell" "$tmp/bin/docker" + +capture_build() { + local label="$1" task="$2" mode="$3" interval="$4" status=0 + PATH="$tmp/bin:$PATH" OPENSHELL_BIN="$tmp/bin/openshell" \ + CONTAINER_ENGINE=docker CAPTURE_CONTEXT="$tmp/$label" \ + bash "$SCRIPT_DIR/run.sh" --agent gator --gateway test --name test \ + "$mode" --poll-interval "$interval" "$task" > "$tmp/$label.log" 2>&1 || status=$? + if [[ "$status" != 77 ]]; then + cat "$tmp/$label.log" >&2 + echo "expected to stop at image build, got $status" >&2 + exit 1 + fi +} + +capture_build first 'TASK_FIRST_UNIQUE Review PR #123; /ok to test' --watch 15 +capture_build second 'TASK_SECOND_UNIQUE Review PR #456; do not test' --once 30 +if grep -R -E 'TASK_FIRST_UNIQUE|TASK_SECOND_UNIQUE' "$tmp/first" "$tmp/second"; then + echo 'task leaked into image build context' >&2 + exit 1 +fi +diff -r "$tmp/first" "$tmp/second" +grep -Fq '{{USER_PROMPT}}' "$tmp/first/openshell-agent-payload/agent-prompt.md" +grep -Fq '{{RUN_MODE}}' "$tmp/first/openshell-agent-payload/agent-prompt.md" +echo 'ok - different assignments and runtime settings produce identical tooling build contexts' diff --git a/scripts/agents/runtime/harnesses/codex/exec_test.sh b/scripts/agents/runtime/harnesses/codex/exec_test.sh index 8c2fc5cdef..5947b5234c 100755 --- a/scripts/agents/runtime/harnesses/codex/exec_test.sh +++ b/scripts/agents/runtime/harnesses/codex/exec_test.sh @@ -28,20 +28,24 @@ account="$(jq -r '.tokens.account_id' "$HOME/.codex/auth.json")" [[ "$account" != openshell:resolve:* ]] [[ "$(jq -r '.tokens.refresh_token' "$HOME/.codex/auth.json")" == "gateway-managed-refresh-token" ]] [[ "$(node -e 'console.log((require("fs").statSync(process.env.HOME + "/.codex/auth.json").mode & 0o777).toString(8))')" == 600 ]] +[[ "$(cat)" == 'test prompt' ]] printf '%s\n' 'ok - Codex auth preserves token placeholders and uses a literal account ID' MOCK chmod +x "$TMP_DIR/codex" printf '%s\n' 'test prompt' > "$TMP_DIR/prompt.md" -OPENSHELL_AGENT_HOME="$TMP_DIR/home" \ -CODEX_BIN="$TMP_DIR/codex" \ -CODEX_AUTH_ACCESS_TOKEN="$ACCESS_PLACEHOLDER" \ -CODEX_AUTH_ACCOUNT_ID="$ACCOUNT_PLACEHOLDER" \ -CODEX_ACCOUNT_ID="$ACCOUNT_ID" \ -GITHUB_TOKEN="openshell:resolve:env:v42_GITHUB_TOKEN" \ -EXPECTED_ACCESS_PLACEHOLDER="$ACCESS_PLACEHOLDER" \ -EXPECTED_ACCOUNT_ID="$ACCOUNT_ID" \ - bash "$SCRIPT_DIR/exec.sh" "$TMP_DIR/prompt.md" +for prompt_source in "$TMP_DIR/prompt.md" /dev/stdin; do + OPENSHELL_AGENT_HOME="$TMP_DIR/home" \ + CODEX_BIN="$TMP_DIR/codex" \ + CODEX_AUTH_ACCESS_TOKEN="$ACCESS_PLACEHOLDER" \ + CODEX_AUTH_ACCOUNT_ID="$ACCOUNT_PLACEHOLDER" \ + CODEX_ACCOUNT_ID="$ACCOUNT_ID" \ + GITHUB_TOKEN="openshell:resolve:env:v42_GITHUB_TOKEN" \ + EXPECTED_ACCESS_PLACEHOLDER="$ACCESS_PLACEHOLDER" \ + EXPECTED_ACCOUNT_ID="$ACCOUNT_ID" \ + bash "$SCRIPT_DIR/exec.sh" "$prompt_source" < "$TMP_DIR/prompt.md" +done +printf '%s\n' 'ok - Codex receives prompts from both a file and supervisor stdin' # Fail before launching Codex when the routing identity is absent or opaque. for invalid_account_id in "" "$ACCOUNT_PLACEHOLDER"; do diff --git a/scripts/agents/runtime/supervisor.sh b/scripts/agents/runtime/supervisor.sh index a2efb5c524..50a97a7f84 100755 --- a/scripts/agents/runtime/supervisor.sh +++ b/scripts/agents/runtime/supervisor.sh @@ -28,6 +28,32 @@ MAX_SLEEP_SECONDS=86400 [[ -f "$PROMPT_FILE" ]] || { echo "missing agent prompt: $PROMPT_FILE" >&2; exit 1; } [[ -x "$ADAPTER" ]] || { echo "missing harness adapter: $ADAPTER" >&2; exit 1; } +# Capture the run assignment once in supervisor memory. Do not write it to the +# image or a writable prompt file that a review cycle could alter for later +# cycles. Replacement is single-pass: task text is data, not another template. +AGENT_PROMPT_CONTENT="$(node - "$PROMPT_FILE" <<'NODE' +const fs = require("fs"); +const values = { + HARNESS: process.env.OPENSHELL_AGENT_HARNESS, + RUN_MODE: process.env.OPENSHELL_AGENT_RUN_MODE || "once", + POLL_INTERVAL_SECONDS: process.env.OPENSHELL_AGENT_POLL_INTERVAL_SECONDS || "900", + USER_PROMPT: process.env.OPENSHELL_AGENT_TASK, +}; +const template = fs.readFileSync(process.argv[2], "utf8"); +const prompt = template.replace(/\{\{([A-Z0-9_]+)\}\}/g, (_, key) => { + if (!Object.hasOwn(values, key) || !values[key]) { + throw new Error(`Missing or unsupported runtime prompt variable: ${key}`); + } + return values[key]; +}); +process.stdout.write(prompt); +NODE +)" +readonly AGENT_PROMPT_CONTENT +# The harness receives the assembled instructions on stdin, not through its +# environment. The long-lived supervisor retains the original assignment. +unset OPENSHELL_AGENT_TASK + case "$RUN_MODE" in once|watch) ;; *) echo "unsupported agent run mode: $RUN_MODE" >&2; exit 2 ;; @@ -283,7 +309,7 @@ run_cycle() { fi set +e - bash "$ADAPTER" "$PROMPT_FILE" 2>&1 | tee "$output_file" + bash "$ADAPTER" /dev/stdin <<< "$AGENT_PROMPT_CONTENT" 2>&1 | tee "$output_file" local status=${PIPESTATUS[0]} set -e diff --git a/scripts/agents/runtime/supervisor_test.sh b/scripts/agents/runtime/supervisor_test.sh index 5b30ea3f7c..293eac6203 100755 --- a/scripts/agents/runtime/supervisor_test.sh +++ b/scripts/agents/runtime/supervisor_test.sh @@ -256,6 +256,51 @@ fi printf 'ok - bounds state history\n' } +test_runtime_prompt_is_literal_and_stable() { + local tmp + tmp="$(mktemp -d)" + make_payload "$tmp/payload" ' +[[ -z "${OPENSHELL_AGENT_TASK+x}" ]] || exit 20 +cat "$1" > "${OPENSHELL_AGENT_TEST_STATE}.prompt" +state_file="${OPENSHELL_AGENT_TEST_STATE}" +if [[ ! -f "$state_file" ]]; then + cp "${state_file}.prompt" "${state_file}.first" + touch "$state_file" + # Even if a cycle could alter the template, it must not change the captured + # instructions for the next cycle. + printf "tampered prompt" > "$(dirname "${BASH_SOURCE[0]}")/../../../agent-prompt.md" + printf "%s\n" "OPENSHELL_AGENT_RESULT {\"status\":\"waiting\",\"reason\":\"checks_pending\",\"next_poll_seconds\":1}" +else + cmp "${state_file}.first" "${state_file}.prompt" + printf "%s\n" "OPENSHELL_AGENT_RESULT {\"status\":\"complete\",\"reason\":\"done\"}" +fi +' + printf '%s\n' '{{HARNESS}}/{{RUN_MODE}}/{{POLL_INTERVAL_SECONDS}}' '{{USER_PROMPT}}' > "$tmp/payload/agent-prompt.md" + # Shell syntax, template tokens, and sentinel-like text in the assignment + # must all remain literal instruction data. + local task='Review PR #123; $(exit 99) `exit 98` {{RUN_MODE}} & /ok to test' + OPENSHELL_AGENT_TASK="$task" OPENSHELL_AGENT_TEST_STATE="$tmp/count" \ + run_supervisor "$tmp/payload" watch "$tmp/output" + assert_contains "$tmp/count.prompt" 'test/watch/1' + assert_contains "$tmp/count.prompt" "$task" + [[ "$(grep -Fc "$task" "$tmp/count.prompt")" -eq 1 ]] || fail "task duplicated" + printf 'ok - runtime prompt is literal and stable across cycles\n' +} + +test_missing_runtime_task_fails_closed() { + local tmp + tmp="$(mktemp -d)" + make_payload "$tmp/payload" 'touch "${OPENSHELL_AGENT_TEST_STATE}"' + printf '%s\n' '{{USER_PROMPT}}' > "$tmp/payload/agent-prompt.md" + if OPENSHELL_AGENT_TASK='' OPENSHELL_AGENT_TEST_STATE="$tmp/started" \ + run_supervisor "$tmp/payload" once "$tmp/output"; then + fail "missing task accepted" + fi + [[ ! -e "$tmp/started" ]] || fail "harness started without task" + assert_contains "$tmp/output" 'Missing or unsupported runtime prompt variable: USER_PROMPT' + printf 'ok - missing runtime task fails closed\n' +} + test_once_requires_sentinel test_watch_retries_missing_sentinel_until_complete test_watch_retries_invalid_status_until_complete @@ -266,3 +311,5 @@ test_watch_transient_failure_honors_next_poll_seconds test_watch_prints_active_cycle_heartbeat test_persists_agent_notes_and_terminal_state test_bounds_state_history +test_runtime_prompt_is_literal_and_stable +test_missing_runtime_task_fails_closed From c8afb7321ad49b8e5b7f947d344cde174f1a671e Mon Sep 17 00:00:00 2001 From: John Myers Date: Tue, 20 Oct 2026 03:41:52 -0700 Subject: [PATCH 2/2] feat(agents): support Kubernetes image delivery and reusable tooling Signed-off-by: John Myers --- .../skills/launch-openshell-gator/SKILL.md | 16 ++- scripts/agents/gator/README.md | 78 +++++++++++- scripts/agents/image.sh | 101 ++++++++++++++++ scripts/agents/image_test.sh | 114 ++++++++++++++++++ scripts/agents/run.sh | 87 +++++++------ scripts/agents/run_test.sh | 33 +++++ scripts/agents/runtime/launch.sh | 19 +++ scripts/agents/runtime/launch_test.sh | 38 ++++++ 8 files changed, 445 insertions(+), 41 deletions(-) create mode 100644 scripts/agents/image.sh create mode 100644 scripts/agents/image_test.sh create mode 100644 scripts/agents/runtime/launch.sh create mode 100644 scripts/agents/runtime/launch_test.sh diff --git a/.agents/skills/launch-openshell-gator/SKILL.md b/.agents/skills/launch-openshell-gator/SKILL.md index 4d59c8b4b4..9fba2b89e2 100644 --- a/.agents/skills/launch-openshell-gator/SKILL.md +++ b/.agents/skills/launch-openshell-gator/SKILL.md @@ -84,6 +84,20 @@ The launcher passes the non-secret host account ID as literal `CODEX_ACCOUNT_ID` The image contains task-independent tooling and a prompt template, not the run's PR assignment or test authorization. The launcher supplies those instructions at runtime through `OPENSHELL_AGENT_TASK` in the canonical process command. The supervisor captures the assembled prompt in memory and supplies it on stdin for every cycle. Task text is non-secret and may appear in runtime command metadata; never put credentials in the task. Task-only changes reuse the same tooling build inputs, although the launcher still builds/tags an image per invocation. +For Kubernetes gateways, select image delivery explicitly: `--k3d-cluster NAME` +builds with Docker and imports into a running local cluster with the matching +current kube context; `--publish-image REGISTRY/REPOSITORY:TAG` builds and pushes, +then launches by resolved digest; `--image REGISTRY/REPOSITORY@sha256:DIGEST` +reuses trusted compatible tooling without local image tools. Do not infer +locality from a loopback endpoint. The k3d selection asserts which cluster +serves the gateway; imported images require `if_not_present` or `never` pull +policy. Registry builds use the builder's platform and existing registry auth; +nodes need their own pull access. `--build-only` builds/delivers without +gateway mutations, provider credential reads, or a sandbox launch. Reused +images must match the local manifest's agent ID and payload version and support +the runtime task contract; legacy baked-task images fail startup. See +`scripts/agents/gator/README.md` for examples and option compatibility. + ### Step 5: Verify Gateway Is Registered And Alive Use the target gateway from the operator request or current session context. Do not assume a gateway name. If the operator did not specify one, list registered gateways and ask before launching when the correct target is ambiguous. @@ -165,7 +179,7 @@ sandbox_name="gtr-pr-${pr_number}-watch" "Review and monitor PR #${pr_number} through the gator-gate workflow. Scope this invocation only to PR #${pr_number}." ``` -The launcher queries the gateway's selected compute driver, builds the gator image in the matching Docker or Podman image store, stages the immutable payload, imports provider profiles, configures provider credentials and refresh, and starts the agent supervisor as the sandbox's canonical main process. The detached main process survives loss of the host CLI connection and reconnects to a restarted gateway. Unless `--keep` is set, the sandbox is marked ephemeral so the gateway deletes it after the canonical main process exits and its terminal result is finalized. `CONTAINER_ENGINE`, when set, must match the gateway driver. +The launcher queries the gateway's selected compute driver and prepares the image through the selected build/import/publish or prebuilt path before importing provider profiles, configuring provider credentials and refresh, and starting the agent supervisor as the sandbox's canonical main process. The detached main process survives loss of the host CLI connection and reconnects to a restarted gateway. Unless `--keep` is set, the sandbox is marked ephemeral so the gateway deletes it after the canonical main process exits and its terminal result is finalized. `CONTAINER_ENGINE`, when set, must match Docker/Podman gateways or Docker for k3d imports; registry builds for Kubernetes may use either local engine. The launcher streams image-build and provisioning output until the detached workload is ready, then exits. Use `openshell logs ` or the TUI for runtime output. diff --git a/scripts/agents/gator/README.md b/scripts/agents/gator/README.md index ee86dff5b0..fbc3adddf5 100644 --- a/scripts/agents/gator/README.md +++ b/scripts/agents/gator/README.md @@ -7,8 +7,8 @@ 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`. - 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 and either Docker or Podman are available to build the - default sandbox image. +- A Docker, Podman, or Kubernetes gateway is available. Local builds require + Docker or Podman; a prebuilt image does not require a local container engine. ## Usage @@ -24,11 +24,75 @@ By default the launcher uses `scripts/agents/gator/Dockerfile` as the sandbox im The PR assignment, operator test authorization, harness selection, watch mode, and polling interval are runtime inputs, not rendered image contents. The launcher supplies the non-secret assignment as `OPENSHELL_AGENT_TASK` in the canonical process command. The supervisor assembles the prompt once in memory and sends it to each harness cycle on stdin; cycles do not read task instructions from an agent-writable file. Assignment text is literal data, so shell syntax and template tokens inside it are not evaluated. Do not put credentials in assignments; use providers. Runtime command metadata can contain the assignment. This separates reusable tooling from per-run instructions; it does not make prompt instructions a security boundary. -The launcher still builds and tags an image for each invocation, but task-only or supervision-setting changes reuse identical tooling build inputs. Publishing and selecting reusable images is a separate launcher capability. +Without `--image`, the launcher builds and tags an image for each invocation, +but task-only or supervision-setting changes reuse identical tooling build +inputs. Docker and Podman builds use the gateway's engine; an explicit +`CONTAINER_ENGINE` must match it. -The launcher queries the selected gateway and builds with its Docker or Podman -compute driver. If `CONTAINER_ENGINE` is set, it must match that driver. Other -gateway drivers cannot run this local-image launcher. +## Kubernetes image delivery + +The launcher discovers the compute driver from the selected gateway. It does +not infer a local cluster from a loopback gateway endpoint: that endpoint could +be a port-forward to a remote deployment. Select one of these image paths for +a Kubernetes gateway. + +For local k3d, explicitly identify the cluster serving the gateway. The launcher +requires the matching current kube context and a running local cluster, builds +with Docker, and imports the image into that cluster before creating a sandbox: + +```shell +OPENSHELL_WORKSPACE=gator mise exec k3d@5.9.0 -- ./scripts/agents/run.sh \ + --agent gator --gateway openshell-dev-main \ + --k3d-cluster openshell-dev-main --name gtr-pr-1234 --watch \ + "Review and monitor PR #1234 through the gator-gate workflow." +``` + +The cluster selection is an operator assertion that the gateway uses that +cluster; it is not inferred from the gateway name. Its workload image pull +policy must allow imported images (`if_not_present` or `never`). The local +Skaffold configuration uses `if_not_present`. + +For a registry-backed deployment, build and publish tooling once without +changing gateway settings, importing providers, reading provider credentials, +or launching a sandbox: + +```shell +./scripts/agents/run.sh --agent gator --gateway remote-kube \ + --publish-image registry.example.com/team/gator:payload-13 --build-only +``` + +The builder uses existing Docker/Podman registry authentication. Both local and +remote Kubernetes nodes must be able to pull from the chosen registry; private +registries require the deployment's image-pull credential configuration. Builds +target the local builder's platform, so build on a matching platform for the +target nodes. Multi-platform builds are not provided by this launcher. + +The launcher prints `Tooling image: REGISTRY/REPOSITORY@sha256:DIGEST` after a +successful push. Reuse that full reference for subsequent tasks: + +```shell +OPENSHELL_WORKSPACE=gator ./scripts/agents/run.sh \ + --agent gator --gateway remote-kube --name gtr-pr-1234 --watch \ + --image "$GATOR_IMAGE" \ + "Review and monitor PR #1234 through the gator-gate workflow." +``` + +Set `GATOR_IMAGE` to the printed digest-pinned reference, not a mutable tag. +`--image` skips local builds and cluster imports and works with Docker/Podman +gateways too. It cannot be combined with `--from`, `--codex-bin`, +`--k3d-cluster`, `--publish-image`, or `--build-only`. The image must contain +tooling built by this launcher with the same agent ID and payload version as +the local manifest. Startup validates that contract before running the agent; +legacy images with baked assignments are incompatible. Only reuse trusted +tooling images: the contract check does not authenticate their contents. + +Omit `--build-only` from `--publish-image` to build, push, and launch in one +invocation. A failed push, missing repository digest, or failed k3d import +stops the launcher before gateway settings, provider configuration, or sandbox +creation. Kubernetes launches without an explicit image-delivery choice fail +with guidance rather than guessing a cluster or registry. + +## Runtime and providers 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. @@ -80,5 +144,7 @@ bash scripts/agents/gator/bin/review_feedback_ledger_test.sh bash scripts/agents/gator/bin/resolve_gator_review_threads_test.sh bash scripts/agents/runtime/harnesses/codex/exec_test.sh bash scripts/agents/run_test.sh +bash scripts/agents/image_test.sh +bash scripts/agents/runtime/launch_test.sh bash scripts/agents/runtime/supervisor_test.sh ``` diff --git a/scripts/agents/image.sh b/scripts/agents/image.sh new file mode 100644 index 0000000000..784d8946ba --- /dev/null +++ b/scripts/agents/image.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +# Image transport for the host launcher. The caller supplies fail/log/require_cmd +# and selects the gateway; no locality inference is made from its endpoint. +prepare_agent_image_transport() { + local gateway_info + gateway_info="$("$OPENSHELL_BIN" --gateway "$GATEWAY" gateway info --output json)" || fail "failed to inspect gateway '$GATEWAY'" + GATEWAY_DRIVER="$(printf '%s' "$gateway_info" | ruby -rjson -e ' + drivers = JSON.parse(STDIN.read).fetch("compute_drivers", []).map { |d| d.fetch("name").downcase } + abort "gateway must report exactly one compute driver" unless drivers.length == 1 + puts drivers.first + ')" || fail "gateway '$GATEWAY' did not report exactly one compute driver" + case "$GATEWAY_DRIVER" in + docker|podman|kubernetes) ;; + *) fail "unsupported agent compute driver: $GATEWAY_DRIVER" ;; + esac + + if [[ -n "$AGENT_IMAGE" ]]; then + [[ "$AGENT_IMAGE" =~ ^[a-zA-Z0-9][a-zA-Z0-9._:/-]*@sha256:[a-f0-9]{64}$ ]] || fail "--image requires a digest-pinned image reference" + [[ -z "$K3D_CLUSTER$PUBLISH_IMAGE" ]] || fail "--image cannot be combined with --k3d-cluster or --publish-image" + IMAGE_MODE=prebuilt + return + fi + + if [[ -n "$K3D_CLUSTER" ]]; then + [[ -z "$PUBLISH_IMAGE" ]] || fail "--k3d-cluster and --publish-image are mutually exclusive" + [[ "$GATEWAY_DRIVER" == kubernetes ]] || fail "--k3d-cluster requires a Kubernetes gateway" + [[ "$K3D_CLUSTER" =~ ^[a-z0-9]([a-z0-9-]{0,30}[a-z0-9])?$ ]] || fail "invalid k3d cluster name" + require_cmd k3d + require_cmd kubectl + local context + context="$(kubectl config current-context)" || fail "cannot determine Kubernetes context" + [[ "$context" == "k3d-$K3D_CLUSTER" ]] || fail "selected kube context '$context' does not match k3d cluster '$K3D_CLUSTER'" + k3d cluster list -o json | ruby -rjson -e ' + cluster = JSON.parse(STDIN.read).find { |c| c.fetch("name") == ARGV.fetch(0) } + abort "selected local k3d cluster is not running" unless cluster && cluster.fetch("serversRunning", 0) > 0 + ' "$K3D_CLUSTER" || fail "cannot use local k3d cluster '$K3D_CLUSTER'" + IMAGE_MODE=k3d + set_agent_build_engine docker + elif [[ -n "$PUBLISH_IMAGE" ]]; then + # Require an explicit registry and tag. No implicit Docker Hub uploads. + [[ "$PUBLISH_IMAGE" =~ ^[a-z0-9][a-z0-9.:-]*/[a-z0-9][a-z0-9._/-]*:[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]] || fail "--publish-image requires REGISTRY/REPOSITORY:TAG" + local registry="${PUBLISH_IMAGE%%/*}" + [[ "$registry" == localhost || "$registry" == *.* || "$registry" == *:* ]] || fail "--publish-image requires an explicit registry hostname (use docker.io for Docker Hub)" + IMAGE_MODE=registry + if [[ "$GATEWAY_DRIVER" == kubernetes ]]; then + # Registry builds may use either local engine, independently of the + # engine used to host the remote Kubernetes cluster. + source "$ROOT_DIR/tasks/scripts/container-engine.sh" + else + set_agent_build_engine "$GATEWAY_DRIVER" + fi + elif [[ "$GATEWAY_DRIVER" == kubernetes ]]; then + fail "Kubernetes needs --image REGISTRY/REPOSITORY@sha256:DIGEST, --k3d-cluster NAME, or --publish-image REGISTRY/REPOSITORY:TAG" + else + IMAGE_MODE=engine + set_agent_build_engine "$GATEWAY_DRIVER" + fi +} + +set_agent_build_engine() { + local engine="$1" + if [[ -n "${CONTAINER_ENGINE:-}" && "$(printf '%s' "$CONTAINER_ENGINE" | tr '[:upper:]' '[:lower:]')" != "$engine" ]]; then + fail "CONTAINER_ENGINE=$CONTAINER_ENGINE conflicts with required image build engine '$engine'" + fi + CONTAINER_ENGINE="$engine" + export CONTAINER_ENGINE + source "$ROOT_DIR/tasks/scripts/container-engine.sh" +} + +deliver_agent_image() { + local image_tag="$1" + case "$IMAGE_MODE" in + k3d) + log "Importing '$image_tag' into k3d cluster '$K3D_CLUSTER'." + k3d image import --cluster "$K3D_CLUSTER" "$image_tag" || fail "k3d image import failed" + SANDBOX_FROM="$image_tag" + ;; + registry) + log "Publishing '$image_tag'." + ce push "$image_tag" || fail "image push failed" + local repository="${image_tag%:*}" + SANDBOX_FROM="$(ce image inspect "$image_tag" --format '{{json .RepoDigests}}' | ruby -rjson -e ' + digests = JSON.parse(STDIN.read) || [] + normalize = lambda do |repository| + # Docker omits its default registry in RepoDigests. + repository = repository.sub(/\A(?:docker\.io|index\.docker\.io)\//, "") + repository.include?("/") ? repository : "library/#{repository}" + end + digest = digests.find { |d| normalize.call(d.split("@", 2).first) == normalize.call(ARGV.fetch(0)) && d.match?(/@sha256:[a-f0-9]{64}\z/) } + abort "push did not produce a matching repository digest" unless digest + puts "#{ARGV.fetch(0)}@#{digest.split("@", 2).last}" + ' "$repository")" || fail "cannot resolve published image digest; refusing mutable-tag launch" + ;; + engine) SANDBOX_FROM="$image_tag" ;; + *) fail "invalid image delivery mode: $IMAGE_MODE" ;; + esac +} diff --git a/scripts/agents/image_test.sh b/scripts/agents/image_test.sh new file mode 100644 index 0000000000..1ab0eff9af --- /dev/null +++ b/scripts/agents/image_test.sh @@ -0,0 +1,114 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/bin" +export CALLS="$tmp/calls" +export TEST_DIGEST="$(printf 'a%.0s' {1..64})" + +cat > "$tmp/bin/openshell" <<'SH' +#!/usr/bin/env bash +printf '{"compute_drivers":[{"name":"%s"}]}\n' "${TEST_DRIVER:-kubernetes}" +SH +cat > "$tmp/bin/kubectl" <<'SH' +#!/usr/bin/env bash +echo "${TEST_CONTEXT:-k3d-local}" +SH +cat > "$tmp/bin/k3d" <<'SH' +#!/usr/bin/env bash +echo "k3d $*" >> "$CALLS" +if [[ "$1" == cluster ]]; then + echo '[{"name":"local","serversRunning":1}]' +else + exit "${TEST_IMPORT_STATUS:-0}" +fi +SH +cat > "$tmp/bin/docker" <<'SH' +#!/usr/bin/env bash +set -eu +echo "docker $*" >> "$CALLS" +case "$1" in + --version) echo 'Docker test stub' ;; + push) exit "${TEST_PUSH_STATUS:-0}" ;; + image) printf '["%s@sha256:%s"]\n' "${TEST_INSPECT_REPOSITORY:-registry.test/team/gator}" "$TEST_DIGEST" ;; +esac +SH +chmod +x "$tmp/bin/"* +ln -s docker "$tmp/bin/podman" +export PATH="$tmp/bin:$PATH" + +fail() { echo "error: $*" >&2; exit 1; } +log() { echo "$*" >&2; } +require_cmd() { command -v "$1" >/dev/null || fail "missing $1"; } +source "$SCRIPT_DIR/image.sh" + +run_transport() ( + OPENSHELL_BIN=openshell + GATEWAY=test + AGENT_IMAGE="${TEST_IMAGE:-}" + K3D_CLUSTER="${TEST_CLUSTER:-}" + PUBLISH_IMAGE="${TEST_PUBLISH:-}" + CONTAINER_ENGINE="${TEST_ENGINE:-docker}" + unset _CONTAINER_ENGINE_LOADED CONTAINER_ENGINE_TARGET + prepare_agent_image_transport + if [[ "$IMAGE_MODE" == prebuilt ]]; then + echo "$AGENT_IMAGE" + else + deliver_agent_image "${PUBLISH_IMAGE:-openshell/agent-gator:test}" + echo "$SANDBOX_FROM" + fi +) + +expect_failure() { + if "$@" > "$tmp/out" 2> "$tmp/error"; then + fail 'expected failure' + fi +} + +TEST_CLUSTER=local run_transport > "$tmp/out" +grep -Fq 'k3d image import --cluster local openshell/agent-gator:test' "$CALLS" +grep -Fxq 'openshell/agent-gator:test' "$tmp/out" +echo 'ok - Kubernetes local import targets the explicit k3d cluster' + +TEST_PUBLISH=registry.test/team/gator:tooling run_transport > "$tmp/out" +grep -Fq 'docker push registry.test/team/gator:tooling' "$CALLS" +grep -Fxq "registry.test/team/gator@sha256:$TEST_DIGEST" "$tmp/out" +echo 'ok - registry delivery resolves a pinned digest' +TEST_PUBLISH=docker.io/team/gator:tooling TEST_INSPECT_REPOSITORY=team/gator \ + run_transport > "$tmp/out" +grep -Fxq "docker.io/team/gator@sha256:$TEST_DIGEST" "$tmp/out" +echo 'ok - Docker Hub digest normalization preserves the explicit registry' + +: > "$CALLS" +TEST_IMAGE="registry.test/team/gator@sha256:$TEST_DIGEST" run_transport > "$tmp/out" +[[ ! -s "$CALLS" ]] || fail 'prebuilt path contacted local image/cluster tools' +echo 'ok - prebuilt path needs no local image engine or kube context' + +expect_failure run_transport +grep -Fq 'Kubernetes needs' "$tmp/error" +TEST_CLUSTER=local TEST_CONTEXT=remote expect_failure run_transport +TEST_CLUSTER=missing expect_failure run_transport +TEST_CLUSTER=local TEST_IMPORT_STATUS=1 expect_failure run_transport +TEST_CLUSTER=local TEST_ENGINE=podman expect_failure run_transport +TEST_CLUSTER=local TEST_PUBLISH=registry.test/team/gator:tag expect_failure run_transport +TEST_CLUSTER=local TEST_IMAGE="registry.test/team/gator@sha256:$TEST_DIGEST" expect_failure run_transport +TEST_IMAGE=registry.test/team/gator:latest expect_failure run_transport +TEST_PUBLISH=registry.test/team/gator:tag TEST_PUSH_STATUS=1 expect_failure run_transport +TEST_PUBLISH=registry.test/team/gator:tag TEST_INSPECT_REPOSITORY=other.test/gator expect_failure run_transport +TEST_PUBLISH='implicit-tag' expect_failure run_transport +TEST_PUBLISH=implicit/repository:tag expect_failure run_transport +TEST_DRIVER=vm expect_failure run_transport +echo 'ok - ambiguous targets, conflicting options, and failed delivery fail closed' + +TEST_DRIVER=docker run_transport > "$tmp/out" +grep -Fxq 'openshell/agent-gator:test' "$tmp/out" +echo 'ok - Docker delivery retains the local-engine path' +TEST_DRIVER=podman TEST_ENGINE=podman run_transport > "$tmp/out" +grep -Fxq 'openshell/agent-gator:test' "$tmp/out" +echo 'ok - Podman delivery retains the local-engine path' diff --git a/scripts/agents/run.sh b/scripts/agents/run.sh index 51df721f7b..5eb7cf07b2 100755 --- a/scripts/agents/run.sh +++ b/scripts/agents/run.sh @@ -13,6 +13,10 @@ AGENT_ARG="${OPENSHELL_AGENT_DIR:-}" GATEWAY_OVERRIDE="" SANDBOX_NAME_OVERRIDE="" SANDBOX_FROM_OVERRIDE="" +AGENT_IMAGE="" +K3D_CLUSTER="" +PUBLISH_IMAGE="" +BUILD_ONLY=0 HARNESS_OVERRIDE="${GATOR_HARNESS:-}" GITHUB_PROVIDER_OVERRIDE="${GATOR_GITHUB_PROVIDER:-}" CODEX_PROVIDER_OVERRIDE="${GATOR_CODEX_PROVIDER:-}" @@ -34,6 +38,10 @@ Options: --gateway NAME Gateway name to use --name NAME Sandbox name (DNS-1123 label, at most 19 characters) --from DOCKERFILE|DIR Local Dockerfile source for the sandbox image + --image IMAGE@sha256:DIGEST Reuse a compatible tooling image; skip local build + --k3d-cluster NAME Build and import into this local cluster (Kubernetes only) + --publish-image REGISTRY/REPOSITORY:TAG Build/push and launch by digest + --build-only Build/deliver tooling without configuring or launching an agent --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 @@ -88,6 +96,28 @@ while [[ $# -gt 0 ]]; do HARNESS_OVERRIDE="$2" shift 2 ;; + --image) + [[ $# -ge 2 ]] || fail "--image requires a value" + [[ -n "$2" ]] || fail "--image requires a non-empty value" + AGENT_IMAGE="$2" + shift 2 + ;; + --k3d-cluster) + [[ $# -ge 2 ]] || fail "--k3d-cluster requires a value" + [[ -n "$2" ]] || fail "--k3d-cluster requires a non-empty value" + K3D_CLUSTER="$2" + shift 2 + ;; + --publish-image) + [[ $# -ge 2 ]] || fail "--publish-image requires a value" + [[ -n "$2" ]] || fail "--publish-image requires a non-empty value" + PUBLISH_IMAGE="$2" + shift 2 + ;; + --build-only) + BUILD_ONLY=1 + shift + ;; --github-provider) [[ $# -ge 2 ]] || fail "--github-provider requires a value" GITHUB_PROVIDER_OVERRIDE="$2" @@ -147,8 +177,12 @@ while [[ $# -gt 0 ]]; do done [[ -n "$AGENT_ARG" ]] || { usage >&2; exit 2; } -[[ $# -gt 0 ]] || { usage >&2; exit 2; } +[[ $# -gt 0 || "$BUILD_ONLY" == 1 ]] || { usage >&2; exit 2; } USER_PROMPT="$*" +if [[ -n "$AGENT_IMAGE" ]]; then + [[ -z "$SANDBOX_FROM_OVERRIDE${GATOR_SANDBOX_FROM:-}$CODEX_LOCAL_BIN" ]] || fail "--image cannot be combined with --from, GATOR_SANDBOX_FROM, or --codex-bin" + [[ "$BUILD_ONLY" != 1 ]] || fail "--image cannot be combined with --build-only" +fi case "$AGENT_ARG" in /*|*/*) @@ -555,8 +589,12 @@ cleanup_payload() { trap 'cleanup_config; cleanup_payload' EXIT log "Preparing $AGENT_DISPLAY_NAME with harness '$HARNESS' in '$RUN_MODE' mode on gateway '$GATEWAY'." +source "$SCRIPT_DIR/image.sh" +prepare_agent_image_transport mkdir -p "$PAYLOAD_DIR" +ruby -rjson -e 'puts JSON.generate({protocol: 1, agent_id: ARGV[0], payload_version: ARGV[1].to_i})' \ + "$AGENT_ID" "$AGENT_PAYLOAD_VERSION" > "$PAYLOAD_DIR/tooling.json" cp -R "$SCRIPT_DIR/runtime" "$PAYLOAD_DIR/runtime" chmod +x "$PAYLOAD_DIR/runtime"/*.sh chmod +x "$PAYLOAD_DIR/runtime/harnesses/$HARNESS"/*.sh @@ -679,42 +717,23 @@ File.open(dockerfile_path, "a") do |file| end RUBY - # Build into the local engine selected by the gateway. Without this, - # auto-detection can choose Podman while the gateway uses Docker (or vice - # versa), leaving the image unavailable to the gateway. - local gateway_info - if ! gateway_info="$("$OPENSHELL_BIN" --gateway "$GATEWAY" gateway info --output json)"; then - fail "failed to determine compute driver for gateway '$GATEWAY'" - fi - local gateway_engine - if ! gateway_engine="$(printf '%s' "$gateway_info" | ruby -rjson -e ' - drivers = JSON.parse(STDIN.read).fetch("compute_drivers", []).map { |driver| driver.fetch("name") } - abort "gateway must report exactly one compute driver" unless drivers.length == 1 - puts drivers.first.downcase - ')"; then - fail "gateway '$GATEWAY' did not report exactly one compute driver" - fi - case "$gateway_engine" in - docker|podman) ;; - *) fail "gateway '$GATEWAY' uses compute driver '$gateway_engine'; agent launcher local image builds require Docker or Podman" ;; - esac - if [[ -n "${CONTAINER_ENGINE:-}" ]] && [[ "$(printf '%s' "$CONTAINER_ENGINE" | tr '[:upper:]' '[:lower:]')" != "$gateway_engine" ]]; then - fail "CONTAINER_ENGINE=$CONTAINER_ENGINE conflicts with gateway '$GATEWAY' compute driver '$gateway_engine'" - fi - CONTAINER_ENGINE="$gateway_engine" - export CONTAINER_ENGINE - - # Source after setting CONTAINER_ENGINE so the helper validates the - # gateway-selected engine instead of auto-detecting another engine. - source "$ROOT_DIR/tasks/scripts/container-engine.sh" - local image_tag="openshell/agent-${AGENT_ID}:$(date +%s)" + local image_tag="${PUBLISH_IMAGE:-openshell/agent-${AGENT_ID}:$(date +%s)-$$}" log "Building sandbox image '$image_tag' with $CONTAINER_ENGINE." ce_build --load --file "$build_dockerfile" --tag "$image_tag" "$build_context" - SANDBOX_FROM="$image_tag" + deliver_agent_image "$image_tag" } -log "Staging immutable sandbox payload from '$SANDBOX_FROM'." -prepare_immutable_sandbox_source "$SANDBOX_FROM" +if [[ "$IMAGE_MODE" == prebuilt ]]; then + SANDBOX_FROM="$AGENT_IMAGE" + log "Using prebuilt tooling image '$SANDBOX_FROM'." +else + log "Staging immutable sandbox payload from '$SANDBOX_FROM'." + prepare_immutable_sandbox_source "$SANDBOX_FROM" +fi +log "Tooling image: $SANDBOX_FROM" +if [[ "$BUILD_ONLY" == 1 ]]; then + exit 0 +fi log "Configuring gateway settings." for ((setting_index = 0; setting_index < SETTING_COUNT; setting_index++)); do @@ -836,7 +855,7 @@ SANDBOX_CREATE_CMD+=( -- env "${HARNESS_ENV_ARGS[@]}" - bash "$PAYLOAD_IMAGE_DIR/runtime/entrypoint.sh" + bash "$PAYLOAD_IMAGE_DIR/runtime/launch.sh" ) run_agent_sandbox() { diff --git a/scripts/agents/run_test.sh b/scripts/agents/run_test.sh index ca0fe11400..4ef348cccd 100644 --- a/scripts/agents/run_test.sh +++ b/scripts/agents/run_test.sh @@ -50,3 +50,36 @@ diff -r "$tmp/first" "$tmp/second" grep -Fq '{{USER_PROMPT}}' "$tmp/first/openshell-agent-payload/agent-prompt.md" grep -Fq '{{RUN_MODE}}' "$tmp/first/openshell-agent-payload/agent-prompt.md" echo 'ok - different assignments and runtime settings produce identical tooling build contexts' + +# Exercise the complete launcher with a credential-free agent fixture. All +# external commands remain mocked; the runtime task must reach sandbox create. +mkdir -p "$tmp/agent" +cat > "$tmp/agent/agent.yaml" <<'YAML' +id: fixture +payload_version: 1 +prompt_template: agent://prompt.md +harness: + default: codex + supported: + codex: {} +YAML +printf '%s\n' '{{USER_PROMPT}}' > "$tmp/agent/prompt.md" +cat > "$tmp/bin/openshell" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +case "$*" in + *'gateway info --output json') echo '{"compute_drivers":[{"name":"kubernetes"}]}' ;; + *'sandbox create'*) printf '%s\n' "$@" > "$LAUNCH_ARGS" ;; + *) exit 90 ;; +esac +SH +image="registry.test/team/gator@sha256:$(printf 'a%.0s' {1..64})" +PATH="$tmp/bin:$PATH" OPENSHELL_BIN="$tmp/bin/openshell" \ + CODEX_AUTH_ACCOUNT_ID=fixture-account LAUNCH_ARGS="$tmp/launch.args" \ + bash "$SCRIPT_DIR/run.sh" --agent "$tmp/agent" --gateway test --name fixture \ + --image "$image" --watch 'RUNTIME_ONLY_TASK; /ok to test' > "$tmp/launch.log" 2>&1 +grep -Fxq "$image" "$tmp/launch.args" +grep -Fxq 'OPENSHELL_AGENT_TASK=RUNTIME_ONLY_TASK; /ok to test' "$tmp/launch.args" +grep -Fxq 'OPENSHELL_AGENT_RUN_MODE=watch' "$tmp/launch.args" +grep -Fxq '/etc/openshell/agent-payload/runtime/launch.sh' "$tmp/launch.args" +echo 'ok - prebuilt Kubernetes launch carries task and watch mode at runtime' diff --git a/scripts/agents/runtime/launch.sh b/scripts/agents/runtime/launch.sh new file mode 100644 index 0000000000..dc1db04afc --- /dev/null +++ b/scripts/agents/runtime/launch.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +RUNTIME_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# New launchers deliberately use a new entrypoint name. Legacy images with a +# baked assignment cannot silently execute it when passed through --image. +node - "$RUNTIME_DIR/../tooling.json" <<'NODE' +const fs = require("fs"); +const metadata = JSON.parse(fs.readFileSync(process.argv[2], "utf8")); +if (metadata.protocol !== 1 || + metadata.agent_id !== process.env.OPENSHELL_AGENT_ID || + String(metadata.payload_version) !== process.env.OPENSHELL_AGENT_PAYLOAD_VERSION) { + throw new Error("Tooling image does not match the requested agent/payload version; rebuild it"); +} +NODE +exec bash "$RUNTIME_DIR/entrypoint.sh" diff --git a/scripts/agents/runtime/launch_test.sh b/scripts/agents/runtime/launch_test.sh new file mode 100644 index 0000000000..7c561ae1e2 --- /dev/null +++ b/scripts/agents/runtime/launch_test.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +mkdir -p "$tmp/runtime" +cp "$SCRIPT_DIR/launch.sh" "$tmp/runtime/launch.sh" +printf '%s\n' 'touch "${START_MARKER:?}"' > "$tmp/runtime/entrypoint.sh" +export START_MARKER="$tmp/started" +export OPENSHELL_AGENT_ID=gator OPENSHELL_AGENT_PAYLOAD_VERSION=13 + +expect_rejected() { + if bash "$tmp/runtime/launch.sh" > "$tmp/log" 2>&1; then + echo 'incompatible tooling accepted' >&2 + exit 1 + fi + [[ ! -e "$START_MARKER" ]] +} + +expect_rejected +for metadata in \ + '{"protocol":0,"agent_id":"gator","payload_version":13}' \ + '{"protocol":1,"agent_id":"other","payload_version":13}' \ + '{"protocol":1,"agent_id":"gator","payload_version":12}' \ + 'invalid json'; do + printf '%s\n' "$metadata" > "$tmp/tooling.json" + expect_rejected +done +echo 'ok - legacy, malformed, wrong-agent, and wrong-version images fail before entrypoint' + +printf '%s\n' '{"protocol":1,"agent_id":"gator","payload_version":13}' > "$tmp/tooling.json" +bash "$tmp/runtime/launch.sh" +[[ -e "$START_MARKER" ]] +echo 'ok - matching tooling starts the entrypoint'