@mandujs/core 0.40.1 → 0.41.1
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/package.json +1 -1
- package/src/brain/adapters/__tests__/_helpers.ts +19 -0
- package/src/brain/adapters/__tests__/chatgpt-auth.test.ts +193 -0
- package/src/brain/adapters/__tests__/openai-oauth.test.ts +7 -0
- package/src/brain/adapters/__tests__/resolver.test.ts +5 -0
- package/src/brain/adapters/chatgpt-auth.ts +300 -0
- package/src/brain/adapters/index.ts +40 -4
- package/src/brain/adapters/openai-oauth.ts +99 -29
package/package.json
CHANGED
|
@@ -5,12 +5,15 @@
|
|
|
5
5
|
* in the real network / keychain / filesystem paths.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import os from "node:os";
|
|
8
10
|
import {
|
|
9
11
|
CredentialStore,
|
|
10
12
|
type CredentialBackend,
|
|
11
13
|
type StoredToken,
|
|
12
14
|
} from "../../credentials";
|
|
13
15
|
import type { HttpClient, OAuthEndpoints } from "../oauth-flow";
|
|
16
|
+
import { ChatGPTAuth } from "../chatgpt-auth";
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* In-memory credential store that satisfies `CredentialBackend`. We
|
|
@@ -62,3 +65,19 @@ export function jsonResponse(body: unknown, status = 200): Response {
|
|
|
62
65
|
headers: { "content-type": "application/json" },
|
|
63
66
|
});
|
|
64
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Isolated ChatGPTAuth that always reports no session token — points at
|
|
71
|
+
* a nonexistent path so it never picks up the developer's real
|
|
72
|
+
* `~/.codex/auth.json`. Tests that exercise the keychain path must
|
|
73
|
+
* pass this helper to prevent the ChatGPT code path from short-
|
|
74
|
+
* circuiting the test's intended flow.
|
|
75
|
+
*/
|
|
76
|
+
export function makeEmptyChatGPTAuth(): ChatGPTAuth {
|
|
77
|
+
return new ChatGPTAuth({
|
|
78
|
+
authFilePath: path.join(
|
|
79
|
+
os.tmpdir(),
|
|
80
|
+
`mandu-test-no-auth-${process.pid}-${Date.now()}.json`,
|
|
81
|
+
),
|
|
82
|
+
});
|
|
83
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the ChatGPT session-token auth helper
|
|
3
|
+
* (`packages/core/src/brain/adapters/chatgpt-auth.ts`).
|
|
4
|
+
*
|
|
5
|
+
* ChatGPTAuth reads whatever `@openai/codex login` wrote to
|
|
6
|
+
* `~/.codex/auth.json` (or the `CHATGPT_LOCAL_HOME` / `CODEX_HOME`
|
|
7
|
+
* override), auto-refreshes when the access token is near expiry, and
|
|
8
|
+
* exposes `{ accessToken, accountId, idToken, refreshToken }` to
|
|
9
|
+
* callers. Tests here exercise the pure-file behaviour — the refresh
|
|
10
|
+
* path uses an injected `httpClient` so no network hits the real
|
|
11
|
+
* `auth.openai.com`.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
15
|
+
import { promises as fs } from "node:fs";
|
|
16
|
+
import { writeFileSync } from "node:fs";
|
|
17
|
+
import os from "node:os";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { ChatGPTAuth } from "../chatgpt-auth";
|
|
20
|
+
|
|
21
|
+
let tmp: string;
|
|
22
|
+
let authPath: string;
|
|
23
|
+
|
|
24
|
+
beforeEach(async () => {
|
|
25
|
+
tmp = await fs.mkdtemp(path.join(os.tmpdir(), "mandu-chatgpt-auth-"));
|
|
26
|
+
authPath = path.join(tmp, "auth.json");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(async () => {
|
|
30
|
+
await fs.rm(tmp, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/** Minimal JWT whose `exp` claim is far in the future (no refresh). */
|
|
34
|
+
function makeFarFutureJwt(): string {
|
|
35
|
+
const header = Buffer.from('{"alg":"none"}').toString("base64url");
|
|
36
|
+
const payload = Buffer.from(
|
|
37
|
+
JSON.stringify({
|
|
38
|
+
exp: Math.floor(Date.now() / 1000) + 60 * 60 * 24, // 24h from now
|
|
39
|
+
"https://api.openai.com/auth": {
|
|
40
|
+
chatgpt_account_id: "acct_test",
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
).toString("base64url");
|
|
44
|
+
return `${header}.${payload}.`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function makeExpiredJwt(): string {
|
|
48
|
+
const header = Buffer.from('{"alg":"none"}').toString("base64url");
|
|
49
|
+
const payload = Buffer.from(
|
|
50
|
+
JSON.stringify({
|
|
51
|
+
exp: Math.floor(Date.now() / 1000) - 60, // 1 min ago
|
|
52
|
+
"https://api.openai.com/auth": {
|
|
53
|
+
chatgpt_account_id: "acct_test",
|
|
54
|
+
},
|
|
55
|
+
}),
|
|
56
|
+
).toString("base64url");
|
|
57
|
+
return `${header}.${payload}.`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function seedAuthFile(contents: unknown): void {
|
|
61
|
+
writeFileSync(authPath, JSON.stringify(contents), { encoding: "utf-8" });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
describe("ChatGPTAuth — discovery + isAuthenticated", () => {
|
|
65
|
+
it("reports false when auth.json does not exist", () => {
|
|
66
|
+
const auth = new ChatGPTAuth({ authFilePath: authPath });
|
|
67
|
+
expect(auth.isAuthenticated()).toBe(false);
|
|
68
|
+
expect(auth.locateAuthFile()).toBe(null);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("reports true when auth.json has an access_token", () => {
|
|
72
|
+
seedAuthFile({ tokens: { access_token: "x" } });
|
|
73
|
+
const auth = new ChatGPTAuth({ authFilePath: authPath });
|
|
74
|
+
expect(auth.isAuthenticated()).toBe(true);
|
|
75
|
+
expect(auth.locateAuthFile()).toBe(authPath);
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("ChatGPTAuth — getAuth with a valid (non-expiring) token", () => {
|
|
80
|
+
it("returns accessToken + accountId without calling refresh", async () => {
|
|
81
|
+
const jwt = makeFarFutureJwt();
|
|
82
|
+
seedAuthFile({
|
|
83
|
+
tokens: {
|
|
84
|
+
access_token: "live-access",
|
|
85
|
+
id_token: jwt,
|
|
86
|
+
refresh_token: "live-refresh",
|
|
87
|
+
},
|
|
88
|
+
last_refresh: new Date().toISOString(),
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
let refreshCalls = 0;
|
|
92
|
+
const auth = new ChatGPTAuth({
|
|
93
|
+
authFilePath: authPath,
|
|
94
|
+
httpClient: async () => {
|
|
95
|
+
refreshCalls++;
|
|
96
|
+
return new Response("{}", { status: 200 });
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const effective = await auth.getAuth();
|
|
101
|
+
expect(effective.accessToken).toBe("live-access");
|
|
102
|
+
expect(effective.accountId).toBe("acct_test");
|
|
103
|
+
expect(effective.sourcePath).toBe(authPath);
|
|
104
|
+
expect(refreshCalls).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("throws a helpful message when auth.json is missing", async () => {
|
|
108
|
+
const auth = new ChatGPTAuth({ authFilePath: authPath });
|
|
109
|
+
await expect(auth.getAuth()).rejects.toThrow(/auth\.json not found/);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe("ChatGPTAuth — automatic refresh on expired access_token", () => {
|
|
114
|
+
it("calls the token endpoint once and persists the new tokens", async () => {
|
|
115
|
+
const expiredJwt = makeExpiredJwt();
|
|
116
|
+
seedAuthFile({
|
|
117
|
+
tokens: {
|
|
118
|
+
access_token: expiredJwt, // exp < now → triggers refresh
|
|
119
|
+
id_token: expiredJwt,
|
|
120
|
+
refresh_token: "r-fresh",
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
let tokenCalls = 0;
|
|
125
|
+
const newJwt = makeFarFutureJwt();
|
|
126
|
+
const auth = new ChatGPTAuth({
|
|
127
|
+
authFilePath: authPath,
|
|
128
|
+
httpClient: async (url, init) => {
|
|
129
|
+
tokenCalls++;
|
|
130
|
+
expect(url).toBe("https://auth.openai.com/oauth/token");
|
|
131
|
+
const body = JSON.parse(String(init?.body ?? "{}"));
|
|
132
|
+
expect(body.grant_type).toBe("refresh_token");
|
|
133
|
+
expect(body.refresh_token).toBe("r-fresh");
|
|
134
|
+
return new Response(
|
|
135
|
+
JSON.stringify({
|
|
136
|
+
access_token: "brand-new-access",
|
|
137
|
+
refresh_token: "brand-new-refresh",
|
|
138
|
+
id_token: newJwt,
|
|
139
|
+
}),
|
|
140
|
+
{ status: 200, headers: { "content-type": "application/json" } },
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
const effective = await auth.getAuth();
|
|
146
|
+
expect(effective.accessToken).toBe("brand-new-access");
|
|
147
|
+
expect(effective.refreshToken).toBe("brand-new-refresh");
|
|
148
|
+
expect(effective.accountId).toBe("acct_test");
|
|
149
|
+
expect(tokenCalls).toBe(1);
|
|
150
|
+
|
|
151
|
+
// auth.json on disk should have been rewritten with the new tokens.
|
|
152
|
+
const onDisk = JSON.parse(await fs.readFile(authPath, "utf-8"));
|
|
153
|
+
expect(onDisk.tokens.access_token).toBe("brand-new-access");
|
|
154
|
+
expect(onDisk.tokens.refresh_token).toBe("brand-new-refresh");
|
|
155
|
+
expect(typeof onDisk.last_refresh).toBe("string");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("surfaces token endpoint errors verbatim", async () => {
|
|
159
|
+
seedAuthFile({
|
|
160
|
+
tokens: {
|
|
161
|
+
access_token: makeExpiredJwt(),
|
|
162
|
+
refresh_token: "r-bad",
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const auth = new ChatGPTAuth({
|
|
167
|
+
authFilePath: authPath,
|
|
168
|
+
httpClient: async () =>
|
|
169
|
+
new Response("bad token", { status: 401 }),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await expect(auth.getAuth()).rejects.toThrow(/Token refresh 401/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("ChatGPTAuth — malformed auth.json", () => {
|
|
177
|
+
it("rejects when access_token is missing", async () => {
|
|
178
|
+
seedAuthFile({ tokens: {} });
|
|
179
|
+
const auth = new ChatGPTAuth({ authFilePath: authPath });
|
|
180
|
+
await expect(auth.getAuth()).rejects.toThrow(/no access_token/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("rejects when refresh is required but refresh_token is absent", async () => {
|
|
184
|
+
seedAuthFile({
|
|
185
|
+
tokens: {
|
|
186
|
+
access_token: makeExpiredJwt(),
|
|
187
|
+
// refresh_token intentionally omitted
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
const auth = new ChatGPTAuth({ authFilePath: authPath });
|
|
191
|
+
await expect(auth.getAuth()).rejects.toThrow(/no refresh_token/);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
makeStubHttpClient,
|
|
24
24
|
FAKE_ENDPOINTS,
|
|
25
25
|
jsonResponse,
|
|
26
|
+
makeEmptyChatGPTAuth,
|
|
26
27
|
} from "./_helpers";
|
|
27
28
|
import type { StoredToken } from "../../credentials";
|
|
28
29
|
|
|
@@ -49,6 +50,7 @@ describe("OpenAIOAuthAdapter — shape + defaults", () => {
|
|
|
49
50
|
credentialStore: store,
|
|
50
51
|
projectRoot: tmp,
|
|
51
52
|
endpoints: FAKE_ENDPOINTS,
|
|
53
|
+
chatgptAuth: makeEmptyChatGPTAuth(),
|
|
52
54
|
httpClient: makeStubHttpClient(() => new Response("ok")),
|
|
53
55
|
skipConsent: true,
|
|
54
56
|
});
|
|
@@ -76,6 +78,7 @@ describe("OpenAIOAuthAdapter — redaction invariant", () => {
|
|
|
76
78
|
credentialStore: store,
|
|
77
79
|
projectRoot: tmp,
|
|
78
80
|
endpoints: FAKE_ENDPOINTS,
|
|
81
|
+
chatgptAuth: makeEmptyChatGPTAuth(),
|
|
79
82
|
httpClient: http,
|
|
80
83
|
skipConsent: true,
|
|
81
84
|
});
|
|
@@ -118,6 +121,7 @@ describe("OpenAIOAuthAdapter — 401 fallback chain", () => {
|
|
|
118
121
|
credentialStore: store,
|
|
119
122
|
projectRoot: tmp,
|
|
120
123
|
endpoints: FAKE_ENDPOINTS,
|
|
124
|
+
chatgptAuth: makeEmptyChatGPTAuth(),
|
|
121
125
|
httpClient: http,
|
|
122
126
|
skipConsent: true,
|
|
123
127
|
});
|
|
@@ -137,6 +141,7 @@ describe("OpenAIOAuthAdapter — no-token → empty completion (not strict)", ()
|
|
|
137
141
|
credentialStore: store,
|
|
138
142
|
projectRoot: tmp,
|
|
139
143
|
endpoints: FAKE_ENDPOINTS,
|
|
144
|
+
chatgptAuth: makeEmptyChatGPTAuth(),
|
|
140
145
|
httpClient: makeStubHttpClient(() =>
|
|
141
146
|
jsonResponse({
|
|
142
147
|
choices: [{ message: { content: "should-not-run" } }],
|
|
@@ -164,6 +169,7 @@ describe("OpenAIOAuthAdapter — consent decline short-circuits transmission", (
|
|
|
164
169
|
credentialStore: store,
|
|
165
170
|
projectRoot: tmp,
|
|
166
171
|
endpoints: FAKE_ENDPOINTS,
|
|
172
|
+
chatgptAuth: makeEmptyChatGPTAuth(),
|
|
167
173
|
httpClient: http,
|
|
168
174
|
consentDeps: {
|
|
169
175
|
ask: async () => "n",
|
|
@@ -192,6 +198,7 @@ describe("OpenAIOAuthAdapter — model override flows through to the wire body",
|
|
|
192
198
|
credentialStore: store,
|
|
193
199
|
projectRoot: tmp,
|
|
194
200
|
endpoints: FAKE_ENDPOINTS,
|
|
201
|
+
chatgptAuth: makeEmptyChatGPTAuth(),
|
|
195
202
|
httpClient: http,
|
|
196
203
|
skipConsent: true,
|
|
197
204
|
model: "gpt-4o",
|
|
@@ -32,6 +32,7 @@ describe("resolveBrainAdapter — priority order", () => {
|
|
|
32
32
|
adapter: "auto",
|
|
33
33
|
credentialStore: store,
|
|
34
34
|
probeOllama: async () => true,
|
|
35
|
+
probeChatGPTAuth: () => ({ authenticated: false, path: null }),
|
|
35
36
|
});
|
|
36
37
|
expect(res.resolved).toBe("openai");
|
|
37
38
|
expect(res.adapter.name).toBe("openai-oauth");
|
|
@@ -43,6 +44,7 @@ describe("resolveBrainAdapter — priority order", () => {
|
|
|
43
44
|
adapter: "auto",
|
|
44
45
|
credentialStore: store,
|
|
45
46
|
probeOllama: async () => true,
|
|
47
|
+
probeChatGPTAuth: () => ({ authenticated: false, path: null }),
|
|
46
48
|
});
|
|
47
49
|
expect(res.resolved).toBe("anthropic");
|
|
48
50
|
expect(res.adapter.name).toBe("anthropic-oauth");
|
|
@@ -54,6 +56,7 @@ describe("resolveBrainAdapter — priority order", () => {
|
|
|
54
56
|
adapter: "auto",
|
|
55
57
|
credentialStore: store,
|
|
56
58
|
probeOllama: async () => true,
|
|
59
|
+
probeChatGPTAuth: () => ({ authenticated: false, path: null }),
|
|
57
60
|
});
|
|
58
61
|
expect(res.resolved).toBe("ollama");
|
|
59
62
|
expect(res.adapter.name).toBe("ollama");
|
|
@@ -65,6 +68,7 @@ describe("resolveBrainAdapter — priority order", () => {
|
|
|
65
68
|
adapter: "auto",
|
|
66
69
|
credentialStore: store,
|
|
67
70
|
probeOllama: async () => false,
|
|
71
|
+
probeChatGPTAuth: () => ({ authenticated: false, path: null }),
|
|
68
72
|
});
|
|
69
73
|
expect(res.resolved).toBe("template");
|
|
70
74
|
expect(res.adapter.name).toBe("noop");
|
|
@@ -104,6 +108,7 @@ describe("resolveBrainAdapter — explicit pins degrade gracefully", () => {
|
|
|
104
108
|
const res = await resolveBrainAdapter({
|
|
105
109
|
adapter: "openai",
|
|
106
110
|
credentialStore: store,
|
|
111
|
+
probeChatGPTAuth: () => ({ authenticated: false, path: null }),
|
|
107
112
|
});
|
|
108
113
|
expect(res.resolved).toBe("template");
|
|
109
114
|
expect(res.reason).toContain("no token");
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatGPT OAuth 토큰 관리 — `codex login`이 만든 auth.json을 읽고 자동 refresh.
|
|
3
|
+
*
|
|
4
|
+
* 첫 로그인은 OpenAI 공식 Codex CLI가 담당:
|
|
5
|
+
* `npx @openai/codex login`
|
|
6
|
+
* → 브라우저에서 ChatGPT 로그인 → 토큰이 `~/.codex/auth.json` 또는
|
|
7
|
+
* `~/.chatgpt-local/auth.json`에 저장됨.
|
|
8
|
+
*
|
|
9
|
+
* Mandu 는 그 파일을 읽고, 만료 임박 시 refresh_token으로 자동 갱신.
|
|
10
|
+
*
|
|
11
|
+
* 이 접근의 장점:
|
|
12
|
+
* - Mandu OAuth 앱 등록 불필요. OpenAI 공식 clientId 재사용.
|
|
13
|
+
* - 사용자가 codex CLI 를 이미 설치했다면 로그인 1회로 끝.
|
|
14
|
+
* - 토큰 저장소를 OpenAI 공식 경로와 공유 → 여러 도구가 같은 세션 사용.
|
|
15
|
+
*
|
|
16
|
+
* 포팅 원본: kakao-bot-sdk `src/auth/chatgpt.ts` (EvanZhouDev/openai-oauth
|
|
17
|
+
* MIT 패턴의 단순화 버전).
|
|
18
|
+
*/
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from "node:fs";
|
|
20
|
+
import { join, dirname } from "node:path";
|
|
21
|
+
import { homedir } from "node:os";
|
|
22
|
+
|
|
23
|
+
const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
|
|
24
|
+
const DEFAULT_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
25
|
+
const REFRESH_EXPIRY_MARGIN_MS = 5 * 60 * 1000;
|
|
26
|
+
|
|
27
|
+
interface StoredTokens {
|
|
28
|
+
id_token?: string;
|
|
29
|
+
access_token?: string;
|
|
30
|
+
refresh_token?: string;
|
|
31
|
+
account_id?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface AuthFile {
|
|
35
|
+
OPENAI_API_KEY?: string;
|
|
36
|
+
tokens?: StoredTokens;
|
|
37
|
+
last_refresh?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface EffectiveAuth {
|
|
41
|
+
accessToken: string;
|
|
42
|
+
accountId: string;
|
|
43
|
+
idToken?: string;
|
|
44
|
+
refreshToken?: string;
|
|
45
|
+
sourcePath: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Minimal fetch surface — matches `HttpClient` from `./oauth-flow`. */
|
|
49
|
+
export type ChatGPTHttpClient = (
|
|
50
|
+
input: string,
|
|
51
|
+
init?: RequestInit,
|
|
52
|
+
) => Promise<Response>;
|
|
53
|
+
|
|
54
|
+
export interface ChatGPTAuthOptions {
|
|
55
|
+
/** auth.json 경로 명시 override. 기본: 자동 탐색. */
|
|
56
|
+
authFilePath?: string;
|
|
57
|
+
/** OAuth client_id. 기본: ChatGPT 공식 (`app_EMoamEEZ73f0CkXaXp7hrann`). */
|
|
58
|
+
clientId?: string;
|
|
59
|
+
/** Token endpoint. 기본: `https://auth.openai.com/oauth/token`. */
|
|
60
|
+
tokenUrl?: string;
|
|
61
|
+
/** Test 주입용 — 실제 fetch 대신 stub. */
|
|
62
|
+
httpClient?: ChatGPTHttpClient;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class ChatGPTAuth {
|
|
66
|
+
private readonly clientId: string;
|
|
67
|
+
private readonly tokenUrl: string;
|
|
68
|
+
private readonly httpClient: ChatGPTHttpClient;
|
|
69
|
+
/** 명시된 경로 또는 첫 read 시 결정된 경로. write 시 같은 경로 사용. */
|
|
70
|
+
private resolvedPath: string | null;
|
|
71
|
+
private readonly explicitPath: string | undefined;
|
|
72
|
+
|
|
73
|
+
constructor(options: ChatGPTAuthOptions = {}) {
|
|
74
|
+
this.clientId = options.clientId ?? DEFAULT_CLIENT_ID;
|
|
75
|
+
this.tokenUrl = options.tokenUrl ?? DEFAULT_TOKEN_URL;
|
|
76
|
+
this.httpClient = options.httpClient ?? fetch;
|
|
77
|
+
this.explicitPath = options.authFilePath;
|
|
78
|
+
this.resolvedPath = options.authFilePath ?? null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 저장된 토큰이 존재하는지 (만료 무관). */
|
|
82
|
+
isAuthenticated(): boolean {
|
|
83
|
+
const data = this.readFile();
|
|
84
|
+
return Boolean(data?.tokens?.access_token);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 유효한 access_token + account_id 반환. 만료 임박 시 자동 refresh.
|
|
89
|
+
* 토큰 없거나 refresh 실패 시 throw.
|
|
90
|
+
*/
|
|
91
|
+
async getAuth(): Promise<EffectiveAuth> {
|
|
92
|
+
let data = this.readFile();
|
|
93
|
+
if (!data) {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`[mandu brain] OpenAI auth.json not found. Expected at one of:\n${candidatePaths(this.explicitPath).join("\n")}\n` +
|
|
96
|
+
`Run \`npx @openai/codex login\` first (or \`mandu brain login\` which wraps it).`,
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
let accessToken = data.tokens?.access_token;
|
|
101
|
+
let idToken = data.tokens?.id_token;
|
|
102
|
+
let refreshToken = data.tokens?.refresh_token;
|
|
103
|
+
let accountId = data.tokens?.account_id ?? deriveAccountId(idToken);
|
|
104
|
+
|
|
105
|
+
if (!accessToken) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
"[mandu brain] auth.json has no access_token. Re-run `npx @openai/codex login`.",
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (shouldRefresh(accessToken, data.last_refresh)) {
|
|
112
|
+
if (!refreshToken) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
"[mandu brain] Token expired and no refresh_token available. Re-run `npx @openai/codex login`.",
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const refreshed = await this.callTokenEndpoint(refreshToken);
|
|
118
|
+
accessToken = refreshed.access_token;
|
|
119
|
+
idToken = refreshed.id_token ?? idToken;
|
|
120
|
+
refreshToken = refreshed.refresh_token ?? refreshToken;
|
|
121
|
+
accountId = deriveAccountId(idToken) ?? accountId;
|
|
122
|
+
|
|
123
|
+
data = {
|
|
124
|
+
...data,
|
|
125
|
+
tokens: {
|
|
126
|
+
id_token: idToken,
|
|
127
|
+
access_token: accessToken,
|
|
128
|
+
refresh_token: refreshToken,
|
|
129
|
+
account_id: accountId,
|
|
130
|
+
},
|
|
131
|
+
last_refresh: new Date().toISOString(),
|
|
132
|
+
};
|
|
133
|
+
this.writeFile(data);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!accountId) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
"[mandu brain] Could not derive chatgpt_account_id from auth.json. Re-login required.",
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (!accessToken) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
"[mandu brain] access_token is null after refresh (unexpected).",
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
accessToken,
|
|
149
|
+
accountId,
|
|
150
|
+
idToken,
|
|
151
|
+
refreshToken,
|
|
152
|
+
sourcePath: this.resolvedPath ?? "(unknown)",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** 로그인 후 auth.json 이 존재하는 위치 반환 (디버그용). */
|
|
157
|
+
locateAuthFile(): string | null {
|
|
158
|
+
const candidates = candidatePaths(this.explicitPath);
|
|
159
|
+
for (const p of candidates) {
|
|
160
|
+
if (existsSync(p)) return p;
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── 내부 ────────────────────────────────────
|
|
166
|
+
|
|
167
|
+
private readFile(): AuthFile | null {
|
|
168
|
+
const candidates = candidatePaths(this.explicitPath);
|
|
169
|
+
for (const p of candidates) {
|
|
170
|
+
try {
|
|
171
|
+
if (!existsSync(p)) continue;
|
|
172
|
+
const text = readFileSync(p, "utf-8");
|
|
173
|
+
const parsed = JSON.parse(text) as AuthFile;
|
|
174
|
+
if (typeof parsed === "object" && parsed !== null) {
|
|
175
|
+
this.resolvedPath = p;
|
|
176
|
+
return parsed;
|
|
177
|
+
}
|
|
178
|
+
} catch {
|
|
179
|
+
// 다음 candidate 시도
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private writeFile(data: AuthFile): void {
|
|
186
|
+
const path = this.resolvedPath ?? this.explicitPath ?? defaultWritePath();
|
|
187
|
+
const dir = dirname(path);
|
|
188
|
+
if (dir && !existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
189
|
+
const tmp = `${path}.tmp`;
|
|
190
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2), {
|
|
191
|
+
encoding: "utf-8",
|
|
192
|
+
mode: 0o600,
|
|
193
|
+
});
|
|
194
|
+
renameSync(tmp, path);
|
|
195
|
+
this.resolvedPath = path;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private async callTokenEndpoint(refreshToken: string): Promise<StoredTokens> {
|
|
199
|
+
const resp = await this.httpClient(this.tokenUrl, {
|
|
200
|
+
method: "POST",
|
|
201
|
+
headers: { "content-type": "application/json" },
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
grant_type: "refresh_token",
|
|
204
|
+
refresh_token: refreshToken,
|
|
205
|
+
client_id: this.clientId,
|
|
206
|
+
scope: "openid profile email offline_access",
|
|
207
|
+
}),
|
|
208
|
+
});
|
|
209
|
+
const text = await resp.text();
|
|
210
|
+
if (!resp.ok) {
|
|
211
|
+
throw new Error(`Token refresh ${resp.status}: ${text.slice(0, 300)}`);
|
|
212
|
+
}
|
|
213
|
+
let json: { access_token?: string; refresh_token?: string; id_token?: string };
|
|
214
|
+
try {
|
|
215
|
+
json = JSON.parse(text);
|
|
216
|
+
} catch {
|
|
217
|
+
throw new Error(`Token refresh: invalid JSON: ${text.slice(0, 200)}`);
|
|
218
|
+
}
|
|
219
|
+
if (!json.access_token) {
|
|
220
|
+
throw new Error(`Token refresh: missing access_token: ${text}`);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
access_token: json.access_token,
|
|
224
|
+
refresh_token: json.refresh_token,
|
|
225
|
+
id_token: json.id_token,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ─── 헬퍼 ─────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
function candidatePaths(explicit?: string): string[] {
|
|
233
|
+
if (explicit) return [explicit];
|
|
234
|
+
const out: string[] = [];
|
|
235
|
+
const env1 = process.env["CHATGPT_LOCAL_HOME"];
|
|
236
|
+
const env2 = process.env["CODEX_HOME"];
|
|
237
|
+
if (env1) out.push(join(env1, "auth.json"));
|
|
238
|
+
if (env2) out.push(join(env2, "auth.json"));
|
|
239
|
+
out.push(join(homedir(), ".chatgpt-local", "auth.json"));
|
|
240
|
+
out.push(join(homedir(), ".codex", "auth.json"));
|
|
241
|
+
return [...new Set(out)];
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function defaultWritePath(): string {
|
|
245
|
+
return process.env["CHATGPT_LOCAL_HOME"]
|
|
246
|
+
? join(process.env["CHATGPT_LOCAL_HOME"]!, "auth.json")
|
|
247
|
+
: process.env["CODEX_HOME"]
|
|
248
|
+
? join(process.env["CODEX_HOME"]!, "auth.json")
|
|
249
|
+
: join(homedir(), ".chatgpt-local", "auth.json");
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function deriveAccountId(idToken?: string): string | undefined {
|
|
253
|
+
if (!idToken || !idToken.includes(".")) return undefined;
|
|
254
|
+
const parts = idToken.split(".");
|
|
255
|
+
if (parts.length < 2 || !parts[1]) return undefined;
|
|
256
|
+
try {
|
|
257
|
+
const padded = parts[1] + "=".repeat(((-parts[1].length % 4) + 4) % 4);
|
|
258
|
+
const payload = JSON.parse(
|
|
259
|
+
Buffer.from(padded, "base64url").toString("utf-8"),
|
|
260
|
+
) as Record<string, unknown>;
|
|
261
|
+
const auth = payload["https://api.openai.com/auth"];
|
|
262
|
+
if (typeof auth === "object" && auth !== null && "chatgpt_account_id" in auth) {
|
|
263
|
+
const id = (auth as Record<string, unknown>)["chatgpt_account_id"];
|
|
264
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
265
|
+
}
|
|
266
|
+
} catch {
|
|
267
|
+
// ignore
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function shouldRefresh(accessToken: string, lastRefreshIso: string | undefined): boolean {
|
|
273
|
+
// JWT exp 클레임으로 우선 판단
|
|
274
|
+
const claims = parseJwtClaims(accessToken);
|
|
275
|
+
if (claims && typeof claims["exp"] === "number") {
|
|
276
|
+
const expMs = (claims["exp"] as number) * 1000;
|
|
277
|
+
if (expMs <= Date.now() + REFRESH_EXPIRY_MARGIN_MS) return true;
|
|
278
|
+
}
|
|
279
|
+
// last_refresh 기반 휴리스틱 (55분 초과)
|
|
280
|
+
if (lastRefreshIso) {
|
|
281
|
+
const last = Date.parse(lastRefreshIso);
|
|
282
|
+
if (!Number.isNaN(last) && Date.now() - last > 55 * 60 * 1000) return true;
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function parseJwtClaims(token: string): Record<string, unknown> | undefined {
|
|
288
|
+
if (!token.includes(".")) return undefined;
|
|
289
|
+
const parts = token.split(".");
|
|
290
|
+
if (parts.length !== 3 || !parts[1]) return undefined;
|
|
291
|
+
try {
|
|
292
|
+
const padded = parts[1] + "=".repeat(((-parts[1].length % 4) + 4) % 4);
|
|
293
|
+
const payload = JSON.parse(Buffer.from(padded, "base64url").toString("utf-8"));
|
|
294
|
+
return typeof payload === "object" && payload !== null
|
|
295
|
+
? (payload as Record<string, unknown>)
|
|
296
|
+
: undefined;
|
|
297
|
+
} catch {
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
@@ -25,6 +25,7 @@ export * from "./ollama";
|
|
|
25
25
|
export * from "./openai-oauth";
|
|
26
26
|
export * from "./anthropic-oauth";
|
|
27
27
|
export * from "./oauth-flow";
|
|
28
|
+
export * from "./chatgpt-auth";
|
|
28
29
|
|
|
29
30
|
import { type LLMAdapter, NoopAdapter } from "./base";
|
|
30
31
|
import { OllamaAdapter, createOllamaAdapter } from "./ollama";
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
createOpenAIOAuthAdapter,
|
|
34
35
|
type OpenAIOAuthAdapterOptions,
|
|
35
36
|
} from "./openai-oauth";
|
|
37
|
+
import { ChatGPTAuth } from "./chatgpt-auth";
|
|
36
38
|
import {
|
|
37
39
|
AnthropicOAuthAdapter,
|
|
38
40
|
createAnthropicOAuthAdapter,
|
|
@@ -81,6 +83,13 @@ export interface BrainAdapterConfig {
|
|
|
81
83
|
* default consults `credentialStore.load(provider)`.
|
|
82
84
|
*/
|
|
83
85
|
probeToken?: (provider: "openai" | "anthropic") => Promise<StoredToken | null>;
|
|
86
|
+
/**
|
|
87
|
+
* Override the ChatGPT session-token probe. Default: instantiate
|
|
88
|
+
* `new ChatGPTAuth()` and check its on-disk auth.json. Tests inject
|
|
89
|
+
* a stub returning `false` so the developer's real `~/.codex/auth.json`
|
|
90
|
+
* doesn't leak into unit-test expectations.
|
|
91
|
+
*/
|
|
92
|
+
probeChatGPTAuth?: () => { authenticated: boolean; path: string | null };
|
|
84
93
|
}
|
|
85
94
|
|
|
86
95
|
/**
|
|
@@ -121,6 +130,13 @@ export async function resolveBrainAdapter(
|
|
|
121
130
|
config.probeOllama ??
|
|
122
131
|
(async (ollama: OllamaAdapter) => ollama.isServerRunning());
|
|
123
132
|
|
|
133
|
+
const probeChatGPTAuth =
|
|
134
|
+
config.probeChatGPTAuth ??
|
|
135
|
+
(() => {
|
|
136
|
+
const c = new ChatGPTAuth();
|
|
137
|
+
return { authenticated: c.isAuthenticated(), path: c.locateAuthFile() };
|
|
138
|
+
});
|
|
139
|
+
|
|
124
140
|
// Explicit template — skip every other check.
|
|
125
141
|
if (requested === "template") {
|
|
126
142
|
return {
|
|
@@ -144,14 +160,17 @@ export async function resolveBrainAdapter(
|
|
|
144
160
|
"adapter: 'openai' requested but telemetryOptOut=true — forcing template",
|
|
145
161
|
};
|
|
146
162
|
}
|
|
147
|
-
|
|
148
|
-
|
|
163
|
+
// Primary: ChatGPT session token (written by `@openai/codex login`).
|
|
164
|
+
const cg = probeChatGPTAuth();
|
|
165
|
+
const hasChatGPT = cg.authenticated;
|
|
166
|
+
const token = hasChatGPT ? null : await probeToken("openai");
|
|
167
|
+
if (!hasChatGPT && !token) {
|
|
149
168
|
return {
|
|
150
169
|
adapter: new NoopAdapter(),
|
|
151
170
|
resolved: "template",
|
|
152
171
|
requested,
|
|
153
172
|
reason:
|
|
154
|
-
"adapter: 'openai' requested but no token
|
|
173
|
+
"adapter: 'openai' requested but no token found — run `mandu brain login --provider=openai`",
|
|
155
174
|
};
|
|
156
175
|
}
|
|
157
176
|
return {
|
|
@@ -163,7 +182,9 @@ export async function resolveBrainAdapter(
|
|
|
163
182
|
}),
|
|
164
183
|
resolved: "openai",
|
|
165
184
|
requested,
|
|
166
|
-
reason:
|
|
185
|
+
reason: hasChatGPT
|
|
186
|
+
? "Explicit adapter: 'openai' + ChatGPT session token present"
|
|
187
|
+
: "Explicit adapter: 'openai' + keychain token present",
|
|
167
188
|
};
|
|
168
189
|
}
|
|
169
190
|
|
|
@@ -216,6 +237,21 @@ export async function resolveBrainAdapter(
|
|
|
216
237
|
// Auto — try cloud providers first (when allowed), then ollama,
|
|
217
238
|
// then template.
|
|
218
239
|
if (!telemetryOptOut) {
|
|
240
|
+
// Primary: ChatGPT session token (managed by `@openai/codex`).
|
|
241
|
+
const cg2 = probeChatGPTAuth();
|
|
242
|
+
if (cg2.authenticated) {
|
|
243
|
+
return {
|
|
244
|
+
adapter: createOpenAIOAuthAdapter({
|
|
245
|
+
...(config.openaiOptions ?? {}),
|
|
246
|
+
model: config.openai?.model ?? config.openaiOptions?.model,
|
|
247
|
+
credentialStore: store,
|
|
248
|
+
projectRoot,
|
|
249
|
+
}),
|
|
250
|
+
resolved: "openai",
|
|
251
|
+
requested,
|
|
252
|
+
reason: `auto: ChatGPT session token at ${cg2.path ?? "(unknown)"}`,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
219
255
|
const openaiToken = await probeToken("openai");
|
|
220
256
|
if (openaiToken) {
|
|
221
257
|
return {
|
|
@@ -1,11 +1,21 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Brain — OpenAI OAuth adapter (Issue #235).
|
|
3
3
|
*
|
|
4
|
-
* Connects to the OpenAI Chat Completions API using a
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Connects to the OpenAI Chat Completions API using a ChatGPT session
|
|
5
|
+
* token. Mandu NEVER owns an OpenAI OAuth app — we reuse the OpenAI
|
|
6
|
+
* official `@openai/codex` CLI's login flow. First-time login:
|
|
7
|
+
*
|
|
8
|
+
* npx @openai/codex login # (or `mandu brain login`)
|
|
9
|
+
*
|
|
10
|
+
* OpenAI handles the browser OAuth handshake and writes the token to
|
|
11
|
+
* `~/.codex/auth.json`. This adapter reads that file via
|
|
12
|
+
* `ChatGPTAuth`, auto-refreshes the access token on expiry, and
|
|
13
|
+
* forwards the resulting Bearer to `api.openai.com/v1/chat/completions`.
|
|
14
|
+
*
|
|
15
|
+
* Legacy code in this file (`runAuthorizationCodeFlow` + keychain
|
|
16
|
+
* `CredentialStore`) remains available for `mandu brain login` flows
|
|
17
|
+
* that opt into a custom OAuth app (e.g. enterprise proxies), but the
|
|
18
|
+
* default path is now the ChatGPT session token.
|
|
9
19
|
*
|
|
10
20
|
* Failure modes handled here:
|
|
11
21
|
* - Missing token → adapter reports `available: false`, the
|
|
@@ -50,6 +60,7 @@ import {
|
|
|
50
60
|
type HttpClient,
|
|
51
61
|
type OAuthEndpoints,
|
|
52
62
|
} from "./oauth-flow";
|
|
63
|
+
import { ChatGPTAuth, type EffectiveAuth } from "./chatgpt-auth";
|
|
53
64
|
|
|
54
65
|
/* -------------------------------------------------------------------- */
|
|
55
66
|
/* Defaults */
|
|
@@ -98,7 +109,7 @@ export const DEFAULT_OPENAI_CONFIG: AdapterConfig = {
|
|
|
98
109
|
export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
|
|
99
110
|
/** Injection point — tests supply an in-memory fetch stub. */
|
|
100
111
|
httpClient?: HttpClient;
|
|
101
|
-
/** Injection point — tests swap in a canned endpoint pair. */
|
|
112
|
+
/** Injection point — tests swap in a canned endpoint pair (legacy flow only). */
|
|
102
113
|
endpoints?: OAuthEndpoints;
|
|
103
114
|
/** OAuth client id override (for enterprise OpenAI proxies). */
|
|
104
115
|
clientId?: string;
|
|
@@ -118,6 +129,18 @@ export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
|
|
|
118
129
|
* command to loudly fail if the flow never wrote a token.
|
|
119
130
|
*/
|
|
120
131
|
strict?: boolean;
|
|
132
|
+
/**
|
|
133
|
+
* ChatGPT session-token auth helper. Default: reads
|
|
134
|
+
* `~/.codex/auth.json` / `~/.chatgpt-local/auth.json` produced by
|
|
135
|
+
* `npx @openai/codex login`. Tests inject a helper pointed at a
|
|
136
|
+
* throwaway fixture path.
|
|
137
|
+
*/
|
|
138
|
+
chatgptAuth?: ChatGPTAuth;
|
|
139
|
+
/**
|
|
140
|
+
* Override: explicit path to the ChatGPT auth.json. Only used when
|
|
141
|
+
* `chatgptAuth` is not supplied.
|
|
142
|
+
*/
|
|
143
|
+
chatgptAuthFilePath?: string;
|
|
121
144
|
}
|
|
122
145
|
|
|
123
146
|
/* -------------------------------------------------------------------- */
|
|
@@ -136,6 +159,7 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
136
159
|
private consentDeps?: ConsentPromptDeps;
|
|
137
160
|
private strict: boolean;
|
|
138
161
|
private refreshInFlight: Promise<StoredToken | null> | null = null;
|
|
162
|
+
private chatgptAuth: ChatGPTAuth;
|
|
139
163
|
|
|
140
164
|
constructor(options: OpenAIOAuthAdapterOptions = {}) {
|
|
141
165
|
super({
|
|
@@ -152,23 +176,38 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
152
176
|
this.skipConsent = options.skipConsent ?? false;
|
|
153
177
|
this.consentDeps = options.consentDeps;
|
|
154
178
|
this.strict = options.strict ?? false;
|
|
179
|
+
this.chatgptAuth =
|
|
180
|
+
options.chatgptAuth ??
|
|
181
|
+
new ChatGPTAuth({
|
|
182
|
+
authFilePath: options.chatgptAuthFilePath,
|
|
183
|
+
httpClient: this.httpClient,
|
|
184
|
+
});
|
|
155
185
|
}
|
|
156
186
|
|
|
157
187
|
/* ----------------------- Status / login ---------------------------- */
|
|
158
188
|
|
|
159
189
|
async checkStatus(): Promise<AdapterStatus> {
|
|
190
|
+
// Primary path — ChatGPT session token from `@openai/codex login`.
|
|
191
|
+
if (this.chatgptAuth.isAuthenticated()) {
|
|
192
|
+
return {
|
|
193
|
+
available: true,
|
|
194
|
+
model: this.config.model,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
// Legacy fallback — custom Mandu OAuth app token stored in keychain.
|
|
160
198
|
const token = await this.credentialStore.load("openai");
|
|
161
|
-
if (
|
|
199
|
+
if (token) {
|
|
162
200
|
return {
|
|
163
|
-
available:
|
|
164
|
-
model:
|
|
165
|
-
error:
|
|
166
|
-
"No OpenAI OAuth token stored. Run `mandu brain login --provider=openai` first.",
|
|
201
|
+
available: true,
|
|
202
|
+
model: this.config.model,
|
|
167
203
|
};
|
|
168
204
|
}
|
|
169
205
|
return {
|
|
170
|
-
available:
|
|
171
|
-
model:
|
|
206
|
+
available: false,
|
|
207
|
+
model: null,
|
|
208
|
+
error:
|
|
209
|
+
"No OpenAI OAuth token found. Run `mandu brain login --provider=openai` " +
|
|
210
|
+
"(which wraps `npx @openai/codex login`) first.",
|
|
172
211
|
};
|
|
173
212
|
}
|
|
174
213
|
|
|
@@ -219,8 +258,18 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
219
258
|
messages: ChatMessage[],
|
|
220
259
|
options: CompletionOptions = {},
|
|
221
260
|
): Promise<CompletionResult> {
|
|
222
|
-
|
|
223
|
-
|
|
261
|
+
// Primary: ChatGPT session token (managed by `@openai/codex login`).
|
|
262
|
+
let chatgpt: EffectiveAuth | null = null;
|
|
263
|
+
if (this.chatgptAuth.isAuthenticated()) {
|
|
264
|
+
try {
|
|
265
|
+
chatgpt = await this.chatgptAuth.getAuth();
|
|
266
|
+
} catch {
|
|
267
|
+
chatgpt = null;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// Legacy keychain fallback only if ChatGPT auth unavailable.
|
|
271
|
+
const token = chatgpt ? null : await this.loadTokenOrReject();
|
|
272
|
+
if (!chatgpt && !token) {
|
|
224
273
|
if (this.strict) {
|
|
225
274
|
throw new Error(
|
|
226
275
|
"OpenAIOAuthAdapter.complete() called without a stored token",
|
|
@@ -273,27 +322,48 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
273
322
|
}
|
|
274
323
|
|
|
275
324
|
// First attempt — fresh token.
|
|
276
|
-
let attemptToken = token
|
|
325
|
+
let attemptToken = chatgpt ? chatgpt.accessToken : token!.access_token;
|
|
277
326
|
let result = await this.callChatApi(
|
|
278
327
|
attemptToken,
|
|
279
328
|
redactedMessages,
|
|
280
329
|
options,
|
|
281
330
|
);
|
|
282
|
-
if (result.status === 401
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
331
|
+
if (result.status === 401) {
|
|
332
|
+
if (chatgpt) {
|
|
333
|
+
// ChatGPTAuth auto-refreshes via JWT exp; a 401 here means even
|
|
334
|
+
// the refreshed token was rejected. Force a re-read (which will
|
|
335
|
+
// trigger another refresh if needed) and retry once.
|
|
336
|
+
try {
|
|
337
|
+
const refreshed = await this.chatgptAuth.getAuth();
|
|
338
|
+
attemptToken = refreshed.accessToken;
|
|
339
|
+
result = await this.callChatApi(
|
|
340
|
+
attemptToken,
|
|
341
|
+
redactedMessages,
|
|
342
|
+
options,
|
|
343
|
+
);
|
|
344
|
+
} catch {
|
|
345
|
+
/* fall through to 401 handling */
|
|
346
|
+
}
|
|
347
|
+
} else if (token!.refresh_token) {
|
|
348
|
+
const refreshed = await this.trySilentRefresh(token!);
|
|
349
|
+
if (refreshed) {
|
|
350
|
+
attemptToken = refreshed.access_token;
|
|
351
|
+
result = await this.callChatApi(
|
|
352
|
+
attemptToken,
|
|
353
|
+
redactedMessages,
|
|
354
|
+
options,
|
|
355
|
+
);
|
|
356
|
+
}
|
|
291
357
|
}
|
|
292
358
|
}
|
|
293
359
|
if (result.status === 401) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
360
|
+
if (!chatgpt) {
|
|
361
|
+
// Persistent auth failure on the legacy keychain path — scrub so
|
|
362
|
+
// subsequent runs skip to the next resolver tier. For the
|
|
363
|
+
// ChatGPTAuth path we leave auth.json alone (the user re-runs
|
|
364
|
+
// `codex login`; we must not race their session).
|
|
365
|
+
await this.credentialStore.delete("openai");
|
|
366
|
+
}
|
|
297
367
|
return emptyCompletion();
|
|
298
368
|
}
|
|
299
369
|
if (!result.ok) {
|
|
@@ -301,7 +371,7 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
301
371
|
`OpenAI request failed (${result.status}): ${result.bodySnippet}`,
|
|
302
372
|
);
|
|
303
373
|
}
|
|
304
|
-
await this.credentialStore.touch("openai");
|
|
374
|
+
if (!chatgpt) await this.credentialStore.touch("openai");
|
|
305
375
|
return result.completion;
|
|
306
376
|
}
|
|
307
377
|
|