@goodtek/vibeops 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/README.md +5 -4
- package/dist/cli.js +32 -5
- package/dist/commands/plan.js +242 -13
- package/dist/commands/task-start.js +16 -4
- package/dist/lib/brief.js +11 -4
- package/dist/lib/git.js +42 -0
- package/dist/lib/plan-apply-planner.js +131 -0
- package/dist/lib/plan-codex-auth.js +183 -0
- package/dist/lib/plan-codex-pkce.js +234 -0
- package/dist/lib/plan-codex-responses.js +228 -0
- package/dist/lib/plan-llm-cursor-agent.js +59 -0
- package/dist/lib/plan-llm-detect.js +74 -0
- package/dist/lib/plan-llm-models.js +237 -0
- package/dist/lib/plan-llm-normalize.js +146 -0
- package/dist/lib/plan-llm-openai.js +134 -0
- package/dist/lib/plan-llm-session.js +682 -0
- package/dist/lib/plan-llm-types.js +1 -0
- package/dist/lib/plan-post-apply-setup.js +116 -0
- package/dist/lib/prompt-builder.js +11 -1
- package/package.json +1 -1
- package/templates/.cursor/rules/03-git-safety.mdc +1 -1
- package/templates/.vibeops/workflows/task-lifecycle.md +1 -1
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { codexOAuthTextCompletion } from "./plan-codex-responses.js";
|
|
3
|
+
import { cursorAgentPrint, extractAgentAssistantText } from "./plan-llm-cursor-agent.js";
|
|
4
|
+
import { openAiChatCompletionText } from "./plan-llm-openai.js";
|
|
5
|
+
import { writeText } from "./filesystem.js";
|
|
6
|
+
import { bold, dim, log } from "./logger.js";
|
|
7
|
+
import { isGitRepository, runGit, gitCommit } from "./git.js";
|
|
8
|
+
const PLANNER_APPLY_SYSTEM = `You are executing the VibeOps **Planner Agent** step in this run.
|
|
9
|
+
|
|
10
|
+
The user message is the full planning prompt (role, ProjectBrief, output format, and file list). Follow it exactly: same doc files, same TASK template sections, same hard rules (docs only under docs/, no application code, no .vibeops edits).
|
|
11
|
+
|
|
12
|
+
Reply with normal markdown only. Include the **Plan Summary** bullets first, then one fenced code block per file.
|
|
13
|
+
|
|
14
|
+
For every file block:
|
|
15
|
+
- Use a fenced block (\`\`\`markdown ... \`\`\` or plain \`\`\` ... \`\`\`).
|
|
16
|
+
- The **first line inside the block** must be exactly: \`<!-- file: <repo-relative-path> -->\` with a path under \`docs/project/\` or \`docs/tasks/\` only.
|
|
17
|
+
- The remaining lines are the full file body.`;
|
|
18
|
+
const ALLOWED_PREFIXES = ["docs/project/", "docs/tasks/"];
|
|
19
|
+
export function isSafePlannerRepoRelPath(p) {
|
|
20
|
+
const norm = p.replace(/\\/g, "/").trim().replace(/^\/+/, "");
|
|
21
|
+
if (norm.includes(".."))
|
|
22
|
+
return false;
|
|
23
|
+
return ALLOWED_PREFIXES.some((pre) => norm.startsWith(pre));
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extracts `<!-- file: path -->` fenced blocks from planner markdown output.
|
|
27
|
+
*/
|
|
28
|
+
export function parsePlannerFenceFiles(markdown) {
|
|
29
|
+
const out = [];
|
|
30
|
+
const re = /```(?:[a-zA-Z0-9+-]*)?\s*\n([\s\S]*?)```/g;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = re.exec(markdown)) !== null) {
|
|
33
|
+
const inner = m[1].replace(/\r\n/g, "\n");
|
|
34
|
+
const firstNl = inner.indexOf("\n");
|
|
35
|
+
const firstLine = (firstNl >= 0 ? inner.slice(0, firstNl) : inner).trim();
|
|
36
|
+
const body = firstNl >= 0 ? inner.slice(firstNl + 1) : "";
|
|
37
|
+
const dm = /^\s*<!--\s*file:\s*(.+?)\s*-->\s*$/i.exec(firstLine);
|
|
38
|
+
if (!dm)
|
|
39
|
+
continue;
|
|
40
|
+
const relPath = dm[1].trim().replace(/\\/g, "/");
|
|
41
|
+
if (!isSafePlannerRepoRelPath(relPath)) {
|
|
42
|
+
log.warn(`Skipping disallowed planner path: ${relPath}`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
out.push({ relativePath: relPath, content: body });
|
|
46
|
+
}
|
|
47
|
+
return out;
|
|
48
|
+
}
|
|
49
|
+
function dedupeByPath(files) {
|
|
50
|
+
const map = new Map();
|
|
51
|
+
for (const f of files) {
|
|
52
|
+
if (map.has(f.relativePath)) {
|
|
53
|
+
log.warn(`Duplicate planner path in model output (using last): ${f.relativePath}`);
|
|
54
|
+
}
|
|
55
|
+
map.set(f.relativePath, f);
|
|
56
|
+
}
|
|
57
|
+
return [...map.values()];
|
|
58
|
+
}
|
|
59
|
+
export async function runPlannerApplyMarkdown(params) {
|
|
60
|
+
const { cwd, planPromptMarkdown, provider, model } = params;
|
|
61
|
+
if (provider === "openai") {
|
|
62
|
+
const messages = [
|
|
63
|
+
{ role: "system", content: PLANNER_APPLY_SYSTEM },
|
|
64
|
+
{ role: "user", content: planPromptMarkdown },
|
|
65
|
+
];
|
|
66
|
+
return openAiChatCompletionText({ messages, model });
|
|
67
|
+
}
|
|
68
|
+
if (provider === "codex-oauth") {
|
|
69
|
+
const messages = [
|
|
70
|
+
{ role: "system", content: PLANNER_APPLY_SYSTEM },
|
|
71
|
+
{ role: "user", content: planPromptMarkdown },
|
|
72
|
+
];
|
|
73
|
+
return codexOAuthTextCompletion(messages, model !== undefined ? { model } : undefined);
|
|
74
|
+
}
|
|
75
|
+
const prompt = `${PLANNER_APPLY_SYSTEM}\n\n--- Planning prompt ---\n${planPromptMarkdown}`;
|
|
76
|
+
const raw = await cursorAgentPrint({ cwd, prompt, model });
|
|
77
|
+
return extractAgentAssistantText(raw);
|
|
78
|
+
}
|
|
79
|
+
export async function writePlannerFiles(cwd, files) {
|
|
80
|
+
const written = [];
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
const abs = join(cwd, f.relativePath);
|
|
83
|
+
await writeText(abs, f.content);
|
|
84
|
+
written.push(f.relativePath);
|
|
85
|
+
}
|
|
86
|
+
return written;
|
|
87
|
+
}
|
|
88
|
+
export async function commitPlannerGit(cwd, repoRelativePaths, message) {
|
|
89
|
+
if (!(await isGitRepository(cwd))) {
|
|
90
|
+
log.warn("Not a Git repository — skipping commit.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (repoRelativePaths.length === 0)
|
|
94
|
+
return;
|
|
95
|
+
for (const p of repoRelativePaths) {
|
|
96
|
+
await runGit(cwd, ["add", "--", p]);
|
|
97
|
+
}
|
|
98
|
+
await gitCommit(cwd, message);
|
|
99
|
+
}
|
|
100
|
+
export async function pushPlannerGit(cwd) {
|
|
101
|
+
if (!(await isGitRepository(cwd))) {
|
|
102
|
+
log.warn("Not a Git repository — skipping push.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
await runGit(cwd, ["push"]);
|
|
106
|
+
}
|
|
107
|
+
export async function applyPlannerMarkdownFlow(params) {
|
|
108
|
+
log.blank();
|
|
109
|
+
log.step(bold("Planner apply — calling LLM with the planning prompt"));
|
|
110
|
+
log.info(dim(` provider: ${params.provider}${params.model ? ` model: ${params.model}` : ""}`));
|
|
111
|
+
const rawMarkdown = await runPlannerApplyMarkdown(params);
|
|
112
|
+
const files = dedupeByPath(parsePlannerFenceFiles(rawMarkdown));
|
|
113
|
+
if (files.length === 0) {
|
|
114
|
+
throw new Error("Planner apply: model output contained no usable <!-- file: ... --> fenced blocks under docs/project/ or docs/tasks/.");
|
|
115
|
+
}
|
|
116
|
+
if (params.dryRun) {
|
|
117
|
+
log.blank();
|
|
118
|
+
log.info(bold("[dry-run] Would write:"));
|
|
119
|
+
for (const f of files) {
|
|
120
|
+
log.info(` · ${f.relativePath} (${f.content.length} chars)`);
|
|
121
|
+
}
|
|
122
|
+
return { rawMarkdown, files, writtenRelativePaths: [] };
|
|
123
|
+
}
|
|
124
|
+
const writtenRelativePaths = await writePlannerFiles(params.cwd, files);
|
|
125
|
+
log.blank();
|
|
126
|
+
log.ok(`Wrote ${writtenRelativePaths.length} file(s) under docs/`);
|
|
127
|
+
for (const p of writtenRelativePaths) {
|
|
128
|
+
log.info(dim(` · ${p}`));
|
|
129
|
+
}
|
|
130
|
+
return { rawMarkdown, files, writtenRelativePaths };
|
|
131
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex / ChatGPT OAuth — same public client_id and token endpoint as
|
|
3
|
+
* Hermes (`hermes_cli/auth.py`) and OpenClaw's openai-codex provider.
|
|
4
|
+
* Reads credentials written by the official Codex CLI (`codex login`) under
|
|
5
|
+
* `CODEX_HOME` (default ~/.codex/auth.json).
|
|
6
|
+
*
|
|
7
|
+
* @see https://developers.openai.com/codex/auth
|
|
8
|
+
* @see https://docs.openclaw.ai/concepts/oauth (OpenAI Codex OAuth)
|
|
9
|
+
*/
|
|
10
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
/** Public OAuth client id used by Codex CLI / Hermes / OpenClaw for ChatGPT login. */
|
|
14
|
+
export const CODEX_OAUTH_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
15
|
+
export const CODEX_OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
16
|
+
const DEFAULT_CODEX_HOME = () => join(homedir(), ".codex");
|
|
17
|
+
export function codexAuthJsonPath() {
|
|
18
|
+
const home = process.env.CODEX_HOME?.trim();
|
|
19
|
+
return join(home && home.length > 0 ? home : DEFAULT_CODEX_HOME(), "auth.json");
|
|
20
|
+
}
|
|
21
|
+
function decodeJwtExpMs(accessToken) {
|
|
22
|
+
const parts = accessToken.split(".");
|
|
23
|
+
if (parts.length < 2)
|
|
24
|
+
return null;
|
|
25
|
+
try {
|
|
26
|
+
const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
27
|
+
const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4));
|
|
28
|
+
const json = Buffer.from(b64 + pad, "base64").toString("utf8");
|
|
29
|
+
const payload = JSON.parse(json);
|
|
30
|
+
return typeof payload.exp === "number" ? payload.exp * 1000 : null;
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
export function codexAccessTokenExpiring(accessToken, skewSeconds) {
|
|
37
|
+
const expMs = decodeJwtExpMs(accessToken);
|
|
38
|
+
if (expMs === null)
|
|
39
|
+
return true;
|
|
40
|
+
return Date.now() >= expMs - skewSeconds * 1000;
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Checks that `auth.json` exists and contains access + refresh tokens (shape
|
|
44
|
+
* compatible with the Codex CLI file store).
|
|
45
|
+
*/
|
|
46
|
+
export async function probeCodexOAuthFile() {
|
|
47
|
+
const path = codexAuthJsonPath();
|
|
48
|
+
try {
|
|
49
|
+
const raw = await readFile(path, "utf-8");
|
|
50
|
+
const data = JSON.parse(raw);
|
|
51
|
+
const tokens = (data.tokens ?? data);
|
|
52
|
+
const access = typeof tokens.access_token === "string" ? tokens.access_token.trim() : "";
|
|
53
|
+
const refresh = typeof tokens.refresh_token === "string" ? tokens.refresh_token.trim() : "";
|
|
54
|
+
if (access.length === 0 || refresh.length === 0) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
path,
|
|
58
|
+
reason: "auth.json exists but is missing access_token or refresh_token.",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
return { ok: true, path };
|
|
62
|
+
}
|
|
63
|
+
catch (e) {
|
|
64
|
+
const err = e;
|
|
65
|
+
if (err.code === "ENOENT") {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
path,
|
|
69
|
+
reason: "No Codex auth file yet. With an interactive TTY, `vibeops plan` can open ChatGPT OAuth in the browser; otherwise run `codex login` or copy auth.json.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
73
|
+
return { ok: false, path, reason: msg };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
async function readAuthPayload() {
|
|
77
|
+
const path = codexAuthJsonPath();
|
|
78
|
+
try {
|
|
79
|
+
const raw = await readFile(path, "utf-8");
|
|
80
|
+
const root = JSON.parse(raw);
|
|
81
|
+
return { path, root };
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Writes or merges OAuth tokens into the Codex CLI auth file (`auth.json`).
|
|
89
|
+
*/
|
|
90
|
+
export async function saveCodexAuthTokens(tokens) {
|
|
91
|
+
const path = codexAuthJsonPath();
|
|
92
|
+
await mkdir(dirname(path), { recursive: true });
|
|
93
|
+
let root = {};
|
|
94
|
+
try {
|
|
95
|
+
root = JSON.parse(await readFile(path, "utf-8"));
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
/* new file */
|
|
99
|
+
}
|
|
100
|
+
const tokenBag = root.tokens !== undefined && typeof root.tokens === "object" && !Array.isArray(root.tokens)
|
|
101
|
+
? { ...root.tokens }
|
|
102
|
+
: {};
|
|
103
|
+
tokenBag.access_token = tokens.access_token;
|
|
104
|
+
tokenBag.refresh_token = tokens.refresh_token;
|
|
105
|
+
if (typeof tokens.id_token === "string" && tokens.id_token.length > 0) {
|
|
106
|
+
tokenBag.id_token = tokens.id_token;
|
|
107
|
+
}
|
|
108
|
+
root.tokens = tokenBag;
|
|
109
|
+
root.auth_mode = "chatgpt";
|
|
110
|
+
await writeFile(path, `${JSON.stringify(root, null, 2)}\n`, "utf-8");
|
|
111
|
+
}
|
|
112
|
+
function extractTokens(root) {
|
|
113
|
+
const tokens = (root.tokens ?? root);
|
|
114
|
+
const access = typeof tokens.access_token === "string" ? tokens.access_token.trim() : "";
|
|
115
|
+
const refresh = typeof tokens.refresh_token === "string" ? tokens.refresh_token.trim() : "";
|
|
116
|
+
if (!access || !refresh)
|
|
117
|
+
return null;
|
|
118
|
+
return { access_token: access, refresh_token: refresh };
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Exchanges refresh_token for a new access_token (and optional rotated refresh).
|
|
122
|
+
*/
|
|
123
|
+
export async function refreshCodexOAuthTokens(refreshToken) {
|
|
124
|
+
const body = new URLSearchParams({
|
|
125
|
+
grant_type: "refresh_token",
|
|
126
|
+
refresh_token: refreshToken,
|
|
127
|
+
client_id: CODEX_OAUTH_CLIENT_ID,
|
|
128
|
+
});
|
|
129
|
+
const res = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
|
130
|
+
method: "POST",
|
|
131
|
+
headers: {
|
|
132
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
133
|
+
Accept: "application/json",
|
|
134
|
+
},
|
|
135
|
+
body,
|
|
136
|
+
});
|
|
137
|
+
const text = await res.text();
|
|
138
|
+
if (!res.ok) {
|
|
139
|
+
throw new Error(`Codex OAuth refresh failed (${res.status}): ${text.slice(0, 400)}`);
|
|
140
|
+
}
|
|
141
|
+
let json;
|
|
142
|
+
try {
|
|
143
|
+
json = JSON.parse(text);
|
|
144
|
+
}
|
|
145
|
+
catch {
|
|
146
|
+
throw new Error("Codex OAuth refresh returned invalid JSON.");
|
|
147
|
+
}
|
|
148
|
+
const access = typeof json.access_token === "string" ? json.access_token.trim() : "";
|
|
149
|
+
if (!access) {
|
|
150
|
+
throw new Error("Codex OAuth refresh response missing access_token.");
|
|
151
|
+
}
|
|
152
|
+
const nextRefresh = typeof json.refresh_token === "string" && json.refresh_token.trim().length > 0
|
|
153
|
+
? json.refresh_token.trim()
|
|
154
|
+
: refreshToken;
|
|
155
|
+
return { access_token: access, refresh_token: nextRefresh };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Returns a usable access token, refreshing and persisting to auth.json when
|
|
159
|
+
* the JWT is near expiry (same pattern as Hermes `resolve_codex_runtime_credentials`).
|
|
160
|
+
*/
|
|
161
|
+
export async function resolveCodexOAuthAccessToken(skewSeconds = 120) {
|
|
162
|
+
const loaded = await readAuthPayload();
|
|
163
|
+
if (!loaded) {
|
|
164
|
+
throw new Error(`Codex auth file missing: ${codexAuthJsonPath()}`);
|
|
165
|
+
}
|
|
166
|
+
const { path, root } = loaded;
|
|
167
|
+
let pair = extractTokens(root);
|
|
168
|
+
if (!pair) {
|
|
169
|
+
throw new Error(`Codex auth file has no tokens: ${path}`);
|
|
170
|
+
}
|
|
171
|
+
if (codexAccessTokenExpiring(pair.access_token, skewSeconds)) {
|
|
172
|
+
pair = await refreshCodexOAuthTokens(pair.refresh_token);
|
|
173
|
+
const nextRoot = { ...root };
|
|
174
|
+
const tokenBag = nextRoot.tokens !== undefined && typeof nextRoot.tokens === "object" && !Array.isArray(nextRoot.tokens)
|
|
175
|
+
? { ...nextRoot.tokens }
|
|
176
|
+
: {};
|
|
177
|
+
tokenBag.access_token = pair.access_token;
|
|
178
|
+
tokenBag.refresh_token = pair.refresh_token;
|
|
179
|
+
nextRoot.tokens = tokenBag;
|
|
180
|
+
await writeFile(path, `${JSON.stringify(nextRoot, null, 2)}\n`, "utf-8");
|
|
181
|
+
}
|
|
182
|
+
return { accessToken: pair.access_token };
|
|
183
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser PKCE OAuth for OpenAI Codex (ChatGPT) — same parameters as community
|
|
3
|
+
* clients (authorize URL, localhost redirect, S256 challenge, token exchange).
|
|
4
|
+
*
|
|
5
|
+
* @see https://docs.rs/codex-oauth/latest/codex_oauth/
|
|
6
|
+
*/
|
|
7
|
+
import { spawn } from "node:child_process";
|
|
8
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
9
|
+
import { createServer } from "node:http";
|
|
10
|
+
import { askInput } from "./inquirer-helpers.js";
|
|
11
|
+
import { bold, cyan, dim, log, yellow } from "./logger.js";
|
|
12
|
+
import { CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, saveCodexAuthTokens, } from "./plan-codex-auth.js";
|
|
13
|
+
export const CODEX_OAUTH_AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
|
|
14
|
+
/** Must match OpenAI's registered redirect for this public Codex client. */
|
|
15
|
+
export const CODEX_OAUTH_REDIRECT_URI = "http://localhost:1455/auth/callback";
|
|
16
|
+
const CODEX_SCOPES = "openid profile email offline_access";
|
|
17
|
+
const CALLBACK_WAIT_MS = 600_000;
|
|
18
|
+
function toBase64Url(buf) {
|
|
19
|
+
return buf
|
|
20
|
+
.toString("base64")
|
|
21
|
+
.replace(/\+/g, "-")
|
|
22
|
+
.replace(/\//g, "_")
|
|
23
|
+
.replace(/=+$/g, "");
|
|
24
|
+
}
|
|
25
|
+
export function createPkceVerifier() {
|
|
26
|
+
return toBase64Url(randomBytes(32));
|
|
27
|
+
}
|
|
28
|
+
export function createPkceChallenge(verifier) {
|
|
29
|
+
return toBase64Url(createHash("sha256").update(verifier).digest());
|
|
30
|
+
}
|
|
31
|
+
export function createOAuthState() {
|
|
32
|
+
return randomBytes(16).toString("hex");
|
|
33
|
+
}
|
|
34
|
+
export function buildCodexAuthorizeUrl(params) {
|
|
35
|
+
const challenge = createPkceChallenge(params.verifier);
|
|
36
|
+
const url = new URL(CODEX_OAUTH_AUTHORIZE_URL);
|
|
37
|
+
url.searchParams.set("response_type", "code");
|
|
38
|
+
url.searchParams.set("client_id", CODEX_OAUTH_CLIENT_ID);
|
|
39
|
+
url.searchParams.set("redirect_uri", CODEX_OAUTH_REDIRECT_URI);
|
|
40
|
+
url.searchParams.set("scope", CODEX_SCOPES);
|
|
41
|
+
url.searchParams.set("code_challenge", challenge);
|
|
42
|
+
url.searchParams.set("code_challenge_method", "S256");
|
|
43
|
+
url.searchParams.set("state", params.state);
|
|
44
|
+
url.searchParams.set("id_token_add_organizations", "true");
|
|
45
|
+
url.searchParams.set("codex_cli_simplified_flow", "true");
|
|
46
|
+
url.searchParams.set("originator", "pi");
|
|
47
|
+
return url.toString();
|
|
48
|
+
}
|
|
49
|
+
function openSystemBrowser(url) {
|
|
50
|
+
const platform = process.platform;
|
|
51
|
+
if (platform === "darwin") {
|
|
52
|
+
spawn("open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
53
|
+
}
|
|
54
|
+
else if (platform === "win32") {
|
|
55
|
+
spawn("cmd", ["/c", "start", "", url], { detached: true, stdio: "ignore" }).unref();
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
spawn("xdg-open", [url], { detached: true, stdio: "ignore" }).unref();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
function parseCallbackInput(input, expectedState) {
|
|
62
|
+
const value = input.trim();
|
|
63
|
+
if (value.length === 0)
|
|
64
|
+
return null;
|
|
65
|
+
try {
|
|
66
|
+
const u = new URL(value);
|
|
67
|
+
if (u.searchParams.get("state") !== expectedState)
|
|
68
|
+
return null;
|
|
69
|
+
const code = u.searchParams.get("code");
|
|
70
|
+
return code && code.length > 0 ? code : null;
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
if (value.includes("code=")) {
|
|
74
|
+
try {
|
|
75
|
+
const params = value.startsWith("http") ? new URL(value).searchParams : new URLSearchParams(value);
|
|
76
|
+
if (params.get("state") !== expectedState)
|
|
77
|
+
return null;
|
|
78
|
+
const c = params.get("code");
|
|
79
|
+
return c && c.length > 0 ? c : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
export async function exchangeCodexAuthorizationCode(code, verifier) {
|
|
89
|
+
const body = new URLSearchParams({
|
|
90
|
+
grant_type: "authorization_code",
|
|
91
|
+
client_id: CODEX_OAUTH_CLIENT_ID,
|
|
92
|
+
code,
|
|
93
|
+
code_verifier: verifier,
|
|
94
|
+
redirect_uri: CODEX_OAUTH_REDIRECT_URI,
|
|
95
|
+
});
|
|
96
|
+
const res = await fetch(CODEX_OAUTH_TOKEN_URL, {
|
|
97
|
+
method: "POST",
|
|
98
|
+
headers: {
|
|
99
|
+
"Content-Type": "application/x-www-form-urlencoded",
|
|
100
|
+
Accept: "application/json",
|
|
101
|
+
},
|
|
102
|
+
body,
|
|
103
|
+
});
|
|
104
|
+
const text = await res.text();
|
|
105
|
+
if (!res.ok) {
|
|
106
|
+
throw new Error(`Codex token exchange failed (${res.status}): ${text.slice(0, 600)}`);
|
|
107
|
+
}
|
|
108
|
+
const json = JSON.parse(text);
|
|
109
|
+
const access = typeof json.access_token === "string" ? json.access_token.trim() : "";
|
|
110
|
+
const refresh = typeof json.refresh_token === "string" ? json.refresh_token.trim() : "";
|
|
111
|
+
if (!access || !refresh) {
|
|
112
|
+
throw new Error("Codex token exchange returned no access_token/refresh_token.");
|
|
113
|
+
}
|
|
114
|
+
const id = typeof json.id_token === "string" && json.id_token.trim().length > 0
|
|
115
|
+
? json.id_token.trim()
|
|
116
|
+
: undefined;
|
|
117
|
+
return { access_token: access, refresh_token: refresh, id_token: id };
|
|
118
|
+
}
|
|
119
|
+
export function startCodexCallbackServer(expectedState) {
|
|
120
|
+
return new Promise((outerResolve) => {
|
|
121
|
+
let outerDone = false;
|
|
122
|
+
let deliverCode = null;
|
|
123
|
+
const codePromise = new Promise((r) => {
|
|
124
|
+
deliverCode = r;
|
|
125
|
+
});
|
|
126
|
+
const server = createServer((req, res) => {
|
|
127
|
+
try {
|
|
128
|
+
const addr = server.address();
|
|
129
|
+
const port = addr?.port ?? 1455;
|
|
130
|
+
const url = new URL(req.url ?? "/", `http://127.0.0.1:${port}`);
|
|
131
|
+
if (url.pathname !== "/auth/callback") {
|
|
132
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
133
|
+
res.end("Not found");
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
if (url.searchParams.get("state") !== expectedState) {
|
|
137
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
138
|
+
res.end("<p>Invalid state. Close this tab and try again from the terminal.</p>");
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
const code = url.searchParams.get("code");
|
|
142
|
+
if (!code) {
|
|
143
|
+
res.writeHead(400, { "Content-Type": "text/html; charset=utf-8" });
|
|
144
|
+
res.end("<p>Missing code. Close this tab and try again.</p>");
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
148
|
+
res.end("<p>ChatGPT sign-in complete. You can close this tab and return to the terminal.</p>");
|
|
149
|
+
deliverCode?.(code);
|
|
150
|
+
deliverCode = null;
|
|
151
|
+
server.close();
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
res.writeHead(500);
|
|
155
|
+
res.end();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
const finishOuter = (handle) => {
|
|
159
|
+
if (outerDone)
|
|
160
|
+
return;
|
|
161
|
+
outerDone = true;
|
|
162
|
+
outerResolve(handle);
|
|
163
|
+
};
|
|
164
|
+
server.once("error", () => {
|
|
165
|
+
finishOuter(null);
|
|
166
|
+
});
|
|
167
|
+
server.listen(1455, "127.0.0.1", () => {
|
|
168
|
+
finishOuter({
|
|
169
|
+
wait: () => Promise.race([
|
|
170
|
+
codePromise,
|
|
171
|
+
new Promise((r) => setTimeout(() => r(null), CALLBACK_WAIT_MS)),
|
|
172
|
+
]),
|
|
173
|
+
close: () => {
|
|
174
|
+
deliverCode?.(null);
|
|
175
|
+
deliverCode = null;
|
|
176
|
+
server.close();
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Opens the system browser, completes PKCE OAuth, and writes `~/.codex/auth.json`.
|
|
184
|
+
*/
|
|
185
|
+
export async function runCodexPkceOAuthLogin() {
|
|
186
|
+
const verifier = createPkceVerifier();
|
|
187
|
+
const state = createOAuthState();
|
|
188
|
+
const authUrl = buildCodexAuthorizeUrl({ state, verifier });
|
|
189
|
+
log.blank();
|
|
190
|
+
log.info(bold("ChatGPT (Codex) OAuth"));
|
|
191
|
+
log.info("A browser window will open. Sign in with your ChatGPT account that has Codex access.");
|
|
192
|
+
log.info(`If it does not open, visit:`);
|
|
193
|
+
log.info(` ${cyan(authUrl)}`);
|
|
194
|
+
log.blank();
|
|
195
|
+
const handle = await startCodexCallbackServer(state);
|
|
196
|
+
if (handle === null) {
|
|
197
|
+
log.warn(`${yellow("!")} Could not listen on ${cyan("localhost:1455")} (port may be in use). Complete sign-in in the browser, then paste the redirect URL below.`);
|
|
198
|
+
openSystemBrowser(authUrl);
|
|
199
|
+
const pasted = await askInput({
|
|
200
|
+
message: "Paste the full redirect URL (http://localhost:1455/auth/callback?...)",
|
|
201
|
+
nonInteractive: false,
|
|
202
|
+
});
|
|
203
|
+
const code = parseCallbackInput(pasted, state);
|
|
204
|
+
if (!code) {
|
|
205
|
+
throw new Error("Could not read authorization code from pasted URL.");
|
|
206
|
+
}
|
|
207
|
+
const tokens = await exchangeCodexAuthorizationCode(code, verifier);
|
|
208
|
+
await saveCodexAuthTokens(tokens);
|
|
209
|
+
log.ok(`Saved Codex tokens to ${dim("~/.codex/auth.json")} (or $CODEX_HOME).`);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
try {
|
|
213
|
+
openSystemBrowser(authUrl);
|
|
214
|
+
log.info(dim("Waiting for redirect (up to 10 minutes). Press Ctrl+C to cancel."));
|
|
215
|
+
let code = await handle.wait();
|
|
216
|
+
if (code === null || code.length === 0) {
|
|
217
|
+
log.warn("No redirect received in time — paste the callback URL from the browser.");
|
|
218
|
+
const pasted = await askInput({
|
|
219
|
+
message: "Paste the full redirect URL",
|
|
220
|
+
nonInteractive: false,
|
|
221
|
+
});
|
|
222
|
+
code = parseCallbackInput(pasted, state);
|
|
223
|
+
}
|
|
224
|
+
if (!code) {
|
|
225
|
+
throw new Error("No authorization code received.");
|
|
226
|
+
}
|
|
227
|
+
const tokens = await exchangeCodexAuthorizationCode(code, verifier);
|
|
228
|
+
await saveCodexAuthTokens(tokens);
|
|
229
|
+
log.ok(`Saved Codex tokens to ${dim("~/.codex/auth.json")} (or $CODEX_HOME).`);
|
|
230
|
+
}
|
|
231
|
+
finally {
|
|
232
|
+
handle.close();
|
|
233
|
+
}
|
|
234
|
+
}
|