diff --git a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
index 9fdee211b0a7..d7dc1b14e170 100644
--- a/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
+++ b/apps/mobile/src/features/settings/SettingsServerControlsRouteScreen.tsx
@@ -389,6 +389,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
+
+ write({ enableDirenvEnvironment: value })}
+ />
+
>
) : null}
diff --git a/apps/mobile/src/features/threads/thread-work-log.tsx b/apps/mobile/src/features/threads/thread-work-log.tsx
index 826b9205ac75..82d43d308a6a 100644
--- a/apps/mobile/src/features/threads/thread-work-log.tsx
+++ b/apps/mobile/src/features/threads/thread-work-log.tsx
@@ -33,6 +33,7 @@ import {
} from "react";
import {
AccessibilityInfo,
+ Alert,
AppState,
type ColorValue,
Pressable,
@@ -41,7 +42,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";
@@ -81,6 +82,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;
@@ -770,6 +773,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,
@@ -972,6 +1023,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
)}
+ {row.projectedItem.item.type === "system_notice" &&
+ row.projectedItem.item.action?.type === "direnv.allow" ? (
+
+ ) : null}
{props.copied ? (
Copied
diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts
index 086fa6b30c46..072c95a579bf 100644
--- a/apps/server/src/auth/RpcAuthorization.ts
+++ b/apps/server/src/auth/RpcAuthorization.ts
@@ -149,6 +149,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-v2/Adapters/AcpAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
index 4c4de1f7aeb0..6c86e163e93f 100644
--- a/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
@@ -63,6 +63,7 @@ import {
type AcpMcpOverAcpBridge,
} from "../../mcp/AcpMcpOverAcpBridge.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import * as DirenvEnvironment from "../../provider/DirenvEnvironment.ts";
import {
applyAcpAgentTerminalUpdate,
acpContentBlockDisplayText,
@@ -138,6 +139,8 @@ export interface AcpAdapterV2RuntimeInput {
readonly acpMcpServers?: ReadonlyArray;
/** Scoped credentials for terminal fallback when an ACP agent drops `mcpServers`. */
readonly processEnvironment?: NodeJS.ProcessEnv;
+ /** The project's direnv environment, for flavors to apply to the agent's launch environment. */
+ readonly direnvEnvironment?: DirenvEnvironment.DirenvEnvironmentDiff;
readonly resumeSessionId?: string;
readonly interruptPromptOnCancel?: boolean;
readonly clientCapabilities: EffectAcpSchema.InitializeRequest["clientCapabilities"];
@@ -1496,7 +1499,10 @@ export function makeAcpAdapterV2(
: yield* makeAcpClientTerminals({
spawner: options.clientTerminals.childProcessSpawner,
defaultCwd: input.runtimePolicy.cwd ?? process.cwd(),
- environment: options.clientTerminals.environment,
+ environment: DirenvEnvironment.withThreadDirenvEnvironment(
+ options.clientTerminals.environment ?? process.env,
+ input.threadId,
+ ),
shellCommands: options.clientTerminals.shellCommands,
environmentForSession: (sessionId) => {
const remembered = terminalEnvironmentBySessionId.get(sessionId);
@@ -1995,6 +2001,9 @@ export function makeAcpAdapterV2(
handleRuntimeTerminationAtGeneration(runtimeGeneration),
): AcpAdapterV2RuntimeInput => {
const mcpContext = acpMcpContext(threadId, self);
+ // The launch environment belongs to the session, which loaded it for
+ // the thread that opened it.
+ const direnvEnvironment = DirenvEnvironment.readThreadDirenvEnvironment(input.threadId);
return {
cwd: input.runtimePolicy.cwd ?? process.cwd(),
runtimePolicy: input.runtimePolicy,
@@ -2003,6 +2012,7 @@ export function makeAcpAdapterV2(
...(mcpContext.processEnvironment === undefined
? {}
: { processEnvironment: mcpContext.processEnvironment }),
+ ...(direnvEnvironment === undefined ? {} : { direnvEnvironment }),
...(resumeSessionId === undefined ? {} : { resumeSessionId }),
interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false,
clientCapabilities: {
diff --git a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts
index 76326b274528..3f03b9c5ff6f 100644
--- a/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts
@@ -32,6 +32,7 @@ import * as AcpRegistryRuntimeCoordinator from "../../provider/acp/AcpRegistryRu
import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
import { makeAcpNativeLoggerFactory } from "../../provider/acp/AcpNativeLogging.ts";
import * as ProviderEventLoggers from "../../provider/Layers/ProviderEventLoggers.ts";
+import { applyDirenvEnvironment } from "../../provider/DirenvEnvironment.ts";
import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
import * as IdAllocator from "../IdAllocator.ts";
import { makeProviderFailure } from "../ProviderFailure.ts";
@@ -146,7 +147,7 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) {
Crypto.Crypto | Scope.Scope
> =>
Effect.gen(function* () {
- const { processEnvironment, ...runtimeInput } = input;
+ const { processEnvironment, direnvEnvironment, ...runtimeInput } = input;
const resolved = yield* options.resolver
.resolve(options.settings, input.cwd, options.environment)
.pipe(
@@ -162,11 +163,17 @@ function makeAcpRegistryRuntime(options: AcpRegistryAdapterV2Options) {
AcpSessionRuntime.layer({
...runtimeInput,
spawn:
- processEnvironment === undefined
+ processEnvironment === undefined && direnvEnvironment === undefined
? resolved.spawn
: {
...resolved.spawn,
- env: { ...resolved.spawn.env, ...processEnvironment },
+ env: {
+ ...applyDirenvEnvironment(
+ resolved.spawn.env ?? options.environment,
+ direnvEnvironment,
+ ),
+ ...processEnvironment,
+ },
},
...(options.settings.authMethodId ? { authMethodId: options.settings.authMethodId } : {}),
}).pipe(
diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
index f6346b05d245..c8fbaeb89b2d 100644
--- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
@@ -111,6 +111,7 @@ import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanc
import { T3_CODE_ORCHESTRATION_INSTRUCTIONS } from "../../provider/T3OrchestrationInstructions.ts";
import { buildRuntimeInstructions } from "../../provider/RuntimeInstructions.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import * as DirenvEnvironment from "../../provider/DirenvEnvironment.ts";
import * as IdAllocator from "../IdAllocator.ts";
import { makeProviderFailure, makeProviderRetryTurnItem } from "../ProviderFailure.ts";
import { turnScopedSelectionTransition } from "../ProviderSelectionTransition.ts";
@@ -6559,7 +6560,10 @@ export function makeClaudeAdapterV2(
cwd: turnInput.runtimePolicy.cwd,
attachmentsDir,
settings: adapterOptions.settings,
- environment: adapterOptions.environment,
+ environment: DirenvEnvironment.withThreadDirenvEnvironment(
+ adapterOptions.environment,
+ turnInput.threadId,
+ ),
tools: queryPolicy.tools ?? CLAUDE_CODE_PRESET_TOOLS,
...mcpOverrides,
permissionMode: queryPolicy.permissionMode,
diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
index 7ceecc4f487f..99af7f412142 100644
--- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
@@ -108,6 +108,7 @@ import {
} from "../../provider/Layers/codexLaunchArgs.ts";
import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import * as DirenvEnvironment from "../../provider/DirenvEnvironment.ts";
import {
ProviderAdapterDriverCreateError,
type ProviderAdapterDriver,
@@ -1576,7 +1577,10 @@ export function makeCodexAdapterV2(adapterOptions: CodexAdapterV2Options): Provi
providerSessionId: input.providerSessionId,
runtimePolicy: input.runtimePolicy,
settings: resolvedRuntime?.config ?? adapterOptions.settings,
- environment: resolvedRuntime?.environment ?? adapterOptions.environment,
+ environment: DirenvEnvironment.withThreadDirenvEnvironment(
+ resolvedRuntime?.environment ?? adapterOptions.environment,
+ input.threadId,
+ ),
});
const additionalContextByThread = yield* Ref.make(
new Map<
diff --git a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
index 0357c4b95ab8..bb128e2e48b7 100644
--- a/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
@@ -49,6 +49,7 @@ import {
XAiAskUserQuestionRequest,
XAiExitPlanModeRequest,
} from "../../provider/acp/XAiAcpExtension.ts";
+import { applyDirenvEnvironment } from "../../provider/DirenvEnvironment.ts";
import { mergeProviderInstanceEnvironment } from "../../provider/ProviderInstanceEnvironment.ts";
import { acpPermissionDisposition } from "../../provider/acp/AcpClientPolicy.ts";
import * as AcpSessionRuntime from "../../provider/acp/AcpSessionRuntime.ts";
@@ -270,12 +271,12 @@ export function makeGrokAcpAdapterFlavor(options: GrokAdapterV2Options): AcpAdap
}),
makeRuntime:
options.makeRuntime ??
- (({ runtimePolicy, ...input }) =>
+ (({ runtimePolicy, direnvEnvironment, ...input }) =>
makeGrokAcpRuntime({
...input,
interruptPromptOnCancel: input.interruptPromptOnCancel ?? false,
grokSettings: options.settings,
- environment: options.environment,
+ environment: applyDirenvEnvironment(options.environment, direnvEnvironment),
childProcessSpawner: options.childProcessSpawner,
runtimeMode: grokLaunchRuntimeMode(runtimePolicy),
})),
diff --git a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts
index 0b781b5f82cf..9612b14a40e9 100644
--- a/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/OpenCodeAdapterV2.ts
@@ -58,6 +58,7 @@ import * as Stream from "effect/Stream";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import * as DirenvEnvironment from "../../provider/DirenvEnvironment.ts";
import type { EventNdjsonLogger } from "../../provider/Layers/EventNdjsonLogger.ts";
import { ProviderEventLoggers } from "../../provider/Layers/ProviderEventLoggers.ts";
import {
@@ -1029,7 +1030,10 @@ export function makeOpenCodeAdapterV2(options: OpenCodeAdapterV2Options): Provid
binaryPath: options.settings.binaryPath,
directory: cwd,
serverUrl: options.settings.serverUrl,
- environment: options.environment,
+ environment: DirenvEnvironment.withThreadDirenvEnvironment(
+ options.environment,
+ input.threadId,
+ ),
});
const client = runtime.createOpenCodeSdkClient({
baseUrl: connection.url,
diff --git a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts
index 88489a1f9040..8dddf78f2095 100644
--- a/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts
+++ b/apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts
@@ -62,6 +62,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
import { resolveAttachmentPath } from "../../attachmentStore.ts";
import * as ServerConfig from "../../config.ts";
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
+import * as DirenvEnvironment from "../../provider/DirenvEnvironment.ts";
import {
expandPiSkillReference,
parsePiCompactCommand,
@@ -414,7 +415,10 @@ export function makePiAdapterV2(
}
const launch = buildPiRpcLaunch({
launchArgs: resolvedLaunchArgs.args,
- environment: options.environment,
+ environment: DirenvEnvironment.withThreadDirenvEnvironment(
+ options.environment,
+ input.threadId,
+ ),
mcpSession,
extensionPath,
runtimeMode: input.runtimePolicy.runtimeMode,
diff --git a/apps/server/src/orchestration-v2/EffectWorker.test.ts b/apps/server/src/orchestration-v2/EffectWorker.test.ts
index af2667a8cf8e..973d15820fd6 100644
--- a/apps/server/src/orchestration-v2/EffectWorker.test.ts
+++ b/apps/server/src/orchestration-v2/EffectWorker.test.ts
@@ -105,6 +105,7 @@ function makeExecutorLayer(input: {
closeInstance: () => Effect.void,
release: () => record("release"),
detach: () => record("detach"),
+ takeProjectEnvironmentFailure: () => Effect.succeed(undefined),
}),
),
Layer.succeed(
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
index 63d4c57fb663..f21af77ea743 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.test.ts
@@ -31,6 +31,7 @@ import { TestClock } from "effect/testing";
import { HttpServer } from "effect/unstable/http";
import { ProviderWorkspaceMissingError } from "../provider/Errors.ts";
+import * as DirenvEnvironment from "../provider/DirenvEnvironment.ts";
import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as McpProviderSession from "../mcp/McpProviderSession.ts";
@@ -363,6 +364,7 @@ function makeTestLayer(input: {
readonly beforeUnload?: Effect.Effect;
readonly serverSettingsLayer?: ReturnType;
readonly projectServiceLayer?: Layer.Layer;
+ readonly direnvLayer?: Layer.Layer;
}) {
const configuredEventSinkLayer = input.failReleaseEventWrites
? FailingReleaseEventSinkLayer
@@ -404,6 +406,7 @@ function makeTestLayer(input: {
TestStoresLayer,
...(input.serverSettingsLayer === undefined ? [] : [input.serverSettingsLayer]),
...(input.projectServiceLayer === undefined ? [] : [input.projectServiceLayer]),
+ ...(input.direnvLayer === undefined ? [] : [input.direnvLayer]),
),
),
),
@@ -3126,3 +3129,167 @@ it.effect(
assert.isFalse(denied?.capabilities?.has("device"));
}),
);
+
+/**
+ * Runs `body` against a manager whose direnv loads answer from `results` in
+ * order, recording each load's `previous` diff and the environment each
+ * provider process opened with.
+ */
+function runDirenvScenario(
+ results: ReadonlyArray,
+ body: (input: {
+ readonly manager: ProviderSessionManager.ProviderSessionManagerV2["Service"];
+ readonly open: Effect.Effect<
+ ProviderAdapterV2SessionRuntime,
+ ProviderSessionManager.ProviderSessionManagerV2Error
+ >;
+ readonly threadId: ThreadId;
+ }) => Effect.Effect,
+) {
+ return Effect.gen(function* () {
+ const state = yield* Ref.make(emptyState);
+ const threadId = ThreadId.make("thread-provider-session-manager-direnv");
+ const loads = yield* Ref.make<
+ ReadonlyArray
+ >([]);
+ const openedWith = yield* Ref.make<
+ ReadonlyArray
+ >([]);
+ const direnvLayer = Layer.succeed(
+ DirenvEnvironment.DirenvEnvironment,
+ DirenvEnvironment.DirenvEnvironment.of({
+ load: (_cwd, previous) =>
+ Ref.modify(loads, (current) => [
+ results[Math.min(current.length, results.length - 1)]!,
+ [...current, previous],
+ ]),
+ allow: () => Effect.succeed({ _tag: "NotFound" }),
+ }),
+ );
+ const beforeOpen = () =>
+ Ref.update(openedWith, (current) => [
+ ...current,
+ DirenvEnvironment.readThreadDirenvEnvironment(threadId),
+ ]);
+
+ yield* Effect.gen(function* () {
+ const eventSink = yield* EventSink.EventSinkV2;
+ const idAllocator = yield* IdAllocator.IdAllocatorV2;
+ const manager = yield* ProviderSessionManager.ProviderSessionManagerV2;
+ const now = yield* DateTime.now;
+ const providerSessionId = yield* idAllocator.allocate.providerSession({
+ providerInstanceId: modelSelection.instanceId,
+ threadId,
+ });
+ yield* eventSink.write({
+ events: [yield* makeThreadCreatedEvent({ idAllocator, threadId, now })],
+ });
+ yield* body({
+ manager,
+ open: manager.open({ threadId, providerSessionId, modelSelection, runtimePolicy }),
+ threadId,
+ });
+ }).pipe(
+ Effect.provide(makeTestLayer({ state, idleTimeoutMs: 60_000, beforeOpen, direnvLayer })),
+ );
+ return {
+ state: yield* Ref.get(state),
+ loads: yield* Ref.get(loads),
+ openedWith: yield* Ref.get(openedWith),
+ };
+ });
+}
+
+it.effect("ProviderSessionManagerV2 opens a session with the project's direnv environment", () =>
+ Effect.gen(function* () {
+ const diff = { PATH: "/nix/store/tools/bin", DIRENV_DIFF: "d1" };
+ const result = yield* runDirenvScenario([{ _tag: "Loaded", diff, changed: true }], ({ open }) =>
+ open.pipe(Effect.asVoid),
+ );
+ assert.deepStrictEqual(result.openedWith, [diff]);
+ assert.deepStrictEqual(result.loads, [undefined]);
+ }),
+);
+
+it.effect("ProviderSessionManagerV2 keeps a session whose direnv environment is current", () =>
+ Effect.gen(function* () {
+ const diff = { PATH: "/nix/store/tools/bin", DIRENV_DIFF: "d1" };
+ const result = yield* runDirenvScenario(
+ [
+ { _tag: "Loaded", diff, changed: true },
+ { _tag: "Loaded", diff, changed: false },
+ ],
+ ({ open }) =>
+ Effect.gen(function* () {
+ const first = yield* open;
+ const second = yield* open;
+ assert.strictEqual(first, second);
+ }),
+ );
+ assert.equal(result.state.openCount, 1);
+ // The reuse is direnv's staleness check against what the session loaded.
+ assert.deepStrictEqual(result.loads, [undefined, diff]);
+ }),
+);
+
+it.effect(
+ "ProviderSessionManagerV2 restarts an idle session when its direnv environment changes",
+ () =>
+ Effect.gen(function* () {
+ const before = { PATH: "/nix/store/old/bin", DIRENV_DIFF: "d1" };
+ const after = { PATH: "/nix/store/new/bin", DIRENV_DIFF: "d2" };
+ const result = yield* runDirenvScenario(
+ [
+ { _tag: "Loaded", diff: before, changed: true },
+ { _tag: "Loaded", diff: after, changed: true },
+ ],
+ ({ open }) =>
+ Effect.gen(function* () {
+ const first = yield* open;
+ const second = yield* open;
+ assert.notStrictEqual(first, second);
+ }),
+ );
+ assert.equal(result.state.openCount, 2);
+ assert.deepStrictEqual(result.openedWith, [before, after]);
+ // The restart reuses the environment the check loaded instead of evaluating again.
+ assert.equal(result.loads.length, 2);
+ }),
+);
+
+it.effect(
+ "ProviderSessionManagerV2 restarts without an environment direnv no longer provides",
+ () =>
+ Effect.gen(function* () {
+ const diff = { PATH: "/nix/store/tools/bin", DIRENV_DIFF: "d1" };
+ const result = yield* runDirenvScenario(
+ [{ _tag: "Loaded", diff, changed: true }, { _tag: "None" }, { _tag: "None" }],
+ ({ open }) => Effect.all([open, open], { discard: true }),
+ );
+ assert.equal(result.state.openCount, 2);
+ assert.deepStrictEqual(result.openedWith, [diff, undefined]);
+ }),
+);
+
+it.effect("ProviderSessionManagerV2 reports a failing .envrc once per thread", () =>
+ Effect.gen(function* () {
+ const blocked: DirenvEnvironment.DirenvLoadResult = {
+ _tag: "Failed",
+ envrcPath: "/project/.envrc",
+ reason: "blocked",
+ message: "/project/.envrc is blocked. Run `direnv allow` to approve its content",
+ };
+ const result = yield* runDirenvScenario([blocked], ({ manager, open, threadId }) =>
+ Effect.gen(function* () {
+ yield* open;
+ assert.deepStrictEqual(yield* manager.takeProjectEnvironmentFailure(threadId), blocked);
+ assert.equal(yield* manager.takeProjectEnvironmentFailure(threadId), undefined);
+ // The per-message check sees the same failure; the thread already shows it.
+ yield* open;
+ assert.equal(yield* manager.takeProjectEnvironmentFailure(threadId), undefined);
+ }),
+ );
+ assert.equal(result.state.openCount, 1);
+ assert.deepStrictEqual(result.openedWith, [undefined]);
+ }),
+);
diff --git a/apps/server/src/orchestration-v2/ProviderSessionManager.ts b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
index 786eb5a6bf0a..f2c19db876f0 100644
--- a/apps/server/src/orchestration-v2/ProviderSessionManager.ts
+++ b/apps/server/src/orchestration-v2/ProviderSessionManager.ts
@@ -30,6 +30,7 @@ import * as Stream from "effect/Stream";
import { ProviderWorkspaceMissingError } from "../provider/Errors.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as McpProviderSession from "../mcp/McpProviderSession.ts";
+import * as DirenvEnvironment from "../provider/DirenvEnvironment.ts";
import * as ServerSettings from "../serverSettings.ts";
import * as McpSessionRegistry from "../mcp/McpSessionRegistry.ts";
import * as EventSink from "./EventSink.ts";
@@ -54,6 +55,7 @@ const UNLOAD_THREAD_TIMEOUT_MS = 10 * 1000;
export const ProviderSessionReleaseReason = Schema.Literals([
"idle_timeout",
+ "environment_changed",
"runtime_error",
"manual_shutdown",
"server_shutdown",
@@ -177,6 +179,14 @@ export interface ProviderSessionManagerV2Shape {
*/
readonly revokeMcpCredential?: boolean;
}) => Effect.Effect;
+ /**
+ * The project's direnv environment failed to load for the thread's latest
+ * `open`, and the thread has not been told yet. Consumed by the caller that
+ * can place a notice on the thread's timeline.
+ */
+ readonly takeProjectEnvironmentFailure: (
+ threadId: ThreadId,
+ ) => Effect.Effect;
}
export class ProviderSessionManagerV2 extends Context.Service<
@@ -209,6 +219,14 @@ interface LiveSessionEntry {
readonly idleFiber: Fiber.Fiber | null;
/** Set when idle release is deferred for pending background work; bounds total deferral. */
readonly pinnedSinceMs: number | null;
+ /** The direnv environment the provider process started with; null when none was loaded. */
+ readonly direnv: SessionDirenvEnvironment | null;
+}
+
+interface SessionDirenvEnvironment {
+ readonly cwd: string;
+ /** Undefined when direnv found nothing to load or the `.envrc` failed. */
+ readonly diff: DirenvEnvironment.DirenvEnvironmentDiff | undefined;
}
type ProviderSessionEventSignal =
@@ -359,6 +377,91 @@ export const layerWithOptions = (
);
},
);
+ const direnv = yield* Effect.serviceOption(DirenvEnvironment.DirenvEnvironment);
+ const direnvEnabled = Effect.fn("ProviderSessionManagerV2.direnvEnabled")(
+ function* (threadId: ThreadId) {
+ if (Option.isNone(serverSettings)) return true;
+ const settings = yield* serverSettings.value.getSettings;
+ const thread = yield* projectionStore.getThread(threadId);
+ return resolveProjectSettings(settings, thread.projectId).settings
+ .enableDirenvEnvironment;
+ },
+ Effect.catch((cause) =>
+ Effect.logWarning(
+ "Could not resolve the direnv setting; starting the provider without the project's environment.",
+ { cause },
+ ).pipe(Effect.as(false)),
+ ),
+ );
+ /** The failure each thread was last told about, so restarts do not repeat it. */
+ const reportedDirenvFailures = new Map();
+ const unreportedDirenvFailures = new Map();
+ const recordDirenvResult = (
+ threadId: ThreadId,
+ result: DirenvEnvironment.DirenvLoadResult,
+ ) => {
+ if (result._tag !== "Failed") {
+ reportedDirenvFailures.delete(threadId);
+ unreportedDirenvFailures.delete(threadId);
+ return;
+ }
+ if (reportedDirenvFailures.get(threadId) === result.message) return;
+ reportedDirenvFailures.set(threadId, result.message);
+ unreportedDirenvFailures.set(threadId, result);
+ };
+ /**
+ * Load the direnv environment for a provider process about to start in
+ * `cwd`. Adapters read it by thread while building their launch
+ * environment. `preloaded` is a diff `checkDirenvEnvironment` just loaded.
+ */
+ const prepareDirenvEnvironment = Effect.fn(
+ "ProviderSessionManagerV2.prepareDirenvEnvironment",
+ )(function* (
+ threadId: ThreadId,
+ cwd: string | null,
+ preloaded: DirenvEnvironment.DirenvEnvironmentDiff | undefined,
+ ) {
+ DirenvEnvironment.setThreadDirenvEnvironment(threadId, preloaded);
+ if (cwd === null) return null;
+ if (preloaded !== undefined) return { cwd, diff: preloaded } as SessionDirenvEnvironment;
+ if (Option.isNone(direnv) || !(yield* direnvEnabled(threadId))) return null;
+ const result = yield* direnv.value.load(cwd);
+ yield* Effect.annotateCurrentSpan({ "provider.direnv.result": result._tag });
+ recordDirenvResult(threadId, result);
+ const diff = result._tag === "Loaded" ? result.diff : undefined;
+ DirenvEnvironment.setThreadDirenvEnvironment(threadId, diff);
+ return { cwd, diff } as SessionDirenvEnvironment;
+ });
+ /**
+ * Re-check a live session's direnv environment before reusing it, as a
+ * shell hook does on each prompt: direnv compares the files it watches
+ * (the `.envrc`, its allow record, `flake.lock`, ...) and only evaluates
+ * again when one changed. `restart` means the process should start over
+ * with `diff`.
+ */
+ const checkDirenvEnvironment = Effect.fn("ProviderSessionManagerV2.checkDirenvEnvironment")(
+ function* (entry: LiveSessionEntry, threadId: ThreadId, cwd: string | null) {
+ const loaded = entry.direnv?.diff;
+ if (Option.isNone(direnv) || cwd === null) return { restart: false } as const;
+ if (!(yield* direnvEnabled(threadId))) return { restart: loaded !== undefined } as const;
+ const previous = entry.direnv?.cwd === cwd ? loaded : undefined;
+ const result = yield* direnv.value.load(cwd, previous);
+ yield* Effect.annotateCurrentSpan({ "provider.direnv.refresh": result._tag });
+ recordDirenvResult(threadId, result);
+ switch (result._tag) {
+ // A running session keeps whatever it started with.
+ case "Failed":
+ return { restart: false } as const;
+ case "None":
+ return { restart: loaded !== undefined } as const;
+ case "Loaded":
+ return result.changed
+ ? ({ restart: true, diff: result.diff } as const)
+ : ({ restart: false } as const);
+ }
+ },
+ );
+
const layerScope = yield* Effect.scope;
const sessions = yield* Ref.make(new Map());
const nextSubscriberId = yield* Ref.make(0);
@@ -1541,11 +1644,20 @@ export const layerWithOptions = (
),
{ discard: true },
);
+ DirenvEnvironment.clearAllThreadDirenvEnvironments();
+ reportedDirenvFailures.clear();
+ unreportedDirenvFailures.clear();
});
yield* Effect.addFinalizer(() => shutdown);
return ProviderSessionManagerV2.of({
shutdown,
+ takeProjectEnvironmentFailure: (threadId) =>
+ Effect.sync(() => {
+ const failure = unreportedDirenvFailures.get(threadId);
+ unreportedDirenvFailures.delete(threadId);
+ return failure;
+ }),
open: (input) =>
sessionOpen.withLock(
input.providerSessionId,
@@ -1565,6 +1677,7 @@ export const layerWithOptions = (
}
const key = sessionKey(input.providerSessionId);
const existing = (yield* Ref.get(sessions)).get(key);
+ let preloadedDirenv: DirenvEnvironment.DirenvEnvironmentDiff | undefined;
if (existing !== undefined) {
if (
!existing.attachedThreadIds.has(input.threadId) &&
@@ -1576,13 +1689,33 @@ export const layerWithOptions = (
cause: `Provider ${existing.runtime.driver} does not support attaching multiple app threads to one session.`,
});
}
- yield* ensureThreadAttached({
+ // Restarting the process would cut off work in flight or other
+ // threads sharing it; those pick the change up once it idles.
+ const ownedIdle =
+ existing.busyCount === 0 &&
+ existing.attachedThreadIds.size === 1 &&
+ existing.attachedThreadIds.has(input.threadId);
+ const direnvRefresh = ownedIdle
+ ? yield* checkDirenvEnvironment(existing, input.threadId, cwd)
+ : ({ restart: false } as const);
+ if (!direnvRefresh.restart) {
+ yield* ensureThreadAttached({
+ providerSessionId: input.providerSessionId,
+ threadId: input.threadId,
+ providerInstanceId: existing.runtime.instanceId,
+ });
+ yield* touchActivity(input.providerSessionId);
+ return existing.exposedRuntime;
+ }
+ yield* Effect.logInfo("orchestration-v2.provider-session.environment-changed", {
providerSessionId: input.providerSessionId,
threadId: input.threadId,
- providerInstanceId: existing.runtime.instanceId,
});
- yield* touchActivity(input.providerSessionId);
- return existing.exposedRuntime;
+ yield* releaseEntry({
+ providerSessionId: input.providerSessionId,
+ reason: "environment_changed",
+ });
+ preloadedDirenv = "diff" in direnvRefresh ? direnvRefresh.diff : undefined;
}
const adapter = yield* registry.get(input.modelSelection.instanceId).pipe(
@@ -1595,6 +1728,11 @@ export const layerWithOptions = (
}),
),
);
+ const sessionDirenv = yield* prepareDirenvEnvironment(
+ input.threadId,
+ cwd,
+ preloadedDirenv,
+ );
const prepared = yield* prepareMcpSession(
input.threadId,
input.modelSelection.instanceId,
@@ -1682,6 +1820,7 @@ export const layerWithOptions = (
lastActivityAtMs: now,
idleFiber: null,
pinnedSinceMs: null,
+ direnv: sessionDirenv,
};
yield* Ref.update(sessions, (current) => {
const updated = new Map(current);
@@ -1864,6 +2003,9 @@ export const layerWithOptions = (
// and the token must not outlive the thread.
if (input.revokeMcpCredential === true) {
yield* clearMcpSession(input.threadId);
+ DirenvEnvironment.setThreadDirenvEnvironment(input.threadId, undefined);
+ reportedDirenvFailures.delete(input.threadId);
+ unreportedDirenvFailures.delete(input.threadId);
}
if (Option.isNone(detached)) {
return;
diff --git a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
index eba830319aaf..182c95cd776f 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnControlService.test.ts
@@ -270,6 +270,7 @@ it.effect(
closeInstance: () => Effect.void,
release: () => Effect.void,
detach: () => Effect.void,
+ takeProjectEnvironmentFailure: () => Effect.succeed(undefined),
}),
);
const controlLayer = ProviderTurnControlService.layer.pipe(
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
index 746fbe235d99..c44c84687d5f 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts
@@ -487,7 +487,10 @@ function makeLocalCommandHarness(input: {
}),
),
}),
- Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({ open }),
+ Layer.mock(ProviderSessionManager.ProviderSessionManagerV2)({
+ open,
+ takeProjectEnvironmentFailure: () => Effect.succeed(undefined),
+ }),
Layer.mock(ProviderAuthService.ProviderAuthService)({ tryHandlePromptCommand }),
Layer.mock(RunExecutionService.RunExecutionServiceV2)({ startRootRun }),
Layer.mock(RuntimePolicy.RuntimePolicyV2)({
diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
index 4ea41f4ca9b8..4f3f3c86f576 100644
--- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
+++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts
@@ -23,6 +23,7 @@ import * as Schema from "effect/Schema";
import * as GitWorkflowService from "../git/GitWorkflowService.ts";
import * as ProjectService from "../project/ProjectService.ts";
import * as ProviderAuthService from "../provider/Services/ProviderAuthService.ts";
+import * as DirenvEnvironment from "../provider/DirenvEnvironment.ts";
import * as EventSink from "./EventSink.ts";
import * as ContextHandoffService from "./ContextHandoffService.ts";
import {
@@ -585,6 +586,49 @@ export const layer: Layer.Layer<
return;
}
const session = sessionResult.success;
+ const direnvFailure = yield* providerSessions.takeProjectEnvironmentFailure(
+ projection.thread.id,
+ );
+ if (direnvFailure !== undefined) {
+ const now = yield* DateTime.now;
+ const notice = DirenvEnvironment.direnvFailureNotice(direnvFailure);
+ const item: OrchestrationV2TurnItem = {
+ id: idAllocator.derive.runSignalTurnItem({ runId, signal: "direnv-failure" }),
+ threadId: projection.thread.id,
+ runId,
+ nodeId: rootNode.id,
+ providerThreadId: providerThread.id,
+ providerTurnId: null,
+ nativeItemRef: null,
+ parentItemId: null,
+ ordinal: yield* projectionStore.getNextTurnItemOrdinal(projection.thread.id),
+ type: "system_notice",
+ status: "completed",
+ title: notice.message,
+ ...notice,
+ startedAt: now,
+ completedAt: now,
+ updatedAt: now,
+ };
+ yield* eventSink.writeIfRunCurrent({
+ threadId: projection.thread.id,
+ runId,
+ activeAttemptId: attempt.id,
+ expectedStatus: "starting",
+ events: [
+ {
+ type: "turn-item.updated",
+ payload: item,
+ id: yield* idAllocator.allocate.event({ threadId: projection.thread.id }),
+ threadId: projection.thread.id,
+ runId,
+ nodeId: rootNode.id,
+ providerInstanceId: run.providerInstanceId,
+ occurredAt: now,
+ },
+ ],
+ });
+ }
// Only the provider's own thread load fails the run on the last attempt;
// store, id and handoff failures around it keep their typed errors.
const loadFromProvider = (
diff --git a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs
index 36d5ce1adb1b..4ea7b82717d2 100644
--- a/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs
+++ b/apps/server/src/orchestration-v2/testkit/ProviderTurnStartMemory.fixture.mjs
@@ -64,7 +64,10 @@ const dependencies = Layer.mergeAll(
getTurnStartHistory: () => Effect.sync(() => current.turnItems),
getRuntimeRecoveryProjection: () => Effect.sync(() => current),
}),
- Layer.mock(Sessions.ProviderSessionManagerV2)({ open: () => Effect.succeed(session) }),
+ Layer.mock(Sessions.ProviderSessionManagerV2)({
+ open: () => Effect.succeed(session),
+ takeProjectEnvironmentFailure: () => Effect.succeed(undefined),
+ }),
Layer.mock(Policy.RuntimePolicyV2)({
resolve: () =>
Effect.succeed({ cwd: "/synthetic", interactionMode: "default", runtimeMode: "full-access" }),
diff --git a/apps/server/src/processRunner.ts b/apps/server/src/processRunner.ts
index 049125de3fbf..2a33ae1b6d7a 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;
@@ -297,7 +299,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..e2b4d1d4b7c2
--- /dev/null
+++ b/apps/server/src/provider/DirenvEnvironment.ts
@@ -0,0 +1,267 @@
+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 DirenvLoadFailure = Extract;
+
+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") {}
+
+const DIRENV_FAILURE_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.",
+};
+
+/** What a thread shows for a failed load; a blocked `.envrc` offers to allow it. */
+export function direnvFailureNotice(failure: DirenvLoadFailure): {
+ readonly message: string;
+ readonly detail: string;
+ readonly action?: { readonly type: "direnv.allow" };
+} {
+ return {
+ message: DIRENV_FAILURE_MESSAGES[failure.reason],
+ detail: failure.message,
+ ...(failure.reason === "blocked" ? { action: { type: "direnv.allow" } as const } : {}),
+ };
+}
+
+// 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. */
+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 )?/,
+ "",
+ );
+}
+
+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;
+}
+
+// ProviderSessionManagerV2 loads the diff before opening 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 cabcc643006e..42acb0ceb6d4 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 { resolveSelfInvocation } from "@t3tools/shared/nodeRuntime";
@@ -242,7 +243,10 @@ export const AntigravityDriver: ProviderDriver {
/** 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 98039e56642a..284058f34a67 100644
--- a/apps/server/src/server.ts
+++ b/apps/server/src/server.ts
@@ -70,6 +70,7 @@ import { deviceHubProxyRouteLayer } from "./device/DeviceHubProxy.ts";
import * as PreviewManager from "./preview/Manager.ts";
import * as PortScanner from "./preview/PortScanner.ts";
import * as ProcessRunner from "./processRunner.ts";
+import * as DirenvEnvironment from "./provider/DirenvEnvironment.ts";
import * as GitManager from "./git/GitManager.ts";
import * as EnvironmentTheme from "./environmentTheme.ts";
import * as Keybindings from "./keybindings.ts";
@@ -443,6 +444,7 @@ const CloudManagedEndpointRuntimeLive = Layer.mergeAll(
);
const OrchestrationV2RuntimeLayerLive = OrchestrationV2ProductionLayerLive.pipe(
+ Layer.provideMerge(DirenvEnvironment.layer.pipe(Layer.provide(ProcessRunner.layer))),
Layer.provide(ProviderEventIngestor.analyticsLive),
Layer.provide(CheckpointStoreLayerLive),
Layer.provide(GitWorkflowLayerLive),
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 8ba874310f15..97891e3f0b54 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -195,6 +195,7 @@ import { linkCreatedPullRequest } from "./git/linkCreatedPullRequest.ts";
import * as ReviewService from "./review/ReviewService.ts";
import * as ProjectEnrichmentService from "./project/ProjectEnrichmentService.ts";
import * as ProjectService from "./project/ProjectService.ts";
+import * as DirenvEnvironment from "./provider/DirenvEnvironment.ts";
import { projectMutationOperation } from "./project/ProjectMutation.ts";
import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts";
import * as ProjectCloneTracker from "./project/ProjectCloneTracker.ts";
@@ -1128,6 +1129,38 @@ const makeWsRpcLayer = (
const projectCloneTracker = yield* ProjectCloneTracker.ProjectCloneTracker;
const repositoryIdentityResolver =
yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
+ const direnvEnvironment = yield* Effect.serviceOption(DirenvEnvironment.DirenvEnvironment);
+ const allowThreadDirenv = Effect.fn("ws.allowThreadDirenv")(function* (threadId: ThreadId) {
+ const thread = yield* threadManagement.getThreadRecords(threadId, []).pipe(
+ Effect.map((records) => records.thread),
+ Effect.orElseSucceed(() => undefined),
+ );
+ const project =
+ thread === undefined || thread.worktreePath !== null
+ ? undefined
+ : Option.getOrUndefined(
+ yield* projectService
+ .getById(thread.projectId)
+ .pipe(Effect.orElseSucceed(() => Option.none())),
+ );
+ // The same directory RuntimePolicyV2 starts the thread's provider in.
+ const cwd = thread?.worktreePath ?? project?.workspaceRoot;
+ 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 };
+ }
+ });
const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner;
const agentSessionImporter = yield* AgentSessionImporter.AgentSessionImporter;
const checkpointDiffQuery = yield* CheckpointDiffQuery.CheckpointDiffQuery;
@@ -3161,6 +3194,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 fd71d616bae9..290c2226fc13 100644
--- a/apps/web/src/components/chat/MessagesTimeline.tsx
+++ b/apps/web/src/components/chat/MessagesTimeline.tsx
@@ -145,6 +145,9 @@ import type {
KnownComposerContextRecord,
} from "@t3tools/contracts";
import { Button, InlineButton } from "../ui/button";
+import { toastManager } from "../ui/toast";
+import { threadEnvironment } from "../../state/threads";
+import { useAtomCommand } from "../../state/use-atom-command";
import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
import { getVirtualizedScrollFadeClassName } from "../ui/scroll-area";
@@ -4929,6 +4932,60 @@ function ReasoningTraceContent({ entries }: { entries: ReadonlyArray();
+
+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 SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
workEntry: TimelineWorkEntry;
workspaceRoot: string | undefined;
@@ -5198,6 +5255,9 @@ const SimpleWorkEntryRow = memo(function SimpleWorkEntryRow(props: {
!toolIconAcceptsTint(entryIconName, entryToolIcon) ? (
) : null}
+ {workEntry.warningAction?.type === "direnv.allow" && ctx.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.responseStreamingMode,
settings.persistComposerContextStrip,
settings.enableProviderUpdateChecks,
+ settings.enableDirenvEnvironment,
settings.continueThreadsAfterServerUpdate,
settings.sidebarAutoSettleAfterDays,
settings.sidebarAutoSettleOnMerge,
@@ -790,6 +794,7 @@ export function useSettingsRestore(onRestored?: () => void) {
snoozeLimitedThreads: DEFAULT_UNIFIED_SETTINGS.snoozeLimitedThreads,
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,
@@ -2846,6 +2851,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/contracts/src/index.ts b/packages/contracts/src/index.ts
index 8690bb1b2390..7aad0ab2ff21 100644
--- a/packages/contracts/src/index.ts
+++ b/packages/contracts/src/index.ts
@@ -60,3 +60,4 @@ export * from "./worktreeMcp.ts";
export * from "./resourceTelemetry.ts";
export * from "./rpc.ts";
export * from "./worktreeSetup.ts";
+export * from "./projectEnvironment.ts";
diff --git a/packages/contracts/src/orchestrationV2.ts b/packages/contracts/src/orchestrationV2.ts
index cafc2bb19945..379ea0437fe0 100644
--- a/packages/contracts/src/orchestrationV2.ts
+++ b/packages/contracts/src/orchestrationV2.ts
@@ -1208,6 +1208,13 @@ export const OrchestrationV2UserMessageInputIntent = Schema.Literals([
export type OrchestrationV2UserMessageInputIntent =
typeof OrchestrationV2UserMessageInputIntent.Type;
+/** Something the user can do about a notice, rendered as a button on its row. */
+export const OrchestrationV2SystemNoticeAction = Schema.Struct({
+ /** `direnv allow` the thread's `.envrc` (`projectEnvironment.allowDirenv`). */
+ type: Schema.Literal("direnv.allow"),
+});
+export type OrchestrationV2SystemNoticeAction = typeof OrchestrationV2SystemNoticeAction.Type;
+
const OrchestrationV2TurnItemBaseFields = {
toolSurface: Schema.optional(ToolActivitySurface),
toolIcon: Schema.optional(ToolActivityIcon),
@@ -1360,6 +1367,8 @@ export const OrchestrationV2TurnItem = Schema.Union([
...OrchestrationV2TurnItemBaseFields,
type: Schema.Literal("system_notice"),
message: Schema.String,
+ detail: Schema.optional(Schema.String),
+ action: Schema.optional(OrchestrationV2SystemNoticeAction),
}),
Schema.Struct({
...OrchestrationV2TurnItemBaseFields,
@@ -2083,6 +2092,8 @@ export const OrchestrationV2TurnItemJson = Schema.Union([
...OrchestrationV2TurnItemJsonBaseFields,
type: Schema.Literal("system_notice"),
message: Schema.String,
+ detail: Schema.optional(Schema.String),
+ action: Schema.optional(OrchestrationV2SystemNoticeAction),
}),
Schema.Struct({
...OrchestrationV2TurnItemJsonBaseFields,
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/rpc.ts b/packages/contracts/src/rpc.ts
index 55b96b87c55e..3a561892c876 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -92,6 +92,10 @@ import {
OrchestrationGetFullThreadDiffError,
OrchestrationGetTurnDiffError,
} from "./checkpointDiff.ts";
+import {
+ ProjectEnvironmentAllowDirenvInput,
+ ProjectEnvironmentAllowDirenvResult,
+} from "./projectEnvironment.ts";
import {
WorktreeSetupCancelInput,
WorktreeSetupCancelResult,
@@ -517,6 +521,7 @@ export const WS_METHODS = {
subscribeVcsStatus: "subscribeVcsStatus",
subscribeWorktreeSetup: "subscribeWorktreeSetup",
worktreeSetupCancel: "worktreeSetup.cancel",
+ projectEnvironmentAllowDirenv: "projectEnvironment.allowDirenv",
subscribeTerminalEvents: "subscribeTerminalEvents",
subscribeTerminalMetadata: "subscribeTerminalMetadata",
subscribePreviewEvents: "subscribePreviewEvents",
@@ -1238,6 +1243,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,
@@ -1773,6 +1784,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 764e6d560a73..acd0bd26e92a 100644
--- a/packages/contracts/src/settings.ts
+++ b/packages/contracts/src/settings.ts
@@ -1111,6 +1111,7 @@ export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
"defaultProjectScripts",
"enableAgentBrowserAccess",
"enableAgentDeviceAccess",
+ "enableDirenvEnvironment",
"textGenerationModelSelection",
"sourceControlWriterModelSelection",
"sourceControlWritingStyle",
@@ -1141,6 +1142,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),
@@ -1254,6 +1256,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
@@ -1616,6 +1624,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),