Skip to content

Commit 33bd4d1

Browse files
authored
Merge pull request #96 from ndycode/refactor/runtime-contract-parity
refactor: harden runtime error handling and contract parity
2 parents 6ccd665 + 939e2c6 commit 33bd4d1

7 files changed

Lines changed: 176 additions & 17 deletions

File tree

index.ts

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ import {
150150
shouldRefreshToken,
151151
transformRequestForCodex,
152152
} from "./lib/request/fetch-helpers.js";
153+
import {
154+
createDeactivatedWorkspaceError,
155+
createUsageRequestTimeoutError,
156+
DEACTIVATED_WORKSPACE_ERROR_CODE,
157+
isDeactivatedWorkspaceErrorMessage,
158+
} from "./lib/runtime-contracts.js";
153159
import { applyFastSessionDefaults } from "./lib/request/request-transformer.js";
154160
import {
155161
getRateLimitBackoff,
@@ -2091,7 +2097,7 @@ while (attempted.size < Math.max(1, accountCount)) {
20912097
...account,
20922098
flaggedAt: Date.now(),
20932099
flaggedReason: "workspace-deactivated",
2094-
lastError: "deactivated_workspace",
2100+
lastError: DEACTIVATED_WORKSPACE_ERROR_CODE,
20952101
};
20962102
await withFlaggedAccountStorageTransaction(async (current, persist) => {
20972103
const nextStorage: typeof current = {
@@ -2769,15 +2775,15 @@ while (attempted.size < Math.max(1, accountCount)) {
27692775
? (errorBody as { error?: { message?: string } }).error?.message
27702776
: bodyText) || `HTTP ${response.status}`;
27712777
if (isDeactivatedWorkspaceError(errorBody, response.status)) {
2772-
throw new Error("deactivated_workspace");
2778+
throw createDeactivatedWorkspaceError();
27732779
}
27742780
throw new Error(message);
27752781
}
27762782

27772783
lastError = new Error("Codex response did not include quota headers");
27782784
} catch (error) {
27792785
lastError = error instanceof Error ? error : new Error(String(error));
2780-
if (lastError.message === "deactivated_workspace") {
2786+
if (isDeactivatedWorkspaceErrorMessage(lastError.message)) {
27812787
throw lastError;
27822788
}
27832789
}
@@ -3003,7 +3009,7 @@ while (attempted.size < Math.max(1, accountCount)) {
30033009
} catch (error) {
30043010
errors += 1;
30053011
const message = error instanceof Error ? error.message : String(error);
3006-
if (message === "deactivated_workspace") {
3012+
if (isDeactivatedWorkspaceErrorMessage(message)) {
30073013
const flaggedRecord: FlaggedAccountMetadataV1 = {
30083014
...account,
30093015
flaggedAt: Date.now(),
@@ -4448,19 +4454,19 @@ while (attempted.size < Math.max(1, accountCount)) {
44484454
bodyText = (await response.text()).slice(0, usageErrorBodyMaxChars);
44494455
} catch (error) {
44504456
if (isAbortError(error) || controller.signal.aborted) {
4451-
throw new Error("Usage request timed out");
4457+
throw createUsageRequestTimeoutError();
44524458
}
44534459
throw error;
44544460
}
44554461
if (controller.signal.aborted) {
4456-
throw new Error("Usage request timed out");
4462+
throw createUsageRequestTimeoutError();
44574463
}
44584464
throw new Error(sanitizeUsageErrorMessage(response.status, bodyText));
44594465
}
44604466
return (await response.json()) as UsagePayload;
44614467
} catch (error) {
44624468
if (isAbortError(error)) {
4463-
throw new Error("Usage request timed out");
4469+
throw createUsageRequestTimeoutError();
44644470
}
44654471
throw error;
44664472
} 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/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: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Shared runtime constants and sentinel helpers only. This module is pure: it
3+
* does not perform I/O, persistence, or logging, so centralizing these values
4+
* does not introduce new Windows lock or token-redaction surfaces.
5+
*/
6+
export const OAUTH_CALLBACK_LOOPBACK_HOST = "127.0.0.1";
7+
export const OAUTH_CALLBACK_PORT = 1455;
8+
export const OAUTH_CALLBACK_PATH = "/auth/callback";
9+
export const OAUTH_CALLBACK_BIND_URL = `http://${OAUTH_CALLBACK_LOOPBACK_HOST}:${OAUTH_CALLBACK_PORT}`;
10+
11+
export const DEACTIVATED_WORKSPACE_ERROR_CODE = "deactivated_workspace";
12+
export const USAGE_REQUEST_TIMEOUT_MESSAGE = "Usage request timed out";
13+
14+
export function createDeactivatedWorkspaceError(): Error {
15+
return new Error(DEACTIVATED_WORKSPACE_ERROR_CODE);
16+
}
17+
18+
export function isDeactivatedWorkspaceErrorMessage(message: string | undefined): boolean {
19+
return message === DEACTIVATED_WORKSPACE_ERROR_CODE;
20+
}
21+
22+
export function createUsageRequestTimeoutError(): Error {
23+
return new Error(USAGE_REQUEST_TIMEOUT_MESSAGE);
24+
}
25+
26+
export function isUsageRequestTimeoutMessage(message: string | undefined): boolean {
27+
return message === USAGE_REQUEST_TIMEOUT_MESSAGE;
28+
}

test/doc-parity.test.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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 { OAUTH_CALLBACK_PATH, OAUTH_CALLBACK_PORT } from "../lib/runtime-contracts.js";
6+
import { transformRequestBody } from "../lib/request/request-transformer.js";
7+
import type { RequestBody, UserConfig } from "../lib/types.js";
8+
9+
const testDir = path.dirname(fileURLToPath(import.meta.url));
10+
11+
function readRepoFile(relativePath: string): string {
12+
try {
13+
return readFileSync(path.resolve(testDir, "..", relativePath), "utf8");
14+
} catch (error) {
15+
const message = error instanceof Error ? error.message : String(error);
16+
throw new Error(`Failed to read ${relativePath}: ${message}`);
17+
}
18+
}
19+
20+
describe("runtime documentation parity", () => {
21+
it("keeps the documented stateless request contract aligned with the runtime transform", async () => {
22+
const requestBody: RequestBody = {
23+
model: "gpt-5",
24+
input: [
25+
{
26+
type: "message",
27+
role: "user",
28+
content: [{ type: "input_text", text: "quota ping" }],
29+
},
30+
],
31+
};
32+
const userConfig: UserConfig = { global: {}, models: {} };
33+
34+
const transformedBody = await transformRequestBody(requestBody, "test instructions", userConfig);
35+
36+
expect(transformedBody.store).toBe(false);
37+
expect(transformedBody.include).toContain("reasoning.encrypted_content");
38+
39+
const docsExpectations: Array<[string, string[]]> = [
40+
[
41+
"docs/getting-started.md",
42+
[
43+
`http://127.0.0.1:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`,
44+
"`store: false`",
45+
"`reasoning.encrypted_content`",
46+
],
47+
],
48+
[
49+
"docs/configuration.md",
50+
[
51+
"`reasoning.encrypted_content`",
52+
"store\": false",
53+
],
54+
],
55+
[
56+
"docs/development/ARCHITECTURE.md",
57+
[
58+
"`store: false`",
59+
"`reasoning.encrypted_content`",
60+
],
61+
],
62+
[
63+
"docs/troubleshooting.md",
64+
[
65+
"1455",
66+
"`reasoning.encrypted_content`",
67+
],
68+
],
69+
[
70+
"docs/faq.md",
71+
[
72+
"`1455`",
73+
],
74+
],
75+
];
76+
77+
for (const [relativePath, fragments] of docsExpectations) {
78+
const fileContents = readRepoFile(relativePath);
79+
for (const fragment of fragments) {
80+
expect(fileContents).toContain(fragment);
81+
}
82+
}
83+
});
84+
});

test/runtime-contracts.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it } from "vitest";
2+
import { REDIRECT_URI } from "../lib/auth/auth.js";
3+
import {
4+
createDeactivatedWorkspaceError,
5+
createUsageRequestTimeoutError,
6+
DEACTIVATED_WORKSPACE_ERROR_CODE,
7+
isDeactivatedWorkspaceErrorMessage,
8+
isUsageRequestTimeoutMessage,
9+
OAUTH_CALLBACK_BIND_URL,
10+
OAUTH_CALLBACK_PATH,
11+
OAUTH_CALLBACK_PORT,
12+
USAGE_REQUEST_TIMEOUT_MESSAGE,
13+
} from "../lib/runtime-contracts.js";
14+
15+
describe("runtime contracts", () => {
16+
it("creates stable sentinel errors for workspace deactivation and usage timeouts", () => {
17+
const deactivatedWorkspaceError = createDeactivatedWorkspaceError();
18+
const usageTimeoutError = createUsageRequestTimeoutError();
19+
20+
expect(deactivatedWorkspaceError.message).toBe(DEACTIVATED_WORKSPACE_ERROR_CODE);
21+
expect(isDeactivatedWorkspaceErrorMessage(deactivatedWorkspaceError.message)).toBe(true);
22+
expect(isDeactivatedWorkspaceErrorMessage("workspace-deactivated")).toBe(false);
23+
24+
expect(usageTimeoutError.message).toBe(USAGE_REQUEST_TIMEOUT_MESSAGE);
25+
expect(isUsageRequestTimeoutMessage(usageTimeoutError.message)).toBe(true);
26+
expect(isUsageRequestTimeoutMessage("request timed out")).toBe(false);
27+
});
28+
29+
it("keeps the OAuth callback runtime values aligned", () => {
30+
expect(OAUTH_CALLBACK_PORT).toBe(1455);
31+
expect(OAUTH_CALLBACK_PATH).toBe("/auth/callback");
32+
expect(OAUTH_CALLBACK_BIND_URL).toBe(`http://127.0.0.1:${OAUTH_CALLBACK_PORT}`);
33+
expect(REDIRECT_URI).toBe(`http://localhost:${OAUTH_CALLBACK_PORT}${OAUTH_CALLBACK_PATH}`);
34+
});
35+
});

0 commit comments

Comments
 (0)