Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
- Added privacy-safe pseudonymous product analytics for onboarding, command use, execution modes, run outcomes, TTFT, latency, usage, tools, retries, and compactions, with disclosure and opt-out controls ([ENG-4682](https://linear.app/primeintellect/issue/ENG-4682/add-privacy-safe-posthog-analytics-to-prime-agent)).
- Changed sent agent messages in the IPython cell UI to show only the message text with a `╰─` gutter when expanded, matching received messages, and hid the raw `agent_message.send` receipt dictionary.
- Fixed Homebrew installs attempting to self-update their versioned Cellar keg instead of directing users to `brew upgrade prime-agent` ([#844](https://github.com/PrimeIntellect-ai/prime-agent/issues/844))
- Fixed concurrent first-time `settings.json` writes losing each other's changes, and crash-interrupted writes leaving a truncated file that silently reset all settings; updates now hold the file lock before reading and write through a temp file and rename ([#983](https://github.com/PrimeIntellect-ai/prime-agent/issues/983))
- Fixed crash-interrupted `auth.json` writes risking loss of all stored API keys and OAuth tokens; writes now go through a `0600` temp file and atomic rename ([#983](https://github.com/PrimeIntellect-ai/prime-agent/issues/983))
- Fixed the legacy credential migration removing `oauth.json` and `settings.json` apiKeys before `auth.json` was safely written, which let a crash mid-migration destroy all credentials ([#983](https://github.com/PrimeIntellect-ai/prime-agent/issues/983))

## [0.7.1] - 2026-08-07

Expand Down
43 changes: 37 additions & 6 deletions packages/coding-agent/src/core/auth-storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,18 @@ import {
type OAuthProviderId,
} from "@earendil-works/pi-ai";
import { getOAuthApiKey, getOAuthProvider, getOAuthProviders } from "@earendil-works/pi-ai/oauth";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { dirname, join } from "path";
import {
chmodSync,
closeSync,
existsSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
writeFileSync,
} from "fs";
import { basename, dirname, join } from "path";
import lockfile from "proper-lockfile";
import { getAgentDir } from "../config.js";
import {
Expand Down Expand Up @@ -122,6 +132,29 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
}
}

private writeAtomically(content: string): void {
const tempPath = join(
dirname(this.authPath),
`.${basename(this.authPath)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
);
let fd: number | undefined = openSync(tempPath, "wx", 0o600);
try {
writeFileSync(fd, content, "utf-8");
closeSync(fd);
fd = undefined;
chmodSync(tempPath, 0o600);
renameSync(tempPath, this.authPath);
chmodSync(this.authPath, 0o600);
} finally {
if (fd !== undefined) {
closeSync(fd);
}
if (existsSync(tempPath)) {
rmSync(tempPath, { force: true });
}
}
}

private acquireLockSyncWithRetry(path: string): () => void {
const maxAttempts = 10;
const delayMs = 20;
Expand Down Expand Up @@ -159,8 +192,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
const current = existsSync(this.authPath) ? readFileSync(this.authPath, "utf-8") : undefined;
const { result, next } = fn(current);
if (next !== undefined) {
writeFileSync(this.authPath, next, "utf-8");
chmodSync(this.authPath, 0o600);
this.writeAtomically(next);
}
return result;
} finally {
Expand Down Expand Up @@ -204,8 +236,7 @@ export class FileAuthStorageBackend implements AuthStorageBackend {
const { result, next } = await fn(current);
throwIfCompromised();
if (next !== undefined) {
writeFileSync(this.authPath, next, "utf-8");
chmodSync(this.authPath, 0o600);
this.writeAtomically(next);
}
throwIfCompromised();
return result;
Expand Down
39 changes: 30 additions & 9 deletions packages/coding-agent/src/core/settings-manager.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { ServiceTier, Transport } from "@earendil-works/pi-ai";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "fs";
import { homedir } from "os";
import { dirname, join } from "path";
import { basename, dirname, join } from "path";
import lockfile from "proper-lockfile";
import { CONFIG_DIR_NAME, getAgentDir } from "../config.js";

Expand Down Expand Up @@ -208,6 +208,22 @@ function deepMergeSettings(base: Settings, overrides: Settings): Settings {

export type SettingsScope = "global" | "project";

function writeSettingsFileAtomically(path: string, content: string): void {
const dir = dirname(path);
const tempPath = join(
dir,
`.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
);
try {
writeFileSync(tempPath, content, "utf-8");
renameSync(tempPath, path);
} finally {
if (existsSync(tempPath)) {
rmSync(tempPath, { force: true });
}
}
}

export interface SettingsStorage {
withLock(scope: SettingsScope, fn: (current: string | undefined) => string | undefined): void;
}
Expand Down Expand Up @@ -264,17 +280,22 @@ export class FileSettingsStorage implements SettingsStorage {
if (fileExists) {
release = this.acquireLockSyncWithRetry(path);
}
const current = fileExists ? readFileSync(path, "utf-8") : undefined;
const next = fn(current);
let current = fileExists ? readFileSync(path, "utf-8") : undefined;
let next = fn(current);
if (next !== undefined) {
// Only create directory when we actually need to write
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (!release) {
// Only create directory when we actually need to write
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
release = this.acquireLockSyncWithRetry(path);
// Re-read under the lock so a concurrent first-time write is not lost.
current = existsSync(path) ? readFileSync(path, "utf-8") : undefined;
next = fn(current);
}
if (next !== undefined) {
writeSettingsFileAtomically(path, next);
}
writeFileSync(path, next, "utf-8");
}
} finally {
if (release) {
Expand Down
56 changes: 47 additions & 9 deletions packages/coding-agent/src/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import chalk from "chalk";
import {
chmodSync,
type Dirent,
existsSync,
mkdirSync,
Expand All @@ -25,6 +26,24 @@ const MIGRATION_GUIDE_URL =
const EXTENSIONS_DOC_URL =
"https://github.com/earendil-works/pi-mono/blob/main/packages/coding-agent/docs/extensions.md";

function writeJsonFileAtomically(path: string, value: unknown, mode?: number): void {
const tempPath = join(
dirname(path),
`.${basename(path)}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`,
);
try {
writeFileSync(tempPath, JSON.stringify(value, null, 2), mode === undefined ? undefined : { mode });
if (mode !== undefined) {
chmodSync(tempPath, mode);
}
renameSync(tempPath, path);
} finally {
if (existsSync(tempPath)) {
rmSync(tempPath, { force: true });
}
}
}

/**
* Migrate legacy oauth.json and settings.json apiKeys to auth.json.
*
Expand All @@ -42,43 +61,62 @@ export function migrateAuthToAuthJson(): string[] {
const migrated: Record<string, unknown> = {};
const providers: string[] = [];

// Migrate oauth.json
// Read oauth.json; it is renamed to .migrated only after auth.json is durable.
let hasOAuth = false;
if (existsSync(oauthPath)) {
try {
const oauth = JSON.parse(readFileSync(oauthPath, "utf-8"));
for (const [provider, cred] of Object.entries(oauth)) {
migrated[provider] = { type: "oauth", ...(cred as object) };
providers.push(provider);
}
renameSync(oauthPath, `${oauthPath}.migrated`);
hasOAuth = true;
} catch {
// Skip on error
}
}

// Migrate settings.json apiKeys
// Read settings.json apiKeys; the file is rewritten only after auth.json is durable.
let settings: Record<string, unknown> | undefined;
if (existsSync(settingsPath)) {
try {
const content = readFileSync(settingsPath, "utf-8");
const settings = JSON.parse(content);
if (settings.apiKeys && typeof settings.apiKeys === "object") {
for (const [provider, key] of Object.entries(settings.apiKeys)) {
const parsed = JSON.parse(content) as Record<string, unknown>;
if (parsed.apiKeys && typeof parsed.apiKeys === "object") {
for (const [provider, key] of Object.entries(parsed.apiKeys)) {
if (!migrated[provider] && typeof key === "string") {
migrated[provider] = { type: "api_key", key };
providers.push(provider);
}
}
delete settings.apiKeys;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
settings = parsed;
}
} catch {
// Skip on error
}
}

// Write auth.json first so credentials survive a crash before the old locations are cleaned up.
if (Object.keys(migrated).length > 0) {
mkdirSync(dirname(authPath), { recursive: true });
writeFileSync(authPath, JSON.stringify(migrated, null, 2), { mode: 0o600 });
writeJsonFileAtomically(authPath, migrated, 0o600);
}

if (settings) {
delete settings.apiKeys;
try {
writeJsonFileAtomically(settingsPath, settings);
} catch {
// Skip on error
}
}

if (hasOAuth) {
try {
renameSync(oauthPath, `${oauthPath}.migrated`);
} catch {
// Skip on error
}
}

return providers;
Expand Down
Loading