mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-02 07:34:45 +08:00
* feat(cli): promote profile commands to top level Add profile discovery and management commands with shared handlers for the existing provider entry points. List a flat catalog across scopes and follow continuation tokens through full and short pages. Describe metadata, credentials, endpoints, TLS inspection, and MCP access settings while preserving complete JSON/YAML definitions. Cover parser equivalence, scope forwarding, pagination, and inspection settings with focused unit and compiled-CLI integration tests. Update docs, public skills, examples, and E2E command invocations. Refs #2588 Signed-off-by: Shiju <shiju@nvidia.com> * fix(cli): remove redundant workspace selector qualification Use the imported WorkspaceSelector in the provider integration helper so the target passes Clippy with warnings denied. Signed-off-by: Shiju <shiju@nvidia.com> --------- Signed-off-by: Shiju <shiju@nvidia.com>
838 lines
31 KiB
Bash
Executable File
838 lines
31 KiB
Bash
Executable File
#!/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 <name-or-path> [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
|