179 lines
6.3 KiB
TypeScript
179 lines
6.3 KiB
TypeScript
import { expect, test } from "vite-plus/test";
|
|
import { mkdir, readFile, rm, stat, symlink, writeFile } from "node:fs/promises";
|
|
|
|
import {
|
|
codexAuthentication,
|
|
isClaudeAuthFailure,
|
|
isCodexAuthFailure,
|
|
persistCodexAuth,
|
|
retryInvalidToken,
|
|
stageCodexAuth,
|
|
} from "./auth.ts";
|
|
import { currentGid, currentUid, IS_WINDOWS, makeTempDir } from "./testing.ts";
|
|
|
|
test("retries once after an invalid refresh token", async () => {
|
|
let attempts = 0;
|
|
let relogins = 0;
|
|
const result = await retryInvalidToken(
|
|
() =>
|
|
Promise.resolve({
|
|
code: 1,
|
|
error: attempts++ === 0 ? "ERROR: invalid_refresh_token" : "still failed",
|
|
}),
|
|
() => {
|
|
relogins++;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
expect({ attempts, relogins, error: result.error }).toStrictEqual({
|
|
attempts: 2,
|
|
relogins: 1,
|
|
error: "still failed",
|
|
});
|
|
});
|
|
|
|
test("does not retry an unrelated failure", async () => {
|
|
let attempts = 0;
|
|
let relogins = 0;
|
|
await retryInvalidToken(
|
|
() => {
|
|
attempts++;
|
|
return Promise.resolve({
|
|
code: 1,
|
|
error: "rate limited",
|
|
});
|
|
},
|
|
() => {
|
|
relogins++;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
expect({ attempts, relogins }).toStrictEqual({ attempts: 1, relogins: 0 });
|
|
});
|
|
|
|
// Verbatim stderr lines from the revoked-login job on lead/memona-home#36.
|
|
const REVOKED_CODEX = [
|
|
`2026-10-12T02:28:04.702126Z ERROR codex_models_manager::manager: failed to refresh available models: unexpected status 401 Unauthorized: Encountered invalidated oauth token for user, failing request, url: https://chatgpt.com/backend-api/codex/models?client_version=0.162.1, cf-ray: a492ab48fdefea25-ICN, request id: req_4caa104fe9184a63bb8877f42817172d, auth error: 401, auth error code: token_revoked`,
|
|
`2026-10-12T02:28:05.082447Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_invalidated"), error_message: Some("Your session has ended. Please log in again."), .. }`,
|
|
`2026-10-12T02:28:05.451678Z ERROR rmcp::transport::worker: worker quit with fatal: Transport channel closed, when UnexpectedServerResponse("HTTP 401: {\\n \\"error\\": {\\n \\"message\\": \\"Encountered invalidated oauth token for user, failing request\\",\\n \\"type\\": null,\\n \\"code\\": \\"token_revoked\\",\\n \\"param\\": null\\n },\\n \\"status\\": 401\\n}")`,
|
|
"ERROR: workspace routing discovery unauthorized (401)",
|
|
];
|
|
|
|
test("recognizes each Codex sign-in failure line", () => {
|
|
for (const line of [
|
|
...REVOKED_CODEX,
|
|
`2026-10-12T02:28:05Z ERROR codex_login::auth::manager: Failed to refresh token status=401 Unauthorized detail=TokenErrorDetail { error_code: Some("refresh_token_expired"), .. }`,
|
|
`ERROR: Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again. (refresh_token_reused)`,
|
|
"ERROR: unexpected status 401 Unauthorized: Incorrect API key provided: sk-inval*d123., auth error: 401, auth error code: invalid_api_key",
|
|
]) {
|
|
expect(isCodexAuthFailure(`exec\n${line}\n`), line).toBe(true);
|
|
}
|
|
});
|
|
|
|
test("ignores auth words outside Codex error lines", () => {
|
|
expect(
|
|
isCodexAuthFailure(
|
|
"exec\ncurl returned 401 Unauthorized\ntoken_revoked in fixture\nERROR: tests failed\n",
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("re-logs in after the job 9837 revoked-session stderr", async () => {
|
|
let relogins = 0;
|
|
let attempts = 0;
|
|
const result = await retryInvalidToken(
|
|
() =>
|
|
Promise.resolve({
|
|
code: attempts++ > 0 ? 0 : 1,
|
|
error: attempts === 1 ? REVOKED_CODEX.join("\n") : "",
|
|
}),
|
|
() => {
|
|
relogins++;
|
|
return Promise.resolve();
|
|
},
|
|
);
|
|
|
|
expect({ relogins, code: result.code }).toStrictEqual({
|
|
relogins: 1,
|
|
code: 0,
|
|
});
|
|
});
|
|
|
|
test("recognizes a rejected Claude OAuth token", () => {
|
|
expect(
|
|
isClaudeAuthFailure({
|
|
api_error_status: 401,
|
|
result: "Failed to authenticate. API Error: 401 OAuth access token is invalid.",
|
|
}),
|
|
).toBe(true);
|
|
expect(
|
|
isClaudeAuthFailure({
|
|
result: "OAuth token has expired. Please obtain a new token or refresh your existing token.",
|
|
}),
|
|
).toBe(true);
|
|
expect(isClaudeAuthFailure({ api_error_status: 429, result: "API Error: 429" })).toBe(false);
|
|
});
|
|
|
|
test("selects API-key login only for a configured Codex token", () => {
|
|
expect(codexAuthentication("selected-key")).toStrictEqual({
|
|
kind: "api-key",
|
|
token: "selected-key",
|
|
});
|
|
expect(codexAuthentication()).toStrictEqual({ kind: "persistent" });
|
|
});
|
|
|
|
const AUTH_TOKENS = {
|
|
access_token: "access",
|
|
refresh_token: "refresh",
|
|
id_token: "id",
|
|
account_id: "account",
|
|
};
|
|
const AUTH = JSON.stringify({ auth_mode: "chatgpt", tokens: AUTH_TOKENS });
|
|
|
|
test.skipIf(IS_WINDOWS)("stages and persists only a regular private Codex login", async () => {
|
|
const root = await makeTempDir();
|
|
try {
|
|
const canonical = `${root}/persistent/auth.json`;
|
|
await mkdir(`${root}/persistent`);
|
|
await writeFile(canonical, AUTH, { mode: 0o600 });
|
|
const home = `${root}/profile`;
|
|
await mkdir(home);
|
|
|
|
const staged = await stageCodexAuth(canonical, home, currentUid(), currentGid());
|
|
expect(await readFile(staged, "utf8")).toStrictEqual(AUTH);
|
|
expect((await stat(staged)).mode & 0o777).toBe(0o600);
|
|
|
|
const refreshed = JSON.stringify({
|
|
auth_mode: "chatgpt",
|
|
tokens: { ...AUTH_TOKENS, access_token: "refreshed" },
|
|
});
|
|
await writeFile(staged, refreshed);
|
|
await persistCodexAuth(staged, canonical);
|
|
expect(await readFile(canonical, "utf8")).toStrictEqual(refreshed);
|
|
expect((await stat(canonical)).mode & 0o777).toBe(0o600);
|
|
} finally {
|
|
await rm(root, { recursive: true });
|
|
}
|
|
});
|
|
|
|
test.skipIf(IS_WINDOWS)("rejects a symlink or malformed Codex login", async () => {
|
|
const root = await makeTempDir();
|
|
try {
|
|
const canonical = `${root}/auth.json`;
|
|
const malformed = `${root}/malformed.json`;
|
|
const linked = `${root}/linked.json`;
|
|
await writeFile(canonical, AUTH, { mode: 0o600 });
|
|
await writeFile(malformed, '{"tokens":{}}', { mode: 0o600 });
|
|
await symlink(canonical, linked);
|
|
|
|
await expect(() => persistCodexAuth(linked, canonical)).rejects.toThrow("regular file");
|
|
await expect(() => persistCodexAuth(malformed, canonical)).rejects.toThrow(
|
|
"invalid Codex login",
|
|
);
|
|
} finally {
|
|
await rm(root, { recursive: true });
|
|
}
|
|
});
|