Skip to content

fix(storage): skip the full startup permission walk after one clean pass (#878) - #885

Open
iceteaSA wants to merge 2 commits into
cortexkit:masterfrom
iceteaSA:fix/storage-permission-walk
Open

iceteaSA wants to merge 2 commits into
cortexkit:masterfrom
iceteaSA:fix/storage-permission-walk

Conversation

@iceteaSA

@iceteaSA iceteaSA commented Nov 9, 2026 •

Copy link
Copy Markdown
Contributor

Approved issue: #878

Harness coverage (required)

For each entry select: covered / not applicable — surface does not exist there / NOT covered.

  • OpenCode 1: covered — the change is in the shared storage code (shared/storage-permissions.ts, features/magic-context/storage-db.ts, shared/storage-file-mutex.ts, hooks/magic-context/lkg-recovery-fence.ts).
  • OpenCode 2: covered — same shared code.
  • Pi: covered — Pi opens the database through the same shared storage code.
  • OMP: covered — same as Pi.
  • Rust module: NOT covered — the module keeps its own store. Its permission code was not changed or checked.

What changes

Startup permission pass. tightenStorageTreeSync checked every entry under the storage folder with synchronous lstat calls, two times on each first database open in a process.

  • After one clean full pass, the pass writes a marker file in the storage folder. The marker name holds a policy version (STORAGE_PERMISSIONS_POLICY_VERSION), so a later change to the permission rules can force one more full pass.
  • When the marker is present and has mode 0600, the pass checks only the storage folder, the database file and its -wal and -shm files, and the models/ and logs/ subtrees. Those two subtrees are written by code that does not set private modes, so they stay recursive.
  • A wrong mode, an access error, or disabled enforcement removes the marker. The next start then does a full pass again.

Fence folder.

  • withStorageFileMutex removes its lock directory with rmdir after the locked operation. rmdir fails while another process holds a ticket in the directory, and the error is ignored. Ticket creation already retries when the directory disappears between mkdir and the ticket write.
  • A fence write uses one fixed temp name, <fence>.json.tmp, under the fence mutex. locked() removes only that name and no longer lists the folder on each operation.
  • The full pass sweeps empty *.json.locks directories that have no fence file, and old temp files with random names from earlier versions. A temp file is removed only when it is older than 10 minutes (LEGACY_FENCE_TEMP_MIN_AGE_MS), because an older version in another process can still be writing it without the new mutex rules.

Commits

  1. fix(storage): bound certified startup permission checks
  2. fix(storage): keep fresh fence temp files during the full-pass sweep

Tests

  • storage-permissions-startup.test.ts: with a marker, a fixture with 2,000 fence files gave 2,004 lstat calls before the change (limit 8) and gives 7 after. A real SQLite reopen gave 4,008 before (limit 16) and gives 14 after. The first boot does a full pass and writes a private marker; a wrong mode removes the marker. When the marker check was removed, the startup test failed at 2,004 calls.
  • storage-permissions-creators.test.ts: every creation path makes files 0600 and directories 0700, including the database, WAL and SHM under umask 0. A creation that finds a wrong mode removes the marker.
  • lkg-recovery-fence.test.ts, storage-file-mutex-review.test.ts: the .locks directory is gone after an operation and stays while a ticket is held; two operations in sequence on one fence work; locked() does not list the folder; the full pass removes empty orphan .locks directories and keeps the others. An old random-name temp file is removed, a fresh one stays, and unrelated names stay. The existing two-process publication race test passes.

Results on this branch (Bun 1.4.0):

  • Focused files: 49 pass, 0 fail.
  • Directory run on the first commit (src/shared, storage database, fence and mutex tests): 1,055 pass, 0 fail.
  • bun run typecheck, Biome on the changed files, and git diff --check: clean.

Summary by cubic

Skips the full storage permission walk on subsequent starts after one clean pass, cutting startup lstat calls without changing what gets enforced.

  • After a clean full walk, a 0600 marker file makes later passes check only the storage root, the database and its -wal/-shm files, and the models/ and logs/ subtrees. Any failure, wrong mode, or disabled enforcement removes the marker and forces a full walk again.
  • The fence mutex removes its empty .locks directory after each operation and uses a fixed temp name instead of listing the folder. The full pass now sweeps empty orphan .locks directories and legacy temp files older than 10 minutes.

Written for commit 857c41f. Summary will update on new commits.

View guided diff

RetriggerView in GreptileConfidence Score: 4/5

The PR appears safe to merge, with a non-blocking gap in marker removal when enforcement changes while a database handle is reused.

Findings

  1. P2 Disabling enforcement leaves stale markers ▶

Summary

Adds a versioned marker so later database opens skip the full storage permission walk.

  • Database opens skip the full storage walk after one clean pass.
  • Fence writes reuse one temp file and empty lock folders disappear.

Acknowledged scope: iceteaSA explicitly chose the ten-minute age threshold for deleting legacy temporary files to preserve recent legacy writes.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Fresh database open] --> B{Enforcement enabled?}
  B -->|No| C[Remove marker]
  B -->|Yes| D{Private marker exists?}
  D -->|No| E[Walk full storage tree]
  E --> F[Write marker after success]
  D -->|Yes| G[Check root, database, models and logs]
  G --> H[Remove marker after repair or failure]
  I[Cached database open] --> J[Reuse handle without permission pass]
Loading

Reviews (1) · Last reviewed commit: "fix(storage): keep fresh fence temp file..." · Reviewed by Greptile

After one clean full pass, a policy-versioned marker in the storage
folder lets later starts check only the root folder, the database and
its -wal/-shm files, and the models/ and logs/ subtrees. A wrong mode,
an access error or disabled enforcement removes the marker, so the next
start walks the whole tree again.

Remove empty fence lock directories after each locked operation, use a
fixed temp name instead of listing the fence folder, and sweep orphan
lock directories and old temp files once during the full pass.

Refs cortexkit#878
@github-actions

github-actions Bot commented Nov 9, 2026

Copy link
Copy Markdown

Waiting for design-approved on #878.

This PR needs a linked issue with the design-approved label before it can be reviewed or merged. Link the approved issue with Approved issue: #<issue> (or Refs #<issue>) in the description. A maintainer will apply the design-approved label on the issue, or trivial on the pull request when there is genuinely no design to agree. The issue stays open until the change ships; maintainers take care of it then.

Comment on lines +255 to +257
if (!shouldEnforcePrivateStoragePermissions()) {
removeMarker();
return { tightened, failures };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Disabling enforcement leaves stale markers

Marker removal only runs when tightenStorageTreeSync is called. Pi shares its database handle across sessions and /reload, and cached openDatabaseAsync calls skip that pass. After a user disables enforcement and reloads, rememberGitIdentity can replace a file with mode 0644 while the old marker remains. A later start with enforcement enabled trusts the marker and skips project-identities, leaving that file readable by other users.

Clear markers when enforcement is disabled, including when the database is already open.

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

4 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. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/plugin/src/shared/storage-file-mutex.ts">

<violation number="1" location="packages/plugin/src/shared/storage-file-mutex.ts:140">
P2: This cleanup can make a contender fail with `ENOENT` before it gets a ticket: three sequential releases can remove the directory between each `ensurePrivateStorageDirectorySync` and `wx` write, exhausting the two retries. Retry directory recreation until ticket creation succeeds (or coordinate cleanup with ticket creation) instead of using a fixed cap.</violation>
</file>

<file name="packages/plugin/src/hooks/magic-context/lkg-recovery-fence.ts">

<violation number="1" location="packages/plugin/src/hooks/magic-context/lkg-recovery-fence.ts:208">
P3: `locked` now removes only the fixed `${path}.tmp`, so a legacy random-name fence temp can remain indefinitely when startup is certified. Sweep legacy names on certified startup or invalidate the permission marker when this format changes.</violation>
</file>

<file name="packages/plugin/src/shared/storage-permissions.ts">

<violation number="1" location="packages/plugin/src/shared/storage-permissions.ts:262">
P2: The startup certificate trusts any regular `0600` marker without validating its version payload. A truncated or corrupted marker can therefore make the next startup skip the full permission walk; read the marker and require the exact expected contents before setting `certified`.</violation>

<violation number="2" location="packages/plugin/src/shared/storage-permissions.ts:262">
P1: Clear the certificate when enforcement is disabled, rather than only from this startup walk. A cached database open can bypass the walk, leaving a stale certificate that makes the next enforced start skip `project-identities` and retain a `0644` file.</violation>
</file>

Reply with feedback, questions, or to request a fix.

View guided diff | Re-trigger cubic

let certified = false;
try {
const stat = lstatSync(marker);
certified = stat.isFile() && (stat.mode & 0o7777) === 0o600;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1: Clear the certificate when enforcement is disabled, rather than only from this startup walk. A cached database open can bypass the walk, leaving a stale certificate that makes the next enforced start skip project-identities and retain a 0644 file.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/plugin/src/shared/storage-permissions.ts, line 262:

<comment>Clear the certificate when enforcement is disabled, rather than only from this startup walk. A cached database open can bypass the walk, leaving a stale certificate that makes the next enforced start skip `project-identities` and retain a `0644` file.</comment>

<file context>
@@ -219,21 +235,78 @@ export async function createStorageFileHandleAsync(
+    let certified = false;
+    try {
+        const stat = lstatSync(marker);
+        certified = stat.isFile() && (stat.mode & 0o7777) === 0o600;
+        if (!certified) removeMarker();
+    } catch (error) {
</file context>

} finally {
rmSync(ownPath, { force: true });
try {
rmdirSync(directory);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: This cleanup can make a contender fail with ENOENT before it gets a ticket: three sequential releases can remove the directory between each ensurePrivateStorageDirectorySync and wx write, exhausting the two retries. Retry directory recreation until ticket creation succeeds (or coordinate cleanup with ticket creation) instead of using a fixed cap.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/plugin/src/shared/storage-file-mutex.ts, line 140:

<comment>This cleanup can make a contender fail with `ENOENT` before it gets a ticket: three sequential releases can remove the directory between each `ensurePrivateStorageDirectorySync` and `wx` write, exhausting the two retries. Retry directory recreation until ticket creation succeeds (or coordinate cleanup with ticket creation) instead of using a fixed cap.</comment>

<file context>
@@ -136,5 +136,10 @@ export function withStorageFileMutex<T>(directory: string, work: () => T, timeou
     } finally {
         rmSync(ownPath, { force: true });
+        try {
+            rmdirSync(directory);
+        } catch {
+            // Do not remove a live contender's ticket.
</file context>

let certified = false;
try {
const stat = lstatSync(marker);
certified = stat.isFile() && (stat.mode & 0o7777) === 0o600;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2: The startup certificate trusts any regular 0600 marker without validating its version payload. A truncated or corrupted marker can therefore make the next startup skip the full permission walk; read the marker and require the exact expected contents before setting certified.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/plugin/src/shared/storage-permissions.ts, line 262:

<comment>The startup certificate trusts any regular `0600` marker without validating its version payload. A truncated or corrupted marker can therefore make the next startup skip the full permission walk; read the marker and require the exact expected contents before setting `certified`.</comment>

<file context>
@@ -219,21 +235,78 @@ export async function createStorageFileHandleAsync(
+    let certified = false;
+    try {
+        const stat = lstatSync(marker);
+        certified = stat.isFile() && (stat.mode & 0o7777) === 0o600;
+        if (!certified) removeMarker();
+    } catch (error) {
</file context>

rmSync(join(dirname(path), name), { force: true });
}
// The mutex prevents concurrent use of the temp name. A folder listing is not necessary.
rmSync(`${path}.tmp`, { force: true });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: locked now removes only the fixed ${path}.tmp, so a legacy random-name fence temp can remain indefinitely when startup is certified. Sweep legacy names on certified startup or invalidate the permission marker when this format changes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At packages/plugin/src/hooks/magic-context/lkg-recovery-fence.ts, line 208:

<comment>`locked` now removes only the fixed `${path}.tmp`, so a legacy random-name fence temp can remain indefinitely when startup is certified. Sweep legacy names on certified startup or invalidate the permission marker when this format changes.</comment>

<file context>
@@ -201,10 +204,8 @@ function locked<T>(sessionId: string, work: () => T): T {
-                rmSync(join(dirname(path), name), { force: true });
-        }
+        // The mutex prevents concurrent use of the temp name. A folder listing is not necessary.
+        rmSync(`${path}.tmp`, { force: true });
         return work();
     });
</file context>

This branch has not been deployed

No deployments
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