Skip to content
Open
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
129 changes: 128 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ import * as ModelManifest from "../../provider/ModelManifest.ts";
import * as PortScanner from "../../preview/PortScanner.ts";
import { resolveProviderCompatibility } from "../../provider/providerCompatibility.ts";
import * as CodexAdapterV2 from "./CodexAdapterV2.ts";
import { requestBrowserAccess } from "../../provider/testFixtures/browserAccessClient.ts";
import { withCodexReplayChildMetadata } from "./CodexAdapterV2.testkit.ts";
import * as CodexAdapterV2Testkit from "./CodexAdapterV2.testkit.ts";

Expand Down Expand Up @@ -1372,9 +1373,11 @@ describe("CodexAdapterV2 post-settle continuation", () => {
promptSuggestions: Effect.Effect<boolean> = Effect.succeed(false),
onNotification: (method: string) => Effect.Effect<void> = () => Effect.void,
models?: CodexAdapterV2.CodexAdapterV2Options["models"],
environment: NodeJS.ProcessEnv = {},
) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
let launchEnvironment: NodeJS.ProcessEnv = {};
const idAllocator = yield* IdAllocator.IdAllocatorV2;
const serverConfig = yield* ProviderReplayHarness.makeReplayServerConfig(
transcript.scenario,
Expand All @@ -1389,6 +1392,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
const clientFactory: CodexAdapterV2.CodexAppServerClientFactoryShape = {
open: (openInput) =>
Layer.build(CodexReplay.layerReplayWithDriver(driver)).pipe(
Effect.tap(() => Effect.sync(() => (launchEnvironment = openInput.environment))),
Effect.mapError(
(cause) =>
new ProviderAdapter.ProviderAdapterOpenSessionError({
Expand Down Expand Up @@ -1431,7 +1435,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
const adapter = yield* CodexAdapterV2.makeCodexAdapterV2({
instanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
settings: DEFAULT_CODEX_SETTINGS,
environment: {},
environment,
clientFactory,
...(onUsageLimits ? { onUsageLimits } : {}),
...(models ? { models } : {}),
Expand Down Expand Up @@ -1502,6 +1506,7 @@ describe("CodexAdapterV2 post-settle continuation", () => {
providerThread,
threadId,
serverConfig,
launchEnvironment,
events,
continuationRequests,
terminalEvents,
Expand Down Expand Up @@ -1581,6 +1586,128 @@ describe("CodexAdapterV2 post-settle continuation", () => {
),
);

it.effect("answers the browser plugin's access check only for this session's running turns", () =>
Effect.gen(function* () {
const scenario = "browser-access";
const nativeThreadId = "browser-access-thread";
const nativeTurnId = "browser-access-turn";
const completeTurn = yield* Deferred.make<void>();
const sessions = yield* McpProviderSessions.McpProviderSessions;
const browserAccessWithdrawn = yield* Deferred.make<void>();
const mcpSession = {
environmentId: EnvironmentId.make("environment-browser-access"),
threadId: ThreadId.make(`thread-${scenario}`),
providerSessionId: "browser-access-mcp",
providerInstanceId: CodexAdapterV2.CODEX_DEFAULT_INSTANCE_ID,
endpoint: "http://127.0.0.1:1/mcp",
authorizationHeader: "Bearer fixture",
browserToolsAvailable: true,
browserAccessAllowed: Deferred.isDone(browserAccessWithdrawn).pipe(
Effect.map((done) => !done),
),
};
yield* sessions.set(mcpSession);
const entries = codexReplayPreamble({ nativeThreadId, nativeTurnId, prompt: "work" });
entries[5] = {
type: "expect_outbound",
label: "turn/start",
frame: {
id: 3,
method: "turn/start",
params: yield* CodexAdapterV2.buildCodexTurnStartParams({
nativeThreadId,
codexInput: [{ type: "text", text: "work" }],
runtimePolicy: CODEX_TEST_RUNTIME_POLICY,
modelSelection: CODEX_TEST_MODEL_SELECTION,
hasT3Mcp: true,
}),
},
};
entries.push({
type: "emit_inbound",
label: "browser turn completed",
frame: {
method: "turn/completed",
params: {
threadId: nativeThreadId,
turn: makeCodexReplayTurn({ id: nativeTurnId, status: "completed" }),
},
},
});
const inherited = {
BROWSER_USE_ACCESS_EXECUTOR_ID: "other-app-executor",
BROWSER_USE_ACCESS_PIPE_PATH: "/other-app/socket",
BROWSER_USE_ACCESS_VERIFYING_IDENTITY: "other-app-identity",
};
const harness = yield* makeCodexReplayHarness(
makeCodexReplayTranscript({ scenario, entries }),
undefined,
undefined,
undefined,
(entry) =>
entry.label === "browser turn completed" ? Deferred.await(completeTurn) : Effect.void,
undefined,
undefined,
undefined,
undefined,
inherited,
);
const environment = harness.launchEnvironment;
assert.notEqual(
environment.BROWSER_USE_ACCESS_EXECUTOR_ID,
inherited.BROWSER_USE_ACCESS_EXECUTOR_ID,
);
assert.notEqual(
environment.BROWSER_USE_ACCESS_PIPE_PATH,
inherited.BROWSER_USE_ACCESS_PIPE_PATH,
);
const allowed = (
request: { sessionId?: string; turnId?: string; executorId?: string } = {},
) =>
requestBrowserAccess(environment, {
sessionId: nativeThreadId,
turnId: nativeTurnId,
...request,
});

assert.isFalse(yield* allowed());
yield* harness.runtime.startTurn(
makeCodexTestTurnInput({
threadId: harness.threadId,
providerThread: harness.providerThread,
now: yield* DateTime.now,
attemptId: RunAttemptId.make("browser-access-attempt"),
text: "work",
}),
);
assert.isTrue(yield* allowed());
assert.isFalse(yield* allowed({ sessionId: "other-session" }));
assert.isFalse(yield* allowed({ turnId: "other-turn" }));
assert.isFalse(yield* allowed({ executorId: "other-executor" }));
yield* Deferred.succeed(browserAccessWithdrawn, undefined);
assert.isFalse(yield* allowed());
yield* sessions.set({ ...mcpSession, browserToolsAvailable: false });
assert.isFalse(yield* allowed());
yield* sessions.clear(mcpSession.threadId);
assert.isFalse(yield* allowed());
yield* sessions.set({ ...mcpSession, browserAccessAllowed: Effect.succeed(true) });
assert.isTrue(yield* allowed());
yield* Deferred.succeed(completeTurn, undefined);
yield* harness.firstTerminal;
assert.isFalse(yield* allowed());
}).pipe(
Effect.scoped,
Effect.provide(
Layer.mergeAll(
IdAllocator.layer,
McpProviderSessions.layer,
NodeServices.layer,
NodeCrypto.layer,
),
),
),
);

it.effect.each(["before", "after"] as const)(
"rejects a previous run's native turn when it completes %s the new start response",
(completionOrder) =>
Expand Down
40 changes: 38 additions & 2 deletions apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
import type { ManagedServerProvider } from "@t3tools/provider-core/server/snapshot";
import type { CodexEffectiveRuntime } from "../../provider/CodexManagedRuntime.ts";
import { classifyCodexManagedError } from "../../provider/CodexManagedErrors.ts";
import { makeCodexBrowserAccessHost } from "../../provider/CodexBrowserAccessHost.ts";
import {
buildCodexInitializeParams,
codexAppServerEnvironment,
Expand Down Expand Up @@ -1872,13 +1873,49 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
}),
),
);
const activeTurns = yield* Ref.make(new Map<string, ActiveCodexTurnContext>());
// OpenAI's browser plugin asks the host before each turn; never inherit another app's host.
const browserAccessEnvironment = yield* makeCodexBrowserAccessHost(
Effect.fn("CodexAdapterV2.authorizeBrowserAccess")(function* (request) {
const context = (yield* Ref.get(activeTurns)).get(request.turnId);
if (context === undefined) return false;
let root = context;
while (root.subagent !== null) root = root.subagent.parentContext;
// Codex reports the root session id for subagent turns.
if (
context.providerThread.nativeThreadRef?.nativeId !== request.sessionId &&
root.providerThread.nativeThreadRef?.nativeId !== request.sessionId
)
return false;
const session = yield* readMcpSession(context.input.threadId);
if (session?.browserToolsAvailable !== true) return false;
return session.browserAccessAllowed === undefined
? true
: yield* session.browserAccessAllowed;
}),
).pipe(
Effect.catch((cause) =>
Effect.logWarning("Browser plugin access is unavailable for this session.", {
cause,
}).pipe(
Effect.as({
BROWSER_USE_ACCESS_EXECUTOR_ID: "",
BROWSER_USE_ACCESS_PIPE_PATH: "",
BROWSER_USE_ACCESS_VERIFYING_IDENTITY: "",
}),
),
),
);
const client = yield* clientFactory.open({
instanceId: adapterOptions.instanceId,
threadId: input.threadId,
providerSessionId: input.providerSessionId,
runtimePolicy: input.runtimePolicy,
settings: resolvedRuntime?.config ?? adapterOptions.settings,
environment: resolvedRuntime?.environment ?? adapterOptions.environment,
environment: {
...(resolvedRuntime?.environment ?? adapterOptions.environment),
...browserAccessEnvironment,
},
});
const additionalContextByThread = yield* Ref.make(
new Map<string, NonNullable<CodexTurnStartParams["additionalContext"]>>(),
Expand Down Expand Up @@ -1995,7 +2032,6 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* (
const limitedTurnItems = yield* Ref.make(
new Map<ProviderThreadId, Extract<OrchestrationV2TurnItem, { type: "error" }>>(),
);
const activeTurns = yield* Ref.make(new Map<string, ActiveCodexTurnContext>());
// Codex reports MCP startup on thread start, usually before the turn context exists.
const mcpStartupFailures = new Map<string, Map<string, string>>();
const turnTokenUsageByThread = new Map<string, CodexTurnTokenUsageState>();
Expand Down
3 changes: 3 additions & 0 deletions apps/server/src/orchestration-v2/ProviderSessionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,9 @@ export const layerWithOptions = (
});
yield* mcpSessions.set({
...credential.config,
browserAccessAllowed: agentAccessSettings(threadId).pipe(
Effect.map((access) => access.browser),
),
hasPendingWake: projectionStore
.getThreadRecords(threadId, ["runs", "subagents"])
.pipe(
Expand Down
30 changes: 30 additions & 0 deletions apps/server/src/provider/CodexBrowserAccessHost.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// @effect-diagnostics nodeBuiltinImport:off -- Checks the real private socket directory and teardown.
import * as NodeFSP from "node:fs/promises";
import * as NodePath from "node:path";
import { assert, describe, it } from "@effect/vitest";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as Effect from "effect/Effect";
import { makeCodexBrowserAccessHost } from "./CodexBrowserAccessHost.ts";

describe("CodexBrowserAccessHost", () => {
it.effect("keeps its socket in an owner-only directory that closing removes", () =>
Effect.gen(function* () {
if ((yield* HostProcess.Platform) === "win32") return;
const directory = yield* Effect.scoped(
Effect.gen(function* () {
const environment = yield* makeCodexBrowserAccessHost(() => Effect.succeed(true));
const directory = NodePath.dirname(environment.BROWSER_USE_ACCESS_PIPE_PATH);
assert.equal((yield* Effect.promise(() => NodeFSP.stat(directory))).mode & 0o777, 0o700);
return directory;
}),
);
const exists = yield* Effect.promise(() =>
NodeFSP.access(directory).then(
() => true,
() => false,
),
);
assert.isFalse(exists);
}),
);
});
Loading
Loading