diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 2d14fa9518de6..de61c8057d974 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, @@ -41,9 +42,12 @@ 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"; 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 +8659,142 @@ describe("codexDynamicToolOutput", () => { ]); }); }); + +describe("CodexAdapterV2 environment home derivation", () => { + const openSessionWithEnvironment = (input: { + readonly scenario: string; + 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, + entries: codexReplayPreamble({ + nativeThreadId: "unused", + nativeTurnId: "unused", + prompt: "unused", + }), + }); + const openInputs: Array< + Parameters[0] + > = []; + return Effect.gen(function* () { + const path = yield* Path.Path; + 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.")); + } + const resolvedSettingsHome = + input.rawSettingsHome === undefined ? "" : path.resolve(input.rawSettingsHome); + return { openInput, resolvedSettingsHome }; + }).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" }, + rawSettingsHome: "/t3-codex-derive/host", + }).pipe( + Effect.map(({ openInput, resolvedSettingsHome }) => { + assert.strictEqual(openInput.settings.homePath, resolvedSettingsHome); + 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 }, + ], + rawSettingsHome: "/t3-codex-derive/instance", + }).pipe( + Effect.map(({ openInput, resolvedSettingsHome }) => { + assert.strictEqual(openInput.settings.homePath, resolvedSettingsHome); + 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" }, + rawSettingsHome: "/t3-codex-derive/settings", + }).pipe( + Effect.map(({ openInput, resolvedSettingsHome }) => { + assert.strictEqual(openInput.settings.homePath, resolvedSettingsHome); + assert.strictEqual(openInput.environment.CODEX_HOME, "/t3-codex-derive/host"); + }), + ), + ); +}); diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index f81a3809b35fe..8e298383e2c22 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -113,7 +113,9 @@ import { import { materializeCodexShadowHome, resolveCodexHomeLayout, + withCodexEnvironmentHome, } from "../../provider/Drivers/CodexHomeLayout.ts"; +import { resolveCodexCredentialFingerprint } from "../../provider/Drivers/CodexCredentials.ts"; import { boundProviderEventForLogging, shouldPersistProviderEvent, @@ -1592,7 +1594,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 +1611,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, @@ -1653,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); @@ -1662,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..e1f3c27f6d23b 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 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({ 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,644 @@ 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 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 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); + 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-unknown-baseline"); + 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; + + // 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.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({ + threadId, + providerSessionId, + modelSelection, + runtimePolicy, + }); + 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); + }); + + yield* effect.pipe( + Effect.provide( + layerTest({ + state, + idleTimeoutMs: 60_000, + credentialFingerprint, + // 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"), + }), + ), + ); + }), +); + +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..f4ddbe062cd95 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,63 @@ 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 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; + } + 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(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. + yield* Effect.logInfo("orchestration-v2.provider-session-released", { + providerSessionId: entry.runtime.providerSessionId, + reason: "manual_shutdown", + detail: "Provider credentials changed.", + }); + } + return released; + }); + const withActivityError = ( providerSessionId: ProviderSessionId, effect: Effect.Effect, @@ -2136,7 +2195,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({ @@ -2179,6 +2240,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, @@ -2243,6 +2312,7 @@ export const layerWithOptions = ( supportsMultipleProviderThreads: runtime.providerSession.capabilities.sessions .supportsMultipleProviderThreadsPerSession, + ...(credentialFingerprint !== undefined ? { credentialFingerprint } : {}), runtime, exposedRuntime, eventSubscribers, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts index 688b974e7658d..c1f5868d455d9 100644 --- a/apps/server/src/project/AgentSessionScanner.test.ts +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -7,8 +7,11 @@ 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"; import * as Effect from "effect/Effect"; @@ -49,7 +52,14 @@ 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"]; + /** 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) => @@ -88,6 +98,23 @@ 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)]), + 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])); + }, + invalidate: () => Effect.void, + }), ), ), ); @@ -665,6 +692,110 @@ 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("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-"); + const secretReads: Array = []; + + 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. 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"]], + ]); + }), + ); + 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 bbd3ba5a631e0..9a0f1ace1ab14 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,8 @@ import { type AgentSessionProjectGit, type AgentSessionScanResult, type ProviderInstanceConfig, + type ProviderInstanceEnvironment, + type ProviderInstanceEnvironmentVariable, } from "@t3tools/contracts"; import * as Context from "effect/Context"; import * as DateTime from "effect/DateTime"; @@ -24,6 +27,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"; @@ -31,7 +35,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"; @@ -726,9 +733,10 @@ 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; + 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) => @@ -743,14 +751,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 => [])); @@ -758,6 +766,31 @@ 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. 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, + relevant: (variable: ProviderInstanceEnvironmentVariable) => boolean, + ) { + const variables = environment ?? []; + 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 }]; + }); + }, + ); + const directoryIdentity = Effect.fn("AgentSessionScanner.directoryIdentity")(function* ( target: string, knownStats?: FileSystem.File.Info, @@ -1302,17 +1335,30 @@ export const make = Effect.gen(function* () { const homes: Array<{ homePath: string; providerInstanceId: ProviderInstanceId }> = []; const seenHomes = new Set(); for (const { instanceId, config: instance } of instances) { + // 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") => { - const configured = instance.environment?.findLast( - (variable) => 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); @@ -1326,13 +1372,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/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/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") }]; 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;