diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..b45a65fb61 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,32 @@ +{ + "name": "Altimate Code", + "image": "mcr.microsoft.com/devcontainers/javascript-node:22", + "features": { + "ghcr.io/devcontainers/features/git:1": {}, + "ghcr.io/shyim/devcontainers-features/bun:0": { + "version": "1.3.10" + } + }, + "postCreateCommand": ".devcontainer/post-create.sh", + "customizations": { + "vscode": { + "extensions": [ + "biomejs.biome", + "dbaeumer.vscode-eslint" + ], + "settings": { + "terminal.integrated.defaultProfile.linux": "bash" + } + }, + "codespaces": { + "openFiles": [ + "README.md" + ] + } + }, + "forwardPorts": [], + "remoteEnv": { + "BUN_INSTALL": "/home/node/.bun", + "PATH": "/home/node/.bun/bin:${containerEnv:PATH}" + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 0000000000..b96823a2c8 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,24 @@ +#!/bin/bash +set -e + +echo "=== Altimate Code: Codespace Setup ===" + +# Configure git (required for tests) +git config --global user.name "${GITHUB_USER:-codespace}" +git config --global user.email "${GITHUB_USER:-codespace}@users.noreply.github.com" + +# Install dependencies +echo "Installing dependencies with Bun..." +bun install + +echo "" +echo "=== Setup complete! ===" +echo "" +echo "Quick start:" +echo " bun run build # Build the CLI" +echo " bun test # Run tests" +echo " bun turbo typecheck # Type-check all packages" +echo "" +echo "To install altimate globally after building:" +echo " bun link" +echo "" diff --git a/.github/meta/commit.txt b/.github/meta/commit.txt index 02ac9673d4..fb8e2b76ff 100644 --- a/.github/meta/commit.txt +++ b/.github/meta/commit.txt @@ -1,14 +1,16 @@ -fix: viewer UX improvements from 100-trace analysis +fix: Codespaces — skip machine-scoped `GITHUB_TOKEN`, cap retries, fix phantom command -- Collapse Files Changed after 5 entries with "Show all N files" toggle -- Rename "GENS" → "LLM Calls" in header cards -- Hide Tokens card when cost is $0 (not actionable without cost context) -- Hide Cost metric card when $0.00 (wasted space) -- Add prominent error summary banner right after header metrics -- Improved dbt outcome detection: catch [PASS], [ERROR], N of M, Compilation Error -- Outcome detection rate improved from 18% → 33% across 100 real traces -- Updated doc screenshots with cleaner samples +Closes #413 -Tested across 100 real production traces: 0 crashes, 0 JS errors. - -Co-Authored-By: Claude Opus 4.6 (1M context) +- Skip auto-enabling `github-models` and `github-copilot` providers in + machine environments (Codespaces: `CODESPACES=true`, GitHub Actions: + `GITHUB_ACTIONS=true`) when only machine-scoped tokens (`GITHUB_TOKEN`, + `GH_TOKEN`) are available. The Codespace/Actions token lacks + `models:read` scope needed for GitHub Models API. +- Cap retry attempts at 5 (`RETRY_MAX_ATTEMPTS`) to prevent infinite + retry loops. Log actionable warning when retries exhaust. +- Replace phantom `/discover-and-add-mcps` toast with actionable message. +- Add `.devcontainer/` config (Node 22, Bun 1.3.10) for Codespaces. +- Add 32 adversarial e2e tests covering full Codespace/Actions env + simulation, `GH_TOKEN`, token variations, config overrides, retry bounds. +- Update docs to reference `mcp_discover` tool. diff --git a/docs/docs/configure/providers.md b/docs/docs/configure/providers.md index d422b34942..f27a14376d 100644 --- a/docs/docs/configure/providers.md +++ b/docs/docs/configure/providers.md @@ -218,6 +218,9 @@ Access 150+ models through a single API key. Uses your GitHub Copilot subscription. Authenticate with `altimate auth`. +!!! note "Codespaces & GitHub Actions" + In GitHub Codespaces and GitHub Actions, the machine-scoped `GITHUB_TOKEN` lacks `models:read` permission and cannot be used for GitHub Copilot or GitHub Models inference. altimate automatically skips these providers in machine environments. To use them, authenticate explicitly with `altimate auth` or set a personal access token with `models:read` scope as a Codespace secret. + ## Snowflake Cortex ```json diff --git a/docs/docs/configure/tools.md b/docs/docs/configure/tools.md index 95d64ff3b5..df57c6bef5 100644 --- a/docs/docs/configure/tools.md +++ b/docs/docs/configure/tools.md @@ -131,10 +131,10 @@ The `mcp_discover` tool finds MCP servers configured in other AI coding tools an - `mcp_discover(action: "add", scope: "project")` — Write new servers to `.altimate-code/altimate-code.json` - `mcp_discover(action: "add", scope: "global")` — Write to the global config dir (`~/.config/opencode/`) -**Auto-discovery:** At startup, altimate-code discovers external MCP servers and shows a toast notification. Servers from your home directory (`~/.claude.json`, `~/.gemini/settings.json`) are auto-enabled since they're user-owned. Servers from project-level files (`.vscode/mcp.json`, `.mcp.json`, `.cursor/mcp.json`) are discovered but **disabled by default** for security — run `/discover-and-add-mcps` to review and enable them. +**Auto-discovery:** At startup, altimate-code discovers external MCP servers and shows a toast notification. Servers from your home directory (`~/.claude.json`, `~/.gemini/settings.json`) are auto-enabled since they're user-owned. Servers from project-level files (`.vscode/mcp.json`, `.mcp.json`, `.cursor/mcp.json`) are discovered but **disabled by default** for security — ask the assistant to add them or use `mcp_discover(action: "add")`. !!! tip - Home-directory MCP servers (from `~/.claude.json`, `~/.gemini/settings.json`) are loaded automatically. Project-scoped servers require explicit approval via `/discover-and-add-mcps` or `mcp_discover(action: "add")`. + Home-directory MCP servers (from `~/.claude.json`, `~/.gemini/settings.json`) are loaded automatically. Project-scoped servers require explicit approval via `mcp_discover(action: "add")`. !!! warning "Security: untrusted repositories" Project-level MCP configs (`.vscode/mcp.json`, `.mcp.json`, `.cursor/mcp.json`) are discovered but not auto-connected. This prevents malicious repositories from executing arbitrary commands. You must explicitly approve project-scoped servers before they run. diff --git a/docs/docs/reference/security-faq.md b/docs/docs/reference/security-faq.md index 66af8b54ec..ce5c8ff4e7 100644 --- a/docs/docs/reference/security-faq.md +++ b/docs/docs/reference/security-faq.md @@ -168,7 +168,7 @@ Altimate Code can automatically discover MCP server definitions from other AI to **Security model:** - **Home-directory configs** (your personal machine config) are treated as trusted and auto-enabled, since you installed them. -- **Project-scoped configs** (checked into a repo) are discovered but **disabled by default**. You must explicitly approve them via the `/discover-and-add-mcps` tool before they run. +- **Project-scoped configs** (checked into a repo) are discovered but **not auto-connected**. They are loaded with `enabled: false` and shown in a notification. Ask the assistant to enable them, or disable auto-discovery entirely with `experimental.auto_mcp_discovery: false`. - **Sensitive details are redacted** in discovery notifications. Server commands and URLs are only shown when you explicitly inspect them. - **Prototype pollution, command injection, and path traversal** are hardened against with input validation and `Object.create(null)` result objects. diff --git a/docs/docs/usage/github.md b/docs/docs/usage/github.md index 4c69c78d61..826b0a5404 100644 --- a/docs/docs/usage/github.md +++ b/docs/docs/usage/github.md @@ -39,6 +39,9 @@ jobs: ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} ``` +!!! important "LLM provider required" + The workflow `GITHUB_TOKEN` is for repository access only — it cannot be used for LLM inference. You must provide a separate API key (e.g., `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) as a repository secret. GitHub Copilot and GitHub Models providers are automatically disabled in Actions environments. + ### Triggers | Event | Behavior | diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 4d54a9ef94..4c80b0ef83 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -215,7 +215,7 @@ export namespace MCP { // altimate_change start — show discovery toast after MCP connections complete if (discoveryResult) { - const message = `Discovered ${discoveryResult.serverNames.length} new MCP server(s): ${discoveryResult.serverNames.join(", ")}. Run /discover-and-add-mcps to enable and add them.` + const message = `Discovered ${discoveryResult.serverNames.length} new MCP server(s): ${discoveryResult.serverNames.join(", ")}. Ask the assistant to add them, or they will be available automatically in the current session.` Bus.publish(TuiEvent.ToastShow, { title: "MCP Servers Discovered", message, diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 43d8a485d0..90c4266051 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1071,11 +1071,41 @@ export namespace Provider { // load env const env = Env.all() + // altimate_change start — skip github-models/github-copilot auto-enable from machine-scoped GITHUB_TOKEN + // In GitHub Codespaces and GitHub Actions, GITHUB_TOKEN is a machine-scoped + // token for repo operations, not for model inference. Auto-enabling these + // providers leads to immediate rate limiting ("Too Many Requests") and long + // retry loops. We detect these environments and skip auto-enable unless the + // user has explicitly set a different token. + // + // Environment detection (official GitHub docs): + // Codespaces: CODESPACES=true, CODESPACE_NAME set + // Actions: GITHUB_ACTIONS=true, CI=true + // + // Machine-scoped tokens to ignore: GITHUB_TOKEN and GH_TOKEN (gh CLI alias) + const isMachineEnv = env["CODESPACES"] === "true" || env["GITHUB_ACTIONS"] === "true" + const machineTokenNames = new Set(["GITHUB_TOKEN", "GH_TOKEN"]) + const skipGithubProviders = new Set(["github-models", "github-copilot", "github-copilot-enterprise"]) + // altimate_change end for (const [id, provider] of Object.entries(database)) { const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue const apiKey = provider.env.map((item) => env[item]).find(Boolean) if (!apiKey) continue + // altimate_change start — skip GitHub providers when only machine-scoped tokens exist + if (isMachineEnv && skipGithubProviders.has(id)) { + // Check if ALL env vars for this provider are machine-scoped tokens + const matchedEnvVars = provider.env.filter((item) => env[item]) + const allMachineScoped = matchedEnvVars.every((item) => machineTokenNames.has(item)) + if (allMachineScoped) { + log.info("skipping provider in machine environment (token is not for model inference)", { + providerID, + environment: env["CODESPACES"] ? "codespace" : "github-actions", + }) + continue + } + } + // altimate_change end mergeProvider(providerID, { source: "env", key: provider.env.length === 1 ? apiKey : undefined, diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 2add53f47c..2cf5c663a5 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -394,7 +394,9 @@ export namespace SessionProcessor { }) } else { const retry = SessionRetry.retryable(error) - if (retry !== undefined) { + // altimate_change start — cap retries to avoid infinite loops, log on exhaustion + if (retry !== undefined && attempt < SessionRetry.RETRY_MAX_ATTEMPTS) { + // altimate_change end attempt++ const delay = SessionRetry.delay(attempt, error.name === "APIError" ? error : undefined) SessionStatus.set(input.sessionID, { @@ -406,6 +408,16 @@ export namespace SessionProcessor { await SessionRetry.sleep(delay, input.abort).catch(() => {}) continue } + // altimate_change start — log when retries exhausted for debugging + if (retry !== undefined) { + log.warn("max retry attempts reached, giving up", { + attempt, + message: retry, + providerID: input.model.providerID, + modelID: input.model.id, + }) + } + // altimate_change end input.assistantMessage.error = error Bus.publish(Session.Event.Error, { sessionID: input.assistantMessage.sessionID, diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index bf37b7fd94..25305460e8 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -7,6 +7,9 @@ export namespace SessionRetry { export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout + // altimate_change start — max retry attempts to prevent infinite retry loops + export const RETRY_MAX_ATTEMPTS = 5 + // altimate_change end export async function sleep(ms: number, signal: AbortSignal): Promise { return new Promise((resolve, reject) => { diff --git a/packages/opencode/test/provider/codespace-e2e.test.ts b/packages/opencode/test/provider/codespace-e2e.test.ts new file mode 100644 index 0000000000..0647cd4ab8 --- /dev/null +++ b/packages/opencode/test/provider/codespace-e2e.test.ts @@ -0,0 +1,459 @@ +/** + * End-to-end + adversarial tests for Codespaces / CI provider detection. + * + * These tests verify that machine-scoped GITHUB_TOKEN (Codespaces, GitHub Actions) + * is NOT used to auto-enable github-models or github-copilot providers, while + * still allowing explicit user-provided tokens. + * + * Official GitHub Codespace env vars (from GitHub docs): + * CODESPACES=true — always set in a Codespace + * CODESPACE_NAME=... — name of the Codespace + * GITHUB_TOKEN=ghu_... — machine-scoped token for repo operations + * GITHUB_USER=... — user who created the Codespace + * GITHUB_REPOSITORY=... — owner/repo + * GITHUB_API_URL=... — API URL + * GITHUB_SERVER_URL=... — Server URL + * + * Official GitHub Actions env vars: + * GITHUB_ACTIONS=true — always set in Actions + * CI=true — always set in Actions + * GITHUB_TOKEN=ghs_... — machine-scoped token for the workflow + */ +import { test, expect, describe } from "bun:test" + +import { tmpdir } from "../fixture/fixture" +import { Instance } from "../../src/project/instance" +import { Provider } from "../../src/provider/provider" +import { Env } from "../../src/env" +import { SessionRetry } from "../../src/session/retry" + +// Machine environment vars that may leak from CI into tests. +// These must be explicitly removed when testing "clean" environments. +const MACHINE_ENV_VARS = ["CODESPACES", "CODESPACE_NAME", "GITHUB_ACTIONS", "CI", "GITHUB_TOKEN", "GH_TOKEN"] + +// Helper: create a minimal config dir and run a test with given env vars. +// Removes all machine-env vars first to isolate tests from CI environment. +async function withEnv(envVars: Record, fn: () => Promise) { + await using tmp = await tmpdir({ config: {} }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + // Remove machine-env vars that may leak from CI + for (const k of MACHINE_ENV_VARS) { + Env.remove(k) + } + // Set the test-specific env vars + for (const [k, v] of Object.entries(envVars)) { + Env.set(k, v) + } + }, + fn, + }) +} + +// ───────────────────────────────────────────────────────────── +// 1. GITHUB CODESPACES ENVIRONMENT +// ───────────────────────────────────────────────────────────── + +describe("Codespace provider detection", () => { + // --- Core behavior: machine GITHUB_TOKEN should NOT auto-enable --- + + test("github-models is excluded in Codespace with only GITHUB_TOKEN", async () => { + await withEnv( + { CODESPACES: "true", CODESPACE_NAME: "test-codespace", GITHUB_TOKEN: "test-codespace-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + ) + }) + + test("github-copilot is excluded in Codespace with only GITHUB_TOKEN", async () => { + await withEnv( + { CODESPACES: "true", CODESPACE_NAME: "test-codespace", GITHUB_TOKEN: "test-codespace-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-copilot"]).toBeUndefined() + }, + ) + }) + + // --- GH_TOKEN (gh CLI alias) should also be treated as machine-scoped --- + + test("github-models is excluded when only GH_TOKEN is set in Codespace", async () => { + await withEnv( + { CODESPACES: "true", GH_TOKEN: "test-gh-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + ) + }) + + // --- Both GITHUB_TOKEN and GH_TOKEN set (both machine-scoped) --- + + test("github-models excluded when both GITHUB_TOKEN and GH_TOKEN are machine-scoped", async () => { + await withEnv( + { CODESPACES: "true", GITHUB_TOKEN: "test-machine-token", GH_TOKEN: "test-machine-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + ) + }) + + // --- Non-Codespace: GITHUB_TOKEN works normally --- + + test("github-models is available outside Codespace with GITHUB_TOKEN", async () => { + await withEnv( + { GITHUB_TOKEN: "test-personal-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeDefined() + }, + ) + }) + + test("github-copilot is not blocked by machine-env detection outside Codespace", async () => { + await withEnv( + { GITHUB_TOKEN: "test-personal-token" }, + async () => { + const providers = await Provider.list() + // github-copilot has autoload: false (needs OAuth), but env detection + // still registers it when GITHUB_TOKEN is set outside machine environments. + // The custom loader adds model-loading options on top. + expect(providers["github-copilot"]).toBeDefined() + expect(providers["github-copilot"].source).toBe("env") + }, + ) + }) +}) + +// ───────────────────────────────────────────────────────────── +// 2. GITHUB ACTIONS ENVIRONMENT +// ───────────────────────────────────────────────────────────── + +describe("GitHub Actions provider detection", () => { + test("github-models is excluded in GitHub Actions with GITHUB_TOKEN", async () => { + await withEnv( + { GITHUB_ACTIONS: "true", CI: "true", GITHUB_TOKEN: "test-actions-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + ) + }) + + test("github-copilot is excluded in GitHub Actions with GITHUB_TOKEN", async () => { + await withEnv( + { GITHUB_ACTIONS: "true", CI: "true", GITHUB_TOKEN: "test-actions-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-copilot"]).toBeUndefined() + }, + ) + }) + + // CI=true alone (generic CI) should NOT block — only GITHUB_ACTIONS matters + test("github-models is available in generic CI (CI=true without GITHUB_ACTIONS)", async () => { + await withEnv( + { CI: "true", GITHUB_TOKEN: "test-personal-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeDefined() + }, + ) + }) +}) + +// ───────────────────────────────────────────────────────────── +// 3. ADVERSARIAL / EDGE CASES +// ───────────────────────────────────────────────────────────── + +describe("Adversarial: Codespace edge cases", () => { + // --- CODESPACES value variations --- + + test("CODESPACES=false does NOT trigger Codespace detection", async () => { + // Some users might set CODESPACES=false in local dev — strict === "true" check ignores it + await withEnv( + { CODESPACES: "false", GITHUB_TOKEN: "test-personal-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeDefined() + }, + ) + }) + + test("CODESPACES='' (empty) does NOT trigger Codespace detection", async () => { + await withEnv( + { CODESPACES: "", GITHUB_TOKEN: "test-personal-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeDefined() + }, + ) + }) + + // --- Token prefix adversarial tests --- + // Codespace tokens start with ghu_, Actions tokens with ghs_, + // personal tokens with ghp_, fine-grained with github_pat_ + // We don't check prefixes — the env var NAME is what matters + + test("works regardless of token prefix (ghu_, ghs_, ghp_, github_pat_)", async () => { + const prefixes = ["test-token-a", "test-token-b", "test-token-c", "test-token-d"] + for (const token of prefixes) { + await withEnv( + { CODESPACES: "true", GITHUB_TOKEN: token }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + ) + } + }) + + // --- Explicit override: user sets a DIFFERENT env var --- + + test("disabled_providers can re-block even with explicit token", async () => { + await using tmp = await tmpdir({ + config: { disabled_providers: ["github-models"] }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + for (const k of MACHINE_ENV_VARS) Env.remove(k) + Env.set("GITHUB_TOKEN", "test-real-personal-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + }) + }) + + // --- Config-based override: user explicitly enables in config --- + + test("config-based provider override works even in Codespace", async () => { + await using tmp = await tmpdir({ + config: { + provider: { + "github-models": { + options: { + apiKey: "test-explicit-config-token", + }, + }, + }, + }, + }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("CODESPACES", "true") + Env.set("GITHUB_TOKEN", "test-machine-token") + }, + fn: async () => { + const providers = await Provider.list() + // Config-based providers are loaded AFTER env-based ones (line 1162+) + // so explicit config should still work even in Codespace + expect(providers["github-models"]).toBeDefined() + }, + }) + }) + + // --- No env vars at all --- + + test("github-models is absent when no GitHub token env vars exist", async () => { + await withEnv({}, async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }) + }) + + // --- Multiple machine environments simultaneously --- + + test("both CODESPACES and GITHUB_ACTIONS set blocks providers", async () => { + await withEnv( + { CODESPACES: "true", GITHUB_ACTIONS: "true", GITHUB_TOKEN: "test-machine-token" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + ) + }) + + // --- Other providers unaffected --- + + test("anthropic provider is NOT blocked in Codespace", async () => { + await withEnv( + { CODESPACES: "true", GITHUB_TOKEN: "test-machine-token", ANTHROPIC_API_KEY: "test-anthropic-key" }, + async () => { + const providers = await Provider.list() + expect(providers["anthropic"]).toBeDefined() + }, + ) + }) + + test("openai provider is NOT blocked in Codespace", async () => { + await withEnv( + { CODESPACES: "true", GITHUB_TOKEN: "test-machine-token", OPENAI_API_KEY: "test-openai-key" }, + async () => { + const providers = await Provider.list() + expect(providers["openai"]).toBeDefined() + }, + ) + }) +}) + +// ───────────────────────────────────────────────────────────── +// 4. RETRY LIMITS +// ───────────────────────────────────────────────────────────── + +describe("Retry limits", () => { + test("RETRY_MAX_ATTEMPTS is exactly 5", () => { + expect(SessionRetry.RETRY_MAX_ATTEMPTS).toBe(5) + }) + + test("delay at max attempts is bounded", () => { + // At attempt 5 (the last allowed retry), delay should be bounded + const delay = SessionRetry.delay(SessionRetry.RETRY_MAX_ATTEMPTS) + expect(delay).toBeLessThanOrEqual(SessionRetry.RETRY_MAX_DELAY_NO_HEADERS) + expect(delay).toBeGreaterThan(0) + }) + + test("delay with retry-after header is respected even at max attempts", () => { + const error = { + name: "APIError", + data: { + message: "rate limited", + isRetryable: true, + responseHeaders: { "retry-after": "60" }, + }, + } as any + const delay = SessionRetry.delay(SessionRetry.RETRY_MAX_ATTEMPTS, error) + expect(delay).toBe(60000) // 60 seconds in ms + }) +}) + +// ───────────────────────────────────────────────────────────── +// 5. ADVERSARIAL: RETRY BEHAVIOR +// ───────────────────────────────────────────────────────────── + +describe("Adversarial: retry edge cases", () => { + test("retryable detects GitHub scraping rate limit message", () => { + // This is the exact error format from the screenshot + const error = { + name: "UnknownError", + data: { + message: JSON.stringify({ + type: "error", + error: { type: "too_many_requests" }, + }), + }, + } as any + expect(SessionRetry.retryable(error)).toBe("Too Many Requests") + }) + + test("retryable detects rate_limit code", () => { + const error = { + name: "UnknownError", + data: { + message: JSON.stringify({ + type: "error", + error: { code: "rate_limit_exceeded" }, + }), + }, + } as any + expect(SessionRetry.retryable(error)).toBe("Rate Limited") + }) + + test("retryable returns undefined for non-retryable errors", () => { + const error = { + name: "UnknownError", + data: { message: "not json" }, + } as any + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + + test("retryable handles malformed JSON gracefully", () => { + const error = { + name: "UnknownError", + data: { message: "{broken json" }, + } as any + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + + test("retryable handles null/undefined data gracefully", () => { + const error = { name: "UnknownError", data: {} } as any + expect(SessionRetry.retryable(error)).toBeUndefined() + }) + + test("delay never returns negative or zero for positive attempts", () => { + for (let attempt = 1; attempt <= 20; attempt++) { + expect(SessionRetry.delay(attempt)).toBeGreaterThan(0) + } + }) + + test("delay is monotonically increasing up to cap", () => { + let prev = 0 + for (let attempt = 1; attempt <= 10; attempt++) { + const d = SessionRetry.delay(attempt) + expect(d).toBeGreaterThanOrEqual(prev) + prev = d + } + }) +}) + +// ───────────────────────────────────────────────────────────── +// 6. FULL CODESPACE ENVIRONMENT SIMULATION +// ───────────────────────────────────────────────────────────── + +describe("Full Codespace environment simulation", () => { + // Simulate ALL env vars that a real Codespace sets + const FULL_CODESPACE_ENV = { + CODESPACES: "true", + CODESPACE_NAME: "user-literate-space-parakeet-abc123", + GITHUB_TOKEN: "test-full-codespace-token", + GITHUB_USER: "testuser", + GITHUB_REPOSITORY: "testorg/test-repo", + GITHUB_API_URL: "https://api.github.com", + GITHUB_GRAPHQL_URL: "https://api.github.com/graphql", + GITHUB_SERVER_URL: "https://github.com", + GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN: "app.github.dev", + GIT_COMMITTER_EMAIL: "testuser@users.noreply.github.com", + GIT_COMMITTER_NAME: "testuser", + } + + test("full Codespace env: github-models is excluded", async () => { + await withEnv(FULL_CODESPACE_ENV, async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }) + }) + + test("full Codespace env: github-copilot is excluded", async () => { + await withEnv(FULL_CODESPACE_ENV, async () => { + const providers = await Provider.list() + expect(providers["github-copilot"]).toBeUndefined() + }) + }) + + test("full Codespace env: opencode free tier still works", async () => { + await withEnv(FULL_CODESPACE_ENV, async () => { + const providers = await Provider.list() + // The opencode provider should still be available (it auto-enables with public key) + expect(providers["opencode"]).toBeDefined() + }) + }) + + test("full Codespace env + explicit Anthropic key: both work", async () => { + await withEnv( + { ...FULL_CODESPACE_ENV, ANTHROPIC_API_KEY: "test-anthropic-key" }, + async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + expect(providers["anthropic"]).toBeDefined() + expect(providers["opencode"]).toBeDefined() + }, + ) + }) +}) diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 7171193a5a..05a6064051 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -2282,3 +2282,52 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }, }) }) + +// altimate_change start — test Codespace GITHUB_TOKEN skip logic +test("github-models is excluded when CODESPACES=true and only GITHUB_TOKEN is set", async () => { + await using tmp = await tmpdir({ config: {} }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITHUB_TOKEN", "test-codespace-token") + Env.set("CODESPACES", "true") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeUndefined() + }, + }) +}) + +test("github-models is available when GITHUB_TOKEN set without CODESPACES", async () => { + await using tmp = await tmpdir({ config: {} }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + // Remove machine-env vars that may leak from CI + Env.remove("CODESPACES") + Env.remove("GITHUB_ACTIONS") + Env.set("GITHUB_TOKEN", "test-personal-token") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["github-models"]).toBeDefined() + }, + }) +}) + +test("github-copilot is excluded when CODESPACES=true and only GITHUB_TOKEN is set", async () => { + await using tmp = await tmpdir({ config: {} }) + await Instance.provide({ + directory: tmp.path, + init: async () => { + Env.set("GITHUB_TOKEN", "test-codespace-token") + Env.set("CODESPACES", "true") + }, + fn: async () => { + const providers = await Provider.list() + expect(providers["github-copilot"]).toBeUndefined() + }, + }) +}) +// altimate_change end diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index b8954b760b..c427f5309b 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -91,6 +91,12 @@ describe("session.retry.delay", () => { }) }) +describe("session.retry.max_attempts", () => { + test("RETRY_MAX_ATTEMPTS is 5", () => { + expect(SessionRetry.RETRY_MAX_ATTEMPTS).toBe(5) + }) +}) + describe("session.retry.retryable", () => { test("maps too_many_requests json messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } }))