@poncho-ai/harness 0.29.0 → 0.31.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": "@poncho-ai/harness",
3
- "version": "0.29.0",
3
+ "version": "0.31.0",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,7 +34,7 @@
34
34
  "redis": "^5.10.0",
35
35
  "yaml": "^2.4.0",
36
36
  "zod": "^3.22.0",
37
- "@poncho-ai/sdk": "1.6.2"
37
+ "@poncho-ai/sdk": "1.7.0"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/mustache": "^4.2.6",
@@ -170,7 +170,7 @@ export const parseAgentMarkdown = (content: string): ParsedAgent => {
170
170
  throw new Error("Invalid AGENT.md: frontmatter requires a non-empty `name`.");
171
171
  }
172
172
 
173
- const KNOWN_PROVIDERS = new Set(["anthropic", "openai"]);
173
+ const KNOWN_PROVIDERS = new Set(["anthropic", "openai", "openai-codex"]);
174
174
  const splitProviderPrefix = (raw: string): { provider?: string; name: string } => {
175
175
  const slashIdx = raw.indexOf("/");
176
176
  if (slashIdx > 0) {
package/src/config.ts CHANGED
@@ -100,6 +100,13 @@ export interface PonchoConfig extends McpConfig {
100
100
  storage?: StorageConfig;
101
101
  providers?: {
102
102
  openai?: { apiKeyEnv?: string };
103
+ openaiCodex?: {
104
+ refreshTokenEnv?: string;
105
+ accessTokenEnv?: string;
106
+ accessTokenExpiresAtEnv?: string;
107
+ accountIdEnv?: string;
108
+ authFilePathEnv?: string;
109
+ };
103
110
  anthropic?: { apiKeyEnv?: string };
104
111
  };
105
112
  telemetry?: {
package/src/harness.ts CHANGED
@@ -451,6 +451,8 @@ export default {
451
451
  providers: {
452
452
  anthropic: { apiKeyEnv: "ANTHROPIC_API_KEY" },
453
453
  openai: { apiKeyEnv: "OPENAI_API_KEY" },
454
+ // openai-codex provider reads OAuth tokens from env vars by default:
455
+ // openaiCodex: { refreshTokenEnv: "OPENAI_CODEX_REFRESH_TOKEN", accountIdEnv: "OPENAI_CODEX_ACCOUNT_ID" },
454
456
  },
455
457
  auth: {
456
458
  required: true,
@@ -1643,7 +1645,7 @@ ${boundedMainMemory.trim()}`
1643
1645
  if (lastMsg && lastMsg.role !== "user") {
1644
1646
  messages.push({
1645
1647
  role: "user",
1646
- content: "[System: Your previous turn was interrupted by a time limit. Continue from where you left off — do NOT repeat what you already said. Proceed directly with the next action or tool call.]",
1648
+ content: "[System: Your previous turn was interrupted by a time limit. Your partial response above is already visible to the user. Continue EXACTLY from where you left off — do NOT restart, re-summarize, or repeat any content you already produced. If you were mid-sentence or mid-table, continue that sentence or table. Proceed directly with the next action or output.]",
1647
1649
  metadata: { timestamp: now(), id: randomUUID() },
1648
1650
  });
1649
1651
  }
@@ -2048,7 +2050,10 @@ ${boundedMainMemory.trim()}`
2048
2050
  let chunkCount = 0;
2049
2051
  const hasRunTimeout = timeoutMs > 0;
2050
2052
  const streamDeadline = hasRunTimeout ? start + timeoutMs : 0;
2053
+ const hasSoftDeadline = softDeadlineMs > 0;
2054
+ const INTER_CHUNK_TIMEOUT_MS = 60_000;
2051
2055
  const fullStreamIterator = result.fullStream[Symbol.asyncIterator]();
2056
+ let softDeadlineFiredDuringStream = false;
2052
2057
  try {
2053
2058
  while (true) {
2054
2059
  if (isCancelled()) {
@@ -2072,25 +2077,36 @@ ${boundedMainMemory.trim()}`
2072
2077
  return;
2073
2078
  }
2074
2079
  }
2075
- const remaining = hasRunTimeout ? streamDeadline - now() : Infinity;
2080
+ if (hasSoftDeadline && chunkCount > 0 && now() - start >= softDeadlineMs) {
2081
+ softDeadlineFiredDuringStream = true;
2082
+ break;
2083
+ }
2084
+ const hardRemaining = hasRunTimeout ? streamDeadline - now() : Infinity;
2085
+ const softRemaining = hasSoftDeadline ? Math.max(0, (start + softDeadlineMs) - now()) : Infinity;
2086
+ const deadlineRemaining = Math.min(hardRemaining, softRemaining);
2076
2087
  const timeout = chunkCount === 0
2077
- ? Math.min(remaining, FIRST_CHUNK_TIMEOUT_MS)
2078
- : hasRunTimeout ? remaining : 0;
2088
+ ? Math.min(deadlineRemaining, FIRST_CHUNK_TIMEOUT_MS)
2089
+ : Math.min(deadlineRemaining, INTER_CHUNK_TIMEOUT_MS);
2079
2090
  let nextPart: IteratorResult<(typeof result.fullStream) extends AsyncIterable<infer T> ? T : never> | null;
2080
- if (timeout <= 0 && chunkCount > 0) {
2091
+ if (timeout <= 0 && chunkCount > 0 && !hasSoftDeadline) {
2081
2092
  nextPart = await fullStreamIterator.next();
2082
2093
  } else {
2094
+ const effectiveTimeout = Math.max(timeout, 1);
2083
2095
  let timer: ReturnType<typeof setTimeout> | undefined;
2084
2096
  nextPart = await Promise.race([
2085
2097
  fullStreamIterator.next(),
2086
2098
  new Promise<null>((resolve) => {
2087
- timer = setTimeout(() => resolve(null), timeout);
2099
+ timer = setTimeout(() => resolve(null), effectiveTimeout);
2088
2100
  }),
2089
2101
  ]);
2090
2102
  clearTimeout(timer);
2091
2103
  }
2092
2104
 
2093
2105
  if (nextPart === null) {
2106
+ if (hasSoftDeadline && deadlineRemaining <= INTER_CHUNK_TIMEOUT_MS) {
2107
+ softDeadlineFiredDuringStream = true;
2108
+ break;
2109
+ }
2094
2110
  const isFirstChunk = chunkCount === 0;
2095
2111
  console.error(
2096
2112
  `[poncho][harness] Stream timeout waiting for ${isFirstChunk ? "first" : "next"} chunk: model="${modelName}", step=${step}, chunks=${chunkCount}, elapsed=${now() - start}ms`,
@@ -2125,6 +2141,31 @@ ${boundedMainMemory.trim()}`
2125
2141
  fullStreamIterator.return?.(undefined)?.catch?.(() => {});
2126
2142
  }
2127
2143
 
2144
+ if (softDeadlineFiredDuringStream) {
2145
+ if (fullText.length > 0) {
2146
+ messages.push({
2147
+ role: "assistant",
2148
+ content: fullText,
2149
+ metadata: { timestamp: now(), id: randomUUID(), step },
2150
+ });
2151
+ }
2152
+ const result_: RunResult = {
2153
+ status: "completed",
2154
+ response: responseText + fullText,
2155
+ steps: step,
2156
+ tokens: { input: totalInputTokens, output: totalOutputTokens, cached: totalCachedTokens },
2157
+ duration: now() - start,
2158
+ continuation: true,
2159
+ continuationMessages: [...messages],
2160
+ maxSteps,
2161
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
2162
+ contextWindow,
2163
+ };
2164
+ console.info(`[poncho][harness] Soft deadline fired mid-stream at step ${step} (${(now() - start).toFixed(0)}ms). Checkpointing with ${fullText.length} chars of partial text.`);
2165
+ yield pushEvent({ type: "run:completed", runId, result: result_ });
2166
+ return;
2167
+ }
2168
+
2128
2169
  if (isCancelled()) {
2129
2170
  yield emitCancellation();
2130
2171
  return;
@@ -2133,6 +2174,13 @@ ${boundedMainMemory.trim()}`
2133
2174
  // Post-streaming soft deadline: if the model stream took long enough to
2134
2175
  // push past the soft deadline, checkpoint now before tool execution.
2135
2176
  if (softDeadlineMs > 0 && now() - start > softDeadlineMs) {
2177
+ if (fullText.length > 0) {
2178
+ messages.push({
2179
+ role: "assistant",
2180
+ content: fullText,
2181
+ metadata: { timestamp: now(), id: randomUUID(), step },
2182
+ });
2183
+ }
2136
2184
  const result_: RunResult = {
2137
2185
  status: "completed",
2138
2186
  response: responseText + fullText,
@@ -2446,6 +2494,13 @@ ${boundedMainMemory.trim()}`
2446
2494
  }
2447
2495
 
2448
2496
  if ((batchResults as unknown) === TOOL_DEADLINE_SENTINEL) {
2497
+ if (fullText.length > 0) {
2498
+ messages.push({
2499
+ role: "assistant",
2500
+ content: fullText,
2501
+ metadata: { timestamp: now(), id: randomUUID(), step },
2502
+ });
2503
+ }
2449
2504
  const result_: RunResult = {
2450
2505
  status: "completed",
2451
2506
  response: responseText + fullText,
package/src/index.ts CHANGED
@@ -8,6 +8,7 @@ export * from "./latitude-capture.js";
8
8
  export * from "./memory.js";
9
9
  export * from "./mcp.js";
10
10
  export * from "./model-factory.js";
11
+ export * from "./openai-codex-auth.js";
11
12
  export * from "./schema-converter.js";
12
13
  export * from "./search-tools.js";
13
14
  export * from "./skill-context.js";
@@ -1,6 +1,10 @@
1
1
  import { createOpenAI } from "@ai-sdk/openai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
3
  import type { LanguageModel } from "ai";
4
+ import {
5
+ getOpenAICodexAccessToken,
6
+ type OpenAICodexAuthConfig,
7
+ } from "./openai-codex-auth.js";
4
8
 
5
9
  export type ModelProviderFactory = (modelName: string) => LanguageModel;
6
10
 
@@ -30,6 +34,33 @@ const MODEL_CONTEXT_WINDOWS: Record<string, number> = {
30
34
  };
31
35
 
32
36
  const DEFAULT_CONTEXT_WINDOW = 200_000;
37
+ const OPENAI_CODEX_DEFAULT_INSTRUCTIONS =
38
+ "You are Codex, based on GPT-5. You are running as a coding agent in Poncho.";
39
+
40
+ const extractSystemInstructionFromInput = (input: unknown): string | undefined => {
41
+ if (!Array.isArray(input)) return undefined;
42
+ for (const message of input) {
43
+ if (!message || typeof message !== "object") continue;
44
+ const candidate = message as { role?: unknown; content?: unknown };
45
+ if (candidate.role !== "system") continue;
46
+ if (typeof candidate.content === "string" && candidate.content.trim().length > 0) {
47
+ return candidate.content;
48
+ }
49
+ if (Array.isArray(candidate.content)) {
50
+ const textParts = candidate.content
51
+ .map((part) => {
52
+ if (!part || typeof part !== "object") return "";
53
+ const p = part as { text?: unknown };
54
+ return typeof p.text === "string" ? p.text : "";
55
+ })
56
+ .filter((text) => text.trim().length > 0);
57
+ if (textParts.length > 0) {
58
+ return textParts.join("\n");
59
+ }
60
+ }
61
+ }
62
+ return undefined;
63
+ };
33
64
 
34
65
  /**
35
66
  * Returns the context window size (in tokens) for a given model name.
@@ -51,6 +82,7 @@ export const getModelContextWindow = (modelName: string): number => {
51
82
 
52
83
  export interface ProviderConfig {
53
84
  openai?: { apiKeyEnv?: string };
85
+ openaiCodex?: OpenAICodexAuthConfig;
54
86
  anthropic?: { apiKeyEnv?: string };
55
87
  }
56
88
 
@@ -62,6 +94,61 @@ export interface ProviderConfig {
62
94
  export const createModelProvider = (provider?: string, config?: ProviderConfig): ModelProviderFactory => {
63
95
  const normalized = (provider ?? "anthropic").toLowerCase();
64
96
 
97
+ if (normalized === "openai-codex") {
98
+ const openai = createOpenAI({
99
+ apiKey: "oauth-placeholder",
100
+ fetch: async (input, init) => {
101
+ const { accessToken, accountId } = await getOpenAICodexAccessToken(config?.openaiCodex);
102
+ const headers = new Headers(init?.headers);
103
+ headers.set("Authorization", `Bearer ${accessToken}`);
104
+ headers.set("originator", "poncho");
105
+ headers.set("User-Agent", "poncho/1.0");
106
+ if (accountId) {
107
+ headers.set("ChatGPT-Account-Id", accountId);
108
+ }
109
+ const originalUrl =
110
+ input instanceof URL
111
+ ? input.toString()
112
+ : typeof input === "string"
113
+ ? input
114
+ : input.url;
115
+ const parsed = new URL(originalUrl);
116
+ const shouldRewrite =
117
+ parsed.pathname.includes("/v1/responses") ||
118
+ parsed.pathname.includes("/chat/completions");
119
+ const targetUrl = shouldRewrite
120
+ ? "https://chatgpt.com/backend-api/codex/responses"
121
+ : originalUrl;
122
+ let body = init?.body;
123
+ if (
124
+ shouldRewrite &&
125
+ typeof body === "string" &&
126
+ headers.get("Content-Type")?.includes("application/json")
127
+ ) {
128
+ try {
129
+ const payload = JSON.parse(body) as {
130
+ instructions?: unknown;
131
+ input?: unknown;
132
+ store?: unknown;
133
+ };
134
+ if (typeof payload.instructions !== "string" || payload.instructions.trim() === "") {
135
+ payload.instructions =
136
+ extractSystemInstructionFromInput(payload.input) ??
137
+ OPENAI_CODEX_DEFAULT_INSTRUCTIONS;
138
+ }
139
+ // Codex endpoint requires store=false explicitly.
140
+ payload.store = false;
141
+ body = JSON.stringify(payload);
142
+ } catch {
143
+ // Keep original body if parsing fails.
144
+ }
145
+ }
146
+ return fetch(targetUrl, { ...init, headers, body });
147
+ },
148
+ });
149
+ return (modelName: string) => openai(modelName);
150
+ }
151
+
65
152
  if (normalized === "openai") {
66
153
  const apiKeyEnv = config?.openai?.apiKeyEnv ?? "OPENAI_API_KEY";
67
154
  const openai = createOpenAI({
@@ -0,0 +1,362 @@
1
+ import { homedir } from "node:os";
2
+ import { dirname, resolve } from "node:path";
3
+ import { mkdir, readFile, chmod, writeFile, rm } from "node:fs/promises";
4
+
5
+ export const OPENAI_CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
6
+ const OPENAI_AUTH_ISSUER = "https://auth.openai.com";
7
+ const REFRESH_TOKEN_GRACE_MS = 5 * 60 * 1000;
8
+ const DEVICE_POLLING_SAFETY_MARGIN_MS = 3000;
9
+ const DEVICE_FLOW_TIMEOUT_MS = 10 * 60 * 1000;
10
+ const REQUIRED_SCOPES = [
11
+ "openid",
12
+ "profile",
13
+ "email",
14
+ "offline_access",
15
+ "api.responses.write",
16
+ "model.request",
17
+ "api.model.read",
18
+ ];
19
+
20
+ export interface OpenAICodexAuthConfig {
21
+ refreshTokenEnv?: string;
22
+ accessTokenEnv?: string;
23
+ accessTokenExpiresAtEnv?: string;
24
+ accountIdEnv?: string;
25
+ authFilePathEnv?: string;
26
+ }
27
+
28
+ export interface OpenAICodexSession {
29
+ refreshToken: string;
30
+ accessToken?: string;
31
+ accessTokenExpiresAt?: number;
32
+ accountId?: string;
33
+ }
34
+
35
+ interface OpenAICodexStoredSession extends OpenAICodexSession {
36
+ updatedAt: string;
37
+ }
38
+
39
+ type SessionSource = "env" | "file";
40
+
41
+ const defaultedConfig = (
42
+ config?: OpenAICodexAuthConfig,
43
+ ): Required<OpenAICodexAuthConfig> => ({
44
+ refreshTokenEnv: config?.refreshTokenEnv ?? "OPENAI_CODEX_REFRESH_TOKEN",
45
+ accessTokenEnv: config?.accessTokenEnv ?? "OPENAI_CODEX_ACCESS_TOKEN",
46
+ accessTokenExpiresAtEnv:
47
+ config?.accessTokenExpiresAtEnv ?? "OPENAI_CODEX_ACCESS_TOKEN_EXPIRES_AT",
48
+ accountIdEnv: config?.accountIdEnv ?? "OPENAI_CODEX_ACCOUNT_ID",
49
+ authFilePathEnv: config?.authFilePathEnv ?? "OPENAI_CODEX_AUTH_FILE",
50
+ });
51
+
52
+ const parseEpochMillis = (value: string | undefined): number | undefined => {
53
+ if (!value) return undefined;
54
+ const parsed = Number.parseInt(value, 10);
55
+ if (Number.isNaN(parsed) || parsed <= 0) return undefined;
56
+ return parsed;
57
+ };
58
+
59
+ export const getOpenAICodexAuthFilePath = (config?: OpenAICodexAuthConfig): string => {
60
+ const env = defaultedConfig(config);
61
+ const fromEnv = process.env[env.authFilePathEnv];
62
+ if (typeof fromEnv === "string" && fromEnv.trim().length > 0) {
63
+ return resolve(fromEnv);
64
+ }
65
+ return resolve(homedir(), ".poncho", "auth", "openai-codex.json");
66
+ };
67
+
68
+ export const readOpenAICodexSession = async (
69
+ config?: OpenAICodexAuthConfig,
70
+ ): Promise<OpenAICodexSession | undefined> => {
71
+ const filePath = getOpenAICodexAuthFilePath(config);
72
+ try {
73
+ const content = await readFile(filePath, "utf8");
74
+ const parsed = JSON.parse(content) as Partial<OpenAICodexStoredSession>;
75
+ if (typeof parsed.refreshToken !== "string" || parsed.refreshToken.length === 0) {
76
+ return undefined;
77
+ }
78
+ return {
79
+ refreshToken: parsed.refreshToken,
80
+ accessToken:
81
+ typeof parsed.accessToken === "string" && parsed.accessToken.length > 0
82
+ ? parsed.accessToken
83
+ : undefined,
84
+ accessTokenExpiresAt:
85
+ typeof parsed.accessTokenExpiresAt === "number" && parsed.accessTokenExpiresAt > 0
86
+ ? parsed.accessTokenExpiresAt
87
+ : undefined,
88
+ accountId:
89
+ typeof parsed.accountId === "string" && parsed.accountId.length > 0
90
+ ? parsed.accountId
91
+ : undefined,
92
+ };
93
+ } catch {
94
+ return undefined;
95
+ }
96
+ };
97
+
98
+ export const writeOpenAICodexSession = async (
99
+ session: OpenAICodexSession,
100
+ config?: OpenAICodexAuthConfig,
101
+ ): Promise<void> => {
102
+ const filePath = getOpenAICodexAuthFilePath(config);
103
+ await mkdir(dirname(filePath), { recursive: true });
104
+ const payload: OpenAICodexStoredSession = {
105
+ ...session,
106
+ updatedAt: new Date().toISOString(),
107
+ };
108
+ await writeFile(filePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
109
+ await chmod(filePath, 0o600);
110
+ };
111
+
112
+ export const deleteOpenAICodexSession = async (
113
+ config?: OpenAICodexAuthConfig,
114
+ ): Promise<void> => {
115
+ const filePath = getOpenAICodexAuthFilePath(config);
116
+ await rm(filePath, { force: true });
117
+ };
118
+
119
+ interface TokenResponse {
120
+ access_token: string;
121
+ refresh_token?: string;
122
+ expires_in?: number;
123
+ id_token?: string;
124
+ }
125
+
126
+ interface DeviceStartResponse {
127
+ device_auth_id: string;
128
+ user_code: string;
129
+ interval?: string | number;
130
+ }
131
+
132
+ interface DeviceTokenResponse {
133
+ authorization_code: string;
134
+ code_verifier: string;
135
+ }
136
+
137
+ const parseAccountIdFromJwt = (token: string | undefined): string | undefined => {
138
+ if (!token) return undefined;
139
+ const parts = token.split(".");
140
+ if (parts.length !== 3) return undefined;
141
+ try {
142
+ const claims = JSON.parse(Buffer.from(parts[1] ?? "", "base64url").toString("utf8")) as {
143
+ chatgpt_account_id?: string;
144
+ "https://api.openai.com/auth"?: {
145
+ chatgpt_account_id?: string;
146
+ };
147
+ organizations?: Array<{ id?: string }>;
148
+ };
149
+ return (
150
+ claims.chatgpt_account_id ??
151
+ claims["https://api.openai.com/auth"]?.chatgpt_account_id ??
152
+ claims.organizations?.[0]?.id
153
+ );
154
+ } catch {
155
+ return undefined;
156
+ }
157
+ };
158
+
159
+ const exchangeRefreshToken = async (refreshToken: string): Promise<TokenResponse> => {
160
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
163
+ body: new URLSearchParams({
164
+ grant_type: "refresh_token",
165
+ refresh_token: refreshToken,
166
+ client_id: OPENAI_CODEX_CLIENT_ID,
167
+ }).toString(),
168
+ });
169
+
170
+ if (!response.ok) {
171
+ const body = await response.text();
172
+ throw new Error(
173
+ `OpenAI Codex token refresh failed (${response.status}). Re-run \`poncho auth login --provider openai-codex --device\`, export the new token, and update deployment secrets. Details: ${body.slice(0, 240)}`,
174
+ );
175
+ }
176
+
177
+ return (await response.json()) as TokenResponse;
178
+ };
179
+
180
+ const exchangeAuthorizationCode = async (
181
+ code: string,
182
+ codeVerifier: string,
183
+ ): Promise<TokenResponse> => {
184
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
185
+ method: "POST",
186
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
187
+ body: new URLSearchParams({
188
+ grant_type: "authorization_code",
189
+ code,
190
+ redirect_uri: `${OPENAI_AUTH_ISSUER}/deviceauth/callback`,
191
+ client_id: OPENAI_CODEX_CLIENT_ID,
192
+ code_verifier: codeVerifier,
193
+ }).toString(),
194
+ });
195
+ if (!response.ok) {
196
+ const body = await response.text();
197
+ throw new Error(
198
+ `OpenAI Codex device token exchange failed (${response.status}): ${body.slice(0, 240)}`,
199
+ );
200
+ }
201
+ return (await response.json()) as TokenResponse;
202
+ };
203
+
204
+ const readSessionFromEnv = (config?: OpenAICodexAuthConfig): OpenAICodexSession | undefined => {
205
+ const env = defaultedConfig(config);
206
+ const refreshToken = process.env[env.refreshTokenEnv];
207
+ if (!refreshToken || refreshToken.trim().length === 0) return undefined;
208
+ return {
209
+ refreshToken: refreshToken.trim(),
210
+ accessToken: process.env[env.accessTokenEnv]?.trim() || undefined,
211
+ accessTokenExpiresAt: parseEpochMillis(process.env[env.accessTokenExpiresAtEnv]),
212
+ accountId: process.env[env.accountIdEnv]?.trim() || undefined,
213
+ };
214
+ };
215
+
216
+ const shouldRefresh = (expiresAt: number | undefined): boolean =>
217
+ !expiresAt || Date.now() + REFRESH_TOKEN_GRACE_MS >= expiresAt;
218
+
219
+ let runtimeCachedSession: OpenAICodexSession | undefined;
220
+
221
+ const readSession = async (
222
+ config?: OpenAICodexAuthConfig,
223
+ ): Promise<{ session: OpenAICodexSession; source: SessionSource }> => {
224
+ const envSession = readSessionFromEnv(config);
225
+ if (envSession) return { session: envSession, source: "env" };
226
+
227
+ if (runtimeCachedSession) {
228
+ return { session: runtimeCachedSession, source: "file" };
229
+ }
230
+
231
+ const fileSession = await readOpenAICodexSession(config);
232
+ if (!fileSession) {
233
+ throw new Error(
234
+ "OpenAI Codex credentials not found. Run `poncho auth login --provider openai-codex --device` locally, or set OPENAI_CODEX_REFRESH_TOKEN in your environment.",
235
+ );
236
+ }
237
+ runtimeCachedSession = fileSession;
238
+ return { session: fileSession, source: "file" };
239
+ };
240
+
241
+ export const getOpenAICodexAccessToken = async (
242
+ config?: OpenAICodexAuthConfig,
243
+ ): Promise<{ accessToken: string; accountId?: string }> => {
244
+ const { session, source } = await readSession(config);
245
+ if (session.accessToken && !shouldRefresh(session.accessTokenExpiresAt)) {
246
+ return { accessToken: session.accessToken, accountId: session.accountId };
247
+ }
248
+
249
+ const refreshed = await exchangeRefreshToken(session.refreshToken);
250
+ const nextSession: OpenAICodexSession = {
251
+ refreshToken: refreshed.refresh_token ?? session.refreshToken,
252
+ accessToken: refreshed.access_token,
253
+ accessTokenExpiresAt: Date.now() + (refreshed.expires_in ?? 3600) * 1000,
254
+ accountId:
255
+ session.accountId ??
256
+ parseAccountIdFromJwt(refreshed.id_token) ??
257
+ parseAccountIdFromJwt(refreshed.access_token),
258
+ };
259
+
260
+ runtimeCachedSession = nextSession;
261
+ if (source === "file") {
262
+ await writeOpenAICodexSession(nextSession, config);
263
+ }
264
+
265
+ return {
266
+ accessToken: nextSession.accessToken!,
267
+ accountId: nextSession.accountId,
268
+ };
269
+ };
270
+
271
+ export interface OpenAICodexDeviceAuthRequest {
272
+ deviceAuthId: string;
273
+ userCode: string;
274
+ verificationUrl: string;
275
+ intervalMs: number;
276
+ }
277
+
278
+ export const getOpenAICodexRequiredScopes = (): string[] => [...REQUIRED_SCOPES];
279
+
280
+ export const startOpenAICodexDeviceAuth = async (): Promise<OpenAICodexDeviceAuthRequest> => {
281
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/usercode`, {
282
+ method: "POST",
283
+ headers: {
284
+ "Content-Type": "application/json",
285
+ "User-Agent": "poncho/1.0",
286
+ },
287
+ body: JSON.stringify({
288
+ client_id: OPENAI_CODEX_CLIENT_ID,
289
+ scope: REQUIRED_SCOPES.join(" "),
290
+ }),
291
+ });
292
+ if (!response.ok) {
293
+ const body = await response.text();
294
+ throw new Error(
295
+ `OpenAI Codex device authorization start failed (${response.status}): ${body.slice(0, 240)}`,
296
+ );
297
+ }
298
+ const data = (await response.json()) as DeviceStartResponse;
299
+ const intervalRaw =
300
+ typeof data.interval === "number"
301
+ ? data.interval
302
+ : Number.parseInt(String(data.interval ?? "5"), 10);
303
+ const intervalMs = Math.max(Number.isNaN(intervalRaw) ? 5 : intervalRaw, 1) * 1000;
304
+ return {
305
+ deviceAuthId: data.device_auth_id,
306
+ userCode: data.user_code,
307
+ verificationUrl: `${OPENAI_AUTH_ISSUER}/codex/device`,
308
+ intervalMs,
309
+ };
310
+ };
311
+
312
+ export const completeOpenAICodexDeviceAuth = async (
313
+ request: OpenAICodexDeviceAuthRequest,
314
+ ): Promise<OpenAICodexSession> => {
315
+ const startedAt = Date.now();
316
+ while (Date.now() - startedAt < DEVICE_FLOW_TIMEOUT_MS) {
317
+ const response = await fetch(`${OPENAI_AUTH_ISSUER}/api/accounts/deviceauth/token`, {
318
+ method: "POST",
319
+ headers: {
320
+ "Content-Type": "application/json",
321
+ "User-Agent": "poncho/1.0",
322
+ },
323
+ body: JSON.stringify({
324
+ device_auth_id: request.deviceAuthId,
325
+ user_code: request.userCode,
326
+ }),
327
+ });
328
+ if (response.ok) {
329
+ const data = (await response.json()) as DeviceTokenResponse;
330
+ const tokens = await exchangeAuthorizationCode(
331
+ data.authorization_code,
332
+ data.code_verifier,
333
+ );
334
+ if (!tokens.refresh_token || tokens.refresh_token.length === 0) {
335
+ throw new Error("OpenAI Codex device auth succeeded but no refresh token was returned.");
336
+ }
337
+ return {
338
+ refreshToken: tokens.refresh_token,
339
+ accessToken: tokens.access_token,
340
+ accessTokenExpiresAt: Date.now() + (tokens.expires_in ?? 3600) * 1000,
341
+ accountId:
342
+ parseAccountIdFromJwt(tokens.id_token) ??
343
+ parseAccountIdFromJwt(tokens.access_token),
344
+ };
345
+ }
346
+
347
+ if (response.status !== 403 && response.status !== 404) {
348
+ const body = await response.text();
349
+ throw new Error(
350
+ `OpenAI Codex device authorization polling failed (${response.status}): ${body.slice(0, 240)}`,
351
+ );
352
+ }
353
+
354
+ await new Promise((resolveWait) => {
355
+ setTimeout(resolveWait, request.intervalMs + DEVICE_POLLING_SAFETY_MARGIN_MS);
356
+ });
357
+ }
358
+
359
+ throw new Error(
360
+ "OpenAI Codex device authorization timed out. Re-run `poncho auth login --provider openai-codex --device`.",
361
+ );
362
+ };
package/src/state.ts CHANGED
@@ -71,6 +71,10 @@ export interface Conversation {
71
71
  /** Harness-internal message chain preserved across continuation runs.
72
72
  * Cleared when a run completes without continuation. */
73
73
  _continuationMessages?: Message[];
74
+ /** Number of continuation pickups for the current multi-step run.
75
+ * Reset when a run completes without continuation. Used to enforce
76
+ * a maximum continuation count across all entry points. */
77
+ _continuationCount?: number;
74
78
  /** Full structured message chain from the last harness run, including
75
79
  * tool-call and tool-result messages the model needs for context.
76
80
  * Unlike `_continuationMessages`, this is always set after a run
@@ -20,6 +20,18 @@ Working dir: {{runtime.workingDir}}
20
20
  expect(parsed.body).toContain("# Hello");
21
21
  });
22
22
 
23
+ it("parses provider prefix from model name for openai-codex", () => {
24
+ const parsed = parseAgentMarkdown(`---
25
+ name: codex-agent
26
+ model: openai-codex/gpt-5.3-codex
27
+ ---
28
+
29
+ # Agent
30
+ `);
31
+ expect(parsed.frontmatter.model?.provider).toBe("openai-codex");
32
+ expect(parsed.frontmatter.model?.name).toBe("gpt-5.3-codex");
33
+ });
34
+
23
35
  it("renders mustache runtime and parameter context", () => {
24
36
  const parsed = parseAgentMarkdown(`---
25
37
  name: test-agent