Skip to content
Merged
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
22 changes: 22 additions & 0 deletions apps/server/src/provider/GitShimManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,28 @@ it.layer(testLayer)("GitShimManager", (it) => {
}),
);

it.effect(
"prepends the shim dir to a caller-supplied basePath (#134 direnv PATH survival)",
() =>
Effect.gen(function* () {
const mgr = yield* GitShimManager;
const config = yield* ServerConfig;
const path = yield* Path.Path;

const sessionId = "test-alloc-basepath";
const allowedRoot = config.baseDir;
const basePath = "/scratch/fake-toolchain:/usr/bin:/bin";
const result = yield* mgr.allocate(sessionId, allowedRoot, basePath);

const shimDir = path.join(config.baseDir, "gits-shims", sessionId);
if (result.vars["PATH"] !== `${shimDir}:${basePath}`) {
throw new Error(`Expected PATH '${shimDir}:${basePath}', got '${result.vars["PATH"]}'`);
}

yield* mgr.release(sessionId);
}),
);

it.effect("fails closed when the shim cannot be written", () =>
Effect.gen(function* () {
const mgr = yield* GitShimManager;
Expand Down
14 changes: 12 additions & 2 deletions apps/server/src/provider/GitShimManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,13 +67,19 @@ export interface GitShimManagerShape {
* Create a shim dir for `sessionId`, write the `git` script that enforces
* `allowedRoot` containment, and return the env vars to inject.
*
* `basePath` is the PATH to prepend the shim dir onto — pass the caller's
* already direnv-merged PATH here so `.envrc` toolchain additions survive
* (#134); omitted callers fall back to the server's own `process.env.PATH`
* (unchanged prior behavior).
*
* Callers are responsible for calling `release(sessionId)` when the session
* ends. Codex/Cursor/OpenCode adapters do this via a Scope finalizer;
* ClaudeAdapter calls release explicitly in stopSessionInternal.
*/
readonly allocate: (
sessionId: string,
allowedRoot: string,
basePath?: string,
) => Effect.Effect<GitShimEnv, GitShimAllocateError>;

/**
Expand Down Expand Up @@ -143,6 +149,7 @@ const makeGitShimManager: Effect.Effect<
const allocate = (
sessionId: string,
allowedRoot: string,
basePath?: string,
): Effect.Effect<GitShimEnv, GitShimAllocateError> =>
Effect.gen(function* () {
const shimDir = shimDirFor(sessionId);
Expand All @@ -154,8 +161,11 @@ const makeGitShimManager: Effect.Effect<
yield* fs.chmod(shimPath, 0o755);

const vars: Record<string, string> = {
// Prepend shim dir to PATH so bare `git` resolves to our script.
PATH: `${shimDir}:${process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}`,
// Prepend shim dir to `basePath` (the caller's already direnv-merged
// PATH, when given) so bare `git` resolves to our script FIRST while
// `.envrc` toolchain additions survive (#134). Falls back to the
// server's own PATH when no basePath is supplied.
PATH: `${shimDir}:${basePath ?? process.env["PATH"] ?? "/usr/local/bin:/usr/bin:/bin"}`,
GITS_REAL_GIT: realGit,
GITS_ALLOWED_ROOT: allowedRoot,
GITS_PROTECTED_BRANCHES: PROTECTED_BRANCHES,
Expand Down
23 changes: 19 additions & 4 deletions apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ import {
import { type ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
import { type GitShimManagerShape } from "../GitShimManager.ts";
import { resolveSessionEnvWithDirenv } from "../direnvSessionEnv.ts";
import { sessionPortEnv } from "../sessionPort.ts";
const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);
const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString);
Expand Down Expand Up @@ -3006,12 +3007,25 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
// Cleanup happens in stopSessionInternal via gitShimManager.release().
// ponytail: shim denied/warn events log via Codex adapter's stderr pipeline;
// Claude SDK stderr is not captured here — add stderr capture when needed.
// direnv (.envrc) delta for this session's cwd, layered under sessionChildEnv
// below so GITS_PORT / shim vars always win (#124 invariant). Failure-safe:
// any direnv error (not installed, un-allowed .envrc, timeout) collapses to {}.
// Resolved before git-shim allocation so the shim's PATH can prepend to the
// direnv-merged PATH (#134) instead of silently discarding it.
const claudeEnvironmentWithDirenv = yield* resolveSessionEnvWithDirenv(
input.cwd ?? process.cwd(),
claudeEnvironment,
);
const childEnv = sessionPortEnv(threadId);
const sessionChildEnv = gitShimManager
? {
...childEnv,
...(yield* gitShimManager
.allocate(threadId, input.cwd ?? "")
.allocate(
threadId,
input.cwd ?? "",
claudeEnvironmentWithDirenv.PATH ?? process.env["PATH"],
)
.pipe(
Effect.mapError((cause) =>
toProcessError(cause, "Failed to allocate git confinement shim.", threadId),
Expand Down Expand Up @@ -3049,9 +3063,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
canUseTool,
...(visualPlanMcpServers ? { mcpServers: visualPlanMcpServers } : {}),
// Merge per-session env vars (GITS_PORT, GITS_ALLOWED_ROOT, PATH prepend, etc.)
// into the claude env. claudeEnvironment is the instance-level base; child vars
// add the per-session policy. Neither object is mutated.
env: Object.assign({}, claudeEnvironment, sessionChildEnv),
// into the claude env. claudeEnvironmentWithDirenv is the instance-level base
// plus the project's direnv delta; child vars add the per-session policy and
// always win. Neither input object is mutated.
env: Object.assign({}, claudeEnvironmentWithDirenv, sessionChildEnv),
...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
};
Expand Down
101 changes: 101 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import * as CodexErrors from "effect-codex-app-server/errors";
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterValidationError } from "../Errors.ts";
import { GitShimManager, GitShimManagerLive } from "../GitShimManager.ts";
import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
import { sessionPortEnv } from "../sessionPort.ts";
Expand Down Expand Up @@ -1246,3 +1247,103 @@ it.effect("flushes managed native logs when the adapter layer shuts down", () =>
}
}),
);

it.effect(
"merges the direnv delta below GITS_PORT, and shim-first PATH survives direnv PATH_add (#134/#124)",
() =>
Effect.gen(function* () {
// Real (trivial) executable named "direnv" on a scratch PATH, standing in for
// the real binary so `resolveDirenvEnvironment`'s production code path — real
// PATH lookup, real execFile — runs unmodified end to end. Its fake .envrc
// output deliberately also sets GITS_PORT, to prove the session value still wins,
// and a PATH pointing at a scratch toolchain dir, to prove a real `.envrc`
// `PATH_add` (nix/mise/asdf shim) survives the git-shim allocation (#134).
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-codex-adapter-direnv-"));
const binPath = path.join(tempDir, "direnv");
const fakeToolchainDir = path.join(tempDir, "fake-toolchain");
fs.mkdirSync(fakeToolchainDir);
fs.writeFileSync(
binPath,
"#!/bin/sh\n" +
"printf '%s' " +
`'{"DIRENV_TEST_VAR":"present","GITS_PORT":"6666-from-direnv","PATH":"${fakeToolchainDir}:/usr/bin:/bin"}'\n`,
);
fs.chmodSync(binPath, 0o755);
const runtimeFactory = makeRuntimeFactory();
const scope = yield* Scope.make("sequential");
let scopeClosed = false;

try {
// Real GitShimManager on its own scratch baseDir (independent of the
// adapter's own ServerConfig below) — exercises the actual allocate()
// basePath-threading fix, not a stand-in.
const gitShimLayer = GitShimManagerLive.pipe(
Layer.provideMerge(
ServerConfig.layerTest(process.cwd(), { prefix: "t3-codex-direnv-shim-" }),
),
Layer.provideMerge(NodeServices.layer),
);
const gitShimContext = yield* Layer.buildWithScope(gitShimLayer, scope);
const gitShimManager = yield* Effect.service(GitShimManager).pipe(
Effect.provide(gitShimContext),
);
const gitShimConfig = yield* Effect.service(ServerConfig).pipe(
Effect.provide(gitShimContext),
);

const layer = Layer.effect(
CodexAdapter,
Effect.gen(function* () {
const codexConfig = decodeCodexSettings({});
return yield* makeCodexAdapter(codexConfig, {
makeRuntime: runtimeFactory.factory,
environment: { PATH: `${tempDir}:${process.env.PATH ?? ""}` },
gitShimManager,
});
}),
).pipe(
Layer.provideMerge(ServerConfig.layerTest(process.cwd(), process.cwd())),
Layer.provideMerge(ServerSettingsService.layerTest()),
Layer.provideMerge(providerSessionDirectoryTestLayer),
Layer.provideMerge(NodeServices.layer),
);
const context = yield* Layer.buildWithScope(layer, scope);
const adapter = yield* Effect.service(CodexAdapter).pipe(Effect.provide(context));

const threadId = asThreadId("sess-direnv-codex");
yield* adapter.startSession({
provider: ProviderDriverKind.make("codex"),
threadId,
runtimeMode: "full-access",
});

const runtime = runtimeFactory.lastRuntime;
assert.ok(runtime);
assert.equal(runtime.options.environment?.DIRENV_TEST_VAR, "present");
assert.equal(runtime.options.environment?.GITS_PORT, sessionPortEnv(threadId).GITS_PORT);

// PATH survival (#134): shim dir FIRST (confinement preserved), direnv's
// toolchain dir PRESENT (PATH_add / nix/mise/asdf shims not discarded).
const finalPath = runtime.options.environment?.PATH ?? "";
const expectedShimDir = path.join(gitShimConfig.baseDir, "gits-shims", threadId);
const pathEntries = finalPath.split(":");
assert.equal(
pathEntries[0],
expectedShimDir,
`expected shim dir '${expectedShimDir}' first in PATH, got: ${finalPath}`,
);
assert.ok(
pathEntries.includes(fakeToolchainDir),
`expected direnv toolchain dir '${fakeToolchainDir}' in PATH, got: ${finalPath}`,
);

yield* Scope.close(scope, Exit.void);
scopeClosed = true;
} finally {
if (!scopeClosed) {
yield* Scope.close(scope, Exit.void);
}
fs.rmSync(tempDir, { recursive: true, force: true });
}
}),
);
37 changes: 25 additions & 12 deletions apps/server/src/provider/Layers/CodexAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import {
} from "./CodexSessionRuntime.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
import { type GitShimManagerShape } from "../GitShimManager.ts";
import { resolveSessionEnvWithDirenv } from "../direnvSessionEnv.ts";
import { sessionPortEnv } from "../sessionPort.ts";
const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
Expand Down Expand Up @@ -1421,29 +1422,41 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
sessionScopeTransferred ? Effect.void : Scope.close(sessionScope, Exit.void),
);

// direnv (.envrc) delta for this session's cwd, layered under GITS_PORT/shimEnv
// below so they always win (#124 invariant). Failure-safe: any direnv error
// (not installed, un-allowed .envrc, timeout) collapses to {}. Resolved before
// git-shim allocation so the shim's PATH can prepend to the direnv-merged PATH
// (#134) instead of silently discarding it.
const baseEnvWithDirenv = yield* resolveSessionEnvWithDirenv(
sessionCwd,
options?.environment ?? {},
options?.environment ?? process.env,
);
// Inject git confinement shim for this session.
// CONFINEMENT LINE: only sessionEnv is modified; options.environment (the
// instance-level env) is never mutated, and server process.env is untouched.
// The shim dir is released when sessionScope closes (session teardown).
const shimEnv = options?.gitShimManager
? (yield* options.gitShimManager.allocate(input.threadId, sessionCwd).pipe(
Effect.mapError(
(cause) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: cause.message,
cause,
}),
),
)).vars
? (yield* options.gitShimManager
.allocate(input.threadId, sessionCwd, baseEnvWithDirenv.PATH ?? process.env["PATH"])
.pipe(
Effect.mapError(
(cause) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: cause.message,
cause,
}),
),
)).vars
: {};
if (options?.gitShimManager && Object.keys(shimEnv).length > 0) {
yield* Scope.addFinalizer(sessionScope, options.gitShimManager.release(input.threadId));
}
const sessionEnv: NodeJS.ProcessEnv = Object.assign(
{},
options?.environment,
baseEnvWithDirenv,
sessionPortEnv(input.threadId),
shimEnv,
);
Expand Down
37 changes: 25 additions & 12 deletions apps/server/src/provider/Layers/CursorAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ import { type CursorAdapterShape } from "../Services/CursorAdapter.ts";
import { resolveCursorAcpBaseModelId } from "./CursorProvider.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
import { type GitShimManagerShape } from "../GitShimManager.ts";
import { resolveSessionEnvWithDirenv } from "../direnvSessionEnv.ts";
import { sessionPortEnv } from "../sessionPort.ts";
const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);

Expand Down Expand Up @@ -541,28 +542,40 @@ export function makeCursorAdapter(
const visualPlanMcpToken = visualPlanMcpSvc
? yield* visualPlanMcpSvc.issueToken(input.threadId)
: undefined;
// direnv (.envrc) delta for this session's cwd, layered under GITS_PORT/shim
// vars below so they always win (#124 invariant). Failure-safe: any direnv
// error (not installed, un-allowed .envrc, timeout) collapses to {}. Resolved
// before git-shim allocation so the shim's PATH can prepend to the
// direnv-merged PATH (#134) instead of silently discarding it.
const cursorBaseEnvWithDirenv = yield* resolveSessionEnvWithDirenv(
cwd,
options?.environment ?? {},
options?.environment ?? process.env,
);
// Inject git confinement shim for this session.
// CONFINEMENT LINE: only sessionEnv is modified; options.environment (the
// instance-level env) is never mutated, and server process.env is untouched.
const cursorShimEnv = options?.gitShimManager
? (yield* options.gitShimManager.allocate(input.threadId, cwd).pipe(
Effect.mapError(
(cause) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: cause.message,
cause,
}),
),
)).vars
? (yield* options.gitShimManager
.allocate(input.threadId, cwd, cursorBaseEnvWithDirenv.PATH ?? process.env["PATH"])
.pipe(
Effect.mapError(
(cause) =>
new ProviderAdapterProcessError({
provider: PROVIDER,
threadId: input.threadId,
detail: cause.message,
cause,
}),
),
)).vars
: {};
if (options?.gitShimManager && Object.keys(cursorShimEnv).length > 0) {
yield* Scope.addFinalizer(sessionScope, options.gitShimManager.release(input.threadId));
}
const cursorSessionEnv: NodeJS.ProcessEnv = Object.assign(
{},
options?.environment,
cursorBaseEnvWithDirenv,
sessionPortEnv(input.threadId),
cursorShimEnv,
);
Expand Down
19 changes: 17 additions & 2 deletions apps/server/src/provider/Layers/OpenCodeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { resolveAttachmentPath } from "../../attachmentStore.ts";
import { ServerConfig } from "../../config.ts";
import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
import { type GitShimManagerShape } from "../GitShimManager.ts";
import { resolveSessionEnvWithDirenv } from "../direnvSessionEnv.ts";
import { sessionPortEnv } from "../sessionPort.ts";
import {
ProviderAdapterProcessError,
Expand Down Expand Up @@ -1045,17 +1046,31 @@ export function makeOpenCodeAdapter(
sessions.delete(input.threadId);
}

// direnv (.envrc) delta for this session's cwd, layered under GITS_PORT/shim
// vars below so they always win (#124 invariant). Failure-safe: any direnv
// error (not installed, un-allowed .envrc, timeout) collapses to {}. Resolved
// before git-shim allocation so the shim's PATH can prepend to the
// direnv-merged PATH (#134) instead of silently discarding it.
const openCodeBaseEnvWithDirenv = yield* resolveSessionEnvWithDirenv(
directory,
options?.environment ?? {},
options?.environment ?? process.env,
);
// Inject git confinement shim for this session.
// CONFINEMENT LINE: only openCodeSessionEnv is modified; options.environment (the
// instance-level env) is never mutated, and server process.env is untouched.
const openCodeShimEnv = options?.gitShimManager
? (yield* options.gitShimManager
.allocate(input.threadId, directory)
.allocate(
input.threadId,
directory,
openCodeBaseEnvWithDirenv.PATH ?? process.env["PATH"],
)
.pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause)))).vars
: {};
const openCodeSessionEnv: NodeJS.ProcessEnv = Object.assign(
{},
options?.environment,
openCodeBaseEnvWithDirenv,
sessionPortEnv(input.threadId),
openCodeShimEnv,
);
Expand Down
Loading
Loading