create-auth-plugin — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited create-auth-plugin (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Comprehensive skill for building OpenCode authentication plugins that enable opencode auth login --provider <name> and seamless token rotation without session restart.
OpenCode auth plugins are npm-style packages that provide:
Plugin Package
├── package.json # name, version, module: "index.ts", type: "module"
├── index.ts # Re-exports: export { MyPlugin, default } from "./src/index"
├── src/
│ ├── index.ts # Main plugin export (async function)
│ ├── constants.ts # Provider ID, OAuth config, API config, models
│ ├── types.ts # Credential and config interfaces
│ └── oauth.ts # OAuth flow implementation (if applicable)
└── README.mdOpenCode resolves plugins in this exact order:
opencode.json "plugin" array
↓
For each entry:
├── If starts with "file://" → load directly via Bun (ONLY tools/events work, NOT auth hooks)
└── If npm-style name → resolve from ~/.cache/opencode/node_modules/<name>/
├── First: try `bun add <name>@latest` against npm registry
├── If 404: fall back to cached version in node_modules
└── If no cache: plugin fails to loadAUTH HOOKS ONLY WORK FROM NPM-RESOLVED PLUGINS. The file:// path only activates tool and event hooks. This is the #1 gotcha.
If the plugin is NOT published to npm, you MUST manually inject it:
# Step 1: Copy plugin to cache node_modules
cp -R ~/.config/opencode/local-plugins/opencode-<name>-auth \
~/.cache/opencode/node_modules/opencode-<name>-auth
# Step 2: Add to cache package.json
# Edit ~/.cache/opencode/package.json and add:
# "opencode-<name>-auth": "1.0.0"
# to the "dependencies" object
# Step 3: Reference in opencode.json plugin array (npm-style, NOT file://)
# "plugin": ["opencode-<name>-auth"]NEVER use `file:///path/to/plugin/index.ts` in the plugin array for auth plugins. It will load without errors but auth hooks silently won't activate.
The plugin MUST export an async function that receives input and returns an object with auth and config properties. Study these working patterns:
index.ts (re-export file):
export { MyProviderAuthPlugin, default } from "./src/index";src/index.ts (main plugin):
export const MyProviderAuthPlugin = async (_input: unknown) => {
return {
// REQUIRED: Auth hooks
auth: {
provider: PROVIDER_ID, // Must match the provider key in opencode config
loader: async (getAuth, provider) => { ... },
methods: [ { type: 'oauth', label: '...', authorize: async () => { ... } } ],
},
// REQUIRED: Config hooks (registers provider + models)
config: async (config: Record<string, unknown>) => { ... },
};
};
export default MyProviderAuthPlugin;{
"name": "opencode-<provider>-auth",
"version": "1.0.0",
"module": "index.ts",
"type": "module",
"devDependencies": {
"@opencode-ai/plugin": "^1.1.48",
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
},
"files": ["index.ts", "src", "README.md"],
"engines": { "node": ">=20.0.0" }
}Critical fields:
"module": "index.ts" — Bun uses this to find the entry point"type": "module" — Required for ESM importsopencode-<provider>-authauth.loaderCalled on EVERY API request. Returns { apiKey, baseURL } or null.
loader: async (
getAuth: () => Promise<{ type: string; access?: string; refresh?: string; expires?: number }>,
provider: { models?: Record<string, { cost?: { input: number; output: number } }> },
) => {
// Zero out costs (free via OAuth)
if (provider?.models) {
for (const model of Object.values(provider.models)) {
if (model) model.cost = { input: 0, output: 0 };
}
}
const auth = await getAuth();
if (!auth || auth.type !== 'oauth') return null;
// Optional: refresh if expired
if (auth.expires && Date.now() > auth.expires - 60_000 && auth.refresh) {
// refresh logic here
}
return {
apiKey: auth.access ?? '',
baseURL: 'https://api.provider.com/v1',
};
},auth.methodsDefines login flows shown in opencode auth login --provider <name>.
OAuth PKCE (browser redirect):
methods: [{
type: 'oauth' as const,
label: 'Sign in with Provider (OAuth)',
authorize: async () => {
// Build auth URL, open browser, wait for callback
return {
url: authorizationUrl,
instructions: 'Complete sign-in in your browser',
method: 'manual' as const, // or 'auto' for polling
callback: async () => ({
type: 'success' as const,
access: accessToken,
refresh: refreshToken,
expires: expiryTimestamp,
}),
};
},
}],Device Flow (code display + polling):
methods: [{
type: 'oauth' as const,
label: 'Provider (Device Flow)',
authorize: async () => {
const deviceAuth = await requestDeviceAuthorization();
openBrowser(deviceAuth.verification_uri_complete);
return {
url: deviceAuth.verification_uri_complete,
instructions: `Code: ${deviceAuth.user_code}`,
method: 'auto' as const, // OpenCode polls automatically
callback: async () => {
// Poll token endpoint until user approves
while (notExpired) {
await sleep(interval);
const token = await pollDeviceToken(deviceAuth.device_code);
if (token) {
return { type: 'success', access: token.access_token, refresh: token.refresh_token, expires: ... };
}
}
return { type: 'failed' as const };
},
};
},
}],configRegisters the provider and its models dynamically. Called during OpenCode bootstrap.
config: async (config: Record<string, unknown>) => {
const providers = (config.provider as Record<string, unknown>) || {};
providers[PROVIDER_ID] = {
npm: '@ai-sdk/openai-compatible', // or specific SDK
name: 'Provider Name',
options: { baseURL: 'https://api.provider.com/v1' },
models: {
'model-id': {
id: 'model-id',
name: 'Model Name',
reasoning: false,
limit: { context: 128000, output: 8192 },
cost: { input: 0, output: 0 },
modalities: { input: ['text'], output: ['text'] },
},
},
};
config.provider = providers;
},OpenCode stores auth in ~/.local/share/opencode/auth.json:
{
"google": { "type": "oauth", "access": "...", "refresh": "...", "expires": 1234567890 },
"qwen-code": { "type": "oauth", "access": "...", "refresh": "...", "expires": 1234567890 },
"openrouter": { "type": "oauth", "access": "...", "refresh": "...", "expires": 1234567890 }
}Key: provider ID from auth.provider field.
For token rotation without session restart, atomically update the provider's entry in auth.json. OpenCode's built-in retry reads fresh credentials on next attempt.
To enable automatic token swap on rate limit (like Antigravity/Qwen):
~/.open-auth-rotator/<provider>/
├── pool.json # Token pool with multiple accounts
├── swap_token.py # Atomic swap script
├── state.json # Swap count tracking
└── rotator.log # Swap historypool.json format:
{
"current_index": 0,
"tokens": [
{
"label": "account-1",
"status": "active",
"access_token": "...",
"refresh_token": "...",
"expires": 1234567890
}
]
}The swap script must atomically update BOTH:
~/.local/share/opencode/auth.json → provider entry~/.qwen/oauth_creds.json)#!/usr/bin/env python3
"""Atomic token swap for <provider>."""
import json, os, shutil, tempfile
AUTH_JSON = os.path.expanduser("~/.local/share/opencode/auth.json")
POOL_JSON = os.path.expanduser("~/.open-auth-rotator/<provider>/pool.json")
PROVIDER_ID = "<provider-id>" # Must match auth.json key
def atomic_write(path, data):
"""Write via temp file + rename for atomicity."""
fd, tmp = tempfile.mkstemp(dir=os.path.dirname(path), suffix=".tmp")
try:
with os.fdopen(fd, "w") as f:
json.dump(data, f, indent=2)
os.replace(tmp, path) # Atomic on POSIX
except:
os.unlink(tmp)
raise
def swap():
pool = json.load(open(POOL_JSON))
tokens = pool["tokens"]
next_idx = (pool["current_index"] + 1) % len(tokens)
next_token = tokens[next_idx]
# Update auth.json atomically
auth = json.load(open(AUTH_JSON))
auth[PROVIDER_ID] = {
"type": "oauth",
"access": next_token["access_token"],
"refresh": next_token.get("refresh_token", ""),
"expires": next_token.get("expires", 0),
}
atomic_write(AUTH_JSON, auth)
# Update pool index
pool["current_index"] = next_idx
atomic_write(POOL_JSON, pool)Add rate limit detection to ~/.open-auth-rotator/antigravity/core/watcher_log_scan.py:
import re
_PROVIDER_RATE_LIMIT_PATTERN = re.compile(
r"providerID=<provider-id>.*(429|rate.limit|Arrearage|usage_limit|InvalidApiKey)",
re.IGNORECASE,
)
def _scan_logs_provider(log_lines: list[str]) -> bool:
"""Return True if <provider> rate limit detected in recent logs."""
for line in log_lines:
if _PROVIDER_RATE_LIMIT_PATTERN.search(line):
return True
return FalseAdd swap trigger to ~/.open-auth-rotator/antigravity/core/watcher_loop.py:
_PROVIDER_SWAP_SCRIPT = os.path.expanduser("~/.open-auth-rotator/<provider>/swap_token.py")
_PROVIDER_COOLDOWN_SECS = 30
_last_provider_swap = 0
# In main loop:
if _scan_logs_provider(recent_lines):
now = time.time()
if now - _last_provider_swap > _PROVIDER_COOLDOWN_SECS:
result = subprocess.run([sys.executable, _PROVIDER_SWAP_SCRIPT, "--force"],
capture_output=True, text=True, timeout=15)
_last_provider_swap = nowPROVIDER="deepseek" # Change this
PLUGIN_DIR=~/.config/opencode/local-plugins/opencode-${PROVIDER}-auth
mkdir -p $PLUGIN_DIR/srcpackage.json with correct name, module, typesrc/constants.ts with provider ID, OAuth endpoints, API config, model listsrc/types.ts with credential interfacessrc/index.ts with the three hooks (auth.loader, auth.methods, config)index.ts re-export filesrc/oauth.ts if using OAuth (PKCE or Device Flow)cd ~/.cache/opencode
bun -e "const mod = require('opencode-${PROVIDER}-auth'); console.log(Object.keys(mod))"
# Must output: [ "MyProviderAuthPlugin", "default" ]# Copy to cache
cp -R $PLUGIN_DIR ~/.cache/opencode/node_modules/opencode-${PROVIDER}-auth
# Add to cache package.json
python3 -c "
import json
p = json.load(open('$HOME/.cache/opencode/package.json'))
p['dependencies']['opencode-${PROVIDER}-auth'] = '1.0.0'
json.dump(p, open('$HOME/.cache/opencode/package.json', 'w'), indent=2)
"
# Add to opencode.json plugin array (npm-style name, NOT file://)
python3 -c "
import json
c = json.load(open('$HOME/.config/opencode/opencode.json'))
plugins = c.get('plugin', [])
name = 'opencode-${PROVIDER}-auth'
if name not in plugins:
plugins.append(name)
c['plugin'] = plugins
json.dump(c, open('$HOME/.config/opencode/opencode.json', 'w'), indent=2)
"# Start a new opencode session and check logs
ls -lt ~/.local/share/opencode/log/ | head -1
# Then grep for plugin loading:
grep -i "${PROVIDER}" ~/.local/share/opencode/log/<latest>.log
# Must see:
# INFO service=plugin path=opencode-${PROVIDER}-auth loading plugin
# WARN service=bun pkg=opencode-${PROVIDER}-auth ... using cached
# Must NOT see:
# ERROR service=plugin ... failed to load pluginmkdir -p ~/.open-auth-rotator/${PROVIDER}
# Create pool.json, swap_token.py, state.json
# Add watcher integration (scan pattern + swap trigger)
# Create CLI symlink: ln -sf ~/.open-auth-rotator/${PROVIDER}/swap_token.py ~/.local/bin/${PROVIDER}-swap
# Restart watcher: launchctl kickstart -k gui/$(id -u)/com.openantigravity.ratelimit-watcher| Symptom | Cause | Fix |
|---|---|---|
| Plugin loads but auth not available | Using file:// path instead of npm name | Inject into ~/.cache/opencode/node_modules/ and use npm-style name |
404 Not Found on npm registry | Plugin not published to npm | Expected warning — cache fallback works if plugin is in node_modules |
Plugin export is not a function | Wrong export pattern | Must export async function as default, not an object |
| Provider not found in auth login | Plugin's config hook not registering provider | Check config hook sets providers[PROVIDER_ID] correctly |
| Token swap doesn't take effect | Not updating auth.json atomically | Use os.replace() pattern (atomic on POSIX) |
| Session restart required after swap | auth.json key doesn't match provider ID | Provider ID in plugin must match key in auth.json |
bun add fails with 404 | Plugin not on npm | Add cachedVersion entry to ~/.cache/opencode/package.json |
| Plugin | Cache Path | Provider ID | Auth Type |
|---|---|---|---|
| Antigravity | ~/.cache/opencode/node_modules/opencode-antigravity-auth/ | google | OAuth (Google) |
| Qwen | ~/.cache/opencode/node_modules/opencode-qwencode-auth/ | qwen-code | Device Flow |
| OpenRouter | ~/.cache/opencode/node_modules/opencode-openrouter-auth/ | openrouter | OAuth PKCE |
| Provider API Style | npm field in config | Notes |
|---|---|---|
| OpenAI-compatible | @ai-sdk/openai-compatible | Most providers (OpenRouter, DeepSeek, Mistral, Together, etc.) |
| Anthropic-compatible | @ai-sdk/anthropic | Direct Anthropic API |
| Google Generative AI | @ai-sdk/google | Gemini models |
| Custom | @ai-sdk/openai-compatible | Set baseURL in options |
file:/// for auth plugins — auth hooks silently won't activateopencode.json that conflicts with the plugin's config hook — the static block overrides the plugin"main" instead of "module" in package.json — Bun needs "module"export default async (_input) => { ... }"type": "module" — ESM imports will failloader hook with getAuth() to read from auth.jsonos.replace() to prevent corruption during concurrent reads~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.