From 02d34d103cb974f0e3ee313266438e72368703fd Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 06:38:57 +0800 Subject: [PATCH 1/8] fix(storage): harden lock cleanup for stale/failed storage locks - cleanup stale/dead process lock artifacts before acquiring account lock - ensure lock release always attempts fallback cleanup - keep clearAccounts/saveTransactions serialized across file and memory locks Co-authored-by: Codex --- lib/storage.ts | 97 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 77 insertions(+), 20 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 2b92654..bfcd924 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -119,6 +119,10 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } +async function withStorageSerializedFileLock(path: string, fn: () => Promise): Promise { + return withStorageLock(() => withAccountFileLock(path, fn)); +} + type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; type AccountLike = { @@ -318,16 +322,70 @@ function getAccountsLockPath(path: string): string { return `${path}.lock`; } +async function releaseStorageLockFallback(lockPath: string): Promise { + try { + await fs.rm(lockPath, { force: true }); + } catch { + // Best-effort lock cleanup fallback. + } +} + +async function cleanupDeadProcessStorageLock(lockPath: string): Promise { + try { + const raw = await fs.readFile(lockPath, "utf-8"); + const parsed = JSON.parse(raw) as { pid?: number; acquiredAt?: number }; + const lockPid = Number(parsed?.pid); + const lockAcquiredAt = Number(parsed?.acquiredAt); + + if (Number.isFinite(lockPid) && lockPid > 0) { + let isDeadProcess = false; + try { + process.kill(lockPid, 0); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + isDeadProcess = code === "ESRCH"; + } + + if (isDeadProcess) { + await releaseStorageLockFallback(lockPath); + return; + } + } + + if (Number.isFinite(lockAcquiredAt) && Date.now() - lockAcquiredAt > ACCOUNT_STORAGE_LOCK_OPTIONS.staleAfterMs) { + await releaseStorageLockFallback(lockPath); + } + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return; + } + await releaseStorageLockFallback(lockPath); + } +} + async function withAccountFileLock(path: string, fn: () => Promise): Promise { + const lockPath = getAccountsLockPath(path); + await cleanupDeadProcessStorageLock(lockPath); await fs.mkdir(dirname(path), { recursive: true }); - const lock = await acquireFileLock(getAccountsLockPath(path), ACCOUNT_STORAGE_LOCK_OPTIONS); + const lock = await acquireFileLock(lockPath, ACCOUNT_STORAGE_LOCK_OPTIONS); try { return await fn(); } finally { - await lock.release(); + try { + await lock.release(); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.warn("Failed to release account storage lock", { + path: lockPath, + error: String(error), + }); + } + } + await releaseStorageLockFallback(lockPath); } } - async function copyFileWithRetry( sourcePath: string, destinationPath: string, @@ -1242,14 +1300,13 @@ export async function withAccountStorageTransaction( ) => Promise, ): Promise { const path = getStoragePath(); - return withAccountFileLock(path, () => - withStorageLock(async () => { - const current = await loadAccountsInternal(saveAccountsUnlocked); - return handler(current, saveAccountsUnlocked); - }), - ); + return withStorageSerializedFileLock(path, async () => { + const current = await loadAccountsInternal(saveAccountsUnlocked); + return handler(current, saveAccountsUnlocked); + }); } + /** * Persists account storage to disk using atomic write (temp file + rename). * Creates the Codex multi-auth storage directory if it doesn't exist. @@ -1259,23 +1316,21 @@ export async function withAccountStorageTransaction( */ export async function saveAccounts(storage: AccountStorageV3): Promise { const path = getStoragePath(); - return withAccountFileLock(path, () => - withStorageLock(async () => { - await saveAccountsUnlocked(storage); - }), - ); + return withStorageSerializedFileLock(path, async () => { + await saveAccountsUnlocked(storage); + }); } + /** * Deletes the account storage file from disk. * Silently ignores if file doesn't exist. */ export async function clearAccounts(): Promise { const path = getStoragePath(); - return withAccountFileLock(path, () => - withStorageLock(async () => { - const walPath = getAccountsWalPath(path); - const backupPaths = getAccountsBackupRecoveryCandidates(path); + return withStorageSerializedFileLock(path, async () => { + const walPath = getAccountsWalPath(path); + const backupPaths = getAccountsBackupRecoveryCandidates(path); const clearPath = async (targetPath: string): Promise => { try { await fs.unlink(targetPath); @@ -1295,10 +1350,10 @@ export async function clearAccounts(): Promise { } catch { // Individual path cleanup is already best-effort with per-artifact logging. } - }), - ); + }); } + function normalizeFlaggedStorage(data: unknown): FlaggedAccountStorageV1 { if (!isRecord(data) || data.version !== 1 || !Array.isArray(data.accounts)) { return { version: 1, accounts: [] }; @@ -1597,3 +1652,5 @@ export async function rotateStoredSecretEncryption(): Promise<{ flaggedAccounts: flaggedCount, }; } + + From d267377c3d7ac9b576fccc3fd850af3ea0e10afa Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 12:45:10 +0800 Subject: [PATCH 2/8] fix(storage): serialize account file lock queue Ensure account-storage mutations keep deterministic ordering while preserving the historical file-lock before in-process mutex acquisition sequence. Co-authored-by: Codex --- lib/storage.ts | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/lib/storage.ts b/lib/storage.ts index bfcd924..557036f 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -109,6 +109,7 @@ export function formatStorageErrorHint(error: unknown, path: string): string { } let storageMutex: Promise = Promise.resolve(); +let accountFileMutex: Promise = Promise.resolve(); function withStorageLock(fn: () => Promise): Promise { const previousMutex = storageMutex; @@ -119,8 +120,22 @@ function withStorageLock(fn: () => Promise): Promise { return previousMutex.then(fn).finally(() => releaseLock()); } +function withAccountFileMutex(fn: () => Promise): Promise { + const previousMutex = accountFileMutex; + let releaseLock: () => void; + accountFileMutex = new Promise((resolve) => { + releaseLock = resolve; + }); + return previousMutex.then(fn).finally(() => releaseLock()); +} + async function withStorageSerializedFileLock(path: string, fn: () => Promise): Promise { - return withStorageLock(() => withAccountFileLock(path, fn)); + // Serialize file-lock acquisition to keep save ordering deterministic, then + // preserve the historical lock order (file lock -> in-process mutex) so all + // account-storage mutation paths share the same acquisition sequence. + return withAccountFileMutex(() => + withAccountFileLock(path, () => withStorageLock(fn)), + ); } type AnyAccountStorage = AccountStorageV1 | AccountStorageV3; From 1f516d1320a8d6a2f360d5e033c6a496ac6c428e Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 12:59:48 +0800 Subject: [PATCH 3/8] fix(storage): remove racy pre-lock cleanup Drop pre-acquire dead-process lock cleanup and only run fallback lock-file deletion when lock.release() fails. Also align lock-order comment and add debug observability for fallback cleanup failures. Co-authored-by: Codex --- lib/storage.ts | 47 +++++++---------------------------------------- 1 file changed, 7 insertions(+), 40 deletions(-) diff --git a/lib/storage.ts b/lib/storage.ts index 557036f..30fafba 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -130,9 +130,8 @@ function withAccountFileMutex(fn: () => Promise): Promise { } async function withStorageSerializedFileLock(path: string, fn: () => Promise): Promise { - // Serialize file-lock acquisition to keep save ordering deterministic, then - // preserve the historical lock order (file lock -> in-process mutex) so all - // account-storage mutation paths share the same acquisition sequence. + // Serialize file-lock acquisition to keep save ordering deterministic. + // Acquisition order: file-queue mutex -> file lock -> storage mutex. return withAccountFileMutex(() => withAccountFileLock(path, () => withStorageLock(fn)), ); @@ -340,48 +339,16 @@ function getAccountsLockPath(path: string): string { async function releaseStorageLockFallback(lockPath: string): Promise { try { await fs.rm(lockPath, { force: true }); - } catch { - // Best-effort lock cleanup fallback. - } -} - -async function cleanupDeadProcessStorageLock(lockPath: string): Promise { - try { - const raw = await fs.readFile(lockPath, "utf-8"); - const parsed = JSON.parse(raw) as { pid?: number; acquiredAt?: number }; - const lockPid = Number(parsed?.pid); - const lockAcquiredAt = Number(parsed?.acquiredAt); - - if (Number.isFinite(lockPid) && lockPid > 0) { - let isDeadProcess = false; - try { - process.kill(lockPid, 0); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - isDeadProcess = code === "ESRCH"; - } - - if (isDeadProcess) { - await releaseStorageLockFallback(lockPath); - return; - } - } - - if (Number.isFinite(lockAcquiredAt) && Date.now() - lockAcquiredAt > ACCOUNT_STORAGE_LOCK_OPTIONS.staleAfterMs) { - await releaseStorageLockFallback(lockPath); - } } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT") { - return; - } - await releaseStorageLockFallback(lockPath); + log.debug("Best-effort lock cleanup fallback failed", { + path: lockPath, + error: String(error), + }); } } async function withAccountFileLock(path: string, fn: () => Promise): Promise { const lockPath = getAccountsLockPath(path); - await cleanupDeadProcessStorageLock(lockPath); await fs.mkdir(dirname(path), { recursive: true }); const lock = await acquireFileLock(lockPath, ACCOUNT_STORAGE_LOCK_OPTIONS); try { @@ -396,9 +363,9 @@ async function withAccountFileLock(path: string, fn: () => Promise): Promi path: lockPath, error: String(error), }); + await releaseStorageLockFallback(lockPath); } } - await releaseStorageLockFallback(lockPath); } } async function copyFileWithRetry( From 6c33f9ce2dad9f473f3d2c70eca030896b1a9ddc Mon Sep 17 00:00:00 2001 From: ndycode Date: Wed, 4 Mar 2026 13:07:07 +0800 Subject: [PATCH 4/8] test(storage): cover lock release fallback behavior Add regression tests for lock.release() EBUSY/EPERM fallback cleanup and concurrent save lock serialization to close outstanding PR feedback. Co-authored-by: Codex --- test/storage.test.ts | 100 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/test/storage.test.ts b/test/storage.test.ts index 3c3157e..82e356b 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { promises as fs, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { tmpdir } from "node:os"; +import * as fileLock from "../lib/file-lock.js"; import { getConfigDir, getProjectStorageKey } from "../lib/storage/paths.js"; import { deduplicateAccounts, @@ -1539,6 +1540,105 @@ describe("storage", () => { statSpy.mockRestore(); }); + it("falls back to best-effort cleanup when lock.release throws EBUSY", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const lockPath = `${testStoragePath}.lock`; + + const acquireSpy = vi.spyOn(fileLock, "acquireFileLock").mockResolvedValue({ + path: lockPath, + release: async () => { + const err = new Error("EBUSY release") as NodeJS.ErrnoException; + err.code = "EBUSY"; + throw err; + }, + }); + const rmSpy = vi.spyOn(fs, "rm"); + try { + await saveAccounts(storage); + + expect(existsSync(testStoragePath)).toBe(true); + expect(rmSpy).toHaveBeenCalledWith(lockPath, { force: true }); + } finally { + rmSpy.mockRestore(); + acquireSpy.mockRestore(); + } + }); + + it("falls back to best-effort cleanup when lock.release throws EPERM", async () => { + const now = Date.now(); + const storage = { + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token", addedAt: now, lastUsed: now }], + }; + const lockPath = `${testStoragePath}.lock`; + + const acquireSpy = vi.spyOn(fileLock, "acquireFileLock").mockResolvedValue({ + path: lockPath, + release: async () => { + const err = new Error("EPERM release") as NodeJS.ErrnoException; + err.code = "EPERM"; + throw err; + }, + }); + const rmSpy = vi.spyOn(fs, "rm"); + try { + await saveAccounts(storage); + + expect(existsSync(testStoragePath)).toBe(true); + expect(rmSpy).toHaveBeenCalledWith(lockPath, { force: true }); + } finally { + rmSpy.mockRestore(); + acquireSpy.mockRestore(); + } + }); + + it("serializes lock acquisition across concurrent saves", async () => { + const now = Date.now(); + let activeLocks = 0; + let maxActiveLocks = 0; + + const acquireSpy = vi.spyOn(fileLock, "acquireFileLock").mockImplementation(async (path) => { + activeLocks += 1; + maxActiveLocks = Math.max(maxActiveLocks, activeLocks); + return { + path, + release: async () => { + await new Promise((resolve) => setTimeout(resolve, 20)); + activeLocks -= 1; + }, + }; + }); + try { + await Promise.all([ + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-1", addedAt: now + 1, lastUsed: now + 1 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-2", addedAt: now + 2, lastUsed: now + 2 }], + }), + saveAccounts({ + version: 3 as const, + activeIndex: 0, + accounts: [{ refreshToken: "token-3", addedAt: now + 3, lastUsed: now + 3 }], + }), + ]); + + expect(maxActiveLocks).toBe(1); + } finally { + acquireSpy.mockRestore(); + } + }); + it("retries backup copyFile on transient EBUSY and succeeds", async () => { const now = Date.now(); const storage = { From 0c48823b438a5cb91d646602d2dabc4249e487dd Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 16:06:25 +0800 Subject: [PATCH 5/8] fix: resolve remaining PR32 review feedback Address unresolved review threads by hardening audit/log redaction, lock scope, cursor validation, retention retries, and storage decrypt behavior; align workflows/docs/scripts; and add regression tests for all actionable items. Co-authored-by: Codex --- .github/workflows/ci.yml | 16 +- .github/workflows/supply-chain.yml | 7 +- docs/development/TESTING.md | 8 + docs/runbooks/operations.md | 3 +- index.ts | 14 +- lib/background-jobs.ts | 18 +- lib/codex-manager.ts | 1 + lib/data-retention.ts | 40 +- lib/file-lock.ts | 54 +- lib/storage.ts | 39 +- lib/unified-settings.ts | 131 +- package-lock.json | 2152 +++++++++++++++++++++++++++- package.json | 3 +- scripts/license-policy-check.js | 47 +- test/authorization.test.ts | 40 +- test/background-jobs.test.ts | 95 +- test/codex-manager-cli.test.ts | 68 +- test/data-retention.test.ts | 82 +- test/file-lock.test.ts | 56 +- test/storage.test.ts | 36 + test/unified-settings.test.ts | 78 +- 21 files changed, 2797 insertions(+), 191 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8679f44..dd2abda 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,17 +52,31 @@ jobs: - name: Build run: npm run build + - name: Upload build artifact + if: matrix.node-version == '20.x' + uses: actions/upload-artifact@v4 + with: + name: dist-node20 + path: dist/ + - name: Run tests run: npm test coverage-gate: name: Coverage Gate runs-on: ubuntu-latest + needs: test steps: - name: Checkout code uses: actions/checkout@v4 + - name: Download build artifact + uses: actions/download-artifact@v4 + with: + name: dist-node20 + path: dist/ + - name: Setup Node.js uses: actions/setup-node@v4 with: @@ -73,7 +87,7 @@ jobs: run: npm ci - name: Run tests with coverage threshold gate - run: npm run coverage + run: npm run test:coverage lint: name: Lint diff --git a/.github/workflows/supply-chain.yml b/.github/workflows/supply-chain.yml index b2d94a4..5a461d6 100644 --- a/.github/workflows/supply-chain.yml +++ b/.github/workflows/supply-chain.yml @@ -8,6 +8,9 @@ on: schedule: - cron: "0 4 * * 1" +env: + CODEX_LICENSE_DENYLIST: "GPL-2.0,GPL-3.0,AGPL-3.0" + jobs: dependency-review: name: Dependency Review @@ -26,7 +29,7 @@ jobs: with: fail-on-severity: high fail-on-scopes: runtime - deny-licenses: GPL-2.0, GPL-3.0, AGPL-3.0 + deny-licenses: ${{ env.CODEX_LICENSE_DENYLIST }} sca-and-license: name: SCA and License Gate @@ -69,7 +72,7 @@ jobs: run: npm ci - name: Generate CycloneDX SBOM - run: npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json --omit dev + run: npm run sbom - name: Upload SBOM artifact uses: actions/upload-artifact@v4 diff --git a/docs/development/TESTING.md b/docs/development/TESTING.md index 5b29534..6695f74 100644 --- a/docs/development/TESTING.md +++ b/docs/development/TESTING.md @@ -50,6 +50,14 @@ npm run bench:edit-formats:smoke 7. `npm run license:check` 8. run docs command checks for newly documented command paths +### Upgrade Notes (PR #32) + +- Gate ordering was updated so `npm run coverage` runs before `npm run build`. +- Two required supply-chain checks were added to the standard local sequence: + - `npm run audit:ci` + - `npm run license:check` +- If you maintain local CI wrappers or pre-push scripts, update them to use the order above and rerun once to refresh baselines. + * * * ## Auth/Account Change Test Matrix diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 88eb326..37d49ff 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -24,7 +24,8 @@ Routine operations checklist for maintainers. 1. Rotate encryption key material: - set `CODEX_AUTH_ENCRYPTION_KEY` (new key) - set `CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY` (prior key) - - run `codex auth rotate-secrets --json` + - run `codex auth rotate-secrets --json --idempotency-key ` + - prefer a stable run id source (for example `weekly-YYYYMMDD` or CI `${{ github.run_id }}`) - remove previous key after successful rotation validation 2. Review dependency and license policy reports: - `npm run audit:ci` diff --git a/index.ts b/index.ts index 9e4a498..341a900 100644 --- a/index.ts +++ b/index.ts @@ -1722,16 +1722,26 @@ while (attempted.size < Math.max(1, accountCount)) { ); } const errorMsg = networkError instanceof Error ? networkError.message : String(networkError); + const safeAuditResource = (() => { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return url; + } + })(); + const networkErrorType = + networkError instanceof Error ? networkError.name : "unknown_error"; logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`); auditLog( AuditAction.REQUEST_FAILURE, account.email ?? `account-${account.index + 1}`, - url, + safeAuditResource, AuditOutcome.FAILURE, { model, accountIndex: account.index + 1, - error: errorMsg, + errorType: networkErrorType, stage: "network", }, ); diff --git a/lib/background-jobs.ts b/lib/background-jobs.ts index 34d98e7..2a0fd8c 100644 --- a/lib/background-jobs.ts +++ b/lib/background-jobs.ts @@ -36,11 +36,25 @@ function toErrorMessage(error: unknown): string { return error instanceof Error ? error.message : String(error); } +function sanitizeErrorMessage(message: string): string { + return message + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "***REDACTED***") + .replace( + /\b(?:access|refresh|id)?_?token(?:=|:)?\s*([A-Z0-9._-]+)/gi, + "token=***REDACTED***", + ) + .replace(/\b(Bearer)\s+[A-Z0-9._-]+\b/gi, "$1 ***REDACTED***"); +} + function getDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number): number { return Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1)); } function isRetryableByDefault(error: unknown): boolean { + const statusCode = (error as { statusCode?: unknown } | undefined)?.statusCode; + if (typeof statusCode === "number" && statusCode === 429) { + return true; + } const code = (error as NodeJS.ErrnoException | undefined)?.code; if (typeof code !== "string") return false; return code === "EBUSY" || code === "EPERM" || code === "EAGAIN" || code === "ETIMEDOUT"; @@ -87,7 +101,7 @@ export async function runBackgroundJobWithRetry(options: BackgroundJobRetryOp } } - const errorMessage = toErrorMessage(lastError); + const errorMessage = sanitizeErrorMessage(toErrorMessage(lastError)); const deadLetter: DeadLetterEntry = { version: 1, timestamp: new Date().toISOString(), @@ -102,7 +116,7 @@ export async function runBackgroundJobWithRetry(options: BackgroundJobRetryOp } catch (dlqError) { logWarn("Failed to append background job dead-letter", { job: options.name, - error: toErrorMessage(dlqError), + error: sanitizeErrorMessage(toErrorMessage(dlqError)), }); } diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 2f7b54e..087cb11 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -1518,6 +1518,7 @@ function encodePaginationCursor(index: number): string { function decodePaginationCursor(cursor: string): number | null { try { const decoded = Buffer.from(cursor, "base64").toString("utf8"); + if (!/^\d+$/.test(decoded)) return null; const parsed = Number.parseInt(decoded, 10); if (!Number.isFinite(parsed) || parsed < 0) return null; return parsed; diff --git a/lib/data-retention.ts b/lib/data-retention.ts index 3b972ee..8b7ae4e 100644 --- a/lib/data-retention.ts +++ b/lib/data-retention.ts @@ -1,6 +1,7 @@ import { promises as fs, type Dirent } from "node:fs"; import { join } from "node:path"; import { getCodexCacheDir, getCodexLogDir, getCodexMultiAuthDir } from "./runtime-paths.js"; +import { sleep } from "./utils.js"; export interface RetentionPolicy { logDays: number; @@ -17,6 +18,26 @@ const DEFAULT_POLICY: RetentionPolicy = { quotaCacheDays: 14, dlqDays: 30, }; +const RETRYABLE_RETENTION_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN"]); + +function isRetryableRetentionError(error: unknown): boolean { + const code = (error as NodeJS.ErrnoException).code; + return typeof code === "string" && RETRYABLE_RETENTION_CODES.has(code); +} + +async function withRetentionIoRetry(operation: () => Promise): Promise { + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + return await operation(); + } catch (error) { + if (!isRetryableRetentionError(error) || attempt >= 4) { + throw error; + } + await sleep(10 * 2 ** attempt); + } + } + throw new Error("Unreachable retention retry state"); +} function parseEnvDays(name: string, fallback: number): number { const raw = process.env[name]; @@ -43,7 +64,9 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise[] = []; try { - entries = await fs.readdir(path, { withFileTypes: true, encoding: "utf8" }); + entries = await withRetentionIoRetry(() => + fs.readdir(path, { withFileTypes: true, encoding: "utf8" }), + ); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") return 0; @@ -55,23 +78,24 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise pruneDirectoryByAge(fullPath, maxAgeMs)); + const childEntries = await withRetentionIoRetry(() => fs.readdir(fullPath)); if (childEntries.length === 0) { - await fs.rmdir(fullPath); + await withRetentionIoRetry(() => fs.rmdir(fullPath)); } continue; } if (!entry.isFile()) continue; - const stats = await fs.stat(fullPath); + const stats = await withRetentionIoRetry(() => fs.stat(fullPath)); if (now - stats.mtimeMs <= maxAgeMs) continue; - await fs.unlink(fullPath); + await withRetentionIoRetry(() => fs.unlink(fullPath)); removed += 1; } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code === "ENOENT") { continue; } + throw error; } } return removed; @@ -79,11 +103,11 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise { try { - const stats = await fs.stat(path); + const stats = await withRetentionIoRetry(() => fs.stat(path)); if (Date.now() - stats.mtimeMs <= maxAgeMs) { return false; } - await fs.unlink(path); + await withRetentionIoRetry(() => fs.unlink(path)); return true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; diff --git a/lib/file-lock.ts b/lib/file-lock.ts index dde17af..1af7cec 100644 --- a/lib/file-lock.ts +++ b/lib/file-lock.ts @@ -66,11 +66,27 @@ export async function acquireFileLock( for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { const handle = await fs.open(path, "wx", 0o600); - await handle.writeFile( - `${JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })}\n`, - "utf8", - ); - await handle.close(); + let writeError: unknown; + let closeError: unknown; + try { + await handle.writeFile( + `${JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })}\n`, + "utf8", + ); + } catch (error) { + writeError = error; + } + try { + await handle.close(); + } catch (error) { + closeError = error; + } + if (writeError !== undefined) { + throw writeError; + } + if (closeError !== undefined) { + throw closeError; + } let released = false; return { path, @@ -135,12 +151,28 @@ export function acquireFileLockSync( for (let attempt = 0; attempt < maxAttempts; attempt += 1) { try { const fd = openSync(path, "wx", 0o600); - writeFileSync( - fd, - `${JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })}\n`, - "utf8", - ); - closeSync(fd); + let writeError: unknown; + let closeError: unknown; + try { + writeFileSync( + fd, + `${JSON.stringify({ pid: process.pid, acquiredAt: Date.now() })}\n`, + "utf8", + ); + } catch (error) { + writeError = error; + } + try { + closeSync(fd); + } catch (error) { + closeError = error; + } + if (writeError !== undefined) { + throw writeError; + } + if (closeError !== undefined) { + throw closeError; + } let released = false; return { path, diff --git a/lib/storage.ts b/lib/storage.ts index 30fafba..bb5c3d1 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -48,6 +48,7 @@ const ACCOUNT_STORAGE_LOCK_OPTIONS = { maxDelayMs: 800, staleAfterMs: 120_000, } as const; +const STORAGE_DECRYPT_ERROR_CODE = "EDECRYPT"; let storageBackupEnabled = true; let lastAccountsSaveTimestamp = 0; @@ -192,6 +193,16 @@ function decryptAccountSensitiveFields(account: AccountMetadataV3): AccountMetad }; } +function toStorageDecryptError(cause: unknown): NodeJS.ErrnoException { + const message = "Failed to decrypt account storage"; + const error = new Error( + message, + cause instanceof Error ? { cause } : undefined, + ) as NodeJS.ErrnoException; + error.code = STORAGE_DECRYPT_ERROR_CODE; + return error; +} + function encryptAccountSensitiveFields(account: AccountMetadataV3): AccountMetadataV3 { return { ...account, @@ -855,6 +866,14 @@ function clampIndex(index: number, length: number): number { return Math.max(0, Math.min(index, length - 1)); } +function isStoredAccountCandidate(account: unknown): account is AccountMetadataV3 { + return ( + isRecord(account) && + typeof account.refreshToken === "string" && + account.refreshToken.trim().length > 0 + ); +} + function toAccountKey(account: Pick): string { return account.accountId || account.refreshToken; } @@ -914,12 +933,17 @@ export function normalizeAccountStorage(data: unknown): AccountStorageV3 | null ? migrateV1ToV3(data as unknown as AccountStorageV1) : (data as unknown as AccountStorageV3); - const validAccounts = rawAccounts - .filter( - (account): account is AccountMetadataV3 => - isRecord(account) && typeof account.refreshToken === "string" && !!account.refreshToken.trim(), - ) - .map((account) => decryptAccountSensitiveFields(account)); + const validAccounts: AccountMetadataV3[] = []; + for (const account of rawAccounts) { + if (!isStoredAccountCandidate(account)) { + continue; + } + try { + validAccounts.push(decryptAccountSensitiveFields(account)); + } catch (error) { + throw toStorageDecryptError(error); + } + } const deduplicatedAccounts = deduplicateAccountsByEmail( deduplicateAccountsByKey(validAccounts), @@ -1099,6 +1123,9 @@ async function loadAccountsInternal( return normalized; } catch (error) { const code = (error as NodeJS.ErrnoException).code; + if (code === STORAGE_DECRYPT_ERROR_CODE) { + throw error; + } if (code === "ENOENT" && migratedLegacyStorage) { return migratedLegacyStorage; } diff --git a/lib/unified-settings.ts b/lib/unified-settings.ts index 9341fe8..2b1ea93 100644 --- a/lib/unified-settings.ts +++ b/lib/unified-settings.ts @@ -19,6 +19,12 @@ export const UNIFIED_SETTINGS_VERSION = 1 as const; const UNIFIED_SETTINGS_PATH = join(getCodexMultiAuthDir(), "settings.json"); const UNIFIED_SETTINGS_LOCK_PATH = `${UNIFIED_SETTINGS_PATH}.lock`; const RETRYABLE_FS_CODES = new Set(["EBUSY", "EPERM"]); +const SETTINGS_LOCK_OPTIONS = { + maxAttempts: 80, + baseDelayMs: 15, + maxDelayMs: 800, + staleAfterMs: 120_000, +} as const; let settingsWriteQueue: Promise = Promise.resolve(); function isRetryableFsError(error: unknown): boolean { @@ -127,39 +133,29 @@ function writeSettingsRecordSync(record: JsonRecord): void { const payload = normalizeForWrite(record); const data = `${JSON.stringify(payload, null, 2)}\n`; const tempPath = `${UNIFIED_SETTINGS_PATH}.${process.pid}.${Date.now()}.tmp`; - const lock = acquireFileLockSync(UNIFIED_SETTINGS_LOCK_PATH, { - maxAttempts: 80, - baseDelayMs: 15, - maxDelayMs: 800, - staleAfterMs: 120_000, - }); + writeFileSync(tempPath, data, "utf8"); + let moved = false; try { - writeFileSync(tempPath, data, "utf8"); - let moved = false; - try { - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - renameSync(tempPath, UNIFIED_SETTINGS_PATH); - moved = true; - return; - } catch (error) { - if (!isRetryableFsError(error) || attempt >= 4) { - throw error; - } - Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * 2 ** attempt); - } - } - } finally { - if (!moved) { - try { - unlinkSync(tempPath); - } catch { - // Best-effort temp cleanup. + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + renameSync(tempPath, UNIFIED_SETTINGS_PATH); + moved = true; + return; + } catch (error) { + if (!isRetryableFsError(error) || attempt >= 4) { + throw error; } + Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * 2 ** attempt); } } } finally { - lock.release(); + if (!moved) { + try { + unlinkSync(tempPath); + } catch { + // Best-effort temp cleanup. + } + } } } @@ -188,39 +184,29 @@ async function writeSettingsRecordAsync(record: JsonRecord): Promise { const payload = normalizeForWrite(record); const data = `${JSON.stringify(payload, null, 2)}\n`; const tempPath = `${UNIFIED_SETTINGS_PATH}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2, 8)}.tmp`; - const lock = await acquireFileLock(UNIFIED_SETTINGS_LOCK_PATH, { - maxAttempts: 80, - baseDelayMs: 15, - maxDelayMs: 800, - staleAfterMs: 120_000, - }); + await fs.writeFile(tempPath, data, "utf8"); + let moved = false; try { - await fs.writeFile(tempPath, data, "utf8"); - let moved = false; - try { - for (let attempt = 0; attempt < 5; attempt += 1) { - try { - await fs.rename(tempPath, UNIFIED_SETTINGS_PATH); - moved = true; - return; - } catch (error) { - if (!isRetryableFsError(error) || attempt >= 4) { - throw error; - } - await sleep(10 * 2 ** attempt); - } - } - } finally { - if (!moved) { - try { - await fs.unlink(tempPath); - } catch { - // Best-effort temp cleanup. + for (let attempt = 0; attempt < 5; attempt += 1) { + try { + await fs.rename(tempPath, UNIFIED_SETTINGS_PATH); + moved = true; + return; + } catch (error) { + if (!isRetryableFsError(error) || attempt >= 4) { + throw error; } + await sleep(10 * 2 ** attempt); } } } finally { - await lock.release(); + if (!moved) { + try { + await fs.unlink(tempPath); + } catch { + // Best-effort temp cleanup. + } + } } } @@ -276,9 +262,14 @@ export function loadUnifiedPluginConfigSync(): JsonRecord | null { * @param pluginConfig - Key/value map representing plugin configuration to persist */ export function saveUnifiedPluginConfigSync(pluginConfig: JsonRecord): void { - const record = readSettingsRecordSync() ?? {}; - record.pluginConfig = { ...pluginConfig }; - writeSettingsRecordSync(record); + const lock = acquireFileLockSync(UNIFIED_SETTINGS_LOCK_PATH, SETTINGS_LOCK_OPTIONS); + try { + const record = readSettingsRecordSync() ?? {}; + record.pluginConfig = { ...pluginConfig }; + writeSettingsRecordSync(record); + } finally { + lock.release(); + } } /** @@ -293,9 +284,14 @@ export function saveUnifiedPluginConfigSync(pluginConfig: JsonRecord): void { */ export async function saveUnifiedPluginConfig(pluginConfig: JsonRecord): Promise { await enqueueSettingsWrite(async () => { - const record = await readSettingsRecordAsync() ?? {}; - record.pluginConfig = { ...pluginConfig }; - await writeSettingsRecordAsync(record); + const lock = await acquireFileLock(UNIFIED_SETTINGS_LOCK_PATH, SETTINGS_LOCK_OPTIONS); + try { + const record = (await readSettingsRecordAsync()) ?? {}; + record.pluginConfig = { ...pluginConfig }; + await writeSettingsRecordAsync(record); + } finally { + await lock.release(); + } }); } @@ -336,8 +332,13 @@ export async function saveUnifiedDashboardSettings( dashboardDisplaySettings: JsonRecord, ): Promise { await enqueueSettingsWrite(async () => { - const record = await readSettingsRecordAsync() ?? {}; - record.dashboardDisplaySettings = { ...dashboardDisplaySettings }; - await writeSettingsRecordAsync(record); + const lock = await acquireFileLock(UNIFIED_SETTINGS_LOCK_PATH, SETTINGS_LOCK_OPTIONS); + try { + const record = (await readSettingsRecordAsync()) ?? {}; + record.dashboardDisplaySettings = { ...dashboardDisplaySettings }; + await writeSettingsRecordAsync(record); + } finally { + await lock.release(); + } }); } diff --git a/package-lock.json b/package-lock.json index 93ee2ca..cf02555 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@codex-ai/sdk": "file:vendor/codex-ai-sdk", + "@cyclonedx/cyclonedx-npm": "4.2.0", "@fast-check/vitest": "^0.2.4", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", @@ -112,6 +113,114 @@ "resolved": "vendor/codex-ai-sdk", "link": true }, + "node_modules/@cyclonedx/cyclonedx-npm": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-npm/-/cyclonedx-npm-4.2.0.tgz", + "integrity": "sha512-TDGb85XzPMagN8WxIGgn24sCMdgQ51UdhwvaGmAIje/3P78+vyf2s9pAU0KMSDBVz3JQ66OpHaIDpR+dpRS1Ww==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://owasp.org/donate/?reponame=www-project-cyclonedx&title=OWASP+CycloneDX" + } + ], + "license": "Apache-2.0", + "dependencies": { + "@cyclonedx/cyclonedx-library": "^10.0.0", + "commander": "^14.0.0", + "normalize-package-data": "^7.0.0 || ^8.0.0", + "packageurl-js": "^2.0.1", + "spdx-expression-parse": "^3.0.1 || ^4.0.0", + "xmlbuilder2": "^3.0.2 || ^4.0.3" + }, + "bin": { + "cyclonedx-npm": "bin/cyclonedx-npm-cli.js" + }, + "engines": { + "node": ">=20.18.0", + "npm": ">=9" + }, + "optionalDependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "ajv-formats-draft2019": "^1.6.1", + "libxmljs2": "^0.35||^0.37" + } + }, + "node_modules/@cyclonedx/cyclonedx-npm/node_modules/@cyclonedx/cyclonedx-library": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@cyclonedx/cyclonedx-library/-/cyclonedx-library-10.0.0.tgz", + "integrity": "sha512-xDXf2eqzeFHdjamj6oBV3duRSfrlmsJ5+2z9tXp7q5qxJP5Awmjf4ABSutS4qkVHHj7JzKFL/EM0V0Nihc7zPg==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://owasp.org/donate/?reponame=www-project-cyclonedx&title=OWASP+CycloneDX" + } + ], + "license": "Apache-2.0", + "engines": { + "node": ">=20.18.0" + }, + "peerDependencies": { + "ajv": "^8.12.0", + "ajv-formats": "^3.0.1", + "ajv-formats-draft2019": "^1.6.1", + "libxmljs2": "^0.35||^0.37", + "packageurl-js": "*", + "spdx-expression-parse": "*", + "xmlbuilder2": "^3.0.2||^4.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + }, + "ajv-formats": { + "optional": true + }, + "ajv-formats-draft2019": { + "optional": true + }, + "libxmljs2": { + "optional": true + }, + "packageurl-js": { + "optional": true + }, + "spdx-expression-parse": { + "optional": true + }, + "xmlbuilder2": { + "optional": true + } + } + }, + "node_modules/@cyclonedx/cyclonedx-npm/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/@cyclonedx/cyclonedx-npm/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz", @@ -723,6 +832,99 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -751,6 +953,90 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@npmcli/agent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "http-proxy-agent": "^7.0.0", + "https-proxy-agent": "^7.0.1", + "lru-cache": "^10.0.1", + "socks-proxy-agent": "^8.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@npmcli/fs": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/@oozcitak/dom": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/infra": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/url": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/@oozcitak/util": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0" + } + }, "node_modules/@openauthjs/openauth": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@openauthjs/openauth/-/openauth-0.4.3.tgz", @@ -817,6 +1103,17 @@ "license": "MIT", "peer": true }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -1666,6 +1963,17 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/abbrev": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.1.tgz", + "integrity": "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -1689,6 +1997,17 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 14" + } + }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -1706,6 +2025,68 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats-draft2019": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/ajv-formats-draft2019/-/ajv-formats-draft2019-1.6.1.tgz", + "integrity": "sha512-JQPvavpkWDvIsBp2Z33UkYCtXCSpW4HD3tAZ+oL4iEFOk9obQZffx0yANwECt6vzr6ET+7HN5czRyqXbnq/u0Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "punycode": "^2.1.1", + "schemes": "^1.4.0", + "smtp-address-parser": "^1.0.3", + "uri-js": "^4.4.1" + }, + "peerDependencies": { + "ajv": "*" + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/ansi-escapes": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.2.0.tgz", @@ -1735,6 +2116,23 @@ "url": "https://github.com/chalk/ansi-regex?sponsor=1" } }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/arctic": { "version": "2.3.4", "resolved": "https://registry.npmjs.org/arctic/-/arctic-2.3.4.tgz", @@ -1747,6 +2145,13 @@ "@oslojs/jwt": "0.2.0" } }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1785,15 +2190,61 @@ "node": "20 || >=22" } }, - "node_modules/brace-expansion": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", - "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], "license": "MIT", - "dependencies": { - "balanced-match": "^4.0.2" - }, + "optional": true + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.2.tgz", + "integrity": "sha512-Pdk8c9poy+YhOgVWw1JNN22/HcivgKWwpxKq04M/jTmHyCZn12WPJebZxdjSa5TmBqISrUSgNYU3eRORljfCCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, "engines": { "node": "20 || >=22" } @@ -1811,6 +2262,57 @@ "node": ">=8" } }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/cacache": { + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/fs": "^4.0.0", + "fs-minipass": "^3.0.0", + "glob": "^10.2.2", + "lru-cache": "^10.0.1", + "minipass": "^7.0.3", + "minipass-collect": "^2.0.1", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/chai": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", @@ -1821,6 +2323,17 @@ "node": ">=18" } }, + "node_modules/chownr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/cli-cursor": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz", @@ -1854,6 +2367,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/colorette": { "version": "2.0.20", "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", @@ -1904,6 +2439,34 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1911,6 +2474,33 @@ "dev": true, "license": "MIT" }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/emoji-regex": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", @@ -1918,6 +2508,39 @@ "dev": true, "license": "MIT" }, + "node_modules/encoding": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", + "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "iconv-lite": "^0.6.2" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/environment": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/environment/-/environment-1.1.0.tgz", @@ -1931,6 +2554,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/err-code": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -2198,6 +2829,17 @@ "dev": true, "license": "MIT" }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "dev": true, + "license": "(MIT OR WTFPL)", + "optional": true, + "engines": { + "node": ">=6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2208,6 +2850,22 @@ "node": ">=12.0.0" } }, + "node_modules/exponential-backoff": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, + "license": "Apache-2.0", + "optional": true + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fast-check": { "version": "4.5.3", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.5.3.tgz", @@ -2252,6 +2910,24 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -2272,6 +2948,14 @@ "node": ">=16.0.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -2323,6 +3007,46 @@ "dev": true, "license": "ISC" }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/fs-minipass": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2351,6 +3075,37 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2364,6 +3119,14 @@ "node": ">=10.13.0" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2383,6 +3146,29 @@ "node": ">=16.9.0" } }, + "node_modules/hosted-git-info": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.2.tgz", + "integrity": "sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==", + "dev": true, + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/hosted-git-info/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2390,6 +3176,44 @@ "dev": true, "license": "MIT" }, + "node_modules/http-cache-semantics": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", + "dev": true, + "license": "BSD-2-Clause", + "optional": true + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -2406,6 +3230,42 @@ "url": "https://github.com/sponsors/typicode" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/ignore": { "version": "7.0.5", "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", @@ -2426,8 +3286,35 @@ "node": ">=0.8.19" } }, - "node_modules/is-extglob": { - "version": "2.1.1", + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/ip-address": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 12" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", "dev": true, @@ -2521,6 +3408,23 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jose": { "version": "5.9.6", "resolved": "https://registry.npmjs.org/jose/-/jose-5.9.6.tgz", @@ -2537,6 +3441,19 @@ "dev": true, "license": "MIT" }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2582,6 +3499,24 @@ "node": ">= 0.8.0" } }, + "node_modules/libxmljs2": { + "version": "0.37.0", + "resolved": "https://registry.npmjs.org/libxmljs2/-/libxmljs2-0.37.0.tgz", + "integrity": "sha512-Xb78V8GZouoZFrq8cCwx7+G3WYOcJG0xb3YUbweSyE4z2EIrQCZMr3Ye/dHn4mESs6YxUMeQeUZm5IXg+iLHog==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bindings": "~1.5.0", + "nan": "~2.22.2", + "node-gyp": "^11.2.0", + "prebuild-install": "^7.1.3" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/lint-staged": { "version": "16.2.7", "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.2.7.tgz", @@ -2661,6 +3596,14 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC", + "optional": true + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2699,6 +3642,30 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/make-fetch-happen": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", + "http-cache-semantics": "^4.1.1", + "minipass": "^7.0.2", + "minipass-fetch": "^4.0.0", + "minipass-flush": "^1.0.5", + "minipass-pipeline": "^1.2.4", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", + "promise-retry": "^2.0.1", + "ssri": "^12.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -2726,6 +3693,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/minimatch": { "version": "10.2.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", @@ -2742,6 +3723,199 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "optional": true, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz", + "integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-collect": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/minipass-fetch": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.0.3", + "minipass-sized": "^1.0.3", + "minizlib": "^3.0.1" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + }, + "optionalDependencies": { + "encoding": "^0.1.13" + } + }, + "node_modules/minipass-flush": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minipass-flush/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-flush/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minipass-pipeline": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-pipeline/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minipass-sized": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", + "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minipass-sized/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/minizlib": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "minipass": "^7.1.2" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/moo": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.3.tgz", + "integrity": "sha512-m2fmM2dDm7GZQsY7KK2cme8agi+AAljILjQnof7p1ZMDe6dQ4bdnSMx0cPppudoeNv5hEFQirN6u+O4fDE0IWA==", + "dev": true, + "license": "BSD-3-Clause", + "optional": true + }, "node_modules/mrmime": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz", @@ -2759,6 +3933,14 @@ "dev": true, "license": "MIT" }, + "node_modules/nan": { + "version": "2.22.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.2.tgz", + "integrity": "sha512-DANghxFkS1plDdRsX0X9pm0Z6SJNN6gBdtXfanwoZ8hooC5gosGFSBGRYHUVPz1asKA/kMRqDRdHrluZ61SpBQ==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -2791,6 +3973,14 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "dev": true, + "license": "MIT", + "optional": true + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -2798,6 +3988,149 @@ "dev": true, "license": "MIT" }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, + "node_modules/nearley/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.87.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz", + "integrity": "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/node-gyp": { + "version": "11.5.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.5.0.tgz", + "integrity": "sha512-ra7Kvlhxn5V9Slyus0ygMa2h+UqExPqUIkfk7Pc8QTLT956JLSy51uWFwHtIYy0vI8cB4BDhc/S03+880My/LQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "env-paths": "^2.2.0", + "exponential-backoff": "^3.1.1", + "graceful-fs": "^4.2.6", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", + "semver": "^7.3.5", + "tar": "^7.4.3", + "tinyglobby": "^0.2.12", + "which": "^5.0.0" + }, + "bin": { + "node-gyp": "bin/node-gyp.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/node-gyp/node_modules/isexe": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.5.tgz", + "integrity": "sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/node-gyp/node_modules/which": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "isexe": "^3.1.1" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/nopt": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "abbrev": "^3.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/normalize-package-data": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-8.0.0.tgz", + "integrity": "sha512-RWk+PI433eESQ7ounYxIp67CYuVsS1uYSonX3kA6ps/3LWfjVQa/ptEg6Y3T6uAMq1mWpX9PQ+qx+QaHpsc7gQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "hosted-git-info": "^9.0.0", + "semver": "^7.3.5", + "validate-npm-package-license": "^3.0.4" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2809,6 +4142,17 @@ ], "license": "MIT" }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "wrappy": "1" + } + }, "node_modules/onetime": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz", @@ -2875,6 +4219,35 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/p-map": { + "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true + }, + "node_modules/packageurl-js": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/packageurl-js/-/packageurl-js-2.0.1.tgz", + "integrity": "sha512-N5ixXjzTy4QDQH0Q9YFjqIWd6zH6936Djpl2m9QNFmDv5Fum8q8BjkpAcHNMzOFE0IwQrFhJWex3AN6kS0OSwg==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2895,6 +4268,24 @@ "node": ">=8" } }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2964,6 +4355,35 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -2974,6 +4394,44 @@ "node": ">= 0.8.0" } }, + "node_modules/proc-log": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "dev": true, + "license": "ISC", + "optional": true, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/promise-retry": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "err-code": "^2.0.2", + "retry": "^0.12.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3001,6 +4459,73 @@ ], "license": "MIT" }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true, + "license": "CC0-1.0", + "optional": true + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "dev": true, + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "optional": true, + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -3018,6 +4543,28 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 4" + } + }, "node_modules/rfdc": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz", @@ -3070,6 +4617,47 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/schemes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/schemes/-/schemes-1.4.0.tgz", + "integrity": "sha512-ImFy9FbCsQlVgnE3TCWmLPCFnVzx0lHL/l+umHplDqAKd0dzFpnS6lFZIpagBlYhKwzVmlV36ec0Y1XTu8JBAQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "extend": "^3.0.0" + } + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3126,6 +4714,55 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "optional": true, + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/sirv": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3152,23 +4789,81 @@ "is-fullwidth-code-point": "^5.0.0" }, "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/chalk/slice-ansi?sponsor=1" + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/slice-ansi?sponsor=1" + } + }, + "node_modules/slice-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/smtp-address-parser": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/smtp-address-parser/-/smtp-address-parser-1.1.0.tgz", + "integrity": "sha512-Gz11jbNU0plrReU9Sj7fmshSBxxJ9ShdD2q4ktHIHo/rpTH6lFyQoYHYKINPJtPe8aHFnsbtW46Ls0tCCBsIZg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "nearley": "^2.20.1" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/socks": { + "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ip-address": "^10.0.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" } }, - "node_modules/slice-ansi/node_modules/ansi-styles": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", - "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "dev": true, "license": "MIT", - "engines": { - "node": ">=12" + "optional": true, + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" + "engines": { + "node": ">= 14" } }, "node_modules/source-map-js": { @@ -3181,6 +4876,67 @@ "node": ">=0.10.0" } }, + "node_modules/spdx-correct": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.2.0.tgz", + "integrity": "sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-correct/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-exceptions": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.5.0.tgz", + "integrity": "sha512-PiU42r+xO4UbUS1buo3LPJkjlO7430Xn5SVAhdpzzsPHsjbYVflnnFdATgabnLude+Cqu25p6N+g2lw/PFsa4w==", + "dev": true, + "license": "CC-BY-3.0" + }, + "node_modules/spdx-expression-parse": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-4.0.0.tgz", + "integrity": "sha512-Clya5JIij/7C6bRR22+tnGXbc4VKlibKSVj2iHvVeX5iMW7s1SIQlqu699JkODJJIhh/pUu8L0/VLh8xflD+LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "node_modules/spdx-license-ids": { + "version": "3.0.23", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.23.tgz", + "integrity": "sha512-CWLcCCH7VLu13TgOH+r8p1O/Znwhqv/dbb6lqWy67G+pT1kHmeD/+V36AVb/vq8QMIQwVShJ6Ssl5FPh0fuSdw==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/ssri": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "minipass": "^7.0.3" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3195,6 +4951,17 @@ "dev": true, "license": "MIT" }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-argv": { "version": "0.3.2", "resolved": "https://registry.npmjs.org/string-argv/-/string-argv-0.3.2.tgz", @@ -3222,6 +4989,67 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/string-width-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-ansi": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", @@ -3238,6 +5066,43 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -3251,6 +5116,64 @@ "node": ">=8" } }, + "node_modules/tar": { + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.10.tgz", + "integrity": "sha512-8mOPs1//5q/rlkNSPcCegA6hiHJYDmSLEI8aMH/CdSQJNWztHC9WHNam5zdQlfpTwB9Xp7IBEsHfV5LKMJGVAw==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "dependencies": { + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.1.0", + "yallist": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-fs/node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3362,6 +5285,20 @@ "typescript": ">=4.8.4" } }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -3409,6 +5346,34 @@ "dev": true, "license": "MIT" }, + "node_modules/unique-filename": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "unique-slug": "^5.0.0" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/unique-slug": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "dev": true, + "license": "ISC", + "optional": true, + "dependencies": { + "imurmurhash": "^0.1.4" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -3419,6 +5384,36 @@ "punycode": "^2.1.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "node_modules/validate-npm-package-license/node_modules/spdx-expression-parse": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.1.tgz", + "integrity": "sha512-cbqHunsQWnJNE6KhVSMsMeH5H/L9EpymbzqTQ3uLwNCLZ1Q481oWaofqH7nO6V07xlXwY6PhQdQ2IedWx/ZK4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", @@ -3677,6 +5672,86 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT", + "optional": true + }, + "node_modules/wrap-ansi-cjs/node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "6.2.3", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", @@ -3708,6 +5783,41 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC", + "optional": true + }, + "node_modules/xmlbuilder2": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" + }, + "engines": { + "node": ">=20.0" + } + }, + "node_modules/yallist": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "dev": true, + "license": "BlueOak-1.0.0", + "optional": true, + "engines": { + "node": ">=18" + } + }, "node_modules/yaml": { "version": "2.8.2", "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", diff --git a/package.json b/package.json index 857207f..dc74c3e 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "audit:dev:allowlist": "node scripts/audit-dev-allowlist.js", "audit:ci": "npm run audit:prod && npm run audit:dev:allowlist", "license:check": "node scripts/license-policy-check.js", - "sbom": "npx --yes @cyclonedx/cyclonedx-npm --output-file sbom.cdx.json --omit dev", + "sbom": "cyclonedx-npm --output-file sbom.cdx.json --omit dev", "prepublishOnly": "npm run build", "prepare": "husky" }, @@ -104,6 +104,7 @@ "devDependencies": { "@fast-check/vitest": "^0.2.4", "@codex-ai/sdk": "file:vendor/codex-ai-sdk", + "@cyclonedx/cyclonedx-npm": "4.2.0", "@types/node": "^25.3.0", "@typescript-eslint/eslint-plugin": "^8.56.0", "@typescript-eslint/parser": "^8.56.0", diff --git a/scripts/license-policy-check.js b/scripts/license-policy-check.js index b00a0fe..973a908 100644 --- a/scripts/license-policy-check.js +++ b/scripts/license-policy-check.js @@ -16,25 +16,58 @@ const packages = packageLock.packages ?? {}; const violations = []; const unknown = []; +function normalizeLicenseValue(value) { + if (typeof value === "string") { + return value; + } + if (value && typeof value === "object" && typeof value.type === "string") { + return value.type; + } + return ""; +} + +function extractRawLicense(record) { + const direct = normalizeLicenseValue(record.license); + if (direct) return direct; + + if (typeof record.licenses === "string") { + return record.licenses; + } + if (Array.isArray(record.licenses)) { + const values = record.licenses + .map((entry) => normalizeLicenseValue(entry)) + .filter((entry) => entry.length > 0); + if (values.length > 0) { + return values.join(" OR "); + } + } + + return ""; +} + +function extractLicenseTokens(rawLicense) { + return rawLicense + .toUpperCase() + .split(/[^A-Z0-9.-]+/) + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + for (const [packagePath, metadata] of Object.entries(packages)) { if (!metadata || typeof metadata !== "object") continue; if (packagePath === "") continue; const record = metadata; const name = typeof record.name === "string" ? record.name : packagePath; const version = typeof record.version === "string" ? record.version : "0.0.0"; - const rawLicense = - typeof record.license === "string" - ? record.license - : typeof record.licenses === "string" - ? record.licenses - : ""; + const rawLicense = extractRawLicense(record); const normalized = rawLicense.trim().toUpperCase(); if (!normalized) { unknown.push(`${name}@${version}`); continue; } + const tokens = new Set(extractLicenseTokens(normalized)); for (const denied of denyList) { - if (normalized.includes(denied)) { + if (tokens.has(denied)) { violations.push(`${name}@${version} (${rawLicense})`); break; } diff --git a/test/authorization.test.ts b/test/authorization.test.ts index b48aaaf..83f2e74 100644 --- a/test/authorization.test.ts +++ b/test/authorization.test.ts @@ -56,40 +56,36 @@ async function removeWithRetry( describe("authorization", () => { it("defaults to admin role", () => { - const previousRole = process.env.CODEX_AUTH_ROLE; + const previous = captureAuthEnv(); try { - delete process.env.CODEX_AUTH_ROLE; + for (const key of AUTH_ENV_KEYS) { + delete process.env[key]; + } expect(getAuthorizationRole()).toBe("admin"); expect(authorizeAction("secrets:rotate").allowed).toBe(true); } finally { - if (previousRole === undefined) { - delete process.env.CODEX_AUTH_ROLE; - } else { - process.env.CODEX_AUTH_ROLE = previousRole; - } + restoreAuthEnv(previous); } }); it("denies write actions for viewer role", () => { - const previousRole = process.env.CODEX_AUTH_ROLE; + const previous = captureAuthEnv(); try { + for (const key of AUTH_ENV_KEYS) { + delete process.env[key]; + } process.env.CODEX_AUTH_ROLE = "viewer"; const auth = authorizeAction("accounts:write"); expect(auth.allowed).toBe(false); expect(auth.role).toBe("viewer"); expect(auth.reason).toContain("accounts:write"); } finally { - if (previousRole === undefined) { - delete process.env.CODEX_AUTH_ROLE; - } else { - process.env.CODEX_AUTH_ROLE = previousRole; - } + restoreAuthEnv(previous); } }); it("allows all actions when break-glass is enabled and audits the bypass", async () => { - const previousRole = process.env.CODEX_AUTH_ROLE; - const previousBreakGlass = process.env.CODEX_AUTH_BREAK_GLASS; + const previous = captureAuthEnv(); const previousAuditConfig = getAuditConfig(); const auditDir = await fs.mkdtemp(join(tmpdir(), "codex-auth-audit-")); try { @@ -99,6 +95,9 @@ describe("authorization", () => { maxFileSizeBytes: 1024 * 1024, maxFiles: 2, }); + for (const key of AUTH_ENV_KEYS) { + delete process.env[key]; + } process.env.CODEX_AUTH_ROLE = "viewer"; process.env.CODEX_AUTH_BREAK_GLASS = "1"; const result = authorizeAction("secrets:rotate"); @@ -125,16 +124,7 @@ describe("authorization", () => { } finally { configureAudit(previousAuditConfig); await removeWithRetry(auditDir, { recursive: true, force: true }); - if (previousRole === undefined) { - delete process.env.CODEX_AUTH_ROLE; - } else { - process.env.CODEX_AUTH_ROLE = previousRole; - } - if (previousBreakGlass === undefined) { - delete process.env.CODEX_AUTH_BREAK_GLASS; - } else { - process.env.CODEX_AUTH_BREAK_GLASS = previousBreakGlass; - } + restoreAuthEnv(previous); } }); diff --git a/test/background-jobs.test.ts b/test/background-jobs.test.ts index 6062c7b..d45bf5d 100644 --- a/test/background-jobs.test.ts +++ b/test/background-jobs.test.ts @@ -3,6 +3,27 @@ import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + 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_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + describe("background jobs", () => { let tempDir: string; let originalDir: string | undefined; @@ -20,7 +41,7 @@ describe("background jobs", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("retries and succeeds before exhausting attempts", async () => { @@ -86,4 +107,74 @@ describe("background jobs", () => { note: "keep-visible", }); }); -}); \ No newline at end of file + + it("writes dead-letter after exhausting retries on 429 errors", async () => { + const { runBackgroundJobWithRetry, getBackgroundJobDlqPath } = + await import("../lib/background-jobs.js"); + let attempts = 0; + await expect( + runBackgroundJobWithRetry({ + name: "test.retry-429-fail", + task: async () => { + attempts += 1; + const error = Object.assign(new Error("rate limited"), { statusCode: 429 }); + throw error; + }, + maxAttempts: 3, + baseDelayMs: 1, + maxDelayMs: 2, + retryable: (error) => + typeof (error as { statusCode?: unknown }).statusCode === "number" && + (error as { statusCode: number }).statusCode === 429, + }), + ).rejects.toThrow("rate limited"); + expect(attempts).toBe(3); + + const content = await fs.readFile(getBackgroundJobDlqPath(), "utf8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0] ?? "{}") as { job?: string; attempts?: number }; + expect(entry.job).toBe("test.retry-429-fail"); + expect(entry.attempts).toBe(3); + }); + + it("redacts sensitive error text in dead-letter entries and warning logs", async () => { + vi.resetModules(); + const warnMock = vi.fn(); + vi.doMock("../lib/logger.js", () => ({ + logWarn: warnMock, + })); + try { + const { runBackgroundJobWithRetry, getBackgroundJobDlqPath } = + await import("../lib/background-jobs.js"); + await expect( + runBackgroundJobWithRetry({ + name: "test.retry-sensitive-error", + task: async () => { + throw new Error( + "network failed for person@example.com Bearer sk_test_123 refresh_token=rt_456", + ); + }, + maxAttempts: 1, + }), + ).rejects.toThrow("person@example.com"); + + const content = await fs.readFile(getBackgroundJobDlqPath(), "utf8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0] ?? "{}") as { error?: string }; + expect(entry.error).toContain("***REDACTED***"); + expect(entry.error).not.toContain("person@example.com"); + expect(entry.error).not.toContain("sk_test_123"); + expect(entry.error).not.toContain("rt_456"); + + const warningPayloads = warnMock.mock.calls.map((args) => args[1]); + const serializedWarnings = JSON.stringify(warningPayloads); + expect(serializedWarnings).not.toContain("person@example.com"); + expect(serializedWarnings).not.toContain("sk_test_123"); + expect(serializedWarnings).not.toContain("rt_456"); + } finally { + vi.doUnmock("../lib/logger.js"); + } + }); +}); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 7cc0b09..0395214 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2050,9 +2050,18 @@ describe("codex manager cli commands", () => { ]); expect(firstExitCode).toBe(0); const firstPayload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + schemaVersion: number; accounts: Array<{ email: string }>; - pagination: { hasMore: boolean; nextCursor: string | null }; + pagination: { + cursor: string | null; + nextCursor: string | null; + hasMore: boolean; + pageSize: number; + }; }; + expect(typeof firstPayload.schemaVersion).toBe("number"); + expect(firstPayload.pagination.cursor).toBeNull(); + expect(firstPayload.pagination.pageSize).toBe(1); expect(firstPayload.accounts).toHaveLength(1); expect(firstPayload.accounts[0]?.email).toBe("one@example.com"); expect(firstPayload.pagination.hasMore).toBe(true); @@ -2070,9 +2079,18 @@ describe("codex manager cli commands", () => { ]); expect(secondExitCode).toBe(0); const secondPayload = JSON.parse(String(logSpy.mock.calls[0]?.[0])) as { + schemaVersion: number; accounts: Array<{ email: string }>; - pagination: { hasMore: boolean; nextCursor: string | null }; + pagination: { + cursor: string | null; + nextCursor: string | null; + hasMore: boolean; + pageSize: number; + }; }; + expect(typeof secondPayload.schemaVersion).toBe("number"); + expect(secondPayload.pagination.cursor).toBe(String(firstPayload.pagination.nextCursor)); + expect(secondPayload.pagination.pageSize).toBe(1); expect(secondPayload.accounts).toHaveLength(1); expect(secondPayload.accounts[0]?.email).toBe("two@example.com"); expect(secondPayload.pagination.hasMore).toBe(false); @@ -2082,6 +2100,52 @@ describe("codex manager cli commands", () => { } }); + it("rejects malformed pagination cursors in JSON list mode", async () => { + loadAccountsMock.mockResolvedValue({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + email: "one@example.com", + accountId: "acc_one", + refreshToken: "refresh-one", + addedAt: Date.now() - 2_000, + lastUsed: Date.now() - 2_000, + enabled: true, + }, + ], + }); + + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const malformedPayloadCursor = Buffer.from("12junk", "utf8").toString("base64"); + const invalidPayloadExitCode = await runCodexMultiAuthCli([ + "auth", + "list", + "--json", + "--cursor", + malformedPayloadCursor, + ]); + expect(invalidPayloadExitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Invalid --cursor value"); + + errorSpy.mockClear(); + const invalidBase64ExitCode = await runCodexMultiAuthCli([ + "auth", + "list", + "--json", + "--cursor", + "%%%invalid-base64%%%", + ]); + expect(invalidBase64ExitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Invalid --cursor value"); + } finally { + errorSpy.mockRestore(); + } + }); + it("applies idempotency key for rotate-secrets automation retries", async () => { checkAndRecordIdempotencyKeyMock .mockResolvedValueOnce({ replayed: false }) diff --git a/test/data-retention.test.ts b/test/data-retention.test.ts index b572f22..709b239 100644 --- a/test/data-retention.test.ts +++ b/test/data-retention.test.ts @@ -4,6 +4,27 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import type { RetentionPolicy } from "../lib/data-retention.js"; +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + 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_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + describe("data retention", () => { let tempDir: string; let originalDir: string | undefined; @@ -21,7 +42,7 @@ describe("data retention", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("prunes stale log/cache/state files", async () => { @@ -76,4 +97,61 @@ describe("data retention", () => { expect(await fs.readFile(freshLog, "utf8")).toBe("fresh"); expect(await fs.readFile(freshCache, "utf8")).toBe("fresh"); }); -}); \ No newline at end of file + + it("retries transient EBUSY during directory entry retention pruning", async () => { + const { enforceDataRetention } = await import("../lib/data-retention.js"); + const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); + const logsDir = join(tempDir, "logs"); + const nestedDir = join(logsDir, "nested"); + const staleLog = join(nestedDir, "stale.log"); + + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(staleLog, "old", "utf8"); + await fs.utimes(staleLog, oldDate, oldDate); + + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat"); + let statBusyInjected = false; + statSpy.mockImplementation(async (path, options) => { + if (!statBusyInjected && path === staleLog) { + statBusyInjected = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(path, options as { bigint?: boolean }); + }); + + const originalRmdir = fs.rmdir.bind(fs); + const rmdirSpy = vi.spyOn(fs, "rmdir"); + let rmdirBusyInjected = false; + rmdirSpy.mockImplementation(async (path) => { + if (!rmdirBusyInjected && path === nestedDir) { + rmdirBusyInjected = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalRmdir(path); + }); + + try { + const policy: RetentionPolicy = { + logDays: 1, + cacheDays: 90, + flaggedDays: 90, + quotaCacheDays: 90, + dlqDays: 90, + }; + const result = await enforceDataRetention(policy); + expect(result.removedLogs).toBe(1); + expect(statBusyInjected).toBe(true); + expect(rmdirBusyInjected).toBe(true); + await expect(fs.stat(staleLog)).rejects.toMatchObject({ code: "ENOENT" }); + await expect(fs.stat(nestedDir)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + statSpy.mockRestore(); + rmdirSpy.mockRestore(); + } + }); +}); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index d8d5919..c0503a9 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -3,6 +3,7 @@ import { promises as fs } from "node:fs"; import { spawn } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { pathToFileURL } from "node:url"; import { acquireFileLock } from "../lib/file-lock.js"; const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); @@ -84,50 +85,40 @@ describe("file lock", () => { const lockPath = join(tempDir, "contention.lock"); const sharedFilePath = join(tempDir, "shared.txt"); const workerScriptPath = join(tempDir, "lock-writer-worker.mjs"); + const transpiledModulePath = join(tempDir, "file-lock-worker-module.mjs"); + const ts = await import("typescript"); + const sourcePath = join(process.cwd(), "lib", "file-lock.ts"); + const source = await fs.readFile(sourcePath, "utf8"); + const transpiled = ts.transpileModule(source, { + compilerOptions: { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ES2022, + }, + }).outputText; + await fs.writeFile(transpiledModulePath, transpiled, "utf8"); + const moduleUrl = pathToFileURL(transpiledModulePath).href; const workerCount = 6; const iterationsPerWorker = 10; await fs.writeFile(sharedFilePath, "", "utf8"); await fs.writeFile( workerScriptPath, `import { promises as fs } from "node:fs"; -const [lockPath, sharedFilePath, workerId, iterationsRaw] = process.argv.slice(2); +const [moduleUrl, lockPath, sharedFilePath, workerId, iterationsRaw] = process.argv.slice(2); const iterations = Number(iterationsRaw); -const RETRYABLE = new Set(["EEXIST", "EBUSY", "EPERM"]); -function sleep(ms) { - return new Promise((resolve) => setTimeout(resolve, ms)); -} -async function acquireLock(path) { - for (let attempt = 0; attempt < 1000; attempt += 1) { - try { - const handle = await fs.open(path, "wx", 0o600); - await handle.writeFile(String(process.pid), "utf8"); - await handle.close(); - return async () => { - try { - await fs.unlink(path); - } catch (error) { - if (error && error.code !== "ENOENT") { - throw error; - } - } - }; - } catch (error) { - if (!error || !RETRYABLE.has(error.code)) { - throw error; - } - await sleep(2); - } - } - throw new Error("Failed to acquire lock under contention"); -} +const { acquireFileLock } = await import(moduleUrl); for (let i = 0; i < iterations; i += 1) { - const release = await acquireLock(lockPath); + const lock = await acquireFileLock(lockPath, { + maxAttempts: 500, + baseDelayMs: 1, + maxDelayMs: 8, + staleAfterMs: 10_000, + }); try { const existing = await fs.readFile(sharedFilePath, "utf8"); - await sleep(2); + await new Promise((resolve) => setTimeout(resolve, 2)); await fs.writeFile(sharedFilePath, existing + workerId + ":" + i + "\\n", "utf8"); } finally { - await release(); + await lock.release(); } } `, @@ -140,6 +131,7 @@ for (let i = 0; i < iterations; i += 1) { process.execPath, [ workerScriptPath, + moduleUrl, lockPath, sharedFilePath, String(workerId), diff --git a/test/storage.test.ts b/test/storage.test.ts index 82e356b..398b076 100644 --- a/test/storage.test.ts +++ b/test/storage.test.ts @@ -677,6 +677,42 @@ describe("storage", () => { expect(saved.version).toBe(3); }); + it("fails fast when encrypted account storage cannot be decrypted", async () => { + const previousPrimary = process.env.CODEX_AUTH_ENCRYPTION_KEY; + const previousSecondary = process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY; + try { + process.env.CODEX_AUTH_ENCRYPTION_KEY = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + delete process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY; + await saveAccounts({ + version: 3, + activeIndex: 0, + activeIndexByFamily: {}, + accounts: [ + { + refreshToken: "refresh-token-sensitive", + accountId: "encrypted-account", + addedAt: Date.now(), + lastUsed: Date.now(), + }, + ], + }); + + process.env.CODEX_AUTH_ENCRYPTION_KEY = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + await expect(loadAccounts()).rejects.toThrow("Failed to decrypt account storage"); + } finally { + if (previousPrimary === undefined) { + delete process.env.CODEX_AUTH_ENCRYPTION_KEY; + } else { + process.env.CODEX_AUTH_ENCRYPTION_KEY = previousPrimary; + } + if (previousSecondary === undefined) { + delete process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY; + } else { + process.env.CODEX_AUTH_PREVIOUS_ENCRYPTION_KEY = previousSecondary; + } + } + }); + it("returns migrated data even when save fails (line 422-423 coverage)", async () => { const v1Storage = { version: 1, diff --git a/test/unified-settings.test.ts b/test/unified-settings.test.ts index 6eff59e..686f76b 100644 --- a/test/unified-settings.test.ts +++ b/test/unified-settings.test.ts @@ -3,6 +3,27 @@ import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + 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_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + describe("unified settings", () => { let tempDir: string; let originalDir: string | undefined; @@ -20,7 +41,7 @@ describe("unified settings", () => { } else { process.env.CODEX_MULTI_AUTH_DIR = originalDir; } - await fs.rm(tempDir, { recursive: true, force: true }); + await removeWithRetry(tempDir, { recursive: true, force: true }); }); it("merges plugin and dashboard sections into one file", async () => { @@ -225,6 +246,61 @@ describe("unified settings", () => { }); }); + it("acquires file lock before reading during plugin config updates", async () => { + vi.resetModules(); + const lockState = { held: false }; + const acquireFileLockMock = vi.fn(async () => { + lockState.held = true; + return { + path: "mock-settings.lock", + release: async () => { + lockState.held = false; + }, + }; + }); + const acquireFileLockSyncMock = vi.fn(() => ({ + path: "mock-settings.lock", + release: () => undefined, + })); + vi.doMock("../lib/file-lock.js", () => ({ + acquireFileLock: acquireFileLockMock, + acquireFileLockSync: acquireFileLockSyncMock, + })); + + try { + const { saveUnifiedPluginConfig, getUnifiedSettingsPath } = await import( + "../lib/unified-settings.js" + ); + await fs.writeFile( + getUnifiedSettingsPath(), + JSON.stringify({ + version: 1, + dashboardDisplaySettings: { menuShowLastUsed: false }, + }), + "utf8", + ); + + const originalReadFile = fs.readFile.bind(fs); + let observedReadUnderLock = false; + const readSpy = vi.spyOn(fs, "readFile").mockImplementation(async (...args) => { + if (lockState.held) { + observedReadUnderLock = true; + } + return originalReadFile(...args); + }); + try { + await saveUnifiedPluginConfig({ codexMode: true, retries: 2 }); + expect(observedReadUnderLock).toBe(true); + expect(acquireFileLockMock).toHaveBeenCalledTimes(1); + } finally { + readSpy.mockRestore(); + } + } finally { + vi.doUnmock("../lib/file-lock.js"); + vi.resetModules(); + } + }); + it("refuses overwriting settings sections when a read fails", async () => { const { saveUnifiedPluginConfig, From 82e50463baf88ea78d1939804003ca843d1e1ec9 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 19:03:21 +0800 Subject: [PATCH 6/8] fix(pr32): resolve remaining review findings Address unresolved PR #32 feedback across audit logging, CLI parsing, lock handling, retention races, storage rotation, and runbook/license guidance. Includes targeted regressions for background jobs, codex-manager CLI, data retention, and file lock behavior. Co-authored-by: Codex --- docs/runbooks/operations.md | 9 +++++ index.ts | 30 +++++++++----- lib/background-jobs.ts | 6 ++- lib/codex-manager.ts | 54 ++++++++++++++++++++++--- lib/data-retention.ts | 2 +- lib/file-lock.ts | 56 ++++++++++++++++++------- lib/storage.ts | 13 +++--- scripts/license-policy-check.js | 8 +++- test/background-jobs.test.ts | 26 ++++++++++++ test/codex-manager-cli.test.ts | 59 +++++++++++++++++++++++++++ test/data-retention.test.ts | 41 +++++++++++++++++++ test/file-lock.test.ts | 72 ++++++++++++++++++++++++++++++++- 12 files changed, 333 insertions(+), 43 deletions(-) diff --git a/docs/runbooks/operations.md b/docs/runbooks/operations.md index 37d49ff..fd681e3 100644 --- a/docs/runbooks/operations.md +++ b/docs/runbooks/operations.md @@ -14,8 +14,11 @@ Routine operations checklist for maintainers. 2. Check recent audit and plugin logs: - `~/.codex/multi-auth/logs/audit.log` - `~/.codex/multi-auth/logs/codex-plugin/` + - `%USERPROFILE%\.codex\multi-auth\logs\audit.log` (Windows) + - `%USERPROFILE%\.codex\multi-auth\logs\codex-plugin\` (Windows) 3. Check dead-letter queue growth: - `~/.codex/multi-auth/background-job-dlq.jsonl` + - `%USERPROFILE%\.codex\multi-auth\background-job-dlq.jsonl` (Windows) --- @@ -74,6 +77,12 @@ Routine operations checklist for maintainers. 2. If writes fail under filesystem lock contention: - check stale `*.lock` files under runtime root - inspect DLQ entries for repeated write failures + - quick log triage: + - macOS/Linux: `tail -n 200 ~/.codex/multi-auth/logs/audit.log` + - Windows PowerShell: `Get-Content $env:USERPROFILE\.codex\multi-auth\logs\audit.log -Tail 200` + - quick DLQ triage: + - macOS/Linux: `tail -n 50 ~/.codex/multi-auth/background-job-dlq.jsonl` + - Windows PowerShell: `Get-Content $env:USERPROFILE\.codex\multi-auth\background-job-dlq.jsonl -Tail 50` 3. If CI secret scan fails: - rotate exposed secret - invalidate related tokens diff --git a/index.ts b/index.ts index 341a900..0bc12cc 100644 --- a/index.ts +++ b/index.ts @@ -1684,13 +1684,21 @@ while (attempted.size < Math.max(1, accountCount)) { } else if (abortSignal && onUserAbort) { abortSignal.addEventListener("abort", onUserAbort, { once: true }); } + const safeAuditResource = (() => { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return url; + } + })(); try { runtimeMetrics.totalRequests++; auditLog( AuditAction.REQUEST_START, account.email ?? `account-${account.index + 1}`, - url, + safeAuditResource, AuditOutcome.SUCCESS, { model, @@ -1722,14 +1730,6 @@ while (attempted.size < Math.max(1, accountCount)) { ); } const errorMsg = networkError instanceof Error ? networkError.message : String(networkError); - const safeAuditResource = (() => { - try { - const parsed = new URL(url); - return `${parsed.origin}${parsed.pathname}`; - } catch { - return url; - } - })(); const networkErrorType = networkError instanceof Error ? networkError.name : "unknown_error"; logWarn(`Network error for account ${account.index + 1}: ${errorMsg}`); @@ -2409,7 +2409,7 @@ while (attempted.size < Math.max(1, accountCount)) { auditLog( AuditAction.REQUEST_SUCCESS, successAccountForResponse.email ?? `account-${successAccountForResponse.index + 1}`, - url, + safeAuditResource, AuditOutcome.SUCCESS, { model, @@ -2460,10 +2460,18 @@ while (attempted.size < Math.max(1, accountCount)) { : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; runtimeMetrics.failedRequests++; runtimeMetrics.lastError = message; + const safePluginAuditResource = (() => { + try { + const parsed = new URL(url); + return `${parsed.origin}${parsed.pathname}`; + } catch { + return url; + } + })(); auditLog( AuditAction.REQUEST_FAILURE, "plugin", - url, + safePluginAuditResource, AuditOutcome.FAILURE, { model, diff --git a/lib/background-jobs.ts b/lib/background-jobs.ts index 2a0fd8c..0c9019a 100644 --- a/lib/background-jobs.ts +++ b/lib/background-jobs.ts @@ -89,7 +89,9 @@ export async function runBackgroundJobWithRetry(options: BackgroundJobRetryOp const retryable = options.retryable ?? isRetryableByDefault; let lastError: unknown; + let attemptsMade = 0; for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + attemptsMade = attempt; try { return await options.task(); } catch (error) { @@ -106,7 +108,7 @@ export async function runBackgroundJobWithRetry(options: BackgroundJobRetryOp version: 1, timestamp: new Date().toISOString(), job: options.name, - attempts: maxAttempts, + attempts: attemptsMade, error: errorMessage, ...(options.context ? { context: redactForExternalOutput(options.context) } : {}), }; @@ -122,7 +124,7 @@ export async function runBackgroundJobWithRetry(options: BackgroundJobRetryOp logWarn("Background job failed after retries", { job: options.name, - attempts: maxAttempts, + attempts: attemptsMade, error: errorMessage, }); throw (lastError instanceof Error ? lastError : new Error(errorMessage)); diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index 087cb11..a62bbf9 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -137,16 +137,26 @@ function maybeRedactJsonOutput(value: T): T { return shouldRedact ? redactForExternalOutput(value) : value; } +function looksLikeOptionToken(value: string): boolean { + const trimmed = value.trim(); + if (trimmed.length === 0) return false; + return /^-{1,2}[A-Za-z0-9]/.test(trimmed); +} + function hasOptionFlag(args: readonly string[], flag: string): boolean { for (let index = 0; index < args.length; index += 1) { const arg = args[index]; if (arg === flag) { const next = args[index + 1]; - return typeof next === "string" && next.trim().length > 0; + return ( + typeof next === "string" && + next.trim().length > 0 && + !looksLikeOptionToken(next) + ); } if (arg?.startsWith(`${flag}=`)) { const value = arg.slice(flag.length + 1).trim(); - return value.length > 0; + return value.length > 0 && !looksLikeOptionToken(value); } } return false; @@ -249,6 +259,31 @@ function normalizeFailureDetail( return bounded.length > 0 ? bounded : "refresh failed"; } +function redactFreeformSecretText(message: string): string { + return message + .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "***REDACTED***") + .replace( + /\b(?:access|refresh|id)?_?token(?:=|:)?\s*([A-Z0-9._-]+)/gi, + "token=***REDACTED***", + ) + .replace(/\b(Bearer)\s+[A-Z0-9._-]+\b/gi, "$1 ***REDACTED***"); +} + +function sanitizeCliErrorMessage(error: unknown): string { + const raw = error instanceof Error ? error.message : String(error); + const redacted = redactForExternalOutput(raw); + if (typeof redacted === "string") { + const normalized = collapseWhitespace(redactFreeformSecretText(redacted)); + return normalized.length > 0 ? normalized : "unknown error"; + } + try { + const normalized = collapseWhitespace(redactFreeformSecretText(JSON.stringify(redacted))); + return normalized.length > 0 ? normalized : "unknown error"; + } catch { + return "unknown error"; + } +} + function joinStyledSegments(parts: string[]): string { if (parts.length === 0) return ""; const separator = stylePromptText(" | ", "muted"); @@ -1965,7 +2000,11 @@ function parseListArgs(args: string[]): ParsedArgsResult { if (!value) { return { ok: false, message: "Missing value for --page-size" }; } - const parsed = Number.parseInt(value, 10); + const normalized = value.trim(); + if (!/^\d+$/.test(normalized)) { + return { ok: false, message: "--page-size must be between 1 and 200" }; + } + const parsed = Number.parseInt(normalized, 10); if (!Number.isFinite(parsed) || parsed < 1 || parsed > 200) { return { ok: false, message: "--page-size must be between 1 and 200" }; } @@ -1975,6 +2014,9 @@ function parseListArgs(args: string[]): ParsedArgsResult { } if (arg.startsWith("--page-size=")) { const value = arg.slice("--page-size=".length).trim(); + if (!/^\d+$/.test(value)) { + return { ok: false, message: "--page-size must be between 1 and 200" }; + } const parsed = Number.parseInt(value, 10); if (!Number.isFinite(parsed) || parsed < 1 || parsed > 200) { return { ok: false, message: "--page-size must be between 1 and 200" }; @@ -2178,7 +2220,7 @@ function parseRotateSecretsArgs(args: string[]): ParsedArgsResult { ); return 0; } catch (error) { - const message = error instanceof Error ? error.message : String(error); + const message = sanitizeCliErrorMessage(error); emitAudit(AuditAction.CONFIG_CHANGE, AuditOutcome.FAILURE, "rotate-secrets", { error: message, ...(idempotencyKey ? { idempotencyKey } : {}), diff --git a/lib/data-retention.ts b/lib/data-retention.ts index 8b7ae4e..57fc338 100644 --- a/lib/data-retention.ts +++ b/lib/data-retention.ts @@ -92,7 +92,7 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise { + try { + await fs.unlink(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } +} + export async function acquireFileLock( path: string, options: FileLockOptions = {}, @@ -81,10 +92,11 @@ export async function acquireFileLock( } catch (error) { closeError = error; } - if (writeError !== undefined) { - throw writeError; - } - if (closeError !== undefined) { + if (writeError !== undefined || closeError !== undefined) { + await cleanupIncompleteLockFile(path); + if (writeError !== undefined) { + throw writeError; + } throw closeError; } let released = false; @@ -92,14 +104,16 @@ export async function acquireFileLock( path, release: async () => { if (released) return; - released = true; try { await fs.unlink(path); + released = true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - throw error; + if (code === "ENOENT") { + released = true; + return; } + throw error; } }, }; @@ -139,6 +153,17 @@ function removeIfStaleSync(path: string, staleAfterMs: number): boolean { } } +function cleanupIncompleteLockFileSync(path: string): void { + try { + unlinkSync(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + throw error; + } + } +} + export function acquireFileLockSync( path: string, options: FileLockOptions = {}, @@ -167,10 +192,11 @@ export function acquireFileLockSync( } catch (error) { closeError = error; } - if (writeError !== undefined) { - throw writeError; - } - if (closeError !== undefined) { + if (writeError !== undefined || closeError !== undefined) { + cleanupIncompleteLockFileSync(path); + if (writeError !== undefined) { + throw writeError; + } throw closeError; } let released = false; @@ -178,14 +204,16 @@ export function acquireFileLockSync( path, release: () => { if (released) return; - released = true; try { unlinkSync(path); + released = true; } catch (error) { const code = (error as NodeJS.ErrnoException).code; - if (code !== "ENOENT") { - throw error; + if (code === "ENOENT") { + released = true; + return; } + throw error; } }, }; diff --git a/lib/storage.ts b/lib/storage.ts index bb5c3d1..548d4e3 100644 --- a/lib/storage.ts +++ b/lib/storage.ts @@ -1643,12 +1643,13 @@ export async function rotateStoredSecretEncryption(): Promise<{ throw new Error("CODEX_AUTH_ENCRYPTION_KEY is required to rotate stored secrets"); } - let accountCount = 0; - const accounts = await loadAccounts(); - if (accounts) { - accountCount = accounts.accounts.length; - await saveAccounts(accounts); - } + const accountCount = await withAccountStorageTransaction(async (current, persist) => { + if (!current) { + return 0; + } + await persist(current); + return current.accounts.length; + }); const flagged = await loadFlaggedAccounts(); const flaggedCount = flagged.accounts.length; diff --git a/scripts/license-policy-check.js b/scripts/license-policy-check.js index 973a908..b8aaabd 100644 --- a/scripts/license-policy-check.js +++ b/scripts/license-policy-check.js @@ -4,9 +4,13 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; const lockPath = resolve(process.cwd(), "package-lock.json"); +function normalizeSpdxToken(value) { + return value.trim().toUpperCase().replace(/-(ONLY|OR-LATER)$/g, ""); +} + const denyList = (process.env.CODEX_LICENSE_DENYLIST ?? "GPL-2.0,GPL-3.0,AGPL-3.0") .split(",") - .map((value) => value.trim().toUpperCase()) + .map((value) => normalizeSpdxToken(value)) .filter((value) => value.length > 0); const failOnUnknown = process.env.CODEX_LICENSE_FAIL_ON_UNKNOWN === "1"; @@ -49,7 +53,7 @@ function extractLicenseTokens(rawLicense) { return rawLicense .toUpperCase() .split(/[^A-Z0-9.-]+/) - .map((value) => value.trim()) + .map((value) => normalizeSpdxToken(value)) .filter((value) => value.length > 0); } diff --git a/test/background-jobs.test.ts b/test/background-jobs.test.ts index d45bf5d..2431078 100644 --- a/test/background-jobs.test.ts +++ b/test/background-jobs.test.ts @@ -138,6 +138,32 @@ describe("background jobs", () => { expect(entry.attempts).toBe(3); }); + it("records actual attempts for non-retryable failures", async () => { + const { runBackgroundJobWithRetry, getBackgroundJobDlqPath } = + await import("../lib/background-jobs.js"); + let attempts = 0; + await expect( + runBackgroundJobWithRetry({ + name: "test.non-retryable", + task: async () => { + attempts += 1; + const error = Object.assign(new Error("bad request"), { statusCode: 400 }); + throw error; + }, + maxAttempts: 5, + retryable: () => false, + }), + ).rejects.toThrow("bad request"); + expect(attempts).toBe(1); + + const content = await fs.readFile(getBackgroundJobDlqPath(), "utf8"); + const lines = content.trim().split("\n"); + expect(lines).toHaveLength(1); + const entry = JSON.parse(lines[0] ?? "{}") as { job?: string; attempts?: number }; + expect(entry.job).toBe("test.non-retryable"); + expect(entry.attempts).toBe(1); + }); + it("redacts sensitive error text in dead-letter entries and warning logs", async () => { vi.resetModules(); const warnMock = vi.fn(); diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index 0395214..c7776b6 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2146,6 +2146,25 @@ describe("codex manager cli commands", () => { } }); + it("rejects partially numeric --page-size values", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "list", + "--json", + "--page-size", + "10junk", + ]); + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("--page-size must be between 1 and 200"); + expect(loadAccountsMock).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + it("applies idempotency key for rotate-secrets automation retries", async () => { checkAndRecordIdempotencyKeyMock .mockResolvedValueOnce({ replayed: false }) @@ -2193,6 +2212,46 @@ describe("codex manager cli commands", () => { } }); + it("rejects option-smuggling for --idempotency-key", async () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli([ + "auth", + "rotate-secrets", + "--idempotency-key", + "--json", + ]); + expect(exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith("Missing value for --idempotency-key"); + expect(rotateStoredSecretEncryptionMock).not.toHaveBeenCalled(); + } finally { + errorSpy.mockRestore(); + } + }); + + it("redacts rotate-secrets failure details in non-json output", async () => { + rotateStoredSecretEncryptionMock.mockRejectedValue( + new Error( + "failed for person@example.com Bearer sk_test_123 refresh_token=rt_456", + ), + ); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + const { runCodexMultiAuthCli } = await import("../lib/codex-manager.js"); + const exitCode = await runCodexMultiAuthCli(["auth", "rotate-secrets"]); + expect(exitCode).toBe(1); + const rendered = String(errorSpy.mock.calls[0]?.[0] ?? ""); + expect(rendered).toContain("Failed to rotate stored secrets:"); + expect(rendered).toContain("***REDACTED***"); + expect(rendered).not.toContain("person@example.com"); + expect(rendered).not.toContain("sk_test_123"); + expect(rendered).not.toContain("rt_456"); + } finally { + errorSpy.mockRestore(); + } + }); + it("enforces ABAC idempotency-key requirement for rotate-secrets", async () => { const previousRole = process.env.CODEX_AUTH_ROLE; const previousAbacRequirement = process.env.CODEX_AUTH_ABAC_REQUIRE_IDEMPOTENCY_KEY; diff --git a/test/data-retention.test.ts b/test/data-retention.test.ts index 709b239..68e158b 100644 --- a/test/data-retention.test.ts +++ b/test/data-retention.test.ts @@ -154,4 +154,45 @@ describe("data retention", () => { rmdirSpy.mockRestore(); } }); + + it("treats ENOTEMPTY on directory cleanup as a non-fatal race", async () => { + const { enforceDataRetention } = await import("../lib/data-retention.js"); + const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); + const logsDir = join(tempDir, "logs"); + const nestedDir = join(logsDir, "nested"); + const staleLog = join(nestedDir, "stale.log"); + + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(staleLog, "old", "utf8"); + await fs.utimes(staleLog, oldDate, oldDate); + + const originalRmdir = fs.rmdir.bind(fs); + const rmdirSpy = vi.spyOn(fs, "rmdir"); + let enotemptyInjected = false; + rmdirSpy.mockImplementation(async (path) => { + if (!enotemptyInjected && path === nestedDir) { + enotemptyInjected = true; + const error = new Error("directory recreated") as NodeJS.ErrnoException; + error.code = "ENOTEMPTY"; + throw error; + } + return originalRmdir(path); + }); + + try { + const policy: RetentionPolicy = { + logDays: 1, + cacheDays: 90, + flaggedDays: 90, + quotaCacheDays: 90, + dlqDays: 90, + }; + const result = await enforceDataRetention(policy); + expect(result.removedLogs).toBe(1); + expect(enotemptyInjected).toBe(true); + await expect(fs.stat(staleLog)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + rmdirSpy.mockRestore(); + } + }); }); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index c0503a9..536f5df 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { spawn } from "node:child_process"; import { join } from "node:path"; @@ -164,4 +164,74 @@ for (let i = 0; i < iterations; i += 1) { expect(lines).toHaveLength(workerCount * iterationsPerWorker); expect(new Set(lines).size).toBe(lines.length); }); + + it("allows release retries after transient unlink failures", async () => { + const lockPath = join(tempDir, "release-retry.lock"); + const lock = await acquireFileLock(lockPath, { + maxAttempts: 3, + baseDelayMs: 1, + maxDelayMs: 2, + staleAfterMs: 10_000, + }); + + const originalUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi.spyOn(fs, "unlink"); + let injectedBusy = false; + unlinkSpy.mockImplementation(async (path) => { + if (!injectedBusy && path === lockPath) { + injectedBusy = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalUnlink(path); + }); + + try { + await expect(lock.release()).rejects.toMatchObject({ code: "EBUSY" }); + await expect(lock.release()).resolves.toBeUndefined(); + expect(injectedBusy).toBe(true); + await expect(fs.stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + unlinkSpy.mockRestore(); + } + }); + + it("cleans up lock files when metadata write fails", async () => { + const lockPath = join(tempDir, "incomplete.lock"); + const originalOpen = fs.open.bind(fs); + type OpenFn = typeof fs.open; + type OpenHandle = Awaited>; + const openSpy = vi.spyOn(fs, "open"); + const mockOpen: OpenFn = (async (...args: Parameters) => { + const [path] = args; + if (String(path) === lockPath) { + await fs.writeFile(lockPath, "partial", "utf8"); + return { + writeFile: async () => { + const error = new Error("metadata write failed") as NodeJS.ErrnoException; + error.code = "EIO"; + throw error; + }, + close: async () => {}, + } as unknown as OpenHandle; + } + return originalOpen(...args); + }) as OpenFn; + openSpy.mockImplementation(mockOpen); + + try { + await expect( + acquireFileLock(lockPath, { + maxAttempts: 1, + baseDelayMs: 1, + maxDelayMs: 2, + staleAfterMs: 10_000, + }), + ).rejects.toThrow("metadata write failed"); + await expect(fs.stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + openSpy.mockRestore(); + } + }); }); From 1749d316577b5798aa992943368d73b41f263687 Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 21:27:16 +0800 Subject: [PATCH 7/8] fix(pr34): resolve remaining review findings Address remaining PR34 review threads across audit URL sanitization, background-job retry behavior, data-retention retry scoping, unified-settings lock release handling, file-lock stale owner protection, secret redaction hardening, and SPDX plus-form denylist matching. Add regression coverage for concurrent lock contention, EPERM/EBUSY release retries, retry-limit/jitter counters, audit fallback sanitization, retention transient IO branches, unified-settings release failures, and license-policy plus-form matches. Co-authored-by: Codex --- index.ts | 8 +- lib/background-jobs.ts | 11 ++- lib/codex-manager.ts | 4 +- lib/data-retention.ts | 12 +-- lib/file-lock.ts | 79 +++++++++++++++---- lib/unified-settings.ts | 41 +++++++++- scripts/license-policy-check.js | 8 +- test/background-jobs.test.ts | 47 ++++++++++-- test/codex-manager-cli.test.ts | 6 +- test/data-retention.test.ts | 81 ++++++++++++++++++++ test/file-lock.test.ts | 122 +++++++++++++++++++++++------- test/index-retry.test.ts | 46 +++++++++++ test/license-policy-check.test.ts | 75 ++++++++++++++++++ test/unified-settings.test.ts | 62 +++++++++++++++ 14 files changed, 527 insertions(+), 75 deletions(-) create mode 100644 test/license-policy-check.test.ts diff --git a/index.ts b/index.ts index 0bc12cc..f4b9c0f 100644 --- a/index.ts +++ b/index.ts @@ -1689,7 +1689,9 @@ while (attempted.size < Math.max(1, accountCount)) { const parsed = new URL(url); return `${parsed.origin}${parsed.pathname}`; } catch { - return url; + const rawUrl = String(url); + const sanitized = rawUrl.split(/[?#]/, 1)[0]; + return sanitized && sanitized.length > 0 ? sanitized : "unknown"; } })(); @@ -2465,7 +2467,9 @@ while (attempted.size < Math.max(1, accountCount)) { const parsed = new URL(url); return `${parsed.origin}${parsed.pathname}`; } catch { - return url; + const rawUrl = String(url); + const sanitized = rawUrl.split(/[?#]/, 1)[0]; + return sanitized && sanitized.length > 0 ? sanitized : "unknown"; } })(); auditLog( diff --git a/lib/background-jobs.ts b/lib/background-jobs.ts index 0c9019a..46342dc 100644 --- a/lib/background-jobs.ts +++ b/lib/background-jobs.ts @@ -39,15 +39,14 @@ function toErrorMessage(error: unknown): string { function sanitizeErrorMessage(message: string): string { return message .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "***REDACTED***") - .replace( - /\b(?:access|refresh|id)?_?token(?:=|:)?\s*([A-Z0-9._-]+)/gi, - "token=***REDACTED***", - ) - .replace(/\b(Bearer)\s+[A-Z0-9._-]+\b/gi, "$1 ***REDACTED***"); + .replace(/\b(?:access|refresh|id)?_?token(?:=|:)?\s*\S+/gi, "token=***REDACTED***") + .replace(/\b(Bearer)\s+\S+/gi, "$1 ***REDACTED***"); } function getDelayMs(attempt: number, baseDelayMs: number, maxDelayMs: number): number { - return Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1)); + const cappedDelay = Math.min(maxDelayMs, baseDelayMs * 2 ** Math.max(0, attempt - 1)); + const jitterFactor = 1 + (Math.random() * 0.4 - 0.2); + return Math.max(1, Math.round(cappedDelay * jitterFactor)); } function isRetryableByDefault(error: unknown): boolean { diff --git a/lib/codex-manager.ts b/lib/codex-manager.ts index a62bbf9..543aad8 100644 --- a/lib/codex-manager.ts +++ b/lib/codex-manager.ts @@ -263,10 +263,10 @@ function redactFreeformSecretText(message: string): string { return message .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, "***REDACTED***") .replace( - /\b(?:access|refresh|id)?_?token(?:=|:)?\s*([A-Z0-9._-]+)/gi, + /\b(?:access|refresh|id)?_?token(?:=|:)?\s*([A-Z0-9._~+/=-]+)/gi, "token=***REDACTED***", ) - .replace(/\b(Bearer)\s+[A-Z0-9._-]+\b/gi, "$1 ***REDACTED***"); + .replace(/\b(Bearer)\s+[A-Z0-9._~+/=-]+\b/gi, "$1 ***REDACTED***"); } function sanitizeCliErrorMessage(error: unknown): string { diff --git a/lib/data-retention.ts b/lib/data-retention.ts index 57fc338..a3263b7 100644 --- a/lib/data-retention.ts +++ b/lib/data-retention.ts @@ -18,7 +18,7 @@ const DEFAULT_POLICY: RetentionPolicy = { quotaCacheDays: 14, dlqDays: 30, }; -const RETRYABLE_RETENTION_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN"]); +const RETRYABLE_RETENTION_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN", "ENOTEMPTY"]); function isRetryableRetentionError(error: unknown): boolean { const code = (error as NodeJS.ErrnoException).code; @@ -78,7 +78,7 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise pruneDirectoryByAge(fullPath, maxAgeMs)); + removed += await pruneDirectoryByAge(fullPath, maxAgeMs); const childEntries = await withRetentionIoRetry(() => fs.readdir(fullPath)); if (childEntries.length === 0) { await withRetentionIoRetry(() => fs.rmdir(fullPath)); @@ -90,12 +90,8 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise fs.unlink(fullPath)); removed += 1; - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENOTEMPTY") { - continue; - } - throw error; + } catch { + continue; } } return removed; diff --git a/lib/file-lock.ts b/lib/file-lock.ts index 3b8afbf..306317d 100644 --- a/lib/file-lock.ts +++ b/lib/file-lock.ts @@ -1,4 +1,4 @@ -import { openSync, writeFileSync, closeSync, unlinkSync, statSync, promises as fs } from "node:fs"; +import { openSync, writeFileSync, closeSync, unlinkSync, statSync, readFileSync, promises as fs } from "node:fs"; export interface FileLockOptions { maxAttempts?: number; @@ -23,6 +23,28 @@ const DEFAULT_MAX_DELAY_MS = 1_000; const DEFAULT_STALE_AFTER_MS = 5 * 60_000; const RETRYABLE_CODES = new Set(["EEXIST", "EBUSY", "EPERM"]); +function isPidAlive(pid: number): boolean { + if (!Number.isInteger(pid) || pid <= 0) return false; + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EPERM"; + } +} + +function parseLockPid(rawLock: string): number | undefined { + const firstLine = rawLock.split(/\r?\n/, 1)[0] ?? ""; + if (!firstLine) return undefined; + try { + const parsed = JSON.parse(firstLine) as { pid?: unknown }; + return typeof parsed.pid === "number" && Number.isInteger(parsed.pid) ? parsed.pid : undefined; + } catch { + return undefined; + } +} + function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -43,6 +65,14 @@ async function removeIfStale(path: string, staleAfterMs: number): Promise { +function throwOriginalOrCleanupError( + originalError: unknown, + cleanupError: unknown, +): never { + if (originalError instanceof Error) { + throw originalError; + } + if (cleanupError instanceof Error) { + throw cleanupError; + } + throw new Error(String(originalError ?? cleanupError)); +} + +async function cleanupIncompleteLockFile(path: string, originalError?: unknown): Promise { try { await fs.unlink(path); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + if (originalError !== undefined) { + throwOriginalOrCleanupError(originalError, error); + } throw error; } } @@ -93,11 +139,9 @@ export async function acquireFileLock( closeError = error; } if (writeError !== undefined || closeError !== undefined) { - await cleanupIncompleteLockFile(path); - if (writeError !== undefined) { - throw writeError; - } - throw closeError; + const originalFailure = writeError ?? closeError; + await cleanupIncompleteLockFile(path, originalFailure); + throwOriginalOrCleanupError(originalFailure, originalFailure); } let released = false; return { @@ -142,6 +186,14 @@ function removeIfStaleSync(path: string, staleAfterMs: number): boolean { if (Date.now() - stats.mtimeMs <= staleAfterMs) { return false; } + try { + const pid = parseLockPid(readFileSync(path, "utf8")); + if (pid !== undefined && isPidAlive(pid)) { + return false; + } + } catch { + // If metadata can't be read, fall back to age-only cleanup. + } unlinkSync(path); return true; } catch (error) { @@ -153,12 +205,15 @@ function removeIfStaleSync(path: string, staleAfterMs: number): boolean { } } -function cleanupIncompleteLockFileSync(path: string): void { +function cleanupIncompleteLockFileSync(path: string, originalError?: unknown): void { try { unlinkSync(path); } catch (error) { const code = (error as NodeJS.ErrnoException).code; if (code !== "ENOENT") { + if (originalError !== undefined) { + throwOriginalOrCleanupError(originalError, error); + } throw error; } } @@ -193,11 +248,9 @@ export function acquireFileLockSync( closeError = error; } if (writeError !== undefined || closeError !== undefined) { - cleanupIncompleteLockFileSync(path); - if (writeError !== undefined) { - throw writeError; - } - throw closeError; + const originalFailure = writeError ?? closeError; + cleanupIncompleteLockFileSync(path, originalFailure); + throwOriginalOrCleanupError(originalFailure, originalFailure); } let released = false; return { diff --git a/lib/unified-settings.ts b/lib/unified-settings.ts index 2b1ea93..f9c6f82 100644 --- a/lib/unified-settings.ts +++ b/lib/unified-settings.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { getCodexMultiAuthDir } from "./runtime-paths.js"; import { sleep } from "./utils.js"; import { acquireFileLock, acquireFileLockSync } from "./file-lock.js"; +import { logWarn } from "./logger.js"; type JsonRecord = Record; @@ -32,6 +33,40 @@ function isRetryableFsError(error: unknown): boolean { return typeof code === "string" && RETRYABLE_FS_CODES.has(code); } +function releaseUnifiedSettingsLockSync(release: () => void): void { + try { + release(); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") return; + if (code === "EBUSY" || code === "EPERM") { + logWarn("Unified settings lock release failed after write", { + lockPath: UNIFIED_SETTINGS_LOCK_PATH, + code, + }); + return; + } + throw error; + } +} + +async function releaseUnifiedSettingsLockAsync(release: () => Promise): Promise { + try { + await release(); + } catch (error) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + if (code === "ENOENT") return; + if (code === "EBUSY" || code === "EPERM") { + logWarn("Unified settings lock release failed after write", { + lockPath: UNIFIED_SETTINGS_LOCK_PATH, + code, + }); + return; + } + throw error; + } +} + /** * Determines whether a value is a non-null object suitable for use as a JsonRecord. * @@ -268,7 +303,7 @@ export function saveUnifiedPluginConfigSync(pluginConfig: JsonRecord): void { record.pluginConfig = { ...pluginConfig }; writeSettingsRecordSync(record); } finally { - lock.release(); + releaseUnifiedSettingsLockSync(() => lock.release()); } } @@ -290,7 +325,7 @@ export async function saveUnifiedPluginConfig(pluginConfig: JsonRecord): Promise record.pluginConfig = { ...pluginConfig }; await writeSettingsRecordAsync(record); } finally { - await lock.release(); + await releaseUnifiedSettingsLockAsync(() => lock.release()); } }); } @@ -338,7 +373,7 @@ export async function saveUnifiedDashboardSettings( record.dashboardDisplaySettings = { ...dashboardDisplaySettings }; await writeSettingsRecordAsync(record); } finally { - await lock.release(); + await releaseUnifiedSettingsLockAsync(() => lock.release()); } }); } diff --git a/scripts/license-policy-check.js b/scripts/license-policy-check.js index b8aaabd..818926c 100644 --- a/scripts/license-policy-check.js +++ b/scripts/license-policy-check.js @@ -5,7 +5,11 @@ import { resolve } from "node:path"; const lockPath = resolve(process.cwd(), "package-lock.json"); function normalizeSpdxToken(value) { - return value.trim().toUpperCase().replace(/-(ONLY|OR-LATER)$/g, ""); + return value + .trim() + .toUpperCase() + .replace(/\+$/g, "") + .replace(/-(ONLY|OR-LATER)$/g, ""); } const denyList = (process.env.CODEX_LICENSE_DENYLIST ?? "GPL-2.0,GPL-3.0,AGPL-3.0") @@ -52,7 +56,7 @@ function extractRawLicense(record) { function extractLicenseTokens(rawLicense) { return rawLicense .toUpperCase() - .split(/[^A-Z0-9.-]+/) + .split(/[^A-Z0-9.+-]+/) .map((value) => normalizeSpdxToken(value)) .filter((value) => value.length > 0); } diff --git a/test/background-jobs.test.ts b/test/background-jobs.test.ts index 2431078..f3209d1 100644 --- a/test/background-jobs.test.ts +++ b/test/background-jobs.test.ts @@ -123,9 +123,6 @@ describe("background jobs", () => { maxAttempts: 3, baseDelayMs: 1, maxDelayMs: 2, - retryable: (error) => - typeof (error as { statusCode?: unknown }).statusCode === "number" && - (error as { statusCode: number }).statusCode === 429, }), ).rejects.toThrow("rate limited"); expect(attempts).toBe(3); @@ -138,6 +135,40 @@ describe("background jobs", () => { expect(entry.attempts).toBe(3); }); + it("adds jitter to retry delays to avoid synchronized retries", async () => { + vi.resetModules(); + const sleepMock = vi.fn(async () => {}); + vi.doMock("../lib/utils.js", () => ({ + sleep: sleepMock, + })); + const randomSpy = vi.spyOn(Math, "random"); + randomSpy.mockReturnValueOnce(0).mockReturnValueOnce(1); + + try { + const { runBackgroundJobWithRetry } = await import("../lib/background-jobs.js"); + let attempts = 0; + await expect( + runBackgroundJobWithRetry({ + name: "test.retry-jitter", + task: async () => { + attempts += 1; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + }, + maxAttempts: 3, + baseDelayMs: 100, + maxDelayMs: 100, + }), + ).rejects.toThrow("busy"); + expect(attempts).toBe(3); + expect(sleepMock.mock.calls.map(([ms]) => ms)).toEqual([80, 120]); + } finally { + randomSpy.mockRestore(); + vi.doUnmock("../lib/utils.js"); + } + }); + it("records actual attempts for non-retryable failures", async () => { const { runBackgroundJobWithRetry, getBackgroundJobDlqPath } = await import("../lib/background-jobs.js"); @@ -178,7 +209,7 @@ describe("background jobs", () => { name: "test.retry-sensitive-error", task: async () => { throw new Error( - "network failed for person@example.com Bearer sk_test_123 refresh_token=rt_456", + "network failed for person@example.com Bearer sk_test+/123== refresh_token=rt+/456==", ); }, maxAttempts: 1, @@ -191,14 +222,14 @@ describe("background jobs", () => { const entry = JSON.parse(lines[0] ?? "{}") as { error?: string }; expect(entry.error).toContain("***REDACTED***"); expect(entry.error).not.toContain("person@example.com"); - expect(entry.error).not.toContain("sk_test_123"); - expect(entry.error).not.toContain("rt_456"); + expect(entry.error).not.toContain("sk_test+/123=="); + expect(entry.error).not.toContain("rt+/456=="); const warningPayloads = warnMock.mock.calls.map((args) => args[1]); const serializedWarnings = JSON.stringify(warningPayloads); expect(serializedWarnings).not.toContain("person@example.com"); - expect(serializedWarnings).not.toContain("sk_test_123"); - expect(serializedWarnings).not.toContain("rt_456"); + expect(serializedWarnings).not.toContain("sk_test+/123=="); + expect(serializedWarnings).not.toContain("rt+/456=="); } finally { vi.doUnmock("../lib/logger.js"); } diff --git a/test/codex-manager-cli.test.ts b/test/codex-manager-cli.test.ts index c7776b6..22e515c 100644 --- a/test/codex-manager-cli.test.ts +++ b/test/codex-manager-cli.test.ts @@ -2233,7 +2233,7 @@ describe("codex manager cli commands", () => { it("redacts rotate-secrets failure details in non-json output", async () => { rotateStoredSecretEncryptionMock.mockRejectedValue( new Error( - "failed for person@example.com Bearer sk_test_123 refresh_token=rt_456", + "failed for person@example.com Bearer sk_test+/123== refresh_token=rt+/456==", ), ); const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); @@ -2245,8 +2245,8 @@ describe("codex manager cli commands", () => { expect(rendered).toContain("Failed to rotate stored secrets:"); expect(rendered).toContain("***REDACTED***"); expect(rendered).not.toContain("person@example.com"); - expect(rendered).not.toContain("sk_test_123"); - expect(rendered).not.toContain("rt_456"); + expect(rendered).not.toContain("sk_test+/123=="); + expect(rendered).not.toContain("rt+/456=="); } finally { errorSpy.mockRestore(); } diff --git a/test/data-retention.test.ts b/test/data-retention.test.ts index 68e158b..ea657a8 100644 --- a/test/data-retention.test.ts +++ b/test/data-retention.test.ts @@ -155,6 +155,87 @@ describe("data retention", () => { } }); + it.each(["EPERM", "EACCES", "EAGAIN"] as const)( + "retries transient %s during directory entry retention pruning", + async (code) => { + const { enforceDataRetention } = await import("../lib/data-retention.js"); + const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); + const logsDir = join(tempDir, "logs"); + const nestedDir = join(logsDir, "nested"); + const staleLog = join(nestedDir, "stale.log"); + + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(staleLog, "old", "utf8"); + await fs.utimes(staleLog, oldDate, oldDate); + + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat"); + let injected = false; + statSpy.mockImplementation(async (path, options) => { + if (!injected && path === staleLog) { + injected = true; + const error = new Error(code.toLowerCase()) as NodeJS.ErrnoException; + error.code = code; + throw error; + } + return originalStat(path, options as { bigint?: boolean }); + }); + + try { + const policy: RetentionPolicy = { + logDays: 1, + cacheDays: 90, + flaggedDays: 90, + quotaCacheDays: 90, + dlqDays: 90, + }; + const result = await enforceDataRetention(policy); + expect(result.removedLogs).toBe(1); + expect(injected).toBe(true); + await expect(fs.stat(staleLog)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + statSpy.mockRestore(); + } + }, + ); + + it("retries transient unlink failures during single-file retention pruning", async () => { + const { enforceDataRetention } = await import("../lib/data-retention.js"); + const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); + const flagged = join(tempDir, "openai-codex-flagged-accounts.json"); + await fs.writeFile(flagged, "{}", "utf8"); + await fs.utimes(flagged, oldDate, oldDate); + + const originalUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi.spyOn(fs, "unlink"); + let injected = false; + unlinkSpy.mockImplementation(async (path) => { + if (!injected && path === flagged) { + injected = true; + const error = new Error("access denied") as NodeJS.ErrnoException; + error.code = "EACCES"; + throw error; + } + return originalUnlink(path); + }); + + try { + const policy: RetentionPolicy = { + logDays: 90, + cacheDays: 90, + flaggedDays: 1, + quotaCacheDays: 90, + dlqDays: 90, + }; + const result = await enforceDataRetention(policy); + expect(result.removedStateFiles).toBe(1); + expect(injected).toBe(true); + await expect(fs.stat(flagged)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + unlinkSpy.mockRestore(); + } + }); + it("treats ENOTEMPTY on directory cleanup as a non-fatal race", async () => { const { enforceDataRetention } = await import("../lib/data-retention.js"); const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index 536f5df..8c1d930 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -98,7 +98,7 @@ describe("file lock", () => { await fs.writeFile(transpiledModulePath, transpiled, "utf8"); const moduleUrl = pathToFileURL(transpiledModulePath).href; const workerCount = 6; - const iterationsPerWorker = 10; + const iterationsPerWorker = 50; await fs.writeFile(sharedFilePath, "", "utf8"); await fs.writeFile( workerScriptPath, @@ -115,7 +115,6 @@ for (let i = 0; i < iterations; i += 1) { }); try { const existing = await fs.readFile(sharedFilePath, "utf8"); - await new Promise((resolve) => setTimeout(resolve, 2)); await fs.writeFile(sharedFilePath, existing + workerId + ":" + i + "\\n", "utf8"); } finally { await lock.release(); @@ -165,44 +164,103 @@ for (let i = 0; i < iterations; i += 1) { expect(new Set(lines).size).toBe(lines.length); }); - it("allows release retries after transient unlink failures", async () => { - const lockPath = join(tempDir, "release-retry.lock"); - const lock = await acquireFileLock(lockPath, { - maxAttempts: 3, - baseDelayMs: 1, - maxDelayMs: 2, - staleAfterMs: 10_000, - }); + it.each(["EBUSY", "EPERM"] as const)( + "allows release retries after transient unlink failures (%s)", + async (transientCode) => { + const lockPath = join(tempDir, "release-retry.lock"); + const lock = await acquireFileLock(lockPath, { + maxAttempts: 3, + baseDelayMs: 1, + maxDelayMs: 2, + staleAfterMs: 10_000, + }); - const originalUnlink = fs.unlink.bind(fs); - const unlinkSpy = vi.spyOn(fs, "unlink"); - let injectedBusy = false; - unlinkSpy.mockImplementation(async (path) => { - if (!injectedBusy && path === lockPath) { - injectedBusy = true; - const error = new Error("busy") as NodeJS.ErrnoException; - error.code = "EBUSY"; - throw error; + const originalUnlink = fs.unlink.bind(fs); + const unlinkSpy = vi.spyOn(fs, "unlink"); + let injectedBusy = false; + unlinkSpy.mockImplementation(async (path) => { + if (!injectedBusy && path === lockPath) { + injectedBusy = true; + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = transientCode; + throw error; + } + return originalUnlink(path); + }); + + try { + await expect(lock.release()).rejects.toMatchObject({ code: transientCode }); + await expect(lock.release()).resolves.toBeUndefined(); + expect(injectedBusy).toBe(true); + await expect(fs.stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); + } finally { + unlinkSpy.mockRestore(); } - return originalUnlink(path); - }); + }, + ); + + it("does not evict stale lock files when lock owner PID is still alive", async () => { + const lockPath = join(tempDir, "live-owner.lock"); + await fs.writeFile(lockPath, `${JSON.stringify({ pid: process.pid, acquiredAt: Date.now() - 120_000 })}\n`, "utf8"); + const staleDate = new Date(Date.now() - 120_000); + await fs.utimes(lockPath, staleDate, staleDate); + + await expect( + acquireFileLock(lockPath, { + maxAttempts: 1, + baseDelayMs: 1, + maxDelayMs: 2, + staleAfterMs: 1_000, + }), + ).rejects.toMatchObject({ code: "EEXIST" }); + await expect(fs.stat(lockPath)).resolves.toBeTruthy(); + }); + + it("cleans up lock files when metadata write fails", async () => { + const lockPath = join(tempDir, "incomplete.lock"); + const originalOpen = fs.open.bind(fs); + type OpenFn = typeof fs.open; + type OpenHandle = Awaited>; + const openSpy = vi.spyOn(fs, "open"); + const mockOpen: OpenFn = (async (...args: Parameters) => { + const [path] = args; + if (String(path) === lockPath) { + await fs.writeFile(lockPath, "partial", "utf8"); + return { + writeFile: async () => { + const error = new Error("metadata write failed") as NodeJS.ErrnoException; + error.code = "EIO"; + throw error; + }, + close: async () => {}, + } as unknown as OpenHandle; + } + return originalOpen(...args); + }) as OpenFn; + openSpy.mockImplementation(mockOpen); try { - await expect(lock.release()).rejects.toMatchObject({ code: "EBUSY" }); - await expect(lock.release()).resolves.toBeUndefined(); - expect(injectedBusy).toBe(true); + await expect( + acquireFileLock(lockPath, { + maxAttempts: 1, + baseDelayMs: 1, + maxDelayMs: 2, + staleAfterMs: 10_000, + }), + ).rejects.toThrow("metadata write failed"); await expect(fs.stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); } finally { - unlinkSpy.mockRestore(); + openSpy.mockRestore(); } }); - it("cleans up lock files when metadata write fails", async () => { - const lockPath = join(tempDir, "incomplete.lock"); + it("preserves metadata write failure when cleanup unlink also fails", async () => { + const lockPath = join(tempDir, "cleanup-error-precedence.lock"); const originalOpen = fs.open.bind(fs); type OpenFn = typeof fs.open; type OpenHandle = Awaited>; const openSpy = vi.spyOn(fs, "open"); + const unlinkSpy = vi.spyOn(fs, "unlink"); const mockOpen: OpenFn = (async (...args: Parameters) => { const [path] = args; if (String(path) === lockPath) { @@ -219,6 +277,14 @@ for (let i = 0; i < iterations; i += 1) { return originalOpen(...args); }) as OpenFn; openSpy.mockImplementation(mockOpen); + unlinkSpy.mockImplementation(async (path) => { + if (String(path) === lockPath) { + const error = new Error("cleanup failed") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + } + return Promise.resolve(); + }); try { await expect( @@ -229,9 +295,9 @@ for (let i = 0; i < iterations; i += 1) { staleAfterMs: 10_000, }), ).rejects.toThrow("metadata write failed"); - await expect(fs.stat(lockPath)).rejects.toMatchObject({ code: "ENOENT" }); } finally { openSpy.mockRestore(); + unlinkSpy.mockRestore(); } }); }); diff --git a/test/index-retry.test.ts b/test/index-retry.test.ts index 3813edc..47397a4 100644 --- a/test/index-retry.test.ts +++ b/test/index-retry.test.ts @@ -213,5 +213,51 @@ describe("OpenAIAuthPlugin rate-limit retry", () => { expect(globalThis.fetch).toHaveBeenCalledTimes(1); expect(response.status).toBe(200); }); + + it("strips query and fragment when audit URL parsing falls back", async () => { + vi.resetModules(); + const auditLogMock = vi.fn(); + vi.doMock("../lib/audit.js", async () => { + const actual = await vi.importActual("../lib/audit.js"); + return { + ...(actual as Record), + auditLog: auditLogMock, + }; + }); + + try { + const { OpenAIAuthPlugin } = await import("../index.js"); + const client = { + tui: { showToast: vi.fn() }, + auth: { set: vi.fn() }, + } as any; + + const plugin = await OpenAIAuthPlugin({ client }); + const getAuth = async () => ({ + type: "oauth" as const, + access: "a", + refresh: "r", + expires: Date.now() + 60_000, + multiAccount: true, + }); + + const sdk = (await plugin.auth.loader(getAuth, { options: {}, models: {} })) as any; + const responsePromise = sdk.fetch("relative/path?token=super-secret#frag", {}); + await vi.advanceTimersByTimeAsync(1500); + const response = await responsePromise; + expect(response.status).toBe(200); + + const resources = auditLogMock.mock.calls.map((args) => String(args[2] ?? "")); + expect(resources.length).toBeGreaterThan(0); + for (const resource of resources) { + expect(resource).not.toContain("?"); + expect(resource).not.toContain("#"); + expect(resource).not.toContain("super-secret"); + } + } finally { + vi.doUnmock("../lib/audit.js"); + vi.resetModules(); + } + }); }); diff --git a/test/license-policy-check.test.ts b/test/license-policy-check.test.ts new file mode 100644 index 0000000..1c88089 --- /dev/null +++ b/test/license-policy-check.test.ts @@ -0,0 +1,75 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { promises as fs } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); + +async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + 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_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} + +describe("license policy check", () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(join(tmpdir(), "codex-license-policy-")); + }); + + afterEach(async () => { + await removeWithRetry(tempDir, { recursive: true, force: true }); + }); + + it.each([ + { denyList: "GPL-2.0+", license: "GPL-2.0+" }, + { denyList: "LGPL-2.1+", license: "MIT OR LGPL-2.1+" }, + ])("blocks denylisted SPDX plus-form (%o)", async ({ denyList, license }) => { + const lock = { + name: "license-test", + lockfileVersion: 3, + packages: { + "": { + name: "license-test", + version: "1.0.0", + }, + "node_modules/blocked-package": { + name: "blocked-package", + version: "1.0.0", + license, + }, + }, + }; + + await fs.writeFile(join(tempDir, "package-lock.json"), JSON.stringify(lock, null, 2), "utf8"); + const scriptPath = join(process.cwd(), "scripts", "license-policy-check.js"); + const result = spawnSync(process.execPath, [scriptPath], { + cwd: tempDir, + encoding: "utf8", + env: { + ...process.env, + CODEX_LICENSE_DENYLIST: denyList, + }, + }); + + expect(result.status).toBe(1); + const stderr = String(result.stderr ?? ""); + expect(stderr).toContain("License policy violations detected:"); + expect(stderr).toContain("blocked-package@1.0.0"); + }); +}); diff --git a/test/unified-settings.test.ts b/test/unified-settings.test.ts index 686f76b..a46fbb5 100644 --- a/test/unified-settings.test.ts +++ b/test/unified-settings.test.ts @@ -301,6 +301,68 @@ describe("unified settings", () => { } }); + it("does not fail async writes when lock release throws EPERM", async () => { + vi.resetModules(); + const acquireFileLockMock = vi.fn(async () => ({ + path: "mock-settings.lock", + release: async () => { + const error = new Error("perm locked") as NodeJS.ErrnoException; + error.code = "EPERM"; + throw error; + }, + })); + const acquireFileLockSyncMock = vi.fn(() => ({ + path: "mock-settings.lock", + release: () => undefined, + })); + vi.doMock("../lib/file-lock.js", () => ({ + acquireFileLock: acquireFileLockMock, + acquireFileLockSync: acquireFileLockSyncMock, + })); + + try { + const { saveUnifiedPluginConfig, loadUnifiedPluginConfigSync } = await import( + "../lib/unified-settings.js" + ); + await expect(saveUnifiedPluginConfig({ codexMode: true, retries: 2 })).resolves.toBeUndefined(); + expect(loadUnifiedPluginConfigSync()).toEqual({ codexMode: true, retries: 2 }); + } finally { + vi.doUnmock("../lib/file-lock.js"); + vi.resetModules(); + } + }); + + it("does not fail sync writes when lock release throws EBUSY", async () => { + vi.resetModules(); + const acquireFileLockMock = vi.fn(async () => ({ + path: "mock-settings.lock", + release: async () => undefined, + })); + const acquireFileLockSyncMock = vi.fn(() => ({ + path: "mock-settings.lock", + release: () => { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + }, + })); + vi.doMock("../lib/file-lock.js", () => ({ + acquireFileLock: acquireFileLockMock, + acquireFileLockSync: acquireFileLockSyncMock, + })); + + try { + const { saveUnifiedPluginConfigSync, loadUnifiedPluginConfigSync } = await import( + "../lib/unified-settings.js" + ); + expect(() => saveUnifiedPluginConfigSync({ codexMode: false, retries: 1 })).not.toThrow(); + expect(loadUnifiedPluginConfigSync()).toEqual({ codexMode: false, retries: 1 }); + } finally { + vi.doUnmock("../lib/file-lock.js"); + vi.resetModules(); + } + }); + it("refuses overwriting settings sections when a read fails", async () => { const { saveUnifiedPluginConfig, From 65112ffc74dc14a29eba827d48297c0a227e11aa Mon Sep 17 00:00:00 2001 From: ndycode Date: Thu, 5 Mar 2026 22:11:17 +0800 Subject: [PATCH 8/8] fix: resolve remaining PR34 review feedback - dedupe safe audit resource sanitization in plugin fetch flow - surface retention retry exhaustion and cover persistent EBUSY regression - share test removeWithRetry helper and add license policy parsing regressions Co-authored-by: Codex --- index.ts | 32 +++++-------- lib/data-retention.ts | 15 +++++- test/data-retention.test.ts | 58 ++++++++++++++-------- test/file-lock.test.ts | 22 +-------- test/helpers/remove-with-retry.ts | 22 +++++++++ test/license-policy-check.test.ts | 80 ++++++++++++++++++------------- test/unified-settings.test.ts | 22 +-------- 7 files changed, 132 insertions(+), 119 deletions(-) create mode 100644 test/helpers/remove-with-retry.ts diff --git a/index.ts b/index.ts index f4b9c0f..91686ca 100644 --- a/index.ts +++ b/index.ts @@ -351,6 +351,16 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => { return sanitized; }; + const toSafeAuditResource = (rawUrl: unknown): string => { + try { + const parsed = new URL(String(rawUrl)); + return `${parsed.origin}${parsed.pathname}`; + } catch { + const sanitized = String(rawUrl).split(/[?#]/, 1)[0]; + return sanitized && sanitized.length > 0 ? sanitized : "unknown"; + } + }; + type RuntimeMetrics = { startedAt: number; totalRequests: number; @@ -1684,16 +1694,7 @@ while (attempted.size < Math.max(1, accountCount)) { } else if (abortSignal && onUserAbort) { abortSignal.addEventListener("abort", onUserAbort, { once: true }); } - const safeAuditResource = (() => { - try { - const parsed = new URL(url); - return `${parsed.origin}${parsed.pathname}`; - } catch { - const rawUrl = String(url); - const sanitized = rawUrl.split(/[?#]/, 1)[0]; - return sanitized && sanitized.length > 0 ? sanitized : "unknown"; - } - })(); + const safeAuditResource = toSafeAuditResource(url); try { runtimeMetrics.totalRequests++; @@ -2462,16 +2463,7 @@ while (attempted.size < Math.max(1, accountCount)) { : `All ${count} account(s) failed (server errors or auth issues). Check account health with \`codex-health\`.`; runtimeMetrics.failedRequests++; runtimeMetrics.lastError = message; - const safePluginAuditResource = (() => { - try { - const parsed = new URL(url); - return `${parsed.origin}${parsed.pathname}`; - } catch { - const rawUrl = String(url); - const sanitized = rawUrl.split(/[?#]/, 1)[0]; - return sanitized && sanitized.length > 0 ? sanitized : "unknown"; - } - })(); + const safePluginAuditResource = toSafeAuditResource(url); auditLog( AuditAction.REQUEST_FAILURE, "plugin", diff --git a/lib/data-retention.ts b/lib/data-retention.ts index a3263b7..0e1981c 100644 --- a/lib/data-retention.ts +++ b/lib/data-retention.ts @@ -1,6 +1,7 @@ import { promises as fs, type Dirent } from "node:fs"; import { join } from "node:path"; import { getCodexCacheDir, getCodexLogDir, getCodexMultiAuthDir } from "./runtime-paths.js"; +import { createLogger } from "./logger.js"; import { sleep } from "./utils.js"; export interface RetentionPolicy { @@ -18,6 +19,7 @@ const DEFAULT_POLICY: RetentionPolicy = { quotaCacheDays: 14, dlqDays: 30, }; +const logger = createLogger("data-retention"); const RETRYABLE_RETENTION_CODES = new Set(["EBUSY", "EPERM", "EACCES", "EAGAIN", "ENOTEMPTY"]); function isRetryableRetentionError(error: unknown): boolean { @@ -90,8 +92,17 @@ async function pruneDirectoryByAge(path: string, maxAgeMs: number): Promise fs.unlink(fullPath)); removed += 1; - } catch { - continue; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + continue; + } + logger.warn("Skipping retention entry after retry exhaustion", { + path: fullPath, + code: code ?? "unknown", + error: error instanceof Error ? error.message : String(error), + }); + throw error; } } return removed; diff --git a/test/data-retention.test.ts b/test/data-retention.test.ts index ea657a8..1c95a10 100644 --- a/test/data-retention.test.ts +++ b/test/data-retention.test.ts @@ -3,27 +3,7 @@ import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import type { RetentionPolicy } from "../lib/data-retention.js"; - -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - 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_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } -} +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("data retention", () => { let tempDir: string; @@ -199,6 +179,42 @@ describe("data retention", () => { }, ); + it("throws after max retries for persistent EBUSY during directory entry pruning", async () => { + const { enforceDataRetention } = await import("../lib/data-retention.js"); + const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); + const logsDir = join(tempDir, "logs"); + const nestedDir = join(logsDir, "nested"); + const staleLog = join(nestedDir, "stale.log"); + + await fs.mkdir(nestedDir, { recursive: true }); + await fs.writeFile(staleLog, "old", "utf8"); + await fs.utimes(staleLog, oldDate, oldDate); + + const originalStat = fs.stat.bind(fs); + const statSpy = vi.spyOn(fs, "stat"); + statSpy.mockImplementation(async (path, options) => { + if (path === staleLog) { + const error = new Error("busy") as NodeJS.ErrnoException; + error.code = "EBUSY"; + throw error; + } + return originalStat(path, options as { bigint?: boolean }); + }); + + try { + const policy: RetentionPolicy = { + logDays: 1, + cacheDays: 90, + flaggedDays: 90, + quotaCacheDays: 90, + dlqDays: 90, + }; + await expect(enforceDataRetention(policy)).rejects.toMatchObject({ code: "EBUSY" }); + } finally { + statSpy.mockRestore(); + } + }); + it("retries transient unlink failures during single-file retention pruning", async () => { const { enforceDataRetention } = await import("../lib/data-retention.js"); const oldDate = new Date(Date.now() - 3 * 24 * 60 * 60_000); diff --git a/test/file-lock.test.ts b/test/file-lock.test.ts index 8c1d930..f8633a6 100644 --- a/test/file-lock.test.ts +++ b/test/file-lock.test.ts @@ -5,27 +5,7 @@ import { join } from "node:path"; import { tmpdir } from "node:os"; import { pathToFileURL } from "node:url"; import { acquireFileLock } from "../lib/file-lock.js"; - -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - 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_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } -} +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("file lock", () => { let tempDir: string; diff --git a/test/helpers/remove-with-retry.ts b/test/helpers/remove-with-retry.ts new file mode 100644 index 0000000..6bd8415 --- /dev/null +++ b/test/helpers/remove-with-retry.ts @@ -0,0 +1,22 @@ +import { promises as fs } from "node:fs"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY", "EACCES", "EAGAIN"]); + +export async function removeWithRetry( + targetPath: string, + options: { recursive?: boolean; force?: boolean }, +): Promise { + 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_REMOVE_CODES.has(code) || attempt === 5) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); + } + } +} diff --git a/test/license-policy-check.test.ts b/test/license-policy-check.test.ts index 1c88089..b97d82a 100644 --- a/test/license-policy-check.test.ts +++ b/test/license-policy-check.test.ts @@ -1,28 +1,14 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { removeWithRetry } from "./helpers/remove-with-retry.js"; -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - 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_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } +function createRetryableFsError(code: string): NodeJS.ErrnoException { + const error = new Error(code.toLowerCase()) as NodeJS.ErrnoException; + error.code = code; + return error; } describe("license policy check", () => { @@ -36,25 +22,51 @@ describe("license policy check", () => { await removeWithRetry(tempDir, { recursive: true, force: true }); }); + it("retries transient windows rm errors before succeeding", async () => { + vi.useFakeTimers(); + const rmSpy = vi.spyOn(fs, "rm"); + rmSpy + .mockRejectedValueOnce(createRetryableFsError("EBUSY")) + .mockRejectedValueOnce(createRetryableFsError("EPERM")) + .mockResolvedValueOnce(undefined); + try { + const cleanupPromise = removeWithRetry("C:\\temp\\codex-license-policy", { + recursive: true, + force: true, + }); + await vi.runAllTimersAsync(); + await expect(cleanupPromise).resolves.toBeUndefined(); + expect(rmSpy).toHaveBeenCalledTimes(3); + } finally { + rmSpy.mockRestore(); + vi.useRealTimers(); + } + }); + it.each([ - { denyList: "GPL-2.0+", license: "GPL-2.0+" }, - { denyList: "LGPL-2.1+", license: "MIT OR LGPL-2.1+" }, - ])("blocks denylisted SPDX plus-form (%o)", async ({ denyList, license }) => { + { denyList: "GPL-2.0+", metadata: { license: "GPL-2.0+" } }, + { denyList: "LGPL-2.1+", metadata: { license: "MIT OR LGPL-2.1+" } }, + { denyList: "GPL-2.0+", metadata: { license: { type: "GPL-2.0+" } } }, + { + denyList: "LGPL-2.1+", + metadata: { licenses: [{ type: "MIT OR LGPL-2.1+" }] }, + }, + ])("blocks denylisted SPDX plus-form (%o)", async ({ denyList, metadata }) => { const lock = { name: "license-test", lockfileVersion: 3, - packages: { - "": { - name: "license-test", - version: "1.0.0", + packages: { + "": { + name: "license-test", + version: "1.0.0", + }, + "node_modules/blocked-package": { + name: "blocked-package", + version: "1.0.0", + ...metadata, + }, }, - "node_modules/blocked-package": { - name: "blocked-package", - version: "1.0.0", - license, - }, - }, - }; + }; await fs.writeFile(join(tempDir, "package-lock.json"), JSON.stringify(lock, null, 2), "utf8"); const scriptPath = join(process.cwd(), "scripts", "license-policy-check.js"); diff --git a/test/unified-settings.test.ts b/test/unified-settings.test.ts index a46fbb5..58e5151 100644 --- a/test/unified-settings.test.ts +++ b/test/unified-settings.test.ts @@ -2,27 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { promises as fs } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; - -const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "EPERM", "ENOTEMPTY"]); - -async function removeWithRetry( - targetPath: string, - options: { recursive?: boolean; force?: boolean }, -): Promise { - 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_REMOVE_CODES.has(code) || attempt === 5) { - throw error; - } - await new Promise((resolve) => setTimeout(resolve, 25 * 2 ** attempt)); - } - } -} +import { removeWithRetry } from "./helpers/remove-with-retry.js"; describe("unified settings", () => { let tempDir: string;