From e3eeb7814c9f38695a5a8a87cf60ab372061e4a5 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:51:04 +0530 Subject: [PATCH 01/11] refactor(server): one helper turns CODEX_HOME into a Codex home CodexDriver, codexUsage, and AgentSessionScanner each carried their own copy of the 'environment CODEX_HOME stands in for blank settings homes' ternary, and CodexAdapterV2 omitted the substitution entirely, so its settings home could diverge from the home the driver spawns with. All four now go through withCodexEnvironmentHome in CodexHomeLayout. The helper assigns the raw env value and trims only for the blank gate, which the existing padded-path fixture in CodexDriver.test pins. The one intentional behavior alignment: the adapter now honors env CODEX_HOME for blank-home instances, matching the routes already honoring it. codexUsage keeps its managed-mode guard at the call site; the adapter hoists its construction-time environment merge once so derivation and the environment slot share it. GLM-5.3-Flash Free via the OpenCode harness. --- .../Adapters/CodexAdapterV2.ts | 11 ++++--- .../server/src/project/AgentSessionScanner.ts | 16 +++++----- .../src/provider/Drivers/CodexDriver.ts | 6 ++-- .../provider/Drivers/CodexHomeLayout.test.ts | 32 +++++++++++++++++++ .../src/provider/Drivers/CodexHomeLayout.ts | 17 ++++++++++ .../server/src/provider/Drivers/codexUsage.ts | 11 +++---- 6 files changed, 70 insertions(+), 23 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f81a3809b35fe..477acd9ec08e4 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -113,6 +113,7 @@ import { import { materializeCodexShadowHome, resolveCodexHomeLayout, + withCodexEnvironmentHome, } from "../../provider/Drivers/CodexHomeLayout.ts"; import { boundProviderEventForLogging, @@ -1592,7 +1593,9 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter const hostEnvironment = yield* HostProcess.Environment; const idAllocator = yield* IdAllocator.IdAllocatorV2; const serverConfig = yield* ServerConfig; - const homeLayout = yield* resolveCodexHomeLayout(config); + const processEnv = yield* mergeProviderInstanceEnvironment(environment, hostEnvironment); + const effectiveConfig = withCodexEnvironmentHome(config, processEnv.CODEX_HOME); + const homeLayout = yield* resolveCodexHomeLayout(effectiveConfig); yield* materializeCodexShadowHome(homeLayout).pipe( Effect.mapError( @@ -1607,16 +1610,16 @@ export const createCodexAdapterV2 = Effect.fn("CodexAdapterV2.createCodexAdapter ); const settings = { - ...config, + ...effectiveConfig, enabled, - binaryPath: expandHomePath(config.binaryPath, yield* HostProcess.HomeDirectory), + binaryPath: expandHomePath(effectiveConfig.binaryPath, yield* HostProcess.HomeDirectory), homePath: homeLayout.effectiveHomePath ?? "", } satisfies CodexSettings; return yield* makeCodexAdapterV2({ instanceId, settings, - environment: yield* mergeProviderInstanceEnvironment(environment, hostEnvironment), + environment: processEnv, clientFactory, crypto, fileSystem, diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index bbd3ba5a631e0..2cecb95612e5d 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -31,7 +31,10 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server import * as ServerConfig from "../config.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; -import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { + resolveCodexHomeLayout, + withCodexEnvironmentHome, +} from "../provider/Drivers/CodexHomeLayout.ts"; import { mergeProviderInstanceEnvironment } from "@t3tools/provider-core/server/instanceEnvironment"; import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { resolvePiSessionsDir } from "@t3tools/provider-pi/server/usage"; @@ -1326,13 +1329,10 @@ export const make = Effect.gen(function* () { } else { const config = decodeCodexSettings(instance.config ?? {}); if (Option.isNone(config)) continue; - const codexHome = environmentHome("CODEX_HOME"); - const codexSettings = - config.value.homePath.trim().length === 0 && - config.value.shadowHomePath.trim().length === 0 && - codexHome?.trim() - ? { ...config.value, homePath: codexHome } - : config.value; + const codexSettings = withCodexEnvironmentHome( + config.value, + environmentHome("CODEX_HOME"), + ); const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( Effect.provideService(Path.Path, path), ); diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 61f26c3f8cc7d..39e3504123222 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -48,6 +48,7 @@ import { codexContinuationIdentity, materializeCodexShadowHome, resolveCodexHomeLayout, + withCodexEnvironmentHome, } from "./CodexHomeLayout.ts"; import { makeManagedCodexProvider } from "./CodexManagedProvider.ts"; import { codexUsageReader } from "./codexUsage.ts"; @@ -154,11 +155,8 @@ export const CodexDriver: ProviderDriver< environment, yield* HostProcess.Environment, ); - const environmentHome = processEnv.CODEX_HOME; const homeLayout = yield* resolveCodexHomeLayout( - !config.homePath.trim() && !config.shadowHomePath.trim() && environmentHome?.trim() - ? { ...config, homePath: environmentHome } - : config, + withCodexEnvironmentHome(config, processEnv.CODEX_HOME), ); const continuationIdentity = codexContinuationIdentity(homeLayout); const stampIdentity = withInstanceIdentity({ diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts index 2bbcea20faa91..e4965ac1cc535 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.test.ts @@ -11,6 +11,7 @@ import { CodexShadowHomePathConflictError, materializeCodexShadowHome, resolveCodexHomeLayout, + withCodexEnvironmentHome, } from "./CodexHomeLayout.ts"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); @@ -23,6 +24,37 @@ const decodeCodexSettings = (input: { readonly binaryPath?: string; }): CodexSettings => decodeCodexSettingsValue(input); +describe("withCodexEnvironmentHome", () => { + it("fills blank settings homes from the environment CODEX_HOME", () => { + const config = decodeCodexSettings({ + homePath: "", + shadowHomePath: "", + }); + + expect(withCodexEnvironmentHome(config, "/tmp/env-home").homePath).toBe("/tmp/env-home"); + expect(withCodexEnvironmentHome(config, " /tmp/padded ").homePath).toBe(" /tmp/padded "); + }); + + it("keeps instance settings and blank/absent environment homes untouched", () => { + const envHome = "/tmp/env-home"; + + const withHome = decodeCodexSettings({ homePath: "/tmp/instance-home" }); + expect(withCodexEnvironmentHome(withHome, envHome)).toStrictEqual(withHome); + + const withShadow = decodeCodexSettings({ shadowHomePath: "/tmp/shadow-home" }); + expect(withCodexEnvironmentHome(withShadow, envHome)).toStrictEqual(withShadow); + + const blank = decodeCodexSettings({ homePath: "", shadowHomePath: "" }); + expect(withCodexEnvironmentHome(blank, undefined)).toStrictEqual(blank); + expect(withCodexEnvironmentHome(blank, " ")).toStrictEqual(blank); + }); + + it("preserves a blank homePath when only the shadow home is blank", () => { + const config = decodeCodexSettings({ homePath: "/tmp/instance-home", shadowHomePath: "" }); + expect(withCodexEnvironmentHome(config, "/tmp/env-home").homePath).toBe("/tmp/instance-home"); + }); +}); + const makeTempDir = Effect.fn("CodexHomeLayout.test.makeTempDir")(function* (prefix: string) { const fileSystem = yield* FileSystem.FileSystem; return yield* fileSystem.makeTempDirectoryScoped({ prefix }); diff --git a/apps/server/src/provider/Drivers/CodexHomeLayout.ts b/apps/server/src/provider/Drivers/CodexHomeLayout.ts index f6d2ba9d44cf5..5515e8c2178e5 100644 --- a/apps/server/src/provider/Drivers/CodexHomeLayout.ts +++ b/apps/server/src/provider/Drivers/CodexHomeLayout.ts @@ -46,6 +46,23 @@ function resolveHomePath(path: Path.Path, home: string, value: string | undefine return path.resolve(expanded); } +/** + * CODEX_HOME in the provider environment stands in for blank settings homes. A shadow home is + * the process's own identity, so it never defers to the environment. + */ +export const withCodexEnvironmentHome = ( + config: CodexSettings, + environmentHome: string | undefined, +): CodexSettings => { + const blankSettingsHomes = + config.homePath.trim().length === 0 && config.shadowHomePath.trim().length === 0; + // Assign the raw value: the spawned process receives it verbatim, so a padding-bearing + // path is a distinct user home ("work" vs "work "), not whitespace noise to normalize. + return blankSettingsHomes && environmentHome !== undefined && environmentHome.trim().length > 0 + ? { ...config, homePath: environmentHome } + : config; +}; + export const resolveCodexHomeLayout = Effect.fn("resolveCodexHomeLayout")(function* ( config: CodexSettings, ): Effect.fn.Return { diff --git a/apps/server/src/provider/Drivers/codexUsage.ts b/apps/server/src/provider/Drivers/codexUsage.ts index 20939e54db799..50e066d530fa4 100644 --- a/apps/server/src/provider/Drivers/codexUsage.ts +++ b/apps/server/src/provider/Drivers/codexUsage.ts @@ -14,7 +14,7 @@ import { import type { UsageTokenTotals } from "@t3tools/contracts"; import * as CodexUsageHistory from "../../usage/CodexUsageHistory.ts"; -import { resolveCodexHomeLayout } from "./CodexHomeLayout.ts"; +import { resolveCodexHomeLayout, withCodexEnvironmentHome } from "./CodexHomeLayout.ts"; const TokenCount = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0)); const CodexTokenUsage = Schema.Struct({ @@ -384,13 +384,10 @@ export const codexUsageReader: ProviderUsageReader< directories: Effect.fn("codexUsageReader.directories")(function* ({ config, environment }) { if (config === undefined) return []; const path = yield* Path.Path; - const environmentHome = environment.CODEX_HOME?.trim(); const layout = yield* resolveCodexHomeLayout( - config.setupMode !== "managed" && - !config.homePath.trim() && - !config.shadowHomePath.trim() && - environmentHome - ? { ...config, homePath: environmentHome } + // Usage history must keep reporting managed homes even when instance settings are blank. + config.setupMode !== "managed" + ? withCodexEnvironmentHome(config, environment.CODEX_HOME) : config, ); return [{ dir: path.resolve(layout.sharedHomePath, "sessions") }]; From 4869bec3e79d3904c7876a7f443f3432210b3e93 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 2 Nov 2026 22:52:46 +0530 Subject: [PATCH 02/11] fix(server): an external Codex account swap releases the running app-server External account changes (codex login, an app swapping auth.json) were invisible to a running app-server: it caches auth at spawn, only same-account reloads are adopted in-process, and the old session kept serving turns on the swapped-away account whenever the old token still worked. Issue #23720 is the loud variant where refresh fails every turn; the silent one has no open PR (#28108 scopes external swaps out). Adapters may expose resolveCredentialFingerprint, a tri-state probe (identity string; null while the provider surface carries no file credential; undefined when unobservable). ProviderSessionManager records the fingerprint at open and releases a live session on any change, with reason manual_shutdown, detail 'Provider credentials changed.', and an orchestration-v2.provider-session-released log since release details are not persisted; the released session respawns and the fresh app-server re-reads auth.json at spawn. Keyring-only swaps leave no disk signal, a documented limitation. GLM-5.3-Flash Free via the OpenCode harness. --- .../Adapters/CodexAdapterV2.ts | 21 + .../ProviderSessionManager.test.ts | 663 ++++++++++++++++++ .../ProviderSessionManager.ts | 85 ++- .../provider/Drivers/CodexCredentials.test.ts | 387 ++++++++++ .../src/provider/Drivers/CodexCredentials.ts | 75 ++ .../src/server/ProviderAdapter.ts | 6 + 6 files changed, 1236 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/provider/Drivers/CodexCredentials.test.ts create mode 100644 apps/server/src/provider/Drivers/CodexCredentials.ts diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index 477acd9ec08e4..8e298383e2c22 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -115,6 +115,7 @@ import { resolveCodexHomeLayout, withCodexEnvironmentHome, } from "../../provider/Drivers/CodexHomeLayout.ts"; +import { resolveCodexCredentialFingerprint } from "../../provider/Drivers/CodexCredentials.ts"; import { boundProviderEventForLogging, shouldPersistProviderEvent, @@ -1656,6 +1657,9 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( adapterOptions: CodexAdapterV2Options, ) { const mcpSessions = yield* McpProviderSessions.McpProviderSessions; + const path = yield* Path.Path; + const hostEnvironment = yield* HostProcess.Environment; + const homeDirectory = yield* HostProcess.HomeDirectory; const readMcpSession = (threadId: ThreadId | null) => // @effect-diagnostics-next-line effectSucceedWithVoid:off - This optional result requires undefined, not the wider void type. threadId === null ? Effect.succeed(undefined) : mcpSessions.read(threadId); @@ -1665,6 +1669,23 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( return ProviderAdapter.ProviderAdapterV2.of({ instanceId: adapterOptions.instanceId, driver: CODEX_PROVIDER, + ...(adapterOptions.settings.setupMode !== "managed" + ? { + resolveCredentialFingerprint: Effect.fn("CodexAdapterV2.resolveCredentialFingerprint")( + () => + resolveCodexCredentialFingerprint( + adapterOptions.settings, + adapterOptions.environment, + ).pipe( + Effect.provideService(Crypto.Crypto, crypto), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(HostProcess.Environment, hostEnvironment), + Effect.provideService(HostProcess.HomeDirectory, homeDirectory), + ), + ), + } + : {}), getCapabilities: Effect.fn("CodexAdapterV2.getCapabilities")(function () { return Effect.succeed(CodexProviderCapabilitiesV2); }), diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 5bbb3926e2b66..8544042d98dba 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -329,6 +329,8 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( readonly mcpConfigs?: Ref.Ref< ReadonlyArray >; + readonly credentialFingerprint?: Ref.Ref; + readonly credentialFingerprintCapability?: { current: boolean }; readonly beforeOpen?: (input: { readonly providerSessionId: ProviderSessionId; readonly initialProviderItemIdentityVersion?: 2; @@ -358,11 +360,28 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( : Deferred.succeed(options.scopeCloseReached, undefined) ).pipe(Effect.andThen(Effect.never)), ); + // exactOptionalPropertyTypes cannot express key-present-undefined; a disabled capability + // resolves to undefined, which the seam records as an unreadable (null) baseline. The first + // readable fingerprint is then adopted. + // @effect-diagnostics-next-line effectSucceedWithVoid:off - optional capability result requires undefined, not void. + const fingerprintUnavailable = Effect.succeed(undefined); return ProviderAdapter.ProviderAdapterV2.of({ instanceId: ProviderInstanceId.make("codex"), driver: CODEX_DRIVER, getCapabilities: () => Effect.succeed(options.capabilities ?? CodexCapabilities), planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" }), + ...(options.credentialFingerprint === undefined && + options.credentialFingerprintCapability === undefined + ? {} + : { + resolveCredentialFingerprint: () => + options.credentialFingerprintCapability !== undefined && + !options.credentialFingerprintCapability.current + ? fingerprintUnavailable + : options.credentialFingerprint === undefined + ? fingerprintUnavailable + : Ref.get(options.credentialFingerprint!), + }), openSession: (input) => Effect.gen(function* () { if (options.spawnBeforeOpen === true) { @@ -469,6 +488,8 @@ function layerTest(input: { readonly mcpConfigs?: Ref.Ref< ReadonlyArray >; + readonly credentialFingerprint?: Ref.Ref; + readonly credentialFingerprintCapability?: { current: boolean }; readonly beforeOpen?: (input: { readonly providerSessionId: ProviderSessionId; readonly initialProviderItemIdentityVersion?: 2; @@ -508,6 +529,12 @@ function layerTest(input: { failEventStream: input.failEventStream ?? false, ...(input.capabilities === undefined ? {} : { capabilities: input.capabilities }), ...(input.mcpConfigs === undefined ? {} : { mcpConfigs: input.mcpConfigs }), + ...(input.credentialFingerprint === undefined + ? {} + : { credentialFingerprint: input.credentialFingerprint }), + ...(input.credentialFingerprintCapability === undefined + ? {} + : { credentialFingerprintCapability: input.credentialFingerprintCapability }), ...(input.beforeOpen === undefined ? {} : { beforeOpen: input.beforeOpen }), ...(input.hasPendingBackgroundWork === undefined ? {} @@ -3819,3 +3846,639 @@ it.effect("ProviderSessionManagerV2 gives a delegated child its project's device assert.isTrue(child?.capabilities?.has("device")); }), ); + +it.effect( + "ProviderSessionManagerV2 returns cached runtime when credential fingerprint is unchanged", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-unchanged"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 releases idle session and opens fresh runtime on credential fingerprint change", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-change-idle"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + yield* Ref.set(credentialFingerprint, "fp-bob"); + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.notStrictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 keeps the cached runtime when the fingerprint re-reads to the same value", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-same-digest"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + yield* Ref.set(credentialFingerprint, "fp-alice"); + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 retains busy session across fingerprint flip until in-flight turn finishes", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const projectionStore = yield* ProjectionStore.ProjectionStoreV2; + const now = yield* DateTime.now; + const threadA = ThreadId.make("thread-fp-busy-peer-a"); + const threadB = ThreadId.make("thread-fp-busy-peer-b"); + const providerSessionId = idAllocator.derive.providerSession({ + providerInstanceId: modelSelection.instanceId, + }); + + yield* eventSink.write({ + events: [ + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadA, now }), + yield* makeThreadCreatedEvent({ idAllocator, threadId: threadB, now }), + ], + }); + const runtimeA = yield* manager.open({ + threadId: threadA, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const providerThreadA = makeProviderThread({ + idAllocator, + threadId: threadA, + providerSessionId, + now, + nativeThreadId: "native-busy-a", + }); + const runId = idAllocator.derive.run({ threadId: threadA, ordinal: 1 }); + const attemptId = idAllocator.derive.runAttempt({ runId, attemptOrdinal: 1 }); + const rootNodeId = idAllocator.derive.rootNode({ runId }); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: "native-turn-busy-a", + }); + + yield* runtimeA.startTurn({ + appThread: (yield* projectionStore.getThreadProjection(threadA)).thread, + threadId: threadA, + runId, + runOrdinal: 1, + providerTurnOrdinal: 1, + attemptId, + rootNodeId, + providerThread: providerThreadA, + message: { + createdBy: "user", + creationSource: "web", + messageId: yield* idAllocator.allocate.message({ threadId: threadA, ordinal: 1 }), + text: "turn", + attachments: [], + }, + modelSelection, + runtimePolicy, + }); + + yield* Ref.set(credentialFingerprint, "fp-bob"); + + const runtimeB = yield* manager.open({ + threadId: threadB, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.strictEqual(runtimeB, runtimeA); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + // The pump clears busyTurns (markIdle) before fan-out, so observing + // turn.terminal here is the real milestone — no scheduler-yield guess. + const subscription = yield* runtimeA.subscribeEvents!; + const queue = (yield* Ref.get(state)).eventQueues.get(String(providerSessionId)); + assert.isDefined(queue); + yield* Queue.offer(queue!, { + type: "turn.terminal", + driver: CODEX_DRIVER, + providerThreadId: providerThreadA.id, + providerTurnId, + runOrdinal: 1, + status: "completed", + failure: null, + threadDisposition: "reusable", + }); + const terminalEvent = Option.getOrNull(yield* subscription.events.pipe(Stream.runHead)); + assert.equal(terminalEvent?.type, "turn.terminal"); + + const freshRuntime = yield* manager.open({ + threadId: threadB, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.notStrictEqual(freshRuntime, runtimeA); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 retains the session while background work is pending across a fingerprint flip", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const hasPendingBackgroundWork = yield* Ref.make(true); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-background-work"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + yield* Ref.set(credentialFingerprint, "fp-bob"); + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + yield* Ref.set(hasPendingBackgroundWork, false); + const thirdRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.notStrictEqual(thirdRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + hasPendingBackgroundWork: Ref.get(hasPendingBackgroundWork), + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 adopts the first readable fingerprint after an unobservable baseline and recycles on a later change", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const capability = { current: false }; + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-adopt-null"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + capability.current = true; + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + yield* Ref.set(credentialFingerprint, "fp-bob"); + const thirdRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.notStrictEqual(thirdRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + credentialFingerprintCapability: capability, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 does not release or churn state when credentials become unreadable", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-unreadable"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + yield* Ref.set(credentialFingerprint, undefined); + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + yield* Ref.set(credentialFingerprint, "fp-alice"); + const thirdRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.strictEqual(thirdRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 releases the session when the probe reads the file credential missing after a known baseline", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-missing-file"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* Ref.get(state)).openCount, 1); + + yield* Ref.set(credentialFingerprint, null); + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.notStrictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 treats a logged-out first reading as a real baseline and releases on sign-in", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make(null); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-login-later"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + + yield* Ref.set(credentialFingerprint, "fp-bob"); + + const thirdRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.notStrictEqual(thirdRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 skips credential check when adapter lacks resolveCredentialFingerprint", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-capability-absent"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + + assert.strictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + }), + ), + ); + }), +); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 03a7278774a64..1ef9432e9b2c5 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -221,6 +221,8 @@ interface LiveSessionEntry { /** Scope revocation to recorded credential ids so superseded sessions cannot revoke replacements opened before detach. */ readonly mcpCredentialIdByThread: ReadonlyMap; readonly supportsMultipleProviderThreads: boolean; + /** Captured external credential identity (e.g. Codex CLI auth.json). undefined: no baseline yet; null: unreadable/missing. */ + readonly credentialFingerprint?: string | null; readonly runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly exposedRuntime: ProviderAdapter.ProviderAdapterV2SessionRuntime; readonly eventSubscribers: Ref.Ref< @@ -1185,6 +1187,78 @@ export const layerWithOptions = ( }, ); + const releaseIfCredentialChanged = Effect.fn( + "ProviderSessionManager.releaseIfCredentialChanged", + )(function* (entry: LiveSessionEntry): Effect.fn.Return { + const adapterOpt = yield* registry.get(entry.runtime.instanceId).pipe(Effect.option); + if (Option.isNone(adapterOpt)) { + return false; + } + const resolve = adapterOpt.value.resolveCredentialFingerprint; + if (resolve === undefined) { + return false; + } + // A failed probe or an unobservable state (undefined) carries no signal: keep, + // unlike the idle path, which releases on failure. + const currentFingerprint = yield* resolve().pipe( + // @effect-diagnostics-next-line effectSucceedWithVoid:off - the probe result must be undefined, not the wider void Effect.void carries. + Effect.catchCause(() => Effect.succeed(undefined)), + ); + if (currentFingerprint === undefined) { + return false; + } + const baseline = entry.credentialFingerprint; + const key = sessionKey(entry.runtime.providerSessionId); + // A legacy (undefined) baseline adopts the first readable fingerprint, whatever it shows. + if (baseline === undefined) { + const live = (yield* Ref.get(sessions)).get(key); + if (live?.runtime !== entry.runtime) { + return false; + } + yield* Ref.update(sessions, (current) => { + const currentEntry = current.get(key); + if (currentEntry?.runtime !== entry.runtime) { + return current; + } + const updated = new Map(current); + updated.set(key, { ...currentEntry, credentialFingerprint: currentFingerprint }); + return updated; + }); + return false; + } + if (baseline === currentFingerprint) { + return false; + } + const hasPendingWork = + entry.runtime.hasPendingBackgroundWork === undefined + ? false + : yield* entry.runtime.hasPendingBackgroundWork.pipe( + Effect.catchCause(() => Effect.succeed(true)), + ); + if (hasPendingWork) { + return false; + } + yield* releaseEntry({ + providerSessionId: entry.runtime.providerSessionId, + reason: "manual_shutdown", + detail: "Provider credentials changed.", + keepIfBusyOtherThan: [], + }).pipe( + // detail is not persisted for non-error releases and idle sessions have no + // subscribers — the log is where a released app-server can be attributed. + Effect.tap(() => + Effect.logInfo("orchestration-v2.provider-session-released", { + providerSessionId: entry.runtime.providerSessionId, + reason: "manual_shutdown", + detail: "Provider credentials changed.", + }), + ), + logReleaseFailure(entry.runtime.providerSessionId), + ); + const live = (yield* Ref.get(sessions)).get(key); + return live?.runtime !== entry.runtime; + }); + const withActivityError = ( providerSessionId: ProviderSessionId, effect: Effect.Effect, @@ -2136,7 +2210,9 @@ export const layerWithOptions = ( } const key = sessionKey(input.providerSessionId); const existing = (yield* Ref.get(sessions)).get(key); - if (existing !== undefined) { + // Reuse requires no credential-driven release; upstream's + // releaseIfAdapterRebuilt will `||` at this same seam once it merges. + if (existing !== undefined && !(yield* releaseIfCredentialChanged(existing))) { const attached = existing.attachedThreadIds.has(input.threadId); if (!attached && !existing.supportsMultipleProviderThreads) { return yield* new ProviderSessionOpenError({ @@ -2233,6 +2309,12 @@ export const layerWithOptions = ( ReadonlyMap> >(new Map()); const exposedRuntime = decorateRuntime(runtime, eventSubscribers); + const credentialFingerprint = adapter.resolveCredentialFingerprint + ? yield* adapter.resolveCredentialFingerprint().pipe( + // @effect-diagnostics-next-line effectSucceedWithVoid:off - a failed open-time probe must read undefined, not the wider void Effect.void carries. + Effect.catchCause(() => Effect.succeed(undefined)), + ) + : undefined; const entry: LiveSessionEntry = { attachedThreadIds: new Set([input.threadId]), loadedProviderThreadKeyByThread: new Map(), @@ -2243,6 +2325,7 @@ export const layerWithOptions = ( supportsMultipleProviderThreads: runtime.providerSession.capabilities.sessions .supportsMultipleProviderThreadsPerSession, + ...(credentialFingerprint !== undefined ? { credentialFingerprint } : {}), runtime, exposedRuntime, eventSubscribers, diff --git a/apps/server/src/provider/Drivers/CodexCredentials.test.ts b/apps/server/src/provider/Drivers/CodexCredentials.test.ts new file mode 100644 index 0000000000000..4f831d752623f --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexCredentials.test.ts @@ -0,0 +1,387 @@ +import * as Crypto from "effect/Crypto"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { CodexSettings } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import { + resolveCodexCredentialFingerprint, + resolveEffectiveCodexAuthJsonPath, +} from "./CodexCredentials.ts"; + +const decodeCodexSettingsValue = Schema.decodeSync(CodexSettings); + +const decodeCodexSettings = (input: { + readonly enabled?: boolean; + readonly homePath?: string; + readonly shadowHomePath?: string; + readonly customModels?: readonly string[]; + readonly binaryPath?: string; +}): CodexSettings => decodeCodexSettingsValue(input); + +// Independently recomputes the pinned digest input strings via the Crypto service NodeServices provides. +const sha256Hex = Effect.fn("CodexCredentials.test.sha256Hex")(function* (text: string) { + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}); + +const makeTempDir = Effect.fn("CodexCredentials.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTextFile = Effect.fn("CodexCredentials.test.writeTextFile")(function* ( + filePath: string, + contents: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(filePath), { recursive: true }); + yield* fileSystem.writeFileString(filePath, contents); +}); + +it.layer(NodeServices.layer)("CodexCredentials", (it) => { + describe("resolveCodexCredentialFingerprint", () => { + it.effect("produces different digests for different ChatGPT account IDs", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const aliceHome = yield* makeTempDir("codex-creds-alice-"); + const bobHome = yield* makeTempDir("codex-creds-bob-"); + + yield* writeTextFile( + path.join(aliceHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + account_id: "acc-alice-123", + access_token: "token-alice", + }, + }), + ); + yield* writeTextFile( + path.join(bobHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + account_id: "acc-bob-456", + access_token: "token-bob", + }, + }), + ); + + const aliceFingerprint = yield* resolveCodexCredentialFingerprint( + decodeCodexSettings({ homePath: aliceHome }), + ); + const bobFingerprint = yield* resolveCodexCredentialFingerprint( + decodeCodexSettings({ homePath: bobHome }), + ); + + const expectedAliceDigest = yield* sha256Hex("codex:chatgpt\0acc-alice-123"); + const expectedBobDigest = yield* sha256Hex("codex:chatgpt\0acc-bob-456"); + + expect(aliceFingerprint).toBe(expectedAliceDigest); + expect(bobFingerprint).toBe(expectedBobDigest); + expect(expectedAliceDigest).toMatch(/^[0-9a-f]{64}$/); + expect(expectedBobDigest).toMatch(/^[0-9a-f]{64}$/); + expect(aliceFingerprint).not.toStrictEqual(bobFingerprint); + }), + ); + + it.effect("preserves digest across token rotation when account ID is unchanged", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempDir("codex-creds-rotation-"); + const authPath = path.join(home, "auth.json"); + + yield* writeTextFile( + authPath, + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + account_id: "acc-stable-id", + access_token: "access-token-initial", + refresh_token: "refresh-token-initial", + id_token: "id-token-initial", + last_refresh: "2026-01-01T00:00:00.000Z", + }, + }), + ); + + const initialFingerprint = yield* resolveCodexCredentialFingerprint( + decodeCodexSettings({ homePath: home }), + ); + + yield* writeTextFile( + authPath, + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + account_id: "acc-stable-id", + access_token: "access-token-refreshed", + refresh_token: "refresh-token-refreshed", + id_token: "id-token-different", + last_refresh: "2026-02-01T12:00:00.000Z", + }, + }), + ); + + const rotatedFingerprint = yield* resolveCodexCredentialFingerprint( + decodeCodexSettings({ homePath: home }), + ); + + const expectedDigest = yield* sha256Hex("codex:chatgpt\0acc-stable-id"); + + expect(initialFingerprint).toBe(expectedDigest); + expect(rotatedFingerprint).toStrictEqual(initialFingerprint); + }), + ); + + it.effect( + "separates the missing-file surface (null) from unreadable states (undefined) without throwing", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fileSystem = yield* FileSystem.FileSystem; + const home = yield* makeTempDir("codex-creds-failures-"); + const settings = decodeCodexSettings({ homePath: home }); + + const missingResult = yield* resolveCodexCredentialFingerprint(settings); + expect(missingResult).toBe(null); + + const authPath = path.join(home, "auth.json"); + yield* writeTextFile(authPath, '{"tokens": {"account_'); + const tornResult = yield* resolveCodexCredentialFingerprint(settings); + expect(tornResult).toBe(undefined); + + yield* writeTextFile( + authPath, + JSON.stringify({ + tokens: { account_id: 12345 }, + }), + ); + const nonStringAccountResult = yield* resolveCodexCredentialFingerprint(settings); + expect(nonStringAccountResult).toBe(undefined); + + yield* writeTextFile(authPath, JSON.stringify({})); + const credentialFreeResult = yield* resolveCodexCredentialFingerprint(settings); + expect(credentialFreeResult).toBe(null); + + yield* fileSystem.remove(authPath, { recursive: true }); + yield* fileSystem.makeDirectory(authPath); + const dirInsteadOfFileResult = yield* resolveCodexCredentialFingerprint(settings); + expect(dirInsteadOfFileResult).toBe(undefined); + yield* fileSystem.remove(authPath, { recursive: true }); + + yield* writeTextFile( + authPath, + JSON.stringify({ + tokens: { account_id: "" }, + }), + ); + const emptyAccountResult = yield* resolveCodexCredentialFingerprint(settings); + expect(emptyAccountResult).toBe(null); + + yield* writeTextFile( + authPath, + JSON.stringify({ + OPENAI_API_KEY: " \t\n ", + }), + ); + const whitespaceKeyResult = yield* resolveCodexCredentialFingerprint(settings); + expect(whitespaceKeyResult).toBe(null); + }), + ); + + it.effect( + "computes digest for API key mode and detects key changes while ignoring whitespace", + () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempDir("codex-creds-apikey-"); + const authPath = path.join(home, "auth.json"); + const settings = decodeCodexSettings({ homePath: home }); + + yield* writeTextFile( + authPath, + JSON.stringify({ + OPENAI_API_KEY: "sk-proj-testkey1", + }), + ); + const key1Fingerprint = yield* resolveCodexCredentialFingerprint(settings); + + yield* writeTextFile( + authPath, + JSON.stringify({ + OPENAI_API_KEY: " sk-proj-testkey1 \n", + }), + ); + const key1PaddedFingerprint = yield* resolveCodexCredentialFingerprint(settings); + + yield* writeTextFile( + authPath, + JSON.stringify({ + OPENAI_API_KEY: "sk-proj-testkey2", + }), + ); + const key2Fingerprint = yield* resolveCodexCredentialFingerprint(settings); + + yield* writeTextFile( + authPath, + JSON.stringify({ + OPENAI_API_KEY: null, + }), + ); + const nullKeyFingerprint = yield* resolveCodexCredentialFingerprint(settings); + + const expectedKey1Digest = yield* sha256Hex("codex:api_key\0sk-proj-testkey1"); + const expectedKey2Digest = yield* sha256Hex("codex:api_key\0sk-proj-testkey2"); + + expect(key1Fingerprint).toBe(expectedKey1Digest); + expect(expectedKey1Digest).toMatch(/^[0-9a-f]{64}$/); + expect(key1PaddedFingerprint).toStrictEqual(key1Fingerprint); + expect(key2Fingerprint).toBe(expectedKey2Digest); + expect(key2Fingerprint).not.toStrictEqual(key1Fingerprint); + expect(nullKeyFingerprint).toBe(null); + }), + ); + + it.effect("tolerates unknown extra keys in auth.json", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const cleanHome = yield* makeTempDir("codex-creds-clean-"); + const extraKeysHome = yield* makeTempDir("codex-creds-extra-"); + + yield* writeTextFile( + path.join(cleanHome, "auth.json"), + JSON.stringify({ + tokens: { + account_id: "acc-enterprise-42", + }, + }), + ); + + yield* writeTextFile( + path.join(extraKeysHome, "auth.json"), + JSON.stringify({ + auth_mode: "chatgpt", + OPENAI_API_KEY: null, + tokens: { + account_id: "acc-enterprise-42", + id_token: "token", + }, + plan: "enterprise-tier", + user_id: "user-internal-99", + last_refresh: "2026-03-01T00:00:00Z", + third_party_tool_metadata: { enabled: true, version: 3 }, + }), + ); + + const cleanFingerprint = yield* resolveCodexCredentialFingerprint( + decodeCodexSettings({ homePath: cleanHome }), + ); + const extraFingerprint = yield* resolveCodexCredentialFingerprint( + decodeCodexSettings({ homePath: extraKeysHome }), + ); + + const expectedDigest = yield* sha256Hex("codex:chatgpt\0acc-enterprise-42"); + + expect(cleanFingerprint).toBe(expectedDigest); + expect(extraFingerprint).toStrictEqual(cleanFingerprint); + }), + ); + + it.effect("prioritizes ChatGPT account ID over OPENAI_API_KEY when both are present", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const home = yield* makeTempDir("codex-creds-both-"); + const authPath = path.join(home, "auth.json"); + const settings = decodeCodexSettings({ homePath: home }); + + yield* writeTextFile( + authPath, + JSON.stringify({ + OPENAI_API_KEY: "sk-proj-should-be-ignored", + tokens: { + account_id: "acc-priority-chatgpt", + }, + }), + ); + + const fingerprint = yield* resolveCodexCredentialFingerprint(settings); + const expectedDigest = yield* sha256Hex("codex:chatgpt\0acc-priority-chatgpt"); + + expect(fingerprint).toBe(expectedDigest); + }), + ); + }); + + describe("resolveEffectiveCodexAuthJsonPath", () => { + it.effect("resolves effective path based on homePath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const customHome = yield* makeTempDir("codex-path-home-"); + const settings = decodeCodexSettings({ homePath: customHome }); + + const resolved = yield* resolveEffectiveCodexAuthJsonPath(settings); + expect(resolved).toBe(path.join(customHome, "auth.json")); + }), + ); + + it.effect("resolves effective path based on shadowHomePath", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const sharedHome = yield* makeTempDir("codex-path-shared-"); + const shadowRoot = yield* makeTempDir("codex-path-shadow-root-"); + const shadowHome = path.join(shadowRoot, "shadow"); + const settings = decodeCodexSettings({ + homePath: sharedHome, + shadowHomePath: shadowHome, + }); + + const resolved = yield* resolveEffectiveCodexAuthJsonPath(settings); + expect(resolved).toBe(path.join(shadowHome, "auth.json")); + }), + ); + + it.effect("resolves effective path from CODEX_HOME environment when paths are empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const envHome = yield* makeTempDir("codex-path-env-"); + const settings = decodeCodexSettings({ + homePath: "", + shadowHomePath: "", + }); + + const resolved = yield* resolveEffectiveCodexAuthJsonPath(settings, { + CODEX_HOME: envHome, + }); + expect(resolved).toBe(path.join(envHome, "auth.json")); + }), + ); + + it.effect("resolves effective path from home directory when config and env are empty", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const fakeUserHome = yield* makeTempDir("codex-path-userhome-"); + const settings = decodeCodexSettings({ + homePath: "", + shadowHomePath: "", + }); + + const resolved = yield* resolveEffectiveCodexAuthJsonPath(settings, {}).pipe( + Effect.provideService(HostProcess.HomeDirectory, fakeUserHome), + ); + expect(resolved).toBe(path.join(fakeUserHome, ".codex", "auth.json")); + }), + ); + }); +}); diff --git a/apps/server/src/provider/Drivers/CodexCredentials.ts b/apps/server/src/provider/Drivers/CodexCredentials.ts new file mode 100644 index 0000000000000..785ab5866e465 --- /dev/null +++ b/apps/server/src/provider/Drivers/CodexCredentials.ts @@ -0,0 +1,75 @@ +import type { CodexSettings } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; +import * as Crypto from "effect/Crypto"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import { resolveCodexHomeLayout, withCodexEnvironmentHome } from "./CodexHomeLayout.ts"; + +const CodexAuthJsonSchema = Schema.Struct({ + tokens: Schema.optionalKey( + Schema.NullOr( + Schema.Struct({ + account_id: Schema.optionalKey(Schema.String), + }), + ), + ), + OPENAI_API_KEY: Schema.optionalKey(Schema.NullOr(Schema.String)), +}); + +const decodeCodexAuthJson = Schema.decodeUnknownOption(Schema.fromJsonString(CodexAuthJsonSchema)); + +const sha256Hex = Effect.fn("CodexCredentials.sha256Hex")(function* (text: string) { + const crypto = yield* Crypto.Crypto; + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(text)); + return Array.from(digest, (byte) => byte.toString(16).padStart(2, "0")).join(""); +}); + +export const resolveEffectiveCodexAuthJsonPath = Effect.fn("resolveEffectiveCodexAuthJsonPath")( + function* (config: CodexSettings, processEnv?: NodeJS.ProcessEnv) { + const path = yield* Path.Path; + const environment = processEnv ?? (yield* HostProcess.Environment); + const layout = yield* resolveCodexHomeLayout( + withCodexEnvironmentHome(config, environment.CODEX_HOME), + ); + const effectiveDir = layout.effectiveHomePath ?? layout.sharedHomePath; + return path.join(effectiveDir, "auth.json"); + }, +); + +export const resolveCodexCredentialFingerprint = Effect.fn("resolveCodexCredentialFingerprint")( + function* (config: CodexSettings, processEnv?: NodeJS.ProcessEnv) { + const authJsonPath = yield* resolveEffectiveCodexAuthJsonPath(config, processEnv); + const fileSystem = yield* FileSystem.FileSystem; + const content = yield* fileSystem.readFileString(authJsonPath).pipe(Effect.option); + if (Option.isNone(content)) { + // null: the file credential is missing — `codex logout` and keyring saves both + // unlink auth.json, so a string → null transition is a real swap signal; under + // keyring|auto a missing file may also mean keyring-held credentials this probe + // cannot see. undefined: any other read failure carries no signal — callers keep. + const exists = yield* fileSystem.exists(authJsonPath).pipe(Effect.orElseSucceed(() => true)); + return exists ? undefined : null; + } + const decoded = Option.flatMap(content, decodeCodexAuthJson); + const authJson = Option.getOrUndefined(decoded); + if (authJson === undefined) { + return undefined; + } + const accountId = authJson.tokens?.account_id; + if (accountId !== undefined && accountId.length > 0) { + return yield* sha256Hex(`codex:chatgpt\0${accountId}`); + } + const apiKey = authJson.OPENAI_API_KEY; + if (apiKey !== null && apiKey !== undefined && apiKey.trim().length > 0) { + return yield* sha256Hex(`codex:api_key\0${apiKey.trim()}`); + } + return null; + }, + // Only for the final catch-all: defects on the read path surface as unobservable (undefined), + // which the caller treats as "no signal" rather than logged-out. + // @effect-diagnostics-next-line effectSucceedWithVoid:off - undefined is distinct here from the wider void Effect.void carries. + Effect.catchCause(() => Effect.succeed(undefined)), +); diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index a92256d7dd791..d63482fc53f1f 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -673,6 +673,12 @@ export class ProviderAdapterV2 extends Context.Service< readonly planSelectionTransition: ( input: ProviderSelectionTransitionInput, ) => Effect.Effect; + /** + * Stable identity of the credentials the session runs on: a string while the same + * credentials serve the runtime, null once the provider surface is known to carry no + * credentials, undefined when nothing can be observed right now. + */ + readonly resolveCredentialFingerprint?: () => Effect.Effect; readonly openSession: ( input: ProviderAdapterV2OpenSessionInput, ) => Effect.Effect; From ebab81aaea4d37842e3d2746a25b380a4b7e00f1 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Mon, 2 Nov 2026 23:57:52 +0530 Subject: [PATCH 03/11] fix(server): log provider-session-released only on an actual release The fingerprint seam's unconditional Effect.tap emitted orchestration-v2.provider-session-released even when keepIfBusyOtherThan kept a busy session in place, falsely reporting that the old-account app-server was stopped on the very path where the fix promises to keep it. Gate the log on the runtime actually leaving sessions. GLM-5.3-Flash Free via the OpenCode harness. --- .../ProviderSessionManager.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 1ef9432e9b2c5..b66fd54061bf3 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1243,20 +1243,20 @@ export const layerWithOptions = ( reason: "manual_shutdown", detail: "Provider credentials changed.", keepIfBusyOtherThan: [], - }).pipe( + }).pipe(logReleaseFailure(entry.runtime.providerSessionId)); + // The busy path keeps the runtime in `sessions`; log only a release that happened. + const live = (yield* Ref.get(sessions)).get(key); + const released = live?.runtime !== entry.runtime; + if (released) { // detail is not persisted for non-error releases and idle sessions have no // subscribers — the log is where a released app-server can be attributed. - Effect.tap(() => - Effect.logInfo("orchestration-v2.provider-session-released", { - providerSessionId: entry.runtime.providerSessionId, - reason: "manual_shutdown", - detail: "Provider credentials changed.", - }), - ), - logReleaseFailure(entry.runtime.providerSessionId), - ); - const live = (yield* Ref.get(sessions)).get(key); - return live?.runtime !== entry.runtime; + yield* Effect.logInfo("orchestration-v2.provider-session-released", { + providerSessionId: entry.runtime.providerSessionId, + reason: "manual_shutdown", + detail: "Provider credentials changed.", + }); + } + return released; }); const withActivityError = ( From 50fd26d59871b355052550420b3a2383736935a8 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:02:00 +0530 Subject: [PATCH 04/11] fix(server): capture the credential baseline before the app-server spawns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit flagged a startup race: the baseline was probed after adapter.openSession returned, so an auth.json swap between spawn and probe recorded the new identity while the running process still held the old one, and every later reuse then matched and never released — the exact staleness this PR fixes. Probing before the spawn pins the baseline to be at most as new as the process identity, so the same swap now releases on the next open. GLM-5.3-Flash Free via the OpenCode harness. --- .../src/orchestration-v2/ProviderSessionManager.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index b66fd54061bf3..39af266540fac 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -2255,6 +2255,14 @@ export const layerWithOptions = ( }); const sessionScope = yield* Scope.fork(sessionScopes); yield* agentScope.clear(input.threadId); + // Probe before the spawn: a baseline newer than the identity the + // app-server reads at spawn would match a stale process forever. + const credentialFingerprint = adapter.resolveCredentialFingerprint + ? yield* adapter.resolveCredentialFingerprint().pipe( + // @effect-diagnostics-next-line effectSucceedWithVoid:off - a failed open-time probe must read undefined, not the wider void Effect.void carries. + Effect.catchCause(() => Effect.succeed(undefined)), + ) + : undefined; const runtime = yield* adapter .openSession({ threadId: input.threadId, @@ -2309,12 +2317,6 @@ export const layerWithOptions = ( ReadonlyMap> >(new Map()); const exposedRuntime = decorateRuntime(runtime, eventSubscribers); - const credentialFingerprint = adapter.resolveCredentialFingerprint - ? yield* adapter.resolveCredentialFingerprint().pipe( - // @effect-diagnostics-next-line effectSucceedWithVoid:off - a failed open-time probe must read undefined, not the wider void Effect.void carries. - Effect.catchCause(() => Effect.succeed(undefined)), - ) - : undefined; const entry: LiveSessionEntry = { attachedThreadIds: new Set([input.threadId]), loadedProviderThreadKeyByThread: new Map(), From c0b75486e0da96e7ca707c541d403fa34c18c4da Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:37:45 +0530 Subject: [PATCH 05/11] fix(server): match instance variable names like the env merge in session discovery On Windows the spawn merge resolves provider instance variable names case-insensitively and skips unresolved secret values, but session discovery matched exact spellings only. An instance configured with e.g. Codex_Home started Codex in the instance home while discovery looked at the host home, so sessions written there never showed up for import. The lookup now mirrors the merge: last string-valued match, case-insensitive on Windows. A scanner test pins the mismatched spelling against a win32 host platform. CodeRabbit (Minor) on PR #29507. GLM-5.3-Flash Free via the OpenCode harness. --- .../src/project/AgentSessionScanner.test.ts | 57 +++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 17 +++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 688b974e7658d..07750e4718073 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -9,6 +9,7 @@ import { ProviderInstanceId, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -49,6 +50,9 @@ interface ScannerTestInput { readonly piAgentDir?: string; readonly importedWorkspaceRoots?: ReadonlyArray; readonly configBaseDir?: string; + readonly hostPlatform?: NodeJS.Platform; + readonly hostEnvironment?: NodeJS.ProcessEnv; + readonly hostHomeDirectory?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; } @@ -88,6 +92,15 @@ const layerScannerTest = (input: ScannerTestInput) => input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, ), layerProjectStore(input.importedWorkspaceRoots ?? []), + ...(input.hostPlatform === undefined + ? [] + : [Layer.succeed(HostProcess.Platform, input.hostPlatform)]), + ...(input.hostEnvironment === undefined + ? [] + : [Layer.succeed(HostProcess.Environment, input.hostEnvironment)]), + ...(input.hostHomeDirectory === undefined + ? [] + : [Layer.succeed(HostProcess.HomeDirectory, input.hostHomeDirectory)]), ), ), ); @@ -665,6 +678,50 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("matches Windows-cased provider environment variable names", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const hostHomePath = yield* makeTempDir("t3code-host-home-"); + const codexEnvironmentHome = yield* makeTempDir("t3code-codex-env-"); + const workspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join( + codexEnvironmentHome, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + hostPlatform: "win32", + hostEnvironment: {}, + hostHomeDirectory: hostHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + // Windows merges variable names case-insensitively; discovery must match the merge. + environment: [{ name: "Codex_Home", value: codexEnvironmentHome, sensitive: false }], + config: {}, + }, + }, + }); + + expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([ + [workspace, ["codex"]], + ]); + }), + ); + it.effect("does not scan provider instances disabled by the envelope or config", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 2cecb95612e5d..25a6bd66ef296 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -729,7 +729,7 @@ export const make = Effect.gen(function* () { const projectStore = yield* ProjectStore.ProjectStoreV2; const baseDir = path.resolve(serverConfig.baseDir); const worktreesDir = path.resolve(serverConfig.worktreesDir); - const foldWorktreeCase = (yield* HostProcess.Platform) === "win32"; + const windows = (yield* HostProcess.Platform) === "win32"; const hostEnvironment = yield* HostProcess.Environment; const homeDir = yield* HostProcess.HomeDirectory; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. @@ -746,14 +746,14 @@ export const make = Effect.gen(function* () { const isExcludedProjectPath = (candidatePath: string) => excludedProjectRoots.has(normalizeProjectPathForComparison(candidatePath)) || excludedProjectAncestors.some((ancestor) => - normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( - normalizeForWorktreeMatch(ancestor, foldWorktreeCase), + normalizeForWorktreeMatch(candidatePath, windows).startsWith( + normalizeForWorktreeMatch(ancestor, windows), ), ) || - normalizeForWorktreeMatch(candidatePath, foldWorktreeCase).startsWith( - normalizeForWorktreeMatch(baseDir, foldWorktreeCase), + normalizeForWorktreeMatch(candidatePath, windows).startsWith( + normalizeForWorktreeMatch(baseDir, windows), ) || - isT3ManagedWorktree(candidatePath, worktreesDir, foldWorktreeCase); + isT3ManagedWorktree(candidatePath, worktreesDir, windows); const listDirectory = (directory: string) => fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); @@ -1306,8 +1306,11 @@ export const make = Effect.gen(function* () { const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { + // Windows merges variable names case-insensitively; discovery must match the merge. const configured = instance.environment?.findLast( - (variable) => variable.name === name, + (variable) => + typeof variable.value === "string" && + (windows ? variable.name.toUpperCase() === name : variable.name === name), )?.value; return typeof configured === "string" ? configured : hostEnvironment[name]; }; From 89f214275457f4791c1e44fa0bc69bbab7f82e6d Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:38:06 +0530 Subject: [PATCH 06/11] test(server): pin the Codex home derivation through createCodexAdapterV2 Only makeCodexAdapterV2 was under test, so the derivation createCodexAdapterV2 performs had no coverage. Four cases now capture the client-factory open input through the real create path and assert the settings home and environment CODEX_HOME handed to the app-server spawn: blank deference, host CODEX_HOME adoption, instance-over-host precedence, and settings-over-environment precedence. CodeRabbit (Major) on PR #29507. GLM-5.3-Flash Free via the OpenCode harness. --- .../Adapters/CodexAdapterV2.test.ts | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 2d14fa9518de6..f1d63fd6faf66 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -25,6 +25,7 @@ import { ProviderSetupError, ProviderThreadId, ProviderTurnId, + type ResolvedProviderInstanceEnvironment, RunAttemptId, RunId, ThreadId, @@ -43,7 +44,9 @@ import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; +import * as HostProcess from "@t3tools/shared/HostProcess"; import { resolveAttachmentPathById } from "../../attachmentStore.ts"; +import { ServerConfig } from "../../config.ts"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; import * as PubSub from "effect/PubSub"; @@ -8655,3 +8658,131 @@ describe("codexDynamicToolOutput", () => { ]); }); }); + +describe("CodexAdapterV2 environment home derivation", () => { + const openSessionWithEnvironment = (input: { + readonly scenario: string; + readonly hostEnvironment: NodeJS.ProcessEnv; + readonly instanceEnvironment?: ResolvedProviderInstanceEnvironment; + readonly config?: CodexSettings; + }) => { + const transcript = makeCodexReplayTranscript({ + scenario: input.scenario, + entries: codexReplayPreamble({ + nativeThreadId: "unused", + nativeTurnId: "unused", + prompt: "unused", + }), + }); + const openInputs: Array< + Parameters[0] + > = []; + return Effect.gen(function* () { + const adapter = yield* CodexAdapterV2.createCodexAdapterV2({ + instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, + displayName: undefined, + environment: input.instanceEnvironment ?? [], + enabled: true, + config: input.config ?? DEFAULT_CODEX_SETTINGS, + }); + yield* adapter.openSession({ + threadId: ThreadId.make(`thread-${input.scenario}`), + providerSessionId: ProviderSessionId.make(`provider-session-${input.scenario}`), + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + assert.strictEqual(openInputs.length, 1); + const [openInput] = openInputs; + if (openInput === undefined) { + return yield* Effect.die(new Error("The client factory never opened.")); + } + return openInput; + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + Layer.succeed(HostProcess.Environment, input.hostEnvironment), + Layer.effect( + ServerConfig, + ProviderReplayHarness.makeReplayServerConfig(input.scenario).pipe(Effect.orDie), + ).pipe(Layer.provide(NodeServices.layer)), + Layer.succeed(CodexAdapterV2.CodexAppServerClientFactory, { + open: (openInput) => { + openInputs.push(openInput); + // Each test opens once, so the replay driver builds inside open. + return Effect.gen(function* () { + const driver = yield* CodexReplay.makeReplayDriver(transcript); + return yield* Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe( + Effect.flatMap((context) => + Effect.service(CodexClient.CodexAppServerClient).pipe(Effect.provide(context)), + ), + ); + }).pipe( + Effect.mapError( + (cause) => + new ProviderAdapter.ProviderAdapterOpenSessionError({ + driver: CodexAdapterV2.CODEX_DRIVER_KIND, + providerSessionId: openInput.providerSessionId, + cause, + }), + ), + ); + }, + }), + ), + ), + ); + }; + + it.effect("keeps a blank home when no CODEX_HOME is configured", () => + openSessionWithEnvironment({ scenario: "env-home-blank", hostEnvironment: {} }).pipe( + Effect.map((openInput) => { + assert.strictEqual(openInput.settings.homePath, ""); + assert.strictEqual(openInput.environment.CODEX_HOME, undefined); + }), + ), + ); + + it.effect("adopts the host environment CODEX_HOME when settings homes are blank", () => + openSessionWithEnvironment({ + scenario: "env-home-host", + hostEnvironment: { CODEX_HOME: "/t3-codex-derive/host" }, + }).pipe( + Effect.map((openInput) => { + assert.strictEqual(openInput.settings.homePath, "/t3-codex-derive/host"); + assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/host"); + }), + ), + ); + + it.effect("prefers the instance environment CODEX_HOME over the host environment", () => + openSessionWithEnvironment({ + scenario: "env-home-instance", + hostEnvironment: { CODEX_HOME: "/t3-codex-derive/host" }, + instanceEnvironment: [ + { name: "CODEX_HOME", value: "/t3-codex-derive/instance", sensitive: false }, + ], + }).pipe( + Effect.map((openInput) => { + assert.strictEqual(openInput.settings.homePath, "/t3-codex-derive/instance"); + assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/instance"); + }), + ), + ); + + it.effect("keeps the settings home over an environment CODEX_HOME", () => + openSessionWithEnvironment({ + scenario: "env-home-settings", + hostEnvironment: { CODEX_HOME: "/t3-codex-derive/host" }, + config: { ...DEFAULT_CODEX_SETTINGS, homePath: "/t3-codex-derive/settings" }, + }).pipe( + Effect.map((openInput) => { + assert.strictEqual(openInput.settings.homePath, "/t3-codex-derive/settings"); + assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/host"); + }), + ), + ); +}); From 9e108a0f00313fd7f976b6b47b9e3fd75960e7ba Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:14:29 +0530 Subject: [PATCH 07/11] fix(server): resolve instance secrets in session discovery --- .../src/project/AgentSessionScanner.test.ts | 62 +++++++++++++++++++ .../server/src/project/AgentSessionScanner.ts | 34 ++++++++-- 2 files changed, 90 insertions(+), 6 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 07750e4718073..9f081b3fe46ec 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -7,8 +7,10 @@ import { ProjectId, ProviderDriverKind, ProviderInstanceId, + SecretProviderId, type ServerSettings as ContractServerSettings, } from "@t3tools/contracts"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { symlinksSupported } from "@t3tools/shared/testing/symlinks"; import * as DateTime from "effect/DateTime"; @@ -54,6 +56,8 @@ interface ScannerTestInput { readonly hostEnvironment?: NodeJS.ProcessEnv; readonly hostHomeDirectory?: string; readonly providerInstances?: ContractServerSettings["providerInstances"]; + /** Secret values the test resolver hands back, keyed by `SecretReference.reference`. */ + readonly secretValues?: Readonly>; } const layerScannerTest = (input: ScannerTestInput) => @@ -101,6 +105,13 @@ const layerScannerTest = (input: ScannerTestInput) => ...(input.hostHomeDirectory === undefined ? [] : [Layer.succeed(HostProcess.HomeDirectory, input.hostHomeDirectory)]), + Layer.succeed(SecretResolver.SecretResolver, { + read: (references) => { + const secretValues = input.secretValues ?? {}; + return Effect.succeed(references.map((reference) => secretValues[reference.reference])); + }, + invalidate: () => Effect.void, + }), ), ), ); @@ -722,6 +733,57 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { }), ); + it.effect("resolves secret-backed provider instance home variables", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const hostHomePath = yield* makeTempDir("t3code-host-home-"); + const secretHomePath = yield* makeTempDir("t3code-codex-secret-"); + const workspace = yield* makeTempDir("t3code-workspace-codex-"); + + yield* writeTranscript({ + filePath: path.join( + secretHomePath, + "sessions", + "2026", + "01", + "01", + "rollout-session.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + hostEnvironment: {}, + hostHomeDirectory: hostHomePath, + providerInstances: { + [ProviderInstanceId.make("codex")]: { + driver: ProviderDriverKind.make("codex"), + // Drivers read the resolved secret; discovery must see the same home. + environment: [ + { + name: "CODEX_HOME", + value: { provider: SecretProviderId.make("test"), reference: "vault:codex-home" }, + sensitive: false, + }, + ], + // Blank so the secret home wins over the harness default settings home. + config: { homePath: "" }, + }, + }, + secretValues: { "vault:codex-home": secretHomePath }, + }); + + expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([ + [workspace, ["codex"]], + ]); + }), + ); + it.effect("does not scan provider instances disabled by the envelope or config", () => Effect.gen(function* () { const path = yield* Path.Path; diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 25a6bd66ef296..6b5169928236e 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -4,6 +4,7 @@ import { AgentSessionScanError, ClaudeSettings, CodexSettings, + isSecretReference, ProviderDriverKind, ProviderInstanceId, resolveProviderInstanceEnabled, @@ -12,6 +13,7 @@ import { type AgentSessionProjectGit, type AgentSessionScanResult, type ProviderInstanceConfig, + type ProviderInstanceEnvironment, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -24,6 +26,7 @@ import * as Schema from "effect/Schema"; import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; +import * as SecretResolver from "@t3tools/secret-provider-core/server/SecretResolver"; import { isBareGitConfig, parseOriginUrlFromGitConfig } from "@t3tools/shared/git"; import * as HostProcess from "@t3tools/shared/HostProcess"; import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; @@ -732,6 +735,7 @@ export const make = Effect.gen(function* () { const windows = (yield* HostProcess.Platform) === "win32"; const hostEnvironment = yield* HostProcess.Environment; const homeDir = yield* HostProcess.HomeDirectory; + const secretResolver = yield* SecretResolver.SecretResolver; // `/private/tmp` is what macOS reports for sessions started in `/tmp`. const excludedProjectRoots = new Set( [homeDir, NodeOS.tmpdir(), "/tmp", "/private/tmp"].map((directory) => @@ -761,6 +765,25 @@ export const make = Effect.gen(function* () { const statOption = (target: string) => fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); + // Drivers see the environment with secret references resolved (readInstanceSecrets); discovery + // must read the same values or a secret-backed home goes undiscovered. + const resolveInstanceEnvironment = Effect.fn("AgentSessionScanner.resolveInstanceEnvironment")( + function* (environment: ProviderInstanceEnvironment | undefined) { + const variables = environment ?? []; + const references = variables.flatMap((variable) => + isSecretReference(variable.value) ? [variable.value] : [], + ); + const values = references.length === 0 ? [] : yield* secretResolver.read(references); + let index = 0; + return variables.flatMap((variable) => { + if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; + const secret = values[index++]; + // An unreadable reference stays out, matching the driver's environment. + return secret === undefined ? [] : [{ ...variable, value: secret, sensitive: true }]; + }); + }, + ); + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( target: string, knownStats?: FileSystem.File.Info, @@ -1305,20 +1328,19 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { + const instanceEnvironment = yield* resolveInstanceEnvironment(instance.environment); const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { // Windows merges variable names case-insensitively; discovery must match the merge. - const configured = instance.environment?.findLast( - (variable) => - typeof variable.value === "string" && - (windows ? variable.name.toUpperCase() === name : variable.name === name), + const configured = instanceEnvironment.findLast((variable) => + windows ? variable.name.toUpperCase() === name : variable.name === name, )?.value; - return typeof configured === "string" ? configured : hostEnvironment[name]; + return configured ?? hostEnvironment[name]; }; let homePath: string; if (source === "pi") { const environment = yield* mergeProviderInstanceEnvironment( - instance.environment, + instanceEnvironment, hostEnvironment, ).pipe(Effect.provideService(HostProcess.HomeDirectory, homeDir)); homePath = resolvePiSessionsDir(environment, homeDir, path); From 1578b4c435b93f147a7d1a3975144f83d49f74c6 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:14:55 +0530 Subject: [PATCH 08/11] test(server): resolve expected codex home paths with host platform --- .../Adapters/CodexAdapterV2.test.ts | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index f1d63fd6faf66..de61c8057d974 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -42,6 +42,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Predicate from "effect/Predicate"; import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; import { MCP_APP_OUTPUT_KEY, readMcpAppReference } from "@t3tools/shared/mcpApp"; import { toolOutputImages } from "@t3tools/shared/toolOutput"; import * as HostProcess from "@t3tools/shared/HostProcess"; @@ -8665,6 +8666,11 @@ describe("CodexAdapterV2 environment home derivation", () => { readonly hostEnvironment: NodeJS.ProcessEnv; readonly instanceEnvironment?: ResolvedProviderInstanceEnvironment; readonly config?: CodexSettings; + /** + * The configured home that should land in `settings.homePath`; resolved with the platform + * Path, matching resolveCodexHomeLayout. Omit for a blank derived home. + */ + readonly rawSettingsHome?: string; }) => { const transcript = makeCodexReplayTranscript({ scenario: input.scenario, @@ -8678,6 +8684,7 @@ describe("CodexAdapterV2 environment home derivation", () => { Parameters[0] > = []; return Effect.gen(function* () { + const path = yield* Path.Path; const adapter = yield* CodexAdapterV2.createCodexAdapterV2({ instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID, displayName: undefined, @@ -8696,7 +8703,9 @@ describe("CodexAdapterV2 environment home derivation", () => { if (openInput === undefined) { return yield* Effect.die(new Error("The client factory never opened.")); } - return openInput; + const resolvedSettingsHome = + input.rawSettingsHome === undefined ? "" : path.resolve(input.rawSettingsHome); + return { openInput, resolvedSettingsHome }; }).pipe( Effect.scoped, Effect.provide( @@ -8739,7 +8748,7 @@ describe("CodexAdapterV2 environment home derivation", () => { it.effect("keeps a blank home when no CODEX_HOME is configured", () => openSessionWithEnvironment({ scenario: "env-home-blank", hostEnvironment: {} }).pipe( - Effect.map((openInput) => { + Effect.map(({ openInput }) => { assert.strictEqual(openInput.settings.homePath, ""); assert.strictEqual(openInput.environment.CODEX_HOME, undefined); }), @@ -8750,9 +8759,10 @@ describe("CodexAdapterV2 environment home derivation", () => { openSessionWithEnvironment({ scenario: "env-home-host", hostEnvironment: { CODEX_HOME: "/t3-codex-derive/host" }, + rawSettingsHome: "/t3-codex-derive/host", }).pipe( - Effect.map((openInput) => { - assert.strictEqual(openInput.settings.homePath, "/t3-codex-derive/host"); + Effect.map(({ openInput, resolvedSettingsHome }) => { + assert.strictEqual(openInput.settings.homePath, resolvedSettingsHome); assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/host"); }), ), @@ -8765,9 +8775,10 @@ describe("CodexAdapterV2 environment home derivation", () => { instanceEnvironment: [ { name: "CODEX_HOME", value: "/t3-codex-derive/instance", sensitive: false }, ], + rawSettingsHome: "/t3-codex-derive/instance", }).pipe( - Effect.map((openInput) => { - assert.strictEqual(openInput.settings.homePath, "/t3-codex-derive/instance"); + Effect.map(({ openInput, resolvedSettingsHome }) => { + assert.strictEqual(openInput.settings.homePath, resolvedSettingsHome); assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/instance"); }), ), @@ -8778,9 +8789,10 @@ describe("CodexAdapterV2 environment home derivation", () => { scenario: "env-home-settings", hostEnvironment: { CODEX_HOME: "/t3-codex-derive/host" }, config: { ...DEFAULT_CODEX_SETTINGS, homePath: "/t3-codex-derive/settings" }, + rawSettingsHome: "/t3-codex-derive/settings", }).pipe( - Effect.map((openInput) => { - assert.strictEqual(openInput.settings.homePath, "/t3-codex-derive/settings"); + Effect.map(({ openInput, resolvedSettingsHome }) => { + assert.strictEqual(openInput.settings.homePath, resolvedSettingsHome); assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/host"); }), ), From 2915f2c5ad3671b525c3f07fe6d811781c181e80 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 01:34:07 +0530 Subject: [PATCH 09/11] fix(server): read only home secrets in session discovery --- .../src/project/AgentSessionScanner.test.ts | 14 +++++++- .../server/src/project/AgentSessionScanner.ts | 32 +++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 9f081b3fe46ec..c1f5868d455d9 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -58,6 +58,8 @@ interface ScannerTestInput { readonly providerInstances?: ContractServerSettings["providerInstances"]; /** Secret values the test resolver hands back, keyed by `SecretReference.reference`. */ readonly secretValues?: Readonly>; + /** Collects every reference `SecretResolver.read` receives, in order. */ + readonly secretReads?: Array; } const layerScannerTest = (input: ScannerTestInput) => @@ -107,6 +109,7 @@ const layerScannerTest = (input: ScannerTestInput) => : [Layer.succeed(HostProcess.HomeDirectory, input.hostHomeDirectory)]), Layer.succeed(SecretResolver.SecretResolver, { read: (references) => { + for (const reference of references) input.secretReads?.push(reference.reference); const secretValues = input.secretValues ?? {}; return Effect.succeed(references.map((reference) => secretValues[reference.reference])); }, @@ -741,6 +744,7 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { const hostHomePath = yield* makeTempDir("t3code-host-home-"); const secretHomePath = yield* makeTempDir("t3code-codex-secret-"); const workspace = yield* makeTempDir("t3code-workspace-codex-"); + const secretReads: Array = []; yield* writeTranscript({ filePath: path.join( @@ -763,21 +767,29 @@ it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { providerInstances: { [ProviderInstanceId.make("codex")]: { driver: ProviderDriverKind.make("codex"), - // Drivers read the resolved secret; discovery must see the same home. + // Drivers read the resolved secret; discovery must see the same home. The API + // key cannot affect discovery, so its reference must stay unread. environment: [ { name: "CODEX_HOME", value: { provider: SecretProviderId.make("test"), reference: "vault:codex-home" }, sensitive: false, }, + { + name: "OPENAI_API_KEY", + value: { provider: SecretProviderId.make("test"), reference: "vault:api-key" }, + sensitive: true, + }, ], // Blank so the secret home wins over the harness default settings home. config: { homePath: "" }, }, }, secretValues: { "vault:codex-home": secretHomePath }, + secretReads, }); + expect(secretReads).toEqual(["vault:codex-home"]); expect(result.candidates.map((candidate) => [candidate.path, candidate.sources])).toEqual([ [workspace, ["codex"]], ]); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts index 6b5169928236e..9a0f1ace1ab14 100644 --- a/apps/server/src/project/AgentSessionScanner.ts +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -14,6 +14,7 @@ import { type AgentSessionScanResult, type ProviderInstanceConfig, type ProviderInstanceEnvironment, + type ProviderInstanceEnvironmentVariable, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -766,17 +767,23 @@ export const make = Effect.gen(function* () { fileSystem.stat(target).pipe(Effect.asSome, Effect.orElseSucceed(Option.none)); // Drivers see the environment with secret references resolved (readInstanceSecrets); discovery - // must read the same values or a secret-backed home goes undiscovered. + // must read the same values or a secret-backed home goes undiscovered. Home selection only + // reads `relevant` variables, so only their references are read; the rest are dropped because + // every consumer either matches relevant names or skips non-string values. const resolveInstanceEnvironment = Effect.fn("AgentSessionScanner.resolveInstanceEnvironment")( - function* (environment: ProviderInstanceEnvironment | undefined) { + function* ( + environment: ProviderInstanceEnvironment | undefined, + relevant: (variable: ProviderInstanceEnvironmentVariable) => boolean, + ) { const variables = environment ?? []; - const references = variables.flatMap((variable) => - isSecretReference(variable.value) ? [variable.value] : [], - ); + const references = variables + .filter(relevant) + .flatMap((variable) => (isSecretReference(variable.value) ? [variable.value] : [])); const values = references.length === 0 ? [] : yield* secretResolver.read(references); let index = 0; return variables.flatMap((variable) => { if (typeof variable.value === "string") return [{ ...variable, value: variable.value }]; + if (!relevant(variable)) return []; const secret = values[index++]; // An unreadable reference stays out, matching the driver's environment. return secret === undefined ? [] : [{ ...variable, value: secret, sensitive: true }]; @@ -1328,9 +1335,20 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { - const instanceEnvironment = yield* resolveInstanceEnvironment(instance.environment); + // Resolve only the secrets home selection reads; other instance secrets stay unread. + const homeVariableNames = + source === "pi" + ? ["PI_CODING_AGENT_DIR", "PI_CODING_AGENT_SESSION_DIR"] + : ["CLAUDE_CONFIG_DIR", "CODEX_HOME"]; + const instanceEnvironment = yield* resolveInstanceEnvironment( + instance.environment, + (variable) => + // Windows merges variable names case-insensitively; discovery must match the merge. + homeVariableNames.some((name) => + windows ? variable.name.toUpperCase() === name : variable.name === name, + ), + ); const environmentHome = (name: "CLAUDE_CONFIG_DIR" | "CODEX_HOME") => { - // Windows merges variable names case-insensitively; discovery must match the merge. const configured = instanceEnvironment.findLast((variable) => windows ? variable.name.toUpperCase() === name : variable.name === name, )?.value; From 3d81e2051a04b4f44b0405e53681a7de8fc280f6 Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:03:45 +0530 Subject: [PATCH 10/11] fix(server): release a session when its credential baseline is unknown The open-time fingerprint probe can fail, which leaves the stored baseline unknown. The change sweep then adopted the first readable fingerprint, so a runtime started before an external account swap kept serving after it. A readable probe now releases that session; the next open spawns a fresh one. Tests cover both the unknown-baseline release and a swap landing inside the startup window. --- .../ProviderSessionManager.test.ts | 76 ++++++++++++++++--- .../ProviderSessionManager.ts | 19 +---- 2 files changed, 69 insertions(+), 26 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 8544042d98dba..85ab9ec96fc5d 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -361,8 +361,8 @@ const makeProviderAdapter = Effect.fnUntraced(function* ( ).pipe(Effect.andThen(Effect.never)), ); // exactOptionalPropertyTypes cannot express key-present-undefined; a disabled capability - // resolves to undefined, which the seam records as an unreadable (null) baseline. The first - // readable fingerprint is then adopted. + // resolves to undefined, which the seam records as an unknown baseline. The first + // readable fingerprint then releases the runtime; the next open spawns a fresh one. // @effect-diagnostics-next-line effectSucceedWithVoid:off - optional capability result requires undefined, not void. const fingerprintUnavailable = Effect.succeed(undefined); return ProviderAdapter.ProviderAdapterV2.of({ @@ -4188,7 +4188,7 @@ it.effect( ); it.effect( - "ProviderSessionManagerV2 adopts the first readable fingerprint after an unobservable baseline and recycles on a later change", + "ProviderSessionManagerV2 releases after an unobservable baseline on the first readable fingerprint, and recycles on a later change", () => Effect.gen(function* () { const state = yield* Ref.make(emptyState); @@ -4199,7 +4199,7 @@ it.effect( const idAllocator = yield* IdAllocator.IdAllocatorV2; const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-fp-adopt-null"); + const threadId = ThreadId.make("thread-fp-unknown-baseline"); const providerSessionId = yield* idAllocator.allocate.providerSession({ providerInstanceId: modelSelection.instanceId, threadId, @@ -4220,15 +4220,17 @@ it.effect( capability.current = true; + // The open-time probe failed, so the first runtime's credentials are unknown. The + // sweep cannot prove that runtime is current, so the first readable probe releases it. const secondRuntime = yield* manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy, }); - assert.strictEqual(secondRuntime, firstRuntime); - assert.equal((yield* Ref.get(state)).openCount, 1); - assert.equal((yield* Ref.get(state)).closeCount, 0); + assert.notStrictEqual(secondRuntime, firstRuntime); + assert.equal((yield* Ref.get(state)).openCount, 2); + assert.equal((yield* Ref.get(state)).closeCount, 1); yield* Ref.set(credentialFingerprint, "fp-bob"); const thirdRuntime = yield* manager.open({ @@ -4237,7 +4239,60 @@ it.effect( modelSelection, runtimePolicy, }); - assert.notStrictEqual(thirdRuntime, firstRuntime); + assert.notStrictEqual(thirdRuntime, secondRuntime); + assert.equal((yield* Ref.get(state)).openCount, 3); + assert.equal((yield* Ref.get(state)).closeCount, 2); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + credentialFingerprintCapability: capability, + }), + ), + ); + }), +); + +it.effect( + "ProviderSessionManagerV2 releases a session whose credentials swapped while it was opening", + () => + Effect.gen(function* () { + const state = yield* Ref.make(emptyState); + const credentialFingerprint = yield* Ref.make("fp-alice"); + const effect = Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; + const now = yield* DateTime.now; + const threadId = ThreadId.make("thread-fp-swap-during-open"); + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, + threadId, + }); + + yield* eventSink.write({ + events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], + }); + const firstRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.equal((yield* Ref.get(state)).openCount, 1); + assert.equal((yield* Ref.get(state)).closeCount, 0); + + const secondRuntime = yield* manager.open({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + assert.notStrictEqual(secondRuntime, firstRuntime); assert.equal((yield* Ref.get(state)).openCount, 2); assert.equal((yield* Ref.get(state)).closeCount, 1); }); @@ -4248,7 +4303,10 @@ it.effect( state, idleTimeoutMs: 60_000, credentialFingerprint, - credentialFingerprintCapability: capability, + // The swap lands after the baseline probe and inside openSession, modeling an + // external account change that races the startup window. The pre-spawn baseline + // must not absorb it. + beforeOpen: () => Ref.set(credentialFingerprint, "fp-bob"), }), ), ); diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts index 39af266540fac..f4ddbe062cd95 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts @@ -1209,23 +1209,8 @@ export const layerWithOptions = ( } const baseline = entry.credentialFingerprint; const key = sessionKey(entry.runtime.providerSessionId); - // A legacy (undefined) baseline adopts the first readable fingerprint, whatever it shows. - if (baseline === undefined) { - const live = (yield* Ref.get(sessions)).get(key); - if (live?.runtime !== entry.runtime) { - return false; - } - yield* Ref.update(sessions, (current) => { - const currentEntry = current.get(key); - if (currentEntry?.runtime !== entry.runtime) { - return current; - } - const updated = new Map(current); - updated.set(key, { ...currentEntry, credentialFingerprint: currentFingerprint }); - return updated; - }); - return false; - } + // A failed open-time probe leaves the baseline unknown. A runtime that may predate the + // current credentials cannot be proven current, so the first readable probe releases it. if (baseline === currentFingerprint) { return false; } From b7a259613ee0c7a6838866aa5da787476fe8316d Mon Sep 17 00:00:00 2001 From: Justin <159104807+Justin24506@users.noreply.github.com> Date: Tue, 3 Nov 2026 02:15:31 +0530 Subject: [PATCH 11/11] test(server): drop the duplicate same-fingerprint reuse case The re-read-to-same-value test set the Ref to the value it already held, which no probe can distinguish from the unchanged test next to it. --- .../ProviderSessionManager.test.ts | 53 ------------------- 1 file changed, 53 deletions(-) diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts index 85ab9ec96fc5d..e1f3c27f6d23b 100644 --- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts +++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts @@ -3952,59 +3952,6 @@ it.effect( }), ); -it.effect( - "ProviderSessionManagerV2 keeps the cached runtime when the fingerprint re-reads to the same value", - () => - Effect.gen(function* () { - const state = yield* Ref.make(emptyState); - const credentialFingerprint = yield* Ref.make("fp-alice"); - const effect = Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const manager = yield* ProviderSessionManager.ProviderSessionManagerV2; - const now = yield* DateTime.now; - const threadId = ThreadId.make("thread-fp-same-digest"); - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - - yield* eventSink.write({ - events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })], - }); - const firstRuntime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - yield* Ref.set(credentialFingerprint, "fp-alice"); - - const secondRuntime = yield* manager.open({ - threadId, - providerSessionId, - modelSelection, - runtimePolicy, - }); - - assert.strictEqual(secondRuntime, firstRuntime); - assert.equal((yield* Ref.get(state)).openCount, 1); - assert.equal((yield* Ref.get(state)).closeCount, 0); - }); - - yield* effect.pipe( - Effect.provide( - layerTest({ - state, - idleTimeoutMs: 60_000, - credentialFingerprint, - }), - ), - ); - }), -); - it.effect( "ProviderSessionManagerV2 retains busy session across fingerprint flip until in-flight turn finishes", () =>