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
19 changes: 19 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterValidationError } from "../Errors.ts";
import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts";
import { sessionPortEnv } from "../sessionPort.ts";
import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts";
import type { ClaudeRtkRewriteRunner } from "./ClaudeRtkToolRewrite.ts";
const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings);
Expand Down Expand Up @@ -411,6 +412,24 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect("injects GITS_PORT into Claude SDK query env", () => {
const harness = makeHarness();
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
yield* adapter.startSession({
threadId: THREAD_ID,
provider: ProviderDriverKind.make("claudeAgent"),
runtimeMode: "full-access",
});

const createInput = harness.getLastCreateQueryInput();
assert.equal(createInput?.options.env?.GITS_PORT, sessionPortEnv(THREAD_ID).GITS_PORT);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
});

it.effect("maps the Claude Opus 4.7 default effort to the SDK-supported max value", () => {
const harness = makeHarness();
return Effect.gen(function* () {
Expand Down
62 changes: 37 additions & 25 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 { sessionPortEnv } from "../sessionPort.ts";
const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);
const decodeUnknownJsonStringExit = Schema.decodeUnknownExit(Schema.UnknownFromJsonString);

Expand Down Expand Up @@ -1055,11 +1056,11 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
options: input.options,
}) as ClaudeQueryRuntime);

// Git shim manager injected at construction; per-session shim env stored here.
// CONFINEMENT LINE: sessionShimEnvs are merged into queryOptions.env only —
// Git shim manager injected at construction; per-session child env stored here.
// CONFINEMENT LINE: sessionChildEnvs are merged into queryOptions.env only —
// claudeEnvironment (instance-level) and server process.env are never mutated.
const gitShimManager: GitShimManagerShape | undefined = options?.gitShimManager;
const sessionShimEnvs = new Map<ThreadId, Record<string, string>>();
const sessionChildEnvs = new Map<ThreadId, Record<string, string>>();

const sessions = new Map<ThreadId, ClaudeSessionContext>();
const runtimeEventQueue = yield* Queue.bounded<ProviderRuntimeEvent>(
Expand Down Expand Up @@ -2571,7 +2572,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}

sessions.delete(context.session.threadId);
sessionShimEnvs.delete(context.session.threadId);
sessionChildEnvs.delete(context.session.threadId);
// Clean up the per-session shim dir when the session stops.
if (gitShimManager) yield* gitShimManager.release(context.session.threadId);
});
Expand Down Expand Up @@ -2999,6 +3000,27 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
...(fastMode ? { fastMode: true } : {}),
...(ultracode ? { ultracode: true } : {}),
};
// Allocate per-session child env before creating the Claude SDK query;
// createQuery is the child-process spawn boundary for Claude.
// CONFINEMENT LINE: child vars merge into a claudeEnvironment copy only.
// 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.
const childEnv = sessionPortEnv(threadId);
const sessionChildEnv = gitShimManager
? {
...childEnv,
...(yield* gitShimManager
.allocate(threadId, input.cwd ?? "")
.pipe(
Effect.mapError((cause) =>
toProcessError(cause, "Failed to allocate git confinement shim.", threadId),
),
)).vars,
}
: childEnv;
sessionChildEnvs.set(threadId, sessionChildEnv);

const queryOptions: ClaudeQueryOptions = {
...(input.cwd ? { cwd: input.cwd } : {}),
...(apiModelId ? { model: apiModelId } : {}),
Expand Down Expand Up @@ -3026,10 +3048,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
includePartialMessages: true,
canUseTool,
...(visualPlanMcpServers ? { mcpServers: visualPlanMcpServers } : {}),
// Merge per-session shim env vars (GITS_ALLOWED_ROOT, PATH prepend, etc.)
// into the claude env. claudeEnvironment is the instance-level base; shimVars
// 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, sessionShimEnvs.get(threadId) ?? {}),
env: Object.assign({}, claudeEnvironment, sessionChildEnv),
...(input.cwd ? { additionalDirectories: [input.cwd] } : {}),
...(Object.keys(extraArgs).length > 0 ? { extraArgs } : {}),
};
Expand Down Expand Up @@ -3078,7 +3100,14 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
detail: toMessage(cause, "Failed to start Claude runtime session."),
cause,
}),
});
}).pipe(
Effect.tapError(() =>
Effect.gen(function* () {
sessionChildEnvs.delete(threadId);
if (gitShimManager) yield* gitShimManager.release(threadId);
}),
),
);

const session: ProviderSession = {
threadId,
Expand Down Expand Up @@ -3123,23 +3152,6 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
yield* Ref.set(contextRef, context);
sessions.set(threadId, context);

// Allocate git confinement shim for this session (non-supervisor sessions only).
// CONFINEMENT LINE: shimVars merged into claudeEnvironment copy per sendTurn only —
// claudeEnvironment (instance-level) and server process.env are never mutated.
// 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.
if (gitShimManager) {
const shimResult = yield* gitShimManager
.allocate(threadId, input.cwd ?? "")
.pipe(
Effect.mapError((cause) =>
toProcessError(cause, "Failed to allocate git confinement shim.", threadId),
),
);
sessionShimEnvs.set(threadId, shimResult.vars);
}

const sessionStartedStamp = yield* makeEventStamp();
yield* offerRuntimeEvent({
type: "session.started",
Expand Down
19 changes: 19 additions & 0 deletions apps/server/src/provider/Layers/CodexAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderAdapterValidationError } from "../Errors.ts";
import type { CodexAdapterShape } from "../Services/CodexAdapter.ts";
import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
import { sessionPortEnv } from "../sessionPort.ts";
import {
type CodexSessionRuntimeOptions,
type CodexForkResumeCursor,
Expand Down Expand Up @@ -292,6 +293,7 @@ validationLayer("CodexAdapterLive validation", (it) => {
assert.deepStrictEqual(validationRuntimeFactory.factory.mock.calls[0]?.[0], {
binaryPath: "codex",
cwd: process.cwd(),
environment: sessionPortEnv("thread-1"),
model: "gpt-5.3-codex",
providerInstanceId: ProviderInstanceId.make("codex"),
serviceTier: "fast",
Expand Down Expand Up @@ -407,6 +409,23 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => {
}),
);

it.effect("injects GITS_PORT into runtime environment", () =>
Effect.gen(function* () {
const adapter = yield* CodexAdapter;
const threadId = asThreadId("sess-gits-port-codex");

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

const runtime = sessionRuntimeFactory.lastRuntime;
assert.ok(runtime);
assert.equal(runtime.options.environment?.GITS_PORT, sessionPortEnv(threadId).GITS_PORT);
}),
);

it.effect("maps codex model options for the adapter's bound custom instance id", () => {
const customInstanceId = ProviderInstanceId.make("codex_personal");
const customRuntimeFactory = makeRuntimeFactory();
Expand Down
13 changes: 8 additions & 5 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 { sessionPortEnv } from "../sessionPort.ts";
const isCodexAppServerProcessExitedError = Schema.is(CodexErrors.CodexAppServerProcessExitedError);
const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTransportError);
const isCodexSessionRuntimeThreadIdMissingError = Schema.is(
Expand Down Expand Up @@ -1440,18 +1441,20 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* (
if (options?.gitShimManager && Object.keys(shimEnv).length > 0) {
yield* Scope.addFinalizer(sessionScope, options.gitShimManager.release(input.threadId));
}
const sessionEnv: NodeJS.ProcessEnv | undefined =
options?.environment != null || Object.keys(shimEnv).length > 0
? { ...(options?.environment ?? {}), ...shimEnv }
: undefined;
const sessionEnv: NodeJS.ProcessEnv = Object.assign(
{},
options?.environment,
sessionPortEnv(input.threadId),
shimEnv,
);

const runtimeInput: CodexSessionRuntimeOptions = {
threadId: input.threadId,
providerInstanceId: boundInstanceId,
cwd: sessionCwd,
binaryPath: codexConfig.binaryPath,
visualPlanMcpUrl: `http://127.0.0.1:${serverConfig.port}${VISUAL_PLAN_MCP_PATH}`,
...(sessionEnv != null ? { environment: sessionEnv } : {}),
environment: sessionEnv,
...(codexConfig.homePath ? { homePath: codexConfig.homePath } : {}),
...(isCodexResumeCursorSchema(input.resumeCursor)
? { resumeCursor: input.resumeCursor }
Expand Down
39 changes: 39 additions & 0 deletions apps/server/src/provider/Layers/CursorAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import type { CursorAdapterShape } from "../Services/CursorAdapter.ts";
import { sessionPortEnv } from "../sessionPort.ts";
import { makeCursorAdapter } from "./CursorAdapter.ts";
const decodeCursorSettings = Schema.decodeSync(CursorSettings);

Expand Down Expand Up @@ -80,6 +81,18 @@ exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
return wrapperPath;
}

async function makeEnvProbeWrapper(envLogPath: string) {
const dir = await mkdtemp(path.join(os.tmpdir(), "cursor-acp-env-probe-"));
const wrapperPath = path.join(dir, "fake-agent.sh");
const script = `#!/bin/sh
printf '%s\n' "\${GITS_PORT:-}" > ${JSON.stringify(envLogPath)}
exec ${JSON.stringify(bunExe)} ${JSON.stringify(mockAgentPath)} "$@"
`;
await writeFile(wrapperPath, script, "utf8");
await chmod(wrapperPath, 0o755);
return wrapperPath;
}

async function readArgvLog(filePath: string) {
const raw = await readFile(filePath, "utf8");
return raw
Expand Down Expand Up @@ -231,6 +244,32 @@ cursorAdapterTestLayer("CursorAdapterLive", (it) => {
}),
);

it.effect("injects GITS_PORT into spawned ACP process env", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
const settings = yield* ServerSettingsService;
const threadId = ThreadId.make("cursor-gits-port-thread");
const tempDir = yield* Effect.promise(() =>
mkdtemp(path.join(os.tmpdir(), "cursor-gits-port-")),
);
const envLogPath = path.join(tempDir, "env.log");

const wrapperPath = yield* Effect.promise(() => makeEnvProbeWrapper(envLogPath));
yield* settings.updateSettings({ providers: { cursor: { binaryPath: wrapperPath } } });

yield* adapter.startSession({
threadId,
provider: ProviderDriverKind.make("cursor"),
cwd: process.cwd(),
runtimeMode: "full-access",
});

const raw = yield* Effect.promise(() => waitForFileContent(envLogPath));
assert.equal(raw.trim(), sessionPortEnv(threadId).GITS_PORT);
yield* adapter.stopSession(threadId);
}),
);

it.effect("closes the ACP child process when a session stops", () =>
Effect.gen(function* () {
const adapter = yield* CursorAdapter;
Expand Down
13 changes: 8 additions & 5 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 { sessionPortEnv } from "../sessionPort.ts";
const encodeUnknownJsonStringExit = Schema.encodeUnknownExit(Schema.UnknownFromJsonString);

const PROVIDER = ProviderDriverKind.make("cursor");
Expand Down Expand Up @@ -559,14 +560,16 @@ export function makeCursorAdapter(
if (options?.gitShimManager && Object.keys(cursorShimEnv).length > 0) {
yield* Scope.addFinalizer(sessionScope, options.gitShimManager.release(input.threadId));
}
const cursorSessionEnv: NodeJS.ProcessEnv | undefined =
options?.environment != null || Object.keys(cursorShimEnv).length > 0
? { ...(options?.environment ?? {}), ...cursorShimEnv }
: undefined;
const cursorSessionEnv: NodeJS.ProcessEnv = Object.assign(
{},
options?.environment,
sessionPortEnv(input.threadId),
cursorShimEnv,
);

const acp = yield* makeCursorAcpRuntime({
cursorSettings: effectiveCursorSettings,
...(cursorSessionEnv != null ? { environment: cursorSessionEnv } : {}),
environment: cursorSessionEnv,
childProcessSpawner,
cwd,
...(resumeSessionId ? { resumeSessionId } : {}),
Expand Down
10 changes: 9 additions & 1 deletion apps/server/src/provider/Layers/OpenCodeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { ServerConfig } from "../../config.ts";
import { ServerSettingsService } from "../../serverSettings.ts";
import { ProviderSessionDirectory } from "../Services/ProviderSessionDirectory.ts";
import type { OpenCodeAdapterShape } from "../Services/OpenCodeAdapter.ts";
import { sessionPortEnv } from "../sessionPort.ts";
import {
OpenCodeRuntime,
OpenCodeRuntimeError,
Expand Down Expand Up @@ -55,6 +56,7 @@ const runtimeMock = {
startCalls: [] as string[],
sessionCreateUrls: [] as string[],
authHeaders: [] as Array<string | null>,
connectEnvironments: [] as Array<NodeJS.ProcessEnv | undefined>,
abortCalls: [] as string[],
closeCalls: [] as string[],
revertCalls: [] as Array<{ sessionID: string; messageID?: string }>,
Expand All @@ -68,6 +70,7 @@ const runtimeMock = {
this.state.startCalls.length = 0;
this.state.sessionCreateUrls.length = 0;
this.state.authHeaders.length = 0;
this.state.connectEnvironments.length = 0;
this.state.abortCalls.length = 0;
this.state.closeCalls.length = 0;
this.state.revertCalls.length = 0;
Expand Down Expand Up @@ -97,8 +100,9 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = {
exitCode: Effect.never,
};
}),
connectToOpenCodeServer: ({ serverUrl }) =>
connectToOpenCodeServer: ({ serverUrl, environment }) =>
Effect.gen(function* () {
runtimeMock.state.connectEnvironments.push(environment);
const url = serverUrl ?? "http://127.0.0.1:4301";
// Unconditionally register a scope finalizer for test observability —
// preserves the `closeCalls` / `closeError` probes that the existing
Expand Down Expand Up @@ -245,6 +249,10 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => {
assert.deepEqual(runtimeMock.state.authHeaders, [
`Basic ${btoa("opencode:secret-password")}`,
]);
assert.equal(
runtimeMock.state.connectEnvironments[0]?.GITS_PORT,
sessionPortEnv("thread-opencode").GITS_PORT,
);
}),
);

Expand Down
13 changes: 8 additions & 5 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 { sessionPortEnv } from "../sessionPort.ts";
import {
ProviderAdapterProcessError,
ProviderAdapterRequestError,
Expand Down Expand Up @@ -1052,10 +1053,12 @@ export function makeOpenCodeAdapter(
.allocate(input.threadId, directory)
.pipe(Effect.mapError((cause) => toProcessError(input.threadId, cause)))).vars
: {};
const openCodeSessionEnv: NodeJS.ProcessEnv | undefined =
options?.environment != null || Object.keys(openCodeShimEnv).length > 0
? { ...(options?.environment ?? {}), ...openCodeShimEnv }
: undefined;
const openCodeSessionEnv: NodeJS.ProcessEnv = Object.assign(
{},
options?.environment,
sessionPortEnv(input.threadId),
openCodeShimEnv,
);

const started = yield* Effect.gen(function* () {
const sessionScope = yield* Scope.make();
Expand All @@ -1070,7 +1073,7 @@ export function makeOpenCodeAdapter(
const server = yield* openCodeRuntime.connectToOpenCodeServer({
binaryPath,
serverUrl,
...(openCodeSessionEnv != null ? { environment: openCodeSessionEnv } : {}),
environment: openCodeSessionEnv,
});
const client = openCodeRuntime.createOpenCodeSdkClient({
baseUrl: server.url,
Expand Down
Loading
Loading