From 18c3993df225fca1d0b156d90629add834d40385 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:20:10 +0000 Subject: [PATCH 01/17] refactor(config): move config into module directory --- eslint.config.mjs | 2 +- src/node/{config.ts => config/index.ts} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename src/node/{config.ts => config/index.ts} (100%) diff --git a/eslint.config.mjs b/eslint.config.mjs index f8e44c6cd9..76b2519602 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1488,7 +1488,7 @@ export default defineConfig([ // Temporarily allow sync fs methods in files with existing usage // TODO: Gradually migrate these to async operations files: [ - "src/node/config.ts", + "src/node/config/index.ts", "src/cli/debug/**/*.ts", "src/node/git.ts", "src/desktop/main.ts", diff --git a/src/node/config.ts b/src/node/config/index.ts similarity index 100% rename from src/node/config.ts rename to src/node/config/index.ts From 142ce0d6516856cc060b9de6bf05cb4789b6dc27 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 16:52:10 +0000 Subject: [PATCH 02/17] refactor(config): extract FileLeaseManager and ProvidersConfigStore --- src/cli/run.ts | 21 +- src/cli/runSessionRoot.test.ts | 6 +- src/cli/workflow.ts | 26 +- src/node/config.test.ts | 279 --------- src/node/config/FileLeaseManager.test.ts | 291 +++++++++ src/node/config/FileLeaseManager.ts | 399 ++++++++++++ src/node/config/ProvidersConfigStore.test.ts | 62 ++ src/node/config/ProvidersConfigStore.ts | 193 ++++++ src/node/config/index.ts | 569 +----------------- src/node/orpc/context.ts | 4 +- src/node/services/agentSession.ts | 15 +- src/node/services/aiService.test.ts | 8 +- src/node/services/aiService.ts | 13 +- src/node/services/coderOauthService.test.ts | 125 ++-- src/node/services/coderOauthService.ts | 34 +- src/node/services/codexOauthService.test.ts | 8 +- src/node/services/codexOauthService.ts | 6 +- src/node/services/coreServices.ts | 16 +- .../services/muxGatewayOauthService.test.ts | 8 +- src/node/services/muxGatewayOauthService.ts | 6 +- src/node/services/policyService.test.ts | 6 +- .../services/providerModelFactory.test.ts | 222 +++---- src/node/services/providerModelFactory.ts | 17 +- src/node/services/providerService.test.ts | 293 +++++---- src/node/services/providerService.ts | 71 ++- src/node/services/serviceContainer.ts | 23 +- src/node/services/turnRequestBuilder.test.ts | 12 +- src/node/services/turnRequestBuilder.ts | 8 +- src/node/services/voiceService.test.ts | 24 +- src/node/services/voiceService.ts | 13 +- src/node/services/workspaceGoalService.ts | 12 +- src/node/services/workspaceService.ts | 6 +- tests/ipc/providers/openaiCompatible.test.ts | 4 +- tests/ipc/setup.ts | 4 +- 34 files changed, 1549 insertions(+), 1255 deletions(-) create mode 100644 src/node/config/FileLeaseManager.test.ts create mode 100644 src/node/config/FileLeaseManager.ts create mode 100644 src/node/config/ProvidersConfigStore.test.ts create mode 100644 src/node/config/ProvidersConfigStore.ts diff --git a/src/cli/run.ts b/src/cli/run.ts index 5bd33960a4..680b3aba34 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -14,7 +14,7 @@ import { tool } from "ai"; import { z } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { Config } from "../node/config"; +import { Config, FileLeaseManager, ProvidersConfigStore } from "../node/config"; import { materializeResolvedTrust, replaceRunTrustProjects } from "./trust"; import { runBestEffortCleanup } from "./runCleanup"; import { DisposableTempDir } from "../node/services/tempDir"; @@ -516,7 +516,9 @@ async function main(): Promise { const config = await createRunConfig(tempDir.path, preparedSessionRoot); // Copy providers and secrets from real config to ephemeral config - const existingProviders = realConfig.loadProvidersConfig(); + const realProvidersStore = new ProvidersConfigStore(realConfig.rootDir); + const runProvidersStore = new ProvidersConfigStore(config.rootDir); + const existingProviders = realProvidersStore.loadProvidersConfig(); const providersFile = path.join(config.rootDir, "providers.jsonc"); await replacePrivateRunConfigFile( providersFile, @@ -618,7 +620,7 @@ async function main(): Promise { if (!hasAnyConfiguredProvider(existingProviders)) { const providersFromEnv = buildProvidersFromEnv(); if (hasAnyConfiguredProvider(providersFromEnv)) { - config.saveProvidersConfig(providersFromEnv); + runProvidersStore.saveProvidersConfig(providersFromEnv); } else { throw new Error( "No provider credentials found. Configure providers.jsonc or set ANTHROPIC_API_KEY / OPENAI_API_KEY / OPENROUTER_API_KEY / GOOGLE_GENERATIVE_AI_API_KEY / MOONSHOT_API_KEY." @@ -676,16 +678,23 @@ async function main(): Promise { // `xum run` uses createCoreServices directly (without ServiceContainer), so wire // Codex OAuth explicitly to ensure Codex-routed OpenAI requests can load/refresh // OAuth tokens from providers.jsonc. - const codexOauthService = new CodexOauthService(config, providerService); + const codexOauthService = new CodexOauthService(runProvidersStore, providerService); turnRequestBuilderBindings.codexOauthService = codexOauthService; // Same for Coder OAuth: coder:* models need per-request token loading/refresh. // Bind it to the REAL config (not the ephemeral tempDir copy): Coder rotates // the refresh token on every use, so persisting rotations only to tempDir // would strand ~/.xum/providers.jsonc with a consumed (dead) refresh token // once this CLI session exits. - const realProviderService = new ProviderService(realConfig, policyService); - const coderOauthService = new CoderOauthService( + const realFileLeaseManager = new FileLeaseManager(realConfig.rootDir); + const realProviderService = new ProviderService( realConfig, + policyService, + realProvidersStore, + realFileLeaseManager + ); + const coderOauthService = new CoderOauthService( + realProvidersStore, + realFileLeaseManager, realProviderService, undefined, // Policy-aware: an enforced forcedBaseUrl overrides the deployment URL for diff --git a/src/cli/runSessionRoot.test.ts b/src/cli/runSessionRoot.test.ts index 01f256f8f2..a77676dd0a 100644 --- a/src/cli/runSessionRoot.test.ts +++ b/src/cli/runSessionRoot.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; -import { Config } from "@/node/config"; +import { Config, ProvidersConfigStore } from "@/node/config"; import { createRunConfig, prepareRunSessionRootOverride, @@ -155,7 +155,9 @@ describe("prepareRunSessionRootOverride", () => { JSON.stringify({ openai: { apiKey: "attacker-key", baseUrl: "https://attacker.example" } }) ); - expect(config.loadProvidersConfig()?.openai?.baseUrl).toBe("https://safe.example"); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.baseUrl).toBe( + "https://safe.example" + ); expect(config.loadConfigOrDefault().projects.get("/trusted-project")?.trusted).toBe(true); const replacementProviders = JSON.parse( await fs.readFile(path.join(runRoot, "providers.jsonc"), "utf8") diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index d95342fdb3..f54ffc49b3 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -22,7 +22,7 @@ import { defaultModel } from "@/common/utils/ai/models"; import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { getErrorMessage } from "@/common/utils/errors"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; -import { Config } from "@/node/config"; +import { Config, FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { AgentSession } from "@/node/services/agentSession"; import { CodexOauthService } from "@/node/services/codexOauthService"; @@ -208,9 +208,10 @@ function generateWorkspaceId(): string { } async function copyPersistentConfig(realConfig: Config, config: Config): Promise { - const existingProviders = realConfig.loadProvidersConfig(); + const realProvidersStore = new ProvidersConfigStore(realConfig.rootDir); + const existingProviders = realProvidersStore.loadProvidersConfig(); if (existingProviders != null && hasAnyConfiguredProvider(existingProviders)) { - config.saveProvidersConfig(existingProviders); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig(existingProviders); } const existingSecrets = realConfig.loadSecretsConfig(); if (Object.keys(existingSecrets).length > 0) { @@ -340,11 +341,14 @@ async function createWorkflowContext(options: { const config = new Config(tempDir.path); await copyPersistentConfig(realConfig, config); - const existingProviders = realConfig.loadProvidersConfig(); + const realProvidersStore = new ProvidersConfigStore(realConfig.rootDir); + const realFileLeaseManager = new FileLeaseManager(realConfig.rootDir); + const runProvidersStore = new ProvidersConfigStore(config.rootDir); + const existingProviders = realProvidersStore.loadProvidersConfig(); if (!hasAnyConfiguredProvider(existingProviders)) { const providersFromEnv = buildProvidersFromEnv(); if (hasAnyConfiguredProvider(providersFromEnv)) { - config.saveProvidersConfig(providersFromEnv); + runProvidersStore.saveProvidersConfig(providersFromEnv); } } @@ -367,15 +371,21 @@ async function createWorkflowContext(options: { extensionMetadataPath: path.join(tempDir.path, "extensionMetadata.json"), mcpConfig: realConfig, }); - codexOauthService = new CodexOauthService(config, services.providerService); + codexOauthService = new CodexOauthService(runProvidersStore, services.providerService); services.turnRequestBuilderBindings.codexOauthService = codexOauthService; // Bind Coder OAuth to the REAL config (not the ephemeral tempDir copy): // Coder rotates the refresh token on every use, so persisting rotations // only to tempDir would strand ~/.xum/providers.jsonc with a consumed // (dead) refresh token once this CLI session exits. - realProviderService = new ProviderService(realConfig, policyService); - coderOauthService = new CoderOauthService( + realProviderService = new ProviderService( realConfig, + policyService, + realProvidersStore, + realFileLeaseManager + ); + coderOauthService = new CoderOauthService( + realProvidersStore, + realFileLeaseManager, realProviderService, undefined, // Policy-aware: an enforced forcedBaseUrl overrides the deployment URL diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 81e022122d..caa0b18a05 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -3885,283 +3885,4 @@ describe("Config", () => { }); }); }); - - /** - * Simulate a crashed lock/lease holder: backdate every generation marker - * past the TTL and rewrite its owner PID to one that provably does not - * exist (stale-breaking requires BOTH — a live process's lock is never - * broken). - */ - function markCrashedHolder(lockPath: string, ttlMs: number): void { - const staleTime = new Date(Date.now() - ttlMs - 1_000); - for (const entry of fs.readdirSync(lockPath)) { - const entryPath = path.join(lockPath, entry); - fs.writeFileSync(entryPath, "999999999"); - fs.utimesSync(entryPath, staleTime, staleTime); - } - } - - describe("tryAcquireCoderOauthClientLease", () => { - const TTL_MS = 60_000; - - it("is exclusive until released, including for a second Config on the same root", () => { - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - - // Same file root = same lease, even from another Config instance - // (stands in for another Xum process sharing providers.jsonc). - const otherProcess = new Config(tempDir); - expect(otherProcess.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); - - release!(); - const reacquired = otherProcess.tryAcquireCoderOauthClientLease(TTL_MS); - expect(reacquired).not.toBeNull(); - reacquired!(); - }); - - it("breaks a stale lease left behind by a crashed holder", () => { - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - fs.mkdirSync(leasePath, { recursive: true }); - const staleTime = new Date(Date.now() - TTL_MS - 1_000); - fs.utimesSync(leasePath, staleTime, staleTime); - - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - release!(); - expect(fs.existsSync(leasePath)).toBe(false); - }); - - it("judges staleness by the holder's generation marker, not the lease directory", () => { - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - - // A breaker that judged staleness by the directory alone could destroy - // a live successor generation created between its check and its remove - // (check/remove TOCTOU). Binding staleness to the marker file makes the - // destructive steps conditional: a fresh marker keeps the lease held - // even when the directory timestamp looks stale. - const staleTime = new Date(Date.now() - TTL_MS - 1_000); - fs.utimesSync(leasePath, staleTime, staleTime); - - const otherProcess = new Config(tempDir); - expect(otherProcess.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); - release!(); - }); - - it("does not stale-break a lease whose holder process is still alive", () => { - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - - // The holder outlives the TTL but its process (this one) is alive — - // e.g. a suspended laptop or a stalled event loop. Breaking it would - // let a second flow enter the same critical section and race the - // resumed original; contenders must fail acquisition instead. - const staleTime = new Date(Date.now() - TTL_MS - 1_000); - for (const entry of fs.readdirSync(leasePath)) { - fs.utimesSync(path.join(leasePath, entry), staleTime, staleTime); - } - - const otherProcess = new Config(tempDir); - expect(otherProcess.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); - release!(); - }); - - it("does not release a lease that was stale-broken and reacquired by another process", () => { - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - - const originalRelease = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(originalRelease).not.toBeNull(); - - // The lease crosses the staleness boundary and its holder "crashes" - // (staleness binds to the holder's generation marker + a gone owner - // PID); another process breaks it and acquires its own generation of - // the same path. - markCrashedHolder(leasePath, TTL_MS); - const otherProcess = new Config(tempDir); - const otherRelease = otherProcess.tryAcquireCoderOauthClientLease(TTL_MS); - expect(otherRelease).not.toBeNull(); - - // The original holder's late release must NOT remove the new owner's - // lease — otherwise a third flow could acquire it concurrently and two - // flows would clobber the stored client's single redirect slot. - originalRelease!(); - expect(fs.existsSync(leasePath)).toBe(true); - expect(config.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); - - // The rightful owner can still release it. - otherRelease!(); - expect(fs.existsSync(leasePath)).toBe(false); - }); - - it("reclaims a dead-owner lease immediately, before the TTL elapses", () => { - // Regression: a crashed holder's PID is deterministically dead, so - // contenders must recover the orphan right away. Gating recovery on - // the mtime TTL (which exceeds every acquisition timeout) would make - // the first operation after a crash always fail despite the owner - // being provably gone. - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - fs.mkdirSync(leasePath, { recursive: true }); - // Fresh mtime (NOT backdated) + dead owner PID. - fs.writeFileSync(path.join(leasePath, "owner-crashed"), "999999999"); - - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - release!(); - }); - - it("reclaims an EMPTY orphaned lease directory immediately, before the TTL elapses", () => { - // Regression: acquisition installs the owner marker atomically with the - // lock directory (staged rename), so an empty directory can only be a - // crash remnant — never a live acquisition. A fresh-mtime empty orphan - // previously read as live until the TTL, and every acquisition timeout - // is shorter than its TTL, so the first operation after such a crash - // always failed. - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - fs.mkdirSync(leasePath, { recursive: true }); // Fresh mtime, no marker. - - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - release!(); - expect(fs.existsSync(leasePath)).toBe(false); - }); - - it("sweeps stage directories abandoned by a crashed acquisition", () => { - const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); - const abandonedStage = `${leasePath}.stage-deadbeef`; - fs.mkdirSync(abandonedStage, { recursive: true }); - fs.writeFileSync(path.join(abandonedStage, "owner-orphan"), "999999999"); - const staleTime = new Date(Date.now() - TTL_MS - 1_000); - fs.utimesSync(abandonedStage, staleTime, staleTime); - // A FRESH stage may belong to a concurrent in-flight acquisition and - // must survive the sweep. - const freshStage = `${leasePath}.stage-cafebabe`; - fs.mkdirSync(freshStage, { recursive: true }); - - const release = config.tryAcquireCoderOauthClientLease(TTL_MS); - expect(release).not.toBeNull(); - release!(); - - expect(fs.existsSync(abandonedStage)).toBe(false); - expect(fs.existsSync(freshStage)).toBe(true); - }); - }); - - describe("withProvidersFileLock", () => { - it("acquires over a dead-owner lock immediately, before the TTL elapses", async () => { - // Same regression as the lease variant: withDirLock's acquisition - // timeout (5s) is shorter than its staleness TTL (10s), so a fresh - // crash orphan must be reclaimed via the dead-PID check or the first - // config write after the crash would always time out. - const lockPath = path.join(tempDir, "providers.jsonc.lock"); - fs.mkdirSync(lockPath, { recursive: true }); - fs.writeFileSync(path.join(lockPath, "owner-crashed"), "999999999"); - - const startedAt = Date.now(); - const result = await config.withProvidersFileLock(() => "ran"); - expect(result).toBe("ran"); - // Well under the 5s acquisition timeout: the orphan was reclaimed on - // the first contention check, not waited out. - expect(Date.now() - startedAt).toBeLessThan(2_000); - expect(fs.existsSync(lockPath)).toBe(false); - }); - - it("acquires over an EMPTY orphaned lock directory immediately, before the TTL elapses", async () => { - // Regression: acquisition installs the owner marker atomically with the - // lock directory (staged rename), so an empty directory can only be a - // crash remnant — never a live acquisition. Previously a fresh-mtime - // empty orphan read as live until the 10s TTL, and the 5s acquisition - // timeout always fired first, so the first config write after such a - // crash always timed out. - const lockPath = path.join(tempDir, "providers.jsonc.lock"); - fs.mkdirSync(lockPath, { recursive: true }); // Fresh mtime, no marker. - - const startedAt = Date.now(); - const result = await config.withProvidersFileLock(() => "ran"); - expect(result).toBe("ran"); - expect(Date.now() - startedAt).toBeLessThan(2_000); - expect(fs.existsSync(lockPath)).toBe(false); - }); - }); - - describe("withCoderOauthRefreshLock", () => { - it("serializes critical sections, including across Config instances on the same root", async () => { - // A second Config on the same root stands in for another Xum process - // sharing providers.jsonc. - const otherProcess = new Config(tempDir); - const events: string[] = []; - let releaseFirst!: () => void; - const firstGate = new Promise((resolve) => (releaseFirst = resolve)); - let firstEntered!: () => void; - const firstEnteredPromise = new Promise((resolve) => (firstEntered = resolve)); - - const first = config.withCoderOauthRefreshLock(async () => { - events.push("first:enter"); - firstEntered(); - await firstGate; - events.push("first:exit"); - }); - await firstEnteredPromise; - - const second = otherProcess.withCoderOauthRefreshLock(() => { - events.push("second:enter"); - }); - // The second section must not start while the first holds the lock. - await new Promise((resolve) => setTimeout(resolve, 50)); - expect(events).toEqual(["first:enter"]); - - releaseFirst(); - await Promise.all([first, second]); - expect(events).toEqual(["first:enter", "first:exit", "second:enter"]); - }); - - it("does not release a successor's lock after being stale-broken mid-section", async () => { - // A holder that outlives staleLockMs (suspended process, stalled event - // loop) can be stale-broken and the lock reacquired before its release - // runs. That release must only remove its OWN generation — deleting the - // successor's lock would let a third process into the critical section - // (for the refresh lock, the concurrent rotating-refresh-token race). - const lockPath = path.join(tempDir, "providers.jsonc.coder-refresh.lock"); - const otherProcess = new Config(tempDir); - - let releaseFirst!: () => void; - const firstGate = new Promise((resolve) => (releaseFirst = resolve)); - let firstEntered!: () => void; - const firstEnteredPromise = new Promise((resolve) => (firstEntered = resolve)); - const first = config.withCoderOauthRefreshLock(async () => { - firstEntered(); - await firstGate; - }); - await firstEnteredPromise; - - // The first holder's process "crashes" past the staleness boundary - // (backdated marker + gone owner PID) while its release closure is - // still pending, and a second process stale-breaks + reacquires. - markCrashedHolder(lockPath, 120_000); - let releaseSecond!: () => void; - const secondGate = new Promise((resolve) => (releaseSecond = resolve)); - let secondEntered!: () => void; - const secondEnteredPromise = new Promise((resolve) => (secondEntered = resolve)); - const second = otherProcess.withCoderOauthRefreshLock(async () => { - secondEntered(); - await secondGate; - }); - await secondEnteredPromise; - - // The original holder finishes while the successor still holds the - // lock: its release must keep the successor's generation in place. - releaseFirst(); - await first; - expect(fs.existsSync(lockPath)).toBe(true); - expect(fs.readdirSync(lockPath).length).toBe(1); - - releaseSecond(); - await second; - // The successor's own release still cleans up normally. - expect(fs.existsSync(lockPath)).toBe(false); - }); - }); }); diff --git a/src/node/config/FileLeaseManager.test.ts b/src/node/config/FileLeaseManager.test.ts new file mode 100644 index 0000000000..6b6db332e5 --- /dev/null +++ b/src/node/config/FileLeaseManager.test.ts @@ -0,0 +1,291 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { FileLeaseManager } from "./FileLeaseManager"; + +describe("FileLeaseManager", () => { + let tempDir: string; + let manager: FileLeaseManager; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-file-lease-test-")); + manager = new FileLeaseManager(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + function markCrashedHolder(leasePath: string, ttlMs: number): void { + const staleTime = new Date(Date.now() - ttlMs - 1_000); + fs.utimesSync(leasePath, staleTime, staleTime); + for (const entry of fs.readdirSync(leasePath)) { + const entryPath = path.join(leasePath, entry); + fs.writeFileSync(entryPath, "999999999"); + fs.utimesSync(entryPath, staleTime, staleTime); + } + } + + describe("tryAcquireCoderOauthClientLease", () => { + const TTL_MS = 60_000; + + it("is exclusive until released, including for a second FileLeaseManager on the same root", () => { + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + + // Same file root = same lease, even from another FileLeaseManager instance + // (stands in for another Xum process sharing providers.jsonc). + const otherProcess = new FileLeaseManager(tempDir); + expect(otherProcess.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); + + release!(); + const reacquired = otherProcess.tryAcquireCoderOauthClientLease(TTL_MS); + expect(reacquired).not.toBeNull(); + reacquired!(); + }); + + it("breaks a stale lease left behind by a crashed holder", () => { + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + fs.mkdirSync(leasePath, { recursive: true }); + const staleTime = new Date(Date.now() - TTL_MS - 1_000); + fs.utimesSync(leasePath, staleTime, staleTime); + + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + release!(); + expect(fs.existsSync(leasePath)).toBe(false); + }); + + it("judges staleness by the holder's generation marker, not the lease directory", () => { + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + + // A breaker that judged staleness by the directory alone could destroy + // a live successor generation created between its check and its remove + // (check/remove TOCTOU). Binding staleness to the marker file makes the + // destructive steps conditional: a fresh marker keeps the lease held + // even when the directory timestamp looks stale. + const staleTime = new Date(Date.now() - TTL_MS - 1_000); + fs.utimesSync(leasePath, staleTime, staleTime); + + const otherProcess = new FileLeaseManager(tempDir); + expect(otherProcess.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); + release!(); + }); + + it("does not stale-break a lease whose holder process is still alive", () => { + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + + // The holder outlives the TTL but its process (this one) is alive — + // e.g. a suspended laptop or a stalled event loop. Breaking it would + // let a second flow enter the same critical section and race the + // resumed original; contenders must fail acquisition instead. + const staleTime = new Date(Date.now() - TTL_MS - 1_000); + for (const entry of fs.readdirSync(leasePath)) { + fs.utimesSync(path.join(leasePath, entry), staleTime, staleTime); + } + + const otherProcess = new FileLeaseManager(tempDir); + expect(otherProcess.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); + release!(); + }); + + it("does not release a lease that was stale-broken and reacquired by another process", () => { + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + + const originalRelease = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(originalRelease).not.toBeNull(); + + // The lease crosses the staleness boundary and its holder "crashes" + // (staleness binds to the holder's generation marker + a gone owner + // PID); another process breaks it and acquires its own generation of + // the same path. + markCrashedHolder(leasePath, TTL_MS); + const otherProcess = new FileLeaseManager(tempDir); + const otherRelease = otherProcess.tryAcquireCoderOauthClientLease(TTL_MS); + expect(otherRelease).not.toBeNull(); + + // The original holder's late release must NOT remove the new owner's + // lease — otherwise a third flow could acquire it concurrently and two + // flows would clobber the stored client's single redirect slot. + originalRelease!(); + expect(fs.existsSync(leasePath)).toBe(true); + expect(manager.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); + + // The rightful owner can still release it. + otherRelease!(); + expect(fs.existsSync(leasePath)).toBe(false); + }); + + it("reclaims a dead-owner lease immediately, before the TTL elapses", () => { + // Regression: a crashed holder's PID is deterministically dead, so + // contenders must recover the orphan right away. Gating recovery on + // the mtime TTL (which exceeds every acquisition timeout) would make + // the first operation after a crash always fail despite the owner + // being provably gone. + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + fs.mkdirSync(leasePath, { recursive: true }); + // Fresh mtime (NOT backdated) + dead owner PID. + fs.writeFileSync(path.join(leasePath, "owner-crashed"), "999999999"); + + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + release!(); + }); + + it("reclaims an EMPTY orphaned lease directory immediately, before the TTL elapses", () => { + // Regression: acquisition installs the owner marker atomically with the + // lock directory (staged rename), so an empty directory can only be a + // crash remnant — never a live acquisition. A fresh-mtime empty orphan + // previously read as live until the TTL, and every acquisition timeout + // is shorter than its TTL, so the first operation after such a crash + // always failed. + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + fs.mkdirSync(leasePath, { recursive: true }); // Fresh mtime, no marker. + + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + release!(); + expect(fs.existsSync(leasePath)).toBe(false); + }); + + it("sweeps stage directories abandoned by a crashed acquisition", () => { + const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); + const abandonedStage = `${leasePath}.stage-deadbeef`; + fs.mkdirSync(abandonedStage, { recursive: true }); + fs.writeFileSync(path.join(abandonedStage, "owner-orphan"), "999999999"); + const staleTime = new Date(Date.now() - TTL_MS - 1_000); + fs.utimesSync(abandonedStage, staleTime, staleTime); + // A FRESH stage may belong to a concurrent in-flight acquisition and + // must survive the sweep. + const freshStage = `${leasePath}.stage-cafebabe`; + fs.mkdirSync(freshStage, { recursive: true }); + + const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); + expect(release).not.toBeNull(); + release!(); + + expect(fs.existsSync(abandonedStage)).toBe(false); + expect(fs.existsSync(freshStage)).toBe(true); + }); + }); + + describe("withProvidersFileLock", () => { + it("acquires over a dead-owner lock immediately, before the TTL elapses", async () => { + // Same regression as the lease variant: withDirLock's acquisition + // timeout (5s) is shorter than its staleness TTL (10s), so a fresh + // crash orphan must be reclaimed via the dead-PID check or the first + // config write after the crash would always time out. + const lockPath = path.join(tempDir, "providers.jsonc.lock"); + fs.mkdirSync(lockPath, { recursive: true }); + fs.writeFileSync(path.join(lockPath, "owner-crashed"), "999999999"); + + const startedAt = Date.now(); + const result = await manager.withProvidersFileLock(() => "ran"); + expect(result).toBe("ran"); + // Well under the 5s acquisition timeout: the orphan was reclaimed on + // the first contention check, not waited out. + expect(Date.now() - startedAt).toBeLessThan(2_000); + expect(fs.existsSync(lockPath)).toBe(false); + }); + + it("acquires over an EMPTY orphaned lock directory immediately, before the TTL elapses", async () => { + // Regression: acquisition installs the owner marker atomically with the + // lock directory (staged rename), so an empty directory can only be a + // crash remnant — never a live acquisition. Previously a fresh-mtime + // empty orphan read as live until the 10s TTL, and the 5s acquisition + // timeout always fired first, so the first config write after such a + // crash always timed out. + const lockPath = path.join(tempDir, "providers.jsonc.lock"); + fs.mkdirSync(lockPath, { recursive: true }); // Fresh mtime, no marker. + + const startedAt = Date.now(); + const result = await manager.withProvidersFileLock(() => "ran"); + expect(result).toBe("ran"); + expect(Date.now() - startedAt).toBeLessThan(2_000); + expect(fs.existsSync(lockPath)).toBe(false); + }); + }); + + describe("withCoderOauthRefreshLock", () => { + it("serializes critical sections, including across FileLeaseManager instances on the same root", async () => { + // A second FileLeaseManager on the same root stands in for another Xum process + // sharing providers.jsonc. + const otherProcess = new FileLeaseManager(tempDir); + const events: string[] = []; + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => (releaseFirst = resolve)); + let firstEntered!: () => void; + const firstEnteredPromise = new Promise((resolve) => (firstEntered = resolve)); + + const first = manager.withCoderOauthRefreshLock(async () => { + events.push("first:enter"); + firstEntered(); + await firstGate; + events.push("first:exit"); + }); + await firstEnteredPromise; + + const second = otherProcess.withCoderOauthRefreshLock(() => { + events.push("second:enter"); + }); + // The second section must not start while the first holds the lock. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(events).toEqual(["first:enter"]); + + releaseFirst(); + await Promise.all([first, second]); + expect(events).toEqual(["first:enter", "first:exit", "second:enter"]); + }); + + it("does not release a successor's lock after being stale-broken mid-section", async () => { + // A holder that outlives staleLockMs (suspended process, stalled event + // loop) can be stale-broken and the lock reacquired before its release + // runs. That release must only remove its OWN generation — deleting the + // successor's lock would let a third process into the critical section + // (for the refresh lock, the concurrent rotating-refresh-token race). + const lockPath = path.join(tempDir, "providers.jsonc.coder-refresh.lock"); + const otherProcess = new FileLeaseManager(tempDir); + + let releaseFirst!: () => void; + const firstGate = new Promise((resolve) => (releaseFirst = resolve)); + let firstEntered!: () => void; + const firstEnteredPromise = new Promise((resolve) => (firstEntered = resolve)); + const first = manager.withCoderOauthRefreshLock(async () => { + firstEntered(); + await firstGate; + }); + await firstEnteredPromise; + + // The first holder's process "crashes" past the staleness boundary + // (backdated marker + gone owner PID) while its release closure is + // still pending, and a second process stale-breaks + reacquires. + markCrashedHolder(lockPath, 120_000); + let releaseSecond!: () => void; + const secondGate = new Promise((resolve) => (releaseSecond = resolve)); + let secondEntered!: () => void; + const secondEnteredPromise = new Promise((resolve) => (secondEntered = resolve)); + const second = otherProcess.withCoderOauthRefreshLock(async () => { + secondEntered(); + await secondGate; + }); + await secondEnteredPromise; + + // The first holder's delayed release runs now. It must NOT unlink the + // second holder's marker or delete the directory. + releaseFirst(); + expect(fs.existsSync(lockPath)).toBe(true); + + // The second holder's critical section completes and cleans up cleanly. + releaseSecond(); + await Promise.all([first, second]); + expect(fs.existsSync(lockPath)).toBe(false); + }); + }); +}); diff --git a/src/node/config/FileLeaseManager.ts b/src/node/config/FileLeaseManager.ts new file mode 100644 index 0000000000..755f1a12cd --- /dev/null +++ b/src/node/config/FileLeaseManager.ts @@ -0,0 +1,399 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import { getXumHome } from "@/common/constants/paths"; +import { log } from "@/node/services/log"; +import { ensurePrivateDirSync } from "@/node/utils/fs"; + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code === "EPERM"; + } +} + +export class FileLeaseManager { + readonly rootDir: string; + readonly providersFile: string; + + constructor(rootDir?: string) { + this.rootDir = rootDir ?? getXumHome(); + this.providersFile = path.join(this.rootDir, "providers.jsonc"); + } + + /** + * Advisory cross-process lock for providers.jsonc read-modify-write cycles. + * + * Multiple xum processes (desktop app, `xum run`, `xum workflow`) share + * providers.jsonc, and OAuth credential rotation requires compare-and-set + * semantics across them. Exclusive directory creation is atomic on all + * platforms, so `.lock/` serves as the mutex. Locks orphaned + * by crashed processes are broken after a staleness timeout. + */ + async withProvidersFileLock(fn: () => Promise | T): Promise { + // Guards sub-second file mutations, so contention resolves quickly. + return this.withDirLock(`${this.providersFile}.lock`, 5_000, 10_000, fn); + } + + /** + * Cross-process serialization of Coder OAuth token refreshes. + * + * Coder rotates refresh tokens on every use, so two processes refreshing + * the same credential race destructively: the loser's `invalid_grant` can + * arrive — and its compare-and-clear delete the credential — while the + * winner's rotation is still in flight and not yet on disk, after which the + * winner's persist CAS fails too and BOTH processes discard the only valid + * token. Serializing the whole refresh round-trip (re-read + token request + * + persist) closes that window: a loser re-reads inside the lock and + * adopts the winner's rotation without ever sending a doomed request. + * + * Timing: the guarded section includes one bounded token request (30s cap, + * see TOKEN_REQUEST_TIMEOUT_MS in coderOauthService.ts), so acquisition + * waits up to 45s and orphaned locks are broken after 60s. + */ + async withCoderOauthRefreshLock(fn: () => Promise | T): Promise { + return this.withDirLock(`${this.providersFile}.coder-refresh.lock`, 45_000, 60_000, fn); + } + + /** + * Cross-process serialization of Coder OAuth desktop-login commits + * (persist -> finish/rollback; see commitDesktopLogin in + * coderOauthService.ts). + * + * A login's rollback snapshot (`previousSection`) must only ever capture a + * COMMITTED section. Login flows are process-local, but the persisted + * section is shared across processes: without this lock, a flow in process + * B could snapshot process A's persisted-but-uncommitted login; if both + * were then cancelled, A's rollback would skip (B's auth is current) and + * revoke A's tokens, after which B's rollback would restore that + * already-revoked auth over the original login. + * + * Timing: the guarded section is a handful of providers-file mutations and + * no network I/O (revocation runs after release), so acquisition waits up + * to 15s and orphaned locks are broken after 20s. + */ + async withCoderOauthLoginCommitLock(fn: () => Promise | T): Promise { + return this.withDirLock(`${this.providersFile}.coder-login.lock`, 15_000, 20_000, fn); + } + + /** + * Atomically install a generation-marked lock directory at `lockPath`: the + * owner marker (content = holder PID, see tryBreakStaleDirLock) is written + * into a staged sibling directory which is then rename(2)d into place. + * Acquisition and marker creation are therefore a single atomic step — a + * live acquisition is never observable as an EMPTY lock directory, so an + * empty directory is always a crash remnant (the unlink→rmdir window of + * release/stale-break) that breakers may reclaim immediately. Without this, + * a crash between mkdir and marker write would look live until the mtime + * TTL, and every acquisition timeout is shorter than its TTL — the first + * operation after such a crash would always time out. + * + * On POSIX, rename onto an existing EMPTY directory atomically replaces it + * (instant orphan recovery); onto a non-empty one it fails ENOTEMPTY. On + * Windows, rename onto any existing directory fails — contenders recover + * empty orphans via tryBreakStaleDirLock instead. + * + * Returns the installed marker path, or null when the lock is held + * (contended). Unexpected filesystem errors (EACCES, EROFS, ...) are + * rethrown after the stage directory is cleaned up. + */ + private tryInstallDirLock(lockPath: string): string | null { + const stagePath = `${lockPath}.stage-${crypto.randomBytes(8).toString("hex")}`; + const markerName = `owner-${crypto.randomBytes(16).toString("hex")}`; + fs.mkdirSync(stagePath); + try { + fs.writeFileSync(path.join(stagePath, markerName), String(process.pid)); + fs.renameSync(stagePath, lockPath); + } catch (error) { + try { + fs.rmSync(stagePath, { recursive: true, force: true }); + } catch { + // Best effort; abandoned stages are swept by cleanupAbandonedStageDirs. + } + const code = (error as NodeJS.ErrnoException).code; + // POSIX rename refuses a non-empty target with ENOTEMPTY (some + // platforms report EEXIST); Windows refuses any existing target with + // EPERM/EEXIST. + if (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") { + return null; + } + throw error; + } + return path.join(lockPath, markerName); + } + + /** + * Remove stage directories abandoned by a crash between staging and the + * rename in tryInstallDirLock. TTL-gated on mtime so a concurrent + * acquisition's in-flight stage (a microseconds-wide window) is never + * destroyed under a live process. + */ + private cleanupAbandonedStageDirs(lockPath: string, ttlMs: number): void { + const parent = path.dirname(lockPath); + const prefix = `${path.basename(lockPath)}.stage-`; + let entries: string[]; + try { + entries = fs.readdirSync(parent); + } catch { + return; + } + for (const entry of entries) { + if (!entry.startsWith(prefix)) { + continue; + } + const stagePath = path.join(parent, entry); + try { + if (Date.now() - fs.statSync(stagePath).mtimeMs > ttlMs) { + fs.rmSync(stagePath, { recursive: true, force: true }); + } + } catch { + // Best effort (already removed, or racing its own install). + } + } + } + + /** + * Shared advisory directory lock: acquisition atomically installs the lock + * directory together with its generation marker (see tryInstallDirLock); + * locks orphaned by crashed processes are broken once they are older than + * `staleLockMs` AND their owner process is gone + * (see tryBreakStaleDirLock — live-but-stalled holders are never broken; + * contenders instead fail acquisition at the bounded timeout). + * + * Ownership generations: a holder that runs past `staleLockMs` (suspended + * process, stalled event loop) can be stale-broken and the lock reacquired + * before its release runs — an unconditional removal would then delete the + * successor's lock and let a third process into the critical section. Each + * acquisition therefore writes a generation-unique marker file and release + * only removes that generation (see tryBreakStaleDirLock for the breaker's + * matching conditional cleanup). + */ + async withDirLock( + lockPath: string, + acquireTimeoutMs: number, + staleLockMs: number, + fn: () => Promise | T + ): Promise { + const RETRY_DELAY_MS = 25; + const deadline = Date.now() + acquireTimeoutMs; + + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + this.cleanupAbandonedStageDirs(lockPath, staleLockMs); + + let ownerFile: string; + for (;;) { + // tryInstallDirLock rethrows permanent filesystem errors (EACCES, + // EROFS, ...) — they would fail on every retry, so callers surface an + // error instead of spinning until the deadline. + const installed = this.tryInstallDirLock(lockPath); + if (installed != null) { + ownerFile = installed; + break; + } + if (Date.now() > deadline) { + throw new Error(`Timed out acquiring providers config lock at ${lockPath}`); + } + // Held by another process (or a crashed one): break stale locks, then + // retry — immediately after a break/vanish, with a delay for a live + // holder. + if (this.tryBreakStaleDirLock(lockPath, staleLockMs)) { + continue; + } + await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); + } + + try { + return await fn(); + } finally { + try { + fs.unlinkSync(ownerFile); + try { + fs.rmdirSync(lockPath); + } catch (error) { + // ENOENT/ENOTEMPTY: a breaker finished the removal or a successor + // generation already acquired the path — leave it to them. + log.debug("Failed to release providers config lock:", error); + } + } catch { + // Marker already gone: this holder outlived staleLockMs and was + // stale-broken; a successor may hold the lock now — keep it. + } + } + } + + /** + * Try to take an exclusive cross-process lease on the stored Coder OAuth + * dynamic client. The client's registration has a single redirect_uris + * slot, so only one login flow — across every Xum process sharing this + * providers file — may reuse (and RFC 7592-update) it at a time; callers + * that fail to acquire the lease must register a fresh client instead. + * + * Non-blocking: returns a release function on success, or null when another + * live flow holds the lease. Unlike withProvidersFileLock (which guards + * sub-second file mutations), this lease spans a whole login flow — the + * redirect URI must stay registered until the user finishes authorizing — + * so staleness is judged against `ttlMs` (the flow timeout). A crashed + * holder's lease is broken after that (only once its process is provably + * gone, see tryBreakStaleDirLock), and in the interim other flows degrade + * gracefully to fresh client registrations. + * + * Ownership safety: a lease that crosses the staleness boundary can be + * broken and reacquired by another process at any instant, so neither + * release nor stale-breaking may check-then-recursively-remove (the check + * and the rm would race the handover). Instead each acquisition writes a + * generation-unique marker FILE inside the lease directory, and every + * destructive step is conditional at the filesystem layer: unlink can only + * remove the specific generation's marker (a successor's marker has a + * different name), and the non-recursive rmdir only removes an EMPTY + * directory — never a directory a successor generation re-marked. + */ + tryAcquireCoderOauthClientLease(ttlMs: number): (() => void) | null { + const leasePath = `${this.providersFile}.coder-client.lock`; + + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + + this.cleanupAbandonedStageDirs(leasePath, ttlMs); + + for (let attempt = 0; attempt < 2; attempt++) { + let ownerFile: string; + try { + const installed = this.tryInstallDirLock(leasePath); + if (installed == null) { + // Contended: held by another flow (or a crash remnant). + if (!this.tryBreakStaleDirLock(leasePath, ttlMs)) { + return null; // Held by a live flow. + } + continue; // Stale lease broken (or it vanished); retry once. + } + ownerFile = installed; + } catch (error) { + // Filesystem errors mean the lease was never installed. The lease is + // an optimization with a documented degradation path — callers fall + // back to registering a fresh client — so prefer a working login + // over surfacing an acquisition error. + log.debug("Failed to install Coder OAuth client lease:", error); + return null; + } + + return () => { + try { + fs.unlinkSync(ownerFile); + } catch { + return; // Stale-broken and reacquired by another flow; keep it. + } + try { + fs.rmdirSync(leasePath); + } catch (error) { + // A release racing the staleness boundary can lose the directory to + // a concurrent breaker after the unlink above: ENOENT means the + // breaker finished the removal, ENOTEMPTY means a successor already + // acquired a new generation — both correctly leave it untouched. + log.debug("Failed to release Coder OAuth client lease:", error); + } + }; + } + return null; + } + + /** + * Break a marker-based directory lock/lease left behind by a crashed (or + * stalled-past-staleness) holder. Shared by withDirLock and + * tryAcquireCoderOauthClientLease, whose generation-marker layout matches. + * Returns true when the caller should retry acquisition (the lock was + * stale or vanished mid-check), false when it is held by a live owner. + */ + private tryBreakStaleDirLock(leasePath: string, ttlMs: number): boolean { + let entries: string[]; + try { + entries = fs.readdirSync(leasePath); + } catch { + return true; // Released between the failed mkdir and now; retry. + } + const isStale = (mtimeMs: number) => Date.now() - mtimeMs > ttlMs; + + if (entries.length === 0) { + // Acquisition installs the marker atomically with the directory + // (staged rename — see tryInstallDirLock), so an empty lock directory + // is never a live acquisition: it can only be a crash remnant from the + // unlink→rmdir window of release/stale-break. Reclaim it immediately — + // waiting out the mtime TTL would make every acquisition timeout (all + // shorter than their TTLs) fire first, so the first operation after + // such a crash would always fail despite being deterministically + // recoverable. The non-recursive rmdir keeps the race with a concurrent + // installer safe: it cannot destroy a renamed-in full generation. + try { + fs.rmdirSync(leasePath); + } catch { + // ENOTEMPTY (a generation was renamed into place) or ENOENT (another + // breaker won); the retried install/staleness check sorts either out. + } + return true; + } + + // Staleness binds to the OBSERVED generation's marker: marker names are + // generation-unique, so if the lease changes hands after this check the + // unlink below ENOENTs and the rmdir ENOTEMPTYs — a live successor lease + // is never destroyed (the reason breaking must not use recursive rm). + for (const entry of entries) { + const entryPath = path.join(leasePath, entry); + // The marker carries the owner's PID, checked FIRST: + // - Owner provably ALIVE: never break, however old the marker. A live + // process that merely outlived the TTL (suspended laptop, stalled + // event loop) may still be mid-critical-section; breaking would let a + // second process in, and for the refresh lock the resumed original + // could then race the successor over the same rotating refresh token + // — both sides clearing/revoking the only valid credential. + // Contenders instead fail bounded (withDirLock times out, the client + // lease falls back to a fresh registration). + // - Owner provably DEAD: reclaim immediately, however fresh the marker. + // A dead process cannot be mid-critical-section, and every + // acquisition timeout is shorter than its staleness TTL — waiting for + // the TTL would make the first operation after a crash always time + // out even though the orphan is deterministically recoverable. + // - Owner unknown (unreadable/partial marker): fall back to the mtime + // TTL, the only remaining staleness signal. + // Residual risk: a recycled PID belonging to an unrelated live process + // keeps an orphaned lock alive until that process exits — rare, and + // strictly safer than destroying a live holder's lock. + let ownerPid: number | null = null; + try { + const content = fs.readFileSync(entryPath, "utf8").trim(); + ownerPid = /^\d+$/.test(content) ? Number(content) : null; + } catch { + continue; // Vanished mid-check; the conditional cleanup below is safe. + } + if (ownerPid !== null) { + if (isProcessAlive(ownerPid)) { + return false; + } + } else { + try { + if (!isStale(fs.statSync(entryPath).mtimeMs)) { + return false; + } + } catch { + continue; // Vanished mid-check; the conditional cleanup below is safe. + } + } + try { + fs.unlinkSync(entryPath); + } catch { + // Already removed by a concurrent breaker or by its owner's release. + } + } + try { + fs.rmdirSync(leasePath); + } catch { + // ENOTEMPTY (a generation appeared) or ENOENT (another breaker won); + // the retried mkdir/staleness check sorts either out. + } + return true; + } +} diff --git a/src/node/config/ProvidersConfigStore.test.ts b/src/node/config/ProvidersConfigStore.test.ts new file mode 100644 index 0000000000..51fce3c678 --- /dev/null +++ b/src/node/config/ProvidersConfigStore.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { ProvidersConfigStore } from "./ProvidersConfigStore"; + +describe("ProvidersConfigStore", () => { + let tempDir: string; + let store: ProvidersConfigStore; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-providers-config-test-")); + store = new ProvidersConfigStore(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + it("returns null when providers.jsonc does not exist", () => { + expect(store.loadProvidersConfig()).toBeNull(); + expect(store.getProvidersFileFingerprint()).toBeNull(); + }); + + it("saves and loads providers config with comments", () => { + store.saveProvidersConfig({ + openai: { apiKey: "sk-test-key" }, + }); + + const loaded = store.loadProvidersConfig(); + expect(loaded?.openai?.apiKey).toBe("sk-test-key"); + + const content = fs.readFileSync(store.providersFile, "utf-8"); + expect(content).toContain("// Providers configuration for xum"); + expect(content).toContain('"apiKey": "sk-test-key"'); + + const fingerprint = store.getProvidersFileFingerprint(); + expect(fingerprint).not.toBeNull(); + expect(typeof fingerprint).toBe("string"); + }); + + it("watches providers file for external changes", async () => { + let triggered = false; + const cleanup = store.watchProvidersFile(() => { + triggered = true; + }); + + try { + fs.writeFileSync( + store.providersFile, + JSON.stringify({ anthropic: { apiKey: "sk-ant-test" } }) + ); + for (let i = 0; i < 20; i++) { + if (triggered) break; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + expect(triggered).toBe(true); + } finally { + cleanup(); + } + }); +}); diff --git a/src/node/config/ProvidersConfigStore.ts b/src/node/config/ProvidersConfigStore.ts new file mode 100644 index 0000000000..2858a6e4ff --- /dev/null +++ b/src/node/config/ProvidersConfigStore.ts @@ -0,0 +1,193 @@ +import * as crypto from "crypto"; +import * as fs from "fs"; +import * as path from "path"; +import * as jsonc from "jsonc-parser"; +import writeFileAtomic from "write-file-atomic"; +import { getXumHome } from "@/common/constants/paths"; +import type { + BaseProviderConfig as ProviderConfig, + ProvidersConfig as CanonicalProvidersConfig, +} from "@/common/config/schemas/providersConfig"; +import { log } from "@/node/services/log"; +import { ensurePrivateDirSync } from "@/node/utils/fs"; + +export type ProvidersConfig = CanonicalProvidersConfig | Record; + +export class ProvidersConfigStore { + readonly rootDir: string; + readonly providersFile: string; + + constructor(rootDir?: string) { + this.rootDir = rootDir ?? getXumHome(); + this.providersFile = path.join(this.rootDir, "providers.jsonc"); + } + + /** + * Load providers configuration from JSONC file + * Supports comments in JSONC format + */ + loadProvidersConfig(): ProvidersConfig | null { + try { + if (fs.existsSync(this.providersFile)) { + const data = fs.readFileSync(this.providersFile, "utf-8"); + return jsonc.parse(data) as ProvidersConfig; + } + } catch (error) { + log.error("Error loading providers config:", error); + } + + return null; + } + + /** + * Return a content fingerprint (sha256) of providers.jsonc, or null if + * the file doesn't exist or can't be read. Used by callers to + * distinguish between watcher events triggered by their own saves + * versus genuine external edits. + * + * We hash the file contents rather than comparing mtime: filesystems + * with coarse timestamp granularity (FAT, some network mounts) can + * bucket two distinct writes into the same `mtimeMs`, which would let + * a real external edit be silently suppressed. If two writes happen + * to produce byte-identical content, suppressing the refresh is a + * no-op anyway, so content equality is the safest possible self- + * write signal. + */ + getProvidersFileFingerprint(): string | null { + try { + const contents = fs.readFileSync(this.providersFile); + return crypto.createHash("sha256").update(contents).digest("hex"); + } catch { + return null; + } + } + + /** + * Watch providers.jsonc for external edits. Fires callback (debounced 300 ms) + * on any create/modify/delete event. Returns a cleanup function. + * + * We watch the parent directory rather than the file directly so that + * creates (first-time manual edit) are also detected on all platforms. + */ + watchProvidersFile(callback: () => void): () => void { + const filename = path.basename(this.providersFile); + let debounceTimer: ReturnType | null = null; + + const fire = (): void => { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + callback(); + }, 300); + }; + + // Anything inside this block can fail in restricted environments: + // - ensurePrivateDirSync: read-only filesystem, unwritable MUX_ROOT + // - fs.watch: ENOENT, network filesystems (NFS/SMB), watch-limit + // exhaustion (ENOSPC on Linux), unsupported virtualized mounts. + // We degrade gracefully in every case: log once, return a no-op + // cleanup, and let the rest of provider config keep working. The UI + // just won't auto-refresh on manual edits in that environment (same + // as the pre-PR behaviour). + let watcher: fs.FSWatcher; + try { + // The xum home directory may not exist on a fresh install. Create it + // so fs.watch doesn't throw ENOENT; the directory being empty is fine. + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + + // persistent: false so the watcher doesn't prevent the process (or + // Jest) from exiting when nothing else is keeping the event loop alive. + watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { + // changedFilename can be null on some platforms/kernels (notably + // older macOS FSEvents). When we can't tell which file changed, + // assume providers.jsonc might have and let the consumer re-fetch + // — better an extra refresh than a missed one, since this is the + // exact scenario the feature is meant to fix. + if (changedFilename != null && changedFilename !== filename) return; + fire(); + }); + + // Without an 'error' listener, FSWatcher errors emit on the global + // 'uncaughtException' path and can terminate the process (e.g. if the + // xum home directory is removed or unmounted after startup). Handle + // it locally: degrade to "no live refresh" the same way we do when + // setup itself fails. The watcher is dead after an error, so we + // close it defensively and clear any pending debounce so the + // cleanup function returned below remains a safe no-op. + watcher.on("error", (error) => { + log.warn( + `providers.jsonc watcher error (${this.rootDir}); live refresh disabled until restart:`, + error + ); + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + try { + watcher.close(); + } catch { + // Watcher may already be torn down by the OS — nothing to do. + } + }); + } catch (error) { + log.warn( + `Could not watch providers.jsonc for external edits (${this.rootDir}); manual edits will require a restart to take effect:`, + error + ); + const noop = (): void => { + // Nothing to clean up — watcher setup never completed. + }; + return noop; + } + + return () => { + if (debounceTimer) clearTimeout(debounceTimer); + watcher.close(); + }; + } + + /** + * Save providers configuration to JSONC file + * @param config The providers configuration to save + */ + saveProvidersConfig(config: ProvidersConfig): void { + try { + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + + // Format with 2-space indentation for readability + const jsonString = JSON.stringify(config, null, 2); + + // Add a comment header to the file + const contentWithComments = `// Providers configuration for xum +// Configure your AI providers here +// Example: +// { +// "anthropic": { +// "apiKey": "sk-ant-..." +// }, +// "openai": { +// "apiKey": "sk-..." +// }, +// "xai": { +// "apiKey": "sk-xai-..." +// }, +// "ollama": { +// "baseUrl": "http://localhost:11434/api" // Optional - only needed for remote/custom URL +// } +// } +${jsonString}`; + + writeFileAtomic.sync(this.providersFile, contentWithComments, { + encoding: "utf-8", + mode: 0o600, + }); + } catch (error) { + log.error("Error saving providers config:", error); + throw error; // Re-throw to let caller handle + } + } +} diff --git a/src/node/config/index.ts b/src/node/config/index.ts index b44a9b8754..891b02f15b 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -1,11 +1,11 @@ import * as fs from "fs"; import * as path from "path"; import * as crypto from "crypto"; -import * as jsonc from "jsonc-parser"; import { EventEmitter } from "events"; import writeFileAtomic from "write-file-atomic"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { log } from "@/node/services/log"; +import { ProvidersConfigStore } from "./ProvidersConfigStore"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; import { isSecretReferenceValue, type Secret, type SecretsConfig } from "@/common/types/secrets"; import { Err, Ok, type Result } from "@/common/types/result"; @@ -86,7 +86,8 @@ import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking // Re-export project/provider types from dedicated schema/types files (for preload usage) export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig }; -export type ProvidersConfig = CanonicalProvidersConfig | Record; +export { FileLeaseManager } from "./FileLeaseManager"; +export { ProvidersConfigStore, type ProvidersConfig } from "./ProvidersConfigStore"; /** True only for fs errors whose errno code is ENOENT (genuinely missing path). */ function isEnoentError(error: unknown): boolean { @@ -286,20 +287,6 @@ function parseWorktreeArchiveBehavior(value: unknown): WorktreeArchiveBehavior | return isWorktreeArchiveBehavior(value) ? value : undefined; } -/** - * Whether a process with `pid` currently exists. Signal 0 performs only the - * existence/permission check; EPERM means the process exists but belongs to - * another user (still alive for lock-liveness purposes). - */ -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (error) { - return (error as NodeJS.ErrnoException).code === "EPERM"; - } -} - function resolveDeleteWorktreeOnArchive(deleteWorktreeOnArchive: unknown): boolean { return parseOptionalBoolean(deleteWorktreeOnArchive) ?? false; } @@ -910,7 +897,7 @@ export class Config { readonly sessionsDir: string; readonly srcDir: string; private readonly configFile: string; - private readonly providersFile: string; + private readonly providersConfigStore: ProvidersConfigStore; private readonly secretsFile: string; private readonly emitter = new EventEmitter(); /** @@ -924,13 +911,13 @@ export class Config { /** One-shot guard for the queued load-time migration persist; see loadConfigOrDefault. */ private migrationPersist: Promise | null = null; - constructor(rootDir?: string) { + constructor(rootDir?: string, providersConfigStore?: ProvidersConfigStore) { this.rootDir = rootDir ?? getXumHome(); this.sessionsDir = path.join(this.rootDir, "sessions"); this.srcDir = path.join(this.rootDir, "src"); this.configFile = path.join(this.rootDir, "config.json"); - this.providersFile = path.join(this.rootDir, "providers.jsonc"); this.secretsFile = path.join(this.rootDir, "secrets.json"); + this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(this.rootDir); } private rememberLegacyTaskVariantWorkspace( @@ -1006,7 +993,7 @@ export class Config { * undefined otherwise — letting callers fall back to their own defaults. */ private seedRoutePriorityFromProviders(): string[] | undefined { - const providersConfig = this.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const priority: string[] = []; for (const gw of GATEWAY_PROVIDERS) { @@ -3530,548 +3517,6 @@ export class Config { }); } - /** - * Load providers configuration from JSONC file - * Supports comments in JSONC format - */ - loadProvidersConfig(): ProvidersConfig | null { - try { - if (fs.existsSync(this.providersFile)) { - const data = fs.readFileSync(this.providersFile, "utf-8"); - return jsonc.parse(data) as ProvidersConfig; - } - } catch (error) { - log.error("Error loading providers config:", error); - } - - return null; - } - - /** - * Return a content fingerprint (sha256) of providers.jsonc, or null if - * the file doesn't exist or can't be read. Used by callers to - * distinguish between watcher events triggered by their own saves - * versus genuine external edits. - * - * We hash the file contents rather than comparing mtime: filesystems - * with coarse timestamp granularity (FAT, some network mounts) can - * bucket two distinct writes into the same `mtimeMs`, which would let - * a real external edit be silently suppressed. If two writes happen - * to produce byte-identical content, suppressing the refresh is a - * no-op anyway, so content equality is the safest possible self- - * write signal. - */ - getProvidersFileFingerprint(): string | null { - try { - const contents = fs.readFileSync(this.providersFile); - return crypto.createHash("sha256").update(contents).digest("hex"); - } catch { - return null; - } - } - - /** - * Watch providers.jsonc for external edits. Fires callback (debounced 300 ms) - * on any create/modify/delete event. Returns a cleanup function. - * - * We watch the parent directory rather than the file directly so that - * creates (first-time manual edit) are also detected on all platforms. - */ - watchProvidersFile(callback: () => void): () => void { - const filename = path.basename(this.providersFile); - let debounceTimer: ReturnType | null = null; - - const fire = (): void => { - if (debounceTimer) clearTimeout(debounceTimer); - debounceTimer = setTimeout(() => { - debounceTimer = null; - callback(); - }, 300); - }; - - // Anything inside this block can fail in restricted environments: - // - ensurePrivateDirSync: read-only filesystem, unwritable MUX_ROOT - // - fs.watch: ENOENT, network filesystems (NFS/SMB), watch-limit - // exhaustion (ENOSPC on Linux), unsupported virtualized mounts. - // We degrade gracefully in every case: log once, return a no-op - // cleanup, and let the rest of provider config keep working. The UI - // just won't auto-refresh on manual edits in that environment (same - // as the pre-PR behaviour). - let watcher: fs.FSWatcher; - try { - // The xum home directory may not exist on a fresh install. Create it - // so fs.watch doesn't throw ENOENT; the directory being empty is fine. - if (!fs.existsSync(this.rootDir)) { - ensurePrivateDirSync(this.rootDir); - } - - // persistent: false so the watcher doesn't prevent the process (or - // Jest) from exiting when nothing else is keeping the event loop alive. - watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { - // changedFilename can be null on some platforms/kernels (notably - // older macOS FSEvents). When we can't tell which file changed, - // assume providers.jsonc might have and let the consumer re-fetch - // — better an extra refresh than a missed one, since this is the - // exact scenario the feature is meant to fix. - if (changedFilename != null && changedFilename !== filename) return; - fire(); - }); - - // Without an 'error' listener, FSWatcher errors emit on the global - // 'uncaughtException' path and can terminate the process (e.g. if the - // xum home directory is removed or unmounted after startup). Handle - // it locally: degrade to "no live refresh" the same way we do when - // setup itself fails. The watcher is dead after an error, so we - // close it defensively and clear any pending debounce so the - // cleanup function returned below remains a safe no-op. - watcher.on("error", (error) => { - log.warn( - `providers.jsonc watcher error (${this.rootDir}); live refresh disabled until restart:`, - error - ); - if (debounceTimer) { - clearTimeout(debounceTimer); - debounceTimer = null; - } - try { - watcher.close(); - } catch { - // Watcher may already be torn down by the OS — nothing to do. - } - }); - } catch (error) { - log.warn( - `Could not watch providers.jsonc for external edits (${this.rootDir}); manual edits will require a restart to take effect:`, - error - ); - const noop = (): void => { - // Nothing to clean up — watcher setup never completed. - }; - return noop; - } - - return () => { - if (debounceTimer) clearTimeout(debounceTimer); - watcher.close(); - }; - } - - /** - * Advisory cross-process lock for providers.jsonc read-modify-write cycles. - * - * Multiple xum processes (desktop app, `xum run`, `xum workflow`) share - * providers.jsonc, and OAuth credential rotation requires compare-and-set - * semantics across them. Exclusive directory creation is atomic on all - * platforms, so `.lock/` serves as the mutex. Locks orphaned - * by crashed processes are broken after a staleness timeout. - */ - async withProvidersFileLock(fn: () => Promise | T): Promise { - // Guards sub-second file mutations, so contention resolves quickly. - return this.withDirLock(`${this.providersFile}.lock`, 5_000, 10_000, fn); - } - - /** - * Cross-process serialization of Coder OAuth token refreshes. - * - * Coder rotates refresh tokens on every use, so two processes refreshing - * the same credential race destructively: the loser's `invalid_grant` can - * arrive — and its compare-and-clear delete the credential — while the - * winner's rotation is still in flight and not yet on disk, after which the - * winner's persist CAS fails too and BOTH processes discard the only valid - * token. Serializing the whole refresh round-trip (re-read + token request - * + persist) closes that window: a loser re-reads inside the lock and - * adopts the winner's rotation without ever sending a doomed request. - * - * Timing: the guarded section includes one bounded token request (30s cap, - * see TOKEN_REQUEST_TIMEOUT_MS in coderOauthService.ts), so acquisition - * waits up to 45s and orphaned locks are broken after 60s. - */ - async withCoderOauthRefreshLock(fn: () => Promise | T): Promise { - return this.withDirLock(`${this.providersFile}.coder-refresh.lock`, 45_000, 60_000, fn); - } - - /** - * Cross-process serialization of Coder OAuth desktop-login commits - * (persist -> finish/rollback; see commitDesktopLogin in - * coderOauthService.ts). - * - * A login's rollback snapshot (`previousSection`) must only ever capture a - * COMMITTED section. Login flows are process-local, but the persisted - * section is shared across processes: without this lock, a flow in process - * B could snapshot process A's persisted-but-uncommitted login; if both - * were then cancelled, A's rollback would skip (B's auth is current) and - * revoke A's tokens, after which B's rollback would restore that - * already-revoked auth over the original login. - * - * Timing: the guarded section is a handful of providers-file mutations and - * no network I/O (revocation runs after release), so acquisition waits up - * to 15s and orphaned locks are broken after 20s. - */ - async withCoderOauthLoginCommitLock(fn: () => Promise | T): Promise { - return this.withDirLock(`${this.providersFile}.coder-login.lock`, 15_000, 20_000, fn); - } - - /** - * Atomically install a generation-marked lock directory at `lockPath`: the - * owner marker (content = holder PID, see tryBreakStaleDirLock) is written - * into a staged sibling directory which is then rename(2)d into place. - * Acquisition and marker creation are therefore a single atomic step — a - * live acquisition is never observable as an EMPTY lock directory, so an - * empty directory is always a crash remnant (the unlink→rmdir window of - * release/stale-break) that breakers may reclaim immediately. Without this, - * a crash between mkdir and marker write would look live until the mtime - * TTL, and every acquisition timeout is shorter than its TTL — the first - * operation after such a crash would always time out. - * - * On POSIX, rename onto an existing EMPTY directory atomically replaces it - * (instant orphan recovery); onto a non-empty one it fails ENOTEMPTY. On - * Windows, rename onto any existing directory fails — contenders recover - * empty orphans via tryBreakStaleDirLock instead. - * - * Returns the installed marker path, or null when the lock is held - * (contended). Unexpected filesystem errors (EACCES, EROFS, ...) are - * rethrown after the stage directory is cleaned up. - */ - private tryInstallDirLock(lockPath: string): string | null { - const stagePath = `${lockPath}.stage-${crypto.randomBytes(8).toString("hex")}`; - const markerName = `owner-${crypto.randomBytes(16).toString("hex")}`; - fs.mkdirSync(stagePath); - try { - fs.writeFileSync(path.join(stagePath, markerName), String(process.pid)); - fs.renameSync(stagePath, lockPath); - } catch (error) { - try { - fs.rmSync(stagePath, { recursive: true, force: true }); - } catch { - // Best effort; abandoned stages are swept by cleanupAbandonedStageDirs. - } - const code = (error as NodeJS.ErrnoException).code; - // POSIX rename refuses a non-empty target with ENOTEMPTY (some - // platforms report EEXIST); Windows refuses any existing target with - // EPERM/EEXIST. - if (code === "EEXIST" || code === "ENOTEMPTY" || code === "EPERM") { - return null; - } - throw error; - } - return path.join(lockPath, markerName); - } - - /** - * Remove stage directories abandoned by a crash between staging and the - * rename in tryInstallDirLock. TTL-gated on mtime so a concurrent - * acquisition's in-flight stage (a microseconds-wide window) is never - * destroyed under a live process. - */ - private cleanupAbandonedStageDirs(lockPath: string, ttlMs: number): void { - const parent = path.dirname(lockPath); - const prefix = `${path.basename(lockPath)}.stage-`; - let entries: string[]; - try { - entries = fs.readdirSync(parent); - } catch { - return; - } - for (const entry of entries) { - if (!entry.startsWith(prefix)) { - continue; - } - const stagePath = path.join(parent, entry); - try { - if (Date.now() - fs.statSync(stagePath).mtimeMs > ttlMs) { - fs.rmSync(stagePath, { recursive: true, force: true }); - } - } catch { - // Best effort (already removed, or racing its own install). - } - } - } - - /** - * Shared advisory directory lock: acquisition atomically installs the lock - * directory together with its generation marker (see tryInstallDirLock); - * locks orphaned by crashed processes are broken once they are older than - * `staleLockMs` AND their owner process is gone - * (see tryBreakStaleDirLock — live-but-stalled holders are never broken; - * contenders instead fail acquisition at the bounded timeout). - * - * Ownership generations: a holder that runs past `staleLockMs` (suspended - * process, stalled event loop) can be stale-broken and the lock reacquired - * before its release runs — an unconditional removal would then delete the - * successor's lock and let a third process into the critical section. Each - * acquisition therefore writes a generation-unique marker file and release - * only removes that generation (see tryBreakStaleDirLock for the breaker's - * matching conditional cleanup). - */ - private async withDirLock( - lockPath: string, - acquireTimeoutMs: number, - staleLockMs: number, - fn: () => Promise | T - ): Promise { - const RETRY_DELAY_MS = 25; - const deadline = Date.now() + acquireTimeoutMs; - - if (!fs.existsSync(this.rootDir)) { - ensurePrivateDirSync(this.rootDir); - } - this.cleanupAbandonedStageDirs(lockPath, staleLockMs); - - let ownerFile: string; - for (;;) { - // tryInstallDirLock rethrows permanent filesystem errors (EACCES, - // EROFS, ...) — they would fail on every retry, so callers surface an - // error instead of spinning until the deadline. - const installed = this.tryInstallDirLock(lockPath); - if (installed != null) { - ownerFile = installed; - break; - } - if (Date.now() > deadline) { - throw new Error(`Timed out acquiring providers config lock at ${lockPath}`); - } - // Held by another process (or a crashed one): break stale locks, then - // retry — immediately after a break/vanish, with a delay for a live - // holder. - if (this.tryBreakStaleDirLock(lockPath, staleLockMs)) { - continue; - } - await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS)); - } - - try { - return await fn(); - } finally { - try { - fs.unlinkSync(ownerFile); - try { - fs.rmdirSync(lockPath); - } catch (error) { - // ENOENT/ENOTEMPTY: a breaker finished the removal or a successor - // generation already acquired the path — leave it to them. - log.debug("Failed to release providers config lock:", error); - } - } catch { - // Marker already gone: this holder outlived staleLockMs and was - // stale-broken; a successor may hold the lock now — keep it. - } - } - } - - /** - * Try to take an exclusive cross-process lease on the stored Coder OAuth - * dynamic client. The client's registration has a single redirect_uris - * slot, so only one login flow — across every Xum process sharing this - * providers file — may reuse (and RFC 7592-update) it at a time; callers - * that fail to acquire the lease must register a fresh client instead. - * - * Non-blocking: returns a release function on success, or null when another - * live flow holds the lease. Unlike withProvidersFileLock (which guards - * sub-second file mutations), this lease spans a whole login flow — the - * redirect URI must stay registered until the user finishes authorizing — - * so staleness is judged against `ttlMs` (the flow timeout). A crashed - * holder's lease is broken after that (only once its process is provably - * gone, see tryBreakStaleDirLock), and in the interim other flows degrade - * gracefully to fresh client registrations. - * - * Ownership safety: a lease that crosses the staleness boundary can be - * broken and reacquired by another process at any instant, so neither - * release nor stale-breaking may check-then-recursively-remove (the check - * and the rm would race the handover). Instead each acquisition writes a - * generation-unique marker FILE inside the lease directory, and every - * destructive step is conditional at the filesystem layer: unlink can only - * remove the specific generation's marker (a successor's marker has a - * different name), and the non-recursive rmdir only removes an EMPTY - * directory — never a directory a successor generation re-marked. - */ - tryAcquireCoderOauthClientLease(ttlMs: number): (() => void) | null { - const leasePath = `${this.providersFile}.coder-client.lock`; - - if (!fs.existsSync(this.rootDir)) { - ensurePrivateDirSync(this.rootDir); - } - - this.cleanupAbandonedStageDirs(leasePath, ttlMs); - - for (let attempt = 0; attempt < 2; attempt++) { - let ownerFile: string; - try { - const installed = this.tryInstallDirLock(leasePath); - if (installed == null) { - // Contended: held by another flow (or a crash remnant). - if (!this.tryBreakStaleDirLock(leasePath, ttlMs)) { - return null; // Held by a live flow. - } - continue; // Stale lease broken (or it vanished); retry once. - } - ownerFile = installed; - } catch (error) { - // Filesystem errors mean the lease was never installed. The lease is - // an optimization with a documented degradation path — callers fall - // back to registering a fresh client — so prefer a working login - // over surfacing an acquisition error. - log.debug("Failed to install Coder OAuth client lease:", error); - return null; - } - - return () => { - try { - fs.unlinkSync(ownerFile); - } catch { - return; // Stale-broken and reacquired by another flow; keep it. - } - try { - fs.rmdirSync(leasePath); - } catch (error) { - // A release racing the staleness boundary can lose the directory to - // a concurrent breaker after the unlink above: ENOENT means the - // breaker finished the removal, ENOTEMPTY means a successor already - // acquired a new generation — both correctly leave it untouched. - log.debug("Failed to release Coder OAuth client lease:", error); - } - }; - } - return null; - } - - /** - * Break a marker-based directory lock/lease left behind by a crashed (or - * stalled-past-staleness) holder. Shared by withDirLock and - * tryAcquireCoderOauthClientLease, whose generation-marker layout matches. - * Returns true when the caller should retry acquisition (the lock was - * stale or vanished mid-check), false when it is held by a live owner. - */ - private tryBreakStaleDirLock(leasePath: string, ttlMs: number): boolean { - let entries: string[]; - try { - entries = fs.readdirSync(leasePath); - } catch { - return true; // Released between the failed mkdir and now; retry. - } - const isStale = (mtimeMs: number) => Date.now() - mtimeMs > ttlMs; - - if (entries.length === 0) { - // Acquisition installs the marker atomically with the directory - // (staged rename — see tryInstallDirLock), so an empty lock directory - // is never a live acquisition: it can only be a crash remnant from the - // unlink→rmdir window of release/stale-break. Reclaim it immediately — - // waiting out the mtime TTL would make every acquisition timeout (all - // shorter than their TTLs) fire first, so the first operation after - // such a crash would always fail despite being deterministically - // recoverable. The non-recursive rmdir keeps the race with a concurrent - // installer safe: it cannot destroy a renamed-in full generation. - try { - fs.rmdirSync(leasePath); - } catch { - // ENOTEMPTY (a generation was renamed into place) or ENOENT (another - // breaker won); the retried install/staleness check sorts either out. - } - return true; - } - - // Staleness binds to the OBSERVED generation's marker: marker names are - // generation-unique, so if the lease changes hands after this check the - // unlink below ENOENTs and the rmdir ENOTEMPTYs — a live successor lease - // is never destroyed (the reason breaking must not use recursive rm). - for (const entry of entries) { - const entryPath = path.join(leasePath, entry); - // The marker carries the owner's PID, checked FIRST: - // - Owner provably ALIVE: never break, however old the marker. A live - // process that merely outlived the TTL (suspended laptop, stalled - // event loop) may still be mid-critical-section; breaking would let a - // second process in, and for the refresh lock the resumed original - // could then race the successor over the same rotating refresh token - // — both sides clearing/revoking the only valid credential. - // Contenders instead fail bounded (withDirLock times out, the client - // lease falls back to a fresh registration). - // - Owner provably DEAD: reclaim immediately, however fresh the marker. - // A dead process cannot be mid-critical-section, and every - // acquisition timeout is shorter than its staleness TTL — waiting for - // the TTL would make the first operation after a crash always time - // out even though the orphan is deterministically recoverable. - // - Owner unknown (unreadable/partial marker): fall back to the mtime - // TTL, the only remaining staleness signal. - // Residual risk: a recycled PID belonging to an unrelated live process - // keeps an orphaned lock alive until that process exits — rare, and - // strictly safer than destroying a live holder's lock. - let ownerPid: number | null = null; - try { - const content = fs.readFileSync(entryPath, "utf8").trim(); - ownerPid = /^\d+$/.test(content) ? Number(content) : null; - } catch { - continue; // Vanished mid-check; the conditional cleanup below is safe. - } - if (ownerPid !== null) { - if (isProcessAlive(ownerPid)) { - return false; - } - } else { - try { - if (!isStale(fs.statSync(entryPath).mtimeMs)) { - return false; - } - } catch { - continue; // Vanished mid-check; the conditional cleanup below is safe. - } - } - try { - fs.unlinkSync(entryPath); - } catch { - // Already removed by a concurrent breaker or by its owner's release. - } - } - try { - fs.rmdirSync(leasePath); - } catch { - // ENOTEMPTY (a generation appeared) or ENOENT (another breaker won); - // the retried mkdir/staleness check sorts either out. - } - return true; - } - - /** - * Save providers configuration to JSONC file - * @param config The providers configuration to save - */ - saveProvidersConfig(config: ProvidersConfig): void { - try { - if (!fs.existsSync(this.rootDir)) { - ensurePrivateDirSync(this.rootDir); - } - - // Format with 2-space indentation for readability - const jsonString = JSON.stringify(config, null, 2); - - // Add a comment header to the file - const contentWithComments = `// Providers configuration for xum -// Configure your AI providers here -// Example: -// { -// "anthropic": { -// "apiKey": "sk-ant-..." -// }, -// "openai": { -// "apiKey": "sk-..." -// }, -// "xai": { -// "apiKey": "sk-xai-..." -// }, -// "ollama": { -// "baseUrl": "http://localhost:11434/api" // Optional - only needed for remote/custom URL -// } -// } -${jsonString}`; - - writeFileAtomic.sync(this.providersFile, contentWithComments, { - encoding: "utf-8", - mode: 0o600, - }); - } catch (error) { - log.error("Error saving providers config:", error); - throw error; // Re-throw to let caller handle - } - } - private static readonly GLOBAL_SECRETS_KEY = "__global__"; private static normalizeSecretsProjectPath(projectPath: string): string { diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 2f5a1271db..9e35987b53 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -1,6 +1,6 @@ import type { IJSRuntimeFactory } from "@/node/services/ptc/runtime"; import type { IncomingHttpHeaders } from "http"; -import type { Config } from "@/node/config"; +import type { Config, FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -56,6 +56,8 @@ import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenMa export interface ORPCContext { config: Config; + providersConfigStore: ProvidersConfigStore; + fileLeaseManager: FileLeaseManager; aiService: AIService; historyService: HistoryService; streamManager: StreamManager; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 0262507f38..cf9cff6912 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -7,7 +7,7 @@ import type { LanguageModelV2Usage } from "@ai-sdk/provider"; import { PlatformPaths } from "@/common/utils/paths"; import { log } from "@/node/services/log"; import { eventSpine } from "@/node/services/events/eventSpine"; -import type { Config } from "@/node/config"; +import { ProvidersConfigStore, type Config } from "@/node/config"; import type { TurnStreamHandle } from "@/node/services/streamManager"; import type { StreamMessageOptions } from "@/node/services/turnRequestBuilder"; import type { HistoryService } from "@/node/services/historyService"; @@ -4299,17 +4299,8 @@ export class AgentSession { return maybeAIService.getProvidersConfig(); } - // Some unit tests provide minimal service mocks; fall back to raw config so custom - // provider model context overrides still work in those environments. - const maybeConfig = this.config as Config & { - loadProvidersConfig?: () => ProvidersConfigMap | null; - }; - if (typeof maybeConfig.loadProvidersConfig !== "function") { - return null; - } - - // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern - return maybeConfig.loadProvidersConfig() as unknown as ProvidersConfigMap | null; + const providersConfig = new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig(); + return providersConfig as unknown as ProvidersConfigMap | null; } catch { // Best-effort read: if config cannot be loaded, keep null and rely on // built-in model limits. This matches prior behavior without crashing. diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index aa5ae7beac..16577f80f1 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -18,7 +18,7 @@ import { HistoryService } from "./historyService"; import { InitStateManager } from "./initStateManager"; import { ProviderService } from "./providerService"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; -import { Config } from "@/node/config"; +import { Config, ProvidersConfigStore } from "@/node/config"; import * as runtimeFactory from "@/node/runtime/runtimeFactory"; import { LocalRuntime } from "@/node/runtime/LocalRuntime"; import { DisposableTempDir } from "@/node/services/tempDir"; @@ -182,7 +182,7 @@ function configureOpenAICodexOAuth( requests: RecordedFetchRequest[], options?: { defaultAuth?: "apiKey"; responseModel?: string; setOauthService?: boolean } ): void { - config.loadProvidersConfig = () => ({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "test-openai-api-key", codexOauth: TEST_CODEX_OAUTH, @@ -2048,7 +2048,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { } as unknown as SessionUsageService; const harness = createHarness(xumHome.path, metadata, { sessionUsageService }); const metadataModel = KNOWN_MODELS.SONNET.id; - harness.config.saveProvidersConfig({ + new ProvidersConfigStore(harness.config.rootDir).saveProvidersConfig({ anthropic: { models: [{ id: "custom-sonnet", mappedToModel: metadataModel }], }, @@ -2166,7 +2166,7 @@ describe("AIService.streamMessage compaction boundary slicing", () => { } as unknown as SessionUsageService; const harness = createHarness(xumHome.path, metadata, { sessionUsageService }); - harness.config.saveProvidersConfig({ + new ProvidersConfigStore(harness.config.rootDir).saveProvidersConfig({ openai: { codexOauth: { type: "oauth", diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 8e4081ac41..40cd4389ad 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -28,7 +28,7 @@ import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; import type { XumToolScope } from "@/common/types/toolScope"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; -import type { Config } from "@/node/config"; +import { ProvidersConfigStore, type Config } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import type { Runtime } from "@/node/runtime/Runtime"; @@ -117,6 +117,7 @@ export class AIService extends EventEmitter { private readonly providerService: ProviderService; private readonly providerModelFactory: ProviderModelFactory; private readonly devToolsService?: DevToolsService; + private readonly providersConfigStore: ProvidersConfigStore; private readonly experimentsService?: ExperimentsService; /** @@ -144,7 +145,8 @@ export class AIService extends EventEmitter { devToolsService?: DevToolsService, experimentsService?: ExperimentsService, streamManager?: StreamManager, - public readonly turnRequestBuilderBindings: TurnRequestBuilderBindings = {} + public readonly turnRequestBuilderBindings: TurnRequestBuilderBindings = {}, + providersConfigStore?: ProvidersConfigStore ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -152,6 +154,7 @@ export class AIService extends EventEmitter { this.setMaxListeners(50); this.workspaceMcpOverridesService = workspaceMcpOverridesService ?? new WorkspaceMcpOverridesService(config); + this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(config.rootDir); this.config = config; this.historyService = historyService; this.initStateManager = initStateManager; @@ -174,10 +177,12 @@ export class AIService extends EventEmitter { providerService, policyService, turnRequestBuilderBindings, - devToolsService + devToolsService, + this.providersConfigStore ); this.turnRequestBuilder = new TurnRequestBuilder({ config: this.config, + providersConfigStore: this.providersConfigStore, historyService: this.historyService, initStateManager: this.initStateManager, providerService: this.providerService, @@ -548,7 +553,7 @@ export class AIService extends EventEmitter { modelString: string, opts?: { agentInitiated?: boolean; workspaceId?: string } ): Promise> { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const result = await this.providerModelFactory.createModel(modelString, undefined, { ...opts, providersConfig, diff --git a/src/node/services/coderOauthService.test.ts b/src/node/services/coderOauthService.test.ts index d917c9db46..a87b930d7f 100644 --- a/src/node/services/coderOauthService.test.ts +++ b/src/node/services/coderOauthService.test.ts @@ -3,7 +3,7 @@ import * as crypto from "crypto"; import type { Result } from "@/common/types/result"; import { Err, Ok } from "@/common/types/result"; -import type { Config, ProvidersConfig } from "@/node/config"; +import type { FileLeaseManager, ProvidersConfig, ProvidersConfigStore } from "@/node/config"; import type { ProviderService } from "@/node/services/providerService"; import type { WindowService } from "@/node/services/windowService"; import type { ProviderModelEntry } from "@/common/config/schemas/providerModelEntry"; @@ -126,24 +126,23 @@ function createMockDeps(): MockDeps { }; } -function createMockConfig( +function createMockProvidersConfigStore( deps: MockDeps -): Pick< - Config, - | "loadProvidersConfig" - | "getProvidersFileFingerprint" - | "tryAcquireCoderOauthClientLease" - | "withCoderOauthRefreshLock" - | "withCoderOauthLoginCommitLock" -> { +): Pick { return { loadProvidersConfig: () => deps.providersConfig, - // Mirrors Config.getProvidersFileFingerprint (content hash): changes - // whenever the in-memory providers config changes, exactly like the real - // file fingerprint changes on every persisted write. getProvidersFileFingerprint: () => JSON.stringify(deps.providersConfig), - // Mirrors Config.tryAcquireCoderOauthClientLease: non-blocking, exclusive, - // released via the returned function. + }; +} + +function createMockFileLeaseManager( + deps: MockDeps, + overrides?: Partial +): Pick< + FileLeaseManager, + "tryAcquireCoderOauthClientLease" | "withCoderOauthRefreshLock" | "withCoderOauthLoginCommitLock" +> { + return { tryAcquireCoderOauthClientLease: () => { if (deps.coderClientLeaseHeld) { return null; @@ -153,10 +152,9 @@ function createMockConfig( deps.coderClientLeaseHeld = false; }; }, - // Single-process tests do not contend on the cross-process locks; the - // two-process race tests wire shared serializing locks instead. withCoderOauthRefreshLock: async (fn: () => Promise | T): Promise => await fn(), withCoderOauthLoginCommitLock: async (fn: () => Promise | T): Promise => await fn(), + ...overrides, }; } @@ -286,7 +284,8 @@ function createMockWindowService(deps: MockDeps): Pick { const sharedLock = createSharedCrossProcessLock(); const makeProcess = (): CoderOauthService => new CoderOauthService( - { - ...createMockConfig(deps), + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps, { withCoderOauthRefreshLock: sharedLock, - } as Config, + }) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService ); @@ -703,10 +702,10 @@ describe("CoderOauthService", () => { return await fn(); }; service = new CoderOauthService( - { - ...createMockConfig(deps), + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps, { withCoderOauthRefreshLock: lockSimulatingEdit, - } as Config, + }) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService ); @@ -789,7 +788,8 @@ describe("CoderOauthService", () => { Promise.resolve(Err("Failed to update provider config: disk full")), }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, failingProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); @@ -1112,7 +1112,8 @@ describe("CoderOauthService", () => { // backend RPCs from registering OAuth clients or minting credentials — // not merely hide the login UI. service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService, { @@ -1142,7 +1143,8 @@ describe("CoderOauthService", () => { // after coder was denied, and the exchanged tokens must be revoked. let coderAllowed = true; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService, { @@ -1241,7 +1243,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService, { @@ -1354,18 +1357,18 @@ describe("CoderOauthService", () => { }, }; const serviceA = new CoderOauthService( - { - ...createMockConfig(deps), + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps, { withCoderOauthLoginCommitLock: sharedCommitLock, - } as Config, + }) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService ); const serviceB = new CoderOauthService( - { - ...createMockConfig(deps), + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps, { withCoderOauthLoginCommitLock: sharedCommitLock, - } as Config, + }) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService ); @@ -1455,7 +1458,8 @@ describe("CoderOauthService", () => { // must be revoked against their own issuer instead. let forcedUrl: string | undefined = undefined; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService, { @@ -1560,12 +1564,14 @@ describe("CoderOauthService", () => { }, }; const serviceA = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService ); const serviceB = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService ); @@ -1642,7 +1648,8 @@ describe("CoderOauthService", () => { it("logs in to the policy-forced deployment instead of the requested URL", async () => { const FORCED_URL = "http://locked.coder.test"; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService, { @@ -1883,7 +1890,8 @@ describe("CoderOauthService", () => { Promise.resolve({ success: false as const, error: "providers.jsonc is unwritable" }), }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, failingProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); @@ -1947,11 +1955,11 @@ describe("CoderOauthService", () => { // task: the flow must finish with the error immediately (not hang until // the five-minute timeout) and the exchanged tokens must be revoked. service = new CoderOauthService( - { - ...createMockConfig(deps), + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps, { withCoderOauthLoginCommitLock: (_fn: () => Promise | T): Promise => Promise.reject(new Error("EACCES: permission denied, mkdir lock")), - } as Config, + }) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService ); @@ -2105,7 +2113,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService ); @@ -2200,7 +2209,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService ); @@ -2300,7 +2310,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); @@ -2405,7 +2416,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService ); @@ -2593,7 +2605,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, gatedProviderService as ProviderService, createMockWindowService(deps) as WindowService ); @@ -2740,10 +2753,10 @@ describe("CoderOauthService", () => { }, }; const service = new CoderOauthService( - { - ...createMockConfig(deps), + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps, { withCoderOauthLoginCommitLock: sharedCommitLock, - } as Config, + }) as FileLeaseManager, gatedProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); @@ -3278,7 +3291,8 @@ describe("CoderOauthService", () => { updateProviderSection: () => Promise.resolve(Err("disk full")), }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, failingProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); @@ -3319,7 +3333,8 @@ describe("CoderOauthService", () => { // Policy is applied at exposure time instead (getConfig filtering, // routing checks, per-model factory enforcement). service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, createMockProviderService(deps) as ProviderService, createMockWindowService(deps) as WindowService, { @@ -5053,7 +5068,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, injectingProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); @@ -5104,7 +5120,8 @@ describe("CoderOauthService", () => { }, }; service = new CoderOauthService( - createMockConfig(deps) as Config, + createMockProvidersConfigStore(deps) as ProvidersConfigStore, + createMockFileLeaseManager(deps) as FileLeaseManager, injectingProviderService as unknown as ProviderService, createMockWindowService(deps) as WindowService ); diff --git a/src/node/services/coderOauthService.ts b/src/node/services/coderOauthService.ts index 22d143529d..2da7c7b517 100644 --- a/src/node/services/coderOauthService.ts +++ b/src/node/services/coderOauthService.ts @@ -17,7 +17,7 @@ import { normalizeCoderDeploymentUrl, type CoderGatewayProvider, } from "@/common/constants/coderOAuth"; -import type { Config } from "@/node/config"; +import type { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import type { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; import type { WindowService } from "@/node/services/windowService"; @@ -254,7 +254,8 @@ export class CoderOauthService { private readonly unsubscribeConfigChanged: () => void; constructor( - private readonly config: Config, + private readonly providersConfigStore: ProvidersConfigStore, + private readonly fileLeaseManager: FileLeaseManager, private readonly providerService: ProviderService, private readonly windowService?: WindowService, private readonly policyService?: PolicyService @@ -340,7 +341,7 @@ export class CoderOauthService { auth: CoderOauthAuth | null; }; try { - clearOutcome = await this.config.withCoderOauthLoginCommitLock(async () => { + clearOutcome = await this.fileLeaseManager.withCoderOauthLoginCommitLock(async () => { const auth = this.readStoredAuth(); const clearResult = await this.providerService.updateProviderSection("coder", (section) => { const stored = parseCoderOauthAuth(section?.coderOauth); @@ -567,7 +568,9 @@ export class CoderOauthService { // lease goes stale after the flow timeout. let releaseClientLease: (() => void) | null = null; try { - releaseClientLease = this.config.tryAcquireCoderOauthClientLease(DEFAULT_DESKTOP_TIMEOUT_MS); + releaseClientLease = this.fileLeaseManager.tryAcquireCoderOauthClientLease( + DEFAULT_DESKTOP_TIMEOUT_MS + ); } catch (error) { // Lease failures must not break login; degrade to a fresh client. log.debug(`[Coder OAuth] Client lease unavailable: ${getErrorMessage(error)}`); @@ -734,7 +737,7 @@ export class CoderOauthService { * refuses once it changed (see coderDisconnectGeneration in the schema). */ private readPersistedDisconnectGeneration(): number { - const section = this.config.loadProvidersConfig()?.coder as + const section = this.providersConfigStore.loadProvidersConfig()?.coder as | { coderDisconnectGeneration?: unknown } | undefined; return sanitizeGenerationCounter(section?.coderDisconnectGeneration); @@ -790,7 +793,7 @@ export class CoderOauthService { // after which our persist CAS fails too and both processes discard the // only valid token. Inside the lock the loser re-reads disk and adopts // the winner's rotation without ever sending a doomed request. - return await this.config.withCoderOauthRefreshLock(async () => { + return await this.fileLeaseManager.withCoderOauthRefreshLock(async () => { // Re-read after acquiring both locks in case another caller (this // process or another) refreshed first. Drop the in-memory cache so the // read reflects cross-process writes. @@ -829,7 +832,7 @@ export class CoderOauthService { } getDeploymentUrl(): string | null { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const coderConfig = providersConfig.coder as Record | undefined; const raw = coderConfig?.deploymentUrl; const configured = typeof raw === "string" ? normalizeCoderDeploymentUrl(raw) : null; @@ -1083,7 +1086,7 @@ export class CoderOauthService { */ private async quarantineStoredClient(clientId: string): Promise { try { - const result = await this.config.withCoderOauthRefreshLock(() => + const result = await this.fileLeaseManager.withCoderOauthRefreshLock(() => this.providerService.updateProviderSection("coder", (section) => { const stored = parseCoderOauthAuth(section?.coderOauth); if (stored?.clientId !== clientId) { @@ -1238,7 +1241,7 @@ export class CoderOauthService { "sendSuccessResponse" | "sendFailureResponse" > ): Promise { - return await this.config.withCoderOauthLoginCommitLock(async () => { + return await this.fileLeaseManager.withCoderOauthLoginCommitLock(async () => { // The flow may have been cancelled (or timed out) while the exchange // round-trip was in flight (or while waiting for the locks). Persisting // anyway would leave the account connected after the user clicked @@ -1820,8 +1823,11 @@ export class CoderOauthService { // committed mid-flight. A stale snapshot read here is safe: it can only // cause a conservative refusal, never a stale commit. const refreshStartGeneration = sanitizeGenerationCounter( - (this.config.loadProvidersConfig()?.coder as { coderCatalogGeneration?: unknown } | undefined) - ?.coderCatalogGeneration + ( + this.providersConfigStore.loadProvidersConfig()?.coder as + | { coderCatalogGeneration?: unknown } + | undefined + )?.coderCatalogGeneration ); // The deployment's configured provider instances decide which gateway // routes exist (each is mounted at //...), so list them first. @@ -1844,7 +1850,7 @@ export class CoderOauthService { if (authoritative) { providers = listing.providers; } else { - const section = this.config.loadProvidersConfig()?.coder as + const section = this.providersConfigStore.loadProvidersConfig()?.coder as | Record | undefined; const known = new Map(); @@ -2139,11 +2145,11 @@ export class CoderOauthService { // un-revoked credential in use after the user disconnected it. The // fingerprint is captured BEFORE the read, so a write racing this load // at worst forces an extra re-read on the next call, never a stale hit. - const fingerprint = this.config.getProvidersFileFingerprint(); + const fingerprint = this.providersConfigStore.getProvidersFileFingerprint(); if (this.cachedAuth && fingerprint != null && fingerprint === this.cachedAuthFingerprint) { return this.cachedAuth; } - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const coderConfig = providersConfig.coder as Record | undefined; const auth = parseCoderOauthAuth(coderConfig?.coderOauth); this.cachedAuth = auth; diff --git a/src/node/services/codexOauthService.test.ts b/src/node/services/codexOauthService.test.ts index d5be97de42..7fa3cef43e 100644 --- a/src/node/services/codexOauthService.test.ts +++ b/src/node/services/codexOauthService.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import type { Result } from "@/common/types/result"; import { Ok } from "@/common/types/result"; -import type { Config, ProvidersConfig } from "@/node/config"; +import type { ProvidersConfig, ProvidersConfigStore } from "@/node/config"; import type { ProviderService } from "@/node/services/providerService"; import type { WindowService } from "@/node/services/windowService"; import type { CodexOauthAuth } from "@/node/utils/codexOauthAuth"; @@ -61,7 +61,9 @@ function createMockDeps(): MockDeps { }; } -function createMockConfig(deps: MockDeps): Pick { +function createMockProvidersConfigStore( + deps: MockDeps +): Pick { return { loadProvidersConfig: () => deps.providersConfig, }; @@ -102,7 +104,7 @@ function createMockWindowService(deps: MockDeps): Pick | undefined; const auth = parseCodexOauthAuth(openaiConfig?.codexOauth); this.cachedAuth = auth; diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index 39a2beab65..c5305be4a7 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -5,6 +5,7 @@ import * as os from "os"; import * as path from "path"; import type { Config } from "@/node/config"; +import { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import { HistoryService } from "@/node/services/historyService"; import { IdleDispatcher } from "@/node/services/idleDispatcher"; import { InitStateManager } from "@/node/services/initStateManager"; @@ -46,6 +47,8 @@ import type { DevToolsService } from "@/node/services/devToolsService"; export interface CoreServicesOptions { config: Config; + providersConfigStore?: ProvidersConfigStore; + fileLeaseManager?: FileLeaseManager; extensionMetadataPath: string; /** Overrides config for MCPConfigService; CLI passes its persistent realConfig. */ mcpConfig?: Config; @@ -92,7 +95,15 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { const historyService = new HistoryService(config); const initStateManager = new InitStateManager(config); - const providerService = new ProviderService(config, opts.policyService); + const providersConfigStore = + opts.providersConfigStore ?? new ProvidersConfigStore(config.rootDir); + const fileLeaseManager = opts.fileLeaseManager ?? new FileLeaseManager(config.rootDir); + const providerService = new ProviderService( + config, + opts.policyService, + providersConfigStore, + fileLeaseManager + ); const backgroundProcessManager = new BackgroundProcessManager( path.join(os.tmpdir(), "mux-bashes") ); @@ -173,7 +184,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.devToolsService, opts.experimentsService, streamManager, - turnRequestBuilderBindings + turnRequestBuilderBindings, + providersConfigStore ); // Agent memory (memory experiment): scope roots derive from Config (xum home diff --git a/src/node/services/muxGatewayOauthService.test.ts b/src/node/services/muxGatewayOauthService.test.ts index ba87f921f8..12ac6880a3 100644 --- a/src/node/services/muxGatewayOauthService.test.ts +++ b/src/node/services/muxGatewayOauthService.test.ts @@ -8,7 +8,7 @@ import { MUX_GATEWAY_SESSION_EXPIRED_MESSAGE, } from "@/common/constants/muxGatewayOAuth"; import { Err, Ok } from "@/common/types/result"; -import type { Config } from "@/node/config"; +import type { ProvidersConfigStore } from "@/node/config"; import type { ProviderService } from "@/node/services/providerService"; import type { WindowService } from "@/node/services/windowService"; import { createDeferred } from "@/node/utils/oauthUtils"; @@ -47,13 +47,13 @@ function requestUrl(input: RequestInfo | URL): string { } interface MockDeps { - providersConfig: ReturnType; + providersConfig: ReturnType; setConfigCalls: Array<{ provider: string; keyPath: string[]; value: string }>; focusCalls: number; } function createService(deps: MockDeps): MuxGatewayOauthService { - const config: Pick = { + const providersConfigStore: Pick = { loadProvidersConfig: () => deps.providersConfig, }; const providerService: Pick = { @@ -68,7 +68,7 @@ function createService(deps: MockDeps): MuxGatewayOauthService { }, }; return new MuxGatewayOauthService( - config, + providersConfigStore, providerService as ProviderService, windowService as WindowService ); diff --git a/src/node/services/muxGatewayOauthService.ts b/src/node/services/muxGatewayOauthService.ts index 14cba7dfdd..6518b5f76d 100644 --- a/src/node/services/muxGatewayOauthService.ts +++ b/src/node/services/muxGatewayOauthService.ts @@ -8,7 +8,7 @@ import { MUX_GATEWAY_ORIGIN, MUX_GATEWAY_SESSION_EXPIRED_MESSAGE, } from "@/common/constants/muxGatewayOAuth"; -import type { Config } from "@/node/config"; +import type { ProvidersConfigStore } from "@/node/config"; import type { ProviderService } from "@/node/services/providerService"; import { resolveProviderCredentials } from "@/node/utils/providerRequirements"; import type { WindowService } from "@/node/services/windowService"; @@ -31,7 +31,7 @@ export class MuxGatewayOauthService { private readonly serverFlows = new Map(); constructor( - private readonly config: Pick, + private readonly providersConfigStore: Pick, private readonly providerService: ProviderService, private readonly windowService?: WindowService ) {} @@ -45,7 +45,7 @@ export class MuxGatewayOauthService { string > > { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const muxConfig = (providersConfig["mux-gateway"] ?? {}) as Record; const creds = resolveProviderCredentials("mux-gateway", { couponCode: typeof muxConfig.couponCode === "string" ? muxConfig.couponCode : undefined, diff --git a/src/node/services/policyService.test.ts b/src/node/services/policyService.test.ts index eaf1471fa6..d5c8c5c690 100644 --- a/src/node/services/policyService.test.ts +++ b/src/node/services/policyService.test.ts @@ -3,7 +3,7 @@ import { createServer } from "node:http"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; -import { Config } from "@/node/config"; +import { Config, ProvidersConfigStore } from "@/node/config"; import { PolicyService } from "./policyService"; const PREFIX = "mux-policy-service-test-"; @@ -104,7 +104,7 @@ describe("PolicyService", () => { }); test("allows listed custom providers and denies unlisted custom providers", async () => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", baseUrl: "http://localhost:8000/v1", @@ -149,7 +149,7 @@ describe("PolicyService", () => { test("allows custom providers by default when provider policy is not configured", async () => { delete process.env.MUX_POLICY_FILE; - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", baseUrl: "http://localhost:8000/v1", diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index f5e317cb43..fdac6f72a6 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -5,7 +5,7 @@ import { writeFile } from "node:fs/promises"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { Config } from "@/node/config"; +import { Config, ProvidersConfigStore } from "@/node/config"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { CODEX_ENDPOINT, CODEX_OAUTH_ROUTED_HEADER } from "@/common/constants/codexOAuth"; @@ -41,22 +41,22 @@ const LOCAL_VLLM_MODEL = "qwen3-coder"; const COPILOT_TOKEN = "copilot-token"; function saveLocalVllmConfig(config: Config, overrides: Record = {}): void { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", baseUrl: LOCAL_VLLM_BASE_URL, ...overrides, }, - } as Parameters[0]); + } as Parameters[0]); } function saveCopilotConfig(config: Config, models: unknown): void { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "github-copilot": { apiKey: COPILOT_TOKEN, models, }, - } as Parameters[0]); + } as Parameters[0]); } async function saveRoutePriority( @@ -309,7 +309,7 @@ describe("normalizeCodexResponsesBody", () => { describe("ProviderModelFactory.createModel", () => { it("returns provider_disabled when a non-gateway provider is disabled", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -330,7 +330,7 @@ describe("ProviderModelFactory.createModel", () => { it("does not return provider_disabled when provider is enabled and credentials exist", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", }, @@ -346,7 +346,7 @@ describe("ProviderModelFactory.createModel", () => { it("routes allowlisted models through gateway automatically", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -440,7 +440,7 @@ describe("ProviderModelFactory.createModel", () => { await withTempConfig(async (config, factory) => { // The request goes directly to the custom endpoint, so gateway // attribution and quota handling must not engage. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { providerType: "openai-compatible", baseUrl: "http://localhost:8000/v1", @@ -462,10 +462,10 @@ describe("ProviderModelFactory.createModel", () => { // ZDR: providers.openai.store applies to every Responses-wire route, // including custom Responses adapters. saveLocalVllmConfig(config, { providerType: "openai-responses" }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), openai: { store: false }, - } as Parameters[0]); + } as Parameters[0]); const muxOptions: MuxProviderOptions = {}; const result = await factory.createModel(`local-vllm:${LOCAL_VLLM_MODEL}`, muxOptions); @@ -481,10 +481,10 @@ describe("ProviderModelFactory.createModel", () => { // never merges and cache_control is injected despite the user // disabling beta features. saveLocalVllmConfig(config, { providerType: "anthropic-messages" }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), anthropic: { disableBetaFeatures: true }, - } as Parameters[0]); + } as Parameters[0]); const muxOptions: MuxProviderOptions = {}; const result = await factory.createModel(`local-vllm:${LOCAL_VLLM_MODEL}`, muxOptions); @@ -500,7 +500,7 @@ describe("ProviderModelFactory.createModel", () => { provider_access: [{ id: "local-vllm" }], }, async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", baseUrl: "http://localhost:8000/v1", @@ -525,7 +525,7 @@ describe("ProviderModelFactory.createModel", () => { provider_access: [{ id: "openai" }], }, async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", baseUrl: "http://localhost:8000/v1", @@ -561,7 +561,7 @@ describe("ProviderModelFactory.createModel", () => { it("returns a clear missing_base_url error for custom OpenAI-compatible providers without a base URL", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", models: ["qwen3-coder"], @@ -602,7 +602,7 @@ describe("ProviderModelFactory.createModel", () => { it("returns provider_not_supported for unknown provider entries without a custom provider type", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { baseUrl: LOCAL_VLLM_BASE_URL, models: [LOCAL_VLLM_MODEL] }, }); @@ -622,7 +622,9 @@ describe("ProviderModelFactory.createModel", () => { describe("ProviderModelFactory xAI API selection", () => { it("uses Responses for frontier Grok so exact billed cost metadata is available", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ xai: { apiKey: "xai-test-key" } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + xai: { apiKey: "xai-test-key" }, + }); for (const model of ["xai:grok-4.6", "xai:grok-4.5"]) { const result = await factory.createModel(model); @@ -637,7 +639,9 @@ describe("ProviderModelFactory xAI API selection", () => { it("surfaces exact xAI billed cost metadata through the installed Responses SDK", async () => { await withTempConfig(async (config, factory) => { const originalXaiRegistry = PROVIDER_REGISTRY.xai; - config.saveProvidersConfig({ xai: { apiKey: "xai-test-key" } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + xai: { apiKey: "xai-test-key" }, + }); PROVIDER_REGISTRY.xai = async () => { const module = await originalXaiRegistry(); @@ -704,7 +708,9 @@ describe("ProviderModelFactory xAI API selection", () => { it("uses Responses for Grok 4.5 aliases", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ xai: { apiKey: "xai-test-key" } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + xai: { apiKey: "xai-test-key" }, + }); const result = await factory.createModel("xai:grok-4.5-latest"); @@ -716,7 +722,7 @@ describe("ProviderModelFactory xAI API selection", () => { it("uses Responses for mapped aliases that target Grok 4.5", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ xai: { apiKey: "xai-test-key", models: [{ id: "team-grok", mappedToModel: "xai:grok-4.5" }], @@ -733,7 +739,9 @@ describe("ProviderModelFactory xAI API selection", () => { it("keeps legacy custom Grok model strings on Chat Completions", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ xai: { apiKey: "xai-test-key" } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + xai: { apiKey: "xai-test-key" }, + }); const result = await factory.createModel("xai:grok-4-1-fast"); @@ -746,7 +754,9 @@ describe("ProviderModelFactory xAI API selection", () => { it("defaults Grok 4.5 Responses requests to store=false for ZDR parity", async () => { await withTempConfig(async (config, factory) => { const originalXaiRegistry = PROVIDER_REGISTRY.xai; - config.saveProvidersConfig({ xai: { apiKey: "xai-test-key" } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + xai: { apiKey: "xai-test-key" }, + }); let capturedBody: Record | undefined; @@ -1044,7 +1054,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { }); it("normalizes Request bodies for the Codex OAuth responses endpoint", async () => { - await withTempConfig(async (config, factory, oauth) => { + await withTempConfig(async (_config, factory, oauth) => { const originalOpenAIRegistry = PROVIDER_REGISTRY.openai; const requests: Array<{ input: Parameters[0]; @@ -1093,7 +1103,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { ); }; - config.loadProvidersConfig = () => ({ + spyOn(ProvidersConfigStore.prototype, "loadProvidersConfig").mockReturnValue({ openai: { codexOauth: auth, fetch: baseFetch, @@ -1188,7 +1198,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { it("returns api_key_not_found before checking a stale Copilot model catalog", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "github-copilot": { models: ["gpt-4.1"], }, @@ -1273,7 +1283,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("attaches cleanup when enabled for Responses models", async () => { await withOpenAIBaseUrlEnvUnset(async () => withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", webSocketTransportEnabled: true, @@ -1293,7 +1303,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("does not attach cleanup for Codex OAuth routed models", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { webSocketTransportEnabled: true, codexOauth: { @@ -1319,7 +1329,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("attaches cleanup when a custom OpenAI base URL is configured", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", baseURL: "https://proxy.openai.test/v1", @@ -1340,7 +1350,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("preserves cleanup when DevTools wraps an OpenAI WebSocket model", async () => { await withOpenAIBaseUrlEnvUnset(async () => withTempConfig(async (config) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", webSocketTransportEnabled: true, @@ -1371,7 +1381,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("does not attach cleanup when Chat Completions is selected", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", wireFormat: "chatCompletions", @@ -1391,12 +1401,12 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("ignores invalid persisted WebSocket transport values", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", webSocketTransportEnabled: "true", }, - } as unknown as Parameters[0]); + } as unknown as Parameters[0]); const result = await factory.createModel("openai:gpt-4.1-mini"); @@ -1412,7 +1422,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { describe("ProviderModelFactory modelCostsIncluded", () => { it("marks gpt-5.3-codex as subscription-covered when routed through Codex OAuth", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { codexOauth: { type: "oauth", @@ -1436,7 +1446,7 @@ describe("ProviderModelFactory modelCostsIncluded", () => { it("routes a custom OpenAI model through Codex OAuth when it inherits from a compatible model", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { codexOauth: { type: "oauth", @@ -1461,7 +1471,7 @@ describe("ProviderModelFactory modelCostsIncluded", () => { it("does not mark gpt-5.3-codex as subscription-covered when routed through API key", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", }, @@ -1480,7 +1490,7 @@ describe("ProviderModelFactory modelCostsIncluded", () => { describe("ProviderModelFactory routing", () => { it("honors non-mux gateway routes end-to-end", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -1509,7 +1519,7 @@ describe("ProviderModelFactory routing", () => { it("passes gateway model accessibility to routing by skipping inaccessible Copilot models", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", }, @@ -1532,7 +1542,7 @@ describe("ProviderModelFactory routing", () => { it("does not treat custom gateway model entries as an exhaustive routed catalog", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openrouter: { apiKey: "or-test", models: ["team-only-model"], @@ -1555,7 +1565,7 @@ describe("ProviderModelFactory routing", () => { const originalOpenRouterRegistry = PROVIDER_REGISTRY.openrouter; let capturedExtraBody: unknown; - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openrouter: { apiKey: "or-test", models: [ @@ -1591,7 +1601,7 @@ describe("ProviderModelFactory routing", () => { it("routes Anthropic models through Bedrock when Bedrock is configured and prioritized", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "ant-test", enabled: false }, bedrock: { region: "us-east-1" }, }); @@ -1608,7 +1618,7 @@ describe("ProviderModelFactory routing", () => { it("skips disabled gateway providers even when credentials exist", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -1633,7 +1643,7 @@ describe("ProviderModelFactory routing", () => { it("keeps shadowed custom OpenAI-compatible providers on the direct route", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { providerType: "openai-compatible", baseUrl: "http://localhost:8000/v1", @@ -1658,7 +1668,7 @@ describe("ProviderModelFactory routing", () => { // the new gateway definition into openai:foo — that would silently // bypass the user's custom endpoint. The shadow check must inspect the // RAW prefix before gateway canonicalization. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { providerType: "openai-compatible", baseUrl: "http://localhost:9000/v1", @@ -1692,7 +1702,7 @@ describe("ProviderModelFactory routing", () => { // restoration can never recover the custom model either: // coder:google/gemini-2.5-pro would be rewritten to // google:gemini-2.5-pro and bypass the user's custom endpoint. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { providerType: "openai-compatible", baseUrl: "http://localhost:9000/v1", @@ -1717,7 +1727,7 @@ describe("ProviderModelFactory routing", () => { it("falls back deterministically to the next configured route", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -1739,7 +1749,7 @@ describe("ProviderModelFactory routing", () => { it("preserves explicit OpenRouter model strings when OpenRouter is configured", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -1772,7 +1782,7 @@ describe("ProviderModelFactory routing", () => { it("falls back from explicit OpenRouter model strings when OpenRouter is unavailable", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -1808,7 +1818,7 @@ describe("ProviderModelFactory routing", () => { it("honors explicit mux-gateway prefixes for compatibility", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "test-coupon", }, @@ -1842,7 +1852,7 @@ describe("ProviderModelFactory routing", () => { delete process.env.OPENAI_API_KEY; try { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { // No apiKey — only Codex OAuth credentials. codexOauth: { @@ -1877,7 +1887,7 @@ describe("ProviderModelFactory routing", () => { it("leaves direct-provider model strings unchanged when direct routing wins", async () => { await withTempConfig(async (config, factory) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", }, @@ -2284,7 +2294,7 @@ describe("ProviderModelFactory Coder", () => { const CODER_DEPLOYMENT_URL = "https://coder.example.com"; function saveCoderConfig(config: Config, overrides: Record = {}): void { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: CODER_DEPLOYMENT_URL, coderOauth: { @@ -2299,7 +2309,7 @@ describe("ProviderModelFactory Coder", () => { }, ...overrides, }, - } as Parameters[0]); + } as Parameters[0]); } function stubCoderOauthService( @@ -2479,11 +2489,11 @@ describe("ProviderModelFactory Coder", () => { models: ["google/gemini-3-pro"], discoveredModels: ["google/gemini-3-pro"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), // Direct Google credentials exist: they must NOT capture the request. google: { apiKey: "g-key" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:google/gemini-3-pro", "off"); @@ -2539,10 +2549,10 @@ describe("ProviderModelFactory Coder", () => { models: ["prod-anthropic/claude-opus-4-5"], discoveredModels: ["prod-anthropic/claude-opus-4-5"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), anthropic: { disableBetaFeatures: true }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; @@ -2562,10 +2572,10 @@ describe("ProviderModelFactory Coder", () => { models: ["anthropic/gpt-5"], discoveredModels: ["anthropic/gpt-5"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), anthropic: { disableBetaFeatures: true }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; @@ -2585,10 +2595,10 @@ describe("ProviderModelFactory Coder", () => { models: ["prod-openai/gpt-5.2"], discoveredModels: ["prod-openai/gpt-5.2"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), openai: { store: false }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; @@ -2608,10 +2618,10 @@ describe("ProviderModelFactory Coder", () => { models: ["openai/gpt-5"], discoveredModels: ["openai/gpt-5"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), openai: { store: false }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const muxOptions: MuxProviderOptions = {}; @@ -2633,13 +2643,13 @@ describe("ProviderModelFactory Coder", () => { models: ["openai/claude-opus-4-5"], discoveredModels: ["openai/claude-opus-4-5"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), // Both direct providers configured: the NAME-alike (openai) must // not capture the request; the TYPE-derived provider wins. openai: { apiKey: "sk-openai" }, anthropic: { apiKey: "sk-anthropic" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:openai/claude-sonnet-4-5", "off"); @@ -2671,9 +2681,14 @@ describe("ProviderModelFactory Coder", () => { }); oauth.coderOauthService = stubCoderOauthService(); - const realLoad = config.loadProvidersConfig.bind(config); + const realLoad = ProvidersConfigStore.prototype.loadProvidersConfig.bind( + new ProvidersConfigStore(config.rootDir) + ); let loads = 0; - const loadSpy = spyOn(config, "loadProvidersConfig").mockImplementation(() => { + const loadSpy = spyOn( + ProvidersConfigStore.prototype, + "loadProvidersConfig" + ).mockImplementation(() => { loads++; // realLoad parses a fresh object per call, so mutating it here never // leaks into other reads. @@ -2715,10 +2730,10 @@ describe("ProviderModelFactory Coder", () => { models: ["bedrock/anthropic.claude-opus-4-5"], discoveredModels: ["bedrock/anthropic.claude-opus-4-5"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), bedrock: { region: "us-east-1" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel( @@ -2774,10 +2789,10 @@ describe("ProviderModelFactory Coder", () => { }); // Direct Anthropic credentials exist: a name-derived fallback would // silently send the rejected gateway selection to direct Anthropic. - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:anthropic/excluded-model", "off"); @@ -2806,10 +2821,10 @@ describe("ProviderModelFactory Coder", () => { models: ["anthropic/some-model"], discoveredModels: ["anthropic/some-model"], }); - config.saveProvidersConfig({ - ...config.loadProvidersConfig(), + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + ...new ProvidersConfigStore(config.rootDir).loadProvidersConfig(), anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); const result = await factory.resolveAndCreateModel("coder:anthropic/some-model", "off"); @@ -3110,7 +3125,7 @@ describe("ProviderModelFactory Coder", () => { // Login performed against the policy-locked deployment (the // policy-aware CoderOauthService logs in to the forced URL). - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: LOCKED_URL, coderOauth: { @@ -3124,7 +3139,7 @@ describe("ProviderModelFactory Coder", () => { clientSecret: "s", }, }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService("at_factory", LOCKED_URL); PROVIDER_REGISTRY.anthropic = async () => { @@ -3159,11 +3174,11 @@ describe("ProviderModelFactory Coder", () => { models: ["anthropic/claude-sonnet-4-5"], discoveredModels: ["anthropic/claude-sonnet-4-5"], }); - const providersConfig = config.loadProvidersConfig() ?? {}; - config.saveProvidersConfig({ + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() ?? {}; + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3190,11 +3205,11 @@ describe("ProviderModelFactory Coder", () => { // strand Coder routing until the next login even after the bridge // recovers. saveCoderConfig(config); - const providersConfig = config.loadProvidersConfig() ?? {}; - config.saveProvidersConfig({ + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() ?? {}; + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3221,11 +3236,11 @@ describe("ProviderModelFactory Coder", () => { models: ["anthropic/claude-sonnet-4-5", "anthropic/claude-3-7"], discoveredModels: ["anthropic/claude-sonnet-4-5"], }); - const providersConfig = config.loadProvidersConfig() ?? {}; - config.saveProvidersConfig({ + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() ?? {}; + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["direct"]); @@ -3266,11 +3281,11 @@ describe("ProviderModelFactory Coder", () => { // skip Coder entirely rather than send every model to a bridge that // rejects them. saveCoderConfig(config, { models: [], discoveredModels: [] }); - const providersConfig = config.loadProvidersConfig() ?? {}; - config.saveProvidersConfig({ + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() ?? {}; + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3302,11 +3317,12 @@ describe("ProviderModelFactory Coder", () => { models: ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-1"], discoveredModels: ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-1"], }); - const providersConfig = config.loadProvidersConfig() ?? {}; - config.saveProvidersConfig({ + const providersConfig = + new ProvidersConfigStore(config.rootDir).loadProvidersConfig() ?? {}; + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ ...providersConfig, anthropic: { apiKey: "sk-ant-test" }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService(); await saveRoutePriority(config, ["coder", "direct"]); @@ -3345,7 +3361,7 @@ describe("ProviderModelFactory Coder", () => { // edited the (unlocked) deploymentUrl field to point elsewhere. The // forced URL must be resolved FIRST so the valid policy-bound // credentials are not rejected as issuer-mismatched. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://user-edited.example.com", coderOauth: { @@ -3359,7 +3375,7 @@ describe("ProviderModelFactory Coder", () => { clientSecret: "s", }, }, - } as Parameters[0]); + } as Parameters[0]); oauth.coderOauthService = stubCoderOauthService("at_factory", LOCKED_URL); PROVIDER_REGISTRY.anthropic = async () => { diff --git a/src/node/services/providerModelFactory.ts b/src/node/services/providerModelFactory.ts index ca84cd7d7b..c7161b17cb 100644 --- a/src/node/services/providerModelFactory.ts +++ b/src/node/services/providerModelFactory.ts @@ -20,6 +20,7 @@ import { } from "@/common/constants/codexOAuth"; import { parseCodexOauthAuth } from "@/node/utils/codexOauthAuth"; import type { Config, ProviderConfig, ProvidersConfig } from "@/node/config"; +import { ProvidersConfigStore } from "@/node/config"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import type { ServiceTier, XAIServiceTier } from "@/common/config/schemas/providersConfig"; import { resolveConfigBaseUrl } from "@/common/utils/providers/baseUrl"; @@ -1086,19 +1087,22 @@ export class ProviderModelFactory { private readonly policyService?: PolicyService; private readonly devToolsService?: DevToolsService; private readonly oauthServices?: OauthServiceBindings; + private readonly providersConfigStore: ProvidersConfigStore; constructor( config: Config, providerService: ProviderService, policyService?: PolicyService, oauthServices?: OauthServiceBindings, - devToolsService?: DevToolsService + devToolsService?: DevToolsService, + providersConfigStore?: ProvidersConfigStore ) { this.config = config; this.providerService = providerService; this.policyService = policyService; this.oauthServices = oauthServices; this.devToolsService = devToolsService; + this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(config.rootDir); } /** @@ -1239,7 +1243,8 @@ export class ProviderModelFactory { // Load providers configuration - the ONLY source of truth. A caller's // snapshot (resolveAndCreateModel) wins so the created model matches // the wire/route identity that snapshot produced (see createModel). - const providersConfig = opts?.providersConfig ?? this.config.loadProvidersConfig() ?? {}; + const providersConfig = + opts?.providersConfig ?? this.providersConfigStore.loadProvidersConfig() ?? {}; const providerConfigEntry = providersConfig[providerName]; const providerIsBuiltIn = isBuiltInProvider(providerName); const customProviderType = isCustomProviderConfig(providerConfigEntry) @@ -2482,7 +2487,7 @@ export class ProviderModelFactory { // through the built-in machinery instead of the user's custom endpoint. // The equivalent guard in resolveGatewayModelString only protects callers // that pass raw strings. - const providersConfigForShadowCheck = this.config.loadProvidersConfig() ?? {}; + const providersConfigForShadowCheck = this.providersConfigStore.loadProvidersConfig() ?? {}; const [rawProviderName] = parseModelString(modelString); const rawPrefixShadowedByCustomProvider = rawProviderName.length > 0 && @@ -2767,7 +2772,8 @@ export class ProviderModelFactory { // resolveAndCreateModel passes its snapshot so route availability, // accessibility, the wire snapshot, and model creation all read one // providers.jsonc state (see createModel's providersConfig option). - const providersConfig = providersConfigSnapshot ?? this.config.loadProvidersConfig?.() ?? {}; + const providersConfig = + providersConfigSnapshot ?? this.providersConfigStore.loadProvidersConfig() ?? {}; const isGatewayModelAccessible = createGatewayModelAccessibilityChecker( providersConfig, this.policyService @@ -2829,7 +2835,8 @@ export class ProviderModelFactory { // Same single-snapshot rule as resolveModelRoute: callers holding one // providers.jsonc read pass it so routing cannot diverge from it. - const providersConfig = providersConfigSnapshot ?? this.config.loadProvidersConfig() ?? {}; + const providersConfig = + providersConfigSnapshot ?? this.providersConfigStore.loadProvidersConfig() ?? {}; // Shadow check on the RAW prefix, BEFORE gateway canonicalization: a // custom provider can shadow a built-in gateway id diff --git a/src/node/services/providerService.test.ts b/src/node/services/providerService.test.ts index 81a5b3df74..5fae24cbd6 100644 --- a/src/node/services/providerService.test.ts +++ b/src/node/services/providerService.test.ts @@ -7,7 +7,7 @@ import * as path from "path"; import { CUSTOM_PROVIDER_TYPES } from "@/common/utils/providers/customProviders"; import type { ProviderModelEntry } from "@/common/orpc/types"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { Config } from "@/node/config"; +import { Config, FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import { log } from "@/node/services/log"; import { PolicyService } from "@/node/services/policyService"; import { ProviderService } from "./providerService"; @@ -16,9 +16,9 @@ const OPENAI_API_KEY = "sk-test"; const LOCAL_VLLM_BASE_URL = "http://localhost:8000/v1"; function saveOpenAIConfig(config: Config, overrides: Record = {}): void { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, ...overrides }, - } as Parameters[0]); + } as Parameters[0]); } function localVllmConfig(overrides: Record = {}): Record { @@ -38,7 +38,9 @@ async function saveRoutePriority( } function saveMuxGatewayConfig(config: Config): void { - config.saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token" } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + "mux-gateway": { couponCode: "gateway-token" }, + }); } function withTempConfig(run: (config: Config, service: ProviderService) => void): void { @@ -237,7 +239,7 @@ describe("ProviderService.getConfig", () => { it("surfaces only supported xAI processing tiers", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ xai: { apiKey: "xai-key", serviceTier: "priority", @@ -247,13 +249,13 @@ describe("ProviderService.getConfig", () => { expect(service.getConfig().xai.serviceTier).toBe("priority"); expect(service.getConfig().xai.fastModePreviousServiceTier).toBe("default"); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ xai: { apiKey: "xai-key", serviceTier: "flex", fastModePreviousServiceTier: "flex", }, - } as Parameters[0]); + } as Parameters[0]); expect(service.getConfig().xai.serviceTier).toBeUndefined(); expect(service.getConfig().xai.fastModePreviousServiceTier).toBeUndefined(); }); @@ -261,7 +263,7 @@ describe("ProviderService.getConfig", () => { it("reports legacy op:// API key references as not set", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "op://Personal/Anthropic/credential", }, @@ -312,7 +314,7 @@ describe("ProviderService.getConfig", () => { it("treats disabled OpenAI as unconfigured even when Codex OAuth tokens are stored", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { enabled: false, codexOauth: { @@ -373,7 +375,7 @@ describe("ProviderService.getConfig", () => { it("returns legacy baseURL config as editable baseUrl", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, baseURL: "https://legacy.openai.test", @@ -396,7 +398,7 @@ describe("ProviderService.getConfig", () => { }, () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "sk-ant-config", baseUrl: "https://config.anthropic.test", @@ -455,7 +457,7 @@ describe("ProviderService.getConfig", () => { it("surfaces keyless custom OpenAI-compatible providers", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": { providerType: "openai-compatible", displayName: "Local vLLM", @@ -482,7 +484,7 @@ describe("ProviderService.getConfig", () => { it("surfaces disabled custom providers as unconfigured", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig({ enabled: false }), }); @@ -496,7 +498,7 @@ describe("ProviderService.getConfig", () => { it("omits unknown provider keys without a custom providerType", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "future-provider": { apiKey: "sk-future", baseUrl: "https://future.example/v1", @@ -511,7 +513,7 @@ describe("ProviderService.getConfig", () => { it("keeps built-in providers alongside custom providers", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, }, @@ -533,7 +535,7 @@ describe("ProviderService.getConfig", () => { it("prefers shadowed custom provider config over a built-in provider id", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { providerType: "openai-compatible", displayName: "Shadowed OpenAI", @@ -552,7 +554,7 @@ describe("ProviderService.getConfig", () => { it("logs shadowed custom provider ids once per detected set", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { providerType: "openai-compatible", displayName: "Shadowed OpenAI", @@ -591,7 +593,7 @@ describe("ProviderService.getConfig", () => { ], }, async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig({ models: ["llama-3", "mistral"] }), "another-custom": { providerType: "openai-compatible", @@ -628,9 +630,9 @@ describe("ProviderService.getConfig", () => { provider_access: [{ id: "openai" }], }, async (config, service, policyService) => { - // A second Config on the same root stands in for another Xum process - // holding the providers file lock while setModels waits for it. - const otherProcess = new Config(config.rootDir); + // A second FileLeaseManager on the same root stands in for another Xum + // process holding the providers file lock while setModels waits for it. + const otherProcess = new FileLeaseManager(config.rootDir); let releaseLock!: () => void; const lockGate = new Promise((resolve) => (releaseLock = resolve)); let lockHeld!: () => void; @@ -665,7 +667,9 @@ describe("ProviderService.getConfig", () => { expect(result.error).toContain("not allowed by policy"); } // Nothing was persisted for the denied provider. - expect(config.loadProvidersConfig()?.openai?.models).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.models + ).toBeUndefined(); } ); }); @@ -683,7 +687,7 @@ describe("ProviderService.getConfig", () => { // Connection status must follow routing — which uses the forced URL — // or Settings would show "Not connected" (and hide Disconnect) while // requests keep succeeding against the forced deployment. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://user-edited.example.com", coderOauth: { @@ -718,7 +722,7 @@ describe("ProviderService.getConfig", () => { // stored full-privilege credential is still live on its deployment. // getConfig() must surface its PRESENCE (nothing else) so the // Disconnect command keeps a revocation path. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com", models: ["anthropic/model-a"], @@ -745,7 +749,7 @@ describe("ProviderService.getConfig", () => { expect(cfg.coder.models).toBeUndefined(); // Without a stored credential the denied provider stays fully hidden. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com" }, }); expect(service.getConfig().coder).toBeUndefined(); @@ -758,7 +762,7 @@ describe("ProviderService.getConfig", () => { // The stored blob no longer matches the configured URL: not routable // (coderOauthSet false), but the credential is still live on its own // issuer and must stay exposed so Disconnect can revoke it. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://new-deployment.example.com", coderOauth: { @@ -779,7 +783,7 @@ describe("ProviderService.getConfig", () => { expect(cfg.coder.coderOauthCredentialStored).toBe(true); // No blob at all: nothing to disconnect. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://new-deployment.example.com" }, }); expect(service.getConfig().coder.coderOauthCredentialStored).toBe(false); @@ -796,7 +800,7 @@ describe("ProviderService.getConfig", () => { // The persisted catalog is policy-unfiltered by design (a temporary // policy must not carve models out of durable state); getConfig() // applies the CURRENT policy when exposing the lists. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com", models: ["anthropic/claude-sonnet-4-5", "anthropic/claude-opus-4-1"], @@ -815,7 +819,7 @@ describe("ProviderService.getConfig", () => { describe("ProviderService model normalization", () => { it("normalizes malformed model entries when reading config", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, models: [ @@ -855,7 +859,7 @@ describe("ProviderService model normalization", () => { // hidden entry. setModels must carry it forward or the edit would // carve it out of durable state until the next login even after the // policy broadens. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com", models: ["anthropic/visible-model", "anthropic/other-visible", "anthropic/hidden"], @@ -871,7 +875,8 @@ describe("ProviderService model normalization", () => { const result = await service.setModels("coder", ["anthropic/visible-model"]); expect(result.success).toBe(true); - const stored = config.loadProvidersConfig()?.coder as Record; + const stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() + ?.coder as Record; // The hidden entry survives; only the visible removal took effect. expect(stored.models).toEqual(["anthropic/visible-model", "anthropic/hidden"]); expect(stored.removedModels).toEqual(["anthropic/other-visible"]); @@ -881,7 +886,7 @@ describe("ProviderService model normalization", () => { it("records removals of discovered Coder models and clears them on re-add", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com", models: ["anthropic/model-a", "anthropic/model-b"], @@ -893,14 +898,20 @@ describe("ProviderService model normalization", () => { // refreshes and re-logins cannot resurrect it. const removal = await service.setModels("coder", ["anthropic/model-a"]); expect(removal.success).toBe(true); - let stored = config.loadProvidersConfig()?.coder as Record; + let stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.coder as Record< + string, + unknown + >; expect(stored.models).toEqual(["anthropic/model-a"]); expect(stored.removedModels).toEqual(["anthropic/model-b"]); // Re-adding the model clears its exclusion. const readd = await service.setModels("coder", ["anthropic/model-a", "anthropic/model-b"]); expect(readd.success).toBe(true); - stored = config.loadProvidersConfig()?.coder as Record; + stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.coder as Record< + string, + unknown + >; expect(stored.models).toEqual(["anthropic/model-a", "anthropic/model-b"]); expect(stored.removedModels).toBeUndefined(); }); @@ -913,7 +924,7 @@ describe("ProviderService model normalization", () => { // `models` still knows it). Deleting it in that state must still // record the exclusion, or the next catalog that lists the ID again // would resurrect a model the user explicitly removed. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com", models: [ @@ -927,7 +938,8 @@ describe("ProviderService model normalization", () => { const result = await service.setModels("coder", ["anthropic/model-a"]); expect(result.success).toBe(true); - const stored = config.loadProvidersConfig()?.coder as Record; + const stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() + ?.coder as Record; expect(stored.models).toEqual(["anthropic/model-a"]); expect(stored.removedModels).toEqual(["anthropic/overridden"]); }); @@ -938,7 +950,7 @@ describe("ProviderService model normalization", () => { // Post-login state: discoveredModels deleted (catalog unknown), but a // removal recorded earlier must survive an unrelated edit — otherwise // the pending discovery would resurrect the removed model. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://coder.example.com", models: ["anthropic/model-a"], @@ -948,7 +960,8 @@ describe("ProviderService model normalization", () => { const result = await service.setModels("coder", ["anthropic/model-a", "anthropic/manual"]); expect(result.success).toBe(true); - const stored = config.loadProvidersConfig()?.coder as Record; + const stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() + ?.coder as Record; expect(stored.removedModels).toEqual(["anthropic/model-b"]); }); }); @@ -965,7 +978,7 @@ describe("ProviderService model normalization", () => { ] as unknown as ProviderModelEntry[]); expect(result.success).toBe(true); - const providersConfig = config.loadProvidersConfig(); + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig(); expect(providersConfig?.openai?.models).toEqual([ "gpt-5", { id: "custom-model", contextWindowTokens: 100_000 }, @@ -987,7 +1000,7 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("built_in_provider"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); @@ -1003,13 +1016,13 @@ describe("ProviderService custom provider mutations", () => { expect(result.error.code).toBe("invalid_provider_id"); expect(result.error.reason).toContain("whitespace"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); it("rejects duplicate custom provider ids", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig(), }); @@ -1037,7 +1050,7 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("invalid_base_url"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); } @@ -1061,14 +1074,14 @@ describe("ProviderService custom provider mutations", () => { expect(result.error.code).toBe("invalid_base_url"); expect(result.error.message).toContain("query"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); } it("rejects setConfig base URL edits carrying a query string or fragment", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig(), }); @@ -1091,7 +1104,9 @@ describe("ProviderService custom provider mutations", () => { ); expect(fragmentResult.success).toBe(false); - expect(config.loadProvidersConfig()?.["local-vllm"]?.baseUrl).toBe(LOCAL_VLLM_BASE_URL); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["local-vllm"]?.baseUrl + ).toBe(LOCAL_VLLM_BASE_URL); }); }); @@ -1130,7 +1145,9 @@ describe("ProviderService custom provider mutations", () => { { id: "mixtral", contextWindowTokens: 32_768, mappedToModel: "openai/gpt-4o" }, ]); - expect(config.loadProvidersConfig()?.["local-vllm"]).toEqual({ + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["local-vllm"] + ).toEqual({ providerType: "openai-compatible", baseUrl: LOCAL_VLLM_BASE_URL, enabled: true, @@ -1156,7 +1173,10 @@ describe("ProviderService custom provider mutations", () => { expect(result.success).toBe(true); expect(result.success && result.data.providerType).toBe(providerType); - expect(config.loadProvidersConfig()?.["custom-provider"]?.providerType).toBe(providerType); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["custom-provider"] + ?.providerType + ).toBe(providerType); expect(service.getConfig()["custom-provider"]?.providerType).toBe(providerType); expect(service.list()).toContain("custom-provider"); }); @@ -1167,7 +1187,7 @@ describe("ProviderService custom provider mutations", () => { await withTempConfigAsync(async (config, service) => { // Upgraded installs can carry a custom provider whose id shadows a // built-in; the add-time id collision rule must not block format edits. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { providerType: "openai-compatible", baseUrl: LOCAL_VLLM_BASE_URL, @@ -1177,7 +1197,9 @@ describe("ProviderService custom provider mutations", () => { const result = await service.setConfig("coder", ["providerType"], "anthropic-messages"); expect(result.success).toBe(true); - expect(config.loadProvidersConfig()?.coder?.providerType).toBe("anthropic-messages"); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.coder?.providerType + ).toBe("anthropic-messages"); }); }); @@ -1192,20 +1214,24 @@ describe("ProviderService custom provider mutations", () => { ); expect(result.success).toBe(false); - expect(config.loadProvidersConfig()?.["ghost-provider"]).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["ghost-provider"] + ).toBeUndefined(); }); }); it("still rejects providerType writes that would convert a built-in entry", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-real" }, }); const result = await service.setConfig("openai", ["providerType"], "openai-compatible"); expect(result.success).toBe(false); - expect(config.loadProvidersConfig()?.openai?.providerType).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.providerType + ).toBeUndefined(); }); }); @@ -1225,7 +1251,7 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("policy_denied"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); } ); }); @@ -1246,7 +1272,7 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("policy_denied"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); } ); }); @@ -1268,7 +1294,7 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("policy_denied"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); } ); }); @@ -1281,7 +1307,7 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("built_in_provider"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); @@ -1295,13 +1321,15 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("built_in_provider"); } - expect(config.loadProvidersConfig()?.openai?.apiKey).toBe("sk-test"); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.apiKey).toBe( + "sk-test" + ); }); }); it("removes a shadowed built-in custom provider from providers config", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { providerType: "openai-compatible", baseUrl: LOCAL_VLLM_BASE_URL, @@ -1315,7 +1343,9 @@ describe("ProviderService custom provider mutations", () => { const result = await service.removeCustomProvider("openai"); expect(result.success).toBe(true); - expect(config.loadProvidersConfig()?.openai).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai + ).toBeUndefined(); expect(config.loadConfigOrDefault().defaultModel).toBeUndefined(); }); }); @@ -1328,13 +1358,13 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("unknown_provider"); } - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); it("rejects removing non-custom provider entries", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "future-provider": { baseUrl: "https://future.example/v1", }, @@ -1346,7 +1376,9 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("not_custom_provider"); } - expect(config.loadProvidersConfig()?.["future-provider"]).toEqual({ + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["future-provider"] + ).toEqual({ baseUrl: "https://future.example/v1", }); }); @@ -1354,7 +1386,7 @@ describe("ProviderService custom provider mutations", () => { it("removes a valid custom provider from providers config", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY }, "local-vllm": localVllmConfig(), "other-custom": { @@ -1366,7 +1398,7 @@ describe("ProviderService custom provider mutations", () => { const result = await service.removeCustomProvider("local-vllm"); expect(result.success).toBe(true); - const providersConfig = config.loadProvidersConfig(); + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig(); expect(providersConfig?.["local-vllm"]).toBeUndefined(); expect(providersConfig?.openai?.apiKey).toBe("sk-test"); expect(providersConfig?.["other-custom"]?.baseUrl).toBe("http://localhost:8001/v1"); @@ -1375,14 +1407,14 @@ describe("ProviderService custom provider mutations", () => { it("does not repair app config if provider deletion fails", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig(), }); await config.editConfig(() => ({ ...config.loadConfigOrDefault(), defaultModel: "local-vllm:qwen3-coder", })); - const saveProvidersConfigSpy = spyOn(config, "saveProvidersConfig"); + const saveProvidersConfigSpy = spyOn(ProvidersConfigStore.prototype, "saveProvidersConfig"); saveProvidersConfigSpy.mockImplementationOnce(() => { throw new Error("disk is read-only"); }); @@ -1392,7 +1424,9 @@ describe("ProviderService custom provider mutations", () => { expect(result.success).toBe(false); expect(config.loadConfigOrDefault().defaultModel).toBe("local-vllm:qwen3-coder"); - expect(config.loadProvidersConfig()?.["local-vllm"]).toBeDefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["local-vllm"] + ).toBeDefined(); } finally { saveProvidersConfigSpy.mockRestore(); } @@ -1401,7 +1435,7 @@ describe("ProviderService custom provider mutations", () => { it("reports partial success and notifies when config repair fails after deletion", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig(), }); await config.editConfig(() => ({ @@ -1424,7 +1458,9 @@ describe("ProviderService custom provider mutations", () => { if (!result.success) { expect(result.error.code).toBe("config_repair_failed"); } - expect(config.loadProvidersConfig()?.["local-vllm"]).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["local-vllm"] + ).toBeUndefined(); expect(config.loadConfigOrDefault().defaultModel).toBe("local-vllm:qwen3-coder"); expect(configChangedCount).toBe(1); } finally { @@ -1437,7 +1473,7 @@ describe("ProviderService custom provider mutations", () => { it("repairs durable app config references when removing a custom provider", async () => { await withTempConfigAsync(async (config, service) => { const provider = "local-vllm"; - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY }, [provider]: { providerType: "openai-compatible", @@ -1536,7 +1572,7 @@ describe("ProviderService custom provider mutations", () => { expect(result.success).toBe(true); const freshConfig = new Config(config.rootDir); - const providersConfig = freshConfig.loadProvidersConfig(); + const providersConfig = new ProvidersConfigStore(freshConfig.rootDir).loadProvidersConfig(); expect(providersConfig?.[provider]).toBeUndefined(); expect(providersConfig?.openai?.apiKey).toBe("sk-test"); expect(providersConfig?.["other-custom"]?.baseUrl).toBe("http://localhost:8001/v1"); @@ -1599,7 +1635,7 @@ describe("ProviderService custom provider mutations", () => { it("preserves workspace thinking level when repairing a removed provider model", async () => { await withTempConfigAsync(async (config, service) => { const provider = "local-vllm"; - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ [provider]: { providerType: "openai-compatible", baseUrl: LOCAL_VLLM_BASE_URL, @@ -1647,7 +1683,7 @@ describe("ProviderService.setConfig", () => { const result = await service.setConfig("mux-gateway", ["couponCode"], "gateway-token"); expect(result.success).toBe(true); - const providersConfig = config.loadProvidersConfig(); + const providersConfig = new ProvidersConfigStore(config.rootDir).loadProvidersConfig(); expect(providersConfig?.["mux-gateway"]?.models).toEqual([ "anthropic/claude-sonnet-5", "anthropic/claude-opus-5", @@ -1659,7 +1695,7 @@ describe("ProviderService.setConfig", () => { it("removes legacy baseURL alias when editing canonical baseUrl", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, baseURL: "https://legacy.openai.test", @@ -1672,10 +1708,14 @@ describe("ProviderService.setConfig", () => { "https://canonical.openai.test" ); expect(updateResult.success).toBe(true); - expect(config.loadProvidersConfig()?.openai?.baseURL).toBeUndefined(); - expect(config.loadProvidersConfig()?.openai?.baseUrl).toBe("https://canonical.openai.test"); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.baseURL + ).toBeUndefined(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.baseUrl).toBe( + "https://canonical.openai.test" + ); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, baseURL: "https://legacy.openai.test", @@ -1684,14 +1724,18 @@ describe("ProviderService.setConfig", () => { const clearResult = await service.setConfig("openai", ["baseUrl"], ""); expect(clearResult.success).toBe(true); - expect(config.loadProvidersConfig()?.openai?.baseURL).toBeUndefined(); - expect(config.loadProvidersConfig()?.openai?.baseUrl).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.baseURL + ).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.baseUrl + ).toBeUndefined(); }); }); it("removes OpenAI serviceTier when set to an empty string", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, serviceTier: "auto", @@ -1701,14 +1745,21 @@ describe("ProviderService.setConfig", () => { const result = await service.setConfig("openai", ["serviceTier"], ""); expect(result.success).toBe(true); - expect(config.loadProvidersConfig()?.openai?.serviceTier).toBeUndefined(); - expect(Object.hasOwn(config.loadProvidersConfig()?.openai ?? {}, "serviceTier")).toBe(false); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai?.serviceTier + ).toBeUndefined(); + expect( + Object.hasOwn( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.openai ?? {}, + "serviceTier" + ) + ).toBe(false); }); }); it("stores enabled=false without deleting existing credentials", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: OPENAI_API_KEY, baseUrl: "https://api.openai.com/v1", @@ -1718,7 +1769,7 @@ describe("ProviderService.setConfig", () => { const disableResult = await service.setConfig("openai", ["enabled"], "false"); expect(disableResult.success).toBe(true); - const afterDisable = config.loadProvidersConfig(); + const afterDisable = new ProvidersConfigStore(config.rootDir).loadProvidersConfig(); expect(afterDisable?.openai?.apiKey).toBe("sk-test"); expect(afterDisable?.openai?.baseUrl).toBe("https://api.openai.com/v1"); expect(afterDisable?.openai?.enabled).toBe(false); @@ -1726,7 +1777,7 @@ describe("ProviderService.setConfig", () => { const enableResult = await service.setConfig("openai", ["enabled"], ""); expect(enableResult.success).toBe(true); - const afterEnable = config.loadProvidersConfig(); + const afterEnable = new ProvidersConfigStore(config.rootDir).loadProvidersConfig(); expect(afterEnable?.openai?.apiKey).toBe("sk-test"); expect(afterEnable?.openai?.baseUrl).toBe("https://api.openai.com/v1"); expect(afterEnable?.openai?.enabled).toBeUndefined(); @@ -1760,20 +1811,25 @@ describe("ProviderService.setConfig", () => { if (!result.success) { expect(result.error).toContain("does not exist"); } - expect(config.loadProvidersConfig()?.["bad provider"]).toBeUndefined(); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["bad provider"] + ).toBeUndefined(); }); }); it("validates custom provider type edits", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig(), }); for (const providerType of CUSTOM_PROVIDER_TYPES) { const result = await service.setConfig("local-vllm", ["providerType"], providerType); expect(result.success).toBe(true); - expect(config.loadProvidersConfig()?.["local-vllm"]?.providerType).toBe(providerType); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["local-vllm"] + ?.providerType + ).toBe(providerType); } const invalid = await service.setConfig("local-vllm", ["providerType"], "unknown-format"); @@ -1781,13 +1837,15 @@ describe("ProviderService.setConfig", () => { if (!invalid.success) { expect(invalid.error).toContain("Invalid custom provider type"); } - expect(config.loadProvidersConfig()?.["local-vllm"]?.providerType).toBe("anthropic-messages"); + expect( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.["local-vllm"]?.providerType + ).toBe("anthropic-messages"); }); }); it("rejects custom providers as route override targets", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "local-vllm": localVllmConfig(), }); @@ -1805,7 +1863,7 @@ describe("ProviderService.setConfig", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-provider-service-")); try { const config = new Config(tmpDir); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "sk-ant-test", cacheTtl: "1h", @@ -1827,7 +1885,7 @@ describe("ProviderService.setConfig", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-provider-service-")); try { const config = new Config(tmpDir); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "sk-ant-test", // Intentionally invalid @@ -1848,7 +1906,7 @@ describe("ProviderService.setConfig", () => { it("surfaces disableBetaFeatures: true for Anthropic", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "sk-ant-test", disableBetaFeatures: true }, }); @@ -1860,7 +1918,7 @@ describe("ProviderService.setConfig", () => { it("omits disableBetaFeatures when not set for Anthropic", () => { withTempConfig((config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "sk-ant-test" }, }); @@ -1887,7 +1945,7 @@ describe("ProviderService denied keyPath segments", () => { success: false, error: `Denied key path segment: "${deniedSegment}"`, }); - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); } @@ -1900,7 +1958,7 @@ describe("ProviderService denied keyPath segments", () => { success: false, error: 'Denied key path segment: "__proto__"', }); - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); @@ -1914,7 +1972,7 @@ describe("ProviderService denied keyPath segments", () => { success: false, error: 'Denied key path segment: "__proto__"', }); - expect(config.loadProvidersConfig()).toBeNull(); + expect(new ProvidersConfigStore(config.rootDir).loadProvidersConfig()).toBeNull(); }); }); }); @@ -1922,7 +1980,9 @@ describe("ProviderService denied keyPath segments", () => { describe("ProviderService.updateConfigValue", () => { it("writes when the predicate accepts and skips when it returns null", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ coder: { coderOauth: { refresh: "rt_a" } } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + coder: { coderOauth: { refresh: "rt_a" } }, + }); // Predicate matches: write applies. const applied = await service.updateConfigValue("coder", ["coderOauth"], (current) => @@ -1932,7 +1992,11 @@ describe("ProviderService.updateConfigValue", () => { ); expect(applied).toEqual({ success: true, data: { applied: true } }); expect( - (config.loadProvidersConfig()?.coder?.coderOauth as { refresh?: string })?.refresh + ( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.coder?.coderOauth as { + refresh?: string; + } + )?.refresh ).toBe("rt_b"); // Predicate no longer matches (rt_a was replaced): compare-and-set skips. @@ -1943,14 +2007,20 @@ describe("ProviderService.updateConfigValue", () => { ); expect(skipped).toEqual({ success: true, data: { applied: false } }); expect( - (config.loadProvidersConfig()?.coder?.coderOauth as { refresh?: string })?.refresh + ( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.coder?.coderOauth as { + refresh?: string; + } + )?.refresh ).toBe("rt_b"); }); }); it("serializes concurrent updates so no write is lost", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ coder: { coderOauth: { generation: 0 } } }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + coder: { coderOauth: { generation: 0 } }, + }); // Both updates read-modify-write the same value; the lock must serialize // them so both increments land. @@ -1963,14 +2033,18 @@ describe("ProviderService.updateConfigValue", () => { const [a, b] = await Promise.all([increment(), increment()]); expect(a.success && b.success).toBe(true); expect( - (config.loadProvidersConfig()?.coder?.coderOauth as { generation?: number })?.generation + ( + new ProvidersConfigStore(config.rootDir).loadProvidersConfig()?.coder?.coderOauth as { + generation?: number; + } + )?.generation ).toBe(2); }); }); it("breaks stale locks left by crashed processes", async () => { await withTempConfigAsync(async (config, service) => { - config.saveProvidersConfig({ coder: {} }); + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: {} }); // Simulate a crashed process: lock dir exists with an old mtime. const lockPath = path.join(config.rootDir, "providers.jsonc.lock"); await fsPromises.mkdir(lockPath); @@ -2043,7 +2117,7 @@ describe("ProviderService gateway lifecycle", () => { it("preserves manual bedrock routePriority entry when only region is configured", async () => { await withTempConfigAsync(async (config, service) => { await saveRoutePriority(config, ["bedrock", "direct"]); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ bedrock: { region: "us-east-1" }, }); @@ -2059,7 +2133,7 @@ describe("ProviderService gateway lifecycle", () => { it("removes bedrock from routePriority when fully deconfigured", async () => { await withTempConfigAsync(async (config, service) => { await saveRoutePriority(config, ["bedrock", "direct"]); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ bedrock: { region: "us-east-1" }, }); @@ -2075,7 +2149,7 @@ describe("ProviderService gateway lifecycle", () => { it("removes bedrock from routePriority when explicitly disabled", async () => { await withTempConfigAsync(async (config, service) => { await saveRoutePriority(config, ["bedrock", "direct"]); - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ bedrock: { region: "us-east-1" }, }); @@ -2187,7 +2261,8 @@ describe("ProviderService gateway lifecycle", () => { expect(result).toEqual({ success: true, data: { applied: true } }); // The credential write itself landed. - const stored = config.loadProvidersConfig()?.coder as Record; + const stored = new ProvidersConfigStore(config.rootDir).loadProvidersConfig() + ?.coder as Record; expect(stored.coderOauth).toBeDefined(); } finally { editSpy.mockRestore(); @@ -2210,7 +2285,7 @@ describe("ProviderService gateway lifecycle", () => { // and runtime model creation — or this write would evict coder from // routePriority while requests keep working against the forced // deployment. - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ coder: { deploymentUrl: "https://user-edited.example.com", coderOauth: { diff --git a/src/node/services/providerService.ts b/src/node/services/providerService.ts index b9586690d6..586a68f660 100644 --- a/src/node/services/providerService.ts +++ b/src/node/services/providerService.ts @@ -1,5 +1,6 @@ import { EventEmitter } from "events"; import type { Config, ProjectsConfig } from "@/node/config"; +import { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import { PROVIDER_DEFINITIONS, SUPPORTED_PROVIDERS, @@ -140,13 +141,19 @@ export class ProviderService { // granularity (FAT, some network mounts) can collide on mtimeMs across // distinct writes, and an external edit that produces byte-identical // contents is a no-op anyway. + public readonly providersConfigStore: ProvidersConfigStore; + public readonly fileLeaseManager: FileLeaseManager; private lastSelfWriteFingerprint: string | null = null; constructor( private readonly config: Config, - policyService?: PolicyService + policyService?: PolicyService, + providersConfigStore?: ProvidersConfigStore, + fileLeaseManager?: FileLeaseManager ) { this.policyService = policyService ?? null; + this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(config.rootDir); + this.fileLeaseManager = fileLeaseManager ?? new FileLeaseManager(config.rootDir); // The provider config subscription may have many concurrent listeners (e.g. multiple windows). // Avoid noisy MaxListenersExceededWarning for normal usage. this.emitter.setMaxListeners(50); @@ -158,9 +165,9 @@ export class ProviderService { // the fingerprint captured by notifyFromMutation(); any external // edit that changes the bytes between the in-app save and the // watcher fire produces a different fingerprint and is forwarded. - this.stopWatchingProvidersFile = this.config.watchProvidersFile(() => { + this.stopWatchingProvidersFile = this.providersConfigStore.watchProvidersFile(() => { const expected = this.lastSelfWriteFingerprint; - const current = this.config.getProvidersFileFingerprint(); + const current = this.providersConfigStore.getProvidersFileFingerprint(); if (expected !== null && current !== null && current === expected) { // This watcher fire corresponds to our own write (or a benign // no-op external save with identical bytes). Clear the @@ -209,7 +216,7 @@ export class ProviderService { * accidentally suppressed. */ private notifyFromMutation(): void { - this.lastSelfWriteFingerprint = this.config.getProvidersFileFingerprint(); + this.lastSelfWriteFingerprint = this.providersConfigStore.getProvidersFileFingerprint(); this.notifyConfigChanged(); } @@ -268,7 +275,7 @@ export class ProviderService { public list(): string[] { try { const providers = this.listBuiltInProviders(); - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const customProviderIds = getCustomProviderIds(providersConfig); this.detectAndLogShadowedProviders(providersConfig); const allowedCustomProviderIds = this.policyService?.isEnforced() @@ -285,7 +292,7 @@ export class ProviderService { * Get the full providers config with safe info (no actual API keys) */ public getConfig(): ProvidersConfigMap { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const mainConfig = this.config.loadConfigOrDefault(); const result: ProvidersConfigMap = {}; const shadowedCustomProviderIds = this.detectAndLogShadowedProviders(providersConfig); @@ -658,9 +665,11 @@ export class ProviderService { // Read-modify-write under the cross-process lock so concurrent writers // (other windows, CLI processes) cannot clobber each other's saves. // The callback returns an error result to bail, or null once saved. - const lockError = await this.config.withProvidersFileLock( + const lockError = await this.fileLeaseManager.withProvidersFileLock( (): CustomProviderMutationResult | null => { - const providersConfig = getProviderConfigRecord(this.config.loadProvidersConfig() ?? {}); + const providersConfig = getProviderConfigRecord( + this.providersConfigStore.loadProvidersConfig() ?? {} + ); if (Object.hasOwn(providersConfig, provider)) { return { success: false, @@ -727,7 +736,7 @@ export class ProviderService { }; providersConfig[provider] = providerConfig; - this.config.saveProvidersConfig(providersConfig); + this.providersConfigStore.saveProvidersConfig(providersConfig); return null; } ); @@ -763,7 +772,9 @@ export class ProviderService { providerInput: string ): Promise> { const provider = providerInput.trim(); - const providersConfig = getProviderConfigRecord(this.config.loadProvidersConfig() ?? {}); + const providersConfig = getProviderConfigRecord( + this.providersConfigStore.loadProvidersConfig() ?? {} + ); const providerConfig = providersConfig[provider]; // Manual providers.jsonc edits can shadow a built-in id. Removing that entry // restores the built-in default, so only reject bona fide built-in configs. @@ -812,10 +823,10 @@ export class ProviderService { try { // Re-validate and delete under the cross-process lock (see setConfigValue). - const lockError = await this.config.withProvidersFileLock( + const lockError = await this.fileLeaseManager.withProvidersFileLock( (): CustomProviderMutationResult | null => { const latestProvidersConfig = getProviderConfigRecord( - this.config.loadProvidersConfig() ?? {} + this.providersConfigStore.loadProvidersConfig() ?? {} ); if (!isCustomProviderConfig(latestProvidersConfig[provider])) { return { @@ -828,7 +839,7 @@ export class ProviderService { } delete latestProvidersConfig[provider]; - this.config.saveProvidersConfig(latestProvidersConfig); + this.providersConfigStore.saveProvidersConfig(latestProvidersConfig); return null; } ); @@ -991,13 +1002,13 @@ export class ProviderService { // Read-modify-write under the cross-process lock (see setConfigValue). // The callback returns a policy denial to bail, or null once saved. - const policyDenial = await this.config.withProvidersFileLock((): string | null => { + const policyDenial = await this.fileLeaseManager.withProvidersFileLock((): string | null => { const denial = this.validateModelsEditPolicy(provider, normalizedModels); if (denial != null) { return denial; } - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; if (!providersConfig[provider]) { providersConfig[provider] = {}; @@ -1011,7 +1022,7 @@ export class ProviderService { } else { providersConfig[provider].models = normalizedModels; } - this.config.saveProvidersConfig(providersConfig); + this.providersConfigStore.saveProvidersConfig(providersConfig); return null; }); if (policyDenial != null) { @@ -1102,7 +1113,7 @@ export class ProviderService { const def = PROVIDER_DEFINITIONS[providerName]; if (def.kind !== "gateway") return; - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const rawProviderConfig = providersConfig[providerName] ?? {}; // Coder credentials are issuer-bound and its deploymentUrl field stays // editable under an enforced forcedBaseUrl: lifecycle checks must resolve @@ -1196,13 +1207,13 @@ export class ProviderService { // writer must cooperate or a whole-file save from one process could // resurrect credentials another process just rotated/cleared. // The callback returns a policy denial to bail, or null once saved. - const policyDenial = await this.config.withProvidersFileLock((): string | null => { + const policyDenial = await this.fileLeaseManager.withProvidersFileLock((): string | null => { const denial = this.validateProviderEditPolicy(provider, keyPath); if (denial != null) { return denial; } - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; // Ensure provider exists if (!providersConfig[provider]) { @@ -1238,7 +1249,7 @@ export class ProviderService { } // Save updated config - this.config.saveProvidersConfig(providersConfig); + this.providersConfigStore.saveProvidersConfig(providersConfig); return null; }); if (policyDenial != null) { @@ -1283,10 +1294,10 @@ export class ProviderService { } try { - const applied = await this.config.withProvidersFileLock(() => { + const applied = await this.fileLeaseManager.withProvidersFileLock(() => { // Load, decide, and write under the lock — no awaits in between, so // the predicate result cannot be invalidated by any cooperating writer. - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; let current: unknown = providersConfig[provider]; for (const key of keyPath) { current = @@ -1318,7 +1329,7 @@ export class ProviderService { target[lastKey] = decision.value; } - this.config.saveProvidersConfig(providersConfig); + this.providersConfigStore.saveProvidersConfig(providersConfig); return true; }); @@ -1370,8 +1381,8 @@ export class ProviderService { ) => { value: Record } | null ): Promise> { try { - const applied = await this.config.withProvidersFileLock(() => { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const applied = await this.fileLeaseManager.withProvidersFileLock(() => { + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const section = providersConfig[provider] as Record | undefined; const decision = update(section); @@ -1387,7 +1398,7 @@ export class ProviderService { } providersConfig[provider] = decision.value as BaseProviderConfig; - this.config.saveProvidersConfig(providersConfig); + this.providersConfigStore.saveProvidersConfig(providersConfig); return true; }); @@ -1432,13 +1443,13 @@ export class ProviderService { // Read-modify-write under the cross-process lock (see setConfigValue). // The callback returns a policy denial to bail, or null once saved. - const policyDenial = await this.config.withProvidersFileLock((): string | null => { + const policyDenial = await this.fileLeaseManager.withProvidersFileLock((): string | null => { const denial = this.validateProviderEditPolicy(provider, keyPath); if (denial != null) { return denial; } - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; // The add-time id collision rule applies only when this write would // CONVERT a non-custom entry into a custom provider. An entry that is @@ -1522,7 +1533,7 @@ export class ProviderService { } // Save updated config - this.config.saveProvidersConfig(providersConfig); + this.providersConfigStore.saveProvidersConfig(providersConfig); return null; }); if (policyDenial != null) { @@ -1548,7 +1559,7 @@ export class ProviderService { } public validateRouteOverrides(routeOverrides: Record): Result { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; for (const routeTarget of Object.values(routeOverrides)) { const targetConfig = providersConfig[routeTarget]; if (isCustomProviderConfig(targetConfig)) { diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 2c91917c18..5712ee48ea 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -3,6 +3,7 @@ import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBeha import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; import { log } from "@/node/services/log"; import type { Config } from "@/node/config"; +import { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import { createCoreServices, type CoreServices } from "@/node/services/coreServices"; import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; @@ -89,6 +90,8 @@ import type { ORPCContext } from "@/node/orpc/context"; export class ServiceContainer { public readonly workflowRuntimeFactory = new QuickJSRuntimeFactory(); public readonly config: Config; + public readonly providersConfigStore: ProvidersConfigStore; + public readonly fileLeaseManager: FileLeaseManager; // Core services — instantiated by createCoreServices (shared with `xum run` CLI) private readonly historyService: CoreServices["historyService"]; public readonly aiService: CoreServices["aiService"]; @@ -153,6 +156,8 @@ export class ServiceContainer { constructor(config: Config) { this.config = config; + this.providersConfigStore = new ProvidersConfigStore(config.rootDir); + this.fileLeaseManager = new FileLeaseManager(config.rootDir); // Cross-cutting services: created first so they can be passed to core // services via constructor params (no setter injection needed). @@ -179,6 +184,8 @@ export class ServiceContainer { const core = createCoreServices({ config, + providersConfigStore: this.providersConfigStore, + fileLeaseManager: this.fileLeaseManager, extensionMetadataPath: path.join(config.rootDir, "extensionMetadata.json"), workspaceMcpOverridesService: this.workspaceMcpOverridesService, policyService: this.policyService, @@ -335,7 +342,7 @@ export class ServiceContainer { this.mcpServerManager.setMcpOauthService(this.mcpOauthService); this.muxGatewayOauthService = new MuxGatewayOauthService( - config, + this.providersConfigStore, this.providerService, this.windowService ); @@ -345,13 +352,14 @@ export class ServiceContainer { this.policyService ); this.codexOauthService = new CodexOauthService( - config, + this.providersConfigStore, this.providerService, this.windowService ); core.turnRequestBuilderBindings.codexOauthService = this.codexOauthService; this.coderOauthService = new CoderOauthService( - config, + this.providersConfigStore, + this.fileLeaseManager, this.providerService, this.windowService, // Policy-aware: an enforced forcedBaseUrl overrides the deployment URL @@ -405,7 +413,12 @@ export class ServiceContainer { ); this.serverService = new ServerService(); this.menuEventService = new MenuEventService(); - this.voiceService = new VoiceService(config, this.providerService, this.policyService); + this.voiceService = new VoiceService( + config, + this.providerService, + this.policyService, + this.providersConfigStore + ); this.coderService = coderService; this.serverAuthService = new ServerAuthService(config); @@ -631,6 +644,8 @@ export class ServiceContainer { return { workflowRuntimeFactory: this.workflowRuntimeFactory, config: this.config, + providersConfigStore: this.providersConfigStore, + fileLeaseManager: this.fileLeaseManager, aiService: this.aiService, historyService: this.historyService, streamManager: this.streamManager, diff --git a/src/node/services/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts index cc961d50b5..1c2ac1ae45 100644 --- a/src/node/services/turnRequestBuilder.test.ts +++ b/src/node/services/turnRequestBuilder.test.ts @@ -7,7 +7,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { addInterruptedSentinel } from "@/browser/utils/messages/modelMessageTransform"; import { buildWorkflowRunCardMessage } from "@/common/utils/workflowRunMessages"; import * as providerOptionsModule from "@/common/utils/ai/providerOptions"; -import type { ProvidersConfig } from "@/node/config"; +import { ProvidersConfigStore, type ProvidersConfig } from "@/node/config"; import { InitStateManager } from "./initStateManager"; import { ProviderModelFactory } from "./providerModelFactory"; import { ProviderService } from "./providerService"; @@ -25,6 +25,7 @@ async function createPreparationHarness() { const testHistory = await createTestHistoryService(); const { config, historyService } = testHistory; const providerService = new ProviderService(config); + const providersConfigStore = new ProvidersConfigStore(config.rootDir); const streamManager = new StreamManager( historyService, undefined, @@ -33,6 +34,7 @@ async function createPreparationHarness() { ); const builder = new TurnRequestBuilder({ config, + providersConfigStore, historyService, initStateManager: new InitStateManager(config), providerService, @@ -65,7 +67,7 @@ async function createPreparationHarness() { isStreaming: () => false, trackPendingDevToolsRunMetadata: () => undefined, }); - return { ...testHistory, builder, providerService }; + return { ...testHistory, builder, providerService, providersConfigStore }; } function preparationOptions( @@ -251,7 +253,7 @@ describe("TurnRequestBuilder model attempt preparation", () => { it("merges call settings and provider extras at the resolved namespace", async () => { const harness = await createPreparationHarness(); try { - harness.config.saveProvidersConfig({ + harness.providersConfigStore.saveProvidersConfig({ openai: { modelParameters: { "*": { temperature: 0.7, reasoning: { max_tokens: 4096 } }, @@ -395,7 +397,9 @@ describe("TurnRequestBuilder model attempt preparation", () => { ])("$name", async (testCase) => { const harness = await createPreparationHarness(); try { - harness.config.saveProvidersConfig(testCase.rawConfig as unknown as ProvidersConfig); + harness.providersConfigStore.saveProvidersConfig( + testCase.rawConfig as unknown as ProvidersConfig + ); const prepared = harness.builder.prepareModelAttempt( preparationOptions( testCase.snapshot as unknown as ProvidersConfigMap, diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 6139a539de..badd10a30f 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -31,7 +31,7 @@ import { type MCPPromptRuntime, type ToolConfiguration, } from "@/common/utils/tools/tools"; -import type { Config } from "@/node/config"; +import type { Config, ProvidersConfigStore } from "@/node/config"; import { getRuntimeType, getXumEnv } from "@/node/runtime/initHook"; import { type WorkspaceRuntimeContext } from "@/node/runtime/runtimeHelpers"; import { agentPluginHookService } from "@/node/services/agentPlugins/hookService"; @@ -464,6 +464,7 @@ export interface TurnRequestBuilderBindings extends OauthServiceBindings { interface TurnRequestBuilderDependencies { config: Config; + providersConfigStore: ProvidersConfigStore; historyService: HistoryService; initStateManager: InitStateManager; providerService: ProviderService; @@ -621,7 +622,7 @@ export class TurnRequestBuilder { ); const resolvedOverrides = resolveModelParameterOverrides( pinCoderInstanceRawProvidersConfig( - this.dependencies.config.loadProvidersConfig(), + this.dependencies.providersConfigStore.loadProvidersConfig(), options.rawModelString, options.coderSelectedInstance ), @@ -1895,7 +1896,8 @@ export class TurnRequestBuilder { // pinned pricing identity: two independent reads would let // a catalog refresh land between them, running the request // on one wire while recording usage under another type. - const advisorProvidersConfig = this.dependencies.config.loadProvidersConfig() ?? {}; + const advisorProvidersConfig = + this.dependencies.providersConfigStore.loadProvidersConfig() ?? {}; // View snapshot captured at creation time for option // building (buildProviderOptions takes the oRPC view, not // the raw config shape). diff --git a/src/node/services/voiceService.test.ts b/src/node/services/voiceService.test.ts index 39865e6110..04486ca17e 100644 --- a/src/node/services/voiceService.test.ts +++ b/src/node/services/voiceService.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { MUX_GATEWAY_ORIGIN } from "@/common/constants/muxGatewayOAuth"; -import { Config } from "@/node/config"; +import { Config, ProvidersConfigStore } from "@/node/config"; import { PolicyService } from "@/node/services/policyService"; import { ProviderService } from "./providerService"; import { VoiceService } from "./voiceService"; @@ -48,7 +48,7 @@ describe("VoiceService.transcribe", () => { it("returns provider-disabled error without calling fetch", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", enabled: false, @@ -75,7 +75,7 @@ describe("VoiceService.transcribe", () => { it("calls fetch when OpenAI provider is enabled with an API key", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ openai: { apiKey: "sk-test", }, @@ -97,7 +97,7 @@ describe("VoiceService.transcribe", () => { it("uses gateway when couponCode is set and OpenAI key is absent", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", }, @@ -125,7 +125,7 @@ describe("VoiceService.transcribe", () => { it("preserves reverse-proxy path prefix from gateway baseURL", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", baseURL: "https://proxy.example.com/gateway/api/v1/ai-gateway/v1/ai", @@ -154,7 +154,7 @@ describe("VoiceService.transcribe", () => { it("prefers gateway over OpenAI when both are configured", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", }, @@ -185,7 +185,7 @@ describe("VoiceService.transcribe", () => { it("respects direct-before-gateway route priority when both are configured", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", }, @@ -220,7 +220,7 @@ describe("VoiceService.transcribe", () => { it("falls back to OpenAI when gateway is disabled", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", enabled: false, @@ -252,7 +252,7 @@ describe("VoiceService.transcribe", () => { it("returns error when the mux-gateway provider is disabled and OpenAI is unavailable", async () => { await withTempConfig(async (config, service) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", enabled: false, @@ -279,7 +279,7 @@ describe("VoiceService.transcribe", () => { it("falls back to OpenAI when policy disallows mux-gateway", async () => { await withTempConfig(async (config, service, _providerService, policyService) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", }, @@ -313,7 +313,7 @@ describe("VoiceService.transcribe", () => { it("uses policy forced base URL for gateway transcription", async () => { await withTempConfig(async (config, service, _providerService, policyService) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", baseURL: "https://config.example.com/config-prefix/api/v1/ai-gateway/v1/ai", @@ -350,7 +350,7 @@ describe("VoiceService.transcribe", () => { it("clears gateway credentials on 401", async () => { await withTempConfig(async (config, service, providerService) => { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ "mux-gateway": { couponCode: "gateway-token", voucher: "legacy-token", diff --git a/src/node/services/voiceService.ts b/src/node/services/voiceService.ts index 7069e99a80..9100878061 100644 --- a/src/node/services/voiceService.ts +++ b/src/node/services/voiceService.ts @@ -4,7 +4,7 @@ import type { Result } from "@/common/types/result"; import { getErrorMessage } from "@/common/utils/errors"; import { isProviderDisabledInConfig } from "@/common/utils/providers/isProviderDisabled"; import { resolveProviderCredentials } from "@/node/utils/providerRequirements"; -import type { Config } from "@/node/config"; +import { ProvidersConfigStore, type Config } from "@/node/config"; import type { PolicyService } from "@/node/services/policyService"; import type { ProviderService } from "@/node/services/providerService"; @@ -32,11 +32,16 @@ interface MuxGatewayTranscriptionConfig { * Voice input service using OpenAI-compatible transcription APIs. */ export class VoiceService { + private readonly providersConfigStore: ProvidersConfigStore; + constructor( private readonly config: Config, private readonly providerService?: ProviderService, - private readonly policyService?: PolicyService - ) {} + private readonly policyService?: PolicyService, + providersConfigStore?: ProvidersConfigStore + ) { + this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(config.rootDir); + } /** * Transcribe audio from base64-encoded data using mux-gateway or OpenAI. @@ -45,7 +50,7 @@ export class VoiceService { */ async transcribe(audioBase64: string): Promise> { try { - const providersConfig = this.config.loadProvidersConfig() ?? {}; + const providersConfig = this.providersConfigStore.loadProvidersConfig() ?? {}; const gatewayConfig = providersConfig["mux-gateway"] as | MuxGatewayTranscriptionConfig | undefined; diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 0040ba5c56..68d0f054a8 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -37,7 +37,7 @@ import { UNPRICED_TARGET_MODEL_GOAL_MESSAGE, } from "@/common/utils/goals/budgetPricing"; import type { SendMessageError } from "@/common/types/errors"; -import type { Config } from "@/node/config"; +import { ProvidersConfigStore, type Config } from "@/node/config"; import type { HistoryService } from "@/node/services/historyService"; import type { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { workspaceFileLocks } from "@/node/utils/concurrency/workspaceFileLocks"; @@ -2642,14 +2642,8 @@ export class WorkspaceGoalService { } private getProvidersConfigForPricing(): ProvidersConfigMap | null { - const maybeConfig = this.config as Config & { - loadProvidersConfig?: () => ProvidersConfigMap | null; - }; - if (typeof maybeConfig.loadProvidersConfig !== "function") { - return null; - } - // eslint-disable-next-line local/no-chained-type-assertions -- grandfathered when the rule was introduced; fix the underlying type instead of copying this pattern - return maybeConfig.loadProvidersConfig() as unknown as ProvidersConfigMap | null; + const providersConfig = new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig(); + return providersConfig as unknown as ProvidersConfigMap | null; } async requestPendingGoalContinuationDispatch(workspaceId: string): Promise { diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5dbcbf7f22..898eadefc1 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -18,7 +18,7 @@ import { import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; -import type { Config } from "@/node/config"; +import { ProvidersConfigStore, type Config } from "@/node/config"; import type { ProjectsConfig, Workspace } from "@/common/types/project"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -10279,9 +10279,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { hasBudgetedResumableGoal(goal) && !modelHasPricingData( normalized.data.model, - typeof this.config.loadProvidersConfig === "function" - ? this.config.loadProvidersConfig() - : null + new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig() ) ) { return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); diff --git a/tests/ipc/providers/openaiCompatible.test.ts b/tests/ipc/providers/openaiCompatible.test.ts index 9346b52537..acb1a4e126 100644 --- a/tests/ipc/providers/openaiCompatible.test.ts +++ b/tests/ipc/providers/openaiCompatible.test.ts @@ -1,7 +1,7 @@ import * as http from "node:http"; import type { AddressInfo } from "node:net"; -import type { ProvidersConfig } from "@/common/config/schemas/providersConfig"; +import { ProvidersConfigStore, type ProvidersConfig } from "@/node/config"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; import { OPENAI_RESPONSES_BASE_URL_HINT } from "@/node/services/utils/openAIResponsesBaseUrlHint"; import { loadTokenizerModules } from "@/node/utils/main/tokenizer"; @@ -238,7 +238,7 @@ async function createConfiguredWorkspace(providersConfig: ProvidersConfig): Prom }> { const tempGitRepo = await createTempGitRepo(); const env = await createTestEnvironment(); - env.config.saveProvidersConfig(providersConfig); + new ProvidersConfigStore(env.config.rootDir).saveProvidersConfig(providersConfig); const created = await createWorkspace(env, tempGitRepo, generateBranchName("openai-compatible")); if (!created.success) { diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 9684144d30..a7f83e04f4 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -2,7 +2,7 @@ import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; import type { BrowserWindow, WebContents } from "electron"; -import { Config } from "../../src/node/config"; +import { Config, ProvidersConfigStore } from "../../src/node/config"; import { ServiceContainer } from "../../src/node/services/serviceContainer"; import { setOpenSSHHostKeyPolicyMode } from "../../src/node/runtime/sshConnectionPool"; import { @@ -66,7 +66,7 @@ export async function createTestEnvironment(): Promise { // For integration tests (TEST_INTEGRATION=1), do NOT write dummy keys here (they would override // real env-backed credentials used by tests like name generation). if (!shouldRunIntegrationTests()) { - config.saveProvidersConfig({ + new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ anthropic: { apiKey: "test-key-for-ui-tests" }, }); } From 8550235535539c772ebb3048eaf2d69a2ff1407a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:06:22 +0000 Subject: [PATCH 03/17] refactor(config): add SecretsStore extraction checkpoint --- src/node/config/SecretsStore.ts | 415 ++++++++++++++++++++++++++++++++ 1 file changed, 415 insertions(+) create mode 100644 src/node/config/SecretsStore.ts diff --git a/src/node/config/SecretsStore.ts b/src/node/config/SecretsStore.ts new file mode 100644 index 0000000000..1777858bf7 --- /dev/null +++ b/src/node/config/SecretsStore.ts @@ -0,0 +1,415 @@ +import * as fs from "fs"; +import * as path from "path"; +import writeFileAtomic from "write-file-atomic"; +import { getXumHome } from "@/common/constants/paths"; +import { isSecretReferenceValue, type Secret, type SecretsConfig } from "@/common/types/secrets"; +import { log } from "@/node/services/log"; +import { ensurePrivateDirSync } from "@/node/utils/fs"; +import { stripTrailingSlashes } from "@/node/utils/pathUtils"; + +export class SecretsStore { + readonly rootDir: string; + private readonly secretsFile: string; + + constructor(rootDir?: string) { + this.rootDir = rootDir ?? getXumHome(); + this.secretsFile = path.join(this.rootDir, "secrets.json"); + } + + private static readonly GLOBAL_SECRETS_KEY = "__global__"; + + private static normalizeSecretsProjectPath(projectPath: string): string { + return stripTrailingSlashes(projectPath); + } + + private static isSecretValue(value: unknown): value is Secret["value"] { + if (typeof value === "string") { + return true; + } + + return isSecretReferenceValue(value); + } + + private static isSecret(value: unknown): value is Secret { + return ( + typeof value === "object" && + value !== null && + "key" in value && + "value" in value && + typeof (value as { key?: unknown }).key === "string" && + SecretsStore.isSecretValue((value as { value?: unknown }).value) + ); + } + + private static parseSecretsArray(value: unknown): Secret[] { + if (!Array.isArray(value)) { + return []; + } + + const sanitizedSecrets: Secret[] = []; + + for (const entry of value) { + // Filter invalid entries to avoid crashes when iterating secrets. + if (!SecretsStore.isSecret(entry)) { + continue; + } + + // Preserve key/value when persisted data includes malformed injectAll values. + // This keeps existing secrets usable while ignoring invalid inject-all flags. + const entryWithInjectAll = entry as Secret & { injectAll?: unknown }; + if (typeof entryWithInjectAll.injectAll === "boolean") { + sanitizedSecrets.push({ + key: entryWithInjectAll.key, + value: entryWithInjectAll.value, + injectAll: entryWithInjectAll.injectAll, + }); + continue; + } + + sanitizedSecrets.push({ + key: entryWithInjectAll.key, + value: entryWithInjectAll.value, + }); + } + + return sanitizedSecrets; + } + + /** + * Merge an updated secrets list with raw on-disk entries, preserving entries + * whose value shapes this build no longer understands (e.g. legacy 1Password + * `{ op: ... }` references) so a downgrade can still read them. Supported + * entries are fully represented in the UI, so `next` is authoritative for + * them; a preserved legacy entry is dropped only when the update reuses its + * key (the new value intentionally replaces it). + */ + private static mergeSecretsPreservingUnsupported( + rawEntries: unknown[], + next: Secret[] + ): unknown[] { + const nextKeys = new Set(next.map((secret) => secret.key)); + const preserved: unknown[] = []; + + for (const entry of rawEntries) { + if (SecretsStore.isSecret(entry)) { + continue; + } + + if ( + typeof entry === "object" && + entry !== null && + typeof (entry as { key?: unknown }).key === "string" && + !nextKeys.has((entry as { key: string }).key) + ) { + preserved.push(entry); + } + } + + return [...next, ...preserved]; + } + + private static mergeSecretsByKey(primary: Secret[], secondary: Secret[]): Secret[] { + // Merge-by-key (last writer wins). + const mergedByKey = new Map(); + for (const secret of primary) { + mergedByKey.set(secret.key, secret); + } + for (const secret of secondary) { + mergedByKey.set(secret.key, secret); + } + return Array.from(mergedByKey.values()); + } + + private static normalizeSecretsConfig(raw: unknown): SecretsConfig { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return {}; + } + + const record = raw as Record; + const normalized: SecretsConfig = {}; + + for (const [rawKey, rawValue] of Object.entries(record)) { + let key = rawKey; + if (rawKey !== SecretsStore.GLOBAL_SECRETS_KEY) { + const normalizedKey = SecretsStore.normalizeSecretsProjectPath(rawKey); + key = normalizedKey || rawKey; + } + + const secrets = SecretsStore.parseSecretsArray(rawValue); + + if (!Object.prototype.hasOwnProperty.call(normalized, key)) { + normalized[key] = secrets; + continue; + } + + normalized[key] = SecretsStore.mergeSecretsByKey(normalized[key], secrets); + } + + return normalized; + } + + /** + * Load secrets configuration from JSON file + * Returns empty config if file doesn't exist + */ + loadSecretsConfig(): SecretsConfig { + try { + if (fs.existsSync(this.secretsFile)) { + const data = fs.readFileSync(this.secretsFile, "utf-8"); + const parsed = JSON.parse(data) as unknown; + return SecretsStore.normalizeSecretsConfig(parsed); + } + } catch (error) { + log.error("Error loading secrets config:", error); + } + + return {}; + } + + /** + * Load the secrets file without filtering entry shapes. Used by the update + * paths so unsupported legacy entries survive round-trips to disk instead of + * being silently deleted when an unrelated secret is saved. + */ + private loadRawSecretsConfig(): Record { + try { + if (fs.existsSync(this.secretsFile)) { + const parsed = JSON.parse(fs.readFileSync(this.secretsFile, "utf-8")) as unknown; + if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { + return { ...(parsed as Record) }; + } + } + } catch (error) { + log.error("Error loading secrets config:", error); + } + + return {}; + } + + /** + * Replace one bucket of the secrets file (global sentinel or a normalized + * project path) while leaving every other bucket byte-for-byte intact and + * preserving unsupported legacy entries within the target bucket. + */ + private async updateSecretsBucket(bucketKey: string, secrets: Secret[]): Promise { + const raw = this.loadRawSecretsConfig(); + + // Project paths may be persisted with trailing slashes; fold every raw key + // that maps to this bucket so preserved entries aren't left in a shadowed + // duplicate bucket. + const rawBucketEntries: unknown[] = []; + for (const [rawKey, rawValue] of Object.entries(raw)) { + const mappedKey = + rawKey === SecretsStore.GLOBAL_SECRETS_KEY + ? rawKey + : SecretsStore.normalizeSecretsProjectPath(rawKey) || rawKey; + if (mappedKey !== bucketKey) { + continue; + } + + if (Array.isArray(rawValue)) { + // Array.isArray narrows unknown to any[]; retype to unknown[] for safe handling. + rawBucketEntries.push(...(rawValue as unknown[])); + } + delete raw[rawKey]; + } + + raw[bucketKey] = SecretsStore.mergeSecretsPreservingUnsupported(rawBucketEntries, secrets); + await this.saveSecretsConfig(raw); + } + + /** + * Save secrets configuration to JSON file + * @param config The secrets configuration to save + */ + async saveSecretsConfig(config: SecretsConfig | Record): Promise { + try { + if (!fs.existsSync(this.rootDir)) { + ensurePrivateDirSync(this.rootDir); + } + + await writeFileAtomic(this.secretsFile, JSON.stringify(config, null, 2), { + encoding: "utf-8", + mode: 0o600, + }); + } catch (error) { + log.error("Error saving secrets config:", error); + throw error; + } + } + + /** + * Get global secrets (not project-scoped). + * + * Stored in /secrets.json under a sentinel key for backwards compatibility. + */ + getGlobalSecrets(): Secret[] { + const config = this.loadSecretsConfig(); + return config[SecretsStore.GLOBAL_SECRETS_KEY] ?? []; + } + + /** Update global secrets (not project-scoped). */ + async updateGlobalSecrets(secrets: Secret[]): Promise { + await this.updateSecretsBucket(SecretsStore.GLOBAL_SECRETS_KEY, secrets); + } + + /** + * Get effective secrets for a project. + * + * Project secrets define which env vars are injected into this project/workspace. + * Global secrets can be injected for all projects when `injectAll` is enabled, + * and are also used as a shared value store for `{ secret: "GLOBAL_KEY" }` references. + */ + getEffectiveSecrets(projectPath: string): Secret[] { + const normalizedProjectPath = + SecretsStore.normalizeSecretsProjectPath(projectPath) || projectPath; + const config = this.loadSecretsConfig(); + const globalSecrets = config[SecretsStore.GLOBAL_SECRETS_KEY] ?? []; + const projectSecrets = config[normalizedProjectPath] ?? []; + + // Keep global reference resolution synchronous so getEffectiveSecrets remains fast and side-effect free. + const globalRawByKey = new Map(); + for (const globalSecret of config[SecretsStore.GLOBAL_SECRETS_KEY] ?? []) { + if (!globalSecret || typeof globalSecret.key !== "string") { + continue; + } + + globalRawByKey.set(globalSecret.key, globalSecret.value); + } + + const globalResolved = new Map(); + const globalResolving = new Set(); + + const resolveGlobalKey = (key: string): Secret["value"] | undefined => { + if (globalResolved.has(key)) { + return globalResolved.get(key); + } + + if (globalResolving.has(key)) { + globalResolved.set(key, undefined); + return undefined; + } + + globalResolving.add(key); + try { + const raw = globalRawByKey.get(key); + + if (typeof raw === "string") { + globalResolved.set(key, raw); + return raw; + } + + if (isSecretReferenceValue(raw)) { + const target = raw.secret.trim(); + if (!target) { + globalResolved.set(key, undefined); + return undefined; + } + + const value = resolveGlobalKey(target); + globalResolved.set(key, value); + return value; + } + + globalResolved.set(key, undefined); + return undefined; + } finally { + globalResolving.delete(key); + } + }; + + const globalSecretsByKey = new Map(); + for (const key of globalRawByKey.keys()) { + const value = resolveGlobalKey(key); + if (value !== undefined) { + globalSecretsByKey.set(key, value); + } + } + + // Normalize duplicate global keys with last-writer semantics before evaluating injectAll. + // This keeps inject behavior aligned with value resolution when the same key appears + // multiple times in persisted data. + const finalGlobalSecretsByKey = new Map(); + for (const secret of globalSecrets) { + finalGlobalSecretsByKey.set(secret.key, secret); + } + + const injectedGlobalSecrets: Secret[] = []; + for (const secret of finalGlobalSecretsByKey.values()) { + if (secret.injectAll !== true) { + continue; + } + + const resolvedValue = globalSecretsByKey.get(secret.key); + // Allow empty-string global secrets by checking for undefined explicitly. + if (resolvedValue !== undefined) { + injectedGlobalSecrets.push({ key: secret.key, value: resolvedValue }); + } + } + + const resolvedProjectSecrets = projectSecrets.map((secret) => { + if (!isSecretReferenceValue(secret.value)) { + return secret; + } + + const targetKey = secret.value.secret.trim(); + if (!targetKey) { + return secret; + } + + // Allow empty-string global secrets by checking for undefined explicitly. + const resolvedGlobalValue = globalSecretsByKey.get(targetKey); + if (resolvedGlobalValue !== undefined) { + return { + ...secret, + value: resolvedGlobalValue, + }; + } + + return secret; + }); + + const projectKeys = new Set(resolvedProjectSecrets.map((secret) => secret.key)); + const nonOverriddenGlobalSecrets = injectedGlobalSecrets.filter( + (secret) => !projectKeys.has(secret.key) + ); + + return [...nonOverriddenGlobalSecrets, ...resolvedProjectSecrets]; + } + + /** + * Get globally injected secrets visible to a project. + * + * This is a read-only view used by project settings to explain inherited environment. + * Project-defined keys are excluded because project secrets override injected globals. + */ + getInjectedGlobalSecrets(projectPath: string): Secret[] { + const projectSecrets = this.getProjectSecrets(projectPath); + const projectKeys = new Set(projectSecrets.map((secret) => secret.key)); + + return this.getEffectiveSecrets(projectPath).filter((secret) => !projectKeys.has(secret.key)); + } + + /** + * Get secrets for a specific project. + * + * Note: this is project-only (does not include global secrets). + */ + getProjectSecrets(projectPath: string): Secret[] { + const normalizedProjectPath = + SecretsStore.normalizeSecretsProjectPath(projectPath) || projectPath; + const config = this.loadSecretsConfig(); + return config[normalizedProjectPath] ?? []; + } + + /** + * Update secrets for a specific project + * @param projectPath The path to the project + * @param secrets The secrets to save for the project + */ + async updateProjectSecrets(projectPath: string, secrets: Secret[]): Promise { + const normalizedProjectPath = + SecretsStore.normalizeSecretsProjectPath(projectPath) || projectPath; + await this.updateSecretsBucket(normalizedProjectPath, secrets); + } +} From fa659a2fadf7e297b2517f87832961b897e8827a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 17:58:48 +0000 Subject: [PATCH 04/17] refactor(config): extract SecretsStore --- src/cli/run.ts | 4 +- src/cli/workflow.ts | 6 +- src/node/config.test.ts | 283 ------------ src/node/config/SecretsStore.test.ts | 308 +++++++++++++ src/node/config/index.ts | 427 +----------------- src/node/orpc/context.ts | 3 +- src/node/orpc/router.ts | 51 ++- .../agentPlugins/workspacePluginOperations.ts | 4 +- src/node/services/aiService.ts | 6 +- src/node/services/coreServices.ts | 17 +- src/node/services/mcpServerManager.ts | 7 +- src/node/services/projectService.ts | 15 +- src/node/services/serviceContainer.ts | 10 +- src/node/services/taskService.test.ts | 5 +- src/node/services/taskService.ts | 16 +- src/node/services/terminalService.test.ts | 75 +-- src/node/services/terminalService.ts | 12 +- src/node/services/turnRequestBuilder.test.ts | 3 +- src/node/services/turnRequestBuilder.ts | 7 +- .../services/utils/multiProjectSecrets.ts | 9 +- .../workspaceService.multiProject.test.ts | 27 +- src/node/services/workspaceService.test.ts | 32 +- src/node/services/workspaceService.ts | 23 +- 23 files changed, 532 insertions(+), 818 deletions(-) create mode 100644 src/node/config/SecretsStore.test.ts diff --git a/src/cli/run.ts b/src/cli/run.ts index 680b3aba34..502a9b921f 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -14,7 +14,7 @@ import { tool } from "ai"; import { z } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { Config, FileLeaseManager, ProvidersConfigStore } from "../node/config"; +import { Config, FileLeaseManager, ProvidersConfigStore, SecretsStore } from "../node/config"; import { materializeResolvedTrust, replaceRunTrustProjects } from "./trust"; import { runBestEffortCleanup } from "./runCleanup"; import { DisposableTempDir } from "../node/services/tempDir"; @@ -528,7 +528,7 @@ async function main(): Promise { ); // Copy secrets so tools/MCP servers get project secrets (e.g., GH_TOKEN) - const existingSecrets = realConfig.loadSecretsConfig(); + const existingSecrets = new SecretsStore(realConfig.rootDir).loadSecretsConfig(); const secretsFile = path.join(config.rootDir, "secrets.json"); await replacePrivateRunConfigFile( secretsFile, diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index f54ffc49b3..d14fdcb64c 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -22,7 +22,7 @@ import { defaultModel } from "@/common/utils/ai/models"; import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { getErrorMessage } from "@/common/utils/errors"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; -import { Config, FileLeaseManager, ProvidersConfigStore } from "@/node/config"; +import { Config, FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { AgentSession } from "@/node/services/agentSession"; import { CodexOauthService } from "@/node/services/codexOauthService"; @@ -213,9 +213,9 @@ async function copyPersistentConfig(realConfig: Config, config: Config): Promise if (existingProviders != null && hasAnyConfiguredProvider(existingProviders)) { new ProvidersConfigStore(config.rootDir).saveProvidersConfig(existingProviders); } - const existingSecrets = realConfig.loadSecretsConfig(); + const existingSecrets = new SecretsStore(realConfig.rootDir).loadSecretsConfig(); if (Object.keys(existingSecrets).length > 0) { - await config.saveSecretsConfig(existingSecrets); + await new SecretsStore(config.rootDir).saveSecretsConfig(existingSecrets); } const existingConfig = realConfig.loadConfigOrDefault(); diff --git a/src/node/config.test.ts b/src/node/config.test.ts index caa0b18a05..0daa16514c 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -16,7 +16,6 @@ import { import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { WorkspaceMetadata } from "@/common/types/workspace"; -import { secretsToRecord } from "@/common/types/secrets"; describe("Config", () => { let tempDir: string; @@ -3603,286 +3602,4 @@ describe("Config", () => { expect(metadata.transcriptOnly).toBeUndefined(); }); }); - - describe("secrets", () => { - it("supports global secrets stored under a sentinel key", async () => { - await config.updateGlobalSecrets([{ key: "GLOBAL_A", value: "1" }]); - - expect(config.getGlobalSecrets()).toEqual([{ key: "GLOBAL_A", value: "1" }]); - - const raw = fs.readFileSync(path.join(tempDir, "secrets.json"), "utf-8"); - const parsed = JSON.parse(raw) as { __global__?: unknown }; - expect(parsed.__global__).toEqual([{ key: "GLOBAL_A", value: "1" }]); - }); - - it("preserves unsupported legacy entries on disk when saving unrelated secrets", async () => { - const secretsFile = path.join(tempDir, "secrets.json"); - const legacyEntry = { key: "LEGACY_OP", value: { op: "op://Vault/Item/field" } }; - fs.writeFileSync( - secretsFile, - JSON.stringify({ - __global__: [legacyEntry, { key: "KEEP", value: "kept" }], - "/other/project": [legacyEntry], - }) - ); - - // Legacy entries are hidden from runtime/UI views... - expect(config.getGlobalSecrets()).toEqual([{ key: "KEEP", value: "kept" }]); - - await config.updateGlobalSecrets([ - { key: "KEEP", value: "kept" }, - { key: "NEW", value: "added" }, - ]); - - // ...but survive on disk so a downgrade can still read them, in both the - // updated bucket and untouched buckets. - const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf-8")) as Record; - expect(parsed.__global__).toEqual([ - { key: "KEEP", value: "kept" }, - { key: "NEW", value: "added" }, - legacyEntry, - ]); - expect(parsed["/other/project"]).toEqual([legacyEntry]); - }); - - it("drops a preserved legacy entry when an update reuses its key", async () => { - const secretsFile = path.join(tempDir, "secrets.json"); - fs.writeFileSync( - secretsFile, - JSON.stringify({ - __global__: [{ key: "TOKEN", value: { op: "op://Vault/Item/field" } }], - }) - ); - - await config.updateGlobalSecrets([{ key: "TOKEN", value: "replaced" }]); - - const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf-8")) as Record; - expect(parsed.__global__).toEqual([{ key: "TOKEN", value: "replaced" }]); - }); - - it("preserves legacy entries from trailing-slash duplicate project buckets", async () => { - const secretsFile = path.join(tempDir, "secrets.json"); - const legacyEntry = { key: "LEGACY_OP", value: { op: "op://Vault/Item/field" } }; - fs.writeFileSync(secretsFile, JSON.stringify({ "/repo/": [legacyEntry] })); - - await config.updateProjectSecrets("/repo", [{ key: "NEW", value: "added" }]); - - const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf-8")) as Record; - expect(parsed["/repo/"]).toBeUndefined(); - expect(parsed["/repo"]).toEqual([{ key: "NEW", value: "added" }, legacyEntry]); - }); - - it("does not inherit global secrets by default", async () => { - await config.updateGlobalSecrets([ - { key: "TOKEN", value: "global" }, - { key: "A", value: "1" }, - ]); - - const projectPath = "/fake/project"; - await config.updateProjectSecrets(projectPath, [ - { key: "TOKEN", value: "project" }, - { key: "B", value: "2" }, - ]); - - const effective = config.getEffectiveSecrets(projectPath); - const record = await secretsToRecord(effective); - - expect(record).toEqual({ - TOKEN: "project", - B: "2", - }); - }); - - it("injects global secrets with injectAll into any project's effective secrets", async () => { - await config.updateGlobalSecrets([ - { key: "INJECTED", value: "everywhere", injectAll: true }, - { key: "STORED_ONLY", value: "shared" }, - ]); - - const record = await secretsToRecord(config.getEffectiveSecrets("/fake/project")); - expect(record).toEqual({ - INJECTED: "everywhere", - }); - }); - - it("project secrets override injectAll global secrets", async () => { - await config.updateGlobalSecrets([{ key: "TOKEN", value: "global", injectAll: true }]); - - const projectPath = "/fake/project"; - await config.updateProjectSecrets(projectPath, [{ key: "TOKEN", value: "project" }]); - - const record = await secretsToRecord(config.getEffectiveSecrets(projectPath)); - expect(record).toEqual({ - TOKEN: "project", - }); - }); - - it("injects injectAll globals alongside project-specific secrets", async () => { - await config.updateGlobalSecrets([{ key: "GLOBAL_TOKEN", value: "global", injectAll: true }]); - - const projectPath = "/fake/project"; - await config.updateProjectSecrets(projectPath, [{ key: "LOCAL_TOKEN", value: "local" }]); - - const record = await secretsToRecord(config.getEffectiveSecrets(projectPath)); - expect(record).toEqual({ - GLOBAL_TOKEN: "global", - LOCAL_TOKEN: "local", - }); - }); - - it("returns only globally injected secrets for project settings visibility", async () => { - await config.updateGlobalSecrets([ - { key: "GLOBAL_VISIBLE", value: "v", injectAll: true }, - { key: "GLOBAL_HIDDEN", value: "h" }, - { key: "SHARED", value: "global", injectAll: true }, - ]); - - const projectPath = "/fake/project"; - await config.updateProjectSecrets(projectPath, [ - { key: "LOCAL_ONLY", value: "local" }, - { key: "SHARED", value: "project" }, - ]); - - expect(config.getInjectedGlobalSecrets(projectPath)).toEqual([ - { key: "GLOBAL_VISIBLE", value: "v" }, - ]); - }); - - it("does not inject global secrets unless injectAll is true", async () => { - await config.updateGlobalSecrets([ - { key: "A", value: "1", injectAll: false }, - { key: "B", value: "2" }, - { key: "C", value: "3", injectAll: true }, - ]); - - const record = await secretsToRecord(config.getEffectiveSecrets("/fake/project")); - expect(record).toEqual({ - C: "3", - }); - }); - - it("uses last global duplicate to decide injectAll behavior", async () => { - await config.updateGlobalSecrets([ - { key: "DUP", value: "first", injectAll: true }, - { key: "DUP", value: "second", injectAll: false }, - ]); - - expect(await secretsToRecord(config.getEffectiveSecrets("/fake/project"))).toEqual({}); - - await config.updateGlobalSecrets([ - { key: "DUP", value: "first", injectAll: false }, - { key: "DUP", value: "second", injectAll: true }, - ]); - - expect(await secretsToRecord(config.getEffectiveSecrets("/fake/project"))).toEqual({ - DUP: "second", - }); - }); - - it('resolves project secret aliases to global secrets via {secret:"KEY"}', async () => { - await config.updateGlobalSecrets([{ key: "GLOBAL_TOKEN", value: "abc" }]); - - const projectPath = "/fake/project"; - await config.updateProjectSecrets(projectPath, [ - { key: "TOKEN", value: { secret: "GLOBAL_TOKEN" } }, - ]); - - const record = await secretsToRecord(config.getEffectiveSecrets(projectPath)); - expect(record).toEqual({ - TOKEN: "abc", - }); - }); - - it("resolves same-key project secret references to global values", async () => { - await config.updateGlobalSecrets([{ key: "OPENAI_API_KEY", value: "abc" }]); - - const projectPath = "/fake/project"; - await config.updateProjectSecrets(projectPath, [ - { key: "OPENAI_API_KEY", value: { secret: "OPENAI_API_KEY" } }, - ]); - - const record = await secretsToRecord(config.getEffectiveSecrets(projectPath)); - expect(record).toEqual({ - OPENAI_API_KEY: "abc", - }); - }); - - it("omits missing referenced secrets when resolving secretsToRecord", async () => { - const record = await secretsToRecord([ - { key: "GLOBAL", value: "1" }, - { key: "A", value: { secret: "MISSING" } }, - ]); - - expect(record).toEqual({ GLOBAL: "1" }); - }); - - it("omits cyclic secret references when resolving secretsToRecord", async () => { - const record = await secretsToRecord([ - { key: "A", value: { secret: "B" } }, - { key: "B", value: { secret: "A" } }, - { key: "OK", value: "y" }, - ]); - - expect(record).toEqual({ OK: "y" }); - }); - - it("resolves mixed literal and { secret } values", async () => { - const record = await secretsToRecord([ - { key: "LITERAL", value: "raw" }, - { key: "GLOBAL_TOKEN", value: "abc" }, - { key: "ALIAS", value: { secret: "GLOBAL_TOKEN" } }, - ]); - - expect(record).toEqual({ - LITERAL: "raw", - GLOBAL_TOKEN: "abc", - ALIAS: "abc", - }); - }); - it("normalizes project paths so trailing slashes don't split secrets", async () => { - const projectPath = "/repo"; - const projectPathWithSlash = "/repo/"; - - await config.updateProjectSecrets(projectPathWithSlash, [{ key: "A", value: "1" }]); - - expect(config.getProjectSecrets(projectPath)).toEqual([{ key: "A", value: "1" }]); - expect(config.getProjectSecrets(projectPathWithSlash)).toEqual([{ key: "A", value: "1" }]); - - const raw = fs.readFileSync(path.join(tempDir, "secrets.json"), "utf-8"); - const parsed = JSON.parse(raw) as Record; - expect(parsed[projectPath]).toEqual([{ key: "A", value: "1" }]); - expect(parsed[projectPathWithSlash]).toBeUndefined(); - }); - - it("treats malformed store shapes as empty arrays", () => { - const secretsFile = path.join(tempDir, "secrets.json"); - fs.writeFileSync( - secretsFile, - JSON.stringify({ - __global__: { key: "NOPE", value: "1" }, - "/repo": "not-an-array", - "/repo/": [{ key: "A", value: "1" }, null, { key: 123, value: "x" }], - }) - ); - - expect(config.getGlobalSecrets()).toEqual([]); - expect(config.getProjectSecrets("/repo")).toEqual([{ key: "A", value: "1" }]); - }); - it("sanitizes malformed injectAll values without dropping valid secrets", async () => { - const projectPath = "/repo"; - const secretsFile = path.join(tempDir, "secrets.json"); - fs.writeFileSync( - secretsFile, - JSON.stringify({ - __global__: [{ key: "GLOBAL_TOKEN", value: "abc", injectAll: "true" }], - [projectPath]: [{ key: "TOKEN", value: { secret: "GLOBAL_TOKEN" } }], - }) - ); - - expect(config.getGlobalSecrets()).toEqual([{ key: "GLOBAL_TOKEN", value: "abc" }]); - expect(await secretsToRecord(config.getEffectiveSecrets(projectPath))).toEqual({ - TOKEN: "abc", - }); - }); - }); }); diff --git a/src/node/config/SecretsStore.test.ts b/src/node/config/SecretsStore.test.ts new file mode 100644 index 0000000000..bdfe3189e2 --- /dev/null +++ b/src/node/config/SecretsStore.test.ts @@ -0,0 +1,308 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import * as fs from "fs"; +import * as os from "os"; +import * as path from "path"; +import { secretsToRecord } from "@/common/types/secrets"; +import { SecretsStore } from "./SecretsStore"; + +describe("SecretsStore", () => { + let tempDir: string; + let secretsStore: SecretsStore; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-secrets-test-")); + secretsStore = new SecretsStore(tempDir); + }); + + afterEach(() => { + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + describe("secrets", () => { + it("supports global secrets stored under a sentinel key", async () => { + await secretsStore.updateGlobalSecrets([{ key: "GLOBAL_A", value: "1" }]); + + expect(secretsStore.getGlobalSecrets()).toEqual([{ key: "GLOBAL_A", value: "1" }]); + + const raw = fs.readFileSync(path.join(tempDir, "secrets.json"), "utf-8"); + const parsed = JSON.parse(raw) as { __global__?: unknown }; + expect(parsed.__global__).toEqual([{ key: "GLOBAL_A", value: "1" }]); + }); + + it("preserves unsupported legacy entries on disk when saving unrelated secrets", async () => { + const secretsFile = path.join(tempDir, "secrets.json"); + const legacyEntry = { key: "LEGACY_OP", value: { op: "op://Vault/Item/field" } }; + fs.writeFileSync( + secretsFile, + JSON.stringify({ + __global__: [legacyEntry, { key: "KEEP", value: "kept" }], + "/other/project": [legacyEntry], + }) + ); + + // Legacy entries are hidden from runtime/UI views... + expect(secretsStore.getGlobalSecrets()).toEqual([{ key: "KEEP", value: "kept" }]); + + await secretsStore.updateGlobalSecrets([ + { key: "KEEP", value: "kept" }, + { key: "NEW", value: "added" }, + ]); + + // ...but survive on disk so a downgrade can still read them, in both the + // updated bucket and untouched buckets. + const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf-8")) as Record; + expect(parsed.__global__).toEqual([ + { key: "KEEP", value: "kept" }, + { key: "NEW", value: "added" }, + legacyEntry, + ]); + expect(parsed["/other/project"]).toEqual([legacyEntry]); + }); + + it("drops a preserved legacy entry when an update reuses its key", async () => { + const secretsFile = path.join(tempDir, "secrets.json"); + fs.writeFileSync( + secretsFile, + JSON.stringify({ + __global__: [{ key: "TOKEN", value: { op: "op://Vault/Item/field" } }], + }) + ); + + await secretsStore.updateGlobalSecrets([{ key: "TOKEN", value: "replaced" }]); + + const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf-8")) as Record; + expect(parsed.__global__).toEqual([{ key: "TOKEN", value: "replaced" }]); + }); + + it("preserves legacy entries from trailing-slash duplicate project buckets", async () => { + const secretsFile = path.join(tempDir, "secrets.json"); + const legacyEntry = { key: "LEGACY_OP", value: { op: "op://Vault/Item/field" } }; + fs.writeFileSync(secretsFile, JSON.stringify({ "/repo/": [legacyEntry] })); + + await secretsStore.updateProjectSecrets("/repo", [{ key: "NEW", value: "added" }]); + + const parsed = JSON.parse(fs.readFileSync(secretsFile, "utf-8")) as Record; + expect(parsed["/repo/"]).toBeUndefined(); + expect(parsed["/repo"]).toEqual([{ key: "NEW", value: "added" }, legacyEntry]); + }); + + it("does not inherit global secrets by default", async () => { + await secretsStore.updateGlobalSecrets([ + { key: "TOKEN", value: "global" }, + { key: "A", value: "1" }, + ]); + + const projectPath = "/fake/project"; + await secretsStore.updateProjectSecrets(projectPath, [ + { key: "TOKEN", value: "project" }, + { key: "B", value: "2" }, + ]); + + const effective = secretsStore.getEffectiveSecrets(projectPath); + const record = await secretsToRecord(effective); + + expect(record).toEqual({ + TOKEN: "project", + B: "2", + }); + }); + + it("injects global secrets with injectAll into any project's effective secrets", async () => { + await secretsStore.updateGlobalSecrets([ + { key: "INJECTED", value: "everywhere", injectAll: true }, + { key: "STORED_ONLY", value: "shared" }, + ]); + + const record = await secretsToRecord(secretsStore.getEffectiveSecrets("/fake/project")); + expect(record).toEqual({ + INJECTED: "everywhere", + }); + }); + + it("project secrets override injectAll global secrets", async () => { + await secretsStore.updateGlobalSecrets([{ key: "TOKEN", value: "global", injectAll: true }]); + + const projectPath = "/fake/project"; + await secretsStore.updateProjectSecrets(projectPath, [{ key: "TOKEN", value: "project" }]); + + const record = await secretsToRecord(secretsStore.getEffectiveSecrets(projectPath)); + expect(record).toEqual({ + TOKEN: "project", + }); + }); + + it("injects injectAll globals alongside project-specific secrets", async () => { + await secretsStore.updateGlobalSecrets([ + { key: "GLOBAL_TOKEN", value: "global", injectAll: true }, + ]); + + const projectPath = "/fake/project"; + await secretsStore.updateProjectSecrets(projectPath, [ + { key: "LOCAL_TOKEN", value: "local" }, + ]); + + const record = await secretsToRecord(secretsStore.getEffectiveSecrets(projectPath)); + expect(record).toEqual({ + GLOBAL_TOKEN: "global", + LOCAL_TOKEN: "local", + }); + }); + + it("returns only globally injected secrets for project settings visibility", async () => { + await secretsStore.updateGlobalSecrets([ + { key: "GLOBAL_VISIBLE", value: "v", injectAll: true }, + { key: "GLOBAL_HIDDEN", value: "h" }, + { key: "SHARED", value: "global", injectAll: true }, + ]); + + const projectPath = "/fake/project"; + await secretsStore.updateProjectSecrets(projectPath, [ + { key: "LOCAL_ONLY", value: "local" }, + { key: "SHARED", value: "project" }, + ]); + + expect(secretsStore.getInjectedGlobalSecrets(projectPath)).toEqual([ + { key: "GLOBAL_VISIBLE", value: "v" }, + ]); + }); + + it("does not inject global secrets unless injectAll is true", async () => { + await secretsStore.updateGlobalSecrets([ + { key: "A", value: "1", injectAll: false }, + { key: "B", value: "2" }, + { key: "C", value: "3", injectAll: true }, + ]); + + const record = await secretsToRecord(secretsStore.getEffectiveSecrets("/fake/project")); + expect(record).toEqual({ + C: "3", + }); + }); + + it("uses last global duplicate to decide injectAll behavior", async () => { + await secretsStore.updateGlobalSecrets([ + { key: "DUP", value: "first", injectAll: true }, + { key: "DUP", value: "second", injectAll: false }, + ]); + + expect(await secretsToRecord(secretsStore.getEffectiveSecrets("/fake/project"))).toEqual({}); + + await secretsStore.updateGlobalSecrets([ + { key: "DUP", value: "first", injectAll: false }, + { key: "DUP", value: "second", injectAll: true }, + ]); + + expect(await secretsToRecord(secretsStore.getEffectiveSecrets("/fake/project"))).toEqual({ + DUP: "second", + }); + }); + + it('resolves project secret aliases to global secrets via {secret:"KEY"}', async () => { + await secretsStore.updateGlobalSecrets([{ key: "GLOBAL_TOKEN", value: "abc" }]); + + const projectPath = "/fake/project"; + await secretsStore.updateProjectSecrets(projectPath, [ + { key: "TOKEN", value: { secret: "GLOBAL_TOKEN" } }, + ]); + + const record = await secretsToRecord(secretsStore.getEffectiveSecrets(projectPath)); + expect(record).toEqual({ + TOKEN: "abc", + }); + }); + + it("resolves same-key project secret references to global values", async () => { + await secretsStore.updateGlobalSecrets([{ key: "OPENAI_API_KEY", value: "abc" }]); + + const projectPath = "/fake/project"; + await secretsStore.updateProjectSecrets(projectPath, [ + { key: "OPENAI_API_KEY", value: { secret: "OPENAI_API_KEY" } }, + ]); + + const record = await secretsToRecord(secretsStore.getEffectiveSecrets(projectPath)); + expect(record).toEqual({ + OPENAI_API_KEY: "abc", + }); + }); + + it("omits missing referenced secrets when resolving secretsToRecord", async () => { + const record = await secretsToRecord([ + { key: "GLOBAL", value: "1" }, + { key: "A", value: { secret: "MISSING" } }, + ]); + + expect(record).toEqual({ GLOBAL: "1" }); + }); + + it("omits cyclic secret references when resolving secretsToRecord", async () => { + const record = await secretsToRecord([ + { key: "A", value: { secret: "B" } }, + { key: "B", value: { secret: "A" } }, + { key: "OK", value: "y" }, + ]); + + expect(record).toEqual({ OK: "y" }); + }); + + it("resolves mixed literal and { secret } values", async () => { + const record = await secretsToRecord([ + { key: "LITERAL", value: "raw" }, + { key: "GLOBAL_TOKEN", value: "abc" }, + { key: "ALIAS", value: { secret: "GLOBAL_TOKEN" } }, + ]); + + expect(record).toEqual({ + LITERAL: "raw", + GLOBAL_TOKEN: "abc", + ALIAS: "abc", + }); + }); + it("normalizes project paths so trailing slashes don't split secrets", async () => { + const projectPath = "/repo"; + const projectPathWithSlash = "/repo/"; + + await secretsStore.updateProjectSecrets(projectPathWithSlash, [{ key: "A", value: "1" }]); + + expect(secretsStore.getProjectSecrets(projectPath)).toEqual([{ key: "A", value: "1" }]); + expect(secretsStore.getProjectSecrets(projectPathWithSlash)).toEqual([ + { key: "A", value: "1" }, + ]); + + const raw = fs.readFileSync(path.join(tempDir, "secrets.json"), "utf-8"); + const parsed = JSON.parse(raw) as Record; + expect(parsed[projectPath]).toEqual([{ key: "A", value: "1" }]); + expect(parsed[projectPathWithSlash]).toBeUndefined(); + }); + + it("treats malformed store shapes as empty arrays", () => { + const secretsFile = path.join(tempDir, "secrets.json"); + fs.writeFileSync( + secretsFile, + JSON.stringify({ + __global__: { key: "NOPE", value: "1" }, + "/repo": "not-an-array", + "/repo/": [{ key: "A", value: "1" }, null, { key: 123, value: "x" }], + }) + ); + + expect(secretsStore.getGlobalSecrets()).toEqual([]); + expect(secretsStore.getProjectSecrets("/repo")).toEqual([{ key: "A", value: "1" }]); + }); + it("sanitizes malformed injectAll values without dropping valid secrets", async () => { + const projectPath = "/repo"; + const secretsFile = path.join(tempDir, "secrets.json"); + fs.writeFileSync( + secretsFile, + JSON.stringify({ + __global__: [{ key: "GLOBAL_TOKEN", value: "abc", injectAll: "true" }], + [projectPath]: [{ key: "TOKEN", value: { secret: "GLOBAL_TOKEN" } }], + }) + ); + + expect(secretsStore.getGlobalSecrets()).toEqual([{ key: "GLOBAL_TOKEN", value: "abc" }]); + expect(await secretsToRecord(secretsStore.getEffectiveSecrets(projectPath))).toEqual({ + TOKEN: "abc", + }); + }); + }); +}); diff --git a/src/node/config/index.ts b/src/node/config/index.ts index 891b02f15b..d6bda30d5f 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -7,8 +7,7 @@ import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { log } from "@/node/services/log"; import { ProvidersConfigStore } from "./ProvidersConfigStore"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; -import { isSecretReferenceValue, type Secret, type SecretsConfig } from "@/common/types/secrets"; -import { Err, Ok, type Result } from "@/common/types/result"; +import type { Result } from "@/common/types/result"; import assert from "node:assert/strict"; import type { Workspace, @@ -32,7 +31,6 @@ import { normalizeLayoutPresetsConfig, type LayoutPresetsConfig, } from "@/common/types/uiLayouts"; -import { getErrorMessage } from "@/common/utils/errors"; import { deriveLegacySubagentAiDefaultsProjection, mergeLegacySubagentAiDefaults, @@ -88,6 +86,7 @@ import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig }; export { FileLeaseManager } from "./FileLeaseManager"; export { ProvidersConfigStore, type ProvidersConfig } from "./ProvidersConfigStore"; +export { SecretsStore } from "./SecretsStore"; /** True only for fs errors whose errno code is ENOENT (genuinely missing path). */ function isEnoentError(error: unknown): boolean { @@ -898,7 +897,6 @@ export class Config { readonly srcDir: string; private readonly configFile: string; private readonly providersConfigStore: ProvidersConfigStore; - private readonly secretsFile: string; private readonly emitter = new EventEmitter(); /** * Legacy variant grouping is hidden from the current runtime but retained here so unrelated @@ -916,7 +914,6 @@ export class Config { this.sessionsDir = path.join(this.rootDir, "sessions"); this.srcDir = path.join(this.rootDir, "src"); this.configFile = path.join(this.rootDir, "config.json"); - this.secretsFile = path.join(this.rootDir, "secrets.json"); this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(this.rootDir); } @@ -2220,32 +2217,6 @@ export class Config { }); } - getScopedSecrets(projectPath: string | null | undefined): Secret[] { - return projectPath?.trim() ? this.getProjectSecrets(projectPath) : this.getGlobalSecrets(); - } - - getInjectedGlobalSecretKeys(projectPath: string | null | undefined): string[] { - return projectPath?.trim() - ? this.getInjectedGlobalSecrets(projectPath).map((secret) => secret.key) - : []; - } - - async updateScopedSecrets(input: { - projectPath?: string | null; - secrets: Secret[]; - }): Promise> { - try { - if (input.projectPath?.trim()) { - await this.updateProjectSecrets(input.projectPath, input.secrets); - } else { - await this.updateGlobalSecrets(input.secrets); - } - return Ok(undefined); - } catch (error) { - return Err(getErrorMessage(error)); - } - } - async markSplashScreenViewed(splashId: string): Promise { await this.editConfig((config) => { const viewed = config.viewedSplashScreens ?? []; @@ -3516,400 +3487,6 @@ export class Config { throw new Error(`Workspace ${workspaceId} not found in config`); }); } - - private static readonly GLOBAL_SECRETS_KEY = "__global__"; - - private static normalizeSecretsProjectPath(projectPath: string): string { - return stripTrailingSlashes(projectPath); - } - - private static isSecretValue(value: unknown): value is Secret["value"] { - if (typeof value === "string") { - return true; - } - - return isSecretReferenceValue(value); - } - - private static isSecret(value: unknown): value is Secret { - return ( - typeof value === "object" && - value !== null && - "key" in value && - "value" in value && - typeof (value as { key?: unknown }).key === "string" && - Config.isSecretValue((value as { value?: unknown }).value) - ); - } - - private static parseSecretsArray(value: unknown): Secret[] { - if (!Array.isArray(value)) { - return []; - } - - const sanitizedSecrets: Secret[] = []; - - for (const entry of value) { - // Filter invalid entries to avoid crashes when iterating secrets. - if (!Config.isSecret(entry)) { - continue; - } - - // Preserve key/value when persisted data includes malformed injectAll values. - // This keeps existing secrets usable while ignoring invalid inject-all flags. - const entryWithInjectAll = entry as Secret & { injectAll?: unknown }; - if (typeof entryWithInjectAll.injectAll === "boolean") { - sanitizedSecrets.push({ - key: entryWithInjectAll.key, - value: entryWithInjectAll.value, - injectAll: entryWithInjectAll.injectAll, - }); - continue; - } - - sanitizedSecrets.push({ - key: entryWithInjectAll.key, - value: entryWithInjectAll.value, - }); - } - - return sanitizedSecrets; - } - - /** - * Merge an updated secrets list with raw on-disk entries, preserving entries - * whose value shapes this build no longer understands (e.g. legacy 1Password - * `{ op: ... }` references) so a downgrade can still read them. Supported - * entries are fully represented in the UI, so `next` is authoritative for - * them; a preserved legacy entry is dropped only when the update reuses its - * key (the new value intentionally replaces it). - */ - private static mergeSecretsPreservingUnsupported( - rawEntries: unknown[], - next: Secret[] - ): unknown[] { - const nextKeys = new Set(next.map((secret) => secret.key)); - const preserved: unknown[] = []; - - for (const entry of rawEntries) { - if (Config.isSecret(entry)) { - continue; - } - - if ( - typeof entry === "object" && - entry !== null && - typeof (entry as { key?: unknown }).key === "string" && - !nextKeys.has((entry as { key: string }).key) - ) { - preserved.push(entry); - } - } - - return [...next, ...preserved]; - } - - private static mergeSecretsByKey(primary: Secret[], secondary: Secret[]): Secret[] { - // Merge-by-key (last writer wins). - const mergedByKey = new Map(); - for (const secret of primary) { - mergedByKey.set(secret.key, secret); - } - for (const secret of secondary) { - mergedByKey.set(secret.key, secret); - } - return Array.from(mergedByKey.values()); - } - - private static normalizeSecretsConfig(raw: unknown): SecretsConfig { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return {}; - } - - const record = raw as Record; - const normalized: SecretsConfig = {}; - - for (const [rawKey, rawValue] of Object.entries(record)) { - let key = rawKey; - if (rawKey !== Config.GLOBAL_SECRETS_KEY) { - const normalizedKey = Config.normalizeSecretsProjectPath(rawKey); - key = normalizedKey || rawKey; - } - - const secrets = Config.parseSecretsArray(rawValue); - - if (!Object.prototype.hasOwnProperty.call(normalized, key)) { - normalized[key] = secrets; - continue; - } - - normalized[key] = Config.mergeSecretsByKey(normalized[key], secrets); - } - - return normalized; - } - - /** - * Load secrets configuration from JSON file - * Returns empty config if file doesn't exist - */ - loadSecretsConfig(): SecretsConfig { - try { - if (fs.existsSync(this.secretsFile)) { - const data = fs.readFileSync(this.secretsFile, "utf-8"); - const parsed = JSON.parse(data) as unknown; - return Config.normalizeSecretsConfig(parsed); - } - } catch (error) { - log.error("Error loading secrets config:", error); - } - - return {}; - } - - /** - * Load the secrets file without filtering entry shapes. Used by the update - * paths so unsupported legacy entries survive round-trips to disk instead of - * being silently deleted when an unrelated secret is saved. - */ - private loadRawSecretsConfig(): Record { - try { - if (fs.existsSync(this.secretsFile)) { - const parsed = JSON.parse(fs.readFileSync(this.secretsFile, "utf-8")) as unknown; - if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) { - return { ...(parsed as Record) }; - } - } - } catch (error) { - log.error("Error loading secrets config:", error); - } - - return {}; - } - - /** - * Replace one bucket of the secrets file (global sentinel or a normalized - * project path) while leaving every other bucket byte-for-byte intact and - * preserving unsupported legacy entries within the target bucket. - */ - private async updateSecretsBucket(bucketKey: string, secrets: Secret[]): Promise { - const raw = this.loadRawSecretsConfig(); - - // Project paths may be persisted with trailing slashes; fold every raw key - // that maps to this bucket so preserved entries aren't left in a shadowed - // duplicate bucket. - const rawBucketEntries: unknown[] = []; - for (const [rawKey, rawValue] of Object.entries(raw)) { - const mappedKey = - rawKey === Config.GLOBAL_SECRETS_KEY - ? rawKey - : Config.normalizeSecretsProjectPath(rawKey) || rawKey; - if (mappedKey !== bucketKey) { - continue; - } - - if (Array.isArray(rawValue)) { - // Array.isArray narrows unknown to any[]; retype to unknown[] for safe handling. - rawBucketEntries.push(...(rawValue as unknown[])); - } - delete raw[rawKey]; - } - - raw[bucketKey] = Config.mergeSecretsPreservingUnsupported(rawBucketEntries, secrets); - await this.saveSecretsConfig(raw); - } - - /** - * Save secrets configuration to JSON file - * @param config The secrets configuration to save - */ - async saveSecretsConfig(config: SecretsConfig | Record): Promise { - try { - if (!fs.existsSync(this.rootDir)) { - ensurePrivateDirSync(this.rootDir); - } - - await writeFileAtomic(this.secretsFile, JSON.stringify(config, null, 2), { - encoding: "utf-8", - mode: 0o600, - }); - } catch (error) { - log.error("Error saving secrets config:", error); - throw error; - } - } - - /** - * Get global secrets (not project-scoped). - * - * Stored in /secrets.json under a sentinel key for backwards compatibility. - */ - getGlobalSecrets(): Secret[] { - const config = this.loadSecretsConfig(); - return config[Config.GLOBAL_SECRETS_KEY] ?? []; - } - - /** Update global secrets (not project-scoped). */ - async updateGlobalSecrets(secrets: Secret[]): Promise { - await this.updateSecretsBucket(Config.GLOBAL_SECRETS_KEY, secrets); - } - - /** - * Get effective secrets for a project. - * - * Project secrets define which env vars are injected into this project/workspace. - * Global secrets can be injected for all projects when `injectAll` is enabled, - * and are also used as a shared value store for `{ secret: "GLOBAL_KEY" }` references. - */ - getEffectiveSecrets(projectPath: string): Secret[] { - const normalizedProjectPath = Config.normalizeSecretsProjectPath(projectPath) || projectPath; - const config = this.loadSecretsConfig(); - const globalSecrets = config[Config.GLOBAL_SECRETS_KEY] ?? []; - const projectSecrets = config[normalizedProjectPath] ?? []; - - // Keep global reference resolution synchronous so getEffectiveSecrets remains fast and side-effect free. - const globalRawByKey = new Map(); - for (const globalSecret of config[Config.GLOBAL_SECRETS_KEY] ?? []) { - if (!globalSecret || typeof globalSecret.key !== "string") { - continue; - } - - globalRawByKey.set(globalSecret.key, globalSecret.value); - } - - const globalResolved = new Map(); - const globalResolving = new Set(); - - const resolveGlobalKey = (key: string): Secret["value"] | undefined => { - if (globalResolved.has(key)) { - return globalResolved.get(key); - } - - if (globalResolving.has(key)) { - globalResolved.set(key, undefined); - return undefined; - } - - globalResolving.add(key); - try { - const raw = globalRawByKey.get(key); - - if (typeof raw === "string") { - globalResolved.set(key, raw); - return raw; - } - - if (isSecretReferenceValue(raw)) { - const target = raw.secret.trim(); - if (!target) { - globalResolved.set(key, undefined); - return undefined; - } - - const value = resolveGlobalKey(target); - globalResolved.set(key, value); - return value; - } - - globalResolved.set(key, undefined); - return undefined; - } finally { - globalResolving.delete(key); - } - }; - - const globalSecretsByKey = new Map(); - for (const key of globalRawByKey.keys()) { - const value = resolveGlobalKey(key); - if (value !== undefined) { - globalSecretsByKey.set(key, value); - } - } - - // Normalize duplicate global keys with last-writer semantics before evaluating injectAll. - // This keeps inject behavior aligned with value resolution when the same key appears - // multiple times in persisted data. - const finalGlobalSecretsByKey = new Map(); - for (const secret of globalSecrets) { - finalGlobalSecretsByKey.set(secret.key, secret); - } - - const injectedGlobalSecrets: Secret[] = []; - for (const secret of finalGlobalSecretsByKey.values()) { - if (secret.injectAll !== true) { - continue; - } - - const resolvedValue = globalSecretsByKey.get(secret.key); - // Allow empty-string global secrets by checking for undefined explicitly. - if (resolvedValue !== undefined) { - injectedGlobalSecrets.push({ key: secret.key, value: resolvedValue }); - } - } - - const resolvedProjectSecrets = projectSecrets.map((secret) => { - if (!isSecretReferenceValue(secret.value)) { - return secret; - } - - const targetKey = secret.value.secret.trim(); - if (!targetKey) { - return secret; - } - - // Allow empty-string global secrets by checking for undefined explicitly. - const resolvedGlobalValue = globalSecretsByKey.get(targetKey); - if (resolvedGlobalValue !== undefined) { - return { - ...secret, - value: resolvedGlobalValue, - }; - } - - return secret; - }); - - const projectKeys = new Set(resolvedProjectSecrets.map((secret) => secret.key)); - const nonOverriddenGlobalSecrets = injectedGlobalSecrets.filter( - (secret) => !projectKeys.has(secret.key) - ); - - return [...nonOverriddenGlobalSecrets, ...resolvedProjectSecrets]; - } - - /** - * Get globally injected secrets visible to a project. - * - * This is a read-only view used by project settings to explain inherited environment. - * Project-defined keys are excluded because project secrets override injected globals. - */ - getInjectedGlobalSecrets(projectPath: string): Secret[] { - const projectSecrets = this.getProjectSecrets(projectPath); - const projectKeys = new Set(projectSecrets.map((secret) => secret.key)); - - return this.getEffectiveSecrets(projectPath).filter((secret) => !projectKeys.has(secret.key)); - } - - /** - * Get secrets for a specific project. - * - * Note: this is project-only (does not include global secrets). - */ - getProjectSecrets(projectPath: string): Secret[] { - const normalizedProjectPath = Config.normalizeSecretsProjectPath(projectPath) || projectPath; - const config = this.loadSecretsConfig(); - return config[normalizedProjectPath] ?? []; - } - - /** - * Update secrets for a specific project - * @param projectPath The path to the project - * @param secrets The secrets to save for the project - */ - async updateProjectSecrets(projectPath: string, secrets: Secret[]): Promise { - const normalizedProjectPath = Config.normalizeSecretsProjectPath(projectPath) || projectPath; - await this.updateSecretsBucket(normalizedProjectPath, secrets); - } } // Default instance for application use diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 9e35987b53..8a0d502c93 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -1,6 +1,6 @@ import type { IJSRuntimeFactory } from "@/node/services/ptc/runtime"; import type { IncomingHttpHeaders } from "http"; -import type { Config, FileLeaseManager, ProvidersConfigStore } from "@/node/config"; +import type { Config, FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -57,6 +57,7 @@ import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenMa export interface ORPCContext { config: Config; providersConfigStore: ProvidersConfigStore; + secretsStore: SecretsStore; fileLeaseManager: FileLeaseManager; aiService: AIService; historyService: HistoryService; diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 4aaee78cf6..27dc287b80 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -55,7 +55,8 @@ import { updateUpcomingWorkspaceGoal, removeWorkspace, } from "@/node/services/workspaceOperations"; -import { Ok } from "@/common/types/result"; +import { Err, Ok } from "@/common/types/result"; +import { getErrorMessage } from "@/common/utils/errors"; import { generateWorkspaceIdentity } from "@/node/services/workspaceTitleGenerator"; @@ -726,17 +727,55 @@ export const router = (authToken?: string) => { get: t .input(schemas.secrets.get.input) .output(schemas.secrets.get.output) - .handler(({ context, input }) => context.config.getScopedSecrets(input.projectPath)), + .handler(({ context, input }) => { + const projectPath = + typeof input.projectPath === "string" && input.projectPath.trim().length > 0 + ? input.projectPath + : undefined; + + return projectPath + ? context.secretsStore.getProjectSecrets(projectPath) + : context.secretsStore.getGlobalSecrets(); + }), getInjectedGlobals: t .input(schemas.secrets.getInjectedGlobals.input) .output(schemas.secrets.getInjectedGlobals.output) - .handler(({ context, input }) => - context.config.getInjectedGlobalSecretKeys(input.projectPath) - ), + .handler(({ context, input }) => { + const projectPath = + typeof input.projectPath === "string" && input.projectPath.trim().length > 0 + ? input.projectPath + : undefined; + + if (!projectPath) { + return []; + } + + return context.secretsStore + .getInjectedGlobalSecrets(projectPath) + .map((secret) => secret.key); + }), update: t .input(schemas.secrets.update.input) .output(schemas.secrets.update.output) - .handler(({ context, input }) => context.config.updateScopedSecrets(input)), + .handler(async ({ context, input }) => { + const projectPath = + typeof input.projectPath === "string" && input.projectPath.trim().length > 0 + ? input.projectPath + : undefined; + + try { + if (projectPath) { + await context.secretsStore.updateProjectSecrets(projectPath, input.secrets); + } else { + await context.secretsStore.updateGlobalSecrets(input.secrets); + } + + return Ok(undefined); + } catch (error) { + const message = getErrorMessage(error); + return Err(message); + } + }), }, mcp: { list: t diff --git a/src/node/services/agentPlugins/workspacePluginOperations.ts b/src/node/services/agentPlugins/workspacePluginOperations.ts index 8583eca56f..d3f651b9f0 100644 --- a/src/node/services/agentPlugins/workspacePluginOperations.ts +++ b/src/node/services/agentPlugins/workspacePluginOperations.ts @@ -83,8 +83,8 @@ export async function listWorkspaceMcpPrompts( await context.workspaceMcpOverridesService.getOverridesForWorkspace(workspaceId); const projectSecrets = await secretsToRecord( isMultiProject(metadata) - ? mergeMultiProjectSecrets(metadata, context.config) - : context.config.getEffectiveSecrets(metadata.projectPath) + ? mergeMultiProjectSecrets(metadata, context.secretsStore) + : context.secretsStore.getEffectiveSecrets(metadata.projectPath) ); return context.mcpServerManager.getPromptsForWorkspace( { diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 40cd4389ad..94ece173d8 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -28,7 +28,7 @@ import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { getSrcBaseDir, isSSHRuntime } from "@/common/types/runtime"; import type { XumToolScope } from "@/common/types/toolScope"; import { cloneToolPreservingDescriptors } from "@/common/utils/tools/cloneToolPreservingDescriptors"; -import { ProvidersConfigStore, type Config } from "@/node/config"; +import { ProvidersConfigStore, SecretsStore, type Config } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; import type { Runtime } from "@/node/runtime/Runtime"; @@ -146,7 +146,8 @@ export class AIService extends EventEmitter { experimentsService?: ExperimentsService, streamManager?: StreamManager, public readonly turnRequestBuilderBindings: TurnRequestBuilderBindings = {}, - providersConfigStore?: ProvidersConfigStore + providersConfigStore?: ProvidersConfigStore, + private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -183,6 +184,7 @@ export class AIService extends EventEmitter { this.turnRequestBuilder = new TurnRequestBuilder({ config: this.config, providersConfigStore: this.providersConfigStore, + secretsStore: this.secretsStore, historyService: this.historyService, initStateManager: this.initStateManager, providerService: this.providerService, diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index c5305be4a7..caf508b198 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -5,7 +5,7 @@ import * as os from "os"; import * as path from "path"; import type { Config } from "@/node/config"; -import { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; +import { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { HistoryService } from "@/node/services/historyService"; import { IdleDispatcher } from "@/node/services/idleDispatcher"; import { InitStateManager } from "@/node/services/initStateManager"; @@ -48,6 +48,7 @@ import type { DevToolsService } from "@/node/services/devToolsService"; export interface CoreServicesOptions { config: Config; providersConfigStore?: ProvidersConfigStore; + secretsStore?: SecretsStore; fileLeaseManager?: FileLeaseManager; extensionMetadataPath: string; /** Overrides config for MCPConfigService; CLI passes its persistent realConfig. */ @@ -97,6 +98,7 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { const initStateManager = new InitStateManager(config); const providersConfigStore = opts.providersConfigStore ?? new ProvidersConfigStore(config.rootDir); + const secretsStore = opts.secretsStore ?? new SecretsStore(config.rootDir); const fileLeaseManager = opts.fileLeaseManager ?? new FileLeaseManager(config.rootDir); const providerService = new ProviderService( config, @@ -185,7 +187,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.experimentsService, streamManager, turnRequestBuilderBindings, - providersConfigStore + providersConfigStore, + secretsStore ); // Agent memory (memory experiment): scope roots derive from Config (xum home @@ -250,8 +253,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { const metadata = metadataResult.success ? metadataResult.data : null; const secrets = metadata && isMultiProject(metadata) - ? mergeMultiProjectSecrets(metadata, config) - : config.getEffectiveSecrets(projectPath); + ? mergeMultiProjectSecrets(metadata, secretsStore) + : secretsStore.getEffectiveSecrets(projectPath); return secretsToRecord(secrets); }); @@ -267,7 +270,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.telemetryService, opts.experimentsService, opts.sessionTimingService, - streamManager + streamManager, + secretsStore ); turnRequestBuilderBindings.workspaceHeartbeatService = workspaceService; // Tool-started workflows share the same sidebar activity cache as ORPC-started workflows, @@ -316,7 +320,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { initStateManager, sessionUsageService, workspaceGoalService, - streamManager + streamManager, + secretsStore ); turnRequestBuilderBindings.taskService = taskService; workspaceService.setAgentTaskIntegration(taskService); diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 4b896f8680..f716ba35a5 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -30,7 +30,7 @@ import { RemoteRuntime } from "@/node/runtime/RemoteRuntime"; import type { AgentPluginsMcpContext } from "@/node/services/agentPlugins/mcpConfig"; import { isMutationEpochUnreadable } from "@/node/services/agentPlugins/journals"; import type { PolicyService } from "@/node/services/policyService"; -import type { Config } from "@/node/config"; +import { SecretsStore, type Config } from "@/node/config"; import type { TelemetryService } from "@/node/services/telemetryService"; import { secretsToRecord } from "@/common/types/secrets"; import { roundToBase2 } from "@/common/telemetry/utils"; @@ -3054,10 +3054,11 @@ export class MCPServerManager { const trusted = projectPathProvided ? isProjectTrusted(this.config, resolvedProjectPath) : false; + const secretsStore = new SecretsStore(this.config.rootDir); const projectSecrets = await secretsToRecord( projectPathProvided - ? this.config.getEffectiveSecrets(resolvedProjectPath) - : this.config.getGlobalSecrets() + ? secretsStore.getEffectiveSecrets(resolvedProjectPath) + : secretsStore.getGlobalSecrets() ); const agentPlugins = options.includeAgentPlugins === false diff --git a/src/node/services/projectService.ts b/src/node/services/projectService.ts index e63854e901..4830dd54ec 100644 --- a/src/node/services/projectService.ts +++ b/src/node/services/projectService.ts @@ -1,4 +1,4 @@ -import type { Config, ProjectConfig } from "@/node/config"; +import { SecretsStore, type Config, type ProjectConfig } from "@/node/config"; import { formatSshEndpoint } from "@/common/utils/ssh/formatSshEndpoint"; import { SSH_PROTOCOL_SCHEMES } from "@/constants/git"; import { spawn } from "child_process"; @@ -400,7 +400,8 @@ export class ProjectService { constructor( private readonly config: Config, - sshPromptService?: SshPromptService + sshPromptService?: SshPromptService, + private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) ) { this.sshPromptService = sshPromptService; } @@ -1229,7 +1230,7 @@ export class ProjectService { if (projectConfig.parentProjectPath) { try { - await this.config.updateProjectSecrets(normalizedPath, []); + await this.secretsStore.updateProjectSecrets(normalizedPath, []); } catch (error) { log.error(`Failed to clean up secrets for sub-project ${normalizedPath}:`, error); } @@ -1404,14 +1405,14 @@ export class ProjectService { for (const subProjectPath of removedSubProjectPaths) { try { - await this.config.updateProjectSecrets(subProjectPath, []); + await this.secretsStore.updateProjectSecrets(subProjectPath, []); } catch (error) { log.error(`Failed to clean up secrets for sub-project ${subProjectPath}:`, error); } } try { - await this.config.updateProjectSecrets(normalizedPath, []); + await this.secretsStore.updateProjectSecrets(normalizedPath, []); } catch (error) { log.error(`Failed to clean up secrets for project ${normalizedPath}:`, error); } @@ -1726,7 +1727,7 @@ export class ProjectService { getSecrets(projectPath: string): Secret[] { try { - return this.config.getProjectSecrets(projectPath); + return this.secretsStore.getProjectSecrets(projectPath); } catch (error) { log.error("Failed to get project secrets:", error); return []; @@ -1766,7 +1767,7 @@ export class ProjectService { async updateSecrets(projectPath: string, secrets: Secret[]): Promise> { try { - await this.config.updateProjectSecrets(projectPath, secrets); + await this.secretsStore.updateProjectSecrets(projectPath, secrets); return Ok(undefined); } catch (error) { const message = getErrorMessage(error); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 5712ee48ea..32aff1c6ae 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -3,7 +3,7 @@ import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBeha import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; import { log } from "@/node/services/log"; import type { Config } from "@/node/config"; -import { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; +import { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { createCoreServices, type CoreServices } from "@/node/services/coreServices"; import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; @@ -91,6 +91,7 @@ export class ServiceContainer { public readonly workflowRuntimeFactory = new QuickJSRuntimeFactory(); public readonly config: Config; public readonly providersConfigStore: ProvidersConfigStore; + public readonly secretsStore: SecretsStore; public readonly fileLeaseManager: FileLeaseManager; // Core services — instantiated by createCoreServices (shared with `xum run` CLI) private readonly historyService: CoreServices["historyService"]; @@ -157,6 +158,7 @@ export class ServiceContainer { constructor(config: Config) { this.config = config; this.providersConfigStore = new ProvidersConfigStore(config.rootDir); + this.secretsStore = new SecretsStore(config.rootDir); this.fileLeaseManager = new FileLeaseManager(config.rootDir); // Cross-cutting services: created first so they can be passed to core @@ -185,6 +187,7 @@ export class ServiceContainer { const core = createCoreServices({ config, providersConfigStore: this.providersConfigStore, + secretsStore: this.secretsStore, fileLeaseManager: this.fileLeaseManager, extensionMetadataPath: path.join(config.rootDir, "extensionMetadata.json"), workspaceMcpOverridesService: this.workspaceMcpOverridesService, @@ -253,7 +256,7 @@ export class ServiceContainer { workspaceMcpOverridesService: this.workspaceMcpOverridesService, }); - this.projectService = new ProjectService(config, this.sshPromptService); + this.projectService = new ProjectService(config, this.sshPromptService, this.secretsStore); this.projectService.setWorkspaceService(this.workspaceService); this.projectService.setWorkspaceMetadataRefresher(this.workspaceService); this.projectService.setMcpServerManager(this.mcpServerManager); @@ -370,7 +373,7 @@ export class ServiceContainer { this.copilotOauthService = new CopilotOauthService(this.providerService, this.windowService); // Terminal services - PTYService is cross-platform this.ptyService = new PTYService(); - this.terminalService = new TerminalService(config, this.ptyService); + this.terminalService = new TerminalService(config, this.ptyService, this.secretsStore); // Wire terminal service to workspace service for cleanup on removal this.workspaceService.setTerminalService(this.terminalService); this.workspaceService.setDesktopSessionManager(this.desktopSessionManager); @@ -645,6 +648,7 @@ export class ServiceContainer { workflowRuntimeFactory: this.workflowRuntimeFactory, config: this.config, providersConfigStore: this.providersConfigStore, + secretsStore: this.secretsStore, fileLeaseManager: this.fileLeaseManager, aiService: this.aiService, historyService: this.historyService, diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index e4346b1ed3..34257b0f17 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -10,6 +10,7 @@ import { } from "@/constants/terminationTimeouts"; import { Config, + SecretsStore, type ProjectConfig, type ProjectsConfig, type Workspace as WorkspaceConfigEntry, @@ -11818,10 +11819,10 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 1, maxTaskNestingDepth: 3 }, })); - await config.updateProjectSecrets(primaryProjectPath, [ + await new SecretsStore(config.rootDir).updateProjectSecrets(primaryProjectPath, [ { key: "PRIMARY_SECRET", value: "primary-secret" }, ]); - await config.updateProjectSecrets(secondaryProjectPath, [ + await new SecretsStore(config.rootDir).updateProjectSecrets(secondaryProjectPath, [ { key: "SECONDARY_SECRET", value: "secondary-secret" }, ]); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 6b5efd2cf9..152263db6a 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -15,7 +15,12 @@ import { withLegacyPtcExclusiveMirror } from "@/common/constants/experiments"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; import { AsyncMutex } from "@/node/utils/concurrency/asyncMutex"; -import type { Config, ProjectsConfig, Workspace as WorkspaceConfigEntry } from "@/node/config"; +import { + SecretsStore, + type Config, + type ProjectsConfig, + type Workspace as WorkspaceConfigEntry, +} from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { StreamManager } from "@/node/services/streamManager"; import type { QueueCutCutter } from "@/node/services/messageQueue"; @@ -2205,7 +2210,8 @@ export class TaskService implements AgentTaskIntegration { private readonly initStateManager: InitStateManager, private readonly sessionUsageService?: SessionUsageService, private readonly workspaceGoalService?: WorkspaceGoalService, - private readonly streamManager?: StreamManager + private readonly streamManager?: StreamManager, + private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) ) { this.agentPeerMessageBroker = new AgentPeerMessageBroker(workspaceService); this.taskHandleStore = new TaskHandleStore(config); @@ -2569,7 +2575,7 @@ export class TaskService implements AgentTaskIntegration { } const projectEnv = await secretsToRecord( - this.config.getEffectiveSecrets(normalizedRuntimeProjectPath) + this.secretsStore.getEffectiveSecrets(normalizedRuntimeProjectPath) ); projectEnvCache.set(normalizedRuntimeProjectPath, projectEnv); return projectEnv; @@ -3950,7 +3956,7 @@ export class TaskService implements AgentTaskIntegration { initLogger.logComplete(0); } else { const secrets = await secretsToRecord( - this.config.getEffectiveSecrets(plan.parentMeta.projectPath) + this.secretsStore.getEffectiveSecrets(plan.parentMeta.projectPath) ); // Registered (not just fired) with the host's abort-and-settlement mechanism: // a model-driven archive of this task workspace must be able to cancel the init and @@ -5554,7 +5560,7 @@ export class TaskService implements AgentTaskIntegration { // mutate the live parent workspace — skip it entirely. if (!useSharedWorkspace) { const secrets = await secretsToRecord( - this.config.getEffectiveSecrets(parentMeta.projectPath) + this.secretsStore.getEffectiveSecrets(parentMeta.projectPath) ); // Registered (not just fired) with the host's abort-and-settlement mechanism: // a model-driven archive of this task workspace must be able to cancel the init and diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 10b21654c1..dba317cedd 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect, mock, beforeEach, afterEach, spyOn, vi, type Mock } from "bun:test"; import { TerminalService } from "./terminalService"; import type { PTYService } from "./ptyService"; -import type { Config } from "@/node/config"; +import type { Config, SecretsStore } from "@/node/config"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; import type { TerminalCreateParams } from "@/common/types/terminal"; import type { RuntimeConfig } from "@/common/types/runtime"; @@ -13,6 +13,9 @@ import * as fs from "fs/promises"; const NATIVE_TERMINAL_SESSIONS_DIR = `/tmp/xum-test-native-terminal-sessions-${process.pid}-${Date.now()}`; const getEffectiveSecretsMock = mock(() => [{ key: "TEST_SECRET", value: "secret-value" }]); +const mockSecretsStore = { + getEffectiveSecrets: getEffectiveSecretsMock, +} as unknown as SecretsStore; // Mock dependencies const mockConfig = { @@ -27,7 +30,6 @@ const mockConfig = { }, ]) ), - getEffectiveSecrets: getEffectiveSecretsMock, loadConfigOrDefault: mock(() => ({ projects: new Map(), terminalDefaultShell: undefined, @@ -44,7 +46,6 @@ function createConfigWithMetadata(metadata: { }): Config { return { getAllWorkspaceMetadata: mock(() => Promise.resolve([metadata])), - getEffectiveSecrets: getEffectiveSecretsMock, loadConfigOrDefault: mock(() => ({ projects: new Map(), terminalDefaultShell: undefined, @@ -120,7 +121,7 @@ describe("TerminalService", () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (mockPTYService.createSession as any) = createSessionMock; - service = new TerminalService(mockConfig, mockPTYService); + service = new TerminalService(mockConfig, mockPTYService, mockSecretsStore); service.setTerminalWindowManager(mockWindowManager); createSessionMock.mockClear(); closeSessionMock.mockClear(); @@ -231,7 +232,8 @@ describe("TerminalService", () => { namedWorkspacePath: "/persisted/workspace-root", runtimeConfig: { type: "worktree", srcBaseDir: "/tmp/runtime-src" }, }), - mockPTYService + mockPTYService, + mockSecretsStore ); await service.create({ workspaceId: "ws-persisted", cols: 80, rows: 24 }); @@ -248,7 +250,8 @@ describe("TerminalService", () => { namedWorkspacePath: "/persisted/workspace-root", runtimeConfig: { type: "docker", image: "node:20" }, }), - mockPTYService + mockPTYService, + mockSecretsStore ); await service.create({ workspaceId: "ws-docker", cols: 80, rows: 24 }); @@ -1113,7 +1116,7 @@ describe("TerminalService.openNative", () => { return { status: 0 }; // other commands available }); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-local"); @@ -1128,7 +1131,7 @@ describe("TerminalService.openNative", () => { it("rolls back the recording when the open fails before the marker persists", async () => { spawnSyncSpy.mockImplementation(() => ({ status: 1 })); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); // Unique IDs: other tests open ws-local and its durable marker would leak in here. expect(await service.hasOpenedNativeTerminal("ws-sticky")).toBe(false); @@ -1147,19 +1150,23 @@ describe("TerminalService.openNative", () => { it("remembers native terminal opens across service instances via the durable marker", async () => { spawnSyncSpy.mockImplementation(() => ({ status: 1 })); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-local"); // Detached emulators outlive Xum restarts; a fresh service (fresh in-memory Set) must // still observe the open through the persisted marker. - const restartedService = new TerminalService(configWithLocalWorkspace, mockPTYService); + const restartedService = new TerminalService( + configWithLocalWorkspace, + mockPTYService, + mockSecretsStore + ); expect(await restartedService.hasOpenedNativeTerminal("ws-local")).toBe(true); expect(await restartedService.hasOpenedNativeTerminal("ws-never-opened")).toBe(false); }); it("refuses native terminal opens while the workspace is being archived", async () => { spawnSyncSpy.mockImplementation(() => ({ status: 1 })); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); service.setWorkspaceArchiveGuard(() => true); // Fresh id: ws-local's durable marker may exist from earlier tests in this run, and @@ -1193,7 +1200,7 @@ describe("TerminalService.openNative", () => { ]) ), } as unknown as Config; - service = new TerminalService(configWithArchivedWorkspace, mockPTYService); + service = new TerminalService(configWithArchivedWorkspace, mockPTYService, mockSecretsStore); // Persisted archived state (e.g. a stale renderer) must refuse like the other // admissions: the checkout may already be snapshot and removed. @@ -1213,7 +1220,7 @@ describe("TerminalService.openNative", () => { ...(configWithLocalWorkspace as unknown as Record), getSessionDir: mock((id: string) => `/dev/null/sessions/${id}`), } as unknown as Config; - service = new TerminalService(configWithUnwritableSessions, mockPTYService); + service = new TerminalService(configWithUnwritableSessions, mockPTYService, mockSecretsStore); // A terminal launched without the marker would be invisible to archive gating after // a restart (the in-memory record dies with the app), so persistence failure must @@ -1236,7 +1243,7 @@ describe("TerminalService.openNative", () => { return Promise.reject(new Error("ENOENT")); }); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-local"); @@ -1257,7 +1264,7 @@ describe("TerminalService.openNative", () => { return { status: 0 }; }); - service = new TerminalService(configWithSSHWorkspace, mockPTYService); + service = new TerminalService(configWithSSHWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-ssh"); @@ -1301,7 +1308,7 @@ describe("TerminalService.openNative", () => { // durable marker was written, and no shell was spawned. spawnSyncSpy.mockImplementation(() => ({ status: 1 })); const config = configWithWorkspace("ws-marker-rollback"); - service = new TerminalService(config, mockPTYService); + service = new TerminalService(config, mockPTYService, mockSecretsStore); try { await service.openNative("ws-marker-rollback"); @@ -1314,7 +1321,7 @@ describe("TerminalService.openNative", () => { // permanently refuses model-driven snapshot/Coder-stop archives — it must roll back // durably (visible to a fresh service instance too). expect(await service.hasOpenedNativeTerminal("ws-marker-rollback")).toBe(false); - const restartedService = new TerminalService(config, mockPTYService); + const restartedService = new TerminalService(config, mockPTYService, mockSecretsStore); expect(await restartedService.hasOpenedNativeTerminal("ws-marker-rollback")).toBe(false); }); @@ -1325,7 +1332,7 @@ describe("TerminalService.openNative", () => { // must not survive. spawnSyncSpy.mockImplementation(() => ({ status: 1 })); const config = configWithWorkspace("ws-marker-concurrent"); - service = new TerminalService(config, mockPTYService); + service = new TerminalService(config, mockPTYService, mockSecretsStore); const results = await Promise.allSettled([ service.openNative("ws-marker-concurrent"), @@ -1334,7 +1341,7 @@ describe("TerminalService.openNative", () => { expect(results.every((r) => r.status === "rejected")).toBe(true); expect(spawnSpy).not.toHaveBeenCalled(); expect(await service.hasOpenedNativeTerminal("ws-marker-concurrent")).toBe(false); - const restartedService = new TerminalService(config, mockPTYService); + const restartedService = new TerminalService(config, mockPTYService, mockSecretsStore); expect(await restartedService.hasOpenedNativeTerminal("ws-marker-concurrent")).toBe(false); }); @@ -1368,7 +1375,7 @@ describe("TerminalService.openNative", () => { return metadata; }), } as unknown as Config; - service = new TerminalService(config, mockPTYService); + service = new TerminalService(config, mockPTYService, mockSecretsStore); const first = service.openNative("ws-pending-sibling"); const second = service.openNative("ws-pending-sibling"); @@ -1388,14 +1395,14 @@ describe("TerminalService.openNative", () => { const config = configWithWorkspace("ws-marker-preexisting"); // First open succeeds and persists the durable marker. spawnSyncSpy.mockImplementation(() => ({ status: 0 })); - service = new TerminalService(config, mockPTYService); + service = new TerminalService(config, mockPTYService, mockSecretsStore); await service.openNative("ws-marker-preexisting"); expect(spawnSpy).toHaveBeenCalledTimes(1); // A relaunch after a restart fails (say the emulator was uninstalled): the earlier // session's shell may still be running, so the pre-existing marker must survive. spawnSyncSpy.mockImplementation(() => ({ status: 1 })); - const restartedService = new TerminalService(config, mockPTYService); + const restartedService = new TerminalService(config, mockPTYService, mockSecretsStore); try { await restartedService.openNative("ws-marker-preexisting"); expect.unreachable("openNative must fail when no terminal emulator exists"); @@ -1412,7 +1419,7 @@ describe("TerminalService.openNative", () => { }); it("should open cmd for local workspace", async () => { - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-local"); @@ -1424,7 +1431,7 @@ describe("TerminalService.openNative", () => { }); it("should open cmd with SSH for SSH workspace", async () => { - service = new TerminalService(configWithSSHWorkspace, mockPTYService); + service = new TerminalService(configWithSSHWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-ssh"); @@ -1440,7 +1447,11 @@ describe("TerminalService.openNative", () => { }); it("escapes devcontainer paths for cmd.exe", async () => { - service = new TerminalService(configWithWindowsDevcontainerWorkspace, mockPTYService); + service = new TerminalService( + configWithWindowsDevcontainerWorkspace, + mockPTYService, + mockSecretsStore + ); await service.openNative("ws-devcontainer-win"); @@ -1479,7 +1490,7 @@ describe("TerminalService.openNative", () => { return { status: 0 }; }); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-local"); @@ -1494,7 +1505,7 @@ describe("TerminalService.openNative", () => { // All terminals not found spawnSyncSpy.mockImplementation(() => ({ status: 1 })); - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); // eslint-disable-next-line @typescript-eslint/await-thenable await expect(service.openNative("ws-local")).rejects.toThrow("No terminal emulator found"); @@ -1509,7 +1520,7 @@ describe("TerminalService.openNative", () => { return { status: 1 }; }); - service = new TerminalService(configWithSSHWorkspace, mockPTYService); + service = new TerminalService(configWithSSHWorkspace, mockPTYService, mockSecretsStore); await service.openNative("ws-ssh"); @@ -1531,7 +1542,11 @@ describe("TerminalService.openNative", () => { return { status: 1 }; }); - service = new TerminalService(configWithDevcontainerWorkspace, mockPTYService); + service = new TerminalService( + configWithDevcontainerWorkspace, + mockPTYService, + mockSecretsStore + ); await service.openNative("ws-devcontainer"); @@ -1553,7 +1568,7 @@ describe("TerminalService.openNative", () => { }); it("should throw error for non-existent workspace", async () => { - service = new TerminalService(configWithLocalWorkspace, mockPTYService); + service = new TerminalService(configWithLocalWorkspace, mockPTYService, mockSecretsStore); // eslint-disable-next-line @typescript-eslint/await-thenable await expect(service.openNative("non-existent")).rejects.toThrow( diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 6a24cef2a1..e89d53bd77 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -7,7 +7,7 @@ import { isErrnoWithCode } from "@/node/utils/fs"; import { findWorkspaceEntry } from "@/node/services/taskUtils"; import { spawn } from "child_process"; import { secretsToRecord } from "@/common/types/secrets"; -import type { Config } from "@/node/config"; +import { SecretsStore, type Config } from "@/node/config"; import { getXumEnv, getRuntimeType } from "@/node/runtime/initHook"; import type { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; @@ -192,7 +192,11 @@ export class TerminalService { private readonly noOscIdleFallbacks = new Map>(); private readonly activityChangeEmitter = new EventEmitter(); - constructor(config: Config, ptyService: PTYService) { + constructor( + config: Config, + ptyService: PTYService, + private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) + ) { this.config = config; this.ptyService = ptyService; } @@ -309,7 +313,9 @@ export class TerminalService { // Secrets are local/worktree only. Remote/docker-style transports would expose env via command args // unless we add a dedicated secure propagation path. const secrets = shouldInjectLocalEnv - ? await secretsToRecord(this.config.getEffectiveSecrets(workspaceMetadata.projectPath)) + ? await secretsToRecord( + this.secretsStore.getEffectiveSecrets(workspaceMetadata.projectPath) + ) : {}; // Any process launched from this terminal inherits these variables. diff --git a/src/node/services/turnRequestBuilder.test.ts b/src/node/services/turnRequestBuilder.test.ts index 1c2ac1ae45..191fafccb8 100644 --- a/src/node/services/turnRequestBuilder.test.ts +++ b/src/node/services/turnRequestBuilder.test.ts @@ -7,7 +7,7 @@ import type { WorkspaceMetadata } from "@/common/types/workspace"; import { addInterruptedSentinel } from "@/browser/utils/messages/modelMessageTransform"; import { buildWorkflowRunCardMessage } from "@/common/utils/workflowRunMessages"; import * as providerOptionsModule from "@/common/utils/ai/providerOptions"; -import { ProvidersConfigStore, type ProvidersConfig } from "@/node/config"; +import { ProvidersConfigStore, SecretsStore, type ProvidersConfig } from "@/node/config"; import { InitStateManager } from "./initStateManager"; import { ProviderModelFactory } from "./providerModelFactory"; import { ProviderService } from "./providerService"; @@ -35,6 +35,7 @@ async function createPreparationHarness() { const builder = new TurnRequestBuilder({ config, providersConfigStore, + secretsStore: new SecretsStore(config.rootDir), historyService, initStateManager: new InitStateManager(config), providerService, diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index badd10a30f..77c3e2ec56 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -31,7 +31,7 @@ import { type MCPPromptRuntime, type ToolConfiguration, } from "@/common/utils/tools/tools"; -import type { Config, ProvidersConfigStore } from "@/node/config"; +import type { Config, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { getRuntimeType, getXumEnv } from "@/node/runtime/initHook"; import { type WorkspaceRuntimeContext } from "@/node/runtime/runtimeHelpers"; import { agentPluginHookService } from "@/node/services/agentPlugins/hookService"; @@ -465,6 +465,7 @@ export interface TurnRequestBuilderBindings extends OauthServiceBindings { interface TurnRequestBuilderDependencies { config: Config; providersConfigStore: ProvidersConfigStore; + secretsStore: SecretsStore; historyService: HistoryService; initStateManager: InitStateManager; providerService: ProviderService; @@ -1488,8 +1489,8 @@ export class TurnRequestBuilder { let systemMessage = prePolicyStreamSystemContext.systemMessage; const projectSecrets = isMultiProject(metadata) - ? mergeMultiProjectSecrets(metadata, this.dependencies.config) - : this.dependencies.config.getEffectiveSecrets(metadata.projectPath); + ? mergeMultiProjectSecrets(metadata, this.dependencies.secretsStore) + : this.dependencies.secretsStore.getEffectiveSecrets(metadata.projectPath); const streamToken = this.dependencies.streamManager.generateStreamToken(); diff --git a/src/node/services/utils/multiProjectSecrets.ts b/src/node/services/utils/multiProjectSecrets.ts index c6f082c1ea..e2bcfd9936 100644 --- a/src/node/services/utils/multiProjectSecrets.ts +++ b/src/node/services/utils/multiProjectSecrets.ts @@ -1,9 +1,12 @@ import type { Secret } from "@/common/types/secrets"; import type { WorkspaceMetadata } from "@/common/types/workspace"; import { getProjects } from "@/common/utils/multiProject"; -import type { Config } from "@/node/config"; +import type { SecretsStore } from "@/node/config"; -export function mergeMultiProjectSecrets(metadata: WorkspaceMetadata, config: Config): Secret[] { +export function mergeMultiProjectSecrets( + metadata: WorkspaceMetadata, + secretsStore: SecretsStore +): Secret[] { const projects = getProjects(metadata); const primaryProject = projects.find((project) => project.projectPath === metadata.projectPath); const orderedProjects = primaryProject @@ -18,7 +21,7 @@ export function mergeMultiProjectSecrets(metadata: WorkspaceMetadata, config: Co // Primary project secrets win on collisions so multi-project bash/AI keep single-project precedence. for (const project of orderedProjects) { - const secrets = config.getEffectiveSecrets(project.projectPath); + const secrets = secretsStore.getEffectiveSecrets(project.projectPath); for (const secret of secrets) { if (seen.has(secret.key)) { continue; diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 78a7bb3764..d458687ecc 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -4,7 +4,7 @@ import * as fsPromises from "node:fs/promises"; import path from "node:path"; import { tmpdir } from "node:os"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; -import type { Config } from "@/node/config"; +import type { Config, SecretsStore } from "@/node/config"; import { ContainerManager } from "@/node/multiProject/containerManager"; import { createStreamLifecycleMocks } from "@/node/services/agentSession.testHarness"; import { MultiProjectRuntime } from "@/node/runtime/multiProjectRuntime"; @@ -72,6 +72,7 @@ interface WorkspaceServiceTestOptions { aiService?: AIService; initStateManager?: InitStateManager; experimentsEnabled?: boolean; + secretsStore?: SecretsStore; } function createMockAIService(metadata?: WorkspaceMetadata): AIService { return { @@ -92,7 +93,10 @@ function createWorkspaceServiceForTest(options: WorkspaceServiceTestOptions): Wo undefined, undefined, undefined, - createMockExperimentsService(options.experimentsEnabled ?? true) + createMockExperimentsService(options.experimentsEnabled ?? true), + undefined, + undefined, + options.secretsStore ); } interface ExecuteBashHarnessOptions { @@ -108,7 +112,8 @@ interface ExecuteBashHarnessOptions { trustedProjects?: Array<[string, boolean]>; runtimeWorkspacePaths?: Record; findWorkspaceProjectPath?: string; - getEffectiveSecrets?: Config["getEffectiveSecrets"]; + getEffectiveSecrets?: SecretsStore["getEffectiveSecrets"]; + secretsStore?: SecretsStore; onCreateRuntime?: ( projectPath: string, options: Parameters[1] @@ -196,8 +201,12 @@ function createExecuteBashHarness(options: ExecuteBashHarnessOptions) { ]) ), })), - getEffectiveSecrets: options.getEffectiveSecrets ?? mock(() => []), }, + secretsStore: + options.secretsStore ?? + ({ + getEffectiveSecrets: options.getEffectiveSecrets ?? mock(() => []), + } as unknown as SecretsStore), }); return { bashExecuteMock, @@ -389,7 +398,9 @@ describe("WorkspaceService executeBash runtime selection", () => { historyService, workspaceId, workspaceName, - getEffectiveSecrets: getEffectiveSecretsMock as Config["getEffectiveSecrets"], + secretsStore: { + getEffectiveSecrets: getEffectiveSecretsMock as SecretsStore["getEffectiveSecrets"], + } as unknown as SecretsStore, }); try { const result = await harness.workspaceService.executeBash(workspaceId, "pwd"); @@ -501,8 +512,8 @@ describe("WorkspaceService executeBash runtime selection", () => { loadConfigOrDefault: mock(() => ({ projects: new Map([[projectPath, { workspaces: [], trusted: true }]]), })), - getEffectiveSecrets: mock(() => []), }, + secretsStore: { getEffectiveSecrets: mock(() => []) } as unknown as SecretsStore, }); try { const result = await workspaceService.executeBash(workspaceId, "pwd"); @@ -711,7 +722,6 @@ describe("WorkspaceService multi-project lifecycle", () => { }) ); }), - getEffectiveSecrets: mock(() => []), getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), findWorkspace: mock(() => null), }; @@ -878,7 +888,6 @@ describe("WorkspaceService multi-project lifecycle", () => { })) ); }), - getEffectiveSecrets: mock(() => []), getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), findWorkspace: mock(() => null), }; @@ -1029,7 +1038,6 @@ describe("WorkspaceService multi-project lifecycle", () => { srcDir, generateStableId: mock(() => workspaceId), loadConfigOrDefault: mock(() => configState), - getEffectiveSecrets: mock(() => []), getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), findWorkspace: mock(() => null), }; @@ -1175,7 +1183,6 @@ describe("WorkspaceService multi-project lifecycle", () => { })) ); }), - getEffectiveSecrets: mock(() => []), getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), findWorkspace: mock(() => null), }; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 5a34ba588d..536d19be79 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -21,7 +21,7 @@ import { Err, Ok, type Result } from "@/common/types/result"; import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import type { SendMessageError } from "@/common/types/errors"; import type { ProjectsConfig } from "@/common/types/project"; -import type { Config } from "@/node/config"; +import type { Config, SecretsStore } from "@/node/config"; import type { HistoryService } from "./historyService"; import { createTestHistoryService } from "./testHistoryService"; import type { SessionTimingService } from "./sessionTimingService"; @@ -198,6 +198,7 @@ function createWorkspaceServiceForTest(options: { experimentsService?: WorkspaceServiceArgs[9]; sessionTimingService?: WorkspaceServiceArgs[10]; streamManager?: WorkspaceServiceArgs[11]; + secretsStore?: WorkspaceServiceArgs[12]; }): WorkspaceService { // Test helpers often don't exercise HistoryService; use a narrow stub for those cases. // eslint-disable-next-line @typescript-eslint/consistent-type-assertions @@ -214,7 +215,8 @@ function createWorkspaceServiceForTest(options: { options.telemetryService, options.experimentsService, options.sessionTimingService, - options.streamManager + options.streamManager, + options.secretsStore ); } @@ -11240,6 +11242,7 @@ describe("WorkspaceService initialize", () => { config, aiService, initStateManager: mockInitStateManager as InitStateManager, + secretsStore: { getEffectiveSecrets: mock(() => []) } as unknown as SecretsStore, }); }); @@ -13609,7 +13612,6 @@ describe("WorkspaceService executeBash archive guards", () => { getSessionDir: mock(() => "/tmp/test/sessions"), generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), - getProjectSecrets: mock(() => []), }; const mockInitStateManager: Partial = { on: mock(() => undefined as unknown as InitStateManager), @@ -13838,7 +13840,6 @@ describe("WorkspaceService executeBash workspace path resolution", () => { getSessionDir: mock(() => "/tmp/test/sessions"), generateStableId: mock(() => "test-id"), findWorkspace: findWorkspaceMock, - getEffectiveSecrets: getEffectiveSecretsMock, loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; const mockInitStateManager: Partial = { @@ -13851,6 +13852,7 @@ describe("WorkspaceService executeBash workspace path resolution", () => { historyService, aiService, initStateManager: mockInitStateManager as InitStateManager, + secretsStore: { getEffectiveSecrets: getEffectiveSecretsMock } as unknown as SecretsStore, }); createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ @@ -19029,7 +19031,6 @@ describe("WorkspaceService init cancellation", () => { return Promise.resolve(); }), getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), - getEffectiveSecrets: mock(() => [{ key: "GH_TOKEN", value: "token" }]), getSessionDir: mock(() => "/tmp/test/sessions"), findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ @@ -19085,7 +19086,14 @@ describe("WorkspaceService init cancellation", () => { mockAIService, mockInitStateManager as InitStateManager, mockExtensionMetadataService as ExtensionMetadataService, - mockBackgroundProcessManager as BackgroundProcessManager + mockBackgroundProcessManager as BackgroundProcessManager, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { getEffectiveSecrets: mock(() => [{ key: "GH_TOKEN", value: "token" }]) } as unknown as SecretsStore ); const metadataEvents: Array = []; @@ -19178,7 +19186,6 @@ describe("WorkspaceService init cancellation", () => { return Promise.resolve(); }), getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), - getEffectiveSecrets: mock(() => []), getSessionDir: mock(() => "/tmp/test/sessions"), findWorkspace: mock(() => null), // Two pre-existing workspaces — auto-naming should skip past them. @@ -19221,7 +19228,14 @@ describe("WorkspaceService init cancellation", () => { mockAIService, mockInitStateManager as InitStateManager, mockExtensionMetadataService as ExtensionMetadataService, - mockBackgroundProcessManager as BackgroundProcessManager + mockBackgroundProcessManager as BackgroundProcessManager, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { getEffectiveSecrets: mock(() => []) } as unknown as SecretsStore ); const removingWorkspaces = ( @@ -19708,7 +19722,6 @@ describe("WorkspaceService fork", () => { generateStableId: mock(() => newWorkspaceId), findWorkspace: mock(() => null), getSessionDir: mock(() => "/tmp/test/sessions"), - getEffectiveSecrets: mock(() => []), loadConfigOrDefault: mock(() => ({ projects: new Map([[sourceProjectPath, { workspaces: [], trusted: true }]]), })), @@ -19719,6 +19732,7 @@ describe("WorkspaceService fork", () => { historyService, aiService: mockAIService, initStateManager: mockInitStateManager as InitStateManager, + secretsStore: { getEffectiveSecrets: mock(() => []) } as unknown as SecretsStore, }); const getOrCreateSessionSpy = spyOn(workspaceService, "getOrCreateSession").mockReturnValue({ diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 898eadefc1..5eea9d4975 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -18,7 +18,7 @@ import { import { SCRATCH_PROJECT_CONFIG_KEY } from "@/common/constants/scratch"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; -import { ProvidersConfigStore, type Config } from "@/node/config"; +import { ProvidersConfigStore, SecretsStore, type Config } from "@/node/config"; import type { ProjectsConfig, Workspace } from "@/common/types/project"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -2375,7 +2375,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { telemetryService?: TelemetryService, experimentsService?: ExperimentsService, sessionTimingService?: SessionTimingService, - private readonly streamManager?: StreamManager + private readonly streamManager?: StreamManager, + private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) ) { super(); this.bashMonitorWakeStore = new BashMonitorWakeStore(config); @@ -5476,7 +5477,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } } - const createEnv = await secretsToRecord(this.config.getEffectiveSecrets(owningProjectPath)); + const createEnv = await secretsToRecord( + this.secretsStore.getEffectiveSecrets(owningProjectPath) + ); const maxCollisionRetries = hasSanitizedWorkspaceName ? 0 : MAX_WORKSPACE_NAME_COLLISION_RETRIES; @@ -5680,7 +5683,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { session.emitMetadata(this.enrichFrontendMetadata(completeMetadata)); // Background init: run postCreateSetup (if present) then initWorkspace - const secrets = await secretsToRecord(this.config.getEffectiveSecrets(owningProjectPath)); + const secrets = await secretsToRecord( + this.secretsStore.getEffectiveSecrets(owningProjectPath) + ); // Background init: postCreateSetup (provisioning) + initWorkspace (sync/checkout/hook) // // If the user cancelled creation while create() was still in flight, avoid spawning @@ -5949,7 +5954,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { ); const createEnv = await secretsToRecord( - this.config.getEffectiveSecrets(projectRuntimeEntry.project.projectPath) + this.secretsStore.getEffectiveSecrets(projectRuntimeEntry.project.projectPath) ); const createResult = await projectRuntimeEntry.runtime.createWorkspace({ @@ -6077,7 +6082,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { try { const secrets = await secretsToRecord( - this.config.getEffectiveSecrets(createdWorkspace.project.projectPath) + this.secretsStore.getEffectiveSecrets(createdWorkspace.project.projectPath) ); const initResult = await runFullInit(createdWorkspace.runtime, { @@ -10482,7 +10487,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { } const projectEnv = await secretsToRecord( - this.config.getEffectiveSecrets(normalizedRuntimeProjectPath) + this.secretsStore.getEffectiveSecrets(normalizedRuntimeProjectPath) ); projectEnvCache.set(normalizedRuntimeProjectPath, projectEnv); return projectEnv; @@ -14856,8 +14861,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // Multi-project bash shares one execution environment, so inject the union of repo secrets. const projectSecrets = isMultiProject(metadata) - ? mergeMultiProjectSecrets(metadata, this.config) - : this.config.getEffectiveSecrets(metadata.projectPath); + ? mergeMultiProjectSecrets(metadata, this.secretsStore) + : this.secretsStore.getEffectiveSecrets(metadata.projectPath); // Create scoped temp directory for this IPC call using tempDir = new DisposableTempDir("mux-ipc-bash"); From b3f7751535e816b782d28ff3805637dc8ed6a93d Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 18:19:38 +0000 Subject: [PATCH 05/17] refactor(config): add session locator extraction checkpoint --- src/node/config/index.ts | 32 +++++++++++++++---------------- src/node/config/sessionLocator.ts | 18 +++++++++++++++++ 2 files changed, 34 insertions(+), 16 deletions(-) create mode 100644 src/node/config/sessionLocator.ts diff --git a/src/node/config/index.ts b/src/node/config/index.ts index d6bda30d5f..cb0da42b67 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -6,6 +6,7 @@ import writeFileAtomic from "write-file-atomic"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { log } from "@/node/services/log"; import { ProvidersConfigStore } from "./ProvidersConfigStore"; +import { WorkspaceSessionLocator } from "./sessionLocator"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { Result } from "@/common/types/result"; import assert from "node:assert/strict"; @@ -46,7 +47,6 @@ import { SCRATCH_PROJECT_NAME } from "@/common/constants/scratch"; import { DEFAULT_RUNTIME_CONFIG } from "@/common/constants/workspace"; import { isIncompatibleRuntimeConfig } from "@/common/utils/runtimeCompatibility"; import { LEGACY_MUX_PRODUCT_NAME, LEGACY_MUX_PRODUCT_SLUG } from "@/common/compat/legacyMux"; -import { getXumHome } from "@/common/constants/paths"; import { XUM_PRODUCT_NAME, XUM_PRODUCT_SLUG } from "@/common/constants/product"; import { GATEWAY_PROVIDERS } from "@/common/constants/providers"; import { @@ -87,6 +87,7 @@ export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, Canonica export { FileLeaseManager } from "./FileLeaseManager"; export { ProvidersConfigStore, type ProvidersConfig } from "./ProvidersConfigStore"; export { SecretsStore } from "./SecretsStore"; +export { WorkspaceSessionLocator } from "./sessionLocator"; /** True only for fs errors whose errno code is ENOENT (genuinely missing path). */ function isEnoentError(error: unknown): boolean { @@ -896,6 +897,7 @@ export class Config { readonly sessionsDir: string; readonly srcDir: string; private readonly configFile: string; + private readonly sessionLocator: WorkspaceSessionLocator; private readonly providersConfigStore: ProvidersConfigStore; private readonly emitter = new EventEmitter(); /** @@ -909,10 +911,15 @@ export class Config { /** One-shot guard for the queued load-time migration persist; see loadConfigOrDefault. */ private migrationPersist: Promise | null = null; - constructor(rootDir?: string, providersConfigStore?: ProvidersConfigStore) { - this.rootDir = rootDir ?? getXumHome(); - this.sessionsDir = path.join(this.rootDir, "sessions"); - this.srcDir = path.join(this.rootDir, "src"); + constructor( + rootDir?: string, + providersConfigStore?: ProvidersConfigStore, + sessionLocator = new WorkspaceSessionLocator(rootDir) + ) { + this.sessionLocator = sessionLocator; + this.rootDir = sessionLocator.rootDir; + this.sessionsDir = sessionLocator.sessionsDir; + this.srcDir = sessionLocator.srcDir; this.configFile = path.join(this.rootDir, "config.json"); this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(this.rootDir); } @@ -1645,7 +1652,7 @@ export class Config { } const usagePath = path.join( - this.getSessionDir(sessionEntry.name), + this.sessionLocator.getSessionDir(sessionEntry.name), "session-usage.json" ); if (fs.existsSync(usagePath)) { @@ -2665,7 +2672,7 @@ export class Config { workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown"; // Try loading metadata with basename as ID (works for old workspaces) - const metadataPath = path.join(this.getSessionDir(workspaceBasename), "metadata.json"); + const metadataPath = path.join(this.sessionLocator.getSessionDir(workspaceBasename), "metadata.json"); try { const data = fs.readFileSync(metadataPath, "utf-8"); const metadata = JSON.parse(data) as WorkspaceMetadata; @@ -2714,7 +2721,7 @@ export class Config { // only in that file would be reported absent while its workspace // remains registered. const legacyId = this.generateLegacyId(projectPath, workspace.path); - const legacyMetadataPath = path.join(this.getSessionDir(legacyId), "metadata.json"); + const legacyMetadataPath = path.join(this.sessionLocator.getSessionDir(legacyId), "metadata.json"); try { const legacyData = fs.readFileSync(legacyMetadataPath, "utf-8"); const legacyMetadata = JSON.parse(legacyData) as WorkspaceMetadata; @@ -2783,13 +2790,6 @@ export class Config { * paths from getWorkspacePath() or getWorkspacePaths() instead. */ - /** - * Get the session directory for a specific workspace - */ - getSessionDir(workspaceId: string): string { - return path.join(this.sessionsDir, workspaceId); - } - /** * Get all workspace metadata by loading config and metadata files. * @@ -3024,7 +3024,7 @@ export class Config { const candidateIds = workspaceBasename === legacyId ? [legacyId] : [legacyId, workspaceBasename]; for (const candidateId of candidateIds) { - const candidatePath = path.join(this.getSessionDir(candidateId), "metadata.json"); + const candidatePath = path.join(this.sessionLocator.getSessionDir(candidateId), "metadata.json"); let candidateRaw: string | undefined; try { candidateRaw = fs.readFileSync(candidatePath, "utf-8"); diff --git a/src/node/config/sessionLocator.ts b/src/node/config/sessionLocator.ts new file mode 100644 index 0000000000..486c6681e3 --- /dev/null +++ b/src/node/config/sessionLocator.ts @@ -0,0 +1,18 @@ +import * as path from "path"; +import { getXumHome } from "@/common/constants/paths"; + +export class WorkspaceSessionLocator { + readonly rootDir: string; + readonly srcDir: string; + readonly sessionsDir: string; + + constructor(rootDir = getXumHome()) { + this.rootDir = rootDir; + this.srcDir = path.join(rootDir, "src"); + this.sessionsDir = path.join(rootDir, "sessions"); + } + + getSessionDir(workspaceId: string): string { + return path.join(this.sessionsDir, workspaceId); + } +} From 8a41ea6b6941140048a290ad269c65b71042a9b9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:11:07 +0000 Subject: [PATCH 06/17] =?UTF-8?q?=F0=9F=A4=96=20refactor(config):=20rewire?= =?UTF-8?q?=20session=20paths=20to=20locator=20storage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/debug/costs.ts | 4 +- src/cli/debug/refinements.ts | 3 +- src/cli/debug/replay-history.ts | 4 +- src/cli/debug/replay-verify.ts | 4 +- src/cli/debug/send-message.ts | 4 +- src/cli/runSessionRoot.test.ts | 2 +- src/cli/workflow.ts | 2 +- src/node/config.test.ts | 19 +- src/node/config/index.ts | 19 +- .../__tests__/devToolsMiddleware.test.ts | 5 +- .../__tests__/devToolsService.test.ts | 5 +- .../services/additionalSystemContext.test.ts | 2 +- src/node/services/additionalSystemContext.ts | 11 +- .../services/agentPlugins/hookService.test.ts | 2 +- .../services/agentSession.disposeRace.test.ts | 3 +- src/node/services/agentSession.ts | 17 +- src/node/services/agentStatusService.test.ts | 2 +- src/node/services/aiService.test.ts | 4 +- src/node/services/aiService.ts | 11 +- .../services/bashMonitorRegistryStore.test.ts | 6 +- src/node/services/bashMonitorRegistryStore.ts | 9 +- .../services/bashMonitorWakeStore.test.ts | 16 +- src/node/services/bashMonitorWakeStore.ts | 6 +- src/node/services/branchSummary.test.ts | 2 +- src/node/services/codexOauthService.test.ts | 3 +- src/node/services/devToolsService.test.ts | 6 +- src/node/services/devToolsService.ts | 6 +- src/node/services/gitPatchArtifactService.ts | 4 +- src/node/services/historyService.test.ts | 58 ++-- src/node/services/historyService.ts | 32 +- src/node/services/initStateManager.test.ts | 4 +- .../memoryConsolidationService.test.ts | 2 +- src/node/services/memoryService.test.ts | 12 +- src/node/services/memoryService.ts | 4 +- src/node/services/partialService.test.ts | 4 +- src/node/services/projectService.test.ts | 7 +- .../services/providerModelFactory.test.ts | 3 +- src/node/services/providerService.test.ts | 3 +- .../services/refinement/refineService.test.ts | 2 +- src/node/services/refinement/refineService.ts | 5 +- .../refinement/refinementRollback.test.ts | 2 +- src/node/services/replay/replayFixture.ts | 2 +- .../replay/replayVerify.fixture.test.ts | 2 +- src/node/services/serviceContainer.test.ts | 4 +- src/node/services/serviceContainer.ts | 4 +- .../services/sessionTimingService.test.ts | 11 +- src/node/services/sessionTimingService.ts | 4 +- src/node/services/sessionUsageService.test.ts | 34 ++- src/node/services/sessionUsageService.ts | 6 +- src/node/services/streamManager.test.ts | 25 +- src/node/services/taskGitPatchEngine.ts | 4 +- src/node/services/taskHandleStore.test.ts | 8 +- src/node/services/taskHandleStore.ts | 2 +- src/node/services/taskService.test.ts | 279 +++++++++++------- src/node/services/taskService.ts | 56 ++-- .../services/terminalAttentionStore.test.ts | 4 +- src/node/services/terminalAttentionStore.ts | 6 +- src/node/services/terminalService.test.ts | 7 +- src/node/services/terminalService.ts | 8 +- src/node/services/timelineService.test.ts | 20 +- src/node/services/timelineService.ts | 10 +- src/node/services/tools/goal.test.ts | 4 +- src/node/services/tools/memory.test.ts | 2 +- src/node/services/turnRequestBuilder.ts | 13 +- .../services/utils/multiProjectSecrets.ts | 2 +- .../services/workflows/WorkflowRunStore.ts | 8 +- .../workflows/WorkflowService.context.test.ts | 2 +- .../services/workflows/WorkflowService.ts | 4 +- .../services/workspaceGoalService.test.ts | 27 +- src/node/services/workspaceGoalService.ts | 4 +- .../workspaceService.multiProject.test.ts | 54 ++-- src/node/services/workspaceService.test.ts | 252 ++++++++-------- src/node/services/workspaceService.ts | 49 +-- .../worktreeArchiveSnapshotService.test.ts | 48 +-- .../worktreeArchiveSnapshotService.ts | 12 +- src/node/utils/sessionFile.ts | 6 +- tests/e2e/utils/historyFixture.ts | 2 +- tests/ipc/agents/planCommands.test.ts | 17 +- tests/ipc/helpers.ts | 4 +- tests/ipc/setup.ts | 3 +- tests/ipc/workspace/init.test.ts | 7 +- tests/ipc/workspace/rename.test.ts | 5 +- 82 files changed, 776 insertions(+), 564 deletions(-) diff --git a/src/cli/debug/costs.ts b/src/cli/debug/costs.ts index 8fc258a474..6a5e326439 100644 --- a/src/cli/debug/costs.ts +++ b/src/cli/debug/costs.ts @@ -1,5 +1,5 @@ -import * as fs from "fs"; import * as path from "path"; +import * as fs from "fs"; import { defaultConfig } from "@/node/config"; import type { MuxMessage } from "@/common/types/message"; import { calculateTokenStats } from "@/common/utils/tokens/tokenStatsCalculator"; @@ -14,7 +14,7 @@ export async function costsCommand(workspaceId: string) { console.log(`\n=== Cost Statistics for workspace: ${workspaceId} ===\n`); // Load chat history - const sessionDir = defaultConfig.getSessionDir(workspaceId); + const sessionDir = path.join(defaultConfig.sessionsDir, workspaceId); const chatHistoryPath = path.join(sessionDir, "chat.jsonl"); if (!fs.existsSync(chatHistoryPath)) { diff --git a/src/cli/debug/refinements.ts b/src/cli/debug/refinements.ts index a154e197ba..d90ca13f4f 100644 --- a/src/cli/debug/refinements.ts +++ b/src/cli/debug/refinements.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { defaultConfig } from "@/node/config"; import { MemoryRefinementActionSchema, @@ -46,7 +47,7 @@ export async function refinementsCommand( workspaceId: string, opts: RefinementsCommandOptions = {} ): Promise { - const sessionDir = opts.sessionDir ?? defaultConfig.getSessionDir(workspaceId); + const sessionDir = opts.sessionDir ?? path.join(defaultConfig.sessionsDir, workspaceId); if (opts.rollback !== undefined) { const result = await rollbackRefinement({ diff --git a/src/cli/debug/replay-history.ts b/src/cli/debug/replay-history.ts index 1a1ebf7b51..f8d7a68e8b 100644 --- a/src/cli/debug/replay-history.ts +++ b/src/cli/debug/replay-history.ts @@ -1,4 +1,5 @@ #!/usr/bin/env bun +import * as path from "path"; /** * Debug script to replay a chat history and send a new message. @@ -12,7 +13,6 @@ */ import * as fs from "fs"; -import * as path from "path"; import { parseArgs } from "util"; import { defaultConfig } from "@/node/config"; import type { MuxMessage } from "@/common/types/message"; @@ -89,7 +89,7 @@ async function main() { // Create a temporary workspace const workspaceId = `debug-replay-${Date.now()}`; - const sessionDir = defaultConfig.getSessionDir(workspaceId); + const sessionDir = path.join(defaultConfig.sessionsDir, workspaceId); fs.mkdirSync(sessionDir, { recursive: true }); // Create workspace metadata diff --git a/src/cli/debug/replay-verify.ts b/src/cli/debug/replay-verify.ts index 91780aa843..f7463ab3d3 100644 --- a/src/cli/debug/replay-verify.ts +++ b/src/cli/debug/replay-verify.ts @@ -21,14 +21,14 @@ export function resolveReplaySessionDir(workspaceId: string): { return { sessionDir: REPLAY_FIXTURE_DIR, historyService: new HistoryService({ - getSessionDir: () => REPLAY_FIXTURE_DIR, + sessionsDir: REPLAY_FIXTURE_DIR, // Read-only verification: rootDir only locates write locks/tombstones. rootDir: path.dirname(REPLAY_FIXTURE_DIR), }), }; } return { - sessionDir: defaultConfig.getSessionDir(workspaceId), + sessionDir: path.join(defaultConfig.sessionsDir, workspaceId), historyService: new HistoryService(defaultConfig), }; } diff --git a/src/cli/debug/send-message.ts b/src/cli/debug/send-message.ts index 3be66f82bb..95788894bf 100644 --- a/src/cli/debug/send-message.ts +++ b/src/cli/debug/send-message.ts @@ -1,5 +1,5 @@ -import * as fs from "fs"; import * as path from "path"; +import * as fs from "fs"; import { defaultConfig } from "@/node/config"; import type { MuxMessage } from "@/common/types/message"; import type { SendMessageOptions } from "@/common/orpc/types"; @@ -23,7 +23,7 @@ export function sendMessageCommand( console.log(); // Load chat history to verify message exists if editing - const sessionDir = defaultConfig.getSessionDir(workspaceId); + const sessionDir = path.join(defaultConfig.sessionsDir, workspaceId); const chatHistoryPath = path.join(sessionDir, "chat.jsonl"); if (!fs.existsSync(chatHistoryPath)) { diff --git a/src/cli/runSessionRoot.test.ts b/src/cli/runSessionRoot.test.ts index a77676dd0a..c77216e784 100644 --- a/src/cli/runSessionRoot.test.ts +++ b/src/cli/runSessionRoot.test.ts @@ -62,7 +62,7 @@ describe("prepareRunSessionRootOverride", () => { path.join(config.rootDir, "secrets.json"), JSON.stringify({ token: "secret-value" }) ); - const sessionDir = config.getSessionDir("workspace-1"); + const sessionDir = path.join(config.sessionsDir, "workspace-1"); await fs.mkdir(sessionDir, { recursive: true }); await fs.writeFile(path.join(sessionDir, "chat.jsonl"), "chat"); await fs.writeFile(path.join(sessionDir, "session-usage.json"), "usage"); diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index d14fdcb64c..56111b27a2 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -472,7 +472,7 @@ function createWorkflowService(input: { workspaceName: input.ctx.workspaceId, workspacePath: input.ctx.workspacePath, }); - const workspaceSessionDir = input.ctx.config.getSessionDir(input.ctx.workspaceId); + const workspaceSessionDir = path.join(input.ctx.config.sessionsDir, input.ctx.workspaceId); return new WorkflowService({ runStore: new WorkflowRunStore({ sessionDir: workspaceSessionDir }), diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 0daa16514c..b93a51275b 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import * as fs from "fs"; import * as os from "os"; -import * as path from "path"; import { log } from "@/node/services/log"; import { Config } from "./config"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; @@ -995,7 +995,7 @@ describe("Config", () => { generateLegacyId(projectPath: string, workspacePath: string): string; } ).generateLegacyId(projectPath, workspacePath); - const canonicalDir = config.getSessionDir(canonicalId); + const canonicalDir = path.join(config.sessionsDir, canonicalId); fs.mkdirSync(canonicalDir, { recursive: true }); fs.writeFileSync( path.join(canonicalDir, "metadata.json"), @@ -1003,7 +1003,7 @@ describe("Config", () => { ); // Basename-backed second candidate is unreadable: a directory at the // metadata.json path fails reads with EISDIR (non-ENOENT). - fs.mkdirSync(path.join(config.getSessionDir("legacy-ws"), "metadata.json"), { + fs.mkdirSync(path.join(path.join(config.sessionsDir, "legacy-ws"), "metadata.json"), { recursive: true, }); @@ -2551,7 +2551,10 @@ describe("Config", () => { }) ); - const usagePath = path.join(config.getSessionDir("workspace-1"), "session-usage.json"); + const usagePath = path.join( + path.join(config.sessionsDir, "workspace-1"), + "session-usage.json" + ); fs.mkdirSync(path.dirname(usagePath), { recursive: true }); fs.writeFileSync(usagePath, JSON.stringify({ totalCost: 1.23 })); @@ -3372,7 +3375,7 @@ describe("Config", () => { // Test backward compatibility: Create metadata file using legacy ID format. // This simulates workspaces created before stable IDs were introduced. const legacyId = config.generateLegacyId(projectPath, workspacePath); - const sessionDir = config.getSessionDir(legacyId); + const sessionDir = path.join(config.sessionsDir, legacyId); fs.mkdirSync(sessionDir, { recursive: true }); const metadataPath = path.join(sessionDir, "metadata.json"); const existingMetadata = { @@ -3424,7 +3427,7 @@ describe("Config", () => { const workspacePath = path.join(config.srcDir, "project", workspaceName); fs.mkdirSync(workspacePath, { recursive: true }); - const sessionDir = config.getSessionDir(workspaceName); + const sessionDir = path.join(config.sessionsDir, workspaceName); fs.mkdirSync(sessionDir, { recursive: true }); fs.writeFileSync( path.join(sessionDir, "metadata.json"), @@ -3462,14 +3465,14 @@ describe("Config", () => { const workspacePath = path.join(config.srcDir, "project", workspaceName); fs.mkdirSync(workspacePath, { recursive: true }); - const basenameSessionDir = config.getSessionDir(workspaceName); + const basenameSessionDir = path.join(config.sessionsDir, workspaceName); fs.mkdirSync(basenameSessionDir, { recursive: true }); fs.writeFileSync( path.join(basenameSessionDir, "metadata.json"), JSON.stringify({ id: "stale-basename-id", name: workspaceName }) ); const legacyId = config.generateLegacyId(projectPath, workspacePath); - const legacySessionDir = config.getSessionDir(legacyId); + const legacySessionDir = path.join(config.sessionsDir, legacyId); fs.mkdirSync(legacySessionDir, { recursive: true }); fs.writeFileSync( path.join(legacySessionDir, "metadata.json"), diff --git a/src/node/config/index.ts b/src/node/config/index.ts index cb0da42b67..e3f51e7d9d 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -1,5 +1,5 @@ -import * as fs from "fs"; import * as path from "path"; +import * as fs from "fs"; import * as crypto from "crypto"; import { EventEmitter } from "events"; import writeFileAtomic from "write-file-atomic"; @@ -1652,7 +1652,7 @@ export class Config { } const usagePath = path.join( - this.sessionLocator.getSessionDir(sessionEntry.name), + path.join(this.sessionLocator.sessionsDir, sessionEntry.name), "session-usage.json" ); if (fs.existsSync(usagePath)) { @@ -2672,7 +2672,10 @@ export class Config { workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown"; // Try loading metadata with basename as ID (works for old workspaces) - const metadataPath = path.join(this.sessionLocator.getSessionDir(workspaceBasename), "metadata.json"); + const metadataPath = path.join( + path.join(this.sessionLocator.sessionsDir, workspaceBasename), + "metadata.json" + ); try { const data = fs.readFileSync(metadataPath, "utf-8"); const metadata = JSON.parse(data) as WorkspaceMetadata; @@ -2721,7 +2724,10 @@ export class Config { // only in that file would be reported absent while its workspace // remains registered. const legacyId = this.generateLegacyId(projectPath, workspace.path); - const legacyMetadataPath = path.join(this.sessionLocator.getSessionDir(legacyId), "metadata.json"); + const legacyMetadataPath = path.join( + path.join(this.sessionLocator.sessionsDir, legacyId), + "metadata.json" + ); try { const legacyData = fs.readFileSync(legacyMetadataPath, "utf-8"); const legacyMetadata = JSON.parse(legacyData) as WorkspaceMetadata; @@ -3024,7 +3030,10 @@ export class Config { const candidateIds = workspaceBasename === legacyId ? [legacyId] : [legacyId, workspaceBasename]; for (const candidateId of candidateIds) { - const candidatePath = path.join(this.sessionLocator.getSessionDir(candidateId), "metadata.json"); + const candidatePath = path.join( + path.join(this.sessionLocator.sessionsDir, candidateId), + "metadata.json" + ); let candidateRaw: string | undefined; try { candidateRaw = fs.readFileSync(candidatePath, "utf-8"); diff --git a/src/node/services/__tests__/devToolsMiddleware.test.ts b/src/node/services/__tests__/devToolsMiddleware.test.ts index ebca9c5dd9..974299e8e8 100644 --- a/src/node/services/__tests__/devToolsMiddleware.test.ts +++ b/src/node/services/__tests__/devToolsMiddleware.test.ts @@ -15,10 +15,7 @@ import { createDevToolsMiddleware, extractUsage } from "@/node/services/devTools import { DevToolsService } from "@/node/services/devToolsService"; function createTestConfig(opts: { sessionsDir: string; enabled?: boolean }): Config { - const config = new Config(opts.sessionsDir); - spyOn(config, "getSessionDir").mockImplementation((workspaceId: string) => - path.join(opts.sessionsDir, workspaceId) - ); + const config = new Config(path.dirname(opts.sessionsDir)); spyOn(config, "getLlmDebugLogsEnabled").mockImplementation(() => opts.enabled ?? true); return config; } diff --git a/src/node/services/__tests__/devToolsService.test.ts b/src/node/services/__tests__/devToolsService.test.ts index 28f8144ebf..74b837300b 100644 --- a/src/node/services/__tests__/devToolsService.test.ts +++ b/src/node/services/__tests__/devToolsService.test.ts @@ -36,10 +36,7 @@ function makeStep(overrides: Partial & { id: string; runId: string } function createTestConfig(opts: { sessionsDir: string; enabled?: boolean }): Config { - const config = new Config(opts.sessionsDir); - spyOn(config, "getSessionDir").mockImplementation((workspaceId: string) => - path.join(opts.sessionsDir, workspaceId) - ); + const config = new Config(path.dirname(opts.sessionsDir)); spyOn(config, "getLlmDebugLogsEnabled").mockImplementation(() => opts.enabled ?? true); return config; } diff --git a/src/node/services/additionalSystemContext.test.ts b/src/node/services/additionalSystemContext.test.ts index 2cc558f7c2..34beb06040 100644 --- a/src/node/services/additionalSystemContext.test.ts +++ b/src/node/services/additionalSystemContext.test.ts @@ -13,7 +13,7 @@ import { function createSessionDirProvider(root: string) { return { - getSessionDir: (workspaceId: string) => path.join(root, workspaceId), + sessionsDir: root, }; } diff --git a/src/node/services/additionalSystemContext.ts b/src/node/services/additionalSystemContext.ts index 2db7f90a29..31b8a3c6f9 100644 --- a/src/node/services/additionalSystemContext.ts +++ b/src/node/services/additionalSystemContext.ts @@ -1,11 +1,11 @@ -import * as fs from "fs/promises"; import * as path from "path"; +import * as fs from "fs/promises"; import { mergeAdditionalSystemInstructions } from "@/common/utils/additionalSystemInstructions"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; interface SessionDirProvider { - getSessionDir(workspaceId: string): string; + sessionsDir: string; } export const ADDITIONAL_SYSTEM_CONTEXT_FILENAME = "additional-system-context.md"; @@ -26,14 +26,17 @@ export function getAdditionalSystemContextPath( config: SessionDirProvider, workspaceId: string ): string { - return path.join(config.getSessionDir(workspaceId), ADDITIONAL_SYSTEM_CONTEXT_FILENAME); + return path.join(path.join(config.sessionsDir, workspaceId), ADDITIONAL_SYSTEM_CONTEXT_FILENAME); } export function getAdditionalSystemContextDisabledPath( config: SessionDirProvider, workspaceId: string ): string { - return path.join(config.getSessionDir(workspaceId), ADDITIONAL_SYSTEM_CONTEXT_DISABLED_FILENAME); + return path.join( + path.join(config.sessionsDir, workspaceId), + ADDITIONAL_SYSTEM_CONTEXT_DISABLED_FILENAME + ); } async function readContentFile(config: SessionDirProvider, workspaceId: string): Promise { diff --git a/src/node/services/agentPlugins/hookService.test.ts b/src/node/services/agentPlugins/hookService.test.ts index a4308014c1..a1069d6d6c 100644 --- a/src/node/services/agentPlugins/hookService.test.ts +++ b/src/node/services/agentPlugins/hookService.test.ts @@ -633,7 +633,7 @@ describe("replay determinism with hooks active", () => { // ...and byte-level replay verification passes with the hook active. const historyService = new HistoryService({ - getSessionDir: () => harness.sessionDir, + sessionsDir: harness.sessionDir, rootDir: path.dirname(harness.sessionDir), }); const history = await collectFullHistory(historyService, REPLAY_FIXTURE_WORKSPACE_ID); diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 7fcace4892..1a1f436e8c 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { describe, expect, test, mock, spyOn } from "bun:test"; import { existsSync } from "node:fs"; import * as fs from "node:fs/promises"; @@ -170,7 +171,7 @@ describe("AgentSession disposal race conditions", () => { } as unknown as BackgroundProcessManager; const workspaceId = "ws-branch-summary-dispose"; - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); try { const session = new AgentSession({ workspaceId, diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index cf9cff6912..8add9f4ff0 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import assert from "@/common/utils/assert"; import { EventEmitter } from "events"; -import * as path from "path"; import { mkdir, readdir, readFile, unlink, writeFile } from "fs/promises"; import type { Dirent } from "fs"; import type { LanguageModelV2Usage } from "@ai-sdk/provider"; @@ -926,7 +926,7 @@ export class AgentSession { this.compactionHandler = new CompactionHandler({ workspaceId: this.workspaceId, historyService: this.historyService, - sessionDir: this.config.getSessionDir(this.workspaceId), + sessionDir: path.join(this.config.sessionsDir, this.workspaceId), telemetryService, emitter: this.emitter, onCompactionComplete: (metadata) => { @@ -1348,7 +1348,10 @@ export class AgentSession { } private getAutoRetryPreferencePath(): string { - return path.join(this.config.getSessionDir(this.workspaceId), AUTO_RETRY_PREFERENCE_FILE); + return path.join( + path.join(this.config.sessionsDir, this.workspaceId), + AUTO_RETRY_PREFERENCE_FILE + ); } setLegacyAutoRetryEnabledHint(enabled: boolean): void { @@ -3342,7 +3345,7 @@ export class AgentSession { this.workspaceId, // Session dir enables the cross-process pending-marker wait (r48): a // fork registered in another backend has no entry in this process. - this.config.getSessionDir(this.workspaceId) + path.join(this.config.sessionsDir, this.workspaceId) ); // Workspace removal disposes the session and cancels the summary writer // while this send is parked on the await above; every append between here @@ -7656,7 +7659,7 @@ export class AgentSession { // Host-side disk read (session dir), independent of workspace metadata/runtime. const completedReportsAttachment = await AttachmentService.generateCompletedReportsAttachment({ workspaceId: this.workspaceId, - sessionDir: this.config.getSessionDir(this.workspaceId), + sessionDir: path.join(this.config.sessionsDir, this.workspaceId), completedBeforeMs: context.reportsCompletedBeforeMs, }); @@ -8104,7 +8107,7 @@ export class AgentSession { */ private async loadExcludedItems(): Promise> { const exclusionsPath = path.join( - this.config.getSessionDir(this.workspaceId), + path.join(this.config.sessionsDir, this.workspaceId), "exclusions.json" ); try { @@ -8144,7 +8147,7 @@ export class AgentSession { return null; } - const todoPath = path.join(this.config.getSessionDir(this.workspaceId), "todos.json"); + const todoPath = path.join(path.join(this.config.sessionsDir, this.workspaceId), "todos.json"); try { const data = await readFile(todoPath, "utf-8"); diff --git a/src/node/services/agentStatusService.test.ts b/src/node/services/agentStatusService.test.ts index b07b4d072a..966bbed4b5 100644 --- a/src/node/services/agentStatusService.test.ts +++ b/src/node/services/agentStatusService.test.ts @@ -118,7 +118,7 @@ describe("AgentStatusService", () => { mockConfig = { loadConfigOrDefault: mock(() => projectsConfig), - getSessionDir: historyHandle.config.getSessionDir.bind(historyHandle.config), + sessionsDir: historyHandle.config.sessionsDir, } as unknown as Config; emitWorkspaceActivityMock = mock(() => undefined); diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 16577f80f1..8789817192 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -2579,7 +2579,7 @@ describe("AIService.streamMessage turn envelope", () => { await streamTurn(harness, workspaceId); expect(harness.startStreamCalls).toHaveLength(2); - const journal = new DurableEventJournal(harness.config.getSessionDir(workspaceId)); + const journal = new DurableEventJournal(path.join(harness.config.sessionsDir, workspaceId)); const events = await journal.read(); expect(events).toHaveLength(2); expect(new Set(events.map((event) => event.id)).size).toBe(2); @@ -2612,7 +2612,7 @@ describe("AIService.streamMessage turn envelope", () => { // A regular file where the session dir should be makes every journal write // fail (ENOTDIR); the turn must still stream. - const sessionDir = harness.config.getSessionDir(workspaceId); + const sessionDir = path.join(harness.config.sessionsDir, workspaceId); await fs.mkdir(path.dirname(sessionDir), { recursive: true }); await fs.writeFile(sessionDir, "not a directory", "utf-8"); diff --git a/src/node/services/aiService.ts b/src/node/services/aiService.ts index 94ece173d8..5ce958eca2 100644 --- a/src/node/services/aiService.ts +++ b/src/node/services/aiService.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { EventEmitter } from "events"; import * as fs from "fs/promises"; @@ -147,7 +148,9 @@ export class AIService extends EventEmitter { streamManager?: StreamManager, public readonly turnRequestBuilderBindings: TurnRequestBuilderBindings = {}, providersConfigStore?: ProvidersConfigStore, - private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) + private readonly secretsStore: Pick = new SecretsStore( + config.rootDir + ) ) { super(); // Increase max listeners to accommodate multiple concurrent workspace listeners @@ -457,7 +460,7 @@ export class AIService extends EventEmitter { try { const runStore = new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(metadata.parentWorkspaceId), + sessionDir: path.join(this.config.sessionsDir, metadata.parentWorkspaceId), }); const run = await runStore.getRun(workflowTask.runId); return run.agentOutputSchemaRequired !== true; @@ -485,7 +488,7 @@ export class AIService extends EventEmitter { * vars-snapshot writer (independent instances would corrupt seq ordering). */ private durableEventJournalFor(workspaceId: string): DurableEventJournal { - return sharedDurableEventJournal(this.config.getSessionDir(workspaceId)); + return sharedDurableEventJournal(path.join(this.config.sessionsDir, workspaceId)); } isMockModeEnabled(): boolean { @@ -959,7 +962,7 @@ export class AIService extends EventEmitter { async deleteWorkspace(workspaceId: string): Promise> { try { - const workspaceDir = this.config.getSessionDir(workspaceId); + const workspaceDir = path.join(this.config.sessionsDir, workspaceId); await fs.rm(workspaceDir, { recursive: true, force: true }); return Ok(undefined); } catch (error) { diff --git a/src/node/services/bashMonitorRegistryStore.test.ts b/src/node/services/bashMonitorRegistryStore.test.ts index c9ea0e2c2e..91f8911d6f 100644 --- a/src/node/services/bashMonitorRegistryStore.test.ts +++ b/src/node/services/bashMonitorRegistryStore.test.ts @@ -78,7 +78,7 @@ describe("BashMonitorRegistryStore", () => { const config = makeConfig(rootDir); const store = new BashMonitorRegistryStore(config); await store.upsert(armedPayload()); - const dir = path.join(config.getSessionDir("owner-1"), BASH_MONITOR_REGISTRY_DIR); + const dir = path.join(path.join(config.sessionsDir, "owner-1"), BASH_MONITOR_REGISTRY_DIR); await fsPromises.writeFile(path.join(dir, "bad.json"), "not json", "utf-8"); await fsPromises.writeFile( path.join(dir, "wrong-shape.json"), @@ -97,7 +97,7 @@ describe("BashMonitorRegistryStore", () => { await store.upsert(armedPayload({ workspaceId: "owner-a" })); await store.remove("owner-b", "proc-1"); // Session dir without a registry dir must be skipped, not crash the walk. - await fsPromises.mkdir(config.getSessionDir("owner-empty"), { recursive: true }); + await fsPromises.mkdir(path.join(config.sessionsDir, "owner-empty"), { recursive: true }); expect(await store.listOwnerWorkspaceIds()).toEqual({ ownerWorkspaceIds: ["owner-a"], @@ -110,7 +110,7 @@ describe("BashMonitorRegistryStore", () => { const store = new BashMonitorRegistryStore(config); await store.upsert(armedPayload({ workspaceId: "owner-good" })); // A plain file where the registry directory should be makes listAll reject with ENOTDIR. - const badSession = config.getSessionDir("owner-bad"); + const badSession = path.join(config.sessionsDir, "owner-bad"); await fsPromises.mkdir(badSession, { recursive: true }); await fsPromises.writeFile(path.join(badSession, BASH_MONITOR_REGISTRY_DIR), "not a dir"); diff --git a/src/node/services/bashMonitorRegistryStore.ts b/src/node/services/bashMonitorRegistryStore.ts index 055a9201e6..8c02474b8d 100644 --- a/src/node/services/bashMonitorRegistryStore.ts +++ b/src/node/services/bashMonitorRegistryStore.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { z } from "zod"; import assert from "@/common/utils/assert"; -import type { Config } from "@/node/config"; +import type { WorkspaceSessionLocator } from "@/node/config"; import type { MonitorArmedPayload } from "@/node/services/backgroundProcessManager"; import { truncateUtf8Prefix } from "@/node/services/bashMonitorWakeStore"; import { log } from "@/node/services/log"; @@ -60,14 +60,17 @@ function boundScript(script: string): string { export class BashMonitorRegistryStore { private readonly locks = new MutexMap(); - constructor(private readonly config: Pick) {} + constructor(private readonly config: Pick) {} private dir(ownerWorkspaceId: string): string { assert( ownerWorkspaceId.trim().length > 0, "BashMonitorRegistryStore requires ownerWorkspaceId" ); - return path.join(this.config.getSessionDir(ownerWorkspaceId), BASH_MONITOR_REGISTRY_DIR); + return path.join( + path.join(this.config.sessionsDir, ownerWorkspaceId), + BASH_MONITOR_REGISTRY_DIR + ); } private file(ownerWorkspaceId: string, processId: string): string { diff --git a/src/node/services/bashMonitorWakeStore.test.ts b/src/node/services/bashMonitorWakeStore.test.ts index e5e3ceb5f3..8169929a9d 100644 --- a/src/node/services/bashMonitorWakeStore.test.ts +++ b/src/node/services/bashMonitorWakeStore.test.ts @@ -3970,7 +3970,7 @@ describe("BashMonitorWakeStore", () => { const store = new BashMonitorWakeStore(config); await store.enqueueOrMergePending(payload()); await fsPromises.writeFile( - path.join(config.getSessionDir("owner-1"), "bash-monitor-wakes", "bad.json"), + path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes", "bad.json"), "not json", "utf-8" ); @@ -3982,7 +3982,7 @@ describe("BashMonitorWakeStore", () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); // Write a pre-kind record shape directly (what older builds persisted). - const dir = path.join(config.getSessionDir("owner-1"), "bash-monitor-wakes"); + const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-legacy.json"), @@ -4011,7 +4011,7 @@ describe("BashMonitorWakeStore", () => { test("legacy monitor-lost records without lostReason default to restart", async () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); - const dir = path.join(config.getSessionDir("owner-1"), "bash-monitor-wakes"); + const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-legacy-lost.json"), @@ -4042,7 +4042,7 @@ describe("BashMonitorWakeStore", () => { test("malformed lostReason values degrade to restart instead of dropping the record", async () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); - const dir = path.join(config.getSessionDir("owner-1"), "bash-monitor-wakes"); + const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-future-lost.json"), @@ -4075,7 +4075,7 @@ describe("BashMonitorWakeStore", () => { test("malformed failureMessage and partially unknown failedOperations degrade without dropping the record", async () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); - const dir = path.join(config.getSessionDir("owner-1"), "bash-monitor-wakes"); + const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-newer-lost.json"), @@ -4841,7 +4841,7 @@ describe("BashMonitorWakeStore", () => { payload({ lines: ["[monitor] process settled: exited (code 1)"] }) ); const file = path.join( - config.getSessionDir("owner-1"), + path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes", `${encodeURIComponent(record.processId)}.json` ); @@ -4867,7 +4867,7 @@ describe("BashMonitorWakeStore", () => { payload({ lines: ["[monitor] process settled: exited (code 1)"] }) ); const file = path.join( - config.getSessionDir("owner-1"), + path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes", `${encodeURIComponent(record.processId)}.json` ); @@ -4892,7 +4892,7 @@ describe("BashMonitorWakeStore", () => { }) ); const file = path.join( - config.getSessionDir("owner-1"), + path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes", `${encodeURIComponent(record.processId)}.json` ); diff --git a/src/node/services/bashMonitorWakeStore.ts b/src/node/services/bashMonitorWakeStore.ts index e22d60ac6b..4218ee48e9 100644 --- a/src/node/services/bashMonitorWakeStore.ts +++ b/src/node/services/bashMonitorWakeStore.ts @@ -8,7 +8,7 @@ import { z } from "zod"; import assert from "@/common/utils/assert"; import { BASH_MONITOR_WAKE_HEADINGS } from "@/common/utils/machineTurnPrompts"; import type { BashMonitorFailedOperation, MuxMessageMetadata } from "@/common/types/message"; -import type { Config } from "@/node/config"; +import type { WorkspaceSessionLocator } from "@/node/config"; import { log } from "@/node/services/log"; import { isErrnoWithCode } from "@/node/utils/fs"; import { MutexMap } from "@/node/utils/concurrency/mutexMap"; @@ -721,7 +721,7 @@ export class BashMonitorWakeStore { private readonly activeClearIds = new Set(); constructor( - private readonly config: Pick, + private readonly config: Pick, options?: { stagedClearRefreshIntervalMs?: number } ) { this.stagedClearRefreshIntervalMs = @@ -841,7 +841,7 @@ export class BashMonitorWakeStore { private dir(ownerWorkspaceId: string): string { assert(ownerWorkspaceId.trim().length > 0, "BashMonitorWakeStore requires ownerWorkspaceId"); - return path.join(this.config.getSessionDir(ownerWorkspaceId), BASH_MONITOR_WAKE_DIR); + return path.join(path.join(this.config.sessionsDir, ownerWorkspaceId), BASH_MONITOR_WAKE_DIR); } /** diff --git a/src/node/services/branchSummary.test.ts b/src/node/services/branchSummary.test.ts index 5a53b0d4f6..4de7143658 100644 --- a/src/node/services/branchSummary.test.ts +++ b/src/node/services/branchSummary.test.ts @@ -1182,7 +1182,7 @@ describe("branch summary placement on fork/truncate flows", () => { const ws = "ws-cross-process-marker"; const branchPoint = createMuxMessage("xp-1", "assistant", "branch point", { timestamp: 1 }); expect((await historyService.appendToHistory(ws, branchPoint)).success).toBe(true); - const sessionDir = config.getSessionDir(ws); + const sessionDir = path.join(config.sessionsDir, ws); // Gate the model so generation is provably in flight while the foreign // send checks the marker. diff --git a/src/node/services/codexOauthService.test.ts b/src/node/services/codexOauthService.test.ts index 7fa3cef43e..a8c1742f76 100644 --- a/src/node/services/codexOauthService.test.ts +++ b/src/node/services/codexOauthService.test.ts @@ -1,8 +1,9 @@ +import { ProvidersConfigStore } from "@/node/config"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import type { Result } from "@/common/types/result"; import { Ok } from "@/common/types/result"; -import type { ProvidersConfig, ProvidersConfigStore } from "@/node/config"; +import type { ProvidersConfig } from "@/node/config"; import type { ProviderService } from "@/node/services/providerService"; import type { WindowService } from "@/node/services/windowService"; import type { CodexOauthAuth } from "@/node/utils/codexOauthAuth"; diff --git a/src/node/services/devToolsService.test.ts b/src/node/services/devToolsService.test.ts index 6a18501d00..bfdc6d9ae5 100644 --- a/src/node/services/devToolsService.test.ts +++ b/src/node/services/devToolsService.test.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { describe, expect, it } from "bun:test"; import * as fs from "fs/promises"; -import * as path from "path"; import { Config } from "@/node/config"; import { DevToolsService } from "@/node/services/devToolsService"; import { workspaceRemovalTombstonePath } from "@/node/services/workspaceRemoval"; @@ -23,7 +23,7 @@ describe("DevToolsService removal gate (r64)", () => { workspaceId: liveId, startedAt: new Date().toISOString(), }); - const liveFile = path.join(config.getSessionDir(liveId), "devtools.jsonl"); + const liveFile = path.join(path.join(config.sessionsDir, liveId), "devtools.jsonl"); expect(await fs.readFile(liveFile, "utf8")).toContain("run-1"); // Removal-tombstoned workspace: with XUM_ALLOW_MULTIPLE_INSTANCES=1 a @@ -43,7 +43,7 @@ describe("DevToolsService removal gate (r64)", () => { workspaceId: removedId, startedAt: new Date().toISOString(), }); - const removedSessionDirExists = await fs.stat(config.getSessionDir(removedId)).then( + const removedSessionDirExists = await fs.stat(path.join(config.sessionsDir, removedId)).then( () => true, () => false ); diff --git a/src/node/services/devToolsService.ts b/src/node/services/devToolsService.ts index 8d6c4c919b..d3154771f7 100644 --- a/src/node/services/devToolsService.ts +++ b/src/node/services/devToolsService.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { EventEmitter } from "events"; import * as fs from "fs/promises"; -import * as path from "path"; import assert from "@/common/utils/assert"; import type { DevToolsEvent, @@ -411,7 +411,7 @@ export class DevToolsService extends EventEmitter { } private getSessionFilePath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), "devtools.jsonl"); + return path.join(path.join(this.config.sessionsDir, workspaceId), "devtools.jsonl"); } private getOrCreateWorkspaceData(workspaceId: string): WorkspaceData { @@ -651,7 +651,7 @@ export class DevToolsService extends EventEmitter { ): Promise { await withTargetMutationLock( this.config.rootDir, - this.config.getSessionDir(workspaceId), + path.join(this.config.sessionsDir, workspaceId), async () => { if (await isWorkspaceRemovalTombstoned(this.config.rootDir, workspaceId)) { log.debug("Skipping DevTools write for removed workspace", { workspaceId }); diff --git a/src/node/services/gitPatchArtifactService.ts b/src/node/services/gitPatchArtifactService.ts index ab977200d1..fc4967cf48 100644 --- a/src/node/services/gitPatchArtifactService.ts +++ b/src/node/services/gitPatchArtifactService.ts @@ -366,7 +366,7 @@ export class GitPatchArtifactService { await this.waitForGeneration(childWorkspaceId); } - const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); + const parentSessionDir = path.join(this.config.sessionsDir, parentWorkspaceId); // Write a pending marker before we attempt cleanup, so the reported task workspace isn't deleted // while we're still reading commits from it. @@ -546,7 +546,7 @@ export class GitPatchArtifactService { assert(parentWorkspaceId.length > 0, "generate: parentWorkspaceId must be non-empty"); assert(childWorkspaceId.length > 0, "generate: childWorkspaceId must be non-empty"); - const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); + const parentSessionDir = path.join(this.config.sessionsDir, parentWorkspaceId); const updateArtifact = async ( updater: Parameters[0]["updater"] diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 0ab1bbec8d..542933889c 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { CONTEXT_BOUNDARY_KINDS } from "@/common/constants/contextBoundary"; import { HistoryService } from "./historyService"; @@ -8,7 +9,6 @@ import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import assert from "node:assert"; import { createHash } from "node:crypto"; import * as fs from "fs/promises"; -import * as path from "path"; import { acquireProcessFileLock } from "@/node/utils/concurrency/fileLock"; import { historyWriteLockPath, @@ -30,7 +30,7 @@ async function writeHistoryLines( workspaceId: string, lines: string[] ): Promise { - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "chat.jsonl"), lines.join("\n") + "\n"); } @@ -137,7 +137,7 @@ describe("HistoryService", () => { const result = await service.appendToHistory(workspaceId, msg); expect(result.success).toBe(true); - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); const exists = await fs .access(workspaceDir) .then(() => true) @@ -213,7 +213,7 @@ describe("HistoryService", () => { it("should initialize sequence counter from max historySequence after restart", async () => { const workspaceId = "workspace-out-of-order-tail"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const messages = [ @@ -286,7 +286,7 @@ describe("HistoryService", () => { await service.appendToHistory(workspaceId, msg); - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); const chatPath = path.join(workspaceDir, "chat.jsonl"); const content = await fs.readFile(chatPath, "utf-8"); const persisted = JSON.parse(content.trim()) as { @@ -350,7 +350,7 @@ describe("HistoryService", () => { describe("appendManyToHistory", () => { it("terminates a torn crash tail so every batch row survives intact (r50)", async () => { const workspaceId = "workspace1"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); // A crash mid-write can leave chat.jsonl ending in an unterminated JSON // fragment. Without healing, the first batch row glues onto those bytes @@ -429,7 +429,7 @@ describe("HistoryService", () => { ); expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain("was removed"); - const sessionDirExists = await fs.stat(config.getSessionDir(workspaceId)).then( + const sessionDirExists = await fs.stat(path.join(config.sessionsDir, workspaceId)).then( () => true, () => false ); @@ -450,7 +450,7 @@ describe("HistoryService", () => { // live transaction back mid-flight — restoring the old archive between // the foreign writer's archive and chat writes, so discarded history // reappears with mismatched archive/chat state. - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); const archivePath = path.join(sessionDir, "chat-archive.jsonl"); const tombstonePath = `${archivePath}.truncate`; const markerPath = `${archivePath}.truncate.json`; @@ -511,7 +511,7 @@ describe("HistoryService", () => { expect(result.success).toBe(false); if (!result.success) expect(result.error).toContain("removed"); expect( - await fs.access(config.getSessionDir(workspaceId)).then( + await fs.access(path.join(config.sessionsDir, workspaceId)).then( () => true, () => false ) @@ -533,7 +533,7 @@ describe("HistoryService", () => { createMuxMessage("foreign-1", "assistant", "foreign row", { historySequence: 7 }) ); await fs.appendFile( - path.join(config.getSessionDir(workspaceId), "chat.jsonl"), + path.join(path.join(config.sessionsDir, workspaceId), "chat.jsonl"), foreignLine + "\n" ); // Without the in-lock counter refresh this batch would assign stale @@ -569,7 +569,7 @@ describe("HistoryService", () => { createMuxMessage("foreign-1", "assistant", "foreign row", { historySequence: 7 }) ); await fs.appendFile( - path.join(config.getSessionDir(workspaceId), "chat.jsonl"), + path.join(path.join(config.sessionsDir, workspaceId), "chat.jsonl"), foreignLine + "\n" ); @@ -1002,7 +1002,7 @@ describe("HistoryService", () => { expect(result.success).toBe(true); - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); const chatPath = path.join(workspaceDir, "chat.jsonl"); const exists = await fs .access(chatPath) @@ -1049,7 +1049,7 @@ describe("HistoryService", () => { describe("sequence number initialization", () => { it("should initialize sequence from existing history", async () => { const workspaceId = "workspace1"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); // Manually create history with specific sequences @@ -1079,7 +1079,7 @@ describe("HistoryService", () => { it("should ignore malformed persisted numeric sequences when initializing counters", async () => { const workspaceId = "workspace-with-malformed-sequences"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const validMessage = createMuxMessage("msg-valid", "user", "Hello", { historySequence: 3 }); @@ -1132,7 +1132,7 @@ describe("HistoryService", () => { workspaceId: string, opts: { preBoundaryCount: number; postBoundaryCount: number; epoch?: number } ) { - const workspaceDir = cfg.getSessionDir(workspaceId); + const workspaceDir = path.join(cfg.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const epoch = opts.epoch ?? 1; @@ -1186,7 +1186,7 @@ describe("HistoryService", () => { describe("getHistoryFromLatestBoundary", () => { it("should return full history when no boundary exists", async () => { const workspaceId = "ws-no-boundary"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const msg1 = createMuxMessage("msg1", "user", "Hello", { historySequence: 0 }); @@ -1237,7 +1237,7 @@ describe("HistoryService", () => { it("should find the latest boundary with multiple compaction epochs", async () => { const workspaceId = "ws-multi-epoch"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const lines: string[] = []; @@ -1314,7 +1314,7 @@ describe("HistoryService", () => { it("should skip malformed lines in boundary region", async () => { const workspaceId = "ws-malformed"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const boundary = createMuxMessage("boundary", "assistant", "Summary", { @@ -1347,7 +1347,7 @@ describe("HistoryService", () => { describe("getHistoryBoundaryWindow", () => { it("returns one older boundary window at a time and reports hasOlder", async () => { const workspaceId = "ws-boundary-window"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const lines: string[] = []; @@ -1418,7 +1418,7 @@ describe("HistoryService", () => { describe("getMessagesForCompactionEpoch", () => { it("returns evidence rows between the previous boundary and the new summary", async () => { const workspaceId = "ws-compaction-epoch"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const lines = [ @@ -1472,7 +1472,7 @@ describe("HistoryService", () => { it("deduplicates rotation replay rows across archive and active history", async () => { const workspaceId = "ws-compaction-epoch-rotation-replay"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const replayedPrefix = [ @@ -1813,7 +1813,7 @@ describe("HistoryService", () => { describe("multi-byte UTF-8 handling", () => { it("should correctly find boundary and read messages with non-ASCII content", async () => { const workspaceId = "ws-utf8"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); // Use multi-byte UTF-8 characters (emoji, CJK) in message content @@ -1877,7 +1877,7 @@ describe("HistoryService", () => { it("should handle messages where all content is multi-byte", async () => { const workspaceId = "ws-utf8-all"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const lines: string[] = []; @@ -1913,7 +1913,7 @@ describe("HistoryService", () => { it("should return false for empty file", async () => { const workspaceId = "ws-empty"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); await fs.writeFile(path.join(workspaceDir, "chat.jsonl"), ""); @@ -1923,7 +1923,7 @@ describe("HistoryService", () => { it("should return true when history exists", async () => { const workspaceId = "ws-has-history"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const msg = createMuxMessage("msg1", "user", "Hello", { historySequence: 0 }); @@ -2040,11 +2040,11 @@ describe("HistoryService", () => { } function chatPath(workspaceId: string): string { - return path.join(config.getSessionDir(workspaceId), "chat.jsonl"); + return path.join(path.join(config.sessionsDir, workspaceId), "chat.jsonl"); } function archivePath(workspaceId: string): string { - return path.join(config.getSessionDir(workspaceId), "chat-archive.jsonl"); + return path.join(path.join(config.sessionsDir, workspaceId), "chat-archive.jsonl"); } it("rotates the sealed prefix into the archive when a boundary is appended", async () => { @@ -2291,7 +2291,7 @@ describe("HistoryService", () => { await fs.rm(chatPath(wsId)); const newWsId = "ws-rotation-renamed"; - await fs.rename(config.getSessionDir(wsId), config.getSessionDir(newWsId)); + await fs.rename(path.join(config.sessionsDir, wsId), path.join(config.sessionsDir, newWsId)); // Fresh process: no cached counter for either workspace ID. const restarted = new HistoryService(config); @@ -2540,7 +2540,7 @@ describe("HistoryService", () => { chatLines: string[] | null, partial: MuxMessage ): Promise { - const ownerDir = config.getSessionDir(ownerId); + const ownerDir = path.join(config.sessionsDir, ownerId); const transcriptDir = path.join(ownerDir, "subagent-transcripts", taskId); const chatPath = path.join(transcriptDir, "chat.jsonl"); const partialPath = path.join(transcriptDir, "partial.json"); diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 4c4b69cfc9..3fb33bb2a6 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { createHash } from "node:crypto"; import * as fs from "fs/promises"; -import * as path from "path"; import writeFileAtomic from "write-file-atomic"; import assert from "node:assert"; import type { CompactionCompletionMetadata } from "@/common/types/compaction"; @@ -11,7 +11,7 @@ import { type MuxMessage, type MuxMetadata, } from "@/common/types/message"; -import type { Config } from "@/node/config"; +import type { WorkspaceSessionLocator } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { TaskService } from "@/node/services/taskService"; import { ensurePrivateDir, isErrnoWithCode } from "@/node/utils/fs"; @@ -216,9 +216,9 @@ export class HistoryService { // Shared file operation lock across all workspace file services // This prevents deadlocks when operations compose while touching the same workspace files. private readonly fileLocks = workspaceFileLocks; - private readonly config: Pick; + private readonly config: Pick; - constructor(config: Pick) { + constructor(config: Pick) { this.config = config; } @@ -237,7 +237,7 @@ export class HistoryService { entry: SubagentTranscriptArtifactIndexEntry; } | null> => { const artifacts = await readSubagentTranscriptArtifactsFile( - this.config.getSessionDir(workspaceId) + path.join(this.config.sessionsDir, workspaceId) ); const entry = artifacts.artifactsByChildTaskId[taskId] ?? null; return entry ? { workspaceId, entry } : null; @@ -286,7 +286,7 @@ export class HistoryService { // Pending artifacts still have a live task session, so read it directly while it exists. if (!resolved) { if (requestingWorkspaceId && isDescendant) { - const taskSessionDir = this.config.getSessionDir(taskId); + const taskSessionDir = path.join(this.config.sessionsDir, taskId); const messages = await this.readTranscriptFromPaths({ workspaceId: taskId, chatPath: path.join(taskSessionDir, CHAT_FILE_NAME), @@ -432,7 +432,7 @@ export class HistoryService { partialPath?: string; logLabel: string; }): Promise { - const workspaceSessionDir = this.config.getSessionDir(params.workspaceId); + const workspaceSessionDir = path.join(this.config.sessionsDir, params.workspaceId); // Refuse path traversal from a corrupted transcript index. if (params.chatPath && !isPathInsideDir(workspaceSessionDir, params.chatPath)) { throw new Error("Refusing to read transcript outside workspace session dir"); @@ -485,11 +485,11 @@ export class HistoryService { } private getChatHistoryPath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), this.CHAT_FILE); + return path.join(path.join(this.config.sessionsDir, workspaceId), this.CHAT_FILE); } private getChatArchivePath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), this.CHAT_ARCHIVE_FILE); + return path.join(path.join(this.config.sessionsDir, workspaceId), this.CHAT_ARCHIVE_FILE); } private getTruncateTransactionPath(workspaceId: string): string { @@ -754,7 +754,7 @@ export class HistoryService { } private getPartialPath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), this.PARTIAL_FILE); + return path.join(path.join(this.config.sessionsDir, workspaceId), this.PARTIAL_FILE); } // ── Reverse-read infrastructure ───────────────────────────────────────────── @@ -1301,7 +1301,7 @@ export class HistoryService { } try { - await ensurePrivateDir(this.config.getSessionDir(targetWorkspaceId)); + await ensurePrivateDir(path.join(this.config.sessionsDir, targetWorkspaceId)); for (const [targetPath, contents] of [ [this.getChatArchivePath(targetWorkspaceId), snapshot.data.archive], [this.getChatHistoryPath(targetWorkspaceId), snapshot.data.chat], @@ -1874,7 +1874,7 @@ export class HistoryService { if (await isWorkspaceRemovalTombstoned(this.config.rootDir, workspaceId)) { return Err(`workspace ${workspaceId} was removed; refusing partial write`); } - const workspaceDir = this.config.getSessionDir(workspaceId); + const workspaceDir = path.join(this.config.sessionsDir, workspaceId); await ensurePrivateDir(workspaceDir); const partialPath = this.getPartialPath(workspaceId); @@ -2083,7 +2083,7 @@ export class HistoryService { message: MuxMessage ): Promise> { try { - const workspaceDir = this.config.getSessionDir(workspaceId); + const workspaceDir = path.join(this.config.sessionsDir, workspaceId); await ensurePrivateDir(workspaceDir); const historyPath = this.getChatHistoryPath(workspaceId); @@ -2210,7 +2210,7 @@ export class HistoryService { workspaceId: string, operation: () => Promise ): Promise { - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); // Lock BEFORE any directory creation (r63): the lockfile lives outside // the session dir, and removal holds this same lock while it tombstones // and deletes — so a mutation serializes with removal instead of racing @@ -2329,7 +2329,7 @@ export class HistoryService { async () => { try { await this.refreshSequenceCounterUnderWriteLock(workspaceId); - const workspaceDir = this.config.getSessionDir(workspaceId); + const workspaceDir = path.join(this.config.sessionsDir, workspaceId); await ensurePrivateDir(workspaceDir); const historyPath = this.getChatHistoryPath(workspaceId); for (const message of messages) { @@ -2541,7 +2541,7 @@ export class HistoryService { // duplicate a foreign backend's sequences and let a later // updateHistory() replace an unrelated row. await this.refreshSequenceCounterUnderWriteLock(workspaceId); - await ensurePrivateDir(this.config.getSessionDir(workspaceId)); + await ensurePrivateDir(path.join(this.config.sessionsDir, workspaceId)); const historyPath = this.getChatHistoryPath(workspaceId); const messages = await this.readChatHistory(workspaceId); diff --git a/src/node/services/initStateManager.test.ts b/src/node/services/initStateManager.test.ts index 856772533f..28cb2dbe55 100644 --- a/src/node/services/initStateManager.test.ts +++ b/src/node/services/initStateManager.test.ts @@ -1,5 +1,5 @@ -import * as fs from "fs/promises"; import * as path from "path"; +import * as fs from "fs/promises"; import * as os from "os"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { Config } from "@/node/config"; @@ -254,7 +254,7 @@ describe("InitStateManager", () => { const workspaceId = "test-workspace"; manager.startInit(workspaceId, "/path/to/hook"); - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(sessionDir, { recursive: true }); let releaseLock: (() => void) | undefined; diff --git a/src/node/services/memoryConsolidationService.test.ts b/src/node/services/memoryConsolidationService.test.ts index 7b00aa00c6..4a66f49280 100644 --- a/src/node/services/memoryConsolidationService.test.ts +++ b/src/node/services/memoryConsolidationService.test.ts @@ -459,7 +459,7 @@ describe("MemoryConsolidationService", () => { // the sweep itself must request the ingest. expect(ingests).toEqual([{ workspaceId: "ws-dream" }]); const sidecar = await fsPromises.readFile( - path.join(fixture.config.getSessionDir("ws-dream"), "headless-usage.jsonl"), + path.join(path.join(fixture.config.sessionsDir, "ws-dream"), "headless-usage.jsonl"), "utf-8" ); expect(sidecar).toContain('"source":"memory_consolidation"'); diff --git a/src/node/services/memoryService.test.ts b/src/node/services/memoryService.test.ts index fb77d2a494..2a43d215af 100644 --- a/src/node/services/memoryService.test.ts +++ b/src/node/services/memoryService.test.ts @@ -140,7 +140,11 @@ describe("MemoryService", () => { ); expect(created.success).toBe(true); - const physical = path.join(fixture.config.getSessionDir("ws-42"), "memory", "scratch.md"); + const physical = path.join( + path.join(fixture.config.sessionsDir, "ws-42"), + "memory", + "scratch.md" + ); expect(await fsPromises.readFile(physical, "utf-8")).toBe("branch context"); }); @@ -594,7 +598,7 @@ describe("MemoryService", () => { ); expect(result).toEqual({ success: true, data: { sha256: sha("fresh") } }); const onDisk = await fsPromises.readFile( - path.join(fixture.config.getSessionDir("ws-ui"), "memory", "notes.md"), + path.join(path.join(fixture.config.sessionsDir, "ws-ui"), "memory", "notes.md"), "utf-8" ); expect(onDisk).toBe("fresh"); @@ -1150,7 +1154,7 @@ describe("MemoryService refinement journal", () => { const WORKSPACE_ID = "ws-1"; function sessionDirOf(fixture: MemoryFixture): string { - return fixture.config.getSessionDir(WORKSPACE_ID); + return path.join(fixture.config.sessionsDir, WORKSPACE_ID); } it("journals create with a delete inverse that round-trips", async () => { @@ -1491,7 +1495,7 @@ describe("MemoryService refinement journal", () => { it("does not fail the mutation when the journal is unavailable", async () => { using fixture = await createFixture(); // Occupy the session dir path with a FILE so journal appends cannot mkdir. - const brokenSessionDir = fixture.config.getSessionDir("ws-broken"); + const brokenSessionDir = path.join(fixture.config.sessionsDir, "ws-broken"); await fsPromises.mkdir(path.dirname(brokenSessionDir), { recursive: true }); await fsPromises.writeFile(brokenSessionDir, "not a directory", "utf-8"); diff --git a/src/node/services/memoryService.ts b/src/node/services/memoryService.ts index 62f15692bf..9b8d7b65ed 100644 --- a/src/node/services/memoryService.ts +++ b/src/node/services/memoryService.ts @@ -679,7 +679,7 @@ export class MemoryService extends EventEmitter { ); } return new LocalMemoryStore( - path.join(this.config.getSessionDir(ctx.workspaceId), "memory") + path.join(path.join(this.config.sessionsDir, ctx.workspaceId), "memory") ); } } @@ -752,7 +752,7 @@ export class MemoryService extends EventEmitter { return; } await appendRefinementEvent({ - sessionDir: this.config.getSessionDir(ctx.workspaceId), + sessionDir: path.join(this.config.sessionsDir, ctx.workspaceId), workspaceId: ctx.workspaceId, kind: "memory", action, diff --git a/src/node/services/partialService.test.ts b/src/node/services/partialService.test.ts index 5f09183e54..bc85948cdd 100644 --- a/src/node/services/partialService.test.ts +++ b/src/node/services/partialService.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; /* eslint-disable @typescript-eslint/unbound-method */ import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import type { HistoryService } from "./historyService"; @@ -6,7 +7,6 @@ import { createMuxMessage, type MuxMessage } from "@/common/types/message"; import { Ok } from "@/common/types/result"; import { createTestHistoryService } from "./testHistoryService"; import * as fs from "fs/promises"; -import * as path from "path"; describe("HistoryService partial persistence - Error Recovery", () => { let partialService: HistoryService; @@ -326,7 +326,7 @@ describe("HistoryService partial persistence - Legacy compatibility", () => { test("readPartial upgrades legacy cmuxMetadata", async () => { const workspaceId = "legacy-ws"; - const workspaceDir = config.getSessionDir(workspaceId); + const workspaceDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(workspaceDir, { recursive: true }); const partialMessage = createMuxMessage("partial-1", "assistant", "legacy", { diff --git a/src/node/services/projectService.test.ts b/src/node/services/projectService.test.ts index c7213260a2..812413fec5 100644 --- a/src/node/services/projectService.test.ts +++ b/src/node/services/projectService.test.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import * as fs from "fs/promises"; -import * as path from "path"; import * as os from "os"; import { execSync } from "child_process"; import { createHash } from "crypto"; @@ -2858,7 +2858,10 @@ exit 1 await config.editConfig(() => cfg); const legacyWorkspaceId = config.generateLegacyId(projectPath, archivedWorkspaceDir); - const metadataPath = path.join(config.getSessionDir(legacyWorkspaceId), "metadata.json"); + const metadataPath = path.join( + path.join(config.sessionsDir, legacyWorkspaceId), + "metadata.json" + ); await fs.mkdir(path.dirname(metadataPath), { recursive: true }); await fs.writeFile( metadataPath, diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index fdac6f72a6..286a1adbc2 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -1,3 +1,4 @@ +import { ProvidersConfigStore } from "@/node/config"; import { describe, expect, it, spyOn } from "bun:test"; import { generateText, jsonSchema, streamText, tool, type Tool } from "ai"; import { xai } from "@ai-sdk/xai"; @@ -5,7 +6,7 @@ import { writeFile } from "node:fs/promises"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { Config, ProvidersConfigStore } from "@/node/config"; +import { Config } from "@/node/config"; import type { MuxProviderOptions } from "@/common/types/providerOptions"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { CODEX_ENDPOINT, CODEX_OAUTH_ROUTED_HEADER } from "@/common/constants/codexOAuth"; diff --git a/src/node/services/providerService.test.ts b/src/node/services/providerService.test.ts index 5fae24cbd6..ce50c4d085 100644 --- a/src/node/services/providerService.test.ts +++ b/src/node/services/providerService.test.ts @@ -1,3 +1,4 @@ +import { FileLeaseManager, ProvidersConfigStore } from "@/node/config"; import { describe, expect, it, spyOn } from "bun:test"; import * as fs from "fs"; import * as fsPromises from "fs/promises"; @@ -7,7 +8,7 @@ import * as path from "path"; import { CUSTOM_PROVIDER_TYPES } from "@/common/utils/providers/customProviders"; import type { ProviderModelEntry } from "@/common/orpc/types"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; -import { Config, FileLeaseManager, ProvidersConfigStore } from "@/node/config"; +import { Config } from "@/node/config"; import { log } from "@/node/services/log"; import { PolicyService } from "@/node/services/policyService"; import { ProviderService } from "./providerService"; diff --git a/src/node/services/refinement/refineService.test.ts b/src/node/services/refinement/refineService.test.ts index 5d38a815a9..b542a08ae9 100644 --- a/src/node/services/refinement/refineService.test.ts +++ b/src/node/services/refinement/refineService.test.ts @@ -261,7 +261,7 @@ async function createFixture(options?: { return { muxHome, workspacePath, - sessionDir: config.getSessionDir(WORKSPACE_ID), + sessionDir: path.join(config.sessionsDir, WORKSPACE_ID), config, service, historyService, diff --git a/src/node/services/refinement/refineService.ts b/src/node/services/refinement/refineService.ts index 91108645db..85403777b8 100644 --- a/src/node/services/refinement/refineService.ts +++ b/src/node/services/refinement/refineService.ts @@ -1,3 +1,4 @@ +import * as path from "path"; /** * /refine orchestration (RLM track, phase r11): user-invokable trajectory * distillation with a paper trail. @@ -438,7 +439,7 @@ export class RefineService { ): Promise> { const workspace = this.config.findWorkspace(workspaceId); if (!workspace) return Err(`workspace not found: ${workspaceId}`); - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); // r32: the in-process inFlight map cannot see a second backend over the // same root (XUM_ALLOW_MULTIPLE_INSTANCES=1). Hold a cross-process lock // across staged-state load, recovery, execution, and progress persistence @@ -1029,7 +1030,7 @@ export class RefineService { projectPath, }; - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); // The pass only STAGES edits (see refineStaging.ts) — journal-baseline // bookkeeping happens at apply time. Skill-tool availability is still // resolved here so the model only sees agent_skill_write when a later diff --git a/src/node/services/refinement/refinementRollback.test.ts b/src/node/services/refinement/refinementRollback.test.ts index 8dfece6398..2c0cfa06d3 100644 --- a/src/node/services/refinement/refinementRollback.test.ts +++ b/src/node/services/refinement/refinementRollback.test.ts @@ -50,7 +50,7 @@ async function createFixture(): Promise { return { muxHome, checkout, - sessionDir: config.getSessionDir(WORKSPACE_ID), + sessionDir: path.join(config.sessionsDir, WORKSPACE_ID), service, ctx: { runtime: new LocalRuntime(checkout), diff --git a/src/node/services/replay/replayFixture.ts b/src/node/services/replay/replayFixture.ts index a63f156726..6f0c372aee 100644 --- a/src/node/services/replay/replayFixture.ts +++ b/src/node/services/replay/replayFixture.ts @@ -106,7 +106,7 @@ export function createReplayFixtureSessionContext( sessionDir, workspaceId, historyService: new HistoryService({ - getSessionDir: () => sessionDir, + sessionsDir: sessionDir, // Fixture writes take the history write lock under `/locks`; // lockfiles are transient (removed on release). rootDir: path.dirname(sessionDir), diff --git a/src/node/services/replay/replayVerify.fixture.test.ts b/src/node/services/replay/replayVerify.fixture.test.ts index c9f908edfd..d0919ed8ec 100644 --- a/src/node/services/replay/replayVerify.fixture.test.ts +++ b/src/node/services/replay/replayVerify.fixture.test.ts @@ -33,7 +33,7 @@ import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; async function readFixtureHistory(): Promise { const historyService = new HistoryService({ - getSessionDir: () => REPLAY_FIXTURE_DIR, + sessionsDir: REPLAY_FIXTURE_DIR, rootDir: path.dirname(REPLAY_FIXTURE_DIR), }); const result = await collectFullHistory(historyService, REPLAY_FIXTURE_WORKSPACE_ID); diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index c0a7c9f118..9b0a034264 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import * as fs from "fs"; import * as os from "os"; -import * as path from "path"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import { Config } from "@/node/config"; @@ -67,7 +67,7 @@ describe("ServiceContainer", () => { expect(ingestWorkspaceSpy).toHaveBeenCalledWith( workspaceId, - config.getSessionDir(workspaceId), + path.join(config.sessionsDir, workspaceId), { projectPath: primaryProjectPath, projectName: path.basename(primaryProjectPath), diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 32aff1c6ae..516e7368a4 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -492,7 +492,7 @@ export class ServiceContainer { // still attribute spend to the workspace's first real project path. const ingestWorkspaceAnalytics = (workspaceId: string) => { const workspaceLookup = this.config.findWorkspace(workspaceId); - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); const analyticsProjectPath = workspaceLookup?.attributionProjectPath ?? workspaceLookup?.projectPath; this.analyticsService.ingestWorkspace(workspaceId, sessionDir, { @@ -539,7 +539,7 @@ export class ServiceContainer { const parentProjectPath = parentLookup?.attributionProjectPath ?? parentLookup?.projectPath; reingestAfterClear = { workspaceId: parentWorkspaceId, - sessionDir: this.config.getSessionDir(parentWorkspaceId), + sessionDir: path.join(this.config.sessionsDir, parentWorkspaceId), meta: { projectPath: parentProjectPath, projectName: parentProjectPath ? path.basename(parentProjectPath) : undefined, diff --git a/src/node/services/sessionTimingService.test.ts b/src/node/services/sessionTimingService.test.ts index 100cd30f22..4d99defe12 100644 --- a/src/node/services/sessionTimingService.test.ts +++ b/src/node/services/sessionTimingService.test.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; import * as fs from "fs/promises"; import * as os from "os"; -import * as path from "path"; import { Config } from "@/node/config"; import { SessionTimingService } from "./sessionTimingService"; @@ -365,7 +365,7 @@ describe("SessionTimingService", () => { expect(result.session?.responseCount).toBe(1); const timingFilePath = path.join( - config.getSessionDir(parentWorkspaceId), + path.join(config.sessionsDir, parentWorkspaceId), "session-timing.json" ); const raw = await fs.readFile(timingFilePath, "utf-8"); @@ -381,7 +381,7 @@ describe("SessionTimingService", () => { emitCompletedStreamWithOneTool({ workspaceId, messageId, model, reasoningTokens: 2 }); await service.waitForIdle(workspaceId); - const filePath = path.join(config.getSessionDir(workspaceId), "session-timing.json"); + const filePath = path.join(path.join(config.sessionsDir, workspaceId), "session-timing.json"); const raw = await fs.readFile(filePath, "utf-8"); const parsed = JSON.parse(raw) as unknown; expect(typeof parsed).toBe("object"); @@ -441,7 +441,10 @@ describe("SessionTimingService", () => { await service.waitForIdle(workspaceId); - const timingFilePath = path.join(config.getSessionDir(workspaceId), "session-timing.json"); + const timingFilePath = path.join( + path.join(config.sessionsDir, workspaceId), + "session-timing.json" + ); const beforeRaw = await fs.readFile(timingFilePath, "utf-8"); const beforeSnapshot = await service.getSnapshot(workspaceId); diff --git a/src/node/services/sessionTimingService.ts b/src/node/services/sessionTimingService.ts index 9ec2382ae7..bcbc7c31f6 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import assert from "@/common/utils/assert"; import * as fs from "fs/promises"; -import * as path from "path"; import { EventEmitter } from "events"; import writeFileAtomic from "write-file-atomic"; import type { Config } from "@/node/config"; @@ -331,7 +331,7 @@ export class SessionTimingService { } private getFilePath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), SESSION_TIMING_FILE); + return path.join(path.join(this.config.sessionsDir, workspaceId), SESSION_TIMING_FILE); } private async readTimingFile(workspaceId: string): Promise { diff --git a/src/node/services/sessionUsageService.test.ts b/src/node/services/sessionUsageService.test.ts index 7c9fd8185c..019cfa5914 100644 --- a/src/node/services/sessionUsageService.test.ts +++ b/src/node/services/sessionUsageService.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import { SessionUsageService, type SessionUsageTokenStatsCacheV1 } from "./sessionUsageService"; import type { HistoryService } from "./historyService"; @@ -14,7 +15,6 @@ import { createTestHistoryService } from "./testHistoryService"; import { workspaceRemovalTombstonePath } from "./workspaceRemoval"; import { existsSync } from "fs"; import * as fs from "fs/promises"; -import * as path from "path"; function createUsage(input: number, output: number): ChatUsageDisplay { return { @@ -329,7 +329,10 @@ describe("SessionUsageService", () => { runtimeConfig: { type: "local" }, }); - const usagePath = path.join(config.getSessionDir(parentWorkspaceId), "session-usage.json"); + const usagePath = path.join( + path.join(config.sessionsDir, parentWorkspaceId), + "session-usage.json" + ); await fs.mkdir(path.dirname(usagePath), { recursive: true }); await fs.writeFile( usagePath, @@ -451,7 +454,7 @@ describe("SessionUsageService", () => { { analyticsSource: "memory_consolidation" } ); expect(recorded).toBeUndefined(); - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); expect( await fs.access(sessionDir).then( () => true, @@ -503,7 +506,10 @@ describe("SessionUsageService", () => { it("appends to the headless-usage sidecar only when an analyticsSource is given", async () => { const workspaceId = "test-workspace"; const model = "anthropic:claude-sonnet-4-20250514"; - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); // No analytics source means no sidecar entry. await service.recordHeadlessUsage(workspaceId, model, { @@ -531,7 +537,7 @@ describe("SessionUsageService", () => { it("still appends the analytics sidecar when the usage ledger is corrupt", async () => { const workspaceId = "test-workspace"; const model = "anthropic:claude-sonnet-4-20250514"; - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(sessionDir, { recursive: true }); // Corrupt ledger: readFile throws on bad JSON (non-ENOENT), which must // not block the sidecar — headless spend has no chat-row fallback. @@ -570,7 +576,7 @@ describe("SessionUsageService", () => { // (directory at the path) must abort BEFORE the ledger update. const workspaceId = "test-workspace"; const model = "anthropic:claude-sonnet-4-20250514"; - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(path.join(sessionDir, "headless-usage.jsonl"), { recursive: true }); const recorded = await service.recordHeadlessUsage( @@ -609,7 +615,10 @@ describe("SessionUsageService", () => { // and would leave cost_usd undefined. expect(recorded?.usage.input.cost_usd).toBeGreaterThan(0); - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); const record = JSON.parse((await fs.readFile(sidecarPath, "utf-8")).trim()) as Record< string, unknown @@ -643,7 +652,10 @@ describe("SessionUsageService", () => { expect(recorded?.model).toBe("anthropic:claude-sonnet-4-20250514"); expect(recorded?.usage.input.cost_usd).toBeGreaterThan(0); - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); const record = JSON.parse((await fs.readFile(sidecarPath, "utf-8")).trim()) as Record< string, unknown @@ -742,7 +754,7 @@ describe("SessionUsageService", () => { ); // Delete session-usage.json but keep session dir (appendToHistory created it) - const usagePath = path.join(config.getSessionDir(workspaceId), "session-usage.json"); + const usagePath = path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"); await fs.rm(usagePath, { force: true }); const result = await service.getSessionUsage(workspaceId); @@ -766,7 +778,7 @@ describe("SessionUsageService", () => { ); // Overwrite session-usage.json with corrupted JSON - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fs.writeFile(path.join(sessionDir, "session-usage.json"), "{ invalid json"); const result = await service.getSessionUsage(workspaceId); @@ -1040,7 +1052,7 @@ describe("SessionUsageService", () => { await historyService.appendToHistory(workspaceId, postCompactionMsg); // Delete session-usage.json to trigger rebuild from messages - const usagePath = path.join(config.getSessionDir(workspaceId), "session-usage.json"); + const usagePath = path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"); await fs.rm(usagePath, { force: true }); const result = await service.getSessionUsage(workspaceId); diff --git a/src/node/services/sessionUsageService.ts b/src/node/services/sessionUsageService.ts index d59b59d0a4..687f74572e 100644 --- a/src/node/services/sessionUsageService.ts +++ b/src/node/services/sessionUsageService.ts @@ -1,5 +1,5 @@ -import * as fs from "fs/promises"; import * as path from "path"; +import * as fs from "fs/promises"; import writeFileAtomic from "write-file-atomic"; import assert from "@/common/utils/assert"; import type { Config } from "@/node/config"; @@ -139,7 +139,7 @@ export class SessionUsageService { } private getFilePath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), this.SESSION_USAGE_FILE); + return path.join(path.join(this.config.sessionsDir, workspaceId), this.SESSION_USAGE_FILE); } private createEmptyUsageFile(): SessionUsageFile { @@ -256,7 +256,7 @@ export class SessionUsageService { // exists. return await withTargetMutationLock( this.config.rootDir, - this.config.getSessionDir(workspaceId), + path.join(this.config.sessionsDir, workspaceId), async () => { if (await isWorkspaceRemovalTombstoned(this.config.rootDir, workspaceId)) { log.debug("Skipping headless usage write for removed workspace", { workspaceId }); diff --git a/src/node/services/streamManager.test.ts b/src/node/services/streamManager.test.ts index 1e678a2dd4..ae2d4c3376 100644 --- a/src/node/services/streamManager.test.ts +++ b/src/node/services/streamManager.test.ts @@ -5598,7 +5598,10 @@ describe("StreamManager - aborted stream usage persistence", () => { ); await cleanupAborted.call(streamManager, workspaceId, streamInfo, "user"); - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); const records = (await fs.readFile(sidecarPath, "utf-8")) .trim() .split("\n") @@ -5636,7 +5639,10 @@ describe("StreamManager - aborted stream usage persistence", () => { "user" ); - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); expect(existsSync(sidecarPath)).toBe(false); // Usage still reaches history via the partial (asserted in the test above). const partial = await hs.readPartial(workspaceId); @@ -5679,7 +5685,10 @@ describe("StreamManager - aborted stream usage persistence", () => { errorType: "empty_output", }); - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); const record = JSON.parse((await fs.readFile(sidecarPath, "utf-8")).trim()) as Record< string, unknown @@ -5725,7 +5734,10 @@ describe("StreamManager - aborted stream usage persistence", () => { errorType: "stream_truncated", }); - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); const record = JSON.parse((await fs.readFile(sidecarPath, "utf-8")).trim()) as Record< string, unknown @@ -5768,7 +5780,10 @@ describe("StreamManager - aborted stream usage persistence", () => { const partial = await hs.readPartial(workspaceId); expect(partial).toBeNull(); // … but the billed usage still reaches analytics via the sidecar. - const sidecarPath = path.join(config.getSessionDir(workspaceId), "headless-usage.jsonl"); + const sidecarPath = path.join( + path.join(config.sessionsDir, workspaceId), + "headless-usage.jsonl" + ); const record = JSON.parse((await fs.readFile(sidecarPath, "utf-8")).trim()) as Record< string, unknown diff --git a/src/node/services/taskGitPatchEngine.ts b/src/node/services/taskGitPatchEngine.ts index e4961edc4a..fa0bb28d7b 100644 --- a/src/node/services/taskGitPatchEngine.ts +++ b/src/node/services/taskGitPatchEngine.ts @@ -405,7 +405,7 @@ async function findPatch(params: { const parent = parentById.get(currentDescendant); if (!parent) break; if (parent === params.workspaceId) { - const artifactSessionDir = configService.getSessionDir(childParentWorkspaceId); + const artifactSessionDir = path.join(configService.sessionsDir, childParentWorkspaceId); const artifact = await readSubagentGitPatchArtifact(artifactSessionDir, params.childTaskId); if (artifact) { return { @@ -457,7 +457,7 @@ async function findPatch(params: { visited.add(parent); - const parentSessionDir = configService.getSessionDir(parent); + const parentSessionDir = path.join(configService.sessionsDir, parent); const artifact = await readSubagentGitPatchArtifact(parentSessionDir, params.childTaskId); if (artifact) { return { diff --git a/src/node/services/taskHandleStore.test.ts b/src/node/services/taskHandleStore.test.ts index 60ce96fc39..e649dd7d2b 100644 --- a/src/node/services/taskHandleStore.test.ts +++ b/src/node/services/taskHandleStore.test.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { describe, expect, it, spyOn } from "bun:test"; import * as fsPromises from "fs/promises"; import * as os from "os"; -import * as path from "path"; import { Config } from "@/node/config"; import { TaskHandleStore, WORKSPACE_TURN_TASK_ID_PREFIX } from "@/node/services/taskHandleStore"; @@ -59,7 +59,7 @@ describe("TaskHandleStore", () => { createdWorkspace: false, disposableWorkspace: false, }); - await fsPromises.mkdir(config.getSessionDir("bad-owner"), { recursive: true }); + await fsPromises.mkdir(path.join(config.sessionsDir, "bad-owner"), { recursive: true }); const original = store.listWorkspaceTurns.bind(store); const listWorkspaceTurns = spyOn(store, "listWorkspaceTurns").mockImplementation( @@ -80,7 +80,7 @@ describe("TaskHandleStore", () => { it("rejects unsafe handle IDs before composing paths", async () => { const { config } = await createTempConfig("task-handle-store-unsafe-id"); const store = new TaskHandleStore(config); - const sessionDir = config.getSessionDir("owner"); + const sessionDir = path.join(config.sessionsDir, "owner"); await fsPromises.mkdir(sessionDir, { recursive: true }); await fsPromises.writeFile( path.join(sessionDir, "chat.json"), @@ -106,7 +106,7 @@ describe("TaskHandleStore", () => { it("self-heals corrupt handle records by ignoring them", async () => { const { config } = await createTempConfig("task-handle-store-corrupt"); const store = new TaskHandleStore(config); - const sessionDir = config.getSessionDir("owner"); + const sessionDir = path.join(config.sessionsDir, "owner"); await fsPromises.mkdir(path.join(sessionDir, "task-handles"), { recursive: true }); await fsPromises.writeFile( path.join(sessionDir, "task-handles", `${WORKSPACE_TURN_TASK_ID_PREFIX}bad.json`), diff --git a/src/node/services/taskHandleStore.ts b/src/node/services/taskHandleStore.ts index 5ac78f9163..dab9f56421 100644 --- a/src/node/services/taskHandleStore.ts +++ b/src/node/services/taskHandleStore.ts @@ -253,7 +253,7 @@ export class TaskHandleStore { private getOwnerHandleDir(ownerWorkspaceId: string): string { assert(ownerWorkspaceId.trim().length > 0, "ownerWorkspaceId must be non-empty"); - return path.join(this.config.getSessionDir(ownerWorkspaceId), TASK_HANDLES_DIR); + return path.join(path.join(this.config.sessionsDir, ownerWorkspaceId), TASK_HANDLES_DIR); } private getHandlePath(ownerWorkspaceId: string, handleId: string): string { diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 34257b0f17..04d2bdcd3e 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -1,6 +1,7 @@ +import { SecretsStore } from "@/node/config"; +import * as path from "path"; import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test"; import * as fsPromises from "fs/promises"; -import * as path from "path"; import * as os from "os"; import { execSync } from "node:child_process"; @@ -10,7 +11,6 @@ import { } from "@/constants/terminationTimeouts"; import { Config, - SecretsStore, type ProjectConfig, type ProjectsConfig, type Workspace as WorkspaceConfigEntry, @@ -149,7 +149,7 @@ function createWorkspaceTurnMetadata(projectPath: string): WorkspaceMetadata { async function workspaceGoalFileExists(config: Config, workspaceId: string): Promise { try { - await fsPromises.access(path.join(config.getSessionDir(workspaceId), "goal.json")); + await fsPromises.access(path.join(path.join(config.sessionsDir, workspaceId), "goal.json")); return true; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { @@ -2016,7 +2016,11 @@ describe("TaskService", () => { // workflow runs is no longer provable, so archive must refuse instead of proceeding // while a crash-recovered run might still resume into the archived workspace. await fsPromises.mkdir( - path.join(harness.config.getSessionDir("childworkspace"), "workflows", "wfr_corrupt"), + path.join( + path.join(harness.config.sessionsDir, "childworkspace"), + "workflows", + "wfr_corrupt" + ), { recursive: true } ); @@ -2038,7 +2042,7 @@ describe("TaskService", () => { test("workspace lifecycle refuses archive while the target owns an active workflow run", async () => { const harness = await createWorkspaceLifecycleHarness(); const runStore = new WorkflowRunStore({ - sessionDir: harness.config.getSessionDir("childworkspace"), + sessionDir: path.join(harness.config.sessionsDir, "childworkspace"), }); await runStore.createRun({ id: "wfr_child_active", @@ -3907,7 +3911,7 @@ describe("TaskService", () => { testTaskSettings() ); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); await upsertSubagentGitPatchArtifact({ workspaceId: parentId, workspaceSessionDir: parentSessionDir, @@ -5810,7 +5814,7 @@ describe("TaskService", () => { const config = await createTestConfig(rootDir); const { parentId } = await saveLocalParentWorkspace(config, rootDir); const runId = "wfr_terminal_notify"; - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: runId, workspaceId: parentId, @@ -10338,7 +10342,9 @@ describe("TaskService", () => { test("workspace-turn deferred recovery waits for active workflow blockers", async () => { const { config, parentId, taskService, historyService } = await startWorkspaceTurnForTest(); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir("childworkspace") }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, "childworkspace"), + }); await runStore.createRun({ id: "wfr_child_background", workspaceId: "childworkspace", @@ -10354,7 +10360,7 @@ describe("TaskService", () => { }); await runStore.appendStatus("wfr_child_background", "running", "2026-06-19T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir("childworkspace"), + workspaceSessionDir: path.join(config.sessionsDir, "childworkspace"), runId: "wfr_child_background", createdAtMs: Date.parse("2026-06-19T00:00:01.000Z"), }); @@ -12460,7 +12466,7 @@ describe("TaskService", () => { ); const queuedInitStatusPath = path.join( - config.getSessionDir(queued.data.taskId), + path.join(config.sessionsDir, queued.data.taskId), "init-status.json" ); await fsPromises.stat(queuedInitStatusPath).then( @@ -14020,7 +14026,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14037,7 +14045,7 @@ describe("TaskService", () => { await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: Date.now(), }); @@ -14229,7 +14237,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14245,7 +14255,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -14287,7 +14297,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14303,7 +14315,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -14348,7 +14360,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14364,7 +14378,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: 2_000, }); @@ -14407,7 +14421,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14484,7 +14500,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14565,7 +14583,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14581,7 +14601,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -14623,7 +14643,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14639,7 +14661,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -14702,7 +14724,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -14718,7 +14742,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -14773,7 +14797,9 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: rootWorkspaceId, @@ -19582,7 +19608,7 @@ describe("TaskService", () => { targetTaskId, createMuxMessage("seed-1", "user", "target brief", { historySequence: 1 }) ); - const targetSessionDir = config.getSessionDir(targetTaskId); + const targetSessionDir = path.join(config.sessionsDir, targetTaskId); await fsPromises.access(targetSessionDir); // Stall the send between its config snapshot and the payload append by @@ -20451,7 +20477,7 @@ describe("TaskService", () => { ); await upsertSubagentGitPatchArtifact({ workspaceId: parentWorkspaceId, - workspaceSessionDir: config.getSessionDir(parentWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, parentWorkspaceId), childTaskId, updater: () => ({ childTaskId, @@ -20522,7 +20548,7 @@ describe("TaskService", () => { ); await upsertSubagentGitPatchArtifact({ workspaceId: parentWorkspaceId, - workspaceSessionDir: config.getSessionDir(parentWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, parentWorkspaceId), childTaskId, updater: () => ({ childTaskId, @@ -20566,7 +20592,7 @@ describe("TaskService", () => { await upsertSubagentGitPatchArtifact({ workspaceId: parentWorkspaceId, - workspaceSessionDir: config.getSessionDir(parentWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, parentWorkspaceId), childTaskId, updater: (existing) => { assert(existing, "pending artifact must exist"); @@ -21493,7 +21519,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: rootWorkspaceId, - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), childTaskId: removedWorkflowChildTaskId, parentWorkspaceId: workflowTaskId, ancestorWorkspaceIds: [workflowTaskId, rootWorkspaceId], @@ -22032,7 +22058,7 @@ describe("TaskService", () => { ], testTaskSettings(10, 3) ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -22048,7 +22074,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "interrupted", "2026-05-29T00:00:01.000Z"); const innerRunStore = new WorkflowRunStore({ - sessionDir: config.getSessionDir(runningChildId), + sessionDir: path.join(config.sessionsDir, runningChildId), }); await innerRunStore.createRun({ id: innerWorkflowRunId, @@ -22113,7 +22139,9 @@ describe("TaskService", () => { ], testTaskSettings(10, 3) ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentTaskId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, parentTaskId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: parentTaskId, @@ -22478,7 +22506,7 @@ describe("TaskService", () => { ], testTaskSettings(10, 3) ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, rootId) }); await runStore.createRun({ id: workflowRunId, workspaceId: rootId, @@ -22558,7 +22586,7 @@ describe("TaskService", () => { ], testTaskSettings(10, 3) ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, rootId) }); await runStore.createRun({ id: workflowRunId, workspaceId: rootId, @@ -22654,7 +22682,7 @@ describe("TaskService", () => { ], testTaskSettings(1, 3) ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, rootId) }); await runStore.createRun({ id: workflowRunId, workspaceId: rootId, @@ -23234,7 +23262,7 @@ describe("TaskService", () => { const planFilePath = path.join(rootDir, "plans", "repo", "child-222.md"); await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23290,7 +23318,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23340,7 +23368,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23385,7 +23413,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23426,7 +23454,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23462,7 +23490,7 @@ describe("TaskService", () => { // no config entry, no report artifact — only the failure artifact remains. await upsertSubagentFailureArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: failedChildId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23471,7 +23499,7 @@ describe("TaskService", () => { }); await upsertSubagentFailureArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: workflowChildId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23541,7 +23569,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -23713,7 +23741,7 @@ describe("TaskService", () => { expect(sendMessage).not.toHaveBeenCalled(); expect( - await readSubagentReportArtifact(config.getSessionDir(rootWorkspaceId), parentTaskId) + await readSubagentReportArtifact(path.join(config.sessionsDir, rootWorkspaceId), parentTaskId) ).toBeNull(); const ws = findWorkspaceInConfig(config, parentTaskId); expect(ws?.taskStatus).toBe("running"); @@ -23887,7 +23915,9 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentTaskId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, parentTaskId), + }); await runStore.createRun({ id: workflowRunId, workspaceId: parentTaskId, @@ -23904,7 +23934,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-19T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(parentTaskId), + workspaceSessionDir: path.join(config.sessionsDir, parentTaskId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -24308,7 +24338,7 @@ describe("TaskService", () => { ); const reportArtifact = await readSubagentReportArtifact( - config.getSessionDir(parentId), + path.join(config.sessionsDir, parentId), childId ); expect(reportArtifact?.reportMarkdown).toBe("Hello from child"); @@ -24530,7 +24560,7 @@ describe("TaskService", () => { title: string; }>; }): Promise { - const parentSessionDir = params.config.getSessionDir(params.parentId); + const parentSessionDir = path.join(params.config.sessionsDir, params.parentId); for (const report of params.reports) { await upsertSubagentReportArtifact({ workspaceId: params.parentId, @@ -25301,7 +25331,7 @@ describe("TaskService", () => { const writeChildPartial = await partialService.writePartial(childId, childPartial); expect(writeChildPartial.success).toBe(true); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); const patchPath = getSubagentGitPatchMboxPath(parentSessionDir, childId, "repo"); const waiter = taskService.waitForAgentReport(childId, { @@ -25481,7 +25511,7 @@ describe("TaskService", () => { ); expect((await partialService.writePartial(childId, childPartial)).success).toBe(true); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); const secondaryPatchPath = getSubagentGitPatchMboxPath(parentSessionDir, childId, "project-b"); const waiter = taskService.waitForAgentReport(childId, { @@ -25635,7 +25665,7 @@ describe("TaskService", () => { const writeChildPartial = await partialService.writePartial(childId, childPartial); expect(writeChildPartial.success).toBe(true); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); const patchPath = getSubagentGitPatchMboxPath(parentSessionDir, childId, "repo"); const waiter = taskService.waitForAgentReport(childId, { @@ -26111,7 +26141,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(childId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, childId) }); await runStore.createRun({ id: workflowRunId, workspaceId: childId, @@ -26127,7 +26157,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(childId), + workspaceSessionDir: path.join(config.sessionsDir, childId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -26194,7 +26224,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(childId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, childId) }); await runStore.createRun({ id: workflowRunId, workspaceId: childId, @@ -26211,7 +26241,7 @@ describe("TaskService", () => { await runStore.appendStatus(workflowRunId, "running", "2026-06-04T00:00:01.000Z"); await runStore.appendStatus(workflowRunId, "completed", "2026-06-04T00:00:02.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(childId), + workspaceSessionDir: path.join(config.sessionsDir, childId), runId: workflowRunId, createdAtMs: 1_000, }); @@ -26284,7 +26314,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(childId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, childId) }); await runStore.createRun({ id: workflowRunId, workspaceId: childId, @@ -26526,7 +26556,7 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -26611,7 +26641,9 @@ describe("TaskService", () => { expect(sendMessage.mock.calls[0]?.[1]).toContain('"status": "in_progress"'); expect(sendMessage.mock.calls[1]?.[1]).toContain("Found a second issue."); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("running"); - expect(await readSubagentReportArtifact(config.getSessionDir(parentId), childId)).toBeNull(); + expect( + await readSubagentReportArtifact(path.join(config.sessionsDir, parentId), childId) + ).toBeNull(); }); // The scan case uses "plan" so it cannot pass via the exec recovery fallback. @@ -26868,7 +26900,10 @@ describe("TaskService", () => { expect(outputJson).not.toContain("fallback"); } - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.reportMarkdown).toBe( "## Final answer\n\nImplicit report content from the child." ); @@ -26921,7 +26956,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -26970,7 +27005,10 @@ describe("TaskService", () => { }); expect(sendMessage).not.toHaveBeenCalled(); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.reportMarkdown).toBe("## Final answer\n\nFinal prose after an earlier update."); expect(report?.structuredOutput).toEqual({ claims: ["persisted"] }); }); @@ -27009,7 +27047,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27052,7 +27090,10 @@ describe("TaskService", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("reported"); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.reportMarkdown).toBe( "## Final answer\n\nThis prose is the final workflow summary." ); @@ -27088,7 +27129,7 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27151,7 +27192,9 @@ describe("TaskService", () => { }); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("awaiting_report"); - expect(await readSubagentReportArtifact(config.getSessionDir(parentId), childId)).toBeNull(); + expect( + await readSubagentReportArtifact(path.join(config.sessionsDir, parentId), childId) + ).toBeNull(); expect(sendMessage).toHaveBeenCalledWith( childId, expect.stringContaining("First call agent_report"), @@ -27189,7 +27232,7 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27240,7 +27283,9 @@ describe("TaskService", () => { }); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("awaiting_report"); - expect(await readSubagentReportArtifact(config.getSessionDir(parentId), childId)).toBeNull(); + expect( + await readSubagentReportArtifact(path.join(config.sessionsDir, parentId), childId) + ).toBeNull(); expect(sendMessage).toHaveBeenCalledWith( childId, expect.stringContaining("First call agent_report"), @@ -27278,7 +27323,7 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27329,7 +27374,10 @@ describe("TaskService", () => { }); expect(sendMessage).not.toHaveBeenCalled(); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.reportMarkdown).toBe("Final summary after correcting structured output."); expect(report?.structuredOutput).toEqual({ claims: ["corrected"] }); }); @@ -27363,7 +27411,7 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27429,7 +27477,9 @@ describe("TaskService", () => { }); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("awaiting_report"); - expect(await readSubagentReportArtifact(config.getSessionDir(parentId), childId)).toBeNull(); + expect( + await readSubagentReportArtifact(path.join(config.sessionsDir, parentId), childId) + ).toBeNull(); expect(sendMessage).toHaveBeenCalledWith( childId, expect.stringContaining("First call agent_report"), @@ -27472,7 +27522,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27524,7 +27574,9 @@ describe("TaskService", () => { expect.objectContaining({ synthetic: true, agentInitiated: true }) ); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("awaiting_report"); - expect(await readSubagentReportArtifact(config.getSessionDir(parentId), childId)).toBeNull(); + expect( + await readSubagentReportArtifact(path.join(config.sessionsDir, parentId), childId) + ).toBeNull(); }); test("legacy workflow subagent with invalid old outputSchema can finalize markdown-only report", async () => { @@ -27556,7 +27608,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27603,7 +27655,10 @@ describe("TaskService", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("reported"); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.reportMarkdown).toBe("Legacy report"); expect(report?.structuredOutput).toBeUndefined(); }); @@ -27635,7 +27690,7 @@ describe("TaskService", () => { ], testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27715,7 +27770,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27763,7 +27818,10 @@ describe("TaskService", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("reported"); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.structuredOutput).toEqual({ code: "ABC", nullableNote: null, @@ -27800,7 +27858,7 @@ describe("TaskService", () => { testTaskSettings() ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -27846,7 +27904,10 @@ describe("TaskService", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(findWorkspaceInConfig(config, childId)?.taskStatus).toBe("reported"); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report?.reportMarkdown).toBe("Done"); expect(report?.structuredOutput).toEqual({ reportMarkdown: "Done", title: null }); }); @@ -28015,7 +28076,10 @@ describe("TaskService", () => { expect(toolPart?.output).toBeUndefined(); } - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report).toBeNull(); expect(remove).not.toHaveBeenCalled(); }); @@ -28185,7 +28249,7 @@ describe("TaskService", () => { ] as const) { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -28295,7 +28359,7 @@ describe("TaskService", () => { ); expect((await partialService.writePartial(parentId, parentPartial)).success).toBe(true); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); await upsertSubagentReportArtifact({ workspaceId: parentId, workspaceSessionDir: parentSessionDir, @@ -28413,7 +28477,7 @@ describe("TaskService", () => { ); expect((await partialService.writePartial(parentId, parentPartial)).success).toBe(true); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); await upsertSubagentReportArtifact({ workspaceId: parentId, workspaceSessionDir: parentSessionDir, @@ -28555,7 +28619,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childOneId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -28688,7 +28752,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childOneId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -28775,7 +28839,7 @@ describe("TaskService", () => { ).toBe(true); await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childOneId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -28860,7 +28924,7 @@ describe("TaskService", () => { ).toBe(true); await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childOneId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -28954,7 +29018,7 @@ describe("TaskService", () => { await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childOneId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -29079,7 +29143,7 @@ describe("TaskService", () => { ); expect((await partialService.writePartial(parentId, parentPartial)).success).toBe(true); - const parentSessionDir = config.getSessionDir(parentId); + const parentSessionDir = path.join(config.sessionsDir, parentId); await upsertSubagentReportArtifact({ workspaceId: parentId, workspaceSessionDir: parentSessionDir, @@ -29650,7 +29714,7 @@ describe("TaskService", () => { }); const parentId = findWorkspaceInConfig(config, childId)?.parentWorkspaceId; assert(parentId, "workflow-owned plan test requires a parent workspace id"); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(parentId) }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, parentId) }); await runStore.createRun({ id: workflowRunId, workspaceId: parentId, @@ -29886,7 +29950,10 @@ describe("TaskService", () => { expect.objectContaining({ synthetic: true, agentInitiated: true }) ); - const report = await readSubagentReportArtifact(config.getSessionDir(parentId), childId); + const report = await readSubagentReportArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(report).toBeNull(); const postCfg = config.loadConfigOrDefault(); @@ -30036,7 +30103,10 @@ describe("TaskService", () => { expect(childWorkspace?.taskLaunchError).toBe(refusalMessage); // Durable failure artifact persisted in the parent's session dir. - const failure = await readSubagentFailureArtifact(config.getSessionDir(parentId), childId); + const failure = await readSubagentFailureArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(failure).not.toBeNull(); expect(failure?.errorType).toBe("model_refusal"); expect(failure?.errorMessage).toBe(refusalMessage); @@ -30512,7 +30582,10 @@ describe("TaskService", () => { expect(childWorkspace?.taskLaunchError).toContain("empty_output"); // The terminal failure is durable: artifact carries the discriminated errorType. - const failure = await readSubagentFailureArtifact(config.getSessionDir(parentId), childId); + const failure = await readSubagentFailureArtifact( + path.join(config.sessionsDir, parentId), + childId + ); expect(failure?.errorType).toBe("task_recovery_limit"); // Waiters observe the same descriptive failure instead of timing out. @@ -30769,7 +30842,7 @@ describe("TaskService", () => { // monotonicity: a completed report must win over the failure. await upsertSubagentReportArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -30779,7 +30852,7 @@ describe("TaskService", () => { }); await upsertSubagentFailureArtifact({ workspaceId: parentId, - workspaceSessionDir: config.getSessionDir(parentId), + workspaceSessionDir: path.join(config.sessionsDir, parentId), childTaskId: childId, parentWorkspaceId: parentId, ancestorWorkspaceIds: [parentId], @@ -31576,7 +31649,9 @@ describe("TaskService", () => { taskSettings: { maxParallelAgentTasks: 3, maxTaskNestingDepth: 3 }, })); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(rootWorkspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, rootWorkspaceId), + }); await runStore.createRun({ id: firstRunId, workspaceId: rootWorkspaceId, @@ -31592,7 +31667,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(firstRunId, "running", "2026-06-04T00:00:01.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: firstRunId, createdAtMs: 1_000, }); @@ -31637,7 +31712,7 @@ describe("TaskService", () => { }); await runStore.appendStatus(secondRunId, "running", "2026-06-04T00:00:04.000Z"); await recordAgentWorkflowRunReference({ - workspaceSessionDir: config.getSessionDir(rootWorkspaceId), + workspaceSessionDir: path.join(config.sessionsDir, rootWorkspaceId), runId: secondRunId, createdAtMs: 3_000, }); diff --git a/src/node/services/taskService.ts b/src/node/services/taskService.ts index 152263db6a..3bc6f72bd9 100644 --- a/src/node/services/taskService.ts +++ b/src/node/services/taskService.ts @@ -1783,7 +1783,9 @@ export class TaskService implements AgentTaskIntegration { } const runIds = new Set(); - const references = await readAgentWorkflowRunReferences(this.config.getSessionDir(workspaceId)); + const references = await readAgentWorkflowRunReferences( + path.join(this.config.sessionsDir, workspaceId) + ); for (const reference of references) { // If the latest user/reset supersession has no durable timestamp, fail safe: only trust // workflow provenance re-established by current/post-supersession assistant output below. @@ -1828,7 +1830,9 @@ export class TaskService implements AgentTaskIntegration { try { const referencedRunIdSet = new Set(referencedWorkflowRunIds); - const runStore = new WorkflowRunStore({ sessionDir: this.config.getSessionDir(workspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(this.config.sessionsDir, workspaceId), + }); const runs = await runStore.listRuns(); return runs .filter( @@ -1861,7 +1865,9 @@ export class TaskService implements AgentTaskIntegration { try { const referencedRunIdSet = new Set(referencedWorkflowRunIds); - const runStore = new WorkflowRunStore({ sessionDir: this.config.getSessionDir(workspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(this.config.sessionsDir, workspaceId), + }); const runs = await runStore.listRuns(); const blockingRunIds: string[] = []; for (const run of runs) { @@ -1956,7 +1962,7 @@ export class TaskService implements AgentTaskIntegration { try { const runStore = new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(parentWorkspaceId), + sessionDir: path.join(this.config.sessionsDir, parentWorkspaceId), }); const run = await runStore.getRun(workflowTask.runId); if (run.workspaceId !== parentWorkspaceId) { @@ -3615,7 +3621,7 @@ export class TaskService implements AgentTaskIntegration { } try { - const sessionDir = this.config.getSessionDir(taskId); + const sessionDir = path.join(this.config.sessionsDir, taskId); await fsPromises.rm(sessionDir, { recursive: true, force: true }); } catch (error: unknown) { log.error("Task launch cleanup: failed to remove session directory", { @@ -7380,7 +7386,7 @@ export class TaskService implements AgentTaskIntegration { } try { - const sessionDir = this.config.getSessionDir(taskId); + const sessionDir = path.join(this.config.sessionsDir, taskId); await fsPromises.rm(sessionDir, { recursive: true, force: true }); } catch (error: unknown) { log.error("Task.create rollback: failed to remove session directory", { @@ -7594,7 +7600,7 @@ export class TaskService implements AgentTaskIntegration { continue; } const runStore = new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(workspace.id), + sessionDir: path.join(this.config.sessionsDir, workspace.id), }); let runs: Awaited>; try { @@ -7926,7 +7932,7 @@ export class TaskService implements AgentTaskIntegration { assert(ownerWorkspaceId.length > 0, "buildWorkflowTerminalPrompt requires ownerWorkspaceId"); assert(runId.length > 0, "buildWorkflowTerminalPrompt requires runId"); const runStore = new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(ownerWorkspaceId), + sessionDir: path.join(this.config.sessionsDir, ownerWorkspaceId), }); let run: Awaited>; try { @@ -7995,7 +8001,7 @@ export class TaskService implements AgentTaskIntegration { const workspaceTurnMuxMetadata = await this.getActiveWorkspaceTurnMuxMetadataForWorkspace(ownerWorkspaceId); - const sessionDir = this.config.getSessionDir(ownerWorkspaceId); + const sessionDir = path.join(this.config.sessionsDir, ownerWorkspaceId); for (const notification of notifications) { if (existingTaskIds.has(notification.sourceId)) { const existingMessage = existingReportMessages.get(notification.sourceId); @@ -10117,7 +10123,7 @@ export class TaskService implements AgentTaskIntegration { return null; } - const sessionDir = this.config.getSessionDir(requestingWorkspaceId); + const sessionDir = path.join(this.config.sessionsDir, requestingWorkspaceId); const artifact = await readSubagentReportArtifact(sessionDir, taskId); if (!artifact) { return null; @@ -10171,7 +10177,7 @@ export class TaskService implements AgentTaskIntegration { return null; } - const sessionDir = this.config.getSessionDir(requestingWorkspaceId); + const sessionDir = path.join(this.config.sessionsDir, requestingWorkspaceId); const failure = await readSubagentFailureArtifact(sessionDir, taskId); return failure ? new Error(failure.errorMessage) : null; }; @@ -11913,7 +11919,7 @@ export class TaskService implements AgentTaskIntegration { const parentWorkspaceId = entry.workspace.parentWorkspaceId; if (parentWorkspaceId) { const patchArtifact = await readSubagentGitPatchArtifact( - this.config.getSessionDir(parentWorkspaceId), + path.join(this.config.sessionsDir, parentWorkspaceId), taskId ); if (patchArtifact?.status === "pending") { @@ -11942,7 +11948,7 @@ export class TaskService implements AgentTaskIntegration { private removedAgentTaskTombstonePath(ownerWorkspaceId: string, taskId: string): string { return path.join( - this.config.getSessionDir(ownerWorkspaceId), + path.join(this.config.sessionsDir, ownerWorkspaceId), REMOVED_AGENT_TASKS_DIR, `${encodeURIComponent(taskId)}.json` ); @@ -12124,7 +12130,7 @@ export class TaskService implements AgentTaskIntegration { // BOTH: a background-failed child that was cleaned up or lost to a restart // must stay in scope for task_await so waitForAgentReport can surface the // persisted typed failure instead of degrading to invalid_scope/not_found. - const sessionDir = this.config.getSessionDir(ancestorWorkspaceId); + const sessionDir = path.join(this.config.sessionsDir, ancestorWorkspaceId); const [reports, failures] = await Promise.all([ readSubagentReportArtifactsFile(sessionDir), readSubagentFailureArtifactsFile(sessionDir), @@ -12214,7 +12220,7 @@ export class TaskService implements AgentTaskIntegration { return false; } - const sessionDir = this.config.getSessionDir(ancestorWorkspaceId); + const sessionDir = path.join(this.config.sessionsDir, ancestorWorkspaceId); const persisted = await readSubagentReportArtifactsFile(sessionDir); const entry = persisted.artifactsByChildTaskId[taskId]; if (entry != null) { @@ -12276,7 +12282,7 @@ export class TaskService implements AgentTaskIntegration { return true; } - const sessionDir = this.config.getSessionDir(ancestorWorkspaceId); + const sessionDir = path.join(this.config.sessionsDir, ancestorWorkspaceId); const [reports, failures] = await Promise.all([ readSubagentReportArtifactsFile(sessionDir), readSubagentFailureArtifactsFile(sessionDir), @@ -12780,7 +12786,7 @@ export class TaskService implements AgentTaskIntegration { return []; } const runStore = new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(workspaceId), + sessionDir: path.join(this.config.sessionsDir, workspaceId), }); const blocking: string[] = []; for (const runId of runIds) { @@ -12823,7 +12829,9 @@ export class TaskService implements AgentTaskIntegration { workspaceId.length > 0, "listActiveWorkflowRunIdsForWorkspaceStrict requires workspaceId" ); - const runStore = new WorkflowRunStore({ sessionDir: this.config.getSessionDir(workspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(this.config.sessionsDir, workspaceId), + }); const runs = await runStore.listRunsForActivityScan(); return runs .filter( @@ -15100,7 +15108,7 @@ export class TaskService implements AgentTaskIntegration { try { await upsertSubagentFailureArtifact({ workspaceId: ancestorWorkspaceId, - workspaceSessionDir: this.config.getSessionDir(ancestorWorkspaceId), + workspaceSessionDir: path.join(this.config.sessionsDir, ancestorWorkspaceId), childTaskId: workspaceId, parentWorkspaceId, ancestorWorkspaceIds, @@ -15838,7 +15846,7 @@ export class TaskService implements AgentTaskIntegration { } } - const parentSessionDir = this.config.getSessionDir(params.parentWorkspaceId); + const parentSessionDir = path.join(this.config.sessionsDir, params.parentWorkspaceId); for (const sibling of siblings) { if ( parentTaskToolState.referencedTaskIds.has(sibling.taskId) || @@ -15957,7 +15965,7 @@ export class TaskService implements AgentTaskIntegration { try { const runStore = new WorkflowRunStore({ - sessionDir: this.config.getSessionDir(parentWorkspaceId), + sessionDir: path.join(this.config.sessionsDir, parentWorkspaceId), }); const run = await runStore.getRun(workflowTask.runId); return run.agentOutputSchemaRequired !== true; @@ -16128,7 +16136,7 @@ export class TaskService implements AgentTaskIntegration { const persistedAtMs = Date.now(); for (const ancestorWorkspaceId of ancestorWorkspaceIds) { try { - const ancestorSessionDir = this.config.getSessionDir(ancestorWorkspaceId); + const ancestorSessionDir = path.join(this.config.sessionsDir, ancestorWorkspaceId); await upsertSubagentReportArtifact({ workspaceId: ancestorWorkspaceId, workspaceSessionDir: ancestorSessionDir, @@ -16623,7 +16631,7 @@ export class TaskService implements AgentTaskIntegration { // Best-of creation can fail or be interrupted after only some candidates are spawned. // When recovering an interrupted parent stream, finalize against the siblings that // actually exist so the parent task tool call does not stay pending forever. - const parentSessionDir = this.config.getSessionDir(params.parentWorkspaceId); + const parentSessionDir = path.join(this.config.sessionsDir, params.parentWorkspaceId); const reports: Array<{ taskId: string; reportMarkdown: string; @@ -17115,7 +17123,7 @@ export class TaskService implements AgentTaskIntegration { return { ok: false, reason: "has_child_tasks" }; } - const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); + const parentSessionDir = path.join(this.config.sessionsDir, parentWorkspaceId); const patchArtifact = await readSubagentGitPatchArtifact(parentSessionDir, workspaceId); if (patchArtifact?.status === "pending") { log.debug("cleanupReportedLeafTask: deferring auto-delete; patch artifact pending", { diff --git a/src/node/services/terminalAttentionStore.test.ts b/src/node/services/terminalAttentionStore.test.ts index 9009e4be9b..d3436eb3c6 100644 --- a/src/node/services/terminalAttentionStore.test.ts +++ b/src/node/services/terminalAttentionStore.test.ts @@ -41,7 +41,7 @@ describe("TerminalAttentionStore", () => { const persisted = JSON.parse( await fsPromises.readFile( path.join( - makeConfig(rootDir).getSessionDir("owner-1"), + path.join(makeConfig(rootDir).sessionsDir, "owner-1"), TERMINAL_ATTENTION_DIR, `${encodeURIComponent("workspace_turn:wst_abc")}.json` ), @@ -59,7 +59,7 @@ describe("TerminalAttentionStore", () => { test("loads pending notifications written with legacy derived fields", async () => { const config = makeConfig(rootDir); - const dir = path.join(config.getSessionDir("owner-1"), TERMINAL_ATTENTION_DIR); + const dir = path.join(path.join(config.sessionsDir, "owner-1"), TERMINAL_ATTENTION_DIR); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, `${encodeURIComponent("agent_task:task-1")}.json`), diff --git a/src/node/services/terminalAttentionStore.ts b/src/node/services/terminalAttentionStore.ts index 3370fa8081..661a4acfab 100644 --- a/src/node/services/terminalAttentionStore.ts +++ b/src/node/services/terminalAttentionStore.ts @@ -5,7 +5,7 @@ import * as path from "node:path"; import { z } from "zod"; import assert from "@/common/utils/assert"; -import type { Config } from "@/node/config"; +import type { WorkspaceSessionLocator } from "@/node/config"; import { log } from "@/node/services/log"; import { isErrnoWithCode } from "@/node/utils/fs"; @@ -78,11 +78,11 @@ const TerminalAttentionNotificationSchema = z.object({ * by skipping malformed files at read time. */ export class TerminalAttentionStore { - constructor(private readonly config: Pick) {} + constructor(private readonly config: Pick) {} private dir(ownerWorkspaceId: string): string { assert(ownerWorkspaceId.trim().length > 0, "TerminalAttentionStore requires ownerWorkspaceId"); - return path.join(this.config.getSessionDir(ownerWorkspaceId), TERMINAL_ATTENTION_DIR); + return path.join(path.join(this.config.sessionsDir, ownerWorkspaceId), TERMINAL_ATTENTION_DIR); } /** Stable id keyed by source and optional execution generation for per-assignment idempotency. */ diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index dba317cedd..177af4000d 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -13,9 +13,10 @@ import * as fs from "fs/promises"; const NATIVE_TERMINAL_SESSIONS_DIR = `/tmp/xum-test-native-terminal-sessions-${process.pid}-${Date.now()}`; const getEffectiveSecretsMock = mock(() => [{ key: "TEST_SECRET", value: "secret-value" }]); -const mockSecretsStore = { - getEffectiveSecrets: getEffectiveSecretsMock, -} as unknown as SecretsStore; +const mockSecretsStore = { getEffectiveSecrets: getEffectiveSecretsMock } as Pick< + SecretsStore, + "getEffectiveSecrets" +>; // Mock dependencies const mockConfig = { diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index e89d53bd77..2b2e2d53c1 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { EventEmitter } from "events"; import * as fs from "fs"; -import * as path from "path"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { isWorkspaceArchived } from "@/common/utils/archive"; import { isErrnoWithCode } from "@/node/utils/fs"; @@ -125,7 +125,7 @@ export class TerminalService { private readonly pendingNativeTerminalOpens = new Map(); private nativeTerminalMarkerPath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), "native-terminal-opened"); + return path.join(path.join(this.config.sessionsDir, workspaceId), "native-terminal-opened"); } /** @@ -195,7 +195,9 @@ export class TerminalService { constructor( config: Config, ptyService: PTYService, - private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) + private readonly secretsStore: Pick = new SecretsStore( + config.rootDir + ) ) { this.config = config; this.ptyService = ptyService; diff --git a/src/node/services/timelineService.test.ts b/src/node/services/timelineService.test.ts index 6c32f2f77a..7e28b81abe 100644 --- a/src/node/services/timelineService.test.ts +++ b/src/node/services/timelineService.test.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import { existsSync } from "fs"; import * as fs from "fs/promises"; -import * as path from "path"; import { TIMELINE_FILE_NAME } from "@/common/constants/paths"; import { TIMELINE_TEXT_MAX_LENGTH, @@ -60,7 +60,7 @@ describe("TimelineService", () => { }); function timelinePath(workspaceId = WORKSPACE_ID): string { - return path.join(config.getSessionDir(workspaceId), TIMELINE_FILE_NAME); + return path.join(path.join(config.sessionsDir, workspaceId), TIMELINE_FILE_NAME); } test("continues monotonic sequences after service restart", async () => { @@ -126,7 +126,7 @@ describe("TimelineService", () => { kind: "future.event.kind", source: { system: "agent" }, }; - await fs.mkdir(config.getSessionDir(WORKSPACE_ID), { recursive: true }); + await fs.mkdir(path.join(config.sessionsDir, WORKSPACE_ID), { recursive: true }); await fs.writeFile(timelinePath(), `${JSON.stringify(unknownEvent)}\n`, "utf-8"); const page = await service.list(WORKSPACE_ID, {}); @@ -144,7 +144,7 @@ describe("TimelineService", () => { source: { system: "chat" }, data: { toolName: "bash", durationMs: 40, digest: "git status" }, }; - await fs.mkdir(config.getSessionDir(WORKSPACE_ID), { recursive: true }); + await fs.mkdir(path.join(config.sessionsDir, WORKSPACE_ID), { recursive: true }); await fs.writeFile(timelinePath(), `${JSON.stringify(retired)}\n`, "utf-8"); service.record(WORKSPACE_ID, draft("after-retired")); @@ -164,7 +164,7 @@ describe("TimelineService", () => { source: { system: "agent" }, data: { description: "Landed the slice", unknownFutureField: "keep me readable" }, }; - await fs.mkdir(config.getSessionDir(WORKSPACE_ID), { recursive: true }); + await fs.mkdir(path.join(config.sessionsDir, WORKSPACE_ID), { recursive: true }); await fs.writeFile(timelinePath(), `${JSON.stringify(forwardCompatible)}\n`, "utf-8"); const page = await service.list(WORKSPACE_ID, {}); @@ -229,7 +229,7 @@ describe("TimelineService", () => { anchor: { messageId: "message-1", stepId: "future-step" }, status: "queued", }; - await fs.mkdir(config.getSessionDir(WORKSPACE_ID), { recursive: true }); + await fs.mkdir(path.join(config.sessionsDir, WORKSPACE_ID), { recursive: true }); await fs.writeFile(timelinePath(), `${JSON.stringify(forwardCompatible)}\n`, "utf-8"); const page = await service.list(WORKSPACE_ID, {}); @@ -355,12 +355,12 @@ describe("TimelineService", () => { test("drops records for a closed workspace so a late append cannot recreate its session dir", async () => { service.record(WORKSPACE_ID, draft("before-close")); await service.closeWorkspace(WORKSPACE_ID); - await fs.rm(config.getSessionDir(WORKSPACE_ID), { recursive: true, force: true }); + await fs.rm(path.join(config.sessionsDir, WORKSPACE_ID), { recursive: true, force: true }); service.record(WORKSPACE_ID, draft("after-close")); await service.flush(); - expect(existsSync(config.getSessionDir(WORKSPACE_ID))).toBe(false); + expect(existsSync(path.join(config.sessionsDir, WORKSPACE_ID))).toBe(false); }); test("keeps a new event readable after an interrupted write left an unterminated line", async () => { @@ -424,12 +424,12 @@ describe("TimelineService", () => { }); const archived = await fs.readFile( - path.join(config.getSessionDir(WORKSPACE_ID), "chat-archive.jsonl"), + path.join(path.join(config.sessionsDir, WORKSPACE_ID), "chat-archive.jsonl"), "utf-8" ); expect(archived).toContain('"id":"target"'); const active = await fs.readFile( - path.join(config.getSessionDir(WORKSPACE_ID), "chat.jsonl"), + path.join(path.join(config.sessionsDir, WORKSPACE_ID), "chat.jsonl"), "utf-8" ); expect(active).not.toContain('"id":"target"'); diff --git a/src/node/services/timelineService.ts b/src/node/services/timelineService.ts index 6a49eb3bb3..7036cfa61b 100644 --- a/src/node/services/timelineService.ts +++ b/src/node/services/timelineService.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { randomUUID } from "crypto"; import { EventEmitter } from "events"; import * as fs from "fs/promises"; -import * as path from "path"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import { TIMELINE_FILE_NAME } from "@/common/constants/paths"; import { @@ -22,7 +22,7 @@ import { } from "@/common/orpc/schemas/timeline"; import type { WorkspaceChatMessage } from "@/common/orpc/types"; import type { MuxMessage, MuxToolPart } from "@/common/types/message"; -import type { Config } from "@/node/config"; +import type { WorkspaceSessionLocator } from "@/node/config"; import type { ExperimentsService } from "@/node/services/experimentsService"; import type { HistoryService } from "@/node/services/historyService"; import { log } from "@/node/services/log"; @@ -67,7 +67,7 @@ type TimelineAppendedListener = (event: { workspaceId: string; events: TimelineE export class TimelineService implements TimelineRecorder { private readonly events = new EventEmitter(); - private readonly config: Pick; + private readonly config: Pick; private readonly historyService: HistoryService; private readonly experimentsService: Pick; private readonly writeQueues = new Map>(); @@ -83,7 +83,7 @@ export class TimelineService implements TimelineRecorder { private mapperState: TimelineMapperState = createTimelineMapperState(); constructor( - config: Pick, + config: Pick, historyService: HistoryService, experimentsService: Pick ) { @@ -526,7 +526,7 @@ export class TimelineService implements TimelineRecorder { } private getFilePath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), TIMELINE_FILE_NAME); + return path.join(path.join(this.config.sessionsDir, workspaceId), TIMELINE_FILE_NAME); } private hasRecentSourceKey(workspaceId: string, sourceKey: string): boolean { diff --git a/src/node/services/tools/goal.test.ts b/src/node/services/tools/goal.test.ts index 3d28681669..3328525753 100644 --- a/src/node/services/tools/goal.test.ts +++ b/src/node/services/tools/goal.test.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; import * as fs from "fs/promises"; -import * as path from "path"; import type { ToolExecutionOptions } from "ai"; import type { Config } from "@/node/config"; @@ -598,7 +598,7 @@ describe("goal tools", () => { tool.execute!({ summary: "Implemented and verified." }, mockToolCallOptions) ); const storedRaw = await fs.readFile( - path.join(config.getSessionDir(workspaceId), "goal.json"), + path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), "utf-8" ); const storedGoal = JSON.parse(storedRaw) as GoalRecordV1; diff --git a/src/node/services/tools/memory.test.ts b/src/node/services/tools/memory.test.ts index 099e279e97..55a54e3bf2 100644 --- a/src/node/services/tools/memory.test.ts +++ b/src/node/services/tools/memory.test.ts @@ -416,7 +416,7 @@ describe("memory tool refinement journal", () => { expect(result.success).toBe(true); // Same session-dir resolution the service uses (Config path derivation is pure). - const sessionDir = new Config(fixture.xumHome).getSessionDir("ws-tool"); + const sessionDir = path.join(new Config(fixture.xumHome).sessionsDir, "ws-tool"); const events = await readRefinementEvents(sessionDir); expect(events).toHaveLength(1); const evidence = RefinementEvidenceSchema.parse(events[0].data.evidence); diff --git a/src/node/services/turnRequestBuilder.ts b/src/node/services/turnRequestBuilder.ts index 77c3e2ec56..8a2101b4bd 100644 --- a/src/node/services/turnRequestBuilder.ts +++ b/src/node/services/turnRequestBuilder.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { EXPERIMENT_IDS } from "@/common/constants/experiments"; import assert from "@/common/utils/assert"; @@ -465,7 +466,7 @@ export interface TurnRequestBuilderBindings extends OauthServiceBindings { interface TurnRequestBuilderDependencies { config: Config; providersConfigStore: ProvidersConfigStore; - secretsStore: SecretsStore; + secretsStore: Pick; historyService: HistoryService; initStateManager: InitStateManager; providerService: ProviderService; @@ -1340,7 +1341,7 @@ export class TurnRequestBuilder { try { await agentPluginHookService.ensureWorkspaceHooks({ workspaceId, - sessionDir: this.dependencies.config.getSessionDir(workspaceId), + sessionDir: path.join(this.dependencies.config.sessionsDir, workspaceId), journal: this.dependencies.durableEventJournalFor(workspaceId), enabled: this.dependencies.isAgentPluginsEnabled(), xumHome: this.dependencies.config.rootDir, @@ -1693,7 +1694,7 @@ export class TurnRequestBuilder { dynamicWorkflowsExperimentEnabled && this.dependencies.bindings.taskService != null ? new WorkflowService({ runStore: new WorkflowRunStore({ - sessionDir: this.dependencies.config.getSessionDir(workspaceId), + sessionDir: path.join(this.dependencies.config.sessionsDir, workspaceId), }), onRunStatusChanged: async (event) => { if (!isTerminalWorkflowRunStatus(event.status)) { @@ -1717,7 +1718,7 @@ export class TurnRequestBuilder { cwd: workspacePath, runtime, runtimeTempDir, - workspaceSessionDir: this.dependencies.config.getSessionDir(workspaceId), + workspaceSessionDir: path.join(this.dependencies.config.sessionsDir, workspaceId), trusted: getWorkflowProjectTrusted(), }, getProjectTrusted: getWorkflowProjectTrusted, @@ -2007,7 +2008,7 @@ export class TurnRequestBuilder { }, workspaceProjectPath: metadata.projectPath, workspaceExecutionRootPath: metadata.subProjectPath ?? metadata.projectPath, - workspaceSessionDir: this.dependencies.config.getSessionDir(workspaceId), + workspaceSessionDir: path.join(this.dependencies.config.sessionsDir, workspaceId), planFilePath, ancestorPlanFilePaths, workspaceId, @@ -2228,7 +2229,7 @@ export class TurnRequestBuilder { emitNestedToolEvent: emitNestedPtcToolEvent, sandbox: { workspaceId, - sessionDir: this.dependencies.config.getSessionDir(workspaceId), + sessionDir: path.join(this.dependencies.config.sessionsDir, workspaceId), kernelFileLoader, }, }); diff --git a/src/node/services/utils/multiProjectSecrets.ts b/src/node/services/utils/multiProjectSecrets.ts index e2bcfd9936..549eedba22 100644 --- a/src/node/services/utils/multiProjectSecrets.ts +++ b/src/node/services/utils/multiProjectSecrets.ts @@ -5,7 +5,7 @@ import type { SecretsStore } from "@/node/config"; export function mergeMultiProjectSecrets( metadata: WorkspaceMetadata, - secretsStore: SecretsStore + secretsStore: Pick ): Secret[] { const projects = getProjects(metadata); const primaryProject = projects.find((project) => project.projectPath === metadata.projectPath); diff --git a/src/node/services/workflows/WorkflowRunStore.ts b/src/node/services/workflows/WorkflowRunStore.ts index 01bbc3f978..3a94c35a48 100644 --- a/src/node/services/workflows/WorkflowRunStore.ts +++ b/src/node/services/workflows/WorkflowRunStore.ts @@ -56,7 +56,7 @@ export function isPathSafeWorkspaceId(workspaceId: string): boolean { } export async function getWorkflowRunStatusesForOwners( - context: { getSessionDir(workspaceId: string): string }, + context: { sessionsDir: string }, refs: ReadonlyArray<{ workspaceId: string; runId: string }> ) { const stores = new Map(); @@ -68,7 +68,7 @@ export async function getWorkflowRunStatusesForOwners( try { let store = stores.get(ref.workspaceId); if (store == null) { - store = new WorkflowRunStore({ sessionDir: context.getSessionDir(ref.workspaceId) }); + store = new WorkflowRunStore({ sessionDir: path.join(context.sessionsDir, ref.workspaceId) }); stores.set(ref.workspaceId, store); } const status = await store.getRunStatusForLiveness(ref); @@ -82,12 +82,12 @@ export async function getWorkflowRunStatusesForOwners( } export async function listActiveWorkflowRunsForOwners( - context: { getSessionDir(workspaceId: string): string }, + context: { sessionsDir: string }, workspaceIds: readonly string[] ) { const results = await Promise.all( workspaceIds.filter(isPathSafeWorkspaceId).map(async (workspaceId) => { - const store = new WorkflowRunStore({ sessionDir: context.getSessionDir(workspaceId) }); + const store = new WorkflowRunStore({ sessionDir: path.join(context.sessionsDir, workspaceId) }); const summaries = await store.listActiveRunSummaries({ workspaceId }); return summaries.map((summary) => ({ workspaceId, ...summary })); }) diff --git a/src/node/services/workflows/WorkflowService.context.test.ts b/src/node/services/workflows/WorkflowService.context.test.ts index 4111decf4e..274f54c05e 100644 --- a/src/node/services/workflows/WorkflowService.context.test.ts +++ b/src/node/services/workflows/WorkflowService.context.test.ts @@ -157,7 +157,7 @@ describe("WorkflowService request orchestration", () => { workspaceService.waitForWorkspaceIdle = mock( () => new Promise((resolve) => (releaseIdle = resolve)) ); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir("workspace-1") }); + const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, "workspace-1") }); const start = startWorkflowRun(context, { workspaceId: "workspace-1", diff --git a/src/node/services/workflows/WorkflowService.ts b/src/node/services/workflows/WorkflowService.ts index 2430127843..e52439d46a 100644 --- a/src/node/services/workflows/WorkflowService.ts +++ b/src/node/services/workflows/WorkflowService.ts @@ -1048,7 +1048,7 @@ export async function resolveWorkflowContext( service: new WorkflowService({ notifyInterruptedBackgroundRunTerminal: options.notifyInterruptedBackgroundRunTerminal === true, - runStore: new WorkflowRunStore({ sessionDir: context.config.getSessionDir(workspaceId) }), + runStore: new WorkflowRunStore({ sessionDir: path.join(context.config.sessionsDir, workspaceId) }), runtimeFactory: context.workflowRuntimeFactory, taskAdapterFactory: (runId, workflowName) => new WorkflowTaskServiceAdapter({ @@ -1062,7 +1062,7 @@ export async function resolveWorkflowContext( cwd: workspacePath, runtime, runtimeTempDir: workflowRuntimeTempDir, - workspaceSessionDir: context.config.getSessionDir(workspaceId), + workspaceSessionDir: path.join(context.config.sessionsDir, workspaceId), trusted: projectTrusted, }, getProjectTrusted: resolveWorkflowProjectTrusted, diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index e5d67a5d4a..a51ec718be 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -1,6 +1,6 @@ +import * as path from "path"; import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test"; import * as fs from "fs/promises"; -import * as path from "path"; import type { Config } from "@/node/config"; import { ExtensionMetadataService } from "@/node/services/ExtensionMetadataService"; import { WorkspaceGoalService, type GoalContinuationRuntimeBridge } from "./workspaceGoalService"; @@ -78,7 +78,7 @@ const PROJECT_PATH = "/tmp/mux-goal-service-test-project"; async function goalFileExists(config: Config, workspaceId: string): Promise { try { - await fs.access(path.join(config.getSessionDir(workspaceId), "goal.json")); + await fs.access(path.join(path.join(config.sessionsDir, workspaceId), "goal.json")); return true; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { @@ -254,7 +254,7 @@ describe("WorkspaceGoalService", () => { // Simulate a partially-written line from a prior crash. The board reader // must skip it instead of throwing. - const historyPath = path.join(config.getSessionDir(workspaceId), "goal-history.jsonl"); + const historyPath = path.join(path.join(config.sessionsDir, workspaceId), "goal-history.jsonl"); await fs.appendFile(historyPath, "{not-json}\n", "utf-8"); const completed = (await service.getGoalBoard(workspaceId)).entries.filter( @@ -476,7 +476,7 @@ describe("WorkspaceGoalService", () => { status: "paused", initiator: "user", }); - const goalPath = path.join(config.getSessionDir(workspaceId), "goal.json"); + const goalPath = path.join(path.join(config.sessionsDir, workspaceId), "goal.json"); await waitForCondition(async () => { try { const raw = JSON.parse(await fs.readFile(goalPath, "utf-8")) as { status?: string }; @@ -1454,7 +1454,7 @@ describe("WorkspaceGoalService", () => { budgetCents: 100, }); await fs.writeFile( - path.join(config.getSessionDir(workspaceId), "goal.json"), + path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), JSON.stringify({ ...legacy, status: "budget_limited", budgetCents: 0 }) ); @@ -1644,7 +1644,7 @@ describe("WorkspaceGoalService", () => { test("preserves goal id and accounting for same-objective set", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Same objective" }); await fs.writeFile( - path.join(config.getSessionDir(workspaceId), "goal.json"), + path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), JSON.stringify({ ...created, costCents: 123, turnsUsed: 4 }) ); @@ -1662,7 +1662,7 @@ describe("WorkspaceGoalService", () => { test("replaces different objective with a new goal id and reset accounting", async () => { const created = await setGoalOk(service, { workspaceId, objective: "First objective" }); await fs.writeFile( - path.join(config.getSessionDir(workspaceId), "goal.json"), + path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), JSON.stringify({ ...created, costCents: 123, turnsUsed: 4 }) ); @@ -1842,7 +1842,7 @@ describe("WorkspaceGoalService", () => { requireUserAcknowledgmentSinceMs: parent.createdAtMs + 1, }; await fs.writeFile( - path.join(config.getSessionDir(workspaceId), "goal.json"), + path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), `${JSON.stringify(parentWithAccounting, null, 2)}\n` ); @@ -1892,7 +1892,7 @@ describe("WorkspaceGoalService", () => { }); test("renames corrupt goal file and treats workspace as having no goal", async () => { - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fs.mkdir(sessionDir, { recursive: true }); await fs.writeFile(path.join(sessionDir, "goal.json"), "{ not json"); @@ -4877,7 +4877,7 @@ describe("WorkspaceGoalService", () => { test("attributes child report cost once and persists the per-goal ledger", async () => { await setGoalOk(service, { workspaceId, objective: "Account for child reports" }); await fs.writeFile( - path.join(config.getSessionDir(workspaceId), "session-usage.json"), + path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"), JSON.stringify({ version: 1, byModel: {}, rolledUpFrom: { "child-a": true } }, null, 2) ); @@ -4906,12 +4906,15 @@ describe("WorkspaceGoalService", () => { }); const goalOnDisk = JSON.parse( - await fs.readFile(path.join(config.getSessionDir(workspaceId), "goal.json"), "utf-8") + await fs.readFile(path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), "utf-8") ) as GoalRecordV1; expect(goalOnDisk.attributedChildren).toEqual(["child-a"]); const sessionUsageOnDisk = JSON.parse( - await fs.readFile(path.join(config.getSessionDir(workspaceId), "session-usage.json"), "utf-8") + await fs.readFile( + path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"), + "utf-8" + ) ) as { rolledUpFrom?: Record }; expect(sessionUsageOnDisk.rolledUpFrom).toEqual({ "child-a": true }); }); diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 68d0f054a8..9cd8579b1d 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -1,5 +1,5 @@ -import * as fs from "fs/promises"; import * as path from "path"; +import * as fs from "fs/promises"; import writeFileAtomic from "write-file-atomic"; import assert from "@/common/utils/assert"; import { @@ -1066,7 +1066,7 @@ export class WorkspaceGoalService { // doesn't re-assert and re-join the same way. private resolveSessionFilePath(workspaceId: string, fileName: string): string { assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(this.config.getSessionDir(workspaceId), fileName); + return path.join(path.join(this.config.sessionsDir, workspaceId), fileName); } private getFilePath(workspaceId: string): string { diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index d458687ecc..8862e369b4 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -67,12 +67,12 @@ function createMockExperimentsService(enabled: boolean): ExperimentsService { } type BashToolConfig = Parameters[0]; interface WorkspaceServiceTestOptions { - config: Partial; + config: Partial & { secretsStore?: Pick }; historyService: HistoryService; aiService?: AIService; initStateManager?: InitStateManager; experimentsEnabled?: boolean; - secretsStore?: SecretsStore; + secretsStore?: Pick; } function createMockAIService(metadata?: WorkspaceMetadata): AIService { return { @@ -113,7 +113,7 @@ interface ExecuteBashHarnessOptions { runtimeWorkspacePaths?: Record; findWorkspaceProjectPath?: string; getEffectiveSecrets?: SecretsStore["getEffectiveSecrets"]; - secretsStore?: SecretsStore; + secretsStore?: Pick; onCreateRuntime?: ( projectPath: string, options: Parameters[1] @@ -186,9 +186,15 @@ function createExecuteBashHarness(options: ExecuteBashHarnessOptions) { getInitState: mock(() => undefined), waitForInit: waitForInitMock, } as unknown as InitStateManager, + secretsStore: + options.secretsStore ?? + ({ getEffectiveSecrets: options.getEffectiveSecrets ?? mock(() => []) } satisfies Pick< + SecretsStore, + "getEffectiveSecrets" + >), config: { srcDir, - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => ({ projectPath: options.findWorkspaceProjectPath ?? projectAPath, workspacePath: primaryWorkspacePath, @@ -202,11 +208,6 @@ function createExecuteBashHarness(options: ExecuteBashHarnessOptions) { ), })), }, - secretsStore: - options.secretsStore ?? - ({ - getEffectiveSecrets: options.getEffectiveSecrets ?? mock(() => []), - } as unknown as SecretsStore), }); return { bashExecuteMock, @@ -399,8 +400,11 @@ describe("WorkspaceService executeBash runtime selection", () => { workspaceId, workspaceName, secretsStore: { - getEffectiveSecrets: getEffectiveSecretsMock as SecretsStore["getEffectiveSecrets"], - } as unknown as SecretsStore, + getEffectiveSecrets: getEffectiveSecretsMock as Pick< + SecretsStore, + "getEffectiveSecrets" + >["getEffectiveSecrets"], + } as Pick, }); try { const result = await harness.workspaceService.executeBash(workspaceId, "pwd"); @@ -507,7 +511,7 @@ describe("WorkspaceService executeBash runtime selection", () => { } as unknown as InitStateManager, config: { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => ({ projectPath, workspacePath })), loadConfigOrDefault: mock(() => ({ projects: new Map([[projectPath, { workspaces: [], trusted: true }]]), @@ -722,7 +726,7 @@ describe("WorkspaceService multi-project lifecycle", () => { }) ); }), - getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), + sessionsDir: path.join(rootDir, "sessions"), findWorkspace: mock(() => null), }; const mockAIService = { @@ -888,7 +892,7 @@ describe("WorkspaceService multi-project lifecycle", () => { })) ); }), - getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), + sessionsDir: path.join(rootDir, "sessions"), findWorkspace: mock(() => null), }; const mockAIService = { @@ -1038,7 +1042,7 @@ describe("WorkspaceService multi-project lifecycle", () => { srcDir, generateStableId: mock(() => workspaceId), loadConfigOrDefault: mock(() => configState), - getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), + sessionsDir: path.join(rootDir, "sessions"), findWorkspace: mock(() => null), }; const mockAIService = { @@ -1183,7 +1187,7 @@ describe("WorkspaceService multi-project lifecycle", () => { })) ); }), - getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), + sessionsDir: path.join(rootDir, "sessions"), findWorkspace: mock(() => null), }; const mockAIService = { @@ -1297,7 +1301,7 @@ describe("WorkspaceService multi-project lifecycle", () => { rootDir, srcDir: path.join(rootDir, "src"), loadConfigOrDefault: mock(() => ({ projects: new Map() })), - getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), + sessionsDir: path.join(rootDir, "sessions"), findWorkspace: mock(() => null), }; const mockAIService = { @@ -1341,7 +1345,7 @@ describe("WorkspaceService multi-project lifecycle", () => { [projectBPath, { workspaces: [], trusted: true }], ]), })), - getSessionDir: mock((workspace: string) => path.join(rootDir, "sessions", workspace)), + sessionsDir: path.join(rootDir, "sessions"), }; const mockAIService = { ...createStreamLifecycleMocks(), @@ -1397,7 +1401,7 @@ describe("WorkspaceService multi-project lifecycle", () => { [projectBPath, { workspaces: [], trusted: true }], ]), })), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), removeWorkspace: removeWorkspaceMock, findWorkspace: mock(() => null), }; @@ -1493,7 +1497,7 @@ describe("WorkspaceService multi-project lifecycle", () => { [projectBPath, { workspaces: [], trusted: true }], ]), })), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), removeWorkspace: removeWorkspaceMock, findWorkspace: mock(() => null), }; @@ -1642,7 +1646,7 @@ describe("WorkspaceService multi-project lifecycle", () => { : [] ); }), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), }; const mockAIService = { isStreaming: mock(() => false), @@ -1815,7 +1819,7 @@ describe("WorkspaceService multi-project lifecycle", () => { : [] ); }), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), }; const mockAIService = { isStreaming: mock(() => false), @@ -1968,7 +1972,7 @@ describe("WorkspaceService multi-project lifecycle", () => { } satisfies FrontendWorkspaceMetadata, ]) ), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), }; const mockAIService = { isStreaming: mock(() => false), @@ -2161,7 +2165,7 @@ describe("WorkspaceService multi-project lifecycle", () => { } satisfies FrontendWorkspaceMetadata, ]) ), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), }; const mockAIService = { isStreaming: mock(() => false), @@ -2408,7 +2412,7 @@ describe("WorkspaceService multi-project lifecycle", () => { } satisfies FrontendWorkspaceMetadata, ]) ), - getSessionDir: mock((id: string) => path.join(rootDir, "sessions", id)), + sessionsDir: path.join(rootDir, "sessions"), }; const mockAIService = { isStreaming: mock(() => false), diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 536d19be79..01a967a638 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -175,6 +175,9 @@ const mockBackgroundProcessManager: Partial = { }; type WorkspaceServiceArgs = ConstructorParameters; +type MockWorkspaceConfig = Partial & { + getEffectiveSecrets?: SecretsStore["getEffectiveSecrets"]; +}; function createMockAIService(overrides: Partial = {}): AIService { return { @@ -186,7 +189,9 @@ function createMockAIService(overrides: Partial = {}): AIService { } function createWorkspaceServiceForTest(options: { - config: Partial | Config; + config: + | (Partial & { getEffectiveSecrets?: SecretsStore["getEffectiveSecrets"] }) + | Config; historyService?: HistoryService; aiService?: AIService; initStateManager?: InitStateManager; @@ -884,7 +889,7 @@ describe("WorkspaceService bash monitor wakes", () => { // durably (otherwise the staged-clear grace scan would eventually roll the // staging back and resurrect the retired wakes). const tombPath = path.join( - config.getSessionDir(workspaceId), + path.join(config.sessionsDir, workspaceId), "bash-monitor-wakes", "cleared-at" ); @@ -969,7 +974,7 @@ describe("WorkspaceService bash monitor wakes", () => { // fires; the retried commitClear's tombstone mutation must not mkdir the // session directory back into existence for a removed workspace. await config.removeWorkspace(workspaceId); - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fsPromises.rm(sessionDir, { recursive: true, force: true }); await new Promise((resolve) => setTimeout(resolve, 1_500)); expect(existsSync(sessionDir)).toBe(false); @@ -1046,7 +1051,7 @@ describe("WorkspaceService bash monitor wakes", () => { // The refused clear touched nothing: no retirement, no staged tombstone. expect((await wakeStore.get(workspaceId, "proc-removal-race"))?.status).toBe("pending"); const tombPath = path.join( - config.getSessionDir(workspaceId), + path.join(config.sessionsDir, workspaceId), "bash-monitor-wakes", "cleared-at" ); @@ -2301,7 +2306,7 @@ describe("WorkspaceService bash monitor wakes", () => { }); const gen2Start = Date.now() - 1_000; const recordFile = path.join( - config.getSessionDir(workspaceId), + path.join(config.sessionsDir, workspaceId), "bash-monitor-wakes", "proc-gen.json" ); @@ -2383,7 +2388,7 @@ describe("WorkspaceService bash monitor wakes", () => { terminal: { status: "exited", exitCode: 1 }, }); const recordFile = path.join( - config.getSessionDir(workspaceId), + path.join(config.sessionsDir, workspaceId), "bash-monitor-wakes", "proc-nan.json" ); @@ -5531,7 +5536,9 @@ describe("WorkspaceService workflow activity", () => { historyService, extensionMetadata, }); - const runStore = new WorkflowRunStore({ sessionDir: config.getSessionDir(workspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, workspaceId), + }); const definition = { name: "demo", description: "Demo workflow", @@ -5981,13 +5988,14 @@ describe("WorkspaceService activity list scoping", () => { path.join(config.rootDir, "config.json"), JSON.stringify({ projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]] }) ); - const basenameSessionDir = config.getSessionDir("old-ws"); + const basenameSessionDir = path.join(config.sessionsDir, "old-ws"); await fsPromises.mkdir(basenameSessionDir, { recursive: true }); await fsPromises.writeFile( path.join(basenameSessionDir, "metadata.json"), JSON.stringify({ id: "basename-stable-id", name: "old-ws" }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -6232,7 +6240,7 @@ describe("WorkspaceService activity list scoping", () => { }) ); const legacyStableId = "legacy-stable-mid-enum"; - const legacySessionDir = config.getSessionDir("legacy-ws"); + const legacySessionDir = path.join(config.sessionsDir, "legacy-ws"); await fsPromises.mkdir(legacySessionDir, { recursive: true }); await fsPromises.writeFile( path.join(legacySessionDir, "metadata.json"), @@ -7025,7 +7033,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -7070,7 +7079,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -7229,7 +7239,7 @@ describe("WorkspaceService activity list scoping", () => { }); await extensionMetadata.updateRecency(workspaceId, 888); const runStore = new WorkflowRunStore({ - sessionDir: config.getSessionDir(workspaceId), + sessionDir: path.join(config.sessionsDir, workspaceId), }); await runStore.createRun({ id: "wfr_midlist", @@ -7294,7 +7304,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -7352,7 +7363,7 @@ describe("WorkspaceService activity list scoping", () => { runtimeConfig: { type: "local" }, }); const runStore = new WorkflowRunStore({ - sessionDir: config.getSessionDir(workspaceId), + sessionDir: path.join(config.sessionsDir, workspaceId), }); await runStore.createRun({ id: "wfr_workflow_only", @@ -7522,7 +7533,7 @@ describe("WorkspaceService activity list scoping", () => { runtimeConfig: { type: "local" }, }); const runStore = new WorkflowRunStore({ - sessionDir: config.getSessionDir(workspaceId), + sessionDir: path.join(config.sessionsDir, workspaceId), }); await runStore.createRun({ id: "wfr_reread_fail", @@ -7589,7 +7600,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -7598,7 +7610,7 @@ describe("WorkspaceService activity list scoping", () => { JSON.stringify({ id: stableId, name: "legacy-ws" }) ); const runStore = new WorkflowRunStore({ - sessionDir: config.getSessionDir(stableId), + sessionDir: path.join(config.sessionsDir, stableId), }); await runStore.createRun({ id: "wfr_legacy_only", @@ -7662,7 +7674,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -7911,7 +7924,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -7980,7 +7994,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -8366,7 +8381,8 @@ describe("WorkspaceService activity list scoping", () => { }) ); // Corrupt the metadata.json holding that entry's stable id. - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -8409,7 +8425,8 @@ describe("WorkspaceService activity list scoping", () => { projects: [[projectPath, { workspaces: [{ path: workspacePath }] }]], }) ); - const legacySessionDir = config.getSessionDir( + const legacySessionDir = path.join( + config.sessionsDir, config.generateLegacyId(projectPath, workspacePath) ); await fsPromises.mkdir(legacySessionDir, { recursive: true }); @@ -8477,10 +8494,10 @@ describe("WorkspaceService activity list scoping", () => { ...mockExtensionMetadataService, deleteWorkspace, } as unknown as ExtensionMetadataService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: path.join(tempRoot, "root"), srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(sessionRoot, id)), + sessionsDir: sessionRoot, removeWorkspace: mock(() => Promise.resolve()), findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), @@ -9913,7 +9930,7 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { workspaceId, createMuxMessage("pre-reset-user", "user", "before reset", {}) ); - const sessionDir = config.getSessionDir(workspaceId); + const sessionDir = path.join(config.sessionsDir, workspaceId); await fsPromises.mkdir(sessionDir, { recursive: true }); const pendingStatePath = path.join(sessionDir, "post-compaction.json"); await fsPromises.writeFile( @@ -9964,7 +9981,10 @@ describe("WorkspaceService truncateHistory goal acknowledgment", () => { // Deterministic unlink failure: a DIRECTORY at the pending-state path // fails unlink with EISDIR (read errors are swallowed at load, so this // models exactly the stale-undeletable-file case). - const pendingStatePath = path.join(config.getSessionDir(workspaceId), "post-compaction.json"); + const pendingStatePath = path.join( + path.join(config.sessionsDir, workspaceId), + "post-compaction.json" + ); await fsPromises.mkdir(pendingStatePath, { recursive: true }); const result = await workspaceService.resetContext(workspaceId); @@ -11542,9 +11562,9 @@ describe("WorkspaceService rename lock", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -11650,9 +11670,9 @@ describe("WorkspaceService sendMessage status clearing", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => ({ workspacePath: "/tmp/test/workspace", @@ -12754,9 +12774,9 @@ describe("WorkspaceService idle compaction dispatch", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -13288,9 +13308,9 @@ describe("WorkspaceService streaming generation guard", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock((workspaceId: string) => `/tmp/test/sessions/${workspaceId}`), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -13607,9 +13627,9 @@ describe("WorkspaceService executeBash archive guards", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -13758,7 +13778,7 @@ describe("WorkspaceService executeBash archive guards", () => { const service = createWorkspaceServiceForTest({ config: { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", loadConfigOrDefault: mock(() => ({ projects: new Map() })), getAllWorkspaceMetadata: mock(() => metadataGate), } as unknown as Config, @@ -13795,7 +13815,6 @@ describe("WorkspaceService executeBash workspace path resolution", () => { let waitForInitMock: ReturnType; let getWorkspaceMetadataMock: ReturnType; let findWorkspaceMock: ReturnType; - let getEffectiveSecretsMock: ReturnType; let createRuntimeSpy: Mock; let createBashToolSpy: Mock; let historyService: HistoryService; @@ -13808,7 +13827,6 @@ describe("WorkspaceService executeBash workspace path resolution", () => { projectPath: "/tmp/proj", workspaceName: "ws", })); - getEffectiveSecretsMock = mock(() => []); getWorkspaceMetadataMock = mock(() => Promise.resolve( Ok({ @@ -13835,9 +13853,9 @@ describe("WorkspaceService executeBash workspace path resolution", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: findWorkspaceMock, loadConfigOrDefault: mock(() => ({ projects: new Map() })), @@ -13852,7 +13870,7 @@ describe("WorkspaceService executeBash workspace path resolution", () => { historyService, aiService, initStateManager: mockInitStateManager as InitStateManager, - secretsStore: { getEffectiveSecrets: getEffectiveSecretsMock } as unknown as SecretsStore, + secretsStore: { getEffectiveSecrets: mock(() => []) } as unknown as SecretsStore, }); createRuntimeSpy = spyOn(runtimeFactory, "createRuntime").mockReturnValue({ @@ -13969,9 +13987,9 @@ describe("WorkspaceService getFileCompletions", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), @@ -14254,9 +14272,9 @@ describe("WorkspaceService getProjectGitStatuses", () => { }, } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -14506,9 +14524,9 @@ describe("WorkspaceService post-compaction metadata refresh", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -14643,9 +14661,9 @@ describe("WorkspaceService maybePersistAISettingsFromOptions", () => { const workspacePath = "/tmp/proj/ws"; const projectPath = "/tmp/proj"; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((workspaceId: string) => workspaceId === "ws" ? { projectPath, workspacePath } : null @@ -14880,7 +14898,7 @@ describe("WorkspaceService assertPricedModelForBudgetedGoal", () => { return new WorkspaceService( { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), } as unknown as Config, @@ -15374,10 +15392,10 @@ describe("WorkspaceService remove timing rollup", () => { } const aiService = new FakeAIService() as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: path.join(tempRoot, "root"), srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(sessionRoot, id)), + sessionsDir: sessionRoot, removeWorkspace: mock(() => Promise.resolve()), findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), @@ -15427,7 +15445,7 @@ describe("WorkspaceService remove shared-workspace guard", () => { // /locks, which must not leak across tests or runs. rootDir: path.join(tmpdir(), "mux-shared-guard", `root-${crypto.randomUUID()}`), srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(tmpdir(), "mux-shared-guard", id)), + sessionsDir: path.join(tmpdir(), "mux-shared-guard"), removeWorkspace: mock(() => Promise.resolve()), findWorkspace: mock(() => ({ workspacePath: sharedPath, projectPath })), loadConfigOrDefault: mock(() => ({ @@ -15519,7 +15537,7 @@ describe("WorkspaceService remove shared-workspace guard", () => { return { rootDir: path.join(tmpdir(), "mux-shared-guard", `root-${crypto.randomUUID()}`), srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(tmpdir(), "mux-shared-guard", id)), + sessionsDir: path.join(tmpdir(), "mux-shared-guard"), removeWorkspace: mock(() => Promise.resolve()), findWorkspace: mock(() => ({ workspacePath: sharedPath, projectPath })), loadConfigOrDefault: mock(() => ({ @@ -15663,12 +15681,12 @@ describe("WorkspaceService remove desktop session cleanup", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", // r63: removal serializes session-dir deletion with the memory target // locks and removal tombstones under `/locks`. rootDir: tempRoot, - getSessionDir: mock((id: string) => path.join(tempRoot, "sessions", id)), + sessionsDir: path.join(tempRoot, "sessions"), removeWorkspace: removeWorkspaceMock, findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), @@ -15791,9 +15809,9 @@ describe("WorkspaceService metadata listeners", () => { } const aiService = new FakeAIService() as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; @@ -15851,9 +15869,9 @@ describe("WorkspaceService metadata listeners", () => { } const aiService = new FakeAIService() as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; @@ -15936,9 +15954,9 @@ describe("WorkspaceService setPinned", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock((id: string) => { const entry = getEntry(id); if (!entry) return null; @@ -16140,9 +16158,9 @@ describe("WorkspaceService reorderPinned", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock((id: string) => { const entry = getEntry(id); if (!entry) return null; @@ -16329,9 +16347,9 @@ describe("WorkspaceService archive lifecycle hooks", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) { @@ -17205,9 +17223,9 @@ describe("WorkspaceService archive init cancellation", () => { runtimeConfig: { type: "local", srcBaseDir: "/tmp" }, }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) { @@ -17328,9 +17346,9 @@ describe("WorkspaceService unarchive lifecycle hooks", () => { return Promise.resolve(); }); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) { @@ -17468,9 +17486,9 @@ describe("WorkspaceService archive snapshots", () => { return Promise.resolve(); }); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) { @@ -17658,9 +17676,9 @@ describe("WorkspaceService preflightArchive and acknowledged archive", () => { worktreeArchiveBehavior: "snapshot", }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) return null; @@ -17941,9 +17959,9 @@ describe("WorkspaceService unarchive snapshot restore", () => { ]), }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) { @@ -18105,9 +18123,9 @@ describe("WorkspaceService deleteWorktree", () => { }; }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: tempSrcBaseDir, - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), getAllWorkspaceMetadata: mock(async () => [await getCurrentMetadata()]), }; @@ -18317,9 +18335,9 @@ describe("WorkspaceService archiveMergedInProject", () => { executeBashMock: ReturnType; archiveMock: ReturnType; } { - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), getAllWorkspaceMetadata: mock(() => Promise.resolve(allMetadata)), @@ -18729,10 +18747,10 @@ describe("WorkspaceService init cancellation", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: "/tmp/mux-root", srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: generateStableIdMock, findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ @@ -18775,10 +18793,10 @@ describe("WorkspaceService init cancellation", () => { test("create() rejects slash branches whose sanitized workspace name already exists", async () => { const projectPath = "/tmp/proj"; const generateStableIdMock = mock(() => "ws-conflict"); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: "/tmp/mux-root", srcDir: "/tmp/src", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: generateStableIdMock, findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ @@ -18826,12 +18844,12 @@ describe("WorkspaceService init cancellation", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", findWorkspace: mock(() => ({ projectPath: "/tmp/proj", workspacePath: "/tmp/proj/ws" })), editConfig: editConfigMock, getAllWorkspaceMetadata: mock(() => Promise.resolve([])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; @@ -18883,12 +18901,12 @@ describe("WorkspaceService init cancellation", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", findWorkspace: mock(() => ({ projectPath: "/tmp/proj", workspacePath: "/tmp/proj/ws" })), editConfig: editConfigMock, getAllWorkspaceMetadata: mock(() => Promise.resolve([])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; @@ -18946,10 +18964,10 @@ describe("WorkspaceService init cancellation", () => { runtimeConfig: { type: "local" }, }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -19022,7 +19040,7 @@ describe("WorkspaceService init cancellation", () => { runtimeConfig: { type: "local" }, }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: "/tmp/mux-root", srcDir: "/tmp/src", generateStableId: mock(() => workspaceId), @@ -19031,7 +19049,7 @@ describe("WorkspaceService init cancellation", () => { return Promise.resolve(); }), getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map([ @@ -19093,7 +19111,9 @@ describe("WorkspaceService init cancellation", () => { undefined, undefined, undefined, - { getEffectiveSecrets: mock(() => [{ key: "GH_TOKEN", value: "token" }]) } as unknown as SecretsStore + { + getEffectiveSecrets: mock(() => [{ key: "GH_TOKEN", value: "token" }]), + } as unknown as SecretsStore ); const metadataEvents: Array = []; @@ -19177,7 +19197,7 @@ describe("WorkspaceService init cancellation", () => { pendingAutoTitle: true, }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: "/tmp/mux-root", srcDir: "/tmp/src", generateStableId: mock(() => workspaceId), @@ -19186,7 +19206,7 @@ describe("WorkspaceService init cancellation", () => { return Promise.resolve(); }), getAllWorkspaceMetadata: mock(() => Promise.resolve([mockMetadata])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", findWorkspace: mock(() => null), // Two pre-existing workspaces — auto-naming should skip past them. loadConfigOrDefault: mock(() => ({ @@ -19303,10 +19323,10 @@ describe("WorkspaceService init cancellation", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: path.join(tempRoot, "root"), srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(tempRoot, id)), + sessionsDir: tempRoot, removeWorkspace: mock(() => Promise.resolve()), findWorkspace: mock(() => null), }; @@ -19380,9 +19400,9 @@ describe("WorkspaceService init cancellation", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(tempRoot, id)), + sessionsDir: tempRoot, removeWorkspace: removeWorkspaceMock, findWorkspace: mock(() => null), }; @@ -19449,10 +19469,10 @@ describe("WorkspaceService init cancellation", () => { off: mock(() => {}), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { rootDir: path.join(tempRoot, "root"), srcDir: "/tmp/src", - getSessionDir: mock((id: string) => path.join(tempRoot, id)), + sessionsDir: tempRoot, removeWorkspace: mock(() => Promise.resolve()), findWorkspace: mock(() => ({ projectPath, workspacePath: "/tmp/proj/ws" })), loadConfigOrDefault: mock(() => ({ projects: new Map() })), @@ -19497,9 +19517,9 @@ describe("WorkspaceService regenerateTitle", () => { ({ historyService, cleanup: cleanupHistory } = await createTestHistoryService()); - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => ({ projectPath: "/tmp/proj", workspacePath: "/tmp/proj/ws" })), }; @@ -19717,11 +19737,11 @@ describe("WorkspaceService fork", () => { enterHookPhase: mock(() => undefined), }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", generateStableId: mock(() => newWorkspaceId), findWorkspace: mock(() => null), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", loadConfigOrDefault: mock(() => ({ projects: new Map([[sourceProjectPath, { workspaces: [], trusted: true }]]), })), @@ -20483,9 +20503,9 @@ describe("WorkspaceService interruptStream", () => { test("sendQueuedImmediately clears hard-interrupt suppression before queued resend", async () => { const workspaceId = "ws-interrupt-queue-111"; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), findWorkspace: mock(() => null), }; @@ -20644,11 +20664,11 @@ describe("WorkspaceService.getGoalContinuationRuntimeState", () => { off: mock(() => undefined), } as unknown as AIService; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", findWorkspace: mock(() => ({ projectPath: "/tmp/proj", workspacePath: "/tmp/proj/ws" })), getAllWorkspaceMetadata: mock(() => Promise.resolve([])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; @@ -20785,10 +20805,10 @@ describe("WorkspaceService.getGoalContinuationRuntimeState", () => { on: mock(() => undefined as unknown as InitStateManager), getInitState: mock(() => undefined), }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", getAllWorkspaceMetadata: mock(() => Promise.resolve([])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), }; const { historyService } = await createTestHistoryService(); @@ -21016,10 +21036,10 @@ describe("WorkspaceService.getGoalContinuationRuntimeState", () => { on: mock(() => undefined as unknown as InitStateManager), getInitState: mock(() => undefined), }; - const mockConfig: Partial = { + const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/test", getAllWorkspaceMetadata: mock(() => Promise.resolve([])), - getSessionDir: mock(() => "/tmp/test/sessions"), + sessionsDir: "/tmp/test/sessions", generateStableId: mock(() => "test-id"), ...configOverrides, }; @@ -22085,7 +22105,7 @@ describe("WorkspaceService.fork branch-summary rollback ordering", () => { // session's chat.jsonl was not recreated by a late guarded append. expect(await awaitPendingBranchSummary(newWorkspaceId)).toBeNull(); expect(guardedAppendSpy).not.toHaveBeenCalled(); - const chatFile = path.join(config.getSessionDir(newWorkspaceId), "chat.jsonl"); + const chatFile = path.join(path.join(config.sessionsDir, newWorkspaceId), "chat.jsonl"); const chatExists = await fsPromises.access(chatFile).then( () => true, () => false diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 5eea9d4975..748269e931 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -1,7 +1,7 @@ +import * as path from "path"; import { TASK_TERMINATION_STOP_STREAM_TIMEOUT_MS } from "@/constants/terminationTimeouts"; import { raceWithAbortAndTimeout } from "@/node/utils/concurrency/withTimeout"; import { EventEmitter } from "events"; -import * as path from "path"; import { acquireCrossProcessLock } from "@/node/utils/main/crossProcessLock"; import * as fsPromises from "fs/promises"; import assert from "@/common/utils/assert"; @@ -2376,7 +2376,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { experimentsService?: ExperimentsService, sessionTimingService?: SessionTimingService, private readonly streamManager?: StreamManager, - private readonly secretsStore: SecretsStore = new SecretsStore(config.rootDir) + private readonly secretsStore: Pick = new SecretsStore( + config.rootDir + ) ) { super(); this.bashMonitorWakeStore = new BashMonitorWakeStore(config); @@ -4138,7 +4140,9 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { activeRunIds: Set ): Promise> { try { - const runStore = new WorkflowRunStore({ sessionDir: this.config.getSessionDir(workspaceId) }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(this.config.sessionsDir, workspaceId), + }); const runs = await runStore.listRunStatusSnapshots(); for (const run of runs) { if ( @@ -4454,7 +4458,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { const nextUpdate = previousUpdate .catch(() => undefined) .then(async () => { - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); const todos = await readTodosForSessionDir(sessionDir); const todoStatus = deriveTodoStatus(todos) ?? null; @@ -4484,7 +4488,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { if (!streaming && (hasTodos === undefined || todoStatus === undefined)) { // Stop snapshots need an authoritative todo summary even for background workspaces, // and centralizing the read here preserves the fire-and-forget abort/error handlers. - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); const todos = await readTodosForSessionDir(sessionDir); hasTodos ??= todos.length > 0; // When there are no todos to derive from, leave `todoStatus` undefined @@ -4907,7 +4911,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private async getPersistedPostCompactionDiffPaths(workspaceId: string): Promise { const postCompactionPath = path.join( - this.config.getSessionDir(workspaceId), + path.join(this.config.sessionsDir, workspaceId), "post-compaction.json" ); @@ -5031,7 +5035,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { * Returns empty exclusions if file doesn't exist. */ public async getPostCompactionExclusions(workspaceId: string): Promise { - const exclusionsPath = path.join(this.config.getSessionDir(workspaceId), "exclusions.json"); + const exclusionsPath = path.join( + path.join(this.config.sessionsDir, workspaceId), + "exclusions.json" + ); try { const data = await fsPromises.readFile(exclusionsPath, "utf-8"); return JSON.parse(data) as PostCompactionExclusions; @@ -5059,7 +5066,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { set.delete(itemId); } - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); await ensurePrivateDir(sessionDir); const exclusionsPath = path.join(sessionDir, "exclusions.json"); await fsPromises.writeFile( @@ -6719,7 +6726,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { await this.bashMonitorWakeStore.abandonWorkspaceClears(workspaceId); // Remove session data - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); // r66: identifies THIS removal attempt in the durable tombstone so the // compensating rollback below cannot delete a concurrent backend // attempt's marker. @@ -6727,7 +6734,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { try { if (parentWorkspaceId) { try { - const parentSessionDir = this.config.getSessionDir(parentWorkspaceId); + const parentSessionDir = path.join(this.config.sessionsDir, parentWorkspaceId); await archiveChildSessionArtifactsIntoParentSessionDir({ parentWorkspaceId, parentSessionDir, @@ -8481,7 +8488,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly pendingExternalEditorRecordings = new Map(); private externalEditorMarkerPath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), "external-editor-opened"); + return path.join(path.join(this.config.sessionsDir, workspaceId), "external-editor-opened"); } /** @@ -10574,8 +10581,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { workspacePath: sourceWorkspace?.workspacePath, }); - const sourceSessionDir = this.config.getSessionDir(sourceWorkspaceId); - const newSessionDir = this.config.getSessionDir(newWorkspaceId); + const sourceSessionDir = path.join(this.config.sessionsDir, sourceWorkspaceId); + const newSessionDir = path.join(this.config.sessionsDir, newWorkspaceId); // Removed tail captured inside the try, summarized only after setup // survives the rollback window (see the comment at the capture site). @@ -10851,7 +10858,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { workspaceId: newWorkspaceId, // Cross-process pending marker home (r48): lets a first send served // by another backend wait for the in-flight summary. - sessionDir: this.config.getSessionDir(newWorkspaceId), + sessionDir: path.join(this.config.sessionsDir, newWorkspaceId), abandonedMessages: abandonedBranchMessages, isExperimentEnabled: (experimentId) => this.isExperimentEnabled(experimentId), guardTailMessageId: sourceMessageId, @@ -12846,7 +12853,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // data from the supposedly cleared context through the kernel. Same // durable invalidation + partial-failure posture as resetContext. try { - await sandboxHostService.discardScope(workspaceId, this.config.getSessionDir(workspaceId)); + await sandboxHostService.discardScope( + workspaceId, + path.join(this.config.sessionsDir, workspaceId) + ); } catch (error) { log.error( `Failed to durably invalidate sandbox state for ${workspaceId} after history clear; ` + @@ -12957,7 +12967,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { try { await sandboxHostService.discardScope( workspaceId, - this.config.getSessionDir(workspaceId) + path.join(this.config.sessionsDir, workspaceId) ); } catch (error) { return Err( @@ -13042,7 +13052,10 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { // snapshotted) — vars must not survive a reset the way they survive // archive/un-archive. try { - await sandboxHostService.discardScope(workspaceId, this.config.getSessionDir(workspaceId)); + await sandboxHostService.discardScope( + workspaceId, + path.join(this.config.sessionsDir, workspaceId) + ); } catch (error) { // The chat-side reset already applied, but the sandbox invalidation // is NOT durable: the empty-snapshot tombstone failed to publish, and @@ -13239,7 +13252,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { try { await sandboxHostService.discardScope( workspaceId, - this.config.getSessionDir(workspaceId) + path.join(this.config.sessionsDir, workspaceId) ); } catch (error) { log.error( diff --git a/src/node/services/worktreeArchiveSnapshotService.test.ts b/src/node/services/worktreeArchiveSnapshotService.test.ts index b5e1164dcc..024cebce8f 100644 --- a/src/node/services/worktreeArchiveSnapshotService.test.ts +++ b/src/node/services/worktreeArchiveSnapshotService.test.ts @@ -182,9 +182,11 @@ describe("WorktreeArchiveSnapshotService", () => { delete workspace.name; return cfg; }); - await fs.mkdir(fixture.config.getSessionDir(fixture.workspaceName), { recursive: true }); + await fs.mkdir(path.join(fixture.config.sessionsDir, fixture.workspaceName), { + recursive: true, + }); await fs.writeFile( - path.join(fixture.config.getSessionDir(fixture.workspaceName), "metadata.json"), + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceName), "metadata.json"), JSON.stringify({ id: fixture.workspaceId }), "utf-8" ); @@ -213,7 +215,7 @@ describe("WorktreeArchiveSnapshotService", () => { expect( await pathExists( path.join( - fixture.config.getSessionDir(fixture.workspaceId), + path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state", "metadata.json" ) @@ -255,7 +257,7 @@ describe("WorktreeArchiveSnapshotService", () => { expect(storedWorkspace?.worktreeArchiveSnapshot).toBeUndefined(); expect( await pathExists( - path.join(fixture.config.getSessionDir(fixture.workspaceId), "archive-state") + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") ) ).toBe(false); }); @@ -508,7 +510,7 @@ describe("WorktreeArchiveSnapshotService", () => { throw new Error("Expected staged patch path"); } await fs.writeFile( - path.join(fixture.config.getSessionDir(fixture.workspaceId), stagedPatchPath), + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), stagedPatchPath), "this is not a valid patch\n", "utf-8" ); @@ -560,7 +562,7 @@ describe("WorktreeArchiveSnapshotService", () => { ).toBeUndefined(); expect( await pathExists( - path.join(fixture.config.getSessionDir(fixture.workspaceId), "archive-state") + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") ) ).toBe(false); }); @@ -712,9 +714,12 @@ describe("WorktreeArchiveSnapshotService", () => { return cfg; }); - await fs.rm(path.join(fixture.config.getSessionDir(fixture.workspaceId), stagedPatchPath), { - force: true, - }); + await fs.rm( + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), stagedPatchPath), + { + force: true, + } + ); await fs.writeFile( path.join(fixture.workspacePath, "tracked.txt"), "base\ncommit one\ncommit two\nstaged change\nunstaged change\nextra drift\n", @@ -871,9 +876,12 @@ describe("WorktreeArchiveSnapshotService", () => { return cfg; }); - await fs.rm(path.join(fixture.config.getSessionDir(fixture.workspaceId), stagedPatchPath), { - force: true, - }); + await fs.rm( + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), stagedPatchPath), + { + force: true, + } + ); runGit(fixture.projectPath, ["worktree", "remove", "--force", fixture.workspacePath]); const restoreResult = await fixture.service.restoreSnapshotAfterUnarchive({ @@ -915,7 +923,7 @@ describe("WorktreeArchiveSnapshotService", () => { if ( typeof targetPath === "string" && targetPath.endsWith( - path.join(fixture.config.getSessionDir(fixture.workspaceId), "archive-state") + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") ) ) { throw new Error("snapshot cleanup failed"); @@ -935,7 +943,7 @@ describe("WorktreeArchiveSnapshotService", () => { ).toEqual(captureResult.data); expect( await pathExists( - path.join(fixture.config.getSessionDir(fixture.workspaceId), "archive-state") + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") ) ).toBe(true); } finally { @@ -1051,7 +1059,7 @@ describe("WorktreeArchiveSnapshotService", () => { ).toEqual(captureResult.data); expect( await pathExists( - path.join(fixture.config.getSessionDir(fixture.workspaceId), "archive-state") + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") ) ).toBe(false); } finally { @@ -1093,7 +1101,7 @@ describe("WorktreeArchiveSnapshotService", () => { workspaceMetadata: fixture.metadata, }); expect(restoreResult).toEqual({ success: true, data: "restored" }); - expect(await pathExists(fixture.config.getSessionDir(fixture.workspaceId))).toBe(true); + expect(await pathExists(path.join(fixture.config.sessionsDir, fixture.workspaceId))).toBe(true); }); test("rejects archive snapshots when untracked files are present", async () => { @@ -1109,7 +1117,7 @@ describe("WorktreeArchiveSnapshotService", () => { ); expect( await pathExists( - path.join(fixture.config.getSessionDir(fixture.workspaceId), "archive-state") + path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") ) ).toBe(false); }); @@ -1158,7 +1166,7 @@ describe("WorktreeArchiveSnapshotService", () => { expect(failResult.success).toBe(false); // Clean up the failed attempt's state dir (if any). - const sessionDir = fixture.config.getSessionDir(fixture.workspaceId); + const sessionDir = path.join(fixture.config.sessionsDir, fixture.workspaceId); await fs.rm(path.join(sessionDir, "archive-state"), { recursive: true, force: true }); // With matching acknowledged paths, capture should succeed. @@ -1196,7 +1204,9 @@ describe("WorktreeArchiveSnapshotService", () => { }) ); - const sessionDirEntries = await fs.readdir(fixture.config.getSessionDir(fixture.workspaceId)); + const sessionDirEntries = await fs.readdir( + path.join(fixture.config.sessionsDir, fixture.workspaceId) + ); expect(sessionDirEntries.filter((entry) => entry.startsWith("archive-state.tmp-")).length).toBe( 0 ); diff --git a/src/node/services/worktreeArchiveSnapshotService.ts b/src/node/services/worktreeArchiveSnapshotService.ts index d62ea92399..04d589223c 100644 --- a/src/node/services/worktreeArchiveSnapshotService.ts +++ b/src/node/services/worktreeArchiveSnapshotService.ts @@ -247,7 +247,7 @@ export class WorktreeArchiveSnapshotService { return Err("Workspace is missing its persisted branch name"); } - const sessionDir = this.config.getSessionDir(args.workspaceId); + const sessionDir = path.join(this.config.sessionsDir, args.workspaceId); const stateDir = path.join(sessionDir, SNAPSHOT_DIR_NAME); const tempStateDir = path.join( sessionDir, @@ -528,7 +528,7 @@ export class WorktreeArchiveSnapshotService { if (!headShaAvailable) { const committedPatchPath = projectSnapshot.committedPatchPath ? this.resolveSessionRelativePath( - this.config.getSessionDir(args.workspaceId), + path.join(this.config.sessionsDir, args.workspaceId), projectSnapshot.committedPatchPath ) : undefined; @@ -551,7 +551,7 @@ export class WorktreeArchiveSnapshotService { if (projectSnapshot.stagedPatchPath) { const stagedPatchPath = this.resolveSessionRelativePath( - this.config.getSessionDir(args.workspaceId), + path.join(this.config.sessionsDir, args.workspaceId), projectSnapshot.stagedPatchPath ); if (!(await this.pathExists(stagedPatchPath))) { @@ -569,7 +569,7 @@ export class WorktreeArchiveSnapshotService { if (projectSnapshot.unstagedPatchPath) { const unstagedPatchPath = this.resolveSessionRelativePath( - this.config.getSessionDir(args.workspaceId), + path.join(this.config.sessionsDir, args.workspaceId), projectSnapshot.unstagedPatchPath ); if (!(await this.pathExists(unstagedPatchPath))) { @@ -927,7 +927,7 @@ export class WorktreeArchiveSnapshotService { workspaceId: string, snapshot: WorktreeArchiveSnapshot ): Promise { - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); const stateDir = this.resolveSessionRelativePath(sessionDir, snapshot.stateDirPath); await fsPromises.rm(stateDir, { recursive: true, force: true }); @@ -1065,7 +1065,7 @@ export class WorktreeArchiveSnapshotService { try { return await fsPromises.readFile( this.resolveSessionRelativePath( - this.config.getSessionDir(args.workspaceId), + path.join(this.config.sessionsDir, args.workspaceId), args.artifactPath ), "utf-8" diff --git a/src/node/utils/sessionFile.ts b/src/node/utils/sessionFile.ts index 83f10230cd..1618c2c580 100644 --- a/src/node/utils/sessionFile.ts +++ b/src/node/utils/sessionFile.ts @@ -1,5 +1,5 @@ -import * as fs from "fs/promises"; import * as path from "path"; +import * as fs from "fs/promises"; import writeFileAtomic from "write-file-atomic"; import type { Result } from "@/common/types/result"; import { Ok, Err } from "@/common/types/result"; @@ -36,7 +36,7 @@ export class SessionFileManager { } private getFilePath(workspaceId: string): string { - return path.join(this.config.getSessionDir(workspaceId), this.fileName); + return path.join(path.join(this.config.sessionsDir, workspaceId), this.fileName); } /** @@ -76,7 +76,7 @@ export class SessionFileManager { return Ok(undefined); } - const sessionDir = this.config.getSessionDir(workspaceId); + const sessionDir = path.join(this.config.sessionsDir, workspaceId); await fs.mkdir(sessionDir, { recursive: true }); const filePath = this.getFilePath(workspaceId); // Atomic write prevents corruption if app crashes mid-write diff --git a/tests/e2e/utils/historyFixture.ts b/tests/e2e/utils/historyFixture.ts index 3cd7d8f981..026d6e3521 100644 --- a/tests/e2e/utils/historyFixture.ts +++ b/tests/e2e/utils/historyFixture.ts @@ -209,7 +209,7 @@ export async function seedWorkspaceHistoryProfile(args: { const profileConfig = HISTORY_PROFILES[profile]; const historyService = new HistoryService({ - getSessionDir: (workspaceId: string) => path.join(demoProject.sessionsDir, workspaceId), + sessionsDir: demoProject.sessionsDir, rootDir: path.dirname(demoProject.sessionsDir), }); diff --git a/tests/ipc/agents/planCommands.test.ts b/tests/ipc/agents/planCommands.test.ts index b020da945a..68021a4c81 100644 --- a/tests/ipc/agents/planCommands.test.ts +++ b/tests/ipc/agents/planCommands.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; /** * Integration tests for plan commands (/plan, /plan open) * @@ -8,7 +9,6 @@ */ import * as fs from "fs/promises"; -import * as path from "path"; import { shouldRunIntegrationTests, createTestEnvironment, cleanupTestEnvironment } from "../setup"; import type { TestEnvironment } from "../setup"; import { @@ -233,7 +233,10 @@ describeIntegration("Plan Commands Integration", () => { expect(replaceResult.success).toBe(true); - const chatHistoryPath = path.join(env.config.getSessionDir(workspaceId), "chat.jsonl"); + const chatHistoryPath = path.join( + path.join(env.config.sessionsDir, workspaceId), + "chat.jsonl" + ); const data = await fs.readFile(chatHistoryPath, "utf-8"); const firstLine = data .split("\n") @@ -295,7 +298,10 @@ describeIntegration("Plan Commands Integration", () => { } ); - const chatHistoryPath = path.join(env.config.getSessionDir(workspaceId), "chat.jsonl"); + const chatHistoryPath = path.join( + path.join(env.config.sessionsDir, workspaceId), + "chat.jsonl" + ); await fs.appendFile( chatHistoryPath, JSON.stringify({ ...malformedBoundaryMessage, workspaceId }) + "\n" @@ -322,7 +328,10 @@ describeIntegration("Plan Commands Integration", () => { // Appending a durable boundary rotates the sealed prefix (legacy summary + // malformed boundary) into chat-archive.jsonl; full history spans both files. - const archivePath = path.join(env.config.getSessionDir(workspaceId), "chat-archive.jsonl"); + const archivePath = path.join( + path.join(env.config.sessionsDir, workspaceId), + "chat-archive.jsonl" + ); const archiveData = await fs.readFile(archivePath, "utf-8"); const activeData = await fs.readFile(chatHistoryPath, "utf-8"); diff --git a/tests/ipc/helpers.ts b/tests/ipc/helpers.ts index 8c620eb386..1732f891ea 100644 --- a/tests/ipc/helpers.ts +++ b/tests/ipc/helpers.ts @@ -692,14 +692,14 @@ export async function cleanupTempGitRepo(repoPath: string): Promise { */ export async function buildLargeHistory( workspaceId: string, - config: { getSessionDir: (id: string) => string; rootDir: string }, + config: { sessionsDir: string; rootDir: string }, options: { messageSize?: number; messageCount?: number; textPrefix?: string; } = {} ): Promise { - // HistoryService needs getSessionDir plus rootDir (write locks/tombstones). + // HistoryService needs sessionsDir plus rootDir (write locks/tombstones). const historyService = new HistoryService(config); const messageSize = options.messageSize ?? 50_000; diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index a7f83e04f4..9d3dfc978d 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -1,8 +1,9 @@ +import { ProvidersConfigStore } from "@/node/config"; import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; import type { BrowserWindow, WebContents } from "electron"; -import { Config, ProvidersConfigStore } from "../../src/node/config"; +import { Config } from "../../src/node/config"; import { ServiceContainer } from "../../src/node/services/serviceContainer"; import { setOpenSSHHostKeyPolicyMode } from "../../src/node/runtime/sshConnectionPool"; import { diff --git a/tests/ipc/workspace/init.test.ts b/tests/ipc/workspace/init.test.ts index 69c41488ca..2581c48552 100644 --- a/tests/ipc/workspace/init.test.ts +++ b/tests/ipc/workspace/init.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; import { shouldRunIntegrationTests, createTestEnvironment, @@ -18,7 +19,6 @@ import { import { createStreamCollector } from "../streamCollector"; import type { WorkspaceInitEvent } from "@/common/orpc/types"; import { isInitOutput, isInitEnd, isInitStart } from "@/common/orpc/types"; -import * as path from "path"; import * as os from "os"; import * as fs from "fs/promises"; import { exec } from "child_process"; @@ -341,7 +341,10 @@ describeIntegration("Workspace init hook", () => { await waitForInitComplete(env, workspaceId, 5000); // Verify init-status.json exists on disk - const initStatusPath = path.join(env.config.getSessionDir(workspaceId), "init-status.json"); + const initStatusPath = path.join( + path.join(env.config.sessionsDir, workspaceId), + "init-status.json" + ); const statusExists = await fs .access(initStatusPath) .then(() => true) diff --git a/tests/ipc/workspace/rename.test.ts b/tests/ipc/workspace/rename.test.ts index 8e2357eabc..648e834197 100644 --- a/tests/ipc/workspace/rename.test.ts +++ b/tests/ipc/workspace/rename.test.ts @@ -1,3 +1,4 @@ +import * as path from "path"; /** * Integration tests for WORKSPACE_RENAME IPC handler * @@ -113,7 +114,7 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { ); const oldWorkspacePath = workspacePath; - const oldSessionDir = env.config.getSessionDir(workspaceId); + const oldSessionDir = path.join(env.config.sessionsDir, workspaceId); // Rename the workspace const newName = "renamed-branch"; @@ -132,7 +133,7 @@ describeIntegration("WORKSPACE_RENAME with both runtimes", () => { expect(newWorkspaceId).toBe(workspaceId); // Session directory should still be the same (stable IDs don't move directories) - const sessionDir = env.config.getSessionDir(workspaceId); + const sessionDir = path.join(env.config.sessionsDir, workspaceId); expect(sessionDir).toBe(oldSessionDir); // Verify metadata was updated (name changed, path changed, but ID stays the same) From b43daa3c926c8284a9941453e36ac4407cbb3a09 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:18:30 +0000 Subject: [PATCH 07/17] =?UTF-8?q?=F0=9F=A4=96=20refactor(config):=20constr?= =?UTF-8?q?uct=20focused=20stores=20through=20factory?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/cli.test.ts | 6 ++-- src/cli/run.ts | 21 ++++++++----- src/cli/server.test.ts | 6 ++-- src/cli/server.ts | 7 +++-- src/cli/workflow.ts | 18 +++++++---- src/desktop/main.ts | 7 +++-- src/node/acp/serverConnection.ts | 6 ++-- src/node/bench/headlessEnvironment.ts | 7 +++-- ...nager.test.ts => fileLeaseManager.test.ts} | 2 +- ...ileLeaseManager.ts => fileLeaseManager.ts} | 0 src/node/config/index.ts | 31 +++++++++++++++---- ...e.test.ts => providersConfigStore.test.ts} | 2 +- ...ConfigStore.ts => providersConfigStore.ts} | 0 ...retsStore.test.ts => secretsStore.test.ts} | 2 +- .../{SecretsStore.ts => secretsStore.ts} | 0 src/node/orpc/context.ts | 9 +++++- src/node/services/coreServices.ts | 11 +++++-- src/node/services/serviceContainer.test.ts | 14 +++++---- src/node/services/serviceContainer.ts | 15 ++++++--- tests/ipc/setup.ts | 11 ++++--- 20 files changed, 116 insertions(+), 59 deletions(-) rename src/node/config/{FileLeaseManager.test.ts => fileLeaseManager.test.ts} (99%) rename src/node/config/{FileLeaseManager.ts => fileLeaseManager.ts} (100%) rename src/node/config/{ProvidersConfigStore.test.ts => providersConfigStore.test.ts} (96%) rename src/node/config/{ProvidersConfigStore.ts => providersConfigStore.ts} (100%) rename src/node/config/{SecretsStore.test.ts => secretsStore.test.ts} (99%) rename src/node/config/{SecretsStore.ts => secretsStore.ts} (100%) diff --git a/src/cli/cli.test.ts b/src/cli/cli.test.ts index 5848b40843..87f17d6a77 100644 --- a/src/cli/cli.test.ts +++ b/src/cli/cli.test.ts @@ -19,7 +19,7 @@ import { createCli, FailedToExitError } from "trpc-cli"; import { router } from "@/node/orpc/router"; import { proxifyOrpc } from "./proxifyOrpc"; import type { ORPCContext } from "@/node/orpc/context"; -import { Config } from "@/node/config"; +import { createConfigStores } from "@/node/config"; import { ServiceContainer } from "@/node/services/serviceContainer"; import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; @@ -38,7 +38,7 @@ interface TestServerHandle { async function createTestServer(authToken?: string): Promise { // Create temp dir for config const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-cli-test-")); - const config = new Config(tempDir); + const stores = createConfigStores(tempDir); // Mock BrowserWindow const mockWindow: BrowserWindow = { @@ -51,7 +51,7 @@ async function createTestServer(authToken?: string): Promise { } as unknown as BrowserWindow; // Initialize services - const services = new ServiceContainer(config); + const services = new ServiceContainer(stores); await services.initialize(); services.windowService.setMainWindow(mockWindow); diff --git a/src/cli/run.ts b/src/cli/run.ts index 502a9b921f..59871d1357 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -14,7 +14,7 @@ import { tool } from "ai"; import { z } from "zod"; import * as path from "path"; import * as fs from "fs/promises"; -import { Config, FileLeaseManager, ProvidersConfigStore, SecretsStore } from "../node/config"; +import { createConfigStores } from "../node/config"; import { materializeResolvedTrust, replaceRunTrustProjects } from "./trust"; import { runBestEffortCleanup } from "./runCleanup"; import { DisposableTempDir } from "../node/services/tempDir"; @@ -501,7 +501,8 @@ async function main(): Promise { using tempDir = new DisposableTempDir("mux-run"); // Read credentials from the real config, then copy them into the private run config. - const realConfig = new Config(); + const realStores = createConfigStores(); + const realConfig = realStores.config; // Session telemetry uses the private root by default. Benchmark/CI harnesses can pin it // to collect chat.jsonl and session-usage.json after the process exits. @@ -513,11 +514,13 @@ async function main(): Promise { return 1; } await using preparedSessionRoot = sessionRootOverride; - const config = await createRunConfig(tempDir.path, preparedSessionRoot); + const preparedConfig = await createRunConfig(tempDir.path, preparedSessionRoot); + const runStores = createConfigStores(preparedConfig.rootDir); + const config = runStores.config; // Copy providers and secrets from real config to ephemeral config - const realProvidersStore = new ProvidersConfigStore(realConfig.rootDir); - const runProvidersStore = new ProvidersConfigStore(config.rootDir); + const realProvidersStore = realStores.providersConfig; + const runProvidersStore = runStores.providersConfig; const existingProviders = realProvidersStore.loadProvidersConfig(); const providersFile = path.join(config.rootDir, "providers.jsonc"); await replacePrivateRunConfigFile( @@ -528,7 +531,7 @@ async function main(): Promise { ); // Copy secrets so tools/MCP servers get project secrets (e.g., GH_TOKEN) - const existingSecrets = new SecretsStore(realConfig.rootDir).loadSecretsConfig(); + const existingSecrets = realStores.secrets.loadSecretsConfig(); const secretsFile = path.join(config.rootDir, "secrets.json"); await replacePrivateRunConfigFile( secretsFile, @@ -657,6 +660,10 @@ async function main(): Promise { turnRequestBuilderBindings, } = createCoreServices({ config, + sessionLocator: runStores.sessionLocator, + providersConfigStore: runStores.providersConfig, + secretsStore: runStores.secrets, + fileLeaseManager: runStores.fileLeases, policyService, extensionMetadataPath: path.join(tempDir.path, "extensionMetadata.json"), // Session config lives in tempDir (deleted on exit) — disable workspace.* @@ -685,7 +692,7 @@ async function main(): Promise { // the refresh token on every use, so persisting rotations only to tempDir // would strand ~/.xum/providers.jsonc with a consumed (dead) refresh token // once this CLI session exits. - const realFileLeaseManager = new FileLeaseManager(realConfig.rootDir); + const realFileLeaseManager = realStores.fileLeases; const realProviderService = new ProviderService( realConfig, policyService, diff --git a/src/cli/server.test.ts b/src/cli/server.test.ts index bdac7bfa21..d1453c1d5f 100644 --- a/src/cli/server.test.ts +++ b/src/cli/server.test.ts @@ -21,7 +21,7 @@ import type { BrowserWindow, WebContents } from "electron"; import { type AppRouter } from "@/node/orpc/router"; import type { ORPCContext } from "@/node/orpc/context"; -import { Config } from "@/node/config"; +import { createConfigStores } from "@/node/config"; import { ServiceContainer } from "@/node/services/serviceContainer"; import type { RouterClient } from "@orpc/server"; import { createOrpcServer, type OrpcServer } from "@/node/orpc/server"; @@ -43,7 +43,7 @@ interface TestServerHandle { async function createTestServer(): Promise { // Create temp dir for config const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-server-test-")); - const config = new Config(tempDir); + const stores = createConfigStores(tempDir); // Mock BrowserWindow const mockWindow: BrowserWindow = { @@ -56,7 +56,7 @@ async function createTestServer(): Promise { } as unknown as BrowserWindow; // Initialize services - const services = new ServiceContainer(config); + const services = new ServiceContainer(stores); await services.initialize(); services.windowService.setMainWindow(mockWindow); diff --git a/src/cli/server.ts b/src/cli/server.ts index bdd4316952..9205decaee 100644 --- a/src/cli/server.ts +++ b/src/cli/server.ts @@ -3,7 +3,7 @@ * Uses ServerService for server lifecycle management. */ import "source-map-support/register"; -import { Config } from "@/node/config"; +import { createConfigStores } from "@/node/config"; import { ServiceContainer } from "@/node/services/serviceContainer"; import { setOpenSSHHostKeyPolicyMode } from "@/node/runtime/sshConnectionPool"; import { cleanupObsoleteXumBinArtifacts, getXumHome } from "@/common/constants/paths"; @@ -127,8 +127,9 @@ async function main(): Promise { process.exit(1); } - const config = new Config(); - const serviceContainer = new ServiceContainer(config); + const stores = createConfigStores(); + const config = stores.config; + const serviceContainer = new ServiceContainer(stores); // Headless server has no interactive host-key dialog setOpenSSHHostKeyPolicyMode("headless-fallback"); await serviceContainer.initialize(); diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index 56111b27a2..07a4d4e31f 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -22,7 +22,7 @@ import { defaultModel } from "@/common/utils/ai/models"; import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { getErrorMessage } from "@/common/utils/errors"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; -import { Config, FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; +import { Config, ProvidersConfigStore, SecretsStore, createConfigStores } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { AgentSession } from "@/node/services/agentSession"; import { CodexOauthService } from "@/node/services/codexOauthService"; @@ -337,13 +337,15 @@ async function createWorkflowContext(options: { let realProviderService: ProviderService | undefined; let policyService: PolicyService | undefined; try { - const realConfig = new Config(); - const config = new Config(tempDir.path); + const realStores = createConfigStores(); + const realConfig = realStores.config; + const runStores = createConfigStores(tempDir.path); + const config = runStores.config; await copyPersistentConfig(realConfig, config); - const realProvidersStore = new ProvidersConfigStore(realConfig.rootDir); - const realFileLeaseManager = new FileLeaseManager(realConfig.rootDir); - const runProvidersStore = new ProvidersConfigStore(config.rootDir); + const realProvidersStore = realStores.providersConfig; + const realFileLeaseManager = realStores.fileLeases; + const runProvidersStore = runStores.providersConfig; const existingProviders = realProvidersStore.loadProvidersConfig(); if (!hasAnyConfiguredProvider(existingProviders)) { const providersFromEnv = buildProvidersFromEnv(); @@ -367,6 +369,10 @@ async function createWorkflowContext(options: { services = createCoreServices({ config, + sessionLocator: runStores.sessionLocator, + providersConfigStore: runStores.providersConfig, + secretsStore: runStores.secrets, + fileLeaseManager: runStores.fileLeases, policyService, extensionMetadataPath: path.join(tempDir.path, "extensionMetadata.json"), mcpConfig: realConfig, diff --git a/src/desktop/main.ts b/src/desktop/main.ts index d37a19ac14..714c5b33fc 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -638,7 +638,7 @@ async function loadServices(): Promise { // - These are large modules (~100ms load time) that would block splash from appearing // - Loading happens once, then cached const [ - { Config: ConfigClass }, + { createConfigStores }, { ServiceContainer: ServiceContainerClass }, { TerminalWindowManager: TerminalWindowManagerClass }, ] = await Promise.all([ @@ -647,9 +647,10 @@ async function loadServices(): Promise { import("./terminalWindowManager"), ]); /* eslint-enable no-restricted-syntax */ - config = new ConfigClass(); + const stores = createConfigStores(); + config = stores.config; - services = new ServiceContainerClass(config); + services = new ServiceContainerClass(stores); // Desktop bootstrap owns interactive host-key trust policy setOpenSSHHostKeyPolicyMode("strict"); await services.initialize(); diff --git a/src/node/acp/serverConnection.ts b/src/node/acp/serverConnection.ts index 4903ff3edc..fae89241f9 100644 --- a/src/node/acp/serverConnection.ts +++ b/src/node/acp/serverConnection.ts @@ -5,7 +5,7 @@ import { RPCLink as WebSocketRPCLink } from "@orpc/client/websocket"; import type { RouterClient } from "@orpc/server"; import WebSocket from "ws"; import { getXumHome } from "@/common/constants/paths"; -import { Config } from "@/node/config"; +import { createConfigStores } from "@/node/config"; import type { AppRouter } from "@/node/orpc/router"; import { createOrpcServer } from "@/node/orpc/server"; import { ServiceContainer } from "@/node/services/serviceContainer"; @@ -151,8 +151,8 @@ async function connectToExistingServer(options: { async function connectToInProcessServer(requestedAuthToken?: string): Promise { const authToken = requestedAuthToken ?? crypto.randomUUID(); - const config = new Config(); - const serviceContainer = new ServiceContainer(config); + const stores = createConfigStores(); + const serviceContainer = new ServiceContainer(stores); let initialized = false; let inProcessServer: InProcessOrpcServer | undefined; diff --git a/src/node/bench/headlessEnvironment.ts b/src/node/bench/headlessEnvironment.ts index bb4bd40244..c389b75e26 100644 --- a/src/node/bench/headlessEnvironment.ts +++ b/src/node/bench/headlessEnvironment.ts @@ -3,7 +3,7 @@ import * as path from "path"; import * as fs from "fs/promises"; import createIPCMock from "electron-mock-ipc"; import type { BrowserWindow, IpcMain as ElectronIpcMain, WebContents } from "electron"; -import { Config } from "@/node/config"; +import { createConfigStores, type Config } from "@/node/config"; import { setOpenSSHHostKeyPolicyMode } from "@/node/runtime/sshConnectionPool"; import { ServiceContainer } from "@/node/services/serviceContainer"; @@ -98,7 +98,8 @@ export async function createHeadlessEnvironment( ): Promise { const { rootDir, dispose: disposeRootDir } = await establishRootDir(options.rootDir); - const config = new Config(rootDir); + const stores = createConfigStores(rootDir); + const config = stores.config; const { window: mockWindow, sentEvents } = createMockBrowserWindow(); @@ -107,7 +108,7 @@ export async function createHeadlessEnvironment( const mockIpcMainModule = mockedElectron.ipcMain; const mockIpcRendererModule = mockedElectron.ipcRenderer; - const services = new ServiceContainer(config); + const services = new ServiceContainer(stores); // Headless bench environment has no interactive host-key dialog setOpenSSHHostKeyPolicyMode("headless-fallback"); await services.initialize(); diff --git a/src/node/config/FileLeaseManager.test.ts b/src/node/config/fileLeaseManager.test.ts similarity index 99% rename from src/node/config/FileLeaseManager.test.ts rename to src/node/config/fileLeaseManager.test.ts index 6b6db332e5..3e479e1d67 100644 --- a/src/node/config/FileLeaseManager.test.ts +++ b/src/node/config/fileLeaseManager.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { FileLeaseManager } from "./FileLeaseManager"; +import { FileLeaseManager } from "./fileLeaseManager"; describe("FileLeaseManager", () => { let tempDir: string; diff --git a/src/node/config/FileLeaseManager.ts b/src/node/config/fileLeaseManager.ts similarity index 100% rename from src/node/config/FileLeaseManager.ts rename to src/node/config/fileLeaseManager.ts diff --git a/src/node/config/index.ts b/src/node/config/index.ts index e3f51e7d9d..8116df3003 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -5,7 +5,9 @@ import { EventEmitter } from "events"; import writeFileAtomic from "write-file-atomic"; import { resolveXumEnvironmentValue } from "@/common/compat/legacyMux"; import { log } from "@/node/services/log"; -import { ProvidersConfigStore } from "./ProvidersConfigStore"; +import { ProvidersConfigStore } from "./providersConfigStore"; +import { FileLeaseManager } from "./fileLeaseManager"; +import { SecretsStore } from "./secretsStore"; import { WorkspaceSessionLocator } from "./sessionLocator"; import type { WorkspaceMetadata, FrontendWorkspaceMetadata } from "@/common/types/workspace"; import type { Result } from "@/common/types/result"; @@ -84,9 +86,9 @@ import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking // Re-export project/provider types from dedicated schema/types files (for preload usage) export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig }; -export { FileLeaseManager } from "./FileLeaseManager"; -export { ProvidersConfigStore, type ProvidersConfig } from "./ProvidersConfigStore"; -export { SecretsStore } from "./SecretsStore"; +export { FileLeaseManager } from "./fileLeaseManager"; +export { ProvidersConfigStore, type ProvidersConfig } from "./providersConfigStore"; +export { SecretsStore } from "./secretsStore"; export { WorkspaceSessionLocator } from "./sessionLocator"; /** True only for fs errors whose errno code is ENOENT (genuinely missing path). */ @@ -3498,5 +3500,22 @@ export class Config { } } -// Default instance for application use -export const defaultConfig = new Config(); +export interface ConfigStores { + config: Config; + sessionLocator: WorkspaceSessionLocator; + providersConfig: ProvidersConfigStore; + secrets: SecretsStore; + fileLeases: FileLeaseManager; +} + +export function createConfigStores(rootDir?: string): ConfigStores { + const sessionLocator = new WorkspaceSessionLocator(rootDir); + const providersConfig = new ProvidersConfigStore(sessionLocator.rootDir); + const secrets = new SecretsStore(sessionLocator.rootDir); + const fileLeases = new FileLeaseManager(sessionLocator.rootDir); + const config = new Config(sessionLocator.rootDir, providersConfig, sessionLocator); + return { config, sessionLocator, providersConfig, secrets, fileLeases }; +} + +const defaultStores = createConfigStores(); +export const defaultConfig = defaultStores.config; diff --git a/src/node/config/ProvidersConfigStore.test.ts b/src/node/config/providersConfigStore.test.ts similarity index 96% rename from src/node/config/ProvidersConfigStore.test.ts rename to src/node/config/providersConfigStore.test.ts index 51fce3c678..f237d6e4f6 100644 --- a/src/node/config/ProvidersConfigStore.test.ts +++ b/src/node/config/providersConfigStore.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; -import { ProvidersConfigStore } from "./ProvidersConfigStore"; +import { ProvidersConfigStore } from "./providersConfigStore"; describe("ProvidersConfigStore", () => { let tempDir: string; diff --git a/src/node/config/ProvidersConfigStore.ts b/src/node/config/providersConfigStore.ts similarity index 100% rename from src/node/config/ProvidersConfigStore.ts rename to src/node/config/providersConfigStore.ts diff --git a/src/node/config/SecretsStore.test.ts b/src/node/config/secretsStore.test.ts similarity index 99% rename from src/node/config/SecretsStore.test.ts rename to src/node/config/secretsStore.test.ts index bdfe3189e2..c6df2b8c73 100644 --- a/src/node/config/SecretsStore.test.ts +++ b/src/node/config/secretsStore.test.ts @@ -3,7 +3,7 @@ import * as fs from "fs"; import * as os from "os"; import * as path from "path"; import { secretsToRecord } from "@/common/types/secrets"; -import { SecretsStore } from "./SecretsStore"; +import { SecretsStore } from "./secretsStore"; describe("SecretsStore", () => { let tempDir: string; diff --git a/src/node/config/SecretsStore.ts b/src/node/config/secretsStore.ts similarity index 100% rename from src/node/config/SecretsStore.ts rename to src/node/config/secretsStore.ts diff --git a/src/node/orpc/context.ts b/src/node/orpc/context.ts index 8a0d502c93..014c8b69b7 100644 --- a/src/node/orpc/context.ts +++ b/src/node/orpc/context.ts @@ -1,6 +1,12 @@ import type { IJSRuntimeFactory } from "@/node/services/ptc/runtime"; import type { IncomingHttpHeaders } from "http"; -import type { Config, FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; +import type { + Config, + FileLeaseManager, + ProvidersConfigStore, + SecretsStore, + WorkspaceSessionLocator, +} from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { HistoryService } from "@/node/services/historyService"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -56,6 +62,7 @@ import type { DesktopTokenManager } from "@/node/services/desktop/DesktopTokenMa export interface ORPCContext { config: Config; + sessionLocator: WorkspaceSessionLocator; providersConfigStore: ProvidersConfigStore; secretsStore: SecretsStore; fileLeaseManager: FileLeaseManager; diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index caf508b198..ee90cda2bc 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -5,7 +5,12 @@ import * as os from "os"; import * as path from "path"; import type { Config } from "@/node/config"; -import { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; +import { + FileLeaseManager, + ProvidersConfigStore, + SecretsStore, + WorkspaceSessionLocator, +} from "@/node/config"; import { HistoryService } from "@/node/services/historyService"; import { IdleDispatcher } from "@/node/services/idleDispatcher"; import { InitStateManager } from "@/node/services/initStateManager"; @@ -47,6 +52,7 @@ import type { DevToolsService } from "@/node/services/devToolsService"; export interface CoreServicesOptions { config: Config; + sessionLocator?: WorkspaceSessionLocator; providersConfigStore?: ProvidersConfigStore; secretsStore?: SecretsStore; fileLeaseManager?: FileLeaseManager; @@ -94,7 +100,8 @@ export interface CoreServices { export function createCoreServices(opts: CoreServicesOptions): CoreServices { const { config, extensionMetadataPath } = opts; - const historyService = new HistoryService(config); + const sessionLocator = opts.sessionLocator ?? new WorkspaceSessionLocator(config.rootDir); + const historyService = new HistoryService(sessionLocator); const initStateManager = new InitStateManager(config); const providersConfigStore = opts.providersConfigStore ?? new ProvidersConfigStore(config.rootDir); diff --git a/src/node/services/serviceContainer.test.ts b/src/node/services/serviceContainer.test.ts index 9b0a034264..9c3812580e 100644 --- a/src/node/services/serviceContainer.test.ts +++ b/src/node/services/serviceContainer.test.ts @@ -3,17 +3,19 @@ import * as fs from "fs"; import * as os from "os"; import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; -import { Config } from "@/node/config"; +import { createConfigStores, type Config, type ConfigStores } from "@/node/config"; import { ServiceContainer } from "./serviceContainer"; describe("ServiceContainer", () => { let tempDir: string; let config: Config; + let stores: ConfigStores; let services: ServiceContainer | undefined; beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-service-container-test-")); - config = new Config(tempDir); + stores = createConfigStores(tempDir); + config = stores.config; }); afterEach(async () => { @@ -51,7 +53,7 @@ describe("ServiceContainer", () => { return cfg; }); - services = new ServiceContainer(config); + services = new ServiceContainer(stores); const ingestWorkspaceSpy = spyOn( services.analyticsService, "ingestWorkspace" @@ -78,7 +80,7 @@ describe("ServiceContainer", () => { }); it("exposes desktopSessionManager in the ORPC context", () => { - services = new ServiceContainer(config); + services = new ServiceContainer(stores); const context = services.toORPCContext(); @@ -86,7 +88,7 @@ describe("ServiceContainer", () => { }); it("closes desktop sessions during shutdown", async () => { - services = new ServiceContainer(config); + services = new ServiceContainer(stores); const closeAllSpy = spyOn(services.desktopSessionManager, "closeAll").mockImplementation(() => Promise.resolve(undefined) ); @@ -97,7 +99,7 @@ describe("ServiceContainer", () => { }); it("closes desktop sessions during dispose", async () => { - services = new ServiceContainer(config); + services = new ServiceContainer(stores); const closeAllSpy = spyOn(services.desktopSessionManager, "closeAll").mockImplementation(() => Promise.resolve(undefined) ); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 516e7368a4..45b0852849 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -2,7 +2,7 @@ import * as path from "path"; import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBehavior"; import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; import { log } from "@/node/services/log"; -import type { Config } from "@/node/config"; +import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; import { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { createCoreServices, type CoreServices } from "@/node/services/coreServices"; import { PTYService } from "@/node/services/ptyService"; @@ -90,6 +90,7 @@ import type { ORPCContext } from "@/node/orpc/context"; export class ServiceContainer { public readonly workflowRuntimeFactory = new QuickJSRuntimeFactory(); public readonly config: Config; + public readonly sessionLocator: WorkspaceSessionLocator; public readonly providersConfigStore: ProvidersConfigStore; public readonly secretsStore: SecretsStore; public readonly fileLeaseManager: FileLeaseManager; @@ -155,11 +156,13 @@ export class ServiceContainer { public readonly heartbeatService: HeartbeatService; public readonly agentStatusService: AgentStatusService; - constructor(config: Config) { + constructor(stores: ConfigStores) { + const config = stores.config; this.config = config; - this.providersConfigStore = new ProvidersConfigStore(config.rootDir); - this.secretsStore = new SecretsStore(config.rootDir); - this.fileLeaseManager = new FileLeaseManager(config.rootDir); + this.sessionLocator = stores.sessionLocator; + this.providersConfigStore = stores.providersConfig; + this.secretsStore = stores.secrets; + this.fileLeaseManager = stores.fileLeases; // Cross-cutting services: created first so they can be passed to core // services via constructor params (no setter injection needed). @@ -186,6 +189,7 @@ export class ServiceContainer { const core = createCoreServices({ config, + sessionLocator: this.sessionLocator, providersConfigStore: this.providersConfigStore, secretsStore: this.secretsStore, fileLeaseManager: this.fileLeaseManager, @@ -647,6 +651,7 @@ export class ServiceContainer { return { workflowRuntimeFactory: this.workflowRuntimeFactory, config: this.config, + sessionLocator: this.sessionLocator, providersConfigStore: this.providersConfigStore, secretsStore: this.secretsStore, fileLeaseManager: this.fileLeaseManager, diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index 9d3dfc978d..fbf19014ca 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -1,9 +1,9 @@ -import { ProvidersConfigStore } from "@/node/config"; +import { createConfigStores } from "@/node/config"; import * as os from "os"; import * as path from "path"; import * as fs from "fs/promises"; import type { BrowserWindow, WebContents } from "electron"; -import { Config } from "../../src/node/config"; +import type { Config } from "../../src/node/config"; import { ServiceContainer } from "../../src/node/services/serviceContainer"; import { setOpenSSHHostKeyPolicyMode } from "../../src/node/runtime/sshConnectionPool"; import { @@ -59,7 +59,8 @@ export async function createTestEnvironment(): Promise { const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "mux-test-")); // Create config with temporary directory - const config = new Config(tempDir); + const stores = createConfigStores(tempDir); + const config = stores.config; // Some UI tests render ProjectPage, which now hard-blocks workspace creation when no providers // are configured. For non-integration tests, seed a dummy provider so the UI can render. @@ -67,7 +68,7 @@ export async function createTestEnvironment(): Promise { // For integration tests (TEST_INTEGRATION=1), do NOT write dummy keys here (they would override // real env-backed credentials used by tests like name generation). if (!shouldRunIntegrationTests()) { - new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + stores.providersConfig.saveProvidersConfig({ anthropic: { apiKey: "test-key-for-ui-tests" }, }); } @@ -76,7 +77,7 @@ export async function createTestEnvironment(): Promise { const mockWindow = createMockBrowserWindow(); // Create ServiceContainer instance - const services = new ServiceContainer(config); + const services = new ServiceContainer(stores); // IPC tests run SSH against Docker containers with ephemeral host keys and no // interactive UI for host-key approval. Reset to headless-fallback so the // ServiceContainer's "strict" mode doesn't block Docker SSH connections. From 08da94bb4de20aeede11e63f42638880e7ef98b9 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:24:21 +0000 Subject: [PATCH 08/17] =?UTF-8?q?=F0=9F=A4=96=20tests(config):=20consolida?= =?UTF-8?q?te=20redundant=20coverage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/config.test.ts | 859 +--------------------------------------- 1 file changed, 12 insertions(+), 847 deletions(-) diff --git a/src/node/config.test.ts b/src/node/config.test.ts index b93a51275b..c4cec86be4 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -5,14 +5,6 @@ import * as os from "os"; import { log } from "@/node/services/log"; import { Config } from "./config"; import { DEFAULT_TASK_SETTINGS } from "@/common/types/tasks"; -import { - CODER_ARCHIVE_BEHAVIORS, - DEFAULT_CODER_ARCHIVE_BEHAVIOR, -} from "@/common/config/coderArchiveBehavior"; -import { - DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR, - WORKTREE_ARCHIVE_BEHAVIORS, -} from "@/common/config/worktreeArchiveBehavior"; import { KNOWN_MODELS } from "@/common/constants/knownModels"; import { MULTI_PROJECT_CONFIG_KEY } from "@/common/constants/multiProject"; import type { WorkspaceMetadata } from "@/common/types/workspace"; @@ -70,39 +62,6 @@ describe("Config", () => { errorSpy.mockRestore(); }); - it("does not create duplicate backups for identical corrupt bytes", () => { - const corruptData = '{ "projects": '; - fs.writeFileSync(configFilePath(), corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - config.loadConfigOrDefault(); - config.loadConfigOrDefault(); - new Config(tempDir).loadConfigOrDefault(); - - const backups = corruptBackups(); - expect(backups).toHaveLength(1); - expect(fs.readFileSync(backups[0])).toEqual(Buffer.from(corruptData)); - errorSpy.mockRestore(); - }); - - it.each(["null", '"just a string"', "[1,2]"])( - "recovers from a non-object JSON root: %s", - (corruptData) => { - const configFile = configFilePath(); - fs.writeFileSync(configFile, corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - const loaded = config.loadConfigOrDefault(); - - expect(loaded.projects.size).toBe(0); - expect(fs.readFileSync(configFile, "utf-8")).toBe(corruptData); - const backups = corruptBackups(); - expect(backups).toHaveLength(1); - expect(fs.readFileSync(backups[0])).toEqual(Buffer.from(corruptData)); - errorSpy.mockRestore(); - } - ); - it("heals invalid fields in an object without creating a backup", () => { fs.writeFileSync( configFilePath(), @@ -167,46 +126,6 @@ describe("Config", () => { errorSpy.mockRestore(); }); - it("retries the backup on the next load after a transient failure", () => { - const corruptData = '{ "projects": '; - fs.writeFileSync(configFilePath(), corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - const writeSpy = spyOn(fs, "writeFileSync").mockImplementationOnce(() => { - throw new Error("disk full"); - }); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.projects.size).toBe(0); - expect(corruptBackups()).toHaveLength(0); - - const retried = config.loadConfigOrDefault(); - expect(retried.projects.size).toBe(0); - const backups = corruptBackups(); - expect(backups).toHaveLength(1); - expect(fs.readFileSync(backups[0])).toEqual(Buffer.from(corruptData)); - - writeSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it("never overwrites an existing sidecar on a timestamp collision", () => { - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - fs.writeFileSync(configFile, corruptData); - const olderSnapshot = "older corrupt snapshot"; - const nowSpy = spyOn(Date, "now").mockReturnValue(1234567890); - const collidingPath = `${configFile}.corrupt-1234567890`; - fs.writeFileSync(collidingPath, olderSnapshot); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - config.loadConfigOrDefault(); - - expect(fs.readFileSync(collidingPath, "utf-8")).toBe(olderSnapshot); - expect(fs.readFileSync(`${collidingPath}-1`)).toEqual(Buffer.from(corruptData)); - nowSpy.mockRestore(); - errorSpy.mockRestore(); - }); - it("does not overwrite the corrupt config through edits until a backup is confirmed", async () => { const configFile = configFilePath(); const corruptData = '{ "projects": '; @@ -244,152 +163,6 @@ describe("Config", () => { errorSpy.mockRestore(); }); - it("re-blocks edits when new corrupt bytes appear after a confirmed backup", async () => { - const configFile = configFilePath(); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - fs.writeFileSync(configFile, '{ "projects": '); - config.loadConfigOrDefault(); - expect(corruptBackups()).toHaveLength(1); - - // Different corrupt bytes whose own backup fails must not inherit the - // earlier confirmation, or an edit would overwrite the only copy. - const newCorrupt = '{ "taskSettings": '; - fs.writeFileSync(configFile, newCorrupt); - const origWrite = fs.writeFileSync.bind(fs); - const writeSpy = spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - if (typeof file === "string" && file.includes(".corrupt-")) { - throw new Error("disk full"); - } - origWrite(file, data, options); - }); - - const blockedError = await config.setUpdateChannel("nightly").then( - () => null, - (e: unknown) => e - ); - expect(String(blockedError)).toContain("no confirmed backup yet"); - expect(fs.readFileSync(configFile, "utf-8")).toBe(newCorrupt); - expect(corruptBackups()).toHaveLength(1); - - writeSpy.mockRestore(); - - await config.setUpdateChannel("nightly"); - expect(corruptBackups()).toHaveLength(2); - const rewritten = JSON.parse(fs.readFileSync(configFile, "utf-8")) as { - updateChannel?: unknown; - }; - expect(rewritten.updateChannel).toBe("nightly"); - errorSpy.mockRestore(); - }); - - it("re-creates a deleted sidecar before an edit may overwrite the corrupt config", async () => { - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - fs.writeFileSync(configFile, corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - config.loadConfigOrDefault(); - const [firstBackup] = corruptBackups(); - fs.rmSync(firstBackup); - - await config.setUpdateChannel("nightly"); - - // The edit-time load must have re-verified against disk and restored preservation. - const backups = corruptBackups(); - expect(backups).toHaveLength(1); - expect(fs.readFileSync(backups[0])).toEqual(Buffer.from(corruptData)); - const rewritten = JSON.parse(fs.readFileSync(configFile, "utf-8")) as { - updateChannel?: unknown; - }; - expect(rewritten.updateChannel).toBe("nightly"); - errorSpy.mockRestore(); - }); - - it("skips an unreadable stale sidecar and still creates a fresh backup", () => { - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - fs.writeFileSync(configFile, corruptData); - const stalePath = `${configFile}.corrupt-1`; - fs.writeFileSync(stalePath, "older unrelated snapshot"); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - const origRead = fs.readFileSync.bind(fs); - const readSpy = spyOn(fs, "readFileSync").mockImplementation((( - file: fs.PathOrFileDescriptor, - options?: Parameters[1] - ) => { - if (file === stalePath) { - throw Object.assign(new Error("EACCES: permission denied"), { code: "EACCES" }); - } - return origRead(file, options); - }) as typeof fs.readFileSync); - - const loaded = config.loadConfigOrDefault(); - - expect(loaded.projects.size).toBe(0); - const fresh = corruptBackups().filter((p) => p !== stalePath); - expect(fresh).toHaveLength(1); - expect(origRead(fresh[0])).toEqual(Buffer.from(corruptData)); - readSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it("logs again when a previously confirmed backup can no longer be restored", () => { - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - fs.writeFileSync(configFile, corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - config.loadConfigOrDefault(); - expect(errorSpy).toHaveBeenCalledTimes(1); - - const [backupPath] = corruptBackups(); - fs.rmSync(backupPath); - const origWrite = fs.writeFileSync.bind(fs); - const writeSpy = spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - if (typeof file === "string" && file.includes(".corrupt-")) { - throw new Error("disk full"); - } - origWrite(file, data, options); - }); - - // Losing the backup must surface the new failure, not stay deduped. - config.loadConfigOrDefault(); - expect(errorSpy).toHaveBeenCalledTimes(2); - expect(String(errorSpy.mock.calls[1]?.[0])).toContain("Backup failed"); - - // The still-failing state stays deduped afterwards. - config.loadConfigOrDefault(); - expect(errorSpy).toHaveBeenCalledTimes(2); - - writeSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it("logs again when the backup is re-verified at a different path", () => { - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - fs.writeFileSync(configFile, corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - const nowSpy = spyOn(Date, "now").mockReturnValue(1000); - - config.loadConfigOrDefault(); - expect(errorSpy).toHaveBeenCalledTimes(1); - - // Delete the confirmed sidecar; recreation at a new path must replace the stale - // guidance that points at the deleted file. - fs.rmSync(`${configFile}.corrupt-1000`); - nowSpy.mockReturnValue(2000); - config.loadConfigOrDefault(); - - expect(errorSpy).toHaveBeenCalledTimes(2); - expect(String(errorSpy.mock.calls[1]?.[0])).toContain("corrupt-2000"); - - config.loadConfigOrDefault(); - expect(errorSpy).toHaveBeenCalledTimes(2); - nowSpy.mockRestore(); - errorSpy.mockRestore(); - }); - it("creates sidecars with owner-only permissions", () => { if (process.platform === "win32") { return; @@ -412,90 +185,6 @@ describe("Config", () => { } }); - it("creates readable owner-only sidecars even under a restrictive umask", () => { - if (process.platform === "win32") { - return; - } - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - // Write the corrupt config while file creation still works normally; only the - // sidecar creation should run under the restrictive umask. - fs.writeFileSync(configFile, corruptData); - // A 0777 umask strips the requested creation mode to 0000; the backup must still - // end up readable or recovery guidance points at an unusable file. - const prevUmask = process.umask(0o777); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - try { - config.loadConfigOrDefault(); - - const [backup] = corruptBackups(); - expect(fs.statSync(backup).mode & 0o777).toBe(0o600); - expect(fs.readFileSync(backup)).toEqual(Buffer.from(corruptData)); - } finally { - process.umask(prevUmask); - errorSpy.mockRestore(); - } - }); - - it("tightens permissions on a reused permissive sidecar", () => { - if (process.platform === "win32") { - return; - } - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - const prevUmask = process.umask(0o022); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - try { - fs.writeFileSync(configFile, corruptData); - // A byte-identical sidecar left by an older build without the 0600 fix. - const stale = `${configFile}.corrupt-1`; - fs.writeFileSync(stale, corruptData); - fs.chmodSync(stale, 0o644); - - config.loadConfigOrDefault(); - - expect(corruptBackups()).toHaveLength(1); - expect(fs.statSync(stale).mode & 0o777).toBe(0o600); - } finally { - process.umask(prevUmask); - errorSpy.mockRestore(); - } - }); - - it("reuses a later usable duplicate when the first cannot be secured", () => { - const configFile = configFilePath(); - const corruptData = '{ "projects": '; - fs.writeFileSync(configFile, corruptData); - const untightenable = `${configFile}.corrupt-1`; - const usable = `${configFile}.corrupt-2`; - fs.writeFileSync(untightenable, corruptData); - fs.writeFileSync(usable, corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - const origChmod = fs.chmodSync.bind(fs); - const chmodSpy = spyOn(fs, "chmodSync").mockImplementation((file, mode) => { - if (file === untightenable) { - throw Object.assign(new Error("EPERM: operation not permitted"), { code: "EPERM" }); - } - origChmod(file, mode); - }); - // Creating a fresh sidecar must not be needed: the usable duplicate is reused. - const origWrite = fs.writeFileSync.bind(fs); - const writeSpy = spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - if (typeof file === "string" && file.includes(".corrupt-") && file !== untightenable) { - throw new Error("unexpected sidecar creation"); - } - origWrite(file, data, options); - }); - - config.loadConfigOrDefault(); - - expect(corruptBackups().sort()).toEqual([untightenable, usable].sort()); - expect(String(errorSpy.mock.calls[0]?.[0])).toContain(usable); - writeSpy.mockRestore(); - chmodSpy.mockRestore(); - errorSpy.mockRestore(); - }); - it("aborts an edit write when the corrupt file changed after the edit loaded it", async () => { const configFile = configFilePath(); const corruptA = '{ "projects": '; @@ -529,63 +218,6 @@ describe("Config", () => { expect(rewritten.updateChannel).toBe("nightly"); errorSpy.mockRestore(); }); - - it("dedupes repeated backup failures whose messages embed the generated path", () => { - const configFile = configFilePath(); - fs.writeFileSync(configFile, '{ "projects": '); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - const nowSpy = spyOn(Date, "now"); - const origWrite = fs.writeFileSync.bind(fs); - const writeSpy = spyOn(fs, "writeFileSync").mockImplementation((file, data, options) => { - if (typeof file === "string" && file.includes(".corrupt-")) { - throw Object.assign(new Error(`ENOSPC: no space left on device, open '${file}'`), { - code: "ENOSPC", - }); - } - origWrite(file, data, options); - }); - - nowSpy.mockReturnValue(1000); - config.loadConfigOrDefault(); - nowSpy.mockReturnValue(2000); - config.loadConfigOrDefault(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - writeSpy.mockRestore(); - nowSpy.mockRestore(); - errorSpy.mockRestore(); - }); - - it("logs a corrupt config once across Config instances on the same path", () => { - const corruptData = '{ "projects": '; - fs.writeFileSync(configFilePath(), corruptData); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - config.loadConfigOrDefault(); - new Config(tempDir).loadConfigOrDefault(); - - expect(errorSpy).toHaveBeenCalledTimes(1); - errorSpy.mockRestore(); - }); - - it("recovers from an unreadable config without creating a backup", () => { - const configFile = configFilePath(); - // A directory at the config path makes readFileSync fail before any bytes exist. - fs.mkdirSync(configFile); - const errorSpy = spyOn(log, "error").mockImplementation(() => undefined); - - const loaded = config.loadConfigOrDefault(); - - expect(loaded.projects.size).toBe(0); - expect(corruptBackups()).toHaveLength(0); - expect(errorSpy).toHaveBeenCalledTimes(1); - expect(String(errorSpy.mock.calls[0]?.[0])).toContain(configFile); - - // Repeated loads of the same unreadable state stay deduped. - config.loadConfigOrDefault(); - expect(errorSpy).toHaveBeenCalledTimes(1); - errorSpy.mockRestore(); - }); }); describe("loadConfigOrDefault settingsBackup sanitizing", () => { @@ -1593,90 +1225,19 @@ describe("Config", () => { }); }); - describe("chat transcript settings", () => { - it("persists the full-width transcript flag", async () => { - await config.editConfig((cfg) => { - cfg.chatTranscriptFullWidth = true; - return cfg; - }); + describe("projectKind normalization", () => { + it("normalizes unknown projectKind to user semantics on load", () => { + const configFile = path.join(tempDir, "config.json"); + fs.writeFileSync( + configFile, + JSON.stringify({ + projects: [["/repo", { workspaces: [], projectKind: "experimental" }]], + }) + ); - const restartedConfig = new Config(tempDir); - expect(restartedConfig.loadConfigOrDefault().chatTranscriptFullWidth).toBe(true); - - const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { - chatTranscriptFullWidth?: unknown; - }; - expect(raw.chatTranscriptFullWidth).toBe(true); - }); - - it("omits the full-width transcript flag when disabled", async () => { - await config.editConfig((cfg) => { - cfg.chatTranscriptFullWidth = false; - return cfg; - }); - - const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { - chatTranscriptFullWidth?: unknown; - }; - expect(raw.chatTranscriptFullWidth).toBeUndefined(); - }); - - it("ignores invalid full-width transcript values on load", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - chatTranscriptFullWidth: "yes", - }) - ); - - expect(config.loadConfigOrDefault().chatTranscriptFullWidth).toBeUndefined(); - }); - }); - - describe("api server settings", () => { - it("should persist apiServerBindHost, apiServerPort, and apiServerServeWebUi", async () => { - await config.editConfig((cfg) => { - cfg.apiServerBindHost = "0.0.0.0"; - cfg.apiServerPort = 3000; - cfg.apiServerServeWebUi = true; - return cfg; - }); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.apiServerBindHost).toBe("0.0.0.0"); - expect(loaded.apiServerPort).toBe(3000); - expect(loaded.apiServerServeWebUi).toBe(true); - }); - - it("should ignore invalid apiServerPort values on load", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - projects: [], - apiServerPort: 70000, - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.apiServerPort).toBeUndefined(); - }); - }); - - describe("projectKind normalization", () => { - it("normalizes unknown projectKind to user semantics on load", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - projects: [["/repo", { workspaces: [], projectKind: "experimental" }]], - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.projects.get("/repo")?.projectKind).toBeUndefined(); - }); + const loaded = config.loadConfigOrDefault(); + expect(loaded.projects.get("/repo")?.projectKind).toBeUndefined(); + }); it("preserves valid projectKind 'system' on load", () => { const configFile = path.join(tempDir, "config.json"); @@ -2113,360 +1674,6 @@ describe("Config", () => { }); }); - describe("update channel preference", () => { - it("defaults to stable when no channel is configured", () => { - expect(config.getUpdateChannel()).toBe("stable"); - }); - - it("persists nightly channel selection", async () => { - await config.setUpdateChannel("nightly"); - - const restartedConfig = new Config(tempDir); - expect(restartedConfig.getUpdateChannel()).toBe("nightly"); - - const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { - updateChannel?: unknown; - }; - expect(raw.updateChannel).toBe("nightly"); - }); - - it("persists explicit stable channel selection", async () => { - await config.setUpdateChannel("nightly"); - await config.setUpdateChannel("stable"); - - const restartedConfig = new Config(tempDir); - expect(restartedConfig.getUpdateChannel()).toBe("stable"); - - const raw = JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { - updateChannel?: unknown; - }; - expect(raw.updateChannel).toBe("stable"); - }); - }); - - describe("server GitHub owner auth setting", () => { - it("persists serverAuthGithubOwner", async () => { - await config.editConfig((cfg) => { - cfg.serverAuthGithubOwner = "octocat"; - return cfg; - }); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.serverAuthGithubOwner).toBe("octocat"); - expect(config.getServerAuthGithubOwner()).toBe("octocat"); - }); - - it("ignores empty serverAuthGithubOwner values on load", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - projects: [], - serverAuthGithubOwner: " ", - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.serverAuthGithubOwner).toBeUndefined(); - }); - }); - - describe("top-level settings loading", () => { - it("loads top-level settings even when projects is missing", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - muxGovernorUrl: "https://governor.example.com", - terminalDefaultShell: "zsh", - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.projects.size).toBe(0); - expect(loaded.muxGovernorUrl).toBe("https://governor.example.com"); - expect(loaded.terminalDefaultShell).toBe("zsh"); - }); - - it("round-trips the legacy 1Password account name across unrelated saves", async () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ onePasswordAccountName: "my-team.1password.com" }) - ); - - await config.editConfig((current) => ({ - ...current, - terminalDefaultShell: "zsh", - })); - - const raw = JSON.parse(fs.readFileSync(configFile, "utf-8")) as Record; - expect(raw.onePasswordAccountName).toBe("my-team.1password.com"); - expect(raw.terminalDefaultShell).toBe("zsh"); - }); - }); - - describe("coderWorkspaceArchiveBehavior", () => { - const readRawArchiveConfig = () => - JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { - coderWorkspaceArchiveBehavior?: unknown; - stopCoderWorkspaceOnArchive?: unknown; - terminalDefaultShell?: unknown; - }; - - const legacyBooleanForBehavior = (behavior: string): false | undefined => - behavior === "keep" ? false : undefined; - - for (const behavior of CODER_ARCHIVE_BEHAVIORS) { - it(`loads the new enum value ${behavior}`, () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - coderWorkspaceArchiveBehavior: behavior, - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.coderWorkspaceArchiveBehavior).toBe(behavior); - expect(loaded.stopCoderWorkspaceOnArchive).toBe(legacyBooleanForBehavior(behavior)); - }); - } - - it("resolves legacy false to keep when the enum is missing", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - stopCoderWorkspaceOnArchive: false, - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.coderWorkspaceArchiveBehavior).toBe("keep"); - expect(loaded.stopCoderWorkspaceOnArchive).toBe(false); - }); - - it("resolves legacy true or undefined to stop when the enum is missing", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - stopCoderWorkspaceOnArchive: true, - }) - ); - expect(config.loadConfigOrDefault().coderWorkspaceArchiveBehavior).toBe( - DEFAULT_CODER_ARCHIVE_BEHAVIOR - ); - - fs.writeFileSync(path.join(tempDir, "config.json"), JSON.stringify({ projects: [] })); - expect(config.loadConfigOrDefault().coderWorkspaceArchiveBehavior).toBe( - DEFAULT_CODER_ARCHIVE_BEHAVIOR - ); - }); - - it("prefers the new enum when both fields are present", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - coderWorkspaceArchiveBehavior: "delete", - stopCoderWorkspaceOnArchive: false, - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.coderWorkspaceArchiveBehavior).toBe("delete"); - expect(loaded.stopCoderWorkspaceOnArchive).toBeUndefined(); - }); - - it("falls back to stop when the enum value is invalid", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - coderWorkspaceArchiveBehavior: "hibernate", - terminalDefaultShell: "zsh", - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.coderWorkspaceArchiveBehavior).toBe(DEFAULT_CODER_ARCHIVE_BEHAVIOR); - expect(loaded.stopCoderWorkspaceOnArchive).toBeUndefined(); - expect(loaded.terminalDefaultShell).toBe("zsh"); - }); - - it("enum field takes precedence over legacy boolean on save", async () => { - // Simulate: user had "keep" (legacy false), then switches to "stop" via the new enum. - await config.editConfig((c) => ({ - ...c, - coderWorkspaceArchiveBehavior: "stop", - stopCoderWorkspaceOnArchive: false, - })); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.coderWorkspaceArchiveBehavior).toBe("stop"); - }); - - it("round-trips each behavior with the enum field and legacy shim", async () => { - for (const behavior of CODER_ARCHIVE_BEHAVIORS) { - await config.editConfig((cfg) => { - cfg.coderWorkspaceArchiveBehavior = behavior; - cfg.stopCoderWorkspaceOnArchive = legacyBooleanForBehavior(behavior); - return cfg; - }); - - const raw = readRawArchiveConfig(); - expect(raw.coderWorkspaceArchiveBehavior).toBe(behavior); - expect(raw.stopCoderWorkspaceOnArchive).toBe(legacyBooleanForBehavior(behavior)); - - const reloaded = new Config(tempDir).loadConfigOrDefault(); - expect(reloaded.coderWorkspaceArchiveBehavior).toBe(behavior); - expect(reloaded.stopCoderWorkspaceOnArchive).toBe(legacyBooleanForBehavior(behavior)); - } - }); - }); - - describe("worktreeArchiveBehavior", () => { - const readRawArchiveConfig = () => - JSON.parse(fs.readFileSync(path.join(tempDir, "config.json"), "utf-8")) as { - worktreeArchiveBehavior?: unknown; - deleteWorktreeOnArchive?: unknown; - }; - - for (const behavior of WORKTREE_ARCHIVE_BEHAVIORS) { - it(`loads the new enum value ${behavior}`, () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - worktreeArchiveBehavior: behavior, - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.worktreeArchiveBehavior).toBe(behavior); - expect(loaded.deleteWorktreeOnArchive).toBe(behavior === "delete"); - }); - } - - it("resolves legacy delete boolean when the enum is missing", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - deleteWorktreeOnArchive: true, - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.worktreeArchiveBehavior).toBe("delete"); - expect(loaded.deleteWorktreeOnArchive).toBe(true); - }); - - it("defaults to keep when the enum is missing and the legacy boolean is false/undefined", () => { - fs.writeFileSync( - path.join(tempDir, "config.json"), - JSON.stringify({ - projects: [], - deleteWorktreeOnArchive: false, - }) - ); - expect(config.loadConfigOrDefault().worktreeArchiveBehavior).toBe( - DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR - ); - - fs.writeFileSync(path.join(tempDir, "config.json"), JSON.stringify({ projects: [] })); - expect(config.loadConfigOrDefault().worktreeArchiveBehavior).toBe( - DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR - ); - }); - - it("round-trips each behavior with the enum field and legacy shim", async () => { - for (const behavior of WORKTREE_ARCHIVE_BEHAVIORS) { - await config.editConfig((cfg) => { - cfg.worktreeArchiveBehavior = behavior; - cfg.deleteWorktreeOnArchive = behavior === "delete"; - return cfg; - }); - - const raw = readRawArchiveConfig(); - expect(raw.worktreeArchiveBehavior).toBe(behavior); - expect(raw.deleteWorktreeOnArchive).toBe(behavior === "delete"); - - const reloaded = new Config(tempDir).loadConfigOrDefault(); - expect(reloaded.worktreeArchiveBehavior).toBe(behavior); - expect(reloaded.deleteWorktreeOnArchive).toBe(behavior === "delete"); - } - }); - }); - - describe("model preferences", () => { - it("should preserve explicit gateway-scoped defaultModel and hiddenModels", async () => { - await config.editConfig((cfg) => { - cfg.defaultModel = "mux-gateway:openai/gpt-4o"; - cfg.hiddenModels = [ - " mux-gateway:openai/gpt-4o-mini ", - "invalid-model", - "openai:gpt-4o-mini", - ]; - return cfg; - }); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.defaultModel).toBe("mux-gateway:openai/gpt-4o"); - expect(loaded.hiddenModels).toEqual(["mux-gateway:openai/gpt-4o-mini", "openai:gpt-4o-mini"]); - }); - - it("preserves explicit gateway-prefixed model strings on load", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - projects: [], - defaultModel: "mux-gateway:openai/gpt-4o", - hiddenModels: ["mux-gateway:openai/gpt-4o-mini"], - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.defaultModel).toBe("mux-gateway:openai/gpt-4o"); - expect(loaded.hiddenModels).toEqual(["mux-gateway:openai/gpt-4o-mini"]); - }); - - it("rejects malformed mux-gateway model strings on load", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - projects: [], - defaultModel: "mux-gateway:openai", // missing "/model" - hiddenModels: ["mux-gateway:openai", "openai:gpt-4o-mini"], - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.defaultModel).toBeUndefined(); - expect(loaded.hiddenModels).toEqual(["openai:gpt-4o-mini"]); - }); - - it("ignores invalid model preference values on load", () => { - const configFile = path.join(tempDir, "config.json"); - fs.writeFileSync( - configFile, - JSON.stringify({ - projects: [], - defaultModel: "gpt-4o", // missing provider - hiddenModels: ["openai:gpt-4o-mini", "bad"], - }) - ); - - const loaded = config.loadConfigOrDefault(); - expect(loaded.defaultModel).toBeUndefined(); - expect(loaded.hiddenModels).toEqual(["openai:gpt-4o-mini"]); - }); - }); - describe("agent AI defaults canonical shape", () => { it("preserves explicit gateway-scoped model strings in nested AI defaults", async () => { await config.editConfig((cfg) => { @@ -3180,48 +2387,6 @@ describe("Config", () => { }); }); - describe("config change notifications", () => { - it("emits for editConfig saves and stops after unsubscribe", async () => { - let notifications = 0; - const unsubscribe = config.onConfigChanged(() => { - notifications += 1; - }); - - await config.editConfig((cfg) => { - cfg.routePriority = ["openai:gpt-4o"]; - return cfg; - }); - - expect(notifications).toBe(1); - - unsubscribe(); - - await config.editConfig((cfg) => { - cfg.routeOverrides = { "openai:gpt-4o": "direct" }; - return cfg; - }); - - expect(notifications).toBe(1); - }); - }); - - describe("generateStableId", () => { - it("should generate a 10-character hex string", () => { - const id = config.generateStableId(); - expect(id).toMatch(/^[0-9a-f]{10}$/); - }); - - it("should generate unique IDs", () => { - const id1 = config.generateStableId(); - const id2 = config.generateStableId(); - const id3 = config.generateStableId(); - - expect(id1).not.toBe(id2); - expect(id2).not.toBe(id3); - expect(id1).not.toBe(id3); - }); - }); - describe("findWorkspace", () => { it("preserves the config key while exposing a real attribution path for multi-project workspaces", async () => { const primaryProjectPath = "/fake/project-a"; From d8fce1b4b186dd3becbd461fa40098032e83fda8 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sat, 29 Aug 2026 21:29:22 +0000 Subject: [PATCH 09/17] =?UTF-8?q?=F0=9F=A4=96=20refactor(config):=20align?= =?UTF-8?q?=20extracted=20stores=20with=20static=20checks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.mjs | 1 + src/cli/workflow.ts | 3 ++- src/node/services/agentSession.ts | 2 +- src/node/services/aiService.test.ts | 24 +++++++++++++++---- src/node/services/codexOauthService.test.ts | 2 +- src/node/services/serviceContainer.ts | 2 +- src/node/services/terminalService.test.ts | 7 +++--- src/node/services/workspaceGoalService.ts | 2 +- .../workspaceService.multiProject.test.ts | 7 ++---- 9 files changed, 31 insertions(+), 19 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 76b2519602..9fa955726b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1489,6 +1489,7 @@ export default defineConfig([ // TODO: Gradually migrate these to async operations files: [ "src/node/config/index.ts", + "src/node/config/**/*.ts", "src/cli/debug/**/*.ts", "src/node/git.ts", "src/desktop/main.ts", diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index 07a4d4e31f..c8fb80ecfb 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -22,7 +22,8 @@ import { defaultModel } from "@/common/utils/ai/models"; import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { getErrorMessage } from "@/common/utils/errors"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; -import { Config, ProvidersConfigStore, SecretsStore, createConfigStores } from "@/node/config"; +import { ProvidersConfigStore, SecretsStore, createConfigStores } from "@/node/config"; +import type { Config } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { AgentSession } from "@/node/services/agentSession"; import { CodexOauthService } from "@/node/services/codexOauthService"; diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 8add9f4ff0..683148611a 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -4303,7 +4303,7 @@ export class AgentSession { } const providersConfig = new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig(); - return providersConfig as unknown as ProvidersConfigMap | null; + return providersConfig as ProvidersConfigMap | null; } catch { // Best-effort read: if config cannot be loaded, keep null and rely on // built-in model limits. This matches prior behavior without crashing. diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index 8789817192..ebb1a8a963 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -75,6 +75,7 @@ interface BasicAIServiceParts { historyService: HistoryService; initStateManager: InitStateManager; providerService: ProviderService; + providersConfigStore: ProvidersConfigStore; service: AIService; } @@ -112,7 +113,8 @@ function createBasicAIService( const config = new Config(root); const historyService = new HistoryService(config); const initStateManager = new InitStateManager(config); - const providerService = new ProviderService(config); + const providersConfigStore = new ProvidersConfigStore(config.rootDir); + const providerService = new ProviderService(config, undefined, providersConfigStore); const service = new AIService( config, historyService, @@ -124,9 +126,19 @@ function createBasicAIService( undefined, undefined, options?.devToolsService, - options?.experimentsService + options?.experimentsService, + undefined, + undefined, + providersConfigStore ); - return { config, historyService, initStateManager, providerService, service }; + return { + config, + historyService, + initStateManager, + providerService, + providersConfigStore, + service, + }; } async function writeMainConfig(root: string, config: object): Promise { @@ -2518,6 +2530,7 @@ describe("AIService.streamMessage turn envelope", () => { interface TurnEnvelopeHarness { service: AIService; config: Config; + providersConfigStore: ProvidersConfigStore; startStreamCalls: TurnExecutionOptions[]; } @@ -2526,7 +2539,8 @@ describe("AIService.streamMessage turn envelope", () => { metadata: WorkspaceMetadata, options?: { allTools?: Record } ): TurnEnvelopeHarness { - const { config, historyService, initStateManager, service } = createBasicAIService(xumHomePath); + const { config, historyService, initStateManager, providersConfigStore, service } = + createBasicAIService(xumHomePath); const startStreamCalls: TurnExecutionOptions[] = []; stubCommonStreamMessageDependencies({ service, @@ -2537,7 +2551,7 @@ describe("AIService.streamMessage turn envelope", () => { startStreamCalls, allTools: options?.allTools, }); - return { service, config, startStreamCalls }; + return { service, config, providersConfigStore, startStreamCalls }; } async function streamTurn(harness: TurnEnvelopeHarness, workspaceId: string): Promise { diff --git a/src/node/services/codexOauthService.test.ts b/src/node/services/codexOauthService.test.ts index a8c1742f76..df8209b13e 100644 --- a/src/node/services/codexOauthService.test.ts +++ b/src/node/services/codexOauthService.test.ts @@ -1,4 +1,4 @@ -import { ProvidersConfigStore } from "@/node/config"; +import type { ProvidersConfigStore } from "@/node/config"; import { describe, it, expect, beforeEach, afterEach } from "bun:test"; import type { Result } from "@/common/types/result"; diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index 45b0852849..a07ec168f0 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -3,7 +3,7 @@ import { DEFAULT_CODER_ARCHIVE_BEHAVIOR } from "@/common/config/coderArchiveBeha import { DEFAULT_WORKTREE_ARCHIVE_BEHAVIOR } from "@/common/config/worktreeArchiveBehavior"; import { log } from "@/node/services/log"; import type { Config, ConfigStores, WorkspaceSessionLocator } from "@/node/config"; -import { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; +import type { FileLeaseManager, ProvidersConfigStore, SecretsStore } from "@/node/config"; import { createCoreServices, type CoreServices } from "@/node/services/coreServices"; import { PTYService } from "@/node/services/ptyService"; import type { TerminalWindowManager } from "@/desktop/terminalWindowManager"; diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 177af4000d..42557504c7 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -13,10 +13,9 @@ import * as fs from "fs/promises"; const NATIVE_TERMINAL_SESSIONS_DIR = `/tmp/xum-test-native-terminal-sessions-${process.pid}-${Date.now()}`; const getEffectiveSecretsMock = mock(() => [{ key: "TEST_SECRET", value: "secret-value" }]); -const mockSecretsStore = { getEffectiveSecrets: getEffectiveSecretsMock } as Pick< - SecretsStore, - "getEffectiveSecrets" ->; +const mockSecretsStore: Pick = { + getEffectiveSecrets: getEffectiveSecretsMock, +}; // Mock dependencies const mockConfig = { diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 9cd8579b1d..2cea9b9157 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -2643,7 +2643,7 @@ export class WorkspaceGoalService { private getProvidersConfigForPricing(): ProvidersConfigMap | null { const providersConfig = new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig(); - return providersConfig as unknown as ProvidersConfigMap | null; + return providersConfig as ProvidersConfigMap | null; } async requestPendingGoalContinuationDispatch(workspaceId: string): Promise { diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 8862e369b4..44b7111d33 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -400,11 +400,8 @@ describe("WorkspaceService executeBash runtime selection", () => { workspaceId, workspaceName, secretsStore: { - getEffectiveSecrets: getEffectiveSecretsMock as Pick< - SecretsStore, - "getEffectiveSecrets" - >["getEffectiveSecrets"], - } as Pick, + getEffectiveSecrets: getEffectiveSecretsMock, + }, }); try { const result = await harness.workspaceService.executeBash(workspaceId, "pwd"); From 20b3cba2428c474254c27c3624d440c3110a1792 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:19:43 +0000 Subject: [PATCH 10/17] =?UTF-8?q?=F0=9F=A4=96=20tests(config):=20repair=20?= =?UTF-8?q?extracted=20store=20harnesses?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../services/agentSession.disposeRace.test.ts | 10 ++--- src/node/services/aiService.test.ts | 20 ++++++---- .../services/providerModelFactory.test.ts | 21 +++++++--- src/node/services/terminalService.test.ts | 13 ++++--- src/node/services/workspaceService.test.ts | 38 ++++++++++--------- 5 files changed, 59 insertions(+), 43 deletions(-) diff --git a/src/node/services/agentSession.disposeRace.test.ts b/src/node/services/agentSession.disposeRace.test.ts index 1a1f436e8c..b45173602b 100644 --- a/src/node/services/agentSession.disposeRace.test.ts +++ b/src/node/services/agentSession.disposeRace.test.ts @@ -80,7 +80,7 @@ describe("AgentSession disposal race conditions", () => { const config: Config = { srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + sessionsDir: "/tmp", } as unknown as Config; const session = new AgentSession({ @@ -290,7 +290,7 @@ describe("AgentSession disposal race conditions", () => { const config: Config = { srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + sessionsDir: "/tmp", } as unknown as Config; const session = new AgentSession({ @@ -377,7 +377,7 @@ describe("AgentSession disposal race conditions", () => { const config: Config = { srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + sessionsDir: "/tmp", } as unknown as Config; const session = new AgentSession({ @@ -471,7 +471,7 @@ describe("AgentSession disposal race conditions", () => { const config: Config = { srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + sessionsDir: "/tmp", } as unknown as Config; const session = new AgentSession({ @@ -616,7 +616,7 @@ describe("AgentSession disposal race conditions", () => { const config: Config = { srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + sessionsDir: "/tmp", } as unknown as Config; const session = new AgentSession({ diff --git a/src/node/services/aiService.test.ts b/src/node/services/aiService.test.ts index ebb1a8a963..f11e5f3c4e 100644 --- a/src/node/services/aiService.test.ts +++ b/src/node/services/aiService.test.ts @@ -190,11 +190,11 @@ function createRecordingOpenAIFetch( function configureOpenAICodexOAuth( service: AIService, - config: Config, + providersConfigStore: ProvidersConfigStore, requests: RecordedFetchRequest[], options?: { defaultAuth?: "apiKey"; responseModel?: string; setOauthService?: boolean } ): void { - new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ + spyOn(providersConfigStore, "loadProvidersConfig").mockReturnValue({ openai: { apiKey: "test-openai-api-key", codexOauth: TEST_CODEX_OAUTH, @@ -874,9 +874,9 @@ describe("AIService.createModel (Codex OAuth routing)", () => { }, ])("$name", async ({ tempDirName, defaultAuth, endpointMatcher }) => { using xumHome = new DisposableTempDir(tempDirName); - const { config, service } = createBasicAIService(xumHome.path); + const { providersConfigStore, service } = createBasicAIService(xumHome.path); const requests: RecordedFetchRequest[] = []; - configureOpenAICodexOAuth(service, config, requests, { defaultAuth }); + configureOpenAICodexOAuth(service, providersConfigStore, requests, { defaultAuth }); await createGeneratedModel(service, KNOWN_MODELS.GPT.id, [ { role: "user", content: [{ type: "text", text: "Hello" }] }, @@ -889,9 +889,11 @@ describe("AIService.createModel (Codex OAuth routing)", () => { it("ensures Codex OAuth routed Responses requests include non-empty instructions", async () => { using xumHome = new DisposableTempDir("codex-oauth-instructions"); - const { config, service } = createBasicAIService(xumHome.path); + const { providersConfigStore, service } = createBasicAIService(xumHome.path); const requests: RecordedFetchRequest[] = []; - configureOpenAICodexOAuth(service, config, requests, { responseModel: "gpt-5.3-codex" }); + configureOpenAICodexOAuth(service, providersConfigStore, requests, { + responseModel: "gpt-5.3-codex", + }); const systemPrompt = "Test system prompt"; await createGeneratedModel(service, KNOWN_MODELS.GPT_53_CODEX.id, [ @@ -950,9 +952,11 @@ describe("AIService.createModel (Codex OAuth routing)", () => { it("filters out item_reference entries and preserves inline items when routing through Codex OAuth", async () => { using xumHome = new DisposableTempDir("codex-oauth-filter-refs"); - const { config, service } = createBasicAIService(xumHome.path); + const { providersConfigStore, service } = createBasicAIService(xumHome.path); const requests: RecordedFetchRequest[] = []; - configureOpenAICodexOAuth(service, config, requests, { responseModel: "gpt-5.3-codex" }); + configureOpenAICodexOAuth(service, providersConfigStore, requests, { + responseModel: "gpt-5.3-codex", + }); await createGeneratedModel(service, KNOWN_MODELS.GPT_53_CODEX.id, [ { role: "system", content: "You are a helpful assistant" }, diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index 286a1adbc2..b5b6f942d1 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -100,17 +100,26 @@ async function withTempConfig( run: ( config: Config, factory: ProviderModelFactory, - oauth: OauthServiceBindings + oauth: OauthServiceBindings, + providersConfigStore: ProvidersConfigStore ) => Promise | void ): Promise { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-provider-model-factory-")); try { const config = new Config(tmpDir); - const providerService = new ProviderService(config); + const providersConfigStore = new ProvidersConfigStore(config.rootDir); + const providerService = new ProviderService(config, undefined, providersConfigStore); const oauth: OauthServiceBindings = {}; - const factory = new ProviderModelFactory(config, providerService, undefined, oauth); - await run(config, factory, oauth); + const factory = new ProviderModelFactory( + config, + providerService, + undefined, + oauth, + undefined, + providersConfigStore + ); + await run(config, factory, oauth, providersConfigStore); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } @@ -1055,7 +1064,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { }); it("normalizes Request bodies for the Codex OAuth responses endpoint", async () => { - await withTempConfig(async (_config, factory, oauth) => { + await withTempConfig(async (_config, factory, oauth, providersConfigStore) => { const originalOpenAIRegistry = PROVIDER_REGISTRY.openai; const requests: Array<{ input: Parameters[0]; @@ -1104,7 +1113,7 @@ describe("ProviderModelFactory GitHub Copilot", () => { ); }; - spyOn(ProvidersConfigStore.prototype, "loadProvidersConfig").mockReturnValue({ + spyOn(providersConfigStore, "loadProvidersConfig").mockReturnValue({ openai: { codexOauth: auth, fetch: baseFetch, diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 42557504c7..83f9a5c8c6 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -34,6 +34,7 @@ const mockConfig = { projects: new Map(), terminalDefaultShell: undefined, })), + sessionsDir: NATIVE_TERMINAL_SESSIONS_DIR, srcDir: "/tmp", } as unknown as Config; @@ -50,7 +51,7 @@ function createConfigWithMetadata(metadata: { projects: new Map(), terminalDefaultShell: undefined, })), - getSessionDir: mock((id: string) => `${NATIVE_TERMINAL_SESSIONS_DIR}/${id}`), + sessionsDir: NATIVE_TERMINAL_SESSIONS_DIR, srcDir: "/tmp", } as unknown as Config; } @@ -990,7 +991,7 @@ describe("TerminalService.openNative", () => { projects: new Map(), terminalDefaultShell: undefined, })), - getSessionDir: mock((id: string) => `${NATIVE_TERMINAL_SESSIONS_DIR}/${id}`), + sessionsDir: NATIVE_TERMINAL_SESSIONS_DIR, srcDir: "/tmp", } as unknown as Config; @@ -1016,7 +1017,7 @@ describe("TerminalService.openNative", () => { projects: new Map(), terminalDefaultShell: undefined, })), - getSessionDir: mock((id: string) => `${NATIVE_TERMINAL_SESSIONS_DIR}/${id}`), + sessionsDir: NATIVE_TERMINAL_SESSIONS_DIR, srcDir: "/tmp", } as unknown as Config; @@ -1039,7 +1040,7 @@ describe("TerminalService.openNative", () => { projects: new Map(), terminalDefaultShell: undefined, })), - getSessionDir: mock((id: string) => `${NATIVE_TERMINAL_SESSIONS_DIR}/${id}`), + sessionsDir: NATIVE_TERMINAL_SESSIONS_DIR, srcDir: "/tmp", } as unknown as Config; @@ -1062,7 +1063,7 @@ describe("TerminalService.openNative", () => { projects: new Map(), terminalDefaultShell: undefined, })), - getSessionDir: mock((id: string) => `${NATIVE_TERMINAL_SESSIONS_DIR}/${id}`), + sessionsDir: NATIVE_TERMINAL_SESSIONS_DIR, srcDir: "/tmp", } as unknown as Config; @@ -1218,7 +1219,7 @@ describe("TerminalService.openNative", () => { // Session dir rooted under /dev/null: marker persistence (mkdir/writeFile) must fail. const configWithUnwritableSessions = { ...(configWithLocalWorkspace as unknown as Record), - getSessionDir: mock((id: string) => `/dev/null/sessions/${id}`), + sessionsDir: "/dev/null/sessions", } as unknown as Config; service = new TerminalService(configWithUnwritableSessions, mockPTYService, mockSecretsStore); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index 01a967a638..d75adfc1b4 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -16306,6 +16306,8 @@ describe("WorkspaceService archive lifecycle hooks", () => { const workspaceId = "ws-archive"; const projectPath = "/tmp/project"; const workspacePath = "/tmp/project/ws-archive"; + const sessionsDir = "/tmp/test/sessions"; + const externalEditorMarkerPath = path.join(sessionsDir, workspaceId, "external-editor-opened"); let workspaceService: WorkspaceService; let mockAIService: AIService; @@ -16349,7 +16351,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { const mockConfig: MockWorkspaceConfig = { srcDir: "/tmp/src", - sessionsDir: "/tmp/test/sessions", + sessionsDir, generateStableId: mock(() => "test-id"), findWorkspace: mock((id: string) => { if (id !== workspaceId) { @@ -16655,7 +16657,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("acquirePreInterruptionArchiveHold validates and arms the gate before turn interruption", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // In-flight user activity must refuse BEFORE the caller destroys delegated turns: the // sink's own gate runs only after interruption, when the turns are already lost. @@ -16713,7 +16715,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { // Released (e.g. the archive failed): admissions flow again. const allowed = await workspaceService.recordExternalEditorOpen(workspaceId, "tok-hold-2"); expect(allowed.success).toBe(true); - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); }); test("acquirePreInterruptionArchiveHold binds the stream exemption to the delegated turns", () => { @@ -16855,7 +16857,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("recordExternalEditorOpen refuses while the workspace is being archived", async () => { // A crashed prior run may have leaked the shared-session-dir marker; clear it first. - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); addToArchivingWorkspaces(workspaceService, workspaceId); const result = await workspaceService.recordExternalEditorOpen(workspaceId, "tok-refused"); @@ -16870,7 +16872,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("recordExternalEditorOpen rejects workspace IDs without a config entry", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // Unknown IDs never reach the marker path (which joins the raw ID beneath the sessions // directory), closing both stale-ID requests and traversal-crafted IDs. @@ -16882,7 +16884,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { } let markerExists = true; try { - await fsPromises.access("/tmp/test/sessions/external-editor-opened"); + await fsPromises.access(externalEditorMarkerPath); } catch { markerExists = false; } @@ -16893,7 +16895,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { test("recordExternalEditorOpen marks the workspace as having an untrackable app open", async () => { // A crashed prior run may have leaked the shared-session-dir marker; clear it first. - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); expect(await workspaceService.hasUntrackableExternalAppOpen(workspaceId)).toBe(false); const result = await workspaceService.recordExternalEditorOpen(workspaceId, "tok-marks"); @@ -16902,11 +16904,11 @@ describe("WorkspaceService archive lifecycle hooks", () => { // The durable marker outlives this test run; remove it so "not yet opened" assertions in // future runs (this fixture shares one session dir) stay deterministic. - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); }); test("recordExternalEditorOpenForLaunch rolls back a freshly created marker after a failed launch", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); const admitted = await workspaceService.recordExternalEditorOpenForLaunch(workspaceId); expect(admitted.success).toBe(true); @@ -16921,7 +16923,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("rollbackAfterFailedLaunch removes the marker when every open in a concurrent batch fails", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // Two first-time recordings overlap in flight: the second sees the marker written by the // first, but that in-flight marker must not masquerade as evidence of a real prior @@ -16942,7 +16944,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("rollbackRecordedEditorOpen redeems a renderer launch token", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // Client-generated token: the renderer knows it even when the recording response is // lost, so an ambiguous outcome can still be reconciled. @@ -16968,7 +16970,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("rollbackRecordedEditorOpen tombstones a token whose recording is still in flight", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // The renderer saw its recording RPC reject at the transport while the backend handler // was still persisting the marker, and rolled back immediately. The not-yet-registered @@ -16990,7 +16992,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("a failed marker persistence does not leave stale ancestry for the next attempt", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // Same filesystem hiccup hits both the probe (EACCES -> fail-closed "unknown", so the // batch records markerPreexisted: true) and the write. The failed attempt must discard @@ -17018,7 +17020,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { }); test("archive gating stays closed while an editor recording is in flight", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); // Freeze the recording at its marker write: the pending-recording count must keep the // untrackable-app probe true for the whole in-flight window even though no durable @@ -17051,7 +17053,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { // An earlier session's editor may still be running behind a pre-existing marker; a later // failed launch must not delete the evidence protecting it. await fsPromises.mkdir("/tmp/test/sessions", { recursive: true }); - await fsPromises.writeFile("/tmp/test/sessions/external-editor-opened", "earlier session"); + await fsPromises.writeFile(externalEditorMarkerPath, "earlier session"); const admitted = await workspaceService.recordExternalEditorOpenForLaunch(workspaceId); expect(admitted.success).toBe(true); @@ -17059,11 +17061,11 @@ describe("WorkspaceService archive lifecycle hooks", () => { await admitted.data.rollbackAfterFailedLaunch(); expect(await workspaceService.hasUntrackableExternalAppOpen(workspaceId)).toBe(true); - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); }); test("rollbackAfterFailedLaunch preserves the marker while another open holds launch evidence", async () => { - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); const failing = await workspaceService.recordExternalEditorOpenForLaunch(workspaceId); expect(failing.success).toBe(true); @@ -17076,7 +17078,7 @@ describe("WorkspaceService archive lifecycle hooks", () => { await failing.data.rollbackAfterFailedLaunch(); expect(await workspaceService.hasUntrackableExternalAppOpen(workspaceId)).toBe(true); - await fsPromises.rm("/tmp/test/sessions/external-editor-opened", { force: true }); + await fsPromises.rm(externalEditorMarkerPath, { force: true }); }); test("archive waits for a retained background-init settlement before proceeding", async () => { From 0390656a20c604e41608e55dcfab650f2e3af857 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:23:37 +0000 Subject: [PATCH 11/17] =?UTF-8?q?=F0=9F=A4=96=20fix(config):=20preserve=20?= =?UTF-8?q?custom=20session=20locators?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/debug/replay-verify.ts | 2 +- src/node/services/historyService.ts | 37 ++++++++++++------- src/node/services/replay/replayFixture.ts | 2 +- .../replay/replayVerify.fixture.test.ts | 2 +- src/node/services/workspaceService.test.ts | 36 +++++++++--------- 5 files changed, 44 insertions(+), 35 deletions(-) diff --git a/src/cli/debug/replay-verify.ts b/src/cli/debug/replay-verify.ts index f7463ab3d3..b43e6bc87c 100644 --- a/src/cli/debug/replay-verify.ts +++ b/src/cli/debug/replay-verify.ts @@ -21,7 +21,7 @@ export function resolveReplaySessionDir(workspaceId: string): { return { sessionDir: REPLAY_FIXTURE_DIR, historyService: new HistoryService({ - sessionsDir: REPLAY_FIXTURE_DIR, + getSessionDir: () => REPLAY_FIXTURE_DIR, // Read-only verification: rootDir only locates write locks/tombstones. rootDir: path.dirname(REPLAY_FIXTURE_DIR), }), diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 3fb33bb2a6..819467dd3f 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -177,6 +177,9 @@ export function hasCommitWorthyParts(parts: MuxMessage["parts"] | undefined): bo }); } +type HistorySessionLocation = Pick & + (Pick | Pick); + /** * HistoryService - Manages chat history persistence and sequence numbering * @@ -216,12 +219,18 @@ export class HistoryService { // Shared file operation lock across all workspace file services // This prevents deadlocks when operations compose while touching the same workspace files. private readonly fileLocks = workspaceFileLocks; - private readonly config: Pick; + private readonly config: HistorySessionLocation; - constructor(config: Pick) { + constructor(config: HistorySessionLocation) { this.config = config; } + private getSessionDir(workspaceId: string): string { + return "getSessionDir" in this.config + ? this.config.getSessionDir(workspaceId) + : path.join(this.config.sessionsDir, workspaceId); + } + async getSubagentTranscript( input: { taskId: string; requestingWorkspaceId?: string | null }, dependencies: SubagentTranscriptDependencies @@ -237,7 +246,7 @@ export class HistoryService { entry: SubagentTranscriptArtifactIndexEntry; } | null> => { const artifacts = await readSubagentTranscriptArtifactsFile( - path.join(this.config.sessionsDir, workspaceId) + this.getSessionDir(workspaceId) ); const entry = artifacts.artifactsByChildTaskId[taskId] ?? null; return entry ? { workspaceId, entry } : null; @@ -286,7 +295,7 @@ export class HistoryService { // Pending artifacts still have a live task session, so read it directly while it exists. if (!resolved) { if (requestingWorkspaceId && isDescendant) { - const taskSessionDir = path.join(this.config.sessionsDir, taskId); + const taskSessionDir = this.getSessionDir(taskId); const messages = await this.readTranscriptFromPaths({ workspaceId: taskId, chatPath: path.join(taskSessionDir, CHAT_FILE_NAME), @@ -432,7 +441,7 @@ export class HistoryService { partialPath?: string; logLabel: string; }): Promise { - const workspaceSessionDir = path.join(this.config.sessionsDir, params.workspaceId); + const workspaceSessionDir = this.getSessionDir(params.workspaceId); // Refuse path traversal from a corrupted transcript index. if (params.chatPath && !isPathInsideDir(workspaceSessionDir, params.chatPath)) { throw new Error("Refusing to read transcript outside workspace session dir"); @@ -485,11 +494,11 @@ export class HistoryService { } private getChatHistoryPath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), this.CHAT_FILE); + return path.join(this.getSessionDir(workspaceId), this.CHAT_FILE); } private getChatArchivePath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), this.CHAT_ARCHIVE_FILE); + return path.join(this.getSessionDir(workspaceId), this.CHAT_ARCHIVE_FILE); } private getTruncateTransactionPath(workspaceId: string): string { @@ -754,7 +763,7 @@ export class HistoryService { } private getPartialPath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), this.PARTIAL_FILE); + return path.join(this.getSessionDir(workspaceId), this.PARTIAL_FILE); } // ── Reverse-read infrastructure ───────────────────────────────────────────── @@ -1301,7 +1310,7 @@ export class HistoryService { } try { - await ensurePrivateDir(path.join(this.config.sessionsDir, targetWorkspaceId)); + await ensurePrivateDir(this.getSessionDir(targetWorkspaceId)); for (const [targetPath, contents] of [ [this.getChatArchivePath(targetWorkspaceId), snapshot.data.archive], [this.getChatHistoryPath(targetWorkspaceId), snapshot.data.chat], @@ -1874,7 +1883,7 @@ export class HistoryService { if (await isWorkspaceRemovalTombstoned(this.config.rootDir, workspaceId)) { return Err(`workspace ${workspaceId} was removed; refusing partial write`); } - const workspaceDir = path.join(this.config.sessionsDir, workspaceId); + const workspaceDir = this.getSessionDir(workspaceId); await ensurePrivateDir(workspaceDir); const partialPath = this.getPartialPath(workspaceId); @@ -2083,7 +2092,7 @@ export class HistoryService { message: MuxMessage ): Promise> { try { - const workspaceDir = path.join(this.config.sessionsDir, workspaceId); + const workspaceDir = this.getSessionDir(workspaceId); await ensurePrivateDir(workspaceDir); const historyPath = this.getChatHistoryPath(workspaceId); @@ -2210,7 +2219,7 @@ export class HistoryService { workspaceId: string, operation: () => Promise ): Promise { - const sessionDir = path.join(this.config.sessionsDir, workspaceId); + const sessionDir = this.getSessionDir(workspaceId); // Lock BEFORE any directory creation (r63): the lockfile lives outside // the session dir, and removal holds this same lock while it tombstones // and deletes — so a mutation serializes with removal instead of racing @@ -2329,7 +2338,7 @@ export class HistoryService { async () => { try { await this.refreshSequenceCounterUnderWriteLock(workspaceId); - const workspaceDir = path.join(this.config.sessionsDir, workspaceId); + const workspaceDir = this.getSessionDir(workspaceId); await ensurePrivateDir(workspaceDir); const historyPath = this.getChatHistoryPath(workspaceId); for (const message of messages) { @@ -2541,7 +2550,7 @@ export class HistoryService { // duplicate a foreign backend's sequences and let a later // updateHistory() replace an unrelated row. await this.refreshSequenceCounterUnderWriteLock(workspaceId); - await ensurePrivateDir(path.join(this.config.sessionsDir, workspaceId)); + await ensurePrivateDir(this.getSessionDir(workspaceId)); const historyPath = this.getChatHistoryPath(workspaceId); const messages = await this.readChatHistory(workspaceId); diff --git a/src/node/services/replay/replayFixture.ts b/src/node/services/replay/replayFixture.ts index 6f0c372aee..a63f156726 100644 --- a/src/node/services/replay/replayFixture.ts +++ b/src/node/services/replay/replayFixture.ts @@ -106,7 +106,7 @@ export function createReplayFixtureSessionContext( sessionDir, workspaceId, historyService: new HistoryService({ - sessionsDir: sessionDir, + getSessionDir: () => sessionDir, // Fixture writes take the history write lock under `/locks`; // lockfiles are transient (removed on release). rootDir: path.dirname(sessionDir), diff --git a/src/node/services/replay/replayVerify.fixture.test.ts b/src/node/services/replay/replayVerify.fixture.test.ts index d0919ed8ec..c9f908edfd 100644 --- a/src/node/services/replay/replayVerify.fixture.test.ts +++ b/src/node/services/replay/replayVerify.fixture.test.ts @@ -33,7 +33,7 @@ import { DurableEventJournal } from "@/node/utils/journal/durableEventJournal"; async function readFixtureHistory(): Promise { const historyService = new HistoryService({ - sessionsDir: REPLAY_FIXTURE_DIR, + getSessionDir: () => REPLAY_FIXTURE_DIR, rootDir: path.dirname(REPLAY_FIXTURE_DIR), }); const result = await collectFullHistory(historyService, REPLAY_FIXTURE_WORKSPACE_ID); diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index d75adfc1b4..e8fd182ebf 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -15815,7 +15815,10 @@ describe("WorkspaceService metadata listeners", () => { findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; - const mockExtensionMetadata: Partial = { setStreaming }; + const mockExtensionMetadata: Partial = { + isWorkspaceDeleted: mock(() => false), + setStreaming, + }; new WorkspaceService( mockConfig as Config, @@ -15875,7 +15878,10 @@ describe("WorkspaceService metadata listeners", () => { findWorkspace: mock(() => null), loadConfigOrDefault: mock(() => ({ projects: new Map() })), }; - const mockExtensionMetadata: Partial = { setTodoStatus }; + const mockExtensionMetadata: Partial = { + isWorkspaceDeleted: mock(() => false), + setTodoStatus, + }; new WorkspaceService( mockConfig as Config, @@ -15899,7 +15905,9 @@ describe("WorkspaceService metadata listeners", () => { await new Promise((resolve) => setTimeout(resolve, 0)); - expect(readTodosSpy).toHaveBeenCalledWith("/tmp/test/sessions"); + expect(readTodosSpy).toHaveBeenCalledWith( + path.join(mockConfig.sessionsDir ?? "", workspaceId) + ); expect(setTodoStatus).toHaveBeenCalledWith( workspaceId, { emoji: "🔄", message: "Run typecheck" }, @@ -19100,23 +19108,15 @@ describe("WorkspaceService init cancellation", () => { } as unknown as AgentSession; try { - const workspaceService = new WorkspaceService( - mockConfig as Config, + const workspaceService = createWorkspaceServiceForTest({ + config: mockConfig, historyService, - mockAIService, - mockInitStateManager as InitStateManager, - mockExtensionMetadataService as ExtensionMetadataService, - mockBackgroundProcessManager as BackgroundProcessManager, - undefined, - undefined, - undefined, - undefined, - undefined, - undefined, - { + aiService: mockAIService, + initStateManager: mockInitStateManager as InitStateManager, + secretsStore: { getEffectiveSecrets: mock(() => [{ key: "GH_TOKEN", value: "token" }]), - } as unknown as SecretsStore - ); + } as unknown as SecretsStore, + }); const metadataEvents: Array = []; workspaceService.on("metadata", (event: unknown) => { From baeaab32f3bdec0ab1521b3aa6cc7063e7d7d2b5 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 08:43:31 +0000 Subject: [PATCH 12/17] =?UTF-8?q?=F0=9F=A4=96=20tests(config):=20preserve?= =?UTF-8?q?=20hook=20replay=20session=20path?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/agentPlugins/hookService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/node/services/agentPlugins/hookService.test.ts b/src/node/services/agentPlugins/hookService.test.ts index a1069d6d6c..a4308014c1 100644 --- a/src/node/services/agentPlugins/hookService.test.ts +++ b/src/node/services/agentPlugins/hookService.test.ts @@ -633,7 +633,7 @@ describe("replay determinism with hooks active", () => { // ...and byte-level replay verification passes with the hook active. const historyService = new HistoryService({ - sessionsDir: harness.sessionDir, + getSessionDir: () => harness.sessionDir, rootDir: path.dirname(harness.sessionDir), }); const history = await collectFullHistory(historyService, REPLAY_FIXTURE_WORKSPACE_ID); From 06170088e62cd026138c161d2935596224f56152 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:06:51 +0000 Subject: [PATCH 13/17] =?UTF-8?q?=F0=9F=A4=96=20refactor(config):=20align?= =?UTF-8?q?=20store=20injection=20and=20session=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/cli/run.ts | 14 +++---- src/cli/workflow.ts | 33 +++++++-------- src/node/config.test.ts | 2 +- src/node/config/index.ts | 40 ++++++++----------- src/node/services/additionalSystemContext.ts | 2 +- src/node/services/agentSession.ts | 2 +- .../services/bashMonitorRegistryStore.test.ts | 2 +- .../services/bashMonitorWakeStore.test.ts | 10 ++--- src/node/services/bashMonitorWakeStore.ts | 2 +- src/node/services/coreServices.ts | 6 ++- src/node/services/devToolsService.test.ts | 2 +- src/node/services/devToolsService.ts | 2 +- src/node/services/historyService.test.ts | 8 ++-- .../memoryConsolidationService.test.ts | 2 +- src/node/services/memoryService.test.ts | 2 +- src/node/services/memoryService.ts | 4 +- .../services/providerModelFactory.test.ts | 15 ++++--- src/node/services/serviceContainer.ts | 12 ++---- .../services/sessionTimingService.test.ts | 2 +- src/node/services/sessionTimingService.ts | 2 +- src/node/services/sessionUsageService.test.ts | 4 +- src/node/services/sessionUsageService.ts | 2 +- src/node/services/taskHandleStore.ts | 2 +- src/node/services/taskService.test.ts | 2 +- .../services/terminalAttentionStore.test.ts | 2 +- src/node/services/terminalAttentionStore.ts | 2 +- src/node/services/terminalService.ts | 2 +- src/node/services/timelineService.test.ts | 6 +-- src/node/services/timelineService.ts | 2 +- src/node/services/tools/goal.test.ts | 2 +- .../services/workspaceGoalService.test.ts | 23 +++++------ src/node/services/workspaceGoalService.ts | 7 ++-- .../workspaceService.multiProject.test.ts | 2 +- src/node/services/workspaceService.test.ts | 2 +- src/node/services/workspaceService.ts | 7 ++-- .../worktreeArchiveSnapshotService.test.ts | 40 +++++++------------ src/node/utils/sessionFile.ts | 2 +- tests/ipc/setup.ts | 2 +- 38 files changed, 126 insertions(+), 149 deletions(-) diff --git a/src/cli/run.ts b/src/cli/run.ts index 59871d1357..973ddc48fb 100644 --- a/src/cli/run.ts +++ b/src/cli/run.ts @@ -519,8 +519,8 @@ async function main(): Promise { const config = runStores.config; // Copy providers and secrets from real config to ephemeral config - const realProvidersStore = realStores.providersConfig; - const runProvidersStore = runStores.providersConfig; + const realProvidersStore = realStores.providersConfigStore; + const runProvidersStore = runStores.providersConfigStore; const existingProviders = realProvidersStore.loadProvidersConfig(); const providersFile = path.join(config.rootDir, "providers.jsonc"); await replacePrivateRunConfigFile( @@ -531,7 +531,7 @@ async function main(): Promise { ); // Copy secrets so tools/MCP servers get project secrets (e.g., GH_TOKEN) - const existingSecrets = realStores.secrets.loadSecretsConfig(); + const existingSecrets = realStores.secretsStore.loadSecretsConfig(); const secretsFile = path.join(config.rootDir, "secrets.json"); await replacePrivateRunConfigFile( secretsFile, @@ -659,11 +659,7 @@ async function main(): Promise { streamManager, turnRequestBuilderBindings, } = createCoreServices({ - config, - sessionLocator: runStores.sessionLocator, - providersConfigStore: runStores.providersConfig, - secretsStore: runStores.secrets, - fileLeaseManager: runStores.fileLeases, + ...runStores, policyService, extensionMetadataPath: path.join(tempDir.path, "extensionMetadata.json"), // Session config lives in tempDir (deleted on exit) — disable workspace.* @@ -692,7 +688,7 @@ async function main(): Promise { // the refresh token on every use, so persisting rotations only to tempDir // would strand ~/.xum/providers.jsonc with a consumed (dead) refresh token // once this CLI session exits. - const realFileLeaseManager = realStores.fileLeases; + const realFileLeaseManager = realStores.fileLeaseManager; const realProviderService = new ProviderService( realConfig, policyService, diff --git a/src/cli/workflow.ts b/src/cli/workflow.ts index c8fb80ecfb..5ee7a77161 100644 --- a/src/cli/workflow.ts +++ b/src/cli/workflow.ts @@ -22,8 +22,8 @@ import { defaultModel } from "@/common/utils/ai/models"; import { normalizeModelInput } from "@/common/utils/ai/normalizeModelInput"; import { getErrorMessage } from "@/common/utils/errors"; import { resolveThinkingInput } from "@/common/utils/thinking/policy"; -import { ProvidersConfigStore, SecretsStore, createConfigStores } from "@/node/config"; -import type { Config } from "@/node/config"; +import { createConfigStores } from "@/node/config"; +import type { Config, ConfigStores } from "@/node/config"; import { createRuntime } from "@/node/runtime/runtimeFactory"; import { AgentSession } from "@/node/services/agentSession"; import { CodexOauthService } from "@/node/services/codexOauthService"; @@ -208,15 +208,20 @@ function generateWorkspaceId(): string { return `workflow-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; } -async function copyPersistentConfig(realConfig: Config, config: Config): Promise { - const realProvidersStore = new ProvidersConfigStore(realConfig.rootDir); +async function copyPersistentConfig( + realStores: ConfigStores, + runStores: ConfigStores +): Promise { + const realConfig = realStores.config; + const config = runStores.config; + const realProvidersStore = realStores.providersConfigStore; const existingProviders = realProvidersStore.loadProvidersConfig(); if (existingProviders != null && hasAnyConfiguredProvider(existingProviders)) { - new ProvidersConfigStore(config.rootDir).saveProvidersConfig(existingProviders); + runStores.providersConfigStore.saveProvidersConfig(existingProviders); } - const existingSecrets = new SecretsStore(realConfig.rootDir).loadSecretsConfig(); + const existingSecrets = realStores.secretsStore.loadSecretsConfig(); if (Object.keys(existingSecrets).length > 0) { - await new SecretsStore(config.rootDir).saveSecretsConfig(existingSecrets); + await runStores.secretsStore.saveSecretsConfig(existingSecrets); } const existingConfig = realConfig.loadConfigOrDefault(); @@ -342,11 +347,11 @@ async function createWorkflowContext(options: { const realConfig = realStores.config; const runStores = createConfigStores(tempDir.path); const config = runStores.config; - await copyPersistentConfig(realConfig, config); + await copyPersistentConfig(realStores, runStores); - const realProvidersStore = realStores.providersConfig; - const realFileLeaseManager = realStores.fileLeases; - const runProvidersStore = runStores.providersConfig; + const realProvidersStore = realStores.providersConfigStore; + const realFileLeaseManager = realStores.fileLeaseManager; + const runProvidersStore = runStores.providersConfigStore; const existingProviders = realProvidersStore.loadProvidersConfig(); if (!hasAnyConfiguredProvider(existingProviders)) { const providersFromEnv = buildProvidersFromEnv(); @@ -369,11 +374,7 @@ async function createWorkflowContext(options: { await policyService.initialize(); services = createCoreServices({ - config, - sessionLocator: runStores.sessionLocator, - providersConfigStore: runStores.providersConfig, - secretsStore: runStores.secrets, - fileLeaseManager: runStores.fileLeases, + ...runStores, policyService, extensionMetadataPath: path.join(tempDir.path, "extensionMetadata.json"), mcpConfig: realConfig, diff --git a/src/node/config.test.ts b/src/node/config.test.ts index c4cec86be4..34a0c61f3e 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -635,7 +635,7 @@ describe("Config", () => { ); // Basename-backed second candidate is unreadable: a directory at the // metadata.json path fails reads with EISDIR (non-ENOENT). - fs.mkdirSync(path.join(path.join(config.sessionsDir, "legacy-ws"), "metadata.json"), { + fs.mkdirSync(path.join(config.sessionsDir, "legacy-ws", "metadata.json"), { recursive: true, }); diff --git a/src/node/config/index.ts b/src/node/config/index.ts index 8116df3003..c44bb04cb8 100644 --- a/src/node/config/index.ts +++ b/src/node/config/index.ts @@ -23,7 +23,6 @@ import type { AppConfigOnDisk, BaseProviderConfig as ProviderConfig, ModelFallbacks, - ProvidersConfig as CanonicalProvidersConfig, } from "@/common/config/schemas"; import { DEFAULT_MODEL_FALLBACKS, sanitizeModelFallbacks } from "@/common/utils/ai/modelFallbacks"; import { DEFAULT_TASK_SETTINGS, normalizeTaskSettings } from "@/common/types/tasks"; @@ -85,7 +84,7 @@ import { deriveProjectHierarchy } from "@/common/utils/subProjects"; import { coerceThinkingLevel, type ThinkingLevel } from "@/common/types/thinking"; // Re-export project/provider types from dedicated schema/types files (for preload usage) -export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig, CanonicalProvidersConfig }; +export type { Workspace, ProjectConfig, ProjectsConfig, ProviderConfig }; export { FileLeaseManager } from "./fileLeaseManager"; export { ProvidersConfigStore, type ProvidersConfig } from "./providersConfigStore"; export { SecretsStore } from "./secretsStore"; @@ -162,7 +161,7 @@ function parseOptionalNonEmptyString(value: unknown): string | undefined { return trimmed ? trimmed : undefined; } -export interface LegacyTaskVariantGroup { +interface LegacyTaskVariantGroup { groupId: string; index: number; total: number; @@ -170,7 +169,7 @@ export interface LegacyTaskVariantGroup { label?: string; } -export interface LegacyTaskVariantWorkspace { +interface LegacyTaskVariantWorkspace { id: string; projectPath: string; parentWorkspaceId?: string; @@ -899,7 +898,6 @@ export class Config { readonly sessionsDir: string; readonly srcDir: string; private readonly configFile: string; - private readonly sessionLocator: WorkspaceSessionLocator; private readonly providersConfigStore: ProvidersConfigStore; private readonly emitter = new EventEmitter(); /** @@ -913,12 +911,8 @@ export class Config { /** One-shot guard for the queued load-time migration persist; see loadConfigOrDefault. */ private migrationPersist: Promise | null = null; - constructor( - rootDir?: string, - providersConfigStore?: ProvidersConfigStore, - sessionLocator = new WorkspaceSessionLocator(rootDir) - ) { - this.sessionLocator = sessionLocator; + constructor(rootDir?: string, providersConfigStore?: ProvidersConfigStore) { + const sessionLocator = new WorkspaceSessionLocator(rootDir); this.rootDir = sessionLocator.rootDir; this.sessionsDir = sessionLocator.sessionsDir; this.srcDir = sessionLocator.srcDir; @@ -1654,7 +1648,7 @@ export class Config { } const usagePath = path.join( - path.join(this.sessionLocator.sessionsDir, sessionEntry.name), + path.join(this.sessionsDir, sessionEntry.name), "session-usage.json" ); if (fs.existsSync(usagePath)) { @@ -2675,7 +2669,7 @@ export class Config { // Try loading metadata with basename as ID (works for old workspaces) const metadataPath = path.join( - path.join(this.sessionLocator.sessionsDir, workspaceBasename), + path.join(this.sessionsDir, workspaceBasename), "metadata.json" ); try { @@ -2727,7 +2721,7 @@ export class Config { // remains registered. const legacyId = this.generateLegacyId(projectPath, workspace.path); const legacyMetadataPath = path.join( - path.join(this.sessionLocator.sessionsDir, legacyId), + path.join(this.sessionsDir, legacyId), "metadata.json" ); try { @@ -3033,7 +3027,7 @@ export class Config { workspaceBasename === legacyId ? [legacyId] : [legacyId, workspaceBasename]; for (const candidateId of candidateIds) { const candidatePath = path.join( - path.join(this.sessionLocator.sessionsDir, candidateId), + path.join(this.sessionsDir, candidateId), "metadata.json" ); let candidateRaw: string | undefined; @@ -3503,18 +3497,18 @@ export class Config { export interface ConfigStores { config: Config; sessionLocator: WorkspaceSessionLocator; - providersConfig: ProvidersConfigStore; - secrets: SecretsStore; - fileLeases: FileLeaseManager; + providersConfigStore: ProvidersConfigStore; + secretsStore: SecretsStore; + fileLeaseManager: FileLeaseManager; } export function createConfigStores(rootDir?: string): ConfigStores { const sessionLocator = new WorkspaceSessionLocator(rootDir); - const providersConfig = new ProvidersConfigStore(sessionLocator.rootDir); - const secrets = new SecretsStore(sessionLocator.rootDir); - const fileLeases = new FileLeaseManager(sessionLocator.rootDir); - const config = new Config(sessionLocator.rootDir, providersConfig, sessionLocator); - return { config, sessionLocator, providersConfig, secrets, fileLeases }; + const providersConfigStore = new ProvidersConfigStore(sessionLocator.rootDir); + const secretsStore = new SecretsStore(sessionLocator.rootDir); + const fileLeaseManager = new FileLeaseManager(sessionLocator.rootDir); + const config = new Config(sessionLocator.rootDir, providersConfigStore); + return { config, sessionLocator, providersConfigStore, secretsStore, fileLeaseManager }; } const defaultStores = createConfigStores(); diff --git a/src/node/services/additionalSystemContext.ts b/src/node/services/additionalSystemContext.ts index 31b8a3c6f9..9a43244c39 100644 --- a/src/node/services/additionalSystemContext.ts +++ b/src/node/services/additionalSystemContext.ts @@ -26,7 +26,7 @@ export function getAdditionalSystemContextPath( config: SessionDirProvider, workspaceId: string ): string { - return path.join(path.join(config.sessionsDir, workspaceId), ADDITIONAL_SYSTEM_CONTEXT_FILENAME); + return path.join(config.sessionsDir, workspaceId, ADDITIONAL_SYSTEM_CONTEXT_FILENAME); } export function getAdditionalSystemContextDisabledPath( diff --git a/src/node/services/agentSession.ts b/src/node/services/agentSession.ts index 683148611a..bdb220d713 100644 --- a/src/node/services/agentSession.ts +++ b/src/node/services/agentSession.ts @@ -8147,7 +8147,7 @@ export class AgentSession { return null; } - const todoPath = path.join(path.join(this.config.sessionsDir, this.workspaceId), "todos.json"); + const todoPath = path.join(this.config.sessionsDir, this.workspaceId, "todos.json"); try { const data = await readFile(todoPath, "utf-8"); diff --git a/src/node/services/bashMonitorRegistryStore.test.ts b/src/node/services/bashMonitorRegistryStore.test.ts index 91f8911d6f..f8018b9cb8 100644 --- a/src/node/services/bashMonitorRegistryStore.test.ts +++ b/src/node/services/bashMonitorRegistryStore.test.ts @@ -78,7 +78,7 @@ describe("BashMonitorRegistryStore", () => { const config = makeConfig(rootDir); const store = new BashMonitorRegistryStore(config); await store.upsert(armedPayload()); - const dir = path.join(path.join(config.sessionsDir, "owner-1"), BASH_MONITOR_REGISTRY_DIR); + const dir = path.join(config.sessionsDir, "owner-1", BASH_MONITOR_REGISTRY_DIR); await fsPromises.writeFile(path.join(dir, "bad.json"), "not json", "utf-8"); await fsPromises.writeFile( path.join(dir, "wrong-shape.json"), diff --git a/src/node/services/bashMonitorWakeStore.test.ts b/src/node/services/bashMonitorWakeStore.test.ts index 8169929a9d..3129710677 100644 --- a/src/node/services/bashMonitorWakeStore.test.ts +++ b/src/node/services/bashMonitorWakeStore.test.ts @@ -3970,7 +3970,7 @@ describe("BashMonitorWakeStore", () => { const store = new BashMonitorWakeStore(config); await store.enqueueOrMergePending(payload()); await fsPromises.writeFile( - path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes", "bad.json"), + path.join(config.sessionsDir, "owner-1", "bash-monitor-wakes", "bad.json"), "not json", "utf-8" ); @@ -3982,7 +3982,7 @@ describe("BashMonitorWakeStore", () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); // Write a pre-kind record shape directly (what older builds persisted). - const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); + const dir = path.join(config.sessionsDir, "owner-1", "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-legacy.json"), @@ -4011,7 +4011,7 @@ describe("BashMonitorWakeStore", () => { test("legacy monitor-lost records without lostReason default to restart", async () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); - const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); + const dir = path.join(config.sessionsDir, "owner-1", "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-legacy-lost.json"), @@ -4042,7 +4042,7 @@ describe("BashMonitorWakeStore", () => { test("malformed lostReason values degrade to restart instead of dropping the record", async () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); - const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); + const dir = path.join(config.sessionsDir, "owner-1", "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-future-lost.json"), @@ -4075,7 +4075,7 @@ describe("BashMonitorWakeStore", () => { test("malformed failureMessage and partially unknown failedOperations degrade without dropping the record", async () => { const config = makeConfig(rootDir); const store = new BashMonitorWakeStore(config); - const dir = path.join(path.join(config.sessionsDir, "owner-1"), "bash-monitor-wakes"); + const dir = path.join(config.sessionsDir, "owner-1", "bash-monitor-wakes"); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, "proc-newer-lost.json"), diff --git a/src/node/services/bashMonitorWakeStore.ts b/src/node/services/bashMonitorWakeStore.ts index 4218ee48e9..7c8e1c4151 100644 --- a/src/node/services/bashMonitorWakeStore.ts +++ b/src/node/services/bashMonitorWakeStore.ts @@ -841,7 +841,7 @@ export class BashMonitorWakeStore { private dir(ownerWorkspaceId: string): string { assert(ownerWorkspaceId.trim().length > 0, "BashMonitorWakeStore requires ownerWorkspaceId"); - return path.join(path.join(this.config.sessionsDir, ownerWorkspaceId), BASH_MONITOR_WAKE_DIR); + return path.join(this.config.sessionsDir, ownerWorkspaceId, BASH_MONITOR_WAKE_DIR); } /** diff --git a/src/node/services/coreServices.ts b/src/node/services/coreServices.ts index ee90cda2bc..8edbee3449 100644 --- a/src/node/services/coreServices.ts +++ b/src/node/services/coreServices.ts @@ -166,7 +166,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { historyService, extensionMetadata, opts.analyticsService, - opts.goalServiceOptions + opts.goalServiceOptions, + providersConfigStore ); // Default-construct when the caller (CLI) does not pass one: workspace MCP @@ -278,7 +279,8 @@ export function createCoreServices(opts: CoreServicesOptions): CoreServices { opts.experimentsService, opts.sessionTimingService, streamManager, - secretsStore + secretsStore, + providersConfigStore ); turnRequestBuilderBindings.workspaceHeartbeatService = workspaceService; // Tool-started workflows share the same sidebar activity cache as ORPC-started workflows, diff --git a/src/node/services/devToolsService.test.ts b/src/node/services/devToolsService.test.ts index bfdc6d9ae5..ec4dd86f59 100644 --- a/src/node/services/devToolsService.test.ts +++ b/src/node/services/devToolsService.test.ts @@ -23,7 +23,7 @@ describe("DevToolsService removal gate (r64)", () => { workspaceId: liveId, startedAt: new Date().toISOString(), }); - const liveFile = path.join(path.join(config.sessionsDir, liveId), "devtools.jsonl"); + const liveFile = path.join(config.sessionsDir, liveId, "devtools.jsonl"); expect(await fs.readFile(liveFile, "utf8")).toContain("run-1"); // Removal-tombstoned workspace: with XUM_ALLOW_MULTIPLE_INSTANCES=1 a diff --git a/src/node/services/devToolsService.ts b/src/node/services/devToolsService.ts index d3154771f7..5824864ff5 100644 --- a/src/node/services/devToolsService.ts +++ b/src/node/services/devToolsService.ts @@ -411,7 +411,7 @@ export class DevToolsService extends EventEmitter { } private getSessionFilePath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), "devtools.jsonl"); + return path.join(this.config.sessionsDir, workspaceId, "devtools.jsonl"); } private getOrCreateWorkspaceData(workspaceId: string): WorkspaceData { diff --git a/src/node/services/historyService.test.ts b/src/node/services/historyService.test.ts index 542933889c..d967560208 100644 --- a/src/node/services/historyService.test.ts +++ b/src/node/services/historyService.test.ts @@ -533,7 +533,7 @@ describe("HistoryService", () => { createMuxMessage("foreign-1", "assistant", "foreign row", { historySequence: 7 }) ); await fs.appendFile( - path.join(path.join(config.sessionsDir, workspaceId), "chat.jsonl"), + path.join(config.sessionsDir, workspaceId, "chat.jsonl"), foreignLine + "\n" ); // Without the in-lock counter refresh this batch would assign stale @@ -569,7 +569,7 @@ describe("HistoryService", () => { createMuxMessage("foreign-1", "assistant", "foreign row", { historySequence: 7 }) ); await fs.appendFile( - path.join(path.join(config.sessionsDir, workspaceId), "chat.jsonl"), + path.join(config.sessionsDir, workspaceId, "chat.jsonl"), foreignLine + "\n" ); @@ -2040,11 +2040,11 @@ describe("HistoryService", () => { } function chatPath(workspaceId: string): string { - return path.join(path.join(config.sessionsDir, workspaceId), "chat.jsonl"); + return path.join(config.sessionsDir, workspaceId, "chat.jsonl"); } function archivePath(workspaceId: string): string { - return path.join(path.join(config.sessionsDir, workspaceId), "chat-archive.jsonl"); + return path.join(config.sessionsDir, workspaceId, "chat-archive.jsonl"); } it("rotates the sealed prefix into the archive when a boundary is appended", async () => { diff --git a/src/node/services/memoryConsolidationService.test.ts b/src/node/services/memoryConsolidationService.test.ts index 4a66f49280..34ca826bc0 100644 --- a/src/node/services/memoryConsolidationService.test.ts +++ b/src/node/services/memoryConsolidationService.test.ts @@ -459,7 +459,7 @@ describe("MemoryConsolidationService", () => { // the sweep itself must request the ingest. expect(ingests).toEqual([{ workspaceId: "ws-dream" }]); const sidecar = await fsPromises.readFile( - path.join(path.join(fixture.config.sessionsDir, "ws-dream"), "headless-usage.jsonl"), + path.join(fixture.config.sessionsDir, "ws-dream", "headless-usage.jsonl"), "utf-8" ); expect(sidecar).toContain('"source":"memory_consolidation"'); diff --git a/src/node/services/memoryService.test.ts b/src/node/services/memoryService.test.ts index 2a43d215af..3da2dae7c9 100644 --- a/src/node/services/memoryService.test.ts +++ b/src/node/services/memoryService.test.ts @@ -598,7 +598,7 @@ describe("MemoryService", () => { ); expect(result).toEqual({ success: true, data: { sha256: sha("fresh") } }); const onDisk = await fsPromises.readFile( - path.join(path.join(fixture.config.sessionsDir, "ws-ui"), "memory", "notes.md"), + path.join(fixture.config.sessionsDir, "ws-ui", "memory", "notes.md"), "utf-8" ); expect(onDisk).toBe("fresh"); diff --git a/src/node/services/memoryService.ts b/src/node/services/memoryService.ts index 9b8d7b65ed..5d8b8240d9 100644 --- a/src/node/services/memoryService.ts +++ b/src/node/services/memoryService.ts @@ -678,9 +678,7 @@ export class MemoryService extends EventEmitter { "Workspace memory is unavailable: no workspace is associated with this session" ); } - return new LocalMemoryStore( - path.join(path.join(this.config.sessionsDir, ctx.workspaceId), "memory") - ); + return new LocalMemoryStore(path.join(this.config.sessionsDir, ctx.workspaceId, "memory")); } } } diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index b5b6f942d1..56186ea894 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -1411,12 +1411,15 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("ignores invalid persisted WebSocket transport values", async () => { await withTempConfig(async (config, factory) => { - new ProvidersConfigStore(config.rootDir).saveProvidersConfig({ - openai: { - apiKey: "sk-test", - webSocketTransportEnabled: "true", - }, - } as unknown as Parameters[0]); + fs.writeFileSync( + path.join(config.rootDir, "providers.jsonc"), + JSON.stringify({ + openai: { + apiKey: "sk-test", + webSocketTransportEnabled: "true", + }, + }) + ); const result = await factory.createModel("openai:gpt-4.1-mini"); diff --git a/src/node/services/serviceContainer.ts b/src/node/services/serviceContainer.ts index a07ec168f0..77cd9b1445 100644 --- a/src/node/services/serviceContainer.ts +++ b/src/node/services/serviceContainer.ts @@ -160,9 +160,9 @@ export class ServiceContainer { const config = stores.config; this.config = config; this.sessionLocator = stores.sessionLocator; - this.providersConfigStore = stores.providersConfig; - this.secretsStore = stores.secrets; - this.fileLeaseManager = stores.fileLeases; + this.providersConfigStore = stores.providersConfigStore; + this.secretsStore = stores.secretsStore; + this.fileLeaseManager = stores.fileLeaseManager; // Cross-cutting services: created first so they can be passed to core // services via constructor params (no setter injection needed). @@ -188,11 +188,7 @@ export class ServiceContainer { this.workspaceMcpOverridesService = new WorkspaceMcpOverridesService(config); const core = createCoreServices({ - config, - sessionLocator: this.sessionLocator, - providersConfigStore: this.providersConfigStore, - secretsStore: this.secretsStore, - fileLeaseManager: this.fileLeaseManager, + ...stores, extensionMetadataPath: path.join(config.rootDir, "extensionMetadata.json"), workspaceMcpOverridesService: this.workspaceMcpOverridesService, policyService: this.policyService, diff --git a/src/node/services/sessionTimingService.test.ts b/src/node/services/sessionTimingService.test.ts index 4d99defe12..002ae026dd 100644 --- a/src/node/services/sessionTimingService.test.ts +++ b/src/node/services/sessionTimingService.test.ts @@ -381,7 +381,7 @@ describe("SessionTimingService", () => { emitCompletedStreamWithOneTool({ workspaceId, messageId, model, reasoningTokens: 2 }); await service.waitForIdle(workspaceId); - const filePath = path.join(path.join(config.sessionsDir, workspaceId), "session-timing.json"); + const filePath = path.join(config.sessionsDir, workspaceId, "session-timing.json"); const raw = await fs.readFile(filePath, "utf-8"); const parsed = JSON.parse(raw) as unknown; expect(typeof parsed).toBe("object"); diff --git a/src/node/services/sessionTimingService.ts b/src/node/services/sessionTimingService.ts index bcbc7c31f6..b43a30a914 100644 --- a/src/node/services/sessionTimingService.ts +++ b/src/node/services/sessionTimingService.ts @@ -331,7 +331,7 @@ export class SessionTimingService { } private getFilePath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), SESSION_TIMING_FILE); + return path.join(this.config.sessionsDir, workspaceId, SESSION_TIMING_FILE); } private async readTimingFile(workspaceId: string): Promise { diff --git a/src/node/services/sessionUsageService.test.ts b/src/node/services/sessionUsageService.test.ts index 019cfa5914..29ac2c9743 100644 --- a/src/node/services/sessionUsageService.test.ts +++ b/src/node/services/sessionUsageService.test.ts @@ -754,7 +754,7 @@ describe("SessionUsageService", () => { ); // Delete session-usage.json but keep session dir (appendToHistory created it) - const usagePath = path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"); + const usagePath = path.join(config.sessionsDir, workspaceId, "session-usage.json"); await fs.rm(usagePath, { force: true }); const result = await service.getSessionUsage(workspaceId); @@ -1052,7 +1052,7 @@ describe("SessionUsageService", () => { await historyService.appendToHistory(workspaceId, postCompactionMsg); // Delete session-usage.json to trigger rebuild from messages - const usagePath = path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"); + const usagePath = path.join(config.sessionsDir, workspaceId, "session-usage.json"); await fs.rm(usagePath, { force: true }); const result = await service.getSessionUsage(workspaceId); diff --git a/src/node/services/sessionUsageService.ts b/src/node/services/sessionUsageService.ts index 687f74572e..135c7e1915 100644 --- a/src/node/services/sessionUsageService.ts +++ b/src/node/services/sessionUsageService.ts @@ -139,7 +139,7 @@ export class SessionUsageService { } private getFilePath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), this.SESSION_USAGE_FILE); + return path.join(this.config.sessionsDir, workspaceId, this.SESSION_USAGE_FILE); } private createEmptyUsageFile(): SessionUsageFile { diff --git a/src/node/services/taskHandleStore.ts b/src/node/services/taskHandleStore.ts index dab9f56421..1a3c115ae4 100644 --- a/src/node/services/taskHandleStore.ts +++ b/src/node/services/taskHandleStore.ts @@ -253,7 +253,7 @@ export class TaskHandleStore { private getOwnerHandleDir(ownerWorkspaceId: string): string { assert(ownerWorkspaceId.trim().length > 0, "ownerWorkspaceId must be non-empty"); - return path.join(path.join(this.config.sessionsDir, ownerWorkspaceId), TASK_HANDLES_DIR); + return path.join(this.config.sessionsDir, ownerWorkspaceId, TASK_HANDLES_DIR); } private getHandlePath(ownerWorkspaceId: string, handleId: string): string { diff --git a/src/node/services/taskService.test.ts b/src/node/services/taskService.test.ts index 04d2bdcd3e..e87e5f1c73 100644 --- a/src/node/services/taskService.test.ts +++ b/src/node/services/taskService.test.ts @@ -149,7 +149,7 @@ function createWorkspaceTurnMetadata(projectPath: string): WorkspaceMetadata { async function workspaceGoalFileExists(config: Config, workspaceId: string): Promise { try { - await fsPromises.access(path.join(path.join(config.sessionsDir, workspaceId), "goal.json")); + await fsPromises.access(path.join(config.sessionsDir, workspaceId, "goal.json")); return true; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { diff --git a/src/node/services/terminalAttentionStore.test.ts b/src/node/services/terminalAttentionStore.test.ts index d3436eb3c6..1274f2a3a2 100644 --- a/src/node/services/terminalAttentionStore.test.ts +++ b/src/node/services/terminalAttentionStore.test.ts @@ -59,7 +59,7 @@ describe("TerminalAttentionStore", () => { test("loads pending notifications written with legacy derived fields", async () => { const config = makeConfig(rootDir); - const dir = path.join(path.join(config.sessionsDir, "owner-1"), TERMINAL_ATTENTION_DIR); + const dir = path.join(config.sessionsDir, "owner-1", TERMINAL_ATTENTION_DIR); await fsPromises.mkdir(dir, { recursive: true }); await fsPromises.writeFile( path.join(dir, `${encodeURIComponent("agent_task:task-1")}.json`), diff --git a/src/node/services/terminalAttentionStore.ts b/src/node/services/terminalAttentionStore.ts index 661a4acfab..c52481caad 100644 --- a/src/node/services/terminalAttentionStore.ts +++ b/src/node/services/terminalAttentionStore.ts @@ -82,7 +82,7 @@ export class TerminalAttentionStore { private dir(ownerWorkspaceId: string): string { assert(ownerWorkspaceId.trim().length > 0, "TerminalAttentionStore requires ownerWorkspaceId"); - return path.join(path.join(this.config.sessionsDir, ownerWorkspaceId), TERMINAL_ATTENTION_DIR); + return path.join(this.config.sessionsDir, ownerWorkspaceId, TERMINAL_ATTENTION_DIR); } /** Stable id keyed by source and optional execution generation for per-assignment idempotency. */ diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 2b2e2d53c1..618bfee876 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -125,7 +125,7 @@ export class TerminalService { private readonly pendingNativeTerminalOpens = new Map(); private nativeTerminalMarkerPath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), "native-terminal-opened"); + return path.join(this.config.sessionsDir, workspaceId, "native-terminal-opened"); } /** diff --git a/src/node/services/timelineService.test.ts b/src/node/services/timelineService.test.ts index 7e28b81abe..d0cca920a5 100644 --- a/src/node/services/timelineService.test.ts +++ b/src/node/services/timelineService.test.ts @@ -60,7 +60,7 @@ describe("TimelineService", () => { }); function timelinePath(workspaceId = WORKSPACE_ID): string { - return path.join(path.join(config.sessionsDir, workspaceId), TIMELINE_FILE_NAME); + return path.join(config.sessionsDir, workspaceId, TIMELINE_FILE_NAME); } test("continues monotonic sequences after service restart", async () => { @@ -424,12 +424,12 @@ describe("TimelineService", () => { }); const archived = await fs.readFile( - path.join(path.join(config.sessionsDir, WORKSPACE_ID), "chat-archive.jsonl"), + path.join(config.sessionsDir, WORKSPACE_ID, "chat-archive.jsonl"), "utf-8" ); expect(archived).toContain('"id":"target"'); const active = await fs.readFile( - path.join(path.join(config.sessionsDir, WORKSPACE_ID), "chat.jsonl"), + path.join(config.sessionsDir, WORKSPACE_ID, "chat.jsonl"), "utf-8" ); expect(active).not.toContain('"id":"target"'); diff --git a/src/node/services/timelineService.ts b/src/node/services/timelineService.ts index 7036cfa61b..22642337c9 100644 --- a/src/node/services/timelineService.ts +++ b/src/node/services/timelineService.ts @@ -526,7 +526,7 @@ export class TimelineService implements TimelineRecorder { } private getFilePath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), TIMELINE_FILE_NAME); + return path.join(this.config.sessionsDir, workspaceId, TIMELINE_FILE_NAME); } private hasRecentSourceKey(workspaceId: string, sourceKey: string): boolean { diff --git a/src/node/services/tools/goal.test.ts b/src/node/services/tools/goal.test.ts index 3328525753..c554d40d2a 100644 --- a/src/node/services/tools/goal.test.ts +++ b/src/node/services/tools/goal.test.ts @@ -598,7 +598,7 @@ describe("goal tools", () => { tool.execute!({ summary: "Implemented and verified." }, mockToolCallOptions) ); const storedRaw = await fs.readFile( - path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), + path.join(config.sessionsDir, workspaceId, "goal.json"), "utf-8" ); const storedGoal = JSON.parse(storedRaw) as GoalRecordV1; diff --git a/src/node/services/workspaceGoalService.test.ts b/src/node/services/workspaceGoalService.test.ts index a51ec718be..a2ad52e592 100644 --- a/src/node/services/workspaceGoalService.test.ts +++ b/src/node/services/workspaceGoalService.test.ts @@ -78,7 +78,7 @@ const PROJECT_PATH = "/tmp/mux-goal-service-test-project"; async function goalFileExists(config: Config, workspaceId: string): Promise { try { - await fs.access(path.join(path.join(config.sessionsDir, workspaceId), "goal.json")); + await fs.access(path.join(config.sessionsDir, workspaceId, "goal.json")); return true; } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { @@ -254,7 +254,7 @@ describe("WorkspaceGoalService", () => { // Simulate a partially-written line from a prior crash. The board reader // must skip it instead of throwing. - const historyPath = path.join(path.join(config.sessionsDir, workspaceId), "goal-history.jsonl"); + const historyPath = path.join(config.sessionsDir, workspaceId, "goal-history.jsonl"); await fs.appendFile(historyPath, "{not-json}\n", "utf-8"); const completed = (await service.getGoalBoard(workspaceId)).entries.filter( @@ -476,7 +476,7 @@ describe("WorkspaceGoalService", () => { status: "paused", initiator: "user", }); - const goalPath = path.join(path.join(config.sessionsDir, workspaceId), "goal.json"); + const goalPath = path.join(config.sessionsDir, workspaceId, "goal.json"); await waitForCondition(async () => { try { const raw = JSON.parse(await fs.readFile(goalPath, "utf-8")) as { status?: string }; @@ -1454,7 +1454,7 @@ describe("WorkspaceGoalService", () => { budgetCents: 100, }); await fs.writeFile( - path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), + path.join(config.sessionsDir, workspaceId, "goal.json"), JSON.stringify({ ...legacy, status: "budget_limited", budgetCents: 0 }) ); @@ -1644,7 +1644,7 @@ describe("WorkspaceGoalService", () => { test("preserves goal id and accounting for same-objective set", async () => { const created = await setGoalOk(service, { workspaceId, objective: "Same objective" }); await fs.writeFile( - path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), + path.join(config.sessionsDir, workspaceId, "goal.json"), JSON.stringify({ ...created, costCents: 123, turnsUsed: 4 }) ); @@ -1662,7 +1662,7 @@ describe("WorkspaceGoalService", () => { test("replaces different objective with a new goal id and reset accounting", async () => { const created = await setGoalOk(service, { workspaceId, objective: "First objective" }); await fs.writeFile( - path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), + path.join(config.sessionsDir, workspaceId, "goal.json"), JSON.stringify({ ...created, costCents: 123, turnsUsed: 4 }) ); @@ -1842,7 +1842,7 @@ describe("WorkspaceGoalService", () => { requireUserAcknowledgmentSinceMs: parent.createdAtMs + 1, }; await fs.writeFile( - path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), + path.join(config.sessionsDir, workspaceId, "goal.json"), `${JSON.stringify(parentWithAccounting, null, 2)}\n` ); @@ -4877,7 +4877,7 @@ describe("WorkspaceGoalService", () => { test("attributes child report cost once and persists the per-goal ledger", async () => { await setGoalOk(service, { workspaceId, objective: "Account for child reports" }); await fs.writeFile( - path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"), + path.join(config.sessionsDir, workspaceId, "session-usage.json"), JSON.stringify({ version: 1, byModel: {}, rolledUpFrom: { "child-a": true } }, null, 2) ); @@ -4906,15 +4906,12 @@ describe("WorkspaceGoalService", () => { }); const goalOnDisk = JSON.parse( - await fs.readFile(path.join(path.join(config.sessionsDir, workspaceId), "goal.json"), "utf-8") + await fs.readFile(path.join(config.sessionsDir, workspaceId, "goal.json"), "utf-8") ) as GoalRecordV1; expect(goalOnDisk.attributedChildren).toEqual(["child-a"]); const sessionUsageOnDisk = JSON.parse( - await fs.readFile( - path.join(path.join(config.sessionsDir, workspaceId), "session-usage.json"), - "utf-8" - ) + await fs.readFile(path.join(config.sessionsDir, workspaceId, "session-usage.json"), "utf-8") ) as { rolledUpFrom?: Record }; expect(sessionUsageOnDisk.rolledUpFrom).toEqual({ "child-a": true }); }); diff --git a/src/node/services/workspaceGoalService.ts b/src/node/services/workspaceGoalService.ts index 2cea9b9157..8bff915a8f 100644 --- a/src/node/services/workspaceGoalService.ts +++ b/src/node/services/workspaceGoalService.ts @@ -653,7 +653,8 @@ export class WorkspaceGoalService { private readonly historyService: HistoryService, private readonly extensionMetadata: ExtensionMetadataService, private readonly analytics?: GoalLifecycleAnalyticsSink, - options: WorkspaceGoalServiceOptions = {} + options: WorkspaceGoalServiceOptions = {}, + private readonly providersConfigStore = new ProvidersConfigStore(config.rootDir) ) { this.continuationCooldownMs = options.continuationCooldownMs ?? DEFAULT_GOAL_CONTINUATION_COOLDOWN_MS; @@ -1066,7 +1067,7 @@ export class WorkspaceGoalService { // doesn't re-assert and re-join the same way. private resolveSessionFilePath(workspaceId: string, fileName: string): string { assert(workspaceId.trim().length > 0, "WorkspaceGoalService requires non-empty workspaceId"); - return path.join(path.join(this.config.sessionsDir, workspaceId), fileName); + return path.join(this.config.sessionsDir, workspaceId, fileName); } private getFilePath(workspaceId: string): string { @@ -2642,7 +2643,7 @@ export class WorkspaceGoalService { } private getProvidersConfigForPricing(): ProvidersConfigMap | null { - const providersConfig = new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig(); + const providersConfig = this.providersConfigStore.loadProvidersConfig(); return providersConfig as ProvidersConfigMap | null; } diff --git a/src/node/services/workspaceService.multiProject.test.ts b/src/node/services/workspaceService.multiProject.test.ts index 44b7111d33..59b956df22 100644 --- a/src/node/services/workspaceService.multiProject.test.ts +++ b/src/node/services/workspaceService.multiProject.test.ts @@ -67,7 +67,7 @@ function createMockExperimentsService(enabled: boolean): ExperimentsService { } type BashToolConfig = Parameters[0]; interface WorkspaceServiceTestOptions { - config: Partial & { secretsStore?: Pick }; + config: Partial; historyService: HistoryService; aiService?: AIService; initStateManager?: InitStateManager; diff --git a/src/node/services/workspaceService.test.ts b/src/node/services/workspaceService.test.ts index e8fd182ebf..36d5ec2623 100644 --- a/src/node/services/workspaceService.test.ts +++ b/src/node/services/workspaceService.test.ts @@ -22107,7 +22107,7 @@ describe("WorkspaceService.fork branch-summary rollback ordering", () => { // session's chat.jsonl was not recreated by a late guarded append. expect(await awaitPendingBranchSummary(newWorkspaceId)).toBeNull(); expect(guardedAppendSpy).not.toHaveBeenCalled(); - const chatFile = path.join(path.join(config.sessionsDir, newWorkspaceId), "chat.jsonl"); + const chatFile = path.join(config.sessionsDir, newWorkspaceId, "chat.jsonl"); const chatExists = await fsPromises.access(chatFile).then( () => true, () => false diff --git a/src/node/services/workspaceService.ts b/src/node/services/workspaceService.ts index 748269e931..2bb77c7c77 100644 --- a/src/node/services/workspaceService.ts +++ b/src/node/services/workspaceService.ts @@ -2378,7 +2378,8 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly streamManager?: StreamManager, private readonly secretsStore: Pick = new SecretsStore( config.rootDir - ) + ), + private readonly providersConfigStore = new ProvidersConfigStore(config.rootDir) ) { super(); this.bashMonitorWakeStore = new BashMonitorWakeStore(config); @@ -8488,7 +8489,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { private readonly pendingExternalEditorRecordings = new Map(); private externalEditorMarkerPath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), "external-editor-opened"); + return path.join(this.config.sessionsDir, workspaceId, "external-editor-opened"); } /** @@ -10291,7 +10292,7 @@ export class WorkspaceService extends EventEmitter implements WorkspaceHost { hasBudgetedResumableGoal(goal) && !modelHasPricingData( normalized.data.model, - new ProvidersConfigStore(this.config.rootDir).loadProvidersConfig() + this.providersConfigStore.loadProvidersConfig() ) ) { return Err(UNPRICED_TARGET_MODEL_GOAL_MESSAGE); diff --git a/src/node/services/worktreeArchiveSnapshotService.test.ts b/src/node/services/worktreeArchiveSnapshotService.test.ts index 024cebce8f..a0fd3b25ca 100644 --- a/src/node/services/worktreeArchiveSnapshotService.test.ts +++ b/src/node/services/worktreeArchiveSnapshotService.test.ts @@ -186,7 +186,7 @@ describe("WorktreeArchiveSnapshotService", () => { recursive: true, }); await fs.writeFile( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceName), "metadata.json"), + path.join(fixture.config.sessionsDir, fixture.workspaceName, "metadata.json"), JSON.stringify({ id: fixture.workspaceId }), "utf-8" ); @@ -256,9 +256,7 @@ describe("WorktreeArchiveSnapshotService", () => { ?.workspaces[0]; expect(storedWorkspace?.worktreeArchiveSnapshot).toBeUndefined(); expect( - await pathExists( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") - ) + await pathExists(path.join(fixture.config.sessionsDir, fixture.workspaceId, "archive-state")) ).toBe(false); }); @@ -510,7 +508,7 @@ describe("WorktreeArchiveSnapshotService", () => { throw new Error("Expected staged patch path"); } await fs.writeFile( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), stagedPatchPath), + path.join(fixture.config.sessionsDir, fixture.workspaceId, stagedPatchPath), "this is not a valid patch\n", "utf-8" ); @@ -561,9 +559,7 @@ describe("WorktreeArchiveSnapshotService", () => { ?.worktreeArchiveSnapshot ).toBeUndefined(); expect( - await pathExists( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") - ) + await pathExists(path.join(fixture.config.sessionsDir, fixture.workspaceId, "archive-state")) ).toBe(false); }); @@ -714,12 +710,9 @@ describe("WorktreeArchiveSnapshotService", () => { return cfg; }); - await fs.rm( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), stagedPatchPath), - { - force: true, - } - ); + await fs.rm(path.join(fixture.config.sessionsDir, fixture.workspaceId, stagedPatchPath), { + force: true, + }); await fs.writeFile( path.join(fixture.workspacePath, "tracked.txt"), "base\ncommit one\ncommit two\nstaged change\nunstaged change\nextra drift\n", @@ -876,12 +869,9 @@ describe("WorktreeArchiveSnapshotService", () => { return cfg; }); - await fs.rm( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), stagedPatchPath), - { - force: true, - } - ); + await fs.rm(path.join(fixture.config.sessionsDir, fixture.workspaceId, stagedPatchPath), { + force: true, + }); runGit(fixture.projectPath, ["worktree", "remove", "--force", fixture.workspacePath]); const restoreResult = await fixture.service.restoreSnapshotAfterUnarchive({ @@ -923,7 +913,7 @@ describe("WorktreeArchiveSnapshotService", () => { if ( typeof targetPath === "string" && targetPath.endsWith( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") + path.join(fixture.config.sessionsDir, fixture.workspaceId, "archive-state") ) ) { throw new Error("snapshot cleanup failed"); @@ -943,7 +933,7 @@ describe("WorktreeArchiveSnapshotService", () => { ).toEqual(captureResult.data); expect( await pathExists( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") + path.join(fixture.config.sessionsDir, fixture.workspaceId, "archive-state") ) ).toBe(true); } finally { @@ -1059,7 +1049,7 @@ describe("WorktreeArchiveSnapshotService", () => { ).toEqual(captureResult.data); expect( await pathExists( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") + path.join(fixture.config.sessionsDir, fixture.workspaceId, "archive-state") ) ).toBe(false); } finally { @@ -1116,9 +1106,7 @@ describe("WorktreeArchiveSnapshotService", () => { Err({ kind: "confirm-lossy-untracked-files", paths: ["untracked.txt"] }) ); expect( - await pathExists( - path.join(path.join(fixture.config.sessionsDir, fixture.workspaceId), "archive-state") - ) + await pathExists(path.join(fixture.config.sessionsDir, fixture.workspaceId, "archive-state")) ).toBe(false); }); diff --git a/src/node/utils/sessionFile.ts b/src/node/utils/sessionFile.ts index 1618c2c580..569b3bdf8a 100644 --- a/src/node/utils/sessionFile.ts +++ b/src/node/utils/sessionFile.ts @@ -36,7 +36,7 @@ export class SessionFileManager { } private getFilePath(workspaceId: string): string { - return path.join(path.join(this.config.sessionsDir, workspaceId), this.fileName); + return path.join(this.config.sessionsDir, workspaceId, this.fileName); } /** diff --git a/tests/ipc/setup.ts b/tests/ipc/setup.ts index fbf19014ca..5076dcb2fb 100644 --- a/tests/ipc/setup.ts +++ b/tests/ipc/setup.ts @@ -68,7 +68,7 @@ export async function createTestEnvironment(): Promise { // For integration tests (TEST_INTEGRATION=1), do NOT write dummy keys here (they would override // real env-backed credentials used by tests like name generation). if (!shouldRunIntegrationTests()) { - stores.providersConfig.saveProvidersConfig({ + stores.providersConfigStore.saveProvidersConfig({ anthropic: { apiKey: "test-key-for-ui-tests" }, }); } From 5d06bef33f7bd0e5f4fabbd588f335364cbbddae Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:12:35 +0000 Subject: [PATCH 14/17] =?UTF-8?q?=F0=9F=A4=96=20refactor(config):=20trim?= =?UTF-8?q?=20extracted=20store=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/config.test.ts | 2 - src/node/config/fileLeaseManager.test.ts | 14 +- src/node/config/fileLeaseManager.ts | 184 ++++------------------- src/node/config/providersConfigStore.ts | 19 +-- src/node/config/secretsStore.ts | 13 -- 5 files changed, 39 insertions(+), 193 deletions(-) diff --git a/src/node/config.test.ts b/src/node/config.test.ts index 34a0c61f3e..b3a95cc8c1 100644 --- a/src/node/config.test.ts +++ b/src/node/config.test.ts @@ -14,13 +14,11 @@ describe("Config", () => { let config: Config; beforeEach(() => { - // Create a temporary directory for each test tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "mux-test-")); config = new Config(tempDir); }); afterEach(() => { - // Clean up temporary directory fs.rmSync(tempDir, { recursive: true, force: true }); }); diff --git a/src/node/config/fileLeaseManager.test.ts b/src/node/config/fileLeaseManager.test.ts index 3e479e1d67..ee5e8b0238 100644 --- a/src/node/config/fileLeaseManager.test.ts +++ b/src/node/config/fileLeaseManager.test.ts @@ -82,7 +82,7 @@ describe("FileLeaseManager", () => { const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); expect(release).not.toBeNull(); - // The holder outlives the TTL but its process (this one) is alive — + // The holder outlives the TTL but its process (this one) is alive. // e.g. a suspended laptop or a stalled event loop. Breaking it would // let a second flow enter the same critical section and race the // resumed original; contenders must fail acquisition instead. @@ -112,13 +112,12 @@ describe("FileLeaseManager", () => { expect(otherRelease).not.toBeNull(); // The original holder's late release must NOT remove the new owner's - // lease — otherwise a third flow could acquire it concurrently and two + // lease; otherwise a third flow could acquire it concurrently and two // flows would clobber the stored client's single redirect slot. originalRelease!(); expect(fs.existsSync(leasePath)).toBe(true); expect(manager.tryAcquireCoderOauthClientLease(TTL_MS)).toBeNull(); - // The rightful owner can still release it. otherRelease!(); expect(fs.existsSync(leasePath)).toBe(false); }); @@ -131,7 +130,6 @@ describe("FileLeaseManager", () => { // being provably gone. const leasePath = path.join(tempDir, "providers.jsonc.coder-client.lock"); fs.mkdirSync(leasePath, { recursive: true }); - // Fresh mtime (NOT backdated) + dead owner PID. fs.writeFileSync(path.join(leasePath, "owner-crashed"), "999999999"); const release = manager.tryAcquireCoderOauthClientLease(TTL_MS); @@ -142,7 +140,7 @@ describe("FileLeaseManager", () => { it("reclaims an EMPTY orphaned lease directory immediately, before the TTL elapses", () => { // Regression: acquisition installs the owner marker atomically with the // lock directory (staged rename), so an empty directory can only be a - // crash remnant — never a live acquisition. A fresh-mtime empty orphan + // crash remnant; never a live acquisition. A fresh-mtime empty orphan // previously read as live until the TTL, and every acquisition timeout // is shorter than its TTL, so the first operation after such a crash // always failed. @@ -198,7 +196,7 @@ describe("FileLeaseManager", () => { it("acquires over an EMPTY orphaned lock directory immediately, before the TTL elapses", async () => { // Regression: acquisition installs the owner marker atomically with the // lock directory (staged rename), so an empty directory can only be a - // crash remnant — never a live acquisition. Previously a fresh-mtime + // crash remnant; never a live acquisition. Previously a fresh-mtime // empty orphan read as live until the 10s TTL, and the 5s acquisition // timeout always fired first, so the first config write after such a // crash always timed out. @@ -235,7 +233,6 @@ describe("FileLeaseManager", () => { const second = otherProcess.withCoderOauthRefreshLock(() => { events.push("second:enter"); }); - // The second section must not start while the first holds the lock. await new Promise((resolve) => setTimeout(resolve, 50)); expect(events).toEqual(["first:enter"]); @@ -247,7 +244,7 @@ describe("FileLeaseManager", () => { it("does not release a successor's lock after being stale-broken mid-section", async () => { // A holder that outlives staleLockMs (suspended process, stalled event // loop) can be stale-broken and the lock reacquired before its release - // runs. That release must only remove its OWN generation — deleting the + // runs. That release must only remove its OWN generation; deleting the // successor's lock would let a third process into the critical section // (for the refresh lock, the concurrent rotating-refresh-token race). const lockPath = path.join(tempDir, "providers.jsonc.coder-refresh.lock"); @@ -282,7 +279,6 @@ describe("FileLeaseManager", () => { releaseFirst(); expect(fs.existsSync(lockPath)).toBe(true); - // The second holder's critical section completes and cleans up cleanly. releaseSecond(); await Promise.all([first, second]); expect(fs.existsSync(lockPath)).toBe(false); diff --git a/src/node/config/fileLeaseManager.ts b/src/node/config/fileLeaseManager.ts index 755f1a12cd..9a52bd05cb 100644 --- a/src/node/config/fileLeaseManager.ts +++ b/src/node/config/fileLeaseManager.ts @@ -15,6 +15,13 @@ function isProcessAlive(pid: number): boolean { } } +/** + * Directory locks are installed by atomically renaming a staged directory with a + * generation marker. Empty directories are release or crash remnants. Live owners + * are never broken, dead owners are reclaimed immediately, and unreadable markers + * fall back to TTL. Release only removes its own marker and an empty directory, so + * a successor generation cannot be deleted by a late holder. + */ export class FileLeaseManager { readonly rootDir: string; readonly providersFile: string; @@ -24,82 +31,23 @@ export class FileLeaseManager { this.providersFile = path.join(this.rootDir, "providers.jsonc"); } - /** - * Advisory cross-process lock for providers.jsonc read-modify-write cycles. - * - * Multiple xum processes (desktop app, `xum run`, `xum workflow`) share - * providers.jsonc, and OAuth credential rotation requires compare-and-set - * semantics across them. Exclusive directory creation is atomic on all - * platforms, so `.lock/` serves as the mutex. Locks orphaned - * by crashed processes are broken after a staleness timeout. - */ + /** Serializes cross-process providers.jsonc read-modify-write cycles. */ async withProvidersFileLock(fn: () => Promise | T): Promise { // Guards sub-second file mutations, so contention resolves quickly. return this.withDirLock(`${this.providersFile}.lock`, 5_000, 10_000, fn); } - /** - * Cross-process serialization of Coder OAuth token refreshes. - * - * Coder rotates refresh tokens on every use, so two processes refreshing - * the same credential race destructively: the loser's `invalid_grant` can - * arrive — and its compare-and-clear delete the credential — while the - * winner's rotation is still in flight and not yet on disk, after which the - * winner's persist CAS fails too and BOTH processes discard the only valid - * token. Serializing the whole refresh round-trip (re-read + token request - * + persist) closes that window: a loser re-reads inside the lock and - * adopts the winner's rotation without ever sending a doomed request. - * - * Timing: the guarded section includes one bounded token request (30s cap, - * see TOKEN_REQUEST_TIMEOUT_MS in coderOauthService.ts), so acquisition - * waits up to 45s and orphaned locks are broken after 60s. - */ + /** Serializes rotating-token refreshes so losers adopt the persisted winner. */ async withCoderOauthRefreshLock(fn: () => Promise | T): Promise { return this.withDirLock(`${this.providersFile}.coder-refresh.lock`, 45_000, 60_000, fn); } - /** - * Cross-process serialization of Coder OAuth desktop-login commits - * (persist -> finish/rollback; see commitDesktopLogin in - * coderOauthService.ts). - * - * A login's rollback snapshot (`previousSection`) must only ever capture a - * COMMITTED section. Login flows are process-local, but the persisted - * section is shared across processes: without this lock, a flow in process - * B could snapshot process A's persisted-but-uncommitted login; if both - * were then cancelled, A's rollback would skip (B's auth is current) and - * revoke A's tokens, after which B's rollback would restore that - * already-revoked auth over the original login. - * - * Timing: the guarded section is a handful of providers-file mutations and - * no network I/O (revocation runs after release), so acquisition waits up - * to 15s and orphaned locks are broken after 20s. - */ + /** Keeps each desktop login rollback snapshot anchored to committed credentials. */ async withCoderOauthLoginCommitLock(fn: () => Promise | T): Promise { return this.withDirLock(`${this.providersFile}.coder-login.lock`, 15_000, 20_000, fn); } - /** - * Atomically install a generation-marked lock directory at `lockPath`: the - * owner marker (content = holder PID, see tryBreakStaleDirLock) is written - * into a staged sibling directory which is then rename(2)d into place. - * Acquisition and marker creation are therefore a single atomic step — a - * live acquisition is never observable as an EMPTY lock directory, so an - * empty directory is always a crash remnant (the unlink→rmdir window of - * release/stale-break) that breakers may reclaim immediately. Without this, - * a crash between mkdir and marker write would look live until the mtime - * TTL, and every acquisition timeout is shorter than its TTL — the first - * operation after such a crash would always time out. - * - * On POSIX, rename onto an existing EMPTY directory atomically replaces it - * (instant orphan recovery); onto a non-empty one it fails ENOTEMPTY. On - * Windows, rename onto any existing directory fails — contenders recover - * empty orphans via tryBreakStaleDirLock instead. - * - * Returns the installed marker path, or null when the lock is held - * (contended). Unexpected filesystem errors (EACCES, EROFS, ...) are - * rethrown after the stage directory is cleaned up. - */ + /** Installs the directory and generation marker as one observable step. */ private tryInstallDirLock(lockPath: string): string | null { const stagePath = `${lockPath}.stage-${crypto.randomBytes(8).toString("hex")}`; const markerName = `owner-${crypto.randomBytes(16).toString("hex")}`; @@ -125,12 +73,7 @@ export class FileLeaseManager { return path.join(lockPath, markerName); } - /** - * Remove stage directories abandoned by a crash between staging and the - * rename in tryInstallDirLock. TTL-gated on mtime so a concurrent - * acquisition's in-flight stage (a microseconds-wide window) is never - * destroyed under a live process. - */ + /** Removes crash-abandoned stages only after their install window has expired. */ private cleanupAbandonedStageDirs(lockPath: string, ttlMs: number): void { const parent = path.dirname(lockPath); const prefix = `${path.basename(lockPath)}.stage-`; @@ -155,23 +98,8 @@ export class FileLeaseManager { } } - /** - * Shared advisory directory lock: acquisition atomically installs the lock - * directory together with its generation marker (see tryInstallDirLock); - * locks orphaned by crashed processes are broken once they are older than - * `staleLockMs` AND their owner process is gone - * (see tryBreakStaleDirLock — live-but-stalled holders are never broken; - * contenders instead fail acquisition at the bounded timeout). - * - * Ownership generations: a holder that runs past `staleLockMs` (suspended - * process, stalled event loop) can be stale-broken and the lock reacquired - * before its release runs — an unconditional removal would then delete the - * successor's lock and let a third process into the critical section. Each - * acquisition therefore writes a generation-unique marker file and release - * only removes that generation (see tryBreakStaleDirLock for the breaker's - * matching conditional cleanup). - */ - async withDirLock( + /** Acquires a bounded lock without deleting a live or successor generation. */ + private async withDirLock( lockPath: string, acquireTimeoutMs: number, staleLockMs: number, @@ -188,7 +116,7 @@ export class FileLeaseManager { let ownerFile: string; for (;;) { // tryInstallDirLock rethrows permanent filesystem errors (EACCES, - // EROFS, ...) — they would fail on every retry, so callers surface an + // EROFS, ...); they would fail on every retry, so callers surface an // error instead of spinning until the deadline. const installed = this.tryInstallDirLock(lockPath); if (installed != null) { @@ -199,7 +127,7 @@ export class FileLeaseManager { throw new Error(`Timed out acquiring providers config lock at ${lockPath}`); } // Held by another process (or a crashed one): break stale locks, then - // retry — immediately after a break/vanish, with a delay for a live + // retry; immediately after a break/vanish, with a delay for a live // holder. if (this.tryBreakStaleDirLock(lockPath, staleLockMs)) { continue; @@ -216,41 +144,19 @@ export class FileLeaseManager { fs.rmdirSync(lockPath); } catch (error) { // ENOENT/ENOTEMPTY: a breaker finished the removal or a successor - // generation already acquired the path — leave it to them. + // generation already acquired the path; leave it to them. log.debug("Failed to release providers config lock:", error); } } catch { // Marker already gone: this holder outlived staleLockMs and was - // stale-broken; a successor may hold the lock now — keep it. + // stale-broken; a successor may hold the lock now; keep it. } } } /** - * Try to take an exclusive cross-process lease on the stored Coder OAuth - * dynamic client. The client's registration has a single redirect_uris - * slot, so only one login flow — across every Xum process sharing this - * providers file — may reuse (and RFC 7592-update) it at a time; callers - * that fail to acquire the lease must register a fresh client instead. - * - * Non-blocking: returns a release function on success, or null when another - * live flow holds the lease. Unlike withProvidersFileLock (which guards - * sub-second file mutations), this lease spans a whole login flow — the - * redirect URI must stay registered until the user finishes authorizing — - * so staleness is judged against `ttlMs` (the flow timeout). A crashed - * holder's lease is broken after that (only once its process is provably - * gone, see tryBreakStaleDirLock), and in the interim other flows degrade - * gracefully to fresh client registrations. - * - * Ownership safety: a lease that crosses the staleness boundary can be - * broken and reacquired by another process at any instant, so neither - * release nor stale-breaking may check-then-recursively-remove (the check - * and the rm would race the handover). Instead each acquisition writes a - * generation-unique marker FILE inside the lease directory, and every - * destructive step is conditional at the filesystem layer: unlink can only - * remove the specific generation's marker (a successor's marker has a - * different name), and the non-recursive rmdir only removes an EMPTY - * directory — never a directory a successor generation re-marked. + * Reserves the stored dynamic client for one login flow. Contenders use a fresh + * registration rather than blocking for the full authorization window. */ tryAcquireCoderOauthClientLease(ttlMs: number): (() => void) | null { const leasePath = `${this.providersFile}.coder-client.lock`; @@ -275,9 +181,8 @@ export class FileLeaseManager { ownerFile = installed; } catch (error) { // Filesystem errors mean the lease was never installed. The lease is - // an optimization with a documented degradation path — callers fall - // back to registering a fresh client — so prefer a working login - // over surfacing an acquisition error. + // an optimization with a documented degradation path. Prefer a working + // login through fresh registration over surfacing an acquisition error. log.debug("Failed to install Coder OAuth client lease:", error); return null; } @@ -294,7 +199,7 @@ export class FileLeaseManager { // A release racing the staleness boundary can lose the directory to // a concurrent breaker after the unlink above: ENOENT means the // breaker finished the removal, ENOTEMPTY means a successor already - // acquired a new generation — both correctly leave it untouched. + // acquired a new generation; both correctly leave it untouched. log.debug("Failed to release Coder OAuth client lease:", error); } }; @@ -302,13 +207,7 @@ export class FileLeaseManager { return null; } - /** - * Break a marker-based directory lock/lease left behind by a crashed (or - * stalled-past-staleness) holder. Shared by withDirLock and - * tryAcquireCoderOauthClientLease, whose generation-marker layout matches. - * Returns true when the caller should retry acquisition (the lock was - * stale or vanished mid-check), false when it is held by a live owner. - */ + /** Returns whether acquisition should retry after checking the observed generation. */ private tryBreakStaleDirLock(leasePath: string, ttlMs: number): boolean { let entries: string[]; try { @@ -319,15 +218,8 @@ export class FileLeaseManager { const isStale = (mtimeMs: number) => Date.now() - mtimeMs > ttlMs; if (entries.length === 0) { - // Acquisition installs the marker atomically with the directory - // (staged rename — see tryInstallDirLock), so an empty lock directory - // is never a live acquisition: it can only be a crash remnant from the - // unlink→rmdir window of release/stale-break. Reclaim it immediately — - // waiting out the mtime TTL would make every acquisition timeout (all - // shorter than their TTLs) fire first, so the first operation after - // such a crash would always fail despite being deterministically - // recoverable. The non-recursive rmdir keeps the race with a concurrent - // installer safe: it cannot destroy a renamed-in full generation. + // Atomic installation makes empty directories reclaimable. Non-recursive + // removal cannot delete a concurrently installed generation. try { fs.rmdirSync(leasePath); } catch { @@ -339,29 +231,13 @@ export class FileLeaseManager { // Staleness binds to the OBSERVED generation's marker: marker names are // generation-unique, so if the lease changes hands after this check the - // unlink below ENOENTs and the rmdir ENOTEMPTYs — a live successor lease + // unlink below ENOENTs and the rmdir ENOTEMPTYs; a live successor lease // is never destroyed (the reason breaking must not use recursive rm). for (const entry of entries) { const entryPath = path.join(leasePath, entry); - // The marker carries the owner's PID, checked FIRST: - // - Owner provably ALIVE: never break, however old the marker. A live - // process that merely outlived the TTL (suspended laptop, stalled - // event loop) may still be mid-critical-section; breaking would let a - // second process in, and for the refresh lock the resumed original - // could then race the successor over the same rotating refresh token - // — both sides clearing/revoking the only valid credential. - // Contenders instead fail bounded (withDirLock times out, the client - // lease falls back to a fresh registration). - // - Owner provably DEAD: reclaim immediately, however fresh the marker. - // A dead process cannot be mid-critical-section, and every - // acquisition timeout is shorter than its staleness TTL — waiting for - // the TTL would make the first operation after a crash always time - // out even though the orphan is deterministically recoverable. - // - Owner unknown (unreadable/partial marker): fall back to the mtime - // TTL, the only remaining staleness signal. - // Residual risk: a recycled PID belonging to an unrelated live process - // keeps an orphaned lock alive until that process exits — rare, and - // strictly safer than destroying a live holder's lock. + // A live PID wins over TTL because the holder may still be in its critical + // section. Dead owners are reclaimed immediately; unreadable markers use TTL. + // A recycled PID may delay recovery, which is safer than overlapping holders. let ownerPid: number | null = null; try { const content = fs.readFileSync(entryPath, "utf8").trim(); diff --git a/src/node/config/providersConfigStore.ts b/src/node/config/providersConfigStore.ts index 2858a6e4ff..5361a77bbc 100644 --- a/src/node/config/providersConfigStore.ts +++ b/src/node/config/providersConfigStore.ts @@ -22,10 +22,6 @@ export class ProvidersConfigStore { this.providersFile = path.join(this.rootDir, "providers.jsonc"); } - /** - * Load providers configuration from JSONC file - * Supports comments in JSONC format - */ loadProvidersConfig(): ProvidersConfig | null { try { if (fs.existsSync(this.providersFile)) { @@ -102,9 +98,8 @@ export class ProvidersConfigStore { watcher = fs.watch(this.rootDir, { persistent: false }, (_eventType, changedFilename) => { // changedFilename can be null on some platforms/kernels (notably // older macOS FSEvents). When we can't tell which file changed, - // assume providers.jsonc might have and let the consumer re-fetch - // — better an extra refresh than a missed one, since this is the - // exact scenario the feature is meant to fix. + // assume providers.jsonc might have changed. An extra refresh is safer + // than missing the external edit this watcher exists to detect. if (changedFilename != null && changedFilename !== filename) return; fire(); }); @@ -128,7 +123,7 @@ export class ProvidersConfigStore { try { watcher.close(); } catch { - // Watcher may already be torn down by the OS — nothing to do. + // Watcher may already be torn down by the OS; nothing to do. } }); } catch (error) { @@ -137,7 +132,7 @@ export class ProvidersConfigStore { error ); const noop = (): void => { - // Nothing to clean up — watcher setup never completed. + // Nothing to clean up; watcher setup never completed. }; return noop; } @@ -148,20 +143,14 @@ export class ProvidersConfigStore { }; } - /** - * Save providers configuration to JSONC file - * @param config The providers configuration to save - */ saveProvidersConfig(config: ProvidersConfig): void { try { if (!fs.existsSync(this.rootDir)) { ensurePrivateDirSync(this.rootDir); } - // Format with 2-space indentation for readability const jsonString = JSON.stringify(config, null, 2); - // Add a comment header to the file const contentWithComments = `// Providers configuration for xum // Configure your AI providers here // Example: diff --git a/src/node/config/secretsStore.ts b/src/node/config/secretsStore.ts index 1777858bf7..c2b95ca48e 100644 --- a/src/node/config/secretsStore.ts +++ b/src/node/config/secretsStore.ts @@ -148,10 +148,6 @@ export class SecretsStore { return normalized; } - /** - * Load secrets configuration from JSON file - * Returns empty config if file doesn't exist - */ loadSecretsConfig(): SecretsConfig { try { if (fs.existsSync(this.secretsFile)) { @@ -218,10 +214,6 @@ export class SecretsStore { await this.saveSecretsConfig(raw); } - /** - * Save secrets configuration to JSON file - * @param config The secrets configuration to save - */ async saveSecretsConfig(config: SecretsConfig | Record): Promise { try { if (!fs.existsSync(this.rootDir)) { @@ -402,11 +394,6 @@ export class SecretsStore { return config[normalizedProjectPath] ?? []; } - /** - * Update secrets for a specific project - * @param projectPath The path to the project - * @param secrets The secrets to save for the project - */ async updateProjectSecrets(projectPath: string, secrets: Secret[]): Promise { const normalizedProjectPath = SecretsStore.normalizeSecretsProjectPath(projectPath) || projectPath; From 18ebed5dba0a34bd3d7594e1cfbf019ef204aa11 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 09:17:54 +0000 Subject: [PATCH 15/17] =?UTF-8?q?=F0=9F=A4=96=20tests(config):=20document?= =?UTF-8?q?=20invalid=20sync=20fixture=20write?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/providerModelFactory.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/node/services/providerModelFactory.test.ts b/src/node/services/providerModelFactory.test.ts index 56186ea894..0c4523dc7e 100644 --- a/src/node/services/providerModelFactory.test.ts +++ b/src/node/services/providerModelFactory.test.ts @@ -1411,6 +1411,7 @@ describe("ProviderModelFactory OpenAI WebSocket transport", () => { it("ignores invalid persisted WebSocket transport values", async () => { await withTempConfig(async (config, factory) => { + // eslint-disable-next-line local/no-sync-fs-methods -- Test setup writes intentionally invalid config bytes. fs.writeFileSync( path.join(config.rootDir, "providers.jsonc"), JSON.stringify({ From af4aef042332b3a3534a4f72b958589ee20feb29 Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:56:14 +0000 Subject: [PATCH 16/17] =?UTF-8?q?=F0=9F=A4=96=20tests(config):=20update=20?= =?UTF-8?q?session=20locator=20doubles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agentSession.admissionGates.test.ts | 4 +- .../agentSession.autoCompaction.test.ts | 40 ++++++------ ...gentSession.continueMessageAgentId.test.ts | 4 +- .../agentSession.editMessageId.test.ts | 4 +- .../agentSession.memoryContext.test.ts | 21 ++++--- ...tSession.postCompactionAttachments.test.ts | 61 +++++++++++++------ ...agentSession.postCompactionRefresh.test.ts | 4 +- .../agentSession.postCompactionRetry.test.ts | 24 ++++++-- .../agentSession.preTurnMessages.test.ts | 4 +- ...ntSession.resumeStreamEmptyHistory.test.ts | 4 +- src/node/services/agentSession.testHarness.ts | 3 +- .../services/bashMonitorRegistryStore.test.ts | 8 +-- .../services/bashMonitorWakeStore.test.ts | 8 +-- .../services/terminalAttentionStore.test.ts | 8 +-- src/node/utils/eventStore.test.ts | 1 - 15 files changed, 120 insertions(+), 78 deletions(-) diff --git a/src/node/services/agentSession.admissionGates.test.ts b/src/node/services/agentSession.admissionGates.test.ts index 91c7b7962b..523f113e27 100644 --- a/src/node/services/agentSession.admissionGates.test.ts +++ b/src/node/services/agentSession.admissionGates.test.ts @@ -13,8 +13,10 @@ import { createTestHistoryService } from "./testHistoryService"; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", + loadConfigOrDefault: () => ({}), } as unknown as Config; // r41/r42: the admissionEpochStale probe is a session-level backstop for diff --git a/src/node/services/agentSession.autoCompaction.test.ts b/src/node/services/agentSession.autoCompaction.test.ts index 6e1e3679c6..72bccaddb5 100644 --- a/src/node/services/agentSession.autoCompaction.test.ts +++ b/src/node/services/agentSession.autoCompaction.test.ts @@ -1,11 +1,7 @@ import { afterEach, describe, expect, mock, spyOn, test } from "bun:test"; import { EventEmitter } from "events"; -import type { - ProvidersConfigMap, - SendMessageOptions, - WorkspaceChatMessage, -} from "@/common/orpc/types"; +import type { SendMessageOptions, WorkspaceChatMessage } from "@/common/orpc/types"; import { createMuxMessage, type CompactionFollowUpRequest, @@ -13,7 +9,7 @@ import { } from "@/common/types/message"; import { GOAL_CONTINUATION_KIND } from "@/constants/goals"; import { Ok, Err } from "@/common/types/result"; -import type { Config } from "@/node/config"; +import { ProvidersConfigStore, type Config } from "@/node/config"; import type { AIService } from "@/node/services/aiService"; import type { BackgroundProcessManager } from "@/node/services/backgroundProcessManager"; import type { InitStateManager } from "@/node/services/initStateManager"; @@ -390,8 +386,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }); const compactionModel = "openai:gpt-4o-mini"; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", loadConfigOrDefault: () => ({ agentAiDefaults: { compact: { modelString: compactionModel } }, }), @@ -648,8 +645,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { const compactionModel = "openai:gpt-5.5"; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", loadConfigOrDefault: () => ({ agentAiDefaults: { compact: { modelString: compactionModel } }, }), @@ -699,8 +697,9 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { const workspaceId = "ws-auto-compaction-compact-thinking-default"; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", loadConfigOrDefault: () => ({ agentAiDefaults: { compact: { modelString: "openai:gpt-5.5", thinkingLevel: "high" }, @@ -783,7 +782,7 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { test("threads providers config into pre-send and mid-stream compaction checks", async () => { const workspaceId = "ws-auto-compaction-providers-config"; - const { historyService, cleanup } = await createTestHistoryService(); + const { config, historyService, cleanup } = await createTestHistoryService(); historyCleanup = cleanup; const providersConfig = { @@ -795,7 +794,8 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }, ], }, - } as unknown as ProvidersConfigMap; + }; + new ProvidersConfigStore(config.rootDir).saveProvidersConfig(providersConfig); const aiEmitter = new EventEmitter(); const streamMessage = mock((_history: MuxMessage[]) => { @@ -845,12 +845,6 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { }), } as unknown as BackgroundProcessManager; - const config = { - srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", - loadProvidersConfig: () => providersConfig, - } as unknown as Config; - const session = new AgentSession({ workspaceId, config, @@ -967,8 +961,10 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { } as unknown as BackgroundProcessManager; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", + loadConfigOrDefault: () => ({}), } as unknown as Config; const session = new AgentSession({ @@ -1077,8 +1073,10 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { } as unknown as BackgroundProcessManager; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", + loadConfigOrDefault: () => ({}), } as unknown as Config; const session = new AgentSession({ @@ -1226,8 +1224,10 @@ describe("AgentSession on-send auto-compaction snapshot deferral", () => { } as unknown as BackgroundProcessManager; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", + loadConfigOrDefault: () => ({}), } as unknown as Config; const session = new AgentSession({ diff --git a/src/node/services/agentSession.continueMessageAgentId.test.ts b/src/node/services/agentSession.continueMessageAgentId.test.ts index 69decddff0..a0e0db679a 100644 --- a/src/node/services/agentSession.continueMessageAgentId.test.ts +++ b/src/node/services/agentSession.continueMessageAgentId.test.ts @@ -131,8 +131,10 @@ function createBackgroundProcessManager(): BackgroundProcessManager { function createConfig(): Config { return { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; } diff --git a/src/node/services/agentSession.editMessageId.test.ts b/src/node/services/agentSession.editMessageId.test.ts index 2872f286b0..5c6125fb70 100644 --- a/src/node/services/agentSession.editMessageId.test.ts +++ b/src/node/services/agentSession.editMessageId.test.ts @@ -15,8 +15,10 @@ type StreamMessageHandler = AIService["streamMessage"]; const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", + loadConfigOrDefault: () => ({}), } as unknown as Config; async function waitForCondition(condition: () => boolean, timeoutMs = 1000): Promise { diff --git a/src/node/services/agentSession.memoryContext.test.ts b/src/node/services/agentSession.memoryContext.test.ts index 16a145773b..9f933f43e1 100644 --- a/src/node/services/agentSession.memoryContext.test.ts +++ b/src/node/services/agentSession.memoryContext.test.ts @@ -22,6 +22,8 @@ import { createTestHistoryService } from "./testHistoryService"; * injected bytes stay prompt-cache-stable. */ +const WORKSPACE_ID = "workspace-hot-memories-test"; + function createSession(args: { historyService: HistoryService; sessionDir: string; @@ -60,12 +62,14 @@ function createSession(args: { } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: path.dirname(args.sessionDir), + sessionsDir: path.dirname(args.sessionDir), srcDir: "/tmp", - getSessionDir: mock(() => args.sessionDir), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; return new AgentSession({ - workspaceId: "workspace-hot-memories-test", + workspaceId: WORKSPACE_ID, config, historyService: args.historyService, aiService, @@ -83,6 +87,7 @@ interface PrivateSessionAccess { } async function writePendingPostCompactionState(sessionDir: string): Promise { + await fs.mkdir(sessionDir, { recursive: true }); await fs.writeFile( path.join(sessionDir, "post-compaction.json"), JSON.stringify({ version: 1, createdAt: Date.now(), diffs: [], loadedSkills: [] }) @@ -107,7 +112,7 @@ describe("AgentSession memory context", () => { const buildMemorySessionContext = mock(() => Promise.resolve(context)); const session = createSession({ historyService, - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), buildMemorySessionContext, }); const priv = session as unknown as PrivateSessionAccess; @@ -138,7 +143,7 @@ describe("AgentSession memory context", () => { ); const session = createSession({ historyService, - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), buildMemorySessionContext, }); const priv = session as unknown as PrivateSessionAccess; @@ -173,7 +178,7 @@ describe("AgentSession memory context", () => { ); const session = createSession({ historyService, - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), buildMemorySessionContext, }); const priv = session as unknown as PrivateSessionAccess; @@ -202,7 +207,7 @@ describe("AgentSession memory context", () => { const buildMemorySessionContext = mock(() => Promise.resolve(null)); const session = createSession({ historyService, - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), buildMemorySessionContext, }); const priv = session as unknown as PrivateSessionAccess; @@ -230,7 +235,7 @@ describe("AgentSession memory context", () => { ); const session = createSession({ historyService, - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), buildMemorySessionContext, }); const priv = session as unknown as PrivateSessionAccess; @@ -242,7 +247,7 @@ describe("AgentSession memory context", () => { // Consume a pending compaction boundary (first stream after compaction). version = 2; - await writePendingPostCompactionState(sessionDir.path); + await writePendingPostCompactionState(path.join(sessionDir.path, WORKSPACE_ID)); await priv.getPostCompactionAttachmentsIfNeeded(); expect((await priv.resolveMemoryContext("test-model"))?.hotMemoriesBlock).toBe( diff --git a/src/node/services/agentSession.postCompactionAttachments.test.ts b/src/node/services/agentSession.postCompactionAttachments.test.ts index e153f66b6f..cdce6fdb1e 100644 --- a/src/node/services/agentSession.postCompactionAttachments.test.ts +++ b/src/node/services/agentSession.postCompactionAttachments.test.ts @@ -102,6 +102,8 @@ function getAttachmentTypes( return attachments.map((attachment) => attachment.type); } +const WORKSPACE_ID = "workspace-post-compaction-test"; + function createSessionForHistory(historyService: HistoryService, sessionDir: string): AgentSession { const aiEmitter = new EventEmitter(); const aiService: AIService = { @@ -135,12 +137,14 @@ function createSessionForHistory(historyService: HistoryService, sessionDir: str } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: path.dirname(sessionDir), + sessionsDir: path.dirname(sessionDir), srcDir: "/tmp", - getSessionDir: mock(() => sessionDir), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; return new AgentSession({ - workspaceId: "workspace-post-compaction-test", + workspaceId: WORKSPACE_ID, config, historyService, aiService, @@ -187,6 +191,7 @@ async function writePendingPostCompactionState(args: { loadedSkills: LoadedSkillSnapshot[]; readFiles?: string[]; }): Promise { + await fs.mkdir(args.sessionDir, { recursive: true }); await fs.writeFile( path.join(args.sessionDir, "post-compaction.json"), JSON.stringify({ @@ -222,13 +227,16 @@ describe("AgentSession post-compaction attachments", () => { // A compaction persisted cumulative pre-boundary read paths... await writePendingPostCompactionState({ - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), diffs: [], loadedSkills: [], readFiles: ["/tmp/pre-boundary-read.ts"], }); - const session = createSessionForHistory(historyService, sessionDir.path); + const session = createSessionForHistory( + historyService, + path.join(sessionDir.path, WORKSPACE_ID) + ); const privateSession = session as unknown as { getPostCompactionAttachmentsIfNeeded: ( includeReadFiles: boolean @@ -251,10 +259,12 @@ describe("AgentSession post-compaction attachments", () => { } // The persisted pending state is discarded too, so a NEW session after // an app restart cannot resurrect the carryover either. - const stateExists = await fs.access(path.join(sessionDir.path, "post-compaction.json")).then( - () => true, - () => false - ); + const stateExists = await fs + .access(path.join(sessionDir.path, WORKSPACE_ID, "post-compaction.json")) + .then( + () => true, + () => false + ); expect(stateExists).toBe(false); } finally { session.dispose(); @@ -299,7 +309,10 @@ describe("AgentSession post-compaction attachments", () => { await historyService.appendToHistory(workspaceId, msg); } - const session = createSessionForHistory(historyService, sessionDir.path); + const session = createSessionForHistory( + historyService, + path.join(sessionDir.path, WORKSPACE_ID) + ); try { const attachments = await generatePeriodicPostCompactionAttachments(session); @@ -333,7 +346,10 @@ describe("AgentSession post-compaction attachments", () => { await historyService.appendToHistory(workspaceId, msg); } - const session = createSessionForHistory(historyService, sessionDir.path); + const session = createSessionForHistory( + historyService, + path.join(sessionDir.path, WORKSPACE_ID) + ); try { const attachments = await generatePeriodicPostCompactionAttachments(session); @@ -353,7 +369,7 @@ describe("AgentSession post-compaction attachments", () => { body: "Avoid unnecessary useEffect calls.", }); await writePendingPostCompactionState({ - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), diffs: [ { path: "/tmp/post-compaction.ts", @@ -364,11 +380,14 @@ describe("AgentSession post-compaction attachments", () => { loadedSkills: [loadedSkill], }); await fs.writeFile( - path.join(sessionDir.path, "todos.json"), + path.join(sessionDir.path, WORKSPACE_ID, "todos.json"), JSON.stringify([{ content: "Verify loaded skills", status: "in_progress" }]) ); - const session = createSessionForHistory(historyService, sessionDir.path); + const session = createSessionForHistory( + historyService, + path.join(sessionDir.path, WORKSPACE_ID) + ); try { const attachments = await getImmediatePostCompactionAttachments(session); @@ -411,7 +430,10 @@ describe("AgentSession post-compaction attachments", () => { await historyService.appendToHistory(workspaceId, msg); } - const session = createSessionForHistory(historyService, sessionDir.path); + const session = createSessionForHistory( + historyService, + path.join(sessionDir.path, WORKSPACE_ID) + ); const loadedSkill = createLoadedSkillFixture({ name: "react-effects", body: "Persist this guardrail across follow-up turns.", @@ -435,7 +457,7 @@ describe("AgentSession post-compaction attachments", () => { historyCleanup = cleanup; await writePendingPostCompactionState({ - sessionDir: sessionDir.path, + sessionDir: path.join(sessionDir.path, WORKSPACE_ID), diffs: [ { path: "/tmp/excluded-skills.ts", @@ -451,15 +473,18 @@ describe("AgentSession post-compaction attachments", () => { ], }); await fs.writeFile( - path.join(sessionDir.path, "todos.json"), + path.join(sessionDir.path, WORKSPACE_ID, "todos.json"), JSON.stringify([{ content: "Keep todo attached", status: "pending" }]) ); await fs.writeFile( - path.join(sessionDir.path, "exclusions.json"), + path.join(sessionDir.path, WORKSPACE_ID, "exclusions.json"), JSON.stringify({ excludedItems: ["skills"] }) ); - const session = createSessionForHistory(historyService, sessionDir.path); + const session = createSessionForHistory( + historyService, + path.join(sessionDir.path, WORKSPACE_ID) + ); try { const attachments = await getImmediatePostCompactionAttachments(session); diff --git a/src/node/services/agentSession.postCompactionRefresh.test.ts b/src/node/services/agentSession.postCompactionRefresh.test.ts index fa0781717e..9e69f19f4c 100644 --- a/src/node/services/agentSession.postCompactionRefresh.test.ts +++ b/src/node/services/agentSession.postCompactionRefresh.test.ts @@ -202,8 +202,10 @@ describe("AgentSession post-compaction refresh trigger", () => { } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; const onPostCompactionStateChange = mock(() => undefined); diff --git a/src/node/services/agentSession.postCompactionRetry.test.ts b/src/node/services/agentSession.postCompactionRetry.test.ts index 4a63dc701a..5307678945 100644 --- a/src/node/services/agentSession.postCompactionRetry.test.ts +++ b/src/node/services/agentSession.postCompactionRetry.test.ts @@ -50,7 +50,9 @@ describe("AgentSession post-compaction context retry", () => { test("retries once without post-compaction injection on context_exceeded", async () => { const workspaceId = "ws"; - const sessionDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + const sessionsDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + const sessionDir = path.join(sessionsDir, workspaceId); + await fsPromises.mkdir(sessionDir); const postCompactionPath = path.join(sessionDir, "post-compaction.json"); await createPersistedPostCompactionState({ @@ -146,8 +148,10 @@ describe("AgentSession post-compaction context retry", () => { } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: sessionsDir, + sessionsDir, srcDir: "/tmp", - getSessionDir: mock(() => sessionDir), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; const session = new AgentSession({ @@ -221,7 +225,9 @@ describe("AgentSession post-compaction context retry", () => { // event) leave a child task running until the parent times out. test("recovery decision resolves only after the context retry startup outcome is known", async () => { const workspaceId = "ws-decision"; - const sessionDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + const sessionsDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + const sessionDir = path.join(sessionsDir, workspaceId); + await fsPromises.mkdir(sessionDir); await createPersistedPostCompactionState({ filePath: path.join(sessionDir, "post-compaction.json"), diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], @@ -299,8 +305,10 @@ describe("AgentSession post-compaction context retry", () => { } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: sessionsDir, + sessionsDir, srcDir: "/tmp", - getSessionDir: mock(() => sessionDir), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; const session = new AgentSession({ @@ -366,7 +374,9 @@ describe("AgentSession post-compaction context retry", () => { // settlement convinced the (dead) retry is still carrying the turn. test("a retry that starts and then fails terminally records separate per-attempt outcomes", async () => { const workspaceId = "ws-overlap"; - const sessionDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + const sessionsDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "mux-agentSession-")); + const sessionDir = path.join(sessionsDir, workspaceId); + await fsPromises.mkdir(sessionDir); await createPersistedPostCompactionState({ filePath: path.join(sessionDir, "post-compaction.json"), diffs: [{ path: "/tmp/foo.ts", diff: "@@ -1 +1 @@\n-foo\n+bar\n", truncated: false }], @@ -442,8 +452,10 @@ describe("AgentSession post-compaction context retry", () => { } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: sessionsDir, + sessionsDir, srcDir: "/tmp", - getSessionDir: mock(() => sessionDir), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; const session = new AgentSession({ diff --git a/src/node/services/agentSession.preTurnMessages.test.ts b/src/node/services/agentSession.preTurnMessages.test.ts index 75f3dbc493..3ee5be6bfa 100644 --- a/src/node/services/agentSession.preTurnMessages.test.ts +++ b/src/node/services/agentSession.preTurnMessages.test.ts @@ -12,8 +12,10 @@ import { createStartedTurnHandle, createStreamLifecycleMocks } from "./agentSess const TEST_MODEL = "anthropic:claude-3-5-sonnet-latest"; const config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: (_workspaceId: string) => "/tmp", + loadConfigOrDefault: () => ({}), } as unknown as Config; // r30: family-message payload rows ride sendMessage as pre-turn rows so they diff --git a/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts index 4865eba44a..1f2f5fb3b5 100644 --- a/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts +++ b/src/node/services/agentSession.resumeStreamEmptyHistory.test.ts @@ -41,8 +41,10 @@ describe("AgentSession.resumeStream", () => { } as unknown as BackgroundProcessManager; const config: Config = { + rootDir: "/tmp", + sessionsDir: "/tmp", srcDir: "/tmp", - getSessionDir: mock(() => "/tmp"), + loadConfigOrDefault: mock(() => ({})), } as unknown as Config; const session = new AgentSession({ diff --git a/src/node/services/agentSession.testHarness.ts b/src/node/services/agentSession.testHarness.ts index 5d53b30cf6..fac756ca28 100644 --- a/src/node/services/agentSession.testHarness.ts +++ b/src/node/services/agentSession.testHarness.ts @@ -34,8 +34,9 @@ export function createFailedTurnHandle( function createAgentSessionTestConfig(sessionDir = "/tmp"): Config { return { + rootDir: sessionDir, + sessionsDir: sessionDir, srcDir: sessionDir, - getSessionDir: mock((_workspaceId: string) => sessionDir), loadConfigOrDefault: mock(() => ({})), } as unknown as Config; } diff --git a/src/node/services/bashMonitorRegistryStore.test.ts b/src/node/services/bashMonitorRegistryStore.test.ts index f8018b9cb8..2ae2db2b6d 100644 --- a/src/node/services/bashMonitorRegistryStore.test.ts +++ b/src/node/services/bashMonitorRegistryStore.test.ts @@ -10,12 +10,8 @@ import { BashMonitorRegistryStore, } from "@/node/services/bashMonitorRegistryStore"; -function makeConfig(rootDir: string): { - sessionsDir: string; - getSessionDir: (id: string) => string; -} { - const sessionsDir = path.join(rootDir, "sessions"); - return { sessionsDir, getSessionDir: (id: string) => path.join(sessionsDir, id) }; +function makeConfig(rootDir: string): { sessionsDir: string } { + return { sessionsDir: path.join(rootDir, "sessions") }; } function armedPayload(overrides: Partial = {}): MonitorArmedPayload { diff --git a/src/node/services/bashMonitorWakeStore.test.ts b/src/node/services/bashMonitorWakeStore.test.ts index 3129710677..47e6899f6e 100644 --- a/src/node/services/bashMonitorWakeStore.test.ts +++ b/src/node/services/bashMonitorWakeStore.test.ts @@ -18,12 +18,8 @@ import { type BashMonitorWakeRecord, } from "@/node/services/bashMonitorWakeStore"; -function makeConfig(rootDir: string): { - sessionsDir: string; - getSessionDir: (id: string) => string; -} { - const sessionsDir = path.join(rootDir, "sessions"); - return { sessionsDir, getSessionDir: (id: string) => path.join(sessionsDir, id) }; +function makeConfig(rootDir: string): { sessionsDir: string } { + return { sessionsDir: path.join(rootDir, "sessions") }; } function payload(overrides: Partial = {}): BashMonitorWakePayload { diff --git a/src/node/services/terminalAttentionStore.test.ts b/src/node/services/terminalAttentionStore.test.ts index 1274f2a3a2..8b17667e1d 100644 --- a/src/node/services/terminalAttentionStore.test.ts +++ b/src/node/services/terminalAttentionStore.test.ts @@ -9,12 +9,8 @@ import { TerminalAttentionStore, } from "@/node/services/terminalAttentionStore"; -function makeConfig(rootDir: string): { - sessionsDir: string; - getSessionDir: (id: string) => string; -} { - const sessionsDir = path.join(rootDir, "sessions"); - return { sessionsDir, getSessionDir: (id: string) => path.join(sessionsDir, id) }; +function makeConfig(rootDir: string): { sessionsDir: string } { + return { sessionsDir: path.join(rootDir, "sessions") }; } describe("TerminalAttentionStore", () => { diff --git a/src/node/utils/eventStore.test.ts b/src/node/utils/eventStore.test.ts index 5f9e79f532..50f29566eb 100644 --- a/src/node/utils/eventStore.test.ts +++ b/src/node/utils/eventStore.test.ts @@ -53,7 +53,6 @@ describe("EventStore", () => { mockConfig = { muxDir: path.join(__dirname, "../.."), sessionsDir: testSessionDir, - getSessionDir: (workspaceId: string) => path.join(testSessionDir, workspaceId), } as unknown as Config; emittedEvents = []; From 67c464fb419730102f24f85575d58110707bbe0a Mon Sep 17 00:00:00 2001 From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com> Date: Sun, 30 Aug 2026 16:15:43 +0000 Subject: [PATCH 17/17] =?UTF-8?q?=F0=9F=A4=96=20style(config):=20format=20?= =?UTF-8?q?rebase-resolution=20fallout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/node/services/historyService.ts | 4 +--- src/node/services/workflows/WorkflowRunStore.ts | 8 ++++++-- .../services/workflows/WorkflowService.context.test.ts | 4 +++- src/node/services/workflows/WorkflowService.ts | 4 +++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/src/node/services/historyService.ts b/src/node/services/historyService.ts index 819467dd3f..b0bbb3b9f5 100644 --- a/src/node/services/historyService.ts +++ b/src/node/services/historyService.ts @@ -245,9 +245,7 @@ export class HistoryService { workspaceId: string; entry: SubagentTranscriptArtifactIndexEntry; } | null> => { - const artifacts = await readSubagentTranscriptArtifactsFile( - this.getSessionDir(workspaceId) - ); + const artifacts = await readSubagentTranscriptArtifactsFile(this.getSessionDir(workspaceId)); const entry = artifacts.artifactsByChildTaskId[taskId] ?? null; return entry ? { workspaceId, entry } : null; }; diff --git a/src/node/services/workflows/WorkflowRunStore.ts b/src/node/services/workflows/WorkflowRunStore.ts index 3a94c35a48..2a0e74e21a 100644 --- a/src/node/services/workflows/WorkflowRunStore.ts +++ b/src/node/services/workflows/WorkflowRunStore.ts @@ -68,7 +68,9 @@ export async function getWorkflowRunStatusesForOwners( try { let store = stores.get(ref.workspaceId); if (store == null) { - store = new WorkflowRunStore({ sessionDir: path.join(context.sessionsDir, ref.workspaceId) }); + store = new WorkflowRunStore({ + sessionDir: path.join(context.sessionsDir, ref.workspaceId), + }); stores.set(ref.workspaceId, store); } const status = await store.getRunStatusForLiveness(ref); @@ -87,7 +89,9 @@ export async function listActiveWorkflowRunsForOwners( ) { const results = await Promise.all( workspaceIds.filter(isPathSafeWorkspaceId).map(async (workspaceId) => { - const store = new WorkflowRunStore({ sessionDir: path.join(context.sessionsDir, workspaceId) }); + const store = new WorkflowRunStore({ + sessionDir: path.join(context.sessionsDir, workspaceId), + }); const summaries = await store.listActiveRunSummaries({ workspaceId }); return summaries.map((summary) => ({ workspaceId, ...summary })); }) diff --git a/src/node/services/workflows/WorkflowService.context.test.ts b/src/node/services/workflows/WorkflowService.context.test.ts index 274f54c05e..3a33934457 100644 --- a/src/node/services/workflows/WorkflowService.context.test.ts +++ b/src/node/services/workflows/WorkflowService.context.test.ts @@ -157,7 +157,9 @@ describe("WorkflowService request orchestration", () => { workspaceService.waitForWorkspaceIdle = mock( () => new Promise((resolve) => (releaseIdle = resolve)) ); - const runStore = new WorkflowRunStore({ sessionDir: path.join(config.sessionsDir, "workspace-1") }); + const runStore = new WorkflowRunStore({ + sessionDir: path.join(config.sessionsDir, "workspace-1"), + }); const start = startWorkflowRun(context, { workspaceId: "workspace-1", diff --git a/src/node/services/workflows/WorkflowService.ts b/src/node/services/workflows/WorkflowService.ts index e52439d46a..2ed151b162 100644 --- a/src/node/services/workflows/WorkflowService.ts +++ b/src/node/services/workflows/WorkflowService.ts @@ -1048,7 +1048,9 @@ export async function resolveWorkflowContext( service: new WorkflowService({ notifyInterruptedBackgroundRunTerminal: options.notifyInterruptedBackgroundRunTerminal === true, - runStore: new WorkflowRunStore({ sessionDir: path.join(context.config.sessionsDir, workspaceId) }), + runStore: new WorkflowRunStore({ + sessionDir: path.join(context.config.sessionsDir, workspaceId), + }), runtimeFactory: context.workflowRuntimeFactory, taskAdapterFactory: (runId, workflowName) => new WorkflowTaskServiceAdapter({