Skip to content

Commit c87aa64

Browse files
committed
refactor: harden runtime error contracts
1 parent 8336f16 commit c87aa64

7 files changed

Lines changed: 156 additions & 17 deletions

File tree

index.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ import {
147147
shouldRefreshToken,
148148
transformRequestForCodex,
149149
} from "./lib/request/fetch-helpers.js";
150+
import {
151+
createDeactivatedWorkspaceError,
152+
createUsageRequestTimeoutError,
153+
DEACTIVATED_WORKSPACE_ERROR_CODE,
154+
isDeactivatedWorkspaceErrorMessage,
155+
} from "./lib/runtime-contracts.js";
150156
import { applyFastSessionDefaults } from "./lib/request/request-transformer.js";
151157
import {
152158
getRateLimitBackoff,
@@ -2088,7 +2094,7 @@ while (attempted.size < Math.max(1, accountCount)) {
20882094
...account,
20892095
flaggedAt: Date.now(),
20902096
flaggedReason: "workspace-deactivated",
2091-
lastError: "deactivated_workspace",
2097+
lastError: DEACTIVATED_WORKSPACE_ERROR_CODE,
20922098
};
20932099
await withFlaggedAccountStorageTransaction(async (current, persist) => {
20942100
const nextStorage: typeof current = {
@@ -2766,15 +2772,15 @@ while (attempted.size < Math.max(1, accountCount)) {
27662772
? (errorBody as { error?: { message?: string } }).error?.message
27672773
: bodyText) || `HTTP ${response.status}`;
27682774
if (isDeactivatedWorkspaceError(errorBody, response.status)) {
2769-
throw new Error("deactivated_workspace");
2775+
throw createDeactivatedWorkspaceError();
27702776
}
27712777
throw new Error(message);
27722778
}
27732779

27742780
lastError = new Error("Codex response did not include quota headers");
27752781
} catch (error) {
27762782
lastError = error instanceof Error ? error : new Error(String(error));
2777-
if (lastError.message === "deactivated_workspace") {
2783+
if (isDeactivatedWorkspaceErrorMessage(lastError.message)) {
27782784
throw lastError;
27792785
}
27802786
}
@@ -3000,7 +3006,7 @@ while (attempted.size < Math.max(1, accountCount)) {
30003006
} catch (error) {
30013007
errors += 1;
30023008
const message = error instanceof Error ? error.message : String(error);
3003-
if (message === "deactivated_workspace") {
3009+
if (isDeactivatedWorkspaceErrorMessage(message)) {
30043010
const flaggedRecord: FlaggedAccountMetadataV1 = {
30053011
...account,
30063012
flaggedAt: Date.now(),
@@ -4465,19 +4471,19 @@ while (attempted.size < Math.max(1, accountCount)) {
44654471
bodyText = (await response.text()).slice(0, usageErrorBodyMaxChars);
44664472
} catch (error) {
44674473
if (isAbortError(error) || controller.signal.aborted) {
4468-
throw new Error("Usage request timed out");
4474+
throw createUsageRequestTimeoutError();
44694475
}
44704476
throw error;
44714477
}
44724478
if (controller.signal.aborted) {
4473-
throw new Error("Usage request timed out");
4479+
throw createUsageRequestTimeoutError();
44744480
}
44754481
throw new Error(sanitizeUsageErrorMessage(response.status, bodyText));
44764482
}
44774483
return (await response.json()) as UsagePayload;
44784484
} catch (error) {
44794485
if (isAbortError(error)) {
4480-
throw new Error("Usage request timed out");
4486+
throw createUsageRequestTimeoutError();
44814487
}
44824488
throw error;
44834489
} finally {

lib/auth/auth.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import { generatePKCE } from "@openauthjs/openauth/pkce";
22
import { randomBytes } from "node:crypto";
33
import type { PKCEPair, AuthorizationFlow, TokenResult, ParsedAuthInput, JWTPayload } from "../types.js";
44
import { logError } from "../logger.js";
5+
import { OAUTH_CALLBACK_PATH, OAUTH_CALLBACK_PORT } from "../runtime-contracts.js";
56
import { safeParseOAuthTokenResponse } from "../schemas.js";
67

78
// OAuth constants (from openai/codex)
89
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
910
export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
1011
export const TOKEN_URL = "https://auth.openai.com/oauth/token";
11-
export const REDIRECT_URI = "http://localhost:1455/auth/callback";
12+
export const REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`;
1213
export const SCOPE = "openid profile email offline_access";
1314

1415
/**

lib/auth/server.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ import path from "node:path";
44
import { fileURLToPath } from "node:url";
55
import type { OAuthServerInfo } from "../types.js";
66
import { logError, logWarn } from "../logger.js";
7+
import {
8+
OAUTH_CALLBACK_BIND_URL,
9+
OAUTH_CALLBACK_LOOPBACK_HOST,
10+
OAUTH_CALLBACK_PATH,
11+
OAUTH_CALLBACK_PORT,
12+
} from "../runtime-contracts.js";
713

814
// Resolve path to oauth-success.html (one level up from auth/ subfolder)
915
const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -19,7 +25,7 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise<OAu
1925
const server = http.createServer((req, res) => {
2026
try {
2127
const url = new URL(req.url || "", "http://localhost");
22-
if (url.pathname !== "/auth/callback") {
28+
if (url.pathname !== OAUTH_CALLBACK_PATH) {
2329
res.statusCode = 404;
2430
res.end("Not found");
2531
return;
@@ -53,9 +59,9 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise<OAu
5359

5460
return new Promise((resolve) => {
5561
server
56-
.listen(1455, "127.0.0.1", () => {
62+
.listen(OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_LOOPBACK_HOST, () => {
5763
resolve({
58-
port: 1455,
64+
port: OAUTH_CALLBACK_PORT,
5965
ready: true,
6066
close: () => {
6167
pollAborted = true;
@@ -79,10 +85,10 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise<OAu
7985
})
8086
.on("error", (err: NodeJS.ErrnoException) => {
8187
logError(
82-
`Failed to bind http://127.0.0.1:1455 (${err?.code}). Suggest device code or manual URL paste.`,
88+
`Failed to bind ${OAUTH_CALLBACK_BIND_URL} (${err?.code}). Suggest device code or manual URL paste.`,
8389
);
8490
resolve({
85-
port: 1455,
91+
port: OAUTH_CALLBACK_PORT,
8692
ready: false,
8793
close: () => {
8894
pollAborted = true;

lib/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export * from "./accounts.js";
22
export * from "./storage.js";
33
export * from "./config.js";
44
export * from "./constants.js";
5+
export * from "./runtime-contracts.js";
56
export * from "./types.js";
67
export * from "./logger.js";
78
export * from "./auth/auth.js";

lib/request/fetch-helpers.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { transformRequestBody, normalizeModel } from "./request-transformer.js";
1111
import { convertSseToJson, ensureContentType } from "./response-handler.js";
1212
import type { UserConfig, RequestBody } from "../types.js";
1313
import { CodexAuthError } from "../errors.js";
14+
import { DEACTIVATED_WORKSPACE_ERROR_CODE } from "../runtime-contracts.js";
1415
import { isRecord } from "../utils.js";
1516
import {
1617
CODEX_BASE_URL,
@@ -279,8 +280,6 @@ export interface ErrorDiagnostics {
279280
httpStatus?: number;
280281
}
281282

282-
const DEACTIVATED_WORKSPACE_CODE = "deactivated_workspace";
283-
284283
function getStructuredErrorCode(errorBody: unknown): string | undefined {
285284
if (!isRecord(errorBody)) return undefined;
286285

@@ -305,7 +304,7 @@ function getStructuredErrorCode(errorBody: unknown): string | undefined {
305304
export function isDeactivatedWorkspaceError(errorBody: unknown, status?: number): boolean {
306305
if (status !== undefined && status !== 402) return false;
307306
const code = getStructuredErrorCode(errorBody);
308-
return code === DEACTIVATED_WORKSPACE_CODE;
307+
return code === DEACTIVATED_WORKSPACE_ERROR_CODE;
309308
}
310309

311310
/**
@@ -765,7 +764,7 @@ function normalizeErrorPayload(
765764
message:
766765
"The selected ChatGPT workspace is deactivated. This workspace entry should be removed from rotation or re-authorized before retrying.",
767766
type: "workspace_deactivated",
768-
code: DEACTIVATED_WORKSPACE_CODE,
767+
code: DEACTIVATED_WORKSPACE_ERROR_CODE,
769768
},
770769
};
771770
if (diagnostics && Object.keys(diagnostics).length > 0) {

lib/runtime-contracts.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export const OAUTH_CALLBACK_LOOPBACK_HOST = "127.0.0.1";
2+
export const OAUTH_CALLBACK_PORT = 1455;
3+
export const OAUTH_CALLBACK_PATH = "/auth/callback";
4+
export const OAUTH_CALLBACK_BIND_URL = `http://${OAUTH_CALLBACK_LOOPBACK_HOST}:${OAUTH_CALLBACK_PORT}`;
5+
6+
export const DEACTIVATED_WORKSPACE_ERROR_CODE = "deactivated_workspace";
7+
export const USAGE_REQUEST_TIMEOUT_MESSAGE = "Usage request timed out";
8+
9+
export function createDeactivatedWorkspaceError(): Error {
10+
return new Error(DEACTIVATED_WORKSPACE_ERROR_CODE);
11+
}
12+
13+
export function isDeactivatedWorkspaceErrorMessage(message: string | undefined): boolean {
14+
return message === DEACTIVATED_WORKSPACE_ERROR_CODE;
15+
}
16+
17+
export function createUsageRequestTimeoutError(): Error {
18+
return new Error(USAGE_REQUEST_TIMEOUT_MESSAGE);
19+
}

test/runtime-contracts.test.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { readFileSync } from "node:fs";
2+
import path from "node:path";
3+
import { fileURLToPath } from "node:url";
4+
import { describe, expect, it } from "vitest";
5+
import { REDIRECT_URI } from "../lib/auth/auth.js";
6+
import { transformRequestBody } from "../lib/request/request-transformer.js";
7+
import {
8+
createDeactivatedWorkspaceError,
9+
createUsageRequestTimeoutError,
10+
DEACTIVATED_WORKSPACE_ERROR_CODE,
11+
isDeactivatedWorkspaceErrorMessage,
12+
OAUTH_CALLBACK_BIND_URL,
13+
OAUTH_CALLBACK_PATH,
14+
OAUTH_CALLBACK_PORT,
15+
USAGE_REQUEST_TIMEOUT_MESSAGE,
16+
} from "../lib/runtime-contracts.js";
17+
import type { RequestBody, UserConfig } from "../lib/types.js";
18+
19+
const testDir = path.dirname(fileURLToPath(import.meta.url));
20+
21+
function readRepoFile(relativePath: string): string {
22+
return readFileSync(path.resolve(testDir, "..", relativePath), "utf8");
23+
}
24+
25+
describe("runtime contracts", () => {
26+
it("creates stable sentinel errors for workspace deactivation and usage timeouts", () => {
27+
const deactivatedWorkspaceError = createDeactivatedWorkspaceError();
28+
const usageTimeoutError = createUsageRequestTimeoutError();
29+
30+
expect(deactivatedWorkspaceError.message).toBe(DEACTIVATED_WORKSPACE_ERROR_CODE);
31+
expect(isDeactivatedWorkspaceErrorMessage(deactivatedWorkspaceError.message)).toBe(true);
32+
expect(isDeactivatedWorkspaceErrorMessage("workspace-deactivated")).toBe(false);
33+
34+
expect(usageTimeoutError.message).toBe(USAGE_REQUEST_TIMEOUT_MESSAGE);
35+
});
36+
37+
it("keeps the OAuth callback runtime values aligned", () => {
38+
expect(OAUTH_CALLBACK_PORT).toBe(1455);
39+
expect(OAUTH_CALLBACK_PATH).toBe("/auth/callback");
40+
expect(OAUTH_CALLBACK_BIND_URL).toBe(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}`);
41+
expect(REDIRECT_URI).toBe(`http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`);
42+
});
43+
44+
it("keeps the documented stateless request contract aligned with the runtime transform", async () => {
45+
const requestBody: RequestBody = {
46+
model: "gpt-5",
47+
input: [
48+
{
49+
type: "message",
50+
role: "user",
51+
content: [{ type: "input_text", text: "quota ping" }],
52+
},
53+
],
54+
};
55+
const userConfig: UserConfig = { global: {}, models: {} };
56+
57+
const transformedBody = await transformRequestBody(requestBody, "test instructions", userConfig);
58+
59+
expect(transformedBody.store).toBe(false);
60+
expect(transformedBody.include).toContain("reasoning.encrypted_content");
61+
62+
const docsExpectations: Array<[string, string[]]> = [
63+
[
64+
"docs/getting-started.md",
65+
[
66+
`http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`,
67+
"`store: false`",
68+
"`reasoning.encrypted_content`",
69+
],
70+
],
71+
[
72+
"docs/configuration.md",
73+
[
74+
"`reasoning.encrypted_content`",
75+
"store\": false",
76+
],
77+
],
78+
[
79+
"docs/development/ARCHITECTURE.md",
80+
[
81+
"`store: false`",
82+
"`reasoning.encrypted_content`",
83+
],
84+
],
85+
[
86+
"docs/troubleshooting.md",
87+
[
88+
"1455",
89+
"`reasoning.encrypted_content`",
90+
],
91+
],
92+
[
93+
"docs/faq.md",
94+
[
95+
"`1455`",
96+
],
97+
],
98+
];
99+
100+
for (const [relativePath, fragments] of docsExpectations) {
101+
const fileContents = readRepoFile(relativePath);
102+
for (const fragment of fragments) {
103+
expect(fileContents).toContain(fragment);
104+
}
105+
}
106+
});
107+
});

0 commit comments

Comments
 (0)