Skip to content

docs(providers): table-safe Kiro install pipes in translations; fix live-test account read (follow-up to #1397) - #1450

Merged
Wibias merged 6 commits into
lidge-jun:devfrom
Wibias:codex/nous-portal-oauth-followup
Aug 11, 2026
Merged

docs(providers): table-safe Kiro install pipes in translations; fix live-test account read (follow-up to #1397)#1450
Wibias merged 6 commits into
lidge-jun:devfrom
Wibias:codex/nous-portal-oauth-followup

Conversation

@Wibias

@Wibias Wibias commented Aug 11, 2026

Copy link
Copy Markdown
Collaborator

Follow-up to #1397 (Nous Portal OAuth provider). This branch is #1397's head
(de819e6c4) plus one commit; merge after #1397 so the diff here collapses to
just the incremental fix.

What

Two small fixes that could not be pushed to the PR #1397 fork head (maintainer edits were disabled):

  1. Table-safe Kiro install pipelines in translated provider guides — the literal | characters inside the Kiro CLI install commands broke the markdown tables in the ja/ko/ru/zh-cn provider guides. Replaced with the table-safe | entity, matching the English row content.
  2. Live-test account read fixtests/nous-oauth-live.test.ts called getCredential("nous", accountId) with an excess argument (a type error). It now reads the refreshed account with getAccountCredential("nous", stored!.accountId!).

Verification

  • bun run typecheck
  • bun run privacy:scan
  • bun test tests/nous-oauth-live.test.ts ✅ (opt-in live test skipped as expected)

Intended to merge after #1397. Once #1397 lands on dev, this PR's diff
reduces to the single incremental commit above.

Summary by CodeRabbit

  • New Features

    • Improved Nous Portal OAuth sign-in, token refresh, and device authorization reliability.
    • Preserved newer credentials during concurrent refresh operations.
  • Bug Fixes

    • Improved handling of expired, invalid, or incomplete tokens.
    • Strengthened token expiry validation and Portal connection URL handling.
    • Improved retry behavior for temporary device-login connection failures.
    • Enhanced refresh-intent security and cleanup handling.
  • Documentation

    • Fixed Kiro installation command formatting across localized provider guides so pipe characters render correctly.

@github-actions

Copy link
Copy Markdown
Contributor

Deterministic PR hygiene checks passed.

@github-actions github-actions Bot added the documentation Improvements or additions to documentation label Aug 11, 2026
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Hardened Nous Portal OAuth token validation, device polling, refresh-token rotation recovery, and account-scoped verification. Escaped Kiro command pipe characters in five localized provider guides.

Changes

Nous Portal OAuth

Layer / File(s) Summary
Nous token and device-flow handling
src/oauth/nous.ts, tests/nous-oauth.test.ts
Hardened refresh-intent directory permissions, JWT expiry validation, token error classification, access-token expiry fallback, and device polling retries. Added regression tests for invalid token responses.
OAuth refresh orchestration
src/oauth/index.ts, tests/oauth-refresh.test.ts
Registered Nous with lazy-only refresh handling. Made rotated-token recovery generation-safe and added coverage for a concurrent credential commit race.
Account-scoped integration coverage
tests/nous-oauth-live.test.ts
Updated live refresh verification to derive and use the active account row ID, read credentials through account-scoped APIs, and use NOUS_INFERENCE_BASE_URL.
Provider documentation
docs-site/src/content/docs/guides/providers.md, docs-site/src/content/docs/ja/guides/providers.md, docs-site/src/content/docs/ko/guides/providers.md, docs-site/src/content/docs/ru/guides/providers.md, docs-site/src/content/docs/zh-cn/guides/providers.md
Escaped Kiro Unix and PowerShell pipe characters as |. OAuth provider content remains unchanged.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant OAuthLogin
  participant NousPortal
  participant OAuthRefresh
  participant CredentialStore
  OAuthLogin->>NousPortal: poll for device token
  NousPortal-->>OAuthLogin: return credentials
  OAuthRefresh->>CredentialStore: persist refresh intent
  OAuthRefresh->>NousPortal: submit refresh token
  NousPortal-->>OAuthRefresh: return rotated credentials
  OAuthRefresh->>CredentialStore: commit exact credential generation
  OAuthRefresh->>CredentialStore: clean up refresh intent
Loading

Possibly related PRs

Suggested labels: bug

Suggested reviewers: lidge-jun, ingwannu

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 35.71% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately identifies the table-safe Kiro installation changes and the live-test account-read fix, which match the pull request objectives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…ive-test account read

- ja/ko/ru/zh-cn kiro rows: replace literal pipe characters in the Kiro CLI
  install pipelines with table-safe &lidge-jun#124; entities so the markdown tables
  render correctly.
- nous-oauth-live.test.ts: read the refreshed account with
  getAccountCredential('nous', accountId) instead of passing an excess
  accountId argument to getCredential (fixes the type error).
@Wibias
Wibias force-pushed the codex/nous-portal-oauth-followup branch from 9d78f2d to 25d462f Compare August 11, 2026 05:14

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-site/src/content/docs/guides/providers.md`:
- Line 117: Update the Kiro table row to replace its raw pipe characters inside
inline commands or URLs with the table-safe entity used by the localized
provider rows, while preserving the displayed installation instructions and
table column structure.
- Line 60: Update the OAuth provider inventory to include command-code in
docs-site/src/content/docs/guides/providers.md:60,
docs-site/src/content/docs/ja/guides/providers.md:55,
docs-site/src/content/docs/ko/guides/providers.md:54,
docs-site/src/content/docs/ru/guides/providers.md:63, and
docs-site/src/content/docs/zh-cn/guides/providers.md:51. Do not add chatgpt,
since its configuration uses forward authentication.

In `@src/oauth/index.ts`:
- Around line 631-641: Remove the inline provider === "nous" branches from
refreshGenericAccountWithLock and move both behaviors onto OAuthProviderDef as
optional onRotationPersisted and rotatedRefreshFromError hooks. Configure the
nous provider definition with clearNousRefreshIntent and NousTokenError
rotated-refresh extraction, then have the generic coordinator invoke these hooks
while preserving existing behavior for providers without them.
- Around line 494-510: Update the recovery write catch in
refreshGenericAccountWithLock around mergeAccountCredential to rethrow
OAuthMutationBusyError unchanged so callers can retry. For all other errors,
record the failure with logOAuthEvent including the error as cause, then return
"failed" as before; preserve the existing persisted/superseded outcomes.
- Around line 198-208: Update the OAuth provider configuration used by Token
Guardian for the `nous` provider so its refresh policy is always lazy-only and
cannot be overridden by `config.providers.nous.refreshPolicy`. Anchor the change
in the `nous` entry and the policy resolution logic near
`providerConfig`/`refreshPolicy`, while preserving proactive refresh behavior
for other providers.
- Around line 662-677: Update preserveNousRotatedRefresh to return
credentialGeneration(recovery) after mergeAccountCredential succeeds, and use
that returned generation in the outcome === "persisted" path when calling
markAccountNeedsReauthIfGeneration. Remove the subsequent getAccountCredential
re-read and its persistedGeneration calculation so concurrent logins cannot
supply a different credential generation.

In `@src/oauth/nous.ts`:
- Around line 4-7: Update the module docstring near the Nous gateway description
in src/oauth/nous.ts:4-7 to remove inclusionai/ling-3.0-flash:free and either
use a currently seeded slug such as poolside/laguna-s-2.1:free or point readers
to src/providers/registry.ts. In src/providers/registry.ts:1062-1076, retain the
removal note and explicitly state that this seed list is the sole enumeration of
fallback slugs.
- Around line 399-410: Update parseTokenPayload so the missing access_token
branch throws a terminal NousTokenError, matching the existing missing
refresh_token and other unusable-response branches. Preserve the current error
message/context while ensuring the error is classified as terminal by the
coordinator.
- Around line 546-554: Add an inline comment at the onAuth payload in loginNous
clarifying that deviceCode intentionally contains device.userCode for display,
while the RFC 8628 device code is secret and must not be exposed. Preserve the
existing assignment and polling behavior.
- Around line 513-517: Update parseTokenPayload to distinguish device-login
responses from refresh-token rotation using the available submittedRefreshToken
value. When no refresh token is submitted, report the missing refresh_token with
an initial-login-appropriate message and OAuth error instead of
refresh_token_reused; preserve the existing rotation error behavior when
submittedRefreshToken is present, and update the caller to pass that value
through.
- Around line 226-255: Update the comment above resolvePortalBaseUrl to remove
the inaccurate claim that it mirrors Hermes’s host allowlist, and describe only
the validations this function actually performs: HTTPS, no embedded credentials,
query strings, or fragments, while returning the URL origin.
- Around line 104-199: Update writeRefreshIntent to prune stale intent files
after ensuring refreshIntentDir exists, using readdirSync and statSync to remove
files whose modification time exceeds the chosen maximum plausible refresh-token
lifetime; skip entries that cannot be inspected or removed. Keep
readRefreshIntent fail-closed and do not use age to allow replay or bypass the
existing guard.

In `@src/providers/registry.ts`:
- Around line 1092-1099: Update the user-facing note in the Nous Research
registry configuration to remove the dated fallback model list, since the
authoritative fallback entries remain in the models array and live discovery
supplies the catalog when available. Keep the note focused on the subscription
gateway and OAuth device login details without duplicating model slugs or dates.
- Around line 1077-1091: Update providerMatchesRegistryTransport() to reject
OAuth registry entries when the matching provider’s authMode is not "oauth",
preventing pre-existing key-auth providers such as "nous" from being retargeted.
Preserve the existing OAuth matching behavior for providers configured with
authMode "oauth" and keep them pinned to the registry endpoint.

In `@tests/nous-oauth-live.test.ts`:
- Around line 61-75: Update the Nous refresh test to use the store row ID rather
than the JWT account ID: import and call getAccountSet("nous"), assign its
activeAccountId to rowId, and assert that rowId exists. Pass rowId! to both
refreshGenericAccountWithLock and getAccountCredential, while preserving the
existing token-rotation assertions.
- Around line 42-51: Update the provider typing used by NOUS_DEF and
refreshGenericAccountWithLock so it references an exported type from
src/oauth/types.ts. Add or reuse a refresh-only provider type matching the
minimal id and refresh members, rather than importing the private full provider
definition from src/oauth/index.ts; preserve the existing two-argument expect
usage.

In `@tests/nous-oauth.test.ts`:
- Around line 10-13: Move previousOpencodexHome and previousPortalBase from
module scope into each describe block whose beforeEach/afterEach uses them,
following the existing previousHome pattern in the later blocks. Keep each
block’s environment-save state independent, and avoid sharing TEST_DIR across
concurrent or interleaved blocks by giving each describe its own temporary
directory state and path.
- Around line 271-303: Update the “Nous Portal base URL hardening” describe
block to save the ambient NOUS_PORTAL_BASE_URL value in beforeEach and restore
it in afterEach, preserving both defined and undefined states. Remove the
redundant per-test deletion of NOUS_PORTAL_BASE_URL from the affected finally
blocks, while retaining fetch cleanup and existing test behavior.

In `@tests/oauth-refresh.test.ts`:
- Around line 911-951: Add a focused regression test alongside “Nous RT-B
preservation never overwrites a newer concurrent generation” that has the
mergeAccountCredential spy commit a newer credential through the real store
write but return { superseded: false, stored: ... }, exercising the persisted
branch of getValidAccessTokenForAccount. Assert the concurrent refresh remains
stored and the newer generation is not marked needsReauth, while preserving the
existing replay-intent expectations; restore the spy in a finally block.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 9f2f05fe-56d2-408c-8067-2d37234f53ce

📥 Commits

Reviewing files that changed from the base of the PR and between a4f5321 and 9d78f2d.

📒 Files selected for processing (12)
  • docs-site/src/content/docs/guides/providers.md
  • docs-site/src/content/docs/ja/guides/providers.md
  • docs-site/src/content/docs/ko/guides/providers.md
  • docs-site/src/content/docs/ru/guides/providers.md
  • docs-site/src/content/docs/zh-cn/guides/providers.md
  • src/oauth/index.ts
  • src/oauth/nous.ts
  • src/providers/registry.ts
  • tests/nous-oauth-live.test.ts
  • tests/nous-oauth.test.ts
  • tests/oauth-refresh.test.ts
  • tests/provider-registry-parity.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.

🛑 Comments failed to post (19)
docs-site/src/content/docs/guides/providers.md (2)

60-60: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -n -C 3 \
  'command-code|github-copilot|authMode.*oauth|ocx login nous' \
  src/providers/registry.ts src/oauth/index.ts \
  docs-site/src/content/docs/guides/providers.md \
  docs-site/src/content/docs/ja/guides/providers.md \
  docs-site/src/content/docs/ko/guides/providers.md \
  docs-site/src/content/docs/ru/guides/providers.md \
  docs-site/src/content/docs/zh-cn/guides/providers.md

Repository: lidge-jun/opencodex

Length of output: 37465


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- auth-table context ---'
for f in \
  docs-site/src/content/docs/guides/providers.md \
  docs-site/src/content/docs/ja/guides/providers.md \
  docs-site/src/content/docs/ko/guides/providers.md \
  docs-site/src/content/docs/ru/guides/providers.md \
  docs-site/src/content/docs/zh-cn/guides/providers.md
do
  printf '\n### %s\n' "$f"
  sed -n '48,66p' "$f"
done

printf '%s\n' '--- parsed inventory comparison ---'
python3 - <<'PY'
from pathlib import Path
import re

registry = Path("src/providers/registry.ts").read_text()
oauth = Path("src/oauth/index.ts").read_text()

# Provider registry entries are object literals with an id and authKind.
registry_oauth = set(re.findall(
    r'(?ms)^\s*{\s*\n\s*id:\s*"([^"]+)",.*?^\s*authKind:\s*"oauth",',
    registry,
))

# OAUTH_PROVIDERS keys are the authoritative OAuth login definitions.
oauth_block = oauth.split("export const OAUTH_PROVIDERS", 1)[1].split("};", 1)[0]
oauth_keys = set(re.findall(r'(?m)^\s*"([^"]+)":\s*{', oauth_block))
oauth_keys |= set(re.findall(r'(?m)^\s*([a-z][a-z0-9-]*):\s*{', oauth_block))

files = [
    Path("docs-site/src/content/docs/guides/providers.md"),
    Path("docs-site/src/content/docs/ja/guides/providers.md"),
    Path("docs-site/src/content/docs/ko/guides/providers.md"),
    Path("docs-site/src/content/docs/ru/guides/providers.md"),
    Path("docs-site/src/content/docs/zh-cn/guides/providers.md"),
]
print("registry authKind=oauth:", sorted(registry_oauth))
print("OAUTH_PROVIDERS keys:", sorted(oauth_keys))
print("registry minus OAUTH_PROVIDERS:", sorted(registry_oauth - oauth_keys))
print("OAUTH_PROVIDERS minus registry:", sorted(oauth_keys - registry_oauth))

for path in files:
    rows = []
    in_auth_table = False
    for number, line in enumerate(path.read_text().splitlines(), 1):
        if "Authentication" in line or "認証" in line or "인증" in line or "Аутентификация" in line or "认证" in line:
            in_auth_table = True
        if in_auth_table and re.match(r"^\| `[^`]+` \|", line):
            provider = re.match(r"^\| `([^`]+)` \|", line).group(1)
            auth = line.split("|")[2].strip()
            rows.append((provider, auth, number))
        if in_auth_table and rows and line.strip() == "":
            break
    print(path, "table rows:", rows)
PY

Repository: lidge-jun/opencodex

Length of output: 6992


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- auth-table context ---'
for f in \
  docs-site/src/content/docs/guides/providers.md \
  docs-site/src/content/docs/ja/guides/providers.md \
  docs-site/src/content/docs/ko/guides/providers.md \
  docs-site/src/content/docs/ru/guides/providers.md \
  docs-site/src/content/docs/zh-cn/guides/providers.md
do
  printf '\n### %s\n' "$f"
  sed -n '48,66p' "$f"
done

printf '%s\n' '--- parsed inventory comparison ---'
python3 - <<'PY'
from pathlib import Path
import re

registry = Path("src/providers/registry.ts").read_text()
oauth = Path("src/oauth/index.ts").read_text()

registry_oauth = set(re.findall(
    r'(?ms)^\s*{\s*\n\s*id:\s*"([^"]+)",.*?^\s*authKind:\s*"oauth",',
    registry,
))

oauth_block = oauth.split("export const OAUTH_PROVIDERS", 1)[1].split("};", 1)[0]
oauth_keys = set(re.findall(r'(?m)^\s*"([^"]+)":\s*{', oauth_block))
oauth_keys |= set(re.findall(r'(?m)^\s*([a-z][a-z0-9-]*):\s*{', oauth_block))

files = [
    Path("docs-site/src/content/docs/guides/providers.md"),
    Path("docs-site/src/content/docs/ja/guides/providers.md"),
    Path("docs-site/src/content/docs/ko/guides/providers.md"),
    Path("docs-site/src/content/docs/ru/guides/providers.md"),
    Path("docs-site/src/content/docs/zh-cn/guides/providers.md"),
]

print("registry authKind=oauth:", sorted(registry_oauth))
print("OAUTH_PROVIDERS keys:", sorted(oauth_keys))
print("registry minus OAUTH_PROVIDERS:", sorted(registry_oauth - oauth_keys))
print("OAUTH_PROVIDERS minus registry:", sorted(oauth_keys - registry_oauth))

for path in files:
    rows = []
    in_auth_table = False
    for number, line in enumerate(path.read_text().splitlines(), 1):
        if any(term in line for term in (
            "Authentication", "認証", "인증", "Аутентификация", "认证"
        )):
            in_auth_table = True
        if in_auth_table and re.match(r"^\| `[^`]+` \|", line):
            provider = re.match(r"^\| `([^`]+)` \|", line).group(1)
            auth = line.split("|")[2].strip()
            rows.append((provider, auth, number))
        if in_auth_table and rows and line.strip() == "":
            break
    print(path, "table rows:", rows)
PY

Repository: lidge-jun/opencodex

Length of output: 6992


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- registry auth declarations ---'
rg -n -A 7 -B 3 'id: "(command-code|xai|anthropic|kimi|kiro|google-antigravity|cursor|github-copilot|nous|chatgpt)"|authKind: "oauth"|authMode: "oauth"' \
  src/providers/registry.ts src/providers/derive.ts src/oauth/index.ts

printf '%s\n' '--- OAuth config and derivation references ---'
rg -n -A 18 -B 8 'function oauthConfig|const oauthConfig|oauthConfig\(|deriveOAuthProviderConfig|OAUTH_PROVIDERS' \
  src/providers/derive.ts src/oauth/index.ts src/providers/registry.ts

printf '%s\n' '--- bounded registry-entry check and table comparison ---'
python3 - <<'PY'
from pathlib import Path
import re

registry = Path("src/providers/registry.ts").read_text()
oauth = Path("src/oauth/index.ts").read_text()

def object_after_id(source, provider_id):
    marker = f'id: "{provider_id}"'
    start = source.find(marker)
    if start < 0:
        return None
    begin = source.rfind("{", 0, start)
    depth = 0
    string = None
    escape = False
    for i in range(begin, len(source)):
        c = source[i]
        if string:
            if escape:
                escape = False
            elif c == "\\":
                escape = True
            elif c == string:
                string = None
        elif c in ('"', "'", "`"):
            string = c
        elif c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return source[begin:i+1]
    raise RuntimeError(provider_id)

ids = [
    "command-code", "xai", "anthropic", "kimi", "kiro",
    "google-antigravity", "cursor", "github-copilot", "nous", "chatgpt",
]
for provider_id in ids:
    entry = object_after_id(registry, provider_id)
    if entry is None:
        print(f"registry {provider_id}: MISSING")
    else:
        auth = re.search(r'\bauthKind:\s*"([^"]+)"', entry)
        adapter = re.search(r'\badapter:\s*"([^"]+)"', entry)
        print(f"registry {provider_id}: authKind={auth.group(1) if auth else '<default>'}, adapter={adapter.group(1) if adapter else '<default>'}")

block = oauth.split("export const OAUTH_PROVIDERS", 1)[1]
block = block.split("};", 1)[0]
keys = re.findall(r'(?m)^\s*(?:"([^"]+)"|([a-z][a-z0-9-]*)):\s*{', block)
oauth_keys = {a or b for a, b in keys}
print("OAUTH_PROVIDERS:", sorted(oauth_keys))

for path in [
    Path("docs-site/src/content/docs/guides/providers.md"),
    Path("docs-site/src/content/docs/ja/guides/providers.md"),
    Path("docs-site/src/content/docs/ko/guides/providers.md"),
    Path("docs-site/src/content/docs/ru/guides/providers.md"),
    Path("docs-site/src/content/docs/zh-cn/guides/providers.md"),
]:
    text = path.read_text()
    line = next((x for x in text.splitlines() if re.match(r'^\| `oauth` \|', x)), None)
    names = re.search(r'\| ([^|]+)\.\s*$', line) if line else None
    print(f"{path}: oauth row present={line is not None}; contains command-code={'command-code' in line if line else False}; contains chatgpt={'ChatGPT' in line or 'ChatGPT' in line if line else False}")
PY

Repository: lidge-jun/opencodex

Length of output: 39111


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

printf '%s\n' '--- registry auth declarations ---'
rg -n -A 7 -B 3 'id: "(command-code|xai|anthropic|kimi|kiro|google-antigravity|cursor|github-copilot|nous|chatgpt)"|authKind: "oauth"|authMode: "oauth"' \
  src/providers/registry.ts src/providers/derive.ts src/oauth/index.ts

printf '%s\n' '--- OAuth config and derivation references ---'
rg -n -A 18 -B 8 'function oauthConfig|const oauthConfig|oauthConfig\(|deriveOAuthProviderConfig|OAUTH_PROVIDERS' \
  src/providers/derive.ts src/oauth/index.ts src/providers/registry.ts

printf '%s\n' '--- bounded registry-entry check and table comparison ---'
python3 - <<'PY'
from pathlib import Path
import re

registry = Path("src/providers/registry.ts").read_text()
oauth = Path("src/oauth/index.ts").read_text()

def object_after_id(source, provider_id):
    marker = f'id: "{provider_id}"'
    start = source.find(marker)
    if start < 0:
        return None
    begin = source.rfind("{", 0, start)
    depth = 0
    string = None
    escape = False
    for i in range(begin, len(source)):
        c = source[i]
        if string:
            if escape:
                escape = False
            elif c == "\\":
                escape = True
            elif c == string:
                string = None
        elif c in ('"', "'", "`"):
            string = c
        elif c == "{":
            depth += 1
        elif c == "}":
            depth -= 1
            if depth == 0:
                return source[begin:i+1]
    raise RuntimeError(provider_id)

ids = [
    "command-code", "xai", "anthropic", "kimi", "kiro",
    "google-antigravity", "cursor", "github-copilot", "nous", "chatgpt",
]
for provider_id in ids:
    entry = object_after_id(registry, provider_id)
    if entry is None:
        print(f"registry {provider_id}: MISSING")
    else:
        auth = re.search(r'\bauthKind:\s*"([^"]+)"', entry)
        adapter = re.search(r'\badapter:\s*"([^"]+)"', entry)
        print(f"registry {provider_id}: authKind={auth.group(1) if auth else '<default>'}, adapter={adapter.group(1) if adapter else '<default>'}")

block = oauth.split("export const OAUTH_PROVIDERS", 1)[1]
block = block.split("};", 1)[0]
keys = re.findall(r'(?m)^\s*(?:"([^"]+)"|([a-z][a-z0-9-]*)):\s*{', block)
oauth_keys = {a or b for a, b in keys}
print("OAUTH_PROVIDERS:", sorted(oauth_keys))

for path in [
    Path("docs-site/src/content/docs/guides/providers.md"),
    Path("docs-site/src/content/docs/ja/guides/providers.md"),
    Path("docs-site/src/content/docs/ko/guides/providers.md"),
    Path("docs-site/src/content/docs/ru/guides/providers.md"),
    Path("docs-site/src/content/docs/zh-cn/guides/providers.md"),
]:
    text = path.read_text()
    line = next((x for x in text.splitlines() if re.match(r'^\| `oauth` \|', x)), None)
    print(f"{path}: oauth row present={line is not None}; contains command-code={'command-code' in line if line else False}; contains chatgpt={'ChatGPT' in line if line else False}")
PY

Repository: lidge-jun/opencodex

Length of output: 39111


Add command-code to all OAuth inventories

src/providers/registry.ts:957-962 defines command-code with authKind: "oauth", and src/oauth/index.ts:168-174 registers its OAuth login. Add it to the OAuth provider list at docs-site/src/content/docs/guides/providers.md:60 and the corresponding Japanese, Korean, Russian, and Simplified Chinese rows. Do not add chatgpt; its provider configuration uses authMode: "forward" at src/oauth/index.ts:235-240.

📍 Affects 5 files
  • docs-site/src/content/docs/guides/providers.md#L60-L60 (this comment)
  • docs-site/src/content/docs/ja/guides/providers.md#L55-L55
  • docs-site/src/content/docs/ko/guides/providers.md#L54-L54
  • docs-site/src/content/docs/ru/guides/providers.md#L63-L63
  • docs-site/src/content/docs/zh-cn/guides/providers.md#L51-L51
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/providers.md` at line 60, Update the OAuth
provider inventory to include command-code in
docs-site/src/content/docs/guides/providers.md:60,
docs-site/src/content/docs/ja/guides/providers.md:55,
docs-site/src/content/docs/ko/guides/providers.md:54,
docs-site/src/content/docs/ru/guides/providers.md:63, and
docs-site/src/content/docs/zh-cn/guides/providers.md:51. Do not add chatgpt,
since its configuration uses forward authentication.

Source: Path instructions


117-117: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Escape the Kiro pipes in this table row.

Raw | characters inside this Markdown table row are parsed as column separators. The rendered Kiro row can split into extra cells and lose part of the installation instructions. The supplied markdownlint output reports MD038 and MD056 for this line.

Use the table-safe entity already used by the localized rows.

Proposed fix
-`curl -fsSL https://cli.kiro.dev/install | bash`
+`curl -fsSL https://cli.kiro.dev/install &`#124`; bash`
-`irm 'https://cli.kiro.dev/install.ps1' | iex`
+`irm 'https://cli.kiro.dev/install.ps1' &`#124`; iex`
🧰 Tools
🪛 markdownlint-cli2 (0.23.2)

[warning] 117-117: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 117-117: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 117-117: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 117-117: Spaces inside code span elements

(MD038, no-space-in-code)


[warning] 117-117: Table column count
Expected: 4; Actual: 6; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/providers.md` at line 117, Update the Kiro
table row to replace its raw pipe characters inside inline commands or URLs with
the table-safe entity used by the localized provider rows, while preserving the
displayed installation instructions and table column structure.

Sources: Path instructions, Linters/SAST tools

src/oauth/index.ts (4)

198-208: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Find any proactive/background/eager OAuth refresh scheduling that could select nous.
set -uo pipefail

echo "=== OAuthProviderDef shape (look for a background/proactive opt-in flag) ==="
ast-grep run --pattern 'export interface OAuthProviderDef { $$$ }' --lang typescript src/oauth/types.ts

echo "=== Scheduler-ish refresh triggers ==="
rg -nP --type=ts -C4 '\b(setInterval|setTimeout|prewarm|preemptive|proactive|background|eager)\w*\b' src/oauth src/server* 2>/dev/null | rg -n -C4 -i 'refresh|token|oauth' || echo "no scheduler-shaped refresh triggers found"

echo "=== Call sites that refresh across all providers rather than one ==="
rg -nP --type=ts -C5 '(Object\.keys|Object\.entries|for\s*\(\s*const\s+\[?\w+)\s*.{0,40}(OAUTH_PROVIDERS|providers)\b' src/oauth

echo "=== Every caller of the generic refresh coordinator ==="
rg -nP --type=ts -C3 '\brefreshGenericAccountWithLock\s*\(' src tests

echo "=== Anything that calls def.refresh directly ==="
ast-grep run --pattern '$DEF.refresh($$$)' --lang typescript src

Repository: lidge-jun/opencodex

Length of output: 35187


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== Provider policy definition and resolution ==="
sed -n '130,170p;245,270p' src/oauth/index.ts

echo "=== Nous registration ==="
sed -n '194,212p' src/oauth/index.ts

echo "=== Token guardian sweep and lifecycle ==="
sed -n '1,190p;270,330p' src/oauth/token-guardian.ts

echo "=== Guardian configuration and startup ==="
rg -n -C5 --type=ts 'tokenGuardian|startTokenGuardian|refreshPolicy' src | head -240

echo "=== Config provider schema/default handling ==="
rg -n -C6 --type=ts 'refreshPolicy|interface OcxProviderConfig|type OcxProviderConfig' src/config src/types.ts src/oauth

Repository: lidge-jun/opencodex

Length of output: 27505


Prevent Token Guardian from proactively refreshing nous

src/oauth/token-guardian.ts:143-150 enumerates all OAuth providers. src/oauth/index.ts:260-264 lets config.providers.nous.refreshPolicy: "proactive" override the lazy-only fallback. Enforce a non-overridable nous opt-out so the Token Guardian cannot refresh its single-use tokens in the background and trigger refresh_token_reused session revocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/index.ts` around lines 198 - 208, Update the OAuth provider
configuration used by Token Guardian for the `nous` provider so its refresh
policy is always lazy-only and cannot be overridden by
`config.providers.nous.refreshPolicy`. Anchor the change in the `nous` entry and
the policy resolution logic near `providerConfig`/`refreshPolicy`, while
preserving proactive refresh behavior for other providers.

494-510: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

The blanket catch maps transient store failures to a forced re-login and logs nothing.

Lines 507-509 convert every throw from mergeAccountCredential into "failed". The caller at Lines 678-683 then marks the account needsReauth and throws OAuthLoginRequiredError. Two problems follow.

First, OAuthMutationBusyError is transient. The outer handler at Line 644 deliberately rethrows it so the caller can retry rather than lose the credential. This inner catch swallows it, so a merely busy mutation queue during the recovery write forces the user through a full device-grant re-login. The rotated refresh token RT-B is discarded even though the store was simply contended.

Second, nothing is logged. Every other failure branch in refreshGenericAccountWithLock emits a logOAuthEvent. When RT-B preservation fails, the operator sees only OAuthLoginRequiredError and has no signal about the underlying store error.

Propagate the busy error and record the rest.

🐛 Proposed fix
     const outcome = await mergeAccountCredential(provider, accountId, recovery, { expectedGeneration });
     return outcome.superseded ? "superseded" : "persisted";
-  } catch {
+  } catch (error) {
+    // A contended mutation queue is transient. Let the caller retry instead of
+    // discarding RT-B and forcing a full re-login.
+    if (error instanceof OAuthMutationBusyError) throw error;
+    logOAuthEvent("OAuth rotated refresh preservation failed", {
+      provider,
+      accountId,
+      cause: error instanceof Error ? error.message : String(error),
+    });
     return "failed";
   }
 }

OAuthMutationBusyError then propagates out of the if (provider === "nous" ...) block at Lines 652-686 to the caller, matching the contract already established at Line 644. Confirm the logOAuthEvent field filter drops nothing needed here: src/oauth/log.ts Lines 35-48 skips keys rejected by isForbiddenFieldKey, and cause is already used for this purpose at Lines 638 and 674.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  try {
    const recovery: OAuthCredentials = {
      refresh: rotatedRefresh,
      // Never persist the unusable access token: an empty placeholder with a
      // past expiry can never be observed as a valid credential.
      access: "",
      expires: 0,
      ...(previous.accountId ? { accountId: previous.accountId } : {}),
      ...(previous.email ? { email: previous.email } : {}),
      ...(previous.source ? { source: previous.source } : {}),
    };
    const outcome = await mergeAccountCredential(provider, accountId, recovery, { expectedGeneration });
    return outcome.superseded ? "superseded" : "persisted";
  } catch (error) {
    // A contended mutation queue is transient. Let the caller retry instead of
    // discarding RT-B and forcing a full re-login.
    if (error instanceof OAuthMutationBusyError) throw error;
    logOAuthEvent("OAuth rotated refresh preservation failed", {
      provider,
      accountId,
      cause: error instanceof Error ? error.message : String(error),
    });
    return "failed";
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/index.ts` around lines 494 - 510, Update the recovery write catch
in refreshGenericAccountWithLock around mergeAccountCredential to rethrow
OAuthMutationBusyError unchanged so callers can retry. For all other errors,
record the failure with logOAuthEvent including the error as cause, then return
"failed" as before; preserve the existing persisted/superseded outcomes.

631-641: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

refreshGenericAccountWithLock now hardcodes two provider === "nous" branches; move the behavior onto the provider definition.

Lines 631 and 652 add provider-specific logic to the shared, provider-agnostic refresh coordinator. The function already carries xai, anthropic, and kiro special cases through the dedicated wrappers in refreshAndPersistAccessToken (Lines 704-706) and through terminal(). Adding inline string comparisons inside the generic path means every future provider with rotation bookkeeping appends another branch to the same two try blocks, and the Nous-specific recovery becomes untestable in isolation from the generic coordinator.

The two behaviors are both expressible as optional hooks on OAuthProviderDef: one that runs after a rotation is durably persisted, and one that recovers credential material from a terminal error. nous supplies both; every other provider supplies neither and the coordinator stays generic.

♻️ Sketch of the hook shape
 export interface OAuthProviderDef {
   id: string;
   refresh(rt: string, signal?: AbortSignal, cred?: OAuthCredentials): Promise<OAuthCredentials>;
   $$$
+  /** Runs after a rotation is durably persisted. Failures are logged, never fatal. */
+  onRotationPersisted?(consumedRefresh: string): void;
+  /** Extracts already-rotated refresh material from a terminal refresh error. */
+  rotatedRefreshFromError?(error: unknown): string | undefined;
 }

The coordinator then reads:

-      if (provider === "nous") {
-        try {
-          clearNousRefreshIntent(stored.refresh);
-        } catch (cleanupErr) {
+      if (def.onRotationPersisted) {
+        try {
+          def.onRotationPersisted(stored.refresh);
+        } catch (cleanupErr) {

and

-      if (provider === "nous" && error instanceof NousTokenError) {
-        const rotated = error.getRotatedRefresh();
+      const rotated = def.rotatedRefreshFromError?.(error);
+      {

with the nous entry at Lines 198-208 supplying onRotationPersisted: clearNousRefreshIntent and rotatedRefreshFromError: e => e instanceof NousTokenError ? e.getRotatedRefresh() : undefined.

As per path instructions for src/**, flag "changes that bypass the shared routing/config layers" — provider-specific branching inside the shared coordinator is the same category of drift.

Also applies to: 652-686

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/index.ts` around lines 631 - 641, Remove the inline provider ===
"nous" branches from refreshGenericAccountWithLock and move both behaviors onto
OAuthProviderDef as optional onRotationPersisted and rotatedRefreshFromError
hooks. Configure the nous provider definition with clearNousRefreshIntent and
NousTokenError rotated-refresh extraction, then have the generic coordinator
invoke these hooks while preserving existing behavior for providers without
them.

Source: Path instructions


662-677: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Confirm normalizeCredential preserves refresh/access/expires so the
# locally computed generation matches what mergeAccountCredential stores.
set -uo pipefail

echo "=== normalizeCredential definition ==="
ast-grep run --pattern 'function normalizeCredential($$$) { $$$ }' --lang typescript src/oauth/store.ts

echo "=== credentialGeneration definition ==="
ast-grep run --pattern 'export function credentialGeneration($$$) { $$$ }' --lang typescript src/oauth/store.ts

echo "=== Does saveCredential take the refresh intent lock? ==="
rg -nP --type=ts -C6 '\bcreateOAuthRefreshIntentLock\s*\(' src/oauth

echo "=== All writers that can change a nous credential concurrently ==="
rg -nP --type=ts -C3 '\b(saveCredential|mergeAccountCredential|mutateStore)\s*\(' src/oauth

Repository: lidge-jun/opencodex

Length of output: 19356


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== store.ts credential normalization and generation ==="
rg -n -C12 'normalizeCredential|credentialGeneration|function mutateStore|export async function mergeAccountCredential' src/oauth/store.ts

echo "=== index.ts preserve helper and recovery call ==="
sed -n '480,680p' src/oauth/index.ts

echo "=== credential types and serialization helpers ==="
rg -n -C8 'interface OAuthCredentials|type OAuthCredentials|function credentialGeneration|const credentialGeneration|normalizeCredential' src/oauth src -g '*.ts'

Repository: lidge-jun/opencodex

Length of output: 48856


🏁 Script executed:

#!/bin/bash
set -uo pipefail

echo "=== Nous provider and single-slot rules ==="
rg -n -C8 'SINGLE_SLOT_PROVIDERS|nous|saveCredential\(' src/oauth -g '*.ts'

echo "=== Login call sites that persist credentials ==="
rg -n -C10 '\bsaveCredential\s*\(' src -g '*.ts'

echo "=== Relevant credential identity behavior ==="
sed -n '238,292p' src/oauth/store.ts
sed -n '475,526p' src/oauth/store.ts

Repository: lidge-jun/opencodex

Length of output: 27548


Carry the committed generation out of preserveNousRotatedRefresh

Do not re-read the credential after mergeAccountCredential succeeds. saveCredential does not acquire the refresh lock, so a concurrent Nous login can replace the credential before getAccountCredential runs. The new credential can then be marked needsReauth.

Return credentialGeneration(recovery) from preserveNousRotatedRefresh and pass it directly to markAccountNeedsReauthIfGeneration. normalizeCredential preserves refresh, access, and expires, so the generation matches the persisted recovery credential.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/index.ts` around lines 662 - 677, Update preserveNousRotatedRefresh
to return credentialGeneration(recovery) after mergeAccountCredential succeeds,
and use that returned generation in the outcome === "persisted" path when
calling markAccountNeedsReauthIfGeneration. Remove the subsequent
getAccountCredential re-read and its persistedGeneration calculation so
concurrent logins cannot supply a different credential generation.
src/oauth/nous.ts (6)

4-7: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The free-model list is stated in two files and they now contradict each other on inclusionai/ling-3.0-flash:free. The registry records that this slug was removed from the Portal free list and returns 404 on the inference API since 2026-08-07, while the OAuth module docstring still presents it as a current example of a :free slug. A reader of src/oauth/nous.ts gets a model id that no longer resolves.

  • src/oauth/nous.ts#L4-L7: remove inclusionai/ling-3.0-flash:free from the example list in the module docstring and cite a slug that is still in the registry seed, for example poolside/laguna-s-2.1:free, or drop the enumeration and point to src/providers/registry.ts as the single source.
  • src/providers/registry.ts#L1062-L1076: keep the removal note, which is the authoritative statement, and make it explicit that the seed list here is the only place the fallback slugs are enumerated.
📍 Affects 2 files
  • src/oauth/nous.ts#L4-L7 (this comment)
  • src/providers/registry.ts#L1062-L1076
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 4 - 7, Update the module docstring near the
Nous gateway description in src/oauth/nous.ts:4-7 to remove
inclusionai/ling-3.0-flash:free and either use a currently seeded slug such as
poolside/laguna-s-2.1:free or point readers to src/providers/registry.ts. In
src/providers/registry.ts:1062-1076, retain the removal note and explicitly
state that this seed list is the sole enumeration of fallback slugs.

104-199: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

updatedAt is recorded and validated but never used; intent files accumulate without bound.

RefreshIntent.updatedAt is written at Line 184, strictly validated at Lines 151-153, and then never read by any consumer. clearRefreshIntent runs only on the success path through clearNousRefreshIntent. Every uncertain outcome (network failure at Line 628, non-2xx at Line 651, parse failure at Line 668) leaves a permanent file in .nous-refresh-intent/, keyed by a token that is already dead. Those files are never removed, so the directory grows once per failed refresh for the lifetime of the config dir.

The guard must stay fail-closed, so do not add a TTL that unblocks replay. Instead, prune files that are older than any plausible refresh-token lifetime during writeRefreshIntent, which is the one point that already touches the directory.

♻️ Suggested pruning on write (keeps the guard fail-closed)
+// Intent files outlive the tokens they key. A refresh token that has not been
+// submitted for this long can no longer be replayed by this client, so its
+// intent record is dead bookkeeping rather than an active guard.
+const REFRESH_INTENT_RETENTION_MS = 90 * 24 * 60 * 60 * 1000;
+
+function pruneExpiredRefreshIntents(dir: string): void {
+  try {
+    for (const entry of readdirSync(dir)) {
+      if (!entry.endsWith(".json")) continue;
+      const full = join(dir, entry);
+      try {
+        const { mtimeMs } = statSync(full);
+        if (Date.now() - mtimeMs > REFRESH_INTENT_RETENTION_MS) rmSync(full, { force: true });
+      } catch { /* a concurrent writer may have removed it */ }
+    }
+  } catch { /* pruning is bookkeeping only; never block a refresh */ }
+}
+
 function writeRefreshIntent(refreshToken: string, status: RefreshIntentStatus): void {
   const dir = refreshIntentDir();
   // Hardened, owner-only directory + atomic (temp+rename) write. Throws on
   // failure so the caller can fail closed instead of refreshing blind.
   mkdirSync(dir, { recursive: true, mode: 0o700 });
   hardenConfigDir();
+  pruneExpiredRefreshIntents(dir);
   const path = refreshIntentPath(refreshToken);

Add readdirSync and statSync to the node:fs import at Line 37.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 104 - 199, Update writeRefreshIntent to prune
stale intent files after ensuring refreshIntentDir exists, using readdirSync and
statSync to remove files whose modification time exceeds the chosen maximum
plausible refresh-token lifetime; skip entries that cannot be inspected or
removed. Keep readRefreshIntent fail-closed and do not use age to allow replay
or bypass the existing guard.

226-255: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

The docstring claims an allowlist that the code does not implement.

Lines 226-228 state this mirrors _NOUS_PORTAL_ALLOWED_HOSTS from Hermes. resolvePortalBaseUrl applies no host allowlist. It accepts any HTTPS origin supplied through NOUS_PORTAL_BASE_URL. The scheme, credential, query, and fragment checks are real; the host check is not.

Accepting any operator-supplied HTTPS origin is consistent with how this repository treats operator-controlled provider URLs, so the behavior is defensible. The comment is not. Correct the claim so a future reader does not assume host containment that is absent.

📝 Proposed comment correction
- * Mirrors the allowlist discipline in Hermes `hermes_cli/auth.py`
- * (`_NOUS_PORTAL_ALLOWED_HOSTS`, https-only default
- * `DEFAULT_NOUS_PORTAL_URL`).
+ * Unlike Hermes `hermes_cli/auth.py`, this does NOT apply a host allowlist:
+ * `NOUS_PORTAL_BASE_URL` is an operator-controlled override, so any HTTPS
+ * origin is accepted. The scheme/credential/query/fragment checks below are
+ * the enforced controls; the default remains `NOUS_PORTAL_BASE_URL`.

Based on learnings, operator-controlled provider URLs are valid when they reject embedded credentials, query strings, and fragments, and OAuth adapters that attach Bearer credentials must enforce HTTPS separately — which this function does.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

 * Unlike Hermes `hermes_cli/auth.py`, this does NOT apply a host allowlist:
 * `NOUS_PORTAL_BASE_URL` is an operator-controlled override, so any HTTPS
 * origin is accepted. The scheme/credential/query/fragment checks below are
 * the enforced controls; the default remains `NOUS_PORTAL_BASE_URL`.
 */
function resolvePortalBaseUrl(): string {
  const raw = (process.env.NOUS_PORTAL_BASE_URL || NOUS_PORTAL_BASE_URL).trim();
  let url: URL;
  try {
    url = new URL(raw);
  } catch {
    // Do not echo the raw value: it may contain embedded credentials. Identify
    // the configuration problem without reflecting secret-bearing input.
    throw new NousTokenError(undefined, undefined, "Nous Portal base URL is not a valid URL");
  }
  if (url.protocol !== "https:") {
    throw new NousTokenError(undefined, undefined, `Nous Portal base URL must use HTTPS (got ${url.protocol})`);
  }
  if (url.username || url.password) {
    throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain embedded credentials");
  }
  if (url.search) {
    throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a query string");
  }
  if (url.hash) {
    throw new NousTokenError(undefined, undefined, "Nous Portal base URL must not contain a fragment");
  }
  // Origin only — no path/query/fragment — so callers cannot smuggle a
  // non-canonical endpoint through the override.
  return url.origin;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 226 - 255, Update the comment above
resolvePortalBaseUrl to remove the inaccurate claim that it mirrors Hermes’s
host allowlist, and describe only the validations this function actually
performs: HTTPS, no embedded credentials, query strings, or fragments, while
returning the URL origin.

Source: Learnings


399-410: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

A 200 response without access_token throws a plain Error, so the coordinator misclassifies it as retryable.

Line 401 throws new Error("Nous Portal token response did not include an access token"). Every sibling unusable-response branch in this function throws a terminal NousTokenError (Lines 404-409, 412-417, 439-444). The classifier in src/oauth/index.ts at Lines 447-456 checks instanceof NousTokenError first, then falls back to isTerminalRefreshError, which substring-matches only invalid_grant, refresh_token_reused, revoked, access_denied, and expired_token. This message matches none of them.

Failure mode on the refresh path: refreshNousToken marks the intent uncertain at Line 668 and rethrows the plain Error. refreshGenericAccountWithLock sees a non-terminal error, rethrows it without calling markAccountNeedsReauthIfGeneration, and the account stays nominally valid while its only refresh token is permanently blocked by the replay guard. The next attempt then fails with refresh_token_reused instead of the true cause. The user sees a misleading error and one wasted round trip before reauthentication is signalled.

Make the branch terminal and consistent with its siblings.

🐛 Proposed fix
 function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: string): OAuthCredentials {
   const access = nonEmptyString(payload.access_token);
-  if (!access) throw new Error("Nous Portal token response did not include an access token");
+  if (!access) {
+    throw new NousTokenError(
+      undefined,
+      "invalid_grant",
+      "Nous Portal token response did not include an access token",
+      { terminal: true },
+    );
+  }

Note that the device-login path reaches this branch only when response.ok && nonEmptyString(payload.access_token) already held (Line 516), so this change affects the refresh path, which is exactly where the misclassification hurts.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

function parseTokenPayload(payload: NousTokenResponse, submittedRefreshToken: string): OAuthCredentials {
  const access = nonEmptyString(payload.access_token);
  if (!access) {
    throw new NousTokenError(
      undefined,
      "invalid_grant",
      "Nous Portal token response did not include an access token",
      { terminal: true },
    );
  }
  const refresh = nonEmptyString(payload.refresh_token);
  if (!refresh) {
    throw new NousTokenError(
      undefined,
      "refresh_token_reused",
      "Nous Portal did not return a replacement refresh token; refusing to reuse the consumed one (would trigger refresh_token_reused and revoke the session)",
      { terminal: true },
    );
  }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 399 - 410, Update parseTokenPayload so the
missing access_token branch throws a terminal NousTokenError, matching the
existing missing refresh_token and other unusable-response branches. Preserve
the current error message/context while ensuring the error is classified as
terminal by the coordinator.

513-517: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

A device-login response without refresh_token reports a token-reuse error to a first-time user.

Line 516 calls parseTokenPayload(payload, ""). If the Portal omits refresh_token on the device grant, Line 404 throws a NousTokenError whose message is "Nous Portal did not return a replacement refresh token; refusing to reuse the consumed one (would trigger refresh_token_reused and revoke the session)" and whose oauthError is refresh_token_reused.

During ocx login nous there is no consumed token and no prior session. The rejection is correct, but the message and the OAuth error code describe a rotation failure that did not occur. A user reads it as a revoked session and may re-authenticate in a loop.

Distinguish the two callers using the already-available submittedRefreshToken.

🐛 Proposed fix in `parseTokenPayload`
   const refresh = nonEmptyString(payload.refresh_token);
   if (!refresh) {
+    if (!submittedRefreshToken) {
+      throw new NousTokenError(
+        undefined,
+        "invalid_grant",
+        "Nous Portal did not return a refresh token for this device authorization; the session cannot be persisted",
+        { terminal: true },
+      );
+    }
     throw new NousTokenError(
       undefined,
       "refresh_token_reused",
       "Nous Portal did not return a replacement refresh token; refusing to reuse the consumed one (would trigger refresh_token_reused and revoke the session)",
       { terminal: true },
     );
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 513 - 517, Update parseTokenPayload to
distinguish device-login responses from refresh-token rotation using the
available submittedRefreshToken value. When no refresh token is submitted,
report the missing refresh_token with an initial-login-appropriate message and
OAuth error instead of refresh_token_reused; preserve the existing rotation
error behavior when submittedRefreshToken is present, and update the caller to
pass that value through.

546-554: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Add a note that deviceCode intentionally carries the user code, not the device code.

Line 551 sets deviceCode: device.userCode. This is correct and is the secure choice: the RFC 8628 device_code is the secret polled against the token endpoint and must never be rendered, while user_code is the value the human types into the Portal. The field name in OAuthController.onAuth (src/oauth/types.ts Lines 50-55) invites a future contributor to "correct" this to device.deviceCode, which would surface a live credential in CLI output and logs.

Record the intent at the call site so the guard is not silently removed.

🛡️ Proposed comment
   ctrl.onAuth?.({
     url: device.verificationUriComplete,
     instructions: `Sign in to Nous Portal and enter the code: ${device.userCode}`,
+    // Deliberately the USER code, not `device.deviceCode`. The device code is
+    // the secret polled against the token endpoint; rendering it would leak a
+    // live credential into CLI output and logs.
     deviceCode: device.userCode,
   });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

export async function loginNous(ctrl: OAuthController): Promise<OAuthCredentials> {
  const device = await requestDeviceAuthorization(ctrl.signal);
  ctrl.onAuth?.({
    url: device.verificationUriComplete,
    instructions: `Sign in to Nous Portal and enter the code: ${device.userCode}`,
    // Deliberately the USER code, not `device.deviceCode`. The device code is
    // the secret polled against the token endpoint; rendering it would leak a
    // live credential into CLI output and logs.
    deviceCode: device.userCode,
  });
  return pollForToken(device.deviceCode, device.intervalMs, device.expiresInMs, ctrl.signal);
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 546 - 554, Add an inline comment at the
onAuth payload in loginNous clarifying that deviceCode intentionally contains
device.userCode for display, while the RFC 8628 device code is secret and must
not be exposed. Preserve the existing assignment and polling behavior.
src/providers/registry.ts (2)

1077-1091: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check how preserveCustomDestination is applied and whether other
# recently added OAuth presets set it.
set -uo pipefail

echo "=== Where preserveCustomDestination is consumed ==="
rg -nP --type=ts -C8 '\bpreserveCustomDestination\b' src

echo "=== Registry entries that set it, with their ids ==="
rg -nP --type=ts -B12 'preserveCustomDestination:\s*true' src/providers/registry.ts | rg -nP '\bid:\s*"|preserveCustomDestination'

echo "=== All authKind: \"oauth\" entries and whether they set the flag ==="
rg -nP --type=ts -A20 'authKind:\s*"oauth"' src/providers/registry.ts | rg -nP '\bid:\s*"|preserveCustomDestination|authKind'

Repository: lidge-jun/opencodex

Length of output: 32723


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Routing and registry-match implementations ==="
rg -n -C14 'function routedProviderConfig|const routedProviderConfig|providerMatchesRegistryTransport|preserveCustomDestination|authKind === "oauth"|authMode' src/config.ts src/providers/registry.ts src -g '*.ts'

echo "=== Nous registry entry and nearby provider entries ==="
sed -n '1025,1105p' src/providers/registry.ts

echo "=== OAuth registry entries with ids ==="
python3 - <<'PY'
from pathlib import Path
p = Path("src/providers/registry.ts")
lines = p.read_text().splitlines()
for i, line in enumerate(lines):
    if 'authKind: "oauth"' in line:
        start = max(0, i - 8)
        end = min(len(lines), i + 8)
        print(f"--- lines {start+1}-{end} ---")
        for n in range(start, end):
            print(f"{n+1}:{lines[n]}")
PY

Repository: lidge-jun/opencodex

Length of output: 50376


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("src/providers/registry.ts")
text = p.read_text()
needle = 'id: "nous"'
pos = text.index(needle)
entry_end = text.find('\n  },', pos)
entry = text[pos:entry_end]
print("Nous entry contains preserveCustomDestination:", "preserveCustomDestination" in entry)
print(entry)
PY

Repository: lidge-jun/opencodex

Length of output: 1393


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Exact provider matching implementation ==="
sed -n '2388,2420p' src/providers/registry.ts

echo "=== Exact routed-provider implementation and callers ==="
rg -n -l 'routedProviderConfig' src -g '*.ts' | while read -r f; do
  echo "--- $f ---"
  rg -n -C18 'routedProviderConfig' "$f"
done

echo "=== Auth selection around routed configuration ==="
rg -n -C12 'authKind|authMode|oauthId|oauth' src/server src/providers src/oauth -g '*.ts' | rg -n -C4 'routedProvider|registry|authKind|authMode|oauthId|bearer|Authorization' | head -n 500

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path
p = Path("src/providers/registry.ts")
lines = p.read_text().splitlines()
for i, line in enumerate(lines):
    if 'id: "nous"' in line:
        print(f"Nous registry entry starts at line {i+1}")
    if 'function providerMatchesRegistryTransport' in line:
        print(f"Matcher starts at line {i+1}")
PY

Repository: lidge-jun/opencodex

Length of output: 225


🏁 Script executed:

#!/bin/bash
set -euo pipefail

python3 - <<'PY'
from pathlib import Path

registry = Path("src/providers/registry.ts").read_text()
router = Path("src/router.ts").read_text()

nous_start = registry.index('id: "nous"')
nous_end = registry.index("\n  },", nous_start)
nous = registry[nous_start:nous_end]

matcher_start = registry.index("export function providerMatchesRegistryTransport")
matcher_end = registry.index("\n}", matcher_start) + 2
matcher = registry[matcher_start:matcher_end]

route_start = router.index("export function routedProviderConfig")
route_end = router.index("\n}", route_start) + 2
route = router[route_start:route_end]

assert 'authKind: "oauth"' in nous
assert "preserveCustomDestination" not in nous
assert 'if (entry.authKind !== "key" || entry.preserveCustomDestination !== true) return true;' in matcher
assert 'registryEntry.authKind === "oauth"' in route
assert 'registryEntry.authKind' in route and 'canonicalAuthMode' in route

# Evaluate the matcher branch for the Nous entry and a pre-existing key provider.
entry = {"authKind": "oauth", "preserveCustomDestination": True}
custom_provider = {
    "authMode": "key",
    "adapter": "openai-chat",
    "baseUrl": "https://user.example/v1",
}
matcher_short_circuit = entry["authKind"] != "key" or entry["preserveCustomDestination"] is not True
assert matcher_short_circuit is True
assert custom_provider["authMode"] == "key"

print("Nous is an OAuth registry entry without preserveCustomDestination.")
print("The matcher short-circuits to true for OAuth entries, even for a same-named key provider.")
print("The router therefore reaches the OAuth canonical-auth branch unless the matcher rejects the collision.")
PY

Repository: lidge-jun/opencodex

Length of output: 417


Guard OAuth registry matching by authMode. providerMatchesRegistryTransport() returns true for every OAuth entry, so a pre-existing key provider named nous can be retargeted and routed with Nous OAuth credentials. Return false when an OAuth entry matches a provider whose authMode is not "oauth"; keep OAuth providers pinned to the registry endpoint.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/registry.ts` around lines 1077 - 1091, Update
providerMatchesRegistryTransport() to reject OAuth registry entries when the
matching provider’s authMode is not "oauth", preventing pre-existing key-auth
providers such as "nous" from being retargeted. Preserve the existing OAuth
matching behavior for providers configured with authMode "oauth" and keep them
pinned to the registry endpoint.

1092-1099: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The note string duplicates the dated seed list and will go stale.

Line 1099 repeats the four fallback slugs and the date 2026-08-10 that already appear in the comment at Lines 1070-1074 and in the models array at Line 1091. liveModels: true means the real catalog comes from discovery, so this list is the logged-out fallback only. When the fallback changes, three places must change together, and the note is the one that is user-visible.

Drop the enumeration from the user-facing note and keep the authoritative list in models.

♻️ Proposed change
-    note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid + :free models discovered live (fallback seed 2026-08-10: tencent/hy3:free, poolside/laguna-s-2.1:free, stepfun/step-3.7-flash:free, poolside/laguna-xs-2.1:free).",
+    note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid and :free models are discovered live from the signed-in account.",
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

    modelDiscovery: {
      // Resolves against effectiveBaseUrl (registry baseUrl .../v1) to the same
      // canonical endpoint https://inference-api.nousresearch.com/v1/models.
      path: "models",
      maxResponseBytes: 262_144,
      maxModels: 512,
    },
    note: "Nous Research subscription gateway. OAuth device login with your own Portal account; mixed paid and :free models are discovered live from the signed-in account.",
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/providers/registry.ts` around lines 1092 - 1099, Update the user-facing
note in the Nous Research registry configuration to remove the dated fallback
model list, since the authoritative fallback entries remain in the models array
and live discovery supplies the catalog when available. Keep the note focused on
the subscription gateway and OAuth device login details without duplicating
model slugs or dates.
tests/nous-oauth-live.test.ts (2)

42-51: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check the OAuthProviderDef required members and whether the
# two-argument expect() form is used elsewhere in the suite.
set -uo pipefail

echo "=== OAuthProviderDef definition ==="
ast-grep run --pattern 'export interface OAuthProviderDef { $$$ }' --lang typescript src/oauth/types.ts
rg -nP --type=ts -C20 '\bOAuthProviderDef\b' src/oauth/types.ts

echo "=== Pinned Bun version ==="
rg -nP '"(bun-types|`@types/bun`)"\s*:' package.json
rg -nP '"bun"\s*:' package.json

echo "=== Is the two-argument expect() form used anywhere else? ==="
rg -nP --type=ts -C1 'expect\([^)]+,\s*"' tests | head -40

Repository: lidge-jun/opencodex

Length of output: 3111


🌐 Web query:

Does Bun 1.3 bun:test expect() accept a second message argument?

💡 Result:

In Bun 1.3, the expect function in bun:test does not accept a second message argument for custom error messages [1]. While there have been community discussions and feature requests to add this capability (similar to other test frameworks), it is not a natively supported feature of the expect function itself [1]. Users typically implement custom error reporting within their test logic or through custom matchers if specific error messaging is required [2].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== OAuthProviderDef declarations and uses ==="
rg -n -C8 --glob '*.ts' --glob '*.tsx' 'OAuthProviderDef' src tests

echo "=== Relevant source files ==="
fd -t f -a 'types.ts|index.ts|nous-oauth-live.test.ts|tsconfig.*|package.json' .

echo "=== Target test context ==="
cat -n tests/nous-oauth-live.test.ts | sed -n '35,85p'

echo "=== Typecheck configuration and scripts ==="
cat -n package.json | sed -n '1,110p'
fd -t f -a -g 'tsconfig*.json' -x sh -c 'echo "--- $1"; cat -n "$1"' sh '{}'

Repository: lidge-jun/opencodex

Length of output: 22348


🌐 Web query:

site:github.com/oven-sh/bun bun:test expect second argument message TypeScript definitions 1.3.14

💡 Result:

In Bun version 1.3.14, the TypeScript definition for the expect function in bun:test includes an optional second argument, customFailMessage, which allows you to specify a custom error message to display if the test assertion fails [1]. The signature of the expect function is defined as [1]: (actual?: never, customFailMessage?: string): Matchers; <T = unknown>(actual: T, customFailMessage?: string): Matchers; By providing this second string argument, you can pass a custom message that will be surfaced in the test output when the assertion fails, providing more context than the default failure message [1][2]. This feature addresses the need for descriptive error messages in test suites, as previously requested by users [3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Imported OAuthProviderDef definition ==="
cat -n src/oauth/types.ts | sed -n '1,220p'

echo "=== All local declarations of OAuthProviderDef ==="
rg -n -C12 --glob '*.ts' '(^|[[:space:]])(export[[:space:]]+)?(interface|type)[[:space:]]+OAuthProviderDef\b' .

echo "=== Test inclusion in TypeScript configurations ==="
rg -n -C4 '"include"|"exclude"|"types"|"typeRoots"' --glob 'tsconfig*.json' .
rg -n -C3 'typecheck|tsc|bun test|tests/' package.json scripts tests

echo "=== Bun 1.3.14 tag references ==="
curl -fsSL 'https://api.github.com/repos/oven-sh/bun/git/refs/tags/bun-v1.3.14' | sed -n '1,80p'

echo "=== Bun expect declaration candidates ==="
for ref in bun-v1.3.14 main; do
  url="https://raw.githubusercontent.com/oven-sh/bun/$ref/packages/bun-types/test.d.ts"
  echo "--- $url"
  curl -fsSL "$url" | rg -n -C3 'function expect|expect<|customFailMessage' || true
done

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== Complete private OAuthProviderDef shape ==="
cat -n src/oauth/index.ts | sed -n '137,160p'

echo "=== Complete imported type module and exports ==="
cat -n src/oauth/types.ts
printf '\nExports named OAuthProviderDef:\n'
rg -n 'OAuthProviderDef|^export ' src/oauth/types.ts || true

echo "=== Exact Bun 1.3.14 tag and expect declaration ==="
curl -fsSL 'https://api.github.com/repos/oven-sh/bun/git/refs/tags/bun-v1.3.14' \
  | jq '{ref: .ref, sha: .object.sha, type: .object.type}'
curl -fsSL 'https://raw.githubusercontent.com/oven-sh/bun/bun-v1.3.14/packages/bun-types/test.d.ts' \
  | rg -n -C5 'customFailMessage|function expect|expect<' | head -80

echo "=== Read-only source verifier ==="
python3 - <<'PY'
from pathlib import Path
import re

types = Path("src/oauth/types.ts").read_text()
index = Path("src/oauth/index.ts").read_text()
test = Path("tests/nous-oauth-live.test.ts").read_text()

print("types exports OAuthProviderDef:",
      bool(re.search(r"\b(?:interface|type)\s+OAuthProviderDef\b", types)))
m = re.search(r"interface OAuthProviderDef\s*\{(.*?)\n\}", index, re.S)
if not m:
    raise SystemExit("private interface not found")
members = re.findall(r"^\s*([A-Za-z_$][\w$]*)\s*(?:\?|:|\()", m.group(1), re.M)
print("index OAuthProviderDef members:", members)
print("test imports OAuthProviderDef from src/oauth/types.ts:",
      'import type { OAuthProviderDef } from "../src/oauth/types"' in test)
print("two-argument expect calls:",
      len(re.findall(r"\bexpect\([^,\n]+,\s*"', test)))
PY

Repository: lidge-jun/opencodex

Length of output: 5507


Use an exported provider type

tests/nous-oauth-live.test.ts:27 imports OAuthProviderDef from src/oauth/types.ts, but that module does not export it. The private definition in src/oauth/index.ts:137-153 also requires login, providerConfig, and defaultModel, so it cannot type the minimal literal at lines 42–45. Export a refresh-only provider type for refreshGenericAccountWithLock, or provide the complete provider definition. Bun 1.3.14 supports the two-argument expect form.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth-live.test.ts` around lines 42 - 51, Update the provider
typing used by NOUS_DEF and refreshGenericAccountWithLock so it references an
exported type from src/oauth/types.ts. Add or reuse a refresh-only provider type
matching the minimal id and refresh members, rather than importing the private
full provider definition from src/oauth/index.ts; preserve the existing
two-argument expect usage.

61-75: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Determine whether a store account row id equals the credential's
# accountId (JWT sub) for a provider saved through saveCredential.
set -uo pipefail

echo "=== newAccountId: how row ids are minted ==="
ast-grep run --pattern 'function newAccountId($$$) { $$$ }' --lang typescript src/oauth/store.ts

echo "=== SINGLE_SLOT_PROVIDERS: does nous get special-cased? ==="
rg -nP --type=ts -C4 '\bSINGLE_SLOT_PROVIDERS\b' src/oauth/store.ts

echo "=== How other tests resolve an account id for the refresh coordinator ==="
rg -nP --type=ts -C2 'refreshGenericAccountWithLock\s*\(' tests

Repository: lidge-jun/opencodex

Length of output: 1762


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== relevant store APIs and account-id construction ==="
rg -n -C8 'function newAccountId|export function (getCredential|getAccountCredential|getAccountSet)|function saveCredential|export async function saveCredential|accounts\.find|activeAccountId|identityFromNousTokens' src/oauth/store.ts src/oauth/nous.ts

echo "=== live test imports and setup ==="
cat -n tests/nous-oauth-live.test.ts | sed -n '1,90p'

echo "=== refresh coordinator lookup and call contract ==="
rg -n -C8 'refreshGenericAccountWithLock|OAuthLoginRequiredError' src tests/oauth-refresh.test.ts tests/oauth-refresh-lock-multiprocess.test.ts

echo "=== focused static verifier for identifier flow ==="
python3 - <<'PY'
from pathlib import Path
store = Path("src/oauth/store.ts").read_text()
live = Path("tests/nous-oauth-live.test.ts").read_text()
nous = Path("src/oauth/nous.ts").read_text()
for needle, text in [
    ("SINGLE_SLOT_PROVIDERS = new Set([\"chatgpt\"])", store),
    ("accounts.find(a => a.id === accountId)", store),
    ("activeAccountId", live),
    ("stored!.accountId!", live),
    ("sub", nous),
]:
    print(f"{needle!r}: {needle in text}")
PY

Repository: lidge-jun/opencodex

Length of output: 50375


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== coordinator implementation ==="
rg -n -C12 'export async function refreshGenericAccountWithLock|async function refreshGenericAccountWithLock|refreshGenericAccountWithLock\s*=' src/oauth

echo "=== all direct account-id setup in the Nous refresh tests ==="
sed -n '755,885p' tests/oauth-refresh.test.ts

echo "=== account-id type definitions ==="
rg -n -C5 'interface ProviderAccount|type ProviderAccount|interface OAuthCredentials|type OAuthCredentials' src/oauth

Repository: lidge-jun/opencodex

Length of output: 10024


Use the store row ID for refresh operations.

stored.accountId is the Nous JWT sub (src/oauth/nous.ts:279-287). The store derives the row ID as sha256(accountId).slice(0, 8) (src/oauth/store.ts:295-297). At src/oauth/index.ts:604-605, the coordinator looks up the row by that ID and throws OAuthLoginRequiredError when it is absent.

Import getAccountSet, assign const rowId = getAccountSet("nous")?.activeAccountId, assert that it exists, and pass rowId! to refreshGenericAccountWithLock and getAccountCredential at tests/nous-oauth-live.test.ts:63,72. Existing Nous refresh tests use this pattern.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth-live.test.ts` around lines 61 - 75, Update the Nous refresh
test to use the store row ID rather than the JWT account ID: import and call
getAccountSet("nous"), assign its activeAccountId to rowId, and assert that
rowId exists. Pass rowId! to both refreshGenericAccountWithLock and
getAccountCredential, while preserving the existing token-rotation assertions.
tests/nous-oauth.test.ts (2)

10-13: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Hoist the env-save variables into each describe block instead of sharing module-level let bindings.

Lines 12-13 declare previousOpencodexHome and previousPortalBase at module scope. Four separate describe blocks write them from their own beforeEach (Lines 62-63, 274, 400-401, 528-531). Two later blocks already use block-local variables instead (previousHome at Lines 669 and 747), which is the correct pattern.

Under Bun's default sequential execution within a file this works. It breaks the moment anyone adds test.concurrent, or runs with --randomize combined with a nested async afterEach, because two blocks then interleave through one save slot and restore the wrong value. The shared TEST_DIR at Line 10 has the same property: every block does rmSync then mkdirSync on the identical path.

Make each block own its state, matching the pattern the file already uses at Lines 669 and 747.

♻️ Proposed change
 const TEST_DIR = join(import.meta.dir, ".tmp-nous-oauth-test");
 const TEST_PORTAL = "https://portal.test";
-let previousOpencodexHome: string | undefined;
-let previousPortalBase: string | undefined;

Then declare let previousOpencodexHome: string | undefined; and let previousPortalBase: string | undefined; inside each describe body that needs them.

As per path instructions for tests/**, tests are flat Bun tests under tests/; keeping per-block isolation self-contained keeps a focused regression test independent of its neighbours.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth.test.ts` around lines 10 - 13, Move previousOpencodexHome
and previousPortalBase from module scope into each describe block whose
beforeEach/afterEach uses them, following the existing previousHome pattern in
the later blocks. Keep each block’s environment-save state independent, and
avoid sharing TEST_DIR across concurrent or interleaved blocks by giving each
describe its own temporary directory state and path.

Source: Path instructions


271-303: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

This describe block does not save NOUS_PORTAL_BASE_URL before its tests overwrite and delete it.

The beforeEach at Lines 273-278 saves and restores OPENCODEX_HOME but not NOUS_PORTAL_BASE_URL. The test at Line 287 assigns process.env.NOUS_PORTAL_BASE_URL and its finally at Line 301 issues delete process.env.NOUS_PORTAL_BASE_URL. The same pattern repeats at Lines 318, 340, 372, and 391.

If the ambient environment already defines NOUS_PORTAL_BASE_URL, these tests delete it permanently for the rest of the process. Bun can run several test files in one process, so the deletion is not contained to this file. Every other describe block in this file saves and restores the variable (Lines 62, 75-76, 157, 163-164, 400, 410-411, 528, 539-540); this block is the outlier.

Save and restore it here as well.

💚 Proposed fix
 describe("Nous Portal base URL hardening", () => {
   const realFetch = globalThis.fetch;
   beforeEach(() => {
     previousOpencodexHome = process.env.OPENCODEX_HOME;
+    previousPortalBase = process.env.NOUS_PORTAL_BASE_URL;
     if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
     mkdirSync(TEST_DIR, { recursive: true });
     process.env.OPENCODEX_HOME = TEST_DIR;
   });
   afterEach(() => {
     globalThis.fetch = realFetch;
     if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
     else process.env.OPENCODEX_HOME = previousOpencodexHome;
+    if (previousPortalBase === undefined) delete process.env.NOUS_PORTAL_BASE_URL;
+    else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase;
     if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
   });

The per-test delete calls in the finally blocks then become redundant and can be removed, since afterEach restores the correct prior value.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

describe("Nous Portal base URL hardening", () => {
  const realFetch = globalThis.fetch;
  beforeEach(() => {
    previousOpencodexHome = process.env.OPENCODEX_HOME;
    previousPortalBase = process.env.NOUS_PORTAL_BASE_URL;
    if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
    mkdirSync(TEST_DIR, { recursive: true });
    process.env.OPENCODEX_HOME = TEST_DIR;
  });
  afterEach(() => {
    globalThis.fetch = realFetch;
    if (previousOpencodexHome === undefined) delete process.env.OPENCODEX_HOME;
    else process.env.OPENCODEX_HOME = previousOpencodexHome;
    if (previousPortalBase === undefined) delete process.env.NOUS_PORTAL_BASE_URL;
    else process.env.NOUS_PORTAL_BASE_URL = previousPortalBase;
    if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true });
  });

  test("an HTTP override fails before fetch is invoked", async () => {
    process.env.NOUS_PORTAL_BASE_URL = "http://portal.test";
    let fetchCalled = false;
    const realFetch = globalThis.fetch;
    globalThis.fetch = (async () => {
      fetchCalled = true;
      return new Response("{}", { status: 200 });
    }) as typeof fetch;
    try {
      const ctrl: OAuthController = { onAuth() {} };
      await expect(loginNous(ctrl)).rejects.toThrow(/must use HTTPS/);
      await expect(refreshNousToken("hardening-refresh")).rejects.toThrow(/must use HTTPS/);
      expect(fetchCalled).toBe(false);
    } finally {
      globalThis.fetch = realFetch;
      delete process.env.NOUS_PORTAL_BASE_URL;
    }
  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth.test.ts` around lines 271 - 303, Update the “Nous Portal
base URL hardening” describe block to save the ambient NOUS_PORTAL_BASE_URL
value in beforeEach and restore it in afterEach, preserving both defined and
undefined states. Remove the redundant per-test deletion of NOUS_PORTAL_BASE_URL
from the affected finally blocks, while retaining fetch cleanup and existing
test behavior.
tests/oauth-refresh.test.ts (1)

911-951: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Add coverage for a concurrent commit that lands in the persisted branch, not only the superseded branch.

This test covers the case where mergeAccountCredential reports superseded, so src/oauth/index.ts takes the else branch at Lines 678-683 and marks the old generation, which is a no-op. That path is correct and well covered.

The uncovered path is the persisted branch at Lines 662-677. There, the code re-reads the credential with getAccountCredential after a successful write and derives persistedGeneration from whatever it finds. A writer that commits between the successful merge and that re-read causes the code to mark a credential it never wrote. No test exercises that window, which is why the defect I flagged on src/oauth/index.ts Lines 662-677 is invisible to this suite.

The existing afterPrePersistRead hook on GenericRefreshDeps is the seam for a deterministic test, but preserveNousRotatedRefresh does not pass it through. A simpler deterministic approach is to make the merge spy commit a newer credential and still report { superseded: false }, which reproduces the exact interleaving.

💚 Sketch of the missing regression test
+  test("Nous RT-B preservation does not mark a credential committed by a concurrent writer", async () => {
+    await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-toctou" });
+    const id = getAccountSet("nous")!.activeAccountId;
+
+    const unusableAccess = nousAccessJwt("nous-toctou", "billing:manage");
+    globalThis.fetch = (async () => new Response(JSON.stringify({
+      access_token: unusableAccess,
+      refresh_token: "rt-new",
+      expires_in: 3600,
+    }), { status: 200 })) as typeof fetch;
+
+    // The recovery write succeeds, then a concurrent login commits a fresh,
+    // fully usable credential before the coordinator re-reads the store.
+    const realMerge = storeModule.mergeAccountCredential;
+    const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async (provider, accountId, cred, opts) => {
+      const outcome = await realMerge(provider, accountId, cred, opts);
+      await saveCredential("nous", {
+        access: nousAccessJwt("nous-toctou"),
+        refresh: "rt-fresh-login",
+        expires: Date.now() + 3600_000,
+        accountId: "nous-toctou",
+      });
+      return outcome;
+    });
+    try {
+      await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError);
+      // The concurrently committed, usable credential must remain usable.
+      expect(getCredential("nous")?.refresh).toBe("rt-fresh-login");
+      expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined();
+    } finally {
+      mergeSpy.mockRestore();
+    }
+  });

As per path instructions for tests/**, "A behavior change in src/ should come with a focused regression test near the existing tests for that subsystem." The generation-safety behavior added in this PR needs the persisted interleaving covered, not only superseded.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

  test("Nous RT-B preservation never overwrites a newer concurrent generation", async () => {
    await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-superseded" });
    const id = getAccountSet("nous")!.activeAccountId;
    const credential = getAccountCredential("nous", id)!;

    const unusableAccess = nousAccessJwt("nous-superseded", "billing:manage");
    globalThis.fetch = (async () => new Response(JSON.stringify({
      access_token: unusableAccess,
      refresh_token: "rt-new",
      expires_in: 3600,
    }), { status: 200 })) as typeof fetch;

    // A concurrent refresh already committed a newer generation (RT-C) before
    // this attempt's RT-B persistence runs; the merge must report superseded
    // and never overwrite it.
    const newerCredential = {
      access: "newer-access",
      refresh: "rt-concurrent",
      expires: Date.now() + 3600_000,
      accountId: "nous-superseded",
    };
    // The concurrent refresh commits RT-C through the real store write before
    // this attempt's RT-B persistence is detected as superseded.
    const realMerge = storeModule.mergeAccountCredential;
    const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async (provider, accountId, cred, opts) => {
      await realMerge(provider, accountId, newerCredential as never, { expectedGeneration: opts?.expectedGeneration });
      return { superseded: true, stored: newerCredential as never };
    });
    try {
      await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError);
      // The concurrent credential is untouched.
      expect(getCredential("nous")?.refresh).toBe("rt-concurrent");
      // RT-A's intent is not cleared (RT-A was consumed; a later refresh of the
      // newer generation manages its own intent).
      expect(nousRefreshIntentBlocksReplay("rt-old")).toBe(true);
      // The newer generation is NOT marked needsReauth (generation-safe no-op).
      expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined();
    } finally {
      mergeSpy.mockRestore();
    }
  });

  test("Nous RT-B preservation does not mark a credential committed by a concurrent writer", async () => {
    await saveCredential("nous", { access: "old", refresh: "rt-old", expires: 1, accountId: "nous-toctou" });
    const id = getAccountSet("nous")!.activeAccountId;

    const unusableAccess = nousAccessJwt("nous-toctou", "billing:manage");
    globalThis.fetch = (async () => new Response(JSON.stringify({
      access_token: unusableAccess,
      refresh_token: "rt-new",
      expires_in: 3600,
    }), { status: 200 })) as typeof fetch;

    // The recovery write succeeds, then a concurrent login commits a fresh,
    // fully usable credential before the coordinator re-reads the store.
    const realMerge = storeModule.mergeAccountCredential;
    const mergeSpy = spyOn(storeModule, "mergeAccountCredential").mockImplementation(async (provider, accountId, cred, opts) => {
      const outcome = await realMerge(provider, accountId, cred, opts);
      await saveCredential("nous", {
        access: nousAccessJwt("nous-toctou"),
        refresh: "rt-fresh-login",
        expires: Date.now() + 3600_000,
        accountId: "nous-toctou",
      });
      return outcome;
    });
    try {
      await expect(getValidAccessTokenForAccount("nous", id)).rejects.toBeInstanceOf(OAuthLoginRequiredError);
      // The concurrently committed, usable credential must remain usable.
      expect(getCredential("nous")?.refresh).toBe("rt-fresh-login");
      expect(getAccountSet("nous")!.accounts[0]!.needsReauth).toBeUndefined();
    } finally {
      mergeSpy.mockRestore();
    }
  });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/oauth-refresh.test.ts` around lines 911 - 951, Add a focused regression
test alongside “Nous RT-B preservation never overwrites a newer concurrent
generation” that has the mergeAccountCredential spy commit a newer credential
through the real store write but return { superseded: false, stored: ... },
exercising the persisted branch of getValidAccessTokenForAccount. Assert the
concurrent refresh remains stored and the newer generation is not marked
needsReauth, while preserving the existing replay-intent expectations; restore
the spy in a finally block.

Source: Path instructions

@Ingwannu Ingwannu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The incremental direction is correct, but I am requesting changes on current head 25d462fd3210798795e7dc4637793a5c5ee86bca for two in-scope correctness gaps:

  1. The live test still uses stored.accountId as the account-store row id in both refreshGenericAccountWithLock() and the new getAccountCredential() call. Those are different identities: saveCredential() stores rows under newAccountId(credential), an 8-character SHA-256-derived id, while credential.accountId is the provider/JWT identity. As written, getAccountCredential(provider, accountId) returns null and the coordinator fails before the live refresh. Read getAccountSet("nous")!.activeAccountId once, assert it exists, and pass that row id to both the coordinator and the post-refresh read.
  2. The same raw Kiro install pipes still exist in the English table at docs-site/src/content/docs/guides/providers.md:117. Escaping only ja/ko/ru/zh-cn leaves the canonical English table malformed. Apply the same &#124; substitution there and keep the displayed commands unchanged.

The current exact-head CI is also not green: test 3/4 was cancelled during its Test step after 15 minutes, which makes the aggregate ci job fail. After the focused fixes, rerun that shard/CI and keep the merge gated on a complete green head.

I am not folding the broad CodeRabbit comments against the already-merged #1397 base into this five-file follow-up review; this request is limited to defects introduced or left incomplete by the incremental diff.

- preserveNousRotatedRefresh returns the exact generation it wrote, removing
  the post-merge getAccountCredential re-read so a concurrent writer cannot be
  marked needsReauth (TOCTOU). Rethrow OAuthMutationBusyError unchanged so the
  caller can retry.
- parseTokenPayload: a missing access_token is now a terminal NousTokenError
  (invalid_token) instead of a plain Error.
- Live test: read the store row id via getAccountSet(...).activeAccountId and
  pass rowId! to both refreshGenericAccountWithLock and getAccountCredential
  (the row id is a SHA-256-derived hash, not the JWT sub).
- Docs: escape the Kiro install pipes (&lidge-jun#124;) in the English providers table
  too, and add command-code to the oauth 'Used by' list across all five locales.
- Regression: persisted-branch TOCTOU test (concurrent writer not marked
  needsReauth).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (10)
src/oauth/nous.ts (5)

290-295: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Bound the JWT exp claim before you prefer it over expires_in.

jwtExpiryMs accepts any finite number and line 434 prefers it over expires_in. The claim comes from a JWT that this code decodes but never verifies, and there is no sanity bound. Two concrete failure modes:

  1. A too-large exp (server bug, wrong unit — for example the Portal emitting milliseconds instead of seconds — or clock skew) produces an expiry years in the future. The shared refresh gate only refreshes on expiry, so the credential is never refreshed. Every inference request then fails with 401 and the client never self-recovers. The user must re-authenticate manually.
  2. A too-small exp produces expires in the past, or even negative after subtracting OAUTH_EXPIRY_SKEW_MS. That drives an immediate refresh on every call, and each refresh consumes a single-use token. Combined with the replay guard, that path burns the session.

Clamp exp to a plausible window relative to now, and fall back to expires_in when the claim is out of range.

🛡️ Proposed fix: reject implausible `exp` values
+/** Upper bound on a plausible inference-JWT lifetime; guards against a bad
+ *  `exp` unit (ms instead of s) or a badly skewed clock. */
+const MAX_PLAUSIBLE_TOKEN_LIFETIME_MS = 30 * 24 * 60 * 60 * 1000;
+
 /** JWT `exp` (epoch seconds) → expiry ms, when present and sane. */
 function jwtExpiryMs(payload: NousJwtPayload | undefined): number | undefined {
   const exp = payload?.exp;
   if (typeof exp !== "number" || !Number.isFinite(exp)) return undefined;
-  return exp * 1000;
+  const expMs = exp * 1000;
+  // Ignore an implausible claim and let the caller fall back to `expires_in`.
+  if (expMs <= Date.now() || expMs > Date.now() + MAX_PLAUSIBLE_TOKEN_LIFETIME_MS) return undefined;
+  return expMs;
 }

Also clamp the final value so a skew subtraction can never yield a negative expiry:

-  const expires = (expMs ?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_DEVICE_FLOW_TTL_MS))
-    - OAUTH_EXPIRY_SKEW_MS;
+  const rawExpires = expMs
+    ?? (expiresInMs !== undefined ? Date.now() + expiresInMs : Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS);
+  const expires = Math.max(0, rawExpires - OAUTH_EXPIRY_SKEW_MS);

Separately, line 434 uses DEFAULT_DEVICE_FLOW_TTL_MS (the device-authorization window) as the fallback access token lifetime. Those are unrelated durations. Introduce a distinct DEFAULT_ACCESS_TOKEN_TTL_MS constant so a later change to the device-flow window does not silently move token expiry.

Also applies to: 429-435

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 290 - 295, Update jwtExpiryMs to accept exp
only within a plausible now-relative window, returning undefined for
out-of-range values so the caller falls back to expires_in, and ensure the final
skew-adjusted expiry cannot be negative. In the access-token expiry flow around
jwtExpiryMs, replace DEFAULT_DEVICE_FLOW_TTL_MS with a distinct
DEFAULT_ACCESS_TOKEN_TTL_MS constant used only for access-token lifetime
fallback.

104-109: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Intent files accumulate without bound, and updatedAt is recorded but never used.

Every refresh writes one JSON file into .nous-refresh-intent, keyed by sha256(refreshToken). Removal happens only through clearNousRefreshIntent, which src/oauth/index.ts calls best-effort and whose failures it deliberately swallows (lines 642-648 and 676-682 of that file). Any of these leaves an orphan file forever:

  • clearRefreshIntent throws RefreshIntentIOError on a non-ENOENT unlink failure, and the caller logs and continues.
  • The superseded branch of the happy path in src/oauth/index.ts (lines 628-631) returns before reaching the cleanup call.
  • Any caller of refreshNousToken that is not refreshGenericAccountWithLock never clears anything.
  • A crash between the 200 response and persistence leaves the file by design.

Nothing prunes the directory. A long-lived install that refreshes hourly writes roughly 8,700 files per year into one directory. That is not fatal, but it degrades directory-scan performance and never self-heals.

RefreshIntent.updatedAt makes this fixable and is currently dead weight: parseRefreshIntent validates it at line 151, and readRefreshIntent then discards it. No code path reads the value.

Use it. Add a maximum intent age and drop expired entries on read, plus an opportunistic sweep on write. Choose the age well above any realistic crash-recovery window (a refresh token's own lifetime is the natural bound) so the fail-closed replay guard keeps its safety property.

♻️ Proposed fix: expire stale intents using the recorded `updatedAt`
+// An intent older than this can no longer correspond to a live session: the
+// refresh token it keys has itself expired server-side, so retaining the
+// block protects nothing and only leaks a file.
+const REFRESH_INTENT_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000;
+
+/** Drop intent files whose recorded `updatedAt` is older than the max age. */
+function pruneStaleRefreshIntents(): void {
+  const dir = refreshIntentDir();
+  if (!existsSync(dir)) return;
+  const cutoff = Date.now() - REFRESH_INTENT_MAX_AGE_MS;
+  for (const name of readdirSync(dir)) {
+    if (!name.endsWith(".json")) continue;
+    const path = join(dir, name);
+    try {
+      if (parseRefreshIntent(readFileSync(path, "utf8")).updatedAt < cutoff) {
+        rmSync(path, { force: true });
+      }
+    } catch {
+      // A file we cannot read stays: fail-closed beats deleting a live guard.
+    }
+  }
+}

Call pruneStaleRefreshIntents() from writeRefreshIntent after mkdirSync, and wrap it so a prune failure never blocks the refresh. Note that parseRefreshIntent returns updatedAt: Date.now() for corrupt input, so a corrupt file is never pruned by age. That is the correct fail-closed behavior.

Also applies to: 151-154, 190-199

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 104 - 109, Use RefreshIntent.updatedAt to
expire stale refresh intents: define a maximum age based on the refresh token
lifetime, have read logic discard entries older than that threshold, and add
pruneStaleRefreshIntents to remove expired files. Invoke pruning from
writeRefreshIntent after directory creation, but swallow or log pruning failures
so they never block refresh; preserve parseRefreshIntent’s fail-closed handling
for corrupt entries.

226-255: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

The HTTPS enforcement is right; the docstring overstates it.

The validation matches the required discipline for a credential-bearing endpoint: HTTPS only, no embedded credentials, no query, no fragment, origin-only return. Based on learnings, operator-controlled provider URLs may use http: or https: in general, but OAuth adapters that attach bearer credentials must enforce HTTPS separately — this function does exactly that.

One inaccuracy: lines 226-228 say the function mirrors _NOUS_PORTAL_ALLOWED_HOSTS from Hermes. No host allowlist is implemented here; any HTTPS origin passes. Either drop the allowlist claim from the comment, or add the host check the comment promises. The comment as written could lead a future maintainer to assume host pinning exists.

📝 Proposed comment correction
- * Mirrors the allowlist discipline in Hermes `hermes_cli/auth.py`
- * (`_NOUS_PORTAL_ALLOWED_HOSTS`, https-only default
- * `DEFAULT_NOUS_PORTAL_URL`).
+ * Mirrors the https-only default in Hermes `hermes_cli/auth.py`
+ * (`DEFAULT_NOUS_PORTAL_URL`). Note: unlike Hermes, this function does not
+ * pin the host to an allowlist; any HTTPS origin passes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 226 - 255, Correct the documentation above
resolvePortalBaseUrl so it no longer claims to mirror Hermes’s host allowlist,
since the function only validates HTTPS, credentials, query, fragment, and
origin normalization. Keep the existing validation behavior unchanged and
describe only the HTTPS/default-URL discipline actually implemented.

Source: Learnings


501-553: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

A single transient network error aborts the whole 15-minute device login.

pollForToken wraps each poll in requestSignal(signal), which applies a 30-second AbortSignal.timeout. Nothing in the loop catches a rejected fetch. Any transport-level failure — the 30-second timeout firing, a DNS hiccup, a dropped connection, a proxy reset — propagates straight out of loginNous and destroys the device-authorization session, even though deadline may still be 14 minutes away and the user is actively signing in at the Portal.

This is the exact window where failures are most likely: the loop polls every 5 seconds for up to 15 minutes, so it issues up to 180 requests. One failure out of 180 kills the login and the user must restart with a new user code.

RFC 8628 polling should tolerate transient transport errors until expires_in elapses. Only genuine cancellation should abort early.

🛠️ Proposed fix: tolerate transient poll failures until the deadline
   while (Date.now() < deadline) {
     if (signal?.aborted) throw new Error("Login cancelled");
-    const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
-      method: "POST",
-      headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
-      body: new URLSearchParams({
-        client_id: NOUS_OAUTH_CLIENT_ID,
-        device_code: deviceCode,
-        grant_type: "urn:ietf:params:oauth:grant-type:device_code",
-      }),
-      redirect: "error",
-      signal: requestSignal(signal),
-    });
+    let response: Response;
+    try {
+      response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
+        method: "POST",
+        headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
+        body: new URLSearchParams({
+          client_id: NOUS_OAUTH_CLIENT_ID,
+          device_code: deviceCode,
+          grant_type: "urn:ietf:params:oauth:grant-type:device_code",
+        }),
+        redirect: "error",
+        signal: requestSignal(signal),
+      });
+    } catch (netErr) {
+      // Caller cancellation must still abort; a transient transport error must
+      // not destroy a device session that is still within its deadline.
+      if (signal?.aborted) throw new Error("Login cancelled");
+      await sleep(waitMs, signal);
+      continue;
+    }

The device-code grant is idempotent for the pending case, so a retried poll is safe here. This is unlike the refresh path, where retrying is unsafe.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 501 - 553, Update pollForToken to catch
transient fetch failures and continue polling until the existing deadline,
preserving the current wait interval between retries. Re-throw genuine
cancellation from the provided AbortSignal immediately, while allowing request
timeout, DNS, connection, and other transport errors to be retried without
aborting login; keep the existing OAuth response handling unchanged.

118-125: 🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Harden the existing refresh-intent directory at src/oauth/nous.ts:176-188.

refreshIntentDir() correctly resolves to <config-dir>/.nous-refresh-intent. However, mkdirSync(..., { mode: 0o700 }) does not change permissions on an existing directory, and hardenConfigDir() does not harden dir. Re-apply 0o700 and the platform-specific ACL hardening to dir after mkdirSync. atomicWriteFile() already protects the intent files with 0o600; the remaining exposure is directory access and hash-filename listing.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 118 - 125, Update the refresh-intent
directory setup in the function containing mkdirSync for refreshIntentDir so it
reapplies 0o700 permissions to the resolved dir after creation and invokes the
existing platform-specific ACL hardening on dir, rather than relying on
mkdirSync or hardening only the config directory.

Source: Path instructions

src/oauth/index.ts (2)

639-649: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

The generic coordinator now hard-codes provider === "nous" twice; move the hook onto OAuthProviderDef.

refreshGenericAccountWithLock is the shared, provider-agnostic refresh path. This change embeds Nous-specific knowledge in it at two sites: the post-persist cleanup at line 639 and the rotated-token recovery at line 660. Both branch on a string literal and call directly into ./nous.

Two concrete costs:

  1. The shared routing layer now imports and depends on one provider's implementation module. Per the path instruction for src/**, changes that bypass the shared routing/config layers are a flagged concern; this is the inverse — provider logic pushed into the shared layer.
  2. Single-use rotating refresh tokens are not unique to Nous. The next provider with the same semantics requires a third and fourth provider === "x" branch, and each one must independently re-derive the "clear only after durable persist" ordering. That ordering is subtle and easy to get wrong.

Express both operations as optional callbacks on OAuthProviderDef, and have the coordinator invoke them generically:

♻️ Proposed refactor: provider-declared rotation hooks
 // src/oauth/types.ts
 export interface OAuthProviderDef {
   id: string;
   refresh: (...) => Promise<OAuthCredentials>;
+  /**
+   * Called after a rotated credential is durably persisted, with the refresh
+   * token that was consumed. Best-effort bookkeeping only: the coordinator
+   * logs and continues if this throws. Providers with single-use rotating
+   * refresh tokens use this to release their replay guard.
+   */
+  onRotationPersisted?: (consumedRefresh: string) => void;
+  /**
+   * Extracts an already-rotated refresh token from a terminal refresh error,
+   * so the coordinator can preserve it before forcing reauthentication.
+   */
+  rotatedRefreshFromError?: (error: unknown) => string | undefined;
 }
 // src/oauth/index.ts — registration
   nous: {
     login: (ctrl) => loginNous(ctrl),
     refresh: (rt, signal) => refreshNousToken(rt, signal),
+    onRotationPersisted: (consumedRefresh) => clearNousRefreshIntent(consumedRefresh),
+    rotatedRefreshFromError: (error) =>
+      error instanceof NousTokenError ? error.getRotatedRefresh() : undefined,
     providerConfig: oauthConfig("nous"),
     defaultModel: oauthDefaultModel("nous"),
   },
 // src/oauth/index.ts — coordinator, post-persist cleanup
-      if (provider === "nous") {
-        try {
-          clearNousRefreshIntent(stored.refresh);
-        } catch (cleanupErr) {
+      if (def.onRotationPersisted) {
+        try {
+          def.onRotationPersisted(stored.refresh);
+        } catch (cleanupErr) {
           logOAuthEvent("OAuth refresh intent cleanup failed (non-fatal)", {
             provider,
             accountId,
             cause: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr),
           });
         }
       }

Apply the same substitution in the recovery branch, replacing provider === "nous" && error instanceof NousTokenError with def.rotatedRefreshFromError?.(error).

The recovery body itself (preserveNousRotatedRefresh) can stay as-is initially; only the dispatch needs to become generic.

Also applies to: 660-695

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/index.ts` around lines 639 - 649, Move Nous-specific refresh-token
handling out of refreshGenericAccountWithLock by adding optional post-persist
cleanup and rotated-refresh recovery callbacks to OAuthProviderDef. Configure
the Nous provider with clearNousRefreshIntent and preserveNousRotatedRefresh,
then invoke def’s callbacks generically in both coordinator branches, preserving
cleanup-after-durable-persist ordering and the existing recovery behavior.

Source: Path instructions


198-208: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Enforce Nous's lazy-only refresh policy

config.providers.nous.refreshPolicy overrides defaultRefreshPolicy, and the guardian refreshes any provider whose effective policy is "proactive". Set defaultRefreshPolicy: "lazy-only" in src/oauth/index.ts:198-208. If the constraint is absolute, also reject "proactive" for Nous to prevent refresh_token_reused session revocation.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/index.ts` around lines 198 - 208, Set Nous’s provider configuration
in the `nous` entry to use `defaultRefreshPolicy: "lazy-only"` so guardian
refreshes remain lazy-only. If `providerConfig` can override this policy, also
enforce the value by rejecting or preventing `"proactive"` for Nous.
tests/nous-oauth-live.test.ts (3)

1-45: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Security-relevant guards in src/oauth/nous.ts have no deterministic test coverage.

This file is the only new test that targets Nous directly, and it is skipped unless NOUS_LIVE_TEST=1. tests/oauth-refresh.test.ts covers the coordinator paths well with a mocked fetch, but several guards in src/oauth/nous.ts are never exercised offline:

  • resolvePortalBaseUrl rejection branches (lines 240-251): non-HTTPS scheme, embedded credentials, query string, fragment. These prevent sending a single-use refresh token over cleartext or to an attacker-influenced URL. They are pure functions of process.env.NOUS_PORTAL_BASE_URL and are trivial to test.
  • parseRefreshIntent fail-closed behavior (lines 136-155): the {} case, a wrong-typed status, and a non-finite updatedAt must all yield uncertain. The docstring calls out {} specifically as the bug being prevented, so a regression here would silently disable the replay guard.
  • pollForToken branch handling (lines 527-549): authorization_pending, slow_down interval escalation, expired_token, and access_denied.

Per the path instruction for tests/**, a behavior change in src/ should come with a focused regression test near the existing tests for that subsystem. Please add an offline tests/nous-oauth.test.ts covering these. The resolvePortalBaseUrl and parseRefreshIntent cases in particular are a few lines each and protect credential-transmission invariants.

I can draft that test file if you want.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth-live.test.ts` around lines 1 - 45, Add an offline focused
test file for the Nous OAuth helpers, alongside the existing OAuth tests,
without relying on NOUS_LIVE_TEST. Cover resolvePortalBaseUrl rejection of
non-HTTPS URLs, embedded credentials, query strings, and fragments; verify
parseRefreshIntent returns uncertain for {}, invalid status types, and
non-finite updatedAt; and exercise pollForToken handling for
authorization_pending, slow_down interval escalation, expired_token, and
access_denied using mocked fetch/timing. Keep credentials out of test output and
preserve existing production behavior.

Source: Path instructions


81-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Import NOUS_INFERENCE_BASE_URL instead of hard-coding the catalog URL.

src/oauth/nous.ts:44 exports NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1". Line 82 repeats that string literally. The comment on line 81 asserts this is "the same endpoint the adapter uses", but nothing enforces that.

If the constant changes, this test keeps hitting the old host and either passes against a stale endpoint or fails with a confusing 404, rather than following the code under test.

♻️ Proposed fix
-import { refreshNousToken } from "../src/oauth/nous";
+import { NOUS_INFERENCE_BASE_URL, refreshNousToken } from "../src/oauth/nous";
-    const res = await fetch("https://inference-api.nousresearch.com/v1/models", {
+    const res = await fetch(`${NOUS_INFERENCE_BASE_URL}/models`, {
       headers: { Authorization: `Bearer ${access}` },
     });
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth-live.test.ts` around lines 81 - 85, Update the live catalog
discovery test to import and use NOUS_INFERENCE_BASE_URL from src/oauth/nous.ts
when constructing the models endpoint, removing the duplicated hard-coded base
URL while preserving the existing authorization header and status assertion.

42-45: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Fix the invalid OAuthProviderDef annotation. tests/nous-oauth-live.test.ts:27,42-45 imports a type that src/oauth/types.ts does not export. The production interface in src/oauth/index.ts:137-153 is private and requires login, providerConfig, and defaultModel. The root tsconfig.json excludes tests/, so CI does not catch this error. Use a shared exported type or the registered Nous provider definition.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/nous-oauth-live.test.ts` around lines 42 - 45, Replace the invalid
OAuthProviderDef annotation in NOUS_DEF with a valid shared exported type or
reuse the registered Nous provider definition from the production OAuth
implementation; ensure the resulting definition satisfies the required login,
providerConfig, and defaultModel fields.

Source: Learnings

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs-site/src/content/docs/guides/providers.md`:
- Line 117: Replace both Kiro shell-pipe entities with the table-safe form
&amp;`#124`; in docs-site/src/content/docs/guides/providers.md:117-117,
docs-site/src/content/docs/ja/guides/providers.md:112-112,
docs-site/src/content/docs/ko/guides/providers.md:111-111,
docs-site/src/content/docs/ru/guides/providers.md:121-121, and
docs-site/src/content/docs/zh-cn/guides/providers.md:102-102; make no other
changes.

---

Outside diff comments:
In `@src/oauth/index.ts`:
- Around line 639-649: Move Nous-specific refresh-token handling out of
refreshGenericAccountWithLock by adding optional post-persist cleanup and
rotated-refresh recovery callbacks to OAuthProviderDef. Configure the Nous
provider with clearNousRefreshIntent and preserveNousRotatedRefresh, then invoke
def’s callbacks generically in both coordinator branches, preserving
cleanup-after-durable-persist ordering and the existing recovery behavior.
- Around line 198-208: Set Nous’s provider configuration in the `nous` entry to
use `defaultRefreshPolicy: "lazy-only"` so guardian refreshes remain lazy-only.
If `providerConfig` can override this policy, also enforce the value by
rejecting or preventing `"proactive"` for Nous.

In `@src/oauth/nous.ts`:
- Around line 290-295: Update jwtExpiryMs to accept exp only within a plausible
now-relative window, returning undefined for out-of-range values so the caller
falls back to expires_in, and ensure the final skew-adjusted expiry cannot be
negative. In the access-token expiry flow around jwtExpiryMs, replace
DEFAULT_DEVICE_FLOW_TTL_MS with a distinct DEFAULT_ACCESS_TOKEN_TTL_MS constant
used only for access-token lifetime fallback.
- Around line 104-109: Use RefreshIntent.updatedAt to expire stale refresh
intents: define a maximum age based on the refresh token lifetime, have read
logic discard entries older than that threshold, and add
pruneStaleRefreshIntents to remove expired files. Invoke pruning from
writeRefreshIntent after directory creation, but swallow or log pruning failures
so they never block refresh; preserve parseRefreshIntent’s fail-closed handling
for corrupt entries.
- Around line 226-255: Correct the documentation above resolvePortalBaseUrl so
it no longer claims to mirror Hermes’s host allowlist, since the function only
validates HTTPS, credentials, query, fragment, and origin normalization. Keep
the existing validation behavior unchanged and describe only the
HTTPS/default-URL discipline actually implemented.
- Around line 501-553: Update pollForToken to catch transient fetch failures and
continue polling until the existing deadline, preserving the current wait
interval between retries. Re-throw genuine cancellation from the provided
AbortSignal immediately, while allowing request timeout, DNS, connection, and
other transport errors to be retried without aborting login; keep the existing
OAuth response handling unchanged.
- Around line 118-125: Update the refresh-intent directory setup in the function
containing mkdirSync for refreshIntentDir so it reapplies 0o700 permissions to
the resolved dir after creation and invokes the existing platform-specific ACL
hardening on dir, rather than relying on mkdirSync or hardening only the config
directory.

In `@tests/nous-oauth-live.test.ts`:
- Around line 1-45: Add an offline focused test file for the Nous OAuth helpers,
alongside the existing OAuth tests, without relying on NOUS_LIVE_TEST. Cover
resolvePortalBaseUrl rejection of non-HTTPS URLs, embedded credentials, query
strings, and fragments; verify parseRefreshIntent returns uncertain for {},
invalid status types, and non-finite updatedAt; and exercise pollForToken
handling for authorization_pending, slow_down interval escalation,
expired_token, and access_denied using mocked fetch/timing. Keep credentials out
of test output and preserve existing production behavior.
- Around line 81-85: Update the live catalog discovery test to import and use
NOUS_INFERENCE_BASE_URL from src/oauth/nous.ts when constructing the models
endpoint, removing the duplicated hard-coded base URL while preserving the
existing authorization header and status assertion.
- Around line 42-45: Replace the invalid OAuthProviderDef annotation in NOUS_DEF
with a valid shared exported type or reuse the registered Nous provider
definition from the production OAuth implementation; ensure the resulting
definition satisfies the required login, providerConfig, and defaultModel
fields.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: bc495d90-556f-4f0d-9810-a6a96e0367ff

📥 Commits

Reviewing files that changed from the base of the PR and between 9d78f2d and c304e83.

📒 Files selected for processing (9)
  • docs-site/src/content/docs/guides/providers.md
  • docs-site/src/content/docs/ja/guides/providers.md
  • docs-site/src/content/docs/ko/guides/providers.md
  • docs-site/src/content/docs/ru/guides/providers.md
  • docs-site/src/content/docs/zh-cn/guides/providers.md
  • src/oauth/index.ts
  • src/oauth/nous.ts
  • tests/nous-oauth-live.test.ts
  • tests/oauth-refresh.test.ts

| `kimi` | `openai-chat` | `https://api.kimi.com/coding/v1` | Kimi K2.7/K2.6/K2.5 coding models. |
| `nous` | `openai-chat` | `https://inference-api.nousresearch.com/v1` | Nous Research subscription gateway (same backend Hermes Agent uses). Device-grant login against `portal.nousresearch.com`; the access token is the per-request inference JWT. Mixed paid + `:free` model catalog (`tencent/hy3:free`, `stepfun/step-3.7-flash:free`, ...) discovered live from the signed-in account. Refresh tokens are single-use and rotated on every refresh. |
| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install | bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' | iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. |
| `kiro` | `kiro` | `https://runtime.us-east-1.kiro.dev` | Initial login imports the installed, signed-in `kiro-cli` session (on Unix, install with `curl -fsSL https://cli.kiro.dev/install &#124; bash`; on Windows PowerShell, use `irm 'https://cli.kiro.dev/install.ps1' &#124; iex`; then run `kiro-cli login`). **Add account** logs `kiro-cli` out, starts a fresh browser login that switches the account used by `kiro-cli`, and stores account-scoped profile metadata. Existing OpenCodex accounts are preserved, and cancellation or failure restores the previous `kiro-cli` session. |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use one table-safe entity form in every locale.

All five Kiro table examples use &#124;, but the required source form is &amp;#124;.

  • docs-site/src/content/docs/guides/providers.md#L117-L117: replace both Kiro pipe entities.
  • docs-site/src/content/docs/ja/guides/providers.md#L112-L112: replace both Kiro pipe entities.
  • docs-site/src/content/docs/ko/guides/providers.md#L111-L111: replace both Kiro pipe entities.
  • docs-site/src/content/docs/ru/guides/providers.md#L121-L121: replace both Kiro pipe entities.
  • docs-site/src/content/docs/zh-cn/guides/providers.md#L102-L102: replace both Kiro pipe entities.

As per path instructions, Kiro shell examples in docs-site/** must use &amp;#124; so Markdown tables render correctly, including localized copies.

📍 Affects 5 files
  • docs-site/src/content/docs/guides/providers.md#L117-L117 (this comment)
  • docs-site/src/content/docs/ja/guides/providers.md#L112-L112
  • docs-site/src/content/docs/ko/guides/providers.md#L111-L111
  • docs-site/src/content/docs/ru/guides/providers.md#L121-L121
  • docs-site/src/content/docs/zh-cn/guides/providers.md#L102-L102
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs-site/src/content/docs/guides/providers.md` at line 117, Replace both
Kiro shell-pipe entities with the table-safe form &amp;`#124`; in
docs-site/src/content/docs/guides/providers.md:117-117,
docs-site/src/content/docs/ja/guides/providers.md:112-112,
docs-site/src/content/docs/ko/guides/providers.md:111-111,
docs-site/src/content/docs/ru/guides/providers.md:121-121, and
docs-site/src/content/docs/zh-cn/guides/providers.md:102-102; make no other
changes.

Source: Path instructions

…eview

- jwtExpiryMs: only accept an exp within a plausible now-relative window,
  falling back to expires_in for out-of-range claims; clamp skew-adjusted
  expiry to non-negative. Add a dedicated DEFAULT_ACCESS_TOKEN_TTL_MS so the
  device-flow window is not reused as the access-token fallback lifetime.
- pollForToken: tolerate transient transport errors (timeout/DNS/connection)
  until the device deadline; only genuine cancellation aborts early.
- writeRefreshIntent: re-apply owner-only 0o700 on an existing intent dir.
- resolvePortalBaseUrl docstring: stop claiming Hermes host-allowlist parity.
- parseTokenPayload: device-login missing refresh_token is invalid_token, not
  refresh_token_reused; missing access_token is now a terminal error.
- index.ts: anchor nous defaultRefreshPolicy explicitly to lazy-only.
- Live test: import NOUS_INFERENCE_BASE_URL instead of the hard-coded URL; use a
  structural refresh-only def type instead of the non-exported OAuthProviderDef.
- Regressions: implausible exp falls back to expires_in; device-login missing
  refresh_token is invalid_token.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/oauth/nous.ts (1)

418-426: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Apply missing-access-token validation to device login.

The new terminal invalid_token branch does not run for a successful device-flow response without access_token. At Line 567, pollForToken calls parseTokenPayload only when access_token is already non-empty. A 200 response without that field instead reaches tokenErrorFromPayload, so it does not receive the required terminal invalid_token classification.

Call parseTokenPayload for every successful token response. Add a device-flow regression test with a 200 response that omits access_token.

Proposed fix
-    if (response.ok && nonEmptyString(payload.access_token)) return parseTokenPayload(payload, "");
+    if (response.ok) return parseTokenPayload(payload, "");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 418 - 426, Update the device-flow logic in
pollForToken so every successful token response is passed to parseTokenPayload,
including responses missing access_token, allowing the terminal invalid_token
NousTokenError to be raised. Add a regression test for a 200 response without
access_token and verify it receives the terminal invalid_token classification.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/oauth/nous.ts`:
- Around line 188-190: Update writeRefreshIntent so a chmodSync(dir, 0o700)
failure is propagated or returned as an operation failure, preventing
atomicWriteFile from running. Remove the swallowed best-effort catch while
preserving the existing hardened-directory write flow when chmod succeeds.
- Around line 541-563: Update the transport-error retry path around the
device-flow polling loop to cap the sleep duration at the remaining time until
the deadline. Use the existing deadline and waitMs values when calling sleep,
while preserving cancellation handling and retry behavior before expiration.

---

Outside diff comments:
In `@src/oauth/nous.ts`:
- Around line 418-426: Update the device-flow logic in pollForToken so every
successful token response is passed to parseTokenPayload, including responses
missing access_token, allowing the terminal invalid_token NousTokenError to be
raised. Add a regression test for a 200 response without access_token and verify
it receives the terminal invalid_token classification.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7b1820bc-b5d0-492e-9cc8-fdbefb024b9e

📥 Commits

Reviewing files that changed from the base of the PR and between c304e83 and cb8ea6e.

📒 Files selected for processing (4)
  • src/oauth/index.ts
  • src/oauth/nous.ts
  • tests/nous-oauth-live.test.ts
  • tests/nous-oauth.test.ts

Comment thread src/oauth/nous.ts Outdated
Comment thread src/oauth/nous.ts
…e-flow deadline, classify missing access_token as terminal

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/oauth/nous.ts (2)

420-447: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Preserve replacement refresh tokens when access_token is missing.

When a refresh response contains a replacement refresh_token but no access_token, this early error path does not carry the replacement token. The refresh coordinator in src/oauth/index.ts Lines 606-707 can preserve rotated credentials only through NousTokenError.getRotatedRefresh(). It will otherwise mark the old generation for reauthentication and discard the replacement token.

Keep the error terminal, but propagate a non-empty replacement refresh token through the error's rotated-refresh field or equivalent. Add a refresh-path regression test in tests/nous-oauth.test.ts.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 420 - 447, The access-token validation in the
Nous token-response handling must preserve a non-empty replacement refresh token
when throwing the terminal NousTokenError. Pass the parsed refresh token through
the error’s rotated-refresh field or equivalent so the refresh coordinator can
retain rotated credentials, while leaving the terminal classification unchanged.
Add a regression test covering a refresh response with refresh_token but no
access_token in the Nous OAuth tests.

306-313: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Do not revive an expired access token through the fallback lifetime.

jwtExpiryMs returns undefined for an expired exp claim. parseTokenPayload then treats the claim as absent and uses expires_in or DEFAULT_ACCESS_TOKEN_TTL_MS. An expired JWT can therefore be stored as usable.

The expires_in check also accepts non-finite numeric values. An overflowed value can produce expires = Infinity and prevent refresh indefinitely.

Keep expired claims distinct from absent claims. Reject them or mark the credential expired. Validate expires_in as finite, non-negative, and bounded before using it. Add tests for expired exp values and overflowing expires_in values.

Also applies to: 465-467

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/oauth/nous.ts` around lines 306 - 313, The token parsing flow around
jwtExpiryMs and parseTokenPayload must distinguish an expired exp claim from an
absent claim so expired credentials cannot fall back to expires_in or
DEFAULT_ACCESS_TOKEN_TTL_MS; reject them or mark them expired. Validate
expires_in as finite, non-negative, and within the supported lifetime bound
before calculating expiry, including overflow cases, and add coverage for
expired exp and overflowing expires_in values.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/oauth/nous.ts`:
- Around line 563-566: Update the device-flow polling logic around the
transport-error, authorization_pending, and slow_down retry paths to use a
shared deadline-aware sleep helper that caps every wait by the remaining
deadline. After each fetch completes, recheck the deadline before accepting a
successful response so delayed responses cannot return credentials. Add focused
tests covering pending-response deadline handling and responses delayed past the
deadline.
- Around line 570-571: Normalize the parsed response in the token exchange flow
before calling parseTokenPayload: require a non-null object/record and replace
invalid values such as null with the existing empty-payload fallback. Preserve
successful object parsing and ensure invalid successful bodies produce the
intended NousTokenError; add a regression test covering a null response body.

---

Outside diff comments:
In `@src/oauth/nous.ts`:
- Around line 420-447: The access-token validation in the Nous token-response
handling must preserve a non-empty replacement refresh token when throwing the
terminal NousTokenError. Pass the parsed refresh token through the error’s
rotated-refresh field or equivalent so the refresh coordinator can retain
rotated credentials, while leaving the terminal classification unchanged. Add a
regression test covering a refresh response with refresh_token but no
access_token in the Nous OAuth tests.
- Around line 306-313: The token parsing flow around jwtExpiryMs and
parseTokenPayload must distinguish an expired exp claim from an absent claim so
expired credentials cannot fall back to expires_in or
DEFAULT_ACCESS_TOKEN_TTL_MS; reject them or mark them expired. Validate
expires_in as finite, non-negative, and within the supported lifetime bound
before calculating expiry, including overflow cases, and add coverage for
expired exp and overflowing expires_in values.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: cfe7cbae-e52a-448c-8d09-162b1939fe74

📥 Commits

Reviewing files that changed from the base of the PR and between cb8ea6e and 9c84042.

📒 Files selected for processing (2)
  • src/oauth/nous.ts
  • tests/nous-oauth.test.ts

Comment thread src/oauth/nous.ts Outdated
Comment thread src/oauth/nous.ts Outdated
Wibias added 2 commits August 11, 2026 08:32
… tables

A bare &lidge-jun#124; entity inside a code span is emitted literally by Astro's
markdown processor, so the Kiro CLI install commands showed the escaped
text instead of a pipe. Move the pipe out of the code span so it renders
as a visible | between the two command fragments, keeping the markdown
table intact across all five locales.
…normalize null token bodies

CodeRabbit follow-up on the deadline cap: authorization_pending and
slow_down still slept the full interval and a delayed success response
could return credentials after the deadline. Route every retry through a
deadline-aware sleep helper and recheck the deadline after each fetch.
Also normalize a valid-JSON null response body to an empty object so a
successful-but-null payload raises the terminal invalid_token
NousTokenError instead of a raw TypeError. Add a regression test for the
null-body case.
@Wibias
Wibias merged commit 8716629 into lidge-jun:dev Aug 11, 2026
71 of 78 checks passed
@Wibias
Wibias deleted the codex/nous-portal-oauth-followup branch August 11, 2026 07:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants