Skip to content

Commit 22bfe07

Browse files
committed
fix(storage): keep per-project account files under ~/.opencode/projects
1 parent 67730f9 commit 22bfe07

8 files changed

Lines changed: 121 additions & 12 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ npm run lint # eslint
6464
## NOTES
6565
- OAuth callback: `http://127.0.0.1:1455/auth/callback`.
6666
- ChatGPT backend requires `store: false`, include `reasoning.encrypted_content`.
67-
- Per-project accounts: `.opencode/openai-codex-accounts.json` (walks up to find project root).
67+
- Per-project accounts: `~/.opencode/projects/<project-key>/openai-codex-accounts.json` (walks up to find project root).
6868
- Global accounts: `~/.opencode/openai-codex-accounts.json`.
6969
- Prompt templates synced from Codex CLI GitHub releases with ETag caching.
7070
- 5xx server errors trigger account rotation and health penalty (same as network errors).

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@
22

33
all notable changes to this project. dates are ISO format (YYYY-MM-DD).
44

5+
## [4.12.5] - 2026-02-04
6+
7+
### changed
8+
9+
- **per-project storage location**: project-scoped account files now live under `~/.opencode/projects/<project-key>/openai-codex-accounts.json` instead of writing into `<project>/.opencode/`.
10+
11+
### added
12+
13+
- **legacy migration**: when the new project-scoped path is empty, the plugin now auto-migrates legacy `<project>/.opencode/openai-codex-accounts.json` data on first load.
14+
515
## [4.12.0] - 2026-01-30
616

717
### breaking

README.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,10 @@ Total accounts: 4
439439

440440
**Per-project accounts (v4.10.0+):**
441441

442-
By default, each project directory gets its own account storage. This means you can have different active accounts per project. Works from subdirectories too the plugin walks up to find the project root (v4.11.0). Disable with `perProjectAccounts: false` in your config.
442+
By default, each project gets its own account storage namespace. This means you can keep different active accounts per project without writing account files into your repo. Works from subdirectories too; the plugin walks up to find the project root (v4.11.0). Disable with `perProjectAccounts: false` in your config.
443443

444444
**Storage locations:**
445-
- Per-project: `{project-root}/.opencode/openai-codex-accounts.json`
445+
- Per-project: `~/.opencode/projects/{project-key}/openai-codex-accounts.json`
446446
- Global (when per-project disabled): `~/.opencode/openai-codex-accounts.json`
447447

448448
---
@@ -460,7 +460,7 @@ OpenCode uses `~/.config/opencode/` on **all platforms** including Windows.
460460
| Main config | `~/.config/opencode/opencode.json` |
461461
| Auth tokens | `~/.opencode/auth/openai.json` |
462462
| Multi-account (global) | `~/.opencode/openai-codex-accounts.json` |
463-
| Multi-account (per-project) | `{project}/.opencode/openai-codex-accounts.json` |
463+
| Multi-account (per-project) | `~/.opencode/projects/{project-key}/openai-codex-accounts.json` |
464464
| Plugin config | `~/.opencode/openai-codex-auth-config.json` |
465465
| Debug logs | `~/.opencode/logs/codex-plugin/` |
466466

@@ -635,7 +635,7 @@ Create `~/.opencode/openai-codex-auth-config.json` for optional settings:
635635

636636
| Option | Default | What It Does |
637637
|--------|---------|--------------|
638-
| `perProjectAccounts` | `true` | Each project gets its own account storage |
638+
| `perProjectAccounts` | `true` | Each project gets its own account storage namespace under `~/.opencode/projects/` |
639639
| `toastDurationMs` | `5000` | How long toast notifications stay visible (ms) |
640640

641641
### Retry Behavior
@@ -700,3 +700,4 @@ By using this plugin, you acknowledge:
700700
- "ChatGPT", "GPT-5", "Codex", and "OpenAI" are trademarks of OpenAI, L.L.C.
701701

702702
</details>
703+

docs/configuration.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ result: project uses `high`, other projects use `medium`.
220220
| `~/.opencode/openai-codex-auth-config.json` | plugin config |
221221
| `~/.opencode/auth/openai.json` | oauth tokens |
222222
| `~/.opencode/openai-codex-accounts.json` | global account storage |
223-
| `<project>/.opencode/openai-codex-accounts.json` | per-project account storage |
223+
| `~/.opencode/projects/<project-key>/openai-codex-accounts.json` | per-project account storage |
224224
| `~/.opencode/logs/codex-plugin/` | debug logs |
225225

226226
---
@@ -284,7 +284,7 @@ look for `hasModelSpecificConfig: true`. if it's false, config lookup failed - c
284284

285285
### per-project accounts not working
286286

287-
make sure you're in a project directory (has `.git`, `package.json`, etc). the plugin auto-detects and falls back to global storage otherwise.
287+
make sure you're in a project directory (has `.git`, `package.json`, etc). the plugin auto-detects the project root and uses a namespaced file under `~/.opencode/projects/`. if no project root is found, it falls back to global storage.
288288

289289
check which storage is being used:
290290
```bash

lib/storage.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ACCOUNT_LIMITS } from "./constants.js";
44
import { createLogger } from "./logger.js";
55
import { MODEL_FAMILIES, type ModelFamily } from "./prompts/codex.js";
66
import { AnyAccountStorageSchema, getValidationErrors } from "./schemas.js";
7-
import { getConfigDir, getProjectConfigDir, findProjectRoot, resolvePath } from "./storage/paths.js";
7+
import { getConfigDir, getProjectConfigDir, getProjectGlobalConfigDir, findProjectRoot, resolvePath } from "./storage/paths.js";
88
import {
99
migrateV1ToV3,
1010
type CooldownReason,
@@ -18,6 +18,7 @@ import {
1818
export type { CooldownReason, RateLimitStateV3, AccountMetadataV1, AccountStorageV1, AccountMetadataV3, AccountStorageV3 };
1919

2020
const log = createLogger("storage");
21+
const ACCOUNTS_FILE_NAME = "openai-codex-accounts.json";
2122

2223
/**
2324
* Custom error class for storage operations with platform-aware hints.
@@ -113,23 +114,28 @@ async function ensureGitignore(storagePath: string): Promise<void> {
113114
}
114115

115116
let currentStoragePath: string | null = null;
117+
let currentLegacyProjectStoragePath: string | null = null;
116118

117119
export function setStoragePath(projectPath: string | null): void {
118120
if (!projectPath) {
119121
currentStoragePath = null;
122+
currentLegacyProjectStoragePath = null;
120123
return;
121124
}
122125

123126
const projectRoot = findProjectRoot(projectPath);
124127
if (projectRoot) {
125-
currentStoragePath = join(getProjectConfigDir(projectRoot), "openai-codex-accounts.json");
128+
currentStoragePath = join(getProjectGlobalConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
129+
currentLegacyProjectStoragePath = join(getProjectConfigDir(projectRoot), ACCOUNTS_FILE_NAME);
126130
} else {
127131
currentStoragePath = null;
132+
currentLegacyProjectStoragePath = null;
128133
}
129134
}
130135

131136
export function setStoragePathDirect(path: string | null): void {
132137
currentStoragePath = path;
138+
currentLegacyProjectStoragePath = null;
133139
}
134140

135141
/**
@@ -140,7 +146,40 @@ export function getStoragePath(): string {
140146
if (currentStoragePath) {
141147
return currentStoragePath;
142148
}
143-
return join(getConfigDir(), "openai-codex-accounts.json");
149+
return join(getConfigDir(), ACCOUNTS_FILE_NAME);
150+
}
151+
152+
async function migrateLegacyProjectStorageIfNeeded(): Promise<AccountStorageV3 | null> {
153+
if (
154+
!currentStoragePath ||
155+
!currentLegacyProjectStoragePath ||
156+
currentLegacyProjectStoragePath === currentStoragePath ||
157+
!existsSync(currentLegacyProjectStoragePath)
158+
) {
159+
return null;
160+
}
161+
162+
try {
163+
const legacyContent = await fs.readFile(currentLegacyProjectStoragePath, "utf-8");
164+
const legacyData = JSON.parse(legacyContent) as unknown;
165+
const normalized = normalizeAccountStorage(legacyData);
166+
if (!normalized) return null;
167+
168+
await saveAccounts(normalized);
169+
log.info("Migrated legacy project account storage", {
170+
from: currentLegacyProjectStoragePath,
171+
to: currentStoragePath,
172+
accounts: normalized.accounts.length,
173+
});
174+
return normalized;
175+
} catch (error) {
176+
log.warn("Failed to migrate legacy project account storage", {
177+
from: currentLegacyProjectStoragePath,
178+
to: currentStoragePath,
179+
error: String(error),
180+
});
181+
return null;
182+
}
144183
}
145184

146185
function selectNewestAccount<T extends AccountLike>(
@@ -425,6 +464,8 @@ export async function loadAccounts(): Promise<AccountStorageV3 | null> {
425464
} catch (error) {
426465
const code = (error as NodeJS.ErrnoException).code;
427466
if (code === "ENOENT") {
467+
const migrated = await migrateLegacyProjectStorageIfNeeded();
468+
if (migrated) return migrated;
428469
return null;
429470
}
430471
log.error("Failed to load account storage", { error: String(error) });

lib/storage/paths.ts

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,13 @@
44
*/
55

66
import { existsSync } from "node:fs";
7-
import { dirname, join, resolve } from "node:path";
7+
import { createHash } from "node:crypto";
8+
import { basename, dirname, join, resolve } from "node:path";
89
import { homedir, tmpdir } from "node:os";
910

1011
const PROJECT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".opencode"];
12+
const PROJECTS_DIR = "projects";
13+
const PROJECT_KEY_HASH_LENGTH = 12;
1114

1215
export function getConfigDir(): string {
1316
return join(homedir(), ".opencode");
@@ -17,6 +20,38 @@ export function getProjectConfigDir(projectPath: string): string {
1720
return join(projectPath, ".opencode");
1821
}
1922

23+
function normalizeProjectPath(projectPath: string): string {
24+
const resolvedPath = resolve(projectPath);
25+
const normalizedSeparators = resolvedPath.replace(/\\/g, "/");
26+
return process.platform === "win32"
27+
? normalizedSeparators.toLowerCase()
28+
: normalizedSeparators;
29+
}
30+
31+
function sanitizeProjectName(projectPath: string): string {
32+
const name = basename(projectPath);
33+
const sanitized = name.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
34+
return sanitized || "project";
35+
}
36+
37+
export function getProjectStorageKey(projectPath: string): string {
38+
const normalizedPath = normalizeProjectPath(projectPath);
39+
const hash = createHash("sha256")
40+
.update(normalizedPath)
41+
.digest("hex")
42+
.slice(0, PROJECT_KEY_HASH_LENGTH);
43+
const projectName = sanitizeProjectName(normalizedPath).slice(0, 40);
44+
return `${projectName}-${hash}`;
45+
}
46+
47+
/**
48+
* Per-project storage is namespaced under ~/.opencode/projects
49+
* to avoid writing account files into user repositories.
50+
*/
51+
export function getProjectGlobalConfigDir(projectPath: string): string {
52+
return join(getConfigDir(), PROJECTS_DIR, getProjectStorageKey(projectPath));
53+
}
54+
2055
export function isProjectDirectory(dir: string): boolean {
2156
return PROJECT_MARKERS.some((marker) => existsSync(join(dir, marker)));
2257
}

test/paths.test.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ import { existsSync } from "node:fs";
1010
import {
1111
getConfigDir,
1212
getProjectConfigDir,
13+
getProjectGlobalConfigDir,
14+
getProjectStorageKey,
1315
isProjectDirectory,
1416
findProjectRoot,
1517
resolvePath,
@@ -47,6 +49,25 @@ describe("Storage Paths Module", () => {
4749
});
4850
});
4951

52+
describe("getProjectStorageKey", () => {
53+
it("returns deterministic key for same project path", () => {
54+
const projectPath = "/home/user/myproject";
55+
const first = getProjectStorageKey(projectPath);
56+
const second = getProjectStorageKey(projectPath);
57+
expect(first).toBe(second);
58+
expect(first).toMatch(/^myproject-[a-f0-9]{12}$/);
59+
});
60+
});
61+
62+
describe("getProjectGlobalConfigDir", () => {
63+
it("returns ~/.opencode/projects/<key>", () => {
64+
const projectPath = "/home/user/myproject";
65+
const result = getProjectGlobalConfigDir(projectPath);
66+
expect(result).toContain(path.join(homedir(), ".opencode", "projects"));
67+
expect(result).toContain("myproject-");
68+
});
69+
});
70+
5071
describe("isProjectDirectory", () => {
5172
const markers = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".opencode"];
5273

test/storage.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -708,11 +708,12 @@ describe("storage", () => {
708708
expect(path).toContain(".opencode");
709709
});
710710

711-
it("sets project-relative path when project root found (line 125 coverage)", () => {
711+
it("sets project-scoped path under global .opencode when project root found", () => {
712712
setStoragePath(process.cwd());
713713
const path = getStoragePath();
714714
expect(path).toContain("openai-codex-accounts.json");
715715
expect(path).toContain(".opencode");
716+
expect(path).toContain("projects");
716717
});
717718
});
718719

0 commit comments

Comments
 (0)