Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ docker run --rm \
| `AWS_ACCESS_KEY_ID` | AWS/S3 access key ID |
| `AWS_SECRET_ACCESS_KEY` | AWS/S3 secret access key |
| `OSC_ACCESS_TOKEN` | Access token for Eyevinn Open Source Cloud config service |
| `OSC_REFRESH_TOKEN` | Set by the runner to the original long-lived `OSC_ACCESS_TOKEN` (unless already set). Exchange it for a 1 hour token with `POST https://token.svc.<env>.osaas.io/runner-token/refresh` and body `{"token": "<OSC_REFRESH_TOKEN>"}`; the response field `token` is the new token. A parameter store key with the same name overrides it. Example: `requests.post(f"https://token.svc.{env}.osaas.io/runner-token/refresh", json={"token": os.environ["OSC_REFRESH_TOKEN"]}).json()["token"]` |
| `CONFIG_API_KEY` | Optional API key for encrypted parameter store. When set alongside `OSC_ACCESS_TOKEN` and `CONFIG_SVC`, secret parameters are decrypted before being injected as environment variables. |
| `CONFIG_SVC` | URL to config service endpoint |
| `PORT` | Port to run the application on (default: `8080`) |
Expand Down
39 changes: 33 additions & 6 deletions scripts/docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ fi
# well-known token shapes (GitHub tokens, JWTs, Basic auth).
redact_config_log() {
local s="$1" v
for v in "${OSC_ACCESS_TOKEN:-}" "${CONFIG_API_KEY:-}" "${GIT_TOKEN:-}" "${GITHUB_TOKEN:-}"; do
for v in "${OSC_ACCESS_TOKEN:-}" "${OSC_REFRESH_TOKEN:-}" "${config_token:-}" "${CONFIG_API_KEY:-}" "${GIT_TOKEN:-}" "${GITHUB_TOKEN:-}"; do
if [ -n "$v" ]; then
s="${s//"$v"/[REDACTED]}"
fi
Expand All @@ -226,11 +226,38 @@ if [ -n "${OSC_ACCESS_TOKEN:-}" ] && [ -n "${CONFIG_SVC:-}" ]; then
_extracted=$(echo "$OSC_MCP_URL" | sed -n 's|.*\.svc\.\([a-z]*\)\.osaas\.io.*|\1|p')
OSC_ENV=${_extracted:-prod}
fi
# Token refresh block (matching birme/claude-runner#14)
REFRESH_RESULT=$(curl -sf -X POST \
# Token refresh block (Stage 1 of Eyevinn/python-runner#39). OSC_ACCESS_TOKEN
# is a long-lived runner-refresh token. Exchange it for a 1h PAT that is used
# ONLY for the config load below; the app keeps the original
# OSC_ACCESS_TOKEN, and additionally gets OSC_REFRESH_TOKEN so it can renew a
# PAT itself. Never assign OSC_ACCESS_TOKEN in the shell: it would reach the
# app (Stage 2, gated on REJECT_RUNNER_REFRESH_TOKENS, does that). A
# parameter-store key named OSC_REFRESH_TOKEN loaded later overrides this.
export OSC_REFRESH_TOKEN="${OSC_REFRESH_TOKEN:-$OSC_ACCESS_TOKEN}"
config_token="$OSC_ACCESS_TOKEN"
refresh_body_file=$(mktemp)
refresh_resp_file=$(mktemp)
chmod 600 "$refresh_body_file" "$refresh_resp_file"
printf '{"token":"%s"}' "$OSC_REFRESH_TOKEN" > "$refresh_body_file"
# No -f: the status code is read from -w. jq is not in the image; parse with node.
refresh_status=$(curl -s --max-time 10 -X POST \
"https://token.svc.${OSC_ENV:-prod}.osaas.io/runner-token/refresh" \
-H "x-pat-jwt: $OSC_ACCESS_TOKEN" 2>&1) && \
OSC_ACCESS_TOKEN=$(echo "$REFRESH_RESULT" | jq -r '.token // empty') || true
-H "Content-Type: application/json" \
--data "@$refresh_body_file" \
-o "$refresh_resp_file" -w '%{http_code}' 2>/dev/null) || refresh_status="000"
refresh_parse='try{const j=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));process.stdout.write(typeof j[process.argv[2]]==="string"?j[process.argv[2]]:"")}catch(e){}'
fresh_token=""
if [ "$refresh_status" = "200" ]; then
fresh_token=$(node -e "$refresh_parse" "$refresh_resp_file" token 2>/dev/null) || fresh_token=""
fi
if [ -n "$fresh_token" ]; then
config_token="$fresh_token"
else
refresh_code=$(node -e "$refresh_parse" "$refresh_resp_file" code 2>/dev/null) || refresh_code=""
refresh_code=$(printf '%s' "$refresh_code" | tr -cd 'A-Za-z0-9_-')
echo "[CONFIG] WARNING: runner token exchange failed (HTTP ${refresh_status:-000}, code=${refresh_code:-none}); using the original token" >&2
fi
rm -f "$refresh_body_file" "$refresh_resp_file"
echo "[CONFIG] Loading environment variables from config service '$CONFIG_SVC'"
# Guard against a hung/unresolvable CONFIG_SVC blocking boot forever.
# The "&& config_exit=0 || config_exit=$?" form (rather than a plain
Expand All @@ -248,7 +275,7 @@ if [ -n "${OSC_ACCESS_TOKEN:-}" ] && [ -n "${CONFIG_SVC:-}" ]; then
# without its environment variables. This runner has no loading server, so
# (like the setup.sh timeout below) failure is a non-zero exit.
config_err_file=$(mktemp)
config_env_output=$(timeout 60s npx -y "@osaas/cli@${OSAAS_CLI_VERSION:?OSAAS_CLI_VERSION must be set (pinned in the Dockerfile)}" web config-to-env ${OSC_ENV:+--env "$OSC_ENV"} "$CONFIG_SVC" 2>"$config_err_file") && config_exit=0 || config_exit=$?
config_env_output=$(OSC_ACCESS_TOKEN="$config_token" timeout 60s npx -y "@osaas/cli@${OSAAS_CLI_VERSION:?OSAAS_CLI_VERSION must be set (pinned in the Dockerfile)}" web config-to-env ${OSC_ENV:+--env "$OSC_ENV"} "$CONFIG_SVC" 2>"$config_err_file") && config_exit=0 || config_exit=$?
config_err_output=$(cat "$config_err_file" 2>/dev/null || true)
rm -f "$config_err_file"
config_failed=0
Expand Down
108 changes: 108 additions & 0 deletions tests/test-entrypoint-runner-token-refresh.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Behavioural tests for the runner token exchange in scripts/docker-entrypoint.sh.
# Stage 1 contract: config-to-env authenticates with the fresh 1h PAT, the app
# keeps the original OSC_ACCESS_TOKEN and gains OSC_REFRESH_TOKEN. `jq` is NOT in
# the runner image, so a stub that exits 127 is on PATH ahead of /usr/bin.

ENTRYPOINT="scripts/docker-entrypoint.sh"
PASS=0
FAIL=0
pass() { echo "PASS: $1"; PASS=$((PASS + 1)); }
fail() { echo "FAIL: $1"; FAIL=$((FAIL + 1)); }

sandbox=$(mktemp -d)
trap 'rm -rf "$sandbox"' EXIT
mkdir "$sandbox/bin"

awk '/^# Load environment variables from config service if configured/{f=1} /^# Install Python dependencies/{f=0} f' "$ENTRYPOINT" > "$sandbox/block.sh"
if [ ! -s "$sandbox/block.sh" ]; then
fail "could not extract config block from entrypoint"
exit 1
fi

ln -s "$(command -v node)" "$sandbox/bin/node"
printf '#!/bin/bash\nexit 127\n' > "$sandbox/bin/jq"

# curl stub: CURL_MODE selects status/body; records args and the body file.
cat > "$sandbox/bin/curl" <<'STUB'
#!/bin/bash
out=""; data=""
while [ $# -gt 0 ]; do
case "$1" in
-o) out=$2; shift ;;
--data) data=${2#@}; shift ;;
esac
shift
done
[ -n "$data" ] && cp "$data" "$SANDBOX/sent-body"
case "$CURL_MODE" in
ok) echo '{"token":"fresh-pat","expiresIn":3600}' > "$out"; printf 200 ;;
ok_notoken) echo '{"expiresIn":3600}' > "$out"; printf 200 ;;
ok_garbage) echo 'not json' > "$out"; printf 200 ;;
expired) echo '{"code":"refresh_token_expired","message":"orig-token-secret"}' > "$out"; printf 401 ;;
invalid) echo '{"code":"refresh_token_invalid"}' > "$out"; printf 401 ;;
err500) echo 'oops' > "$out"; printf 500 ;;
exit22) exit 22 ;;
exit28) printf 000; exit 28 ;;
esac
STUB
cat > "$sandbox/bin/timeout" <<'STUB'
#!/bin/bash
shift
exec "$@"
STUB
cat > "$sandbox/bin/npx" <<'STUB'
#!/bin/bash
echo "$OSC_ACCESS_TOKEN" > "$SANDBOX/npx-token"
echo "export FOO=bar"
STUB
chmod +x "$sandbox/bin/"*

run_block() {
local mode=$1; shift
rm -f "$sandbox/npx-token" "$sandbox/sent-body"
env -i PATH="$sandbox/bin:/usr/bin:/bin" SANDBOX="$sandbox" CURL_MODE="$mode" \
OSC_ACCESS_TOKEN=orig-token-secret CONFIG_SVC=store OSAAS_CLI_VERSION=4.35.0 "$@" \
bash -c 'set -e; '"$(cat "$sandbox/block.sh")"'; echo "REACHED_PIP APP_TOKEN=$OSC_ACCESS_TOKEN REFRESH=${OSC_REFRESH_TOKEN:-}"' \
2>"$sandbox/stderr" >"$sandbox/stdout"
echo $?
}

# Success path: config load uses the fresh token, app keeps the original
rc=$(run_block ok)
if [ "$rc" = "0" ] \
&& [ "$(cat "$sandbox/npx-token")" = "fresh-pat" ] \
&& grep -q 'REACHED_PIP APP_TOKEN=orig-token-secret REFRESH=orig-token-secret' "$sandbox/stdout" \
&& [ "$(cat "$sandbox/sent-body")" = '{"token":"orig-token-secret"}' ] \
&& ! grep -q WARNING "$sandbox/stderr"; then
pass "success: config-to-env gets fresh PAT, app keeps original token and gains OSC_REFRESH_TOKEN"
else
fail "success path (rc=$rc, npx token=$(cat "$sandbox/npx-token" 2>/dev/null))"
fi

# Pre-set OSC_REFRESH_TOKEN is the exchange input
rc=$(run_block ok OSC_REFRESH_TOKEN=preset-refresh)
if [ "$rc" = "0" ] && [ "$(cat "$sandbox/sent-body")" = '{"token":"preset-refresh"}' ] && grep -q 'REFRESH=preset-refresh' "$sandbox/stdout"; then
pass "existing OSC_REFRESH_TOKEN is used and kept"
else
fail "preset refresh token (rc=$rc)"
fi

# Failure paths: warning with status/code, original token used, block continues, no leak
for spec in "expired:401:refresh_token_expired" "invalid:401:refresh_token_invalid" "err500:500:none" "exit22:000:none" "exit28:000:none" "ok_notoken:200:none" "ok_garbage:200:none"; do
mode=${spec%%:*}; rest=${spec#*:}; status=${rest%%:*}; code=${rest#*:}
rc=$(run_block "$mode")
if [ "$rc" = "0" ] \
&& [ "$(cat "$sandbox/npx-token")" = "orig-token-secret" ] \
&& grep -q 'REACHED_PIP APP_TOKEN=orig-token-secret' "$sandbox/stdout" \
&& grep -q "\[CONFIG\] WARNING: runner token exchange failed (HTTP $status, code=$code)" "$sandbox/stderr" \
&& ! grep -q 'orig-token-secret' "$sandbox/stderr"; then
pass "failure ($mode): warning, original token used, continues, token not logged"
else
fail "failure $mode (rc=$rc)"
fi
done

echo ""
echo "Results: $PASS passed, $FAIL failed"
[ $FAIL -eq 0 ]
Loading