@bitkyc08/opencodex 2.1.8 → 2.1.10
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/gui/dist/assets/{index-DalshCSi.js → index-3RZw8J9v.js} +1 -1
- package/gui/dist/assets/index-xJdeQjzZ.css +1 -0
- package/gui/dist/index.html +2 -2
- package/package.json +2 -1
- package/src/adapters/anthropic.ts +29 -6
- package/src/adapters/openai-responses.ts +12 -0
- package/src/bridge.ts +21 -1
- package/src/codex-account-label.ts +34 -0
- package/src/codex-account-lifecycle.ts +21 -0
- package/src/codex-account-runtime-state.ts +13 -0
- package/src/codex-account-store.ts +355 -0
- package/src/codex-account-usability.ts +10 -0
- package/src/codex-auth-api.ts +446 -0
- package/src/codex-auth-collision.ts +66 -0
- package/src/codex-auth-context.ts +136 -0
- package/src/codex-catalog.ts +8 -2
- package/src/codex-quota.ts +130 -0
- package/src/codex-routing.ts +382 -0
- package/src/codex-websocket-registry.ts +57 -0
- package/src/config.ts +86 -26
- package/src/debug.ts +5 -4
- package/src/oauth/chatgpt.ts +150 -0
- package/src/oauth/index.ts +35 -7
- package/src/oauth/store.ts +9 -5
- package/src/privacy.ts +11 -0
- package/src/router.ts +1 -1
- package/src/server.ts +360 -23
- package/src/types.ts +32 -0
- package/src/vision/describe.ts +7 -3
- package/src/vision/index.ts +7 -3
- package/src/web-search/executor.ts +8 -3
- package/src/web-search/index.ts +3 -1
- package/src/web-search/loop.ts +6 -5
- package/src/ws-bridge.ts +56 -10
- package/gui/dist/assets/index-dCS-lwCM.css +0 -1
package/src/config.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, chmodSync } from "node:fs";
|
|
1
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync, chmodSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
|
+
import * as z from "zod/v4";
|
|
4
5
|
import type { OcxConfig } from "./types";
|
|
5
6
|
|
|
6
7
|
let _atomicSeq = 0;
|
|
@@ -14,9 +15,38 @@ export function atomicWriteFile(path: string, content: string): void {
|
|
|
14
15
|
renameSync(tmp, path);
|
|
15
16
|
}
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
18
|
+
function resolveConfigDir(): string {
|
|
19
|
+
return process.env["OPENCODEX_HOME"] || join(homedir(), ".opencodex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function resolveConfigPath(): string {
|
|
23
|
+
return join(resolveConfigDir(), "config.json");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function resolvePidPath(): string {
|
|
27
|
+
return join(resolveConfigDir(), "ocx.pid");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const warnedConfigFallbacks = new Set<string>();
|
|
31
|
+
|
|
32
|
+
const providerConfigSchema = z.object({
|
|
33
|
+
adapter: z.string().min(1),
|
|
34
|
+
baseUrl: z.string().min(1),
|
|
35
|
+
}).passthrough();
|
|
36
|
+
|
|
37
|
+
const configSchema = z.object({
|
|
38
|
+
port: z.number().int().min(0).max(65535).default(10100),
|
|
39
|
+
providers: z.record(z.string(), providerConfigSchema),
|
|
40
|
+
defaultProvider: z.string().min(1),
|
|
41
|
+
}).passthrough().superRefine((config, ctx) => {
|
|
42
|
+
if (Object.keys(config.providers).length > 0 && !(config.defaultProvider in config.providers)) {
|
|
43
|
+
ctx.addIssue({
|
|
44
|
+
code: "custom",
|
|
45
|
+
path: ["defaultProvider"],
|
|
46
|
+
message: "defaultProvider must exist in providers",
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
20
50
|
|
|
21
51
|
/**
|
|
22
52
|
* Default featured subagent models (native GPT) seeded on a fresh install and when `subagentModels`
|
|
@@ -28,20 +58,21 @@ const PID_PATH = join(OCX_DIR, "ocx.pid");
|
|
|
28
58
|
export const DEFAULT_SUBAGENT_MODELS = ["gpt-5.5", "gpt-5.4", "gpt-5.4-mini", "gpt-5.3-codex-spark"];
|
|
29
59
|
|
|
30
60
|
export function getConfigDir(): string {
|
|
31
|
-
return
|
|
61
|
+
return resolveConfigDir();
|
|
32
62
|
}
|
|
33
63
|
|
|
34
64
|
export function getConfigPath(): string {
|
|
35
|
-
return
|
|
65
|
+
return resolveConfigPath();
|
|
36
66
|
}
|
|
37
67
|
|
|
38
68
|
export function getPidPath(): string {
|
|
39
|
-
return
|
|
69
|
+
return resolvePidPath();
|
|
40
70
|
}
|
|
41
71
|
|
|
42
72
|
export function hardenConfigDir(): void {
|
|
43
|
-
|
|
44
|
-
|
|
73
|
+
const dir = getConfigDir();
|
|
74
|
+
if (existsSync(dir)) {
|
|
75
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort */ }
|
|
45
76
|
}
|
|
46
77
|
}
|
|
47
78
|
|
|
@@ -52,27 +83,31 @@ export function hardenExistingSecret(path: string): void {
|
|
|
52
83
|
}
|
|
53
84
|
|
|
54
85
|
export function loadConfig(): OcxConfig {
|
|
86
|
+
const dir = getConfigDir();
|
|
87
|
+
const configPath = getConfigPath();
|
|
55
88
|
hardenConfigDir();
|
|
56
|
-
hardenExistingSecret(
|
|
57
|
-
hardenExistingSecret(join(
|
|
58
|
-
if (!existsSync(
|
|
89
|
+
hardenExistingSecret(configPath);
|
|
90
|
+
hardenExistingSecret(join(dir, "auth.json"));
|
|
91
|
+
if (!existsSync(configPath)) {
|
|
59
92
|
return getDefaultConfig();
|
|
60
93
|
}
|
|
61
94
|
try {
|
|
62
|
-
const raw = readFileSync(
|
|
63
|
-
return JSON.parse(raw) as OcxConfig;
|
|
64
|
-
} catch {
|
|
95
|
+
const raw = readFileSync(configPath, "utf-8");
|
|
96
|
+
return configSchema.parse(JSON.parse(raw)) as OcxConfig;
|
|
97
|
+
} catch (error) {
|
|
98
|
+
warnAndBackupInvalidConfig(configPath, error);
|
|
65
99
|
return getDefaultConfig();
|
|
66
100
|
}
|
|
67
101
|
}
|
|
68
102
|
|
|
69
103
|
export function saveConfig(config: OcxConfig): void {
|
|
70
|
-
|
|
71
|
-
|
|
104
|
+
const dir = getConfigDir();
|
|
105
|
+
if (!existsSync(dir)) {
|
|
106
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
72
107
|
} else {
|
|
73
|
-
try { chmodSync(
|
|
108
|
+
try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
|
|
74
109
|
}
|
|
75
|
-
atomicWriteFile(
|
|
110
|
+
atomicWriteFile(getConfigPath(), JSON.stringify(config, null, 2) + "\n");
|
|
76
111
|
}
|
|
77
112
|
|
|
78
113
|
export function websocketsEnabled(config: Pick<OcxConfig, "websockets">): boolean {
|
|
@@ -112,18 +147,20 @@ export function resolveEnvValue(value: string | undefined): string | undefined {
|
|
|
112
147
|
}
|
|
113
148
|
|
|
114
149
|
export function writePid(pid: number): void {
|
|
115
|
-
|
|
116
|
-
|
|
150
|
+
const dir = getConfigDir();
|
|
151
|
+
if (!existsSync(dir)) {
|
|
152
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
117
153
|
} else {
|
|
118
154
|
hardenConfigDir();
|
|
119
155
|
}
|
|
120
|
-
writeFileSync(
|
|
156
|
+
writeFileSync(getPidPath(), String(pid), "utf-8");
|
|
121
157
|
}
|
|
122
158
|
|
|
123
159
|
export function readPid(): number | null {
|
|
124
|
-
|
|
160
|
+
const pidPath = getPidPath();
|
|
161
|
+
if (!existsSync(pidPath)) return null;
|
|
125
162
|
try {
|
|
126
|
-
const raw = readFileSync(
|
|
163
|
+
const raw = readFileSync(pidPath, "utf-8").trim();
|
|
127
164
|
const pid = parseInt(raw, 10);
|
|
128
165
|
if (isNaN(pid)) return null;
|
|
129
166
|
try {
|
|
@@ -140,7 +177,30 @@ export function readPid(): number | null {
|
|
|
140
177
|
|
|
141
178
|
export function removePid(): void {
|
|
142
179
|
try {
|
|
143
|
-
|
|
144
|
-
unlinkSync(PID_PATH);
|
|
180
|
+
unlinkSync(getPidPath());
|
|
145
181
|
} catch { /* ignore */ }
|
|
146
182
|
}
|
|
183
|
+
|
|
184
|
+
function warnAndBackupInvalidConfig(configPath: string, error: unknown): void {
|
|
185
|
+
if (warnedConfigFallbacks.has(configPath)) return;
|
|
186
|
+
warnedConfigFallbacks.add(configPath);
|
|
187
|
+
|
|
188
|
+
const backupPath = backupInvalidConfig(configPath);
|
|
189
|
+
const reason = error instanceof z.ZodError
|
|
190
|
+
? error.issues.map(issue => `${issue.path.join(".") || "config"}: ${issue.message}`).join("; ")
|
|
191
|
+
: error instanceof Error ? error.message : String(error);
|
|
192
|
+
const backupNote = backupPath ? ` A backup was written to ${backupPath}.` : "";
|
|
193
|
+
console.error(`Could not load opencodex config at ${configPath}: ${reason}. Using default config.${backupNote}`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function backupInvalidConfig(configPath: string): string | null {
|
|
197
|
+
if (!existsSync(configPath)) return null;
|
|
198
|
+
const backupPath = `${configPath}.invalid-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
199
|
+
try {
|
|
200
|
+
copyFileSync(configPath, backupPath);
|
|
201
|
+
try { chmodSync(backupPath, 0o600); } catch { /* best-effort */ }
|
|
202
|
+
return backupPath;
|
|
203
|
+
} catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/debug.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
// Opt-in frame-drop visibility. The streaming path is intentionally quiet (no unconditional
|
|
2
2
|
// console output), so this no-ops unless OCX_DEBUG_FRAMES=1. Lets a malformed/chunk-split
|
|
3
3
|
// upstream frame be detected instead of silently truncating content.
|
|
4
|
-
|
|
4
|
+
function debugFramesEnabled(): boolean {
|
|
5
|
+
return process.env.OCX_DEBUG_FRAMES === "1";
|
|
6
|
+
}
|
|
5
7
|
|
|
6
8
|
export function debugDroppedFrame(adapter: string, payload: string): void {
|
|
7
|
-
if (!
|
|
8
|
-
|
|
9
|
-
console.error(`[ocx:frame-drop] ${adapter}: ${preview}`);
|
|
9
|
+
if (!debugFramesEnabled()) return;
|
|
10
|
+
console.error(`[ocx:frame-drop] ${adapter}: dropped malformed upstream frame (payload redacted, bytes=${payload.length})`);
|
|
10
11
|
}
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { OAuthCallbackFlow } from "./callback-server";
|
|
2
|
+
import type { OAuthController, OAuthCredentials } from "./types";
|
|
3
|
+
import { generatePKCE } from "./pkce";
|
|
4
|
+
|
|
5
|
+
const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
6
|
+
const AUTH_URL = "https://auth.openai.com/oauth/authorize";
|
|
7
|
+
const TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
8
|
+
const SCOPE = "openid profile email offline_access api.connectors.read api.connectors.invoke";
|
|
9
|
+
const CALLBACK_PORT = 1455;
|
|
10
|
+
const CALLBACK_PATH = "/auth/callback";
|
|
11
|
+
const ORIGINATOR = "opencodex";
|
|
12
|
+
|
|
13
|
+
export function decodeJwtPayload(token: string): Record<string, unknown> | undefined {
|
|
14
|
+
const parts = token.split(".");
|
|
15
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8")) as Record<string, unknown>;
|
|
18
|
+
} catch {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function extractAccountId(idToken?: string, accessToken?: string): string | undefined {
|
|
24
|
+
for (const token of [idToken, accessToken]) {
|
|
25
|
+
if (!token) continue;
|
|
26
|
+
const payload = decodeJwtPayload(token);
|
|
27
|
+
if (!payload) continue;
|
|
28
|
+
if (typeof payload.chatgpt_account_id === "string") return payload.chatgpt_account_id;
|
|
29
|
+
const ns = payload["https://api.openai.com/auth"];
|
|
30
|
+
if (ns && typeof ns === "object" && typeof (ns as Record<string, unknown>).chatgpt_account_id === "string") {
|
|
31
|
+
return (ns as Record<string, unknown>).chatgpt_account_id as string;
|
|
32
|
+
}
|
|
33
|
+
const orgs = payload.organizations;
|
|
34
|
+
if (Array.isArray(orgs) && orgs[0] && typeof orgs[0].id === "string") return orgs[0].id as string;
|
|
35
|
+
}
|
|
36
|
+
return undefined;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function extractEmail(idToken?: string, accessToken?: string): string | undefined {
|
|
40
|
+
for (const token of [idToken, accessToken]) {
|
|
41
|
+
if (!token) continue;
|
|
42
|
+
const payload = decodeJwtPayload(token);
|
|
43
|
+
if (!payload) continue;
|
|
44
|
+
if (typeof payload.email === "string") return payload.email.toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function credsFromToken(data: Record<string, unknown>): OAuthCredentials {
|
|
50
|
+
const idToken = typeof data.id_token === "string" ? data.id_token : undefined;
|
|
51
|
+
const accessToken = data.access_token as string;
|
|
52
|
+
return {
|
|
53
|
+
access: accessToken,
|
|
54
|
+
refresh: (data.refresh_token as string) ?? "",
|
|
55
|
+
expires: Date.now() + ((data.expires_in as number) ?? 3600) * 1000,
|
|
56
|
+
accountId: extractAccountId(idToken, accessToken),
|
|
57
|
+
email: extractEmail(idToken, accessToken),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class ChatGPTOAuthFlow extends OAuthCallbackFlow {
|
|
62
|
+
#verifier = "";
|
|
63
|
+
forceLogin = false;
|
|
64
|
+
|
|
65
|
+
constructor(ctrl: OAuthController) {
|
|
66
|
+
super(ctrl, {
|
|
67
|
+
preferredPort: CALLBACK_PORT,
|
|
68
|
+
callbackPath: CALLBACK_PATH,
|
|
69
|
+
callbackHostname: "localhost",
|
|
70
|
+
callbackBindHostname: "127.0.0.1",
|
|
71
|
+
redirectUri: `http://localhost:${CALLBACK_PORT}${CALLBACK_PATH}`,
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async generateAuthUrl(state: string, redirectUri: string): Promise<{ url: string; instructions?: string }> {
|
|
76
|
+
const pkce = await generatePKCE();
|
|
77
|
+
this.#verifier = pkce.verifier;
|
|
78
|
+
const params = new URLSearchParams({
|
|
79
|
+
response_type: "code",
|
|
80
|
+
client_id: CLIENT_ID,
|
|
81
|
+
redirect_uri: redirectUri,
|
|
82
|
+
scope: SCOPE,
|
|
83
|
+
code_challenge: pkce.challenge,
|
|
84
|
+
code_challenge_method: "S256",
|
|
85
|
+
state,
|
|
86
|
+
codex_cli_simplified_flow: "true",
|
|
87
|
+
originator: ORIGINATOR,
|
|
88
|
+
});
|
|
89
|
+
params.set("id_token_add_organizations", "true");
|
|
90
|
+
if (this.forceLogin) params.set("prompt", "login");
|
|
91
|
+
return {
|
|
92
|
+
url: `${AUTH_URL}?${params}`,
|
|
93
|
+
instructions: "Complete ChatGPT login in your browser.",
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async exchangeToken(code: string, _state: string, redirectUri: string): Promise<OAuthCredentials> {
|
|
98
|
+
if (!this.#verifier) throw new Error("ChatGPT PKCE verifier not initialized");
|
|
99
|
+
const resp = await fetch(TOKEN_URL, {
|
|
100
|
+
method: "POST",
|
|
101
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
102
|
+
body: new URLSearchParams({
|
|
103
|
+
grant_type: "authorization_code",
|
|
104
|
+
client_id: CLIENT_ID,
|
|
105
|
+
code,
|
|
106
|
+
redirect_uri: redirectUri,
|
|
107
|
+
code_verifier: this.#verifier,
|
|
108
|
+
}).toString(),
|
|
109
|
+
});
|
|
110
|
+
if (!resp.ok) {
|
|
111
|
+
const errDesc = await safeErrorDescription(resp);
|
|
112
|
+
throw new Error(`ChatGPT token exchange failed: ${resp.status} ${errDesc}`);
|
|
113
|
+
}
|
|
114
|
+
return credsFromToken((await resp.json()) as Record<string, unknown>);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function safeErrorDescription(resp: Response): Promise<string> {
|
|
119
|
+
return resp.text().catch(() => "").then(text => {
|
|
120
|
+
try {
|
|
121
|
+
const parsed = JSON.parse(text) as { error?: string; error_description?: string };
|
|
122
|
+
return [parsed.error, parsed.error_description].filter(Boolean).join(": ") || `HTTP ${resp.status}`;
|
|
123
|
+
} catch { return `HTTP ${resp.status}`; }
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function loginChatGPT(ctrl: OAuthController, opts?: { forceLogin?: boolean }): Promise<OAuthCredentials> {
|
|
128
|
+
const flow = new ChatGPTOAuthFlow(ctrl);
|
|
129
|
+
if (opts?.forceLogin) flow.forceLogin = true;
|
|
130
|
+
return flow.login();
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Note: uses form-urlencoded per OAuth 2.0 spec (RFC 6749 §6).
|
|
134
|
+
// Codex-rs uses JSON for refresh — intentional divergence; both accepted by auth.openai.com.
|
|
135
|
+
export async function refreshChatGPTToken(refreshToken: string): Promise<OAuthCredentials> {
|
|
136
|
+
const resp = await fetch(TOKEN_URL, {
|
|
137
|
+
method: "POST",
|
|
138
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
139
|
+
body: new URLSearchParams({
|
|
140
|
+
grant_type: "refresh_token",
|
|
141
|
+
client_id: CLIENT_ID,
|
|
142
|
+
refresh_token: refreshToken,
|
|
143
|
+
}).toString(),
|
|
144
|
+
});
|
|
145
|
+
if (!resp.ok) {
|
|
146
|
+
const errDesc = await safeErrorDescription(resp);
|
|
147
|
+
throw new Error(`ChatGPT refresh failed: ${resp.status} ${errDesc}`);
|
|
148
|
+
}
|
|
149
|
+
return credsFromToken((await resp.json()) as Record<string, unknown>);
|
|
150
|
+
}
|
package/src/oauth/index.ts
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
import type { OAuthController, OAuthCredentials } from "./types";
|
|
2
2
|
import type { OcxConfig, OcxProviderConfig } from "../types";
|
|
3
3
|
import { loadConfig, resolveEnvValue, saveConfig } from "../config";
|
|
4
|
+
import { maskEmail } from "../privacy";
|
|
4
5
|
import { getCredential, saveCredential } from "./store";
|
|
5
6
|
import { loginXai, refreshXaiToken } from "./xai";
|
|
6
7
|
import { ANTHROPIC_OAUTH_BETA, loginAnthropic, refreshAnthropicToken } from "./anthropic";
|
|
7
8
|
import { loginKimi, refreshKimiToken } from "./kimi";
|
|
9
|
+
import { loginChatGPT, refreshChatGPTToken } from "./chatgpt";
|
|
8
10
|
import { deriveOAuthDefaultModel, deriveOAuthProviderConfig } from "../providers/derive";
|
|
9
11
|
|
|
10
12
|
const REFRESH_SKEW_MS = 60_000;
|
|
11
13
|
|
|
14
|
+
export interface LoginOpts { forceLogin?: boolean }
|
|
15
|
+
|
|
12
16
|
interface OAuthProviderDef {
|
|
13
|
-
login(ctrl: OAuthController): Promise<OAuthCredentials>;
|
|
17
|
+
login(ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials>;
|
|
14
18
|
refresh(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials>;
|
|
15
19
|
/** provider entry written into config.json on first login. */
|
|
16
20
|
providerConfig: OcxProviderConfig;
|
|
@@ -48,6 +52,12 @@ export const OAUTH_PROVIDERS: Record<string, OAuthProviderDef> = {
|
|
|
48
52
|
providerConfig: oauthConfig("kimi"),
|
|
49
53
|
defaultModel: oauthDefaultModel("kimi"),
|
|
50
54
|
},
|
|
55
|
+
chatgpt: {
|
|
56
|
+
login: loginChatGPT,
|
|
57
|
+
refresh: (rt) => refreshChatGPTToken(rt),
|
|
58
|
+
providerConfig: { adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const },
|
|
59
|
+
defaultModel: "gpt-5.4",
|
|
60
|
+
},
|
|
51
61
|
};
|
|
52
62
|
|
|
53
63
|
export function isOAuthProvider(name: string): boolean {
|
|
@@ -178,10 +188,10 @@ export function upsertOAuthProvider(config: OcxConfig, provider: string): void {
|
|
|
178
188
|
}
|
|
179
189
|
|
|
180
190
|
/** Run the login flow, persist the credential + upsert the provider entry to disk, return cred. */
|
|
181
|
-
export async function runLogin(provider: string, ctrl: OAuthController): Promise<OAuthCredentials> {
|
|
191
|
+
export async function runLogin(provider: string, ctrl: OAuthController, opts?: LoginOpts): Promise<OAuthCredentials> {
|
|
182
192
|
const def = OAUTH_PROVIDERS[provider];
|
|
183
193
|
if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
184
|
-
const cred = await def.login(ctrl);
|
|
194
|
+
const cred = await def.login(ctrl, opts);
|
|
185
195
|
saveCredential(provider, cred);
|
|
186
196
|
const config = loadConfig();
|
|
187
197
|
upsertOAuthProvider(config, provider);
|
|
@@ -195,18 +205,31 @@ export async function runLogin(provider: string, ctrl: OAuthController): Promise
|
|
|
195
205
|
* error surfaced via getLoginStatus().
|
|
196
206
|
*/
|
|
197
207
|
const loginState = new Map<string, { error?: string; done: boolean }>();
|
|
208
|
+
const loginAbort = new Map<string, AbortController>();
|
|
198
209
|
|
|
199
|
-
export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; error?: string } {
|
|
210
|
+
export function getLoginStatus(provider: string): { loggedIn: boolean; email?: string; error?: string; done: boolean } {
|
|
200
211
|
const cred = getCredential(provider);
|
|
201
212
|
const st = loginState.get(provider);
|
|
202
|
-
return { loggedIn: !!cred, email: cred?.email, error: st?.error };
|
|
213
|
+
return { loggedIn: !!cred, email: maskEmail(cred?.email) ?? undefined, error: st?.error, done: st?.done ?? false };
|
|
203
214
|
}
|
|
204
215
|
|
|
205
216
|
export function clearLoginState(provider: string): void {
|
|
217
|
+
loginAbort.get(provider)?.abort("cleared");
|
|
218
|
+
loginAbort.delete(provider);
|
|
206
219
|
loginState.delete(provider);
|
|
207
220
|
}
|
|
208
221
|
|
|
209
|
-
export
|
|
222
|
+
export function cancelLoginFlow(provider: string): boolean {
|
|
223
|
+
const ctrl = loginAbort.get(provider);
|
|
224
|
+
const existing = loginState.get(provider);
|
|
225
|
+
if (!ctrl && (!existing || existing.done)) return false;
|
|
226
|
+
ctrl?.abort("cancelled");
|
|
227
|
+
loginAbort.delete(provider);
|
|
228
|
+
loginState.set(provider, { done: true, error: "Login cancelled" });
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export async function startLoginFlow(provider: string, opts?: LoginOpts): Promise<{ url: string; instructions?: string }> {
|
|
210
233
|
const def = OAUTH_PROVIDERS[provider];
|
|
211
234
|
if (!def) throw new Error(`Unknown OAuth provider: ${provider}`);
|
|
212
235
|
const existing = loginState.get(provider);
|
|
@@ -214,6 +237,8 @@ export async function startLoginFlow(provider: string): Promise<{ url: string; i
|
|
|
214
237
|
throw new Error(`A login for ${provider} is already in progress`);
|
|
215
238
|
}
|
|
216
239
|
loginState.set(provider, { done: false });
|
|
240
|
+
const abort = new AbortController();
|
|
241
|
+
loginAbort.set(provider, abort);
|
|
217
242
|
return new Promise((resolve, reject) => {
|
|
218
243
|
let urlResolved = false;
|
|
219
244
|
const ctrl: OAuthController = {
|
|
@@ -222,16 +247,19 @@ export async function startLoginFlow(provider: string): Promise<{ url: string; i
|
|
|
222
247
|
resolve({ url, instructions });
|
|
223
248
|
},
|
|
224
249
|
onProgress: () => {},
|
|
250
|
+
signal: abort.signal,
|
|
225
251
|
};
|
|
226
252
|
// Background: runLogin persists the credential + upserts the provider entry to disk config.
|
|
227
|
-
runLogin(provider, ctrl)
|
|
253
|
+
runLogin(provider, ctrl, opts)
|
|
228
254
|
.then(() => {
|
|
255
|
+
loginAbort.delete(provider);
|
|
229
256
|
loginState.set(provider, { done: true });
|
|
230
257
|
// Local-token import (grok-cli / Claude Code keychain) completes WITHOUT firing onAuth —
|
|
231
258
|
// resolve so the GUI call returns instead of hanging.
|
|
232
259
|
if (!urlResolved) resolve({ url: "", instructions: "Logged in via an existing local CLI/keychain token — no browser needed." });
|
|
233
260
|
})
|
|
234
261
|
.catch((e: unknown) => {
|
|
262
|
+
loginAbort.delete(provider);
|
|
235
263
|
const msg = e instanceof Error ? e.message : String(e);
|
|
236
264
|
loginState.set(provider, { done: true, error: msg });
|
|
237
265
|
if (!urlResolved) reject(e);
|
package/src/oauth/store.ts
CHANGED
|
@@ -4,15 +4,19 @@ import { join } from "node:path";
|
|
|
4
4
|
import { getConfigDir, atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config";
|
|
5
5
|
import type { OAuthCredentials } from "./types";
|
|
6
6
|
|
|
7
|
-
const AUTH_PATH = join(getConfigDir(), "auth.json");
|
|
8
7
|
type AuthStore = Record<string, OAuthCredentials>;
|
|
9
8
|
|
|
9
|
+
function authPath(): string {
|
|
10
|
+
return join(getConfigDir(), "auth.json");
|
|
11
|
+
}
|
|
12
|
+
|
|
10
13
|
export function loadAuthStore(): AuthStore {
|
|
14
|
+
const path = authPath();
|
|
11
15
|
hardenConfigDir();
|
|
12
|
-
hardenExistingSecret(
|
|
13
|
-
if (!existsSync(
|
|
16
|
+
hardenExistingSecret(path);
|
|
17
|
+
if (!existsSync(path)) return {};
|
|
14
18
|
try {
|
|
15
|
-
return JSON.parse(readFileSync(
|
|
19
|
+
return JSON.parse(readFileSync(path, "utf-8")) as AuthStore;
|
|
16
20
|
} catch {
|
|
17
21
|
return {};
|
|
18
22
|
}
|
|
@@ -25,7 +29,7 @@ function persist(store: AuthStore): void {
|
|
|
25
29
|
} else {
|
|
26
30
|
try { chmodSync(dir, 0o700); } catch { /* best-effort on existing dir */ }
|
|
27
31
|
}
|
|
28
|
-
atomicWriteFile(
|
|
32
|
+
atomicWriteFile(authPath(), JSON.stringify(store, null, 2) + "\n");
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
export function getCredential(provider: string): OAuthCredentials | null {
|
package/src/privacy.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function maskEmail(value: string | null | undefined): string | null {
|
|
2
|
+
if (!value) return null;
|
|
3
|
+
const at = value.indexOf("@");
|
|
4
|
+
if (at <= 0) return value;
|
|
5
|
+
const local = value.slice(0, at);
|
|
6
|
+
const domain = value.slice(at + 1);
|
|
7
|
+
if (!domain) return value;
|
|
8
|
+
if (local.length === 1) return `*@${domain}`;
|
|
9
|
+
if (local.length === 2) return `${local[0]}*@${domain}`;
|
|
10
|
+
return `${local[0]}***${local[local.length - 1]}@${domain}`;
|
|
11
|
+
}
|