diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9023a9fa40f8..7ca045c7a037 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,3 +1,4 @@ +# ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"} name: test on: @@ -566,6 +567,112 @@ jobs: bin="$(find packages/opencode/dist -type f -name 'opencode' | head -1)" OPENCODE_BIN="$bin" bun run .github/scripts/validate-opencode-config.ts + # Gates anr-token-auth-smoke: probes credential resolution directly (no CLI + # build) so a known external failure mode — the configured Cognito Identity + # Pool having been deleted/rotated server-side — shows as a skipped smoke + # job instead of a false regression. Any other failure (including "no + # secret configured") still runs the smoke job so it surfaces full + # diagnostics; this only suppresses the one specific, non-code condition. + anr-token-auth-preflight: + name: anr-token-auth-preflight + runs-on: ubuntu-latest + outputs: + run_smoke: ${{ steps.probe.outputs.run_smoke }} + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Probe token-auth credential resolution + id: probe + run: | + if [ -z "${{ secrets.ANR_REFRESH_TOKEN }}" ] && [ -z "${{ secrets.ANR_ID_TOKEN }}" ]; then + echo "run_smoke=false" >> "$GITHUB_OUTPUT" + echo "::notice::Neither ANR_REFRESH_TOKEN nor ANR_ID_TOKEN secret is set — skipping token-auth smoke." + exit 0 + fi + + result="$(bun run packages/opencode/script/anr-token-auth-preflight.ts .opencode/.env.commercial 2>preflight-stderr.log | tail -1)" + + case "$result" in + AUTH_OK) + echo "run_smoke=true" >> "$GITHUB_OUTPUT" + ;; + SKIP_STALE_IDENTITY_POOL) + echo "run_smoke=false" >> "$GITHUB_OUTPUT" + echo "::notice::Configured Cognito Identity Pool was not found (deleted/rotated server-side) — skipping smoke until infra is restored." + cat preflight-stderr.log + ;; + *) + echo "run_smoke=true" >> "$GITHUB_OUTPUT" + cat preflight-stderr.log + ;; + esac + env: + OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }} + OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }} + AWS_ACCESS_KEY_ID: ${{ secrets.ANR_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ANR_AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.ANR_AWS_SESSION_TOKEN }} + + # ANR token-auth smoke: verifies non-interactive token mode can initialize + # and reach ANR backend services without opening a browser. + # Requires a GitHub Actions secret: ANR_REFRESH_TOKEN (long-lived, recommended) + # or ANR_ID_TOKEN (short-lived, needs manual rotation). + # Gated by anr-token-auth-preflight; promote to required gate once secrets + # and backend infra (Cognito Identity Pool) are stable. + anr-token-auth-smoke: + name: anr-token-auth-smoke + needs: anr-token-auth-preflight + if: needs.anr-token-auth-preflight.outputs.run_smoke == 'true' + continue-on-error: true + runs-on: ubuntu-latest + defaults: + run: + shell: bash + steps: + - name: Checkout repository + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup Node + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: "24" + + - name: Setup Bun + uses: ./.github/actions/setup-bun + + - name: Build single-platform CLI + run: bun run ./packages/opencode/script/build.ts --single --skip-embed-web-ui + env: + OPENCODE_VERSION: "0.0.0-smoke" + + - name: Run ANR token-auth smoke (agent list) + run: | + bin="$(find packages/opencode/dist -type f -name 'opencode' | head -1)" + echo "Binary: $bin" + # Pin the env flavor: the repo ships multiple .opencode/.env.* files and + # auto-selection order is filesystem-dependent. The stored refresh token + # is issued by the commercial app client, so the config must match. + "$bin" agent list --env-file .opencode/.env.commercial + env: + OPENCODE_FLAVOR: anr + OPENCODE_ANR_AUTH_MODE: token + # Refresh token is preferred: it is long-lived and a fresh ID token is + # minted at startup (refresh-first bootstrap). ID token is a fallback. + OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }} + OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }} + # Optional: provide static AWS creds to bypass federation exchange + AWS_ACCESS_KEY_ID: ${{ secrets.ANR_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ANR_AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.ANR_AWS_SESSION_TOKEN }} + # Upgrade/migration smoke: install the previous released version, create state, # then run the build-under-test against that same state to catch migration # regressions (session/config on-disk format changes). diff --git a/docs/anr-token-auth.md b/docs/anr-token-auth.md new file mode 100644 index 000000000000..6c66bb3ede95 --- /dev/null +++ b/docs/anr-token-auth.md @@ -0,0 +1,140 @@ + + +# ANR Token-Based Authentication Mode + +Non-interactive, browserless auth for CI/CD pipelines. Set `OPENCODE_ANR_AUTH_MODE=token` to skip the OIDC browser flow. + +**Recommended CI setup:** store a single long-lived Cognito **refresh token** in GitHub Actions secrets. At startup opencode exchanges it for a fresh ID token (refresh-first bootstrap), then federates that into AWS credentials — no manual token rotation, no browser. + +## Environment Variable Contract + +| Variable | Required | Description | +|---|---|---| +| `OPENCODE_ANR_AUTH_MODE` | No (default: `interactive`) | `interactive` or `token` | +| `OPENCODE_ANR_REFRESH_TOKEN` | Recommended for CI\* | Long-lived Cognito refresh token; a fresh ID token is minted at startup and on schedule | +| `OPENCODE_ANR_ID_TOKEN` | Only if no refresh token\* | Cognito OIDC ID token (JWT), short-lived (~1 h) | +| `AWS_ACCESS_KEY_ID` | No | If set with SECRET+TOKEN, skips federation exchange | +| `AWS_SECRET_ACCESS_KEY` | No | See above | +| `AWS_SESSION_TOKEN` | No | See above | +| `AWS_REGION` | No | Overrides config region when using static creds | +| `OPENCODE_ANR_SKIP_AUTH` | No | **Config-validation only** — not for real auth | + +\* Token mode needs at least one of: `OPENCODE_ANR_REFRESH_TOKEN`, `OPENCODE_ANR_ID_TOKEN`, or the full static AWS credential triple. + +## Credential Resolution + +Token mode resolves credentials in this order: + +1. **Static AWS creds** — if `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, and `AWS_SESSION_TOKEN` are all set, use them directly. No Cognito call is made. +2. **Refresh-first bootstrap** — if `OPENCODE_ANR_REFRESH_TOKEN` is set, exchange it at Cognito's token endpoint for a fresh ID token, then federate that via the Cognito Identity Pool. If the refresh fails and `OPENCODE_ANR_ID_TOKEN` is also set, fall back to step 3; otherwise fail fast. +3. **Federation exchange** — exchange `OPENCODE_ANR_ID_TOKEN` via the Cognito Identity Pool configured in your `.env` file. + +## Typical CI Usage + +### Recommended: long-lived refresh token (autonomous) + +```yaml +- name: Run opencode + env: + OPENCODE_FLAVOR: anr + OPENCODE_ANR_AUTH_MODE: token + OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }} + run: opencode agent list +``` + +### With a short-lived ID token (manual rotation) + +```yaml +- name: Run opencode + env: + OPENCODE_FLAVOR: anr + OPENCODE_ANR_AUTH_MODE: token + OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }} + run: opencode agent list +``` + +### With pre-issued AWS credentials (skip federation) + +```yaml +- name: Run opencode + env: + OPENCODE_FLAVOR: anr + OPENCODE_ANR_AUTH_MODE: token + OPENCODE_ANR_ID_TOKEN: ${{ secrets.ANR_ID_TOKEN }} # still used for telemetry context + AWS_ACCESS_KEY_ID: ${{ secrets.ANR_AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.ANR_AWS_SECRET_ACCESS_KEY }} + AWS_SESSION_TOKEN: ${{ secrets.ANR_AWS_SESSION_TOKEN }} + run: opencode agent list +``` + +## Provisioning the Refresh Token (one-time bootstrap) + +1. Run opencode interactively once (`OPENCODE_ANR_AUTH_MODE` unset) and complete the browser OIDC login — ideally as a dedicated CI service account in the Cognito User Pool, not a personal account. +2. Capture the refresh token issued by the login and store it as the `ANR_REFRESH_TOKEN` GitHub Actions secret. +3. Confirm two settings on the Cognito **app client** with whoever administers the user pool: + - **Refresh token validity** covers your desired CI credential lifetime (Cognito default is 30 days; configurable up to 10 years). + - **Refresh token rotation is disabled.** If Cognito rotates the refresh token on each use, the statically stored secret is invalidated after the first CI run. + +After that, every CI run self-serves fresh credentials for the life of the refresh token. The refresh call is a plain POST to Cognito's `/oauth2/token` endpoint using the public app client — no client secret is involved. + +## Token Refresh Behaviour in Token Mode + +| Scenario | Behaviour | +|---|---| +| `OPENCODE_ANR_REFRESH_TOKEN` set | Fresh ID token minted at startup; silent refresh on schedule thereafter — no browser | +| `OPENCODE_ANR_REFRESH_TOKEN` set, refresh fails at startup | Falls back to `OPENCODE_ANR_ID_TOKEN` if set; otherwise fails fast | +| `OPENCODE_ANR_REFRESH_TOKEN` set, scheduled refresh fails mid-run | Logs error, keeps existing creds until STS expiry. No browser fallback. | +| No `OPENCODE_ANR_REFRESH_TOKEN` | One-time warning logged. Creds remain valid until AWS STS expiry. No interactive fallback. | + +Interactive mode (default) is unchanged: silent refresh attempted first, browser opened on failure. + +## Fail-Fast Error Messages + +Missing or invalid configuration exits immediately with a clear, actionable message: + +``` +[ANR] Token auth mode is missing required environment variable(s): + - OPENCODE_ANR_ID_TOKEN is not set + - OPENCODE_ANR_REFRESH_TOKEN is not set + +To fix (one of): + • Set OPENCODE_ANR_REFRESH_TOKEN to a long-lived Cognito refresh token + (recommended for CI — a fresh ID token is minted automatically). + • Set OPENCODE_ANR_ID_TOKEN to a valid, unexpired Cognito ID token. + • Provide AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN + to bypass federation entirely. +``` + +Error messages **never** include secret values. + +## Differences: `OPENCODE_ANR_SKIP_AUTH` vs `OPENCODE_ANR_AUTH_MODE=token` + +| | `OPENCODE_ANR_SKIP_AUTH=1` | `OPENCODE_ANR_AUTH_MODE=token` | +|---|---|---| +| Purpose | Config-lint / validation only | Real production CI auth | +| Authentication | Skipped entirely | Performed (token or federation) | +| AWS credentials | Not obtained | Obtained and set in env | +| Telemetry | Not initialized | Initialized | +| Quota check | Skipped | Performed | +| Use in | `validate-opencode-config.ts` | Any CI job needing full ANR | + +**Do not** use `OPENCODE_ANR_SKIP_AUTH` for jobs that need to reach backend services — it bypasses all auth and will result in missing credentials. + +## Secret Rotation and Expiry + +- **Refresh tokens** are the recommended CI secret: long-lived (configurable on the Cognito app client, up to 10 years), revocable, and exchanged automatically for short-lived ID tokens. Rotate per your org's policy. +- Cognito **ID tokens** are short-lived (typically 1 hour). Only use `ANR_ID_TOKEN` directly if you regenerate it before each CI run. +- AWS **STS session tokens** (`AWS_SESSION_TOKEN`) have their own expiry. If pre-issued, ensure they are valid for the duration of the job. +- Store all tokens exclusively in GitHub Actions secrets (or equivalent). Never commit them to `.env` files. + +## Troubleshooting + +| Symptom | Likely cause | Fix | +|---|---|---| +| `missing required environment variable(s)` | No secret wired in workflow | Add `OPENCODE_ANR_REFRESH_TOKEN: ${{ secrets.ANR_REFRESH_TOKEN }}` to env | +| `refresh token exchange failed` | Expired/revoked refresh token, wrong app client, or rotation enabled | Re-provision the refresh token; verify `CLIENT_ID` matches the issuing app client; disable rotation on the app client | +| `federation exchange failed` | Expired or invalid ID token | Regenerate token; check identity pool ID and region in config | +| `does not appear to be a valid JWT` | Wrong secret mapped | Verify `ANR_ID_TOKEN` secret contains a valid Cognito ID token (three-part JWT) | +| `Unknown OPENCODE_ANR_AUTH_MODE value` | Typo in env var | Valid values: `interactive`, `token` | +| Credentials expire mid-job | No refresh token + long job | Add `OPENCODE_ANR_REFRESH_TOKEN` secret or break job into shorter steps | +| Second CI run fails after first succeeds | Refresh token rotation enabled on app client | Disable rotation, or update the stored secret with the rotated token | diff --git a/packages/anr-core/src/config/env-loader.ts b/packages/anr-core/src/config/env-loader.ts index 0ba5901fc04a..1869ff1da359 100644 --- a/packages/anr-core/src/config/env-loader.ts +++ b/packages/anr-core/src/config/env-loader.ts @@ -206,8 +206,7 @@ export async function loadANRConfig(envPath?: string, quiet = false): Promise { try { - const file = Bun.file(path) - const text = await file.text() + const text = readFileSync(path, "utf-8") const lines = text.split("\n") for (const line of lines) { diff --git a/packages/anr-core/src/index.ts b/packages/anr-core/src/index.ts index 05481ec06980..145f42c5cfff 100644 --- a/packages/anr-core/src/index.ts +++ b/packages/anr-core/src/index.ts @@ -18,7 +18,16 @@ export { // Authentication export { authenticateWithOIDC, refreshOIDCTokens, type OIDCTokens } from "./integrations/oidc-auth" -export { exchangeTokenForAWSCredentials } from "./integrations/aws-federation" +export { exchangeTokenForAWSCredentials, type AWSCredentials } from "./integrations/aws-federation" +export { + parseANRAuthMode, + validateTokenModeEnv, + resolveTokenModeCredentials, + type ANRAuthMode, + type TokenAuthResult, + type TokenModeValidationOk, + type TokenModeValidationError, +} from "./integrations/token-auth" // Telemetry & Observability export { diff --git a/packages/anr-core/src/integrations/token-auth.ts b/packages/anr-core/src/integrations/token-auth.ts new file mode 100644 index 000000000000..a1206d6040a1 --- /dev/null +++ b/packages/anr-core/src/integrations/token-auth.ts @@ -0,0 +1,269 @@ +// ANRCODE_CHANGE {"issue":310,"branch":"anr/321/create-anrcode-agentic-dev-team","date":"2026-07-22"} +/** + * Token-based (non-interactive) authentication for ANR CI mode. + * + * Provides mode selection, environment validation, and credential resolution + * without any browser or interactive TTY dependency. Used by both the CLI + * (packages/opencode/src/index.ts) and the desktop sidecar + * (packages/opencode/src/anr-boot.ts) so neither copy drifts on auth logic. + * + * Environment contract: + * OPENCODE_ANR_AUTH_MODE "interactive" (default) | "token" + * OPENCODE_ANR_REFRESH_TOKEN Recommended for CI — long-lived Cognito refresh token; + * exchanged for a fresh ID token at startup (refresh-first + * bootstrap) and used for scheduled refresh thereafter + * OPENCODE_ANR_ID_TOKEN Required in token mode when neither a refresh token nor + * static AWS creds are provided + * AWS_ACCESS_KEY_ID Optional — if present with SECRET+TOKEN, skips federation + * AWS_SECRET_ACCESS_KEY Optional — see above + * AWS_SESSION_TOKEN Optional — see above + * AWS_REGION Optional — overrides config.awsRegion in token mode + */ + +import type { ANRConfig } from "../config/types" +import { exchangeTokenForAWSCredentials, type AWSCredentials } from "./aws-federation" +import { refreshOIDCTokens } from "./oidc-auth" + +// --------------------------------------------------------------------------- +// Public types +// --------------------------------------------------------------------------- + +export type ANRAuthMode = "interactive" | "token" + +export interface TokenAuthResult { + idToken: string + refreshToken: string | undefined + awsCredentials: AWSCredentials + /** How credentials were obtained — useful for logging/diagnostics. */ + credentialSource: "static" | "exchange" | "refresh-exchange" +} + +// --------------------------------------------------------------------------- +// Mode selection +// --------------------------------------------------------------------------- + +/** + * Read OPENCODE_ANR_AUTH_MODE from the given env map (defaults to process.env). + * Throws a CI-friendly error if an unrecognised value is set. + */ +export function parseANRAuthMode(env: NodeJS.ProcessEnv = process.env): ANRAuthMode { + const raw = env.OPENCODE_ANR_AUTH_MODE + if (!raw || raw === "interactive") return "interactive" + if (raw === "token") return "token" + throw new Error( + `[ANR] Unknown OPENCODE_ANR_AUTH_MODE value: "${raw}"\n` + + ` Valid values: "interactive" (default), "token"\n` + + ` Set OPENCODE_ANR_AUTH_MODE=token for CI / non-interactive use.`, + ) +} + +// --------------------------------------------------------------------------- +// Token-mode validation +// --------------------------------------------------------------------------- + +export interface TokenModeValidationOk { + ok: true + idToken: string + refreshToken: string | undefined + staticAWSCreds: StaticAWSCreds | undefined +} + +export interface TokenModeValidationError { + ok: false + /** Human-readable, CI-friendly error. Never contains secret values. */ + message: string +} + +interface StaticAWSCreds { + accessKeyId: string + secretAccessKey: string + sessionToken: string + region: string +} + +/** + * Validate the environment contract for token mode. + * Returns a typed ok/error result — never throws. + * Error messages are actionable and contain NO secret values. + */ +export function validateTokenModeEnv( + env: NodeJS.ProcessEnv = process.env, +): TokenModeValidationOk | TokenModeValidationError { + const staticAWSCreds = resolveStaticAWSCreds(env) + // Tokens are base64url/JWT and can never legitimately contain whitespace, + // but secrets pasted from a terminal often pick up newlines at visual wrap + // points (e.g. macOS Terminal copies soft-wrapped lines with hard breaks). + // Strip all whitespace so a mangled paste still authenticates. + const idToken = env.OPENCODE_ANR_ID_TOKEN?.replace(/\s+/g, "") || "" + const refreshToken = env.OPENCODE_ANR_REFRESH_TOKEN?.replace(/\s+/g, "") || undefined + + // If full static AWS creds are present, we don't need an ID token for exchange. + // We still accept OPENCODE_ANR_ID_TOKEN for telemetry context building. + if (staticAWSCreds) { + return { + ok: true, + idToken, + refreshToken, + staticAWSCreds, + } + } + + // No static creds — require an ID token or a refresh token for federation exchange. + // A refresh token alone is sufficient: it is exchanged for a fresh ID token at + // startup (refresh-first bootstrap), which is the recommended CI configuration. + if (!idToken && !refreshToken) { + return { + ok: false, + message: + `[ANR] Token auth mode is missing required environment variable(s):\n` + + ` - OPENCODE_ANR_ID_TOKEN is not set\n` + + ` - OPENCODE_ANR_REFRESH_TOKEN is not set\n` + + `\nTo fix (one of):\n` + + ` • Set OPENCODE_ANR_REFRESH_TOKEN to a long-lived Cognito refresh token\n` + + ` (recommended for CI — a fresh ID token is minted automatically).\n` + + ` • Set OPENCODE_ANR_ID_TOKEN to a valid, unexpired Cognito ID token.\n` + + ` • Provide AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN\n` + + ` to bypass federation entirely.`, + } + } + + return { + ok: true, + idToken, + refreshToken, + staticAWSCreds: undefined, + } +} + +// --------------------------------------------------------------------------- +// Credential resolution +// --------------------------------------------------------------------------- + +/** + * Resolve AWS credentials for token mode: + * 1. If AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN are all + * set, return them directly (source: "static") — no federation call. + * 2. Else if OPENCODE_ANR_REFRESH_TOKEN is set, exchange it for a fresh ID + * token first (refresh-first bootstrap), then federate that ID token + * (source: "refresh-exchange"). Falls back to step 3 if the refresh fails + * and an ID token is also available. + * 3. Otherwise exchange OPENCODE_ANR_ID_TOKEN via Cognito Identity Pool + * (source: "exchange"). + * + * Throws a CI-friendly error on failure. Redacts secret values from messages. + */ +export async function resolveTokenModeCredentials( + config: ANRConfig, + env: NodeJS.ProcessEnv = process.env, +): Promise { + const validation = validateTokenModeEnv(env) + if (!validation.ok) { + throw new Error(validation.message) + } + + // Static path — caller provided full AWS creds. + if (validation.staticAWSCreds) { + const { accessKeyId, secretAccessKey, sessionToken } = validation.staticAWSCreds + return { + idToken: validation.idToken, + refreshToken: validation.refreshToken, + credentialSource: "static", + awsCredentials: { + accessKeyId, + secretAccessKey, + sessionToken, + expiration: undefined, + }, + } + } + + // Refresh-first bootstrap — a refresh token mints a fresh ID token without a + // browser, so CI stores one long-lived secret instead of rotating ID tokens. + if (validation.refreshToken) { + let refreshed + try { + refreshed = await refreshOIDCTokens(config, validation.refreshToken) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + if (!validation.idToken) { + throw new Error( + `[ANR] Token auth: refresh token exchange failed and no OPENCODE_ANR_ID_TOKEN fallback is set.\n` + + ` ${msg}\n\n` + + ` Check that:\n` + + ` • OPENCODE_ANR_REFRESH_TOKEN is a valid, unexpired Cognito refresh token\n` + + ` • The refresh token was issued to the app client in your config (CLIENT_ID)\n` + + ` • Refresh token rotation is disabled on the Cognito app client —\n` + + ` rotation invalidates a statically stored secret after first use`, + ) + } + console.error("⚠️ [ANR] Refresh-first bootstrap failed — falling back to OPENCODE_ANR_ID_TOKEN.") + console.error(` ${msg}`) + } + + if (refreshed) { + return { + idToken: refreshed.idToken, + refreshToken: refreshed.refreshToken ?? validation.refreshToken, + credentialSource: "refresh-exchange", + awsCredentials: await federateIdToken(refreshed.idToken, config), + } + } + } + + // Exchange path — use ID token to get AWS creds via Cognito Identity Pool. + const idToken = validation.idToken + if (!idToken) { + throw new Error( + `[ANR] Token auth: OPENCODE_ANR_ID_TOKEN is empty.\n` + + ` Ensure the token is a valid Cognito ID token (JWT, three dot-separated parts).`, + ) + } + + // Sanity-check token shape without logging the value. + if (idToken.split(".").length !== 3) { + throw new Error( + `[ANR] Token auth: OPENCODE_ANR_ID_TOKEN does not appear to be a valid JWT.\n` + + ` Expected three dot-separated base64url segments. Token length: ${idToken.length}.`, + ) + } + + return { + idToken, + refreshToken: validation.refreshToken, + credentialSource: "exchange", + awsCredentials: await federateIdToken(idToken, config), + } +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +async function federateIdToken(idToken: string, config: ANRConfig): Promise { + try { + return await exchangeTokenForAWSCredentials(idToken, config) + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + throw new Error( + `[ANR] Token auth: federation exchange failed.\n` + + ` ${msg}\n\n` + + ` Check that:\n` + + ` • OPENCODE_ANR_ID_TOKEN is a fresh, unexpired Cognito ID token\n` + + ` • The identity pool ID and region in your config are correct\n` + + ` • The token's issuer matches the configured Cognito User Pool`, + ) + } +} + +function resolveStaticAWSCreds(env: NodeJS.ProcessEnv): StaticAWSCreds | undefined { + const { AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN, AWS_REGION } = env + if (AWS_ACCESS_KEY_ID && AWS_SECRET_ACCESS_KEY && AWS_SESSION_TOKEN) { + return { + accessKeyId: AWS_ACCESS_KEY_ID, + secretAccessKey: AWS_SECRET_ACCESS_KEY, + sessionToken: AWS_SESSION_TOKEN, + region: AWS_REGION || "", + } + } + return undefined +} diff --git a/packages/anr-core/test/init-pipeline.test.ts b/packages/anr-core/test/init-pipeline.test.ts index e88748b0248f..3b4526ac125d 100644 --- a/packages/anr-core/test/init-pipeline.test.ts +++ b/packages/anr-core/test/init-pipeline.test.ts @@ -407,3 +407,103 @@ describe("ANR Init Pipeline: complete flow simulation", () => { expect(config.cognitoUserPoolId).toBe("us-gov-west-1_FlowTest") }) }) + +// --------------------------------------------------------------------------- +// Token auth mode — integration cases +// --------------------------------------------------------------------------- + +describe("ANR Init Pipeline: token auth mode selection", () => { + const savedEnv: Record = {} + const WATCHED = [ + "OPENCODE_ANR_AUTH_MODE", + "OPENCODE_ANR_ID_TOKEN", + "OPENCODE_ANR_REFRESH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + ] + + beforeEach(() => { + for (const k of WATCHED) savedEnv[k] = process.env[k] + for (const k of WATCHED) delete process.env[k] + }) + + afterEach(() => { + for (const k of WATCHED) { + if (savedEnv[k] === undefined) delete process.env[k] + else process.env[k] = savedEnv[k] + } + }) + + test("OPENCODE_ANR_AUTH_MODE unset → interactive mode", async () => { + const { parseANRAuthMode } = await import("../src/integrations/token-auth") + expect(parseANRAuthMode({})).toBe("interactive") + }) + + test("OPENCODE_ANR_AUTH_MODE=token → token mode selected", async () => { + const { parseANRAuthMode } = await import("../src/integrations/token-auth") + expect(parseANRAuthMode({ OPENCODE_ANR_AUTH_MODE: "token" })).toBe("token") + }) + + test("token mode with static AWS creds skips federation exchange", async () => { + const { resolveTokenModeCredentials } = await import("../src/integrations/token-auth") + const env = { + OPENCODE_ANR_ID_TOKEN: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2ln", + AWS_ACCESS_KEY_ID: "AKIASTATIC", + AWS_SECRET_ACCESS_KEY: "STATICSECRET", + AWS_SESSION_TOKEN: "STATICTOKEN", + AWS_REGION: "us-gov-west-1", + } + const result = await resolveTokenModeCredentials(fullTestConfig(), env) + expect(result.credentialSource).toBe("static") + expect(result.awsCredentials.accessKeyId).toBe("AKIASTATIC") + }) + + test("token mode without AWS creds attempts federation exchange (fails without real endpoint)", async () => { + const { resolveTokenModeCredentials } = await import("../src/integrations/token-auth") + const env = { OPENCODE_ANR_ID_TOKEN: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2ln" } + // Without static creds and with a fake token, exchange will fail. + // Verify the failure is wrapped in a CI-friendly message. + await expect(resolveTokenModeCredentials(fullTestConfig(), env)).rejects.toThrow( + /\[ANR\] Token auth: federation exchange failed/, + ) + }) + + test("token mode with refresh token — validation passes", async () => { + const { validateTokenModeEnv } = await import("../src/integrations/token-auth") + const result = validateTokenModeEnv({ + OPENCODE_ANR_ID_TOKEN: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2ln", + OPENCODE_ANR_REFRESH_TOKEN: "refresh-token-value", + }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.refreshToken).toBe("refresh-token-value") + }) + + test("token mode without refresh token — validation still passes (refresh is optional)", async () => { + const { validateTokenModeEnv } = await import("../src/integrations/token-auth") + const result = validateTokenModeEnv({ + OPENCODE_ANR_ID_TOKEN: "eyJhbGciOiJSUzI1NiJ9.eyJzdWIiOiJ1c2VyIn0.c2ln", + }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.refreshToken).toBeUndefined() + }) + + test("missing required vars in token mode produces actionable error", async () => { + const { validateTokenModeEnv } = await import("../src/integrations/token-auth") + const result = validateTokenModeEnv({}) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain("OPENCODE_ANR_ID_TOKEN") + // Must be CI-friendly: no raw secret values in message + expect(result.message.length).toBeGreaterThan(20) + } + }) + + test("invalid OPENCODE_ANR_AUTH_MODE throws with helpful message", async () => { + const { parseANRAuthMode } = await import("../src/integrations/token-auth") + expect(() => parseANRAuthMode({ OPENCODE_ANR_AUTH_MODE: "headless" })).toThrow( + /Unknown OPENCODE_ANR_AUTH_MODE/, + ) + }) +}) diff --git a/packages/anr-core/test/token-auth.test.ts b/packages/anr-core/test/token-auth.test.ts new file mode 100644 index 000000000000..053e3314f153 --- /dev/null +++ b/packages/anr-core/test/token-auth.test.ts @@ -0,0 +1,365 @@ +// ANRCODE_CHANGE {"issue":310,"branch":"anr/321/create-anrcode-agentic-dev-team","date":"2026-07-22"} +/** + * Unit tests for token-auth.ts + * + * Tests mode selection, environment validation, and credential resolution + * without making any real network calls. + */ +import { describe, expect, test, beforeEach, afterEach } from "bun:test" +import { + parseANRAuthMode, + validateTokenModeEnv, + resolveTokenModeCredentials, +} from "../src/integrations/token-auth" +import type { ANRConfig } from "../src/config/types" + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function minimalConfig(): ANRConfig { + return { + awsRegion: "us-gov-west-1", + useBedrockProvider: true, + anthropicModel: "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + anthropicSmallFastModel: "us-gov.anthropic.claude-sonnet-4-5-20250929-v1:0", + enableTelemetry: false, + otelMetricsExporter: "otlp", + otelProtocol: "http/protobuf", + otelEndpoint: "", + enableAudit: false, + metricsBatchSize: 100, + metricsIntervalSeconds: 60, + auditTableName: "AuditEvents", + quotaFailMode: "open", + quotaCheckInterval: 300, + modelsApiEndpoint: "https://api.example.com/v1", + providerDomain: "auth.govcloud.example.com", + clientId: "gov-client-id", + awsRegionProfile: "us-gov-west-1", + providerType: "cognito", + credentialStorage: "session", + crossRegionProfile: "us-gov-west-1", + identityPoolId: "us-gov-west-1:aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + federationType: "cognito", + cognitoUserPoolId: "us-gov-west-1_AbCdEfGhI", + } +} + +/** A syntactically-valid-looking (but fake) JWT — three dot-separated base64url parts. */ +const FAKE_JWT = + "eyJhbGciOiJSUzI1NiJ9" + + ".eyJzdWIiOiJ1c2VyLTEyMyIsImVtYWlsIjoidGVzdEBleGFtcGxlLmNvbSJ9" + + ".c2lnbmF0dXJl" + +// --------------------------------------------------------------------------- +// parseANRAuthMode +// --------------------------------------------------------------------------- + +describe("parseANRAuthMode", () => { + test("defaults to interactive when variable is unset", () => { + expect(parseANRAuthMode({})).toBe("interactive") + }) + + test('returns "interactive" when explicitly set', () => { + expect(parseANRAuthMode({ OPENCODE_ANR_AUTH_MODE: "interactive" })).toBe("interactive") + }) + + test('returns "token" when set to token', () => { + expect(parseANRAuthMode({ OPENCODE_ANR_AUTH_MODE: "token" })).toBe("token") + }) + + test("throws a CI-friendly error for unknown values", () => { + expect(() => parseANRAuthMode({ OPENCODE_ANR_AUTH_MODE: "browser" })).toThrow( + /Unknown OPENCODE_ANR_AUTH_MODE value/, + ) + }) + + test("error message includes the bad value and valid options", () => { + let msg = "" + try { + parseANRAuthMode({ OPENCODE_ANR_AUTH_MODE: "magic" }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).toContain('"magic"') + expect(msg).toContain("interactive") + expect(msg).toContain("token") + }) +}) + +// --------------------------------------------------------------------------- +// validateTokenModeEnv +// --------------------------------------------------------------------------- + +describe("validateTokenModeEnv", () => { + test("fails with actionable message when OPENCODE_ANR_ID_TOKEN is missing", () => { + const result = validateTokenModeEnv({}) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain("OPENCODE_ANR_ID_TOKEN") + expect(result.message).toContain("is not set") + // Must NOT contain secret values (there are none to leak here, but check structure) + expect(result.message).not.toMatch(/eyJ/) + } + }) + + test("strips whitespace mangled into tokens by terminal copy/paste", () => { + const wrapped = FAKE_JWT.slice(0, 20) + "\n" + FAKE_JWT.slice(20, 45) + "\r\n " + FAKE_JWT.slice(45) + const result = validateTokenModeEnv({ + OPENCODE_ANR_ID_TOKEN: wrapped, + OPENCODE_ANR_REFRESH_TOKEN: "refresh-\npart\n", + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.idToken).toBe(FAKE_JWT) + expect(result.refreshToken).toBe("refresh-part") + } + }) + + test("whitespace-only tokens are treated as unset", () => { + const result = validateTokenModeEnv({ OPENCODE_ANR_REFRESH_TOKEN: " \n " }) + expect(result.ok).toBe(false) + }) + + test("succeeds with only a refresh token (refresh-first bootstrap)", () => { + const result = validateTokenModeEnv({ OPENCODE_ANR_REFRESH_TOKEN: "refresh-only" }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.idToken).toBe("") + expect(result.refreshToken).toBe("refresh-only") + expect(result.staticAWSCreds).toBeUndefined() + } + }) + + test("failure message mentions the refresh token option", () => { + const result = validateTokenModeEnv({}) + expect(result.ok).toBe(false) + if (!result.ok) { + expect(result.message).toContain("OPENCODE_ANR_REFRESH_TOKEN") + expect(result.message).toContain("recommended for CI") + } + }) + + test("succeeds when ID token is provided", () => { + const result = validateTokenModeEnv({ OPENCODE_ANR_ID_TOKEN: FAKE_JWT }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.idToken).toBe(FAKE_JWT) + expect(result.refreshToken).toBeUndefined() + expect(result.staticAWSCreds).toBeUndefined() + } + }) + + test("includes refresh token when provided", () => { + const result = validateTokenModeEnv({ + OPENCODE_ANR_ID_TOKEN: FAKE_JWT, + OPENCODE_ANR_REFRESH_TOKEN: "refresh-abc", + }) + expect(result.ok).toBe(true) + if (result.ok) expect(result.refreshToken).toBe("refresh-abc") + }) + + test("succeeds with full static AWS creds (no ID token required)", () => { + const result = validateTokenModeEnv({ + AWS_ACCESS_KEY_ID: "AKIAIOSFODNN7EXAMPLE", + AWS_SECRET_ACCESS_KEY: "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY", + AWS_SESSION_TOKEN: "AQoXnyc4lcK4w4OIaYnuFgIa...", + AWS_REGION: "us-gov-west-1", + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.staticAWSCreds).toBeDefined() + expect(result.staticAWSCreds?.accessKeyId).toBe("AKIAIOSFODNN7EXAMPLE") + expect(result.staticAWSCreds?.region).toBe("us-gov-west-1") + } + }) + + test("static creds path accepts an ID token too (for telemetry context)", () => { + const result = validateTokenModeEnv({ + OPENCODE_ANR_ID_TOKEN: FAKE_JWT, + AWS_ACCESS_KEY_ID: "AKID", + AWS_SECRET_ACCESS_KEY: "SECRET", + AWS_SESSION_TOKEN: "TOKEN", + }) + expect(result.ok).toBe(true) + if (result.ok) { + expect(result.idToken).toBe(FAKE_JWT) + expect(result.staticAWSCreds).toBeDefined() + } + }) + + test("error message is actionable and does not contain secret values", () => { + const result = validateTokenModeEnv({}) + expect(result.ok).toBe(false) + if (!result.ok) { + // Actionable: tells the user what to set + expect(result.message).toContain("OPENCODE_ANR_ID_TOKEN") + expect(result.message).toContain("AWS_ACCESS_KEY_ID") + // No secrets leaked (nothing to leak in this case — verify structure) + expect(result.message).not.toContain("password") + expect(result.message).not.toContain("secret") + } + }) +}) + +// --------------------------------------------------------------------------- +// resolveTokenModeCredentials — static creds path (no network) +// --------------------------------------------------------------------------- + +describe("resolveTokenModeCredentials — static creds", () => { + test("returns static creds without calling exchange when all AWS vars are set", async () => { + const env = { + OPENCODE_ANR_ID_TOKEN: FAKE_JWT, + AWS_ACCESS_KEY_ID: "STATIC_KEY", + AWS_SECRET_ACCESS_KEY: "STATIC_SECRET", + AWS_SESSION_TOKEN: "STATIC_TOKEN", + AWS_REGION: "us-gov-west-1", + } + const result = await resolveTokenModeCredentials(minimalConfig(), env) + expect(result.credentialSource).toBe("static") + expect(result.awsCredentials.accessKeyId).toBe("STATIC_KEY") + expect(result.awsCredentials.secretAccessKey).toBe("STATIC_SECRET") + expect(result.awsCredentials.sessionToken).toBe("STATIC_TOKEN") + expect(result.idToken).toBe(FAKE_JWT) + }) + + test("passes refresh token through on static path", async () => { + const env = { + OPENCODE_ANR_ID_TOKEN: FAKE_JWT, + OPENCODE_ANR_REFRESH_TOKEN: "myrefresh", + AWS_ACCESS_KEY_ID: "K", + AWS_SECRET_ACCESS_KEY: "S", + AWS_SESSION_TOKEN: "T", + } + const result = await resolveTokenModeCredentials(minimalConfig(), env) + expect(result.refreshToken).toBe("myrefresh") + }) +}) + +// --------------------------------------------------------------------------- +// resolveTokenModeCredentials — exchange path (mocked) +// --------------------------------------------------------------------------- + +describe("resolveTokenModeCredentials — exchange path", () => { + test("calls exchangeTokenForAWSCredentials when no static creds", async () => { + // We mock the aws-federation module to avoid real network calls. + // Because Bun's module mock replaces the module at import time, we test + // the integration by checking the error thrown when the exchange fails — + // the error message should come from our wrapper, not raw AWS SDK noise. + const env = { OPENCODE_ANR_ID_TOKEN: FAKE_JWT } + + // With no static creds and a fake JWT, the exchange will throw (no real + // AWS endpoint). We verify the error is wrapped in a CI-friendly message. + let thrown = false + try { + await resolveTokenModeCredentials(minimalConfig(), env) + } catch (e) { + thrown = true + const msg = (e as Error).message + // Should be wrapped with our CI-friendly prefix + expect(msg).toContain("[ANR] Token auth: federation exchange failed") + // Must NOT contain the raw JWT value + expect(msg).not.toContain(FAKE_JWT) + // Should include actionable hints + expect(msg).toContain("OPENCODE_ANR_ID_TOKEN") + } + expect(thrown).toBe(true) + }) + + test("throws CI-friendly error when ID token is missing", async () => { + await expect(resolveTokenModeCredentials(minimalConfig(), {})).rejects.toThrow( + /missing required environment variable/, + ) + }) + + test("throws CI-friendly error when ID token is not a valid JWT shape", async () => { + await expect( + resolveTokenModeCredentials(minimalConfig(), { OPENCODE_ANR_ID_TOKEN: "not-a-jwt" }), + ).rejects.toThrow(/does not appear to be a valid JWT/) + }) + + test("error messages do not contain the token value", async () => { + const badToken = "part1.part2" // only two parts — invalid JWT + let msg = "" + try { + await resolveTokenModeCredentials(minimalConfig(), { OPENCODE_ANR_ID_TOKEN: badToken }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).not.toContain(badToken) + expect(msg).toContain("Token length:") + }) +}) + +// --------------------------------------------------------------------------- +// resolveTokenModeCredentials — refresh-first bootstrap (fetch stubbed) +// --------------------------------------------------------------------------- + +describe("resolveTokenModeCredentials — refresh-first bootstrap", () => { + const originalFetch = globalThis.fetch + + beforeEach(() => { + globalThis.fetch = originalFetch + }) + + afterEach(() => { + globalThis.fetch = originalFetch + }) + + test("exchanges the refresh token for a fresh ID token before federation", async () => { + const calls: { url: string; body: string }[] = [] + globalThis.fetch = (async (url: URL | RequestInfo, init?: RequestInit) => { + calls.push({ url: String(url), body: String(init?.body ?? "") }) + return new Response( + JSON.stringify({ id_token: FAKE_JWT, access_token: "access", refresh_token: "rotated", expires_in: 3600 }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ) + }) as unknown as typeof fetch + + // The refresh call succeeds (stubbed); the subsequent federation exchange + // hits a fake identity pool and fails — proving refresh-first ordering. + let msg = "" + try { + await resolveTokenModeCredentials(minimalConfig(), { OPENCODE_ANR_REFRESH_TOKEN: "long-lived-refresh" }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).toContain("federation exchange failed") + expect(calls[0].url).toBe("https://auth.govcloud.example.com/oauth2/token") + expect(calls[0].body).toContain("grant_type=refresh_token") + expect(calls[0].body).toContain("long-lived-refresh") + }) + + test("fails with actionable error when refresh fails and no ID token fallback exists", async () => { + globalThis.fetch = (async () => new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch + + let msg = "" + try { + await resolveTokenModeCredentials(minimalConfig(), { OPENCODE_ANR_REFRESH_TOKEN: "expired-refresh" }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).toContain("refresh token exchange failed") + expect(msg).toContain("OPENCODE_ANR_REFRESH_TOKEN") + expect(msg).not.toContain("expired-refresh") + }) + + test("falls back to the provided ID token when refresh fails", async () => { + globalThis.fetch = (async () => new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch + + // Refresh fails, so resolution falls back to exchanging FAKE_JWT directly — + // which then fails at the (fake) identity pool with the federation error. + let msg = "" + try { + await resolveTokenModeCredentials(minimalConfig(), { + OPENCODE_ANR_ID_TOKEN: FAKE_JWT, + OPENCODE_ANR_REFRESH_TOKEN: "expired-refresh", + }) + } catch (e) { + msg = (e as Error).message + } + expect(msg).toContain("federation exchange failed") + expect(msg).not.toContain("refresh token exchange failed") + }) +}) diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d1..1be2fb5d3191 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os" import { join } from "node:path" import { getCACertificates, setDefaultCACertificates } from "node:tls" import type { Event } from "electron" -import { app } from "electron" +import { app, shell } from "electron" import { Deferred, Effect, Fiber } from "effect" import contextMenu from "electron-context-menu" @@ -350,7 +350,14 @@ const main = Effect.gen(function* () { spawnLocalServer(hostname, port, password, { userDataPath: app.getPath("userData"), onStdout: (message) => writeLog("server", "stdout", { message }), - onStderr: (message) => writeLog("server", "stderr", { message }, "warn"), + onStderr: (message) => { + const authUrl = message.startsWith("auth-url:") ? message.slice("auth-url:".length).trim() : null + if (authUrl) { + void shell.openExternal(authUrl) + return + } + writeLog("server", "stderr", { message }, "warn") + }, onExit: (code) => writeLog("utility", "sidecar exited", { code }, "warn"), }), ) diff --git a/packages/desktop/src/main/sidecar.ts b/packages/desktop/src/main/sidecar.ts index 246871fb2b4c..537642726a4c 100644 --- a/packages/desktop/src/main/sidecar.ts +++ b/packages/desktop/src/main/sidecar.ts @@ -54,7 +54,19 @@ async function start(command: StartCommand) { ensureLoopbackNoProxy() useSystemCertificates() useEnvProxy() - const { Server } = await import("virtual:opencode-server") + const { Server, initializeANR, detectANR, selectEnvFile, clearStaleEnv } = await import("virtual:opencode-server") + + // Run the ANR boot sequence (OIDC auth, AWS credentials, quota) when in ANR mode. + // This mirrors what main() does in the CLI path — the sidecar skips main() entirely + // so we must run it here before the server starts. + if (!process.env.OPENCODE_FLAVOR && detectANR()) { + process.env.OPENCODE_FLAVOR = "anr" + } + if (process.env.OPENCODE_FLAVOR === "anr" && !process.env.OPENCODE_ANR_SKIP_AUTH) { + const envFile = process.env.OPENCODE_ANR_ENV_FILE ?? (await selectEnvFile()) + clearStaleEnv() + await initializeANR(envFile) + } listener = await Server.listen({ port: command.port, diff --git a/packages/opencode/script/anr-provision-ci-token.ts b/packages/opencode/script/anr-provision-ci-token.ts new file mode 100644 index 000000000000..c32adeca381e --- /dev/null +++ b/packages/opencode/script/anr-provision-ci-token.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env bun +// ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"} +/** + * Provision a long-lived Cognito refresh token for ANR CI token auth. + * + * Runs the standard interactive OIDC/PKCE login once and prints the resulting + * refresh token so it can be stored as the ANR_REFRESH_TOKEN GitHub Actions + * secret (see docs/anr-token-auth.md). Re-run whenever the stored token + * expires or is revoked. + * + * Usage: + * bun run script/anr-provision-ci-token.ts [path/to/.env.flavor] + * + * e.g. from packages/opencode: + * bun run script/anr-provision-ci-token.ts ../../.opencode/.env.commercial + * + * The auth URL is printed rather than auto-opened so the login can be + * completed in a private/incognito window as the CI service account — + * an existing hosted-UI session in the default browser would otherwise + * silently mint a token for the wrong user. + */ +import { getValidatedANRConfig, authenticateWithOIDC } from "@opencode-ai/anr-core" + +// Desktop mode makes authenticateWithOIDC print "auth-url:" to stderr +// instead of opening the default browser. +process.env.OPENCODE_CLIENT = "desktop" + +const config = await getValidatedANRConfig(process.argv[2], false) + +console.error("ANR CI refresh-token provisioning") +console.error(` provider domain: ${config.providerDomain}`) +console.error(` app client: ${config.clientId}`) +console.error("") +console.error("An auth-url line will appear below. Open that URL in a PRIVATE/incognito") +console.error("window and log in as the CI service account (not your own user).") +console.error("Waiting for login callback on http://localhost:8400 ...") +console.error("") + +const tokens = await authenticateWithOIDC(config) + +if (!tokens.refreshToken) { + console.error("❌ Login succeeded but Cognito returned no refresh token.") + console.error(" Check that the app client has ALLOW_REFRESH_TOKEN_AUTH enabled.") + process.exit(1) +} + +console.error("✅ Login complete. Refresh token follows on stdout (single line):") +console.error("") +console.log(tokens.refreshToken) +console.error("") +console.error("Next steps:") +console.error(" 1. Store it: repo Settings → Secrets and variables → Actions →") +console.error(" new secret ANR_REFRESH_TOKEN (or: gh secret set ANR_REFRESH_TOKEN)") +console.error(" 2. Clear this terminal / your clipboard afterwards.") +console.error(" 3. Token lifetime = the app client's refresh token expiration at the") +console.error(" time of this login. Set a reminder to re-provision before then.") diff --git a/packages/opencode/script/anr-token-auth-preflight.ts b/packages/opencode/script/anr-token-auth-preflight.ts new file mode 100644 index 000000000000..43ffdebab56f --- /dev/null +++ b/packages/opencode/script/anr-token-auth-preflight.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env bun +// ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"} +/** + * Lightweight preflight for the ANR token-auth CI smoke job. + * + * Attempts refresh-first credential resolution directly (no CLI build) + * against the configured Cognito Identity Pool. Gates the more expensive + * smoke job: when the pool has been deleted/rotated server-side + * (ResourceNotFoundException), the smoke job is skipped rather than + * reported as a false regression. Any other failure still lets the smoke + * job run so it surfaces full diagnostics — this only suppresses the one + * known, external, non-code failure mode. + * + * Usage: + * bun run script/anr-token-auth-preflight.ts path/to/.env.flavor + * + * Prints exactly one line: AUTH_OK | SKIP_STALE_IDENTITY_POOL | PREFLIGHT_ERROR + */ +import { getValidatedANRConfig, resolveTokenModeCredentials } from "@opencode-ai/anr-core" + +try { + const config = await getValidatedANRConfig(process.argv[2], true) + await resolveTokenModeCredentials(config, process.env) + console.log("AUTH_OK") +} catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (/IdentityPool.*not found/i.test(message) || /ResourceNotFoundException/.test(message)) { + console.log("SKIP_STALE_IDENTITY_POOL") + console.error(message) + } else { + console.log("PREFLIGHT_ERROR") + console.error(message) + } +} diff --git a/packages/opencode/src/anr-boot.ts b/packages/opencode/src/anr-boot.ts new file mode 100644 index 000000000000..ee94c0838387 --- /dev/null +++ b/packages/opencode/src/anr-boot.ts @@ -0,0 +1,446 @@ +// ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"} +/** + * ANR boot sequence for the Electron desktop sidecar. + * + * This module is intentionally free of CLI/TUI imports (no yargs, no @opentui, + * no TUI commands) so it can be included in the node bundle (src/node.ts) + * without pulling in browser-only code. + * + * Logic mirrors main() in src/index.ts — the desktop sidecar skips main() + * entirely and calls Server.listen() directly, so we must run the ANR boot + * sequence here before the server starts. + */ + +import { existsSync, readdirSync, readFileSync } from "fs" +import path from "path" +import { fileURLToPath } from "url" +import { platform, arch, release } from "os" +import { randomUUID } from "crypto" +import { + getValidatedANRConfig, + authenticateWithOIDC, + refreshOIDCTokens, + exchangeTokenForAWSCredentials, + parseANRAuthMode, + resolveTokenModeCredentials, + initializeOTEL, + shutdownOTEL, + trackSessionStart, + trackSessionEnd, + clearOTELLogs, + initializeAuditLogger, + logAuthEvent, + logSessionStart, + logSessionEnd, + logQuotaCheck, + checkQuota, + findEnvFiles, + saveLastEnv, + getLastEnv, + clearStaleEnv, + type TelemetryContext, +} from "@opencode-ai/anr-core" +import * as ANRRefresh from "./auth/anr-refresh" + +export { clearStaleEnv } + +const ANR_MARKERS = ["OPENCODE_API_ENDPOINT", "PROVIDER_DOMAIN", "IDENTITY_POOL_ID"] + +export function detectANR(): boolean { + if (process.env.OPENCODE_FLAVOR === "anr") return true + const home = process.env.HOME || process.env.USERPROFILE + if (!home) return false + const globalDir = + process.platform === "win32" + ? path.resolve(process.env.PROGRAMDATA || "C:\\ProgramData", "opencode") + : "/etc/opencode" + const parts = fileURLToPath(import.meta.url).split(path.sep + "src" + path.sep) + const pkg = parts.length > 1 ? parts[0] : undefined + const root = pkg ? path.resolve(pkg, "../..") : undefined + const dirs = [ + path.join(process.cwd(), ".opencode"), + ...(root && root !== process.cwd() ? [path.join(root, ".opencode")] : []), + path.join(home, ".opencode"), + globalDir, + ] + for (const dir of dirs) { + if (!existsSync(dir)) continue + for (const name of readdirSync(dir)) { + if (name !== ".env" && !name.startsWith(".env.")) continue + try { + const content = readFileSync(path.join(dir, name), "utf-8") + const found = content.split("\n").some((line: string) => { + const trimmed = line.trim() + return ANR_MARKERS.some((m) => trimmed.startsWith(m)) + }) + if (found) return true + } catch {} + } + } + return false +} + +export async function selectEnvFile(): Promise { + const home = process.env.HOME || process.env.USERPROFILE || "~" + const srcParts = fileURLToPath(import.meta.url).split(path.sep + "src" + path.sep) + const srcPkg = srcParts.length > 1 ? srcParts[0] : undefined + const root = srcPkg ? path.resolve(srcPkg, "../..") : undefined + const globalDir = + process.platform === "win32" + ? path.resolve(process.env.PROGRAMDATA || "C:\\ProgramData", "opencode") + : "/etc/opencode" + const dirs = [ + path.join(process.cwd(), ".opencode"), + ...(root && root !== process.cwd() ? [path.join(root, ".opencode")] : []), + path.resolve(home, ".opencode"), + globalDir, + ] + + const files = findEnvFiles(dirs) + + // If last-used env file still exists, prefer it directly — avoids cwd + // resolution issues when running from within the Electron sidecar process. + const last = getLastEnv() + if (last && existsSync(last)) { + // Make sure it's in the discovered list so non-interactive path still works + const inList = files.some((f) => f.path === last) + if (!inList) return last + } + + if (files.length === 0) return undefined + if (files.length === 1) { + saveLastEnv(files[0].path) + return files[0].path + } + + const lastIdx = last ? files.findIndex((f) => f.path === last) : -1 + + // Non-interactive: use last or first (desktop sidecar has no TTY) + if (!process.stderr.isTTY) { + const idx = lastIdx >= 0 ? lastIdx : 0 + return files[idx]?.path + } + + process.stderr.write("\nSelect environment:\n") + for (let i = 0; i < files.length; i++) { + const marker = i === lastIdx ? " (last used)" : "" + process.stderr.write(` ${i + 1}. ${files[i]?.display ?? files[i]?.name}${marker}\n`) + } + + const rl = await import("readline") + const prompt = rl.createInterface({ input: process.stdin, output: process.stderr }) + const dflt = lastIdx >= 0 ? lastIdx + 1 : 1 + const answer = await new Promise((ok) => { + prompt.question(`Choice [${dflt}]: `, (a) => { + prompt.close() + ok(a.trim()) + }) + }) + + const choice = answer === "" ? dflt : parseInt(answer, 10) + if (Number.isNaN(choice) || choice < 1 || choice > files.length) { + process.stderr.write("Invalid selection, using default.\n") + return files[dflt - 1]?.path + } + + const selected = files[choice - 1]?.path + if (selected) saveLastEnv(selected) + return selected +} + +export async function initializeANR(envFile?: string): Promise { + clearOTELLogs() + + console.error("\n🚀 OpenCode ANR\n") + process.stderr.write("") + + const config = await getValidatedANRConfig(envFile, false) + + if (envFile) process.env.OPENCODE_ANR_ENV_FILE = envFile + + const sessionId = randomUUID() + + // Authenticate — branch on auth mode + const authMode = parseANRAuthMode(process.env) + console.error(`🔐 Authenticating... (mode: ${authMode})`) + + let tokens: { idToken: string; accessToken: string; refreshToken?: string; expiresIn?: number } + let awsCredentials: Awaited> + let credentialSource: "interactive" | "static" | "exchange" | "refresh-exchange" = "interactive" + + if (authMode === "token") { + let result + try { + result = await resolveTokenModeCredentials(config, process.env) + } catch (err) { + console.error("❌ Token auth failed:", err instanceof Error ? err.message : err) + process.exit(1) + } + tokens = { idToken: result.idToken, accessToken: "", refreshToken: result.refreshToken } + awsCredentials = result.awsCredentials + credentialSource = result.credentialSource + console.error(`✅ Token auth resolved (source: ${credentialSource})`) + console.error(` - idToken length: ${tokens.idToken?.length || 0}`) + console.error(` - refreshToken: ${tokens.refreshToken ? "present" : "not provided"}`) + } else { + let oidcTokens + try { + oidcTokens = await authenticateWithOIDC(config) + } catch (err) { + console.error("❌ Authentication failed:", err instanceof Error ? err.message : err) + process.exit(1) + } + tokens = oidcTokens + console.error("✅ Authenticated") + console.error(` - idToken length: ${tokens.idToken?.length || 0}`) + console.error(` - refreshToken: ${tokens.refreshToken ? "present" : "not provided"}`) + + try { + awsCredentials = await exchangeTokenForAWSCredentials(tokens.idToken, config) + } catch (err) { + console.error("❌ AWS credential exchange failed:", err instanceof Error ? err.message : err) + process.exit(1) + } + console.error("✅ AWS credentials obtained") + } + + const telemetryContext = buildTelemetryContext(tokens.idToken, config, sessionId) + + process.env.AWS_ACCESS_KEY_ID = awsCredentials.accessKeyId + process.env.AWS_SECRET_ACCESS_KEY = awsCredentials.secretAccessKey + process.env.AWS_SESSION_TOKEN = awsCredentials.sessionToken + process.env.AWS_REGION = config.awsRegion + delete process.env.AWS_PROFILE + + if (awsCredentials.expiration) { + const minutesUntilExpiry = Math.round((awsCredentials.expiration.getTime() - Date.now()) / 60000) + console.error(`🔄 Credentials expire in ${minutesUntilExpiry} min`) + } + + let currentRefreshToken = tokens.refreshToken + ANRRefresh.init({ + stsExpiration: awsCredentials.expiration?.getTime(), + async refresh() { + let refreshedTokens + + if (authMode === "token") { + // Token mode: use refresh token if available; never open a browser. + if (currentRefreshToken) { + try { + refreshedTokens = await refreshOIDCTokens(config, currentRefreshToken) + console.error("🔄 [token mode] Silently refreshed OIDC tokens") + } catch { + console.error("❌ [token mode] Refresh token exchange failed. No interactive fallback in CI.") + console.error( + " Credentials will remain in use until STS expiry. Check that OPENCODE_ANR_REFRESH_TOKEN is still valid.", + ) + return { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", + sessionToken: process.env.AWS_SESSION_TOKEN || "", + } + } + } else { + // No refresh token — warn once, keep using existing creds until expiry. + console.error("⚠️ [token mode] No OPENCODE_ANR_REFRESH_TOKEN provided. Token refresh is disabled.") + console.error(" AWS credentials will remain valid until STS expiry. No interactive fallback will occur.") + return { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", + sessionToken: process.env.AWS_SESSION_TOKEN || "", + } + } + } else { + // Interactive mode: try silent refresh first, fall back to browser. + if (currentRefreshToken) { + try { + refreshedTokens = await refreshOIDCTokens(config, currentRefreshToken) + console.error("🔄 Silently refreshed OIDC tokens") + } catch { + console.error("🔄 Silent token refresh failed, opening browser for re-authentication...") + refreshedTokens = await authenticateWithOIDC(config) + } + } else { + console.error("🔄 No refresh token available, opening browser for re-authentication...") + refreshedTokens = await authenticateWithOIDC(config) + } + } + + const creds = await exchangeTokenForAWSCredentials(refreshedTokens.idToken, config) + currentRefreshToken = refreshedTokens.refreshToken ?? currentRefreshToken + console.error("✅ AWS credentials refreshed successfully") + return { + accessKeyId: creds.accessKeyId, + secretAccessKey: creds.secretAccessKey, + sessionToken: creds.sessionToken, + idToken: refreshedTokens.idToken, + expiration: creds.expiration, + refreshToken: refreshedTokens.refreshToken, + } + }, + }) + + if (config.modelsApiEndpoint) { + process.env.OPENCODE_API_ENDPOINT = config.modelsApiEndpoint + } + + initializeAuditLogger(config, { + accessKeyId: awsCredentials.accessKeyId, + secretAccessKey: awsCredentials.secretAccessKey, + sessionToken: awsCredentials.sessionToken, + }) + await logAuthEvent(config, telemetryContext.userId, "success", telemetryContext) + + process.env.OPENCODE_ANR_USER_ID = telemetryContext.userId + process.env.OPENCODE_ANR_ID_TOKEN = tokens.idToken + if (telemetryContext.userEmail) process.env.OPENCODE_ANR_USER_EMAIL = telemetryContext.userEmail + if (telemetryContext.userName) process.env.OPENCODE_ANR_USER_NAME = telemetryContext.userName + if (telemetryContext.osType) process.env.OPENCODE_ANR_OS_TYPE = telemetryContext.osType + if (telemetryContext.osVersion) process.env.OPENCODE_ANR_OS_VERSION = telemetryContext.osVersion + if (telemetryContext.terminalType) process.env.OPENCODE_ANR_TERMINAL_TYPE = telemetryContext.terminalType + if (telemetryContext.sessionId) process.env.OPENCODE_ANR_SESSION_ID = telemetryContext.sessionId + if (telemetryContext.department) process.env.OPENCODE_ANR_DEPARTMENT = telemetryContext.department + if (telemetryContext.teamId) process.env.OPENCODE_ANR_TEAM_ID = telemetryContext.teamId + if (telemetryContext.costCenter) process.env.OPENCODE_ANR_COST_CENTER = telemetryContext.costCenter + if (telemetryContext.manager) process.env.OPENCODE_ANR_MANAGER = telemetryContext.manager + if (telemetryContext.role) process.env.OPENCODE_ANR_ROLE = telemetryContext.role + if (telemetryContext.location) process.env.OPENCODE_ANR_LOCATION = telemetryContext.location + if (telemetryContext.organization) process.env.OPENCODE_ANR_ORGANIZATION = telemetryContext.organization + if (telemetryContext.accountId) process.env.OPENCODE_ANR_ACCOUNT_ID = telemetryContext.accountId + + if (config.enableTelemetry) { + initializeOTEL(config, telemetryContext) + trackSessionStart(telemetryContext.userId) + } + try { + await logSessionStart(config, telemetryContext.userId, telemetryContext, { sessionId }) + } catch (err) { + console.error("⚠️ Session logging failed:", err instanceof Error ? err.message : err) + } + + let quotaResult + try { + quotaResult = await checkQuota( + { + userEmail: telemetryContext.userEmail || telemetryContext.userId, + organization: telemetryContext.organization, + teamId: telemetryContext.teamId, + }, + config.modelsApiEndpoint, + config.quotaFailMode, + process.env.OPENCODE_ANR_ID_TOKEN || tokens.idToken, + ) + } catch (err) { + if (config.quotaFailMode === "open") { + console.error("⚠️ Quota service unavailable — continuing with limited tracking.") + } else { + console.error("❌ Unable to verify quota (service unavailable). Access denied for safety.") + process.exit(1) + } + } + + logQuotaCheck(config, telemetryContext.userId, !!quotaResult?.usage?.allowed, telemetryContext, { + daily: quotaResult?.usage?.dailyUsagePercent, + monthly: quotaResult?.usage?.monthlyUsagePercent, + }) + + if (quotaResult && !quotaResult.usage.allowed) { + console.error("❌ Quota exceeded. Access denied.") + await logSessionEnd(config, telemetryContext.userId, 0, telemetryContext) + if (config.enableTelemetry) { + trackSessionEnd(telemetryContext.userId, 0) + await shutdownOTEL() + } + process.exit(1) + } + + if (quotaResult?.usage) { + console.error( + `📊 Quota: ${Math.round(quotaResult.usage.dailyUsagePercent)}% daily, ${Math.round(quotaResult.usage.monthlyUsagePercent)}% monthly`, + ) + process.env.OPENCODE_ANR_QUOTA_DAILY_TOKENS = String(quotaResult.usage.dailyTokens) + process.env.OPENCODE_ANR_QUOTA_MONTHLY_TOKENS = String(quotaResult.usage.monthlyTokens) + process.env.OPENCODE_ANR_QUOTA_DAILY_LIMIT = String(quotaResult.policy.dailyTokenLimit) + process.env.OPENCODE_ANR_QUOTA_MONTHLY_LIMIT = String(quotaResult.policy.monthlyTokenLimit) + process.env.OPENCODE_ANR_QUOTA_DAILY_PERCENT = String(quotaResult.usage.dailyUsagePercent) + process.env.OPENCODE_ANR_QUOTA_MONTHLY_PERCENT = String(quotaResult.usage.monthlyUsagePercent) + process.env.OPENCODE_ANR_QUOTA_WARNING_LEVEL = quotaResult.usage.warningLevel + process.env.OPENCODE_ANR_QUOTA_ALLOWED = String(quotaResult.usage.allowed) + process.env.OPENCODE_ANR_USER_EMAIL = telemetryContext.userEmail || telemetryContext.userId + } + + ;(global as any).__ANR_TELEMETRY_CONTEXT__ = telemetryContext + + const exitHandler = async () => { + const duration = (Date.now() - Date.now()) / 1000 + if (config.enableTelemetry) { + trackSessionEnd(telemetryContext.userId, duration) + await shutdownOTEL() + } + await logSessionEnd(config, telemetryContext.userId, duration, telemetryContext) + } + + process.on("SIGINT", async () => { + await exitHandler() + process.exit(0) + }) + + process.on("SIGTERM", async () => { + await exitHandler() + process.exit(0) + }) +} + +function detectTerminalType(): string { + if (process.env.WT_SESSION) return "windows-terminal" + if (process.env.ITERM_SESSION_ID) return "iterm2" + if (process.env.GNOME_TERMINAL_SCREEN) return "gnome-terminal" + if (process.env.VTE_VERSION) return "vte-based" + if (process.env.KITTY_WINDOW_ID) return "kitty" + if (process.env.TERM_PROGRAM === "iTerm.app") return "iterm2" + if (process.env.TERM === "screen" && process.env.TMUX) return "tmux" + if (process.env.TERM === "screen") return "screen" + if (process.env.WSL_DISTRO_NAME) return `wsl-${process.env.WSL_DISTRO_NAME}` + if (process.env.WSL_INTEROP) return "wsl" + return process.env.TERM || "unknown" +} + +function extractTokenClaims(idToken: string): Record { + const parts = idToken.split(".") + if (parts.length !== 3) return {} + try { + return JSON.parse(Buffer.from(parts[1]!, "base64url").toString()) + } catch { + return {} + } +} + +function buildTelemetryContext(idToken: string, config: any, sessionId: string): TelemetryContext { + const claims = extractTokenClaims(idToken) + const userId = claims.sub || claims.cognito_username || "unknown" + + const ctx: TelemetryContext = { + userId, + userEmail: claims.email, + userName: claims.name || claims.preferred_username, + osType: platform(), + osVersion: release(), + hostArch: arch(), + terminalType: detectTerminalType(), + sessionId, + organization: claims.organization || claims["custom:organization"], + department: claims["custom:department"], + costCenter: claims["custom:cost_center"], + } + + if (config.department) ctx.department = config.department + if (config.teamId) ctx.teamId = config.teamId + if (config.costCenter) ctx.costCenter = config.costCenter + if (config.manager) ctx.manager = config.manager + if (config.role) ctx.role = config.role + if (config.location) ctx.location = config.location + if (config.organization) ctx.organization = config.organization + if (config.accountId) ctx.accountId = config.accountId + + return ctx +} diff --git a/packages/opencode/src/index.ts b/packages/opencode/src/index.ts index 24f5afa5fc9f..50f777bf960d 100644 --- a/packages/opencode/src/index.ts +++ b/packages/opencode/src/index.ts @@ -1,3 +1,4 @@ +// ANRCODE_CHANGE {"issue":310,"branch":"anr-token-based-auth","date":"2026-07-29"} // DANGER ZONE: Shared across CLI + ANR + Desktop sidecar surfaces. // Changes here must be tested with all flavors. See /AGENTS.md#surface-flavor-rules import yargs from "yargs" @@ -38,6 +39,8 @@ import { authenticateWithOIDC, refreshOIDCTokens, exchangeTokenForAWSCredentials, + parseANRAuthMode, + resolveTokenModeCredentials, initializeOTEL, shutdownOTEL, trackSessionStart, @@ -234,7 +237,7 @@ function buildTelemetryContext(idToken: string, config: any, sessionId: string): /** * Initialize ANR mode: authentication, quota, telemetry */ -async function initializeANR(envFile?: string): Promise { +export async function initializeANR(envFile?: string): Promise { // Clear OTEL logs from previous session for clean debugging clearOTELLogs() @@ -250,40 +253,60 @@ async function initializeANR(envFile?: string): Promise { // Generate session ID const sessionId = randomUUID() - // Authenticate with OIDC - console.error("🔐 Authenticating...") - let tokens - try { - tokens = await authenticateWithOIDC(config) - } catch (err) { - console.error("❌ Authentication failed:", err instanceof Error ? err.message : err) - process.exit(1) - } - console.error("✅ Authenticated") - console.error("📍 Debug: Received tokens from OIDC") - console.error(` - idToken length: ${tokens.idToken?.length || 0}`) - console.error(` - accessToken length: ${tokens.accessToken?.length || 0}`) - console.error(` - refreshToken: ${tokens.refreshToken ? "present" : "not provided"}`) - console.error(` - expiresIn: ${tokens.expiresIn ?? "not provided"}s`) - - // Build telemetry context - const telemetryContext = buildTelemetryContext(tokens.idToken, config, sessionId) + // Authenticate — branch on auth mode + const authMode = parseANRAuthMode(process.env) + console.error(`🔐 Authenticating... (mode: ${authMode})`) - // Exchange token for AWS credentials - console.error("💱 Exchanging token for AWS credentials...") - let awsCredentials - try { - awsCredentials = await exchangeTokenForAWSCredentials(tokens.idToken, config) - } catch (err) { - console.error("❌ AWS credential exchange failed:", err instanceof Error ? err.message : err) - process.exit(1) + let tokens: { idToken: string; accessToken: string; refreshToken?: string; expiresIn?: number } + let awsCredentials: Awaited> + let credentialSource: "interactive" | "static" | "exchange" | "refresh-exchange" = "interactive" + + if (authMode === "token") { + let result + try { + result = await resolveTokenModeCredentials(config, process.env) + } catch (err) { + console.error("❌ Token auth failed:", err instanceof Error ? err.message : err) + process.exit(1) + } + tokens = { idToken: result.idToken, accessToken: "", refreshToken: result.refreshToken } + awsCredentials = result.awsCredentials + credentialSource = result.credentialSource + console.error(`✅ Token auth resolved (source: ${credentialSource})`) + console.error(` - idToken length: ${tokens.idToken?.length || 0}`) + console.error(` - refreshToken: ${tokens.refreshToken ? "present" : "not provided"}`) + } else { + try { + tokens = await authenticateWithOIDC(config) + } catch (err) { + console.error("❌ Authentication failed:", err instanceof Error ? err.message : err) + process.exit(1) + } + console.error("✅ Authenticated") + console.error("📍 Debug: Received tokens from OIDC") + console.error(` - idToken length: ${tokens.idToken?.length || 0}`) + console.error(` - accessToken length: ${tokens.accessToken?.length || 0}`) + console.error(` - refreshToken: ${tokens.refreshToken ? "present" : "not provided"}`) + console.error(` - expiresIn: ${tokens.expiresIn ?? "not provided"}s`) + + // Exchange token for AWS credentials + console.error("💱 Exchanging token for AWS credentials...") + try { + awsCredentials = await exchangeTokenForAWSCredentials(tokens.idToken, config) + } catch (err) { + console.error("❌ AWS credential exchange failed:", err instanceof Error ? err.message : err) + process.exit(1) + } + console.error("✅ AWS credentials obtained") + console.error("📍 Debug: AWS credentials exchanged") + console.error(` - accessKeyId length: ${awsCredentials.accessKeyId?.length || 0}`) + console.error(` - secretAccessKey length: ${awsCredentials.secretAccessKey?.length || 0}`) + console.error(` - sessionToken length: ${awsCredentials.sessionToken?.length || 0}`) + console.error(` - expiration: ${awsCredentials.expiration?.toISOString() ?? "not provided"}`) } - console.error("✅ AWS credentials obtained") - console.error("📍 Debug: AWS credentials exchanged") - console.error(` - accessKeyId length: ${awsCredentials.accessKeyId?.length || 0}`) - console.error(` - secretAccessKey length: ${awsCredentials.secretAccessKey?.length || 0}`) - console.error(` - sessionToken length: ${awsCredentials.sessionToken?.length || 0}`) - console.error(` - expiration: ${awsCredentials.expiration?.toISOString() ?? "not provided"}`) + + // Build telemetry context from the resolved ID token + const telemetryContext = buildTelemetryContext(tokens.idToken, config, sessionId) // Set AWS credentials in environment for model calls process.env.AWS_ACCESS_KEY_ID = awsCredentials.accessKeyId @@ -299,18 +322,47 @@ async function initializeANR(envFile?: string): Promise { async refresh() { let refreshedTokens - // Try silent refresh first - if (currentRefreshToken) { - try { - refreshedTokens = await refreshOIDCTokens(config, currentRefreshToken) - console.error("🔄 Silently refreshed OIDC tokens") - } catch { - console.error("🔄 Silent token refresh failed, opening browser for re-authentication...") - refreshedTokens = await authenticateWithOIDC(config) + if (authMode === "token") { + // Token mode: use refresh token if available; never open a browser. + if (currentRefreshToken) { + try { + refreshedTokens = await refreshOIDCTokens(config, currentRefreshToken) + console.error("🔄 [token mode] Silently refreshed OIDC tokens") + } catch { + console.error("❌ [token mode] Refresh token exchange failed. No interactive fallback in CI.") + console.error( + " Credentials will remain in use until STS expiry. Check that OPENCODE_ANR_REFRESH_TOKEN is still valid.", + ) + return { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", + sessionToken: process.env.AWS_SESSION_TOKEN || "", + } + } + } else { + // No refresh token — warn once, keep using existing creds until expiry. + console.error("⚠️ [token mode] No OPENCODE_ANR_REFRESH_TOKEN provided. Token refresh is disabled.") + console.error(" AWS credentials will remain valid until STS expiry. No interactive fallback will occur.") + return { + accessKeyId: process.env.AWS_ACCESS_KEY_ID || "", + secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || "", + sessionToken: process.env.AWS_SESSION_TOKEN || "", + } } } else { - console.error("🔄 No refresh token available, opening browser for re-authentication...") - refreshedTokens = await authenticateWithOIDC(config) + // Interactive mode: try silent refresh first, fall back to browser. + if (currentRefreshToken) { + try { + refreshedTokens = await refreshOIDCTokens(config, currentRefreshToken) + console.error("🔄 Silently refreshed OIDC tokens") + } catch { + console.error("🔄 Silent token refresh failed, opening browser for re-authentication...") + refreshedTokens = await authenticateWithOIDC(config) + } + } else { + console.error("🔄 No refresh token available, opening browser for re-authentication...") + refreshedTokens = await authenticateWithOIDC(config) + } } // Exchange new ID token for AWS credentials @@ -533,7 +585,7 @@ process.on("uncaughtException", (e) => { const ANR_MARKERS = ["OPENCODE_API_ENDPOINT", "PROVIDER_DOMAIN", "IDENTITY_POOL_ID"] -function detectANR(): boolean { +export function detectANR(): boolean { if (process.env.OPENCODE_FLAVOR === "anr") return true const home = process.env.HOME || process.env.USERPROFILE if (!home) return false @@ -572,7 +624,7 @@ function detectANR(): boolean { * Interactive env file picker for ANR mode. * Matches Donta's ui.Select() behavior from GovClaudeClient. */ -async function selectEnvFile(): Promise { +export async function selectEnvFile(): Promise { // Search for .env files in standard .opencode locations (3-tier): // 1. Project-level: /.opencode/ — developer overrides // (+ monorepo root for dev mode where cwd is packages/opencode) @@ -667,7 +719,10 @@ export async function main(argv?: string[]) { } // Clear stale env vars before loading new config + // Save externally-provided OPENCODE_ANR_ID_TOKEN so token auth mode works + const externalIdToken = process.env.OPENCODE_ANR_ID_TOKEN clearStaleEnv() + if (externalIdToken) process.env.OPENCODE_ANR_ID_TOKEN = externalIdToken await initializeANR(envFile) } diff --git a/packages/opencode/src/node.ts b/packages/opencode/src/node.ts index 04cd95a58c42..09181c139c5e 100644 --- a/packages/opencode/src/node.ts +++ b/packages/opencode/src/node.ts @@ -2,3 +2,5 @@ export { Config } from "@/config/config" export { Server } from "./server/server" export { bootstrap } from "./cli/bootstrap" export { Database } from "@opencode-ai/core/database/database" +export { initializeANR, detectANR, selectEnvFile } from "./anr-boot" +export { clearStaleEnv } from "@opencode-ai/anr-core"