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