From 58435b59c611764a8d79deebde3c52f7470ebd63 Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Sun, 8 Nov 2026 10:27:43 +0000 Subject: [PATCH] fix: make runner token exchange work, expose OSC_REFRESH_TOKEN (stage 1) Co-Authored-By: Claude --- README.md | 1 + scripts/docker-entrypoint.sh | 39 ++++++- tests/test-entrypoint-runner-token-refresh.sh | 108 ++++++++++++++++++ 3 files changed, 142 insertions(+), 6 deletions(-) create mode 100644 tests/test-entrypoint-runner-token-refresh.sh diff --git a/README.md b/README.md index 2de1843..200244b 100644 --- a/README.md +++ b/README.md @@ -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..osaas.io/runner-token/refresh` and body `{"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`) | diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index cc40bcb..0737779 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -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 @@ -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 @@ -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 diff --git a/tests/test-entrypoint-runner-token-refresh.sh b/tests/test-entrypoint-runner-token-refresh.sh new file mode 100644 index 0000000..2bc459c --- /dev/null +++ b/tests/test-entrypoint-runner-token-refresh.sh @@ -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 ]