Skip to content
Closed
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,17 @@ all notable changes to this project. dates are ISO format (YYYY-MM-DD).
- **account storage schema**: V3 account metadata now includes optional `accountTags` and `accountNote`.
- **docs refresh for operational flows**: README + docs portal/development guides updated to reflect beginner commands, safe mode, interactive picker behavior, and backup/import safeguards.
- **test matrix expansion**: coverage now includes beginner UI helpers, safe-fix diagnostics edge cases, tag/note command behavior, and timestamped backup/import preview utilities.
- **dependency security baseline**: refreshed lockfile dependency graph via `npm audit fix` to remove all known high/moderate advisories in the audited tree.

### fixed

- **non-interactive command guidance**: optional-index commands provide explicit usage guidance when interactive menus are unavailable.
- **doctor safe-fix edge path**: `codex-doctor fix` now reports a clear non-crashing message when no eligible account is available for auto-switch.
- **first-time import flow**: `codex-import` no longer fails with `No accounts to export` when storage is empty; pre-import backup is skipped cleanly in zero-account setups.
- **oauth callback host alignment**: authorization redirect now uses `http://127.0.0.1:1455/auth/callback` to match the loopback server binding and avoid `localhost` resolver drift.
- **oauth success-page resilience**: callback server now falls back to a built-in success HTML page when `oauth-success.html` is unavailable, preventing hard startup failure.
- **oauth poll contract hardening**: `waitForCode(state)` now verifies the captured callback state before returning code, matching the declared interface contract.
- **hybrid account selection eligibility**: token-bucket depletion is now enforced during hybrid selection/current-account reuse, preventing premature request failures when other accounts remain eligible.

## [5.4.0] - 2026-02-28

Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ Explore the engineering depth behind this plugin:
- **[Config Fields Guide](development/CONFIG_FIELDS.md)** - Understanding config keys, `id`, and `name`
- **[Testing Guide](development/TESTING.md)** - Test scenarios, verification procedures, integration testing
- **[TUI Parity Checklist](development/TUI_PARITY_CHECKLIST.md)** - Auth dashboard/UI parity requirements for future changes
- **[Architecture Audit (2026-02-28)](development/ARCHITECTURE_AUDIT_2026-02-28.md)** - Full security/reliability audit findings and remediation summary

## Key Architectural Decisions

Expand Down
48 changes: 48 additions & 0 deletions docs/development/ARCHITECTURE_AUDIT_2026-02-28.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Architecture + Security Audit (2026-02-28)

## Scope

- Full repository audit across auth, request pipeline, account rotation, storage, and dependency supply chain.
- Severity focus: Critical, High, Medium.
- Remediation PR policy: fix-in-place for findings above threshold.

## Findings and Remediations

### 1) Dependency Vulnerabilities (High/Moderate)

- Baseline `npm audit` reported 4 vulnerabilities (3 high, 1 moderate), including direct `hono` exposure plus transitive `rollup`, `minimatch`, and `ajv`.
- Remediation: ran `npm audit fix`, updated lockfile graph, and verified `npm audit` reports zero vulnerabilities.

### 2) OAuth Loopback Host Mismatch (Medium)

- OAuth redirect URI used `localhost` while callback listener binds to `127.0.0.1`.
- On environments where `localhost` resolves to non-IPv4 loopback, this can cause callback failures.
- Remediation: aligned redirect URI to `http://127.0.0.1:1455/auth/callback`.

### 3) Hybrid Selection vs Token-Bucket Eligibility Mismatch (Medium)

- Hybrid account selection and current-account fast path did not enforce token availability.
- This could pick accounts that are locally token-depleted and trigger avoidable request failure behavior.
- Remediation:
- enforce token availability during current-account reuse and hybrid eligibility filtering;
- continue account traversal when local token consumption fails to avoid premature loop exit.

### 4) OAuth Success-Page Single-Point Failure (Medium)

- OAuth callback server loaded `oauth-success.html` synchronously at module import with no fallback.
- If that asset was missing in a runtime package edge case, plugin startup could fail before auth flow execution.
- Remediation:
- add resilient loader with warning telemetry;
- serve a built-in minimal success page when file load fails.
- enforce `waitForCode(state)` contract by checking captured callback state before returning a code.

## Verification

- `npm run lint` pass
- `npm run typecheck` pass
- `npm test` pass
- `npm audit` reports zero vulnerabilities

## Notes

- This audit focused on root-cause correctness and supply-chain risk reduction, while preserving existing plugin APIs and storage format compatibility.
8 changes: 4 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -388,9 +388,9 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
instructions: AUTH_LABELS.INSTRUCTIONS_MANUAL,
validate: (input: string): string | undefined => {
const parsed = parseAuthorizationInput(input);
if (!parsed.code) {
return "No authorization code found. Paste the full callback URL (e.g., http://localhost:1455/auth/callback?code=...)";
}
if (!parsed.code) {
return "No authorization code found. Paste the full callback URL (e.g., http://127.0.0.1:1455/auth/callback?code=...)";
}
if (!parsed.state) {
return "Missing OAuth state. Paste the full callback URL including both code and state parameters.";
}
Expand Down Expand Up @@ -2336,7 +2336,7 @@ while (attempted.size < Math.max(1, accountCount)) {
logWarn(
`Skipping account ${account.index + 1}: local token bucket depleted for ${modelFamily}${model ? `:${model}` : ""}`,
);
break;
continue;
}

while (true) {
Expand Down
15 changes: 9 additions & 6 deletions lib/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,9 @@ export class AccountManager {
getCurrentOrNextForFamilyHybrid(family: ModelFamily, model?: string | null, options?: HybridSelectionOptions): ManagedAccount | null {
const count = this.accounts.length;
if (count === 0) return null;
const quotaKey = model ? `${family}:${model}` : family;
const healthTracker = getHealthTracker();
const tokenTracker = getTokenTracker();

const currentIndex = this.currentAccountIndexByFamily[family];
if (currentIndex >= 0 && currentIndex < count) {
Expand All @@ -582,7 +585,8 @@ export class AccountManager {
clearExpiredRateLimits(currentAccount);
if (
!isRateLimitedForFamily(currentAccount, family, model) &&
!this.isAccountCoolingDown(currentAccount)
!this.isAccountCoolingDown(currentAccount) &&
tokenTracker.getTokens(currentAccount.index, quotaKey) >= 1
) {
currentAccount.lastUsed = nowMs();
return currentAccount;
Expand All @@ -591,17 +595,16 @@ export class AccountManager {
}
}

const quotaKey = model ? `${family}:${model}` : family;
const healthTracker = getHealthTracker();
const tokenTracker = getTokenTracker();

const accountsWithMetrics: AccountWithMetrics[] = this.accounts
.map((account): AccountWithMetrics | null => {
if (!account) return null;
if (account.enabled === false) return null;
clearExpiredRateLimits(account);
const tokensAvailable = tokenTracker.getTokens(account.index, quotaKey);
const isAvailable =
!isRateLimitedForFamily(account, family, model) && !this.isAccountCoolingDown(account);
!isRateLimitedForFamily(account, family, model) &&
!this.isAccountCoolingDown(account) &&
tokensAvailable >= 1;
return {
index: account.index,
isAvailable,
Expand Down
2 changes: 1 addition & 1 deletion lib/auth/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { safeParseOAuthTokenResponse } from "../schemas.js";
export const CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
export const AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize";
export const TOKEN_URL = "https://auth.openai.com/oauth/token";
export const REDIRECT_URI = "http://localhost:1455/auth/callback";
export const REDIRECT_URI = "http://127.0.0.1:1455/auth/callback";
export const SCOPE = "openid profile email offline_access";

/**
Expand Down
39 changes: 34 additions & 5 deletions lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,32 @@ import { logError, logWarn } from "../logger.js";

// Resolve path to oauth-success.html (one level up from auth/ subfolder)
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const successHtml = fs.readFileSync(path.join(__dirname, "..", "oauth-success.html"), "utf-8");
const SUCCESS_HTML_PATH = path.join(__dirname, "..", "oauth-success.html");
const FALLBACK_SUCCESS_HTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>Authorization Complete</title>
</head>
<body>
<h1>Authorization complete</h1>
<p>You can return to OpenCode.</p>
</body>
</html>`;

function loadSuccessHtml(): string {
try {
return fs.readFileSync(SUCCESS_HTML_PATH, "utf-8");
} catch (error) {
logWarn("oauth-success.html missing; using fallback success page", {
path: SUCCESS_HTML_PATH,
error: (error as Error)?.message ?? String(error),
});
return FALLBACK_SUCCESS_HTML;
}
}

const successHtml = loadSuccessHtml();

/**
* Start a small local HTTP server that waits for /auth/callback and returns the code
Expand Down Expand Up @@ -41,7 +66,9 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise<OAu
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'none'");
res.end(successHtml);
(server as http.Server & { _lastCode?: string })._lastCode = code;
const codeStore = server as http.Server & { _lastCode?: string; _lastState?: string };
codeStore._lastCode = code;
codeStore._lastState = state;
} catch (err) {
logError(`Request handler error: ${(err as Error)?.message ?? String(err)}`);
res.statusCode = 500;
Expand All @@ -61,15 +88,17 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise<OAu
pollAborted = true;
server.close();
},
waitForCode: async () => {
waitForCode: async (_expectedState: string) => {
const POLL_INTERVAL_MS = 100;
const TIMEOUT_MS = 5 * 60 * 1000;
const maxIterations = Math.floor(TIMEOUT_MS / POLL_INTERVAL_MS);
const poll = () => new Promise<void>((r) => setTimeout(r, POLL_INTERVAL_MS));
for (let i = 0; i < maxIterations; i++) {
if (pollAborted) return null;
const lastCode = (server as http.Server & { _lastCode?: string })._lastCode;
if (lastCode) return { code: lastCode };
const codeStore = server as http.Server & { _lastCode?: string; _lastState?: string };
const lastCode = codeStore._lastCode;
const lastState = codeStore._lastState;
if (lastCode && lastState === _expectedState) return { code: lastCode };
await poll();
}
logWarn("OAuth poll timeout after 5 minutes");
Expand Down
Loading