refactor(auth-storage): cleanup auth.json mentions

- Migrated authentication storage from JSON-based (auth.json) to database-based (agent.db) format across test files and configuration.
- Simplified auth storage discovery in sdk.ts by removing manual path construction and fallback logic in favor of centralized getAgentDbPath() function.
- Removed dbPath instance property from AuthStorage class as database path is now managed centrally.
- Updated environment variable precedence documentation to reflect agent.db instead of auth.json as the lowest priority source.
- Removed OAuth provider section header comments from multiple test files for cleaner test organization.
- Added support for JSON and JSONC configuration file formats without requiring migration to YAML.
This commit is contained in:
can1357
2026-02-05 14:23:49 +01:00
parent e47657c4d1
commit f8950c22b4
33 changed files with 145 additions and 382 deletions
+1 -1
View File
@@ -349,7 +349,7 @@ Our fork has architectural decisions that differ from upstream. **Do not port th
| Upstream | Our Fork | Notes |
| ------------------------------- | ------------------------------------------- | ----------------------------------------------------- |
| `proper-lockfile` + `auth.json` | `agent.db` (bun:sqlite) | Legacy `auth.json` is migrated; do not reintroduce it |
| `proper-lockfile` + `auth.json` | `agent.db` (bun:sqlite) | Credentials stored exclusively in `agent.db` |
| Single credential per provider | Multi-credential with round-robin selection | Session affinity and backoff logic preserved |
### Extensions
+4 -4
View File
@@ -983,7 +983,7 @@ bunx @oh-my-pi/pi-ai login anthropic # login to specific provider
bunx @oh-my-pi/pi-ai list # list available providers
```
Credentials are saved to `auth.json` in the current directory.
Credentials are saved to `agent.db` in the agent directory.
### Programmatic OAuth
@@ -1036,7 +1036,7 @@ const credentials = await loginGitHubCopilot({
// Store credentials yourself
const auth = { "github-copilot": { type: "oauth", ...credentials } };
fs.writeFileSync("auth.json", JSON.stringify(auth, null, 2));
fs.writeFileSync("credentials.json", JSON.stringify(auth, null, 2));
```
### Using OAuth Tokens
@@ -1048,7 +1048,7 @@ import { getModel, complete, getOAuthApiKey } from "@oh-my-pi/pi-ai";
import * as fs from "node:fs";
// Load your stored credentials
const auth = JSON.parse(fs.readFileSync("auth.json", "utf-8"));
const auth = JSON.parse(fs.readFileSync("credentials.json", "utf-8"));
// Get API key (refreshes if expired)
const result = await getOAuthApiKey("github-copilot", auth);
@@ -1056,7 +1056,7 @@ if (!result) throw new Error("Not logged in");
// Save refreshed credentials
auth["github-copilot"] = { type: "oauth", ...result.newCredentials };
fs.writeFileSync("auth.json", JSON.stringify(auth, null, 2));
fs.writeFileSync("credentials.json", JSON.stringify(auth, null, 2));
// Use the API key
const model = getModel("github-copilot", "gpt-4o");
-4
View File
@@ -465,10 +465,6 @@ describe("AI Providers Empty Message Tests", () => {
);
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider Empty Messages", () => {
const llm = getModel("anthropic", "claude-3-5-haiku-20241022");
@@ -321,10 +321,6 @@ describe("Tool Results with Images", () => {
);
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider (claude-sonnet-4-5)", () => {
const model = getModel("anthropic", "claude-sonnet-4-5");
+5 -5
View File
@@ -1,8 +1,8 @@
/**
* Test helper for resolving API keys from ~/.pi/agent/auth.json
* Test helper for resolving API keys from ~/.pi/agent/testauth.db
*
* Supports both API key and OAuth credentials.
* OAuth tokens are automatically refreshed if expired and saved back to auth.json.
* OAuth tokens are automatically refreshed if expired and saved back to testauth.db.
*
* E2E tests are disabled by default. Set E2E=1 environment variable to enable.
*/
@@ -28,7 +28,7 @@ export function e2eApiKey(envVar: string): string | undefined {
return Bun.env[envVar];
}
const AUTH_PATH = path.join(os.homedir(), ".pi", "agent", "auth.json");
const AUTH_PATH = path.join(os.homedir(), ".pi", "agent", "testauth.db");
type ApiKeyCredential = {
type: "api_key";
@@ -59,7 +59,7 @@ async function saveAuthStorage(storage: AuthStorage): Promise<void> {
}
/**
* Resolve API key for a provider from ~/.pi/agent/auth.json
* Resolve API key for a provider from ~/.pi/agent/testauth.db
*
* For API key credentials, returns the key directly.
* For OAuth credentials, returns the access token (refreshing if expired and saving back).
@@ -91,7 +91,7 @@ export async function resolveApiKey(provider: string): Promise<string | undefine
const result = await getOAuthApiKey(provider as OAuthProvider, oauthCredentials);
if (!result) return undefined;
// Save refreshed credentials back to auth.json
// Save refreshed credentials back to testauth.db
storage[provider] = { type: "oauth", ...result.newCredentials };
await saveAuthStorage(storage);
-5
View File
@@ -980,11 +980,6 @@ describe("Generate E2E Tests", () => {
);
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// Tokens are resolved at module level (see oauthTokens above)
// =========================================================================
describe("Anthropic OAuth Provider (claude-sonnet-4-20250514)", () => {
const model = getModel("anthropic", "claude-sonnet-4-20250514");
-4
View File
@@ -180,10 +180,6 @@ describe("Token Statistics on Abort", () => {
);
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider", () => {
const llm = getModel("anthropic", "claude-3-5-haiku-20241022");
@@ -206,10 +206,6 @@ describe("Tool Call Without Result Tests", () => {
);
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider", () => {
const model = getModel("anthropic", "claude-3-5-haiku-20241022");
@@ -389,10 +389,6 @@ describe("AI Providers Unicode Surrogate Pair Tests", () => {
);
});
// =========================================================================
// OAuth-based providers (credentials from ~/.pi/agent/oauth.json)
// =========================================================================
describe("Anthropic OAuth Provider Unicode Handling", () => {
const llm = getModel("anthropic", "claude-3-5-haiku-20241022");
+4 -5
View File
@@ -1,7 +1,6 @@
# Changelog
## [Unreleased]
### Added
- Added `omp commit` command to generate commit messages and update changelogs with `--push`, `--dry-run`, `--no-changelog`, and model override flags
@@ -29,8 +28,7 @@
- Changed Perplexity search context size from 'high' to 'medium' and added search classifier, reasoning effort, and language preference settings
- Increased Perplexity default max tokens from 4096 to 8192 for more comprehensive responses
- Updated Anthropic and Gemini search providers to support `max_tokens` and `temperature` parameters for finer control over response generation
- Simplified `AuthStorage.create()` to accept direct agent.db path instead of legacy auth.json path with fallback resolution
- Updated `discoverAuthStorage()` to skip JSON-to-SQLite migration step, improving startup performance
- Simplified `AuthStorage.create()` to accept direct agent.db path
- Renamed web search types and exports for consistency: `WebSearchProvider` → `SearchProviderId`, `WebSearchResponse` → `SearchResponse`, `WebSearchTool` → `SearchTool`, and related functions
- Refactored web search provider system to use centralized provider registry with `getSearchProvider()` and `resolveProviderChain()` for improved provider management
- Updated web search system prompt to emphasize comprehensive, detailed answers with concrete data and specific examples over brevity
@@ -49,8 +47,7 @@
### Removed
- Removed legacy auth.json migration system—credentials are now stored exclusively in agent.db
- Removed `getAuthPath()` configuration function—use `getAgentDbPath()` for credential storage location
- Removed legacy auth.json file—credentials are stored exclusively in agent.db
### Fixed
@@ -1703,6 +1700,8 @@
- Fixed editor border rendering glitch after canceling slash command autocomplete
- Fixed login/logout credential path message to reference agent.db
- Removed legacy auth.json file—credentials are stored exclusively in agent.db
- Removed legacy auth.json file—credentials are stored exclusively in agent.db
## [4.2.0] - 2026-01-10
+1 -1
View File
@@ -370,7 +370,7 @@ When disabled, neither case triggers automatic compaction (use `/compact` manual
- Automatically sets environment variables when the application starts
- Only sets variables that aren't already present in `Bun.env`
- Supports any environment variable, not just API keys
- Order of precedence: existing env vars > settings.json env vars > auth.json env vars
- Order of precedence: existing env vars > settings.json env vars > agent.db
> **Note:** Compaction is lossy. The agent loses full conversation access afterward. Size tasks to avoid context limits when possible. For critical context, ask the agent to write a summary to a file, iterate on it until it covers everything, then start a new session with that file. The full session history is preserved in the JSONL file; use `/tree` to revisit any previous point.
+3 -4
View File
@@ -50,7 +50,7 @@ Many modules now use the **capability/discovery system** (`discovery/builtin.ts`
| `session/agent-session.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | Database path |
| `session/session-manager.ts` | `getAgentDir` | `~/.omp/agent/sessions/` | Session storage |
| `session/agent-storage.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | Settings/auth storage |
| `session/auth-storage.ts` | `getAgentDbPath`, `getAuthPath` | agent.db, auth.json | Auth credential storage |
| `session/auth-storage.ts` | `getAgentDbPath` | agent.db | Auth credential storage |
| `session/history-storage.ts` | `getAgentDir` | `~/.omp/agent/` | Command history |
| `session/storage-migration.ts` | `getAgentDbPath` | `~/.omp/agent/agent.db` | JSON→SQLite migration |
| `modes/theme/theme.ts` | `getCustomThemesDir` | `~/.omp/agent/themes/` | Custom themes |
@@ -78,12 +78,12 @@ These use helpers to check `.omp`, `.pi`, `.claude`, `.codex`, `.gemini` directo
| File | Helper Used | Subpath(s) | Levels |
| ---------------------------------------- | ------------------------------------------------------ | --------------------------- | ------------ |
| `main.ts` | `findConfigFile` | `SYSTEM.md`, `APPEND_SYSTEM.md` | user+project |
| `sdk.ts` | `getConfigDirPaths` | `auth.json`, `models.yml`, `models.json` | user |
| `sdk.ts` | `getConfigDirPaths` | `models.yml`, `models.json` | user |
| `lsp/config.ts` | `getConfigDirPaths` | `lsp.json`, `.lsp.json` | user+project |
| `task/discovery.ts` | `getConfigDirs`, `findAllNearestProjectConfigDirs` | `agents/` | user+project |
| `extensibility/plugins/paths.ts` | `getConfigDirPaths` | `plugin-overrides.json` | project |
| `extensibility/custom-commands/loader.ts`| `getConfigDirs` | `commands/` | user+project |
| `web/search/auth.ts` | `getConfigDirPaths`, `getAgentDbPath` | auth.json, agent.db | user |
| `web/search/auth.ts` | `getConfigDirPaths`, `getAgentDbPath` | agent.db | user |
| `web/search/providers/codex.ts` | `getConfigDirPaths`, `getAgentDbPath` | auth config | user |
| `web/search/providers/gemini.ts` | `getConfigDirPaths`, `getAgentDbPath` | auth config | user |
@@ -110,7 +110,6 @@ These modules use `discovery/builtin.ts` which has its own config directory reso
```
User-level (~/.omp/agent/, ~/.pi/agent/, ~/.claude/, ~/.codex/, ~/.gemini/):
├── agent.db ← SQLite storage (settings, auth)
├── auth.json ← Legacy auth (migrated to agent.db)
├── models.yml ← Model configuration (preferred)
├── models.json ← Model configuration (legacy)
├── config.yml ← Settings (alternative to agent.db)
+6 -5
View File
@@ -272,7 +272,7 @@ const { session } = await createAgentSession({
`agentDir` is used for:
- Global settings (`config.yml` + `agent.db`)
- Primary auth/models locations (`auth.json`, `models.yml`, `models.json`)
- Primary auth/models locations (`agent.db`, `models.yml`, `models.json`)
- Prompt templates (`prompts/`)
- Custom TS commands (`commands/`)
@@ -328,12 +328,13 @@ API key resolution priority (handled by AuthStorage):
3. Environment variables (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.)
4. Fallback resolver (for custom provider keys from `models.yml`)
`discoverAuthStorage` also migrates legacy `auth.json` from user config directories (`.pi`, `.claude`, `.codex`, `.gemini`) into `agent.db`.
`discoverAuthStorage` opens the `agent.db` SQLite database in the agent directory.
```typescript
import { AuthStorage, ModelRegistry, discoverAuthStorage, discoverModels } from "@oh-my-pi/pi-coding-agent";
// Default: uses agentDir/auth.json → agent.db and agentDir/models.yml (with legacy fallbacks)
// Default: uses agentDir/agent.db and agentDir/models.yml
const authStorage = await discoverAuthStorage();
const modelRegistry = discoverModels(authStorage);
@@ -347,7 +348,7 @@ const { session } = await createAgentSession({
authStorage.setRuntimeApiKey("anthropic", "sk-my-temp-key");
// Custom auth storage location (use create(), constructor is private)
const customAuth = await AuthStorage.create("/my/app/auth.json");
const customAuth = await AuthStorage.create("/my/app/agent.db");
const customRegistry = new ModelRegistry(customAuth, "/my/app/models.yml");
const { session } = await createAgentSession({
@@ -788,7 +789,7 @@ import {
} from "@oh-my-pi/pi-coding-agent";
// Auth and Models
const authStorage = await discoverAuthStorage(); // <agentDir>/auth.json → agent.db (with fallbacks)
const authStorage = await discoverAuthStorage(); // <agentDir>/agent.db
const modelRegistry = discoverModels(authStorage); // + <agentDir>/models.yml (or models.json)
const allModels = modelRegistry.getAll(); // All models (built-in + custom)
const available = modelRegistry.getAvailable(); // Only models with API keys
@@ -12,7 +12,7 @@ import {
SessionManager,
} from "@oh-my-pi/pi-coding-agent";
// Default: discoverAuthStorage() uses ~/.omp/agent/auth.json
// Default: discoverAuthStorage() uses ~/.omp/agent/agent.db
// discoverModels() loads built-in + custom models from ~/.omp/agent/models.json
const authStorage = await discoverAuthStorage();
const modelRegistry = await discoverModels(authStorage);
@@ -25,7 +25,7 @@ await createAgentSession({
console.log("Session with default auth storage and model registry");
// Custom auth storage location
const customAuthStorage = new AuthStorage("/tmp/my-app/auth.json");
const customAuthStorage = await AuthStorage.create("/tmp/my-app/agent.db");
const customModelRegistry = new ModelRegistry(customAuthStorage, "/tmp/my-app/models.json");
await createAgentSession({
+1 -1
View File
@@ -77,7 +77,7 @@ const { session } = await createAgentSession({
});
// Full control
const customAuth = new AuthStorage("/my/app/auth.json");
const customAuth = await AuthStorage.create("/my/app/agent.db");
customAuth.setRuntimeApiKey("anthropic", Bun.env.MY_KEY!);
const customRegistry = new ModelRegistry(customAuth);
+2
View File
@@ -159,6 +159,8 @@ export class ConfigFile<T> implements IConfigFile<T> {
} else if (configPath.endsWith(".yaml")) {
const jsonPath = `${configPath.slice(0, -5)}.json`;
migrateJsonToYml(jsonPath, configPath);
} else if (configPath.endsWith(".json") || configPath.endsWith(".jsonc")) {
// JSON configs are still supported without migration.
} else {
throw new Error(`Invalid config file path: ${configPath}`);
}
@@ -255,6 +255,7 @@ export class ModelRegistry {
* Reload models from disk (built-in + custom from models.json).
*/
refresh(): void {
this.modelsConfigFile.invalidate();
this.customProviderApiKeys.clear();
this.configError = undefined;
this.loadModels();
-13
View File
@@ -23,7 +23,6 @@ import { Settings, settings } from "./config/settings";
import { initializeWithSettings } from "./discovery";
import { exportFromFile } from "./export/html";
import type { ExtensionUIContext } from "./extensibility/extensions/types";
import { runMigrations, showDeprecationWarnings } from "./migrations";
import { InteractiveMode, runPrintMode, runRpcMode } from "./modes";
import { initTheme, stopThemeWatcher } from "./modes/theme/theme";
import { type CreateAgentSessionOptions, createAgentSession, discoverAuthStorage } from "./sdk";
@@ -488,13 +487,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
const notifs: (InteractiveModeNotify | null)[] = [];
// Run migrations (pass cwd for project-local migrations)
const { migratedAuthProviders: migratedProviders, deprecationWarnings } = await runMigrations(process.cwd());
debugStartup("main:runMigrations");
if (migratedProviders.length > 0) {
notifs.push({ kind: "warn", message: `Migrated credentials to agent.db: ${migratedProviders.join(", ")}` });
}
// Create AuthStorage and ModelRegistry upfront
const authStorage = await discoverAuthStorage();
const modelRegistry = new ModelRegistry(authStorage);
@@ -564,11 +556,6 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
debugStartup("main:initTheme2");
time("initTheme");
// Show deprecation warnings in interactive mode
if (isInteractive && deprecationWarnings.length > 0) {
await showDeprecationWarnings(deprecationWarnings);
}
let scopedModels: ScopedModel[] = [];
const modelPatterns = parsedArgs.models ?? settings.get("enabledModels");
const modelMatchPreferences = {
-175
View File
@@ -1,175 +0,0 @@
/**
* One-time migrations that run on startup.
*/
import * as fs from "node:fs";
import * as path from "node:path";
import { isEnoent, logger } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { getAgentDbPath, getAgentDir } from "./config";
import { AgentStorage } from "./session/agent-storage";
import type { AuthCredential } from "./session/auth-storage";
type SessionHeader = {
type: "session";
cwd: string;
};
function isSessionHeader(value: unknown): value is SessionHeader {
if (!value || typeof value !== "object") return false;
const record = value as Record<string, unknown>;
return record.type === "session" && typeof record.cwd === "string" && record.cwd.length > 0;
}
/**
* Migrate legacy oauth.json and settings.json apiKeys to agent.db.
*
* @returns Array of provider names that were migrated
*/
export async function migrateAuthToAgentDb(): Promise<string[]> {
const agentDir = getAgentDir();
const oauthPath = path.join(agentDir, "oauth.json");
const settingsPath = path.join(agentDir, "settings.json");
const storage = await AgentStorage.open(getAgentDbPath(agentDir));
const migrated: Record<string, AuthCredential[]> = {};
const providers: string[] = [];
try {
const oauth = await Bun.file(oauthPath).json();
try {
for (const [provider, cred] of Object.entries(oauth)) {
if (storage.listAuthCredentials(provider).length > 0) {
continue;
}
migrated[provider] = [{ type: "oauth", ...(cred as object) } as AuthCredential];
providers.push(provider);
}
await fs.promises.rename(oauthPath, `${oauthPath}.migrated`);
} catch (error) {
logger.warn("Failed to migrate oauth.json", { path: oauthPath, error: String(error) });
}
} catch (err) {
if (!isEnoent(err)) {
logger.warn("Failed to read oauth.json", { path: oauthPath, error: String(err) });
}
}
try {
const settings = await Bun.file(settingsPath).json();
try {
if (settings.apiKeys && typeof settings.apiKeys === "object") {
for (const [provider, key] of Object.entries(settings.apiKeys)) {
if (typeof key !== "string") continue;
if (migrated[provider]) continue;
if (storage.listAuthCredentials(provider).length > 0) continue;
migrated[provider] = [{ type: "api_key", key }];
providers.push(provider);
}
delete settings.apiKeys;
await Bun.write(settingsPath, JSON.stringify(settings, null, 2));
}
} catch (error) {
logger.warn("Failed to migrate settings.json apiKeys", { path: settingsPath, error: String(error) });
}
} catch (err) {
if (!isEnoent(err)) {
logger.warn("Failed to read settings.json", { path: settingsPath, error: String(err) });
}
}
for (const [provider, credentials] of Object.entries(migrated)) {
storage.replaceAuthCredentialsForProvider(provider, credentials);
}
return providers;
}
/**
* Migrate sessions from ~/.omp/agent/*.jsonl to proper session directories.
*
* Bug in v0.30.0: Sessions were saved to ~/.omp/agent/ instead of
* ~/.omp/agent/sessions/<encoded-cwd>/. This migration moves them
* to the correct location based on the cwd in their session header.
*
* See: https://github.com/badlogic/pi-mono/issues/320
*/
export async function migrateSessionsFromAgentRoot(): Promise<void> {
const agentDir = getAgentDir();
// Find all .jsonl files directly in agentDir (not in subdirectories)
let files: string[];
try {
const entries = await fs.promises.readdir(agentDir);
files = entries.filter(f => f.endsWith(".jsonl")).map(f => path.join(agentDir, f));
} catch (error) {
logger.warn("Failed to read agent directory for session migration", { path: agentDir, error: String(error) });
return;
}
if (files.length === 0) return;
for (const file of files) {
try {
// Read first line to get session header
let content: string;
try {
content = await Bun.file(file).text();
} catch (err) {
if (isEnoent(err)) continue;
throw err;
}
const entries: unknown[] = Bun.JSONL.parse(content);
const header = entries[0];
if (!isSessionHeader(header)) continue;
const cwd = header.cwd;
// Compute the correct session directory (same encoding as session-manager.ts)
const safePath = `--${cwd.replace(/^[/\\]/, "").replace(/[/\\:]/g, "-")}--`;
const correctDir = path.join(agentDir, "sessions", safePath);
// Create directory if needed
await fs.promises.mkdir(correctDir, { recursive: true });
// Move the file
const fileName = file.split("/").pop() || file.split("\\").pop();
const newPath = path.join(correctDir, fileName!);
if (fs.existsSync(newPath)) continue; // Skip if target exists
await fs.promises.rename(file, newPath);
} catch (error) {
logger.warn("Failed to migrate session file", { path: file, error: String(error) });
}
}
}
/**
* Run all migrations. Called once on startup.
*
* @param _cwd - Current working directory (reserved for future project-local migrations)
* @returns Object with migration results
*/
export async function runMigrations(_cwd: string): Promise<{
migratedAuthProviders: string[];
deprecationWarnings: string[];
}> {
// Then: run data migrations
const migratedAuthProviders = await migrateAuthToAgentDb();
await migrateSessionsFromAgentRoot();
return { migratedAuthProviders, deprecationWarnings: [] };
}
/**
* Display deprecation warnings to the user in interactive mode.
*
* @param warnings - Array of deprecation warning messages
*/
export async function showDeprecationWarnings(warnings: string[]): Promise<void> {
console.log(chalk.yellow("\n⚠ Deprecation Warnings:"));
for (const warning of warnings) {
console.log(chalk.yellow(` • ${warning}`));
}
console.log();
}
+4 -8
View File
@@ -1,4 +1,3 @@
import * as path from "node:path";
import { Agent, type AgentEvent, type AgentMessage, type AgentTool, type ThinkingLevel } from "@oh-my-pi/pi-agent-core";
import { type Message, type Model, supportsXhigh } from "@oh-my-pi/pi-ai";
import type { Component } from "@oh-my-pi/pi-tui";
@@ -6,7 +5,7 @@ import { $env, logger, postmortem } from "@oh-my-pi/pi-utils";
import chalk from "chalk";
import { loadCapability } from "./capability";
import { type Rule, ruleCapability } from "./capability/rule";
import { getAgentDir, getConfigDirPaths } from "./config";
import { getAgentDbPath, getAgentDir } from "./config";
import { ModelRegistry } from "./config/model-registry";
import { formatModelString, parseModelString } from "./config/model-resolver";
import { loadPromptTemplates as loadPromptTemplatesInternal, type PromptTemplate } from "./config/prompt-templates";
@@ -229,13 +228,10 @@ function getDefaultAgentDir(): string {
* Reads from primary path first, then falls back to legacy paths (.pi, .claude).
*/
export async function discoverAuthStorage(agentDir: string = getDefaultAgentDir()): Promise<AuthStorage> {
const primaryPath = path.join(agentDir, "auth.json");
// Get all auth.json paths (user-level only), excluding the primary
const allPaths = getConfigDirPaths("auth.json", { project: false });
const fallbackPaths = allPaths.filter(p => p !== primaryPath);
logger.debug("discoverAuthStorage", { agentDir, primaryPath, allPaths, fallbackPaths });
const dbPath = getAgentDbPath(agentDir);
logger.debug("discoverAuthStorage", { agentDir, dbPath });
const storage = await AuthStorage.create(primaryPath);
const storage = await AuthStorage.create(dbPath);
await storage.reload();
return storage;
}
@@ -329,6 +329,7 @@ export class AgentSession {
private _streamingEditAbortTriggered = false;
private _streamingEditCheckedLineCounts = new Map<string, number>();
private _streamingEditFileCache = new Map<string, string>();
private _promptInFlight = false;
constructor(config: AgentSessionConfig) {
this.agent = config.agent;
@@ -693,7 +694,11 @@ export class AgentSession {
.map(line => line.slice(1));
if (removedLines.length > 0) {
const resolvedPath = resolveToCwd(path, this.sessionManager.getCwd());
const cachedContent = this._streamingEditFileCache.get(resolvedPath);
let cachedContent = this._streamingEditFileCache.get(resolvedPath);
if (cachedContent === undefined) {
this._ensureFileCache(resolvedPath);
cachedContent = this._streamingEditFileCache.get(resolvedPath);
}
if (cachedContent !== undefined) {
const missing = removedLines.find(line => !cachedContent.includes(normalizeToLF(line)));
if (missing) {
@@ -898,7 +903,7 @@ export class AgentSession {
/** Whether agent is currently streaming a response */
get isStreaming(): boolean {
return this.agent.state.isStreaming;
return this.agent.state.isStreaming || this._promptInFlight;
}
/** Current retry attempt (0 if not retrying) */
@@ -1246,95 +1251,100 @@ export class AgentSession {
expandedText: string,
options?: Pick<PromptOptions, "toolChoice" | "images">,
): Promise<void> {
// Flush any pending bash messages before the new prompt
this._flushPendingBashMessages();
this._flushPendingPythonMessages();
this._promptInFlight = true;
try {
// Flush any pending bash messages before the new prompt
this._flushPendingBashMessages();
this._flushPendingPythonMessages();
// Reset todo reminder count on new user prompt
this._todoReminderCount = 0;
// Reset todo reminder count on new user prompt
this._todoReminderCount = 0;
// Validate model
if (!this.model) {
throw new Error(
"No model selected.\n\n" +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
"Then use /model to select a model.",
);
}
// Validate model
if (!this.model) {
throw new Error(
"No model selected.\n\n" +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}\n\n` +
"Then use /model to select a model.",
);
}
// Validate API key
const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
if (!apiKey) {
throw new Error(
`No API key found for ${this.model.provider}.\n\n` +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
);
}
// Validate API key
const apiKey = await this._modelRegistry.getApiKey(this.model, this.sessionId);
if (!apiKey) {
throw new Error(
`No API key found for ${this.model.provider}.\n\n` +
`Use /login, set an API key environment variable, or create ${getAgentDbPath()}`,
);
}
// Check if we need to compact before sending (catches aborted responses)
const lastAssistant = this._findLastAssistantMessage();
if (lastAssistant) {
await this._checkCompaction(lastAssistant, false);
}
// Check if we need to compact before sending (catches aborted responses)
const lastAssistant = this._findLastAssistantMessage();
if (lastAssistant) {
await this._checkCompaction(lastAssistant, false);
}
// Build messages array (custom messages if any, then user message)
const messages: AgentMessage[] = [];
const planReferenceMessage = await this._buildPlanReferenceMessage?.();
if (planReferenceMessage) {
messages.push(planReferenceMessage);
}
const planModeMessage = await this._buildPlanModeMessage();
if (planModeMessage) {
messages.push(planModeMessage);
}
// Build messages array (custom messages if any, then user message)
const messages: AgentMessage[] = [];
const planReferenceMessage = await this._buildPlanReferenceMessage?.();
if (planReferenceMessage) {
messages.push(planReferenceMessage);
}
const planModeMessage = await this._buildPlanModeMessage();
if (planModeMessage) {
messages.push(planModeMessage);
}
messages.push(message);
messages.push(message);
// Inject any pending "nextTurn" messages as context alongside the user message
for (const msg of this._pendingNextTurnMessages) {
messages.push(msg);
}
this._pendingNextTurnMessages = [];
// Inject any pending "nextTurn" messages as context alongside the user message
for (const msg of this._pendingNextTurnMessages) {
messages.push(msg);
}
this._pendingNextTurnMessages = [];
// Auto-read @filepath mentions
const fileMentions = extractFileMentions(expandedText);
if (fileMentions.length > 0) {
const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
autoResizeImages: this.settings.get("images.autoResize"),
});
messages.push(...fileMentionMessages);
}
// Auto-read @filepath mentions
const fileMentions = extractFileMentions(expandedText);
if (fileMentions.length > 0) {
const fileMentionMessages = await generateFileMentionMessages(fileMentions, this.sessionManager.getCwd(), {
autoResizeImages: this.settings.get("images.autoResize"),
});
messages.push(...fileMentionMessages);
}
// Emit before_agent_start extension event
if (this._extensionRunner) {
const result = await this._extensionRunner.emitBeforeAgentStart(
expandedText,
options?.images,
this._baseSystemPrompt,
);
if (result?.messages) {
for (const msg of result.messages) {
messages.push({
role: "custom",
customType: msg.customType,
content: msg.content,
display: msg.display,
details: msg.details,
timestamp: Date.now(),
});
// Emit before_agent_start extension event
if (this._extensionRunner) {
const result = await this._extensionRunner.emitBeforeAgentStart(
expandedText,
options?.images,
this._baseSystemPrompt,
);
if (result?.messages) {
for (const msg of result.messages) {
messages.push({
role: "custom",
customType: msg.customType,
content: msg.content,
display: msg.display,
details: msg.details,
timestamp: Date.now(),
});
}
}
if (result?.systemPrompt !== undefined) {
this.agent.setSystemPrompt(result.systemPrompt);
} else {
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
}
if (result?.systemPrompt !== undefined) {
this.agent.setSystemPrompt(result.systemPrompt);
} else {
this.agent.setSystemPrompt(this._baseSystemPrompt);
}
const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
await this.agent.prompt(messages, agentPromptOptions);
await this.waitForRetry();
} finally {
this._promptInFlight = false;
}
const agentPromptOptions = options?.toolChoice ? { toolChoice: options.toolChoice } : undefined;
await this.agent.prompt(messages, agentPromptOptions);
await this.waitForRetry();
}
/**
@@ -137,7 +137,7 @@ class AuthStorageUsageCache implements UsageCache {
/**
* Credential storage backed by agent.db.
* Reads from SQLite and migrates legacy auth.json paths.
* Reads from SQLite (agent.db).
*/
export class AuthStorage {
private static readonly defaultBackoffMs = 60_000; // Default backoff when no reset time available
@@ -159,7 +159,6 @@ export class AuthStorage {
private fallbackResolver?: (provider: string) => string | undefined;
private constructor(
private dbPath: string,
private storage: AgentStorage,
options: AuthStorageOptions = {},
) {
@@ -181,7 +180,7 @@ export class AuthStorage {
*/
static async create(dbPath: string, options: AuthStorageOptions = {}): Promise<AuthStorage> {
const storage = await AgentStorage.open(dbPath);
return new AuthStorage(dbPath, storage, options);
return new AuthStorage(storage, options);
}
/**
@@ -193,7 +192,6 @@ export class AuthStorage {
const storage = await AgentStorage.open(dbPath);
const instance = Object.create(AuthStorage.prototype) as AuthStorage;
instance.dbPath = dbPath;
instance.storage = storage;
instance.data = new Map();
instance.runtimeOverrides = new Map();
@@ -252,7 +250,6 @@ export class AuthStorage {
return {
credentials,
runtimeOverrides: Object.keys(runtimeOverrides).length > 0 ? runtimeOverrides : undefined,
dbPath: this.dbPath,
};
}
@@ -281,7 +278,7 @@ export class AuthStorage {
/**
* Reload credentials from agent.db.
* Migrates legacy auth.json/settings.json on first load.
* Reloads credentials from the database.
*/
async reload(): Promise<void> {
const records = this.storage.listAuthCredentials();
+3 -26
View File
@@ -7,12 +7,11 @@
* 3. OAuth credentials in ~/.omp/agent/agent.db (with expiry check)
* 4. ANTHROPIC_API_KEY / ANTHROPIC_BASE_URL fallback
*/
import * as path from "node:path";
import { buildAnthropicHeaders as buildProviderAnthropicHeaders, getEnvApiKey } from "@oh-my-pi/pi-ai";
import { $env, logger } from "@oh-my-pi/pi-utils";
import { getAgentDbPath, getConfigDirPaths } from "../../config";
import { AgentStorage } from "../../session/agent-storage";
import type { AuthCredential, AuthCredentialEntry, AuthStorageData } from "../../session/auth-storage";
import type { AuthCredential } from "../../session/auth-storage";
import type { AnthropicAuthConfig, AnthropicOAuthCredential, ModelsJson } from "./types";
const DEFAULT_BASE_URL = "https://api.anthropic.com";
@@ -59,26 +58,8 @@ function toAnthropicOAuthCredential(credential: AuthCredential): AnthropicOAuthC
};
}
function normalizeAuthEntry(entry: AuthCredentialEntry | undefined): AuthCredential[] {
if (!entry) return [];
return Array.isArray(entry) ? entry : [entry];
}
async function readLegacyAnthropicOAuthCredentials(configDir: string): Promise<AnthropicOAuthCredential[]> {
const authJson = await readJson<AuthStorageData>(path.join(configDir, "auth.json"));
if (!authJson) return [];
const entry = authJson.anthropic as AuthCredentialEntry | undefined;
const credentials = normalizeAuthEntry(entry);
const results: AnthropicOAuthCredential[] = [];
for (const credential of credentials) {
const mapped = toAnthropicOAuthCredential(credential);
if (mapped) results.push(mapped);
}
return results;
}
/**
* Reads Anthropic OAuth credentials from agent.db, migrating from legacy auth.json if needed.
* Reads Anthropic OAuth credentials from agent.db.
* @param configDir - Path to the config directory containing agent.db
* @returns Array of valid Anthropic OAuth credentials
*/
@@ -93,10 +74,6 @@ async function readAnthropicOAuthCredentials(configDir: string): Promise<Anthrop
}
}
if (credentials.length === 0) {
return readLegacyAnthropicOAuthCredentials(configDir);
}
return credentials;
}
@@ -125,7 +102,7 @@ export async function findAnthropicAuth(): Promise<AnthropicAuthConfig | null> {
// 2. Provider with api="anthropic-messages" in models.json (check all config dirs)
for (const configDir of configDirs) {
const modelsJson = await readJson<ModelsJson>(path.join(configDir, "models.json"));
const modelsJson = await readJson<ModelsJson>(`${configDir}/models.json`);
if (modelsJson?.providers) {
// First pass: look for providers with actual API keys
for (const [_name, provider] of Object.entries(modelsJson.providers)) {
@@ -88,7 +88,7 @@ export interface ModelsJson {
>;
}
/** auth.json structure for OAuth credentials */
/** OAuth credential for Anthropic API access */
export interface AnthropicOAuthCredential {
type: "oauth";
access: string;
@@ -97,12 +97,6 @@ export interface AnthropicOAuthCredential {
expires: number;
}
export type AnthropicAuthJsonEntry = AnthropicOAuthCredential | AnthropicOAuthCredential[];
export interface AuthJson {
anthropic?: AnthropicAuthJsonEntry;
}
/** Anthropic API response types */
export interface AnthropicSearchResult {
type: "web_search_result";
@@ -64,8 +64,8 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession branching", () =>
sessionManager = noSession ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
session = new AgentSession({
agent,
@@ -68,7 +68,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("AgentSession compaction e2e",
sessionManager = inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settings = Settings.isolated({ "compaction.keepRecentTokens": 1 });
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage);
session = new AgentSession({
@@ -89,8 +89,8 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
session = new AgentSession({
@@ -179,8 +179,8 @@ describe("AgentSession concurrent prompt guard", () => {
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
session = new AgentSession({
@@ -104,7 +104,7 @@ describe.skipIf(!e2eApiKey("ANTHROPIC_API_KEY"))("Compaction hooks", () => {
const sessionManager = SessionManager.create(tempDir);
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage);
hookRunner = new HookRunner(hooks, tempDir, sessionManager, modelRegistry);
@@ -76,7 +76,7 @@ describe.skipIf(!HAS_ANTIGRAVITY_AUTH)("Compaction with thinking models (Antigra
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage);
session = new AgentSession({
@@ -179,7 +179,7 @@ describe.skipIf(!HAS_ANTHROPIC_AUTH)("Compaction with thinking models (Anthropic
const sessionManager = SessionManager.inMemory();
const settings = Settings.isolated();
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage);
session = new AgentSession({
@@ -23,7 +23,7 @@ describe("ExtensionRunner", () => {
extensionsDir = path.join(tempDir.path(), ".omp", "extensions");
fs.mkdirSync(extensionsDir, { recursive: true });
sessionManager = SessionManager.inMemory();
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "auth.json"));
const authStorage = await AuthStorage.create(path.join(tempDir.path(), "testauth.db"));
modelRegistry = new ModelRegistry(authStorage);
});
@@ -15,7 +15,7 @@ describe("ModelRegistry", () => {
tempDir = path.join(os.tmpdir(), `pi-test-model-registry-${Snowflake.next()}`);
fs.mkdirSync(tempDir, { recursive: true });
modelsJsonPath = path.join(tempDir, "models.json");
authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
});
afterEach(() => {
@@ -93,9 +93,9 @@ async function createSession(tempDir: string, streamFn: Agent["streamFn"], tool:
const sessionManager = SessionManager.inMemory(tempDir);
const settings = Settings.isolated({ "edit.streamingAbort": true });
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
authStorage.setRuntimeApiKey("anthropic", "test-key");
const modelRegistry = new ModelRegistry(authStorage, tempDir);
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
return new AgentSession({
agent,
+2 -2
View File
@@ -99,8 +99,8 @@ export async function createTestSession(options: TestSessionOptions = {}): Promi
const sessionManager = options.inMemory ? SessionManager.inMemory() : SessionManager.create(tempDir);
const settings = Settings.isolated(options.settingsOverrides);
const authStorage = await AuthStorage.create(path.join(tempDir, "auth.json"));
const modelRegistry = new ModelRegistry(authStorage, tempDir);
const authStorage = await AuthStorage.create(path.join(tempDir, "testauth.db"));
const modelRegistry = new ModelRegistry(authStorage, path.join(tempDir, "models.yml"));
const session = new AgentSession({
agent,
sessionManager,