mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 18:22:38 +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
|
must fail closed for Hub unattended execution until it can approve one exact injected MCP server
|
||||||
and tool identity without approving native tools.
|
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
|
## Two Integration Patterns
|
||||||
|
|
||||||
### ACP (Agent Client Protocol) -- recommended
|
### 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 () => {
|
test("preapproves only granted tools on the injected Codex MCP server", async () => {
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
modeId: undefined,
|
modeId: undefined,
|
||||||
@@ -1024,6 +1050,80 @@ describe("Codex app-server provider", () => {
|
|||||||
await session.close();
|
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 () => {
|
test("initializes Codex app-server without making Paseo the request originator", async () => {
|
||||||
let initializeParams: unknown;
|
let initializeParams: unknown;
|
||||||
const appServer = createFakeCodexAppServer({
|
const appServer = createFakeCodexAppServer({
|
||||||
|
|||||||
@@ -830,6 +830,22 @@ function toObjectRecord(value: unknown): Record<string, unknown> | undefined {
|
|||||||
return isRecord(value) ? value : 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
|
// Codex app-server API response types
|
||||||
interface CodexReasoningEffortEntry {
|
interface CodexReasoningEffortEntry {
|
||||||
reasoningEffort?: string;
|
reasoningEffort?: string;
|
||||||
@@ -6414,6 +6430,16 @@ export class CodexAppServerAgentSession implements AgentSession {
|
|||||||
if (parsed.mode === "url") {
|
if (parsed.mode === "url") {
|
||||||
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
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;
|
const requiredFields = toObjectRecord(parsed.requestedSchema)?.required;
|
||||||
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
|
if (Array.isArray(requiredFields) && requiredFields.length > 0) {
|
||||||
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
return Promise.resolve({ action: "decline", content: null, _meta: null });
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ export interface FakeCodexAppServer {
|
|||||||
readonly child: CodexAppServerChildProcess;
|
readonly child: CodexAppServerChildProcess;
|
||||||
readonly recordedRollbacks: JsonObject[];
|
readonly recordedRollbacks: JsonObject[];
|
||||||
assertNoErrors(): void;
|
assertNoErrors(): void;
|
||||||
|
waitForThreadStart(): Promise<JsonObject>;
|
||||||
waitForTurnStart(): Promise<JsonObject>;
|
waitForTurnStart(): Promise<JsonObject>;
|
||||||
nextResponse(): Promise<string>;
|
nextResponse(): Promise<string>;
|
||||||
startsTurn(params: { threadId: string; turnId?: string }): void;
|
startsTurn(params: { threadId: string; turnId?: string }): void;
|
||||||
@@ -297,6 +298,13 @@ export function createFakeCodexAppServer(
|
|||||||
throw errors[0];
|
throw errors[0];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async waitForThreadStart() {
|
||||||
|
const message = await waitForMessage(
|
||||||
|
(candidate) => candidate.method === "thread/start",
|
||||||
|
"thread start request",
|
||||||
|
);
|
||||||
|
return toJsonObject(message.params);
|
||||||
|
},
|
||||||
async waitForTurnStart() {
|
async waitForTurnStart() {
|
||||||
const message = await waitForMessage(
|
const message = await waitForMessage(
|
||||||
(candidate) => candidate.method === "turn/start",
|
(candidate) => candidate.method === "turn/start",
|
||||||
|
|||||||
Reference in New Issue
Block a user