Skip to content
Closed
Show file tree
Hide file tree
Changes from all 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