From 8320a646f9e24c03f8e81c4df567d8e5e34ee945 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sat, 26 Sep 2026 00:19:46 -0300 Subject: [PATCH 1/3] feat(server): agents start with the project's direnv environment Projects that provide their toolchain through direnv (Nix dev shells, devenv, mise) only worked in integrated terminals: agents never go through a shell, so they ran without the project's tools. Provider sessions now load the `.envrc` governing their workspace, as a shell with the direnv hook would, for every provider. Trust stays with direnv: only `.envrc` files approved with `direnv allow` are loaded, and a blocked or failing one surfaces as a thread warning while the agent starts without it. Before each message on a reused session, the server runs direnv's own staleness check against the environment the session started with. It is cheap when nothing changed, and when the user allowed or edited the `.envrc` or a watched file such as `flake.lock` changed, the session resumes with the new environment instead of requiring a new thread. Concurrent loads of one checkout share a single evaluation, since a first `nix develop` can take minutes. A blocked `.envrc` warning offers "Allow .envrc" on web and mobile, backed by a `projectEnvironment.allowDirenv` RPC that resolves the path from the thread. The behavior is on by default and can be turned off per environment or project with "Load direnv environment". --- .../SettingsServerControlsRouteScreen.tsx | 10 + .../src/features/threads/ThreadFeed.tsx | 4 + .../src/features/threads/thread-work-log.tsx | 48 +++- apps/mobile/src/lib/threadActivity.ts | 6 + ...ProviderSessionStartup.integration.test.ts | 1 + apps/server/src/auth/RpcAuthorization.ts | 1 + .../Layers/CheckpointReactor.test.ts | 1 + .../Layers/ProviderCommandReactor.test.ts | 44 ++++ .../Layers/ProviderCommandReactor.ts | 22 +- .../Layers/ProviderRuntimeIngestion.test.ts | 1 + .../Layers/ProviderRuntimeIngestion.ts | 1 + apps/server/src/processRunner.ts | 4 +- .../src/provider/DirenvEnvironment.test.ts | 236 +++++++++++++++++ apps/server/src/provider/DirenvEnvironment.ts | 246 ++++++++++++++++++ .../src/provider/Drivers/AntigravityDriver.ts | 6 +- .../src/provider/Layers/AntigravityAdapter.ts | 5 + .../src/provider/Layers/ClaudeAdapter.ts | 6 +- .../src/provider/Layers/CodexAdapter.ts | 12 +- .../src/provider/Layers/CursorAdapter.ts | 16 +- .../server/src/provider/Layers/GrokAdapter.ts | 16 +- .../src/provider/Layers/OpenCodeAdapter.ts | 6 +- .../provider/Layers/ProviderService.test.ts | 199 ++++++++++++++ .../src/provider/Layers/ProviderService.ts | 162 ++++++++++++ .../Layers/ProviderSessionReaper.test.ts | 1 + .../src/provider/Services/ProviderService.ts | 10 + .../src/provider/acp/AntigravityAcpSupport.ts | 3 + apps/server/src/server.ts | 2 + .../serverRuntimeStartup.reconcile.test.ts | 1 + apps/server/src/ws.ts | 41 +++ .../src/components/chat/MessagesTimeline.tsx | 45 ++++ .../components/settings/SettingsPanels.tsx | 35 +++ .../src/components/settings/settingsSearch.ts | 7 + apps/web/src/session-logic.ts | 8 + docs/user/project-settings.md | 17 ++ .../src/state/threadCommands.ts | 4 + .../src/work-log/presentation.test.ts | 21 ++ .../src/work-log/presentation.ts | 16 ++ packages/contracts/src/index.ts | 1 + packages/contracts/src/projectEnvironment.ts | 18 ++ packages/contracts/src/providerRuntime.ts | 8 + packages/contracts/src/rpc.ts | 12 + packages/contracts/src/settings.ts | 9 + 42 files changed, 1282 insertions(+), 30 deletions(-) create mode 100644 apps/server/src/provider/DirenvEnvironment.test.ts create mode 100644 apps/server/src/provider/DirenvEnvironment.ts create mode 100644 packages/contracts/src/projectEnvironment.ts 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..3e83c860ce2a 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,43 @@ function workLogRowKey(row: ThreadFeedActivity): string { return row.id; } +/** Allows the thread's blocked `.envrc`; the next message loads it. */ +function AllowDirenvButton(props: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; +}) { + const allowDirenv = useAtomCommand(threadEnvironment.allowDirenv, "allow direnv"); + const [state, setState] = useState<"idle" | "pending" | "allowed">("idle"); + return ( + { + setState("pending"); + const result = await allowDirenv({ + environmentId: props.environmentId, + input: { threadId: props.threadId }, + }); + if (result._tag === "Success" && result.value.allowed) { + 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" : state === "pending" ? "Allowing…" : "Allow .envrc"} + + + ); +} + const ThreadWorkLogRow = memo(function ThreadWorkLogRow( props: Omit< ThreadWorkLogProps, @@ -846,6 +889,9 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( )} + {row.workEntry.warningAction?.type === "direnv.allow" ? ( + + ) : null} {props.copied ? ( Copied diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index 8f000b1b55fc..bf0dff127944 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 @@ -574,6 +578,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; } 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..0871bb3a1e11 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,201 @@ 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 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..29ab06cf67de 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,159 @@ 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); + return result._tag === "Failed" ? result : undefined; + }); + + 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 +1432,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 +1456,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 +1665,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 +1694,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 +2234,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 +2528,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 +2587,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.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index f812f67f2bd4..d97941c58a56 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,45 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { ); }); +/** Allows the thread's blocked `.envrc`; the next message loads it. */ +function AllowDirenvButton({ threadRef }: { readonly threadRef: ScopedThreadRef }) { + const allowDirenv = useAtomCommand(threadEnvironment.allowDirenv); + const [state, setState] = useState<"idle" | "pending" | "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) { + 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 +4929,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), From ba4797ab9a2d720a9fbd7cc2b987757ca4641e34 Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sat, 26 Sep 2026 00:33:50 -0300 Subject: [PATCH 2/3] fix: keep the direnv allow button visible and warn once per thread Testing the draft in a real client showed two problems. The timeline folded a lone warning into a collapsed work group on web and mobile, which hid its "Allow .envrc" button behind a click; warnings that offer an action now stay on their own row, like errors and answered questions do. Sessions also restart for reasons unrelated to the environment (an interrupted turn, a Claude model selection), and each restart re-published the same blocked-.envrc warning. Session start now skips a failure identical to the one the thread already shows, as the per-message check did. --- apps/mobile/src/lib/threadActivity.test.ts | 51 +++++++++++++++++++ apps/mobile/src/lib/threadActivity.ts | 21 +++++--- .../provider/Layers/ProviderService.test.ts | 18 +++++++ .../src/provider/Layers/ProviderService.ts | 5 +- .../chat/MessagesTimeline.logic.test.ts | 37 ++++++++++++++ .../components/chat/MessagesTimeline.logic.ts | 5 +- 6 files changed, 129 insertions(+), 8 deletions(-) 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 bf0dff127944..87a99c95fe29 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -283,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 { @@ -1590,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(); } @@ -1717,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), ); @@ -1908,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", ) @@ -2075,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/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 0871bb3a1e11..4dfb6373db1f 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -5401,6 +5401,24 @@ describe("ProviderService direnv environment", () => { }).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"); diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 29ab06cf67de..635f225dc353 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -1028,7 +1028,10 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( const result = yield* direnv.value.load(cwd); yield* Effect.annotateCurrentSpan({ "provider.direnv.result": result._tag }); recordDirenvResult(threadId, cwd, result, false); - return result._tag === "Failed" ? result : undefined; + 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( 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) From 1c37efe17175e0124da206fd3c51a2cca07fadcd Mon Sep 17 00:00:00 2001 From: Otavio Salvador Date: Sat, 26 Sep 2026 01:03:32 -0300 Subject: [PATCH 3/3] fix(web): the direnv allow button says when it applies and survives remounts In a real client the button read "Allowed" directly above the agent's earlier "NOT SET", which looked like a failure, and it reverted to "Allow .envrc" whenever the virtualized timeline remounted the row. The label now says the environment applies to the next message, and the outcome is kept per warning so it survives remounts on web and mobile. --- .../src/features/threads/thread-work-log.tsx | 21 +++++++++++++--- .../src/components/chat/MessagesTimeline.tsx | 25 +++++++++++++++---- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx index 3e83c860ce2a..9f91196b700a 100644 --- a/apps/mobile/src/features/threads/thread-work-log.tsx +++ b/apps/mobile/src/features/threads/thread-work-log.tsx @@ -737,12 +737,18 @@ function workLogRowKey(row: ThreadFeedActivity): string { } /** 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">("idle"); + const [state, setState] = useState<"idle" | "pending" | "allowed">(() => + allowedDirenvWarnings.has(props.warningId) ? "allowed" : "idle", + ); return ( - {state === "allowed" ? "Allowed" : state === "pending" ? "Allowing…" : "Allow .envrc"} + {state === "allowed" + ? "Allowed · applies to your next message" + : state === "pending" + ? "Allowing…" + : "Allow .envrc"} ); @@ -890,7 +901,11 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow( {row.workEntry.warningAction?.type === "direnv.allow" ? ( - + ) : null} {props.copied ? ( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index d97941c58a56..0a70bba7005d 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -4725,9 +4725,20 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: { }); /** Allows the thread's blocked `.envrc`; the next message loads it. */ -function AllowDirenvButton({ threadRef }: { readonly threadRef: ScopedThreadRef }) { +// 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">("idle"); + const [state, setState] = useState<"idle" | "pending" | "allowed">(() => + allowedDirenvWarnings.has(warningId) ? "allowed" : "idle", + ); const onClick = async (event: MouseEvent) => { event.stopPropagation(); setState("pending"); @@ -4736,6 +4747,7 @@ function AllowDirenvButton({ threadRef }: { readonly threadRef: ScopedThreadRef input: { threadId: threadRef.threadId }, }); if (result._tag === "Success" && result.value.allowed) { + allowedDirenvWarnings.add(warningId); setState("allowed"); return; } @@ -4754,11 +4766,14 @@ function AllowDirenvButton({ threadRef }: { readonly threadRef: ScopedThreadRef onClick={onClick} onKeyDown={(event) => event.stopPropagation()} size="xs" - title={state === "allowed" ? "Loads with your next message" : undefined} type="button" variant="ghost-muted" > - {state === "allowed" ? "Allowed" : state === "pending" ? "Allowing…" : "Allow .envrc"} + {state === "allowed" + ? "Allowed · applies to your next message" + : state === "pending" + ? "Allowing…" + : "Allow .envrc"} ); } @@ -4930,7 +4945,7 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { ) : null} {workEntry.warningAction?.type === "direnv.allow" && threadRef ? ( - + ) : null}