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
67 changes: 66 additions & 1 deletion src/AcpExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ export {

export const LEGACY_SET_SESSION_MODEL_METHOD = "session/set_model";
export const SESSION_STEERING_METHOD = "_session/steering";
export const KANDEV_GUARDED_TTY_CAPABILITY = "kandev.guarded-tty-exec";
export const KANDEV_GUARDED_TTY_VERSION = 1;
export const KANDEV_GUARDED_TTY_CAPABILITY_METHOD = "_kandev/guarded_tty/capability";
export const KANDEV_GUARDED_TTY_EXEC_METHOD = "_kandev/guarded_tty/exec";

export type LegacySessionModel = {
modelId: string;
Expand Down Expand Up @@ -63,14 +67,18 @@ export type ExtMethodRequest =
| LegacySetSessionModelExtRequest
| SessionSteeringExtRequest
| GoalControlExtRequest
| KandevGuardedTtyCapabilityExtRequest
| KandevGuardedTtyExecExtRequest

export function isExtMethodRequest(request: { method: string, params: Record<string, unknown> }): request is ExtMethodRequest {
return request.method === "authentication/status"
|| request.method === "authentication/logout"
|| request.method === LEGACY_SET_SESSION_MODEL_METHOD
|| request.method === GOAL_CONTROL_METHOD
|| request.method === LEGACY_GOAL_CONTROL_METHOD
|| request.method === SESSION_STEERING_METHOD;
|| request.method === SESSION_STEERING_METHOD
|| request.method === KANDEV_GUARDED_TTY_CAPABILITY_METHOD
|| request.method === KANDEV_GUARDED_TTY_EXEC_METHOD;
}

export type AuthenticationStatusRequest = { method: "authentication/status", params: {} }
Expand Down Expand Up @@ -110,6 +118,63 @@ export type SessionSteeringExtRequest = {
params: SessionSteerRequest;
}

export type KandevGuardedTtyCapabilityRequest = {
sessionId: SessionId;
}

export type KandevGuardedTtyCapabilityResponse = {
capability: typeof KANDEV_GUARDED_TTY_CAPABILITY;
version: typeof KANDEV_GUARDED_TTY_VERSION;
supported: true;
capability_method: typeof KANDEV_GUARDED_TTY_CAPABILITY_METHOD;
exec_method: typeof KANDEV_GUARDED_TTY_EXEC_METHOD;
session_id: SessionId;
}

export type KandevGuardedTtyCapabilityExtRequest = {
method: typeof KANDEV_GUARDED_TTY_CAPABILITY_METHOD;
params: KandevGuardedTtyCapabilityRequest;
}

export type KandevGuardedTtyExecRequest = {
sessionId: SessionId;
argv: string[];
}

export type KandevGuardedTtyDenialCode =
| "app_server_error"
| "cancelled"
| "invalid_output"
| "output_overflow"
| "stale_session"
| "timeout";

export type KandevGuardedTtyExecReceipt = {
capability: typeof KANDEV_GUARDED_TTY_CAPABILITY;
version: typeof KANDEV_GUARDED_TTY_VERSION;
session_id: SessionId;
method: "command/exec";
requested_tty: true;
dispatched_tty: boolean;
process_id: string | null;
cwd: string | null;
outcome: "completed" | "failed" | "denied";
denial_code: KandevGuardedTtyDenialCode | null;
stdout: string;
stderr: string;
stdout_bytes: number;
stderr_bytes: number;
output_bytes: number;
exit_code: number | null;
started_at: string;
completed_at: string;
}

export type KandevGuardedTtyExecExtRequest = {
method: typeof KANDEV_GUARDED_TTY_EXEC_METHOD;
params: KandevGuardedTtyExecRequest;
}

export async function steerSessionWithFallback(
connection: Pick<ClientContext, "request">,
params: SessionSteerRequest,
Expand Down
9 changes: 9 additions & 0 deletions src/CodexAcpClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ import {
import {CodexSubagentSubscriptions} from "./subagents/CodexSubagentSubscriptions";
import {forkSession as runForkSession} from "./SessionFork";
import type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";
import {
executeGuardedTtyExec,
type GuardedTtyExecOptions,
} from "./GuardedTtyExec";
import type {KandevGuardedTtyExecReceipt} from "./AcpExtensions";
export type {SessionMetadata, SessionMetadataWithThread} from "./SessionMetadata";

/**
Expand Down Expand Up @@ -149,6 +154,10 @@ export class CodexAcpClient {
return this.configPath;
}

async guardedTtyExec(options: GuardedTtyExecOptions): Promise<KandevGuardedTtyExecReceipt> {
return await executeGuardedTtyExec(this.codexClient, options);
}

async authenticate(
authRequest: acp.AuthenticateRequest,
urlElicitationRequester?: UrlElicitationRequester,
Expand Down
127 changes: 126 additions & 1 deletion src/CodexAcpServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ import {
GOAL_CONTROL_METHOD,
GOAL_EXTENSION_VERSION,
isExtMethodRequest,
KANDEV_GUARDED_TTY_CAPABILITY,
KANDEV_GUARDED_TTY_CAPABILITY_METHOD,
KANDEV_GUARDED_TTY_EXEC_METHOD,
KANDEV_GUARDED_TTY_VERSION,
LEGACY_GOAL_CONTROL_METHOD,
LEGACY_SET_SESSION_MODEL_METHOD,
type LegacyLoadSessionResponse,
Expand All @@ -66,6 +70,10 @@ import {
SESSION_STEERING_METHOD,
type SessionSteeringResponse,
type SessionSteerRequest,
type KandevGuardedTtyCapabilityRequest,
type KandevGuardedTtyCapabilityResponse,
type KandevGuardedTtyExecReceipt,
type KandevGuardedTtyExecRequest,
} from "./AcpExtensions";
import {
createCollabAgentToolCallUpdate,
Expand Down Expand Up @@ -126,6 +134,10 @@ import {
createUnavailableAgentFileChangeReport,
parseAgentFileChangeReportRequest,
} from "./AgentFileChangeReport";
import {
createUndispatchedGuardedTtyReceipt,
validateGuardedTtyArgv,
} from "./GuardedTtyExec";


export interface SessionState {
Expand Down Expand Up @@ -253,6 +265,7 @@ export class CodexAcpServer {
private readonly sessionGenerations: Map<string, number>;
private readonly sessionOpenGenerations: Map<string, number>;
private readonly goalControlGenerations: Map<string, number>;
private readonly guardedTtyExecutions: Map<string, Set<AbortController>>;
private readonly permissionLifecycleContexts: WeakMap<SessionState, PermissionLifecycleContext>;
private readonly codexProcessState: CodexProcessState | null;
private initializeRequest: acp.InitializeRequest | null = null;
Expand All @@ -275,6 +288,7 @@ export class CodexAcpServer {
this.sessionGenerations = new Map();
this.sessionOpenGenerations = new Map();
this.goalControlGenerations = new Map();
this.guardedTtyExecutions = new Map();
this.permissionLifecycleContexts = new WeakMap();
this.connection = connection;
this.codexAcpClient = codexAcpClient;
Expand Down Expand Up @@ -353,6 +367,12 @@ export class CodexAcpServer {
controlMethod: GOAL_CONTROL_METHOD,
actions: [...GOAL_CONTROL_ACTIONS],
},
guardedTtyExec: {
capability: KANDEV_GUARDED_TTY_CAPABILITY,
version: KANDEV_GUARDED_TTY_VERSION,
capabilityMethod: KANDEV_GUARDED_TTY_CAPABILITY_METHOD,
execMethod: KANDEV_GUARDED_TTY_EXEC_METHOD,
},
[JETBRAINS_META_KEY]: {
[AIR_META_KEY]: {
[AIR_EXTENSION_VERSION_KEY]: AIR_EXTENSION_VERSION,
Expand All @@ -367,7 +387,11 @@ export class CodexAcpServer {
};
}

async extMethod(method: string, params: Record<string, unknown>): Promise<Record<string, unknown>> {
async extMethod(
method: string,
params: Record<string, unknown>,
signal?: AbortSignal,
): Promise<Record<string, unknown>> {
const methodRequest = { method: method, params: params };
if (!isExtMethodRequest(methodRequest)) {
return {};
Expand All @@ -383,6 +407,13 @@ export class CodexAcpServer {
return await this.unstable_setSessionModel(this.parseLegacySetSessionModelParams(methodRequest.params));
case SESSION_STEERING_METHOD:
return await this.executeOrQueueSteeringRequest(this.parseSessionSteerParams(methodRequest.params));
case KANDEV_GUARDED_TTY_CAPABILITY_METHOD:
return this.guardedTtyCapability(this.parseGuardedTtyCapabilityParams(methodRequest.params));
case KANDEV_GUARDED_TTY_EXEC_METHOD:
return await this.executeGuardedTtyExec(
this.parseGuardedTtyExecParams(methodRequest.params),
signal,
);
case GOAL_CONTROL_METHOD:
case LEGACY_GOAL_CONTROL_METHOD: {
const sessionState = this.sessions.get(methodRequest.params.sessionId);
Expand Down Expand Up @@ -790,6 +821,7 @@ export class CodexAcpServer {
const closeGeneration = this.bumpSessionGeneration(params.sessionId);
const sessionState = this.sessions.get(params.sessionId);
this.beginSessionCloseFence(params.sessionId);
this.abortGuardedTtyExecutions(params.sessionId);

try {
if (sessionState) {
Expand Down Expand Up @@ -821,6 +853,14 @@ export class CodexAcpServer {
return {};
}

private abortGuardedTtyExecutions(sessionId: string): void {
const executions = this.guardedTtyExecutions.get(sessionId);
if (!executions) return;
for (const execution of executions) {
execution.abort("stale_session");
}
}

async deleteSession(params: acp.DeleteSessionRequest): Promise<acp.DeleteSessionResponse> {
logger.log("Deleting session...", {sessionId: params.sessionId});
const sessionId = params.sessionId;
Expand Down Expand Up @@ -1203,6 +1243,86 @@ export class CodexAcpServer {
return {};
}

private guardedTtyCapability(
params: KandevGuardedTtyCapabilityRequest,
): KandevGuardedTtyCapabilityResponse {
const sessionState = this.sessions.get(params.sessionId);
if (!sessionState || !this.sessionPublishIsCurrent(
sessionState,
this.getSessionGeneration(params.sessionId),
)) {
throw RequestError.invalidParams(undefined, "Unknown or stale session");
}
return {
capability: KANDEV_GUARDED_TTY_CAPABILITY,
version: KANDEV_GUARDED_TTY_VERSION,
supported: true,
capability_method: KANDEV_GUARDED_TTY_CAPABILITY_METHOD,
exec_method: KANDEV_GUARDED_TTY_EXEC_METHOD,
session_id: sessionState.sessionId,
};
}

private async executeGuardedTtyExec(
params: KandevGuardedTtyExecRequest,
requestSignal?: AbortSignal,
): Promise<KandevGuardedTtyExecReceipt> {
const sessionState = this.sessions.get(params.sessionId);
if (!sessionState || this.sessionIsClosing(params.sessionId)) {
return createUndispatchedGuardedTtyReceipt(params.sessionId, "stale_session");
}
const sessionGeneration = this.getSessionGeneration(params.sessionId);
const controller = new AbortController();
const abortFromRequest = () => controller.abort("cancelled");
if (requestSignal?.aborted) {
abortFromRequest();
} else {
requestSignal?.addEventListener("abort", abortFromRequest, {once: true});
}
const executions = this.guardedTtyExecutions.get(params.sessionId) ?? new Set<AbortController>();
executions.add(controller);
this.guardedTtyExecutions.set(params.sessionId, executions);

try {
return await this.codexAcpClient.guardedTtyExec({
sessionId: sessionState.sessionId,
argv: params.argv,
cwd: sessionState.cwd,
sandboxPolicy: sessionState.agentMode.sandboxPolicy,
signal: controller.signal,
isSessionCurrent: () => this.sessionPublishIsCurrent(sessionState, sessionGeneration),
});
} finally {
requestSignal?.removeEventListener("abort", abortFromRequest);
executions.delete(controller);
if (executions.size === 0) {
this.guardedTtyExecutions.delete(params.sessionId);
}
}
}

private parseGuardedTtyCapabilityParams(
params: Record<string, unknown>,
): KandevGuardedTtyCapabilityRequest {
if (!hasExactKeys(params, ["sessionId"]) || typeof params["sessionId"] !== "string") {
throw RequestError.invalidParams();
}
return {sessionId: params["sessionId"]};
}

private parseGuardedTtyExecParams(params: Record<string, unknown>): KandevGuardedTtyExecRequest {
const sessionId = params["sessionId"];
const argv = params["argv"];
if (!hasExactKeys(params, ["argv", "sessionId"])
|| typeof sessionId !== "string"
|| !Array.isArray(argv)
|| !argv.every((arg): arg is string => typeof arg === "string")
|| !validateGuardedTtyArgv(argv)) {
throw RequestError.invalidParams();
}
return {sessionId, argv};
}

private parseLegacySetSessionModelParams(params: Record<string, unknown>): LegacySetSessionModelRequest {
const sessionId = params["sessionId"];
const modelId = params["modelId"];
Expand Down Expand Up @@ -3070,3 +3190,8 @@ function historyUpdateContentKey(update: UpdateSessionEvent): string | null {
function getRequestedMcpServerNames(mcpServers: Array<acp.McpServer>): Array<string> {
return Array.from(new Set(mcpServers.map(server => sanitizeMcpServerName(server.name))));
}

function hasExactKeys(value: Record<string, unknown>, expected: string[]): boolean {
const actual = Object.keys(value).sort();
return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
}
Loading