fix: replace python3 with bun/jq in shared scripts (#1697) (#1701)

* fix: replace python3 with bun/jq in shared scripts (#1697)

Replace python3 -c inline scripting with jq (preferred) and bun -e
fallbacks per project policy. Python is not a declared dependency;
jq and bun are the project's scripting runtimes.

Changes:
- shared/common.sh: Replace all 9 python3 -c calls with jq/bun -e
- shared/key-request.sh: Replace all 4 python3 -c calls with jq/bun -e
- check_python_available: Now checks for jq or bun instead of python3
- Update test expectations for JS semantics (true/false vs True/False,
  bracket access vs .get(), null handling)

Fixes #1697
Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

* fix: replace eval() with safe property access, rename check_python_available

Security: eliminate eval() from _extract_json_field() — use regex-based
bracket-notation parser to traverse JSON paths safely. The function now
extracts ['key'] and [N] segments from the expression string and
iterates through them, preventing arbitrary code execution.

Also rename check_python_available() → check_json_processor_available()
throughout the codebase (shared/common.sh, local/lib/common.sh, and
tests) since the function now checks for jq/bun, not python3.

Agent: security-auditor
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
A 2026-02-22 09:57:49 -08:00 committed by GitHub
parent 945b60317c
commit e28deca91b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 232 additions and 162 deletions

View file

@ -289,14 +289,14 @@ _extract_json_field '{"items":["first","second"]}' "d['items'][0]"
_extract_json_field '{"active":true}' "d['active']"
`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("True");
expect(result.stdout).toBe("true");
});
it("should handle null values with default", () => {
const result = runBash(`
_extract_json_field '{"ip":null}' "d['ip'] if d['ip'] else ''" "none"
_extract_json_field '{"ip":null}' "d['ip']" "none"
`);
expect(result.stdout).toBe("");
expect(result.stdout).toBe("none");
});
it("should extract from deeply nested response structures", () => {

View file

@ -325,7 +325,7 @@ describe("_extract_json_field edge cases", () => {
`_extract_json_field '{"enabled":false}' "d['enabled']" "default"`
);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("False");
expect(result.stdout).toBe("false");
});
});

View file

@ -160,7 +160,7 @@ describe("_load_json_config_fields", () => {
expect(result.exitCode).toBe(0);
const lines = result.stdout.split("\n");
expect(lines[0]).toBe("8080");
expect(lines[1]).toBe("True");
expect(lines[1]).toBe("true");
rmSync(dir, { recursive: true, force: true });
});

View file

@ -93,15 +93,15 @@ describe("_extract_json_field", () => {
_extract_json_field '{"ready": true}' "d['ready']"
`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("True");
expect(result.stdout).toBe("true");
});
it("should extract a null field", () => {
it("should extract a null field and return default", () => {
const result = runBash(`
_extract_json_field '{"value": null}' "d['value']"
_extract_json_field '{"value": null}' "d['value']" "fallback"
`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("None");
expect(result.stdout).toBe("fallback");
});
});
@ -147,18 +147,18 @@ describe("_extract_json_field", () => {
});
});
describe("complex Python expressions", () => {
it("should support .get() with default", () => {
describe("complex JS expressions", () => {
it("should support bracket access for existing key", () => {
const result = runBash(`
_extract_json_field '{"status": "active"}' "d.get('status', 'unknown')"
_extract_json_field '{"status": "active"}' "d['status']"
`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("active");
});
it("should support .get() default when key missing", () => {
it("should return default when key missing", () => {
const result = runBash(`
_extract_json_field '{"other": 1}' "d.get('status', 'unknown')"
_extract_json_field '{"other": 1}' "d['status']" "unknown"
`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("unknown");
@ -258,7 +258,7 @@ describe("_extract_json_field", () => {
it("should handle empty JSON object", () => {
const result = runBash(`
_extract_json_field '{}' "d.get('key', 'empty')"
_extract_json_field '{}' "d['key']" "empty"
`);
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe("empty");

View file

@ -11,7 +11,7 @@ import { tmpdir } from "os";
* pervasively across all cloud provider scripts:
* - log_step: progress messages (cyan), added in PR #757
* - _log_diagnostic: structured error output (header + causes + fixes)
* - check_python_available: Python 3 dependency check
* - check_json_processor_available: JSON processor (jq/bun) dependency check
* - find_node_runtime: bun/node detection
* - track_temp_file + cleanup_temp_files: secure credential temp file cleanup
* - get_cloud_init_userdata: cloud-init YAML generation for all providers
@ -179,34 +179,34 @@ describe("_log_diagnostic", () => {
});
});
// ── check_python_available ──────────────────────────────────────────────────
// ── check_json_processor_available ──────────────────────────────────────────────────
describe("check_python_available", () => {
describe("check_json_processor_available", () => {
it("should return 0 when python3 is available", () => {
const result = runBash("check_python_available");
const result = runBash("check_json_processor_available");
expect(result.exitCode).toBe(0);
});
it("should return 1 when python3 is not in PATH", () => {
const result = runBash("check_python_available", { PATH: "/nonexistent" });
const result = runBash("check_json_processor_available", { PATH: "/nonexistent" });
expect(result.exitCode).toBe(1);
});
it("should show install instructions when python3 is missing", () => {
// Override command to simulate python3 not found (can't restrict PATH since sourcing needs it)
it("should show install instructions when jq and bun are missing", () => {
// Override command to simulate jq and bun not found (can't restrict PATH since sourcing needs it)
const result = runBash(`
command() { if [[ "$2" == "python3" ]]; then return 1; fi; builtin command "$@"; }
check_python_available
command() { if [[ "$2" == "jq" || "$2" == "bun" ]]; then return 1; fi; builtin command "$@"; }
check_json_processor_available
`);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Python 3 is required");
expect(result.stderr).toContain("Install Python 3:");
expect(result.stderr).toContain("jq or bun is required");
expect(result.stderr).toContain("Install jq:");
});
it("should mention Ubuntu, Fedora, macOS, and Arch install options", () => {
const result = runBash(`
command() { if [[ "$2" == "python3" ]]; then return 1; fi; builtin command "$@"; }
check_python_available
command() { if [[ "$2" == "jq" || "$2" == "bun" ]]; then return 1; fi; builtin command "$@"; }
check_json_processor_available
`);
expect(result.exitCode).toBe(1);
expect(result.stderr).toContain("Ubuntu/Debian");

View file

@ -579,7 +579,7 @@ mock_api() {
}
INSTANCE_STATUS_POLL_DELAY=0
generic_wait_for_instance mock_api "/x/1" "ready" \\
"d['x']['status']" "d['x'].get('ip','')" \\
"d['x']['status']" "d['x']['ip']" \\
X_IP "Droplet" 2
`);
expect(stderr).toContain("Check your cloud dashboard");
@ -604,7 +604,7 @@ mock_api() {
}
INSTANCE_STATUS_POLL_DELAY=0
generic_wait_for_instance mock_api "/s/1" "running" \\
"d['s']" "d.get('ip','')" \\
"d['s']" "d['ip']" \\
S_IP "Server" 5
`);
expect(exitCode).toBe(0);

View file

@ -357,12 +357,12 @@ describe("_multi_creds_validate", () => {
});
// ============================================================================
// check_python_available
// check_json_processor_available
// ============================================================================
describe("check_python_available", () => {
describe("check_json_processor_available", () => {
it("should return 0 when python3 is available", () => {
const result = runBash("check_python_available");
const result = runBash("check_json_processor_available");
// python3 should be available in CI/test environment
expect(result.exitCode).toBe(0);
});
@ -371,20 +371,20 @@ describe("check_python_available", () => {
const result = runBash(`
PATH=/nonexistent
hash -r
check_python_available 2>/dev/null
check_json_processor_available 2>/dev/null
`);
expect(result.exitCode).toBe(1);
});
it("should show installation instructions when python3 is missing", () => {
it("should show installation instructions when jq and bun are missing", () => {
const result = runBash(`
PATH=/nonexistent
hash -r
check_python_available 2>&1
check_json_processor_available 2>&1
`);
expect(result.stdout).toContain("Python 3 is required");
expect(result.stdout).toContain("jq or bun is required");
expect(result.stdout).toContain("sudo apt-get");
expect(result.stdout).toContain("brew install python3");
expect(result.stdout).toContain("brew install jq");
});
});

View file

@ -113,29 +113,29 @@ POLL_INTERVAL="${SPAWN_POLL_INTERVAL:-1}"
# Dependency checks
# ============================================================
# Check if Python 3 is available (required for JSON parsing throughout Spawn)
check_python_available() {
if ! command -v python3 &> /dev/null; then
log_error "Python 3 is required but not installed"
log_error ""
log_error "Spawn uses Python 3 for JSON parsing and API interactions."
log_error ""
printf '%b\n' "${YELLOW}Install Python 3:${NC}" >&2
log_error " ${CYAN}# Ubuntu/Debian${NC}"
log_error " sudo apt-get update && sudo apt-get install -y python3"
log_error ""
log_error " ${CYAN}# Fedora/RHEL${NC}"
log_error " sudo dnf install -y python3"
log_error ""
log_error " ${CYAN}# macOS${NC}"
log_error " brew install python3"
log_error ""
log_error " ${CYAN}# Arch Linux${NC}"
log_error " sudo pacman -S python"
log_error ""
return 1
# Check if a JSON processor is available (jq or bun, required for JSON parsing throughout Spawn)
check_json_processor_available() {
if command -v jq &>/dev/null || command -v bun &>/dev/null; then
return 0
fi
return 0
log_error "jq or bun is required but neither is installed"
log_error ""
log_error "Spawn uses jq (or bun) for JSON parsing and API interactions."
log_error ""
printf '%b\n' "${YELLOW}Install jq:${NC}" >&2
log_error " ${CYAN}# Ubuntu/Debian${NC}"
log_error " sudo apt-get update && sudo apt-get install -y jq"
log_error ""
log_error " ${CYAN}# Fedora/RHEL${NC}"
log_error " sudo dnf install -y jq"
log_error ""
log_error " ${CYAN}# macOS${NC}"
log_error " brew install jq"
log_error ""
log_error " ${CYAN}# Arch Linux${NC}"
log_error " sudo pacman -S jq"
log_error ""
return 1
}
# Install jq if not already present (required by some cloud providers)
@ -344,7 +344,6 @@ verify_openrouter_model() {
if [[ "${model_id}" == "openrouter/auto" || "${model_id}" == "openrouter/free" ]]; then return 0; fi
if [[ -n "${SPAWN_SKIP_API_VALIDATION:-}" || "${BUN_ENV:-}" == "test" || "${NODE_ENV:-}" == "test" ]]; then return 0; fi
if ! command -v curl &>/dev/null; then return 0; fi
if ! command -v python3 &>/dev/null; then return 0; fi
local models_json
models_json=$(curl -s --connect-timeout 5 --max-time 15 \
@ -352,14 +351,23 @@ verify_openrouter_model() {
# Extract model IDs and check for exact match
local found
found=$(printf '%s' "${models_json}" | python3 -c "
import sys, json
try:
data = json.load(sys.stdin)
ids = [m['id'] for m in data.get('data', [])]
print('yes' if sys.argv[1] in ids else 'no')
except: print('skip')
" "${model_id}" 2>/dev/null)
if command -v jq &>/dev/null; then
if printf '%s' "${models_json}" | jq -e '.data[].id' 2>/dev/null | grep -qFx "\"${model_id}\""; then
found="yes"
else
found="no"
fi
elif command -v bun &>/dev/null; then
found=$(_INPUT="${models_json}" _MODEL="${model_id}" bun -e "
try {
const d = JSON.parse(process.env._INPUT);
const ids = (d.data || []).map(m => m.id);
process.stdout.write(ids.includes(process.env._MODEL) ? 'yes' : 'no');
} catch { process.stdout.write('skip'); }
" 2>/dev/null)
else
found="skip"
fi
if [[ "${found}" == "no" ]]; then
log_warn "Model '${model_id}' not found on OpenRouter"
@ -877,12 +885,12 @@ wait_for_oauth_code() {
log_step "Waiting for authentication in browser (this usually takes 10-30 seconds, timeout: ${timeout}s)..."
while [[ ! -f "${code_file}" ]] && [[ ${elapsed} -lt ${timeout} ]]; do
sleep "${POLL_INTERVAL}"
# Use python3 for float addition since bash arithmetic only handles integers
# Use bun for float addition since bash arithmetic only handles integers
# If POLL_INTERVAL is 0.5, bash $(( )) would fail. Fallback keeps timeout working.
if command -v python3 &>/dev/null; then
elapsed=$(python3 -c "print(int(${elapsed} + ${POLL_INTERVAL}))" 2>/dev/null || echo "$((elapsed + 1))")
if command -v bun &>/dev/null; then
elapsed=$(_E="${elapsed}" _P="${POLL_INTERVAL}" bun -e "process.stdout.write(String(Math.floor(Number(process.env._E) + Number(process.env._P))))" 2>/dev/null || echo "$((elapsed + 1))")
else
# No python3 available - fall back to integer seconds (may timeout early with fractional POLL_INTERVAL)
# No bun available - fall back to integer seconds (may timeout early with fractional POLL_INTERVAL)
elapsed=$((elapsed + 1))
fi
done
@ -1926,7 +1934,7 @@ get_ssh_fingerprint() {
# Usage: json_escape STRING
json_escape() {
local string="${1}"
python3 -c "import json, sys; print(json.dumps(sys.stdin.read().rstrip('\n')))" <<< "${string}" 2>/dev/null || {
_INPUT="${string}" bun -e "process.stdout.write(JSON.stringify(process.env._INPUT) + '\n')" 2>/dev/null || {
# Fallback: manually escape backslashes, quotes, and JSON control characters
local escaped="${string//\\/\\\\}"
escaped="${escaped//\"/\\\"}"
@ -1943,18 +1951,23 @@ json_escape() {
extract_ssh_key_ids() {
local api_response="${1}"
local key_field="${2:-ssh_keys}"
# SECURITY: Pass key_field via sys.argv to prevent Python code injection.
# Previously interpolated directly into Python source as '${key_field}'.
python3 -c "
import json, sys
data = json.loads(sys.stdin.read())
ids = [k['id'] for k in data.get(sys.argv[1], [])]
print(json.dumps(ids))
" "${key_field}" <<< "${api_response}" 2>/dev/null || {
log_error "Failed to parse SSH key IDs from API response"
log_error "The API response may be malformed or python3 is unavailable"
return 1
}
# Use jq with --arg to safely pass key_field (prevents code injection).
if command -v jq &>/dev/null; then
printf '%s' "${api_response}" | jq --arg field "${key_field}" '[.[$field][]?.id]' 2>/dev/null || {
log_error "Failed to parse SSH key IDs from API response"
return 1
}
else
_DATA="${api_response}" _FIELD="${key_field}" bun -e "
const d = JSON.parse(process.env._DATA);
const ids = (d[process.env._FIELD] || []).map(k => k.id);
process.stdout.write(JSON.stringify(ids) + '\n');
" 2>/dev/null || {
log_error "Failed to parse SSH key IDs from API response"
log_error "The API response may be malformed or bun is unavailable"
return 1
}
fi
}
# ============================================================
@ -2018,8 +2031,8 @@ calculate_retry_backoff() {
fi
# Add jitter: ±20% randomization to prevent thundering herd
# Fallback to no-jitter interval if python3 is unavailable
python3 -c "import random; print(int(${interval} * (0.8 + random.random() * 0.4)))" 2>/dev/null || printf '%s' "${interval}"
# Fallback to no-jitter interval if bun is unavailable
_INTERVAL="${interval}" bun -e "process.stdout.write(String(Math.floor(Number(process.env._INTERVAL) * (0.8 + Math.random() * 0.4))) + '\n')" 2>/dev/null || printf '%s\n' "${interval}"
}
# Handle API retry decision with backoff - extracted to reduce duplication across API wrappers
@ -2598,16 +2611,42 @@ ssh_verify_connectivity() {
generic_ssh_wait "${SSH_USER:-root}" "${ip}" "$SSH_OPTS -o ConnectTimeout=5" "echo ok" "SSH connectivity" "${max_attempts}" "${initial_interval}"
}
# Extract a value from a JSON response using a Python expression
# Usage: _extract_json_field JSON_STRING PYTHON_EXPR [DEFAULT]
# The Python expression receives 'd' as the parsed JSON dict.
# Extract a value from a JSON response using bracket-notation path
# Usage: _extract_json_field JSON_STRING JS_EXPR [DEFAULT]
# The JS expression uses bracket access syntax: d['key1']['key2'][0]
# Returns DEFAULT (or empty string) on parse failure.
_extract_json_field() {
local json="${1}"
local py_expr="${2}"
local js_expr="${2}"
local default="${3:-}"
printf '%s' "${json}" | python3 -c "import json,sys; d=json.loads(sys.stdin.read()); print(${py_expr})" 2>/dev/null || echo "${default}"
_DATA="${json}" _EXPR="${js_expr}" bun -e "
try {
const d = JSON.parse(process.env._DATA);
const expr = process.env._EXPR || '';
// Parse bracket-notation path: d['key1']['key2'][0]
// Extract segments from ['...'] or [N] patterns
const segments = [];
const re = /\[(\d+|'[^']*'|\"[^\"]*\")\]/g;
let m;
while ((m = re.exec(expr)) !== null) {
let key = m[1];
if ((key.startsWith(\"'\") && key.endsWith(\"'\")) || (key.startsWith('\"') && key.endsWith('\"'))) {
key = key.slice(1, -1);
} else {
key = Number(key);
}
segments.push(key);
}
let result = d;
for (const seg of segments) {
if (result === null || result === undefined) { process.exit(1); }
result = result[seg];
}
if (result !== undefined && result !== null) process.stdout.write(String(result) + '\n');
else process.exit(1);
} catch { process.exit(1); }
" 2>/dev/null || echo "${default}"
}
# Extract an error message from a JSON API response.
@ -2619,24 +2658,19 @@ extract_api_error_message() {
local json="${1}"
local fallback="${2:-Unknown error}"
printf '%s' "${json}" | python3 -c "
import json, sys
try:
d = json.loads(sys.stdin.read())
e = d.get('error', '')
msg = (
(isinstance(e, dict) and (e.get('message') or e.get('error_message')))
or d.get('message')
or d.get('reason')
or (isinstance(e, str) and e)
or ''
)
if msg:
print(msg)
else:
sys.exit(1)
except:
sys.exit(1)
_DATA="${json}" bun -e "
try {
const d = JSON.parse(process.env._DATA);
const e = d.error || '';
const msg =
(typeof e === 'object' && e !== null && (e.message || e.error_message)) ||
d.message ||
d.reason ||
(typeof e === 'string' && e) ||
'';
if (msg) process.stdout.write(String(msg) + '\n');
else process.exit(1);
} catch { process.exit(1); }
" 2>/dev/null || echo "${fallback}"
}
@ -2778,7 +2812,15 @@ _load_token_from_config() {
fi
local saved_token
saved_token=$(python3 -c "import json, sys; data=json.load(open(sys.argv[1])); print(data.get('api_key','') or data.get('token',''))" "${config_file}" 2>/dev/null)
if command -v jq &>/dev/null; then
saved_token=$(jq -r '(if (.api_key // "" | length) > 0 then .api_key else (.token // "") end)' "${config_file}" 2>/dev/null)
else
saved_token=$(_FILE="${config_file}" bun -e "
import fs from 'fs';
const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8'));
process.stdout.write(d.api_key || d.token || '');
" 2>/dev/null)
fi
if [[ -z "${saved_token}" ]]; then
return 1
fi
@ -2882,7 +2924,7 @@ ensure_api_token_with_provider() {
local help_url="${4}"
local test_func="${5:-}"
check_python_available || return 1
check_json_processor_available || return 1
# Try environment variable (validate if test function provided)
if _load_token_from_env "${env_var_name}" "${provider_name}"; then
@ -2925,7 +2967,7 @@ ensure_api_token_with_provider() {
# Multi-credential configuration helpers
# ============================================================
# Load multiple fields from a JSON config file in a single python3 call.
# Load multiple fields from a JSON config file in a single call.
# Outputs each field value on a separate line. Returns 1 if file missing or parse fails.
# Usage: local creds; creds=$(_load_json_config_fields CONFIG_FILE field1 field2 ...)
# Then: { read -r var1; read -r var2; ... } <<< "${creds}"
@ -2933,14 +2975,22 @@ _load_json_config_fields() {
local config_file="${1}"; shift
[[ -f "${config_file}" ]] || return 1
# SECURITY: Pass field names via sys.argv to prevent Python code injection.
# Previously built Python source by interpolating field names as '${field}'.
python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
for field in sys.argv[2:]:
print(d.get(field, ''))
" "${config_file}" "$@" 2>/dev/null || return 1
if command -v jq &>/dev/null; then
# Use jq to extract each field; output one value per line
local field
for field in "$@"; do
jq -r --arg f "${field}" '.[$f] // ""' "${config_file}" 2>/dev/null || return 1
done
else
# SECURITY: Pass field names via env var to prevent code injection.
_FILE="${config_file}" _FIELDS="$(printf '%s\n' "$@")" bun -e "
import fs from 'fs';
const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8'));
for (const field of process.env._FIELDS.split('\n')) {
if (field) process.stdout.write((d[field] || '') + '\n');
}
" 2>/dev/null || return 1
fi
}
# Save key-value pairs to a JSON config file using json_escape for safe encoding.
@ -3100,7 +3150,7 @@ ensure_multi_credentials() {
local test_func="${4:-}"
shift 4
check_python_available || return 1
check_json_processor_available || return 1
# Parse credential specs into parallel arrays
local env_vars=() config_keys=() labels=()

View file

@ -2,7 +2,7 @@
# Shell helpers for API key provisioning
# Sourced by qa-cycle.sh for Phase 0 key loading and Phase 1 stale key handling
#
# Requires: python3, curl, REPO_ROOT set, log() function defined by caller
# Requires: jq or bun, curl, REPO_ROOT set, log() function defined by caller
#
# Functions:
# load_cloud_keys_from_config — Load keys from ~/.config/spawn/{cloud}.json into env
@ -23,17 +23,30 @@ fi
# Outputs one env var name per line, empty if CLI-based auth
get_cloud_env_vars() {
local cloud="${1}"
python3 -c "
import json, re, sys
m = json.load(open(sys.argv[1]))
auth = m.get('clouds', {}).get(sys.argv[2], {}).get('auth', '')
if re.search(r'\b(login|configure|setup)\b', auth, re.I):
sys.exit(0)
for var in re.split(r'\s*[+,]\s*', auth):
v = var.strip()
if v:
print(v)
" "${REPO_ROOT}/manifest.json" "${cloud}" 2>/dev/null
if command -v jq &>/dev/null; then
local auth
auth=$(jq -r --arg c "${cloud}" '.clouds[$c].auth // ""' "${REPO_ROOT}/manifest.json" 2>/dev/null) || return 0
# Skip CLI-based auth (login, configure, setup)
if printf '%s' "${auth}" | grep -qiE '\b(login|configure|setup)\b'; then
return 0
fi
# Empty auth means no env vars needed
if [[ -z "${auth}" ]]; then
return 0
fi
# Split on + or , and output each var name
printf '%s' "${auth}" | tr '+,' '\n' | sed 's/^ *//;s/ *$//' | grep -v '^$' || true
else
_MANIFEST="${REPO_ROOT}/manifest.json" _CLOUD="${cloud}" bun -e "
import fs from 'fs';
const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8'));
const auth = (m.clouds?.[process.env._CLOUD]?.auth) || '';
if (/\b(login|configure|setup)\b/i.test(auth)) process.exit(0);
for (const v of auth.split(/\s*[+,]\s*/)) {
if (v.trim()) process.stdout.write(v.trim() + '\n');
}
" 2>/dev/null
fi
}
# Parse manifest.json to extract cloud_key|auth_string lines for API-token clouds.
@ -41,17 +54,20 @@ for var in re.split(r'\s*[+,]\s*', auth):
# Outputs one "cloud_key|auth_string" per line to stdout.
_parse_cloud_auths() {
local manifest_path="${1}"
python3 -c "
import json, re, sys
manifest = json.load(open(sys.argv[1]))
for key, cloud in manifest.get('clouds', {}).items():
auth = cloud.get('auth', '')
if re.search(r'\b(login|configure|setup)\b', auth, re.I):
continue
if not auth.strip():
continue
print(key + '|' + auth)
" "${manifest_path}" 2>/dev/null
if command -v jq &>/dev/null; then
jq -r '.clouds | to_entries[] | select(.value.auth != null and .value.auth != "") | select(.value.auth | test("\\b(login|configure|setup)\\b"; "i") | not) | "\(.key)|\(.value.auth)"' "${manifest_path}" 2>/dev/null
else
_MANIFEST="${manifest_path}" bun -e "
import fs from 'fs';
const m = JSON.parse(fs.readFileSync(process.env._MANIFEST, 'utf8'));
for (const [key, cloud] of Object.entries(m.clouds || {})) {
const auth = cloud.auth || '';
if (/\b(login|configure|setup)\b/i.test(auth)) continue;
if (!auth.trim()) continue;
process.stdout.write(key + '|' + auth + '\n');
}
" 2>/dev/null
fi
}
# Try to load a single env var from config file if not already set in environment.
@ -76,12 +92,15 @@ _try_load_env_var() {
# Try loading from config file
if [[ -f "${config_file}" ]]; then
local val
val=$(python3 -c "
import json, sys
data = json.load(open(sys.argv[1]))
v = data.get(sys.argv[2], '') or data.get('api_key', '') or data.get('token', '')
print(v)
" "${config_file}" "${var_name}" 2>/dev/null)
if command -v jq &>/dev/null; then
val=$(jq -r --arg v "${var_name}" '(.[$v] // .api_key // .token) // "" | select(. != null)' "${config_file}" 2>/dev/null)
else
val=$(_FILE="${config_file}" _VAR="${var_name}" bun -e "
import fs from 'fs';
const d = JSON.parse(fs.readFileSync(process.env._FILE, 'utf8'));
process.stdout.write(d[process.env._VAR] || d.api_key || d.token || '');
" 2>/dev/null)
fi
if [[ -n "${val}" ]]; then
# SECURITY: Defense-in-depth — prevent malicious values from being misused
# downstream in unquoted expansions, eval contexts, or logging
@ -138,8 +157,8 @@ load_cloud_keys_from_config() {
return 1
fi
if ! command -v python3 &>/dev/null; then
log "Key preflight: python3 not found, skipping"
if ! command -v jq &>/dev/null && ! command -v bun &>/dev/null; then
log "Key preflight: neither jq nor bun found, skipping"
return 1
fi
@ -188,17 +207,18 @@ request_missing_cloud_keys() {
return 0 # Nothing to request
fi
if ! command -v python3 &>/dev/null; then
return 0
fi
# Build JSON array of provider names
local providers_json
providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | python3 -c "
import json, sys
providers = [line.strip() for line in sys.stdin if line.strip()]
print(json.dumps(providers))
if command -v jq &>/dev/null; then
providers_json=$(printf '%s\n' ${MISSING_KEY_PROVIDERS} | jq -Rn '[inputs | select(. != "")]' 2>/dev/null) || return 0
elif command -v bun &>/dev/null; then
providers_json=$(_PROVIDERS="${MISSING_KEY_PROVIDERS}" bun -e "
const providers = process.env._PROVIDERS.trim().split(/\s+/).filter(Boolean);
process.stdout.write(JSON.stringify(providers));
" 2>/dev/null) || return 0
else
return 0
fi
log "Key preflight: Requesting keys for: ${MISSING_KEY_PROVIDERS}"