Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,16 @@ function ServerSettingsDetail(props: { readonly page: SettingsPage }) {
onValueChange={(value) => write({ enableAgentBrowserAccess: value })}
/>
</SettingsSection>
<SettingsSection title="Project environment">
<SettingsSwitchRow
icon="hammer"
label="Load direnv environment"
subtitle="Start agents with the project's allowed .envrc, such as a Nix dev shell."
value={uniform("enableDirenvEnvironment")}
disabled={disabledFor("enableDirenvEnvironment")}
onValueChange={(value) => write({ enableDirenvEnvironment: value })}
/>
</SettingsSection>
</>
) : null}

Expand Down
61 changes: 60 additions & 1 deletion apps/mobile/src/features/threads/thread-work-log.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
} from "react";
import {
AccessibilityInfo,
Alert,
AppState,
type ColorValue,
Pressable,
Expand All @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<string>();

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 (
<Pressable
accessibilityRole="button"
accessibilityHint="Loads the project's direnv environment with your next message."
disabled={state !== "idle"}
hitSlop={6}
onPress={async () => {
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"
>
<Text className="font-t3-medium text-xs text-foreground">
{state === "allowed"
? "Allowed · applies to your next message"
: state === "pending"
? "Allowing…"
: "Allow .envrc"}
</Text>
</Pressable>
);
}

const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
props: Omit<
ThreadWorkLogProps,
Expand Down Expand Up @@ -972,6 +1023,14 @@ const ThreadWorkLogRow = memo(function ThreadWorkLogRow(
)}

<View className="shrink-0 flex-row items-center gap-px">
{row.projectedItem.item.type === "system_notice" &&
row.projectedItem.item.action?.type === "direnv.allow" ? (
<AllowDirenvButton
environmentId={props.environmentId}
threadId={row.projectedItem.item.threadId}
warningId={row.projectedItem.item.id}
/>
) : null}
{props.copied ? (
<Text className="pr-1 font-t3-medium text-3xs text-adaptive-emerald-600-400">
Copied
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/auth/RpcAuthorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
12 changes: 11 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -138,6 +139,8 @@ export interface AcpAdapterV2RuntimeInput {
readonly acpMcpServers?: ReadonlyArray<EffectAcpSchema.McpServer>;
/** 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"];
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand All @@ -2003,6 +2012,7 @@ export function makeAcpAdapterV2(
...(mcpContext.processEnvironment === undefined
? {}
: { processEnvironment: mcpContext.processEnvironment }),
...(direnvEnvironment === undefined ? {} : { direnvEnvironment }),
...(resumeSessionId === undefined ? {} : { resumeSessionId }),
interruptPromptOnCancel: flavor.interruptPromptOnCancel ?? false,
clientCapabilities: {
Expand Down
13 changes: 10 additions & 3 deletions apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High Adapters/AcpRegistryAdapterV2.ts:152

A bare commandPath provided by the project's .envrc fails with runner_unavailable even when the executable is available on the direnv-provided PATH. resolve receives options.environment at line 152, before direnvEnvironment is applied to resolved.spawn.env, so executable lookup cannot see that PATH; resolve with the direnv-merged environment instead.

Suggested change
.resolve(options.settings, input.cwd, options.environment)
.resolve(
options.settings, input.cwd, applyDirenvEnvironment(options.environment, direnvEnvironment),
)
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration-v2/Adapters/AcpRegistryAdapterV2.ts around line 152:

A bare `commandPath` provided by the project's `.envrc` fails with `runner_unavailable` even when the executable is available on the direnv-provided `PATH`. `resolve` receives `options.environment` at line 152, before `direnvEnvironment` is applied to `resolved.spawn.env`, so executable lookup cannot see that `PATH`; resolve with the direnv-merged environment instead.

.pipe(
Expand All @@ -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(
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<
Expand Down
5 changes: 3 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/GrokAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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),
})),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 5 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/PiAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions apps/server/src/orchestration-v2/EffectWorker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ function makeExecutorLayer(input: {
closeInstance: () => Effect.void,
release: () => record("release"),
detach: () => record("detach"),
takeProjectEnvironmentFailure: () => Effect.succeed(undefined),
}),
),
Layer.succeed(
Expand Down
Loading
Loading