@poncho-ai/harness 0.30.0 → 0.31.1
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/.turbo/turbo-lint.log +6 -0
- package/.turbo/turbo-test.log +34 -0
- package/CHANGELOG.md +19 -0
- package/dist/index.d.ts +41 -1
- package/dist/index.js +430 -51
- package/package.json +2 -2
- package/src/agent-parser.ts +1 -1
- package/src/config.ts +7 -0
- package/src/harness.ts +2 -0
- package/src/index.ts +1 -0
- package/src/model-factory.ts +120 -0
- package/src/openai-codex-auth.ts +362 -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.1",
|
|
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,
|
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,51 @@ 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
|
+
const OPENAI_CODEX_RESPONSES_URL =
|
|
40
|
+
process.env.OPENAI_CODEX_RESPONSES_URL ?? "https://chatgpt.com/backend-api/codex/responses";
|
|
41
|
+
|
|
42
|
+
const extractSystemInstructionFromInput = (input: unknown): string | undefined => {
|
|
43
|
+
if (!Array.isArray(input)) return undefined;
|
|
44
|
+
for (const message of input) {
|
|
45
|
+
if (!message || typeof message !== "object") continue;
|
|
46
|
+
const candidate = message as { role?: unknown; content?: unknown };
|
|
47
|
+
if (candidate.role !== "system") continue;
|
|
48
|
+
if (typeof candidate.content === "string" && candidate.content.trim().length > 0) {
|
|
49
|
+
return candidate.content;
|
|
50
|
+
}
|
|
51
|
+
if (Array.isArray(candidate.content)) {
|
|
52
|
+
const textParts = candidate.content
|
|
53
|
+
.map((part) => {
|
|
54
|
+
if (!part || typeof part !== "object") return "";
|
|
55
|
+
const p = part as { text?: unknown };
|
|
56
|
+
return typeof p.text === "string" ? p.text : "";
|
|
57
|
+
})
|
|
58
|
+
.filter((text) => text.trim().length > 0);
|
|
59
|
+
if (textParts.length > 0) {
|
|
60
|
+
return textParts.join("\n");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return undefined;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const normalizeToolParameterSchemas = (tools: unknown): void => {
|
|
68
|
+
if (!Array.isArray(tools)) return;
|
|
69
|
+
for (const tool of tools) {
|
|
70
|
+
if (!tool || typeof tool !== "object") continue;
|
|
71
|
+
const entry = tool as { parameters?: unknown };
|
|
72
|
+
if (!entry.parameters || typeof entry.parameters !== "object") continue;
|
|
73
|
+
const schema = entry.parameters as {
|
|
74
|
+
type?: unknown;
|
|
75
|
+
properties?: unknown;
|
|
76
|
+
};
|
|
77
|
+
if (schema.type === "object" && (typeof schema.properties !== "object" || schema.properties === null)) {
|
|
78
|
+
schema.properties = {};
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
};
|
|
33
82
|
|
|
34
83
|
/**
|
|
35
84
|
* Returns the context window size (in tokens) for a given model name.
|
|
@@ -51,6 +100,7 @@ export const getModelContextWindow = (modelName: string): number => {
|
|
|
51
100
|
|
|
52
101
|
export interface ProviderConfig {
|
|
53
102
|
openai?: { apiKeyEnv?: string };
|
|
103
|
+
openaiCodex?: OpenAICodexAuthConfig;
|
|
54
104
|
anthropic?: { apiKeyEnv?: string };
|
|
55
105
|
}
|
|
56
106
|
|
|
@@ -62,6 +112,76 @@ export interface ProviderConfig {
|
|
|
62
112
|
export const createModelProvider = (provider?: string, config?: ProviderConfig): ModelProviderFactory => {
|
|
63
113
|
const normalized = (provider ?? "anthropic").toLowerCase();
|
|
64
114
|
|
|
115
|
+
if (normalized === "openai-codex") {
|
|
116
|
+
const openai = createOpenAI({
|
|
117
|
+
apiKey: "oauth-placeholder",
|
|
118
|
+
fetch: async (input, init) => {
|
|
119
|
+
const { accessToken, accountId } = await getOpenAICodexAccessToken(config?.openaiCodex);
|
|
120
|
+
const headers = new Headers(init?.headers);
|
|
121
|
+
headers.set("Authorization", `Bearer ${accessToken}`);
|
|
122
|
+
headers.set("originator", "poncho");
|
|
123
|
+
headers.set("User-Agent", "poncho/1.0");
|
|
124
|
+
if (accountId) {
|
|
125
|
+
headers.set("ChatGPT-Account-Id", accountId);
|
|
126
|
+
}
|
|
127
|
+
const originalUrl =
|
|
128
|
+
input instanceof URL
|
|
129
|
+
? input.toString()
|
|
130
|
+
: typeof input === "string"
|
|
131
|
+
? input
|
|
132
|
+
: input.url;
|
|
133
|
+
const parsed = new URL(originalUrl);
|
|
134
|
+
const shouldRewrite =
|
|
135
|
+
parsed.pathname.includes("/v1/responses") ||
|
|
136
|
+
parsed.pathname.includes("/chat/completions");
|
|
137
|
+
const targetUrl = shouldRewrite
|
|
138
|
+
? OPENAI_CODEX_RESPONSES_URL
|
|
139
|
+
: originalUrl;
|
|
140
|
+
let body = init?.body;
|
|
141
|
+
if (
|
|
142
|
+
shouldRewrite &&
|
|
143
|
+
typeof body === "string" &&
|
|
144
|
+
headers.get("Content-Type")?.includes("application/json")
|
|
145
|
+
) {
|
|
146
|
+
try {
|
|
147
|
+
const payload = JSON.parse(body) as {
|
|
148
|
+
instructions?: unknown;
|
|
149
|
+
input?: unknown;
|
|
150
|
+
store?: unknown;
|
|
151
|
+
tools?: unknown;
|
|
152
|
+
};
|
|
153
|
+
if (typeof payload.instructions !== "string" || payload.instructions.trim() === "") {
|
|
154
|
+
payload.instructions =
|
|
155
|
+
extractSystemInstructionFromInput(payload.input) ??
|
|
156
|
+
OPENAI_CODEX_DEFAULT_INSTRUCTIONS;
|
|
157
|
+
}
|
|
158
|
+
normalizeToolParameterSchemas(payload.tools);
|
|
159
|
+
// Codex endpoint requires store=false explicitly.
|
|
160
|
+
payload.store = false;
|
|
161
|
+
body = JSON.stringify(payload);
|
|
162
|
+
} catch {
|
|
163
|
+
// Keep original body if parsing fails.
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
return await fetch(targetUrl, { ...init, headers, body });
|
|
168
|
+
} catch (error) {
|
|
169
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
170
|
+
if (
|
|
171
|
+
shouldRewrite &&
|
|
172
|
+
targetUrl.includes("chatgpt.com") &&
|
|
173
|
+
message.includes("ENOTFOUND chatgpt.com")
|
|
174
|
+
) {
|
|
175
|
+
// Some networks block/override chatgpt.com DNS; retry on the SDK's original URL.
|
|
176
|
+
return fetch(originalUrl, { ...init, headers, body });
|
|
177
|
+
}
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
});
|
|
182
|
+
return (modelName: string) => openai(modelName);
|
|
183
|
+
}
|
|
184
|
+
|
|
65
185
|
if (normalized === "openai") {
|
|
66
186
|
const apiKeyEnv = config?.openai?.apiKeyEnv ?? "OPENAI_API_KEY";
|
|
67
187
|
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
|
+
};
|
|
@@ -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
|
|
@@ -17,7 +17,7 @@ describe("model factory", () => {
|
|
|
17
17
|
|
|
18
18
|
const model = provider("claude-3-opus-20240229");
|
|
19
19
|
expect(model).toBeDefined();
|
|
20
|
-
expect((model as { provider: string }).provider).toMatch(/^anthropic
|
|
20
|
+
expect((model as { provider: string }).provider).toMatch(/^anthropic(?:\.|$)/);
|
|
21
21
|
});
|
|
22
22
|
|
|
23
23
|
it("defaults to Anthropic when no provider specified", () => {
|
|
@@ -26,7 +26,7 @@ describe("model factory", () => {
|
|
|
26
26
|
|
|
27
27
|
const model = provider("claude-3-opus-20240229");
|
|
28
28
|
expect(model).toBeDefined();
|
|
29
|
-
expect((model as { provider: string }).provider).toMatch(/^anthropic
|
|
29
|
+
expect((model as { provider: string }).provider).toMatch(/^anthropic(?:\.|$)/);
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
it("normalizes provider names to lowercase", () => {
|
|
@@ -36,4 +36,14 @@ describe("model factory", () => {
|
|
|
36
36
|
const model = provider("gpt-4");
|
|
37
37
|
expect((model as { provider: string }).provider).toBe("openai.responses");
|
|
38
38
|
});
|
|
39
|
+
|
|
40
|
+
it("creates a function for OpenAI Codex provider", () => {
|
|
41
|
+
process.env.OPENAI_CODEX_REFRESH_TOKEN = "test-refresh-token";
|
|
42
|
+
const provider = createModelProvider("openai-codex");
|
|
43
|
+
expect(provider).toBeInstanceOf(Function);
|
|
44
|
+
|
|
45
|
+
const model = provider("gpt-5.3-codex");
|
|
46
|
+
expect(model).toBeDefined();
|
|
47
|
+
expect((model as { provider: string }).provider).toBe("openai.responses");
|
|
48
|
+
});
|
|
39
49
|
});
|