Skip to content

Commit cd096b9

Browse files
feat(server): let users withhold browser access from agents (#7083)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 13458e6 commit cd096b9

9 files changed

Lines changed: 293 additions & 25 deletions

File tree

apps/server/src/provider/CodexDeveloperInstructions.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,19 @@ For browser work, first call \`preview_status\`. If no automation-capable previe
1111
Do not switch to global browser skills, Chrome, Node REPL browser automation, standalone Playwright, or agent-browser merely because the preview is initially closed or a first call fails. Use an alternative browser system only when the T3 preview tools are absent, the user explicitly requests another browser, or \`preview_open\` returns an explicit unsupported/unavailable error. A failed T3 preview tool call should be inspected and retried with corrected arguments when the error is actionable.
1212
`;
1313

14-
export const CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Plan Mode (Conversational)
14+
/**
15+
* The browser block is omitted entirely when the preview tools aren't attached.
16+
* Describing `preview_*` tools that aren't in the turn's tool list would be
17+
* worse than saying nothing: the instructions actively steer the model away
18+
* from Playwright and agent-browser, so leaving them in would talk it out of
19+
* the only browser automation it still has.
20+
*/
21+
const browserToolInstructions = (browserToolsAvailable: boolean): string =>
22+
browserToolsAvailable ? T3_CODE_BROWSER_TOOL_INSTRUCTIONS : "";
23+
24+
export const codexPlanModeDeveloperInstructions = (
25+
browserToolsAvailable: boolean,
26+
): string => `<collaboration_mode># Plan Mode (Conversational)
1527
1628
You work in 3 phases, and you should *chat your way* to a great plan before finalizing it. A great plan is very detailed-intent- and implementation-wise-so that it can be handed to another engineer or agent to be implemented right away. It must be **decision complete**, where the implementer does not need to make any decisions.
1729
@@ -139,10 +151,12 @@ Do not ask "should I proceed?" in the final output. The user can easily switch o
139151
Only produce at most one \`<proposed_plan>\` block per turn, and only when you are presenting a complete spec.
140152
141153
If the user stays in Plan mode and asks for revisions after a prior \`<proposed_plan>\`, any new \`<proposed_plan>\` must be a complete replacement. If the user indicates that the prior plan is not acceptable but does not provide enough information to produce a complete replacement, address the concern and continue planning without producing a \`<proposed_plan>\` block. If the follow-up neither requires changes nor calls the plan into question (e.g. clarifying question), answer it before the block, then reproduce the prior \`<proposed_plan>\` unchanged.
142-
${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
154+
${browserToolInstructions(browserToolsAvailable)}
143155
</collaboration_mode>`;
144156

145-
export const CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS = `<collaboration_mode># Collaboration Mode: Default
157+
export const codexDefaultModeDeveloperInstructions = (
158+
browserToolsAvailable: boolean,
159+
): string => `<collaboration_mode># Collaboration Mode: Default
146160
147161
You are now in Default mode. Any previous instructions for other modes (e.g. Plan mode) are no longer active.
148162
@@ -153,7 +167,7 @@ Your active mode changes only when new developer instructions with a different \
153167
Use the \`request_user_input\` tool only when it is listed in the available tools for this turn.
154168
155169
In Default mode, strongly prefer making reasonable assumptions and executing the user's request rather than stopping to ask questions. If you absolutely must ask a question because the answer cannot be discovered from local context and a reasonable assumption would be risky, ask the user directly with a concise plain-text question. Never write a multiple choice question as a textual assistant message.
156-
${T3_CODE_BROWSER_TOOL_INSTRUCTIONS}
170+
${browserToolInstructions(browserToolsAvailable)}
157171
</collaboration_mode>`;
158172

159173
export interface CodexRuntimeInfo {
@@ -169,11 +183,17 @@ function toSingleLine(value: string): string {
169183
export function buildCodexDeveloperInstructions(
170184
interactionMode: ProviderInteractionMode,
171185
runtime: CodexRuntimeInfo,
186+
/**
187+
* Whether the `t3-code` MCP server is attached to this turn. Callers derive
188+
* it from the session's actual MCP configuration rather than re-reading the
189+
* setting, so the prompt cannot claim tools the turn doesn't have.
190+
*/
191+
browserToolsAvailable = true,
172192
): string {
173193
const base =
174194
interactionMode === "plan"
175-
? CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS
176-
: CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS;
195+
? codexPlanModeDeveloperInstructions(browserToolsAvailable)
196+
: codexDefaultModeDeveloperInstructions(browserToolsAvailable);
177197
return `${base}
178198
179199
<runtime_info>In case you're asked: you are running in T3 Code through the Codex harness, as ${toSingleLine(runtime.model)} with ${toSingleLine(runtime.reasoningEffort)} reasoning effort. No need to mention this otherwise.</runtime_info>`;

apps/server/src/provider/Layers/CodexSessionRuntime.test.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema";
1111

1212
import {
1313
buildCodexDeveloperInstructions,
14-
CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
15-
CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
14+
codexDefaultModeDeveloperInstructions,
15+
codexPlanModeDeveloperInstructions,
1616
} from "../CodexDeveloperInstructions.ts";
1717
import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts";
1818
import {
@@ -255,7 +255,7 @@ describe("buildCodexDeveloperInstructions", () => {
255255
reasoningEffort: "high",
256256
});
257257

258-
NodeAssert.ok(instructions.startsWith(CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS));
258+
NodeAssert.ok(instructions.startsWith(codexDefaultModeDeveloperInstructions(true)));
259259
NodeAssert.match(instructions, /T3 Code/);
260260
NodeAssert.match(instructions, /Codex harness/);
261261
NodeAssert.match(instructions, /as gpt-5\.3-codex with high reasoning effort/);
@@ -267,7 +267,7 @@ describe("buildCodexDeveloperInstructions", () => {
267267
reasoningEffort: "medium",
268268
});
269269

270-
NodeAssert.ok(instructions.startsWith(CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS));
270+
NodeAssert.ok(instructions.startsWith(codexPlanModeDeveloperInstructions(true)));
271271
NodeAssert.match(instructions, /as gpt-5\.3-codex with medium reasoning effort/);
272272
});
273273

@@ -298,15 +298,41 @@ describe("buildCodexDeveloperInstructions", () => {
298298
describe("T3 browser developer instructions", () => {
299299
it("prefers the product-native preview tools in both collaboration modes", () => {
300300
for (const instructions of [
301-
CODEX_DEFAULT_MODE_DEVELOPER_INSTRUCTIONS,
302-
CODEX_PLAN_MODE_DEVELOPER_INSTRUCTIONS,
301+
codexDefaultModeDeveloperInstructions(true),
302+
codexPlanModeDeveloperInstructions(true),
303303
]) {
304304
NodeAssert.match(instructions, /t3-code/);
305305
NodeAssert.match(instructions, /preview_status/);
306306
NodeAssert.match(instructions, /preview_open/);
307307
NodeAssert.match(instructions, /Do not switch to global browser skills/);
308308
}
309309
});
310+
311+
it("omits the browser block entirely when the preview tools are not attached", () => {
312+
for (const instructions of [
313+
codexDefaultModeDeveloperInstructions(false),
314+
codexPlanModeDeveloperInstructions(false),
315+
]) {
316+
NodeAssert.doesNotMatch(instructions, /preview_status/);
317+
NodeAssert.doesNotMatch(instructions, /preview_open/);
318+
NodeAssert.doesNotMatch(instructions, /T3 Code collaborative browser/);
319+
// Steering away from other browser automation must go with the tools;
320+
// keeping it would leave the model talked out of its only option.
321+
NodeAssert.doesNotMatch(instructions, /Do not switch to global browser skills/);
322+
// The rest of the collaboration mode is untouched.
323+
NodeAssert.match(instructions, /<collaboration_mode>/);
324+
NodeAssert.match(instructions, /<\/collaboration_mode>/);
325+
}
326+
});
327+
328+
it("tracks the turn's MCP configuration rather than defaulting to on", () => {
329+
const runtime = { model: "gpt-5.3-codex", reasoningEffort: "high" };
330+
NodeAssert.match(buildCodexDeveloperInstructions("default", runtime, true), /preview_open/);
331+
NodeAssert.doesNotMatch(
332+
buildCodexDeveloperInstructions("default", runtime, false),
333+
/preview_open/,
334+
);
335+
});
310336
});
311337

312338
describe("hasConfiguredMcpServer", () => {

apps/server/src/provider/Layers/CodexSessionRuntime.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ function buildCodexCollaborationMode(input: {
340340
readonly interactionMode?: ProviderInteractionMode;
341341
readonly model?: string;
342342
readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
343+
readonly browserToolsAvailable?: boolean;
343344
}): EffectCodexSchema.V2TurnStartParams__CollaborationMode | undefined {
344345
if (input.interactionMode === undefined) {
345346
return undefined;
@@ -351,10 +352,11 @@ function buildCodexCollaborationMode(input: {
351352
settings: {
352353
model,
353354
reasoning_effort: reasoningEffort,
354-
developer_instructions: buildCodexDeveloperInstructions(input.interactionMode, {
355-
model,
356-
reasoningEffort,
357-
}),
355+
developer_instructions: buildCodexDeveloperInstructions(
356+
input.interactionMode,
357+
{ model, reasoningEffort },
358+
input.browserToolsAvailable ?? true,
359+
),
358360
},
359361
};
360362
}
@@ -371,6 +373,8 @@ export function buildTurnStartParams(input: {
371373
readonly serviceTier?: CodexServiceTier;
372374
readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort;
373375
readonly interactionMode?: ProviderInteractionMode;
376+
/** Defaults to true so callers that predate the agent-access gate are unchanged. */
377+
readonly browserToolsAvailable?: boolean;
374378
}): Effect.Effect<
375379
CodexTurnStartParamsWithCollaborationMode,
376380
CodexErrors.CodexAppServerProtocolParseError
@@ -391,6 +395,7 @@ export function buildTurnStartParams(input: {
391395
...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
392396
...(input.model ? { model: input.model } : {}),
393397
...(input.effort ? { effort: input.effort } : {}),
398+
browserToolsAvailable: input.browserToolsAvailable ?? true,
394399
});
395400

396401
return decodeCodexTurnStartParamsWithCollaborationMode({
@@ -1822,6 +1827,10 @@ export const makeCodexSessionRuntime = (
18221827
...(input.serviceTier ? { serviceTier: input.serviceTier } : {}),
18231828
...(input.effort ? { effort: input.effort } : {}),
18241829
...(input.interactionMode ? { interactionMode: input.interactionMode } : {}),
1830+
// Derived from the session's own MCP configuration rather than the
1831+
// setting, so the prompt describes the tools this turn actually
1832+
// has even if the setting changed after the session started.
1833+
browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs),
18251834
});
18261835
const rawResponse = yield* client.raw.request("turn/start", params);
18271836
const response = yield* decodeV2TurnStartResponse(rawResponse).pipe(

apps/server/src/provider/Layers/ProviderService.test.ts

Lines changed: 89 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type {
1212
} from "@t3tools/contracts";
1313
import {
1414
ApprovalRequestId,
15+
EnvironmentId,
1516
EventId,
1617
ProviderDriverKind,
1718
ProviderInstanceId,
@@ -20,7 +21,7 @@ import {
2021
TurnId,
2122
} from "@t3tools/contracts";
2223
import { createModelSelection } from "@t3tools/shared/model";
23-
import { it, assert, vi } from "@effect/vitest";
24+
import { it, assert, describe, vi } from "@effect/vitest";
2425

2526
import * as Effect from "effect/Effect";
2627
import * as Exit from "effect/Exit";
@@ -1957,3 +1958,90 @@ validation.layer("ProviderServiceLive validation", (it) => {
19571958
}),
19581959
);
19591960
});
1961+
1962+
describe("agent browser access", () => {
1963+
const revokedThreads: Array<ThreadId> = [];
1964+
1965+
const startSessionWith = (enableAgentBrowserAccess: boolean, threadId: ThreadId) =>
1966+
Effect.gen(function* () {
1967+
const issued: Array<ThreadId> = [];
1968+
const codex = makeFakeCodexAdapter();
1969+
const providerAdapterLayer = Layer.succeed(
1970+
ProviderAdapterRegistry.ProviderAdapterRegistry,
1971+
makeAdapterRegistryMock({ [CODEX_DRIVER]: codex.adapter }),
1972+
);
1973+
const runtimeRepositoryLayer = ProviderSessionRuntime.layer.pipe(
1974+
Layer.provide(SqlitePersistenceMemory),
1975+
);
1976+
const directoryLayer = ProviderSessionDirectoryLive.pipe(
1977+
Layer.provide(runtimeRepositoryLayer),
1978+
);
1979+
const providerLayer = makeProviderServiceLive({
1980+
issueMcpCredential: (request) =>
1981+
Effect.sync(() => {
1982+
issued.push(request.threadId);
1983+
return undefined;
1984+
}),
1985+
revokeMcpCredential: (revoked) => Effect.sync(() => void revokedThreads.push(revoked)),
1986+
}).pipe(
1987+
Layer.provide(providerAdapterLayer),
1988+
Layer.provide(directoryLayer),
1989+
Layer.provide(ServerSettings.ServerSettingsService.layerTest({ enableAgentBrowserAccess })),
1990+
Layer.provide(serverConfigTestLayer),
1991+
Layer.provide(AnalyticsService.layerTest),
1992+
Layer.provide(
1993+
Layer.succeed(
1994+
ProviderEventLoggers.ProviderEventLoggers,
1995+
ProviderEventLoggers.NoOpProviderEventLoggers,
1996+
),
1997+
),
1998+
);
1999+
2000+
yield* Effect.gen(function* () {
2001+
const provider = yield* ProviderService.ProviderService;
2002+
return yield* provider.startSession(threadId, {
2003+
provider: CODEX_DRIVER,
2004+
providerInstanceId: codexInstanceId,
2005+
threadId,
2006+
runtimeMode: "full-access",
2007+
});
2008+
}).pipe(Effect.provide(providerLayer));
2009+
2010+
return issued;
2011+
});
2012+
2013+
// Credential issuance is the observable that matters: it is the only place a
2014+
// credential is minted, and `/mcp` accepts nothing else, so withholding it is
2015+
// what actually denies every provider and external MCP client.
2016+
it.effect("requests no MCP credential when agent browser access is off", () =>
2017+
Effect.gen(function* () {
2018+
const issued = yield* startSessionWith(false, asThreadId("thread-browser-off"));
2019+
2020+
assert.deepEqual(issued, []);
2021+
}).pipe(Effect.provide(NodeServices.layer)),
2022+
);
2023+
2024+
it.effect("revokes an already-issued credential when access is off", () =>
2025+
Effect.gen(function* () {
2026+
const threadId = asThreadId("thread-browser-revoke");
2027+
revokedThreads.length = 0;
2028+
2029+
yield* startSessionWith(false, threadId);
2030+
2031+
// Clearing the in-memory map is not enough: a token issued before the
2032+
// toggle flipped stays valid against `/mcp` for its whole liveness
2033+
// window, and later turns refresh it.
2034+
assert.deepEqual(revokedThreads, [threadId]);
2035+
}).pipe(Effect.provide(NodeServices.layer)),
2036+
);
2037+
2038+
it.effect("requests an MCP credential when agent browser access is on", () =>
2039+
Effect.gen(function* () {
2040+
const threadId = asThreadId("thread-browser-on");
2041+
2042+
const issued = yield* startSessionWith(true, threadId);
2043+
2044+
assert.deepEqual(issued, [threadId]);
2045+
}).pipe(Effect.provide(NodeServices.layer)),
2046+
);
2047+
});

apps/server/src/provider/Layers/ProviderService.ts

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ import * as ProviderEventLoggers from "./ProviderEventLoggers.ts";
5757
import * as AnalyticsService from "../../telemetry/AnalyticsService.ts";
5858
import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
5959
import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
60+
import * as ServerSettings from "../../serverSettings.ts";
6061
const isModelSelection = Schema.is(ModelSelection);
6162

6263
/**
@@ -66,6 +67,15 @@ const isModelSelection = Schema.is(ModelSelection);
6667
*/
6768
export interface ProviderServiceLiveOptions {
6869
readonly canonicalEventLogger?: EventNdjsonLogger;
70+
/**
71+
* Overrides MCP credential issuance. The real issuer reads a module-global
72+
* registry that only a running MCP server installs, which makes the
73+
* agent-browser-access gate unobservable from a unit test; this seam lets a
74+
* test see whether a credential was requested at all.
75+
*/
76+
readonly issueMcpCredential?: typeof McpSessionRegistry.issueActiveMcpCredential;
77+
/** Same seam as `issueMcpCredential`, for observing the deny path's revoke. */
78+
readonly revokeMcpCredential?: typeof McpSessionRegistry.revokeActiveMcpThread;
6979
}
7080

7181
type ProviderServiceMethod<Name extends keyof ProviderService.ProviderService["Service"]> =
@@ -215,16 +225,58 @@ const makeProviderService = Effect.fn("makeProviderService")(function* (
215225

216226
const registry = yield* ProviderAdapterRegistry.ProviderAdapterRegistry;
217227
const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory;
228+
const serverSettings = yield* ServerSettings.ServerSettingsService;
229+
const issueMcpCredential =
230+
options?.issueMcpCredential ?? McpSessionRegistry.issueActiveMcpCredential;
231+
const revokeMcpCredential =
232+
options?.revokeMcpCredential ?? McpSessionRegistry.revokeActiveMcpThread;
218233
const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>();
219234
const nowIso = Effect.map(DateTime.now, DateTime.formatIso);
235+
/**
236+
* Attach the `t3-code` MCP server to the session that is about to start.
237+
*
238+
* This is the only place a credential is minted, so withholding one here is
239+
* what disables agent browser access everywhere: every adapter already
240+
* treats a missing session as "no MCP server", and the `/mcp` endpoint
241+
* accepts nothing but tokens issued from this path.
242+
*/
243+
/**
244+
* Deny on an unreadable settings file rather than letting the read failure
245+
* escape: adding `ServerSettingsError` to `ProviderServiceError` would widen
246+
* a union every caller handles, for a branch that only decides whether one
247+
* optional toolset is attached. Denying is the safe direction — an explicit
248+
* "off" silently becoming "on" would violate the user's stated choice,
249+
* whereas the reverse costs an agent one toolset and is visible immediately.
250+
*/
251+
const agentBrowserAccessEnabled = serverSettings.getSettings.pipe(
252+
Effect.map((settings) => settings.enableAgentBrowserAccess),
253+
Effect.catch((cause) =>
254+
Effect.logWarning(
255+
"Could not read server settings; withholding agent browser access for this session.",
256+
{ cause },
257+
).pipe(Effect.as(false)),
258+
),
259+
);
260+
220261
const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) =>
221-
McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe(
222-
Effect.tap((credential) =>
223-
credential
224-
? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config))
225-
: Effect.void,
226-
),
227-
);
262+
Effect.gen(function* () {
263+
if (!(yield* agentBrowserAccessEnabled)) {
264+
// Revoke as well as clear. Every other prepare path reaches
265+
// `issueActiveMcpCredential`, which revokes the thread first, so
266+
// skipping it here would leave a previously issued bearer token valid
267+
// against `/mcp` for the rest of its liveness window — and later turns
268+
// would keep refreshing it. A session restart (runtime mode, cwd,
269+
// model) re-prepares without stopping, so it relies on this.
270+
yield* revokeMcpCredential(threadId);
271+
yield* Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId));
272+
return undefined;
273+
}
274+
const credential = yield* issueMcpCredential({ threadId, providerInstanceId });
275+
if (credential) {
276+
yield* Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config));
277+
}
278+
return credential;
279+
});
228280
const clearMcpSession = (threadId: ThreadId) =>
229281
McpSessionRegistry.revokeActiveMcpThread(threadId).pipe(
230282
Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))),

0 commit comments

Comments
 (0)