feat(oauth): add runtime auto-refresh token bridge#85
Conversation
- Add anthropic-token-bridge.js/.ts: checks token every 5 messages, auto-refreshes from macOS Keychain when <2h remaining - Add lib/token-utils.ts + lib/refresh-manager.ts: token lifecycle logic (3 strategies: Keychain → claude setup-token → exchange API) - Add info()/warn()/error() wrappers to file-logger.ts (needed by token bridge) - Add anthropic-token-bridge.js to contrib/anthropic-max-bridge/plugins/ - Update contrib/install.sh: copies both plugins, updates token refresh note
📝 WalkthroughWalkthroughAdds an Anthropic OAuth token auto-refresh plugin and supporting utilities that read/write session auth at Changes
Sequence Diagram(s)sequenceDiagram
participant Handler as Chat Handler
participant Plugin as Token Bridge Plugin
participant TokenUtils as Token Utils
participant AuthFile as Auth File\n(~/.local/share/opencode/auth.json)
participant Keychain as macOS Keychain
participant Claude as Claude CLI
participant AnthropicAPI as Anthropic OAuth API
Handler->>Plugin: Trigger check (every 5th user message or session start)
Plugin->>TokenUtils: checkAnthropicToken()
TokenUtils->>AuthFile: Read auth.json
AuthFile-->>TokenUtils: Token data / expiresAt
alt Missing/expired/expiring soon
TokenUtils-->>Plugin: status (requires refresh)
Plugin->>Plugin: isRefreshInProgress? / cooldown
alt Not refreshing
Plugin->>Keychain: Run `security find-generic-password`
alt Keychain returns tokens
Keychain-->>Plugin: access_token, refresh_token
else Keychain failed
Plugin->>Claude: Run `claude setup-token`
Claude-->>Plugin: setup_token (sk-ant-...)
Plugin->>AnthropicAPI: POST /v1/oauth/setup_token/exchange
AnthropicAPI-->>Plugin: access_token, refresh_token, expires_in
end
Plugin->>TokenUtils: updateAnthropicTokens(access, refresh, expires_in)
TokenUtils->>AuthFile: Write updated auth.json
AuthFile-->>TokenUtils: Write success
TokenUtils-->>Plugin: success
else Refresh in progress or cooldown
Plugin-->>Handler: Skip refresh (in-progress/cooldown)
end
else Valid
TokenUtils-->>Plugin: no action required
end
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
- README: Token Expiry → auto-refresh primary, manual fallback secondary - README: File Reference → add anthropic-token-bridge.js entry - README: install.sh step → mention both plugins copied - README: token flow diagram → show both plugins - README: HTTP 401 troubleshooting → mention auto-refresh failed - TECHNICAL: Token lifetime → replace manual-only with auto-refresh details
There was a problem hiding this comment.
Actionable comments posted: 5
🧹 Nitpick comments (3)
.opencode/plugins/lib/refresh-manager.ts (1)
21-40: Consider adding a timeout to prevent indefinite hangs.The
execCommandfunction has no timeout, so ifsecurityorclaudecommands hang (e.g., waiting for user input, Keychain prompt stuck), the refresh process will hang indefinitely.⏱️ Proposed fix to add timeout
-function execCommand(command: string, args: string[]): Promise<ExecResult> { - return new Promise((resolve) => { +const EXEC_TIMEOUT_MS = 30_000; // 30 seconds + +function execCommand(command: string, args: string[]): Promise<ExecResult> { + return new Promise((resolve) => { const child = spawn(command, args); let stdout = ""; let stderr = ""; + const timeout = setTimeout(() => { + child.kill(); + resolve({ stdout, stderr: "Command timed out", exitCode: 124 }); + }, EXEC_TIMEOUT_MS); child.stdout?.on("data", (data: Buffer) => { stdout += data.toString(); }); child.stderr?.on("data", (data: Buffer) => { stderr += data.toString(); }); child.on("close", (exitCode: number | null) => { + clearTimeout(timeout); resolve({ stdout, stderr, exitCode: exitCode ?? 0 }); }); child.on("error", (err: Error) => { + clearTimeout(timeout); resolve({ stdout, stderr: String(err), exitCode: 1 }); }); }); }🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.opencode/plugins/lib/refresh-manager.ts around lines 21 - 40, The execCommand function can hang indefinitely; update execCommand to accept an optional timeout parameter (or use a default timeout) and enforce it by starting a timer when spawn(command, args) is called, and on timeout kill the child process (child.kill()) and resolve with stderr indicating a timeout and a non-zero exitCode; ensure you clear the timer on normal close or error and remove/avoid double-resolving the promise by wiring the timer, 'close', and 'error' handlers to a single resolve path. Reference symbols: function execCommand, local child variable (spawn), child.on('close'), child.on('error')..opencode/plugins/anthropic-token-bridge.js (1)
41-59: Async/sync inconsistency: functions markedasyncbut use synchronous operations.
readAuthFileandwriteAuthFileare markedasyncandawaiton theerror()calls, buterror()is synchronous (returnsvoid). The TypeScript source uses synchronous functions. This won't break at runtime, but indicates the bundle may be out of sync with the source.Consider regenerating this bundle from the TypeScript source to ensure consistency.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.opencode/plugins/anthropic-token-bridge.js around lines 41 - 59, The bundle incorrectly marks readAuthFile and writeAuthFile as async and awaits error(), while they use synchronous fs APIs; change these to synchronous functions to match the TS source by removing the async keyword and any await before error(), keeping fs.readFileSync and fs.writeFileSync and returning values directly (use error(...) instead of await error(...)) so readAuthFile, writeAuthFile, AUTH_FILE and error() usage are consistent; alternatively regenerate the bundle from the TypeScript source if that was the intended fix.contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js (1)
1-405: This file is a duplicate of.opencode/plugins/anthropic-token-bridge.js.The same issues noted in the other review apply here:
- Line 164: Dead code (
promisify(spawn))- Lines 41-59: Async/sync inconsistency
- Lines 206-207: Pipeline false positive for secret detection
Consider generating both files from a single source to avoid maintenance burden and drift between copies.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js` around lines 1 - 405, This file duplicates .opencode/plugins/anthropic-token-bridge.js and has three actionable issues: remove the dead promisify(spawn) usage (the exec variable should call your execCommand wrapper, remove "var exec = promisify(spawn)" and any use that relies on it), fix async/sync mismatch in readAuthFile and writeAuthFile (these functions are declared async but use synchronous fs methods and call await error/logger which are synchronous — either make the logger functions return Promises or remove the unnecessary async/await and return values synchronously; update readAuthFile/writeAuthFile to consistently use either fs.promises or to be non-async), and stop leaking potential setup tokens in logs by not matching or logging raw tokens in generateSetupToken (replace the raw regex tokenMatch usage by safely validating presence without storing or logging the full token, and ensure any logs redact/mask matches). Also consolidate this file with the existing one to avoid duplication and drift (generate both outputs from a single source or remove the duplicate).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.opencode/plugins/anthropic-token-bridge.js:
- Around line 206-207: The pipeline flagged runtime extractions of secrets for
accessToken and refreshToken as hardcoded — update the lines that define const
accessToken = credentials.accessToken || credentials.access_token; and const
refreshToken = credentials.refreshToken || credentials.refresh_token; to include
the same suppression comment used in the TypeScript source (e.g., an appropriate
Snyk/semgrep/secret-scan suppression annotation) indicating these are runtime
Keychain extractions, so the scanner treats them as false positives; place the
suppression comment immediately above or on the same line as those const
declarations and ensure it references the scanner rule ID used in the project.
- Around line 163-164: Remove the dead promisify usage: the line creating exec
via promisify(spawn) is invalid because spawn returns a ChildProcess, and exec
is unused; delete the import of promisify and the var exec = promisify(spawn)
statement so the module only uses spawn directly (see symbols promisify, spawn,
and exec in this file) and ensure no remaining references to exec remain.
In @.opencode/plugins/anthropic-token-bridge.ts:
- Around line 13-14: The chat.message handler incorrectly reads role from input;
change it to read the message from output by extracting const msg = (output as
any).message and then guard with if (!msg?.role || msg.role !== "user") return;
— update the async "chat.message"(...) handler to use msg/role from
output.message instead of input.role so only user messages pass.
In @.opencode/plugins/lib/refresh-manager.ts:
- Around line 70-71: This is a false positive from the secret scanner on runtime
token extractions: update the token-extraction site (where you set accessToken =
credentials.accessToken ?? credentials.access_token and refreshToken =
credentials.refreshToken ?? credentials.refresh_token) to include a suppression
comment recognized by our secret-scanner immediately above those lines (or the
surrounding function) so the scanner knows these are runtime values from
Keychain, not hardcoded secrets; reference the variables accessToken,
refreshToken and the credentials object when adding the suppression and ensure
the comment follows the scanner's expected syntax/pattern for ignored findings.
In @.opencode/plugins/lib/token-utils.ts:
- Line 5: AUTH_FILE currently uses process.env.HOME with a literal "~" fallback
which won't expand; update token-utils.ts to import node:os and use os.homedir()
when building AUTH_FILE (replace the AUTH_FILE definition that references
process.env.HOME or "~" with a path.join(os.homedir(), ".local", "share",
"opencode", "auth.json")) and ensure the os import is added at the top of the
file.
---
Nitpick comments:
In @.opencode/plugins/anthropic-token-bridge.js:
- Around line 41-59: The bundle incorrectly marks readAuthFile and writeAuthFile
as async and awaits error(), while they use synchronous fs APIs; change these to
synchronous functions to match the TS source by removing the async keyword and
any await before error(), keeping fs.readFileSync and fs.writeFileSync and
returning values directly (use error(...) instead of await error(...)) so
readAuthFile, writeAuthFile, AUTH_FILE and error() usage are consistent;
alternatively regenerate the bundle from the TypeScript source if that was the
intended fix.
In @.opencode/plugins/lib/refresh-manager.ts:
- Around line 21-40: The execCommand function can hang indefinitely; update
execCommand to accept an optional timeout parameter (or use a default timeout)
and enforce it by starting a timer when spawn(command, args) is called, and on
timeout kill the child process (child.kill()) and resolve with stderr indicating
a timeout and a non-zero exitCode; ensure you clear the timer on normal close or
error and remove/avoid double-resolving the promise by wiring the timer,
'close', and 'error' handlers to a single resolve path. Reference symbols:
function execCommand, local child variable (spawn), child.on('close'),
child.on('error').
In `@contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js`:
- Around line 1-405: This file duplicates
.opencode/plugins/anthropic-token-bridge.js and has three actionable issues:
remove the dead promisify(spawn) usage (the exec variable should call your
execCommand wrapper, remove "var exec = promisify(spawn)" and any use that
relies on it), fix async/sync mismatch in readAuthFile and writeAuthFile (these
functions are declared async but use synchronous fs methods and call await
error/logger which are synchronous — either make the logger functions return
Promises or remove the unnecessary async/await and return values synchronously;
update readAuthFile/writeAuthFile to consistently use either fs.promises or to
be non-async), and stop leaking potential setup tokens in logs by not matching
or logging raw tokens in generateSetupToken (replace the raw regex tokenMatch
usage by safely validating presence without storing or logging the full token,
and ensure any logs redact/mask matches). Also consolidate this file with the
existing one to avoid duplication and drift (generate both outputs from a single
source or remove the duplicate).
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 9ee43b80-3f36-4f66-acf3-2ac8928f2e25
📒 Files selected for processing (7)
.opencode/plugins/anthropic-token-bridge.js.opencode/plugins/anthropic-token-bridge.ts.opencode/plugins/lib/file-logger.ts.opencode/plugins/lib/refresh-manager.ts.opencode/plugins/lib/token-utils.tscontrib/anthropic-max-bridge/install.shcontrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js
There was a problem hiding this comment.
🧹 Nitpick comments (1)
contrib/anthropic-max-bridge/README.md (1)
88-90: Broaden auto-refresh wording to match actual fallback behavior.Current text implies Keychain is the only refresh source, but runtime logic also falls back to
claude setup-tokenand setup-token exchange. Please align this section with the 3-strategy flow to avoid support confusion.Suggested doc tweak
-**Auto-refresh (default):** The `anthropic-token-bridge` plugin checks your token every 5 messages and refreshes it automatically from the macOS Keychain — no action needed. +**Auto-refresh (default):** The `anthropic-token-bridge` plugin checks your token every 5 messages and refreshes it automatically (Keychain first, then CLI/setup-token fallback paths) — no action needed. ... -> So the Keychain always has a fresh token after any `claude` use — which is what the auto-refresh plugin pulls from. +> So the Keychain is usually fresh after any `claude` use, and the plugin uses it first before fallback refresh paths.Also applies to: 99-99
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@contrib/anthropic-max-bridge/README.md` around lines 88 - 90, Update the "Auto-refresh (default)" / "Manual refresh (fallback)" wording to reflect the three-step refresh flow used by the anthroptic-token-bridge: state that the plugin attempts automatic refresh from the macOS Keychain every 5 messages, then falls back to invoking the local `claude setup-token` flow, and finally to the interactive `setup-token` exchange if the previous methods fail; mention all three strategies and that no user action is needed unless all fallbacks fail, and reference the `anthropic-token-bridge`, `claude setup-token`, and `setup-token` terms so readers can find the commands.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Nitpick comments:
In `@contrib/anthropic-max-bridge/README.md`:
- Around line 88-90: Update the "Auto-refresh (default)" / "Manual refresh
(fallback)" wording to reflect the three-step refresh flow used by the
anthroptic-token-bridge: state that the plugin attempts automatic refresh from
the macOS Keychain every 5 messages, then falls back to invoking the local
`claude setup-token` flow, and finally to the interactive `setup-token` exchange
if the previous methods fail; mention all three strategies and that no user
action is needed unless all fallbacks fail, and reference the
`anthropic-token-bridge`, `claude setup-token`, and `setup-token` terms so
readers can find the commands.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: d5173885-03f4-4b39-890b-25077b5b8fb9
📒 Files selected for processing (2)
contrib/anthropic-max-bridge/README.mdcontrib/anthropic-max-bridge/TECHNICAL.md
- token-utils.ts: os.homedir() instead of process.env.HOME (avoids unexpanded ~) - refresh-manager.ts: remove dead promisify(spawn), add execCommand timeout (15s), add pragma: allowlist secret on Keychain extractions, mask token in logs - anthropic-token-bridge.ts/.js: read role from output.message instead of input - Regenerate compiled .js from fixed TS sources (both plugin + contrib copy)
The macOS Keychain stores credentials as { claudeAiOauth: { accessToken, refreshToken } }
but the plugin was looking for flat structure. This caused auto-refresh to fail with
hasAccess: false, hasRefresh: false errors, falling back to browser OAuth flow.
Now correctly extracts from nested structure with fallback for legacy formats.
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (2)
.opencode/plugins/lib/token-utils.ts (1)
29-47: Synchronous I/O is acceptable here, but note the lack of file locking.The
readFileSync/writeFileSynccalls are fine for small JSON files. However, as noted inrefresh-manager.ts, there's no inter-process locking. If multiple processes (e.g., concurrent OpenCode instances) attempt to refresh tokens simultaneously, auth.json could be corrupted or updates could be lost.For a plugin of this scope, this is an acceptable trade-off given the 5-minute cooldown between refresh attempts, but worth documenting.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In @.opencode/plugins/lib/token-utils.ts around lines 29 - 47, Document that readAuthFile and writeAuthFile use synchronous I/O without file locking and can suffer inter-process races: add a clear comment (or a short JSDoc) above the functions readAuthFile and writeAuthFile explaining that readFileSync/writeFileSync are chosen intentionally, that concurrent processes can corrupt auth.json or lose updates, and reference the 5-minute refresh cooldown as the rationale and trade-off; optionally include a TODO suggesting future use of advisory locks or an atomic write strategy if stronger guarantees are required.contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js (1)
1-33: Bundled JS file risks becoming stale.This file inlines all modules (file-logger, token-utils, refresh-manager) into a single distributable JS file. Per
install.sh(lines 132-137), it's installed viacprather than symlink.If the source TypeScript files are updated, this bundled JS will not automatically reflect those changes. Consider:
- Adding a build step to regenerate this file from TS sources
- Documenting the regeneration process
- Adding a version/hash comment to detect drift
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js` around lines 1 - 33, The bundled JS (which defines LOG_PATH, fileLog, fileLogError, info, warn, error) can drift from the TypeScript sources because it's copied in install.sh; update the repo to ensure the bundle regenerates automatically and is detectable: add a build step that compiles the TypeScript sources (token-utils, refresh-manager, file-logger) into this JS during CI and/or npm build, add a documented regeneration command in CONTRIBUTING or INSTALL (referencing the bundling step that produces the file containing fileLog/fileLogError), and insert a version/hash comment header into the generated file so mismatches between source and bundle are easy to detect.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In @.opencode/plugins/lib/refresh-manager.ts:
- Around line 76-87: The code parses claudeAiOauth.expiresAt but then ignores it
and always passes a hardcoded 28800s to updateAnthropicTokens; update
refreshAnthropicToken to compute the real TTL from claudeAiOauth.expiresAt
(convert to seconds relative to now), clamp to a sensible minimum (e.g., 0) and
use that value when calling updateAnthropicTokens (fall back to 28800 only if
expiresAt is missing or invalid) so you don't store an incorrect expiry;
reference claudeAiOauth.expiresAt, refreshAnthropicToken, and
updateAnthropicTokens when making the change.
---
Nitpick comments:
In @.opencode/plugins/lib/token-utils.ts:
- Around line 29-47: Document that readAuthFile and writeAuthFile use
synchronous I/O without file locking and can suffer inter-process races: add a
clear comment (or a short JSDoc) above the functions readAuthFile and
writeAuthFile explaining that readFileSync/writeFileSync are chosen
intentionally, that concurrent processes can corrupt auth.json or lose updates,
and reference the 5-minute refresh cooldown as the rationale and trade-off;
optionally include a TODO suggesting future use of advisory locks or an atomic
write strategy if stronger guarantees are required.
In `@contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js`:
- Around line 1-33: The bundled JS (which defines LOG_PATH, fileLog,
fileLogError, info, warn, error) can drift from the TypeScript sources because
it's copied in install.sh; update the repo to ensure the bundle regenerates
automatically and is detectable: add a build step that compiles the TypeScript
sources (token-utils, refresh-manager, file-logger) into this JS during CI
and/or npm build, add a documented regeneration command in CONTRIBUTING or
INSTALL (referencing the bundling step that produces the file containing
fileLog/fileLogError), and insert a version/hash comment header into the
generated file so mismatches between source and bundle are easy to detect.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 7802f728-80d8-4d9d-a670-b3b12819aebd
📒 Files selected for processing (5)
.opencode/plugins/anthropic-token-bridge.js.opencode/plugins/anthropic-token-bridge.ts.opencode/plugins/lib/refresh-manager.ts.opencode/plugins/lib/token-utils.tscontrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js
✅ Files skipped from review due to trivial changes (1)
- .opencode/plugins/anthropic-token-bridge.js
| const credentials = JSON.parse(stdout.trim()) as { | ||
| claudeAiOauth?: { | ||
| accessToken?: string; | ||
| refreshToken?: string; | ||
| expiresAt?: number; | ||
| }; | ||
| // Legacy fallback for direct storage (rare) | ||
| accessToken?: string; | ||
| refreshToken?: string; | ||
| access_token?: string; | ||
| refresh_token?: string; | ||
| }; |
There was a problem hiding this comment.
Consider using the Keychain token's actual expiration.
The claudeAiOauth structure includes an expiresAt field (line 80), but it's not used. Instead, a hardcoded 8-hour expiry (28800 seconds) is passed to updateAnthropicTokens at line 211.
If the Keychain token has a different remaining lifetime, this could cause premature "expires soon" warnings or, worse, storing an already-expired token as if it were valid for 8 hours.
🔧 Suggested improvement
// Extract from nested claudeAiOauth structure (standard Claude Code format)
const oauth = credentials.claudeAiOauth;
+ const expiresAt = oauth?.expiresAt;
// pragma: allowlist secret — runtime extraction from macOS Keychain
const accessToken = oauth?.accessToken ?? credentials.accessToken ?? credentials.access_token;
const refreshToken = oauth?.refreshToken ?? credentials.refreshToken ?? credentials.refresh_token;
if (!accessToken || !refreshToken) {
// ...
}
- return { accessToken, refreshToken };
+ return { accessToken, refreshToken, expiresAt };Then in refreshAnthropicToken:
if (keychainTokens) {
info("Found tokens in Keychain, updating auth.json");
+ // Calculate remaining seconds, default to 8 hours if no expiry
+ const expiresInSeconds = keychainTokens.expiresAt
+ ? Math.max(0, Math.floor((keychainTokens.expiresAt - Date.now()) / 1000))
+ : 28800;
const success = updateAnthropicTokens(
keychainTokens.accessToken,
keychainTokens.refreshToken,
- 28800,
+ expiresInSeconds,
);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In @.opencode/plugins/lib/refresh-manager.ts around lines 76 - 87, The code
parses claudeAiOauth.expiresAt but then ignores it and always passes a hardcoded
28800s to updateAnthropicTokens; update refreshAnthropicToken to compute the
real TTL from claudeAiOauth.expiresAt (convert to seconds relative to now),
clamp to a sensible minimum (e.g., 0) and use that value when calling
updateAnthropicTokens (fall back to 28800 only if expiresAt is missing or
invalid) so you don't store an incorrect expiry; reference
claudeAiOauth.expiresAt, refreshAnthropicToken, and updateAnthropicTokens when
making the change.
* feat(oauth): add runtime auto-refresh token bridge
- Add anthropic-token-bridge.js/.ts: checks token every 5 messages,
auto-refreshes from macOS Keychain when <2h remaining
- Add lib/token-utils.ts + lib/refresh-manager.ts: token lifecycle logic
(3 strategies: Keychain → claude setup-token → exchange API)
- Add info()/warn()/error() wrappers to file-logger.ts (needed by token bridge)
- Add anthropic-token-bridge.js to contrib/anthropic-max-bridge/plugins/
- Update contrib/install.sh: copies both plugins, updates token refresh note
* docs(contrib): update README + TECHNICAL for auto-refresh token bridge
- README: Token Expiry → auto-refresh primary, manual fallback secondary
- README: File Reference → add anthropic-token-bridge.js entry
- README: install.sh step → mention both plugins copied
- README: token flow diagram → show both plugins
- README: HTTP 401 troubleshooting → mention auto-refresh failed
- TECHNICAL: Token lifetime → replace manual-only with auto-refresh details
* fix(oauth): address CI findings in token bridge
- token-utils.ts: os.homedir() instead of process.env.HOME (avoids unexpanded ~)
- refresh-manager.ts: remove dead promisify(spawn), add execCommand timeout (15s),
add pragma: allowlist secret on Keychain extractions, mask token in logs
- anthropic-token-bridge.ts/.js: read role from output.message instead of input
- Regenerate compiled .js from fixed TS sources (both plugin + contrib copy)
* fix(oauth): parse claudeAiOauth nested structure from Keychain
The macOS Keychain stores credentials as { claudeAiOauth: { accessToken, refreshToken } }
but the plugin was looking for flat structure. This caused auto-refresh to fail with
hasAccess: false, hasRefresh: false errors, falling back to browser OAuth flow.
Now correctly extracts from nested structure with fallback for legacy formats.
* feat(oauth): add runtime auto-refresh token bridge
- Add anthropic-token-bridge.js/.ts: checks token every 5 messages,
auto-refreshes from macOS Keychain when <2h remaining
- Add lib/token-utils.ts + lib/refresh-manager.ts: token lifecycle logic
(3 strategies: Keychain → claude setup-token → exchange API)
- Add info()/warn()/error() wrappers to file-logger.ts (needed by token bridge)
- Add anthropic-token-bridge.js to contrib/anthropic-max-bridge/plugins/
- Update contrib/install.sh: copies both plugins, updates token refresh note
* docs(contrib): update README + TECHNICAL for auto-refresh token bridge
- README: Token Expiry → auto-refresh primary, manual fallback secondary
- README: File Reference → add anthropic-token-bridge.js entry
- README: install.sh step → mention both plugins copied
- README: token flow diagram → show both plugins
- README: HTTP 401 troubleshooting → mention auto-refresh failed
- TECHNICAL: Token lifetime → replace manual-only with auto-refresh details
* fix(oauth): address CI findings in token bridge
- token-utils.ts: os.homedir() instead of process.env.HOME (avoids unexpanded ~)
- refresh-manager.ts: remove dead promisify(spawn), add execCommand timeout (15s),
add pragma: allowlist secret on Keychain extractions, mask token in logs
- anthropic-token-bridge.ts/.js: read role from output.message instead of input
- Regenerate compiled .js from fixed TS sources (both plugin + contrib copy)
* fix(oauth): parse claudeAiOauth nested structure from Keychain
The macOS Keychain stores credentials as { claudeAiOauth: { accessToken, refreshToken } }
but the plugin was looking for flat structure. This caused auto-refresh to fail with
hasAccess: false, hasRefresh: false errors, falling back to browser OAuth flow.
Now correctly extracts from nested structure with fallback for legacy formats.
#83–#85) (#87) * refactor(v3.0 Part B/2): add correct structure — Utilities/, VoiceServer/, PAI root docs, voice ID fix (#83) * refactor: move orphan skills into Utilities/ category Moved 6 flat-level skills into Utilities/ for PAI 4.0.3 parity: - AudioEditor → Utilities/AudioEditor (was missing from Utilities) - CodeReview → Utilities/CodeReview (OpenCode-specific) - OpenCodeSystem → Utilities/OpenCodeSystem (OpenCode-specific) - Sales → Utilities/Sales (OpenCode-specific) - System → Utilities/System (OpenCode-specific) - WriteStory → Utilities/WriteStory (OpenCode-specific) OpenCode-specific skills preserved in Utilities/ category. * feat: add missing PAI/ root docs from 4.0.3 Added 3 files missing from PAI/ root: - doc-dependencies.json (documentation dependency graph) - PIPELINES.md (pipeline system documentation) - THEHOOKSYSTEM.md (hooks/plugin system documentation) All .claude/ references replaced with .opencode/. * refactor: rename voice-server/ to VoiceServer/ + add missing files Renamed: voice-server/ → VoiceServer/ (CamelCase, matches PAI 4.0.3) Added missing files from PAI 4.0.3: - install.sh, start.sh, stop.sh, restart.sh, status.sh, uninstall.sh - voices.json, pronunciations.json - menubar/ directory All .claude/ references replaced with .opencode/ in copied files. * fix: restore missing files + remove legacy content from skills/PAI/ deletion Restored from git history (accidentally deleted with skills/PAI/): - PAI/THEPLUGINSYSTEM.md (OpenCode replacement for THEHOOKSYSTEM.md) - PAI/Tools/GenerateSkillIndex.ts + ValidateSkillStructure.ts + SkillSearch.ts - PAI/PIPELINES.md, PAI/doc-dependencies.json - PAISECURITYSYSTEM/HOOKS.md Removed legacy files NOT in 4.0.3 upstream: - PAI/BACKUPS.md, BROWSERAUTOMATION.md, CONSTITUTION.md, RESPONSEFORMAT.md, SCRAPINGREFERENCE.md, TERMINALTABS.md (all pre-v3.0 artifacts) - PAI/UPDATES/ directory (not in 4.0.3) - PAI/Workflows/ (11 legacy workflows not in 4.0.3 root) - PAI/USER/ reset to 4.0.3 template (README.md placeholders only, removed 47 personal template files that don't belong in public repo) Fixed references: THEHOOKSYSTEM → THEPLUGINSYSTEM in SKILL.md and DOCUMENTATIONINDEX.md Regenerated skill-index.json (52 skills, 7 categories) PAI/ root now matches 4.0.3 exactly, with only 2 justified deviations: - THEPLUGINSYSTEM.md (replaces THEHOOKSYSTEM.md per ADR-001) - MINIMAL_BOOTSTRAP.md (OpenCode lazy loading, created in v3.0 WP-C) * chore: remove INSTALLER-REFACTOR-PLAN.md (internal planning doc) * fix: replace all hardcoded ElevenLabs voice IDs with dynamic routing VoiceServer sendNotification() now uses title (agent name) instead of voiceId for voice resolution. This enables dynamic provider switching via TTS_PROVIDER env var (google/elevenlabs/macos). Changed: voice_id:'fTtv3eikoepIosk8dTZ5' → voice_id:'default' + title:'AgentName' Files affected: - VoiceServer/server.ts (line 425: voiceId → safeTitle) - PAI/SKILL.md (7 phase curls) - PAI/Algorithm/v3.7.0.md (2 voice curls) - agents/Algorithm.md (frontmatter + 3 references) - PAI/Tools/algorithm.ts (VOICE_ID constant) Zero hardcoded ElevenLabs IDs remain in the codebase. * fix: address CodeRabbit review findings on PR #83 Fixes verified against actual code: Tools path resolution: - SkillSearch.ts: INDEX_FILE pointed to PAI/Skills/ instead of skills/ - ValidateSkillStructure.ts: SKILLS_DIR went 3 levels up instead of 2 - Both now match GenerateSkillIndex.ts (.opencode/skills/) Symlink cycle prevention: - GenerateSkillIndex.ts: findSkillFiles now tracks visited canonical paths - ValidateSkillStructure.ts: scanDirectory now populates visitedPaths Set Error handling: - GenerateSkillIndex.ts + SkillSearch.ts: main().catch now exits non-zero Documentation accuracy: - doc-dependencies.json: add missing THEPLUGINSYSTEM.md entry - THEHOOKSYSTEM.md: PAI_DIR example $HOME/.claude → $HOME/.opencode - THEPLUGINSYSTEM.md: library count 8 → 9 (matches table) - pronunciations.json: stale skills/PAI/USER/ → PAI/USER/ path Stats accuracy: - ValidateSkillStructure.ts: category SKILL.md files no longer counted as flat skills - pai-voice.5s.sh: PAI_DIR default $HOME/.claude → $HOME/.opencode Skipped (verified not applicable): - PIPELINES.md paths: upstream semantics, both paths correct in context - VoiceServer shell script hardening: upstream 4.0.3 files, out of scope - PAISECURITYSYSTEM/HOOKS.md: doc is factually accurate for hook architecture - THEHOOKSYSTEM.md voiceId examples: settings.json config docs, not hardcoded IDs * fix: delete THEHOOKSYSTEM.md and HOOKS.md, complete hook→plugin migration (ADR-001) - Delete .opencode/PAI/THEHOOKSYSTEM.md (superseded by THEPLUGINSYSTEM.md) - Delete .opencode/PAISECURITYSYSTEM/HOOKS.md (superseded by PLUGINS.md) - Update doc-dependencies.json: remove THEHOOKSYSTEM.md entry, rename hook-system section to plugin-system referencing THEPLUGINSYSTEM.md - Update PAISECURITYSYSTEM/ARCHITECTURE.md: all hook→plugin terminology, execution flow updated for plugin model (throw Error, not exit codes) - Update PAISECURITYSYSTEM/README.md: hook→plugin in prose and file tree - Fix GenerateSkillIndex.ts division by zero when totalSkills == 0 * feat(installer): add anthropic-max preset for Max/Pro OAuth subscription (#84) * feat(installer): add anthropic-max preset for Max/Pro OAuth subscription Adds 'anthropic-max' as a built-in installer preset so users with an existing Anthropic Max or Pro subscription can use PAI-OpenCode without paying extra for API keys. ## What's new ### Provider preset - provider-models.ts: add 'anthropic-max' to ProviderName union, PROVIDER_MODELS, and PROVIDER_LABELS (label: 'Anthropic Max/Pro (OAuth)') - Model tiers: haiku-4-5 / sonnet-4-6 / opus-4-6 — same models as 'anthropic' but routed through OAuth instead of an API key ### Installer engine (steps-fresh.ts) - installAnthropicMaxBridge(): new helper function that: 1. Copies .opencode/plugins/anthropic-max-bridge.js to the local plugins dir 2. Extracts the OAuth token from macOS Keychain (service: Claude Code-credentials) 3. Parses claudeAiOauth.{accessToken,refreshToken,expiresAt} 4. Writes token to ~/.local/share/opencode/auth.json under the 'anthropic' key 5. Returns success + hours remaining, non-throwing (install continues on failure) - stepInstallPAI(): call installAnthropicMaxBridge() when provider === 'anthropic-max' - runFreshInstall(): skip API key prompt for anthropic-max, show Claude CLI check message ### CLI (quick-install.ts) - --preset anthropic-max works without --api-key (all other presets still require it) - Inline instructions printed when anthropic-max selected - Updated help text and examples ### New files - .opencode/plugins/anthropic-max-bridge.js: 80-line minimal plugin (3 API fixes only) Fix 1: system prompt array-of-objects format (prevents HTTP 400) Fix 2: anthropic-beta: oauth-2025-04-20 header (prevents HTTP 401) Fix 3: Authorization: Bearer <token> instead of x-api-key - PAI-Install/anthropic-max-refresh.sh: one-command token refresh after expiry - docs/providers/anthropic-max.md: user-facing setup guide, troubleshooting, tech details ## Usage Interactive: bash install.sh → choose 'Anthropic Max/Pro (OAuth)' Headless: bun PAI-Install/cli/quick-install.ts --preset anthropic-max --name 'User' Token refresh (every ~8-12 hours): bash PAI-Install/anthropic-max-refresh.sh ## Notes - macOS only (requires Keychain access) - Requires Claude Code CLI installed and authenticated - Using OAuth tokens in third-party tools may violate Anthropic ToS - Non-fatal: if Keychain extraction fails, install continues with a warning * fix(anthropic-max): address CodeRabbit findings + add standalone contrib package CodeRabbit fixes (PR #84): - anthropic-max-refresh.sh: use quoted heredoc ('PYEOF') and export tokens as env vars to prevent shell injection via token values - anthropic-max-refresh.sh: add validation for REFRESH_TOKEN (non-empty) and EXPIRES_AT (numeric, >0) before writing auth.json - steps-fresh.ts: fix progress regression — onProgress(92) called after 95% steps; corrected to 96 - steps-fresh.ts: replace brittle import.meta.url.replace('file://', '') with fileURLToPath(new URL(import.meta.url)) from node:url - anthropic-max-bridge.js: add explicit comment explaining the authorize block returns { type: 'failed' } intentionally (tokens come from auth.json, not from an OAuth redirect flow) - docs/providers/anthropic-max.md: add YAML frontmatter (title, tags, published, type, summary) and convert tip block to Obsidian callout syntax Goal 2 — standalone extractable package: - contrib/anthropic-max-bridge/ — self-contained, no Bun/PAI required - install.sh (bash only, injection-safe heredoc, validates all tokens) - refresh-token.sh (same safety fixes as PAI-Install version) - plugins/anthropic-max-bridge.js (identical to .opencode/plugins/) - README.md (quick-start focused, team-sharing notes) - TECHNICAL.md (curl proof, token structure, what we ruled out) - docs/providers/anthropic-max.md: Option B now points to contrib/ in this repo instead of the external jeremy-opencode repository * fix(anthropic-max): address second-round CodeRabbit findings - anthropic-max-bridge.js: guard experimental.chat.system.transform against output.system being undefined/null/non-array before calling .map(); normalise to [] if missing, wrap bare string/object into array, then apply existing string->{type,text} conversion - contrib/anthropic-max-bridge/install.sh: chmod auth.json to 0o600 (owner read/write only) immediately after writing via os.chmod + stat module; mirrors the chmodSync(authFile, 0o600) already present in steps-fresh.ts - steps-fresh.ts: add zero-case message when hoursRemaining === 0 ('Token installed — expired or valid for less than 1 hour. Run anthropic-max-refresh.sh now.'); Math.max(0, ...) clamp was already present * fix(anthropic-max): check Bun.spawn exit code for Keychain lookup Previously proc.exited was awaited but its return value discarded, so a non-zero exit from the 'security' command (e.g. item not found) was only caught via an empty-stdout check. Stdout and stderr are now read concurrently with proc.exited via Promise.all to avoid pipe deadlock; the exit code is inspected before accepting keychainJson, and stderr is surfaced in the error message when present. * nitpick(steps-fresh): add warning when refresh token is missing When extracting OAuth tokens from Keychain, warn users if the refresh token is undefined. This alerts them that they'll need to re-authenticate with 'claude' when the access token expires. * feat(oauth): add runtime auto-refresh token bridge (#85) * feat(oauth): add runtime auto-refresh token bridge - Add anthropic-token-bridge.js/.ts: checks token every 5 messages, auto-refreshes from macOS Keychain when <2h remaining - Add lib/token-utils.ts + lib/refresh-manager.ts: token lifecycle logic (3 strategies: Keychain → claude setup-token → exchange API) - Add info()/warn()/error() wrappers to file-logger.ts (needed by token bridge) - Add anthropic-token-bridge.js to contrib/anthropic-max-bridge/plugins/ - Update contrib/install.sh: copies both plugins, updates token refresh note * docs(contrib): update README + TECHNICAL for auto-refresh token bridge - README: Token Expiry → auto-refresh primary, manual fallback secondary - README: File Reference → add anthropic-token-bridge.js entry - README: install.sh step → mention both plugins copied - README: token flow diagram → show both plugins - README: HTTP 401 troubleshooting → mention auto-refresh failed - TECHNICAL: Token lifetime → replace manual-only with auto-refresh details * fix(oauth): address CI findings in token bridge - token-utils.ts: os.homedir() instead of process.env.HOME (avoids unexpanded ~) - refresh-manager.ts: remove dead promisify(spawn), add execCommand timeout (15s), add pragma: allowlist secret on Keychain extractions, mask token in logs - anthropic-token-bridge.ts/.js: read role from output.message instead of input - Regenerate compiled .js from fixed TS sources (both plugin + contrib copy) * fix(oauth): parse claudeAiOauth nested structure from Keychain The macOS Keychain stores credentials as { claudeAiOauth: { accessToken, refreshToken } } but the plugin was looking for flat structure. This caused auto-refresh to fail with hasAccess: false, hasRefresh: false errors, falling back to browser OAuth flow. Now correctly extracts from nested structure with fallback for legacy formats. * fix: address CodeRabbit review findings on PR #87 Security: - auth.json now written with mode 0600 (token-utils, compiled JS, contrib) - Validate access token and expires field before reporting healthy status - Refresh scripts check for already-expired Keychain tokens before writing - Refresh scripts restore chmod 600 after rewriting auth.json - quick-install.ts fails fast on non-macOS for anthropic-max preset Correctness: - Use actual Keychain expiresAt instead of hardcoded 8h TTL - Token bridge attempts refresh on no_anthropic_config (not just expiry) - Installer copies anthropic-token-bridge.js alongside the bridge plugin - contrib anthropic-max-bridge.js gets defensive output.system null-safety - Remove non-existent FEEDSYSTEM.md from doc-dependencies.json - GenerateSkillIndex frontmatter regex accepts CRLF line endings - SkillSearch normalizes PascalCase names for natural language queries Shell scripts: - VoiceServer: sed replaces cut -d= -f2 to handle = in values - VoiceServer: remove unused RED/PLIST_PATH variables - restart.sh: check exit codes from stop.sh/start.sh Docs: - Add language tags to fenced code blocks in contrib README/TECHNICAL * fix: CodeRabbit round 2 - session-start validation, platform guards, token expiry logic Session-start improvements: - GenerateSkillIndex.ts: case-insensitive ALWAYS_LOADED_SKILLS matching - anthropic-token-bridge: enhanced session-start token validation - refresh-manager: improved expiresAt handling with null/undefined/past checks Platform guards: - VoiceServer/install.sh: curl --fail for notify endpoint - VoiceServer/uninstall.sh: Darwin platform guard + safe process kill - steps-fresh.ts: macOS platform gate for anthropic-max + expiresAt validation Token logic fixes: - steps-fresh.ts: skip provider API key for anthropic-max in .env - refresh-manager: AbortController 10s timeout for fetch - contrib plugin: mirror all fixes * fix(token-bridge): CRITICAL - synchronous token refresh at session start The 5-second setTimeout delay caused API calls to fail with expired tokens before refresh completed. Now refreshes immediately and synchronously when session starts with an expired/soon-expiring token. - Changed from setTimeout(5000) to immediate await - Added fallback async retry on failure - Applied to: .ts source + compiled .js + contrib plugin * fix(token-bridge): always sync Keychain→auth.json at session start Root cause fix for stale token issue: the session-start hook previously only checked auth.json's `expires` timestamp, which can be valid even when Anthropic has invalidated the token (e.g. after re-authentication). Now the hook always reads the Keychain first (source of truth) and writes it to auth.json before any expiry logic runs. If Keychain and auth.json tokens differ, a warning is logged and the fresh token is synced. Fallback path (Keychain unavailable or expired) still triggers full refreshAnthropicToken() as before. Also exports extractFromKeychain() from refresh-manager.ts so it can be used directly in the bridge hook. * fix: address PR #87 review comments contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js - Session-start hook now syncs Keychain→auth.json first (same pattern as main plugin); extractFromKeychain() result is awaited and wrapped in its own try/catch with fileLog/fileLogError; existing refreshAnthropicToken() / isRefreshInProgress() fallback path is unchanged .opencode/PAI/Tools/GenerateSkillIndex.ts - Duplicate skill name detection now throws an Error instead of warn+continue, making the build fail fast with a clear message .opencode/VoiceServer/install.sh - API_KEY extraction strips surrounding quotes, inline comments, and whitespace before comparing against sentinel values - ProgramArguments now point to a runtime wrapper (run-server.sh) that resolves Bun via PATH at launch time instead of baking in /Users/steffen/.bun/bin/bun - ELEVENLABS_API_KEY plist entry is only emitted when API_KEY is non-empty - Fixed sleep 2 with a polling health-check loop (60s timeout, 1s interval) that exits with an error if the server never responds .opencode/VoiceServer/uninstall.sh - Port cleanup now uses lsof -ti for PID-only output (no header line risk) and verifies each PID's command via ps before killing - Script directory echo now resolves symlinks via readlink -f / realpath * fix: address second round of PR #87 review comments .opencode/VoiceServer/install.sh - ENV_FILE now matches the server's path resolution: ${OPENCODE_DIR:-${PAI_DIR:-$HOME/.opencode}}/.env (was $HOME/.env) - API_KEY extraction uses grep -m1 to avoid concatenating multiple matching lines; inline-comment stripping now only removes trailing '#[^"']*$' (unquoted comments) so '#' inside a key value is preserved .opencode/plugins/lib/refresh-manager.ts .opencode/plugins/anthropic-token-bridge.js contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js - extractFromKeychain: destructure { stdout, stderr, exitCode } from execCommand and pass the real stderr into the error() call (was incorrectly passing stdout as stderr)
Summary
Changes
New files
.opencode/plugins/anthropic-token-bridge.js— compiled plugin, runs in OpenCode.opencode/plugins/anthropic-token-bridge.ts— source TypeScript.opencode/plugins/lib/token-utils.ts— reads/writes auth.json, checks expiry.opencode/plugins/lib/refresh-manager.ts— 3-strategy refresh (Keychain →claude setup-token→ exchange API)contrib/anthropic-max-bridge/plugins/anthropic-token-bridge.js— included in standalone contrib packageModified files
.opencode/plugins/lib/file-logger.ts— addinfo()/warn()/error()wrappers (needed by token bridge)contrib/anthropic-max-bridge/install.sh— copies both plugins, updates token refresh noteHow it works
Every 5 user messages the plugin checks
auth.json. If the token expires in <2h it triggers an async refresh:security find-generic-password)claude setup-tokenCLIapi.anthropic.com/v1/oauth/setup_token/exchangeRefresh has a 5-minute cooldown to prevent hammering. All logging goes to
/tmp/pai-opencode-debug.log(TUI-safe, ADR-004).Summary by CodeRabbit
New Features
Documentation
Other