mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7533e8330 |
@@ -37,6 +37,11 @@ Each provider definition owns its option schema and exact MCP preapproval mappin
|
||||
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||
and tool identity without approving native tools.
|
||||
|
||||
Codex **Full Access** sends `approvalPolicy: "never"` and `danger-full-access` to the app server.
|
||||
If a permission-only MCP form still reaches the provider, Paseo accepts it when its schema has no
|
||||
properties. Forms with fields and URL/auth flows are declined because Paseo cannot supply user data
|
||||
or credentials. Restricted Codex modes keep their existing approval behavior.
|
||||
|
||||
## Two Integration Patterns
|
||||
|
||||
### ACP (Agent Client Protocol) -- recommended
|
||||
|
||||
@@ -564,6 +564,32 @@ describe("Codex app-server provider", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("launches full-access turns with Codex's no-approval unrestricted policy", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
|
||||
try {
|
||||
await session.connect();
|
||||
await session.startTurn("run an ordinary command");
|
||||
|
||||
await expect(appServer.waitForThreadStart()).resolves.toMatchObject({
|
||||
approvalPolicy: "never",
|
||||
sandbox: "danger-full-access",
|
||||
});
|
||||
await expect(appServer.waitForTurnStart()).resolves.toMatchObject({
|
||||
approvalPolicy: "never",
|
||||
sandboxPolicy: { type: "dangerFullAccess" },
|
||||
});
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("preapproves only granted tools on the injected Codex MCP server", async () => {
|
||||
const session = createSession({
|
||||
modeId: undefined,
|
||||
@@ -1024,6 +1050,80 @@ describe("Codex app-server provider", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("allows permission-only MCP elicitations in full-access mode", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
await session.connect();
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
appServer.requestMcpElicitation({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
serverName: "github",
|
||||
message: "Allow Codex to use the GitHub connector?",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
|
||||
action: "accept",
|
||||
content: {},
|
||||
_meta: null,
|
||||
});
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("does not fabricate data for a data-bearing full-access MCP elicitation", async () => {
|
||||
const appServer = createFakeCodexAppServer();
|
||||
const session = new CodexAppServerAgentSession(
|
||||
createConfig({ cwd: "/workspace/project", modeId: "full-access" }),
|
||||
null,
|
||||
createTestLogger(),
|
||||
async () => appServer.child,
|
||||
);
|
||||
const events: AgentStreamEvent[] = [];
|
||||
|
||||
await session.connect();
|
||||
session.subscribe((event) => events.push(event));
|
||||
|
||||
try {
|
||||
appServer.requestMcpElicitation({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
serverName: "github",
|
||||
message: "Enter the repository name to use.",
|
||||
requestedSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
repository: { type: "string" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(appServer.waitForMcpElicitationDecision()).resolves.toEqual({
|
||||
action: "decline",
|
||||
content: null,
|
||||
_meta: null,
|
||||
});
|
||||
expect(events).not.toContainEqual(expect.objectContaining({ type: "permission_requested" }));
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("initializes Codex app-server without making Paseo the request originator", async () => {
|
||||
let initializeParams: unknown;
|
||||
const appServer = createFakeCodexAppServer({
|
||||
|
||||
@@ -830,6 +830,22 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
||||
return isRecord(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function isPermissionOnlyMcpElicitationSchema(schema: unknown): boolean {
|
||||
const schemaRecord = toObjectRecord(schema);
|
||||
const properties = toObjectRecord(schemaRecord?.properties);
|
||||
return properties !== undefined && Object.keys(properties).length === 0;
|
||||
}
|
||||
|
||||
function isCodexFullAccessMode(modeId: string, providerOptions: CodexProviderOptions): boolean {
|
||||
return (
|
||||
modeId === "full-access" &&
|
||||
(providerOptions.approval_policy === undefined ||
|
||||
providerOptions.approval_policy === "never") &&
|
||||
(providerOptions.sandbox_mode === undefined ||
|
||||
providerOptions.sandbox_mode === "danger-full-access")
|
||||
);
|
||||
}
|
||||
|
||||
// Codex app-server API response types
|
||||
interface CodexReasoningEffortEntry {
|
||||
reasoningEffort?: string;
|
||||
@@ -6414,6 +6430,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
||||
if (parsed.mode === "url") {
|
||||
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
||||
}
|
||||
if (isCodexFullAccessMode(this.currentMode, this.providerOptions)) {
|
||||
// Codex's never-ask policy accepts only empty MCP forms and declines
|
||||
// forms that need data. Keep that provider contract when an app-server
|
||||
// request still reaches Paseo; never fabricate form or auth values.
|
||||
return Promise.resolve(
|
||||
isPermissionOnlyMcpElicitationSchema(parsed.requestedSchema)
|
||||
? { action: "accept", content: {}, _meta: null }
|
||||
: { action: "decline", content: null, _meta: null },
|
||||
);
|
||||
}
|
||||
const requiredFields = toObjectRecord(parsed.requestedSchema)?.required;
|
||||
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
|
||||
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface FakeCodexAppServer {
|
||||
readonly child: CodexAppServerChildProcess;
|
||||
readonly recordedRollbacks: JsonObject[];
|
||||
assertNoErrors(): void;
|
||||
waitForThreadStart(): Promise<JsonObject>;
|
||||
waitForTurnStart(): Promise<JsonObject>;
|
||||
nextResponse(): Promise<string>;
|
||||
startsTurn(params: { threadId: string; turnId?: string }): void;
|
||||
@@ -297,6 +298,13 @@ export function createFakeCodexAppServer(
|
||||
throw errors[0];
|
||||
}
|
||||
},
|
||||
async waitForThreadStart() {
|
||||
const message = await waitForMessage(
|
||||
(candidate) => candidate.method === "thread/start",
|
||||
"thread start request",
|
||||
);
|
||||
return toJsonObject(message.params);
|
||||
},
|
||||
async waitForTurnStart() {
|
||||
const message = await waitForMessage(
|
||||
(candidate) => candidate.method === "turn/start",
|
||||
|
||||
Reference in New Issue
Block a user