From 6b350b7b17a79a3feebc99314c211a419df83706 Mon Sep 17 00:00:00 2001 From: Donald Silveira <9506146+donnes@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:30:41 -0300 Subject: [PATCH 1/2] feat(server): use selected GitHub login in shells and agents --- .../src/provider/Drivers/AntigravityDriver.ts | 5 +- .../src/provider/Layers/AntigravityAdapter.ts | 4 ++ .../src/provider/Layers/ClaudeAdapter.ts | 8 ++- .../src/provider/Layers/CodexAdapter.ts | 15 +++- .../src/provider/Layers/CursorAdapter.ts | 9 ++- .../server/src/provider/Layers/GrokAdapter.ts | 9 ++- .../src/provider/Layers/OpenCodeAdapter.ts | 6 +- .../src/provider/acp/AntigravityAcpSupport.ts | 2 + apps/server/src/server.ts | 16 ++++- apps/server/src/sourceControl/GitHubCli.ts | 13 +++- .../GitHubCliAccountSelection.test.ts | 71 ++++++++++++++++++- .../GitHubCliAccountSelection.ts | 60 +++++++++++++++- apps/server/src/terminal/Manager.test.ts | 30 ++++++++ apps/server/src/terminal/Manager.ts | 10 ++- docs/user/source-control.md | 9 ++- 15 files changed, 246 insertions(+), 21 deletions(-) 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 pasted files 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.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 11a5322b4eee..244ed3a9cea8 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, @@ -4902,6 +4904,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 @@ -4947,7 +4950,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.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.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.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.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 f2f370068181..16340483156d 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 9badc0f0fd1d..6b7dfe2aaf93 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -291,9 +291,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( @@ -419,6 +426,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), @@ -537,7 +545,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..49bffa1b7632 100644 --- a/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts +++ b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts @@ -1,7 +1,8 @@ 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 { ChildProcessSpawner } from "effect/unstable/process"; import { ProjectionProjectRepositoryLive } from "../persistence/Layers/ProjectionProjects.ts"; import { ProjectionThreadRepositoryLive } from "../persistence/Layers/ProjectionThreads.ts"; @@ -9,7 +10,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 +118,68 @@ 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, + }); + }, + }), + ), + ), + ), + ); +}); 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 80ab2c43e42c..870e96c8b348 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"; @@ -1848,6 +1850,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 1a65587430d5..ea7d7ee28a8a 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, @@ -1283,6 +1284,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)) { @@ -1290,6 +1292,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] = @@ -1437,6 +1440,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, @@ -2214,7 +2218,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/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 From 48237dfab9d9e4ffd1bb277da6bedc61d93838fe Mon Sep 17 00:00:00 2001 From: Donald Silveira <9506146+donnes@users.noreply.github.com> Date: Sat, 26 Sep 2026 15:50:06 -0300 Subject: [PATCH 2/2] test(server): cover selected GitHub login reaching agents and cache transitions Each provider adapter now has a test proving the selected login's token reaches its process over an ambient token, including Codex's MCP merge and the Antigravity driver handoff. The account environment test covers selection changes, the one-minute token expiry on the test clock, and retrying after a failed lookup. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../Drivers/AntigravityDriver.test.ts | 25 +++++++ .../src/provider/Layers/ClaudeAdapter.test.ts | 31 ++++++++- .../src/provider/Layers/CodexAdapter.test.ts | 64 +++++++++++++++++ .../src/provider/Layers/CursorAdapter.test.ts | 40 +++++++++++ .../src/provider/Layers/GrokAdapter.test.ts | 40 +++++++++++ .../provider/Layers/OpenCodeAdapter.test.ts | 25 ++++++- .../GitHubCliAccountSelection.test.ts | 68 +++++++++++++++++++ apps/server/src/testUtils/fakeCli.ts | 9 ++- 8 files changed, 299 insertions(+), 3 deletions(-) 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/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 502620359836..98144a29d1a2 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/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/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/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/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/sourceControl/GitHubCliAccountSelection.test.ts b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts index 49bffa1b7632..c10a7b9294be 100644 --- a/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts +++ b/apps/server/src/sourceControl/GitHubCliAccountSelection.test.ts @@ -2,6 +2,7 @@ import { assert, it } from "@effect/vitest"; 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"; @@ -183,3 +184,70 @@ it.effect("hands processes the selected login's token, or nothing when it is unr ), ); }); + +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/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 ? [] : [