@wix/pathgrade 1.0.18 → 1.0.20

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/README.md CHANGED
@@ -35,7 +35,8 @@ By default, Pathgrade tries to reuse the agent CLI's native auth before falling
35
35
  - **Codex**
36
36
  - forwards `OPENAI_API_KEY` when present
37
37
  - or runs `codex login --with-api-key` inside the sandbox when an API key is present
38
- - `codex exec` can reuse cached `~/.codex/auth.json` when no key is available; `app-server` may reject cached ChatGPT-token refreshes, so prefer `OPENAI_API_KEY` for the default transport
38
+ - without API credentials, the default `app-server` transport reuses the active Codex login through short-lived auth-only stdio sessions; `codex exec` retains its cached `~/.codex/auth.json` behavior
39
+ - keyless Codex evals automatically use that managed login for plain and tool-using judges; an explicit `evaluate(..., { llm })` still wins
39
40
  - **Cursor**
40
41
  - forwards `CURSOR_API_KEY` when set
41
42
  - macOS: reuses `cursor-agent login` OAuth tokens from the login Keychain
@@ -47,7 +48,9 @@ By default, Pathgrade tries to reuse the agent CLI's native auth before falling
47
48
 
48
49
  If you set `ANTHROPIC_BASE_URL`, `OPENAI_BASE_URL`, or `CURSOR_API_BASE_URL`, set the matching API key too.
49
50
 
50
- Do not add `copyFromHome: ['.codex']` just to reuse Codex authentication. When no OpenAI API key is available, Pathgrade automatically stages only `~/.codex/auth.json`. A deliberate full `.codex` copy remains supported, but it also imports host config, MCP definitions, plugins, caches, sessions, and other potentially large or sensitive state; Pathgrade prints a warning when requested.
51
+ An [experimental ChatGPT judge that reuses Codex-managed authentication](docs/OPENAI_OAUTH_JUDGE.md) is also available. It requires no API key, auth-file option, or second login and is selected automatically for keyless Codex evals.
52
+
53
+ Do not add `copyFromHome: ['.codex']` just to reuse Codex authentication. Keyless app-server runs stage no credential file; the legacy exec transport stages only `~/.codex/auth.json`. A deliberate full `.codex` copy remains supported, but it also imports host config, MCP definitions, plugins, caches, sessions, and other potentially large or sensitive state; Pathgrade prints a warning when requested.
51
54
 
52
55
  Override credentials per test with `env`:
53
56
 
@@ -64,7 +67,7 @@ const agent = await createAgent({
64
67
 
65
68
  Codex supports two transports and Pathgrade defaults to `app-server`:
66
69
 
67
- - `app-server` (default) — uses `codex app-server` and keeps native thread state. Required for `AskUserReaction` handshakes (`request_user_input` reaches the model). Prefer `OPENAI_API_KEY`; cached ChatGPT auth can fail if the app-server asks Pathgrade to refresh tokens.
70
+ - `app-server` (default) — uses `codex app-server` and keeps native thread state. Required for `AskUserReaction` handshakes (`request_user_input` reaches the model). Without API credentials it acquires and refreshes access sessions through the active Codex login.
68
71
  - `exec` — uses `codex exec` and re-injects the transcript every turn. Kept for stateless CI matrices that don't need the handshake.
69
72
 
70
73
  Precedence: `createAgent({ transport })` > `PATHGRADE_CODEX_TRANSPORT` env > default (`app-server`). An invalid env value throws at `createAgent` time.
@@ -80,7 +83,7 @@ If `transport: 'exec'` is resolved and any `AskUserReaction` is present in `Conv
80
83
 
81
84
  Migrating from `exec` to `app-server`:
82
85
 
83
- - Export `OPENAI_API_KEY`, or set `transport: 'exec'` / `PATHGRADE_CODEX_TRANSPORT=exec` to stay on the old transport and its cached-auth behavior.
86
+ - Sign in with `codex login` for subscription-backed app-server and judge calls, export `OPENAI_API_KEY` for API billing, or select `transport: 'exec'` for the old cached-auth behavior.
84
87
  - The `noninteractive-user-question` runtime policy no longer attaches under `app-server`. Snapshots that captured model output influenced by that policy text may need re-recording.
85
88
  - `MAX_TURN_RETRIES` does not apply under `app-server` — a crashed turn ends the conversation with `completionReason: 'agent_crashed'`.
86
89
 
@@ -28,6 +28,8 @@ export interface CodexAppServerAgentDeps {
28
28
  onPermissionGrant?: (entry: PermissionGrantLogEntry) => void;
29
29
  /** Injectable clocks keep wall timestamps and monotonic durations independently testable. */
30
30
  clock?: LifecycleClock;
31
+ /** Inject Codex-managed ChatGPT access sessions for deterministic tests. */
32
+ authBroker?: import('../../openai-oauth/codex-auth-broker.js').CodexAuthBroker;
31
33
  }
32
34
  export declare class CodexAppServerAgent extends BaseAgent {
33
35
  private deps;
@@ -15,6 +15,7 @@ import { resolveCodexModel } from '../codex-model.js';
15
15
  import { CodexMcpApprovalCorrelator, extractMcpToolApprovalRequest, hasScriptedApprovalPolicy, isMcpToolCallApprovalRequest, recordPolicyDeniedMcpToolCall } from './mcp-approval-correlator.js';
16
16
  import { projectItemIntoTurn } from './item-projection.js';
17
17
  import { CodexItemLifecycle, } from './item-lifecycle.js';
18
+ import { createCodexManagedAuth } from './managed-auth.js';
18
19
  const TURN_COMPLETED_METHOD = 'turn/completed';
19
20
  function projectMcpStartupStatusIntoTurn(params, turn) {
20
21
  const name = params.name ?? 'unknown';
@@ -80,6 +81,12 @@ export class CodexAppServerAgent extends BaseAgent {
80
81
  let activeTurn = null;
81
82
  let disposed = false;
82
83
  let codexUserAgent = 'unknown';
84
+ const managedAuth = createCodexManagedAuth({
85
+ enabled: !runtimeEnv.OPENAI_API_KEY && !runtimeEnv.OPENAI_BASE_URL
86
+ && (this.deps.authBroker !== undefined || this.deps.createTransport === undefined),
87
+ ...(this.deps.authBroker ? { broker: this.deps.authBroker } : {}),
88
+ ...(runtimeEnv.CODEX_HOME ? { codexHome: runtimeEnv.CODEX_HOME } : {}),
89
+ });
83
90
  const scriptedHost = options?.scriptedMcpHost;
84
91
  const correlator = scriptedHost
85
92
  ? new CodexMcpApprovalCorrelator(scriptedHost, () => turnCounter, () => threadId, () => options?.getRemainingMs?.() ?? Number.POSITIVE_INFINITY)
@@ -92,8 +99,9 @@ export class CodexAppServerAgent extends BaseAgent {
92
99
  const factory = this.deps.createTransport
93
100
  ?? (async (ctx) => spawnAppServerTransport({ cwd: ctx.workspacePath, env: ctx.env,
94
101
  args: scriptedHost ? ['--enable', 'tool_call_mcp_elicitation'] : [] }));
95
- handle = await factory({ workspacePath, env: runtimeEnv });
96
- const transport = handle.transport;
102
+ const spawnedHandle = await factory({ workspacePath, env: runtimeEnv });
103
+ handle = spawnedHandle;
104
+ const transport = spawnedHandle.transport;
97
105
  transport.onServerRequest((req) => this.dispatchServerRequest(req, {
98
106
  transport,
99
107
  askBus,
@@ -102,6 +110,7 @@ export class CodexAppServerAgent extends BaseAgent {
102
110
  mcpSafety: options?.mcpSafety,
103
111
  scriptedHost,
104
112
  correlator,
113
+ managedAuth,
105
114
  }));
106
115
  transport.onClose((info) => {
107
116
  closeInfo = info;
@@ -132,17 +141,27 @@ export class CodexAppServerAgent extends BaseAgent {
132
141
  if (params)
133
142
  turn.itemLifecycle?.receiveCompleted(params);
134
143
  });
135
- const initialized = await transport.sendRequest('initialize', {
136
- clientInfo: { name: 'pathgrade', version: '0.5.0', title: null },
137
- capabilities: { experimentalApi: true, optOutNotificationMethods: null },
144
+ const initialize = async () => {
145
+ const initialized = await transport.sendRequest('initialize', {
146
+ clientInfo: { name: 'pathgrade', version: '0.5.0', title: null },
147
+ capabilities: { experimentalApi: true, optOutNotificationMethods: null },
148
+ });
149
+ if (typeof initialized.userAgent === 'string') {
150
+ codexUserAgent = initialized.userAgent.slice(0, 200);
151
+ }
152
+ // Upstream ClientNotification = { method: "initialized" }: send it
153
+ // before any thread/start so the handshake matches the v0.124
154
+ // contract and is forward-compatible with servers that enforce it.
155
+ transport.sendNotification('initialized', null);
156
+ await managedAuth.login(transport);
157
+ return transport;
158
+ };
159
+ return initialize().catch(async (error) => {
160
+ if (handle === spawnedHandle)
161
+ handle = null;
162
+ await spawnedHandle.close().catch(() => undefined);
163
+ throw error;
138
164
  });
139
- if (typeof initialized.userAgent === 'string')
140
- codexUserAgent = initialized.userAgent.slice(0, 200);
141
- // Upstream ClientNotification = { method: "initialized" }: send it
142
- // before any thread/start so the handshake matches the v0.124
143
- // contract and is forward-compatible with servers that enforce it.
144
- transport.sendNotification('initialized', null);
145
- return transport;
146
165
  };
147
166
  const runTurn = async (message) => {
148
167
  const t = await ensureTransport();
@@ -462,11 +481,11 @@ export class CodexAppServerAgent extends BaseAgent {
462
481
  }
463
482
  return;
464
483
  case 'account/chatgptAuthTokens/refresh': {
465
- const message = 'codex app-server requires OPENAI_API_KEY for pathgrade and honors OPENAI_BASE_URL when set; ChatGPT/cached auth unsupported under transport=app-server';
466
- transport.sendErrorResponse(req.id, -32001, message);
467
- const turn = activeTurn();
468
- if (turn)
469
- failTurn(turn, message);
484
+ ctx.managedAuth.respondToRefresh(req.id, transport, (message) => {
485
+ const turn = activeTurn();
486
+ if (turn)
487
+ failTurn(turn, message);
488
+ });
470
489
  return;
471
490
  }
472
491
  default:
@@ -0,0 +1,12 @@
1
+ import { createDefaultCodexAuthBroker, type CodexAuthBroker } from '../../openai-oauth/codex-auth-broker.js';
2
+ import type { AppServerTransport } from './transport.js';
3
+ export interface CodexManagedAuth {
4
+ login(transport: AppServerTransport): Promise<void>;
5
+ respondToRefresh(requestId: number | string, transport: AppServerTransport, failTurn: (message: string) => void): void;
6
+ }
7
+ export declare function createCodexManagedAuth(input: {
8
+ enabled: boolean;
9
+ broker?: CodexAuthBroker;
10
+ codexHome?: string;
11
+ createBroker?: typeof createDefaultCodexAuthBroker;
12
+ }): CodexManagedAuth;
@@ -0,0 +1,53 @@
1
+ import { createDefaultCodexAuthBroker, } from '../../openai-oauth/codex-auth-broker.js';
2
+ const UNSUPPORTED_MESSAGE = 'codex app-server requires OPENAI_API_KEY for pathgrade and honors OPENAI_BASE_URL when set; ChatGPT/cached auth unsupported under transport=app-server';
3
+ export function createCodexManagedAuth(input) {
4
+ let brokerPromise;
5
+ const broker = () => brokerPromise ??= input.broker
6
+ ? Promise.resolve(input.broker)
7
+ : (input.createBroker ?? createDefaultCodexAuthBroker)({
8
+ env: {
9
+ ...process.env,
10
+ ...(input.codexHome ? { CODEX_HOME: input.codexHome } : {}),
11
+ },
12
+ });
13
+ let refreshPromise;
14
+ const refresh = () => refreshPromise ??= broker()
15
+ .then((value) => value.acquireAccessSession({ refresh: true }))
16
+ .finally(() => { refreshPromise = undefined; });
17
+ return {
18
+ async login(transport) {
19
+ if (!input.enabled)
20
+ return;
21
+ try {
22
+ const session = await (await broker()).acquireAccessSession({ refresh: false });
23
+ await transport.sendRequest('account/login/start', {
24
+ type: 'chatgptAuthTokens',
25
+ accessToken: session.accessToken,
26
+ chatgptAccountId: session.accountId,
27
+ chatgptPlanType: null,
28
+ });
29
+ }
30
+ catch {
31
+ throw new Error('Codex-managed ChatGPT authentication unavailable');
32
+ }
33
+ },
34
+ respondToRefresh(requestId, transport, failTurn) {
35
+ if (!input.enabled) {
36
+ transport.sendErrorResponse(requestId, -32001, UNSUPPORTED_MESSAGE);
37
+ failTurn(UNSUPPORTED_MESSAGE);
38
+ return;
39
+ }
40
+ void refresh()
41
+ .then((session) => transport.sendResponse(requestId, {
42
+ accessToken: session.accessToken,
43
+ chatgptAccountId: session.accountId,
44
+ chatgptPlanType: null,
45
+ }))
46
+ .catch(() => {
47
+ const message = 'Codex-managed ChatGPT authentication refresh failed';
48
+ transport.sendErrorResponse(requestId, -32001, message);
49
+ failTurn(message);
50
+ });
51
+ },
52
+ };
53
+ }
@@ -44,6 +44,8 @@ export interface SpawnAppServerTransportInput {
44
44
  * Exposed for tests; defaults to 2s for production spawns.
45
45
  */
46
46
  killGracePeriodMs?: number;
47
+ /** Disable stderr logging for secret-bearing control-plane uses. */
48
+ logStderrOnExit?: boolean;
47
49
  }
48
50
  export declare function buildAppServerSpawnArgs(args?: readonly string[], env?: NodeJS.ProcessEnv): string[];
49
51
  /**
@@ -73,6 +73,12 @@ function createNdjsonTransportInternal(cfg) {
73
73
  return;
74
74
  output.write(JSON.stringify(obj) + '\n');
75
75
  };
76
+ const rejectPending = () => {
77
+ for (const resolver of pendingRequests.values()) {
78
+ resolver({ error: { code: -32_000, message: 'AppServerTransport closed' } });
79
+ }
80
+ pendingRequests.clear();
81
+ };
76
82
  return {
77
83
  sendRequest(method, params) {
78
84
  if (closed)
@@ -121,9 +127,10 @@ function createNdjsonTransportInternal(cfg) {
121
127
  return;
122
128
  closed = true;
123
129
  rl.close();
124
- pendingRequests.clear();
130
+ rejectPending();
125
131
  },
126
132
  notifyClose(info) {
133
+ rejectPending();
127
134
  for (const h of closeHandlers) {
128
135
  try {
129
136
  h(info);
@@ -234,15 +241,35 @@ export function spawnAppServerTransport(cfg = {}) {
234
241
  input: child.stdout,
235
242
  pid: child.pid,
236
243
  });
244
+ let spawnFailed = false;
245
+ child.on('error', () => {
246
+ spawnFailed = true;
247
+ transport.notifyClose({ exitCode: null, signal: null, pid: child.pid });
248
+ });
237
249
  child.on('exit', (exitCode, signal) => {
238
- if (stderrBuf.trim().length > 0) {
250
+ if (cfg.logStderrOnExit !== false && stderrBuf.trim().length > 0) {
239
251
  console.error(`[codex app-server pid=${child.pid}] exited with code=${exitCode} signal=${signal}. stderr:\n${stderrBuf}`);
240
252
  }
241
253
  transport.notifyClose({ exitCode, signal, pid: child.pid });
242
254
  });
255
+ const sessionChild = {
256
+ pid: child.pid,
257
+ kill: (signal) => child.kill(signal),
258
+ once: (_event, listener) => {
259
+ const finish = () => {
260
+ child.off('exit', finish);
261
+ child.off('error', finish);
262
+ listener();
263
+ };
264
+ child.once('exit', finish);
265
+ child.once('error', finish);
266
+ },
267
+ get exitCode() { return spawnFailed ? -1 : child.exitCode; },
268
+ get signalCode() { return child.signalCode; },
269
+ };
243
270
  return createAppServerSessionHandle({
244
271
  transport,
245
- child,
272
+ child: sessionChild,
246
273
  ...(cfg.killGracePeriodMs !== undefined ? { killGracePeriodMs: cfg.killGracePeriodMs } : {}),
247
274
  });
248
275
  }
@@ -0,0 +1,25 @@
1
+ import type { ToolCapableLLMPort } from '../utils/llm-types.js';
2
+ import { type CodexAuthBroker } from './codex-auth-broker.js';
3
+ export interface ChatGptOAuthJudgeOptions {
4
+ model: string;
5
+ reasoningEffort: 'low' | 'medium' | 'high';
6
+ requestTimeoutMs?: number;
7
+ }
8
+ export type ChatGptOAuthJudgeErrorCode = 'OAUTH_CONFIG_INVALID' | 'OAUTH_DEPENDENCY_UNAVAILABLE' | 'OAUTH_CODEX_UNAVAILABLE' | 'OAUTH_CODEX_LOGIN_REQUIRED' | 'OAUTH_AUTH_TIMEOUT' | 'OAUTH_MODEL_MISMATCH' | 'OAUTH_MODEL_UNAVAILABLE' | 'OAUTH_REQUEST_TIMEOUT' | 'OAUTH_UPSTREAM_RATE_LIMITED' | 'OAUTH_UPSTREAM_FAILED' | 'OAUTH_PROTOCOL_RESPONSE_INVALID';
9
+ export declare class ChatGptOAuthJudgeError extends Error {
10
+ readonly code: ChatGptOAuthJudgeErrorCode;
11
+ constructor(code: ChatGptOAuthJudgeErrorCode, message?: string);
12
+ }
13
+ type CoreModule = typeof import('@openai-oauth/core');
14
+ export interface ChatGptOAuthJudgeDependencies {
15
+ broker?: CodexAuthBroker;
16
+ fetch?: typeof globalThis.fetch;
17
+ loadCore?: () => Promise<CoreModule>;
18
+ codexBinary?: string;
19
+ codexEnv?: NodeJS.ProcessEnv;
20
+ authTimeoutMs?: number;
21
+ }
22
+ export declare function createChatGptOAuthJudgeLLM(options: ChatGptOAuthJudgeOptions): ToolCapableLLMPort;
23
+ /** Internal deterministic seam; intentionally omitted from the package subpath exports. */
24
+ export declare function createChatGptOAuthJudgeLLMWithDependencies(options: ChatGptOAuthJudgeOptions, dependencies?: ChatGptOAuthJudgeDependencies): ToolCapableLLMPort;
25
+ export {};
@@ -0,0 +1,398 @@
1
+ import { CodexAuthBrokerError, createCodexAuthBroker, } from './codex-auth-broker.js';
2
+ const CORE_VERSION = '2.0.0';
3
+ const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
4
+ export class ChatGptOAuthJudgeError extends Error {
5
+ code;
6
+ constructor(code, message = code) {
7
+ super(message);
8
+ this.code = code;
9
+ this.name = 'ChatGptOAuthJudgeError';
10
+ }
11
+ }
12
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
13
+ const oauthError = (code) => new ChatGptOAuthJudgeError(code);
14
+ const mapBrokerError = (error) => {
15
+ if (error instanceof ChatGptOAuthJudgeError)
16
+ return error;
17
+ if (error instanceof CodexAuthBrokerError)
18
+ return oauthError(error.code);
19
+ if (error instanceof Error && error.name === 'AbortError')
20
+ return oauthError('OAUTH_REQUEST_TIMEOUT');
21
+ return oauthError('OAUTH_CODEX_UNAVAILABLE');
22
+ };
23
+ const normalizeProfile = (options) => {
24
+ if (options.model.trim().length === 0 ||
25
+ !['low', 'medium', 'high'].includes(options.reasoningEffort)) {
26
+ throw oauthError('OAUTH_CONFIG_INVALID');
27
+ }
28
+ const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
29
+ if (!Number.isFinite(requestTimeoutMs) || !Number.isInteger(requestTimeoutMs) || requestTimeoutMs <= 0) {
30
+ throw oauthError('OAUTH_CONFIG_INVALID');
31
+ }
32
+ return Object.freeze({
33
+ model: options.model,
34
+ reasoningEffort: options.reasoningEffort,
35
+ requestTimeoutMs,
36
+ });
37
+ };
38
+ const loadCore = async () => {
39
+ try {
40
+ return await import('@openai-oauth/core');
41
+ }
42
+ catch {
43
+ throw new ChatGptOAuthJudgeError('OAUTH_DEPENDENCY_UNAVAILABLE', `OAUTH_DEPENDENCY_UNAVAILABLE: install @openai-oauth/core@${CORE_VERSION}`);
44
+ }
45
+ };
46
+ const createBoundedFetch = (timeoutMs, sourceFetch = globalThis.fetch) => async (input, init) => {
47
+ if (typeof sourceFetch !== 'function') {
48
+ throw oauthError('OAUTH_UPSTREAM_FAILED');
49
+ }
50
+ const timeoutController = new AbortController();
51
+ const timeout = setTimeout(() => timeoutController.abort(), timeoutMs);
52
+ timeout.unref?.();
53
+ const signal = init?.signal
54
+ ? AbortSignal.any([init.signal, timeoutController.signal])
55
+ : timeoutController.signal;
56
+ try {
57
+ return await sourceFetch(input, { ...init, signal });
58
+ }
59
+ catch (error) {
60
+ if (error instanceof ChatGptOAuthJudgeError ||
61
+ (error instanceof Error && error.name === 'AbortError')) {
62
+ throw error;
63
+ }
64
+ throw oauthError('OAUTH_UPSTREAM_FAILED');
65
+ }
66
+ finally {
67
+ clearTimeout(timeout);
68
+ }
69
+ };
70
+ const classifyStatus = (status) => {
71
+ if (status === 401 || status === 403)
72
+ return oauthError('OAUTH_CODEX_LOGIN_REQUIRED');
73
+ if (status === 429)
74
+ return oauthError('OAUTH_UPSTREAM_RATE_LIMITED');
75
+ return oauthError('OAUTH_UPSTREAM_FAILED');
76
+ };
77
+ const assertModel = (profile, requested) => {
78
+ if (requested !== undefined && requested !== profile.model) {
79
+ throw oauthError('OAUTH_MODEL_MISMATCH');
80
+ }
81
+ };
82
+ const requestBody = (profile, input, instructions, tools) => ({
83
+ model: profile.model,
84
+ reasoning: { effort: profile.reasoningEffort },
85
+ instructions,
86
+ input,
87
+ ...(tools === undefined ? {} : {
88
+ tools,
89
+ tool_choice: tools.length === 0 ? 'none' : 'auto',
90
+ parallel_tool_calls: true,
91
+ }),
92
+ });
93
+ const toResponsesInput = (messages) => {
94
+ const input = [];
95
+ for (const message of messages) {
96
+ if (typeof message.content === 'string') {
97
+ input.push({
98
+ role: message.role,
99
+ content: [{
100
+ type: message.role === 'assistant' ? 'output_text' : 'input_text',
101
+ text: message.content,
102
+ }],
103
+ });
104
+ continue;
105
+ }
106
+ let text = [];
107
+ const flushText = () => {
108
+ if (text.length === 0)
109
+ return;
110
+ input.push({
111
+ role: message.role,
112
+ content: text.map((block) => ({
113
+ type: message.role === 'assistant' ? 'output_text' : 'input_text',
114
+ text: block.text,
115
+ })),
116
+ });
117
+ text = [];
118
+ };
119
+ for (const block of message.content) {
120
+ if (block.type === 'text') {
121
+ text.push(block);
122
+ continue;
123
+ }
124
+ flushText();
125
+ if (block.type === 'tool_use') {
126
+ if (message.role !== 'assistant' || block.id.length === 0 || block.name.length === 0 ||
127
+ !isRecord(block.input)) {
128
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
129
+ }
130
+ input.push({
131
+ type: 'function_call', call_id: block.id,
132
+ name: block.name, arguments: JSON.stringify(block.input),
133
+ });
134
+ }
135
+ else if (block.type === 'tool_result') {
136
+ if (message.role !== 'user' || block.tool_use_id.length === 0) {
137
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
138
+ }
139
+ input.push({
140
+ type: 'function_call_output', call_id: block.tool_use_id,
141
+ output: block.content,
142
+ });
143
+ }
144
+ }
145
+ flushText();
146
+ }
147
+ return input;
148
+ };
149
+ const safeToken = (value) => Number.isInteger(value) && value >= 0 ? value : undefined;
150
+ const parseResponse = async (response) => {
151
+ if (!response.ok)
152
+ throw classifyStatus(response.status);
153
+ try {
154
+ const parsed = await response.json();
155
+ if (!isRecord(parsed) || parsed.status !== 'completed' || !Array.isArray(parsed.output)) {
156
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
157
+ }
158
+ for (const item of parsed.output)
159
+ validateOutputItem(item);
160
+ return parsed;
161
+ }
162
+ catch (error) {
163
+ if (error instanceof ChatGptOAuthJudgeError)
164
+ throw error;
165
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
166
+ }
167
+ };
168
+ const validateOutputItem = (value) => {
169
+ if (!isRecord(value) || !['reasoning', 'message', 'function_call'].includes(String(value.type))) {
170
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
171
+ }
172
+ if (value.type === 'function_call') {
173
+ if (value.status !== undefined && value.status !== 'completed') {
174
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
175
+ }
176
+ return;
177
+ }
178
+ if (value.type === 'reasoning')
179
+ return;
180
+ if (value.role !== 'assistant' || !Array.isArray(value.content)) {
181
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
182
+ }
183
+ for (const content of value.content) {
184
+ if (!isRecord(content) || content.type !== 'output_text' || typeof content.text !== 'string') {
185
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
186
+ }
187
+ }
188
+ };
189
+ const outputText = (result) => (result.output ?? []).flatMap((item) => Array.isArray(item.content) ? item.content : [])
190
+ .filter((item) => isRecord(item) && item.type === 'output_text')
191
+ .map((item) => typeof item.text === 'string' ? item.text : '')
192
+ .join('');
193
+ const toolBlocks = (result, allowedNames) => {
194
+ const seenIds = new Set();
195
+ return (result.output ?? [])
196
+ .filter((item) => item.type === 'function_call')
197
+ .map((item) => {
198
+ if (typeof item.call_id !== 'string' || item.call_id.length === 0 ||
199
+ typeof item.name !== 'string' || item.name.length === 0 ||
200
+ !allowedNames.has(item.name) || typeof item.arguments !== 'string' ||
201
+ seenIds.has(item.call_id)) {
202
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
203
+ }
204
+ seenIds.add(item.call_id);
205
+ let input;
206
+ try {
207
+ input = JSON.parse(item.arguments);
208
+ }
209
+ catch {
210
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
211
+ }
212
+ if (!isRecord(input))
213
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
214
+ return { type: 'tool_use', id: item.call_id, name: item.name, input };
215
+ });
216
+ };
217
+ export function createChatGptOAuthJudgeLLM(options) {
218
+ return createChatGptOAuthJudgeLLMWithDependencies(options);
219
+ }
220
+ /** Internal deterministic seam; intentionally omitted from the package subpath exports. */
221
+ export function createChatGptOAuthJudgeLLMWithDependencies(options, dependencies = {}) {
222
+ const profile = normalizeProfile(options);
223
+ const boundedFetch = createBoundedFetch(profile.requestTimeoutMs, dependencies.fetch);
224
+ let corePromise;
225
+ let brokerPromise;
226
+ let session;
227
+ let sessionGeneration = 0;
228
+ let acquirePromise;
229
+ let refreshPromise;
230
+ let transportPromise;
231
+ let preflight;
232
+ let inputTokens = 0;
233
+ let outputTokens = 0;
234
+ const core = () => corePromise ??= (dependencies.loadCore ?? loadCore)();
235
+ const broker = () => brokerPromise ??= dependencies.broker
236
+ ? Promise.resolve(dependencies.broker)
237
+ : core().then((loaded) => createCodexAuthBroker({
238
+ ...(dependencies.codexBinary !== undefined ? { binary: dependencies.codexBinary } : {}),
239
+ ...(dependencies.codexEnv !== undefined ? { env: dependencies.codexEnv } : {}),
240
+ ...(dependencies.authTimeoutMs !== undefined ? { authTimeoutMs: dependencies.authTimeoutMs } : {}),
241
+ deriveAccountId: loaded.deriveAccountId,
242
+ deriveIsFedRamp: loaded.deriveChatGptAccountIsFedRamp,
243
+ }));
244
+ const getSession = async () => {
245
+ if (session)
246
+ return session;
247
+ acquirePromise ??= broker()
248
+ .then((value) => value.acquireAccessSession({ refresh: false }))
249
+ .then((value) => {
250
+ session = value;
251
+ sessionGeneration += 1;
252
+ return value;
253
+ })
254
+ .catch((error) => { throw mapBrokerError(error); })
255
+ .finally(() => { acquirePromise = undefined; });
256
+ return acquirePromise;
257
+ };
258
+ const refreshAfterUnauthorized = async (staleGeneration) => {
259
+ if (session && sessionGeneration !== staleGeneration)
260
+ return session;
261
+ refreshPromise ??= broker()
262
+ .then((value) => value.acquireAccessSession({ refresh: true }))
263
+ .then((value) => {
264
+ session = value;
265
+ sessionGeneration += 1;
266
+ // The core transport caches its model catalog by account. A catalog
267
+ // request made with an expired token can therefore cache the failure;
268
+ // rebuild it after refresh so the retry uses both the new token and a
269
+ // fresh catalog resolver.
270
+ transportPromise = undefined;
271
+ return value;
272
+ })
273
+ .catch((error) => { throw mapBrokerError(error); })
274
+ .finally(() => { refreshPromise = undefined; });
275
+ return refreshPromise;
276
+ };
277
+ const transport = () => transportPromise ??= core().then((loaded) => loaded.createOpenAIOAuthTransport({
278
+ auth: getSession,
279
+ codexVersion: '0.144.1',
280
+ fetch: boundedFetch,
281
+ responsesState: false,
282
+ }));
283
+ const requestWithAuthRetry = async (path, init, refreshStatuses = [401]) => {
284
+ await getSession();
285
+ const generation = sessionGeneration;
286
+ let response = await (await transport()).request(path, init);
287
+ if (!refreshStatuses.includes(response.status))
288
+ return response;
289
+ await refreshAfterUnauthorized(generation);
290
+ response = await (await transport()).request(path, init);
291
+ return response;
292
+ };
293
+ const ensurePreflight = () => preflight ??= (async () => {
294
+ const response = await requestWithAuthRetry('/v1/models', {
295
+ method: 'GET',
296
+ signal: AbortSignal.timeout(profile.requestTimeoutMs),
297
+ }, [401, 502]);
298
+ if (!response.ok)
299
+ throw classifyStatus(response.status);
300
+ let parsed;
301
+ try {
302
+ parsed = await response.json();
303
+ }
304
+ catch {
305
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
306
+ }
307
+ // @openai-oauth/core normalizes the upstream Codex `models[].slug`
308
+ // catalog into this OpenAI-compatible `data[].id` response.
309
+ const compatibleModels = isRecord(parsed) && Array.isArray(parsed.data) ? parsed.data : [];
310
+ if (!compatibleModels.some((entry) => isRecord(entry) && entry.id === profile.model)) {
311
+ throw oauthError('OAUTH_MODEL_UNAVAILABLE');
312
+ }
313
+ })().catch((error) => {
314
+ preflight = undefined;
315
+ if (error instanceof ChatGptOAuthJudgeError)
316
+ throw error;
317
+ if (error instanceof Error && error.name === 'AbortError') {
318
+ throw oauthError('OAUTH_REQUEST_TIMEOUT');
319
+ }
320
+ throw oauthError('OAUTH_UPSTREAM_FAILED');
321
+ });
322
+ const send = async (body) => {
323
+ await ensurePreflight();
324
+ try {
325
+ const response = await requestWithAuthRetry('/v1/responses', {
326
+ method: 'POST',
327
+ headers: { 'content-type': 'application/json' },
328
+ body: JSON.stringify(body),
329
+ signal: AbortSignal.timeout(profile.requestTimeoutMs),
330
+ });
331
+ const result = await parseResponse(response);
332
+ inputTokens += safeToken(result.usage?.input_tokens) ?? 0;
333
+ outputTokens += safeToken(result.usage?.output_tokens) ?? 0;
334
+ return result;
335
+ }
336
+ catch (error) {
337
+ if (error instanceof ChatGptOAuthJudgeError)
338
+ throw error;
339
+ if (error instanceof Error && error.name === 'AbortError') {
340
+ throw oauthError('OAUTH_REQUEST_TIMEOUT');
341
+ }
342
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
343
+ }
344
+ };
345
+ const port = {
346
+ get tokenUsage() { return { inputTokens, outputTokens }; },
347
+ get supportsToolUse() { return true; },
348
+ async measure(fn) {
349
+ const beforeInput = inputTokens;
350
+ const beforeOutput = outputTokens;
351
+ const result = await fn();
352
+ return {
353
+ result,
354
+ tokens: {
355
+ inputTokens: inputTokens - beforeInput,
356
+ outputTokens: outputTokens - beforeOutput,
357
+ },
358
+ costUsd: 0,
359
+ };
360
+ },
361
+ async call(prompt, callOptions = {}) {
362
+ assertModel(profile, callOptions.model);
363
+ const result = await send(requestBody(profile, [{
364
+ role: 'user', content: [{ type: 'input_text', text: prompt }],
365
+ }], ''));
366
+ if ((result.output ?? []).some((item) => item.type === 'function_call')) {
367
+ throw oauthError('OAUTH_PROTOCOL_RESPONSE_INVALID');
368
+ }
369
+ return {
370
+ text: outputText(result),
371
+ inputTokens: safeToken(result.usage?.input_tokens),
372
+ outputTokens: safeToken(result.usage?.output_tokens),
373
+ provider: 'openai',
374
+ model: profile.model,
375
+ };
376
+ },
377
+ async callWithTools(messages, callOptions) {
378
+ assertModel(profile, callOptions.model);
379
+ const tools = callOptions.tools.map((tool) => ({
380
+ type: 'function',
381
+ name: tool.name,
382
+ description: tool.description,
383
+ parameters: tool.input_schema,
384
+ }));
385
+ const result = await send(requestBody(profile, toResponsesInput(messages), callOptions.system ?? '', tools));
386
+ const blocks = toolBlocks(result, new Set(callOptions.tools.map((tool) => tool.name)));
387
+ const text = outputText(result);
388
+ const usage = {
389
+ inputTokens: safeToken(result.usage?.input_tokens),
390
+ outputTokens: safeToken(result.usage?.output_tokens),
391
+ };
392
+ return blocks.length > 0
393
+ ? { kind: 'tool_use', blocks, text: text || undefined, ...usage }
394
+ : { kind: 'final', text, ...usage };
395
+ },
396
+ };
397
+ return Object.freeze(port);
398
+ }
@@ -0,0 +1,32 @@
1
+ import type { OpenAIOAuthSession } from '@openai-oauth/core';
2
+ import { type AppServerSessionHandle } from '../agents/codex-app-server/transport.js';
3
+ export type CodexAuthBrokerErrorCode = 'OAUTH_CODEX_UNAVAILABLE' | 'OAUTH_CODEX_LOGIN_REQUIRED' | 'OAUTH_AUTH_TIMEOUT';
4
+ export declare class CodexAuthBrokerError extends Error {
5
+ readonly code: CodexAuthBrokerErrorCode;
6
+ constructor(code: CodexAuthBrokerErrorCode);
7
+ }
8
+ export interface CodexAuthBroker {
9
+ acquireAccessSession(input: {
10
+ refresh: boolean;
11
+ signal?: AbortSignal;
12
+ }): Promise<OpenAIOAuthSession>;
13
+ }
14
+ export interface CodexAuthBrokerDependencies {
15
+ binary?: string;
16
+ env?: NodeJS.ProcessEnv;
17
+ authTimeoutMs?: number;
18
+ spawn?: (input: {
19
+ binary?: string;
20
+ env: NodeJS.ProcessEnv;
21
+ }) => AppServerSessionHandle;
22
+ deriveAccountId: (token: string | undefined) => string | undefined;
23
+ deriveIsFedRamp: (token: string | undefined) => boolean;
24
+ }
25
+ export declare const sanitizeCodexAuthEnvironment: (source?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv;
26
+ export declare function createCodexAuthBroker(deps: CodexAuthBrokerDependencies): CodexAuthBroker;
27
+ /** Build the production broker lazily so @openai-oauth/core stays an optional runtime dependency. */
28
+ export declare function createDefaultCodexAuthBroker(input?: {
29
+ binary?: string;
30
+ env?: NodeJS.ProcessEnv;
31
+ authTimeoutMs?: number;
32
+ }): Promise<CodexAuthBroker>;
@@ -0,0 +1,110 @@
1
+ import { spawnAppServerTransport, } from '../agents/codex-app-server/transport.js';
2
+ const DEFAULT_AUTH_TIMEOUT_MS = 10_000;
3
+ const AUTH_ENV_ALLOWLIST = new Set([
4
+ 'PATH', 'HOME', 'CODEX_HOME', 'SHELL', 'USER', 'LOGNAME',
5
+ 'LANG', 'LC_ALL', 'LC_CTYPE', 'TERM', 'TMPDIR', 'TMP', 'TEMP',
6
+ 'SYSTEMROOT', 'WINDIR', 'COMSPEC', 'PATHEXT',
7
+ ]);
8
+ export class CodexAuthBrokerError extends Error {
9
+ code;
10
+ constructor(code) {
11
+ super(code);
12
+ this.code = code;
13
+ this.name = 'CodexAuthBrokerError';
14
+ }
15
+ }
16
+ const brokerError = (code) => new CodexAuthBrokerError(code);
17
+ export const sanitizeCodexAuthEnvironment = (source = process.env) => Object.fromEntries(Object.entries(source).filter(([name]) => AUTH_ENV_ALLOWLIST.has(name.toUpperCase())));
18
+ const abortError = () => new DOMException('Aborted', 'AbortError');
19
+ async function withDeadline(promise, timeoutMs, signal) {
20
+ if (signal?.aborted)
21
+ throw abortError();
22
+ let timeout;
23
+ let onAbort;
24
+ const deadline = new Promise((_resolve, reject) => {
25
+ timeout = setTimeout(() => reject(brokerError('OAUTH_AUTH_TIMEOUT')), timeoutMs);
26
+ timeout.unref?.();
27
+ if (signal) {
28
+ onAbort = () => reject(abortError());
29
+ signal.addEventListener('abort', onAbort, { once: true });
30
+ }
31
+ });
32
+ try {
33
+ return await Promise.race([promise, deadline]);
34
+ }
35
+ finally {
36
+ if (timeout)
37
+ clearTimeout(timeout);
38
+ if (signal && onAbort)
39
+ signal.removeEventListener('abort', onAbort);
40
+ }
41
+ }
42
+ export function createCodexAuthBroker(deps) {
43
+ const timeoutMs = deps.authTimeoutMs ?? DEFAULT_AUTH_TIMEOUT_MS;
44
+ const spawn = deps.spawn ?? ((input) => spawnAppServerTransport({
45
+ ...input,
46
+ logStderrOnExit: false,
47
+ }));
48
+ return {
49
+ async acquireAccessSession({ refresh, signal }) {
50
+ let session;
51
+ try {
52
+ session = spawn({
53
+ ...(deps.binary !== undefined ? { binary: deps.binary } : {}),
54
+ env: sanitizeCodexAuthEnvironment(deps.env),
55
+ });
56
+ const operation = (async () => {
57
+ await session.transport.sendRequest('initialize', {
58
+ clientInfo: { name: 'pathgrade-auth', version: '1.0.0', title: null },
59
+ capabilities: { experimentalApi: true, optOutNotificationMethods: null },
60
+ });
61
+ session.transport.sendNotification('initialized', null);
62
+ const status = await session.transport.sendRequest('getAuthStatus', {
63
+ includeToken: true,
64
+ refreshToken: refresh,
65
+ });
66
+ if (status?.authMethod !== 'chatgpt') {
67
+ throw brokerError('OAUTH_CODEX_LOGIN_REQUIRED');
68
+ }
69
+ if (typeof status.authToken !== 'string' || status.authToken.length === 0) {
70
+ throw brokerError('OAUTH_CODEX_LOGIN_REQUIRED');
71
+ }
72
+ const accountId = deps.deriveAccountId(status.authToken);
73
+ if (!accountId)
74
+ throw brokerError('OAUTH_CODEX_LOGIN_REQUIRED');
75
+ return {
76
+ accessToken: status.authToken,
77
+ accountId,
78
+ isFedRamp: deps.deriveIsFedRamp(status.authToken),
79
+ };
80
+ })();
81
+ return await withDeadline(operation, timeoutMs, signal);
82
+ }
83
+ catch (error) {
84
+ if (error instanceof CodexAuthBrokerError ||
85
+ (error instanceof Error && error.name === 'AbortError')) {
86
+ throw error;
87
+ }
88
+ throw brokerError('OAUTH_CODEX_UNAVAILABLE');
89
+ }
90
+ finally {
91
+ await session?.close().catch(() => undefined);
92
+ }
93
+ },
94
+ };
95
+ }
96
+ /** Build the production broker lazily so @openai-oauth/core stays an optional runtime dependency. */
97
+ export async function createDefaultCodexAuthBroker(input = {}) {
98
+ let core;
99
+ try {
100
+ core = await import('@openai-oauth/core');
101
+ }
102
+ catch {
103
+ throw brokerError('OAUTH_CODEX_UNAVAILABLE');
104
+ }
105
+ return createCodexAuthBroker({
106
+ ...input,
107
+ deriveAccountId: core.deriveAccountId,
108
+ deriveIsFedRamp: core.deriveChatGptAccountIsFedRamp,
109
+ });
110
+ }
@@ -0,0 +1,2 @@
1
+ export { ChatGptOAuthJudgeError, createChatGptOAuthJudgeLLM, } from './chatgpt-oauth-llm.js';
2
+ export type { ChatGptOAuthJudgeErrorCode, ChatGptOAuthJudgeOptions, } from './chatgpt-oauth-llm.js';
@@ -0,0 +1 @@
1
+ export { ChatGptOAuthJudgeError, createChatGptOAuthJudgeLLM, } from './chatgpt-oauth-llm.js';
@@ -1,4 +1,4 @@
1
- import type { AgentName } from '../sdk/types.js';
1
+ import type { AgentName, AgentTransport } from '../sdk/types.js';
2
2
  export interface CredentialPorts {
3
3
  /** Read a host environment variable. */
4
4
  hostEnv(key: string): string | undefined;
@@ -46,5 +46,6 @@ export interface CredentialResult {
46
46
  export declare function defaultPorts(): CredentialPorts;
47
47
  export interface CredentialContext {
48
48
  model?: string;
49
+ transport?: AgentTransport;
49
50
  }
50
51
  export declare function resolveCredentials(agent: AgentName, userEnv: Record<string, string>, ports?: CredentialPorts, context?: CredentialContext): Promise<CredentialResult>;
@@ -61,7 +61,7 @@ export async function resolveCredentials(agent, userEnv, ports, context = {}) {
61
61
  case 'claude':
62
62
  return resolveClaude(userEnv, p);
63
63
  case 'codex':
64
- return resolveCodex(userEnv, p);
64
+ return resolveCodex(userEnv, p, context.transport);
65
65
  case 'cursor':
66
66
  return resolveCursor(userEnv, p);
67
67
  case 'opencode':
@@ -260,7 +260,7 @@ async function resolveCursor(userEnv, ports) {
260
260
  }
261
261
  return { env, setupCommands: [], copyFromHome: [], linkFromHome };
262
262
  }
263
- async function resolveCodex(userEnv, ports) {
263
+ async function resolveCodex(userEnv, ports, transport) {
264
264
  const env = {};
265
265
  const setupCommands = [];
266
266
  const copyFromHome = [];
@@ -295,7 +295,7 @@ async function resolveCodex(userEnv, ports) {
295
295
  setupCommands.push('if [ ! -d "$HOME/.codex" ] && [ -n "${OPENAI_API_KEY:-}" ]; then printenv OPENAI_API_KEY | codex login --with-api-key >/dev/null 2>&1; fi');
296
296
  }
297
297
  }
298
- else {
298
+ else if (transport !== 'app-server') {
299
299
  // No key — check for cached auth.json
300
300
  const authCachePath = path.join(ports.homedir, '.codex', 'auth.json');
301
301
  if (await ports.fileExists(authCachePath)) {
@@ -2,6 +2,8 @@ export interface SandboxConfig {
2
2
  agent: import('../sdk/types.js').AgentName;
3
3
  /** Internal provider/model context; ignored by sandbox creation itself. */
4
4
  model?: string;
5
+ /** Internal Codex transport context used while resolving credentials. */
6
+ transport?: import('../sdk/types.js').AgentTransport;
5
7
  workspace?: string;
6
8
  skillDir?: string;
7
9
  copyFromHome?: string[];
@@ -33,7 +33,10 @@ type Observer = (identity: {
33
33
  serverName: string;
34
34
  toolName: string;
35
35
  }) => void;
36
+ type ResourceObserver = (activeTransports: number) => void;
36
37
  /** Non-public acceptance seam. It observes only authenticated manifest-member calls. */
37
38
  export declare function __setScriptedMcpMockObserverForTesting(observer: Observer | null): void;
39
+ /** Non-public regression seam for request-scoped transport retention. */
40
+ export declare function __setScriptedMcpMockResourceObserverForTesting(observer: ResourceObserver | null): void;
38
41
  export declare function startScriptedMcpMockHost(plan: CompiledMcpMockSession): Promise<ScriptedMcpMockHost>;
39
42
  export {};
@@ -10,10 +10,15 @@ import { getOriginalMcpInput } from '../sdk/mcp-event-input.js';
10
10
  import { cloneToolEventWithRuntimeMetadata } from '../sdk/tool-event-secrets.js';
11
11
  import { createMcpArgumentDigest, createMcpReceiptKey, matchesMcpApprovalArguments, } from '../sdk/mcp-mock-approvals.js';
12
12
  let testObserver = null;
13
+ let testResourceObserver = null;
13
14
  /** Non-public acceptance seam. It observes only authenticated manifest-member calls. */
14
15
  export function __setScriptedMcpMockObserverForTesting(observer) {
15
16
  testObserver = observer;
16
17
  }
18
+ /** Non-public regression seam for request-scoped transport retention. */
19
+ export function __setScriptedMcpMockResourceObserverForTesting(observer) {
20
+ testResourceObserver = observer;
21
+ }
17
22
  const MAX_BODY_BYTES = 1024 * 1024;
18
23
  const MATCH_TIMEOUT_MS = 250;
19
24
  function isRecord(value) {
@@ -173,9 +178,37 @@ export async function startScriptedMcpMockHost(plan) {
173
178
  });
174
179
  const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
175
180
  activeTransports.add(transport);
176
- transport.onclose = () => activeTransports.delete(transport);
177
- await server.connect(transport);
178
- await transport.handleRequest(req, res, body);
181
+ testResourceObserver?.(activeTransports.size);
182
+ const releaseTransport = () => {
183
+ if (activeTransports.delete(transport))
184
+ testResourceObserver?.(activeTransports.size);
185
+ };
186
+ transport.onclose = releaseTransport;
187
+ let cleanupPromise;
188
+ const cleanup = () => {
189
+ cleanupPromise ??= (async () => {
190
+ try {
191
+ await server.close();
192
+ }
193
+ catch {
194
+ await transport.close().catch(() => undefined);
195
+ }
196
+ finally {
197
+ releaseTransport();
198
+ }
199
+ })();
200
+ return cleanupPromise;
201
+ };
202
+ const cleanupOnResponseClose = () => { void cleanup(); };
203
+ res.once('close', cleanupOnResponseClose);
204
+ try {
205
+ await server.connect(transport);
206
+ await transport.handleRequest(req, res, body);
207
+ }
208
+ finally {
209
+ res.off('close', cleanupOnResponseClose);
210
+ await cleanup();
211
+ }
179
212
  }
180
213
  catch (error) {
181
214
  latch(error instanceof Error ? error : new Error(String(error)));
@@ -48,7 +48,10 @@ export async function prepareWorkspace(spec) {
48
48
  try {
49
49
  // Resolve credentials: pass user's original env (not sandboxEnv) so
50
50
  // the resolver can distinguish explicit user intent from auto-resolved values.
51
- const creds = await resolveCredentials(spec.agent, spec.env ?? {}, undefined, { model: spec.model });
51
+ const creds = await resolveCredentials(spec.agent, spec.env ?? {}, undefined, {
52
+ model: spec.model,
53
+ transport: spec.transport,
54
+ });
52
55
  Object.assign(sandboxEnv, creds.env);
53
56
  await copyPathsFromHostHome(creds.copyFromHome, homePath);
54
57
  await linkPathsFromHostHome(creds.linkFromHome ?? [], homePath);
package/dist/sdk/agent.js CHANGED
@@ -430,6 +430,7 @@ export async function createAgent(opts) {
430
430
  const workspace = await prepareWorkspace({
431
431
  ...rest,
432
432
  agent: agentName,
433
+ transport,
433
434
  mcp: scriptedMcp
434
435
  ? undefined
435
436
  : mcpConfigFile ? { configFile: mcpConfigFile } : mcpMock ? { mock: mcpMock } : undefined,
@@ -69,10 +69,21 @@ async function checkFinalContainment(workspace, absolutePath) {
69
69
  return false;
70
70
  }
71
71
  }
72
+ async function resolveExistingInWorkspace(workspace, relPath) {
73
+ const resolved = await resolveInWorkspace(workspace, relPath);
74
+ const [workspaceReal, targetReal] = await Promise.all([
75
+ fs.realpath(workspace),
76
+ fs.realpath(resolved),
77
+ ]);
78
+ if (!isInside(workspaceReal, targetReal)) {
79
+ throw new Error(`Path "${relPath}" is outside workspace`);
80
+ }
81
+ return targetReal;
82
+ }
72
83
  export async function readFile(ctx, relPath) {
73
- const resolved = await resolveInWorkspace(ctx.workspace, relPath);
74
84
  let content;
75
85
  try {
86
+ const resolved = await resolveExistingInWorkspace(ctx.workspace, relPath);
76
87
  content = await fs.readFile(resolved, 'utf8');
77
88
  }
78
89
  catch (err) {
@@ -88,9 +99,9 @@ export async function readFile(ctx, relPath) {
88
99
  return content;
89
100
  }
90
101
  export async function listDir(ctx, relPath) {
91
- const resolved = await resolveInWorkspace(ctx.workspace, relPath);
92
102
  let entries;
93
103
  try {
104
+ const resolved = await resolveExistingInWorkspace(ctx.workspace, relPath);
94
105
  entries = await fs.readdir(resolved, { withFileTypes: true });
95
106
  }
96
107
  catch (err) {
@@ -107,7 +118,7 @@ export async function listDir(ctx, relPath) {
107
118
  }
108
119
  export async function grep(ctx, pattern, relPath) {
109
120
  const rootRel = relPath ?? '.';
110
- const root = await resolveInWorkspace(ctx.workspace, rootRel);
121
+ const root = await resolveExistingInWorkspace(ctx.workspace, rootRel);
111
122
  const workspaceReal = await fs.realpath(ctx.workspace);
112
123
  const regex = new RegExp(pattern);
113
124
  const matches = [];
package/dist/utils/llm.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { resolveCodexModel } from '../agents/codex-model.js';
2
+ import { createChatGptOAuthJudgeLLM } from '../openai-oauth/chatgpt-oauth-llm.js';
1
3
  import { cliProvider } from './llm-providers/cli.js';
2
4
  import { anthropicProvider } from './llm-providers/anthropic.js';
3
5
  import { openaiProvider } from './llm-providers/openai.js';
@@ -191,6 +193,15 @@ export async function callLLM(prompt, opts = {}) {
191
193
  * the agent's env propagates to persona/judge/summarization calls.
192
194
  */
193
195
  export function createAgentLLM(agentName, agentEnv, agentModel) {
196
+ const effectiveAgentEnv = agentEnv ?? process.env;
197
+ if (agentName === 'codex'
198
+ && !effectiveAgentEnv.OPENAI_API_KEY
199
+ && !effectiveAgentEnv.OPENAI_BASE_URL) {
200
+ return createChatGptOAuthJudgeLLM({
201
+ model: resolveCodexModel(agentModel),
202
+ reasoningEffort: 'high',
203
+ });
204
+ }
194
205
  // Tool-using judges need a provider that implements callWithTools.
195
206
  // For claude, anthropicProvider is added as a tool-use-capable fallback
196
207
  // alongside the CLI. The CLI still wins for plain call() when available.
@@ -0,0 +1,91 @@
1
+ # Experimental Codex-authenticated ChatGPT judge
2
+
3
+ Status: **experimental**. W0b passed for the direct
4
+ `@openai-oauth/core@2.0.0` Responses mapping, and W0c passed for acquiring a
5
+ managed ChatGPT access session from Codex App Server.
6
+
7
+ ## Install and authenticate
8
+
9
+ Install the exact optional peer and sign in once with Codex:
10
+
11
+ ```bash
12
+ yarn add -D @wix/pathgrade @openai-oauth/core@2.0.0
13
+ codex login
14
+ ```
15
+
16
+ Pathgrade starts a short-lived `codex app-server` child over stdio, calls only
17
+ the auth-status RPC, then closes it before making the direct judge request. It
18
+ does not read, copy, or write `~/.codex/auth.json`, receive a refresh token,
19
+ open a local port, or start a Codex model thread. After a real upstream 401 it
20
+ asks Codex to refresh, retries once, and otherwise fails closed.
21
+
22
+ ## Inject explicitly
23
+
24
+ Codex agents select this judge automatically when neither `OPENAI_API_KEY` nor
25
+ `OPENAI_BASE_URL` is present. The judge uses the resolved Codex model (Luna by
26
+ default) with high reasoning effort. Passing `evaluate(agent, scorers, { llm })`
27
+ remains the highest-precedence override. Explicit construction is available for
28
+ other agents and custom flows:
29
+
30
+ ```typescript
31
+ import { createChatGptOAuthJudgeLLM } from '@wix/pathgrade/openai-oauth';
32
+ import { evaluate, judge } from '@wix/pathgrade';
33
+
34
+ const llm = createChatGptOAuthJudgeLLM({
35
+ model: 'gpt-5.6-sol',
36
+ reasoningEffort: 'high',
37
+ requestTimeoutMs: 60_000,
38
+ });
39
+
40
+ const result = await evaluate(agent, [
41
+ judge('quality', { rubric: 'Apply the frozen rubric.', model: 'gpt-5.6-sol' }),
42
+ ], { llm });
43
+ ```
44
+
45
+ The scorer model must exactly equal the factory model. There is no auth-file,
46
+ API-key, base-URL, login callback, provider fallback, model rewrite, or default
47
+ provider registration. Plain calls expose zero tools. Tool judges expose only
48
+ their declared Pathgrade schemas through the existing bounded tool registry.
49
+
50
+ ## Stable public failures
51
+
52
+ - `OAUTH_CONFIG_INVALID`
53
+ - `OAUTH_DEPENDENCY_UNAVAILABLE`
54
+ - `OAUTH_CODEX_UNAVAILABLE`
55
+ - `OAUTH_CODEX_LOGIN_REQUIRED`
56
+ - `OAUTH_AUTH_TIMEOUT`
57
+ - `OAUTH_MODEL_MISMATCH`
58
+ - `OAUTH_MODEL_UNAVAILABLE`
59
+ - `OAUTH_REQUEST_TIMEOUT`
60
+ - `OAUTH_UPSTREAM_RATE_LIMITED`
61
+ - `OAUTH_UPSTREAM_FAILED`
62
+ - `OAUTH_PROTOCOL_RESPONSE_INVALID`
63
+
64
+ Tokens, Codex paths, auth JSON, prompts, App Server stderr/RPC text, upstream
65
+ bodies, and tool results are never included in public errors. Failures never
66
+ fall back to another provider.
67
+
68
+ ## Repository-only verification
69
+
70
+ These maintainer probes run from a source checkout and are not included in the
71
+ published npm package. Run them from the repository root.
72
+
73
+ The no-cost installed-binary probe reports only version, auth-mode booleans,
74
+ and its ADAPT/STOP verdict:
75
+
76
+ ```bash
77
+ yarn smoke:codex-auth
78
+ ```
79
+
80
+ The explicit paid live suite uses only the active Codex login. It exercises
81
+ direct plain and bounded `readFile` judges, then a real keyless Codex app-server
82
+ agent followed by automatically selected plain and `readFile` judges:
83
+
84
+ ```bash
85
+ yarn test:evals:judge-oauth:live
86
+ ```
87
+
88
+ It writes redacted reports to `.pathgrade/judge-oauth-live-smoke.{json,md}` and
89
+ `.pathgrade/codex-oauth-agent-judge-e2e.json`.
90
+ Any `@openai-oauth/core` or Codex protocol upgrade must rerun W0b, W0c, and
91
+ the live smoke.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wix/pathgrade",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "packageManager": "yarn@4.12.0",
5
5
  "description": "Evaluate whether AI agents discover and use your skills correctly",
6
6
  "exports": {
@@ -44,6 +44,10 @@
44
44
  "types": "./dist/core/mcp-mock.d.ts",
45
45
  "default": "./dist/core/mcp-mock.js"
46
46
  },
47
+ "./openai-oauth": {
48
+ "types": "./dist/openai-oauth/index.d.ts",
49
+ "default": "./dist/openai-oauth/index.js"
50
+ },
47
51
  "./cli": {
48
52
  "types": "./dist/pathgrade.d.ts",
49
53
  "default": "./dist/pathgrade.js"
@@ -59,6 +63,7 @@
59
63
  "!dist/**/*.js.map",
60
64
  "!dist/**/*.d.ts.map",
61
65
  "bin/",
66
+ "docs/OPENAI_OAUTH_JUDGE.md",
62
67
  "templates/",
63
68
  "README.md"
64
69
  ],
@@ -102,10 +107,14 @@
102
107
  "node": ">=20.19.0"
103
108
  },
104
109
  "peerDependencies": {
110
+ "@openai-oauth/core": "2.0.0",
105
111
  "jest": "^30.0.0",
106
112
  "vitest": "^4.0.0"
107
113
  },
108
114
  "peerDependenciesMeta": {
115
+ "@openai-oauth/core": {
116
+ "optional": true
117
+ },
109
118
  "jest": {
110
119
  "optional": true
111
120
  },
@@ -114,11 +123,13 @@
114
123
  }
115
124
  },
116
125
  "devDependencies": {
126
+ "@openai-oauth/core": "2.0.0",
117
127
  "@types/fs-extra": "^11.0.4",
118
128
  "@types/jest": "^30.0.0",
119
129
  "@types/picomatch": "^4.0.2",
120
130
  "@vitest/coverage-v8": "4.1.10",
121
131
  "jest": "^30.0.0",
132
+ "openai-oauth": "2.0.0",
122
133
  "vitest": "4.1.10"
123
134
  },
124
135
  "dependencies": {
@@ -133,5 +144,5 @@
133
144
  "typescript": "^5.9.3",
134
145
  "zod": "4.3.6"
135
146
  },
136
- "falconPackageHash": "3b9bb9a9b04396a2419626ab26afcee636663d2a034597d2e4fcff92"
147
+ "falconPackageHash": "4b55cfec1102172799601d1b5a3a8089a73b9c5e093a4533316f1e7c"
137
148
  }