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),