#!/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)" ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" OPENSHELL_BIN="${OPENSHELL_BIN:-openshell}" AGENT_ARG="${OPENSHELL_AGENT_DIR:-}" GATEWAY_OVERRIDE="" SANDBOX_NAME_OVERRIDE="" SANDBOX_FROM_OVERRIDE="" HARNESS_OVERRIDE="${GATOR_HARNESS:-}" GITHUB_PROVIDER_OVERRIDE="${GATOR_GITHUB_PROVIDER:-}" CODEX_PROVIDER_OVERRIDE="${GATOR_CODEX_PROVIDER:-}" CODEX_PROVIDER_PROFILE_OVERRIDE="${GATOR_CODEX_PROVIDER_PROFILE:-}" CODEX_ACCESS_KEY_OVERRIDE="${GATOR_CODEX_ACCESS_CREDENTIAL_KEY:-}" CODEX_LOCAL_BIN="${GATOR_CODEX_LOCAL_BIN:-}" RUN_MODE_OVERRIDE="${OPENSHELL_AGENT_RUN_MODE:-}" POLL_INTERVAL_OVERRIDE="${OPENSHELL_AGENT_POLL_INTERVAL_SECONDS:-}" MAX_TRANSIENT_FAILURES_OVERRIDE="${OPENSHELL_AGENT_MAX_TRANSIENT_FAILURES:-}" RESET_REFRESH="${OPENSHELL_AGENT_RESET_REFRESH:-0}" KEEP_SANDBOX=0 usage() { printf '%s\n' 'Usage: scripts/agents/run.sh --agent [options] "agent prompt"' cat <<'EOF' Options: --agent NAME|PATH Agent manifest directory or name under scripts/agents/ --gateway NAME Gateway name to use --name NAME Sandbox name --from DOCKERFILE|DIR Local Dockerfile source for the sandbox image --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 --codex-access-key KEY Override the Codex access-token credential key --codex-bin PATH Upload this Codex executable into the sandbox --once Run one bounded agent cycle --watch Keep the sandbox alive and re-run bounded cycles --poll-interval SECONDS Sleep duration between watch cycles --reset-refresh Replace gateway-owned refresh material from host auth before rotating --keep Keep the sandbox after the harness exits -h, --help Show this help EOF } fail() { echo "error: $*" >&2 exit 1 } log() { echo "openshell-agent-launcher: $*" >&2 } require_cmd() { command -v "$1" >/dev/null 2>&1 || fail "missing required command: $1" } while [[ $# -gt 0 ]]; do case "$1" in --agent) [[ $# -ge 2 ]] || fail "--agent requires a value" AGENT_ARG="$2" shift 2 ;; --gateway) [[ $# -ge 2 ]] || fail "--gateway requires a value" GATEWAY_OVERRIDE="$2" shift 2 ;; --name) [[ $# -ge 2 ]] || fail "--name requires a value" SANDBOX_NAME_OVERRIDE="$2" shift 2 ;; --from) [[ $# -ge 2 ]] || fail "--from requires a value" SANDBOX_FROM_OVERRIDE="$2" shift 2 ;; --harness) [[ $# -ge 2 ]] || fail "--harness requires a value" HARNESS_OVERRIDE="$2" shift 2 ;; --github-provider) [[ $# -ge 2 ]] || fail "--github-provider requires a value" GITHUB_PROVIDER_OVERRIDE="$2" shift 2 ;; --codex-provider) [[ $# -ge 2 ]] || fail "--codex-provider requires a value" CODEX_PROVIDER_OVERRIDE="$2" shift 2 ;; --codex-access-key) [[ $# -ge 2 ]] || fail "--codex-access-key requires a value" CODEX_ACCESS_KEY_OVERRIDE="$2" shift 2 ;; --codex-bin) [[ $# -ge 2 ]] || fail "--codex-bin requires a value" CODEX_LOCAL_BIN="$2" shift 2 ;; --once) RUN_MODE_OVERRIDE="once" shift ;; --watch) RUN_MODE_OVERRIDE="watch" shift ;; --poll-interval) [[ $# -ge 2 ]] || fail "--poll-interval requires a value" POLL_INTERVAL_OVERRIDE="$2" shift 2 ;; --reset-refresh) RESET_REFRESH=1 shift ;; --keep) KEEP_SANDBOX=1 shift ;; -h|--help) usage exit 0 ;; --) shift break ;; -* ) fail "unknown option: $1" ;; *) break ;; esac done [[ -n "$AGENT_ARG" ]] || { usage >&2; exit 2; } [[ $# -gt 0 ]] || { usage >&2; exit 2; } USER_PROMPT="$*" case "$AGENT_ARG" in /*|*/*) AGENT_DIR="$AGENT_ARG" ;; *) AGENT_DIR="$SCRIPT_DIR/$AGENT_ARG" ;; esac [[ -d "$AGENT_DIR" ]] || fail "missing agent directory: $AGENT_DIR" AGENT_DIR="$(cd "$AGENT_DIR" && pwd)" MANIFEST_FILE="$AGENT_DIR/agent.yaml" [[ -f "$MANIFEST_FILE" ]] || fail "missing agent manifest: $MANIFEST_FILE" require_cmd ruby require_cmd "$OPENSHELL_BIN" CONFIG_FILE="$(mktemp "${TMPDIR:-/tmp}/openshell-agent-config.XXXXXX")" cleanup_config() { rm -f "$CONFIG_FILE" } trap cleanup_config EXIT ruby -ryaml -rshellwords - "$MANIFEST_FILE" "$HARNESS_OVERRIDE" >"$CONFIG_FILE" <<'RUBY' manifest = YAML.load_file(ARGV[0]) || {} harness = ARGV[1].to_s.empty? ? manifest.dig("harness", "default").to_s : ARGV[1].to_s supported = manifest.dig("harness", "supported") || {} abort "unsupported harness: #{harness} (supported: #{supported.keys.join(', ')})" unless supported.key?(harness) def sh(value) Shellwords.escape(value.to_s) end def emit(name, value) puts "#{name}=#{sh(value)}" end def emit_array(name, values) puts "#{name}=(#{values.map { |value| sh(value) }.join(' ')})" end harness_config = supported[harness] || {} emit "AGENT_ID", manifest.fetch("id") emit "AGENT_PAYLOAD_VERSION", manifest.fetch("payload_version", 1) emit "AGENT_DISPLAY_NAME", manifest.fetch("display_name", manifest.fetch("id")) emit "HARNESS", harness emit "HARNESS_MODEL", harness_config.fetch("model", "") emit "HARNESS_REASONING", harness_config.fetch("reasoning", "") emit "SANDBOX_NAME_PREFIX", manifest.dig("sandbox", "name_prefix") || manifest.fetch("id") emit "SANDBOX_FROM_DEFAULT", manifest.dig("sandbox", "from") || "agent://." emit "GATEWAY_DEFAULT", manifest.dig("sandbox", "gateway") || "docker-dev" emit "PROMPT_TEMPLATE", manifest.fetch("prompt_template") emit_array "PROFILE_PATHS", manifest.fetch("profile_paths", []) runtime = manifest.fetch("runtime", {}) emit "RUNTIME_MODE", runtime.fetch("mode", "once") emit "RUNTIME_POLL_INTERVAL_SECONDS", runtime.fetch("poll_interval_seconds", 900) emit "RUNTIME_MAX_TRANSIENT_FAILURES", runtime.fetch("max_transient_failures", 5) settings = manifest.fetch("settings", []) emit "SETTING_COUNT", settings.length settings.each_with_index do |setting, index| emit "SETTING_#{index}_KEY", setting.fetch("key") emit "SETTING_#{index}_VALUE", setting.fetch("value") end providers = manifest.fetch("providers", []).select do |provider| provider["harness"].nil? || provider["harness"] == harness end emit "PROVIDER_COUNT", providers.length providers.each_with_index do |provider, index| emit "PROVIDER_#{index}_ID", provider.fetch("id") emit "PROVIDER_#{index}_NAME", provider.fetch("name") emit "PROVIDER_#{index}_PROFILE", provider.fetch("profile") emit "PROVIDER_#{index}_CREDENTIAL_MODE", provider.fetch("credential_mode", "explicit") credentials = provider.fetch("credentials", []) emit "PROVIDER_#{index}_CREDENTIAL_COUNT", credentials.length credentials.each_with_index do |credential, credential_index| source = credential.fetch("source", {}) prefix = "PROVIDER_#{index}_CREDENTIAL_#{credential_index}" emit "#{prefix}_ENV", credential.fetch("env") emit "#{prefix}_EXPORT", credential.fetch("export", true) emit "#{prefix}_KIND", source.fetch("kind", "value") emit "#{prefix}_COMMAND", source.fetch("command", "") emit "#{prefix}_PATH", source.fetch("path", "") emit "#{prefix}_QUERY", source.fetch("query", "") emit "#{prefix}_VALUE", source.fetch("value", "") end refresh = provider["refresh"] || {} emit "PROVIDER_#{index}_REFRESH_ENABLED", refresh.empty? ? "false" : "true" emit "PROVIDER_#{index}_REFRESH_CREDENTIAL_KEY", refresh.fetch("credential_key", "") emit "PROVIDER_#{index}_REFRESH_STRATEGY", refresh.fetch("strategy", "") materials = refresh.fetch("materials", []) emit "PROVIDER_#{index}_REFRESH_MATERIAL_COUNT", materials.length materials.each_with_index do |material, material_index| source = material.fetch("source", {}) prefix = "PROVIDER_#{index}_REFRESH_MATERIAL_#{material_index}" emit "#{prefix}_NAME", material.fetch("name") emit "#{prefix}_SECRET", material.fetch("secret", false) emit "#{prefix}_KIND", source.fetch("kind", material.key?("value") ? "value" : "") emit "#{prefix}_COMMAND", source.fetch("command", "") emit "#{prefix}_PATH", source.fetch("path", "") emit "#{prefix}_QUERY", source.fetch("query", "") emit "#{prefix}_VALUE", material.fetch("value", source.fetch("value", "")) end end uploads = [] manifest.fetch("skills", []).each do |skill| uploads << [skill.fetch("source"), skill.fetch("destination")] end manifest.fetch("subagents", []).each do |subagent| uploads << [subagent.fetch("source"), subagent.fetch("destination")] end manifest.fetch("resources", []).each do |resource| uploads << [resource.fetch("source"), resource.fetch("destination")] end emit "UPLOAD_COUNT", uploads.length uploads.each_with_index do |(source, destination), index| emit "UPLOAD_#{index}_SOURCE", source emit "UPLOAD_#{index}_DESTINATION", destination end RUBY # shellcheck source=/dev/null source "$CONFIG_FILE" set_var() { printf -v "$1" '%s' "$2" } resolve_manifest_path() { local path="$1" case "$path" in repo://*) printf '%s/%s' "$ROOT_DIR" "${path#repo://}" ;; agent://*) printf '%s/%s' "$AGENT_DIR" "${path#agent://}" ;; /*) printf '%s' "$path" ;; *) printf '%s/%s' "$AGENT_DIR" "$path" ;; esac } expand_home_path() { local path="$1" case "$path" in \~) printf '%s' "$HOME" ;; \~/*) printf '%s/%s' "$HOME" "${path#\~/}" ;; *) printf '%s' "$path" ;; esac } openshell_cmd() { "$OPENSHELL_BIN" --gateway "$GATEWAY" "$@" } upsert_provider() { local name="$1" local type="$2" shift 2 if openshell_cmd provider get "$name" >/dev/null 2>&1; then openshell_cmd provider update "$name" "$@" >/dev/null else openshell_cmd provider create --name "$name" --type "$type" "$@" >/dev/null fi } provider_exists() { openshell_cmd provider get "$1" >/dev/null 2>&1 } import_provider_profile() { local profile_id="$1" local profile_file="$2" local import_output current_profile resource_version update_dir update_file openshell_cmd profile delete "$profile_id" >/dev/null 2>&1 || true if import_output="$(openshell_cmd profile import --file "$profile_file" 2>&1)"; then return 0 fi if [[ "$import_output" == *"already exists"* ]]; then if ! current_profile="$(openshell_cmd profile export \ --output json "$profile_id")"; then echo "failed to export existing provider profile: $profile_id" >&2 return 1 fi resource_version="$(printf '%s' "$current_profile" | \ jq -r '.resource_version // 0')" [[ "$resource_version" =~ ^[1-9][0-9]*$ ]] || { echo "existing provider profile has no resource version: $profile_id" >&2 return 1 } update_dir="$(mktemp -d "${TMPDIR:-/tmp}/openshell-provider-profile-XXXXXX")" update_file="$update_dir/profile.yaml" ruby -ryaml - "$profile_file" "$resource_version" "$update_file" <<'RUBY' profile_file, resource_version, update_file = ARGV profile = YAML.load_file(profile_file) || {} profile["resource_version"] = Integer(resource_version, 10) File.write(update_file, YAML.dump(profile)) RUBY if openshell_cmd profile update "$profile_id" \ --file "$update_file" >/dev/null; then rm -f "$update_file" rmdir "$update_dir" echo "Updated provider profile: $profile_file" return 0 fi rm -f "$update_file" rmdir "$update_dir" echo "failed to update existing provider profile: $profile_id" >&2 return 1 fi printf '%s\n' "$import_output" >&2 return 1 } resolve_profile_file() { local profile_id="$1" ruby -ryaml - "$MANIFEST_FILE" "$ROOT_DIR" "$AGENT_DIR" "$profile_id" <<'RUBY' manifest_path, root_dir, agent_dir, profile_id = ARGV manifest = YAML.load_file(manifest_path) || {} def resolve(path, root_dir, agent_dir) case path.to_s when /^repo:\/\// then File.expand_path(path.delete_prefix("repo://"), root_dir) when /^agent:\/\// then File.expand_path(path.delete_prefix("agent://"), agent_dir) when /^\// then path else File.expand_path(path, agent_dir) end end selected = nil manifest.fetch("profile_paths", []).each do |raw_path| dir = resolve(raw_path, root_dir, agent_dir) next unless File.directory?(dir) ids = {} Dir.glob(File.join(dir, "*.{yaml,yml}")).sort.each do |file| data = YAML.load_file(file) || {} id = data["id"] next if id.nil? || id.to_s.empty? if ids.key?(id) abort "duplicate provider profile id '#{id}' in #{dir}: #{ids[id]} and #{file}" end ids[id] = file rescue Psych::SyntaxError => error abort "invalid provider profile YAML #{file}: #{error.message}" end match = ids[profile_id] next unless match if selected warn "warning: provider profile #{profile_id} in #{match} is shadowed by #{selected}" else selected = match end end abort "provider profile not found in profile_paths: #{profile_id}" unless selected puts selected RUBY } resolve_source_value() { local kind="$1" local command_value="$2" local path_value="$3" local query_value="$4" local literal_value="$5" case "$kind" in host_command) bash -lc "$command_value" ;; file_json) local expanded_path expanded_path="$(expand_home_path "$path_value")" [[ -f "$expanded_path" ]] || fail "missing credential file: $expanded_path" ruby -rjson - "$expanded_path" "$query_value" <<'RUBY' path, query = ARGV value = JSON.parse(File.read(path)) query.split(".").each do |part| value = value.fetch(part) end print value.to_s RUBY ;; value) printf '%s' "$literal_value" ;; *) fail "unsupported credential source kind: $kind" ;; esac } configure_provider_refresh() { local provider_index="$1" local provider_name_var="PROVIDER_${provider_index}_NAME" local key_var="PROVIDER_${provider_index}_REFRESH_CREDENTIAL_KEY" local strategy_var="PROVIDER_${provider_index}_REFRESH_STRATEGY" local count_var="PROVIDER_${provider_index}_REFRESH_MATERIAL_COUNT" local provider_name="${!provider_name_var}" local credential_key="${!key_var}" local strategy="${!strategy_var}" local material_count="${!count_var}" local args=( provider refresh configure "$provider_name" --credential-key "$credential_key" --strategy "$strategy" ) local material_index for ((material_index = 0; material_index < material_count; material_index++)); do local prefix="PROVIDER_${provider_index}_REFRESH_MATERIAL_${material_index}" local name_var="${prefix}_NAME" local secret_var="${prefix}_SECRET" local kind_var="${prefix}_KIND" local command_var="${prefix}_COMMAND" local path_var="${prefix}_PATH" local query_var="${prefix}_QUERY" local value_var="${prefix}_VALUE" local material_name="${!name_var}" local material_value if [[ "$material_name" == "client_id" && -n "${GATOR_CODEX_OAUTH_CLIENT_ID:-}" ]]; then material_value="$GATOR_CODEX_OAUTH_CLIENT_ID" else material_value="$(resolve_source_value "${!kind_var}" "${!command_var}" "${!path_var}" "${!query_var}" "${!value_var}")" fi [[ -n "$material_value" ]] || fail "empty refresh material: $provider_name/$material_name" args+=(--material "$material_name=$material_value") if [[ "${!secret_var}" == "true" ]]; then args+=(--secret-material-key "$material_name") fi done local status_output local rotate_output status_output="$(openshell_cmd provider refresh status "$provider_name" --credential-key "$credential_key" 2>&1 || true)" if [[ "$RESET_REFRESH" != "1" && "$status_output" != *"No refresh configuration found"* ]]; then echo "Preserving existing gateway refresh state for $provider_name/$credential_key. Use --reset-refresh to replace it from host auth." else openshell_cmd "${args[@]}" >/dev/null echo "Configured gateway refresh for $provider_name/$credential_key." fi if ! rotate_output="$(openshell_cmd provider refresh rotate "$provider_name" --credential-key "$credential_key" 2>&1)"; then if [[ "$RESET_REFRESH" != "1" && "$status_output" != *"No refresh configuration found"* ]]; then echo "Gateway refresh rotation failed; resetting $provider_name/$credential_key from host auth and retrying once." >&2 openshell_cmd "${args[@]}" >/dev/null openshell_cmd provider refresh rotate "$provider_name" --credential-key "$credential_key" >/dev/null else printf '%s\n' "$rotate_output" >&2 return 1 fi fi echo "Rotated gateway refresh credential for $provider_name/$credential_key." } GATEWAY="${GATEWAY_OVERRIDE:-${GATOR_GATEWAY:-$GATEWAY_DEFAULT}}" SANDBOX_NAME="${SANDBOX_NAME_OVERRIDE:-${GATOR_SANDBOX_NAME:-$SANDBOX_NAME_PREFIX-$(date +%Y%m%d%H%M%S)}}" SANDBOX_FROM="${SANDBOX_FROM_OVERRIDE:-${GATOR_SANDBOX_FROM:-$(resolve_manifest_path "$SANDBOX_FROM_DEFAULT")}}" 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}" case "$RUN_MODE" in once|watch) ;; *) fail "unsupported runtime mode: $RUN_MODE" ;; 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" for ((provider_index = 0; provider_index < PROVIDER_COUNT; provider_index++)); do profile_var="PROVIDER_${provider_index}_PROFILE" name_var="PROVIDER_${provider_index}_NAME" refresh_key_var="PROVIDER_${provider_index}_REFRESH_CREDENTIAL_KEY" case "${!profile_var}" in github-gator) [[ -z "$GITHUB_PROVIDER_OVERRIDE" ]] || set_var "$name_var" "$GITHUB_PROVIDER_OVERRIDE" ;; codex-gator) [[ -z "$CODEX_PROVIDER_OVERRIDE" ]] || set_var "$name_var" "$CODEX_PROVIDER_OVERRIDE" [[ -z "$CODEX_PROVIDER_PROFILE_OVERRIDE" ]] || set_var "$profile_var" "$CODEX_PROVIDER_PROFILE_OVERRIDE" [[ -z "$CODEX_ACCESS_KEY_OVERRIDE" ]] || set_var "$refresh_key_var" "$CODEX_ACCESS_KEY_OVERRIDE" ;; esac done PAYLOAD_PARENT="$(mktemp -d "${TMPDIR:-/tmp}/openshell-agent.XXXXXX")" PAYLOAD_DIR="$PAYLOAD_PARENT/payload" PAYLOAD_IMAGE_DIR="/etc/openshell/agent-payload" cleanup_payload() { rm -rf "$PAYLOAD_PARENT" } trap 'cleanup_config; cleanup_payload' EXIT log "Preparing $AGENT_DISPLAY_NAME with harness '$HARNESS' in '$RUN_MODE' mode on gateway '$GATEWAY'." mkdir -p "$PAYLOAD_DIR" cp -R "$SCRIPT_DIR/runtime" "$PAYLOAD_DIR/runtime" chmod +x "$PAYLOAD_DIR/runtime"/*.sh chmod +x "$PAYLOAD_DIR/runtime/harnesses/$HARNESS"/*.sh if [[ -n "$CODEX_LOCAL_BIN" ]]; then [[ -x "$CODEX_LOCAL_BIN" ]] || fail "--codex-bin is not executable: $CODEX_LOCAL_BIN" [[ "$HARNESS" == "codex" ]] || fail "--codex-bin is only valid with --harness codex" cp "$CODEX_LOCAL_BIN" "$PAYLOAD_DIR/runtime/harnesses/codex/codex" chmod +x "$PAYLOAD_DIR/runtime/harnesses/codex/codex" fi for ((upload_index = 0; upload_index < UPLOAD_COUNT; upload_index++)); do source_var="UPLOAD_${upload_index}_SOURCE" destination_var="UPLOAD_${upload_index}_DESTINATION" source_path="$(resolve_manifest_path "${!source_var}")" destination_path="$PAYLOAD_DIR/${!destination_var}" [[ -f "$source_path" ]] || fail "missing payload source: $source_path" mkdir -p "$(dirname "$destination_path")" cp "$source_path" "$destination_path" 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 manifest = YAML.load_file(manifest_path) || {} subagents = manifest.fetch("subagents", []) subagent_command = lambda do |id| "bash #{Shellwords.escape(File.join(payload_image_dir, "runtime/subagent.sh"))} #{Shellwords.escape(id)} < task.md" 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, } subagents.each do |subagent| next unless subagent.key?("expose_as") expose_as = subagent.fetch("expose_as") 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) 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)) end File.write(output_path, rendered) RUBY prepare_immutable_sandbox_source() { local source="$1" local dockerfile local context if [[ -f "$source" ]]; then local lower_name lower_name="$(basename "$source" | tr '[:upper:]' '[:lower:]')" [[ "$lower_name" == *dockerfile* || "$lower_name" == *.dockerfile ]] || fail "immutable agent payload requires --from to be a Dockerfile path or directory: $source" dockerfile="$(cd "$(dirname "$source")" && pwd)/$(basename "$source")" context="$(cd "$(dirname "$source")" && pwd)" elif [[ -d "$source" && -f "$source/Dockerfile" ]]; then context="$(cd "$source" && pwd)" dockerfile="$context/Dockerfile" else fail "immutable agent payload requires a local Dockerfile source; --from '$source' cannot receive read-only agent guts" fi local build_context="$PAYLOAD_PARENT/build-context" mkdir -p "$build_context" ( cd "$context" tar --exclude './gator/logs' --exclude './logs' -cf - . ) | ( cd "$build_context" tar -xf - ) rm -rf "$build_context/openshell-agent-payload" mkdir -p "$build_context/openshell-agent-payload" cp -R "$PAYLOAD_DIR/." "$build_context/openshell-agent-payload/" if [[ -L "$build_context/.dockerignore" ]]; then rm -f "$build_context/.dockerignore" fi { printf '\n# OpenShell staged immutable agent payload\n' printf '!openshell-agent-payload\n' printf '!openshell-agent-payload/**\n' } >> "$build_context/.dockerignore" local rel_dockerfile rel_dockerfile="${dockerfile#$context/}" local build_dockerfile="$build_context/$rel_dockerfile" [[ -f "$build_dockerfile" ]] || fail "failed to stage Dockerfile: $rel_dockerfile" [[ ! -L "$build_dockerfile" ]] || fail "staged Dockerfile must not be a symlink: $rel_dockerfile" ruby - "$build_dockerfile" "$PAYLOAD_IMAGE_DIR" <<'RUBY' dockerfile_path, payload_image_dir = ARGV lines = File.readlines(dockerfile_path) final_stage_start = lines.rindex { |line| line.strip.start_with?("FROM ") } || 0 final_user = lines[final_stage_start..].reverse.find { |line| line.strip.start_with?("USER ") }&.strip File.open(dockerfile_path, "a") do |file| file.puts file.puts "USER root" file.puts "COPY openshell-agent-payload/ #{payload_image_dir}/" file.puts "RUN chmod -R a+rX #{payload_image_dir}" file.puts "RUN chmod -R a-w #{payload_image_dir}" file.puts final_user if final_user 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)" log "Building sandbox image '$image_tag' with $CONTAINER_ENGINE." ce_build --load --file "$build_dockerfile" --tag "$image_tag" "$build_context" SANDBOX_FROM="$image_tag" } log "Staging immutable sandbox payload from '$SANDBOX_FROM'." prepare_immutable_sandbox_source "$SANDBOX_FROM" log "Configuring gateway settings." for ((setting_index = 0; setting_index < SETTING_COUNT; setting_index++)); do key_var="SETTING_${setting_index}_KEY" value_var="SETTING_${setting_index}_VALUE" openshell_cmd settings set --global --key "${!key_var}" --value "${!value_var}" --yes >/dev/null done PROVIDER_ARGS=() for ((provider_index = 0; provider_index < PROVIDER_COUNT; provider_index++)); do name_var="PROVIDER_${provider_index}_NAME" profile_var="PROVIDER_${provider_index}_PROFILE" mode_var="PROVIDER_${provider_index}_CREDENTIAL_MODE" credential_count_var="PROVIDER_${provider_index}_CREDENTIAL_COUNT" refresh_enabled_var="PROVIDER_${provider_index}_REFRESH_ENABLED" refresh_key_var="PROVIDER_${provider_index}_REFRESH_CREDENTIAL_KEY" provider_name="${!name_var}" profile_id="${!profile_var}" credential_mode="${!mode_var}" credential_count="${!credential_count_var}" profile_file="$(resolve_profile_file "$profile_id")" log "Importing provider profile '$profile_id' for provider '$provider_name'." import_provider_profile "$profile_id" "$profile_file" credential_args=() for ((credential_index = 0; credential_index < credential_count; credential_index++)); do prefix="PROVIDER_${provider_index}_CREDENTIAL_${credential_index}" env_var="${prefix}_ENV" export_var="${prefix}_EXPORT" kind_var="${prefix}_KIND" command_var="${prefix}_COMMAND" path_var="${prefix}_PATH" query_var="${prefix}_QUERY" value_var="${prefix}_VALUE" credential_env="${!env_var}" credential_value="$(resolve_source_value "${!kind_var}" "${!command_var}" "${!path_var}" "${!query_var}" "${!value_var}")" [[ -n "$credential_value" ]] || fail "empty credential value: $provider_name/$credential_env" if [[ "${!export_var}" == "true" ]]; then export "$credential_env=$credential_value" fi if [[ "$credential_mode" == "explicit" ]]; then credential_args+=(--credential "$credential_env") fi done provider_credential_args=() case "$credential_mode" in explicit) provider_credential_args=("${credential_args[@]}") ;; from_existing) provider_credential_args=(--from-existing) ;; *) fail "unsupported credential_mode for $provider_name: $credential_mode" ;; esac if [[ "${!refresh_enabled_var}" == "true" ]] && provider_exists "$provider_name"; then if [[ "$RESET_REFRESH" == "1" ]]; then log "Resetting refresh-owned provider credential '$provider_name/${!refresh_key_var}'." openshell_cmd provider refresh delete "$provider_name" \ --credential-key "${!refresh_key_var}" >/dev/null upsert_provider "$provider_name" "$profile_id" "${provider_credential_args[@]}" else log "Reusing provider '$provider_name' because its credentials are managed by gateway refresh." fi else upsert_provider "$provider_name" "$profile_id" "${provider_credential_args[@]}" fi if [[ "${!refresh_enabled_var}" == "true" ]]; then log "Refreshing provider credential '$provider_name/${!refresh_key_var}'." configure_provider_refresh "$provider_index" fi PROVIDER_ARGS+=(--provider "$provider_name") done HARNESS_ENV_ARGS=( "OPENSHELL_AGENT_ID=$AGENT_ID" "OPENSHELL_AGENT_HARNESS=$HARNESS" "OPENSHELL_AGENT_RUN_MODE=$RUN_MODE" "OPENSHELL_AGENT_POLL_INTERVAL_SECONDS=$POLL_INTERVAL_SECONDS" "OPENSHELL_AGENT_MAX_TRANSIENT_FAILURES=$MAX_TRANSIENT_FAILURES" "OPENSHELL_AGENT_PAYLOAD_VERSION=$AGENT_PAYLOAD_VERSION" ) case "$HARNESS" in codex) HARNESS_ENV_ARGS+=( "CODEX_MODEL=${CODEX_MODEL:-$HARNESS_MODEL}" "CODEX_REASONING=${CODEX_REASONING:-$HARNESS_REASONING}" ) ;; esac SANDBOX_CREATE_CMD=( env -u OPENSHELL_SANDBOX_POLICY "$OPENSHELL_BIN" --gateway "$GATEWAY" sandbox create --name "$SANDBOX_NAME" --from "$SANDBOX_FROM" "${PROVIDER_ARGS[@]}" --no-auto-providers --no-tty --detach ) if [[ "$KEEP_SANDBOX" != "1" ]]; then SANDBOX_CREATE_CMD+=(--no-keep) fi SANDBOX_CREATE_CMD+=( -- env "${HARNESS_ENV_ARGS[@]}" bash "$PAYLOAD_IMAGE_DIR/runtime/entrypoint.sh" ) run_agent_sandbox() { "${SANDBOX_CREATE_CMD[@]}" } log "Launching $AGENT_DISPLAY_NAME sandbox '$SANDBOX_NAME' on gateway '$GATEWAY'." run_agent_sandbox