Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 63 additions & 4 deletions lib/auth/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,12 @@ function commandExists(command: string): boolean {
}

/**
* Opens a URL in the default browser
* Silently fails if browser cannot be opened (user can copy URL manually)
* @param url - URL to open
* @returns True if a browser launch was attempted
* Attempts to open the given URL in the system default browser.
*
* Concurrency: this function launches a detached child process and does not wait for completion; calling it concurrently is safe. On Windows the URL is sanitized for PowerShell quoting rules but not redacted. The function does not modify or redact any sensitive tokens that may be present in the URL.
*
* @param url - The URL to open; may contain query tokens or sensitive data which will not be redacted by this function.
* @returns `true` if a browser launch was attempted, `false` otherwise.
*/
export function openBrowserUrl(url: string): boolean {
try {
Expand Down Expand Up @@ -95,3 +97,60 @@ export function openBrowserUrl(url: string): boolean {
}
}

/**
* Attempts to copy the provided string to the system clipboard using platform-appropriate utilities.
*
* This is a best-effort, fire-and-forget operation: child processes are launched and errors are ignored, so concurrent calls are safe but success is only indicated by whether a clipboard command was started. On Windows the text is escaped for PowerShell before launching Set-Clipboard. This function does not redact secrets; callers must remove or mask tokens/credentials before calling.
*
* @param text - The string to copy; empty or falsy values are ignored
* @returns `true` if a platform clipboard command was launched, `false` otherwise
*/
export function copyTextToClipboard(text: string): boolean {
try {
if (!text) return false;

if (process.platform === "win32") {
const psText = text
.replace(/`/g, "``")
.replace(/\$/g, "`$")
.replace(/"/g, '""');
const child = spawn(
"powershell.exe",
["-NoLogo", "-NoProfile", "-Command", `Set-Clipboard -Value "${psText}"`],
{ stdio: "ignore" },
);
child.on("error", () => {});
return true;
}

if (process.platform === "darwin") {
if (!commandExists("pbcopy")) return false;
const child = spawn("pbcopy", [], {
stdio: ["pipe", "ignore", "ignore"],
shell: false,
});
child.on("error", () => {});
child.stdin?.end(text);
return true;
}

const linuxClipboardCommands: Array<{ command: string; args: string[] }> = [
{ command: "wl-copy", args: [] },
{ command: "xclip", args: ["-selection", "clipboard"] },
{ command: "xsel", args: ["--clipboard", "--input"] },
];
for (const { command, args } of linuxClipboardCommands) {
if (!commandExists(command)) continue;
const child = spawn(command, args, {
stdio: ["pipe", "ignore", "ignore"],
shell: false,
});
child.on("error", () => {});
child.stdin?.end(text);
return true;
}
return false;
} catch {
return false;
}
}
18 changes: 14 additions & 4 deletions lib/auth/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,16 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const successHtml = fs.readFileSync(path.join(__dirname, "..", "oauth-success.html"), "utf-8");

/**
* Start a small local HTTP server that waits for /auth/callback and returns the code
* @param options - OAuth state for validation
* @returns Promise that resolves to server info
* Start a local HTTP listener that accepts the OAuth redirect on /auth/callback and provides helpers to obtain the authorization code.
*
* The server binds to 127.0.0.1:1455 and validates the `state` query parameter before capturing the `code`. The resolved object exposes `port`, `ready`, `close()`, and `waitForCode()`; `waitForCode()` polls for a captured code for up to 5 minutes and returns `{ code }` or `null`. Concurrency: the implementation expects a single consumer of `waitForCode()` (it returns the first captured code). No filesystem side effects are performed (Windows path semantics are not relevant). Authorization codes are handled in-memory and are not logged by this module; callers should avoid logging or persisting returned codes to prevent accidental token leakage.
*
* @param state - The expected OAuth `state` value used to validate incoming callback requests
* @returns An object with runtime info and helpers:
* - `port`: 1455
* - `ready`: `true` if the server successfully bound to the port, `false` if binding failed and manual paste is required
* - `close()`: stops the server and aborts any pending polling
* - `waitForCode()`: polls for the authorization code and resolves to `{ code }` when received or `null` on abort/timeout
*/
export function startLocalOAuthServer({ state }: { state: string }): Promise<OAuthServerInfo> {
let pollAborted = false;
Expand All @@ -39,7 +46,10 @@ export function startLocalOAuthServer({ state }: { state: string }): Promise<OAu
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.setHeader("X-Frame-Options", "DENY");
res.setHeader("X-Content-Type-Options", "nosniff");
res.setHeader("Content-Security-Policy", "default-src 'self'; script-src 'none'");
res.setHeader(
"Content-Security-Policy",
"default-src 'none'; style-src 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; script-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
);
res.end(successHtml);
(server as http.Server & { _lastCode?: string })._lastCode = code;
} catch (err) {
Expand Down
153 changes: 142 additions & 11 deletions lib/auto-update-checker.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { homedir } from "node:os";
import { createLogger } from "./logger.js";
import { getCodexCacheDir } from "./runtime-paths.js";

const log = createLogger("update-checker");

const PACKAGE_NAME = "codex-multi-auth";
const NPM_REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
const CACHE_DIR = join(homedir(), ".opencode", "cache");
const CACHE_DIR = getCodexCacheDir();
const CACHE_FILE = join(CACHE_DIR, "update-check-cache.json");
const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;

Expand All @@ -22,6 +22,21 @@ interface NpmPackageInfo {
name: string;
}

interface ParsedSemver {
core: [number, number, number];
prerelease: string[];
}

/**
* Reads the package.json located one directory above this module and returns its version string.
*
* The function performs a synchronous file read and parse; if the file cannot be accessed or parsed it returns "0.0.0".
* It uses import.meta.dirname when available and falls back to __dirname for environments where that is required (including Windows/CommonJS interop).
*
* Concurrency: the synchronous filesystem call blocks the event loop briefly but is safe to invoke from multiple callers.
*
* @returns The package version string, or "0.0.0" when the version cannot be determined.
*/
function getCurrentVersion(): string {
try {
const packageJsonPath = join(import.meta.dirname ?? __dirname, "..", "package.json");
Expand All @@ -42,6 +57,17 @@ function loadCache(): UpdateCheckCache | null {
}
}

/**
* Persists the update-check cache to disk, creating the cache directory if it does not exist.
*
* Writes `cache` to the configured cache file as pretty-printed JSON. On failure the error is logged and the function returns without throwing.
*
* @param cache - Cache object containing `lastCheck`, `latestVersion`, and `currentVersion`
*
* Concurrency: no locking is performed; concurrent writers may race and last write wins.
* Windows: directory creation and file writes follow Node's fs semantics on Windows.
* Redaction: the cache is not expected to contain sensitive tokens; logged error messages will include only the error message, not file contents.
*/
function saveCache(cache: UpdateCheckCache): void {
try {
if (!existsSync(CACHE_DIR)) {
Expand All @@ -53,19 +79,124 @@ function saveCache(cache: UpdateCheckCache): void {
}
}

function compareVersions(current: string, latest: string): number {
const currentParts = current.split(".").map((p) => parseInt(p, 10) || 0);
const latestParts = latest.split(".").map((p) => parseInt(p, 10) || 0);

for (let i = 0; i < Math.max(currentParts.length, latestParts.length); i++) {
const c = currentParts[i] ?? 0;
const l = latestParts[i] ?? 0;
if (l > c) return 1;
if (l < c) return -1;
/**
* Parse a semantic-version string into numeric core components and prerelease segments.
*
* Accepts versions with an optional leading "v", build metadata (after "+"), and prerelease (after "-").
* The core is returned as [major, minor, patch] with non-numeric or missing parts coerced to 0.
* The prerelease is returned as an array of dot-separated segments (strings); an empty array indicates a release.
*
* This function is pure and safe for concurrent use. It does not access the filesystem, does not perform
* network calls, and does not perform any token redaction or exposure.
*
* @param version - The semver string to parse (for example "v1.2.3", "1.2.3-alpha.1+build.5")
* @returns An object with `core` set to numeric [major, minor, patch] and `prerelease` set to an array of prerelease segments
*/
function parseSemver(version: string): ParsedSemver {
const normalized = version.trim().replace(/^v/i, "");
const [withoutBuild] = normalized.split("+");
const [corePart = "0.0.0", prereleasePart] = (withoutBuild ?? "0.0.0").split("-", 2);
const [majorRaw = "0", minorRaw = "0", patchRaw = "0"] = corePart.split(".");

const toSafeInt = (value: string): number => {
if (!/^\d+$/.test(value)) return 0;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : 0;
};

return {
core: [toSafeInt(majorRaw), toSafeInt(minorRaw), toSafeInt(patchRaw)],
prerelease:
prereleasePart && prereleasePart.trim().length > 0
? prereleasePart.split(".").filter((segment) => segment.length > 0)
: [],
};
}

/**
* Compare two prerelease identifier arrays to determine which represents a newer prerelease.
*
* @param current - Prerelease segments from the current version (e.g., `["alpha", "1"]`)
* @param latest - Prerelease segments from the latest version to compare against
* @returns `1` if `latest` is newer than `current`, `-1` if `current` is newer, `0` if they are equivalent
*/
function comparePrerelease(current: string[], latest: string[]): number {
const maxLen = Math.max(current.length, latest.length);

for (let i = 0; i < maxLen; i++) {
const currentPart = current[i];
const latestPart = latest[i];

if (currentPart === undefined && latestPart === undefined) return 0;
if (currentPart === undefined) return 1;
if (latestPart === undefined) return -1;

if (currentPart === latestPart) continue;

const currentIsNumeric = /^\d+$/.test(currentPart);
const latestIsNumeric = /^\d+$/.test(latestPart);

if (currentIsNumeric && latestIsNumeric) {
const currentNum = Number.parseInt(currentPart, 10);
const latestNum = Number.parseInt(latestPart, 10);
if (latestNum > currentNum) return 1;
if (latestNum < currentNum) return -1;
continue;
}

if (currentIsNumeric && !latestIsNumeric) return 1;
if (!currentIsNumeric && latestIsNumeric) return -1;

const lexical = latestPart.localeCompare(currentPart, "en", { sensitivity: "case" });
if (lexical > 0) return 1;
if (lexical < 0) return -1;
}

return 0;
}

/**
* Compare two semantic version strings and determine which is newer.
*
* @param current - The currently installed version (semver string; may start with "v" and may include build metadata)
* @param latest - The candidate version to compare against (semver string; may start with "v" and may include build metadata)
* @returns `1` if `latest` is newer than `current`, `-1` if `current` is newer than `latest`, `0` if they are equivalent
*
* Notes: pure and safe for concurrent use; does not perform I/O. Version parsing tolerates non-numeric segments by coercion. Safe on Windows filesystems and does not read or emit sensitive tokens.
*/
function compareVersions(current: string, latest: string): number {
const parsedCurrent = parseSemver(current);
const parsedLatest = parseSemver(latest);

for (let i = 0; i < parsedCurrent.core.length; i++) {
const currentPart = parsedCurrent.core[i] ?? 0;
const latestPart = parsedLatest.core[i] ?? 0;
if (latestPart > currentPart) return 1;
if (latestPart < currentPart) return -1;
}

const currentHasPrerelease = parsedCurrent.prerelease.length > 0;
const latestHasPrerelease = parsedLatest.prerelease.length > 0;

if (!currentHasPrerelease && latestHasPrerelease) {
return -1;
}
if (currentHasPrerelease && !latestHasPrerelease) {
return 1;
}

return comparePrerelease(parsedCurrent.prerelease, parsedLatest.prerelease);
}

/**
* Fetches the latest published version string for the package from the npm registry.
*
* Callers may invoke this concurrently; each call issues an independent HTTP request.
* This function performs no filesystem I/O (so Windows filesystem semantics are not applicable)
* and does not process or emit authentication tokens.
*
* @returns The latest version string from the registry, or `null` if the registry could not be reached, returned a non-OK response, or the version field was absent.
*/
async function fetchLatestVersion(): Promise<string | null> {
try {
const controller = new AbortController();
Expand Down
Loading