@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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +24 -0
- package/dist/index.d.ts +45 -1
- package/dist/index.js +466 -57
- package/package.json +2 -2
- package/src/agent-parser.ts +1 -1
- package/src/config.ts +7 -0
- package/src/harness.ts +61 -6
- package/src/index.ts +1 -0
- package/src/model-factory.ts +87 -0
- package/src/openai-codex-auth.ts +362 -0
- package/src/state.ts +4 -0
- package/test/agent-parser.test.ts +12 -0
- package/test/model-factory.test.ts +12 -2
- package/test/openai-codex-auth.test.ts +82 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poncho-ai/harness",
|
|
3
|
-
"version": "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.
|
|
37
|
+
"@poncho-ai/sdk": "1.7.0"
|
|
38
38
|
},
|
|
39
39
|
"devDependencies": {
|
|
40
40
|
"@types/mustache": "^4.2.6",
|
package/src/agent-parser.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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(
|
|
2078
|
-
:
|
|
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),
|
|
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";
|
package/src/model-factory.ts
CHANGED
|
@@ -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
|