@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mandujs/core",
3
- "version": "0.40.0",
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
 
@@ -43,12 +44,13 @@ function seedToken(): StoredToken {
43
44
  }
44
45
 
45
46
  describe("OpenAIOAuthAdapter — shape + defaults", () => {
46
- it("reports default model gpt-4o-mini and adapter name openai-oauth", async () => {
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 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 */
@@ -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 — gpt-4o-mini balances cost and quality for brain
79
- * doctor triage. Override via `ManduConfig.brain.openai.model`.
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-4o-mini";
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 (!token) {
199
+ if (token) {
158
200
  return {
159
- available: false,
160
- model: null,
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: true,
167
- 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.",
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
- const token = await this.loadTokenOrReject();
219
- 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) {
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.access_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 && token.refresh_token) {
279
- const refreshed = await this.trySilentRefresh(token);
280
- if (refreshed) {
281
- attemptToken = refreshed.access_token;
282
- result = await this.callChatApi(
283
- attemptToken,
284
- redactedMessages,
285
- options,
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
- // Persistent auth failure — scrub the token so subsequent runs
291
- // skip straight to the next resolver tier.
292
- 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
+ }
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