fix: reorder auth flow and persist OpenRouter API key (#2320)

* fix: reorder auth flow and persist OpenRouter API key across retries

Two onboarding issues reported by users:

1. After DigitalOcean OAuth, the message said "OpenRouter authentication
   in 5s..." but then a GitHub CLI prompt appeared first. Fix: move API
   key acquisition immediately after cloud auth, before preProvision
   hooks (which include the GitHub prompt). Remove the misleading 5s
   delay message.

2. On retry after billing failure, DigitalOcean token was remembered but
   the OpenRouter API key was lost (only stored in process.env). Fix:
   persist the key to ~/.config/spawn/openrouter.json and load it on
   subsequent runs, matching how cloud tokens are already persisted.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: add mode 0o700 to config dir and await saveOpenRouterKey

- Add mode: 0o700 to mkdirSync in saveOpenRouterKey to match other cloud
  modules (aws, hetzner, digitalocean) and prevent directory permission leak
- Add missing await on saveOpenRouterKey(manualKey) to ensure manual API
  keys persist to disk before the function returns

Agent: pr-maintainer
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: B <6723574+louisgv@users.noreply.github.com>
This commit is contained in:
Ahmed Abushagur 2026-03-08 03:48:14 -07:00 committed by GitHub
parent de732fa695
commit bc0c1827bb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 93 additions and 13 deletions

View file

@ -1,6 +1,6 @@
{
"name": "@openrouter/spawn",
"version": "0.15.15",
"version": "0.15.16",
"type": "module",
"bin": {
"spawn": "cli.js"

View file

@ -169,6 +169,31 @@ describe("runOrchestration", () => {
exitSpy.mockRestore();
});
it("obtains API key before preProvision (no surprise prompts after cloud auth)", async () => {
const callOrder: string[] = [];
mockGetOrPromptApiKey.mockImplementation(async () => {
callOrder.push("getApiKey");
return "sk-or-v1-test-key";
});
const cloud = createMockCloud({
authenticate: mock(async () => {
callOrder.push("authenticate");
}),
});
const agent = createMockAgent({
preProvision: mock(async () => {
callOrder.push("preProvision");
}),
});
await runOrchestrationSafe(cloud, agent, "testagent");
expect(callOrder.indexOf("authenticate")).toBeLessThan(callOrder.indexOf("getApiKey"));
expect(callOrder.indexOf("getApiKey")).toBeLessThan(callOrder.indexOf("preProvision"));
stderrSpy.mockRestore();
exitSpy.mockRestore();
});
it("passes API key to agent.envVars", async () => {
const envVarsFn = mock((key: string) => [
`OPENROUTER_API_KEY=${key}`,

View file

@ -6,7 +6,6 @@ import type { CloudOrchestrator } from "../shared/orchestrate";
import { saveLaunchCmd } from "../history.js";
import { runOrchestration } from "../shared/orchestrate";
import { logStep } from "../shared/ui";
import { agents, resolveAgent } from "./agents";
import {
checkAccountStatus,
@ -49,12 +48,8 @@ async function main() {
},
async authenticate() {
await promptSpawnName();
const usedBrowserAuth = await ensureDoToken();
await ensureDoToken();
await ensureSshKey();
if (usedBrowserAuth) {
logStep("Next step: OpenRouter authentication (opening browser in 5s)...");
await new Promise((r) => setTimeout(r, 5000));
}
},
async checkAccountReady() {
await checkAccountStatus();

View file

@ -1,9 +1,12 @@
// shared/oauth.ts — OpenRouter OAuth flow + API key management
import { mkdirSync, readFileSync } from "node:fs";
import { dirname } from "node:path";
import * as v from "valibot";
import { OAUTH_CODE_REGEX } from "./oauth-constants";
import { parseJsonWith } from "./parse";
import { logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui";
import { isString } from "./type-guards";
import { getSpawnCloudConfigPath, logError, logInfo, logStep, logWarn, openBrowser, prompt } from "./ui";
// ─── Schemas ─────────────────────────────────────────────────────────────────
@ -211,6 +214,49 @@ async function tryOauthFlow(callbackPort = 5180, agentSlug?: string, cloudSlug?:
}
}
// ─── API Key Persistence ─────────────────────────────────────────────────────
/** Save OpenRouter API key to ~/.config/spawn/openrouter.json so it persists across runs. */
async function saveOpenRouterKey(key: string): Promise<void> {
try {
const configPath = getSpawnCloudConfigPath("openrouter");
mkdirSync(dirname(configPath), {
recursive: true,
mode: 0o700,
});
await Bun.write(
configPath,
JSON.stringify(
{
api_key: key,
},
null,
2,
) + "\n",
{
mode: 0o600,
},
);
} catch {
// non-fatal — key still works in memory for this session
}
}
/** Load a previously saved OpenRouter API key from ~/.config/spawn/openrouter.json. */
function loadSavedOpenRouterKey(): string | null {
try {
const configPath = getSpawnCloudConfigPath("openrouter");
const data = JSON.parse(readFileSync(configPath, "utf-8"));
const key = isString(data.api_key) ? data.api_key : "";
if (key && /^sk-or-v1-[a-f0-9]{64}$/.test(key)) {
return key;
}
return null;
} catch {
return null;
}
}
// ─── Main API Key Acquisition ────────────────────────────────────────────────
async function promptAndValidateApiKey(): Promise<string | null> {
@ -249,12 +295,24 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string):
logWarn("Environment key failed validation, prompting for a new one...");
}
// 2. Try OAuth + manual fallback (3 attempts)
// 2. Check saved key from previous session
const savedKey = loadSavedOpenRouterKey();
if (savedKey) {
logInfo("Using saved OpenRouter API key");
if (await verifyOpenrouterKey(savedKey)) {
process.env.OPENROUTER_API_KEY = savedKey;
return savedKey;
}
logWarn("Saved key failed validation, prompting for a new one...");
}
// 3. Try OAuth + manual fallback (3 attempts)
for (let attempt = 1; attempt <= 3; attempt++) {
// Try OAuth first
const key = await tryOauthFlow(5180, agentSlug, cloudSlug);
if (key && (await verifyOpenrouterKey(key))) {
process.env.OPENROUTER_API_KEY = key;
await saveOpenRouterKey(key);
return key;
}
@ -278,6 +336,7 @@ export async function getOrPromptApiKey(agentSlug?: string, cloudSlug?: string):
const manualKey = await promptAndValidateApiKey();
if (manualKey && (await verifyOpenrouterKey(manualKey))) {
process.env.OPENROUTER_API_KEY = manualKey;
await saveOpenRouterKey(manualKey);
return manualKey;
}
}

View file

@ -79,7 +79,11 @@ export async function runOrchestration(
}
}
// 2. Pre-provision hooks
// 2. Get API key (immediately after cloud auth — before any other prompts
// so the "opening browser" message leads directly to OpenRouter OAuth)
const apiKey = await getOrPromptApiKey(agentName, cloud.cloudName);
// 3. Pre-provision hooks (e.g., GitHub auth prompt — non-fatal)
if (agent.preProvision) {
try {
await agent.preProvision();
@ -88,9 +92,6 @@ export async function runOrchestration(
}
}
// 3. Get API key (before provisioning so user isn't waiting)
const apiKey = await getOrPromptApiKey(agentName, cloud.cloudName);
// 4. Model ID (use agent default — no interactive prompt)
const modelId = agent.modelDefault || process.env.MODEL_ID;