diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts index 18f7aaffee06..bbd880a7293c 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.test.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.test.ts @@ -4,6 +4,7 @@ import { ANTIGRAVITY_DEFAULT_MODEL, ProviderInstanceId, type AntigravitySettings, + ThreadId, } from "@t3tools/contracts"; import { HostProcessEnvironment, @@ -24,6 +25,7 @@ import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawne import * as BackgroundPolicy from "../../background/BackgroundPolicy.ts"; import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { AntigravityInstallation, AntigravityInstallationError, @@ -126,6 +128,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( forceFileStorage: string | undefined; credentialKeys: ReadonlyArray; geminiApiKey: string | undefined; + gitHubToken: string | undefined; tempDirectory: string | undefined; handle: ChildProcessSpawner.ChildProcessHandle; }> = []; @@ -179,6 +182,7 @@ const makeHarness = Effect.fn("makeAntigravityDriverHarness")(function* ( blockedCredentialKeys.has(key.toUpperCase()), ), geminiApiKey: environment.GEMINI_API_KEY, + gitHubToken: environment.GH_TOKEN, // Only the agent gets a per-process temp directory. Other launches // inherit the host TMPDIR. tempDirectory: @@ -277,6 +281,27 @@ it.layer(testLayer)("AntigravityDriver", (it) => { ), ); + it.effect.skipIf(windowsHost)( + "starts session agents as the checkout's selected GitHub CLI login", + () => + Effect.gen(function* () { + const h = yield* makeHarness({ enabled: true }).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: () => Effect.succeed({ GH_TOKEN: "selected", GITHUB_TOKEN: "selected" }), + }), + ); + const threadId = ThreadId.make("antigravity-github-login"); + yield* h.instance.adapter.startSession({ + threadId, + cwd: process.cwd(), + runtimeMode: "full-access", + }); + yield* h.instance.adapter.stopSession(threadId); + const agentLaunches = h.launches.filter((launch) => launch.harnessPath !== undefined); + expect(agentLaunches.map((launch) => launch.gitHubToken)).toEqual(["selected"]); + }).pipe(Effect.scoped), + ); + it.effect.skipIf(windowsHost)("does not launch a process for a disabled instance", () => Effect.gen(function* () { const h = yield* makeHarness(); diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fb9c7041b5fd..3e28f68e0312 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -233,7 +233,10 @@ export const AntigravityDriver: ProviderDriver(); @@ -786,6 +788,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi stopOwned, Effect.gen(function* () { const mcp = McpProviderSession.readMcpProviderSession(input.threadId); + const gitHubEnvironment = yield* gitHubAccountEnvironment.forCwd(cwd); // The attachments dir grant lets the agent read path-only uploads // at the paths ProviderService injects into the turn text. It is // a leaf directory holding only uploads. @@ -796,6 +799,7 @@ export const makeAntigravityAdapter = Effect.fn("makeAntigravityAdapter")(functi ...(mcp?.agentDeviceEnvironment ? { agentDeviceEnvironment: mcp.agentDeviceEnvironment } : {}), + gitHubEnvironment, additionalDirectories: [serverConfig.attachmentsDir], ...(Option.isSome(cursor) ? { resumeSessionId: cursor.value.sessionId } : {}), mcpServers: mcp diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 295926b26644..c9c39e988de3 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -39,6 +39,7 @@ import * as TestClock from "effect/testing/TestClock"; import { attachmentRelativePath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { SYNTHETIC_CLAUDE_CAPABLE_MODEL, @@ -173,6 +174,7 @@ function makeHarness(config?: { readonly environment?: ClaudeAdapterLiveOptions["environment"]; readonly getSessionMessages?: ClaudeAdapterLiveOptions["getSessionMessages"]; readonly forkSession?: ClaudeAdapterLiveOptions["forkSession"]; + readonly gitHubAccountEnvironment?: Readonly>; }) { const query = new FakeClaudeQuery(); const queries = [query]; @@ -212,7 +214,11 @@ function makeHarness(config?: { ClaudeAdapter, Effect.gen(function* () { const claudeConfig = decodeClaudeSettings(config?.claudeConfig ?? {}); - return yield* makeClaudeAdapter(claudeConfig, adapterOptions); + return yield* makeClaudeAdapter(claudeConfig, adapterOptions).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: () => Effect.succeed(config?.gitHubAccountEnvironment ?? {}), + }), + ); }), ).pipe( Layer.provideMerge( @@ -369,6 +375,29 @@ const sendCompletedClaudeTurn = ( }); describe("ClaudeAdapterLive", () => { + it.effect("starts queries as the checkout's selected GitHub CLI login", () => { + const harness = makeHarness({ + environment: { ...process.env, GH_TOKEN: "ambient", GITHUB_TOKEN: "ambient" }, + gitHubAccountEnvironment: { GH_TOKEN: "selected", GITHUB_TOKEN: "selected" }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + runtimeMode: "full-access", + cwd: "/tmp/claude-github-login", + }); + assert.include(harness.getLastCreateQueryInput()?.options.env, { + GH_TOKEN: "selected", + GITHUB_TOKEN: "selected", + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("returns validation error for non-claude provider on startSession", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 6e30f5241780..31ffa1bedf3c 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -115,6 +115,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { spawnAndCollect } from "../providerSnapshot.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -2080,6 +2081,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, options?.environment).pipe( Effect.provideService(Path.Path, path), ); + const gitHubAccountEnvironment = yield* GitHubCliAccountEnvironment; const claudeSdkExecutablePath = yield* resolveClaudeSdkExecutablePath( claudeSettings.binaryPath, claudeEnvironment, @@ -4890,6 +4892,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( extraArgs["thinking-display"] = "summarized"; } const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const gitHubEnvironment = yield* gitHubAccountEnvironment.forCwd(input.cwd ?? process.cwd()); // The attachments dir grant lets the agent Read/copy pasted images at // the paths ProviderService injects into the turn text, without an // approval prompt. It is a leaf directory holding only attachment @@ -4935,7 +4938,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( canUseTool, onUserDialog, supportedDialogKinds: ["resume_return"], - env: McpProviderSession.withAgentDeviceEnvironment(claudeEnvironment, mcpSession), + env: McpProviderSession.withAgentDeviceEnvironment( + { ...claudeEnvironment, ...gitHubEnvironment }, + mcpSession, + ), additionalDirectories, ...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.test.ts b/apps/server/src/provider/Layers/CodexAdapter.test.ts index 8380c2dc1fdd..19618997747a 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.test.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.test.ts @@ -6,6 +6,7 @@ import * as NodePath from "node:path"; import { ApprovalRequestId, CodexSettings, + EnvironmentId, EventId, ProviderDriverKind, ProviderInstanceId, @@ -36,6 +37,8 @@ import * as TestClock from "effect/testing/TestClock"; import * as CodexErrors from "effect-codex-app-server/errors"; import { ServerConfig } from "../../config.ts"; +import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterValidationError } from "../Errors.ts"; import type { CodexAdapterShape } from "../Services/CodexAdapter.ts"; @@ -496,6 +499,67 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("starts app-servers as the checkout's selected GitHub CLI login", () => { + const runtimeFactory = makeRuntimeFactory(); + const mcpThreadId = asThreadId("sess-github-login-mcp"); + const layer = Layer.effect( + CodexAdapter, + makeCodexAdapter(decodeCodexSettings({}), { + makeRuntime: runtimeFactory.factory, + environment: { PATH: "/usr/bin", GH_TOKEN: "ambient", GITHUB_TOKEN: "ambient" }, + }).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: () => Effect.succeed({ GH_TOKEN: "selected", GITHUB_TOKEN: "selected" }), + }), + ), + ).pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())), + Layer.provideMerge(ServerSettingsService.layerTest()), + Layer.provideMerge(providerSessionDirectoryTestLayer), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* CodexAdapter; + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: asThreadId("sess-github-login"), + runtimeMode: "full-access", + }); + NodeAssert.deepEqual(runtimeFactory.lastRuntime?.options.environment, { + PATH: "/usr/bin", + GH_TOKEN: "selected", + GITHUB_TOKEN: "selected", + }); + + // The device environment is layered over the selected login, not instead of it. + McpProviderSession.setMcpProviderSession({ + environmentId: EnvironmentId.make("environment-1"), + threadId: mcpThreadId, + providerSessionId: "provider-session-1", + providerInstanceId: ProviderInstanceId.make("codex"), + endpoint: "http://127.0.0.1:1/mcp", + authorizationHeader: "Bearer mcp-token", + capabilities: new Set(["device"]), + agentDeviceEnvironment: { PATH: "/t3/device/bin", PATH_SEPARATOR: ":" }, + }); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId: mcpThreadId, + runtimeMode: "full-access", + }); + NodeAssert.deepEqual(runtimeFactory.lastRuntime?.options.environment, { + PATH: "/t3/device/bin:/usr/bin", + GH_TOKEN: "selected", + GITHUB_TOKEN: "selected", + T3_MCP_BEARER_TOKEN: "mcp-token", + }); + }).pipe( + Effect.ensuring(Effect.sync(() => McpProviderSession.clearMcpProviderSession(mcpThreadId))), + Effect.provide(layer), + ); + }); + it.effect("passes configured launch args into the session runtime", () => { const runtimeFactory = makeRuntimeFactory(); const layer = Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index baa8d846f7c6..523acd310ade 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -58,6 +58,7 @@ import { type ProviderAdapterError, } from "../Errors.ts"; import { type CodexAdapterShape } from "../Services/CodexAdapter.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -2241,6 +2242,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("codex"); const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const crypto = yield* Crypto.Crypto; + const gitHubAccountEnvironment = yield* GitHubCliAccountEnvironment; const serverConfig = yield* Effect.service(ServerConfig); const nativeEventLogger = options?.nativeEventLogger ?? @@ -2275,14 +2277,21 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const cwd = input.cwd ?? process.cwd(); + const gitHubEnvironment = yield* gitHubAccountEnvironment.forCwd(cwd); + // Undefined keeps the app-server inheriting the server's environment as-is. + const environment = + options?.environment || Object.keys(gitHubEnvironment).length > 0 + ? { ...(options?.environment ?? process.env), ...gitHubEnvironment } + : undefined; const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, - cwd: input.cwd ?? process.cwd(), + cwd, binaryPath: codexConfig.binaryPath, ...(options?.models ? { models: options.models } : {}), launchArgs: resolveCodexLaunchArgs(codexConfig.launchArgs, options?.environment), - ...(options?.environment ? { environment: options.environment } : {}), + ...(environment ? { environment } : {}), ...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}), ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } @@ -2296,7 +2305,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? { environment: { ...McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + environment ?? process.env, mcpSession, ), T3_MCP_BEARER_TOKEN: mcpSession.authorizationHeader.replace(/^Bearer\s+/, ""), diff --git a/apps/server/src/provider/Layers/CursorAdapter.test.ts b/apps/server/src/provider/Layers/CursorAdapter.test.ts index 6b6aeff17f35..5e509d290df1 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.test.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.test.ts @@ -31,6 +31,7 @@ import { ServerSettingsService } from "../../serverSettings.ts"; import type { CursorAdapterShape } from "../Services/CursorAdapter.ts"; import { makeCursorAdapter } from "./CursorAdapter.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; const decodeCursorSettings = Schema.decodeSync(CursorSettings); @@ -162,6 +163,45 @@ const cursorAdapterTestLayer = it.layer( ); cursorAdapterTestLayer("CursorAdapterLive", (it) => { + it.effect("starts the agent as the checkout's selected GitHub CLI login", () => + Effect.gen(function* () { + const threadId = ThreadId.make("cursor-github-login"); + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "cursor-github-login-")), + ); + const envLogPath = NodePath.join(dir, "env.json"); + const wrapperPath = writeFakeCli({ + directory: dir, + name: "fake-agent", + source: execScriptSource({ + scriptPath: mockAgentPath, + envLog: { path: envLogPath, keys: ["GH_TOKEN", "GITHUB_TOKEN"] }, + }), + }); + const adapter = yield* makeCursorAdapter(decodeCursorSettings({ binaryPath: wrapperPath }), { + environment: { ...process.env, GH_TOKEN: "ambient", GITHUB_TOKEN: "ambient" }, + }).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: () => Effect.succeed({ GH_TOKEN: "selected", GITHUB_TOKEN: "selected" }), + }), + ); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.stopSession(threadId); + const logged = yield* Effect.promise(() => NodeFSP.readFile(envLogPath, "utf8")); + assert.deepStrictEqual( + yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ GH_TOKEN: Schema.String, GITHUB_TOKEN: Schema.String }), + ), + )(logged), + { + GH_TOKEN: "selected", + GITHUB_TOKEN: "selected", + }, + ); + }), + ); + it.effect("rejects rollback without discarding the provider conversation", () => Effect.gen(function* () { const adapter = yield* CursorAdapter; diff --git a/apps/server/src/provider/Layers/CursorAdapter.ts b/apps/server/src/provider/Layers/CursorAdapter.ts index e0fe5016326d..3fab4c8544f7 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -77,6 +77,7 @@ import { extractTodosAsPlan, } from "../acp/CursorAcpExtension.ts"; import { type CursorAdapterShape } from "../Services/CursorAdapter.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { @@ -332,6 +333,7 @@ export function makeCursorAdapter( const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const gitHubAccountEnvironment = yield* GitHubCliAccountEnvironment; const serverConfig = yield* Effect.service(ServerConfig); const crypto = yield* Crypto.Crypto; const nativeEventLogger = @@ -545,12 +547,15 @@ export function makeCursorAdapter( : cursorSettings; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const gitHubEnvironment = yield* gitHubAccountEnvironment.forCwd(cwd); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment || mcpSession?.agentDeviceEnvironment + ...(options?.environment || + mcpSession?.agentDeviceEnvironment || + Object.keys(gitHubEnvironment).length > 0 ? { environment: McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + { ...(options?.environment ?? process.env), ...gitHubEnvironment }, mcpSession, ), } diff --git a/apps/server/src/provider/Layers/GrokAdapter.test.ts b/apps/server/src/provider/Layers/GrokAdapter.test.ts index ecd73af72dbe..f8c984398a14 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.test.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.test.ts @@ -27,6 +27,7 @@ import { import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { ServerConfig } from "../../config.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { execScriptSource, writeFakeCli } from "../../testUtils/fakeCli.ts"; import { grokPromptSettlementBelongsToContext, @@ -213,6 +214,45 @@ it("requires a settlement to match the live Grok turn", () => { }); it.layer(grokAdapterTestLayer)("GrokAdapterLive", (it) => { + it.effect("starts the agent as the checkout's selected GitHub CLI login", () => + Effect.gen(function* () { + const threadId = ThreadId.make("grok-github-login"); + const dir = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(NodeOS.tmpdir(), "grok-github-login-")), + ); + const envLogPath = NodePath.join(dir, "env.json"); + const wrapperPath = writeFakeCli({ + directory: dir, + name: "fake-grok", + source: execScriptSource({ + scriptPath: mockAgentPath, + envLog: { path: envLogPath, keys: ["GH_TOKEN", "GITHUB_TOKEN"] }, + }), + }); + const adapter = yield* makeTestAdapter(wrapperPath, { + environment: { ...process.env, GH_TOKEN: "ambient", GITHUB_TOKEN: "ambient" }, + }).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: () => Effect.succeed({ GH_TOKEN: "selected", GITHUB_TOKEN: "selected" }), + }), + ); + yield* adapter.startSession({ threadId, cwd: process.cwd(), runtimeMode: "full-access" }); + yield* adapter.stopSession(threadId); + const logged = yield* Effect.promise(() => NodeFSP.readFile(envLogPath, "utf8")); + assert.deepStrictEqual( + yield* Schema.decodeEffect( + Schema.fromJsonString( + Schema.Struct({ GH_TOKEN: Schema.String, GITHUB_TOKEN: Schema.String }), + ), + )(logged), + { + GH_TOKEN: "selected", + GITHUB_TOKEN: "selected", + }, + ); + }), + ); + it.effect("rejects rollback without discarding the provider conversation", () => Effect.gen(function* () { const threadId = ThreadId.make("grok-unsupported-rollback"); diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 601ce9ffa37e..236354827630 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -84,6 +84,7 @@ import { XAiExitPlanModeRequest, } from "../acp/XAiAcpExtension.ts"; import { type GrokAdapterShape } from "../Services/GrokAdapter.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.fromJsonString(Schema.Unknown)); @@ -349,6 +350,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const gitHubAccountEnvironment = yield* GitHubCliAccountEnvironment; const serverConfig = yield* Effect.service(ServerConfig); const crypto = yield* Crypto.Crypto; const nativeEventLogger = @@ -994,12 +996,15 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const gitHubEnvironment = yield* gitHubAccountEnvironment.forCwd(cwd); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment || mcpSession?.agentDeviceEnvironment + ...(options?.environment || + mcpSession?.agentDeviceEnvironment || + Object.keys(gitHubEnvironment).length > 0 ? { environment: McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + { ...(options?.environment ?? process.env), ...gitHubEnvironment }, mcpSession, ), } diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index baded8094ba6..2a8b1e21decd 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -33,6 +33,7 @@ import { } from "@t3tools/contracts"; import { createModelSelection } from "@t3tools/shared/model"; import { ServerConfig } from "../../config.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts"; @@ -68,6 +69,7 @@ type MessageEntry = { const runtimeMock = { state: { startCalls: [] as string[], + connectEnvironments: [] as Array, sessionCreateUrls: [] as string[], sessionCreateInputs: [] as Array>, createdSessionIds: [] as string[], @@ -138,6 +140,7 @@ const runtimeMock = { }, reset() { this.state.startCalls.length = 0; + this.state.connectEnvironments.length = 0; this.state.sessionCreateUrls.length = 0; this.state.sessionCreateInputs.length = 0; this.state.createdSessionIds.length = 0; @@ -218,8 +221,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { isRunning: Effect.succeed(true), }; }), - connectToOpenCodeServer: ({ serverUrl, serverPassword }) => + connectToOpenCodeServer: ({ serverUrl, serverPassword, environment }) => Effect.gen(function* () { + runtimeMock.state.connectEnvironments.push(environment); const url = serverUrl ?? "http://127.0.0.1:4301"; // Always register a finalizer so the closeCalls/closeError probes fire; // production attaches none for external servers. @@ -667,6 +671,25 @@ const questionRequest = (id: string, sessionID: string): QuestionRequest => ({ }); it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { + it.effect("starts servers as the checkout's selected GitHub CLI login", () => + Effect.gen(function* () { + const adapter = yield* makeOpenCodeAdapter(openCodeAdapterTestSettings, { + environment: { ...process.env, GH_TOKEN: "ambient", GITHUB_TOKEN: "ambient" }, + }).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: () => Effect.succeed({ GH_TOKEN: "selected", GITHUB_TOKEN: "selected" }), + }), + ); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId: asThreadId("thread-opencode-github-login"), + runtimeMode: "full-access", + }); + NodeAssert.equal(runtimeMock.state.connectEnvironments[0]?.GH_TOKEN, "selected"); + NodeAssert.equal(runtimeMock.state.connectEnvironments[0]?.GITHUB_TOKEN, "selected"); + }), + ); + it.effect("reuses a configured OpenCode server URL instead of spawning a local server", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 04535edbd3af..a1a0683aaaff 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -45,6 +45,7 @@ import { } from "../Errors.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import { type OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts"; +import { GitHubCliAccountEnvironment } from "../../sourceControl/GitHubCli.ts"; import { buildOpenCodePermissionRules, OpenCodeRuntime, @@ -946,6 +947,7 @@ export function makeOpenCodeAdapter( const boundInstanceId = options?.instanceId ?? ProviderInstanceId.make("opencode"); const serverConfig = yield* ServerConfig; const openCodeRuntime = yield* OpenCodeRuntime; + const gitHubAccountEnvironment = yield* GitHubCliAccountEnvironment; const crypto = yield* Crypto.Crypto; const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -2852,13 +2854,15 @@ export function makeOpenCodeAdapter( // we provide below — closing `sessionScope` kills the child // process automatically. No manual `server.close()` needed. const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + // An external `serverUrl` server keeps its own environment and login. + const gitHubEnvironment = yield* gitHubAccountEnvironment.forCwd(directory); const server = yield* openCodeRuntime.connectToOpenCodeServer({ binaryPath, directory, serverUrl, ...(serverPassword ? { serverPassword } : {}), environment: McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + { ...(options?.environment ?? process.env), ...gitHubEnvironment }, mcpSession, ), }); diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index 8f2877330bbe..e40bc768222d 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -37,6 +37,8 @@ export interface AntigravityAcpRuntimeInput extends Omit< > { /** Device CLI environment supplied for this provider session. */ readonly agentDeviceEnvironment?: Readonly>; + /** Selected GitHub CLI login's token for this session's checkout. */ + readonly gitHubEnvironment?: Readonly>; readonly childProcessSpawner: ChildProcessSpawner.ChildProcessSpawner["Service"]; readonly onAuthorizationUrl?: (url: string) => Effect.Effect; /** diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 566bb19e4064..2b8bc4903159 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -296,9 +296,16 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +const GitHubCliAccountSelectionLive = GitHubCliAccountSelection.layer.pipe( + Layer.provide(ServerSettingsLayerLive), +); + // Every server-side GitHub command resolves its checkout's selected `gh` login through this one instance. -const GitHubCliLayerLive = GitHubCli.layer.pipe( - Layer.provide(GitHubCliAccountSelection.layer.pipe(Layer.provide(ServerSettingsLayerLive))), +const GitHubCliLayerLive = GitHubCli.layer.pipe(Layer.provide(GitHubCliAccountSelectionLive)); + +// Terminals and agent sessions start as the checkout's selected `gh` login too. +const GitHubCliAccountEnvironmentLive = GitHubCliAccountSelection.environmentLayer.pipe( + Layer.provide(GitHubCliAccountSelectionLive), ); const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( @@ -424,6 +431,7 @@ const CheckpointingLayerLive = Layer.empty.pipe( const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); const TerminalLayerLive = TerminalManager.layer.pipe( + Layer.provide(GitHubCliAccountEnvironmentLive), Layer.provide(PtyAdapterLive), Layer.provide(PortScannerLayerLive), Layer.provide(NativeTelemetryLayerLive), @@ -543,7 +551,9 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( // through this layer. Built-in drivers come from `BUILT_IN_DRIVERS`; // `providerInstances` hydration merges `settings.providers.` // with explicit `providerInstances` entries on boot. - Layer.provideMerge(ProviderInstanceRegistryHydrationLive), + Layer.provideMerge( + ProviderInstanceRegistryHydrationLive.pipe(Layer.provide(GitHubCliAccountEnvironmentLive)), + ), ).pipe( Layer.provideMerge(AntigravityInstallation.layer), // Shared native/canonical NDJSON writers used by both the per-instance diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts index 383e786a2d79..cd0457aae48c 100644 --- a/apps/server/src/sourceControl/GitHubCli.ts +++ b/apps/server/src/sourceControl/GitHubCli.ts @@ -47,8 +47,19 @@ export class GitHubCliAccountSelection extends Context.Reference<{ defaultValue: () => ({ forCwd: () => Effect.succeed(null) }), }) {} +/** + * Environment that makes `gh` (and git's `gh auth git-credential` helper) act as + * the login selected for a checkout. Terminals and agent sessions merge it into + * the processes they start; empty when no login is selected or it cannot be read. + */ +export const GitHubCliAccountEnvironment = Context.Reference<{ + readonly forCwd: (cwd: string) => Effect.Effect>>; +}>("t3/sourceControl/GitHubCliAccountEnvironment", { + defaultValue: () => ({ forCwd: () => Effect.succeed({}) }), +}); + /** gh reads github.com and GHE.com tenancies from GH_TOKEN, every other host from GH_ENTERPRISE_TOKEN. */ -function tokenEnv(host: string, token: string): NodeJS.ProcessEnv { +export function tokenEnv(host: string, token: string): Record { return host === "github.com" || host.endsWith(".ghe.com") ? { GH_TOKEN: token, GITHUB_TOKEN: token } : { GH_ENTERPRISE_TOKEN: token, GITHUB_ENTERPRISE_TOKEN: token }; diff --git a/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts index 298f3655d64d..c10a7b9294be 100644 --- a/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts +++ b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts @@ -1,7 +1,9 @@ import { assert, it } from "@effect/vitest"; -import { ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; +import { ProjectId, ProviderInstanceId, ThreadId, VcsProcessExitError } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as TestClock from "effect/testing/TestClock"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; @@ -9,7 +11,8 @@ import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import { ProjectionProjectRepository } from "../persistence/Services/ProjectionProjects.ts"; import { ProjectionThreadRepository } from "../persistence/Services/ProjectionThreads.ts"; import * as ServerSettings from "../serverSettings.ts"; -import { GitHubCliAccountSelection } from "./GitHubCli.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { GitHubCliAccountEnvironment, GitHubCliAccountSelection } from "./GitHubCli.ts"; import * as GitHubCliAccountSelectionLayer from "./GitHubCliAccountSelection.ts"; const at = "2026-09-01T00:00:00.000Z"; @@ -116,3 +119,135 @@ it.effect("lets a project opt back into the CLI's active login", () => ), ), ); + +it.effect("hands processes the selected login's token, or nothing when it is unreadable", () => { + const lookups: Array> = []; + const accounts: Record = { + "/work": work, + "/enterprise": { host: "GitHub.Example.test", login: "enterprise" }, + "/gone": { host: "github.com", login: "gone" }, + }; + return Effect.gen(function* () { + const environment = yield* GitHubCliAccountEnvironment; + assert.deepStrictEqual(yield* environment.forCwd("/work"), { + GH_TOKEN: "token-for-work", + GITHUB_TOKEN: "token-for-work", + }); + assert.deepStrictEqual(yield* environment.forCwd("/enterprise"), { + GH_ENTERPRISE_TOKEN: "token-for-enterprise", + GITHUB_ENTERPRISE_TOKEN: "token-for-enterprise", + }); + // A signed-out login falls back to gh's active login instead of blocking the process. + assert.deepStrictEqual(yield* environment.forCwd("/gone"), {}); + assert.deepStrictEqual(yield* environment.forCwd("/unselected"), {}); + yield* environment.forCwd("/work"); + assert.deepStrictEqual(lookups, [ + ["auth", "token", "--hostname", "github.com", "--user", "work"], + ["auth", "token", "--hostname", "github.example.test", "--user", "enterprise"], + ["auth", "token", "--hostname", "github.com", "--user", "gone"], + ]); + }).pipe( + Effect.provide( + GitHubCliAccountSelectionLayer.environmentLayer.pipe( + Layer.provide( + Layer.succeed(GitHubCliAccountSelection, { + forCwd: (cwd) => Effect.succeed(accounts[cwd] ?? null), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => { + lookups.push(input.args); + const login = input.args[5]; + return login === "gone" + ? Effect.fail( + new VcsProcessExitError({ + operation: input.operation, + command: "gh", + cwd: input.cwd, + exitCode: 1, + failureKind: "authentication", + detail: "no oauth token found", + }), + ) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `token-for-${login}\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }, + }), + ), + ), + ), + ); +}); + +it.effect("follows selection changes, token expiry, and a recovered login", () => { + let selected = work; + let issued = 0; + let signedOut = false; + const lookups: Array = []; + return Effect.gen(function* () { + const environment = yield* GitHubCliAccountEnvironment; + const token = Effect.map(environment.forCwd("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/src/work"), (env) => env.GH_TOKEN); + + assert.strictEqual(yield* token, "work-1"); + // A new selection applies to the next launch without waiting for expiry. + selected = personal; + assert.strictEqual(yield* token, "personal-2"); + selected = work; + assert.strictEqual(yield* token, "work-1"); + + // Tokens are re-read once the cached one is a minute old. + yield* TestClock.adjust("61 seconds"); + assert.strictEqual(yield* token, "work-3"); + + // A failed lookup is not cached, so signing back in works on the next launch. + yield* TestClock.adjust("61 seconds"); + signedOut = true; + assert.strictEqual(yield* token, undefined); + signedOut = false; + assert.strictEqual(yield* token, "work-5"); + assert.deepStrictEqual(lookups, ["work", "personal", "work", "work", "work"]); + }).pipe( + Effect.provide( + GitHubCliAccountSelectionLayer.environmentLayer.pipe( + Layer.provide( + Layer.succeed(GitHubCliAccountSelection, { + forCwd: () => Effect.sync(() => selected), + }), + ), + Layer.provide( + Layer.mock(VcsProcess.VcsProcess)({ + run: (input) => + Effect.suspend(() => { + lookups.push(input.args[5]); + issued += 1; + return signedOut + ? Effect.fail( + new VcsProcessExitError({ + operation: input.operation, + command: "gh", + cwd: input.cwd, + exitCode: 1, + failureKind: "authentication", + detail: "no oauth token found", + }), + ) + : Effect.succeed({ + exitCode: ChildProcessSpawner.ExitCode(0), + stdout: `${input.args[5]}-${issued}\n`, + stderr: "", + stdoutTruncated: false, + stderrTruncated: false, + }); + }), + }), + ), + ), + ), + ); +}); diff --git a/apps/server/src/sourceControl/GitHubCliAccountSelection.ts b/apps/server/src/sourceControl/GitHubCliAccountSelection.ts index aeff55c93300..02c540cda584 100644 --- a/apps/server/src/sourceControl/GitHubCliAccountSelection.ts +++ b/apps/server/src/sourceControl/GitHubCliAccountSelection.ts @@ -11,7 +11,8 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as ServerSettings from "../serverSettings.ts"; -import { GitHubCliAccountSelection } from "./GitHubCli.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import { GitHubCliAccountEnvironment, GitHubCliAccountSelection, tokenEnv } from "./GitHubCli.ts"; /** * Resolves the `gh` login for a GitHub command from its cwd: the project @@ -73,3 +74,60 @@ export const layer = Layer.effect( }; }), ); + +/** + * Hands terminals and agent sessions the selected login's token, so `gh` in them + * matches the checkout's selection instead of the CLI's globally active login. + * An unreadable login leaves the environment untouched rather than blocking the process. + */ +export const environmentLayer = Layer.effect( + GitHubCliAccountEnvironment, + Effect.gen(function* () { + const selection = yield* GitHubCliAccountSelection; + const process = yield* VcsProcess.VcsProcess; + const environments = yield* Cache.makeWith( + (key: string) => { + const [host = "", login = ""] = key.split("\0"); + return process + .run({ + operation: "GitHubCliAccountEnvironment.token", + command: "gh", + args: ["auth", "token", "--hostname", host, "--user", login], + cwd: globalThis.process.cwd(), + timeoutMs: 10_000, + // Blank env tokens so an ambient GH_TOKEN cannot answer for the login. + env: { ...tokenEnv(host, ""), GH_DEBUG: "" }, + }) + .pipe( + Effect.map((result) => result.stdout.trim()), + Effect.flatMap((token) => + token ? Effect.succeed(tokenEnv(host, token)) : Effect.fail(null), + ), + ); + }, + { + capacity: 16, + timeToLive: (exit) => (Exit.isSuccess(exit) ? Duration.minutes(1) : Duration.zero), + }, + ); + return { + forCwd: Effect.fn("GitHubCliAccountEnvironment.forCwd")(function* (cwd: string) { + const account = yield* selection.forCwd(cwd); + if (account === null) return {}; + const host = account.host.toLowerCase(); + return yield* Cache.get(environments, `${host}\0${account.login}`).pipe( + // Never attach credential lookup output to the log. + Effect.catch(() => + Effect.logWarning( + "Selected GitHub CLI account is unavailable; using gh's active login.", + { + host, + login: account.login, + }, + ).pipe(Effect.as({})), + ), + ); + }), + }; + }), +); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index f04161cfbd3c..89734b8a0538 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -1,3 +1,4 @@ +import * as NodeOS from "node:os"; import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, it } from "@effect/vitest"; import { @@ -39,6 +40,7 @@ import * as ServerConfig from "../config.ts"; import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; import * as ProcessRunner from "../processRunner.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { GitHubCliAccountEnvironment } from "../sourceControl/GitHubCli.ts"; import * as TerminalManager from "./Manager.ts"; import * as PtyAdapter from "./PtyAdapter.ts"; @@ -1915,6 +1917,34 @@ it.layer( }), ); + it.effect("starts shells as the checkout's selected GitHub CLI login", () => + Effect.gen(function* () { + const selectedCwd = process.cwd(); + const { manager, ptyAdapter } = yield* createManager(5, { + env: { GH_TOKEN: "ambient", GITHUB_TOKEN: "ambient" }, + }).pipe( + Effect.provideService(GitHubCliAccountEnvironment, { + forCwd: (cwd) => + Effect.succeed( + cwd === selectedCwd ? { GH_TOKEN: "selected", GITHUB_TOKEN: "selected" } : {}, + ), + }), + ); + yield* manager.open(openInput({ cwd: selectedCwd })); + yield* manager.open(openInput({ terminalId: "other", cwd: NodeOS.tmpdir() })); + + expect(ptyAdapter.spawnInputs[0]?.env).toMatchObject({ + GH_TOKEN: "selected", + GITHUB_TOKEN: "selected", + }); + // Without a selection the shell keeps gh's own resolution. + expect(ptyAdapter.spawnInputs[1]?.env).toMatchObject({ + GH_TOKEN: "ambient", + GITHUB_TOKEN: "ambient", + }); + }), + ); + it.effect("expands provider home paths passed to setup terminals", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(5); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 75d592c00c0d..39882be5d09a 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -64,6 +64,7 @@ import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; import { makeClaudeEnvironment } from "../provider/Drivers/ClaudeHome.ts"; import { deriveProviderInstanceConfigMap } from "../provider/Layers/ProviderInstanceRegistryHydration.ts"; import * as ServerSettings from "../serverSettings.ts"; +import { GitHubCliAccountEnvironment } from "../sourceControl/GitHubCli.ts"; import { increment, terminalRestartsTotal, @@ -1306,6 +1307,7 @@ function stripAppImageRuntimeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { function createTerminalSpawnEnv( baseEnv: NodeJS.ProcessEnv, runtimeEnv?: Record | null, + gitHubAccountEnv: Readonly> = {}, ): NodeJS.ProcessEnv { const spawnEnv: NodeJS.ProcessEnv = {}; for (const [key, value] of Object.entries(baseEnv)) { @@ -1313,6 +1315,7 @@ function createTerminalSpawnEnv( if (shouldExcludeTerminalEnvKey(key)) continue; spawnEnv[key] = value; } + Object.assign(spawnEnv, gitHubAccountEnv); if (runtimeEnv) { for (const [key, value] of Object.entries(runtimeEnv)) { spawnEnv[key] = @@ -1460,6 +1463,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const baseEnv = options.env ?? process.env; const shellResolver = options.shellResolver ?? (() => defaultShellResolver(platform, baseEnv)); const processRunner = yield* ProcessRunner.ProcessRunner; + const gitHubAccountEnvironment = yield* GitHubCliAccountEnvironment; const resolveLaunchInputEnvironment = Effect.fn("terminal.resolveLaunchInputEnvironment")( function* ( input: Input, @@ -2237,7 +2241,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func Effect.andThen( Effect.gen(function* () { const shellCandidates = resolveShellCandidates(shellResolver, platform, baseEnv); - const terminalEnv = createTerminalSpawnEnv(baseEnv, session.runtimeEnv); + const terminalEnv = createTerminalSpawnEnv( + baseEnv, + session.runtimeEnv, + yield* gitHubAccountEnvironment.forCwd(session.cwd), + ); const spawnResult = yield* trySpawn(shellCandidates, terminalEnv, session); ptyProcess = spawnResult.process; startedShell = spawnResult.shellLabel; diff --git a/apps/server/src/testUtils/fakeCli.ts b/apps/server/src/testUtils/fakeCli.ts index 68ab157aed7a..87a5ba8ae3b7 100644 --- a/apps/server/src/testUtils/fakeCli.ts +++ b/apps/server/src/testUtils/fakeCli.ts @@ -82,11 +82,18 @@ export function execScriptSource(options: { readonly argvLogPath?: string; /** Wait before handing over, for tests that race a slow startup. */ readonly delayMs?: number; + /** Writes the named variables as JSON here at startup, for launch-environment assertions. */ + readonly envLog?: { readonly path: string; readonly keys: ReadonlyArray }; }): string { return [ - 'import { appendFileSync } from "node:fs";', + 'import { appendFileSync, writeFileSync } from "node:fs";', 'import { pathToFileURL } from "node:url";', "const args = process.argv.slice(2);", + ...(options.envLog === undefined + ? [] + : [ + `writeFileSync(${JSON.stringify(options.envLog.path)}, JSON.stringify(Object.fromEntries(${JSON.stringify(options.envLog.keys)}.map((key) => [key, process.env[key]]))));`, + ]), ...(options.argvLogPath === undefined ? [] : [ diff --git a/docs/user/source-control.md b/docs/user/source-control.md index f3c6d96637ce..4e14d60bc096 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -20,9 +20,12 @@ gh auth login When `gh` holds more than one login, choose which one T3 Code uses under **GitHub CLI account** in the GitHub entry of **Settings → Source Control**. Select a project to give it a different account, for example a work login for company repositories. T3 Code passes that login's token to -its own GitHub commands and never switches the login active in `gh`, so terminals and other apps -are unaffected. If the chosen login is signed out, GitHub actions fail until you sign it back in -or pick another account; they never fall back to a different login. +its own GitHub commands, to terminals it opens, and to agent sessions it starts, so `gh` there acts +as the selected login. It never switches the login active in `gh`, so apps outside T3 Code are +unaffected. A change applies to terminals and agent sessions started afterwards. If the chosen +login is signed out, GitHub actions fail until you sign it back in or pick another account; they +never fall back to a different login. Terminals and agent sessions start with `gh`'s active login +instead. An OpenCode server you connect to by URL keeps its own login. ### Forgejo and Gitea