@mandujs/core 0.40.0 → 0.41.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/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 +8 -1
- package/src/brain/adapters/chatgpt-auth.ts +300 -0
- package/src/brain/adapters/index.ts +1 -0
- package/src/brain/adapters/openai-oauth.ts +106 -32
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
|
|
|
@@ -43,12 +44,13 @@ function seedToken(): StoredToken {
|
|
|
43
44
|
}
|
|
44
45
|
|
|
45
46
|
describe("OpenAIOAuthAdapter — shape + defaults", () => {
|
|
46
|
-
it("reports default model gpt-
|
|
47
|
+
it("reports default model gpt-5.4 and adapter name openai-oauth", async () => {
|
|
47
48
|
const store = makeMemoryStore({ openai: seedToken() });
|
|
48
49
|
const adapter = new OpenAIOAuthAdapter({
|
|
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",
|
|
@@ -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";
|
|
@@ -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 */
|
|
@@ -75,10 +86,14 @@ export const OPENAI_OAUTH_CLIENT_ID = "mandu-brain-cli";
|
|
|
75
86
|
export const OPENAI_OAUTH_SCOPE = "openai.chat";
|
|
76
87
|
|
|
77
88
|
/**
|
|
78
|
-
* Default model —
|
|
79
|
-
* doctor triage
|
|
89
|
+
* Default model — GPT-5.4 (current-generation OpenAI flagship as of
|
|
90
|
+
* 2026-04). Gives brain doctor triage the quality it needs to produce
|
|
91
|
+
* actionable patches, which was the whole motivation for moving off
|
|
92
|
+
* the local ministral-3:3b adapter. Override via
|
|
93
|
+
* `ManduConfig.brain.openai.model` (e.g. set to a cheaper tier for
|
|
94
|
+
* low-stakes automated runs).
|
|
80
95
|
*/
|
|
81
|
-
export const OPENAI_DEFAULT_MODEL = "gpt-
|
|
96
|
+
export const OPENAI_DEFAULT_MODEL = "gpt-5.4";
|
|
82
97
|
export const OPENAI_API_BASE = "https://api.openai.com/v1";
|
|
83
98
|
|
|
84
99
|
export const DEFAULT_OPENAI_CONFIG: AdapterConfig = {
|
|
@@ -94,7 +109,7 @@ export const DEFAULT_OPENAI_CONFIG: AdapterConfig = {
|
|
|
94
109
|
export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
|
|
95
110
|
/** Injection point — tests supply an in-memory fetch stub. */
|
|
96
111
|
httpClient?: HttpClient;
|
|
97
|
-
/** Injection point — tests swap in a canned endpoint pair. */
|
|
112
|
+
/** Injection point — tests swap in a canned endpoint pair (legacy flow only). */
|
|
98
113
|
endpoints?: OAuthEndpoints;
|
|
99
114
|
/** OAuth client id override (for enterprise OpenAI proxies). */
|
|
100
115
|
clientId?: string;
|
|
@@ -114,6 +129,18 @@ export interface OpenAIOAuthAdapterOptions extends Partial<AdapterConfig> {
|
|
|
114
129
|
* command to loudly fail if the flow never wrote a token.
|
|
115
130
|
*/
|
|
116
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;
|
|
117
144
|
}
|
|
118
145
|
|
|
119
146
|
/* -------------------------------------------------------------------- */
|
|
@@ -132,6 +159,7 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
132
159
|
private consentDeps?: ConsentPromptDeps;
|
|
133
160
|
private strict: boolean;
|
|
134
161
|
private refreshInFlight: Promise<StoredToken | null> | null = null;
|
|
162
|
+
private chatgptAuth: ChatGPTAuth;
|
|
135
163
|
|
|
136
164
|
constructor(options: OpenAIOAuthAdapterOptions = {}) {
|
|
137
165
|
super({
|
|
@@ -148,23 +176,38 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
148
176
|
this.skipConsent = options.skipConsent ?? false;
|
|
149
177
|
this.consentDeps = options.consentDeps;
|
|
150
178
|
this.strict = options.strict ?? false;
|
|
179
|
+
this.chatgptAuth =
|
|
180
|
+
options.chatgptAuth ??
|
|
181
|
+
new ChatGPTAuth({
|
|
182
|
+
authFilePath: options.chatgptAuthFilePath,
|
|
183
|
+
httpClient: this.httpClient,
|
|
184
|
+
});
|
|
151
185
|
}
|
|
152
186
|
|
|
153
187
|
/* ----------------------- Status / login ---------------------------- */
|
|
154
188
|
|
|
155
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.
|
|
156
198
|
const token = await this.credentialStore.load("openai");
|
|
157
|
-
if (
|
|
199
|
+
if (token) {
|
|
158
200
|
return {
|
|
159
|
-
available:
|
|
160
|
-
model:
|
|
161
|
-
error:
|
|
162
|
-
"No OpenAI OAuth token stored. Run `mandu brain login --provider=openai` first.",
|
|
201
|
+
available: true,
|
|
202
|
+
model: this.config.model,
|
|
163
203
|
};
|
|
164
204
|
}
|
|
165
205
|
return {
|
|
166
|
-
available:
|
|
167
|
-
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.",
|
|
168
211
|
};
|
|
169
212
|
}
|
|
170
213
|
|
|
@@ -215,8 +258,18 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
215
258
|
messages: ChatMessage[],
|
|
216
259
|
options: CompletionOptions = {},
|
|
217
260
|
): Promise<CompletionResult> {
|
|
218
|
-
|
|
219
|
-
|
|
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) {
|
|
220
273
|
if (this.strict) {
|
|
221
274
|
throw new Error(
|
|
222
275
|
"OpenAIOAuthAdapter.complete() called without a stored token",
|
|
@@ -269,27 +322,48 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
269
322
|
}
|
|
270
323
|
|
|
271
324
|
// First attempt — fresh token.
|
|
272
|
-
let attemptToken = token
|
|
325
|
+
let attemptToken = chatgpt ? chatgpt.accessToken : token!.access_token;
|
|
273
326
|
let result = await this.callChatApi(
|
|
274
327
|
attemptToken,
|
|
275
328
|
redactedMessages,
|
|
276
329
|
options,
|
|
277
330
|
);
|
|
278
|
-
if (result.status === 401
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
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
|
+
}
|
|
287
357
|
}
|
|
288
358
|
}
|
|
289
359
|
if (result.status === 401) {
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
+
}
|
|
293
367
|
return emptyCompletion();
|
|
294
368
|
}
|
|
295
369
|
if (!result.ok) {
|
|
@@ -297,7 +371,7 @@ export class OpenAIOAuthAdapter extends BaseLLMAdapter {
|
|
|
297
371
|
`OpenAI request failed (${result.status}): ${result.bodySnippet}`,
|
|
298
372
|
);
|
|
299
373
|
}
|
|
300
|
-
await this.credentialStore.touch("openai");
|
|
374
|
+
if (!chatgpt) await this.credentialStore.touch("openai");
|
|
301
375
|
return result.completion;
|
|
302
376
|
}
|
|
303
377
|
|