@mandujs/core 0.40.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.40.1",
3
+ "version": "0.41.0",
4
4
  "description": "Mandu Framework Core - Spec, Generator, Guard, Runtime",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -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",
@@ -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 token obtained
5
- * via OAuth authorization code + PKCE. Mandu never owns an OpenAI API
6
- * key the user's OAuth credentials are loaded from the OS keychain
7
- * (`packages/core/src/brain/credentials.ts`) and forwarded on each
8
- * request.
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 (!token) {
199
+ if (token) {
162
200
  return {
163
- available: false,
164
- model: null,
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: true,
171
- model: this.config.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
- const token = await this.loadTokenOrReject();
223
- if (!token) {
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.access_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 && token.refresh_token) {
283
- const refreshed = await this.trySilentRefresh(token);
284
- if (refreshed) {
285
- attemptToken = refreshed.access_token;
286
- result = await this.callChatApi(
287
- attemptToken,
288
- redactedMessages,
289
- options,
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
- // Persistent auth failure — scrub the token so subsequent runs
295
- // skip straight to the next resolver tier.
296
- await this.credentialStore.delete("openai");
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