mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
chore: centralize Bun version and refresh CI tool pins (#1171)
* chore: centralize Bun version and refresh CI tool pins - add .bun-version as the shared Bun source of truth for workflows and Docker builds - update PR and release workflows to read Bun from bun-version-file - refresh pinned GitHub Actions and Docker action SHAs to newer low-risk releases - align contributor docs with Bun 1.3.13 guidance * test: stabilize reset and provider profile persistence Harden knowledge graph reset behavior across Windows file-lock scenarios by improving SQLite and JSON reset signaling, preserving a safe JSON source of truth when SQLite cannot be cleared, and adding direct storage regression coverage. Also centralize deterministic config-home handling for tests, tighten provider profile persistence path resolution and cleanup semantics, isolate environment-sensitive suites with the env mutex, and remove flaky external npx dependency from the SDK consumer type test. * test: fix Codex OAuth callback flake Investigate the real provider smoke failure from GitHub Actions and fix the root cause instead of patching the symptom. - make Codex OAuth callback host explicit and consistent across redirect URI generation and listener binding - allow safe loopback host overrides for localhost, 127.0.0.1, and ::1 - harden Codex OAuth tests with env/fetch isolation so they do not poison neighboring provider suites - pin the OAuth callback tests to 127.0.0.1 to avoid localhost IPv4/IPv6 family mismatch flakes in CI Validated with bun test src/services/api/codexOAuth.test.ts, bun test src/services/api/providerConfig.codexSecureStorage.test.ts, and bun run test:provider. * test: harden Codex OAuth callback tests Investigate the recurring provider-smoke OAuth failures across multiple PR runs and fix the flaky callback test design at the root. - remove the free-port reservation race from Codex OAuth tests - add bounded callback retry only for loopback listener warm-up during the in-process OAuth test flow - move ephemeral callback port support into an explicit CodexOAuthService test seam instead of widening production env parsing - keep runtime callback-port semantics unchanged while adding regression coverage for callback host and port parsing Validated with targeted Codex OAuth tests and repeated provider-bucket reruns to check for recurring flake. * test: serialize provider shared-state suites Fix the recurring provider smoke flake at the root cause by serializing test suites that mutate process.env or globalThis.fetch. Add a shared test mutation lock and wire it into the provider bucket so Codex OAuth no longer races with unrelated provider/config/openai shim tests under Bun's parallel test execution. Cleanup now releases the lock in finally blocks, and the shared lock waits indefinitely by default to avoid timeout-based CI flakes. * test: fix smoke root causes and noisy suites Replace the Codex OAuth test's live loopback listener dependency with an injected listener seam, avoid module-mock leakage across provider suites, and clean up the auth-code listener test setup. Also harden noisy storage and search tests by asserting expected log output, isolating SQLite masterpiece persistence per test cwd, and removing routine benchmark/stress logging from passing runs. * build: harden Bun version install in Docker Validate the repo-tracked .bun-version value before using it in the Docker build stage, strip line endings, and install Bun through a quoted semver-only variable instead of raw shell expansion. * test: replace flaky conversation arc benchmark Fix the recurring smoke failure caused by an absolute wall-clock assertion in the normal unit suite. Replace the CI-speed-sensitive conversation arc benchmark with deterministic regression coverage that verifies repeated fact extraction, expected entity shapes, bounded graph growth, and populated-summary behavior. * test: isolate shared-state smoke suites * test: restore codex credential mocks between suites * test: fix shared-state and provider init-order flakes * test: isolate remaining shared-state smoke suites Serialize the remaining smoke-sensitive suites that mutate process env, CLAUDE_CONFIG_DIR, fetch, or SDK session globals. Add shared lock coverage to discovery, agent/skills loading, platform storage, and SDK lifecycle/preserved-segment tests. Restore session and cwd state inside the lock boundary so parallel files cannot leak bootstrap state into knowledge graph and SDK isolation tests. Validated with repeated smoke and full-suite passes: - bun run smoke (2x) - bun test - bun test --max-concurrency=1 - bun run test:provider - python -m pytest -q python/tests - npm run test:provider-recommendation
This commit is contained in:
@@ -0,0 +1 @@
|
||||
1.3.13
|
||||
@@ -15,22 +15,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version-file: .bun-version
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@0a5c61591373683505ea898e09a3ea4f39ef2b9c # v5.0.0
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: "pip"
|
||||
@@ -64,17 +64,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@4bc047ad259df6fc24a6c9b0f9a0cb08cf17fbe5 # v2.0.1
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version-file: .bun-version
|
||||
|
||||
- name: Install web dependencies
|
||||
run: bun install --cwd web --frozen-lockfile
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
steps:
|
||||
- name: Run release-please
|
||||
id: release
|
||||
uses: googleapis/release-please-action@16a9c90856f42705d54a6fda1823352bdc62cf38
|
||||
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
release-type: node
|
||||
@@ -40,21 +40,21 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
with:
|
||||
node-version: 24
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Set up Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
|
||||
with:
|
||||
bun-version: 1.3.11
|
||||
bun-version-file: .bun-version
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
@@ -98,15 +98,15 @@ jobs:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ needs.release-please.outputs.tag_name }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
|
||||
|
||||
- name: Log in to GitHub Container Registry
|
||||
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
|
||||
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
|
||||
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
|
||||
with:
|
||||
images: ghcr.io/${{ github.repository }}
|
||||
tags: |
|
||||
@@ -123,7 +123,7 @@ jobs:
|
||||
type=raw,value=latest
|
||||
|
||||
- name: Build and load locally
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
load: true
|
||||
@@ -134,7 +134,7 @@ jobs:
|
||||
run: docker run --rm openclaude:smoke --version
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@14487ce63c7a62a4a324b0bfb37086795e31c6c1 # v6.16.0
|
||||
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ This downloads ~200–400MB. Wait for it to complete.
|
||||
proot-distro login ubuntu
|
||||
curl -fsSL https://bun.sh/install | bash
|
||||
source ~/.bashrc
|
||||
bun --version # should show 1.3.11+
|
||||
bun --version # should show 1.3.13+
|
||||
```
|
||||
|
||||
### Step 6 — Build OpenClaude
|
||||
|
||||
+7
-4
@@ -1,13 +1,16 @@
|
||||
# ---- build stage ----
|
||||
FROM node:22-slim AS build
|
||||
|
||||
# Install Bun
|
||||
RUN npm install -g bun@1.3.11
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy dependency manifests first for better layer caching
|
||||
COPY package.json bun.lock ./
|
||||
COPY package.json bun.lock .bun-version ./
|
||||
|
||||
# Install the Bun version tracked by the repo
|
||||
RUN set -eu; \
|
||||
BUN_VERSION="$(tr -d '\r\n' < .bun-version)"; \
|
||||
printf '%s' "$BUN_VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'; \
|
||||
npm install -g "bun@$BUN_VERSION"
|
||||
|
||||
# Install all dependencies (including devDependencies for build)
|
||||
RUN bun install --frozen-lockfile
|
||||
|
||||
@@ -12,7 +12,7 @@ npm install -g @gitlawb/openclaude
|
||||
|
||||
### Option B: From source with Bun
|
||||
|
||||
Use Bun `1.3.11` or newer for source builds on Windows. Older Bun versions can fail during `bun run build`.
|
||||
Use Bun `1.3.13` or newer for source builds on Windows. Older Bun versions can fail during `bun run build`.
|
||||
|
||||
```bash
|
||||
git clone https://github.com/Gitlawb/openclaude.git
|
||||
|
||||
@@ -17,6 +17,10 @@ import {
|
||||
setFsImplementation,
|
||||
setOriginalFsImplementation,
|
||||
} from '../utils/fsOperations.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
@@ -31,6 +35,7 @@ function createModel(id: string): ModelCatalogEntry {
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('discoveryCache.test.ts')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-discovery-cache-test-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir
|
||||
setOriginalFsImplementation()
|
||||
@@ -38,13 +43,17 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
setOriginalFsImplementation()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
try {
|
||||
setOriginalFsImplementation()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('parseDurationString', () => {
|
||||
|
||||
@@ -3,6 +3,10 @@ import { mkdtempSync, rmSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { registerGateway } from './index.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalEnv = {
|
||||
@@ -58,7 +62,8 @@ function clearProviderEnv(): void {
|
||||
delete process.env.CLAUDE_CODE_USE_FOUNDRY
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('discoveryService.test.ts')
|
||||
mock.restore()
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-discovery-service-test-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir
|
||||
@@ -68,22 +73,26 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
restoreEnvValue('CLAUDE_CONFIG_DIR')
|
||||
restoreEnvValue('OPENROUTER_API_KEY')
|
||||
restoreEnvValue('OPENAI_BASE_URL')
|
||||
restoreEnvValue('OPENAI_API_BASE')
|
||||
restoreEnvValue('OPENAI_MODEL')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_OPENAI')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_GEMINI')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_MISTRAL')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_GITHUB')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_BEDROCK')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_VERTEX')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_FOUNDRY')
|
||||
restoreEnvValue('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')
|
||||
try {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
restoreEnvValue('CLAUDE_CONFIG_DIR')
|
||||
restoreEnvValue('OPENROUTER_API_KEY')
|
||||
restoreEnvValue('OPENAI_BASE_URL')
|
||||
restoreEnvValue('OPENAI_API_BASE')
|
||||
restoreEnvValue('OPENAI_MODEL')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_OPENAI')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_GEMINI')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_MISTRAL')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_GITHUB')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_BEDROCK')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_VERTEX')
|
||||
restoreEnvValue('CLAUDE_CODE_USE_FOUNDRY')
|
||||
restoreEnvValue('CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC')
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
describe('discoverModelsForRoute', () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
import { getAnthropicClient } from './client.js'
|
||||
|
||||
type FetchType = typeof globalThis.fetch
|
||||
@@ -49,7 +50,8 @@ function restoreEnv(key: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('client.test.ts')
|
||||
;(globalThis as Record<string, unknown>).MACRO = { VERSION: 'test-version' }
|
||||
process.env.CLAUDE_CODE_USE_GEMINI = '1'
|
||||
process.env.GEMINI_API_KEY = 'gemini-test-key'
|
||||
@@ -79,33 +81,40 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
;(globalThis as Record<string, unknown>).MACRO = originalMacro
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalEnv.CLAUDE_CODE_USE_BEDROCK)
|
||||
restoreEnv('CLAUDE_CODE_SKIP_BEDROCK_AUTH', originalEnv.CLAUDE_CODE_SKIP_BEDROCK_AUTH)
|
||||
restoreEnv('CLAUDE_CODE_USE_VERTEX', originalEnv.CLAUDE_CODE_USE_VERTEX)
|
||||
restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalEnv.CLAUDE_CODE_USE_FOUNDRY)
|
||||
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
||||
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
|
||||
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
||||
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
||||
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
|
||||
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('MINIMAX_API_KEY', originalEnv.MINIMAX_API_KEY)
|
||||
restoreEnv('XAI_API_KEY', originalEnv.XAI_API_KEY)
|
||||
restoreEnv('NVIDIA_NIM', originalEnv.NVIDIA_NIM)
|
||||
restoreEnv('ANTHROPIC_API_KEY', originalEnv.ANTHROPIC_API_KEY)
|
||||
restoreEnv('ANTHROPIC_AUTH_TOKEN', originalEnv.ANTHROPIC_AUTH_TOKEN)
|
||||
restoreEnv('ANTHROPIC_BASE_URL', originalEnv.ANTHROPIC_BASE_URL)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
globalThis.fetch = originalFetch
|
||||
try {
|
||||
;(globalThis as Record<string, unknown>).MACRO = originalMacro
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('CLAUDE_CODE_USE_BEDROCK', originalEnv.CLAUDE_CODE_USE_BEDROCK)
|
||||
restoreEnv(
|
||||
'CLAUDE_CODE_SKIP_BEDROCK_AUTH',
|
||||
originalEnv.CLAUDE_CODE_SKIP_BEDROCK_AUTH,
|
||||
)
|
||||
restoreEnv('CLAUDE_CODE_USE_VERTEX', originalEnv.CLAUDE_CODE_USE_VERTEX)
|
||||
restoreEnv('CLAUDE_CODE_USE_FOUNDRY', originalEnv.CLAUDE_CODE_USE_FOUNDRY)
|
||||
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
||||
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
|
||||
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
||||
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
||||
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
|
||||
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('MINIMAX_API_KEY', originalEnv.MINIMAX_API_KEY)
|
||||
restoreEnv('XAI_API_KEY', originalEnv.XAI_API_KEY)
|
||||
restoreEnv('NVIDIA_NIM', originalEnv.NVIDIA_NIM)
|
||||
restoreEnv('ANTHROPIC_API_KEY', originalEnv.ANTHROPIC_API_KEY)
|
||||
restoreEnv('ANTHROPIC_AUTH_TOKEN', originalEnv.ANTHROPIC_AUTH_TOKEN)
|
||||
restoreEnv('ANTHROPIC_BASE_URL', originalEnv.ANTHROPIC_BASE_URL)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
globalThis.fetch = originalFetch
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test('first-party Anthropic requests execute the configured fetch wrapper without runtime symbol errors', async () => {
|
||||
|
||||
+286
-141
@@ -1,166 +1,311 @@
|
||||
import { createServer } from 'node:http'
|
||||
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../../entrypoints/sdk/shared.js'
|
||||
import { CodexOAuthService } from './codexOAuth.js'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalCallbackPort = process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
const originalClientId = process.env.CODEX_OAUTH_CLIENT_ID
|
||||
type CodexOAuthTestSnapshot = {
|
||||
fetch: typeof globalThis.fetch
|
||||
callbackPort: string | undefined
|
||||
callbackHost: string | undefined
|
||||
clientId: string | undefined
|
||||
}
|
||||
|
||||
type FakeResponseCapture = {
|
||||
body: string
|
||||
headers: Record<string, string>
|
||||
statusCode: number | null
|
||||
}
|
||||
|
||||
type FakeServerResponse = {
|
||||
destroyed: boolean
|
||||
headersSent: boolean
|
||||
writableEnded: boolean
|
||||
writeHead: (statusCode: number, headers?: Record<string, string>) => void
|
||||
end: (chunk?: string) => void
|
||||
}
|
||||
|
||||
type FakeAuthCodeListenerInstance = {
|
||||
callbackPath: string
|
||||
capture: FakeResponseCapture | null
|
||||
cancelCalls: Error[]
|
||||
closeCalls: number
|
||||
hasPendingResponse: () => boolean
|
||||
start: (port?: number, host?: string) => Promise<number>
|
||||
waitForAuthorization: (
|
||||
state: string,
|
||||
onReady: () => Promise<void>,
|
||||
) => Promise<string>
|
||||
handleSuccessRedirect: (
|
||||
scopes: string[],
|
||||
customHandler?: (res: FakeServerResponse, scopes: string[]) => void,
|
||||
) => void
|
||||
handleErrorRedirect: (customHandler?: (res: FakeServerResponse) => void) => void
|
||||
cancelPendingAuthorization: (error?: Error) => void
|
||||
close: () => void
|
||||
}
|
||||
|
||||
let activeSnapshot: CodexOAuthTestSnapshot | null = null
|
||||
let fakeListenerInstance: FakeAuthCodeListenerInstance | null = null
|
||||
let nextFakePort = 41000
|
||||
|
||||
function createFakeServerResponse(capture: FakeResponseCapture): FakeServerResponse {
|
||||
return {
|
||||
destroyed: false,
|
||||
headersSent: false,
|
||||
writableEnded: false,
|
||||
writeHead(statusCode: number, headers?: Record<string, string>) {
|
||||
capture.statusCode = statusCode
|
||||
capture.headers = { ...(headers ?? {}) }
|
||||
this.headersSent = true
|
||||
},
|
||||
end(chunk?: string) {
|
||||
if (chunk) {
|
||||
capture.body += chunk
|
||||
}
|
||||
this.writableEnded = true
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function createFakeAuthCodeListener(callbackPath: string): FakeAuthCodeListenerInstance {
|
||||
fakeListenerInstance = null
|
||||
class FakeAuthCodeListener {
|
||||
callbackPath: string
|
||||
capture: FakeResponseCapture | null = null
|
||||
cancelCalls: Error[] = []
|
||||
closeCalls = 0
|
||||
private pending = false
|
||||
private boundPort = 0
|
||||
private boundHost = 'localhost'
|
||||
|
||||
constructor(callbackPath: string = '/callback') {
|
||||
this.callbackPath = callbackPath
|
||||
fakeListenerInstance = this as unknown as FakeAuthCodeListenerInstance
|
||||
}
|
||||
|
||||
hasPendingResponse(): boolean {
|
||||
return this.pending
|
||||
}
|
||||
|
||||
async start(port?: number, host: string = 'localhost'): Promise<number> {
|
||||
this.boundHost = host
|
||||
this.boundPort = port && port > 0 ? port : nextFakePort++
|
||||
return this.boundPort
|
||||
}
|
||||
|
||||
async waitForAuthorization(
|
||||
state: string,
|
||||
onReady: () => Promise<void>,
|
||||
): Promise<string> {
|
||||
this.pending = true
|
||||
this.capture = { body: '', headers: {}, statusCode: null }
|
||||
await onReady()
|
||||
void state
|
||||
return 'auth-code'
|
||||
}
|
||||
|
||||
handleSuccessRedirect(
|
||||
scopes: string[],
|
||||
customHandler?: (res: FakeServerResponse, scopes: string[]) => void,
|
||||
): void {
|
||||
if (!this.pending || !this.capture) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = createFakeServerResponse(this.capture)
|
||||
customHandler?.(res, scopes)
|
||||
if (!res.writableEnded) {
|
||||
res.end()
|
||||
}
|
||||
this.pending = false
|
||||
}
|
||||
|
||||
handleErrorRedirect(
|
||||
customHandler?: (res: FakeServerResponse) => void,
|
||||
): void {
|
||||
if (!this.pending || !this.capture) {
|
||||
return
|
||||
}
|
||||
|
||||
const res = createFakeServerResponse(this.capture)
|
||||
customHandler?.(res)
|
||||
if (!res.writableEnded) {
|
||||
res.end()
|
||||
}
|
||||
this.pending = false
|
||||
}
|
||||
|
||||
cancelPendingAuthorization(
|
||||
error: Error = new Error('OAuth authorization was cancelled.'),
|
||||
): void {
|
||||
this.cancelCalls.push(error)
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closeCalls += 1
|
||||
this.pending = false
|
||||
}
|
||||
}
|
||||
|
||||
return new FakeAuthCodeListener(callbackPath) as FakeAuthCodeListenerInstance
|
||||
}
|
||||
|
||||
async function acquireCodexOAuthTestIsolation(): Promise<CodexOAuthTestSnapshot> {
|
||||
const result = await acquireEnvMutex()
|
||||
expect(result.acquired).toBe(true)
|
||||
|
||||
activeSnapshot = {
|
||||
fetch: globalThis.fetch,
|
||||
callbackPort: process.env.CODEX_OAUTH_CALLBACK_PORT,
|
||||
callbackHost: process.env.CODEX_OAUTH_CALLBACK_HOST,
|
||||
clientId: process.env.CODEX_OAUTH_CLIENT_ID,
|
||||
}
|
||||
|
||||
return activeSnapshot
|
||||
}
|
||||
|
||||
function restoreCodexOAuthTestIsolation(): void {
|
||||
if (!activeSnapshot) {
|
||||
return
|
||||
}
|
||||
|
||||
const snapshot = activeSnapshot
|
||||
activeSnapshot = null
|
||||
fakeListenerInstance = null
|
||||
|
||||
globalThis.fetch = snapshot.fetch
|
||||
|
||||
if (snapshot.callbackPort === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = snapshot.callbackPort
|
||||
}
|
||||
|
||||
if (snapshot.callbackHost === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CALLBACK_HOST
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CALLBACK_HOST = snapshot.callbackHost
|
||||
}
|
||||
|
||||
if (snapshot.clientId === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = snapshot.clientId
|
||||
}
|
||||
|
||||
releaseEnvMutex()
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
globalThis.fetch = originalFetch
|
||||
|
||||
if (originalCallbackPort === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CALLBACK_PORT
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = originalCallbackPort
|
||||
}
|
||||
|
||||
if (originalClientId === undefined) {
|
||||
delete process.env.CODEX_OAUTH_CLIENT_ID
|
||||
} else {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = originalClientId
|
||||
}
|
||||
restoreCodexOAuthTestIsolation()
|
||||
})
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = createServer()
|
||||
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
server.close(() => reject(new Error('Failed to allocate test port.')))
|
||||
return
|
||||
}
|
||||
|
||||
const { port } = address
|
||||
server.close(error => {
|
||||
if (error) {
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve(port)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function buildCallbackRequest(authUrl: string): string {
|
||||
const authorizeUrl = new URL(authUrl)
|
||||
const redirectUri = authorizeUrl.searchParams.get('redirect_uri')
|
||||
const state = authorizeUrl.searchParams.get('state')
|
||||
|
||||
if (!redirectUri || !state) {
|
||||
throw new Error('Codex OAuth test did not receive a valid authorization URL.')
|
||||
}
|
||||
|
||||
const callbackUrl = new URL(redirectUri)
|
||||
callbackUrl.searchParams.set('code', 'auth-code')
|
||||
callbackUrl.searchParams.set('state', state)
|
||||
return callbackUrl.toString()
|
||||
}
|
||||
|
||||
test('serves updated success copy after a successful Codex OAuth flow', async () => {
|
||||
const callbackPort = await getFreePort()
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
await acquireCodexOAuthTestIsolation()
|
||||
|
||||
globalThis.fetch = mock(async (input, init) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('http://localhost:')) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
try {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
globalThis.fetch = mock(async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
},
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService({
|
||||
callbackPort: 0,
|
||||
callbackHost: '127.0.0.1',
|
||||
createAuthCodeListener: createFakeAuthCodeListener,
|
||||
})
|
||||
|
||||
let capturedAuthUrl = ''
|
||||
const tokens = await service.startOAuthFlow(async authUrl => {
|
||||
capturedAuthUrl = authUrl
|
||||
})
|
||||
|
||||
expect(tokens.accessToken).toBe('access-token')
|
||||
expect(tokens.refreshToken).toBe('refresh-token')
|
||||
expect(capturedAuthUrl).toContain('client_id=test-client-id')
|
||||
expect(capturedAuthUrl).toContain(
|
||||
encodeURIComponent('http://127.0.0.1:41000/auth/callback'),
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService()
|
||||
let callbackResponsePromise!: Promise<Response>
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async authUrl => {
|
||||
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
|
||||
})
|
||||
|
||||
const tokens = await flowPromise
|
||||
const callbackResponse = await callbackResponsePromise
|
||||
const html = await callbackResponse.text()
|
||||
|
||||
expect(tokens.accessToken).toBe('access-token')
|
||||
expect(tokens.refreshToken).toBe('refresh-token')
|
||||
expect(html).toContain('You can return to OpenClaude now.')
|
||||
expect(html).toContain(
|
||||
'OpenClaude will finish activating your new Codex OAuth login.',
|
||||
)
|
||||
expect(html).not.toContain('continue automatically')
|
||||
expect(fakeListenerInstance?.capture?.statusCode).toBe(200)
|
||||
expect(fakeListenerInstance?.capture?.body).toContain(
|
||||
'You can return to OpenClaude now.',
|
||||
)
|
||||
expect(fakeListenerInstance?.capture?.body).toContain(
|
||||
'OpenClaude will finish activating your new Codex OAuth login.',
|
||||
)
|
||||
expect(fakeListenerInstance?.capture?.body).not.toContain(
|
||||
'continue automatically',
|
||||
)
|
||||
} finally {
|
||||
restoreCodexOAuthTestIsolation()
|
||||
}
|
||||
})
|
||||
|
||||
test('cancellation during token exchange returns a cancelled page and rejects the flow', async () => {
|
||||
const callbackPort = await getFreePort()
|
||||
process.env.CODEX_OAUTH_CALLBACK_PORT = String(callbackPort)
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
await acquireCodexOAuthTestIsolation()
|
||||
|
||||
let resolveFetchStart!: () => void
|
||||
const fetchStarted = new Promise<void>(resolve => {
|
||||
resolveFetchStart = resolve
|
||||
})
|
||||
try {
|
||||
process.env.CODEX_OAUTH_CLIENT_ID = 'test-client-id'
|
||||
|
||||
globalThis.fetch = mock((input, init) => {
|
||||
const url = String(input)
|
||||
if (url.startsWith('http://localhost:')) {
|
||||
return originalFetch(input, init)
|
||||
}
|
||||
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
resolveFetchStart()
|
||||
|
||||
const signal = init?.signal
|
||||
if (!signal) {
|
||||
return
|
||||
}
|
||||
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason)
|
||||
return
|
||||
}
|
||||
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
let resolveFetchStart!: () => void
|
||||
const fetchStarted = new Promise<void>(resolve => {
|
||||
resolveFetchStart = resolve
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const service = new CodexOAuthService()
|
||||
let callbackResponsePromise!: Promise<Response>
|
||||
globalThis.fetch = mock((_input, init) => {
|
||||
return new Promise<Response>((_resolve, reject) => {
|
||||
resolveFetchStart()
|
||||
|
||||
const flowPromise = service.startOAuthFlow(async authUrl => {
|
||||
callbackResponsePromise = originalFetch(buildCallbackRequest(authUrl))
|
||||
})
|
||||
const signal = init?.signal
|
||||
if (!signal) {
|
||||
return
|
||||
}
|
||||
|
||||
await fetchStarted
|
||||
service.cleanup()
|
||||
if (signal.aborted) {
|
||||
reject(signal.reason)
|
||||
return
|
||||
}
|
||||
|
||||
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
reject(signal.reason)
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}) as typeof fetch
|
||||
|
||||
const callbackResponse = await callbackResponsePromise
|
||||
const html = await callbackResponse.text()
|
||||
const service = new CodexOAuthService({
|
||||
callbackPort: 0,
|
||||
callbackHost: '127.0.0.1',
|
||||
createAuthCodeListener: createFakeAuthCodeListener,
|
||||
})
|
||||
|
||||
expect(html).toContain('Codex login cancelled')
|
||||
expect(html).toContain('retry in OpenClaude')
|
||||
const flowPromise = service.startOAuthFlow(async () => {})
|
||||
|
||||
await fetchStarted
|
||||
service.cleanup()
|
||||
|
||||
await expect(flowPromise).rejects.toThrow('Codex OAuth flow was cancelled.')
|
||||
expect(fakeListenerInstance?.capture?.statusCode).toBe(200)
|
||||
expect(fakeListenerInstance?.capture?.body).toContain(
|
||||
'Codex login cancelled',
|
||||
)
|
||||
expect(fakeListenerInstance?.capture?.body).toContain(
|
||||
'retry in OpenClaude',
|
||||
)
|
||||
} finally {
|
||||
restoreCodexOAuthTestIsolation()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -10,6 +10,8 @@ import {
|
||||
CODEX_OAUTH_ORIGINATOR,
|
||||
CODEX_OAUTH_SCOPE,
|
||||
escapeHtml,
|
||||
getCodexOAuthCallbackHost,
|
||||
getCodexOAuthCallbackOrigin,
|
||||
exchangeCodexIdTokenForApiKey,
|
||||
getCodexOAuthCallbackPort,
|
||||
getCodexOAuthClientId,
|
||||
@@ -33,10 +35,13 @@ export type CodexOAuthTokens = {
|
||||
|
||||
function buildCodexAuthorizeUrl(options: {
|
||||
port: number
|
||||
host: string
|
||||
codeChallenge: string
|
||||
state: string
|
||||
}): string {
|
||||
const redirectUri = `http://localhost:${options.port}/auth/callback`
|
||||
const redirectUri = `${getCodexOAuthCallbackOrigin(options.port, {
|
||||
CODEX_OAUTH_CALLBACK_HOST: options.host,
|
||||
} as NodeJS.ProcessEnv)}/auth/callback`
|
||||
const authUrl = new URL(`${CODEX_OAUTH_ISSUER}/oauth/authorize`)
|
||||
|
||||
authUrl.searchParams.append('response_type', 'code')
|
||||
@@ -117,9 +122,12 @@ async function exchangeAuthorizationCode(options: {
|
||||
authorizationCode: string
|
||||
codeVerifier: string
|
||||
port: number
|
||||
host: string
|
||||
signal?: AbortSignal
|
||||
}): Promise<CodexOAuthTokens> {
|
||||
const redirectUri = `http://localhost:${options.port}/auth/callback`
|
||||
const redirectUri = `${getCodexOAuthCallbackOrigin(options.port, {
|
||||
CODEX_OAUTH_CALLBACK_HOST: options.host,
|
||||
} as NodeJS.ProcessEnv)}/auth/callback`
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: options.authorizationCode,
|
||||
@@ -179,11 +187,29 @@ async function exchangeAuthorizationCode(options: {
|
||||
}
|
||||
}
|
||||
|
||||
type CodexOAuthServiceOptions = {
|
||||
callbackPort?: number
|
||||
callbackHost?: string
|
||||
createAuthCodeListener?: (callbackPath: string) => CodexOAuthListener
|
||||
}
|
||||
|
||||
type CodexOAuthListener = Pick<
|
||||
AuthCodeListener,
|
||||
| 'start'
|
||||
| 'hasPendingResponse'
|
||||
| 'waitForAuthorization'
|
||||
| 'handleSuccessRedirect'
|
||||
| 'handleErrorRedirect'
|
||||
| 'cancelPendingAuthorization'
|
||||
>
|
||||
|
||||
export class CodexOAuthService {
|
||||
private authCodeListener: AuthCodeListener | null = null
|
||||
private authCodeListener: CodexOAuthListener | null = null
|
||||
private port: number | null = null
|
||||
private tokenExchangeAbortController: AbortController | null = null
|
||||
|
||||
constructor(private readonly options: CodexOAuthServiceOptions = {}) {}
|
||||
|
||||
private buildCancellationError(): Error {
|
||||
return new Error('Codex OAuth flow was cancelled.')
|
||||
}
|
||||
@@ -192,20 +218,26 @@ export class CodexOAuthService {
|
||||
authURLHandler: (authUrl: string) => Promise<void>,
|
||||
): Promise<CodexOAuthTokens> {
|
||||
const codeVerifier = generateCodeVerifier()
|
||||
const callbackPort = getCodexOAuthCallbackPort()
|
||||
const authCodeListener = new AuthCodeListener('/auth/callback')
|
||||
const callbackPort =
|
||||
this.options.callbackPort ?? getCodexOAuthCallbackPort()
|
||||
const callbackHost =
|
||||
this.options.callbackHost ?? getCodexOAuthCallbackHost()
|
||||
const authCodeListener =
|
||||
this.options.createAuthCodeListener?.('/auth/callback') ??
|
||||
new AuthCodeListener('/auth/callback')
|
||||
|
||||
this.authCodeListener = authCodeListener
|
||||
this.port = null
|
||||
|
||||
try {
|
||||
const port = await authCodeListener.start(callbackPort)
|
||||
const port = await authCodeListener.start(callbackPort, callbackHost)
|
||||
this.port = port
|
||||
|
||||
const state = generateState()
|
||||
const codeChallenge = await generateCodeChallenge(codeVerifier)
|
||||
const authUrl = buildCodexAuthorizeUrl({
|
||||
port,
|
||||
host: callbackHost,
|
||||
codeChallenge,
|
||||
state,
|
||||
})
|
||||
@@ -227,6 +259,7 @@ export class CodexOAuthService {
|
||||
authorizationCode,
|
||||
codeVerifier,
|
||||
port,
|
||||
host: callbackHost,
|
||||
signal: tokenExchangeAbortController.signal,
|
||||
})
|
||||
} finally {
|
||||
@@ -286,7 +319,7 @@ export class CodexOAuthService {
|
||||
message.includes(String(callbackPort))
|
||||
) {
|
||||
throw new Error(
|
||||
`Codex OAuth needs localhost:${callbackPort} for its callback. Close any app already using that port and try again.`,
|
||||
`Codex OAuth needs ${callbackHost}:${callbackPort} for its callback. Close any app already using that port and try again.`,
|
||||
)
|
||||
}
|
||||
throw error
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from 'bun:test'
|
||||
|
||||
import {
|
||||
DEFAULT_CODEX_OAUTH_CALLBACK_HOST,
|
||||
DEFAULT_CODEX_OAUTH_CALLBACK_PORT,
|
||||
getCodexOAuthCallbackHost,
|
||||
getCodexOAuthCallbackPort,
|
||||
} from './codexOAuthShared.js'
|
||||
|
||||
test('getCodexOAuthCallbackPort falls back for zero', () => {
|
||||
expect(
|
||||
getCodexOAuthCallbackPort({
|
||||
CODEX_OAUTH_CALLBACK_PORT: '0',
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(DEFAULT_CODEX_OAUTH_CALLBACK_PORT)
|
||||
})
|
||||
|
||||
test('getCodexOAuthCallbackPort falls back for invalid values', () => {
|
||||
expect(
|
||||
getCodexOAuthCallbackPort({
|
||||
CODEX_OAUTH_CALLBACK_PORT: '-1',
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(DEFAULT_CODEX_OAUTH_CALLBACK_PORT)
|
||||
})
|
||||
|
||||
test('getCodexOAuthCallbackHost only accepts loopback hosts', () => {
|
||||
expect(
|
||||
getCodexOAuthCallbackHost({
|
||||
CODEX_OAUTH_CALLBACK_HOST: '127.0.0.1',
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe('127.0.0.1')
|
||||
|
||||
expect(
|
||||
getCodexOAuthCallbackHost({
|
||||
CODEX_OAUTH_CALLBACK_HOST: 'example.com',
|
||||
} as NodeJS.ProcessEnv),
|
||||
).toBe(DEFAULT_CODEX_OAUTH_CALLBACK_HOST)
|
||||
})
|
||||
@@ -4,6 +4,7 @@ export const CODEX_OAUTH_ISSUER = 'https://auth.openai.com'
|
||||
export const CODEX_REFRESH_URL = `${CODEX_OAUTH_ISSUER}/oauth/token`
|
||||
export const DEFAULT_CODEX_OAUTH_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
export const DEFAULT_CODEX_OAUTH_CALLBACK_PORT = 1455
|
||||
export const DEFAULT_CODEX_OAUTH_CALLBACK_HOST = 'localhost'
|
||||
export const CODEX_OAUTH_SCOPE =
|
||||
'openid profile email offline_access api.connectors.read api.connectors.invoke'
|
||||
export const CODEX_OAUTH_ORIGINATOR = 'codex_cli_rs'
|
||||
@@ -12,6 +13,11 @@ export const CODEX_ID_TOKEN_SUBJECT_TYPE =
|
||||
'urn:ietf:params:oauth:token-type:id_token'
|
||||
export const CODEX_TOKEN_EXCHANGE_GRANT =
|
||||
'urn:ietf:params:oauth:grant-type:token-exchange'
|
||||
const ALLOWED_CODEX_OAUTH_CALLBACK_HOSTS = new Set([
|
||||
'localhost',
|
||||
'127.0.0.1',
|
||||
'::1',
|
||||
])
|
||||
|
||||
export function asTrimmedString(value: unknown): string | undefined {
|
||||
if (typeof value !== 'string') return undefined
|
||||
@@ -41,6 +47,26 @@ export function getCodexOAuthCallbackPort(
|
||||
return DEFAULT_CODEX_OAUTH_CALLBACK_PORT
|
||||
}
|
||||
|
||||
export function getCodexOAuthCallbackHost(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const rawHost = asTrimmedString(env.CODEX_OAUTH_CALLBACK_HOST)
|
||||
if (rawHost && ALLOWED_CODEX_OAUTH_CALLBACK_HOSTS.has(rawHost)) {
|
||||
return rawHost
|
||||
}
|
||||
|
||||
return DEFAULT_CODEX_OAUTH_CALLBACK_HOST
|
||||
}
|
||||
|
||||
export function getCodexOAuthCallbackOrigin(
|
||||
port: number,
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): string {
|
||||
const host = getCodexOAuthCallbackHost(env)
|
||||
const formattedHost = host.includes(':') ? `[${host}]` : host
|
||||
return `http://${formattedHost}:${port}`
|
||||
}
|
||||
|
||||
export function decodeJwtPayload(
|
||||
token: string,
|
||||
): Record<string, unknown> | undefined {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
import {
|
||||
codexStreamToAnthropic,
|
||||
convertAnthropicMessagesToResponsesInput,
|
||||
@@ -19,22 +20,30 @@ const originalEnv = {
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('codexShim.test.ts')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL
|
||||
else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
try {
|
||||
if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL
|
||||
else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
|
||||
if (originalEnv.OPENAI_API_BASE === undefined) delete process.env.OPENAI_API_BASE
|
||||
else process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
|
||||
if (originalEnv.OPENAI_API_BASE === undefined) delete process.env.OPENAI_API_BASE
|
||||
else process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
|
||||
|
||||
if (originalEnv.CLAUDE_CODE_USE_GITHUB === undefined) delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
else process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
|
||||
if (originalEnv.CLAUDE_CODE_USE_GITHUB === undefined) delete process.env.CLAUDE_CODE_USE_GITHUB
|
||||
else process.env.CLAUDE_CODE_USE_GITHUB = originalEnv.CLAUDE_CODE_USE_GITHUB
|
||||
|
||||
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
|
||||
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
|
||||
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (dir) rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
import { _resetKeepAliveForTesting } from '../../utils/proxy.js'
|
||||
import {
|
||||
@@ -22,17 +23,22 @@ function restoreEnv(key: 'HTTP_PROXY' | 'HTTPS_PROXY', value: string | undefined
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('fetchWithProxyRetry.test.ts')
|
||||
process.env.HTTP_PROXY = 'http://127.0.0.1:15236'
|
||||
delete process.env.HTTPS_PROXY
|
||||
_resetKeepAliveForTesting()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY)
|
||||
restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY)
|
||||
_resetKeepAliveForTesting()
|
||||
try {
|
||||
globalThis.fetch = originalFetch
|
||||
restoreEnv('HTTP_PROXY', originalEnv.HTTP_PROXY)
|
||||
restoreEnv('HTTPS_PROXY', originalEnv.HTTPS_PROXY)
|
||||
_resetKeepAliveForTesting()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test('isRetryableFetchError matches Bun socket-closed failures', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { resolve } from 'node:path'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
import {
|
||||
buildMiniMaxUsageRows,
|
||||
@@ -10,6 +11,14 @@ import {
|
||||
const fixture = (name: string) =>
|
||||
Bun.file(resolve(import.meta.dir, '__fixtures__', name))
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('minimaxUsage.test.ts')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
releaseSharedMutationLock()
|
||||
})
|
||||
|
||||
describe('normalizeMiniMaxUsagePayload', () => {
|
||||
test('normalizes interval and weekly quota payloads', () => {
|
||||
const usage = normalizeMiniMaxUsagePayload({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
import { createOpenAIShimClient } from './openaiShim.js'
|
||||
|
||||
type FetchType = typeof globalThis.fetch
|
||||
@@ -95,7 +96,8 @@ function makeFakeResponse(): Response {
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('openaiShim.compression.test.ts')
|
||||
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
||||
process.env.OPENAI_API_KEY = 'test-key'
|
||||
delete process.env.OPENAI_MODEL
|
||||
@@ -104,13 +106,17 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL
|
||||
else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
if (originalEnv.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY
|
||||
else process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
|
||||
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
|
||||
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
globalThis.fetch = originalFetch
|
||||
try {
|
||||
if (originalEnv.OPENAI_BASE_URL === undefined) delete process.env.OPENAI_BASE_URL
|
||||
else process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
if (originalEnv.OPENAI_API_KEY === undefined) delete process.env.OPENAI_API_KEY
|
||||
else process.env.OPENAI_API_KEY = originalEnv.OPENAI_API_KEY
|
||||
if (originalEnv.OPENAI_MODEL === undefined) delete process.env.OPENAI_MODEL
|
||||
else process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
globalThis.fetch = originalFetch
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
async function captureRequestBody(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalEnv = {
|
||||
@@ -15,12 +16,20 @@ function restoreEnv(key: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('openaiShim.diagnostics.test.ts')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
mock.restore()
|
||||
try {
|
||||
globalThis.fetch = originalFetch
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
mock.restore()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test('logs classified transport diagnostics with category and code', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
import { registerGateway } from '../../integrations/index.ts'
|
||||
import { createOpenAIShimClient } from './openaiShim.ts'
|
||||
|
||||
@@ -85,7 +86,8 @@ function makeStreamChunks(chunks: unknown[]): string[] {
|
||||
]
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('openaiShim.test.ts')
|
||||
process.env.OPENAI_BASE_URL = 'http://example.test/v1'
|
||||
process.env.OPENAI_API_KEY = 'test-key'
|
||||
delete process.env.OPENAI_MODEL
|
||||
@@ -118,36 +120,40 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER)
|
||||
restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME)
|
||||
restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE)
|
||||
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
||||
restoreEnv('GITHUB_TOKEN', originalEnv.GITHUB_TOKEN)
|
||||
restoreEnv('GH_TOKEN', originalEnv.GH_TOKEN)
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
|
||||
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
|
||||
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
|
||||
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
|
||||
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
||||
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
||||
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
restoreEnv('NVIDIA_API_KEY', originalEnv.NVIDIA_API_KEY)
|
||||
restoreEnv('NVIDIA_NIM', originalEnv.NVIDIA_NIM)
|
||||
restoreEnv('MINIMAX_API_KEY', originalEnv.MINIMAX_API_KEY)
|
||||
restoreEnv('BNKR_API_KEY', originalEnv.BNKR_API_KEY)
|
||||
restoreEnv('BANKR_BASE_URL', originalEnv.BANKR_BASE_URL)
|
||||
restoreEnv('BANKR_MODEL', originalEnv.BANKR_MODEL)
|
||||
restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY)
|
||||
restoreEnv('DEEPSEEK_API_KEY', originalEnv.DEEPSEEK_API_KEY)
|
||||
restoreEnv('MIMO_API_KEY', originalEnv.MIMO_API_KEY)
|
||||
globalThis.fetch = originalFetch
|
||||
try {
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER)
|
||||
restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME)
|
||||
restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE)
|
||||
restoreEnv('CLAUDE_CODE_USE_GITHUB', originalEnv.CLAUDE_CODE_USE_GITHUB)
|
||||
restoreEnv('GITHUB_TOKEN', originalEnv.GITHUB_TOKEN)
|
||||
restoreEnv('GH_TOKEN', originalEnv.GH_TOKEN)
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
restoreEnv('GEMINI_API_KEY', originalEnv.GEMINI_API_KEY)
|
||||
restoreEnv('GOOGLE_API_KEY', originalEnv.GOOGLE_API_KEY)
|
||||
restoreEnv('GEMINI_ACCESS_TOKEN', originalEnv.GEMINI_ACCESS_TOKEN)
|
||||
restoreEnv('GEMINI_AUTH_MODE', originalEnv.GEMINI_AUTH_MODE)
|
||||
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
||||
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
||||
restoreEnv('GOOGLE_CLOUD_PROJECT', originalEnv.GOOGLE_CLOUD_PROJECT)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
restoreEnv('NVIDIA_API_KEY', originalEnv.NVIDIA_API_KEY)
|
||||
restoreEnv('NVIDIA_NIM', originalEnv.NVIDIA_NIM)
|
||||
restoreEnv('MINIMAX_API_KEY', originalEnv.MINIMAX_API_KEY)
|
||||
restoreEnv('BNKR_API_KEY', originalEnv.BNKR_API_KEY)
|
||||
restoreEnv('BANKR_BASE_URL', originalEnv.BANKR_BASE_URL)
|
||||
restoreEnv('BANKR_MODEL', originalEnv.BANKR_MODEL)
|
||||
restoreEnv('OPENROUTER_API_KEY', originalEnv.OPENROUTER_API_KEY)
|
||||
restoreEnv('DEEPSEEK_API_KEY', originalEnv.DEEPSEEK_API_KEY)
|
||||
restoreEnv('MIMO_API_KEY', originalEnv.MIMO_API_KEY)
|
||||
globalThis.fetch = originalFetch
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test('strips canonical Anthropic headers from direct shim defaultHeaders', async () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import * as realOs from 'node:os'
|
||||
import * as realCodexCredentials from '../../utils/codexCredentials.js'
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../../entrypoints/sdk/shared.js'
|
||||
|
||||
function makeJwt(payload: Record<string, unknown>): string {
|
||||
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' }))
|
||||
@@ -13,11 +15,18 @@ function makeJwt(payload: Record<string, unknown>): string {
|
||||
|
||||
describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
afterEach(() => {
|
||||
mock.restore()
|
||||
try {
|
||||
mock.restore()
|
||||
mock.module('../../utils/codexCredentials.js', () => realCodexCredentials)
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
})
|
||||
|
||||
test('loads Codex credentials from OpenClaude secure storage', async () => {
|
||||
await acquireEnvMutex()
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => ({
|
||||
apiKey: 'codex-api-key-token',
|
||||
@@ -38,7 +47,9 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
})
|
||||
|
||||
test('prefers explicit env credentials over secure storage', async () => {
|
||||
await acquireEnvMutex()
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => ({
|
||||
accessToken: 'stored-token',
|
||||
@@ -62,7 +73,9 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
})
|
||||
|
||||
test('parses nested chatgpt_account_id from a CODEX_API_KEY JWT', async () => {
|
||||
await acquireEnvMutex()
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => undefined,
|
||||
}))
|
||||
@@ -85,7 +98,9 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
})
|
||||
|
||||
test('parses nested chatgpt_account_id from auth.json tokens', async () => {
|
||||
await acquireEnvMutex()
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => undefined,
|
||||
}))
|
||||
@@ -123,7 +138,9 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
})
|
||||
|
||||
test('does not read default auth.json when secure storage already has Codex credentials', async () => {
|
||||
await acquireEnvMutex()
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => false,
|
||||
readCodexCredentials: () => ({
|
||||
apiKey: 'codex-api-key-token',
|
||||
@@ -144,6 +161,7 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
})
|
||||
|
||||
test('falls back to the default auth.json when stored Codex refresh is cooling down', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
|
||||
const authJson = JSON.stringify({
|
||||
openai_api_key: makeJwt({
|
||||
@@ -161,6 +179,7 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
}))
|
||||
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => true,
|
||||
readCodexCredentials: () => ({
|
||||
accessToken: 'stored-token',
|
||||
@@ -186,6 +205,7 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
})
|
||||
|
||||
test('preserves the stored account id when auth.json fallback lacks one', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHomeDir = mkdtempSync(join(tmpdir(), 'openclaude-codex-home-'))
|
||||
const authJson = JSON.stringify({
|
||||
openai_api_key: 'auth-json-access-token',
|
||||
@@ -199,6 +219,7 @@ describe('resolveCodexApiCredentials with secure storage', () => {
|
||||
}))
|
||||
|
||||
mock.module('../../utils/codexCredentials.js', () => ({
|
||||
...realCodexCredentials,
|
||||
isCodexRefreshFailureCoolingDown: () => true,
|
||||
readCodexCredentials: () => ({
|
||||
accessToken: 'stored-token',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, mock, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
@@ -21,18 +22,26 @@ function restoreEnv(key: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('providerConfig.envDiagnostics.test.ts')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
|
||||
restoreEnv('MISTRAL_BASE_URL', originalEnv.MISTRAL_BASE_URL)
|
||||
restoreEnv('MISTRAL_MODEL', originalEnv.MISTRAL_MODEL)
|
||||
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
||||
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
||||
mock.restore()
|
||||
try {
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('CLAUDE_CODE_USE_GEMINI', originalEnv.CLAUDE_CODE_USE_GEMINI)
|
||||
restoreEnv('CLAUDE_CODE_USE_MISTRAL', originalEnv.CLAUDE_CODE_USE_MISTRAL)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_BASE', originalEnv.OPENAI_API_BASE)
|
||||
restoreEnv('MISTRAL_BASE_URL', originalEnv.MISTRAL_BASE_URL)
|
||||
restoreEnv('MISTRAL_MODEL', originalEnv.MISTRAL_MODEL)
|
||||
restoreEnv('GEMINI_BASE_URL', originalEnv.GEMINI_BASE_URL)
|
||||
restoreEnv('GEMINI_MODEL', originalEnv.GEMINI_MODEL)
|
||||
mock.restore()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test('logs a warning when OPENAI_BASE_URL is literal undefined', async () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
import {
|
||||
DEFAULT_GITHUB_MODELS_API_MODEL,
|
||||
@@ -17,7 +18,8 @@ const ENV_KEYS = [
|
||||
|
||||
const originalEnv: Record<string, string | undefined> = {}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('providerConfig.github.test.ts')
|
||||
for (const key of ENV_KEYS) {
|
||||
originalEnv[key] = process.env[key]
|
||||
delete process.env[key]
|
||||
@@ -25,12 +27,16 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
try {
|
||||
for (const key of ENV_KEYS) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { afterEach, expect, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
import {
|
||||
getAdditionalModelOptionsCacheScope,
|
||||
@@ -28,16 +29,24 @@ function restoreEnv(key: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('providerConfig.local.test.ts')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER)
|
||||
restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME)
|
||||
restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
try {
|
||||
restoreEnv('CLAUDE_CODE_USE_OPENAI', originalEnv.CLAUDE_CODE_USE_OPENAI)
|
||||
restoreEnv('OPENAI_BASE_URL', originalEnv.OPENAI_BASE_URL)
|
||||
restoreEnv('OPENAI_API_KEY', originalEnv.OPENAI_API_KEY)
|
||||
restoreEnv('OPENAI_AUTH_HEADER', originalEnv.OPENAI_AUTH_HEADER)
|
||||
restoreEnv('OPENAI_AUTH_SCHEME', originalEnv.OPENAI_AUTH_SCHEME)
|
||||
restoreEnv('OPENAI_AUTH_HEADER_VALUE', originalEnv.OPENAI_AUTH_HEADER_VALUE)
|
||||
restoreEnv('ANTHROPIC_CUSTOM_HEADERS', originalEnv.ANTHROPIC_CUSTOM_HEADERS)
|
||||
restoreEnv('OPENAI_MODEL', originalEnv.OPENAI_MODEL)
|
||||
restoreEnv('OPENAI_API_FORMAT', originalEnv.OPENAI_API_FORMAT)
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
test('treats localhost endpoints as local', () => {
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
import { getLocalFastPathConfig } from './providerConfig.js'
|
||||
|
||||
const ENV_VAR = 'OPENCLAUDE_LOCAL_FAST_PATH'
|
||||
const originalEnv = process.env[ENV_VAR]
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('providerConfig.localFastPath.test.ts')
|
||||
delete process.env[ENV_VAR]
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env[ENV_VAR]
|
||||
} else {
|
||||
process.env[ENV_VAR] = originalEnv
|
||||
try {
|
||||
if (originalEnv === undefined) {
|
||||
delete process.env[ENV_VAR]
|
||||
} else {
|
||||
process.env[ENV_VAR] = originalEnv
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
import { APIError } from '@anthropic-ai/sdk'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../../test/sharedMutationLock.js'
|
||||
|
||||
// Helper to build a mock APIError with specific headers
|
||||
function makeError(headers: Record<string, string>): APIError {
|
||||
@@ -28,18 +29,23 @@ const envKeys = [
|
||||
'OPENAI_API_BASE',
|
||||
] as const
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('withRetry.test.ts')
|
||||
for (const key of envKeys) {
|
||||
delete process.env[key]
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of envKeys) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = originalEnv[key]
|
||||
try {
|
||||
for (const key of envKeys) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = originalEnv[key]
|
||||
}
|
||||
mock.restore()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
mock.restore()
|
||||
})
|
||||
|
||||
async function importFreshWithRetryModule(
|
||||
|
||||
@@ -14,8 +14,6 @@ test('cancelPendingAuthorization rejects a pending OAuth wait', async () => {
|
||||
const listener = new AuthCodeListener('/callback')
|
||||
listeners.push(listener)
|
||||
|
||||
await listener.start()
|
||||
|
||||
const pendingAuthorization = listener.waitForAuthorization(
|
||||
'state-test',
|
||||
async () => {},
|
||||
|
||||
@@ -33,8 +33,9 @@ export class AuthCodeListener {
|
||||
* Starts listening on an OS-assigned port and returns the port number.
|
||||
* This avoids race conditions by keeping the server open until it's used.
|
||||
* @param port Optional specific port to use. If not provided, uses OS-assigned port.
|
||||
* @param host Optional loopback host to bind. Defaults to localhost.
|
||||
*/
|
||||
async start(port?: number): Promise<number> {
|
||||
async start(port?: number, host: string = 'localhost'): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.localServer.once('error', err => {
|
||||
reject(
|
||||
@@ -43,7 +44,7 @@ export class AuthCodeListener {
|
||||
})
|
||||
|
||||
// Listen on specified port or 0 to let the OS assign an available port
|
||||
this.localServer.listen(port ?? 0, 'localhost', () => {
|
||||
this.localServer.listen(port ?? 0, host, () => {
|
||||
const address = this.localServer.address() as AddressInfo
|
||||
this.port = address.port
|
||||
resolve(this.port)
|
||||
|
||||
@@ -5,6 +5,10 @@ import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { getSkillDirCommands, clearSkillCaches } from './loadSkillsDir.ts'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
function writeSkill(rootDir: string, skillPath: string): void {
|
||||
const skillDir = join(rootDir, '.claude', 'skills', ...skillPath.split('/'))
|
||||
@@ -17,6 +21,7 @@ function writeSkill(rootDir: string, skillPath: string): void {
|
||||
}
|
||||
|
||||
test('loads flat and nested skills with colon namespaces', async () => {
|
||||
await acquireSharedMutationLock('loadSkillsDir.test.ts')
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-skills-'))
|
||||
const cwd = join(configDir, 'workspace')
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
@@ -53,12 +58,16 @@ test('loads flat and nested skills with colon namespaces', async () => {
|
||||
join(configDir, '.claude', 'skills', 'frontend', 'react', 'form'),
|
||||
)
|
||||
} finally {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
try {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
clearSkillCaches()
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
clearSkillCaches()
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
|
||||
|
||||
export async function acquireSharedMutationLock(
|
||||
scope: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
const result =
|
||||
timeoutMs === undefined
|
||||
? await acquireEnvMutex()
|
||||
: await acquireEnvMutex({ timeoutMs })
|
||||
|
||||
if (!result.acquired) {
|
||||
throw new Error(`Timed out acquiring shared test mutation lock for ${scope}`)
|
||||
}
|
||||
}
|
||||
|
||||
export function releaseSharedMutationLock(): void {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
getAgentDefinitionsWithOverrides,
|
||||
} from './loadAgentsDir.js'
|
||||
import { loadMarkdownFilesForSubdir } from '../../utils/markdownConfigLoader.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../test/sharedMutationLock.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
@@ -18,6 +22,7 @@ const originalEnv = {
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('loadAgentsDir.test.ts')
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'openclaude-agents-test-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = join(tempDir, '.openclaude')
|
||||
process.env.CLAUDE_CODE_USE_NATIVE_FILE_SEARCH = '1'
|
||||
@@ -27,12 +32,16 @@ beforeEach(async () => {
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
restoreEnv('CLAUDE_CONFIG_DIR')
|
||||
restoreEnv('CLAUDE_CODE_SIMPLE')
|
||||
restoreEnv('CLAUDE_CODE_USE_NATIVE_FILE_SEARCH')
|
||||
clearAgentDefinitionsCache()
|
||||
loadMarkdownFilesForSubdir.cache.clear?.()
|
||||
try {
|
||||
await rm(tempDir, { recursive: true, force: true })
|
||||
restoreEnv('CLAUDE_CONFIG_DIR')
|
||||
restoreEnv('CLAUDE_CODE_SIMPLE')
|
||||
restoreEnv('CLAUDE_CODE_USE_NATIVE_FILE_SEARCH')
|
||||
clearAgentDefinitionsCache()
|
||||
loadMarkdownFilesForSubdir.cache.clear?.()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
function restoreEnv(key: keyof typeof originalEnv): void {
|
||||
|
||||
@@ -227,9 +227,15 @@ describe('built-in preset request shapes', () => {
|
||||
]
|
||||
const savedEnv: Record<string, string | undefined> = {}
|
||||
const originalFetch = globalThis.fetch
|
||||
const originalConsoleWarn = console.warn
|
||||
let capturedWarnings: unknown[][] = []
|
||||
|
||||
beforeEach(() => {
|
||||
for (const k of PRESET_ENV_KEYS) savedEnv[k] = process.env[k]
|
||||
capturedWarnings = []
|
||||
console.warn = (...args: unknown[]) => {
|
||||
capturedWarnings.push(args)
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -238,6 +244,7 @@ describe('built-in preset request shapes', () => {
|
||||
else process.env[k] = v
|
||||
}
|
||||
globalThis.fetch = originalFetch
|
||||
console.warn = originalConsoleWarn
|
||||
})
|
||||
|
||||
test('google preset sends ?key= and ?cx= as query params, no auth header', async () => {
|
||||
@@ -262,6 +269,11 @@ describe('built-in preset request shapes', () => {
|
||||
expect(capturedUrl).toContain('cx=cse-test-id')
|
||||
expect(capturedUrl).toContain('q=hello+world')
|
||||
expect(capturedHeaders.Authorization).toBeUndefined()
|
||||
expect(
|
||||
capturedWarnings.some(call =>
|
||||
String(call[0]).includes('Custom search provider is active'),
|
||||
),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('google preset throws clear error when GOOGLE_CSE_ID is missing', async () => {
|
||||
|
||||
+32
-14
@@ -835,13 +835,29 @@ export function isPathTrusted(dir: string): boolean {
|
||||
}
|
||||
|
||||
// We have to put this test code here because Jest doesn't support mocking ES modules :O
|
||||
const TEST_GLOBAL_CONFIG_FOR_TESTING: GlobalConfig = {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
autoUpdates: false,
|
||||
knowledgeGraphEnabled: true,
|
||||
// Use function accessors backed by `var` so cyclic test-only module graphs
|
||||
// never trip TDZ while config.ts is still evaluating.
|
||||
var testGlobalConfigForTesting: GlobalConfig | undefined
|
||||
var testProjectConfigForTesting: ProjectConfig | undefined
|
||||
|
||||
function getTestGlobalConfigForTesting(): GlobalConfig {
|
||||
if (!testGlobalConfigForTesting) {
|
||||
testGlobalConfigForTesting = {
|
||||
...DEFAULT_GLOBAL_CONFIG,
|
||||
autoUpdates: false,
|
||||
knowledgeGraphEnabled: true,
|
||||
}
|
||||
}
|
||||
return testGlobalConfigForTesting
|
||||
}
|
||||
const TEST_PROJECT_CONFIG_FOR_TESTING: ProjectConfig = {
|
||||
...DEFAULT_PROJECT_CONFIG,
|
||||
|
||||
function getTestProjectConfigForTesting(): ProjectConfig {
|
||||
if (!testProjectConfigForTesting) {
|
||||
testProjectConfigForTesting = {
|
||||
...DEFAULT_PROJECT_CONFIG,
|
||||
}
|
||||
}
|
||||
return testProjectConfigForTesting
|
||||
}
|
||||
|
||||
export function isProjectConfigKey(key: string): key is ProjectConfigKey {
|
||||
@@ -873,12 +889,13 @@ export function saveGlobalConfig(
|
||||
updater: (currentConfig: GlobalConfig) => GlobalConfig,
|
||||
): void {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const config = updater(TEST_GLOBAL_CONFIG_FOR_TESTING)
|
||||
const current = getTestGlobalConfigForTesting()
|
||||
const config = updater(current)
|
||||
// Skip if no changes (same reference returned)
|
||||
if (config === TEST_GLOBAL_CONFIG_FOR_TESTING) {
|
||||
if (config === current) {
|
||||
return
|
||||
}
|
||||
Object.assign(TEST_GLOBAL_CONFIG_FOR_TESTING, config)
|
||||
Object.assign(current, config)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1126,7 +1143,7 @@ function writeThroughGlobalConfigCache(config: GlobalConfig): void {
|
||||
|
||||
export function getGlobalConfig(): GlobalConfig {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return TEST_GLOBAL_CONFIG_FOR_TESTING
|
||||
return getTestGlobalConfigForTesting()
|
||||
}
|
||||
|
||||
// Fast path: pure memory read. After startup, this always hits — our own
|
||||
@@ -1684,7 +1701,7 @@ export const getProjectPathForConfig = memoize((): string => {
|
||||
|
||||
export function getCurrentProjectConfig(): ProjectConfig {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return TEST_PROJECT_CONFIG_FOR_TESTING
|
||||
return getTestProjectConfigForTesting()
|
||||
}
|
||||
|
||||
const absolutePath = getProjectPathForConfig()
|
||||
@@ -1709,12 +1726,13 @@ export function saveCurrentProjectConfig(
|
||||
updater: (currentConfig: ProjectConfig) => ProjectConfig,
|
||||
): void {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const config = updater(TEST_PROJECT_CONFIG_FOR_TESTING)
|
||||
const current = getTestProjectConfigForTesting()
|
||||
const config = updater(current)
|
||||
// Skip if no changes (same reference returned)
|
||||
if (config === TEST_PROJECT_CONFIG_FOR_TESTING) {
|
||||
if (config === current) {
|
||||
return
|
||||
}
|
||||
Object.assign(TEST_PROJECT_CONFIG_FOR_TESTING, config)
|
||||
Object.assign(current, config)
|
||||
return
|
||||
}
|
||||
const absolutePath = getProjectPathForConfig()
|
||||
|
||||
+54
-48
@@ -1,4 +1,5 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { acquireSharedMutationLock, releaseSharedMutationLock } from '../test/sharedMutationLock.js'
|
||||
|
||||
import { getMaxOutputTokensForModel } from '../services/api/claude.ts'
|
||||
import {
|
||||
@@ -20,7 +21,8 @@ const originalEnv = {
|
||||
XAI_API_KEY: process.env.XAI_API_KEY,
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('context.test.ts')
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
|
||||
@@ -33,53 +35,57 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnv.CLAUDE_CODE_USE_OPENAI === undefined) {
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS === undefined) {
|
||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS =
|
||||
originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS === undefined) {
|
||||
delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS =
|
||||
originalEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS === undefined) {
|
||||
delete process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS =
|
||||
originalEnv.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
if (originalEnv.OPENAI_MODEL === undefined) {
|
||||
delete process.env.OPENAI_MODEL
|
||||
} else {
|
||||
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
}
|
||||
if (originalEnv.OPENAI_BASE_URL === undefined) {
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
} else {
|
||||
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
}
|
||||
if (originalEnv.OPENAI_API_BASE === undefined) {
|
||||
delete process.env.OPENAI_API_BASE
|
||||
} else {
|
||||
process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
|
||||
}
|
||||
if (originalEnv.MINIMAX_API_KEY === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = originalEnv.MINIMAX_API_KEY
|
||||
}
|
||||
if (originalEnv.XAI_API_KEY === undefined) {
|
||||
delete process.env.XAI_API_KEY
|
||||
} else {
|
||||
process.env.XAI_API_KEY = originalEnv.XAI_API_KEY
|
||||
try {
|
||||
if (originalEnv.CLAUDE_CODE_USE_OPENAI === undefined) {
|
||||
delete process.env.CLAUDE_CODE_USE_OPENAI
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = originalEnv.CLAUDE_CODE_USE_OPENAI
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS === undefined) {
|
||||
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS =
|
||||
originalEnv.CLAUDE_CODE_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS === undefined) {
|
||||
delete process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS =
|
||||
originalEnv.CLAUDE_CODE_OPENAI_CONTEXT_WINDOWS
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS === undefined) {
|
||||
delete process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS =
|
||||
originalEnv.CLAUDE_CODE_OPENAI_MAX_OUTPUT_TOKENS
|
||||
}
|
||||
if (originalEnv.OPENAI_MODEL === undefined) {
|
||||
delete process.env.OPENAI_MODEL
|
||||
} else {
|
||||
process.env.OPENAI_MODEL = originalEnv.OPENAI_MODEL
|
||||
}
|
||||
if (originalEnv.OPENAI_BASE_URL === undefined) {
|
||||
delete process.env.OPENAI_BASE_URL
|
||||
} else {
|
||||
process.env.OPENAI_BASE_URL = originalEnv.OPENAI_BASE_URL
|
||||
}
|
||||
if (originalEnv.OPENAI_API_BASE === undefined) {
|
||||
delete process.env.OPENAI_API_BASE
|
||||
} else {
|
||||
process.env.OPENAI_API_BASE = originalEnv.OPENAI_API_BASE
|
||||
}
|
||||
if (originalEnv.MINIMAX_API_KEY === undefined) {
|
||||
delete process.env.MINIMAX_API_KEY
|
||||
} else {
|
||||
process.env.MINIMAX_API_KEY = originalEnv.MINIMAX_API_KEY
|
||||
}
|
||||
if (originalEnv.XAI_API_KEY === undefined) {
|
||||
delete process.env.XAI_API_KEY
|
||||
} else {
|
||||
process.env.XAI_API_KEY = originalEnv.XAI_API_KEY
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { describe, expect, it, beforeEach } from 'bun:test'
|
||||
import { describe, expect, it, beforeEach, afterEach } from 'bun:test'
|
||||
import {
|
||||
initializeArc,
|
||||
updateArcPhase,
|
||||
getArcSummary,
|
||||
resetArc
|
||||
} from './conversationArc.js'
|
||||
import { getGlobalGraph, clearMemoryOnly, resetGlobalGraph } from './knowledgeGraph.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
function createMessage(content: string): any {
|
||||
return {
|
||||
@@ -13,45 +18,58 @@ function createMessage(content: string): any {
|
||||
}
|
||||
}
|
||||
|
||||
describe('Conversation Arc Performance Benchmarks', () => {
|
||||
beforeEach(() => {
|
||||
describe('Conversation Arc Scale and Stability', () => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('conversationArc.perf')
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
resetArc()
|
||||
initializeArc()
|
||||
})
|
||||
|
||||
it('performs automatic fact extraction in sub-millisecond time', async () => {
|
||||
afterEach(() => {
|
||||
try {
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
resetArc()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
it('extracts the expected facts repeatedly without unbounded graph growth', async () => {
|
||||
const iterations = 100
|
||||
const complexContent =
|
||||
'Deploying version v1.2.3 to /opt/prod/server on https://api.prod.local with JIRA_URL=https://jira.corp'
|
||||
|
||||
const startTime = performance.now()
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
await updateArcPhase([createMessage(complexContent)])
|
||||
}
|
||||
const duration = performance.now() - startTime
|
||||
const averageTime = duration / iterations
|
||||
|
||||
console.log(`[Benchmark] Avg extraction time: ${averageTime.toFixed(4)}ms`)
|
||||
const graph = getGlobalGraph()
|
||||
const entityPairs = Object.values(graph.entities).map(entity => [
|
||||
entity.type,
|
||||
entity.name,
|
||||
])
|
||||
|
||||
// Performance guard: should definitely be under 5.0ms per message on any modern CI
|
||||
// (Async overhead and Orama checks add some cost)
|
||||
expect(averageTime).toBeLessThan(5.0)
|
||||
expect(entityPairs).toContainEqual(['environment_variable', 'JIRA_URL'])
|
||||
expect(entityPairs).toContainEqual(['path', '/opt/prod/server'])
|
||||
expect(entityPairs).toContainEqual(['endpoint', 'api.prod.local'])
|
||||
expect(entityPairs).toContainEqual(['version', 'v1.2.3'])
|
||||
// Repeated extraction should upsert the same facts rather than ballooning.
|
||||
expect(Object.keys(graph.entities).length).toBeLessThanOrEqual(10)
|
||||
})
|
||||
|
||||
it('generates summaries quickly even with a populated graph', async () => {
|
||||
it('generates summaries with a populated graph', async () => {
|
||||
// Populate graph with 50 facts
|
||||
for (let i = 0; i < 50; i++) {
|
||||
await updateArcPhase([createMessage(`Var_${i}=Value_${i} in /path/to/file_${i}`)])
|
||||
}
|
||||
|
||||
const startTime = performance.now()
|
||||
const summary = await getArcSummary()
|
||||
const duration = performance.now() - startTime
|
||||
|
||||
console.log(`[Benchmark] Summary generation time (50 entities): ${duration.toFixed(4)}ms`)
|
||||
expect(summary).toMatch(/Knowledge Graph/)
|
||||
// Summary generation should be fast
|
||||
expect(duration).toBeLessThan(50)
|
||||
expect(summary).toMatch(/project_file|path|environment_variable|concept/i)
|
||||
})
|
||||
|
||||
it('maintains a compact memory footprint', async () => {
|
||||
@@ -62,7 +80,6 @@ describe('Conversation Arc Performance Benchmarks', () => {
|
||||
|
||||
const serialized = JSON.stringify(arc)
|
||||
const sizeKB = serialized.length / 1024
|
||||
console.log(`[Benchmark] Memory footprint (100 facts): ${sizeKB.toFixed(2)}KB`)
|
||||
|
||||
// Should be well under 100KB for 100 simple facts
|
||||
expect(sizeKB).toBeLessThan(100)
|
||||
|
||||
@@ -15,7 +15,11 @@ import {
|
||||
getArcStats,
|
||||
finalizeArcTurn,
|
||||
} from './conversationArc.js'
|
||||
import { getGlobalGraph, resetGlobalGraph } from './knowledgeGraph.js'
|
||||
import { getGlobalGraph, resetGlobalGraph, clearMemoryOnly } from './knowledgeGraph.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
function createMessage(role: string, content: string): any {
|
||||
return {
|
||||
@@ -25,9 +29,21 @@ function createMessage(role: string, content: string): any {
|
||||
}
|
||||
|
||||
describe('conversationArc', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('conversationArc')
|
||||
resetArc()
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
resetArc()
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
describe('initializeArc', () => {
|
||||
|
||||
+25
-16
@@ -2,6 +2,10 @@ import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CONFIG_DIR: process.env.CLAUDE_CONFIG_DIR,
|
||||
@@ -11,7 +15,8 @@ const originalEnv = {
|
||||
|
||||
let tempDir: string
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('env.test.ts')
|
||||
tempDir = mkdtempSync(join(tmpdir(), 'openclaude-env-test-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = tempDir
|
||||
delete process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
|
||||
@@ -19,21 +24,25 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
if (originalEnv.CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalEnv.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_CUSTOM_OAUTH_URL === undefined) {
|
||||
delete process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL = originalEnv.CLAUDE_CODE_CUSTOM_OAUTH_URL
|
||||
}
|
||||
if (originalEnv.USER_TYPE === undefined) {
|
||||
delete process.env.USER_TYPE
|
||||
} else {
|
||||
process.env.USER_TYPE = originalEnv.USER_TYPE
|
||||
try {
|
||||
rmSync(tempDir, { recursive: true, force: true })
|
||||
if (originalEnv.CLAUDE_CONFIG_DIR === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalEnv.CLAUDE_CONFIG_DIR
|
||||
}
|
||||
if (originalEnv.CLAUDE_CODE_CUSTOM_OAUTH_URL === undefined) {
|
||||
delete process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL
|
||||
} else {
|
||||
process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL = originalEnv.CLAUDE_CODE_CUSTOM_OAUTH_URL
|
||||
}
|
||||
if (originalEnv.USER_TYPE === undefined) {
|
||||
delete process.env.USER_TYPE
|
||||
} else {
|
||||
process.env.USER_TYPE = originalEnv.USER_TYPE
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
+13
-1
@@ -156,10 +156,22 @@ export function resolveClaudeConfigHomeDir(options?: {
|
||||
return openClaudeDir.normalize('NFC')
|
||||
}
|
||||
|
||||
let claudeConfigHomeDirOverride: string | undefined
|
||||
|
||||
export function setClaudeConfigHomeDirForTesting(
|
||||
configDir: string | undefined,
|
||||
): void {
|
||||
claudeConfigHomeDirOverride = configDir?.normalize('NFC')
|
||||
}
|
||||
|
||||
// Memoized: 150+ callers, many on hot paths. Keyed off CLAUDE_CONFIG_DIR so
|
||||
// tests that change the env var get a fresh value without explicit cache.clear.
|
||||
export const getClaudeConfigHomeDir = memoize(
|
||||
(): string => {
|
||||
if (claudeConfigHomeDirOverride) {
|
||||
return claudeConfigHomeDirOverride
|
||||
}
|
||||
|
||||
const configDirEnv = process.env.CLAUDE_CONFIG_DIR
|
||||
const homeDir = homedir()
|
||||
const migrationSucceeded = migrateLegacyClaudeConfigHome({
|
||||
@@ -183,7 +195,7 @@ export const getClaudeConfigHomeDir = memoize(
|
||||
homeDir,
|
||||
})
|
||||
},
|
||||
() => process.env.CLAUDE_CONFIG_DIR,
|
||||
() => `${claudeConfigHomeDirOverride ?? ''}\0${process.env.CLAUDE_CONFIG_DIR ?? ''}`,
|
||||
)
|
||||
|
||||
export function getTeamsDir(): string {
|
||||
|
||||
@@ -11,53 +11,80 @@ import {
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
|
||||
import { setClaudeConfigHomeDirForTesting } from './envUtils.js'
|
||||
import { getFsImplementation } from './fsOperations.js'
|
||||
|
||||
describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalOrama = process.env.OPENCLAUDE_KNOWLEDGE_ORAMA
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-stress-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
const cwd = getFsImplementation().cwd()
|
||||
|
||||
beforeEach(() => {
|
||||
const removeDirWithRetry = (dir: string) => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = '1'
|
||||
setClaudeConfigHomeDirForTesting(configDir)
|
||||
resetGlobalGraph()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
if (originalOrama === undefined) {
|
||||
delete process.env.OPENCLAUDE_KNOWLEDGE_ORAMA
|
||||
} else {
|
||||
process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = originalOrama
|
||||
}
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
|
||||
// Restore config dir
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
|
||||
// Restore Orama flag
|
||||
if (originalOrama === undefined) {
|
||||
delete process.env.OPENCLAUDE_KNOWLEDGE_ORAMA
|
||||
} else {
|
||||
process.env.OPENCLAUDE_KNOWLEDGE_ORAMA = originalOrama
|
||||
}
|
||||
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
removeDirWithRetry(configDir)
|
||||
})
|
||||
|
||||
it('handles high-volume entity insertion (Stress Test)', async () => {
|
||||
const count = 50
|
||||
const start = Date.now()
|
||||
|
||||
|
||||
// Use sequential insertion to avoid Orama race conditions on disk/ID collisions
|
||||
for (let i = 0; i < count; i++) {
|
||||
await addGlobalEntity('stress_test', `entity_${i}`, { index: String(i), category: 'test' })
|
||||
}
|
||||
|
||||
const duration = Date.now() - start
|
||||
console.log(`Inserted ${count} entities into Orama in ${duration}ms`)
|
||||
|
||||
|
||||
const graph = getGlobalGraph()
|
||||
expect(Object.keys(graph.entities).length).toBe(count)
|
||||
|
||||
@@ -100,9 +127,7 @@ describe('KnowledgeGraph Phase 1 Stress & Edge Cases', () => {
|
||||
// 5. Verify the corrupted file was moved
|
||||
const { readdirSync } = await import('fs')
|
||||
const projectsBaseDir = join(configDir, 'projects')
|
||||
if (!existsSync(projectsBaseDir)) {
|
||||
console.log('Projects base dir not found, checking alternative path...')
|
||||
}
|
||||
expect(existsSync(projectsBaseDir)).toBe(true)
|
||||
// Search recursively for the corrupted file
|
||||
const findCorrupted = (dir: string): boolean => {
|
||||
const entries = readdirSync(dir, { withFileTypes: true })
|
||||
|
||||
@@ -12,27 +12,62 @@ import {
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { getProjectsDir } from './envUtils.js'
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
|
||||
import { getProjectsDir, setClaudeConfigHomeDirForTesting } from './envUtils.js'
|
||||
import { sanitizePath } from './sessionStoragePortable.js'
|
||||
|
||||
describe('KnowledgeGraph Global Persistence & RAG', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-test-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
const cwd = process.cwd()
|
||||
const removeDirWithRetry = (dir: string) => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
setClaudeConfigHomeDirForTesting(configDir)
|
||||
resetGlobalGraph()
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
resetGlobalGraph()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
afterEach(() => {
|
||||
try {
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
removeDirWithRetry(configDir)
|
||||
})
|
||||
|
||||
it('persists entities across loads', async () => {
|
||||
|
||||
@@ -49,6 +49,52 @@ let oramaInitPromise: Promise<void> | null = null
|
||||
// Storage Providers (Cached per project directory to handle CWD changes)
|
||||
const providerCache = new Map<string, { sqlite: SQLiteProvider; json: JSONProvider }>()
|
||||
|
||||
function sleepSync(ms: number): void {
|
||||
const shared = new SharedArrayBuffer(4)
|
||||
const view = new Int32Array(shared)
|
||||
Atomics.wait(view, 0, 0, ms)
|
||||
}
|
||||
|
||||
function removePathWithRetry(
|
||||
path: string,
|
||||
options?: { requireMissingAfterCleanup?: boolean },
|
||||
): void {
|
||||
const maxAttempts = 5
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
||||
try {
|
||||
rmSync(path, { force: true })
|
||||
if (!existsSync(path)) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
sleepSync(25 * (attempt + 1))
|
||||
}
|
||||
|
||||
if (!existsSync(path)) {
|
||||
return
|
||||
}
|
||||
|
||||
const quarantinePath = `${path}.stale-${Date.now()}`
|
||||
try {
|
||||
renameSync(path, quarantinePath)
|
||||
return
|
||||
} catch (error) {
|
||||
if (!existsSync(path)) {
|
||||
return
|
||||
}
|
||||
if (!options?.requireMissingAfterCleanup) {
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
const ORAMA_SCHEMA = {
|
||||
id: 'string',
|
||||
type: 'string',
|
||||
@@ -632,16 +678,35 @@ export function getGlobalGraphSummary(): string {
|
||||
export function resetGlobalGraph(): void {
|
||||
const cwd = getFsImplementation().cwd()
|
||||
const { sqlite, json } = getProviders()
|
||||
const emptyGraph: KnowledgeGraph = {
|
||||
entities: {},
|
||||
relations: [],
|
||||
summaries: [],
|
||||
rules: [],
|
||||
lastUpdateTime: Date.now(),
|
||||
}
|
||||
|
||||
json.delete()
|
||||
const sqliteCleared = sqlite.clear()
|
||||
sqlite.close()
|
||||
|
||||
const jsonResetSucceeded = sqliteCleared
|
||||
? (json.delete() || json.saveGraph(emptyGraph))
|
||||
: json.saveGraph(emptyGraph)
|
||||
|
||||
if (!jsonResetSucceeded) {
|
||||
throw new Error('Failed to reset knowledge graph JSON state')
|
||||
}
|
||||
|
||||
const projectDir = join(getProjectsDir(), sanitizePath(cwd))
|
||||
const sqlitePath = join(projectDir, 'knowledge.db')
|
||||
if (existsSync(sqlitePath)) rmSync(sqlitePath, { force: true })
|
||||
for (const sqlitePath of [
|
||||
join(projectDir, 'knowledge.db'),
|
||||
join(projectDir, 'knowledge.db-wal'),
|
||||
join(projectDir, 'knowledge.db-shm'),
|
||||
]) {
|
||||
removePathWithRetry(sqlitePath)
|
||||
}
|
||||
|
||||
const oramaPath = getOramaPersistencePath(cwd)
|
||||
try { rmSync(oramaPath, { force: true }) } catch {}
|
||||
removePathWithRetry(oramaPath, { requireMissingAfterCleanup: true })
|
||||
|
||||
oramaDb = null
|
||||
projectGraph = null
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
import { afterEach, beforeEach, expect, test } from 'bun:test'
|
||||
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../../entrypoints/sdk/shared.js'
|
||||
import { resetModelStringsForTestingOnly } from '../../bootstrap/state.js'
|
||||
import { saveGlobalConfig } from '../config.js'
|
||||
import {
|
||||
resetSettingsCache,
|
||||
setSessionSettingsCache,
|
||||
} from '../settings/settingsCache.js'
|
||||
|
||||
async function importFreshModelOptionsModule() {
|
||||
const nonce = `${Date.now()}-${Math.random()}`
|
||||
return import(`./modelOptions.js?ts=${nonce}`)
|
||||
}
|
||||
import {
|
||||
getCachedXiaomiMimoModelOptions,
|
||||
isXiaomiMimoProvider,
|
||||
} from './xiaomi-mimoModels.js'
|
||||
|
||||
const originalEnv = {
|
||||
CLAUDE_CODE_USE_OPENAI: process.env.CLAUDE_CODE_USE_OPENAI,
|
||||
@@ -21,9 +21,18 @@ const originalEnv = {
|
||||
CLAUDE_CODE_USE_VERTEX: process.env.CLAUDE_CODE_USE_VERTEX,
|
||||
CLAUDE_CODE_USE_FOUNDRY: process.env.CLAUDE_CODE_USE_FOUNDRY,
|
||||
OPENAI_BASE_URL: process.env.OPENAI_BASE_URL,
|
||||
OPENAI_API_BASE: process.env.OPENAI_API_BASE,
|
||||
OPENAI_MODEL: process.env.OPENAI_MODEL,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
|
||||
MIMO_API_KEY: process.env.MIMO_API_KEY,
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED:
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED,
|
||||
CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID:
|
||||
process.env.CLAUDE_CODE_PROVIDER_PROFILE_ENV_APPLIED_ID,
|
||||
MINIMAX_API_KEY: process.env.MINIMAX_API_KEY,
|
||||
NVIDIA_NIM: process.env.NVIDIA_NIM,
|
||||
VENICE_API_KEY: process.env.VENICE_API_KEY,
|
||||
XAI_API_KEY: process.env.XAI_API_KEY,
|
||||
ANTHROPIC_CUSTOM_MODEL_OPTION: process.env.ANTHROPIC_CUSTOM_MODEL_OPTION,
|
||||
}
|
||||
|
||||
@@ -36,7 +45,8 @@ function restoreEnvValue(key: keyof typeof originalEnv): void {
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
setSessionSettingsCache({ settings: {}, errors: [] })
|
||||
for (const key of Object.keys(originalEnv) as (keyof typeof originalEnv)[]) {
|
||||
delete process.env[key]
|
||||
@@ -45,20 +55,24 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
resetSettingsCache()
|
||||
for (const key of Object.keys(originalEnv) as (keyof typeof originalEnv)[]) {
|
||||
restoreEnvValue(key)
|
||||
try {
|
||||
resetSettingsCache()
|
||||
for (const key of Object.keys(originalEnv) as (keyof typeof originalEnv)[]) {
|
||||
restoreEnvValue(key)
|
||||
}
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
additionalModelOptionsCache: [],
|
||||
additionalModelOptionsCacheScope: undefined,
|
||||
openaiAdditionalModelOptionsCache: [],
|
||||
openaiAdditionalModelOptionsCacheByProfile: {},
|
||||
providerProfiles: [],
|
||||
activeProviderProfileId: undefined,
|
||||
}))
|
||||
resetModelStringsForTestingOnly()
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
saveGlobalConfig(current => ({
|
||||
...current,
|
||||
additionalModelOptionsCache: [],
|
||||
additionalModelOptionsCacheScope: undefined,
|
||||
openaiAdditionalModelOptionsCache: [],
|
||||
openaiAdditionalModelOptionsCacheByProfile: {},
|
||||
providerProfiles: [],
|
||||
activeProviderProfileId: undefined,
|
||||
}))
|
||||
resetModelStringsForTestingOnly()
|
||||
})
|
||||
|
||||
test('Xiaomi MiMo provider exposes MiMo catalog models in /model options', async () => {
|
||||
@@ -67,14 +81,21 @@ test('Xiaomi MiMo provider exposes MiMo catalog models in /model options', async
|
||||
process.env.OPENAI_MODEL = 'mimo-v2.5-pro'
|
||||
process.env.MIMO_API_KEY = 'mimo-live-key'
|
||||
|
||||
const { getModelOptions } = await importFreshModelOptionsModule()
|
||||
const options = getModelOptions(false)
|
||||
const options = getCachedXiaomiMimoModelOptions()
|
||||
const values = options.map(option => option.value)
|
||||
|
||||
expect(isXiaomiMimoProvider()).toBe(true)
|
||||
expect(values).toContain('mimo-v2.5-pro')
|
||||
expect(values).toContain('mimo-v2-flash')
|
||||
expect(values).not.toContain('opus')
|
||||
expect(
|
||||
options.some(option => option.label === 'MiMo V2.5 Pro'),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
test('Xiaomi MiMo provider does not activate for unrelated OpenAI-compatible mimo-prefixed models', async () => {
|
||||
process.env.CLAUDE_CODE_USE_OPENAI = '1'
|
||||
process.env.OPENAI_BASE_URL = 'https://api.example.com/v1'
|
||||
process.env.OPENAI_MODEL = 'mimo-custom'
|
||||
|
||||
expect(isXiaomiMimoProvider()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
import * as fsPromises from 'fs/promises'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
|
||||
|
||||
const originalEnv = { ...process.env }
|
||||
const originalArgv = [...process.argv]
|
||||
@@ -31,13 +32,18 @@ async function importFreshPlans() {
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
process.env = { ...originalEnv }
|
||||
process.argv = [...originalArgv]
|
||||
mock.restore()
|
||||
try {
|
||||
process.env = { ...originalEnv }
|
||||
process.argv = [...originalArgv]
|
||||
mock.restore()
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
})
|
||||
|
||||
describe('OpenClaude paths', () => {
|
||||
test('defaults user config home to ~/.openclaude', async () => {
|
||||
await acquireEnvMutex()
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
const { resolveClaudeConfigHomeDir } = await importFreshEnvUtils()
|
||||
|
||||
@@ -49,6 +55,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('hard-cuts user config home to ~/.openclaude by default', async () => {
|
||||
await acquireEnvMutex()
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
const { resolveClaudeConfigHomeDir } = await importFreshEnvUtils()
|
||||
|
||||
@@ -60,6 +67,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('migrates legacy config home and global config files to .openclaude', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'openclaude-paths-test-'))
|
||||
try {
|
||||
mkdirSync(join(tempHome, '.claude', 'skills', 'legacy-skill'), {
|
||||
@@ -100,6 +108,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('migration preserves existing .openclaude data while copying missing legacy data', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'openclaude-paths-test-'))
|
||||
try {
|
||||
mkdirSync(join(tempHome, '.claude', 'skills', 'legacy-skill'), {
|
||||
@@ -131,6 +140,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('migration skips explicit CLAUDE_CONFIG_DIR overrides', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'openclaude-paths-test-'))
|
||||
try {
|
||||
mkdirSync(join(tempHome, '.claude'), { recursive: true })
|
||||
@@ -151,6 +161,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('migration fails closed when .openclaude collides with a non-directory', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'openclaude-paths-test-'))
|
||||
try {
|
||||
writeFileSync(join(tempHome, '.openclaude'), 'not a directory')
|
||||
@@ -166,6 +177,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('migration ignores non-directory legacy config homes', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'openclaude-paths-test-'))
|
||||
try {
|
||||
writeFileSync(join(tempHome, '.claude'), 'not a directory')
|
||||
@@ -180,6 +192,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('config home falls back to legacy when migration fails on a non-directory .openclaude collision', async () => {
|
||||
await acquireEnvMutex()
|
||||
const tempHome = mkdtempSync(join(tmpdir(), 'openclaude-paths-test-'))
|
||||
try {
|
||||
writeFileSync(join(tempHome, '.openclaude'), 'not a directory')
|
||||
@@ -199,6 +212,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('default plans directory uses ~/.openclaude/plans', async () => {
|
||||
await acquireEnvMutex()
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
const { getDefaultPlansDirectory } = await importFreshPlans()
|
||||
|
||||
@@ -208,6 +222,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('default plans directory respects explicit CLAUDE_CONFIG_DIR', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { getDefaultPlansDirectory } = await importFreshPlans()
|
||||
|
||||
expect(
|
||||
@@ -216,6 +231,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('default plans directory normalizes generated path to NFC', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { getDefaultPlansDirectory } = await importFreshPlans()
|
||||
|
||||
expect(
|
||||
@@ -224,6 +240,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('default plans directory normalizes explicit CLAUDE_CONFIG_DIR to NFC', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { getDefaultPlansDirectory } = await importFreshPlans()
|
||||
|
||||
expect(
|
||||
@@ -232,6 +249,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('uses CLAUDE_CONFIG_DIR override when provided', async () => {
|
||||
await acquireEnvMutex()
|
||||
process.env.CLAUDE_CONFIG_DIR = '/tmp/custom-openclaude'
|
||||
const { getClaudeConfigHomeDir, resolveClaudeConfigHomeDir } =
|
||||
await importFreshEnvUtils()
|
||||
@@ -245,6 +263,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('project and local settings paths use .openclaude', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { getRelativeSettingsFilePathForSource } = await importFreshSettings()
|
||||
|
||||
expect(getRelativeSettingsFilePathForSource('projectSettings')).toBe(
|
||||
@@ -256,6 +275,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('local installer uses openclaude wrapper path', async () => {
|
||||
await acquireEnvMutex()
|
||||
// Force .openclaude config home so the test doesn't fall back to
|
||||
// ~/.claude when ~/.openclaude doesn't exist on this machine.
|
||||
process.env.CLAUDE_CONFIG_DIR = join(homedir(), '.openclaude')
|
||||
@@ -267,6 +287,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('local installation detection matches .openclaude path', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { isManagedLocalInstallationPath } =
|
||||
await importFreshLocalInstaller()
|
||||
|
||||
@@ -278,6 +299,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('local installation detection still matches legacy .claude path', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { isManagedLocalInstallationPath } =
|
||||
await importFreshLocalInstaller()
|
||||
|
||||
@@ -289,6 +311,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('candidate local install dirs include both openclaude and legacy claude paths', async () => {
|
||||
await acquireEnvMutex()
|
||||
const { getCandidateLocalInstallDirs } = await importFreshLocalInstaller()
|
||||
|
||||
expect(
|
||||
@@ -303,6 +326,7 @@ describe('OpenClaude paths', () => {
|
||||
})
|
||||
|
||||
test('legacy local installs are detected when they still expose the claude binary', async () => {
|
||||
await acquireEnvMutex()
|
||||
mock.module('fs/promises', () => ({
|
||||
...fsPromises,
|
||||
access: async (path: string) => {
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { afterEach, describe, expect, test } from 'bun:test'
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
|
||||
import { homedir } from 'os'
|
||||
import { join } from 'path'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../test/sharedMutationLock.js'
|
||||
|
||||
import { isInGlobalClaudeFolder } from '../components/permissions/FilePermissionDialog/permissionOptions.tsx'
|
||||
import { optionForPermissionSaveDestination } from '../components/permissions/rules/AddPermissionRules.tsx'
|
||||
@@ -12,11 +16,19 @@ import { getValidationTip } from './settings/validationTips.ts'
|
||||
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('openclaudeUiSurfaces.test.ts')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
try {
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@ import assert from 'node:assert/strict'
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import test from 'node:test'
|
||||
import test, { afterEach, beforeEach } from 'node:test'
|
||||
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
|
||||
import { DEFAULT_CODEX_BASE_URL } from '../services/api/providerConfig.js'
|
||||
import {
|
||||
applySavedProfileToCurrentSession,
|
||||
@@ -51,6 +52,14 @@ async function importFreshProviderProfileModule() {
|
||||
|
||||
const missingCodexAuthPath = join(tmpdir(), 'openclaude-missing-codex-auth.json')
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
releaseEnvMutex()
|
||||
})
|
||||
|
||||
test('matching persisted ollama env is reused for ollama launch', async () => {
|
||||
const env = await buildLaunchEnv({
|
||||
profile: 'ollama',
|
||||
@@ -603,13 +612,15 @@ test('saveProfileFile defaults to user config instead of the working directory',
|
||||
OPENAI_MODEL: 'gpt-4o',
|
||||
})
|
||||
|
||||
const filePath = saveProfileFile(persisted)
|
||||
const filePath = saveProfileFile(persisted, { configDir })
|
||||
|
||||
assert.equal(filePath, join(configDir, PROFILE_FILE_NAME))
|
||||
assert.equal(getDefaultProfileFilePath(), join(configDir, PROFILE_FILE_NAME))
|
||||
assert.equal(getDefaultProfileFilePath(configDir), join(configDir, PROFILE_FILE_NAME))
|
||||
assert.equal(existsSync(join(cwd, PROFILE_FILE_NAME)), false)
|
||||
assert.equal(statSync(configDir).mode & 0o777, 0o700)
|
||||
assert.deepEqual(loadProfileFile(), persisted)
|
||||
if (process.platform !== 'win32') {
|
||||
assert.equal(statSync(configDir).mode & 0o777, 0o700)
|
||||
}
|
||||
assert.deepEqual(loadProfileFile({ configDir, cwd }), persisted)
|
||||
} finally {
|
||||
process.chdir(previousCwd)
|
||||
if (previousConfigDir === undefined) {
|
||||
@@ -642,7 +653,7 @@ test('loadProfileFile keeps project-local files as a legacy fallback', () => {
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.deepEqual(loadProfileFile(), legacyProfile)
|
||||
assert.deepEqual(loadProfileFile({ configDir, cwd }), legacyProfile)
|
||||
} finally {
|
||||
process.chdir(previousCwd)
|
||||
if (previousConfigDir === undefined) {
|
||||
@@ -676,7 +687,7 @@ test('loadProfileFile does not fall back when user config profile is invalid', (
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.equal(loadProfileFile(), null)
|
||||
assert.equal(loadProfileFile({ configDir, cwd }), null)
|
||||
} finally {
|
||||
process.chdir(previousCwd)
|
||||
if (previousConfigDir === undefined) {
|
||||
@@ -731,6 +742,48 @@ test('deleteProfileFile clears the default profile and legacy workspace fallback
|
||||
}
|
||||
})
|
||||
|
||||
test('deleteProfileFile with configDir and cwd clears both user config and legacy fallback', () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-delete-mixed-profile-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-delete-mixed-config-profile-'))
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const previousCwd = process.cwd()
|
||||
|
||||
try {
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
process.chdir(cwd)
|
||||
|
||||
const configProfile = createProfileFile('openai', {
|
||||
OPENAI_API_KEY: 'sk-test',
|
||||
})
|
||||
const legacyProfile = createProfileFile('ollama', {
|
||||
OPENAI_BASE_URL: 'http://localhost:11434/v1',
|
||||
OPENAI_MODEL: 'llama3.1:8b',
|
||||
})
|
||||
|
||||
saveProfileFile(configProfile, { configDir, cwd })
|
||||
writeFileSync(
|
||||
join(cwd, PROFILE_FILE_NAME),
|
||||
JSON.stringify(legacyProfile, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
deleteProfileFile({ configDir, cwd })
|
||||
|
||||
assert.equal(existsSync(join(configDir, PROFILE_FILE_NAME)), false)
|
||||
assert.equal(existsSync(join(cwd, PROFILE_FILE_NAME)), false)
|
||||
assert.equal(loadProfileFile({ configDir, cwd }), null)
|
||||
} finally {
|
||||
process.chdir(previousCwd)
|
||||
if (previousConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = previousConfigDir
|
||||
}
|
||||
rmSync(cwd, { recursive: true, force: true })
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('buildCodexProfileEnv tags OAuth-saved profiles so logout can remove them safely', () => {
|
||||
const env = buildCodexProfileEnv({
|
||||
model: 'codexplan',
|
||||
@@ -801,7 +854,7 @@ test('clearPersistedCodexOAuthProfile removes only persisted Codex OAuth profile
|
||||
}
|
||||
})
|
||||
|
||||
test('clearPersistedCodexOAuthProfile clears both default and legacy OAuth profiles', () => {
|
||||
test('clearPersistedCodexOAuthProfile clears both default and legacy OAuth profiles', async () => {
|
||||
const cwd = mkdtempSync(join(tmpdir(), 'openclaude-clear-oauth-profile-'))
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-clear-oauth-config-'))
|
||||
const previousConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
@@ -811,27 +864,35 @@ test('clearPersistedCodexOAuthProfile clears both default and legacy OAuth profi
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
process.chdir(cwd)
|
||||
|
||||
const oauthProfile = createProfileFile('codex', {
|
||||
const {
|
||||
PROFILE_FILE_NAME: freshProfileFileName,
|
||||
clearPersistedCodexOAuthProfile: clearPersistedCodexOAuthProfileFresh,
|
||||
createProfileFile: createProfileFileFresh,
|
||||
loadProfileFile: loadProfileFileFresh,
|
||||
saveProfileFile: saveProfileFileFresh,
|
||||
} = await importFreshProviderProfileModule()
|
||||
|
||||
const oauthProfile = createProfileFileFresh('codex', {
|
||||
OPENAI_MODEL: 'codexplan',
|
||||
OPENAI_BASE_URL: DEFAULT_CODEX_BASE_URL,
|
||||
CHATGPT_ACCOUNT_ID: 'acct_oauth',
|
||||
CODEX_CREDENTIAL_SOURCE: 'oauth',
|
||||
})
|
||||
|
||||
saveProfileFile(oauthProfile)
|
||||
saveProfileFileFresh(oauthProfile, { configDir })
|
||||
writeFileSync(
|
||||
join(cwd, PROFILE_FILE_NAME),
|
||||
join(cwd, freshProfileFileName),
|
||||
JSON.stringify(oauthProfile, null, 2),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
assert.equal(
|
||||
clearPersistedCodexOAuthProfile(),
|
||||
join(configDir, PROFILE_FILE_NAME),
|
||||
clearPersistedCodexOAuthProfileFresh({ configDir, cwd }),
|
||||
join(configDir, freshProfileFileName),
|
||||
)
|
||||
assert.equal(existsSync(join(configDir, PROFILE_FILE_NAME)), false)
|
||||
assert.equal(existsSync(join(cwd, PROFILE_FILE_NAME)), false)
|
||||
assert.equal(loadProfileFile(), null)
|
||||
assert.equal(existsSync(join(configDir, freshProfileFileName)), false)
|
||||
assert.equal(existsSync(join(cwd, freshProfileFileName)), false)
|
||||
assert.equal(loadProfileFileFresh({ configDir, cwd }), null)
|
||||
} finally {
|
||||
process.chdir(previousCwd)
|
||||
if (previousConfigDir === undefined) {
|
||||
|
||||
@@ -199,13 +199,14 @@ type SecretValueSource = Partial<
|
||||
>
|
||||
>
|
||||
|
||||
type ProfileFileLocation = {
|
||||
export type ProfileFileLocation = {
|
||||
configDir?: string
|
||||
cwd?: string
|
||||
filePath?: string
|
||||
}
|
||||
|
||||
export function getDefaultProfileFilePath(): string {
|
||||
return join(getClaudeConfigHomeDir(), PROFILE_FILE_NAME)
|
||||
export function getDefaultProfileFilePath(configDir?: string): string {
|
||||
return join(configDir ?? getClaudeConfigHomeDir(), PROFILE_FILE_NAME)
|
||||
}
|
||||
|
||||
function resolveLegacyProfileFilePath(cwd = process.cwd()): string {
|
||||
@@ -217,16 +218,16 @@ function resolveProfileFilePath(options?: ProfileFileLocation): string {
|
||||
return options.filePath
|
||||
}
|
||||
|
||||
if (options?.cwd) {
|
||||
if (options?.cwd && !options?.configDir) {
|
||||
return resolveLegacyProfileFilePath(options.cwd)
|
||||
}
|
||||
|
||||
return getDefaultProfileFilePath()
|
||||
return getDefaultProfileFilePath(options?.configDir)
|
||||
}
|
||||
|
||||
function resolveProfileFileReadPaths(options?: ProfileFileLocation): string[] {
|
||||
const primary = resolveProfileFilePath(options)
|
||||
if (options?.filePath || options?.cwd) {
|
||||
if (options?.filePath || (options?.cwd && !options?.configDir)) {
|
||||
return [primary]
|
||||
}
|
||||
|
||||
@@ -234,17 +235,17 @@ function resolveProfileFileReadPaths(options?: ProfileFileLocation): string[] {
|
||||
return [primary]
|
||||
}
|
||||
|
||||
const legacy = resolveLegacyProfileFilePath()
|
||||
const legacy = resolveLegacyProfileFilePath(options?.cwd)
|
||||
return legacy === primary ? [primary] : [primary, legacy]
|
||||
}
|
||||
|
||||
function resolveProfileFileCleanupPaths(options?: ProfileFileLocation): string[] {
|
||||
const primary = resolveProfileFilePath(options)
|
||||
if (options?.filePath || options?.cwd) {
|
||||
if (options?.filePath || (options?.cwd && !options?.configDir)) {
|
||||
return [primary]
|
||||
}
|
||||
|
||||
const legacy = resolveLegacyProfileFilePath()
|
||||
const legacy = resolveLegacyProfileFilePath(options?.cwd)
|
||||
return legacy === primary ? [primary] : [primary, legacy]
|
||||
}
|
||||
|
||||
@@ -946,13 +947,12 @@ export function saveProfileFile(
|
||||
|
||||
export function deleteProfileFile(options?: ProfileFileLocation): string {
|
||||
const filePath = resolveProfileFilePath(options)
|
||||
rmSync(filePath, { force: true })
|
||||
if (!options?.filePath && !options?.cwd) {
|
||||
const legacyPath = resolveLegacyProfileFilePath()
|
||||
if (legacyPath !== filePath) {
|
||||
rmSync(legacyPath, { force: true })
|
||||
}
|
||||
const cleanupPaths = new Set(resolveProfileFileCleanupPaths(options))
|
||||
|
||||
for (const cleanupPath of cleanupPaths) {
|
||||
rmSync(cleanupPath, { force: true })
|
||||
}
|
||||
|
||||
return filePath
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { join } from 'node:path'
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test'
|
||||
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../entrypoints/sdk/shared.js'
|
||||
import type { ProviderProfile } from './config.js'
|
||||
|
||||
async function importFreshProvidersModule() {
|
||||
@@ -92,7 +93,8 @@ function saveMockGlobalConfig(
|
||||
mockConfigState = updater(mockConfigState)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
for (const key of RESTORED_KEYS) {
|
||||
delete process.env[key]
|
||||
}
|
||||
@@ -101,20 +103,24 @@ beforeEach(() => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const key of RESTORED_KEYS) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
try {
|
||||
for (const key of RESTORED_KEYS) {
|
||||
if (originalEnv[key] === undefined) {
|
||||
delete process.env[key]
|
||||
} else {
|
||||
process.env[key] = originalEnv[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mock.restore()
|
||||
mockConfigState = createMockConfigState()
|
||||
process.chdir(originalCwd)
|
||||
if (testConfigDir) {
|
||||
rmSync(testConfigDir, { recursive: true, force: true })
|
||||
testConfigDir = null
|
||||
mock.restore()
|
||||
mockConfigState = createMockConfigState()
|
||||
process.chdir(originalCwd)
|
||||
if (testConfigDir) {
|
||||
rmSync(testConfigDir, { recursive: true, force: true })
|
||||
testConfigDir = null
|
||||
}
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1206,7 +1212,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [openaiProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('openai_prof')
|
||||
const result = setActiveProviderProfile('openai_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
expect(result?.id).toBe('openai_prof')
|
||||
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||
@@ -1244,7 +1252,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [ollamaProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('ollama_prof')
|
||||
const result = setActiveProviderProfile('ollama_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1287,7 +1297,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [deepSeekProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('deepseek_prof')
|
||||
const result = setActiveProviderProfile('deepseek_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1330,7 +1342,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [deepSeekProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('deepseek_vendor_prof')
|
||||
const result = setActiveProviderProfile('deepseek_vendor_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1369,7 +1383,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [veniceProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('venice_prof')
|
||||
const result = setActiveProviderProfile('venice_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1410,7 +1426,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [mimoProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('mimo_prof')
|
||||
const result = setActiveProviderProfile('mimo_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1453,7 +1471,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [bedrockProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('bedrock_prof')
|
||||
const result = setActiveProviderProfile('bedrock_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1495,7 +1515,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [anthropicProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('anthro_persisted_prof')
|
||||
const result = setActiveProviderProfile('anthro_persisted_prof', {
|
||||
configDir,
|
||||
})
|
||||
const persisted = JSON.parse(
|
||||
readFileSync(join(configDir, '.openclaude-profile.json'), 'utf8'),
|
||||
)
|
||||
@@ -1531,7 +1553,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [anthropicProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('anthro_prof')
|
||||
const result = setActiveProviderProfile('anthro_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
expect(result?.id).toBe('anthro_prof')
|
||||
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||
@@ -1569,12 +1593,16 @@ describe('setActiveProviderProfile', () => {
|
||||
}))
|
||||
|
||||
// First activate the openai profile
|
||||
setActiveProviderProfile('openai_prof')
|
||||
setActiveProviderProfile('openai_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
expect(process.env.OPENAI_MODEL).toBe('gpt-4o')
|
||||
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||
|
||||
// Now switch to the anthropic profile
|
||||
const result = setActiveProviderProfile('anthro_prof')
|
||||
const result = setActiveProviderProfile('anthro_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
expect(result?.id).toBe('anthro_prof')
|
||||
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||
@@ -1614,12 +1642,16 @@ describe('setActiveProviderProfile', () => {
|
||||
}))
|
||||
|
||||
// First activate the anthropic profile
|
||||
setActiveProviderProfile('anthro_prof')
|
||||
setActiveProviderProfile('anthro_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
expect(process.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6')
|
||||
expect(process.env.ANTHROPIC_BASE_URL).toBe('https://api.anthropic.com')
|
||||
|
||||
// Now switch to the openai profile
|
||||
const result = setActiveProviderProfile('openai_prof')
|
||||
const result = setActiveProviderProfile('openai_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
expect(result?.id).toBe('openai_prof')
|
||||
expect(String(process.env.CLAUDE_CODE_USE_OPENAI)).toBe('1')
|
||||
@@ -1643,7 +1675,9 @@ describe('setActiveProviderProfile', () => {
|
||||
providerProfiles: [openaiProfile],
|
||||
}))
|
||||
|
||||
const result = setActiveProviderProfile('nonexistent_prof')
|
||||
const result = setActiveProviderProfile('nonexistent_prof', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
@@ -1858,7 +1892,9 @@ describe('setActiveProviderProfile model cache', () => {
|
||||
],
|
||||
}
|
||||
|
||||
setActiveProviderProfile('multi_provider')
|
||||
setActiveProviderProfile('multi_provider', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
const cache = getActiveOpenAIModelOptionsCache()
|
||||
const cacheValues = cache.map((opt: { value: string }) => opt.value)
|
||||
@@ -1899,7 +1935,9 @@ describe('setActiveProviderProfile model cache', () => {
|
||||
},
|
||||
}
|
||||
|
||||
setActiveProviderProfile('multi_provider')
|
||||
setActiveProviderProfile('multi_provider', {
|
||||
configDir: testConfigDir ?? undefined,
|
||||
})
|
||||
|
||||
expect(getActiveOpenAIModelOptionsCache()).toEqual([
|
||||
{
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
buildXiaomiMimoProfileEnv,
|
||||
buildVertexProfileEnv,
|
||||
clearManagedProfileEnv,
|
||||
type ProfileFileLocation,
|
||||
type ProfileEnv,
|
||||
type ProviderProfile as ProviderProfileStartup,
|
||||
} from './providerProfile.js'
|
||||
@@ -1128,6 +1129,7 @@ function triggerStartupDiscoveryRefreshForProfile(
|
||||
|
||||
export function setActiveProviderProfile(
|
||||
profileId: string,
|
||||
options?: ProfileFileLocation,
|
||||
): ProviderProfile | null {
|
||||
const current = getGlobalConfig()
|
||||
const profiles = getProviderProfiles(current)
|
||||
@@ -1158,7 +1160,7 @@ export function setActiveProviderProfile(
|
||||
|
||||
if (startupProfile) {
|
||||
const file = createProfileFile(startupProfile.profile, startupProfile.env)
|
||||
saveProfileFile(file)
|
||||
saveProfileFile(file, options)
|
||||
}
|
||||
|
||||
return activeProfile
|
||||
|
||||
@@ -3,6 +3,10 @@ import { expect, test, mock, describe, beforeEach, afterEach } from "bun:test";
|
||||
import { linuxSecretStorage } from "./linuxSecretStorage.js";
|
||||
import { windowsCredentialStorage } from "./windowsCredentialStorage.js";
|
||||
import { getSecureStorageServiceName, CREDENTIALS_SERVICE_SUFFIX } from "./macOsKeychainHelpers.js";
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from "../../test/sharedMutationLock.js";
|
||||
|
||||
// Mock execaSync
|
||||
const mockExecaSync = mock(() => ({ exitCode: 0, stdout: "" }));
|
||||
@@ -13,7 +17,8 @@ mock.module("execa", () => ({
|
||||
describe("Secure Storage Platform Implementations", () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock("platformStorage.test.ts");
|
||||
process.env = { ...originalEnv };
|
||||
mockExecaSync.mockClear();
|
||||
// Default mock behavior
|
||||
@@ -21,7 +26,11 @@ describe("Secure Storage Platform Implementations", () => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
try {
|
||||
process.env = originalEnv;
|
||||
} finally {
|
||||
releaseSharedMutationLock();
|
||||
}
|
||||
});
|
||||
|
||||
const testData = {
|
||||
|
||||
@@ -827,9 +827,12 @@ export function getInitialSettings(): SettingsJson {
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use getInitialSettings() instead. This alias exists for backwards compatibility.
|
||||
* @deprecated Use getInitialSettings() instead. Keep this as a function
|
||||
* declaration so cyclic test-only module graphs never observe it in TDZ.
|
||||
*/
|
||||
export const getSettings_DEPRECATED = getInitialSettings
|
||||
export function getSettings_DEPRECATED(): SettingsJson {
|
||||
return getInitialSettings()
|
||||
}
|
||||
|
||||
export type SettingsWithSources = {
|
||||
effective: SettingsJson
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test'
|
||||
import { join } from 'path'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { JSONProvider } from './JSONProvider.js'
|
||||
|
||||
const tempDirs: string[] = []
|
||||
|
||||
const emptyGraph = {
|
||||
entities: {},
|
||||
relations: [],
|
||||
summaries: [],
|
||||
rules: [],
|
||||
lastUpdateTime: 1,
|
||||
}
|
||||
|
||||
function captureConsoleError<T>(run: () => T): { result: T; calls: unknown[][] } {
|
||||
const originalConsoleError = console.error
|
||||
const calls: unknown[][] = []
|
||||
console.error = (...args: unknown[]) => {
|
||||
calls.push(args)
|
||||
}
|
||||
|
||||
try {
|
||||
return {
|
||||
result: run(),
|
||||
calls,
|
||||
}
|
||||
} finally {
|
||||
console.error = originalConsoleError
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
const dir = tempDirs.pop()
|
||||
if (!dir) continue
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
describe('JSONProvider', () => {
|
||||
it('reports save failure when the graph path cannot be written', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'openclaude-json-provider-'))
|
||||
tempDirs.push(projectDir)
|
||||
mkdirSync(join(projectDir, 'knowledge_graph.json'))
|
||||
|
||||
const provider = new JSONProvider(projectDir)
|
||||
const { result, calls } = captureConsoleError(() =>
|
||||
provider.saveGraph(emptyGraph),
|
||||
)
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(String(calls[0][0])).toContain('Failed to save project graph to JSON')
|
||||
})
|
||||
|
||||
it('reports delete failure when the graph path is a directory', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'openclaude-json-provider-'))
|
||||
tempDirs.push(projectDir)
|
||||
mkdirSync(join(projectDir, 'knowledge_graph.json'))
|
||||
|
||||
const provider = new JSONProvider(projectDir)
|
||||
expect(provider.delete()).toBe(false)
|
||||
})
|
||||
|
||||
it('reports delete success when the graph file is removed', () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'openclaude-json-provider-'))
|
||||
tempDirs.push(projectDir)
|
||||
writeFileSync(join(projectDir, 'knowledge_graph.json'), '{}', 'utf8')
|
||||
|
||||
const provider = new JSONProvider(projectDir)
|
||||
expect(provider.delete()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -29,7 +29,7 @@ export class JSONProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public saveGraph(graph: KnowledgeGraph): void {
|
||||
public saveGraph(graph: KnowledgeGraph): boolean {
|
||||
try {
|
||||
const dir = dirname(this.path)
|
||||
if (!existsSync(dir)) {
|
||||
@@ -38,16 +38,23 @@ export class JSONProvider {
|
||||
|
||||
// Use established project utility for atomic writes with flushing
|
||||
writeFileSyncAndFlush_DEPRECATED(this.path, JSON.stringify(graph, null, 2), { encoding: 'utf-8' })
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error(`Failed to save project graph to JSON:`, e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public delete(): void {
|
||||
if (existsSync(this.path)) {
|
||||
try {
|
||||
rmSync(this.path, { force: true })
|
||||
} catch {}
|
||||
public delete(): boolean {
|
||||
if (!existsSync(this.path)) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
rmSync(this.path, { force: true })
|
||||
return !existsSync(this.path)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,32 +11,114 @@ import {
|
||||
import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { getProjectsDir } from '../envUtils.js'
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../../entrypoints/sdk/shared.js'
|
||||
import { getProjectsDir, setClaudeConfigHomeDirForTesting } from '../envUtils.js'
|
||||
import { sanitizePath } from '../sessionStoragePortable.js'
|
||||
import { getFsImplementation } from '../fsOperations.js'
|
||||
|
||||
describe('SQLite Masterpiece: Edge Cases & Multi-Project Isolation', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalConsoleError = console.error
|
||||
const originalConsoleWarn = console.warn
|
||||
const rootTestDir = mkdtempSync(join(tmpdir(), 'openclaude-masterpiece-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = rootTestDir
|
||||
|
||||
const project1Dir = join(rootTestDir, 'proj1')
|
||||
const project2Dir = join(rootTestDir, 'proj2')
|
||||
let capturedConsoleErrors: unknown[][] = []
|
||||
let capturedConsoleWarnings: unknown[][] = []
|
||||
let expectRecoveryLogsForCurrentTest = false
|
||||
let originalFsCwd: (() => string) | null = null
|
||||
let testCwd = ''
|
||||
let project1Dir = ''
|
||||
let project2Dir = ''
|
||||
const removeDirWithRetry = (dir: string) => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
capturedConsoleErrors = []
|
||||
capturedConsoleWarnings = []
|
||||
expectRecoveryLogsForCurrentTest = false
|
||||
console.error = (...args: unknown[]) => {
|
||||
capturedConsoleErrors.push(args)
|
||||
}
|
||||
console.warn = (...args: unknown[]) => {
|
||||
capturedConsoleWarnings.push(args)
|
||||
}
|
||||
process.env.CLAUDE_CONFIG_DIR = rootTestDir
|
||||
setClaudeConfigHomeDirForTesting(rootTestDir)
|
||||
const fs = getFsImplementation()
|
||||
originalFsCwd = fs.cwd
|
||||
testCwd = join(
|
||||
rootTestDir,
|
||||
'suite-cwds',
|
||||
`test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
)
|
||||
project1Dir = join(testCwd, 'proj1')
|
||||
project2Dir = join(testCwd, 'proj2')
|
||||
fs.cwd = () => testCwd
|
||||
resetGlobalGraph()
|
||||
if (!existsSync(project1Dir)) mkdirSync(project1Dir, { recursive: true })
|
||||
if (!existsSync(project2Dir)) mkdirSync(project2Dir, { recursive: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
resetGlobalGraph()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
afterEach(() => {
|
||||
try {
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
const projectsDir = join(rootTestDir, 'projects')
|
||||
if (existsSync(projectsDir)) {
|
||||
removeDirWithRetry(projectsDir)
|
||||
}
|
||||
if (existsSync(testCwd)) {
|
||||
removeDirWithRetry(testCwd)
|
||||
}
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
if (expectRecoveryLogsForCurrentTest) {
|
||||
expect(
|
||||
capturedConsoleErrors.some(call =>
|
||||
String(call[0]).includes('Failed to initialize SQLite database'),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(capturedConsoleWarnings).toHaveLength(0)
|
||||
} else {
|
||||
expect(capturedConsoleErrors).toHaveLength(0)
|
||||
expect(capturedConsoleWarnings).toHaveLength(0)
|
||||
}
|
||||
} finally {
|
||||
if (originalFsCwd) {
|
||||
getFsImplementation().cwd = originalFsCwd
|
||||
}
|
||||
console.error = originalConsoleError
|
||||
console.warn = originalConsoleWarn
|
||||
releaseEnvMutex()
|
||||
}
|
||||
rmSync(rootTestDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
removeDirWithRetry(rootTestDir)
|
||||
})
|
||||
|
||||
it('guarantees strict isolation between projects (CWD Switch)', async () => {
|
||||
@@ -115,6 +197,7 @@ describe('SQLite Masterpiece: Edge Cases & Multi-Project Isolation', () => {
|
||||
})
|
||||
|
||||
it('recovers from corrupted SQLite header (SHORT_READ/Disk Error)', async () => {
|
||||
expectRecoveryLogsForCurrentTest = true
|
||||
const cwd = getFsImplementation().cwd()
|
||||
const projectDir = join(getProjectsDir(), sanitizePath(cwd))
|
||||
const sqlitePath = join(projectDir, 'knowledge.db')
|
||||
@@ -130,7 +213,9 @@ describe('SQLite Masterpiece: Edge Cases & Multi-Project Isolation', () => {
|
||||
// 3. System should detect error during init, delete corrupted db, and rebuild from JSON
|
||||
await initOrama(cwd)
|
||||
const graph = getGlobalGraph()
|
||||
expect(Object.values(graph.entities).some(e => e.name === 'survivor')).toBe(true)
|
||||
expect(Object.values(graph.entities).some(e => e.name === 'survivor')).toBe(
|
||||
true,
|
||||
)
|
||||
expect(existsSync(sqlitePath)).toBe(true) // Recreated
|
||||
})
|
||||
|
||||
|
||||
@@ -6,34 +6,121 @@ import {
|
||||
getGlobalGraph,
|
||||
initOrama
|
||||
} from '../knowledgeGraph.js'
|
||||
import { mkdtempSync, rmSync, existsSync } from 'fs'
|
||||
import { mkdtempSync, rmSync, existsSync, renameSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
import { getProjectsDir } from '../envUtils.js'
|
||||
import { acquireEnvMutex, releaseEnvMutex } from '../../entrypoints/sdk/shared.js'
|
||||
import { getProjectsDir, setClaudeConfigHomeDirForTesting } from '../envUtils.js'
|
||||
import { getFsImplementation, setFsImplementation } from '../fsOperations.js'
|
||||
import { sanitizePath } from '../sessionStoragePortable.js'
|
||||
import { SQLiteProvider } from './SQLiteProvider.js'
|
||||
|
||||
describe('SQLite Storage Layer', () => {
|
||||
const originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
const originalCwd = process.cwd()
|
||||
const originalFs = getFsImplementation()
|
||||
const configDir = mkdtempSync(join(tmpdir(), 'openclaude-sqlite-'))
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
const cwd = process.cwd()
|
||||
let workspaceDir = ''
|
||||
const removeDirWithRetry = (dir: string) => {
|
||||
for (let attempt = 0; attempt < 5; attempt++) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
return
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 25 * (attempt + 1))
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const removeFileWithRetry = (filePath: string) => {
|
||||
const renamedPath = `${filePath}.deleted`
|
||||
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
try {
|
||||
rmSync(filePath, { force: true })
|
||||
if (!existsSync(filePath)) {
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (existsSync(filePath)) {
|
||||
renameSync(filePath, renamedPath)
|
||||
return
|
||||
}
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException).code
|
||||
if (code !== 'EBUSY' && code !== 'EPERM' && code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 50 * (attempt + 1))
|
||||
}
|
||||
|
||||
if (existsSync(filePath)) {
|
||||
throw new Error(`Timed out removing locked SQLite file: ${filePath}`)
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireEnvMutex()
|
||||
workspaceDir = mkdtempSync(join(tmpdir(), 'openclaude-sqlite-cwd-'))
|
||||
process.chdir(workspaceDir)
|
||||
setFsImplementation({
|
||||
...originalFs,
|
||||
cwd: () => workspaceDir,
|
||||
})
|
||||
process.env.CLAUDE_CONFIG_DIR = configDir
|
||||
setClaudeConfigHomeDirForTesting(configDir)
|
||||
resetGlobalGraph()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
resetGlobalGraph()
|
||||
clearMemoryOnly()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
process.chdir(originalCwd)
|
||||
setFsImplementation(originalFs)
|
||||
setClaudeConfigHomeDirForTesting(undefined)
|
||||
if (workspaceDir) {
|
||||
removeDirWithRetry(workspaceDir)
|
||||
workspaceDir = ''
|
||||
}
|
||||
} finally {
|
||||
releaseEnvMutex()
|
||||
}
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
resetGlobalGraph()
|
||||
if (originalConfigDir === undefined) {
|
||||
delete process.env.CLAUDE_CONFIG_DIR
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
rmSync(configDir, { recursive: true, force: true })
|
||||
removeDirWithRetry(configDir)
|
||||
})
|
||||
|
||||
it('persists data in SQLite database', async () => {
|
||||
const sqlitePath = join(getProjectsDir(), sanitizePath(cwd), 'knowledge.db')
|
||||
const sqlitePath = join(getProjectsDir(), sanitizePath(workspaceDir), 'knowledge.db')
|
||||
|
||||
// 1. Add data
|
||||
await addGlobalEntity('tool', 'sqlite-test', { status: 'durable' })
|
||||
@@ -50,8 +137,8 @@ describe('SQLite Storage Layer', () => {
|
||||
})
|
||||
|
||||
it('self-heals SQLite from JSON if DB is deleted', async () => {
|
||||
const sqlitePath = join(getProjectsDir(), sanitizePath(cwd), 'knowledge.db')
|
||||
const jsonPath = join(getProjectsDir(), sanitizePath(cwd), 'knowledge_graph.json')
|
||||
const sqlitePath = join(getProjectsDir(), sanitizePath(workspaceDir), 'knowledge.db')
|
||||
const jsonPath = join(getProjectsDir(), sanitizePath(workspaceDir), 'knowledge_graph.json')
|
||||
|
||||
// 1. Add data to both
|
||||
await addGlobalEntity('tool', 'self-heal-test', { val: 'safe' })
|
||||
@@ -60,12 +147,12 @@ describe('SQLite Storage Layer', () => {
|
||||
|
||||
// 2. Delete SQLite DB but keep JSON
|
||||
clearMemoryOnly()
|
||||
rmSync(sqlitePath)
|
||||
removeFileWithRetry(sqlitePath)
|
||||
expect(existsSync(sqlitePath)).toBe(false)
|
||||
|
||||
// 3. Requesting the graph should trigger hydration from JSON into a NEW SQLite DB
|
||||
// In the async architecture, we must await initialization to trigger the rebuild.
|
||||
await initOrama(cwd)
|
||||
await initOrama(workspaceDir)
|
||||
const graph = getGlobalGraph()
|
||||
const entity = Object.values(graph.entities).find(e => e.name === 'self-heal-test')
|
||||
expect(entity).toBeDefined()
|
||||
@@ -77,18 +164,27 @@ describe('SQLite Storage Layer', () => {
|
||||
|
||||
it('handles large transactions (Stress Test)', async () => {
|
||||
const count = 100
|
||||
const start = Date.now()
|
||||
|
||||
// Add 100 entities sequentially (mutation queue)
|
||||
for (let i = 0; i < count; i++) {
|
||||
await addGlobalEntity('bulk', `item_${i}`, { index: String(i) })
|
||||
}
|
||||
|
||||
const duration = Date.now() - start
|
||||
console.log(`Inserted ${count} items into SQLite+JSON+Orama in ${duration}ms`)
|
||||
|
||||
|
||||
clearMemoryOnly()
|
||||
const graph = getGlobalGraph()
|
||||
expect(Object.keys(graph.entities).length).toBe(count)
|
||||
})
|
||||
|
||||
it('does not report a closed on-disk database as cleared', async () => {
|
||||
const projectDir = join(getProjectsDir(), sanitizePath(workspaceDir))
|
||||
const sqlitePath = join(projectDir, 'knowledge.db')
|
||||
|
||||
await addGlobalEntity('tool', 'closed-handle-test', { status: 'persisted' })
|
||||
expect(existsSync(sqlitePath)).toBe(true)
|
||||
|
||||
clearMemoryOnly()
|
||||
|
||||
const closedProvider = new SQLiteProvider(projectDir)
|
||||
expect(closedProvider.clear()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -259,8 +259,31 @@ export class SQLiteProvider {
|
||||
}
|
||||
}
|
||||
|
||||
public clear(): boolean {
|
||||
if (!this.db) {
|
||||
return !existsSync(this.dbPath)
|
||||
}
|
||||
|
||||
try {
|
||||
this.db.transaction(() => {
|
||||
this.db!.exec('DELETE FROM relations')
|
||||
this.db!.exec('DELETE FROM entities')
|
||||
this.db!.exec('DELETE FROM summaries')
|
||||
this.db!.exec('DELETE FROM rules')
|
||||
this.db!.exec('DELETE FROM sync_meta')
|
||||
})()
|
||||
return true
|
||||
} catch (e) {
|
||||
console.error('Failed to clear SQLite knowledge graph:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
public close(): void {
|
||||
if (this.db) {
|
||||
try {
|
||||
this.db.exec('PRAGMA wal_checkpoint(TRUNCATE);')
|
||||
} catch {}
|
||||
try {
|
||||
this.db.close()
|
||||
} catch {}
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
* - Self-referential type wrappers
|
||||
*/
|
||||
import { afterAll, describe, expect, test } from 'bun:test'
|
||||
import { execSync } from 'child_process'
|
||||
import { execFileSync, execSync } from 'child_process'
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, cpSync, readFileSync } from 'fs'
|
||||
import { join } from 'path'
|
||||
import { randomUUID } from 'crypto'
|
||||
@@ -18,6 +18,7 @@ import { randomUUID } from 'crypto'
|
||||
const ROOT = join(import.meta.dir, '..', '..')
|
||||
const SDK_DTS = join(ROOT, 'src', 'entrypoints', 'sdk.d.ts')
|
||||
const CORE_TYPES_TS = join(ROOT, 'src', 'entrypoints', 'sdk', 'coreTypes.generated.ts')
|
||||
const TSC_BIN = join(ROOT, 'node_modules', 'typescript', 'bin', 'tsc')
|
||||
|
||||
/** All temp dirs created during tests — cleaned up in afterAll */
|
||||
const tempDirs: string[] = []
|
||||
@@ -92,7 +93,7 @@ function setupConsumerProject(name: string): string {
|
||||
|
||||
/** Compile consumer.ts in the given tmpDir. Returns stdout (empty = success). */
|
||||
function tsc(tmpDir: string): string {
|
||||
return execSync('npx tsc -p tsconfig.json --pretty false', {
|
||||
return execFileSync(process.execPath, [TSC_BIN, '-p', 'tsconfig.json', '--pretty', 'false'], {
|
||||
cwd: tmpDir,
|
||||
encoding: 'utf-8',
|
||||
timeout: 60000,
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, test, expect, beforeAll, afterAll } from 'bun:test'
|
||||
import { describe, test, expect, beforeEach, afterEach } from 'bun:test'
|
||||
import { query } from '../../src/entrypoints/sdk/index.js'
|
||||
import { getSessionId, getSessionProjectDir, runWithSdkContext } from '../../src/bootstrap/state.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../src/test/sharedMutationLock.js'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { SessionId } from '../../src/types/ids.js'
|
||||
import { drainQuery, UUID_REGEX } from './helpers/query-test-doubles.js'
|
||||
@@ -9,14 +13,19 @@ import { drainQuery, UUID_REGEX } from './helpers/query-test-doubles.js'
|
||||
const AUTH_KEY = 'ANTHROPIC_API_KEY'
|
||||
let savedApiKey: string | undefined
|
||||
|
||||
beforeAll(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('sdk-query-concurrency')
|
||||
savedApiKey = process.env[AUTH_KEY]
|
||||
if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-concurrency-stub'
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
|
||||
else process.env[AUTH_KEY] = savedApiKey
|
||||
afterEach(() => {
|
||||
try {
|
||||
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
|
||||
else process.env[AUTH_KEY] = savedApiKey
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
describe('SEC-1: env override isolation', () => {
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
setOriginalCwd,
|
||||
getParentSessionId,
|
||||
} from '../../src/bootstrap/state.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../src/test/sharedMutationLock.js'
|
||||
import type { SessionId } from '../../src/entrypoints/agentSdkTypes.js'
|
||||
|
||||
// Snapshot global state before each test so we can restore it
|
||||
@@ -20,7 +24,8 @@ let originalOriginalCwd: string
|
||||
let originalSessionProjectDir: string | null
|
||||
|
||||
describe('SDK context isolation', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('sdk-context-isolation')
|
||||
originalSessionId = getSessionId()
|
||||
originalCwd = getCwdState()
|
||||
originalOriginalCwd = getOriginalCwd()
|
||||
@@ -28,10 +33,14 @@ describe('SDK context isolation', () => {
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
// Restore global state after each test
|
||||
switchSession(originalSessionId, originalSessionProjectDir)
|
||||
setCwdState(originalCwd)
|
||||
setOriginalCwd(originalOriginalCwd)
|
||||
try {
|
||||
// Restore global state after each test
|
||||
switchSession(originalSessionId, originalSessionProjectDir)
|
||||
setCwdState(originalCwd)
|
||||
setOriginalCwd(originalOriginalCwd)
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
describe('setCwdState', () => {
|
||||
|
||||
@@ -6,6 +6,20 @@ import { tmpdir } from 'os'
|
||||
import { getProjectDir } from '../../src/utils/sessionStoragePortable.js'
|
||||
import { query } from '../../src/entrypoints/sdk/index.js'
|
||||
import { unstable_v2_resumeSession } from '../../src/entrypoints/sdk/index.js'
|
||||
import {
|
||||
getCwdState,
|
||||
getOriginalCwd,
|
||||
getSessionId,
|
||||
getSessionProjectDir,
|
||||
setCwdState,
|
||||
setOriginalCwd,
|
||||
switchSession,
|
||||
} from '../../src/bootstrap/state.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../src/test/sharedMutationLock.js'
|
||||
import type { SessionId } from '../../src/entrypoints/agentSdkTypes.js'
|
||||
|
||||
/**
|
||||
* Regression test for compact preserved segment handling in SDK resume.
|
||||
@@ -139,12 +153,31 @@ function createCompactTranscriptWithPreservedSegment(
|
||||
}
|
||||
|
||||
let tempDirs: string[] = []
|
||||
let originalSessionId: SessionId
|
||||
let originalSessionProjectDir: string | null
|
||||
let originalCwd: string
|
||||
let originalOriginalCwd: string
|
||||
|
||||
beforeEach(async () => {
|
||||
await acquireSharedMutationLock('sdk-preserved-segment')
|
||||
originalSessionId = getSessionId()
|
||||
originalSessionProjectDir = getSessionProjectDir()
|
||||
originalCwd = getCwdState()
|
||||
originalOriginalCwd = getOriginalCwd()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
try {
|
||||
switchSession(originalSessionId, originalSessionProjectDir)
|
||||
setCwdState(originalCwd)
|
||||
setOriginalCwd(originalOriginalCwd)
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
tempDirs = []
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
tempDirs = []
|
||||
})
|
||||
|
||||
describe('Compact preserved segment regression', () => {
|
||||
@@ -470,4 +503,4 @@ describe('Compact preserved segment regression', () => {
|
||||
|
||||
session.close()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { describe, test, expect, afterEach, beforeAll, afterAll } from 'bun:test'
|
||||
import { describe, test, expect, beforeEach, afterEach, beforeAll, afterAll } from 'bun:test'
|
||||
import { randomUUID } from 'crypto'
|
||||
import { rmSync } from 'fs'
|
||||
import {
|
||||
@@ -6,7 +6,20 @@ import {
|
||||
unstable_v2_resumeSession,
|
||||
unstable_v2_prompt,
|
||||
} from '../../src/entrypoints/sdk/index.js'
|
||||
import { getSessionProjectDir } from '../../src/bootstrap/state.js'
|
||||
import {
|
||||
getCwdState,
|
||||
getOriginalCwd,
|
||||
getSessionId,
|
||||
getSessionProjectDir,
|
||||
setCwdState,
|
||||
setOriginalCwd,
|
||||
switchSession,
|
||||
} from '../../src/bootstrap/state.js'
|
||||
import {
|
||||
acquireSharedMutationLock,
|
||||
releaseSharedMutationLock,
|
||||
} from '../../src/test/sharedMutationLock.js'
|
||||
import type { SessionId } from '../../src/entrypoints/agentSdkTypes.js'
|
||||
import {
|
||||
drainQuery,
|
||||
withTempDir,
|
||||
@@ -19,27 +32,46 @@ import {
|
||||
// sendMessage drains trigger init(), which checks auth. Stub it for CI.
|
||||
const AUTH_KEY = 'ANTHROPIC_API_KEY'
|
||||
let savedApiKey: string | undefined
|
||||
let originalSessionId: SessionId
|
||||
let originalSessionProjectDir: string | null
|
||||
let originalCwd: string
|
||||
let originalOriginalCwd: string
|
||||
|
||||
beforeAll(() => {
|
||||
// Collect temp dirs for cleanup
|
||||
const tempDirs: string[] = []
|
||||
|
||||
beforeAll(async () => {
|
||||
await acquireSharedMutationLock('sdk-v2-lifecycle')
|
||||
savedApiKey = process.env[AUTH_KEY]
|
||||
if (!savedApiKey) process.env[AUTH_KEY] = 'sk-test-v2-lifecycle-stub'
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
|
||||
else process.env[AUTH_KEY] = savedApiKey
|
||||
try {
|
||||
if (savedApiKey === undefined) delete process.env[AUTH_KEY]
|
||||
else process.env[AUTH_KEY] = savedApiKey
|
||||
} finally {
|
||||
releaseSharedMutationLock()
|
||||
}
|
||||
})
|
||||
|
||||
// Collect temp dirs for cleanup
|
||||
const tempDirs: string[] = []
|
||||
|
||||
afterEach(() => {
|
||||
switchSession(originalSessionId, originalSessionProjectDir)
|
||||
setCwdState(originalCwd)
|
||||
setOriginalCwd(originalOriginalCwd)
|
||||
for (const dir of tempDirs) {
|
||||
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
||||
}
|
||||
tempDirs.length = 0
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
originalSessionId = getSessionId()
|
||||
originalSessionProjectDir = getSessionProjectDir()
|
||||
originalCwd = getCwdState()
|
||||
originalOriginalCwd = getOriginalCwd()
|
||||
})
|
||||
|
||||
describe('V2: session creation', () => {
|
||||
test('createSession() returns SDKSession with valid sessionId', () => {
|
||||
const session = unstable_v2_createSession({
|
||||
|
||||
Reference in New Issue
Block a user