mirror of
https://github.com/OpenRouterTeam/spawn.git
synced 2026-04-28 03:49:31 +00:00
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:
parent
de732fa695
commit
bc0c1827bb
5 changed files with 93 additions and 13 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@openrouter/spawn",
|
||||
"version": "0.15.15",
|
||||
"version": "0.15.16",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"spawn": "cli.js"
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue