Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 15 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ Package version: see `package.json` (`version` field).
- Do not hardcode ports other than OAuth callback port `1455`; use existing constants/helpers.
- Do not remove `store: false` or `reasoning.encrypted_content` from shipped config templates.
- Do not treat `oc-chatgpt-multi-auth` as current except in migration/cleanup logic.
- Do not identify a plugin entry by the spelling of its last path segment. Resolve what it points at; a path outside package-manager output - `node_modules`, and the versioned directories of the OpenCode package cache - belongs to whoever wrote it and is never rewritten or removed.
- Do not run the installer to repair a developer machine's config. It writes that machine's real OpenCode config; `update` refreshes the package cache without touching either file.
- Do not expose account emails, access tokens, refresh tokens, or raw prompt/response bodies in normal diagnostics.
- Do not silently delete JSON credentials when keychain operations fail.
- Do not document boolean env overrides as truthy for `"true"` or `"yes"`. Only `"1"` is truthy.
Expand All @@ -91,11 +93,22 @@ npm run test:watch # vitest watch mode
npm run lint # eslint
```

Installer, which writes the real `~/.config/opencode/opencode.json` and
`tui.json` of whoever runs it:

```bash
npx -y oc-codex-multi-auth@latest # register plugin entries only
npx -y oc-codex-multi-auth@latest --full # also install the explicit model catalog
npx -y oc-codex-multi-auth@latest update # refresh the package cache; never reads or writes config
```

A config that already registers this plugin keeps the entry it has, including
one pointing at a working checkout of this repository. The published package
name is added only when nothing in the config resolves to this plugin.

Standalone CLI examples:

```bash
npx -y oc-codex-multi-auth@latest
npx -y oc-codex-multi-auth@latest --full
oc-codex-multi-auth warm
oc-codex-multi-auth status --json
oc-codex-multi-auth doctor
Expand Down
25 changes: 23 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,28 @@ opencode debug config
opencode auth login
```

The default installer only normalizes the plugin entry in `~/.config/opencode/opencode.json`, enables the TUI status plugin in `~/.config/opencode/tui.json`, and clears the cached plugin copy. Catalog modes also merge their selected `provider.openai` definitions. Changed config files are backed up before writing.
The default installer only registers the plugin entry in `~/.config/opencode/opencode.json`, enables the TUI status plugin in `~/.config/opencode/tui.json`, and clears the cached plugin copy. Catalog modes also merge their selected `provider.openai` definitions. Changed config files are backed up before writing.

### Running from a local checkout

You can point OpenCode at a clone of this repository instead of the published
package, which is how the project is developed:

```json
{ "plugin": ["file:///path/to/oc-codex-multi-auth"] }
```

The installer leaves that entry exactly as written. It identifies an entry by
the package it resolves to rather than by how the path is spelled, so a clone
is recognized under any directory name, whether it is referenced as a path, a
`file://` URL, or its build output. `oc-codex-multi-auth` is appended only when
no entry in the config resolves to this plugin, so the installer never replaces
a checkout with the published package or registers both at once.

Stale references the installer itself produced are still retired: the bare
package name repeated, version-pinned entries, the former
`oc-chatgpt-multi-auth` name, and paths into `node_modules` or the OpenCode
package cache.

### Standalone CLI (no agent / no token cost)

Expand Down Expand Up @@ -504,7 +525,7 @@ opencode auth login
<summary><b>Common symptoms</b></summary>

- Plugin does not load: rerun `npx -y oc-codex-multi-auth@latest`, then restart OpenCode
- Config looks wrong: run `opencode debug config` and confirm `"plugin": ["oc-codex-multi-auth"]`
- Config looks wrong: run `opencode debug config` and confirm `"plugin": ["oc-codex-multi-auth"]`, or the path to your checkout when running one
- OAuth callback fails: free port `1455`, then rerun `opencode auth login`
- Browser launch is blocked: use the remote/headless login path from [docs/getting-started.md](docs/getting-started.md#remote-or-headless-login)
- Wrong account is selected: run `codex-list`, then `codex-switch`
Expand Down
24 changes: 20 additions & 4 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,7 @@ import {
} from "./lib/logger.js";
import { createQuotaMonitor } from "./lib/quota-notifications.js";
import { checkAndNotify } from "./lib/auto-update-checker.js";
import { describePluginOrigin, getPluginOrigin, recordPluginOrigin } from "./lib/plugin-origin.js";
import { handleContextOverflow } from "./lib/context-overflow.js";
import {
AccountManager,
Expand Down Expand Up @@ -391,6 +392,7 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
let accountManagerPromise: Promise<AccountManager> | null = null;
let loaderMutex: Promise<void> | null = null;
let startupPrewarmTriggered = false;
let startupOriginRecorded = false;
let startupPreflightShown = false;
let beginnerSafeModeEnabled = false;
const MIN_BACKOFF_MS = 100;
Expand Down Expand Up @@ -2153,10 +2155,10 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
});
}

const underTestRunner =
process.env.VITEST === "true" || process.env.NODE_ENV === "test";
const prewarmEnabled =
process.env.CODEX_AUTH_PREWARM !== "0" &&
process.env.VITEST !== "true" &&
process.env.NODE_ENV !== "test";
process.env.CODEX_AUTH_PREWARM !== "0" && !underTestRunner;

if (!startupPrewarmTriggered && prewarmEnabled && getRequestTransformMode(pluginConfig) === "legacy") {
startupPrewarmTriggered = true;
Expand All @@ -2174,9 +2176,23 @@ export const OpenAIOAuthPlugin: Plugin = async ({ client }: PluginInput) => {
)
: null;

const pluginOrigin = getPluginOrigin();
if (pluginOrigin?.isLocalCheckout) {
logInfo(`Running from ${describePluginOrigin(pluginOrigin)}`);
}
if (pluginOrigin && !startupOriginRecorded && !underTestRunner) {
startupOriginRecorded = true;
recordPluginOrigin(pluginOrigin).catch((err) => {
logDebug(`Failed to record plugin origin: ${err instanceof Error ? err.message : String(err)}`);
});
}

checkAndNotify(async (message, variant) => {
await showToast(message, variant);
}, { autoUpdate: autoUpdateEnabled }).catch((err) => {
}, {
autoUpdate: autoUpdateEnabled,
localCheckout: pluginOrigin?.isLocalCheckout ?? false,
}).catch((err) => {
logDebug(`Update check failed: ${err instanceof Error ? err.message : String(err)}`);
});
await runStartupPreflight();
Expand Down
53 changes: 50 additions & 3 deletions lib/auto-update-checker.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { join } from "node:path";
import { readFileSync, writeFileSync, existsSync, mkdirSync, rmSync, realpathSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { homedir } from "node:os";
import { createLogger } from "./logger.js";

Expand Down Expand Up @@ -104,6 +104,7 @@ export interface UpdateCheckResult {
export interface CheckAndNotifyOptions {
autoUpdate?: boolean;
scheduleCacheClear?: () => boolean;
localCheckout?: boolean;
}

function getManagedPackageNames(): string[] {
Expand All @@ -118,12 +119,50 @@ function getManagedCachePaths(): string[] {
]);
}

export function clearManagedOpenCodePluginCache(paths = getManagedCachePaths()): boolean {
function isInsideDirectory(candidate: string, directory: string): boolean {
const relativePath = relative(directory, candidate);
return relativePath !== "" && !relativePath.startsWith("..") && !isAbsolute(relativePath);
}

export interface EvictionScope {
cacheRoot?: string;
resolveRealPath?: (path: string) => string;
}

/**
* Cache eviction deletes recursively, so it must never act on a path that only
* looks like cache. Resolving symlinks before the containment check is the part
* that matters: a developer who links their working checkout into the cache
* would otherwise have it deleted on exit by a name match alone.
*/
export function isEvictableCachePath(cachePath: string, scope: EvictionScope = {}): boolean {
const { cacheRoot = OPENCODE_CACHE_DIR, resolveRealPath = realpathSync } = scope;
const absolutePath = resolve(cachePath);
const absoluteRoot = resolve(cacheRoot);
if (!isInsideDirectory(absolutePath, absoluteRoot)) return false;

try {
return isInsideDirectory(resolveRealPath(absolutePath), resolveRealPath(absoluteRoot));
} catch {
return false;
}
}

export function clearManagedOpenCodePluginCache(
paths = getManagedCachePaths(),
scope: EvictionScope = {},
): boolean {
let cleared = false;

for (const cachePath of paths) {
try {
if (!existsSync(cachePath)) continue;
if (!isEvictableCachePath(cachePath, scope)) {
log.warn("Refused to clear a plugin cache path that resolves outside the OpenCode cache", {
path: cachePath,
});
continue;
}
rmSync(cachePath, { recursive: true, force: true });
cleared = true;
log.info("Cleared OpenCode plugin cache for update", { path: cachePath });
Expand Down Expand Up @@ -186,6 +225,14 @@ export async function checkAndNotify(
options: CheckAndNotifyOptions = {},
): Promise<void> {
try {
// The published version says nothing about a build loaded from a checkout,
// and evicting the cache would not update it. Offering either is noise at
// best and an invitation to overwrite the checkout at worst.
if (options.localCheckout) {
log.debug("Skipping the update check for a plugin loaded from a local checkout");
return;
}

const result = await checkForUpdates();

if (result.hasUpdate && result.latestVersion) {
Expand Down
Loading