Skip to content
Draft
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
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const COMMANDS = [
["codex_fast", "Toggle Codex fast mode."],
["codex_model", "List or switch the Codex model."],
["codex_permissions", "Show Codex permissions and account status."],
["codex_login", "Start or cancel the Codex ChatGPT login flow for this bound conversation."],
["codex_init", "Forward /init to Codex."],
["codex_diff", "Forward /diff to Codex."],
["codex_rename", "Rename the Codex thread and sync the channel name when possible."],
Expand Down
13 changes: 13 additions & 0 deletions src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,19 @@ describe("buildThreadResumePayloads", () => {
});
});

describe("normalizeLoginCallbackReplayUrl", () => {
it("rewrites localhost callbacks to 127.0.0.1 for replay", () => {
expect(
__testing.normalizeLoginCallbackReplayUrl({
callbackUrl:
"http://localhost:1455/auth/callback?code=abc123&state=xyz789",
authUrl:
"https://auth.example.test/start?redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback",
}),
).toBe("http://127.0.0.1:1455/auth/callback?code=abc123&state=xyz789");
});
});

describe("extractStartupProbeInfo", () => {
it("extracts server info from initialize responses without losing CLI probe details", () => {
expect(
Expand Down
196 changes: 196 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,17 @@ export type ActiveCodexRun = {
getThreadId: () => string | undefined;
};

export type ActiveCodexLogin = {
loginId: string;
authUrl: string;
submitCallbackUrl: (callbackUrl: string) => Promise<void>;
cancel: () => Promise<void>;
result: Promise<void>;
};

const DEFAULT_PROTOCOL_VERSION = "1.0";
const TRAILING_NOTIFICATION_SETTLE_MS = 250;
const LOGIN_CALLBACK_TIMEOUT_MS = 10_000;
const TURN_STEER_METHODS = ["turn/steer"] as const;
const TURN_INTERRUPT_METHODS = ["turn/interrupt"] as const;
const execFileAsync = promisify(execFile);
Expand Down Expand Up @@ -417,6 +426,53 @@ function extractOptionValues(value: unknown): string[] {
.filter(Boolean);
}

function extractLoginAccountResponse(value: unknown): {
type?: string;
loginId?: string;
authUrl?: string;
} {
const record = asRecord(value) ?? {};
return {
type: pickString(record, ["type"]),
loginId: pickString(record, ["loginId", "login_id"]),
authUrl: pickString(record, ["authUrl", "auth_url"]),
};
}

function normalizeLoginCallbackReplayUrl(params: {
callbackUrl: string;
authUrl: string;
}): string {
const target = new URL(params.callbackUrl.trim());
const redirectUrl = new URL(params.authUrl).searchParams.get("redirect_uri");
if (!redirectUrl) {
throw new Error("Codex login URL did not include a redirect URI.");
}
const expected = new URL(redirectUrl);
const allowedHosts = new Set(["127.0.0.1", "localhost"]);
const targetHost = target.hostname.toLowerCase();
const expectedHost = expected.hostname.toLowerCase();
if (!allowedHosts.has(targetHost)) {
throw new Error("Paste the localhost callback URL from the browser address bar.");
}
if (
target.protocol !== expected.protocol ||
(!allowedHosts.has(expectedHost) || !allowedHosts.has(targetHost)) &&
targetHost !== expectedHost ||
target.port !== expected.port ||
target.pathname !== expected.pathname
) {
throw new Error("That callback URL does not match the active Codex login request.");
}
if (!target.searchParams.get("code") || !target.searchParams.get("state")) {
throw new Error("That callback URL is missing the OAuth code or state.");
}
// Codex binds the local login server on 127.0.0.1, even though the browser
// redirect uses localhost. Force IPv4 loopback for the replay request.
target.hostname = "127.0.0.1";
return target.toString();
}

function isInteractiveServerRequest(method: string): boolean {
const normalized = method.trim().toLowerCase();
return normalized.includes("requestuserinput") || normalized.includes("requestapproval");
Expand Down Expand Up @@ -2046,6 +2102,145 @@ export class CodexAppServerClient {
});
}

async startChatgptLogin(params: { sessionKey?: string } = {}): Promise<ActiveCodexLogin> {
const client = createJsonRpcClient(this.settings);
let loginId = "";
let authUrl = "";
let settled = false;
let completeLogin: (() => void) | null = null;
let failLogin: ((error: Error) => void) | null = null;
const result = new Promise<void>((resolve, reject) => {
completeLogin = () => {
if (settled) {
return;
}
settled = true;
resolve();
};
failLogin = (error) => {
if (settled) {
return;
}
settled = true;
reject(error);
};
}).finally(async () => {
await client.close().catch(() => undefined);
});

client.setNotificationHandler((method, notificationParams) => {
const methodLower = method.trim().toLowerCase();
if (methodLower !== "account/login/completed") {
return;
}
const record = asRecord(notificationParams) ?? {};
const completedLoginId = pickString(record, ["loginId", "login_id"]);
if (loginId && completedLoginId && completedLoginId !== loginId) {
return;
}
const success = pickBoolean(record, ["success"]) ?? false;
const errorMessage =
pickString(record, ["error"], { trim: false }) ?? "Codex login failed.";
if (success) {
this.logger.info(`codex login completed loginId=${loginId || completedLoginId || "<none>"}`);
completeLogin?.();
} else {
failLogin?.(new Error(errorMessage));
}
});
client.setRequestHandler(async () => ({}));

try {
await client.connect();
await initializeClient({ client, settings: this.settings, sessionKey: params.sessionKey });
const loginResponse = extractLoginAccountResponse(
await requestWithFallbacks({
client,
methods: ["account/login/start"],
payloads: [{ type: "chatgpt" }],
timeoutMs: this.settings.requestTimeoutMs,
}),
);
if (loginResponse.type !== "chatgpt" || !loginResponse.loginId || !loginResponse.authUrl) {
throw new Error("Codex App Server did not return a ChatGPT login URL.");
}
loginId = loginResponse.loginId;
authUrl = loginResponse.authUrl;
this.logger.info(`codex login started loginId=${loginId}`);
} catch (error) {
await client.close().catch(() => undefined);
throw error;
}

const submitCallbackUrl = async (callbackUrl: string): Promise<void> => {
const replayUrl = normalizeLoginCallbackReplayUrl({
callbackUrl,
authUrl,
});
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), LOGIN_CALLBACK_TIMEOUT_MS);
this.logger.info(
`codex login callback replay start loginId=${loginId || "<none>"} url=${replayUrl}`,
);
let response: Response;
try {
response = await fetch(replayUrl, {
method: "GET",
redirect: "manual",
signal: controller.signal,
});
} catch (error) {
this.logger.warn(
`codex login callback replay failed loginId=${loginId || "<none>"}: ${error instanceof Error ? error.message : String(error)}`,
);
if (error instanceof Error && error.name === "AbortError") {
throw new Error(
"Codex login callback timed out contacting the local Codex login server. Paste the localhost URL again or run `/codex_login cancel` and retry.",
);
}
throw error;
} finally {
clearTimeout(timeout);
}
this.logger.info(
`codex login callback replay response loginId=${loginId || "<none>"} status=${response.status}`,
);
if (!response.ok && response.status !== 302) {
throw new Error(`Codex login callback failed with HTTP ${response.status}.`);
}
if (response.status === 302) {
this.logger.info(
`codex login callback accepted loginId=${loginId || "<none>"}; treating redirect as success`,
);
completeLogin?.();
}
};

const cancel = async (): Promise<void> => {
if (settled) {
return;
}
try {
await requestWithFallbacks({
client,
methods: ["account/login/cancel"],
payloads: [{ loginId }],
timeoutMs: this.settings.requestTimeoutMs,
}).catch(() => undefined);
} finally {
failLogin?.(new Error("Codex login cancelled."));
}
};

return {
loginId,
authUrl,
submitCallbackUrl,
cancel,
result,
};
}

async listThreads(params: {
sessionKey?: string;
workspaceDir?: string;
Expand Down Expand Up @@ -3080,6 +3275,7 @@ export const __testing = {
buildTurnSteerPayloads,
createPendingInputCoordinator,
extractFileChangePathsFromReadResult,
normalizeLoginCallbackReplayUrl,
extractStartupProbeInfo,
extractThreadTokenUsageSnapshot,
extractRateLimitSummaries,
Expand Down
Loading