bridge the host pi config, with the credential by env only
The image ships pi with no provider and no preferences, so a sandboxed run
meant configuring the model twice. ai-sandbox now reads the host
~/.pi/agent/{models,settings}.json and rewrites them instead of copying them:
every provider apiKey becomes "$AI_API_TOKEN" (pi resolves an env reference
itself, so no value is written to disk), '!command' header values are dropped
rather than executed, settings that point into $HOME are dropped because that
path is not mounted, and auth.json / models-store.json / sessions / extensions
are never read.
The rewritten pair is seeded into /home/sandbox/.pi/agent at container start,
owned by uid 1000 and mode 600, and is treated as managed: a host change is
re-seeded, an edit made inside the sandbox is kept and reported, a deletion
takes the host version again.
The token follows the existing environment rules -- one variable, passed by
name through the 0600 env file, announced when auto-passed. A bridged run
whose token variable is unset or empty is refused instead of starting a pi
that cannot reach its model, and the bridge never widens the allowlist: it
warns with the exact --allow to add.
Docs and examples now use a self-hosted gateway (https://aipi.reeseapps.com)
instead of vendor API endpoints, since that is the only thing this tool is
used against. 391 assertions pass (make lint, ./tests/run.sh --e2e).
LLM-Generated: true
This commit is contained in:
@@ -147,6 +147,66 @@ LOG="$(cat "$AISB_STATE_DIR/log" 2>/dev/null || true)"
|
||||
printf '%s' "$LOG" | grep -q 'net=none' && _ok "the log records the run" || _bad "the log records the run"
|
||||
printf '%s' "$LOG" | grep -q 'canary-value-42' && _bad "secrets must never reach the log" || _ok "no secrets in the log"
|
||||
|
||||
t_name "the pi config bridge lands inside the container"
|
||||
AGENT="$WORK/agent"; mkdir -p "$AGENT"
|
||||
cat > "$AGENT/models.json" <<EOF
|
||||
{ "providers": { "gateway": { "baseUrl": "https://aipi.reeseapps.com/v1",
|
||||
"api": "openai-completions", "apiKey": "!pass show gateway",
|
||||
"models": [ { "id": "turbo", "contextWindow": 262144 } ] } } }
|
||||
EOF
|
||||
printf '{ "defaultProvider": "gateway", "defaultModel": "turbo", "theme": "dark/dark" }\n' > "$AGENT/settings.json"
|
||||
printf '{ "gateway": "HOSTSECRET-must-not-travel" }' > "$AGENT/auth.json"
|
||||
export AISB_PI_AGENT_DIR="$AGENT" AISB_PI_TOKEN_ENV=AI_API_TOKEN AISB_PI_TOKEN_AUTO=1
|
||||
export AI_API_TOKEN=container-token-777
|
||||
export AISB_HOME_VOLUME=aisb-test-pihome
|
||||
|
||||
sb $DARK --harness pi -- bash -lc '
|
||||
f="$HOME/.pi/agent/models.json"
|
||||
[ -f "$f" ] && echo seeded=yes || echo seeded=no
|
||||
[ "$(id -u)" = 1000 ] || echo uid=wrong
|
||||
echo "mode=$(stat -c %a "$f" 2>/dev/null)"
|
||||
grep -q "\$AI_API_TOKEN" "$f" && echo ref=env || echo ref=other
|
||||
grep -q "pass show" "$f" && echo LEAKED_CMD || echo cmd=gone
|
||||
grep -q "HOSTSECRET" "$f" && echo LEAKED_KEY || echo key=gone
|
||||
[ -f "$HOME/.pi/agent/auth.json" ] && echo LEAKED_AUTH || echo auth=absent
|
||||
grep -q "turbo" "$HOME/.pi/agent/settings.json" && echo settings=yes
|
||||
[ -n "$AI_API_TOKEN" ] && echo token=present || echo token=missing'
|
||||
assert_contains "seeded=yes" "pi's config is seeded into the sandbox home"
|
||||
assert_contains "mode=600" "and only the sandbox user can read it"
|
||||
assert_contains "ref=env" "the credential stays an environment reference"
|
||||
assert_contains "cmd=gone" "the host '!command' did not cross"
|
||||
assert_contains "key=gone" "nor did the literal key"
|
||||
assert_contains "auth=absent" "auth.json was not bridged"
|
||||
assert_contains "settings=yes" "settings.json is in place"
|
||||
assert_contains "token=present" "and the token reaches pi by env"
|
||||
assert_absent "container-token-777" "while its value is never printed"
|
||||
assert_absent "uid=wrong" "seeded files belong to the sandbox user"
|
||||
|
||||
# an edit made inside the sandbox survives; a change on the host does not get lost
|
||||
sb $DARK --harness pi -- bash -c 'sed -i "s/turbo/juggernaut/" "$HOME/.pi/agent/models.json"; echo edited' >/dev/null
|
||||
sb $DARK --harness pi -- bash -lc 'grep -q juggernaut "$HOME/.pi/agent/models.json" && echo edit_kept || echo edit_lost'
|
||||
assert_contains "edit_kept" "a change made inside the sandbox is not clobbered"
|
||||
|
||||
sb $DARK --harness pi -- bash -lc 'grep -q juggernaut "$HOME/.pi/agent/models.json" && echo still_here' 2>/dev/null
|
||||
sed -i 's/262144/524288/' "$AGENT/models.json"
|
||||
sb $DARK --harness pi -- bash -lc '
|
||||
grep -q 524288 "$HOME/.pi/agent/models.json" && echo host_change_ignored_fully || echo host_change_partly_ignored
|
||||
grep -q juggernaut "$HOME/.pi/agent/models.json" && echo edit_still_kept'
|
||||
assert_contains "edit_still_kept" "the sandbox edit still wins over the bridged file"
|
||||
|
||||
sb $DARK --harness pi -- bash -c 'rm -f "$HOME/.pi/agent/models.json"; echo reset' >/dev/null
|
||||
sb $DARK --harness pi -- bash -lc 'grep -q 524288 "$HOME/.pi/agent/models.json" && echo reseeded || echo not_reseeded'
|
||||
assert_contains "reseeded" "deleting it inside the sandbox takes the host version again"
|
||||
|
||||
unset AI_API_TOKEN
|
||||
sb_timeout 90 $DARK --harness pi
|
||||
assert_rc 1 "a run without the credential is refused before anything starts"
|
||||
assert_contains "not going to reach the container" "and it says why"
|
||||
sb_env AI_API_TOKEN= --net none --harness pi
|
||||
assert_rc 1 "an empty token is refused too -- pi would send an empty credential"
|
||||
unset AISB_PI_AGENT_DIR AISB_PI_TOKEN_ENV AISB_PI_TOKEN_AUTO AISB_HOME_VOLUME
|
||||
sb clean --all --volumes=aisb-test-pihome > /dev/null
|
||||
|
||||
t_name "doctor"
|
||||
sb doctor
|
||||
assert_rc 0 "doctor reports no problems"
|
||||
|
||||
@@ -22,6 +22,15 @@ sb() {
|
||||
return 0
|
||||
}
|
||||
|
||||
# sb_timeout <seconds> [args...] -- like sb, but bounded. Use it when a test expects
|
||||
# the script to refuse: the refusal happens before podman, and the timeout is there in
|
||||
# case a regression turns "refused instantly" into "hung forever".
|
||||
sb_timeout() {
|
||||
local t="$1"; shift
|
||||
OUT="$(timeout "$t" "$AISB" "$@" 2>&1 </dev/null)" && RC=$? || RC=$?
|
||||
return 0
|
||||
}
|
||||
|
||||
# sb_env VAR=val ... -- args (env prefix, without leaking into the test shell)
|
||||
sb_env() {
|
||||
local -a envs=()
|
||||
|
||||
@@ -72,6 +72,10 @@ for f in "${FILES[@]}"; do
|
||||
*/unit/*) export AISB_BASE_AUTO_UPDATE="${AISB_BASE_AUTO_UPDATE:-0}" \
|
||||
AISB_BASE_PULL_FALLBACK="${AISB_BASE_PULL_FALLBACK:-0}" ;;
|
||||
esac
|
||||
# Never bridge the developer's own ~/.pi/agent into a test: tests that exercise the
|
||||
# bridge point AISB_PI_AGENT_DIR at a fixture of their own. Same for a real token.
|
||||
export AISB_PI_AGENT_DIR="${AISB_PI_AGENT_DIR:-$W/absent-pi-agent-dir}"
|
||||
unset AI_API_TOKEN AISB_PI_TOKEN_ENV AISB_PI_CONFIG AISB_PI_TOKEN_AUTO
|
||||
# NOTE: HOME is deliberately not overridden -- podman storage lives there.
|
||||
unset AISB_ALLOW AISB_ENV AISB_MOUNT AISB_INSTALL AISB_NET AISB_EXTRA_ARGS AISB_SESSION_OVERRIDE
|
||||
source "$f"
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env bash
|
||||
# tests/unit/08_pi_config.sh -- the pi configuration bridge.
|
||||
#
|
||||
# The bridge rewrites the host agent directory instead of copying it, so what matters
|
||||
# most here is what does NOT cross the boundary: the credential, host commands, host
|
||||
# paths, auth.json. Everything is preview (`--print-policy` / `--dry-run`) plus the
|
||||
# refusal paths, so no container is started and no token is real.
|
||||
source "$TESTS/lib.sh"
|
||||
|
||||
AGENT="$WORK/agent"; mkdir -p "$AGENT"
|
||||
export AISB_PI_AGENT_DIR="$AGENT" AISB_PI_TOKEN_AUTO=1
|
||||
export AI_API_TOKEN=sandbox-only-token-4242
|
||||
|
||||
CANARY_SECRET='REALSECRET-nev3r-lef7s-the-host'
|
||||
CANARY_CMD='pass show services/gateway | head -1'
|
||||
|
||||
cat > "$AGENT/models.json" <<EOF
|
||||
{
|
||||
"providers": {
|
||||
"gateway": {
|
||||
"baseUrl": "https://aipi.reeseapps.com/v1",
|
||||
"api": "openai-completions",
|
||||
"apiKey": "!$CANARY_CMD",
|
||||
"headers": { "X-Trace": "!date +%s", "X-Team": "self" },
|
||||
"models": [
|
||||
{ "id": "juggernaut", "reasoning": true, "contextWindow": 131072 },
|
||||
{ "id": "turbo", "reasoning": true, "contextWindow": 262144 }
|
||||
]
|
||||
},
|
||||
"lab-ollama": {
|
||||
"baseUrl": "http://10.20.0.7:11434/v1",
|
||||
"api": "openai-completions",
|
||||
"models": [ { "id": "small" } ]
|
||||
}
|
||||
}
|
||||
}
|
||||
EOF
|
||||
# sessionDir points at the *real* host home on purpose: that is the case the bridge
|
||||
# has to drop, and HOME must stay the real home for podman's sake.
|
||||
cat > "$AGENT/settings.json" <<EOF
|
||||
{
|
||||
"defaultProvider": "gateway",
|
||||
"defaultModel": "turbo",
|
||||
"theme": "dark/dark",
|
||||
"defaultThinkingLevel": "xhigh",
|
||||
"enableInstallTelemetry": false,
|
||||
"thinkingBudgets": { "xhigh": 65536 },
|
||||
"sessionDir": "$HOME/pi-sessions"
|
||||
}
|
||||
EOF
|
||||
printf '{ "gateway": "%s" }' "$CANARY_SECRET" > "$AGENT/auth.json"
|
||||
printf '{}' > "$AGENT/models-store.json"
|
||||
|
||||
clear_sessions() { rm -rf "${AISB_STATE_DIR:?}"/sessions/*; }
|
||||
gen_file() { find "$AISB_STATE_DIR/sessions" -path "*/pi/$1" -print -quit 2>/dev/null; }
|
||||
restore_models() { cat > "$AGENT/models.json" <<EOF
|
||||
{ "providers": { "gateway": { "baseUrl": "https://aipi.reeseapps.com/v1",
|
||||
"api": "openai-completions", "apiKey": "literal-key-never-copied",
|
||||
"models": [ { "id": "turbo" } ] } } }
|
||||
EOF
|
||||
}
|
||||
|
||||
t_name "the credential becomes an environment reference, not a copy"
|
||||
clear_sessions
|
||||
sb --keep --print-policy --net none
|
||||
assert_contains "pi_config=bridged from $AGENT" "the bridge reports where the config came from"
|
||||
assert_contains "pi_token_env=AI_API_TOKEN (set)" "and whether the token is in reach"
|
||||
M="$(gen_file models.json)"
|
||||
[[ -n "$M" ]] && _ok "models.json was generated" || _bad "models.json was generated"
|
||||
if [[ -n "$M" ]]; then
|
||||
grep -qF '"apiKey": "$AI_API_TOKEN"' "$M" && _ok "apiKey is the variable; pi resolves it at request time" || _bad "apiKey is the variable; pi resolves it at request time"
|
||||
grep -qF "$CANARY_CMD" "$M" && _bad "the host '!command' is gone" || _ok "the host '!command' is gone"
|
||||
grep -qF "$CANARY_SECRET" "$M" && _bad "the literal key is gone" || _ok "the literal key is gone"
|
||||
grep -qF 'literal-key-never-copied' "$M" && _bad "no literal key from the host file" || _ok "no literal key from the host file"
|
||||
grep -qF '"X-Team": "self"' "$M" && _ok "an ordinary header survives" || _bad "an ordinary header survives"
|
||||
grep -qF 'X-Trace' "$M" && _bad "a header built by a host command is dropped" || _ok "a header built by a host command is dropped"
|
||||
fi
|
||||
assert_contains "dropped header(s) X-Trace" "and it says which one it dropped"
|
||||
if printf '%s' "$OUT" | grep -qF "$AI_API_TOKEN"; then
|
||||
_bad "the token value never appears in the policy output"
|
||||
else
|
||||
_ok "the token value never appears in the policy output"
|
||||
fi
|
||||
|
||||
t_name "providers and models are carried over untouched"
|
||||
if [[ -n "$M" ]]; then
|
||||
grep -qF '"baseUrl": "https://aipi.reeseapps.com/v1"' "$M" && _ok "the gateway endpoint" || _bad "the gateway endpoint"
|
||||
grep -qF '"id": "juggernaut"' "$M" && grep -qF '"id": "turbo"' "$M" && _ok "both models" || _bad "both models"
|
||||
grep -qF '"contextWindow": 262144' "$M" && _ok "with their limits" || _bad "with their limits"
|
||||
grep -qF '"lab-ollama"' "$M" && _ok "and the second provider" || _bad "and the second provider"
|
||||
fi
|
||||
JSON_CHECK="$(python3 - "${M:-/nonexistent}" <<'PY'
|
||||
import json, sys
|
||||
try:
|
||||
prov = json.load(open(sys.argv[1]))["providers"]
|
||||
except Exception as e:
|
||||
print(f"invalid:{e}"); raise SystemExit
|
||||
if "apiKey" in prov.get("lab-ollama", {}):
|
||||
print("gained-a-key")
|
||||
elif prov["gateway"]["apiKey"] != "$AI_API_TOKEN":
|
||||
print("wrong-key-ref")
|
||||
else:
|
||||
print("json-valid")
|
||||
PY
|
||||
)"
|
||||
assert_eq "json-valid" "$JSON_CHECK" "valid JSON, and a provider without a key does not gain one"
|
||||
assert_contains "pi_endpoints=10.20.0.7 aipi.reeseapps.com" "both endpoints are reported"
|
||||
|
||||
t_name "settings travel, host paths do not"
|
||||
S="$(gen_file settings.json)"
|
||||
if [[ -n "$S" ]]; then
|
||||
for pair in '"defaultProvider": "gateway"' '"defaultModel": "turbo"' '"theme": "dark/dark"' \
|
||||
'"defaultThinkingLevel": "xhigh"' '"xhigh": 65536' '"enableInstallTelemetry": false'; do
|
||||
grep -qF "$pair" "$S" && _ok "kept $pair" || _bad "kept $pair"
|
||||
done
|
||||
grep -qF 'sessionDir' "$S" && _bad "a value pointing at the host home is dropped" || _ok "a value pointing at the host home is dropped"
|
||||
fi
|
||||
assert_contains "dropped settings sessionDir" "and it says so"
|
||||
|
||||
t_name "auth.json and the credential store are never bridged"
|
||||
if find "$AISB_STATE_DIR/sessions" \( -name auth.json -o -name models-store.json \) | grep -q .; then
|
||||
_bad "only models.json and settings.json are bridged"
|
||||
else
|
||||
_ok "only models.json and settings.json are bridged"
|
||||
fi
|
||||
if grep -rlF "$CANARY_SECRET" "$AISB_STATE_DIR" "$AISB_CACHE_DIR" > /dev/null 2>&1; then
|
||||
_bad "the host credential appears nowhere in ai-sandbox state"
|
||||
else
|
||||
_ok "the host credential appears nowhere in ai-sandbox state"
|
||||
fi
|
||||
|
||||
t_name "the token is passed by name, once, and announced"
|
||||
assert_contains "passing \$AI_API_TOKEN from the host" "the auto-pass is announced"
|
||||
assert_eq "env_names=AI_API_TOKEN" "$(printf '%s' "$OUT" | grep '^env_names=' || true)" "exactly one variable is passed"
|
||||
assert_absent "$AI_API_TOKEN" "and only its name is reported"
|
||||
|
||||
t_name "--no-pi-token-env leaves it out and says so"
|
||||
clear_sessions
|
||||
sb --keep --no-pi-token-env --print-policy --net none
|
||||
assert_eq "env_names=" "$(printf '%s' "$OUT" | grep '^env_names=' || true)" "nothing is passed"
|
||||
assert_contains "will not reach the container" "preview warns instead of launching a pi with no credential"
|
||||
|
||||
t_name "a bridged provider with no credential in reach is refused, not attempted"
|
||||
unset AI_API_TOKEN
|
||||
sb --net none --harness pi
|
||||
assert_rc 1 "the run refuses to start"
|
||||
assert_contains 'asks for $AI_API_TOKEN, but it is not going to reach the container' "it names the variable and why"
|
||||
assert_contains "env = AI_API_TOKEN" "it shows the config-file way"
|
||||
assert_contains "--no-pi-config" "and the way to opt out"
|
||||
assert_eq "0" "$([[ -n "$(podman ps -q --filter label=ai-sandbox=1)" ]] && echo 1 || echo 0)" "and nothing was started"
|
||||
|
||||
t_name "an empty token is treated as missing"
|
||||
sb_env AI_API_TOKEN= --net none --harness pi # set, but empty
|
||||
assert_rc 1 "an empty credential is refused, not sent"
|
||||
assert_contains "unset or empty" "and it says which"
|
||||
sb_env AI_API_TOKEN= --net none --print-policy
|
||||
assert_contains "pi_token_env=AI_API_TOKEN (set but empty)" "preview distinguishes the two states"
|
||||
|
||||
t_name "but only when pi is the thing being launched"
|
||||
export AI_API_TOKEN=x
|
||||
sb --net none --dry-run -- make test
|
||||
assert_absent "not going to reach the container" "a replaced command needs no credential"
|
||||
sb --net none --dry-run --shell
|
||||
assert_rc 0 "--shell works without a token"
|
||||
assert_absent "not going to reach the container" "and does not demand one"
|
||||
sb --net none --dry-run --harness codex
|
||||
assert_absent "not going to reach the container" "codex does not read pi's models.json"
|
||||
|
||||
t_name "--no-pi-config opts out cleanly"
|
||||
sb --no-pi-config --net none --print-policy
|
||||
assert_absent "pi_config=" "no bridge is reported"
|
||||
assert_absent "not going to reach the container" "and nothing is required"
|
||||
clear_sessions
|
||||
sb --keep --no-pi-config --net none -- true
|
||||
if [[ -n "$(gen_file models.json)" ]]; then _bad "nothing was generated"; else _ok "nothing was generated"; fi
|
||||
|
||||
t_name "the token variable name is yours to choose"
|
||||
clear_sessions
|
||||
sb --keep --pi-token-env GW_TOKEN --print-policy --net none
|
||||
assert_contains "pi_token_env=GW_TOKEN (NOT SET)" "reported under the new name"
|
||||
clear_sessions
|
||||
sb_env GW_TOKEN=whatever-gw --keep --pi-token-env GW_TOKEN --print-policy --net none
|
||||
assert_contains "pi_token_env=GW_TOKEN (set)" "it is recognised as in reach"
|
||||
assert_contains "passing \$GW_TOKEN" "and announced under that name"
|
||||
M2="$(gen_file models.json)"
|
||||
grep -qF '"apiKey": "$GW_TOKEN"' "${M2:-/nonexistent}" && _ok "the generated config references it" || _bad "the generated config references it"
|
||||
sb --net none --harness pi --pi-token-env GW_TOKEN # GW_TOKEN is not set
|
||||
assert_rc 1 "and it is enforced under that name too"
|
||||
|
||||
t_name "a missing agent directory is a skip, not an error"
|
||||
sb_env AISB_PI_AGENT_DIR="$WORK/nope" --net none --print-policy
|
||||
assert_rc 0 "nothing to bridge, nothing demanded"
|
||||
assert_absent "pi_config=" "and no bridge is claimed"
|
||||
sb_env AISB_PI_AGENT_DIR="$WORK/nope" --net none --harness pi
|
||||
assert_rc 0 "pi may still be launched, with its own defaults"
|
||||
|
||||
t_name "broken JSON is reported, not propagated"
|
||||
printf '{ "providers": oops' > "$AGENT/models.json"
|
||||
clear_sessions
|
||||
sb --keep --net none --print-policy
|
||||
assert_contains "models.json is not valid JSON" "it says the file is broken"
|
||||
if grep -qF 'oops' "${AISB_STATE_DIR}/sessions"/*/mnt/pi/models.json 2>/dev/null; then
|
||||
_bad "no invalid file is placed in front of pi"
|
||||
else
|
||||
_ok "no invalid file is placed in front of pi"
|
||||
fi
|
||||
restore_models
|
||||
|
||||
t_name "the model endpoint has to be allowed, and says when it is not"
|
||||
sb --net allow --allow registry.npmjs.org --print-policy
|
||||
assert_contains "the model endpoint is not in the allowlist" "a pi that cannot reach its gateway is a warning, not a mystery"
|
||||
assert_contains "add --allow aipi.reeseapps.com" "and it names the host to add"
|
||||
sb --net allow --allow aipi.reeseapps.com --allow 10.20.0.7 --print-policy
|
||||
assert_absent "not in the allowlist" "silent once both endpoints are allowed"
|
||||
|
||||
t_name "the bridge never widens the allowlist by itself"
|
||||
sb --net allow --allow aipi.reeseapps.com --print-policy
|
||||
assert_contains "gate_domains=aipi.reeseapps.com" "what you asked for is what you got"
|
||||
assert_absent "10.20.0.7 80,443" "the discovered endpoint was not added on your behalf"
|
||||
|
||||
t_name "an invalid token variable name is rejected"
|
||||
sb --net none --pi-token-env '9bad name' --print-policy
|
||||
assert_rc 1 "rejected"
|
||||
assert_contains "not a valid identifier" "with a clear reason"
|
||||
Reference in New Issue
Block a user