Skip to content

feat: Remote permission server #1187

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
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
72 changes: 72 additions & 0 deletions codex-cli/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from enum import Enum
from typing import Literal, Optional
import socketio
from fastapi import FastAPI
from pydantic import BaseModel, ValidationError
from rich.console import Console
from rich.prompt import Prompt
import asyncio

console = Console()



class Decision(str, Enum):
YES = "yes"
NO_CONTINUE = "no-continue"
NO_EXIT = "no-exit"
EXPLAIN = "explain"

class PermissionRequest(BaseModel):
type: Literal["permission-request"] = "permission-request"
agentId: str
message: str


class PermissionResponse(BaseModel):
type: Literal["permission-response"] = "permission-response"
agentId: str
decision: Decision
customDenyMessage: Optional[str] = None

class Config:
use_enum_values = True


# socket.io + FastAPI setup
sio = socketio.AsyncServer(
cors_allowed_origins="*",
async_mode="asgi",
# logger=True,
# engineio_logger=True,
ping_interval=25,
ping_timeout=60,
)
app = FastAPI()
app.mount("/", socketio.ASGIApp(sio, socketio_path="socket.io"))


@sio.event
async def connect(sid, environ):
console.log(f"[green]Client connected:[/green] {sid}")


@sio.on("permission_request")
async def on_permission_request(sid, data):
try:
req = PermissionRequest.parse_obj(data)
except ValidationError as exc:
console.log(f"[red]Invalid request from {sid}[/red]: {exc}")
return

console.print(f"🟡 New request from [bold]{req.agentId}[/bold]: {req.message}")

# Run Prompt.ask in a thread
choice = await asyncio.to_thread(Prompt.ask, "Reply", choices=[d.value for d in Decision], show_choices=True)

custom_msg = None
if choice == Decision.NO_CONTINUE.value:
custom_msg = (await asyncio.to_thread(Prompt.ask, "Custom deny message", default="")).strip() or None

resp = PermissionResponse(agentId=req.agentId, decision=Decision(choice), customDenyMessage=custom_msg)
await sio.emit("permission_response", resp.dict(exclude_none=True), room=sid)
139 changes: 138 additions & 1 deletion codex-cli/src/cli.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ if (major < 22) {
(process as any).noDeprecation = true;

import type { AppRollout } from "./app";
import type { ApprovalPolicy } from "./approvals";
import type { ApplyPatchCommand, ApprovalPolicy } from "./approvals";
import type { CommandConfirmation } from "./utils/agent/agent-loop";
import type { AppConfig } from "./utils/config";
import type { ResponseItem } from "openai/resources/responses/responses";
Expand All @@ -28,7 +28,9 @@ import type { ReasoningEffort } from "openai/resources.mjs";
import App from "./app";
import { runSinglePass } from "./cli-singlepass";
import SessionsOverlay from "./components/sessions-overlay.js";
import { requestRemotePermission } from "./permission-client";
import { AgentLoop } from "./utils/agent/agent-loop";
import { generateCommandExplanation } from "./utils/agent/generate-explanation";
import { ReviewDecision } from "./utils/agent/review";
import { AutoApprovalMode } from "./utils/auto-approval-mode";
import { checkForUpdates } from "./utils/check-updates";
Expand Down Expand Up @@ -111,11 +113,17 @@ const cli = meow(
-f, --full-context Launch in "full-context" mode which loads the entire repository
into context and applies a batch of edits in one go. Incompatible
with all other flags, except for --model.
-r, --remote-permission Send permission requests to a remote server.
Requires --permission-server-url and --agent-id flags.
--permission-server-url <url>
URL of the remote server to send permission requests to.
--agent-id <id> Agent ID to use for remote permission requests.

Examples
$ codex "Write and run a python program that prints ASCII art"
$ codex -q "fix build issues"
$ codex completion bash
$ codex -r --permission-server-url http://localhost:3000 "Create a CI workflow that runs ESLint on every PR"
`,
{
importMeta: import.meta,
Expand Down Expand Up @@ -213,6 +221,20 @@ const cli = meow(
description: `Run in full-context editing approach. The model is given the whole code
directory as context and performs changes in one go without acting.`,
},

remotePermission: {
type: "boolean",
aliases: ["r"],
description: "Send permission requests to a remote server. ",
},
permissionServerUrl: {
type: "string",
description: "URL of the remote server to send permission requests to.",
},
agentId: {
type: "string",
description: "Agent ID to use for remote permission requests.",
},
},
},
);
Expand Down Expand Up @@ -572,6 +594,43 @@ const approvalPolicy: ApprovalPolicy =
? AutoApprovalMode.AUTO_EDIT
: config.approvalMode || AutoApprovalMode.SUGGEST;

if (cli.flags.remotePermission) {
if (!cli.flags.permissionServerUrl) {
// eslint-disable-next-line no-console
console.error(
"The --remote-permission flag requires the --permission-server-url flag to be set.",
);
process.exit(1);
}
if (!cli.flags.agentId) {
// We require an agent ID to send remote permission requests, since permission server
// might be dealing with multiple agents.
// eslint-disable-next-line no-console
console.error(
"The --remote-permission flag requires the --agent-id flag to be set.",
);
process.exit(1);
}
if (!prompt || prompt.trim() === "") {
// eslint-disable-next-line no-console
console.error(
'Remote permission mode requires a prompt string, e.g.,: codex -r http://localhost:8080 "Find and fix bugs"',
);
process.exit(1);
}
await runRemoteMode({
agentId: cli.flags.agentId,
url: cli.flags.permissionServerUrl,
prompt,
imagePaths: imagePaths || [],
approvalPolicy,
additionalWritableRoots,
config,
});
onExit();
process.exit(0);
}

const instance = render(
<App
prompt={prompt}
Expand Down Expand Up @@ -684,6 +743,84 @@ async function runQuietMode({
await agent.run([inputItem]);
}

async function runRemoteMode({
agentId,
url,
prompt,
imagePaths,
approvalPolicy,
additionalWritableRoots,
config,
}: {
agentId: string;
url: string;
prompt: string;
imagePaths: Array<string>;
approvalPolicy: ApprovalPolicy;
additionalWritableRoots: ReadonlyArray<string>;
config: AppConfig;
}): Promise<void> {
const agent = new AgentLoop({
model: config.model,
config: config,
instructions: config.instructions,
provider: config.provider,
approvalPolicy,
additionalWritableRoots,
disableResponseStorage: config.disableResponseStorage,
onItem: (item: ResponseItem) => {
/* Could also send over the wire */
// eslint-disable-next-line no-console
console.log(formatResponseItemForQuietMode(item));
},
onLoading: () => {
/* intentionally ignored in socket mode */
},
onLastResponseId: () => {
/* intentionally ignored in socket mode */
},
getCommandConfirmation: async (
command: Array<string>,
applyPatch: ApplyPatchCommand | undefined,
): Promise<CommandConfirmation> => {
// First request for confirmation
let { decision: review, customDenyMessage } =
await requestRemotePermission(
agentId,
url,
`command: ${command.join(" ")}`,
);

// If the user wants an explanation, generate one and ask again.
if (review === ReviewDecision.EXPLAIN) {
const explanation = await generateCommandExplanation(
command,
config.model,
Boolean(config.flexMode),
config,
);
// Ask for confirmation again, but with the explanation.
const confirmResult = await requestRemotePermission(
url,
`command: ${command.join(" ")}}`,
explanation,
);

// Update the decision based on the second confirmation.
review = confirmResult.decision;
customDenyMessage = confirmResult.customDenyMessage;

// Return the final decision with the explanation.
return { review, customDenyMessage, applyPatch, explanation };
}
return { review, customDenyMessage, applyPatch };
},
});

const inputItem = await createInputItem(prompt, imagePaths);
await agent.run([inputItem]);
}

const exit = () => {
onExit();
process.exit(0);
Expand Down
72 changes: 1 addition & 71 deletions codex-cli/src/components/chat/terminal-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { formatCommandForDisplay } from "../../format-command.js";
import { useConfirmation } from "../../hooks/use-confirmation.js";
import { useTerminalSize } from "../../hooks/use-terminal-size.js";
import { AgentLoop } from "../../utils/agent/agent-loop.js";
import { generateCommandExplanation } from "../../utils/agent/generate-explanation.js";
import { ReviewDecision } from "../../utils/agent/review.js";
import { generateCompactSummary } from "../../utils/compact-summary.js";
import { saveConfig } from "../../utils/config.js";
Expand All @@ -25,7 +26,6 @@ import {
calculateContextPercentRemaining,
uniqueById,
} from "../../utils/model-utils.js";
import { createOpenAIClient } from "../../utils/openai-client.js";
import { shortCwd } from "../../utils/short-path.js";
import { saveRollout } from "../../utils/storage/save-rollout.js";
import { CLI_VERSION } from "../../version.js";
Expand Down Expand Up @@ -66,76 +66,6 @@ const colorsByPolicy: Record<ApprovalPolicy, ColorName | undefined> = {
"full-auto": "green",
};

/**
* Generates an explanation for a shell command using the OpenAI API.
*
* @param command The command to explain
* @param model The model to use for generating the explanation
* @param flexMode Whether to use the flex-mode service tier
* @param config The configuration object
* @returns A human-readable explanation of what the command does
*/
async function generateCommandExplanation(
command: Array<string>,
model: string,
flexMode: boolean,
config: AppConfig,
): Promise<string> {
try {
// Create a temporary OpenAI client
const oai = createOpenAIClient(config);

// Format the command for display
const commandForDisplay = formatCommandForDisplay(command);

// Create a prompt that asks for an explanation with a more detailed system prompt
const response = await oai.chat.completions.create({
model,
...(flexMode ? { service_tier: "flex" } : {}),
messages: [
{
role: "system",
content:
"You are an expert in shell commands and terminal operations. Your task is to provide detailed, accurate explanations of shell commands that users are considering executing. Break down each part of the command, explain what it does, identify any potential risks or side effects, and explain why someone might want to run it. Be specific about what files or systems will be affected. If the command could potentially be harmful, make sure to clearly highlight those risks.",
},
{
role: "user",
content: `Please explain this shell command in detail: \`${commandForDisplay}\`\n\nProvide a structured explanation that includes:\n1. A brief overview of what the command does\n2. A breakdown of each part of the command (flags, arguments, etc.)\n3. What files, directories, or systems will be affected\n4. Any potential risks or side effects\n5. Why someone might want to run this command\n\nBe specific and technical - this explanation will help the user decide whether to approve or reject the command.`,
},
],
});

// Extract the explanation from the response
const explanation =
response.choices[0]?.message.content || "Unable to generate explanation.";
return explanation;
} catch (error) {
log(`Error generating command explanation: ${error}`);

let errorMessage = "Unable to generate explanation due to an error.";
if (error instanceof Error) {
errorMessage = `Unable to generate explanation: ${error.message}`;

// If it's an API error, check for more specific information
if ("status" in error && typeof error.status === "number") {
// Handle API-specific errors
if (error.status === 401) {
errorMessage =
"Unable to generate explanation: API key is invalid or expired.";
} else if (error.status === 429) {
errorMessage =
"Unable to generate explanation: Rate limit exceeded. Please try again later.";
} else if (error.status >= 500) {
errorMessage =
"Unable to generate explanation: OpenAI service is currently unavailable. Please try again later.";
}
}
}

return errorMessage;
}
}

export default function TerminalChat({
config,
prompt: _initialPrompt,
Expand Down
2 changes: 1 addition & 1 deletion codex-cli/src/hooks/use-confirmation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type React from "react";

import { useState, useCallback, useRef } from "react";

type ConfirmationResult = {
export type ConfirmationResult = {
decision: ReviewDecision;
customDenyMessage?: string;
};
Expand Down
Loading