Location via proxy:   [ UP ]  
[Report a bug]   [Manage cookies]                
Skip to content

feat(sync): split oc backend primitives#60

Open
ndycode wants to merge 12 commits intosplit/pr55-cli-settings-harnessfrom
split/pr58-backend-primitives
Open

feat(sync): split oc backend primitives#60
ndycode wants to merge 12 commits intosplit/pr55-cli-settings-harnessfrom
split/pr58-backend-primitives

Conversation

@ndycode
Copy link
Owner

@ndycode ndycode commented Mar 10, 2026

Summary

  • split the backend sync primitives out of the larger stack into target detection, import/merge preview, named backup export, and storage hardening
  • keep the storage and merge-preview fixes with the primitives they actually adjust so orchestration can build on a cleaner base
  • close the current review gaps around Windows-safe export behavior, target scope inference, and preview redaction

Why

This branch is the backend foundation for the later sync PRs. Keeping the primitives isolated makes the downstream orchestration and settings flows smaller and easier to review.

Verification

  • npm exec vitest run test/named-backup-export.test.ts test/oc-chatgpt-import-adapter.test.ts test/oc-chatgpt-target-detection.test.ts test/storage.test.ts
  • npm run typecheck
  • npm run build

Notes

  • split replacement for part of #58
  • downstream branches in this stack build on top of this PR

note: greptile review for oc-chatgpt-multi-auth. cite files like lib/foo.ts:123. confirm regression tests + windows concurrency/token redaction coverage.

Greptile Summary

this pr splits backend sync primitives into four focused modules — target detection, import/merge preview, named backup export, and storage hardening — and closes several review gaps from the prior thread. the addressed issues (suffix ordering in normalizeNamedBackupFileName, inferScopeFromRoot false-positives, renameFileWithRetry in exportAccounts, payload token redaction, dead defaultIndex parameter) are all correctly handled and have good vitest coverage.

key changes:

  • lib/named-backup-export.ts — new module with path traversal guards, case-insensitive windows path comparison, and suffix validation correctly ordered after .json strip
  • lib/oc-chatgpt-target-detection.tsinferScopeFromRoot now anchors scope inference to relative(canonicalRoot, root) instead of scanning path segments; trailing slash regex now covers both \ and /
  • lib/oc-chatgpt-import-adapter.tspayload now typed as OcChatgptPreviewPayload (token-redacted); dead defaultIndex parameter removed; merge preview logic is correct
  • lib/storage.tsexportAccounts uses activeTransactionStorage bypass to avoid deadlock when called inside withAccountStorageTransaction; renameFileWithRetry now used for windows-safe atomic rename

open concerns:

  • storageLockDepth is incremented/decremented in withStorageLock (lines 113–128) but never read anywhere — it provides no actual re-entrancy protection. saveAccounts, clearAccounts, saveFlaggedAccounts, and clearFlaggedAccounts all still deadlock silently if called inside a withAccountStorageTransaction callback. the counter should either be wired up as a guard or removed to avoid misleading future readers
  • exportAccounts called inside a transaction exports the in-flight (pre-persist()) snapshot — intentional and tested, but undocumented; a backup created inside an abandoned transaction will diverge from committed storage
  • no vitest coverage for calling the other lock-acquiring exports (saveAccounts, clearAccounts, etc.) from inside a transaction

Confidence Score: 3/5

  • safe to merge as a foundation layer, but storageLockDepth is dead code that creates a false sense of re-entrancy safety for downstream callers
  • the addressed issues from prior threads are well-fixed and tested. the remaining concern — storageLockDepth being tracked but never checked — is not a runtime regression today but is a correctness trap: any downstream code that calls saveAccounts/clearAccounts inside a transaction will deadlock silently, and the counter gives no indication that a guard exists. the pre-persist export snapshot is intentional but undocumented. both issues are manageable before downstream orchestration PRs build on this base.
  • lib/storage.ts — specifically the storageLockDepth dead counter and the exportAccounts in-transaction snapshot semantics

Important Files Changed

Filename Overview
lib/storage.ts adds exportAccounts with re-entrancy bypass via activeTransactionStorage and renameFileWithRetry; storageLockDepth is tracked but never read, leaving other lock-acquiring exports unguarded against in-transaction calls
lib/named-backup-export.ts new module with path traversal guards, suffix validation (correctly ordered after .json strip), and windows-safe case-insensitive comparison; assertWithinDirectory logic is sound
lib/oc-chatgpt-import-adapter.ts merge preview correctly redacts tokens in payload via OcChatgptPreviewPayload; defaultIndex dead parameter removed; normalizeActiveIndexByFamily and clampIndex logic looks correct; merged field still carries raw tokens (pre-existing open thread)
lib/oc-chatgpt-target-detection.ts inferScopeFromRoot now correctly anchors to relative(canonicalRoot, root) removing false-positives; trailing slash handling now uses [\\/]+$ for windows backslash; scope inference tests cover the edge cases
test/named-backup-export.test.ts covers path traversal, suffix rejection (including backup.tmp.json), force-overwrite, and in-transaction export without deadlock; does not cover the case where persist() is called before exportAccounts inside a transaction
test/oc-chatgpt-target-detection.test.ts good coverage of ambiguous, signals-only, windows drive root, projects-in-path false-positive, and explicit-override scenarios; removes the previous scope-inference gap
test/oc-chatgpt-import-adapter.test.ts thorough coverage of dedup priority, merge preview shape, identity-rich collision protection, and refresh-token fallback matching; all assertions target redacted refs, no raw token leaks in test output
test/storage.test.ts exercises deduplication, named backup path building, and export/import roundtrips; no coverage for calling saveAccounts/clearAccounts inside a transaction (deadlock path) or for the Windows lock retry in exportAccounts

Sequence Diagram

sequenceDiagram
    participant Caller
    participant exportNamedBackupFile
    participant exportAccounts
    participant withAccountStorageTransaction
    participant withStorageLock
    participant fs

    note over Caller,fs: path A — called outside a transaction
    Caller->>exportNamedBackupFile: name, deps, options
    exportNamedBackupFile->>exportAccounts: destination, force
    note right of exportAccounts: activeTransactionStorage === undefined
    exportAccounts->>withAccountStorageTransaction: read current
    withAccountStorageTransaction->>withStorageLock: acquire lock
    withStorageLock-->>withAccountStorageTransaction: locked
    withAccountStorageTransaction-->>exportAccounts: storage snapshot
    exportAccounts->>fs: writeFile(tempPath)
    exportAccounts->>fs: renameFileWithRetry(temp→dest)
    exportAccounts-->>exportNamedBackupFile: destination path
    exportNamedBackupFile-->>Caller: destination path

    note over Caller,fs: path B — called inside a transaction (re-entrancy bypass)
    Caller->>withAccountStorageTransaction: callback
    withAccountStorageTransaction->>withStorageLock: acquire lock
    withStorageLock-->>withAccountStorageTransaction: locked, sets activeTransactionStorage
    withAccountStorageTransaction->>exportNamedBackupFile: (inside callback)
    exportNamedBackupFile->>exportAccounts: destination, force
    note right of exportAccounts: activeTransactionStorage !== undefined → skip lock
    exportAccounts->>fs: writeFile(tempPath)
    exportAccounts->>fs: renameFileWithRetry(temp→dest)
    exportAccounts-->>exportNamedBackupFile: destination path
    exportNamedBackupFile-->>withAccountStorageTransaction: destination path
    withAccountStorageTransaction-->>Caller: result
Loading

Fix All in Codex

Last reviewed commit: 428aaea

Greptile also left 1 inline comment on this PR.

Context used:

  • Rule used - What: Every code change must explain how it defend... (source)

@coderabbitai
Copy link

coderabbitai bot commented Mar 10, 2026

📝 Walkthrough

Walkthrough

adds three new modules for oc-chatgpt multi-auth: named backup export with path validation, account import/merge with deduplication, and target detection. updates storage api to expose named backups. includes test coverage for all features.

Changes

Cohort / File(s) Summary
Named backup export
lib/named-backup-export.ts, test/named-backup-export.test.ts
new module for named backup creation with path normalization, directory-escape prevention (assertWithinDirectory), and validation of backup filenames. exported functions: normalizeNamedBackupFileName, getNamedBackupRoot, resolveNamedBackupPath, exportNamedBackupFile. tests validate path safety, filename restrictions, and existing file rejection.
OC ChatGPT import adapter
lib/oc-chatgpt-import-adapter.ts, test/oc-chatgpt-import-adapter.test.ts
new module for merge preview and payload normalization. implements account deduplication by accountId → email → refreshToken, matching strategies, and skip reason tracking. exported functions: buildOcChatgptImportPayload, previewOcChatgptImportMerge. tests cover add/update/skip logic, metadata preservation, and identity matching edge cases.
OC ChatGPT target detection
lib/oc-chatgpt-target-detection.ts, test/oc-chatgpt-target-detection.test.ts
new module for discovering multi-auth targets via three phases: accounts, signals. returns found/ambiguous/none with candidate details (scope, root, artifacts). exported function: detectOcChatgptMultiAuthTarget. tests validate explicit root overrides, ambiguity when multiple candidates qualify, and candidate normalization.
Storage API updates
lib/storage.ts
adds buildNamedBackupPath(name) and exportNamedBackup(name, options) surface; updates exportAccounts(filePath, force) signature to accept optional force flag for controlled overwrites.

Sequence Diagram(s)

sequenceDiagram
    actor Client
    participant Backup as Named Backup Export
    participant Storage as Storage Module
    participant FS as File System
    
    Client->>Backup: exportNamedBackupFile(name, deps)
    Backup->>Backup: normalizeNamedBackupFileName(name)
    Backup->>Backup: getNamedBackupRoot(storagePath)
    Backup->>Backup: resolveNamedBackupPath(name, storagePath)
    Backup->>Backup: assertWithinDirectory(target, root)
    Backup->>Storage: exportAccounts(filePath, force)
    Storage->>FS: write backup file
    FS-->>Storage: file created
    Storage-->>Backup: Promise<void>
    Backup-->>Client: resolved backup path
Loading
sequenceDiagram
    actor Client
    participant Preview as Import Adapter
    participant Normalize as Normalization
    participant Match as Matching
    participant Result as Result Builder
    
    Client->>Preview: previewOcChatgptImportMerge({source, destination})
    Preview->>Normalize: normalizeAccountsForTarget(source)
    Normalize-->>Preview: normalized source + skipped
    Preview->>Normalize: normalizeAccountsForTarget(destination)
    Normalize-->>Preview: normalized destination + skipped
    Preview->>Match: matchDestination(account, normalized dest)
    Match-->>Preview: match result
    Preview->>Result: buildOcChatgptImportPayload(source)
    Result-->>Preview: OcChatgptMergePreview
    Result-->>Client: toAdd, toUpdate, toSkip, unchanged
Loading
sequenceDiagram
    actor Client
    participant Detector as Target Detection
    participant CandidateResolver as Candidate Resolution
    participant Prober as Artifact Prober
    
    Client->>Detector: detectOcChatgptMultiAuthTarget({explicitRoot?, projectRoot?})
    Detector->>CandidateResolver: buildCandidateRoots(explicit, canonical, project)
    CandidateResolver-->>Detector: ordered candidates
    Detector->>Prober: probePhase(candidates, "accounts")
    Prober-->>Detector: qualified candidates with artifacts
    Detector->>Prober: probePhase(candidates, "signals")
    Prober-->>Detector: candidates with backup signals
    Detector-->>Client: OcChatgptTargetDetectionResult
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~65 minutes


flags

path traversal & security: lib/named-backup-export.ts:assertWithinDirectory is the critical guard against directory escape. verify it handles symlinks and case-sensitivity on all platforms. windows drive-letter handling in lib/oc-chatgpt-target-detection.ts:normalizePathForComparison needs explicit validation—does it account for mixed separators?

windows edge cases: target detection probes filesystem artifacts without explicit windows-path-normalization tests. test/oc-chatgpt-target-detection.test.ts has "windows drive root" test but lacks coverage for mixed-separator paths (e.g., C:\Users/profile), UNC paths, and drive-relative paths (e.g., D:..).

account deduplication logic: lib/oc-chatgpt-import-adapter.ts:normalizeAccountsForTarget dedupes by accountId, then email, then refreshToken. email normalization via normalizeEmailKey is not shown in the diff—verify it's case-insensitive and handles unicode correctly. no test for email variants (e.g., user+tag@example.com vs user@example.com).

missing regression tests: test/oc-chatgpt-import-adapter.test.ts does not cover activeIndex preservation when merging accounts with mismatched family counts. test/named-backup-export.test.ts lacks tests for concurrent writes (race on force=false); verify storage handles this.

concurrency risk: lib/storage.ts:exportNamedBackup delegates to exportNamedBackupFile with force flag, but no locking mechanism visible. if two processes call simultaneously with same name and force=false, behavior is undefined.

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 12.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ⚠️ Warning Pull request description covers summary, context, and verification steps, but the required docs/governance checklist is incomplete and rollback plan is missing. Add checked documentation items (README, docs/*.md, SECURITY.md, CONTRIBUTING.md review) and provide a rollback plan before merge. Validation section is present but docs governance is not confirmed.
✅ Passed checks (1 passed)
Check name Status Explanation
Title check ✅ Passed Title uses correct conventional commit format (feat type, lowercase summary, 39 chars) and clearly describes the main change: splitting backend primitives from orchestration.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch split/pr58-backend-primitives

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/oc-chatgpt-target-detection.ts">

<violation number="1" location="lib/oc-chatgpt-target-detection.ts:110">
P2: Do not strip the trailing separator from Windows drive roots; converting `C:\\` to `C:` makes root directory scans drive-relative.</violation>

<violation number="2" location="lib/oc-chatgpt-target-detection.ts:202">
P2: Compare against a normalized explicit root; trailing separators currently cause explicit targets to be misclassified as non-explicit.</violation>
</file>

<file name="test/storage.test.ts">

<violation number="1" location="test/storage.test.ts:1">
P2: Use `removeWithRetry` for test cleanup instead of bare `fs.rm` to avoid flaky EBUSY/EPERM/ENOTEMPTY failures on Windows.</violation>

<violation number="2" location="test/storage.test.ts:1">
P2: This placeholder assertion does not test filename migration, so regressions in migration logic will go undetected.</violation>
</file>

<file name="lib/oc-chatgpt-import-adapter.ts">

<violation number="1" location="lib/oc-chatgpt-import-adapter.ts:154">
P1: `activeIndexByFamily` is clamped after deduplication without key-based remapping, so family selection can point to the wrong account when dedup removes earlier entries.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { promises as fs, existsSync } from "node:fs";
import { dirname, join } from "node:path";
import { existsSync, promises as fs } from "node:fs";
Copy link

@cubic-dev-ai cubic-dev-ai bot Mar 10, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: This placeholder assertion does not test filename migration, so regressions in migration logic will go undetected.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At test/storage.test.ts:

<comment>This placeholder assertion does not test filename migration, so regressions in migration logic will go undetected.</comment>
Fix with Cubic

@ndycode ndycode changed the base branch from draft/pr1-settings-regression-harness to split/pr55-cli-settings-harness March 10, 2026 08:20
…d-primitives

# Conflicts:
#	test/dashboard-settings.test.ts
#	test/unified-settings.test.ts
Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 4 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test/storage.test.ts">

<violation number="1">
P2: This test is outside `describe("storage")`, so it skips the suite’s setup/teardown and can touch the default storage path.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@lib/named-backup-export.ts`:
- Around line 59-63: The thrown error for invalid backup names doesn't mention
that dots are disallowed by BACKUP_SAFE_NAME_REGEX; update the error text thrown
where baseName is validated to explicitly state the allowed characters and that
dots are not permitted (e.g. "Backup filename may only contain letters, numbers,
hyphens, and underscores; dots (.) are not allowed"), or if dots should be
allowed instead, change BACKUP_SAFE_NAME_REGEX to include a literal dot (e.g.,
add '.' to the character class like /^[a-zA-Z0-9._-]+$/) and update any related
validation/tests accordingly.

In `@lib/oc-chatgpt-import-adapter.ts`:
- Around line 142-156: The function normalizeActiveIndexByFamily declares a dead
parameter defaultIndex that is never used; remove the unused defaultIndex
parameter from normalizeActiveIndexByFamily signature and adjust all call sites
that pass a defaultIndex (update their calls to drop that argument), or
alternatively use defaultIndex as a fallback by passing it into remapActiveIndex
when remapping fails (e.g., set normalized[family] =
remapActiveIndex(originalAccounts, raw, normalizedAccounts) ?? defaultIndex).
Choose one approach and make the corresponding change consistently: either
delete defaultIndex from the function and all callers, or wire defaultIndex into
the remapActiveIndex fallback logic inside normalizeActiveIndexByFamily.

In `@lib/oc-chatgpt-target-detection.ts`:
- Around line 182-184: inferScopeFromRoot currently treats any path containing
the token PROJECTS_DIR_NAME as project scope; instead normalize the path and
split into segments, then detect the canonical sequence where OPENCODE_DIR_NAME
is immediately followed by PROJECTS_DIR_NAME (i.e.,
".../.opencode/projects/...") — e.g., in inferScopeFromRoot call
path.normalize(root), split on path.sep and check for an index i where
segments[i] === OPENCODE_DIR_NAME && segments[i+1] === PROJECTS_DIR_NAME; return
"project" only in that case, otherwise "global".

In `@lib/storage.ts`:
- Around line 1572-1597: exportAccounts currently writes directly with
fs.writeFile which can corrupt exports on crash; change exportAccounts to use
the same atomic write pattern as saveAccounts: write JSON to a temp file in the
same directory (use dirname(resolvedPath) and a unique temp name), fsync the
temp file, set file mode 0o600, then rename the temp file to resolvedPath with a
retry/backoff loop and ensure cleanup of the temp file on failure; preserve
logging (log.info path and count) and reuse resolvePath and
withAccountStorageTransaction/ storage.accounts to generate the content before
performing the atomic write/rename.
- Around line 1328-1357: clearAccounts currently calls
getAccountsBackupRecoveryCandidatesWithDiscovery() while inside withStorageLock,
so a readdir error (EPERM/EBUSY on Windows) can abort before Promise.all runs
and block removal of the main accounts file; move discovery outside the lock by
invoking getAccountsBackupRecoveryCandidatesWithDiscovery(path) before entering
withStorageLock (or, alternatively, call it inside but wrap it in a try/catch
that logs the error and returns an empty array) so that backupPaths is always
defined and the Promise.all([...clearPath]) block inside clearAccounts (which
calls clearPath for path, walPath and backupPaths) still runs under the lock to
remove the main files; update references to backupPaths,
getAccountsBackupRecoveryCandidatesWithDiscovery, withStorageLock, Promise.all,
and clearPath accordingly.

In `@test/named-backup-export.test.ts`:
- Around line 89-116: Add a regression test to cover concurrent export races by
invoking exportNamedBackupFile twice in parallel for the same backup name and
asserting one succeeds while the other fails (to cover EBUSY/Windows semantics);
use Promise.allSettled to run two exportNamedBackupFile("concurrent-test", {
getStoragePath, exportAccounts }) calls after preparing state with saveAccounts,
then assert that at least one result has status "fulfilled" and the other is
rejected with an "already exists" or filesystem error; reference the existing
helpers saveAccounts, exportNamedBackupFile, getStoragePath, exportAccounts and
resolveNamedBackupPath to create the destination and verify behavior without
changing production code.
- Around line 131-159: The test currently validates exported content but doesn't
verify atomic writes; update the test (the spec using exportNamedBackupFile) to
assert atomic write behavior: after calling exportNamedBackupFile, confirm the
final resolved file
(join(dirname(storagePath),"backups","backup-2026-03-09.json")) contains the
exact expected JSON and also assert there are no leftover temp/partial files in
the backups directory (check for common temp patterns like *.tmp or a leading
dot/partial filename) to detect non-atomic writes, or if you prefer not to add
runtime checks, add a short test comment referencing lib/storage.ts
exportAccounts and document the known gap that it currently uses fs.writeFile
instead of temp-file-then-rename so reviewers know why atomicity isn't enforced
by the test.

In `@test/oc-chatgpt-import-adapter.test.ts`:
- Around line 291-319: The inline storage objects `destination` and `source` in
the test should be explicitly typed as AccountStorageV3 to avoid implicit type
inference and catch schema drift; update the test to annotate those constants
(the objects passed into previewOcChatgptImportMerge) with the AccountStorageV3
type so the compiler will validate shape and fields when calling
previewOcChatgptImportMerge and when asserting preview results.

In `@test/oc-chatgpt-target-detection.test.ts`:
- Around line 81-104: Add a regression test that ensures
detectOcChatgptMultiAuthTarget (which uses inferScopeFromRoot) does not
misclassify a global target when the user HOME contains a "projects" path
segment: create a fake home directory path containing "projects" (e.g.,
workDir/home/user/my-projects/nested), set process.env.HOME and
process.env.USERPROFILE to it, create a global ~/.opencode with
openai-codex-accounts.json inside, call detectOcChatgptMultiAuthTarget() with no
projectRoot, and assert the returned target has kind "target" and
descriptor.scope "global" (or equivalent assertions used elsewhere in the test
file) to reproduce and prevent the inferScopeFromRoot bug.
- Around line 14-30: The helper function removeWithRetry is duplicated in tests;
extract it into a shared test helper module (e.g., export async function
removeWithRetry(...) from test/helpers/fs-retry.ts) preserving the same
signature and retry logic, update the tests that currently declare
removeWithRetry to import it from that shared module, and remove the duplicate
local implementations so both tests use the single exported removeWithRetry
helper.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 23e73eea-60b6-4c0f-a853-c6c9f9c3b330

📥 Commits

Reviewing files that changed from the base of the PR and between 9b28611 and cfd7e9b.

📒 Files selected for processing (8)
  • lib/named-backup-export.ts
  • lib/oc-chatgpt-import-adapter.ts
  • lib/oc-chatgpt-target-detection.ts
  • lib/storage.ts
  • test/named-backup-export.test.ts
  • test/oc-chatgpt-import-adapter.test.ts
  • test/oc-chatgpt-target-detection.test.ts
  • test/storage.test.ts
📜 Review details
🧰 Additional context used
📓 Path-based instructions (2)
test/**

⚙️ CodeRabbit configuration file

tests must stay deterministic and use vitest. demand regression cases that reproduce concurrency bugs, token refresh races, and windows filesystem behavior. reject changes that mock real secrets or skip assertions.

Files:

  • test/oc-chatgpt-import-adapter.test.ts
  • test/oc-chatgpt-target-detection.test.ts
  • test/named-backup-export.test.ts
lib/**

⚙️ CodeRabbit configuration file

focus on auth rotation, windows filesystem IO, and concurrency. verify every change cites affected tests (vitest) and that new queues handle EBUSY/429 scenarios. check for logging that leaks tokens or emails.

Files:

  • lib/storage.ts
  • lib/named-backup-export.ts
  • lib/oc-chatgpt-target-detection.ts
  • lib/oc-chatgpt-import-adapter.ts
🔇 Additional comments (14)
test/named-backup-export.test.ts (1)

17-33: solid windows-safe cleanup helper.

good use of exponential backoff with retryable error codes for windows filesystem edge cases. covers EBUSY, EPERM, ENOTEMPTY, EACCES.

lib/storage.ts (3)

1593-1596: log statement exposes account count but not tokens - looks safe.

log.info("Exported accounts", { path: resolvedPath, count: storage.accounts.length }) doesn't leak tokens or emails. good.


518-534: exportNamedBackup wiring looks correct.

correctly passes getStoragePath and exportAccounts as dependencies to exportNamedBackupFile, forwarding the force option.


760-767: normalizeEmailKey exported for reuse - good.

clean extraction for email normalization, handles trim and lowercase consistently.

test/oc-chatgpt-import-adapter.test.ts (2)

1-70: solid test coverage for import payload normalization.

tests correctly validate:

  • filtering invalid accounts (empty refresh tokens)
  • identity-rich duplicate preservation by accountId
  • email fallback for id-less entries

deterministic and uses vitest as required.


72-202: comprehensive merge preview test with exact shape assertions.

good validation of:

  • preserve-destination active selection behavior
  • matchedBy strategies (accountId, email)
  • toAdd, toUpdate, toSkip, unchangedDestinationOnly arrays

the exact equality assertions at lines 160-201 are thorough.

lib/oc-chatgpt-target-detection.ts (2)

144-158: hasRotated uses readdirSync - blocking but acceptable for cli startup.

sync filesystem operations block the event loop. acceptable here since target detection runs at cli startup, not in hot paths. the try-catch swallowing unreadable directories is correct.


106-121: normalizeCandidatePath handles windows drive roots correctly.

properly preserves trailing backslash for drive roots like C:\ and strips trailing separators from other paths. good windows handling.

test/oc-chatgpt-target-detection.test.ts (1)

186-197: good windows drive root preservation test.

validates that C:\ is preserved correctly after normalization. uses Object.defineProperty to mock platform - acceptable for this case.

lib/named-backup-export.ts (2)

32-66: thorough backup filename validation.

covers:

  • empty names
  • path separators (/, \)
  • traversal tokens (..)
  • rotation-style sequences (.rotate.)
  • temporary suffixes (.tmp, .wal)
  • safe character regex

good defense against path traversal attacks.


86-97: exportNamedBackupFile correctly wires force flag.

per context snippet 1, options?.force === true ensures force is only passed when explicitly set. the dependency injection pattern is clean.

lib/oc-chatgpt-import-adapter.ts (3)

305-340: three-tier matching strategy is correct.

matchDestination correctly prioritizes:

  1. accountId match (exact identity)
  2. email match (when source has email but dest has no accountId)
  3. refreshToken match (last resort for identity-less accounts)

the usedIndexes set prevents double-matching. good.


65-90: sanitizeAccount defensively handles undefined/invalid fields.

correctly:

  • trims strings
  • returns null for empty refreshToken
  • normalizes addedAt/lastUsed to 0 if not finite

no token/email leakage in this transform.


349-417: previewOcChatgptImportMerge orchestration looks correct.

correctly:

  • normalizes both source and destination
  • clones destination to avoid mutation
  • tracks used indexes for matching
  • preserves destination active selection
  • computes toAdd/toUpdate/toSkip/unchangedDestinationOnly

the activeSelectionBehavior: "preserve-destination" literal is clear.

Comment on lines +89 to +116
it("fails on an existing backup file by default", async () => {
await saveAccounts({
version: 3,
activeIndex: 0,
accounts: [
{
accountId: "acct-1",
refreshToken: "refresh-1",
addedAt: 1,
lastUsed: 1,
},
],
});

const destination = resolveNamedBackupPath(
"backup-2026-03-09",
storagePath,
);
await fs.mkdir(dirname(destination), { recursive: true });
await fs.writeFile(destination, "already-here", "utf-8");

await expect(
exportNamedBackupFile("backup-2026-03-09", {
getStoragePath,
exportAccounts,
}),
).rejects.toThrow(/already exists/);
});
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

missing regression test for concurrent export races.

test validates "already exists" behavior but doesn't cover concurrent export attempts or EBUSY during writeFile. per coding guidelines for test/**, demand regression cases for windows filesystem behavior.

add a test that attempts parallel exports to the same backup name and verifies one succeeds while the other fails gracefully:

it("handles concurrent export attempts without corruption", async () => {
  await saveAccounts({ version: 3, activeIndex: 0, accounts: [{ accountId: "a", refreshToken: "r", addedAt: 1, lastUsed: 1 }] });
  const results = await Promise.allSettled([
    exportNamedBackupFile("concurrent-test", { getStoragePath, exportAccounts }),
    exportNamedBackupFile("concurrent-test", { getStoragePath, exportAccounts }),
  ]);
  const successes = results.filter(r => r.status === "fulfilled");
  expect(successes.length).toBeGreaterThanOrEqual(1);
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/named-backup-export.test.ts` around lines 89 - 116, Add a regression
test to cover concurrent export races by invoking exportNamedBackupFile twice in
parallel for the same backup name and asserting one succeeds while the other
fails (to cover EBUSY/Windows semantics); use Promise.allSettled to run two
exportNamedBackupFile("concurrent-test", { getStoragePath, exportAccounts })
calls after preparing state with saveAccounts, then assert that at least one
result has status "fulfilled" and the other is rejected with an "already exists"
or filesystem error; reference the existing helpers saveAccounts,
exportNamedBackupFile, getStoragePath, exportAccounts and resolveNamedBackupPath
to create the destination and verify behavior without changing production code.

Comment on lines +14 to +30
async function removeWithRetry(
targetPath: string,
options: { recursive?: boolean; force?: boolean },
): Promise<void> {
const retryable = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES"]);
for (let attempt = 0; attempt < 6; attempt += 1) {
try {
await fs.rm(targetPath, options);
return;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ENOENT") return;
if (!code || !retryable.has(code) || attempt === 5) throw error;
await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt));
}
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

duplicate removeWithRetry helper - consider extracting to shared test utility.

this exact helper appears in test/named-backup-export.test.ts:17-33. extract to a shared test/helpers/fs-retry.ts to reduce duplication.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/oc-chatgpt-target-detection.test.ts` around lines 14 - 30, The helper
function removeWithRetry is duplicated in tests; extract it into a shared test
helper module (e.g., export async function removeWithRetry(...) from
test/helpers/fs-retry.ts) preserving the same signature and retry logic, update
the tests that currently declare removeWithRetry to import it from that shared
module, and remove the duplicate local implementations so both tests use the
single exported removeWithRetry helper.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/storage.ts">

<violation number="1" location="lib/storage.ts:1597">
P2: Use the existing retrying rename helper for the temp-file swap; a single `fs.rename` here can cause intermittent export failures on Windows when antivirus/file-lock contention returns `EPERM`/`EBUSY`.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 3 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="lib/storage.ts">

<violation number="1" location="lib/storage.ts:1606">
P1: `exportAccounts` uses a global lock-depth check to bypass the mutex, so concurrent unrelated calls can run unlocked and race with active storage transactions.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.


export type OcChatgptMergePreview = {
payload: OcChatgptPreviewPayload;
merged: AccountStorageV3;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

merged: AccountStorageV3 carries full tokens — same logging risk as payload had

the previous thread addressed payload leaking tokens; payload is now correctly typed as OcChatgptPreviewPayload (only refreshTokenLast4). but merged is still AccountStorageV3, which has raw refreshToken on every account. any caller that serializes or logs the full OcChatgptMergePreview object (e.g. log.debug("merge preview", { preview })) leaks credentials. this is the same class of risk as the prior payload concern.

merged must hold full tokens to be applicable to storage — that's fine. but it needs explicit documentation that it must never be passed to a logger directly. consider:

merged: AccountStorageV3; // WARNING: contains raw refreshToken values — do not log this field directly

no existing vitest coverage logs or serializes previewOcChatgptImportMerge's return value through a mock logger, so this is currently silent.

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/oc-chatgpt-import-adapter.ts
Line: 27

Comment:
**`merged: AccountStorageV3` carries full tokens — same logging risk as `payload` had**

the previous thread addressed `payload` leaking tokens; `payload` is now correctly typed as `OcChatgptPreviewPayload` (only `refreshTokenLast4`). but `merged` is still `AccountStorageV3`, which has raw `refreshToken` on every account. any caller that serializes or logs the full `OcChatgptMergePreview` object (e.g. `log.debug("merge preview", { preview })`) leaks credentials. this is the same class of risk as the prior `payload` concern.

`merged` must hold full tokens to be applicable to storage — that's fine. but it needs explicit documentation that it must never be passed to a logger directly. consider:

```ts
merged: AccountStorageV3; // WARNING: contains raw refreshToken values — do not log this field directly
```

no existing vitest coverage logs or serializes `previewOcChatgptImportMerge`'s return value through a mock logger, so this is currently silent.

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Copy link

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 8 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="test/oc-chatgpt-target-detection.test.ts">

<violation number="1" location="test/oc-chatgpt-target-detection.test.ts:203">
P2: This test case doesn't exercise a real `projects` path segment, so it can miss the scope-classification regression it's meant to cover.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.

@ndycode
Copy link
Owner Author

ndycode commented Mar 10, 2026

Pushed follow-up 428aaea on split/pr58-backend-primitives.

This pass addressed the current high-confidence items still worth fixing on the latest head:

  • gated the export bypass on an active transaction snapshot instead of the global lock depth in lib/storage.ts
  • switched flagged-storage temp swaps to the existing retrying rename helper in lib/storage.ts
  • strengthened the target-detection regression to use a real projects path segment in test/oc-chatgpt-target-detection.test.ts
  • replaced the remaining bare fs.rm cleanup calls in test/storage.test.ts with a Windows-safe retry helper
  • removed the dead defaultIndex parameter in lib/oc-chatgpt-import-adapter.ts
  • clarified the backup-name validation error in lib/named-backup-export.ts to state that dots are not allowed

Validated with npm exec vitest run test/named-backup-export.test.ts test/oc-chatgpt-import-adapter.test.ts test/oc-chatgpt-target-detection.test.ts test/storage.test.ts.

The PR body was already in good shape after the recent rewrite, so I left it concise rather than expanding it again.

Comment on lines +113 to +131
let storageLockDepth = 0;
let activeTransactionStorage: AccountStorageV3 | null | undefined;

function withStorageLock<T>(fn: () => Promise<T>): Promise<T> {
const previousMutex = storageMutex;
let releaseLock: () => void;
storageMutex = new Promise<void>((resolve) => {
releaseLock = resolve;
});
return previousMutex.then(fn).finally(() => releaseLock());
const previousMutex = storageMutex;
let releaseLock: () => void;
storageMutex = new Promise<void>((resolve) => {
releaseLock = resolve;
});
return previousMutex
.then(async () => {
storageLockDepth += 1;
try {
return await fn();
} finally {
storageLockDepth -= 1;
}
})
.finally(() => releaseLock());
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

storageLockDepth tracked but never used as a guard

storageLockDepth is incremented and decremented inside withStorageLock but is never read anywhere in the codebase. it provides zero actual re-entrancy protection — it's dead counter code.

the practical consequence: saveAccounts, clearAccounts, saveFlaggedAccounts, and clearFlaggedAccounts all call withStorageLock and will still deadlock silently if invoked from inside a withAccountStorageTransaction callback. only exportAccounts has the activeTransactionStorage !== undefined bypass. a future caller that e.g. calls saveAccounts mid-transaction will hit a promise-chain deadlock with no diagnostic from this counter.

either wire the counter up as an actual guard (throw if storageLockDepth > 0 and activeTransactionStorage === undefined), or remove it to avoid misleading readers into thinking protection exists. no vitest coverage exercises the deadlock path for any of the other lock-acquiring exported functions.

Suggested change
let storageLockDepth = 0;
let activeTransactionStorage: AccountStorageV3 | null | undefined;
function withStorageLock<T>(fn: () => Promise<T>): Promise<T> {
const previousMutex = storageMutex;
let releaseLock: () => void;
storageMutex = new Promise<void>((resolve) => {
releaseLock = resolve;
});
return previousMutex.then(fn).finally(() => releaseLock());
const previousMutex = storageMutex;
let releaseLock: () => void;
storageMutex = new Promise<void>((resolve) => {
releaseLock = resolve;
});
return previousMutex
.then(async () => {
storageLockDepth += 1;
try {
return await fn();
} finally {
storageLockDepth -= 1;
}
})
.finally(() => releaseLock());
let storageMutex: Promise<void> = Promise.resolve();
let activeTransactionStorage: AccountStorageV3 | null | undefined;
function withStorageLock<T>(fn: () => Promise<T>): Promise<T> {
const previousMutex = storageMutex;
let releaseLock: () => void;
storageMutex = new Promise<void>((resolve) => {
releaseLock = resolve;
});
return previousMutex
.then(async () => {
try {
return await fn();
} finally {
}
})
.finally(() => releaseLock());
}

(or keep the counter and actually check it — the suggestion above is the "remove dead code" path; the "guard" path needs explicit thought around which callers need a bypass like exportAccounts has.)

Prompt To Fix With AI
This is a comment left during a code review.
Path: lib/storage.ts
Line: 113-131

Comment:
**`storageLockDepth` tracked but never used as a guard**

`storageLockDepth` is incremented and decremented inside `withStorageLock` but is never read anywhere in the codebase. it provides zero actual re-entrancy protection — it's dead counter code.

the practical consequence: `saveAccounts`, `clearAccounts`, `saveFlaggedAccounts`, and `clearFlaggedAccounts` all call `withStorageLock` and will still deadlock silently if invoked from inside a `withAccountStorageTransaction` callback. only `exportAccounts` has the `activeTransactionStorage !== undefined` bypass. a future caller that e.g. calls `saveAccounts` mid-transaction will hit a promise-chain deadlock with no diagnostic from this counter.

either wire the counter up as an actual guard (throw if `storageLockDepth > 0` and `activeTransactionStorage === undefined`), or remove it to avoid misleading readers into thinking protection exists. no vitest coverage exercises the deadlock path for any of the other lock-acquiring exported functions.

```suggestion
let storageMutex: Promise<void> = Promise.resolve();
let activeTransactionStorage: AccountStorageV3 | null | undefined;

function withStorageLock<T>(fn: () => Promise<T>): Promise<T> {
	const previousMutex = storageMutex;
	let releaseLock: () => void;
	storageMutex = new Promise<void>((resolve) => {
		releaseLock = resolve;
	});
	return previousMutex
		.then(async () => {
			try {
				return await fn();
			} finally {
			}
		})
		.finally(() => releaseLock());
}
```
(or keep the counter and actually check it — the suggestion above is the "remove dead code" path; the "guard" path needs explicit thought around which callers need a bypass like `exportAccounts` has.)

How can I resolve this? If you propose a fix, please make it concise.

Fix in Codex

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant