diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx index bc5315bec132..b53f30236bfc 100644 --- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx @@ -396,6 +396,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) { onValueChange={(value) => write({ enableAgentBrowserAccess: value })} /> + + write({ enableDirenvEnvironment: value })} + /> + ) : null} diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 83b0bef9c022..e65fb38b68f4 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -1355,6 +1355,7 @@ function renderFeedEntry( props: Pick< ThreadFeedProps, | "environmentId" + | "threadId" | "onUseArtifactTemplate" | "skills" | "dispatchingMessageId" @@ -1755,6 +1756,7 @@ function renderFeedEntry( key={`${entry.id}:${props.workRowSizing.textSizeKey}`} activities={entry.activities} environmentId={props.environmentId} + threadId={props.threadId} anchorKey={entry.id} copiedRowId={props.copiedRowId} expandedRows={props.expandedWorkRows} @@ -2755,6 +2757,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { {renderFeedEntry(info, { environmentId: props.environmentId, + threadId: props.threadId, dispatchingMessageId: props.dispatchingMessageId, onEditPendingMessage: props.onEditPendingMessage, copiedRowId, @@ -2829,6 +2832,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleWorkGroup, onToggleWorkRow, props.environmentId, + props.threadId, props.onUseArtifactTemplate, props.skills, renderMarkdownImage, diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index a2053a2a67b3..9f91196b700a 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -24,6 +24,7 @@ import { } from "react"; import { AccessibilityInfo, + Alert, AppState, type ColorValue, Pressable, @@ -32,7 +33,7 @@ import { View, } from "react-native"; import Svg, { Defs, LinearGradient, Rect, Stop } from "react-native-svg"; -import type { EnvironmentId, ToolActivityIcon } from "@t3tools/contracts"; +import type { EnvironmentId, ThreadId, ToolActivityIcon } from "@t3tools/contracts"; import { toolActivityFaviconUrl } from "@t3tools/shared/favicon"; import { AppText as Text } from "../../components/AppText"; @@ -71,6 +72,8 @@ import Animated, { withTiming, } from "react-native-reanimated"; import { useAssetUrl } from "../../state/assets"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; const SHIMMER_WIDTH = 72; const SHIMMER_SWEEP_MS = 1_350; @@ -416,6 +419,7 @@ interface ThreadWorkLogProps { readonly activities: ReadonlyArray; readonly anchorKey: string; readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; readonly copiedRowId: string | null; readonly expandedRows: Readonly>; readonly rowSizing: ReturnType; @@ -439,6 +443,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { copied={props.copiedRowId === row.id} expanded={props.expandedRows[row.id] ?? false} environmentId={props.environmentId} + threadId={props.threadId} iconSubtleColor={props.iconSubtleColor} onCopyRow={props.onCopyRow} onToggleRow={props.onToggleRow} @@ -451,6 +456,7 @@ export function ThreadWorkLog(props: ThreadWorkLogProps) { props.copiedRowId, props.expandedRows, props.environmentId, + props.threadId, props.iconSubtleColor, props.onCopyRow, props.onToggleRow, @@ -730,6 +736,54 @@ function workLogRowKey(row: ThreadFeedActivity): string { return row.id; } +/** Allows the thread's blocked `.envrc`; the next message loads it. */ +// The work log remounts rows, so the outcome outlives the button. +const allowedDirenvWarnings = new Set(); + +function AllowDirenvButton(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly warningId: string; +}) { + const allowDirenv = useAtomCommand(threadEnvironment.allowDirenv, "allow direnv"); + const [state, setState] = useState<"idle" | "pending" | "allowed">(() => + allowedDirenvWarnings.has(props.warningId) ? "allowed" : "idle", + ); + return ( + { + setState("pending"); + const result = await allowDirenv({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }); + if (result._tag === "Success" && result.value.allowed) { + allowedDirenvWarnings.add(props.warningId); + setState("allowed"); + return; + } + setState("idle"); + if (result._tag === "Success") { + Alert.alert("Could not allow the .envrc", result.value.error); + } + }} + className="min-h-8 justify-center px-2" + > + + {state === "allowed" + ? "Allowed · applies to your next message" + : state === "pending" + ? "Allowing…" + : "Allow .envrc"} + + + ); +} + const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, @@ -846,6 +900,13 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( )} + {row.workEntry.warningAction?.type === "direnv.allow" ? ( + + ) : null} {props.copied ? ( Copied diff --git a/apps/mobile/src/lib/threadActivity.test.ts b/apps/mobile/src/lib/threadActivity.test.ts index 5d256f7c6206..528c0c15c0d5 100644 --- a/apps/mobile/src/lib/threadActivity.test.ts +++ b/apps/mobile/src/lib/threadActivity.test.ts @@ -3748,3 +3748,54 @@ it("keeps attachment-only question answers expandable outside mobile work groups expect(running[1]).toBe(group); expect(running[2]?.type).toBe("work-toggle"); }); + +it("keeps a warning with a button visible beside the tools around it", () => { + const turnId = TurnId.make("turn-direnv"); + const thread = makeThread({ + id: ThreadId.make("thread-direnv"), + projectId: ProjectId.make("project-direnv"), + title: "Blocked .envrc", + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-09-08T00:00:00.000Z", + startedAt: "2026-09-08T00:00:00.000Z", + completedAt: "2026-09-08T00:00:04.000Z", + assistantMessageId: null, + }, + activities: [ + makeActivity({ + id: EventId.make("direnv-warning"), + createdAt: "2026-09-08T00:00:01.000Z", + kind: "runtime.warning", + summary: "The project's .envrc is blocked.", + turnId, + payload: { + message: "The project's .envrc is blocked.", + action: { type: "direnv.allow" }, + }, + }), + makeActivity({ + id: EventId.make("tool-after-warning"), + createdAt: "2026-09-08T00:00:02.000Z", + kind: "tool.completed", + tone: "tool", + summary: "Read files", + turnId, + payload: { itemType: "command_execution", status: "completed" }, + }), + ], + }); + + const feed = buildThreadFeed(thread); + const warning = feed.find( + (entry) => + entry.type === "activity-group" && + entry.activities.some((activity) => activity.workEntry.warningAction !== undefined), + ); + expect(warning).toMatchObject({ + activities: [{ workEntry: { warningAction: { type: "direnv.allow" } } }], + }); + const presented = deriveThreadFeedPresentation(feed, thread.latestTurn, new Set()); + expect(presented).toContain(warning); +}); diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc..87a99c95fe29 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -10,6 +10,7 @@ import type { OrchestrationLatestTurn, OrchestrationThread, OrchestrationThreadActivity, + RuntimeWarningAction, ToolLifecycleItemType, TurnId, UserInputQuestion, @@ -30,6 +31,7 @@ import { workEntryIndicatesToolFailure, workEntryIndicatesToolSuccess, workLogEntryIsToolLike, + readRuntimeWarningAction, type ToolGroupSummaryKind, type WorkLogToolLifecycleStatus, } from "@t3tools/client-runtime/work-log/presentation"; @@ -101,6 +103,8 @@ export interface WorkLogEntry { requestKind?: PendingApproval["requestKind"]; toolLifecycleStatus?: WorkLogToolLifecycleStatus; sourceActivityKind?: OrchestrationThreadActivity["kind"]; + /** Button a runtime warning offers, such as allowing a blocked `.envrc`. */ + warningAction?: RuntimeWarningAction; toolCallId?: string; /** * One row per workflow run or per-turn batch of direct spawns, like web's @@ -279,8 +283,16 @@ export function isContextCompactionActivityGroup( ); } -function isUserInputActivityGroup(entry: ThreadFeedActivityGroup): boolean { - return entry.activities.some((activity) => activity.workEntry.questionAnswer !== undefined); +/** + * Groups that render as their own rows instead of folding behind a toggle: + * answered questions, and warnings offering a button the user needs to see. + */ +function isInlineActivityGroup(entry: ThreadFeedActivityGroup): boolean { + return entry.activities.some( + (activity) => + activity.workEntry.questionAnswer !== undefined || + activity.workEntry.warningAction !== undefined, + ); } function normalizeDraftAnswer(value: string | undefined): string | null { @@ -574,6 +586,8 @@ function toDerivedWorkLogEntry(activity: OrchestrationThreadActivity): DerivedWo const message = asTrimmedString(payload?.message); if (message) entry.detail = message; } + const warningAction = readRuntimeWarningAction(activity.kind, payload); + if (warningAction) entry.warningAction = warningAction; if (viewedImagePath) { entry.viewedImagePath = viewedImagePath; } @@ -1584,7 +1598,8 @@ function groupAdjacentActivities(entries: ReadonlyArray): Th const isStandalone = entry.activity.workEntry.sourceActivityKind === "context-compaction" || - entry.activity.workEntry.questionAnswer !== undefined; + entry.activity.workEntry.questionAnswer !== undefined || + entry.activity.workEntry.warningAction !== undefined; if (isStandalone || firstActivityEntry?.turnId !== entry.turnId) { flushGroup(); } @@ -1711,7 +1726,7 @@ function deriveThreadFeedTurnFolds( (entry) => entry.id !== firstAssistantMessageId && entry.id !== terminalAssistantMessageId && - !(entry.type === "activity-group" && isUserInputActivityGroup(entry)), + !(entry.type === "activity-group" && isInlineActivityGroup(entry)), ) .map((entry) => entry.id), ); @@ -1902,7 +1917,7 @@ function activityRunTurnId(entry: ThreadFeedEntry): TurnId | null { if ( entry.type === "activity-group" && !isContextCompactionActivityGroup(entry) && - !isUserInputActivityGroup(entry) && + !isInlineActivityGroup(entry) && entry.activities.every( (activity) => !activity.workEntry.agentSpawn && activity.workEntry.tone !== "error", ) @@ -2069,7 +2084,7 @@ function appendPresentedFeedEntry( result.push(entry); return; } - if (isContextCompactionActivityGroup(entry) || isUserInputActivityGroup(entry)) { + if (isContextCompactionActivityGroup(entry) || isInlineActivityGroup(entry)) { result.push(entry); return; } diff --git a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts index b483a3f99f8e..62758852af21 100644 --- a/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts +++ b/apps/server/integration/orphanedProviderSessionStartup.integration.test.ts @@ -125,6 +125,7 @@ const startupDependencies = Layer.mergeAll( getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), + refreshProjectEnvironment: () => Effect.succeed(false), streamEvents: Stream.empty, }), ); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 8ab1520a6f34..e064965c7dbd 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -122,6 +122,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeWorktreeSetup]: AuthOrchestrationReadScope, [WS_METHODS.worktreeSetupCancel]: AuthOrchestrationOperateScope, + [WS_METHODS.projectEnvironmentAllowDirenv]: AuthOrchestrationOperateScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, [WS_METHODS.vcsRefreshStatus]: AuthOrchestrationReadScope, [WS_METHODS.vcsPull]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts index 1d0b3da1bdbc..109cb244140d 100644 --- a/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +++ b/apps/server/src/orchestration/Layers/CheckpointReactor.test.ts @@ -140,6 +140,7 @@ function createProviderServiceHarness( }), rollbackConversation, uploadFeedback: () => unsupported(), + refreshProjectEnvironment: () => Effect.succeed(false), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 254aec4bde7e..ea25b9db0a62 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -357,6 +357,9 @@ describe("ProviderCommandReactor", () => { ]; const unsupported = () => Effect.die(new Error("Unsupported provider call in test")) as never; + const refreshProjectEnvironment = vi.fn(() => + Effect.succeed(false), + ); const service: ProviderServiceShape = { startSession: startSession as ProviderServiceShape["startSession"], sendTurn: sendTurn as ProviderServiceShape["sendTurn"], @@ -398,6 +401,7 @@ describe("ProviderCommandReactor", () => { }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), + refreshProjectEnvironment, get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub); }, @@ -615,6 +619,7 @@ describe("ProviderCommandReactor", () => { ), tryHandlePromptCommand, startSession, + refreshProjectEnvironment, sendTurn, compactThread, interruptTurn, @@ -3053,6 +3058,45 @@ describe("ProviderCommandReactor", () => { expect(harness.stopSession.mock.calls.length).toBe(0); }); + it("resumes the provider session when the project environment changed", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const turnStart = (id: string, text: string) => + Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make(`cmd-turn-start-direnv-${id}`), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId(`user-message-direnv-${id}`), + role: "user", + text, + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await turnStart("1", "first"); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + // For example, the user ran `direnv allow` after the first turn. + harness.refreshProjectEnvironment.mockReturnValueOnce(Effect.succeed(true)); + await turnStart("2", "second"); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + expect(harness.refreshProjectEnvironment).toHaveBeenCalledWith({ + threadId: ThreadId.make("thread-1"), + cwd: expect.any(String), + }); + expect(harness.startSession.mock.calls.length).toBe(2); + expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({ + resumeCursor: { opaque: "resume-1" }, + }); + expect(harness.stopSession.mock.calls.length).toBe(0); + }); + it("restarts an existing Codex thread on a compatible requested instance", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index f6794135a925..23d381349b12 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -779,13 +779,20 @@ const make = Effect.gen(function* () { requestedModelSelection !== undefined && !Equal.equals(previousModelSelection, requestedModelSelection); - if ( - !runtimeModeChanged && - !cwdChanged && - !instanceChanged && - !shouldRestartForModelChange && - !shouldRestartForModelSelectionChange - ) { + const otherRestartReason = + runtimeModeChanged || + cwdChanged || + instanceChanged || + shouldRestartForModelChange || + shouldRestartForModelSelectionChange; + // A restart re-evaluates direnv anyway; only a reused session needs the + // shell-hook style check for an allowed, edited or newly added `.envrc`. + const projectEnvironmentChanged = + !otherRestartReason && effectiveCwd !== undefined + ? yield* providerService.refreshProjectEnvironment({ threadId, cwd: effectiveCwd }) + : false; + + if (!otherRestartReason && !projectEnvironmentChanged) { yield* refreshWorkspaceSnapshot; return existingSessionThreadId; } @@ -810,6 +817,7 @@ const make = Effect.gen(function* () { instanceChanged, shouldRestartForModelChange, shouldRestartForModelSelectionChange, + projectEnvironmentChanged, hasResumeCursor: resumeCursor !== undefined, }); const restartedSession = yield* startProviderSession( diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 9b5b56309749..10d06777ecfc 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -148,6 +148,7 @@ function createProviderServiceHarness() { }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), + refreshProjectEnvironment: () => Effect.succeed(false), get streamEvents() { return Stream.fromPubSub(runtimeEventPubSub).pipe( Stream.flatMap(({ events, enqueued }) => diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index 072ac5110b9d..8f52dff77eba 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -611,6 +611,7 @@ export function runtimeEventToActivities( payload: { message: truncateDetail(event.payload.message), ...(event.payload.detail !== undefined ? { detail: event.payload.detail } : {}), + ...(event.payload.action !== undefined ? { action: event.payload.action } : {}), }, turnId: toTurnId(event.turnId) ?? null, ...maybeSequence, diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts index 36bb5b649f06..3bd4efdca330 100644 --- a/apps/server/src/processRunner.ts +++ b/apps/server/src/processRunner.ts @@ -24,6 +24,8 @@ export interface ProcessRunInput { readonly spawnCwd?: string | undefined; readonly timeout?: Duration.Input | undefined; readonly env?: NodeJS.ProcessEnv | undefined; + /** Merge `env` over the server's environment. Defaults to true when `env` is set. */ + readonly extendEnv?: boolean | undefined; readonly stdin?: string | undefined; /** Receives every stdout chunk, including bytes beyond the buffered output limit. */ readonly onStdoutChunk?: ((chunk: Uint8Array) => void) | undefined; @@ -296,7 +298,7 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* ( const maxOutputBytes = input.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES; const outputMode = input.outputMode ?? "error"; const truncatedMarker = input.truncatedMarker ?? ""; - const extendEnv = input.env !== undefined; + const extendEnv = input.extendEnv ?? input.env !== undefined; const spawnCommand = yield* resolveSpawnCommand( input.command, input.args, diff --git a/apps/server/src/provider/DirenvEnvironment.test.ts b/apps/server/src/provider/DirenvEnvironment.test.ts new file mode 100644 index 000000000000..de6993f9ec21 --- /dev/null +++ b/apps/server/src/provider/DirenvEnvironment.test.ts @@ -0,0 +1,236 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, describe, expect, it } from "@effect/vitest"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner"; +import { vi } from "vite-plus/test"; + +import * as ProcessRunner from "../processRunner.ts"; +import * as DirenvEnvironment from "./DirenvEnvironment.ts"; + +const runMock = vi.fn(); + +const TestLayer = DirenvEnvironment.layer.pipe( + Layer.provide( + Layer.succeed(ProcessRunner.ProcessRunner, ProcessRunner.ProcessRunner.of({ run: runMock })), + ), + Layer.provide(Layer.succeed(HostProcessPlatform, "linux")), + Layer.provideMerge(NodeServices.layer), +); + +const output = ( + overrides: Partial, +): ProcessRunner.ProcessRunOutput => ({ + stdout: "", + stderr: "", + code: ChildProcessSpawner.ExitCode(0), + timedOut: false, + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + ...overrides, +}); + +/** A project root holding an `.envrc`, and a nested directory a thread might run in. */ +const makeProject = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const root = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-" }); + const nested = path.join(root, "packages", "app"); + yield* fileSystem.makeDirectory(nested, { recursive: true }); + yield* fileSystem.writeFileString(path.join(root, ".envrc"), "use flake\n"); + return { envrcPath: path.join(root, ".envrc"), nested }; +}); + +afterEach(() => { + runMock.mockReset(); +}); + +describe("DirenvEnvironment.load", () => { + it.effect("skips direnv when no .envrc governs the directory", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const cwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-direnv-" }); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + expect(yield* direnv.load(cwd)).toEqual({ _tag: "None" }); + expect(runMock).not.toHaveBeenCalled(); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("loads the exported variables from the nearest parent .envrc", () => + Effect.gen(function* () { + const { nested } = yield* makeProject; + runMock.mockReturnValueOnce( + Effect.succeed(output({ stdout: '{"PATH":"/nix/store/x/bin:/usr/bin","OLD":null}' })), + ); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + const result = yield* direnv.load(nested); + + expect(result).toEqual({ + _tag: "Loaded", + diff: { PATH: "/nix/store/x/bin:/usr/bin", OLD: null }, + changed: true, + }); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ command: "direnv", args: ["export", "json"], cwd: nested }), + ); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("reports a blocked .envrc instead of loading it", () => + Effect.gen(function* () { + const { envrcPath, nested } = yield* makeProject; + runMock.mockReturnValueOnce( + Effect.succeed( + output({ + // direnv still prints its own bookkeeping variables when blocked. + stdout: '{"DIRENV_DIR":"-/project"}', + stderr: `\u001b[31mdirenv: error ${envrcPath} is blocked. Run \`direnv allow\` to approve its content\u001b[0m\n`, + code: ChildProcessSpawner.ExitCode(1), + }), + ), + ); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + const result = yield* direnv.load(nested); + + expect(result).toEqual({ + _tag: "Failed", + envrcPath, + reason: "blocked", + message: `${envrcPath} is blocked. Run \`direnv allow\` to approve its content`, + }); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("treats a missing direnv binary as no project environment", () => + Effect.gen(function* () { + const { nested } = yield* makeProject; + runMock.mockReturnValueOnce( + Effect.fail( + new ProcessRunner.ProcessSpawnError({ + command: "direnv", + argumentCount: 2, + cause: new Error("spawn direnv ENOENT"), + }), + ), + ); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + expect(yield* direnv.load(nested)).toEqual({ _tag: "None" }); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("reports a timed-out load", () => + Effect.gen(function* () { + const { nested } = yield* makeProject; + runMock.mockReturnValueOnce(Effect.succeed(output({ code: null, timedOut: true }))); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + const result = yield* direnv.load(nested); + + expect(result).toMatchObject({ _tag: "Failed", reason: "timeout" }); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("checks staleness against exactly the previously loaded environment", () => + Effect.gen(function* () { + const { nested } = yield* makeProject; + const previous = { PATH: "/nix/store/old/bin", STALE: null, DIRENV_DIFF: "v1" }; + runMock.mockReturnValueOnce(Effect.succeed(output({ stdout: "" }))); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + const result = yield* direnv.load(nested, previous); + + expect(result).toEqual({ _tag: "Loaded", diff: previous, changed: false }); + const input = runMock.mock.calls[0]?.[0]; + expect(input?.extendEnv).toBe(false); + expect(input?.env?.PATH).toBe("/nix/store/old/bin"); + expect(input?.env && "STALE" in input.env).toBe(false); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("keeps the diff relative to the server environment after a change", () => + Effect.gen(function* () { + const { nested } = yield* makeProject; + const previous = { PATH: "/nix/store/old/bin", FOO: "1", DIRENV_DIFF: "v1" }; + runMock.mockReturnValueOnce( + Effect.succeed(output({ stdout: '{"PATH":"/nix/store/new/bin","DIRENV_DIFF":"v2"}' })), + ); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + expect(yield* direnv.load(nested, previous)).toEqual({ + _tag: "Loaded", + diff: { PATH: "/nix/store/new/bin", FOO: "1", DIRENV_DIFF: "v2" }, + changed: true, + }); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); + + it.effect("shares one evaluation between concurrent loads of a directory", () => + Effect.gen(function* () { + const { nested } = yield* makeProject; + const release = yield* Deferred.make(); + runMock.mockReturnValueOnce( + Deferred.await(release).pipe(Effect.as(output({ stdout: '{"FOO":"bar"}' }))), + ); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + const first = yield* Effect.forkChild(direnv.load(nested), { startImmediately: true }); + const second = yield* Effect.forkChild(direnv.load(nested), { startImmediately: true }); + yield* Deferred.succeed(release, undefined); + + expect(yield* Fiber.join(first)).toEqual(yield* Fiber.join(second)); + expect(runMock).toHaveBeenCalledTimes(1); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); +}); + +describe("DirenvEnvironment.allow", () => { + it.effect("allows the .envrc governing the directory", () => + Effect.gen(function* () { + const { envrcPath, nested } = yield* makeProject; + runMock.mockReturnValueOnce(Effect.succeed(output({}))); + const direnv = yield* DirenvEnvironment.DirenvEnvironment; + + expect(yield* direnv.allow(nested)).toEqual({ _tag: "Allowed", envrcPath }); + expect(runMock).toHaveBeenCalledWith( + expect.objectContaining({ command: "direnv", args: ["allow", envrcPath] }), + ); + }).pipe(Effect.scoped, Effect.provide(TestLayer)), + ); +}); + +describe("applyDirenvEnvironment", () => { + const serverEnv = { PATH: "/usr/bin", HOME: "/home/user", STALE: "1" }; + + it("sets and unsets variables over the server environment", () => { + expect( + DirenvEnvironment.applyDirenvEnvironment( + serverEnv, + { PATH: "/nix/store/x/bin:/usr/bin", STALE: null, IN_NIX_SHELL: "impure" }, + serverEnv, + ), + ).toEqual({ PATH: "/nix/store/x/bin:/usr/bin", HOME: "/home/user", IN_NIX_SHELL: "impure" }); + }); + + it("keeps values the provider instance configured itself", () => { + const instanceEnv = { ...serverEnv, CLAUDE_CONFIG_DIR: "/home/user/.claude-work" }; + + expect( + DirenvEnvironment.applyDirenvEnvironment( + instanceEnv, + { CLAUDE_CONFIG_DIR: "/project/.claude", FOO: "bar" }, + serverEnv, + ), + ).toEqual({ ...instanceEnv, FOO: "bar" }); + }); +}); diff --git a/apps/server/src/provider/DirenvEnvironment.ts b/apps/server/src/provider/DirenvEnvironment.ts new file mode 100644 index 000000000000..54b1375bac35 --- /dev/null +++ b/apps/server/src/provider/DirenvEnvironment.ts @@ -0,0 +1,246 @@ +import type { ThreadId } from "@t3tools/contracts"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; + +import * as ProcessRunner from "../processRunner.ts"; + +/** Variables to set (string) or unset (null), as `direnv export json` reports them. */ +export type DirenvEnvironmentDiff = Readonly>; + +export type DirenvFailureReason = "blocked" | "timeout" | "failed"; + +export type DirenvLoadResult = + /** No `.envrc` governs the directory, or direnv is not installed. */ + | { readonly _tag: "None" } + | { + readonly _tag: "Loaded"; + /** The full diff over the server's environment, bookkeeping variables included. */ + readonly diff: DirenvEnvironmentDiff; + /** False when direnv reported `previous` as still current. */ + readonly changed: boolean; + } + | { + readonly _tag: "Failed"; + readonly envrcPath: string; + readonly reason: DirenvFailureReason; + readonly message: string; + }; + +export type DirenvAllowResult = + | { readonly _tag: "Allowed"; readonly envrcPath: string } + | { readonly _tag: "NotFound" } + | { readonly _tag: "Failed"; readonly message: string }; + +/** + * Loads the direnv environment a shell would get in a directory, so provider + * sessions see the same project tools (Nix dev shells, devenv, mise, ...) a + * terminal does. Trust stays with direnv: a blocked `.envrc` is reported and + * only allowed when the user asks for it. + */ +export class DirenvEnvironment extends Context.Service< + DirenvEnvironment, + { + /** + * Load the environment for `cwd`. With `previous` (a diff this service + * returned earlier), this is the shell hook's cheap staleness check: direnv + * compares the files it watches, such as `.envrc`, its allow record and + * `flake.lock`, and only re-evaluates when one changed. + */ + readonly load: ( + cwd: string, + previous?: DirenvEnvironmentDiff, + ) => Effect.Effect; + /** `direnv allow` the `.envrc` governing `cwd`. */ + readonly allow: (cwd: string) => Effect.Effect; + } +>()("t3/provider/DirenvEnvironment") {} + +// A first `nix develop` evaluation can legitimately take minutes. +const DIRENV_TIMEOUT = "5 minutes"; + +const decodeDirenvExport = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Record(Schema.String, Schema.NullOr(Schema.String))), +); + +const ANSI_PATTERN = new RegExp(String.raw`\u001b\[[0-9;]*m`, "g"); + +/** The `direnv: error ...` lines, or the last stderr line when there are none. */ +export function direnvErrorMessage(stderr: string): string { + const lines = stderr + .replace(ANSI_PATTERN, "") + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const errors = lines.filter((line) => line.startsWith("direnv: error")); + return (errors.length > 0 ? errors.join("\n") : (lines.at(-1) ?? "direnv failed")).replace( + /^direnv: (error )?/, + "", + ); +} + +export const make = Effect.fn("DirenvEnvironment.make")(function* () { + const runner = yield* ProcessRunner.ProcessRunner; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + + const findEnvrc = Effect.fn("DirenvEnvironment.findEnvrc")(function* (cwd: string) { + let directory = path.resolve(cwd); + while (true) { + const candidate = path.join(directory, ".envrc"); + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return Option.some(candidate); + } + const parent = path.dirname(directory); + if (parent === directory) return Option.none(); + directory = parent; + } + }); + + const loadUncached = Effect.fn("DirenvEnvironment.load")(function* ( + cwd: string, + previous: DirenvEnvironmentDiff | undefined, + ) { + if (platform === "win32") return { _tag: "None" } as const; + const envrcPath = yield* findEnvrc(cwd); + // Without a previous load there is nothing for direnv to unload either. + if (Option.isNone(envrcPath) && previous === undefined) return { _tag: "None" } as const; + + const failed = (reason: DirenvFailureReason, message: string) => + ({ + _tag: "Failed", + envrcPath: Option.getOrElse(envrcPath, () => path.join(cwd, ".envrc")), + reason, + message, + }) as const; + const result = yield* runner + .run({ + command: "direnv", + args: ["export", "json"], + cwd, + // direnv diffs against the environment it is given, so a staleness + // check must see exactly what the previous load produced. + ...(previous + ? { env: applyDirenvEnvironment(process.env, previous), extendEnv: false } + : {}), + timeout: DIRENV_TIMEOUT, + timeoutBehavior: "timedOutResult", + }) + .pipe(Effect.result); + if (result._tag === "Failure") { + // Having an `.envrc` without direnv installed is common in shared repos. + return result.failure._tag === "ProcessSpawnError" + ? ({ _tag: "None" } as const) + : failed("failed", result.failure.message); + } + const output = result.success; + if (output.timedOut) + return failed("timeout", `direnv did not finish within ${DIRENV_TIMEOUT}.`); + if (output.code !== 0) { + const message = direnvErrorMessage(output.stderr); + return failed(message.includes(" is blocked") ? "blocked" : "failed", message); + } + // Empty output means the environment is already current for this directory. + if (output.stdout.trim().length === 0) { + return { _tag: "Loaded", diff: previous ?? {}, changed: false } as const; + } + const delta = decodeDirenvExport(output.stdout); + if (Option.isNone(delta)) { + return failed("failed", "direnv printed an environment T3 Code could not read."); + } + // `delta` is relative to the previous environment; keep the diff relative + // to the server's own environment. + return { _tag: "Loaded", diff: { ...previous, ...delta.value }, changed: true } as const; + }); + + // Threads started together in one checkout share a single evaluation. + const inFlight = new Map>(); + const load = (cwd: string, previous?: DirenvEnvironmentDiff) => + Effect.suspend(() => { + // DIRENV_DIFF identifies the environment a staleness check starts from. + const key = `${path.resolve(cwd)}\0${previous?.DIRENV_DIFF ?? ""}`; + const existing = inFlight.get(key); + if (existing) return Deferred.await(existing); + const deferred = Deferred.makeUnsafe(); + inFlight.set(key, deferred); + return loadUncached(cwd, previous).pipe( + Deferred.into(deferred), + Effect.ensuring(Effect.sync(() => inFlight.delete(key))), + Effect.andThen(Deferred.await(deferred)), + ); + }); + + const allow = Effect.fn("DirenvEnvironment.allow")(function* (cwd: string) { + const envrcPath = yield* findEnvrc(cwd); + if (Option.isNone(envrcPath)) return { _tag: "NotFound" } as const; + const result = yield* runner + .run({ command: "direnv", args: ["allow", envrcPath.value], cwd }) + .pipe(Effect.result); + if (result._tag === "Failure") { + return { _tag: "Failed", message: result.failure.message } as const; + } + return result.success.code === 0 + ? ({ _tag: "Allowed", envrcPath: envrcPath.value } as const) + : ({ _tag: "Failed", message: direnvErrorMessage(result.success.stderr) } as const); + }); + + return DirenvEnvironment.of({ load, allow }); +}); + +export const layer = Layer.effect(DirenvEnvironment, make()); + +/** + * Apply a direnv diff over a provider's launch environment. Values the + * provider instance set itself (anything differing from the server's own + * environment, like `CODEX_HOME` or `CLAUDE_CONFIG_DIR`) are explicit user + * configuration and win over the project's `.envrc`. + */ +export function applyDirenvEnvironment( + base: NodeJS.ProcessEnv, + diff: DirenvEnvironmentDiff | undefined, + serverEnv: NodeJS.ProcessEnv = process.env, +): NodeJS.ProcessEnv { + if (!diff) return base; + const next: NodeJS.ProcessEnv = { ...base }; + for (const [name, value] of Object.entries(diff)) { + if (base[name] !== serverEnv[name]) continue; + if (value === null) delete next[name]; + else next[name] = value; + } + return next; +} + +// ProviderService loads the diff before starting a session; adapters read it +// while building the provider's launch environment. +const diffsByThread = new Map(); + +export function setThreadDirenvEnvironment( + threadId: ThreadId, + diff: DirenvEnvironmentDiff | undefined, +): void { + if (diff && Object.keys(diff).length > 0) diffsByThread.set(threadId, diff); + else diffsByThread.delete(threadId); +} + +export function readThreadDirenvEnvironment(threadId: ThreadId): DirenvEnvironmentDiff | undefined { + return diffsByThread.get(threadId); +} + +export function clearAllThreadDirenvEnvironments(): void { + diffsByThread.clear(); +} + +/** `base` with the thread's direnv environment applied, or `base` untouched. */ +export function withThreadDirenvEnvironment( + base: NodeJS.ProcessEnv, + threadId: ThreadId, +): NodeJS.ProcessEnv { + return applyDirenvEnvironment(base, diffsByThread.get(threadId)); +} diff --git a/apps/server/src/provider/Drivers/AntigravityDriver.ts b/apps/server/src/provider/Drivers/AntigravityDriver.ts index fb9c7041b5fd..262d7ed5bbb8 100644 --- a/apps/server/src/provider/Drivers/AntigravityDriver.ts +++ b/apps/server/src/provider/Drivers/AntigravityDriver.ts @@ -1,4 +1,5 @@ import { withAgentDeviceEnvironment } from "../../mcp/McpProviderSession.ts"; +import { applyDirenvEnvironment } from "../DirenvEnvironment.ts"; import { AntigravitySettings, ProviderDriverKind, ProviderSetupError } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { @@ -233,7 +234,10 @@ export const AntigravityDriver: ProviderDriver 0 ? { extraArgs } : {}), ...(mcpSession diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index baa8d846f7c6..f8c010c0eae5 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -48,6 +48,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { ProviderAdapterRequestError, @@ -2275,6 +2276,13 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ? getCodexServiceTierOptionValue(input.modelSelection) : undefined; const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); + const environment = + options?.environment || DirenvEnvironment.readThreadDirenvEnvironment(input.threadId) + ? DirenvEnvironment.withThreadDirenvEnvironment( + options?.environment ?? process.env, + input.threadId, + ) + : undefined; const runtimeInput: CodexSessionRuntimeOptions = { threadId: input.threadId, providerInstanceId: boundInstanceId, @@ -2282,7 +2290,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( 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 +2304,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..8deff085382c 100644 --- a/apps/server/src/provider/Layers/CursorAdapter.ts +++ b/apps/server/src/provider/Layers/CursorAdapter.ts @@ -44,6 +44,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -547,14 +548,13 @@ export function makeCursorAdapter( const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeCursorAcpRuntime({ cursorSettings: effectiveCursorSettings, - ...(options?.environment || mcpSession?.agentDeviceEnvironment - ? { - environment: McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, - mcpSession, - ), - } - : {}), + environment: McpProviderSession.withAgentDeviceEnvironment( + DirenvEnvironment.withThreadDirenvEnvironment( + options?.environment ?? process.env, + input.threadId, + ), + mcpSession, + ), childProcessSpawner, cwd, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/GrokAdapter.ts b/apps/server/src/provider/Layers/GrokAdapter.ts index 601ce9ffa37e..93d73cd8330a 100644 --- a/apps/server/src/provider/Layers/GrokAdapter.ts +++ b/apps/server/src/provider/Layers/GrokAdapter.ts @@ -42,6 +42,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import { buildRuntimeInstructions } from "../RuntimeInstructions.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { ProviderAdapterProcessError, ProviderAdapterRequestError, @@ -996,14 +997,13 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); const acp = yield* makeGrokAcpRuntime({ grokSettings, - ...(options?.environment || mcpSession?.agentDeviceEnvironment - ? { - environment: McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, - mcpSession, - ), - } - : {}), + environment: McpProviderSession.withAgentDeviceEnvironment( + DirenvEnvironment.withThreadDirenvEnvironment( + options?.environment ?? process.env, + input.threadId, + ), + mcpSession, + ), childProcessSpawner, cwd, runtimeMode: input.runtimeMode, diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 04535edbd3af..dcfe812a472c 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -35,6 +35,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; import { ProviderAdapterProcessError, @@ -2858,7 +2859,10 @@ export function makeOpenCodeAdapter( serverUrl, ...(serverPassword ? { serverPassword } : {}), environment: McpProviderSession.withAgentDeviceEnvironment( - options?.environment ?? process.env, + DirenvEnvironment.withThreadDirenvEnvironment( + options?.environment ?? process.env, + input.threadId, + ), mcpSession, ), }); diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 242d0b55aa2f..4dfb6373db1f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -64,6 +64,7 @@ import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; import * as ProviderService from "../Services/ProviderService.ts"; import * as ProviderSessionDirectory from "../Services/ProviderSessionDirectory.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import { makeProviderServiceLive } from "./ProviderService.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderSessionDirectoryLive } from "./ProviderSessionDirectory.ts"; @@ -5221,3 +5222,219 @@ describe("agent browser access", () => { }).pipe(Effect.provide(NodeServices.layer)), ); }); + +describe("ProviderService direnv environment", () => { + const cwd = NodeOS.tmpdir(); + const envrcPath = `${cwd}/.envrc`; + const nixDiff = { PATH: "/nix/store/x/bin:/usr/bin", DIRENV_DIFF: "v1" }; + const blocked: DirenvEnvironment.DirenvLoadResult = { + _tag: "Failed", + envrcPath, + reason: "blocked", + message: `${envrcPath} is blocked. Run \`direnv allow\` to approve its content`, + }; + + /** + * Runs `body` against a live ProviderService whose direnv loader answers + * from `results` in order and records each call. + */ + const withProviderService = ( + results: ReadonlyArray, + body: (provider: ProviderService.ProviderService["Service"]) => Effect.Effect, + enableDirenvEnvironment = true, + ) => + Effect.gen(function* () { + const loads: Array<{ cwd: string; previous: unknown }> = []; + const remaining = [...results]; + const codex = makeFakeCodexAdapter(); + const providerLayer = makeProviderServiceLive().pipe( + Layer.provide( + Layer.succeed( + ProviderAdapterRegistry.ProviderAdapterRegistry, + makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter }), + ), + ), + Layer.provide( + ProviderSessionDirectoryLive.pipe( + Layer.provide( + ProviderSessionRuntime.layer.pipe(Layer.provide(SqlitePersistenceMemory)), + ), + ), + ), + Layer.provide( + Layer.succeed(DirenvEnvironment.DirenvEnvironment, { + load: (loadCwd, previous) => + Effect.sync(() => { + loads.push({ cwd: loadCwd, previous }); + return remaining.shift() ?? { _tag: "None" }; + }), + allow: () => Effect.die("unused"), + }), + ), + Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableDirenvEnvironment })), + Layer.provide(serverConfigTestLayer), + Layer.provide(AnalyticsService.layerTest), + Layer.provide( + Layer.succeed( + ProviderEventLoggers.ProviderEventLoggers, + ProviderEventLoggers.NoOpProviderEventLoggers, + ), + ), + ); + const value = yield* Effect.gen(function* () { + return yield* body(yield* ProviderService.ProviderService); + }).pipe(Effect.provide(providerLayer)); + return { value, loads }; + }); + + const start = (provider: ProviderService.ProviderService["Service"], threadId: ThreadId) => + provider.startSession(threadId, { + provider: CODEX_DRIVER, + providerInstanceId: codexInstanceId, + threadId, + cwd, + runtimeMode: "full-access", + }); + + const nextWarning = (provider: ProviderService.ProviderService["Service"], threadId: ThreadId) => + provider.streamEvents.pipe( + Stream.filter((event) => event.threadId === threadId && event.type === "runtime.warning"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); + + it.effect("hands the session cwd's direnv environment to the adapter", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-loaded"); + + const { value: diff, loads } = yield* withProviderService( + [{ _tag: "Loaded", diff: nixDiff, changed: true }], + (provider) => + start(provider, threadId).pipe( + // Read while the service is alive; shutting it down clears every thread. + Effect.map(() => DirenvEnvironment.readThreadDirenvEnvironment(threadId)), + ), + ); + + assert.deepEqual(loads, [{ cwd, previous: undefined }]); + assert.deepEqual(diff, nixDiff); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("skips direnv when the setting is off", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-disabled"); + + const { loads } = yield* withProviderService( + [{ _tag: "Loaded", diff: nixDiff, changed: true }], + (provider) => start(provider, threadId), + false, + ); + + assert.deepEqual(loads, []); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("warns with an allow action when the .envrc is blocked", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-blocked"); + + const { value } = yield* withProviderService([blocked], (provider) => + Effect.gen(function* () { + const warning = yield* nextWarning(provider, threadId); + yield* start(provider, threadId); + return { + warning: Option.getOrThrow(yield* Fiber.join(warning)), + diff: DirenvEnvironment.readThreadDirenvEnvironment(threadId), + }; + }), + ); + + assert.equal(value.diff, undefined); + assert.equal(value.warning.type, "runtime.warning"); + if (value.warning.type === "runtime.warning") { + assert.equal(value.warning.payload.detail, blocked._tag === "Failed" && blocked.message); + assert.deepEqual(value.warning.payload.action, { type: "direnv.allow" }); + } + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("picks up a later direnv allow without evaluating the .envrc twice", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-allowed-later"); + + const { value, loads } = yield* withProviderService( + [blocked, { _tag: "Loaded", diff: nixDiff, changed: true }], + (provider) => + Effect.gen(function* () { + yield* start(provider, threadId); + const changed = yield* provider.refreshProjectEnvironment({ threadId, cwd }); + // The reactor restarts the session when the environment changed. + yield* start(provider, threadId); + return { changed, diff: DirenvEnvironment.readThreadDirenvEnvironment(threadId) }; + }), + ); + + assert.equal(value.changed, true); + assert.deepEqual(value.diff, nixDiff); + assert.equal(loads.length, 2); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("checks a loaded environment against what the session started with", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-unchanged"); + + const { value: changed, loads } = yield* withProviderService( + [ + { _tag: "Loaded", diff: nixDiff, changed: true }, + { _tag: "Loaded", diff: nixDiff, changed: false }, + ], + (provider) => + start(provider, threadId).pipe( + Effect.andThen(provider.refreshProjectEnvironment({ threadId, cwd })), + ), + ); + + assert.equal(changed, false); + assert.deepEqual(loads[1], { cwd, previous: nixDiff }); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not repeat the warning when a blocked session restarts", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-restart-blocked"); + + const { value } = yield* withProviderService([blocked, blocked], (provider) => + Effect.gen(function* () { + yield* start(provider, threadId); + const warning = yield* nextWarning(provider, threadId); + yield* start(provider, threadId); + yield* Fiber.interrupt(warning); + return warning.pollUnsafe(); + }), + ); + + assert.equal(value?._tag === "Success" && Option.isSome(value.value), false); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("does not repeat the warning while the .envrc stays blocked", () => + Effect.gen(function* () { + const threadId = asThreadId("thread-direnv-still-blocked"); + + const { value } = yield* withProviderService([blocked, blocked], (provider) => + Effect.gen(function* () { + yield* start(provider, threadId); + const warning = yield* nextWarning(provider, threadId); + const changed = yield* provider.refreshProjectEnvironment({ threadId, cwd }); + yield* Fiber.interrupt(warning); + return { changed, warning: warning.pollUnsafe() }; + }), + ); + + assert.equal(value.changed, false); + assert.equal(value.warning?._tag === "Success" && Option.isSome(value.warning.value), false); + }).pipe(Effect.provide(NodeServices.layer)), + ); +}); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 12f8869da486..635f225dc353 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -75,6 +75,7 @@ import { ProviderValidationError, ProviderWorkspaceMissingError, } from "../Errors.ts"; +import * as DirenvEnvironment from "../DirenvEnvironment.ts"; import type { ProviderAdapterShape } from "../Services/ProviderAdapter.ts"; import * as ProviderAdapterRegistry from "../Services/ProviderAdapterRegistry.ts"; import * as ProviderService from "../Services/ProviderService.ts"; @@ -911,6 +912,162 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ), ); + const direnv = yield* Effect.serviceOption(DirenvEnvironment.DirenvEnvironment); + const direnvEnabled = Effect.fn("ProviderService.direnvEnabled")( + function* (threadId: ThreadId) { + const settings = yield* serverSettings.getSettings; + const overridden = Object.values(settings.projectSettingsOverrides).some( + (entry) => entry.enableDirenvEnvironment !== undefined, + ); + if (!overridden || Option.isNone(projectionQuery)) return settings.enableDirenvEnvironment; + const thread = yield* projectionQuery.value.getThreadShellById(threadId); + if (Option.isNone(thread)) return settings.enableDirenvEnvironment; + return resolveProjectSettings(settings, thread.value.projectId).settings + .enableDirenvEnvironment; + }, + Effect.catch((cause) => + Effect.logWarning( + "Could not read server settings; skipping the direnv environment for this session.", + { cause }, + ).pipe(Effect.as(false)), + ), + ); + + /** + * What the thread's session was started with. `fresh` marks a reload done + * by `refreshProjectEnvironment`, which the restart it triggers consumes + * instead of evaluating the `.envrc` a second time. + */ + type DirenvThreadState = + | { readonly status: "none"; readonly cwd: string } + | { + readonly status: "loaded"; + readonly cwd: string; + readonly diff: DirenvEnvironment.DirenvEnvironmentDiff; + readonly fresh: boolean; + } + | { readonly status: "failed"; readonly cwd: string; readonly message: string }; + const direnvStates = new Map(); + const clearDirenvState = (threadId: ThreadId) => { + direnvStates.delete(threadId); + DirenvEnvironment.setThreadDirenvEnvironment(threadId, undefined); + }; + const recordDirenvResult = ( + threadId: ThreadId, + cwd: string, + result: DirenvEnvironment.DirenvLoadResult, + fresh: boolean, + ) => { + switch (result._tag) { + case "None": + clearDirenvState(threadId); + direnvStates.set(threadId, { status: "none", cwd }); + return; + case "Loaded": + direnvStates.set(threadId, { status: "loaded", cwd, diff: result.diff, fresh }); + DirenvEnvironment.setThreadDirenvEnvironment(threadId, result.diff); + return; + case "Failed": + // A running session keeps whatever it was started with. + direnvStates.set(threadId, { status: "failed", cwd, message: result.message }); + return; + } + }; + + type DirenvFailure = Extract; + const DIRENV_WARNING_MESSAGES: Record = { + blocked: "The project's .envrc is blocked, so its direnv environment was not loaded.", + timeout: "Loading the project's direnv environment timed out.", + failed: "The project's direnv environment failed to load.", + }; + const publishDirenvWarning = ( + target: { + readonly threadId: ThreadId; + readonly provider: ProviderDriverKind; + readonly providerInstanceId?: ProviderInstanceId | undefined; + }, + failure: DirenvFailure | undefined, + ) => + failure === undefined + ? Effect.void + : Effect.gen(function* () { + const createdAt = yield* nowIso; + yield* publishRuntimeEvent({ + eventId: EventId.make(`direnv:${target.threadId}:${createdAt}`), + provider: target.provider, + ...(target.providerInstanceId ? { providerInstanceId: target.providerInstanceId } : {}), + threadId: target.threadId, + createdAt, + type: "runtime.warning", + payload: { + message: DIRENV_WARNING_MESSAGES[failure.reason], + detail: failure.message, + ...(failure.reason === "blocked" + ? { action: { type: "direnv.allow" as const } } + : {}), + }, + }); + }); + + /** + * Load the project's direnv environment for the adapter about to start a + * session in `cwd`. Returns a failure to publish once the session exists. + */ + const prepareDirenvEnvironment = Effect.fn("ProviderService.prepareDirenvEnvironment")(function* ( + threadId: ThreadId, + cwd: string | undefined, + ) { + const current = direnvStates.get(threadId); + if (current?.status === "loaded" && current.fresh && current.cwd === cwd) { + direnvStates.set(threadId, { ...current, fresh: false }); + return undefined; + } + clearDirenvState(threadId); + if (Option.isNone(direnv) || cwd === undefined) return undefined; + if (!(yield* direnvEnabled(threadId))) return undefined; + const result = yield* direnv.value.load(cwd); + yield* Effect.annotateCurrentSpan({ "provider.direnv.result": result._tag }); + recordDirenvResult(threadId, cwd, result, false); + if (result._tag !== "Failed") return undefined; + // Sessions restart for many reasons; the thread already shows this failure. + const repeated = current?.status === "failed" && current.message === result.message; + return repeated ? undefined : result; + }); + + const refreshProjectEnvironment: ProviderServiceMethod<"refreshProjectEnvironment"> = Effect.fn( + "ProviderService.refreshProjectEnvironment", + )(function* (input) { + const { threadId, cwd } = input; + const current = direnvStates.get(threadId); + if (Option.isNone(direnv)) return false; + if (!(yield* direnvEnabled(threadId))) { + // Turned off mid-thread: restart without what the session loaded. + const hadEnvironment = current?.status === "loaded"; + clearDirenvState(threadId); + return hadEnvironment; + } + const previous = current?.status === "loaded" && current.cwd === cwd ? current.diff : undefined; + const result = yield* direnv.value.load(cwd, previous); + yield* Effect.annotateCurrentSpan({ "provider.direnv.refresh": result._tag }); + if (result._tag === "Failed") { + const repeated = current?.status === "failed" && current.message === result.message; + recordDirenvResult(threadId, cwd, result, false); + if (repeated) return false; + const binding = Option.getOrUndefined( + yield* directory.getBinding(threadId).pipe(Effect.orElseSucceed(() => Option.none())), + ); + if (binding) yield* publishDirenvWarning(binding, result); + return false; + } + if (result._tag === "None") { + recordDirenvResult(threadId, cwd, result, false); + return current?.status === "loaded"; + } + if (!result.changed) return false; + recordDirenvResult(threadId, cwd, result, true); + return true; + }); + const agentAccessCapabilities = Effect.fn("ProviderService.agentAccessCapabilities")(function* ( threadId: ThreadId, ) { @@ -1278,6 +1435,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const persistedModelSelection = readPersistedModelSelection(input.binding.runtimePayload); yield* prepareMcpSession(input.binding.threadId, bindingInstanceId); + const direnvFailure = yield* prepareDirenvEnvironment(input.binding.threadId, persistedCwd); const resumed = yield* adapter .startSession({ threadId: input.binding.threadId, @@ -1301,6 +1459,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( { ...resumed, providerInstanceId: bindingInstanceId }, input.binding.threadId, ); + yield* publishDirenvWarning(resumed, direnvFailure); yield* analytics.record("provider.session.recovered", { provider: resumed.provider, strategy: "resume-thread", @@ -1509,6 +1668,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const adapter = yield* registry.getByInstance(resolvedInstanceId); yield* clearTurnAnalyticsSession(resolvedInstanceId, threadId); yield* prepareMcpSession(threadId, resolvedInstanceId); + const direnvFailure = yield* prepareDirenvEnvironment(threadId, effectiveCwd); const session = yield* adapter .startSession({ ...input, @@ -1537,6 +1697,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* upsertSessionBinding(sessionWithInstance, threadId, { modelSelection: input.modelSelection, }); + yield* publishDirenvWarning(sessionWithInstance, direnvFailure); yield* analytics.record("provider.session.started", { provider: sessionWithInstance.provider, runtimeMode: input.runtimeMode, @@ -2076,6 +2237,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( timedOutNativeCompactions.delete(input.threadId); yield* clearTurnAnalyticsSession(routed.instanceId, input.threadId); yield* clearMcpSession(input.threadId); + clearDirenvState(input.threadId); yield* directory.upsert({ threadId: input.threadId, provider: routed.adapter.provider, @@ -2369,6 +2531,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); McpProviderSession.clearAllMcpProviderSessions(); + direnvStates.clear(); + DirenvEnvironment.clearAllThreadDirenvEnvironments(); // Stopped rows stay for their resume cursors, so long-lived installs hold // thousands. Only rewrite the ones this shutdown actually stops. const bindings = yield* directory.listBindings().pipe( @@ -2426,6 +2590,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( assertConversationRollbackSupported, rollbackConversation, uploadFeedback, + refreshProjectEnvironment, // Each access creates a fresh PubSub subscription so that multiple // consumers (ProviderRuntimeIngestion, CheckpointReactor, etc.) each // independently receive all runtime events. diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 7b1fec90f867..fa71f2446d27 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -215,6 +215,7 @@ describe("ProviderSessionReaper", () => { }, rollbackConversation: () => unsupported(), uploadFeedback: () => unsupported(), + refreshProjectEnvironment: () => Effect.succeed(false), streamEvents: Stream.empty, }; diff --git a/apps/server/src/provider/Services/ProviderService.ts b/apps/server/src/provider/Services/ProviderService.ts index c189e2916ff1..ab10b79c5a53 100644 --- a/apps/server/src/provider/Services/ProviderService.ts +++ b/apps/server/src/provider/Services/ProviderService.ts @@ -128,6 +128,16 @@ export interface ProviderServiceShape { input: ProviderUploadFeedbackInput, ) => Effect.Effect; + /** + * Re-check the thread's direnv environment before a turn, as a shell hook + * would on each prompt. True when it changed (for example the `.envrc` was + * edited or allowed) and the session should restart to pick it up. + */ + readonly refreshProjectEnvironment: (input: { + readonly threadId: ThreadId; + readonly cwd: string; + }) => Effect.Effect; + /** * Canonical provider runtime event stream. * diff --git a/apps/server/src/provider/acp/AntigravityAcpSupport.ts b/apps/server/src/provider/acp/AntigravityAcpSupport.ts index f2f370068181..6606e6915bd0 100644 --- a/apps/server/src/provider/acp/AntigravityAcpSupport.ts +++ b/apps/server/src/provider/acp/AntigravityAcpSupport.ts @@ -22,6 +22,7 @@ import { makeAntigravityStderrHandler, makeAntigravityStdoutTransform, } from "../antigravityAuthSupport.ts"; +import type { DirenvEnvironmentDiff } from "../DirenvEnvironment.ts"; import * as AcpSessionRuntime from "./AcpSessionRuntime.ts"; import { normalizeAntigravitySessionUpdate } from "./AntigravityProtocol.ts"; @@ -37,6 +38,8 @@ export interface AntigravityAcpRuntimeInput extends Omit< > { /** Device CLI environment supplied for this provider session. */ readonly agentDeviceEnvironment?: Readonly>; + /** The project's direnv environment loaded for this provider session. */ + readonly direnvEnvironment?: DirenvEnvironmentDiff; 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 c22b48519da2..8808809b1f33 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -53,6 +53,7 @@ import * as ModelManifest from "./provider/ModelManifest.ts"; import * as ResetCreditCoordinator from "./provider/Layers/resetCreditCoordinator.ts"; import * as ProviderEventLoggers from "./provider/Layers/ProviderEventLoggers.ts"; import { ProviderServiceLive } from "./provider/Layers/ProviderService.ts"; +import * as DirenvEnvironment from "./provider/DirenvEnvironment.ts"; import { ProviderAuthServiceLive } from "./provider/Layers/ProviderAuthService.ts"; import { AntigravityInstallation } from "./provider/AntigravityInstallation.ts"; import { ProviderInstanceRegistry } from "./provider/Services/ProviderInstanceRegistry.ts"; @@ -285,6 +286,7 @@ const ProviderSessionDirectoryLayerLive = ProviderSessionDirectoryLive.pipe( // `ProviderService` and the per-instance drivers read the same logger pair. const ProviderLayerLive = ProviderServiceLive.pipe( Layer.provide(ProviderAdapterRegistryLive), + Layer.provideMerge(DirenvEnvironment.layer.pipe(Layer.provide(ProcessRunner.layer))), Layer.provideMerge(ProviderSessionDirectoryLayerLive), ); diff --git a/apps/server/src/serverRuntimeStartup.reconcile.test.ts b/apps/server/src/serverRuntimeStartup.reconcile.test.ts index 050650b11515..bbef18798804 100644 --- a/apps/server/src/serverRuntimeStartup.reconcile.test.ts +++ b/apps/server/src/serverRuntimeStartup.reconcile.test.ts @@ -69,6 +69,7 @@ const makeProviderService = (liveThreadIds: ReadonlyArray = []) => getInstanceInfo: () => Effect.die("unused"), rollbackConversation: () => Effect.die("unused"), uploadFeedback: () => Effect.die("unused"), + refreshProjectEnvironment: () => Effect.succeed(false), streamEvents: Stream.empty, }) satisfies ProviderService.ProviderService["Service"]; diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 077087a7d84e..a28481be6e67 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -102,6 +102,8 @@ import { } from "./orchestration/Normalizer.ts"; import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveThreadWorkspaceCwd } from "./checkpointing/Utils.ts"; +import * as DirenvEnvironment from "./provider/DirenvEnvironment.ts"; import { ThreadDeletionReactor } from "./orchestration/Services/ThreadDeletionReactor.ts"; import { observeRpcEffect as instrumentRpcEffect, @@ -506,6 +508,39 @@ const makeWsRpcLayer = ( const crypto = yield* Crypto.Crypto; const sql = yield* SqlClient.SqlClient; const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const direnvEnvironment = yield* Effect.serviceOption(DirenvEnvironment.DirenvEnvironment); + const allowThreadDirenv = Effect.fn("ws.allowThreadDirenv")(function* (threadId: ThreadId) { + const thread = Option.getOrUndefined( + yield* projectionSnapshotQuery + .getThreadShellById(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())), + ); + const project = thread + ? Option.getOrUndefined( + yield* projectionSnapshotQuery + .getProjectShellById(thread.projectId) + .pipe(Effect.orElseSucceed(() => Option.none())), + ) + : undefined; + const cwd = thread + ? resolveThreadWorkspaceCwd({ thread, projects: project ? [project] : [] }) + : undefined; + if (cwd === undefined) { + return { allowed: false, error: "The thread has no workspace." }; + } + if (Option.isNone(direnvEnvironment)) { + return { allowed: false, error: "direnv support is unavailable on this server." }; + } + const result = yield* direnvEnvironment.value.allow(cwd); + switch (result._tag) { + case "Allowed": + return { allowed: true }; + case "NotFound": + return { allowed: false, error: "No .envrc governs the thread's workspace." }; + case "Failed": + return { allowed: false, error: result.message }; + } + }); /** A reference's host-level link key; the project's own host where the ref names none. */ const resolvePullRequestSyncKey = (reference: PullRequestRef) => reference.host !== undefined && reference.repository.includes("/") @@ -3293,6 +3328,12 @@ const makeWsRpcLayer = ( .pipe(Effect.map((cancelled) => ({ cancelled }))), { "rpc.aggregate": "vcs" }, ), + [WS_METHODS.projectEnvironmentAllowDirenv]: (input) => + observeRpcEffect( + WS_METHODS.projectEnvironmentAllowDirenv, + allowThreadDirenv(input.threadId), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.vcsRefreshStatus]: (input) => observeRpcEffect( WS_METHODS.vcsRefreshStatus, diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index b2b46302dd25..4a69ae232480 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -3656,6 +3656,43 @@ describe("deriveMessagesTimelineRows", () => { ); }); +describe("actionable warnings", () => { + it("keeps a warning with a button visible instead of folding it into a group", () => { + const entry = (index: number, extra: Omit) => { + const createdAt = `2026-01-01T00:00:0${index}Z`; + return { + id: `work-entry-${index}`, + kind: "work" as const, + createdAt, + entry: { id: `work-${index}`, createdAt, ...extra }, + }; + }; + + const rows = deriveMessagesTimelineRows({ + timelineEntries: [ + entry(0, { + label: "The project's .envrc is blocked.", + tone: "info" as const, + sourceActivityKind: "runtime.warning" as const, + warningAction: { type: "direnv.allow" as const }, + }), + entry(1, { label: "Status updated", tone: "info" as const }), + entry(2, { label: "Status updated", tone: "info" as const }), + ], + isWorking: false, + activeTurnStartedAt: null, + turnDiffSummaries: [], + supportsConversationRollback: false, + }); + + expect(rows[0]).toMatchObject({ + kind: "work", + groupedEntries: [{ warningAction: { type: "direnv.allow" } }], + }); + expect(rows[1]).toMatchObject({ kind: "work-toggle", hiddenCount: 2 }); + }); +}); + describe("computeStableMessagesTimelineRows", () => { it("replaces a cached work toggle when its icon presentation changes", () => { const initialRow: MessagesTimelineRow = { diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 21508a9663ae..25bb07a841d9 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -1221,7 +1221,9 @@ export function deriveMessagesTimelineRows(input: { if ( timelineEntry.entry.agentSpawn !== undefined || timelineEntry.entry.questionAnswer !== undefined || - timelineEntry.entry.tone === "error" + timelineEntry.entry.tone === "error" || + // A warning with a button must stay visible, not fold into a group. + timelineEntry.entry.warningAction !== undefined ) { const spawn = timelineEntry.entry.agentSpawn; if (spawn && entryBelongsToActiveTurn(timelineEntry, index)) { @@ -1249,6 +1251,7 @@ export function deriveMessagesTimelineRows(input: { nextEntry.entry.questionAnswer !== undefined || nextEntry.entry.sourceActivityKind === "context-compaction" || nextEntry.entry.tone === "error" || + nextEntry.entry.warningAction !== undefined || activeWorkEntryIds.has(nextEntry.id) || collapsedEntryIds.has(nextEntry.id) || foldsByAnchorEntryId.has(nextEntry.id) diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f812f67f2bd4..0a70bba7005d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -137,6 +137,9 @@ import type { KnownComposerContextRecord, } from "@t3tools/contracts"; import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; +import { threadEnvironment } from "../../state/threads"; +import { useAtomCommand } from "../../state/use-atom-command"; import type { QueuedComposerMessage } from "../../queuedMessageStore"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; @@ -4721,6 +4724,60 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ); }); +/** Allows the thread's blocked `.envrc`; the next message loads it. */ +// The virtualized timeline remounts rows, so the outcome outlives the button. +const allowedDirenvWarnings = new Set(); + +function AllowDirenvButton({ + threadRef, + warningId, +}: { + readonly threadRef: ScopedThreadRef; + readonly warningId: string; +}) { + const allowDirenv = useAtomCommand(threadEnvironment.allowDirenv); + const [state, setState] = useState<"idle" | "pending" | "allowed">(() => + allowedDirenvWarnings.has(warningId) ? "allowed" : "idle", + ); + const onClick = async (event: MouseEvent) => { + event.stopPropagation(); + setState("pending"); + const result = await allowDirenv({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag === "Success" && result.value.allowed) { + allowedDirenvWarnings.add(warningId); + setState("allowed"); + return; + } + setState("idle"); + if (result._tag === "Success") { + toastManager.add({ + type: "error", + title: "Could not allow the .envrc", + description: result.value.error, + }); + } + }; + return ( + + ); +} + const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { workEntry: TimelineWorkEntry; workspaceRoot: string | undefined; @@ -4887,6 +4944,9 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { !toolIconAcceptsTint(entryIconName, entryToolIcon) ? ( ) : null} + {workEntry.warningAction?.type === "direnv.allow" && threadRef ? ( + + ) : null} void) { DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks ? ["Provider update checks"] : []), + ...(settings.enableDirenvEnvironment !== DEFAULT_UNIFIED_SETTINGS.enableDirenvEnvironment + ? ["Load direnv environment"] + : []), ...(settings.continueThreadsAfterServerUpdate !== DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate ? ["Continue threads after restarts"] @@ -680,6 +683,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.panelAnimationDurationMs, settings.responseStreamingMode, settings.enableProviderUpdateChecks, + settings.enableDirenvEnvironment, settings.continueThreadsAfterServerUpdate, settings.sidebarAutoSettleAfterDays, settings.sidebarAutoSettleOnMerge, @@ -785,6 +789,7 @@ export function useSettingsRestore(onRestored?: () => void) { sidebarAutoSettleOnMerge: DEFAULT_UNIFIED_SETTINGS.sidebarAutoSettleOnMerge, responseStreamingMode: DEFAULT_UNIFIED_SETTINGS.responseStreamingMode, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, + enableDirenvEnvironment: DEFAULT_UNIFIED_SETTINGS.enableDirenvEnvironment, continueThreadsAfterServerUpdate: DEFAULT_UNIFIED_SETTINGS.continueThreadsAfterServerUpdate, backgroundActivity: DEFAULT_UNIFIED_SETTINGS.backgroundActivity, backgroundActivityProfile: DEFAULT_UNIFIED_SETTINGS.backgroundActivityProfile, @@ -2840,6 +2845,36 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + enableDirenvEnvironment: DEFAULT_UNIFIED_SETTINGS.enableDirenvEnvironment, + }) + } + /> + ) : null + } + control={ + + updateSettings({ enableDirenvEnvironment: Boolean(checked) }) + } + aria-label="Load direnv environment" + /> + } + /> + ( scheduler, concurrency, }), + allowDirenv: createEnvironmentRpcCommand(runtime, { + label: "environment-data:commands:thread:allow-direnv", + tag: WS_METHODS.projectEnvironmentAllowDirenv, + }), }; const optimistic = createOptimisticThreadLifecycle(snapshotAtom); return { diff --git a/packages/client-runtime/src/work-log/presentation.test.ts b/packages/client-runtime/src/work-log/presentation.test.ts index a0909117ad39..dfa9899f6f1f 100644 --- a/packages/client-runtime/src/work-log/presentation.test.ts +++ b/packages/client-runtime/src/work-log/presentation.test.ts @@ -4,6 +4,7 @@ import { ThreadId } from "@t3tools/contracts"; import { commandDetailRepeatsCommand, + readRuntimeWarningAction, extractCommandOutputText, resolveViewedImageAsset, resolveWorkEntryToolPresentation, @@ -710,3 +711,23 @@ describe("device group summaries", () => { ).toBe("Used 1 tool"); }); }); + +describe("readRuntimeWarningAction", () => { + it("reads the action a runtime warning offers", () => { + expect( + readRuntimeWarningAction("runtime.warning", { + message: "The project's .envrc is blocked.", + action: { type: "direnv.allow" }, + }), + ).toEqual({ type: "direnv.allow" }); + }); + + it("ignores unknown actions and other activity kinds", () => { + expect( + readRuntimeWarningAction("runtime.warning", { action: { type: "rm.rf" } }), + ).toBeUndefined(); + expect( + readRuntimeWarningAction("runtime.error", { action: { type: "direnv.allow" } }), + ).toBeUndefined(); + }); +}); diff --git a/packages/client-runtime/src/work-log/presentation.ts b/packages/client-runtime/src/work-log/presentation.ts index 9f9f049d29fe..ad706f7eecd0 100644 --- a/packages/client-runtime/src/work-log/presentation.ts +++ b/packages/client-runtime/src/work-log/presentation.ts @@ -1,5 +1,6 @@ import { isToolLifecycleItemType, + RuntimeWarningAction, type AssetResource, type RuntimeItemStatus, type ThreadId, @@ -10,6 +11,7 @@ import { classifyMarkdownImageSource } from "@t3tools/client-runtime/markdown-im import { resolveMediaSource } from "@t3tools/client-runtime/media-source"; import { parseChangeRequestUrl } from "@t3tools/shared/changeRequestUrl"; import { isWorkspaceImagePreviewPath } from "@t3tools/shared/filePreview"; +import * as Schema from "effect/Schema"; /** * Activities the worktree setup card already represents. The settled record @@ -24,6 +26,20 @@ export function isWorktreeSetupActivity(kind: string): boolean { ); } +const isRuntimeWarningAction = Schema.is(RuntimeWarningAction); + +/** The button a `runtime.warning` activity offers, if any. */ +export function readRuntimeWarningAction( + kind: string, + payload: unknown, +): RuntimeWarningAction | undefined { + if (kind !== "runtime.warning" || typeof payload !== "object" || payload === null) { + return undefined; + } + const action = (payload as { readonly action?: unknown }).action; + return isRuntimeWarningAction(action) ? action : undefined; +} + export type WorkLogToolLifecycleStatus = RuntimeItemStatus | "stopped"; export interface WorkLogPresentationEntry { diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 978a0459e69b..15f2d4a033d7 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -44,3 +44,4 @@ export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; export * from "./rpc.ts"; export * from "./worktreeSetup.ts"; +export * from "./projectEnvironment.ts"; diff --git a/packages/contracts/src/projectEnvironment.ts b/packages/contracts/src/projectEnvironment.ts new file mode 100644 index 000000000000..37a229245303 --- /dev/null +++ b/packages/contracts/src/projectEnvironment.ts @@ -0,0 +1,18 @@ +import * as Schema from "effect/Schema"; + +import { ThreadId } from "./baseSchemas.ts"; + +/** + * `direnv allow` the `.envrc` governing a thread's workspace. The server + * resolves the directory from the thread, so a client cannot name an + * arbitrary path. The next message picks the environment up. + */ +export const ProjectEnvironmentAllowDirenvInput = Schema.Struct({ threadId: ThreadId }); +export type ProjectEnvironmentAllowDirenvInput = typeof ProjectEnvironmentAllowDirenvInput.Type; + +export const ProjectEnvironmentAllowDirenvResult = Schema.Struct({ + allowed: Schema.Boolean, + /** Why nothing was allowed. */ + error: Schema.optional(Schema.String), +}); +export type ProjectEnvironmentAllowDirenvResult = typeof ProjectEnvironmentAllowDirenvResult.Type; diff --git a/packages/contracts/src/providerRuntime.ts b/packages/contracts/src/providerRuntime.ts index 309b61935485..8c5aa24fbc16 100644 --- a/packages/contracts/src/providerRuntime.ts +++ b/packages/contracts/src/providerRuntime.ts @@ -801,9 +801,17 @@ const ToolDeniedPayload = Schema.Struct({ }); export type ToolDeniedPayload = typeof ToolDeniedPayload.Type; +/** Something the user can do about a warning, rendered as a button on its row. */ +export const RuntimeWarningAction = Schema.Struct({ + /** `direnv allow` the thread's `.envrc` (`projectEnvironment.allowDirenv`). */ + type: Schema.Literal("direnv.allow"), +}); +export type RuntimeWarningAction = typeof RuntimeWarningAction.Type; + const RuntimeWarningPayload = Schema.Struct({ message: TrimmedNonEmptyStringSchema, detail: Schema.optional(Schema.Unknown), + action: Schema.optional(RuntimeWarningAction), }); export type RuntimeWarningPayload = typeof RuntimeWarningPayload.Type; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index dbc143048a72..0caa1eb37eaa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -48,6 +48,10 @@ import { AttachmentDeleteInput, AttachmentUploadSigningKeyError, } from "./assets.ts"; +import { + ProjectEnvironmentAllowDirenvInput, + ProjectEnvironmentAllowDirenvResult, +} from "./projectEnvironment.ts"; import { WorktreeSetupCancelInput, WorktreeSetupCancelResult, @@ -433,6 +437,7 @@ export const WS_METHODS = { subscribeVcsStatus: "subscribeVcsStatus", subscribeWorktreeSetup: "subscribeWorktreeSetup", worktreeSetupCancel: "worktreeSetup.cancel", + projectEnvironmentAllowDirenv: "projectEnvironment.allowDirenv", subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", @@ -1038,6 +1043,12 @@ const WsWorktreeSetupCancelRpc = Rpc.make(WS_METHODS.worktreeSetupCancel, { error: EnvironmentAuthorizationError, }); +const WsProjectEnvironmentAllowDirenvRpc = Rpc.make(WS_METHODS.projectEnvironmentAllowDirenv, { + payload: ProjectEnvironmentAllowDirenvInput, + success: ProjectEnvironmentAllowDirenvResult, + error: EnvironmentAuthorizationError, +}); + const WsGitRunStackedActionRpc = Rpc.make(WS_METHODS.gitRunStackedAction, { payload: GitRunStackedActionInput, success: GitActionProgressEvent, @@ -1480,6 +1491,7 @@ export const WsRpcGroup = RpcGroup.make( WsSubscribeVcsStatusRpc, WsSubscribeWorktreeSetupRpc, WsWorktreeSetupCancelRpc, + WsProjectEnvironmentAllowDirenvRpc, WsVcsPullRpc, WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index e8dc37bfc839..c2adf8d21e61 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1014,6 +1014,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ "defaultProjectScripts", "enableAgentBrowserAccess", "enableAgentDeviceAccess", + "enableDirenvEnvironment", "textGenerationModelSelection", "sourceControlWriterModelSelection", "sourceControlWritingStyle", @@ -1041,6 +1042,7 @@ export const ProjectSettingsOverrides = Schema.Struct({ defaultProjectScripts: Schema.optionalKey(Schema.Array(ProjectScript)), enableAgentBrowserAccess: Schema.optionalKey(Schema.Boolean), enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), + enableDirenvEnvironment: Schema.optionalKey(Schema.Boolean), textGenerationModelSelection: Schema.optionalKey(ModelSelection), sourceControlWriterModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)), sourceControlWritingStyle: Schema.optionalKey(SourceControlWritingStyleSettings), @@ -1155,6 +1157,12 @@ export const ServerSettings = Schema.Struct({ * unaffected. */ enableAgentDeviceAccess: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + /** + * Whether provider sessions load the project's direnv environment (`.envrc`), + * as a shell with the direnv hook would. Only `.envrc` files the user has + * already approved with `direnv allow` are loaded. + */ + enableDirenvEnvironment: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(true))), /** * Whether this server may install and run T3's device helper processes. * Kept separate from agent access so enabling the user's Device panel does @@ -1500,6 +1508,7 @@ export const ServerSettingsPatch = Schema.Struct({ Schema.Record(ProjectId, Schema.NullOr(ProjectSettingsOverrides)), ), enableAgentDeviceAccess: Schema.optionalKey(Schema.Boolean), + enableDirenvEnvironment: Schema.optionalKey(Schema.Boolean), enableDeviceSupport: Schema.optionalKey(Schema.Boolean), deviceOnboardingCompleted: Schema.optionalKey(Schema.Boolean), deviceHosts: Schema.optionalKey(SshDeviceHostConfigs),