diff --git a/README.md b/README.md index 5efdb8790..6915f9245 100644 --- a/README.md +++ b/README.md @@ -27,10 +27,14 @@ The application interface currently supports English and Russian. This documenta Launch a shell or agent in a project directory, move and resize its live terminal, zoom out to navigate semantically, and return to Home for sessions, limits, media, and launch shortcuts. CanvasTTY keeps PTY state in the trusted main process and exposes only typed, allow-listed capabilities to the renderer. +For Codex profiles routed through a local OmniRoute gateway, see [real upstream subscription limits](docs/codex-api-limits.md). + ## Agents, orchestration and protection Agents start in **Auto** by default: the CLI's own auto mode (Claude Code, Codex, Grok), CanvasTTY's per-run rules for OpenCode, or, for a CLI without one, its approval bypass only inside agent isolation. Manual, Accept edits, Plan and Bypass are offered only where the CLI has them; Bypass is acknowledged once per CLI and never handed to a subagent. Around every mode sit layers the person controls in Settings → Agents: base protection (hooks that refuse elevation, `curl | sh`, disk commands and writes outside the project before the tool call runs), delegation rules (a subagent never gets more than its orchestrator and stays in its project; 2 levels and 8 live subagents by default), and an OS isolation layer (macOS `sandbox-exec`, Linux bubblewrap; none on Windows yet, where subagents run in Manual). When an isolated session ends, CanvasTTY checks the repositories it touched for git settings that would run programs outside isolation and offers to neutralize them. +For a new local Codex folder, CanvasTTY asks the person to trust it before launching. The host saves only that folder's trust through Codex's configuration API, checks for concurrent edits and keeps a private backup. Isolated agents still cannot modify their permission settings or read these backups. Plugin-managed profiles keep their own trust flow. + An **Orchestrator** session gets the `canvastty_agents` tools: `list_providers` (installed agents, sign-in state, models, efforts, profiles), `spawn_agent` with optional `model`, `effort` and `profile`, `wait_for_agent` (up to 600 s) and `get_agent_result`, which returns a Codex or OpenCode subagent's final reply as `answer` (up to 4,096 characters, masked). See [agent orchestration and isolation](docs/agent-orchestration.md) and [protection layers](docs/installing-and-security.md#agent-protection-layers). ## Windows shells and provider CLIs diff --git a/docs/agent-orchestration.md b/docs/agent-orchestration.md index 0e8b6e3f4..bf0e96436 100644 --- a/docs/agent-orchestration.md +++ b/docs/agent-orchestration.md @@ -125,6 +125,10 @@ Human changes to an owned window still matter. Do not submit work while someone For a visible numbered menu, `screen` returns `interaction.options`, the selected option and a screen revision. After inspecting the choices and confirming the action is within the task's authorization, use `choose SESSION_ID --choice N --revision HASH`. To close an idle menu that explicitly offers Escape, use `dismiss SESSION_ID --revision HASH`. Both commands reject a changed screen, act only on the owned PTY and return delivery receipts; inspect the next screen to confirm the outcome. They do not automatically approve anything. +Launch options do not imply a separate Codex profile: after preparation, CanvasTTY compares the resolved configuration file with its local profile, including symlink aliases. A local person-started or restarted card keeps the host folder-trust check; a plugin-owned profile or environment does not grant trust to the unrelated local profile. The per-launch interactive guard is never persisted or handed to agents or controllers. + +Automatic restoration checks the saved trust for builtin local Codex cards before launch. If trust is absent or cannot be read, the card and its descendants remain stopped; use Restart to review the folder through the host. Restoration never grants new trust, and descendants wait for a contributed parent's trust check before starting. Concurrent person-approved trust changes are serialized per canonical Codex profile and retain Codex's version guard against external edits. + Codex may show **Hooks need review** on first launch or after the bundled lifecycle helpers change. Review the listed commands before trusting them through the observed menu. This is a separate startup step from selecting YOLO and is never dismissed by submitting a task. Controlled launches disable Codex's terminal animations for stable composer detection; ordinary sessions keep their configuration. ## Retry and ownership diff --git a/docs/codex-api-limits.md b/docs/codex-api-limits.md new file mode 100644 index 000000000..705930b3f --- /dev/null +++ b/docs/codex-api-limits.md @@ -0,0 +1,17 @@ +# Codex limits behind an API gateway + +`codex app-server` reports ChatGPT subscription limits only for its authenticated native account. A custom API profile cannot identify the upstream account from its model name. + +For a local OmniRoute gateway, CanvasTTY can read the real Codex connection quota through the installed OmniRoute CLI. Set these variables in CanvasTTY's launch environment: + +- `CANVASTTY_OMNIROUTE_BIN`: absolute path to the installed `omniroute` executable (or Windows `.cmd`). +- `CANVASTTY_OMNIROUTE_URL`: the local gateway origin, such as `http://127.0.0.1:20128`. +- `CANVASTTY_OMNIROUTE_CODEX_CONNECTION`: the connection ID used by the inference route. + +This is explicit opt-in. The connection must be active and belong to `codex`; update the binding if routing moves to another account. CanvasTTY uses the CLI's existing local management authentication and never reads or copies upstream OAuth credentials. Remote gateways and URL-borne credentials are rejected. + +Only API-backed Codex profiles use this fallback. Native profiles retain their normal app-server source. The reader uses the connection-specific live usage endpoint, not the generic synthetic quota/queue signal. It shows only windows actually reported by the provider, including their real duration and reset time. A source key named `session` does not imply a five-hour window. + +Gateway observations carry an unknown local account scope and are not attributed to a different local ChatGPT login. Missing configuration keeps the API-profile explanation; unavailable or malformed upstream data never becomes a fabricated quota bar. + +Cached quotas are discarded when the local Codex account or provider configuration changes. A login change during a gateway read also discards that response, including transient-error fallback, so a previous connection's quota cannot appear under the new account. diff --git a/src/main/index.ts b/src/main/index.ts index b9832dcee..b391198e4 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -31,6 +31,7 @@ import { PixelSkinPackRegistry } from "./services/PixelSkinPackRegistry"; import { TerminalManager, reachesObservers, reachesRenderer } from "./services/TerminalManager"; import { TerminalRendererOutbox } from "./services/TerminalRendererOutbox"; import { AgentControlGateway } from "./services/agent-control/AgentControlGateway"; +import { ensureCodexFolderTrust } from "./services/CodexFolderTrust"; import { TerminalSessionStore } from "./services/TerminalSessionStore"; import { AgentChatHistoryService } from "./services/AgentChatHistoryService"; import { LimitsService, withoutAccountScope } from "./services/LimitsService"; @@ -728,6 +729,15 @@ async function initializeServices(ipc: IpcRegistrar): Promise { // launch options or an environment ask their plugin's service, so start services first. pluginServices.hostReady(); await pluginServicesStarted.catch(() => undefined); + const restoreTrustClis = providerClis; + terminalManager.configureCodexRestoreTrust((cwd) => ensureCodexFolderTrust({ + providerClis: restoreTrustClis, + clientVersion: app.getVersion(), + cwd, + backupDirectory: join(userDataPath, "codex-config-backups"), + // Saved cards do not authorize a new trust decision; Restart uses authenticated person IPC. + confirm: async () => false + })); await terminalManager.restorePersistedSessions(); // The agent-control endpoint follows Settings → Agents → "Agent orchestration // endpoint"; the start flag / env var force it on for one launch (CI smoke) @@ -852,6 +862,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise { return { availability, settings: updatedSettings }; }, terminals: terminalManager, + providerClis, limits: limitsService, agentChatHistory, usageHistory, diff --git a/src/main/ipc/registerIpc.ts b/src/main/ipc/registerIpc.ts index 00d3feb6c..9ab11298d 100644 --- a/src/main/ipc/registerIpc.ts +++ b/src/main/ipc/registerIpc.ts @@ -1,4 +1,5 @@ import { realpath } from "node:fs/promises"; +import { join } from "node:path"; import { app, BrowserWindow, clipboard, dialog, shell } from "electron"; import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions, WebContents } from "electron"; import type { @@ -50,6 +51,7 @@ import type { HermesHudService } from "../services/HermesHudService"; import { normalizeExternalUrl } from "../../shared/externalUrl"; import { readHomeMedia } from "../services/homeMedia"; import { openTerminalFile } from "../services/terminalFileEditor"; +import { ensureCodexFolderTrust } from "../services/CodexFolderTrust"; interface CriticalDependencies { settings: SettingsStore; @@ -66,6 +68,7 @@ interface Dependencies { recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>; terminals: TerminalManager; agentChatHistory: AgentChatHistoryService; + providerClis: ProviderCliRegistry; limits: LimitsService; usageHistory: UsageHistoryService; plugins: PluginManager; @@ -206,6 +209,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { recheckProviderClis, terminals, agentChatHistory, + providerClis, limits, usageHistory, plugins, @@ -285,7 +289,7 @@ export function registerIpc(ipcMain: IpcRegistrar, { ipcMain.handle(IPC.agentChatHistoryResume, (event, provider: AgentChatHistoryProviderId, id: string, position: Point) => { assertMainRenderer(event, getMainWindow); if (!position || !Number.isFinite(position.x) || !Number.isFinite(position.y)) throw new Error("Invalid history card position."); - return agentChatHistory.resume(provider, id, position); + return agentChatHistory.resume(provider, id, position, trustCodexFolder); }); ipcMain.handle(IPC.settingsUpdate, async (event, patch: Partial) => { assertMainRenderer(event, getMainWindow); @@ -941,13 +945,45 @@ export function registerIpc(ipcMain: IpcRegistrar, { if (typeof id !== "string") throw new Error("Terminal session ID is required."); return terminals.readBuffer(id); }); - ipcMain.handle(IPC.terminalCreate, (event, request: CreateSessionRequest) => { + // Only the main renderer may ask the host to persist this narrow permission change. An agent's control, + // orchestration, plugin and terminal gateways cannot invoke it; config.toml remains read-only in isolation. + const trustCodexFolder = (cwd: string): Promise => ensureCodexFolderTrust({ + providerClis, + clientVersion: app.getVersion(), + cwd, + backupDirectory: join(app.getPath("userData"), "codex-config-backups"), + confirm: async (folder) => { + const owner = getMainWindow(); + if (!owner || owner.isDestroyed()) return false; + const ru = settings.get().locale === "ru"; + const { response } = await dialog.showMessageBox(owner, { + type: "warning", + title: ru ? "Доверие к папке Codex" : "Codex folder trust", + message: ru ? "Доверять этой папке?" : "Trust this folder?", + detail: `${folder}\n\n${ru + ? "Настройки этой папки могут автоматически запускать код. Продолжайте, только если доверяете её содержимому. Решение сохранится в профиле Codex." + : "Folder settings can run code automatically. Continue only if you trust its contents. Your decision will be saved in the Codex profile."}`, + buttons: ru ? ["Отмена", "Доверять и продолжить"] : ["Cancel", "Trust and continue"], + defaultId: 0, cancelId: 0, noLink: true + }); + return response === 1; + } + }); + ipcMain.handle(IPC.terminalCreate, async (event, request: CreateSessionRequest) => { assertMainRenderer(event, getMainWindow); - return terminals.create(request); + // Without launch options the profile is known now; contributed launches check their resolved profile before spawning. + if (request?.provider === "codex" && !request.environment && !request.parentSessionId + && request.role !== "subagent" && !Object.keys(request.launchOptions ?? {}).length) { + await trustCodexFolder(request.cwd); + } + return terminals.create(request, { beforeLocalCodexLaunch: trustCodexFolder }); }); - ipcMain.handle(IPC.terminalRestart, (event, id: string, options?: { resume?: unknown }) => { + ipcMain.handle(IPC.terminalRestart, async (event, id: string, options?: { resume?: unknown }) => { assertMainRenderer(event, getMainWindow); - return terminals.restart(id, { resume: options?.resume === true }); + const metadata = terminals.getMetadata(id); + const folder = terminals.localCodexFolderForTrust(id); + if (metadata?.exitCode !== null && folder) await trustCodexFolder(folder); + return terminals.restart(id, { resume: options?.resume === true, beforeLocalCodexLaunch: trustCodexFolder }); }); ipcMain.on(IPC.terminalInput, (event, id: string, data: string) => { // Fire-and-forget: a foreign sender is dropped instead of throwing into the IPC layer. diff --git a/src/main/services/AgentChatHistoryService.ts b/src/main/services/AgentChatHistoryService.ts index 0b361744a..5c24c7c0e 100644 --- a/src/main/services/AgentChatHistoryService.ts +++ b/src/main/services/AgentChatHistoryService.ts @@ -16,6 +16,7 @@ import { cursorHistory } from "./agent-history/cursorHistory.ts"; import { resolveAgentHistoryPaths } from "./agent-history/historyPaths.ts"; const PAGE_SIZE = 50; +type CodexLaunchGuard = (cwd: string) => Promise; type CachedHistory = HistoryRecords & { generation: number }; export class AgentChatHistoryService { @@ -92,8 +93,8 @@ export class AgentChatHistoryService { } } - async resume(provider: AgentChatHistoryProviderId, id: string, position: Point): Promise { - try { return await this.resumeConversation(provider, id, position); } + async resume(provider: AgentChatHistoryProviderId, id: string, position: Point, beforeCodexLaunch?: CodexLaunchGuard): Promise { + try { return await this.resumeConversation(provider, id, position, beforeCodexLaunch); } catch (error) { return { error: { code: "resume-failed", message: missing(error) ? "History store was not found. Refresh the list." @@ -101,12 +102,12 @@ export class AgentChatHistoryService { } } - private async resumeConversation(provider: AgentChatHistoryProviderId, id: string, position: Point): Promise { + private async resumeConversation(provider: AgentChatHistoryProviderId, id: string, position: Point, beforeCodexLaunch?: CodexLaunchGuard): Promise { this.assertProvider(provider); const threadId = normalizeThreadId(provider, id); if (!threadId) return { error: { code: "invalid-id", message: "Invalid provider conversation ID." } }; const existing = this.terminals.findLocalConversation(provider, threadId); - if (existing) return this.resumeExisting(existing); + if (existing) return this.resumeExisting(existing, beforeCodexLaunch); const cli = this.providerClis.get(provider); if (cli.state === "unavailable") return { error: { code: "cli-unavailable", message: cli.diagnostic } }; // Reload before a launch: the entry may have been deleted or its recorded cwd changed. @@ -124,13 +125,30 @@ export class AgentChatHistoryService { } signal.throwIfAborted(); this.assertProvider(provider); + if (provider === "codex") await beforeCodexLaunch?.(item.cwd); + signal.throwIfAborted(); + this.assertProvider(provider); const raced = this.terminals.findLocalConversation(provider, threadId); - if (raced) return this.resumeExisting(raced); + if (raced) return this.resumeExisting(raced, beforeCodexLaunch); return { session: this.terminals.create({ provider, cwd: item.cwd, profile: "normal", position, title: item.title, resumeThreadId: threadId }), reused: false }; } - private resumeExisting(session: SessionSnapshot): AgentChatHistoryResumeResult { - const resumed = session.exitCode === null ? session : this.terminals.restart(session.id, { resume: true }); + private async resumeExisting(session: SessionSnapshot, beforeCodexLaunch?: CodexLaunchGuard): Promise { + if (session.exitCode !== null && beforeCodexLaunch) { + const folder = this.terminals.localCodexFolderForTrust(session.id); + if (folder) await beforeCodexLaunch(folder); + this.controller.signal.throwIfAborted(); + this.assertProvider(session.provider as AgentChatHistoryProviderId); + const current = this.terminals.list().find((candidate) => candidate.id === session.id); + if (!current) throw new Error("The conversation was closed during confirmation."); + if (current.exitCode !== null && folder && this.terminals.localCodexFolderForTrust(session.id) !== folder) { + throw new Error("The conversation folder changed during confirmation."); + } + session = current; + } + const resumed = session.exitCode === null ? session : this.terminals.restart(session.id, { + resume: true, ...(beforeCodexLaunch ? { beforeLocalCodexLaunch: beforeCodexLaunch } : {}) + }); if (resumed.status === "failed") { return { error: { code: "resume-failed", message: resumed.failureDetails || "The conversation could not be resumed. Check the terminal card." } }; } diff --git a/src/main/services/CodexAppServerClient.ts b/src/main/services/CodexAppServerClient.ts new file mode 100644 index 000000000..949ba76c9 --- /dev/null +++ b/src/main/services/CodexAppServerClient.ts @@ -0,0 +1,253 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { providerChildProcessLaunch, type AvailableProviderCli } from "./providerCliRegistry.ts"; +import { NdjsonLineReader } from "../../agent-runtime/ndjson.mjs"; + +const MAX_LINE_BYTES = 1_048_576; +const REQUEST_TIMEOUT_MS = 15_000; +type CodexAppServerErrorReason = "cli-not-found" | "protocol-error" | "timeout"; +interface JsonRpcResponse { id?: unknown; result?: unknown; error?: unknown } +interface PendingRequest { + resolve(value: unknown): void; + reject(error: CodexAppServerError): void; + timer: NodeJS.Timeout; +} +export class CodexAppServerError extends Error { + readonly reason: CodexAppServerErrorReason; + constructor(reason: CodexAppServerErrorReason) { super(reason); this.reason = reason; } +} + +/** Shared bounded stdio transport. No provider credentials or RPC error bodies are logged. */ +export class CodexAppServerClient { + private child: ChildProcessWithoutNullStreams | null = null; + private ready: Promise | null = null; + private pending = new Map(); + private nextId = 1; + private disposed = false; + private idleTimer: NodeJS.Timeout | null = null; + private readonly cli: AvailableProviderCli | null; + private readonly clientVersion: string; + private readonly idleMs: number; + private readonly cwd: string | undefined; + + constructor(cli: AvailableProviderCli | null, clientVersion: string, idleMs: number, cwd?: string) { + this.cli = cli; + this.clientVersion = clientVersion; + this.idleMs = idleMs; + this.cwd = cwd; + } + + async call(method: string, params?: unknown): Promise { + this.clearIdleTimer(); + try { + await this.ensureConnected(); + return await this.request(method, params); + } finally { + this.scheduleIdleStop(); + } + } + + /** Drops the current app-server so the next read starts one with the current login and configuration. */ + restart(): void { + if (this.disposed) return; + this.resetConnection(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.clearIdleTimer(); + this.rejectPending("protocol-error"); + this.stopChild(); + } + + /** Stops the app-server once no read came for `idleMs`; the next read starts it again. */ + private scheduleIdleStop(): void { + this.clearIdleTimer(); + if (this.disposed || !this.child || this.pending.size > 0) return; + this.idleTimer = setTimeout(() => { + this.idleTimer = null; + if (this.pending.size === 0) this.resetConnection(); + }, this.idleMs); + this.idleTimer.unref(); + } + + private clearIdleTimer(): void { + if (this.idleTimer) clearTimeout(this.idleTimer); + this.idleTimer = null; + } + + private ensureConnected(): Promise { + if (this.disposed) return Promise.reject(new CodexAppServerError("protocol-error")); + if (this.ready) return this.ready; + + this.startChild(); + this.ready = this.request("initialize", { + clientInfo: { name: "canvastty", version: this.clientVersion } + }).then(() => { + this.notify("initialized", {}); + }).catch((error: unknown) => { + this.resetConnection(); + throw error; + }); + return this.ready; + } + + private startChild(): void { + if (!this.cli) throw new CodexAppServerError("cli-not-found"); + const launch = providerChildProcessLaunch( + this.cli, + ["app-server", "--listen", "stdio://"] + ); + let child: ChildProcessWithoutNullStreams; + try { + child = spawn(launch.command, launch.args, { + shell: false, + ...(this.cwd ? { cwd: this.cwd } : {}), + env: { ...process.env, ...launch.environment }, + stdio: ["pipe", "pipe", "pipe"], + ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) + }); + } catch { + throw new CodexAppServerError("cli-not-found"); + } + + this.child = child; + const lines = new NdjsonLineReader({ maxLineBytes: MAX_LINE_BYTES }); + child.stdout.on("data", (chunk: Buffer) => this.consume(child, lines, chunk)); + child.stdin.on("error", () => this.connectionFailed("protocol-error", child)); + child.stderr.resume(); + child.once("error", (error: NodeJS.ErrnoException) => { + this.connectionFailed(error.code === "ENOENT" ? "cli-not-found" : "protocol-error", child); + }); + child.once("close", () => this.connectionFailed("protocol-error", child)); + } + + private request(method: string, params?: unknown): Promise { + const child = this.child; + if (!child || !child.stdin.writable || this.disposed) { + return Promise.reject(new CodexAppServerError("protocol-error")); + } + + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new CodexAppServerError("timeout")); + this.resetConnection(); + }, REQUEST_TIMEOUT_MS); + timer.unref(); + this.pending.set(id, { resolve, reject, timer }); + + if (!this.write({ id, method, ...(params === undefined ? {} : { params }) })) { + clearTimeout(timer); + this.pending.delete(id); + reject(new CodexAppServerError("protocol-error")); + this.resetConnection(); + } + }); + } + + private notify(method: string, params: unknown): void { + if (!this.write({ method, params })) { + throw new CodexAppServerError("protocol-error"); + } + } + + private write(message: object): boolean { + const child = this.child; + if (!child || !child.stdin.writable || this.disposed) return false; + const line = `${JSON.stringify(message)}\n`; + if (Buffer.byteLength(line) > MAX_LINE_BYTES) return false; + try { + child.stdin.write(line); + return true; + } catch { + return false; + } + } + + private consume(child: ChildProcessWithoutNullStreams, lines: NdjsonLineReader, chunk: Buffer): void { + if (this.disposed || child !== this.child) return; + let complete: Buffer[]; + try { + complete = lines.push(chunk); + } catch { + this.connectionFailed("protocol-error", child); + return; + } + for (const raw of complete) { + if (child !== this.child) return; + const line = raw.toString("utf8").trim(); + if (line) this.consumeLine(line); + } + } + + private consumeLine(line: string): void { + let message: JsonRpcResponse; + try { + message = JSON.parse(line) as JsonRpcResponse; + } catch { + this.connectionFailed("protocol-error", this.child); + return; + } + + if (typeof message.id !== "number") return; + const pending = this.pending.get(message.id); + if (!pending) return; + clearTimeout(pending.timer); + this.pending.delete(message.id); + + if (message.error !== undefined) { + pending.reject(new CodexAppServerError("protocol-error")); + return; + } + pending.resolve(message.result); + } + + private connectionFailed(reason: CodexAppServerErrorReason, child: ChildProcessWithoutNullStreams | null): void { + if (child && child !== this.child) return; + this.rejectPending(reason); + this.resetConnection(); + } + + private rejectPending(reason: CodexAppServerErrorReason): void { + for (const pending of this.pending.values()) { + clearTimeout(pending.timer); + pending.reject(new CodexAppServerError(reason)); + } + this.pending.clear(); + } + + private resetConnection(): void { + this.ready = null; + this.rejectPending("protocol-error"); + this.stopChild(); + } + + private stopChild(): void { + const child = this.child; + this.child = null; + if (!child) return; + + child.removeAllListeners("error"); + child.removeAllListeners("close"); + child.stdout.removeAllListeners("data"); + try { + child.stdin.end(); + child.kill(); + } catch { + // The process may already be gone. + } + + if (child.exitCode === null && child.signalCode === null) { + const hardKillTimer = setTimeout(() => { + try { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } catch { + // Best-effort hardening during shutdown. + } + }, 500); + hardKillTimer.unref(); + } + } +} diff --git a/src/main/services/CodexFolderTrust.ts b/src/main/services/CodexFolderTrust.ts new file mode 100644 index 000000000..e610f06a0 --- /dev/null +++ b/src/main/services/CodexFolderTrust.ts @@ -0,0 +1,120 @@ +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises"; +import { homedir, userInfo } from "node:os"; +import { basename, dirname, isAbsolute, join } from "node:path"; +import { CodexAppServerClient } from "./CodexAppServerClient.ts"; +import type { ProviderCliRegistry } from "./providerCliRegistry.ts"; + +const record = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +// New plugin profiles may not exist yet; resolve through their nearest existing ancestor. +async function canonicalConfigPath(file: string): Promise { + try { return await realpath(file); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + const parent = dirname(file); + if (parent === file) throw error; + return join(await canonicalConfigPath(parent), basename(file)); + } +} + +function profileConfig(environment: Readonly>): string { + const value = (name: string): string | undefined => process.platform === "win32" + ? Object.entries(environment).reverse().find(([key]) => key.toUpperCase() === name)?.[1] + : environment[name]; + // Codex's dirs::home_dir uses HOME on Unix and the OS account profile on Windows. + const home = value("CODEX_HOME") || join( + (process.platform === "win32" ? undefined : value("HOME")) || userInfo().homedir, ".codex" + ); + if (!isAbsolute(home)) throw new Error("Codex profile path must be absolute."); + return join(home, "config.toml"); +} + +/** Compare the actual config file, including explicit overrides and symlink aliases. */ +export async function usesLocalCodexConfig( + baseEnvironment: Readonly>, + launchEnvironment: Readonly> +): Promise { + return await canonicalConfigPath(profileConfig(baseEnvironment)) + === await canonicalConfigPath(profileConfig(launchEnvironment)); +} + +// Canonical profile paths also coalesce launches made through symlinked CODEX_HOME aliases. +const profileTrustWrites = new Map>(); + +async function serializeTrust(file: string, operation: () => Promise): Promise { + const previous = profileTrustWrites.get(file) ?? Promise.resolve(); + const current = previous.catch(() => undefined).then(operation); + profileTrustWrites.set(file, current); + try { await current; } finally { + if (profileTrustWrites.get(file) === current) profileTrustWrites.delete(file); + } +} + +/** Host-only, called from person-authenticated IPC, never a terminal or agent gateway. */ +export async function ensureCodexFolderTrust(options: { + providerClis: ProviderCliRegistry; + clientVersion: string; + cwd: string; + backupDirectory: string; + confirm(folder: string): Promise; +}): Promise { + const folder = await realpath(options.cwd); + if (!(await stat(folder)).isDirectory()) throw new Error("Project folder is not a directory."); + const cli = options.providerClis.get("codex"); + if (cli.state !== "available") throw new Error("Codex CLI is unavailable."); + const environment = { ...process.env, ...cli.environment }; + const requestedFile = profileConfig(environment); + const file = await canonicalConfigPath(requestedFile); + await serializeTrust(file, async () => { + // Codex requires an explicit CODEX_HOME to exist, including on the person's first launch. + await mkdir(dirname(requestedFile), { recursive: true, mode: 0o700 }); + if (await canonicalConfigPath(requestedFile) !== file) throw new Error("Codex profile changed before trust could be read."); + const client = new CodexAppServerClient(cli, options.clientVersion, 1_000, environment.HOME || homedir()); + try { + // Do not load the new project's settings before the person has trusted it. + const response = await client.call("config/read", { includeLayers: true }); + if (!record(response) || !Array.isArray(response.layers)) throw new Error("Codex returned no configuration layers."); + let layer: Record | undefined; + for (const entry of response.layers) { + if (record(entry) && record(entry.name) && entry.name.type === "user" && !entry.name.profile + && typeof entry.name.file === "string" && isAbsolute(entry.name.file) + && await canonicalConfigPath(entry.name.file) === file) { + layer = entry; + break; + } + } + // Older app-servers serialize a layer as JSON, including "{}" for an empty profile. + let config = layer?.config; + if (typeof config === "string") { + try { config = JSON.parse(config); } catch { config = null; } + } + if (!layer || !record(config) || typeof layer.version !== "string") { + throw new Error("Codex user configuration could not be identified safely."); + } + const projects = config.projects; + // Exact folder only: a new folder is never silently trusted because a sibling was accepted. + if (record(projects) && record(projects[folder]) && projects[folder].trust_level === "trusted") return; + if (!await options.confirm(folder)) throw new Error("Folder trust was not granted. Session was not started."); + if (await realpath(options.cwd) !== folder) throw new Error("Project folder changed while awaiting trust."); + if (await canonicalConfigPath(requestedFile) !== file) throw new Error("Codex profile changed while awaiting trust."); + // Private, unique backup of the current file; Codex's version check refuses a concurrent edit. + let original: Buffer | null; + try { original = await readFile(file); } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + original = null; + } + if (original !== null) { + await mkdir(options.backupDirectory, { recursive: true, mode: 0o700 }); + await writeFile(join(options.backupDirectory, `${randomUUID()}.toml`), original, { flag: "wx", mode: 0o600 }); + } + await client.call("config/batchWrite", { + filePath: file, + expectedVersion: layer.version, + edits: [{ keyPath: `projects.${JSON.stringify(folder)}.trust_level`, value: "trusted", mergeStrategy: "upsert" }] + }); + } finally { + client.dispose(); + } + }); +} diff --git a/src/main/services/LimitsService.ts b/src/main/services/LimitsService.ts index aa8e8a5bb..9e16ca3f6 100644 --- a/src/main/services/LimitsService.ts +++ b/src/main/services/LimitsService.ts @@ -1,3 +1,4 @@ +import { readOmniRouteCodexLimits, OmniRouteQuotaError } from "./OmniRouteCodexLimits.ts"; import { execFile, spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; @@ -17,7 +18,7 @@ import { type AvailableProviderCli, type ProviderCliRegistry } from "./providerCliRegistry.ts"; -import { NdjsonLineReader } from "../../agent-runtime/ndjson.mjs"; +import { CodexAppServerClient, CodexAppServerError } from "./CodexAppServerClient.ts"; const CACHE_TTL_MS = 60_000; const REQUEST_TIMEOUT_MS = 15_000; @@ -49,23 +50,11 @@ export interface LimitsServiceOptions { claude?: Omit; } -interface JsonRpcResponse { - id?: unknown; - result?: unknown; - error?: unknown; -} - interface RawLimitBucket { snapshot: Record; isDefaultBucket: boolean; } -interface PendingRequest { - resolve(value: unknown): void; - reject(error: LimitsAdapterError): void; - timer: NodeJS.Timeout; -} - interface GrokCredential { token: string; authMode: number; @@ -74,12 +63,13 @@ interface GrokCredential { } export class LimitsService { - private codex: CodexAppServerClient; + private codex: CodexLimitsClient; private kimi: KimiWebUsageClient; private readonly providerClis: ProviderCliRegistry; private readonly clientVersion: string; private readonly cacheTtlMs: number; private readonly codexHome: string; + private readonly environment: Readonly>; private readonly claudeOptions: Omit; private cache: CacheEntry | null = null; private inFlight: Promise | null = null; @@ -87,6 +77,7 @@ export class LimitsService { private readonly providerLoads = new Map>(); // undefined until the first Codex read; null when that read found no account. private lastCodexScope: string | null | undefined = undefined; + private lastCodexConfiguration: string | null | undefined = undefined; private lastGoodCodex: Extract | null = null; private lastGoodClaude: Extract | null = null; private lastGoodKimi: Extract | null = null; @@ -102,9 +93,10 @@ export class LimitsService { this.codexIdleMs = options.codexIdleMs ?? CODEX_IDLE_MS; this.cacheTtlMs = options.cacheTtlMs ?? CACHE_TTL_MS; const environment = options.environment ?? process.env; + this.environment = environment; this.codexHome = environment.CODEX_HOME || join(options.homeDirectory ?? homedir(), ".codex"); this.claudeOptions = options.claude ?? {}; - this.codex = new CodexAppServerClient(availableCli(providerClis, "codex"), clientVersion, this.codexIdleMs); + this.codex = new CodexLimitsClient(availableCli(providerClis, "codex"), clientVersion, this.codexIdleMs); this.kimi = new KimiWebUsageClient(availableCli(providerClis, "kimi")); } @@ -159,11 +151,12 @@ export class LimitsService { if (this.disposed) return; this.codex.dispose(); this.kimi.dispose(); - this.codex = new CodexAppServerClient(availableCli(this.providerClis, "codex"), this.clientVersion, this.codexIdleMs); + this.codex = new CodexLimitsClient(availableCli(this.providerClis, "codex"), this.clientVersion, this.codexIdleMs); this.kimi = new KimiWebUsageClient(availableCli(this.providerClis, "kimi")); this.cache = null; this.providerCache.clear(); this.lastCodexScope = undefined; + this.lastCodexConfiguration = undefined; this.lastGoodCodex = null; this.lastGoodClaude = null; this.lastGoodKimi = null; @@ -219,14 +212,22 @@ export class LimitsService { return unavailable("codex", "codex-app-server", "protocol-error", checkedAt); } + let requestScope: string | null | undefined; try { // The account is bound around the request: a login change before, during or since // the previous read cannot be attributed, so that observation stays account-unknown. const before = await readCodexAccountScope(this.codexHome); + requestScope = before; const accountChanged = this.lastCodexScope !== undefined && before !== this.lastCodexScope; this.lastCodexScope = before; - // The long-lived app-server may still hold the previous login; start a fresh one. - if (accountChanged) this.codex.restart(); + // The app-server captures the provider/profile configuration at startup too. + // Keep only a fingerprint: configuration can contain private provider settings. + const configuration = await readFile(join(this.codexHome, "config.toml")) + .then((contents) => createHash("sha256").update(contents).digest("hex")).catch(() => null); + const configurationChanged = this.lastCodexConfiguration !== undefined && configuration !== this.lastCodexConfiguration; + this.lastCodexConfiguration = configuration; + if (accountChanged || configurationChanged) this.codex.restart(); + if (accountChanged || configurationChanged) this.lastGoodCodex = null; const raw = await this.codex.readRateLimits(); const after = await readCodexAccountScope(this.codexHome); const windows = normalizeCodexLimits(raw); @@ -240,10 +241,43 @@ export class LimitsService { windows, accountScope: !accountChanged && before !== null && before === after ? before : null }; - this.lastGoodCodex = available; + // A response spanning a login change is unscoped and cannot seed the stale cache. + this.lastGoodCodex = before === after ? available : null; return available; } catch (error) { - const reason = adapterReason(error); + if (requestScope !== undefined && requestScope !== await readCodexAccountScope(this.codexHome)) { + this.lastGoodCodex = null; + return unavailable("codex", "codex-app-server", "protocol-error", checkedAt); + } + let reason = adapterReason(error); + let source: LimitSource = "codex-app-server"; + if (reason === "api-profile") { + // Retain gateway observations across transient polls, never native-account windows. + if (this.lastGoodCodex?.source !== "omniroute-usage-api") this.lastGoodCodex = null; + try { + const windows = await readOmniRouteCodexLimits(this.environment, undefined, availableCli(this.providerClis, "codex")?.environment); + if (requestScope !== await readCodexAccountScope(this.codexHome)) { + this.lastGoodCodex = null; + throw new LimitsAdapterError("protocol-error"); + } + if (windows) { + const available: Extract = { + provider: "codex", state: "available", source: "omniroute-usage-api", + fetchedAt: Date.now(), windows, accountScope: null + }; + this.lastGoodCodex = available; + return available; + } + this.lastGoodCodex = null; + } catch (quotaError) { + source = "omniroute-usage-api"; + reason = quotaError instanceof OmniRouteQuotaError ? quotaError.reason : "protocol-error"; + if (reason !== "timeout" && reason !== "protocol-error") this.lastGoodCodex = null; + } + } + if (requestScope !== undefined && requestScope !== await readCodexAccountScope(this.codexHome)) { + this.lastGoodCodex = null; + } if (this.lastGoodCodex) { return { ...this.lastGoodCodex, @@ -252,7 +286,7 @@ export class LimitsService { reason }; } - return unavailable("codex", "codex-app-server", reason, checkedAt); + return unavailable("codex", source, reason, checkedAt); } } @@ -819,235 +853,18 @@ function reserveLoopbackPort(): Promise { }); } -class CodexAppServerClient { - private child: ChildProcessWithoutNullStreams | null = null; - private ready: Promise | null = null; - private pending = new Map(); - private nextId = 1; - private disposed = false; - private idleTimer: NodeJS.Timeout | null = null; - private readonly cli: AvailableProviderCli | null; - private readonly clientVersion: string; - private readonly idleMs: number; - - constructor(cli: AvailableProviderCli | null, clientVersion: string, idleMs: number) { - this.cli = cli; - this.clientVersion = clientVersion; - this.idleMs = idleMs; - } - +class CodexLimitsClient extends CodexAppServerClient { async readRateLimits(): Promise { - this.clearIdleTimer(); - try { - await this.ensureConnected(); - return await this.request("account/rateLimits/read"); - } finally { - this.scheduleIdleStop(); - } - } - - /** Drops the current app-server so the next read starts one with the current login. */ - restart(): void { - if (this.disposed) return; - this.resetConnection(); - } - - dispose(): void { - if (this.disposed) return; - this.disposed = true; - this.clearIdleTimer(); - this.rejectPending("protocol-error"); - this.stopChild(); - } - - /** Stops the app-server once no read came for `idleMs`; the next read starts it again. */ - private scheduleIdleStop(): void { - this.clearIdleTimer(); - if (this.disposed || !this.child || this.pending.size > 0) return; - this.idleTimer = setTimeout(() => { - this.idleTimer = null; - if (this.pending.size === 0) this.resetConnection(); - }, this.idleMs); - this.idleTimer.unref(); - } - - private clearIdleTimer(): void { - if (this.idleTimer) clearTimeout(this.idleTimer); - this.idleTimer = null; - } - - private ensureConnected(): Promise { - if (this.disposed) return Promise.reject(new LimitsAdapterError("protocol-error")); - if (this.ready) return this.ready; - - this.startChild(); - this.ready = this.request("initialize", { - clientInfo: { name: "canvastty", version: this.clientVersion } - }).then(() => { - this.notify("initialized", {}); - }).catch((error: unknown) => { - this.resetConnection(); - throw error; - }); - return this.ready; - } - - private startChild(): void { - if (!this.cli) throw new LimitsAdapterError("cli-not-found"); - const launch = providerChildProcessLaunch( - this.cli, - ["app-server", "--listen", "stdio://"] - ); - let child: ChildProcessWithoutNullStreams; - try { - child = spawn(launch.command, launch.args, { - shell: false, - env: { ...process.env, ...launch.environment }, - stdio: ["pipe", "pipe", "pipe"], - ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) - }); - } catch { - throw new LimitsAdapterError("cli-not-found"); - } - - this.child = child; - const lines = new NdjsonLineReader({ maxLineBytes: MAX_LINE_BYTES }); - child.stdout.on("data", (chunk: Buffer) => this.consume(child, lines, chunk)); - child.stdin.on("error", () => this.connectionFailed("protocol-error", child)); - child.stderr.resume(); - child.once("error", (error: NodeJS.ErrnoException) => { - this.connectionFailed(error.code === "ENOENT" ? "cli-not-found" : "protocol-error", child); - }); - child.once("close", () => this.connectionFailed("protocol-error", child)); - } - - private request(method: string, params?: unknown): Promise { - const child = this.child; - if (!child || !child.stdin.writable || this.disposed) { - return Promise.reject(new LimitsAdapterError("protocol-error")); - } - - const id = this.nextId++; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.pending.delete(id); - reject(new LimitsAdapterError("timeout")); - this.resetConnection(); - }, REQUEST_TIMEOUT_MS); - timer.unref(); - this.pending.set(id, { resolve, reject, timer }); - - if (!this.write({ id, method, ...(params === undefined ? {} : { params }) })) { - clearTimeout(timer); - this.pending.delete(id); - reject(new LimitsAdapterError("protocol-error")); - this.resetConnection(); - } - }); - } - - private notify(method: string, params: unknown): void { - if (!this.write({ method, params })) { + const auth = await this.call("account/read", { refreshToken: false }); + if (!isRecord(auth) || typeof auth.requiresOpenaiAuth !== "boolean" + || !(auth.account === null || isRecord(auth.account) && typeof auth.account.type === "string")) { throw new LimitsAdapterError("protocol-error"); } - } - - private write(message: object): boolean { - const child = this.child; - if (!child || !child.stdin.writable || this.disposed) return false; - const line = `${JSON.stringify(message)}\n`; - if (Buffer.byteLength(line) > MAX_LINE_BYTES) return false; - try { - child.stdin.write(line); - return true; - } catch { - return false; - } - } - - private consume(child: ChildProcessWithoutNullStreams, lines: NdjsonLineReader, chunk: Buffer): void { - if (this.disposed || child !== this.child) return; - let complete: Buffer[]; - try { - complete = lines.push(chunk); - } catch { - this.connectionFailed("protocol-error", child); - return; - } - for (const raw of complete) { - if (child !== this.child) return; - const line = raw.toString("utf8").trim(); - if (line) this.consumeLine(line); - } - } - - private consumeLine(line: string): void { - let message: JsonRpcResponse; - try { - message = JSON.parse(line) as JsonRpcResponse; - } catch { - this.connectionFailed("protocol-error", this.child); - return; - } - - if (typeof message.id !== "number") return; - const pending = this.pending.get(message.id); - if (!pending) return; - clearTimeout(pending.timer); - this.pending.delete(message.id); - - if (message.error !== undefined) { - pending.reject(new LimitsAdapterError("protocol-error")); - return; - } - pending.resolve(message.result); - } - - private connectionFailed(reason: LimitUnavailableReason, child: ChildProcessWithoutNullStreams | null): void { - if (child && child !== this.child) return; - this.rejectPending(reason); - this.resetConnection(); - } - - private rejectPending(reason: LimitUnavailableReason): void { - for (const pending of this.pending.values()) { - clearTimeout(pending.timer); - pending.reject(new LimitsAdapterError(reason)); - } - this.pending.clear(); - } - - private resetConnection(): void { - this.ready = null; - this.rejectPending("protocol-error"); - this.stopChild(); - } - - private stopChild(): void { - const child = this.child; - this.child = null; - if (!child) return; - - child.removeAllListeners("error"); - child.removeAllListeners("close"); - child.stdout.removeAllListeners("data"); - try { - child.stdin.end(); - child.kill(); - } catch { - // The process may already be gone. - } - - if (child.exitCode === null && child.signalCode === null) { - const hardKillTimer = setTimeout(() => { - try { - if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); - } catch { - // Best-effort hardening during shutdown. - } - }, 500); - hardKillTimer.unref(); + if (!auth.requiresOpenaiAuth || isRecord(auth.account) && auth.account.type === "apiKey") { + throw new LimitsAdapterError("api-profile"); } + if (auth.account === null) throw new LimitsAdapterError("not-authenticated"); + return await this.call("account/rateLimits/read"); } } @@ -1061,7 +878,7 @@ class LimitsAdapterError extends Error { } function adapterReason(error: unknown): LimitUnavailableReason { - return error instanceof LimitsAdapterError ? error.reason : "protocol-error"; + return (error instanceof LimitsAdapterError || error instanceof CodexAppServerError) ? error.reason : "protocol-error"; } function unavailable( diff --git a/src/main/services/OmniRouteCodexLimits.ts b/src/main/services/OmniRouteCodexLimits.ts new file mode 100644 index 000000000..9964b1195 --- /dev/null +++ b/src/main/services/OmniRouteCodexLimits.ts @@ -0,0 +1,79 @@ +import { execFile } from "node:child_process"; +import { isAbsolute } from "node:path"; +import type { LimitUnavailableReason, LimitWindow } from "../../shared/contracts"; +import { providerChildProcessLaunch } from "./providerCliRegistry.ts"; + +type Environment = Readonly>; +interface Target { executable: string; baseUrl: string; connectionId: string; environment: Readonly> } +type Run = (target: Target, args: string[]) => Promise; + +export class OmniRouteQuotaError extends Error { + readonly reason: LimitUnavailableReason; + constructor(reason: LimitUnavailableReason) { super(reason); this.reason = reason; } +} + +/** Explicit opt-in: the inference profile alone does not identify an upstream account. */ +export async function readOmniRouteCodexLimits(environment: Environment, run: Run = runJson, childEnvironment: Readonly> = {}): Promise { + const executable = environment.CANVASTTY_OMNIROUTE_BIN; + const baseUrl = environment.CANVASTTY_OMNIROUTE_URL; + const connectionId = environment.CANVASTTY_OMNIROUTE_CODEX_CONNECTION; + if (!executable && !baseUrl && !connectionId) return null; + if (!executable || !isAbsolute(executable) || !baseUrl || !connectionId || !/^[a-zA-Z0-9._-]{1,128}$/.test(connectionId)) { + throw new OmniRouteQuotaError("protocol-error"); + } + let url: URL; + try { url = new URL(baseUrl); } catch { throw new OmniRouteQuotaError("protocol-error"); } + // The installed CLI owns local management authentication; never send it to a remote host. + if (url.protocol !== "http:" || !["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) + || url.username || url.password || url.search || url.hash || url.pathname !== "/") { + throw new OmniRouteQuotaError("unsupported-protocol"); + } + const target = { executable, baseUrl: url.origin, connectionId, environment: childEnvironment }; + const providers = await run(target, ["api", "providers", "get-api-providers"]); + const connections = Array.isArray(providers) ? providers : record(providers) ? providers.connections ?? providers.providers : null; + if (!Array.isArray(connections)) throw new OmniRouteQuotaError("protocol-error"); + if (!connections.some((value) => record(value) + && value.id === connectionId && value.provider === "codex" && value.isActive === true)) { + throw new OmniRouteQuotaError("not-authenticated"); + } + const usage = await run(target, ["api", "usage", "get-api-usage-connection-id-", "--connection-id", connectionId]); + if (!record(usage) || !record(usage.quotas)) throw new OmniRouteQuotaError("protocol-error"); + const windows: LimitWindow[] = []; + for (const [key, value] of Object.entries(usage.quotas)) { + if (!record(value) || value.unlimited === true || !finite(value.used) || !finite(value.total) + || value.used < 0 || value.total <= 0) continue; + const reset = typeof value.resetAt === "string" ? Date.parse(value.resetAt) : NaN; + windows.push({ + id: `omniroute:${key}`, bucketId: `omniroute:${key}`, slot: "primary", isDefaultBucket: key === "session" || key === "weekly", + label: typeof value.displayName === "string" ? value.displayName.slice(0, 80) : null, + usedPercent: Math.min(100, value.used / value.total * 100), used: value.used, limit: value.total, + windowMinutes: finite(value.windowSeconds) && value.windowSeconds > 0 ? value.windowSeconds / 60 : null, + resetsAt: Number.isFinite(reset) ? reset : null + }); + if (windows.length === 12) break; + } + if (!windows.length) throw new OmniRouteQuotaError("protocol-error"); + return windows; +} + +function runJson(target: Target, args: string[]): Promise { + const launch = providerChildProcessLaunch({ executable: target.executable, + launcher: process.platform === "win32" && /\.(cmd|bat)$/i.test(target.executable) ? "batch" : "native", + commandPrompt: process.env.ComSpec, environment: {} }, + ["--base-url", target.baseUrl, "--output", "json", ...args]); + return new Promise((resolve, reject) => { + execFile(launch.command, launch.args, { encoding: "utf8", timeout: 15_000, maxBuffer: 1_048_576, + env: { ...process.env, ...target.environment }, windowsHide: true, ...(launch.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}) }, (error, stdout) => { + if (error) return reject(new OmniRouteQuotaError(error.killed ? "timeout" : error.code === "ENOENT" ? "cli-not-found" : "protocol-error")); + // OmniRoute may print boot diagnostics before its JSON. Never log diagnostics or API error bodies. + const text = stdout.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, ""); + for (const match of text.matchAll(/(?:^|\n)[ \t]*(?=[\[{])/g)) { + try { resolve(JSON.parse(text.slice(match.index).trim())); return; } catch { /* try the next JSON line */ } + } + reject(new OmniRouteQuotaError("protocol-error")); + }); + }); +} + +function record(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } +function finite(value: unknown): value is number { return typeof value === "number" && Number.isFinite(value); } diff --git a/src/main/services/TerminalManager.ts b/src/main/services/TerminalManager.ts index 2b70689de..fb815747e 100644 --- a/src/main/services/TerminalManager.ts +++ b/src/main/services/TerminalManager.ts @@ -55,6 +55,7 @@ import { import { codexTrustArguments, mergeOpenCodeLaunchEnvironment } from "./agent-runtime/ProviderRuntimeLaunch.ts"; import { openCodeProjectFolderEnvironment } from "./openCodeConfig.ts"; import { onDiskPath } from "./onDiskPath.ts"; +import { usesLocalCodexConfig } from "./CodexFolderTrust.ts"; import { RESULT_CAPTURE_PROVIDERS } from "./resultCapture.ts"; import { launchEffortProblem, launchModelProblem, type ReasoningEffort } from "../../shared/launchModel.ts"; import { SecretRedactionRegistry } from "./safety/SecretRedaction.ts"; @@ -241,6 +242,7 @@ export class TerminalManager { private launchPipeline: (Pick & Partial>) | null = null; private sessionStore: TerminalSessionStore | null = null; private sessionRestoreMode: SessionRestoreMode = "off"; + private codexRestoreTrust: ((cwd: string) => Promise) | null = null; // Without the registry a placed session can only come back stopped: it never runs locally. private environments: EnvironmentService | null = null; // Every text an agent reads from another card passes through it (EP-8). @@ -367,6 +369,15 @@ export class TerminalManager { }; } + /** The builtin local profile only; a plugin's launch may use a different account home. */ + localCodexFolderForTrust(id: string): string | null { + const session = this.sessions.get(id); + if (!session || session.metadata.provider !== "codex" || session.metadata.parentSessionId + || session.metadata.role === "subagent" || session.extras.ownerPluginId || session.extras.options + || session.extras.environment || session.extras.environmentChoice) return null; + return session.metadata.cwd; + } + /** * What plugins may know about a card (EP-4): its metadata, the folder it actually runs in (an environment may * move it) and its environment ref. No screen text. @@ -407,6 +418,11 @@ export class TerminalManager { this.sessionRestoreMode = mode; } + /** Read-only host trust check; automatic restore must never grant new folder trust. */ + configureCodexRestoreTrust(check: (cwd: string) => Promise): void { + this.codexRestoreTrust = check; + } + async restorePersistedSessions(): Promise { const store = this.sessionStore; if (!store) return; @@ -444,7 +460,29 @@ export class TerminalManager { environmentAvailable: (environment, record) => resumed.get(record.id)?.ok ?? this.environmentUsable(environment), launchOptionsAvailable: (options) => this.unavailableLaunchPlugins(options).length === 0 }); - for (const step of steps) this.restorePersistedSession(step, resumed.get(step.record.id)); + const held = new Map(); + const parents = new Set(steps.map(step => step.record.parentSessionId)); + for (const step of steps) { + const record = step.record; + let reason = record.parentSessionId ? held.get(record.parentSessionId) : undefined; + if (!reason && step.launch !== "stopped" && this.codexRestoreTrust && record.provider === "codex" + && !record.parentSessionId && record.role !== "subagent" && !record.ownerPluginId + && !record.options && !record.environment && !record.environmentChoice) { + try { await this.codexRestoreTrust(record.cwd); } catch { + reason = "Codex folder trust could not be verified. Use Restart to review and trust the folder."; + } + } + if (reason) held.set(record.id, reason); + const pending = this.restorePersistedSession(reason ? { ...step, launch: "stopped" } : step, resumed.get(record.id), reason); + // A contributed parent's profile is known only after preparation; its children must not race the trust check. + if (this.codexRestoreTrust && record.provider === "codex" && parents.has(record.id)) { + await pending; + const parent = this.sessions.get(record.id); + if (parent?.metadata.status === "failed") { + held.set(record.id, parent.metadata.failureDetails ?? "Parent session could not be restored. Use Restart to review it."); + } + } + } await this.persistSessions(); } @@ -606,7 +644,7 @@ export class TerminalManager { create( request: CreateSessionRequest, - control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string } = {} + control: { captureResult?: boolean; answerCaptureGrantExpiresAt?: number; origin?: LaunchOrigin; ownerPluginId?: string; beforeLocalCodexLaunch?: (cwd: string) => Promise } = {} ): SessionSnapshot { assertCreateRequest(request, this.containment()); const origin: LaunchOrigin = request.role === "subagent" ? "subagent" : control.origin ?? "person"; @@ -747,7 +785,8 @@ export class TerminalManager { this.sessions.set(id, session); this.adoptIsolationStart(id, session); if (launched.process) this.bindProcess(id, session, launched.process); - if (contributed) this.launchContributed(id, session, resume, null, control.answerCaptureGrantExpiresAt); + if (contributed) this.launchContributed(id, session, resume, null, control.answerCaptureGrantExpiresAt, false, + origin === "person" ? control.beforeLocalCodexLaunch : undefined); const runtimeStatus = this.agentRuntime?.currentStatus(id); if (runtimeStatus) session.metadata.status = runtimeStatus; @@ -764,7 +803,7 @@ export class TerminalManager { return session ? snapshot(session) : null; } - restart(id: string, options: { resume?: boolean } = {}): SessionSnapshot { + restart(id: string, options: { resume?: boolean; beforeLocalCodexLaunch?: (cwd: string) => Promise } = {}): SessionSnapshot { const session = this.sessions.get(id); if (!session) throw new Error("Terminal session does not exist."); if (session.metadata.exitCode === null) throw new Error("Terminal session is still running."); @@ -836,7 +875,7 @@ export class TerminalManager { session.metadata.exitCode = null; session.metadata.failureDetails = null; this.emitSession(session.metadata); - this.launchContributed(id, session, resume, "user"); + this.launchContributed(id, session, resume, "user", undefined, false, options.beforeLocalCodexLaunch); return snapshot(session); } const launched = this.spawnProcess( @@ -1197,7 +1236,7 @@ export class TerminalManager { } } - private restorePersistedSession(step: RestoreStep, resumed?: { ok: true } | { ok: false; reason: string }): void { + private restorePersistedSession(step: RestoreStep, resumed?: { ok: true } | { ok: false; reason: string }, holdReason?: string): void | Promise { let descriptor = step.record; if (this.sessions.has(descriptor.id)) return; // A saved subagent never comes back with more than its orchestrator may give (whatever its record says). @@ -1243,7 +1282,12 @@ export class TerminalManager { if (step.launch === "stopped") { // A finished card comes back as it ended; a placed card whose environment // is unavailable is held with its reason and keeps its saved state. - if (step.note === "environment-unavailable" && descriptor.environment) { + if (holdReason) { + extras.heldState = descriptor.lastState; + metadata.status = "failed"; + metadata.exitCode = 1; + metadata.failureDetails = holdReason; + } else if (step.note === "environment-unavailable" && descriptor.environment) { extras.heldState = descriptor.lastState; metadata.status = "failed"; metadata.exitCode = descriptor.exitCode ?? 1; @@ -1364,7 +1408,7 @@ export class TerminalManager { this.sessions.set(descriptor.id, session); this.adoptIsolationStart(descriptor.id, session); if (process) this.bindProcess(descriptor.id, session, process); - if (contributed) this.launchContributed(descriptor.id, session, resume, "restore", undefined, true); + const pending = contributed ? this.launchContributed(descriptor.id, session, resume, "restore", undefined, true) : undefined; const runtimeStatus = this.agentRuntime?.currentStatus(descriptor.id); if (runtimeStatus) session.metadata.status = runtimeStatus; // Restoring re-derives a persisted session's status, so a failure here is @@ -1373,6 +1417,7 @@ export class TerminalManager { // would notify about the same silent state again and again. this.emitSession(metadata, metadata.status === "failed" ? "restore" : null); if (extras.gitAuditSince !== undefined) void this.auditGit(descriptor.id, session); + return pending; } /** @@ -1868,11 +1913,12 @@ export class TerminalManager { resume: ResumeRequest, failureOrigin: FailureOrigin | null, answerCaptureGrantExpiresAt?: number, - restoring = false - ): void { + restoring = false, + beforeLocalCodexLaunch?: (cwd: string) => Promise + ): Promise { const token = ++session.launchToken; const { metadata } = session; - void this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt) + return this.runContributedLaunch(id, session, token, resume, restoring, answerCaptureGrantExpiresAt, beforeLocalCodexLaunch) .catch((error: unknown): LaunchOutcome => { metadata.failureDetails = this.redactSecrets(`Launch refused: ${error instanceof Error ? error.message : String(error)}`); return "failed"; @@ -1895,7 +1941,8 @@ export class TerminalManager { token: number, resume: ResumeRequest, restoring: boolean, - answerCaptureGrantExpiresAt: number | undefined + answerCaptureGrantExpiresAt: number | undefined, + beforeLocalCodexLaunch?: (cwd: string) => Promise ): Promise { const { metadata } = session; const live = (): boolean => this.sessions.get(id) === session && session.launchToken === token && !session.process; @@ -2012,6 +2059,24 @@ export class TerminalManager { planned.cleanup(); dropContribution(); }; + // Only an authenticated person launch carries an interactive guard. Restore may only read existing trust. + // Classify after contributions resolve: options can add args without selecting another account profile. + const trust = beforeLocalCodexLaunch ?? (restoring ? this.codexRestoreTrust : null); + if (trust && metadata.provider === "codex" && !metadata.parentSessionId && metadata.role !== "subagent" + && !session.extras.ownerPluginId && !environment) { + try { + const cli = this.providerClis.get("codex"); + if (cli.state !== "available") throw new Error("Codex CLI is unavailable."); + if (await usesLocalCodexConfig({ ...terminalEnvironment(), ...cli.environment }, planned.env)) { + if (!live()) { abandon(); return "superseded"; } + await trust(planned.cwd); + } + if (!live()) { abandon(); return "superseded"; } + } catch (error) { + abandon(); + return refuse(error instanceof Error ? error.message : String(error)); + } + } this.noteConfiguredMode(id, metadata.provider, decision.profile, planned.env, planned.cwd); let spawn: { command: string; args: string[] | string; cwd: string; env: Record } = planned; if (environment && environments) { diff --git a/src/main/services/isolation/isolationPaths.ts b/src/main/services/isolation/isolationPaths.ts index 5cdcb9a5e..f873677a7 100644 --- a/src/main/services/isolation/isolationPaths.ts +++ b/src/main/services/isolation/isolationPaths.ts @@ -144,7 +144,7 @@ function sensitiveHomeFolders(home: string, xdgConfig: string): string[] { * token-authenticated sockets and per-run hook settings the CLI must read. */ export function privateAppData(userDataPath: string): string[] { - return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs"] + return ["agent-control", "provider-secrets.bin", "plugin-secrets", "account-homes", "github-oauth.json", "launch-runs", "codex-config-backups"] .map((name) => join(userDataPath, name)); } diff --git a/src/main/services/providerCliRegistry.ts b/src/main/services/providerCliRegistry.ts index a364e481d..6f219677d 100644 --- a/src/main/services/providerCliRegistry.ts +++ b/src/main/services/providerCliRegistry.ts @@ -273,7 +273,7 @@ export interface ProviderChildProcessLaunch { } export function providerChildProcessLaunch( - resolution: AvailableProviderCli, + resolution: Pick, args: string[] ): ProviderChildProcessLaunch { if (resolution.launcher === "native") { diff --git a/src/main/services/safety/baseProtection.ts b/src/main/services/safety/baseProtection.ts index 2675e022d..62bf39827 100644 --- a/src/main/services/safety/baseProtection.ts +++ b/src/main/services/safety/baseProtection.ts @@ -87,11 +87,11 @@ export function actionFromHook(toolName: string, toolInput: unknown, preview: st */ export function canvasTtyPrivateData(userDataPath: string): PrivateData { const names = ['agent-control', join('browser', 'runtime'), join('lifecycle', 'runtime'), join('orchestration', 'runtime'), - 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs']; + 'provider-secrets.bin', 'plugin-secrets', 'account-homes', 'github-oauth.json', 'launch-runs', 'codex-config-backups']; return { appRoots: [userDataPath], paths: names.map(name => join(userDataPath, name)), - markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth'] + markers: ['agent-control', 'provider-secrets', 'plugin-secrets', 'account-homes', 'github-oauth', 'codex-config-backups'] }; } diff --git a/src/renderer/src/features/home/HomeZone.tsx b/src/renderer/src/features/home/HomeZone.tsx index af9068e6a..127cb1eff 100644 --- a/src/renderer/src/features/home/HomeZone.tsx +++ b/src/renderer/src/features/home/HomeZone.tsx @@ -579,6 +579,7 @@ function formatPercent(value: number, locale: LocaleId): string { } function limitReasonLabel(reason: HomeLimitReason | null, locale: LocaleId): string { + if (reason === "api-profile") return t(locale, "limitApiProfile"); const key = reason === "cli-not-found" ? "limitCliNotFound" : reason === "not-authenticated" diff --git a/src/renderer/src/lib/i18n.ts b/src/renderer/src/lib/i18n.ts index b69e9190a..e4dad2c1f 100644 --- a/src/renderer/src/lib/i18n.ts +++ b/src/renderer/src/lib/i18n.ts @@ -39,6 +39,7 @@ const ru = { limitStale: "Устарело", limitLoading: "Получаем лимит…", limitUnavailable: "Данные недоступны", + limitApiProfile: "API-профиль: лимиты подписки недоступны", limitCliNotFound: "CLI не найден", limitNotAuthenticated: "Требуется вход", limitSessionExpired: "Сессия истекла, запустите CLI", @@ -1070,6 +1071,7 @@ const en: Record = { limitStale: "Stale", limitLoading: "Checking limit…", limitUnavailable: "Data unavailable", + limitApiProfile: "API profile: subscription limits unavailable", limitCliNotFound: "CLI not found", limitNotAuthenticated: "Sign-in required", limitSessionExpired: "Session expired, launch the CLI", diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts index 12db40b04..0a05b1acd 100644 --- a/src/shared/contracts.ts +++ b/src/shared/contracts.ts @@ -1760,12 +1760,14 @@ export interface BrowserActivityStateEvent { export type LimitSource = | "codex-app-server" + | "omniroute-usage-api" | "claude-usage-api" | "qwen-cli" | "kimi-usage-api" | "opencode-go-usage-api" | "grok-billing-api"; export type LimitUnavailableReason = + | "api-profile" | "cli-not-found" | "not-authenticated" | "session-expired" diff --git a/tests/agent-history-paths.test.mjs b/tests/agent-history-paths.test.mjs index a1e64e5cd..8db87389d 100644 --- a/tests/agent-history-paths.test.mjs +++ b/tests/agent-history-paths.test.mjs @@ -216,3 +216,40 @@ test("Cursor explicit config directory wins over XDG and MiniMax accepts its leg assert.equal(paths.cursor, "/cursor"); assert.equal(paths.minimax, "/mavis"); }); + + +test("person history resumes await Codex trust for fresh and exited local sessions only",async t=>{ + const {root,service}=await setup(t,()=>({}));const calls=[]; + service.adapters.codex={read:async()=>({items:[{id,cwd:root,title:"History",lastActivityAt:0}]})}; + const active={id:"card",provider:"codex",cwd:root,exitCode:null,status:"idle"};let existing=null; + service.terminals={findLocalConversation:()=>existing,create:request=>{calls.push("create");return {...active,...request};}, + restart:()=>{calls.push("restart");return active;},list:()=>[existing],localCodexFolderForTrust:()=>root}; + const guard=async folder=>{assert.equal(folder,root);calls.push("trust");}; + assert.equal((await service.resume("codex",id,{x:0,y:0},guard)).error,undefined); + assert.deepEqual(calls,["trust","create"]);calls.length=0; + existing={...active,exitCode:0}; + assert.equal((await service.resume("codex",id,{x:0,y:0},guard)).reused,true); + assert.deepEqual(calls,["trust","restart"]);calls.length=0; + existing=active; + await service.resume("codex",id,{x:0,y:0},guard);assert.deepEqual(calls,[]); + existing={...active,exitCode:0};service.terminals.localCodexFolderForTrust=()=>null; + service.terminals.restart = (_id, options) => { + assert.equal(options.beforeLocalCodexLaunch, guard, "contributed history cards retain the authenticated guard until their profile resolves"); + calls.push("restart"); return active; + }; + await service.resume("codex",id,{x:0,y:0},guard);assert.deepEqual(calls,["restart"]); +}); + +test("history trust refusal or a concurrently closed card cannot launch a session",async t=>{ + const {root,service}=await setup(t,()=>({}));let existing=null;let launches=0; + service.adapters.codex={read:async()=>({items:[{id,cwd:root,title:"History",lastActivityAt:0}]})}; + service.terminals={findLocalConversation:()=>existing,create:()=>{launches++;},restart:()=>{launches++;}, + list:()=>[],localCodexFolderForTrust:()=>root}; + for(const card of [null,{id:"card",provider:"codex",cwd:root,exitCode:0}]){ + existing=card; + const result=await service.resume("codex",id,{x:0,y:0},async()=>{throw new Error("Folder trust was not granted");}); + assert.match(result.error.message,/not granted/); + } + const closed=await service.resume("codex",id,{x:0,y:0},async()=>{}); + assert.match(closed.error.message,/closed during confirmation/);assert.equal(launches,0); +}); diff --git a/tests/agent-isolation.test.mjs b/tests/agent-isolation.test.mjs index 89b2266aa..51e9cf741 100644 --- a/tests/agent-isolation.test.mjs +++ b/tests/agent-isolation.test.mjs @@ -378,6 +378,27 @@ test("seatbelt, for real: the launch writes the account home it was handed, not assert.equal(await readFile(join(own, "auth.json"), "utf8"), "refreshed\n"); }); +test("seatbelt, for real: saved folder trust stays readable but configuration and its backups stay protected", onMac, async (t) => { + const w = await world(t); + const own = join(w.home, ".custom-codex"); + const backups = join(w.userData, "codex-config-backups"); + await mkdir(own); await mkdir(backups); + const original = 'approval_policy = "never"\n'; + // The host has saved the person's decision; this is what the next isolated CLI reads. + const saved = `${original}[projects.${JSON.stringify(w.project)}]\ntrust_level = "trusted"\n`; + await writeFile(join(own, "config.toml"), saved); + await writeFile(join(backups, "before.toml"), original); + const { lines } = run(w, [ + 'cat "$CODEX_HOME/config.toml" >/dev/null && echo TRUST-R-ok', + 'echo changed > "$CODEX_HOME/config.toml" 2>/dev/null || echo CONFIG-W-denied', + `cat "${join(backups, "before.toml")}" 2>/dev/null || echo BACKUP-R-denied`, + `echo changed > "${join(backups, "before.toml")}" 2>/dev/null || echo BACKUP-W-denied` + ].join("; "), { env: { CODEX_HOME: own } }); + assert.deepEqual(lines, ["TRUST-R-ok", "CONFIG-W-denied", "BACKUP-R-denied", "BACKUP-W-denied"]); + assert.equal(await readFile(join(own, "config.toml"), "utf8"), saved); + assert.equal(await readFile(join(backups, "before.toml"), "utf8"), original); +}); + test("seatbelt, for real: git init works, but no repository in the project gets a hook or attributes from the agent", onMac, async (t) => { const w = await world(t); const { lines, env } = run(w, [ diff --git a/tests/auto-profile.test.mjs b/tests/auto-profile.test.mjs index 218f57de6..0de4b3cc7 100644 --- a/tests/auto-profile.test.mjs +++ b/tests/auto-profile.test.mjs @@ -381,3 +381,15 @@ test("every open card is saved, not only the first 64; a broken record does not const broken = [{ id: "nope" }, ...Array.from({ length: 64 }, (_, index) => record(index))]; assert.equal(normalizePersistedTerminalSessions({ version: 2, sessions: broken }).sessions.length, 64); }); + +test("only a builtin top-level Codex card may ask the host to persist profile trust", async (t) => { + const { pipeline } = await pipelineAnswering(t, null); + const { terminals } = manager(t, pipeline); + const person = terminals.create({provider:"codex",profile:"normal",cwd:process.cwd(),position:at}); + const child = terminals.create({provider:"codex",profile:"plan",role:"subagent",parentSessionId:person.id,cwd:process.cwd(),position:at}); + const plugin = terminals.create({provider:"codex",profile:"normal",cwd:process.cwd(),position:at,launchOptions:{"p.accounts":{on:true}}}); + assert.equal(terminals.localCodexFolderForTrust(person.id),process.cwd()); + assert.equal(terminals.localCodexFolderForTrust(child.id),null); + assert.equal(terminals.localCodexFolderForTrust(plugin.id),null,"a plugin may have selected another profile home"); + assert.equal(terminals.localCodexFolderForTrust("missing"),null); +}); diff --git a/tests/codex-api-limits-ui.test.mjs b/tests/codex-api-limits-ui.test.mjs new file mode 100644 index 000000000..3fd13f961 --- /dev/null +++ b/tests/codex-api-limits-ui.test.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { build } from "esbuild"; +import { selectHomeModel } from "../src/renderer/src/features/home/homeModel.ts"; + +// Render the real row without changing its production exports or starting Electron. +const bundle = await build({ + stdin: { + contents: 'export { LimitRow } from "./src/renderer/src/features/home/HomeZone.tsx"; export { createElement } from "react"; export { renderToStaticMarkup } from "react-dom/server";', + resolveDir: fileURLToPath(new URL("..", import.meta.url)), loader: "ts" + }, + plugins: [{ name: "test-limit-row", setup(build) { + build.onLoad({ filter: /[/\\]HomeZone\.tsx$/ }, async ({ path }) => ({ + contents: await readFile(path, "utf8") + "\nexport { LimitRow };", loader: "tsx" + })); + } }], + bundle: true, platform: "node", format: "esm", jsx: "automatic", write: false, + loader: { ".svg": "dataurl", ".png": "dataurl", ".ico": "dataurl", ".css": "empty" }, logLevel: "silent", + banner: { js: `import { createRequire } from "node:module"; const require = createRequire(${JSON.stringify(import.meta.url)});` } +}); +const { LimitRow, createElement, renderToStaticMarkup } = await import( + "data:text/javascript;base64," + Buffer.from(bundle.outputFiles[0].text).toString("base64") +); + +for (const [locale, label, errorLabel] of [ + ["en", "API profile: subscription limits unavailable", "Protocol error"], + ["ru", "API-профиль: лимиты подписки недоступны", "Ошибка протокола"] +]) test(`HOME explains API-profile limits in ${locale} and preserves real protocol errors`, () => { + const now = 1_786_100_000_000; + const render = (reason) => { + const snapshot = { fetchedAt: now, providers: [ + { provider: "codex", source: "codex-app-server", state: "unavailable", checkedAt: now, reason } + ] }; + const row = selectHomeModel([], snapshot, "ready", now, ["codex"]).limitRows[0]; + return renderToStaticMarkup(createElement(LimitRow, { row, locale, now })); + }; + const api = render("api-profile"); + assert.ok(api.includes(label)); + assert.ok(!api.includes(errorLabel)); + assert.ok(!api.includes("limit-row__fill"), "unavailable subscription limits must not become a quota bar"); + assert.ok(render("protocol-error").includes(errorLabel)); +}); + +test("HOME renders an available upstream Codex quota as a real percentage bar", () => { + const now = Date.parse("2030-01-09T12:00:00Z"); + const snapshot = { fetchedAt: now, providers: [{ provider: "codex", source: "omniroute-usage-api", + state: "available", fetchedAt: now, accountScope: null, + windows: [{ id:"omniroute:session",bucketId:"omniroute:session",slot:"primary",isDefaultBucket:true, + label:"Weekly",usedPercent:9,used:9,limit:100,windowMinutes:10080,resetsAt:Date.parse("2030-01-10T12:00:00Z")/1000 }] }] }; + const row = selectHomeModel([], snapshot, "ready", now, ["codex"]).limitRows[0]; + const html = renderToStaticMarkup(createElement(LimitRow, { row, locale:"ru", now })); + assert.ok(html.includes("9%")); + assert.ok(html.includes("limit-row__fill")); + assert.ok(!html.includes("лимиты подписки недоступны")); +}); diff --git a/tests/codex-folder-trust.test.mjs b/tests/codex-folder-trust.test.mjs new file mode 100644 index 000000000..102344d12 --- /dev/null +++ b/tests/codex-folder-trust.test.mjs @@ -0,0 +1,254 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, readdir, realpath, rename, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { tmpdir, userInfo } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { canvasTtyPrivateData } from "../src/main/services/safety/baseProtection.ts"; +import { privateAppData } from "../src/main/services/isolation/isolationPaths.ts"; +import { ensureCodexFolderTrust, usesLocalCodexConfig } from "../src/main/services/CodexFolderTrust.ts"; + +async function fixture(t, { trusted = false, conflict = false, fresh = false, missingHome = false, layerConfig, serialized = false, profileAlias = false } = {}) { + const root = await realpath(await mkdtemp(join(tmpdir(), "canvas-folder-trust-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const home = join(root, "profile"), cwd = join(root, 'project.with dot " Ю'); + if (!missingHome) await mkdir(home); + await mkdir(cwd); + const profile = profileAlias ? join(root, "profile-link") : home; + if (profileAlias) await symlink(home, profile, "dir"); + const projects = {projects: trusted ? {[cwd]: {trust_level:"trusted"}} : {}}; + const userConfig = layerConfig === undefined ? (fresh ? "{}" : serialized ? JSON.stringify(projects) : projects) : layerConfig; + const config = join(home, "config.toml"), calls = join(root, "calls.jsonl"), cli = join(root, "codex"); + const versionFile = join(root, "version"); + await writeFile(versionFile, "1"); + if (!fresh) await writeFile(config, 'model = "existing-model"\napproval_policy = "never"\n'); + await writeFile(cli, `#!${process.execPath} +const fs = require("node:fs"), readline = require("node:readline"); +const file = ${JSON.stringify(config)}, folder = ${JSON.stringify(cwd)}, versionFile = ${JSON.stringify(versionFile)}; +readline.createInterface({input:process.stdin}).on("line",line=>{ + const m = JSON.parse(line); if(m.id === undefined) return; + fs.appendFileSync(${JSON.stringify(calls)}, JSON.stringify(m)+"\\n"); + let result = {}; + if(m.method === "config/read") result = {layers:[{name:{type:"user",file},version:"version-"+fs.readFileSync(versionFile,"utf8"),config:${JSON.stringify(userConfig)}}]}; + if(m.method === "config/batchWrite") { + if(${conflict} || m.params.expectedVersion !== "version-"+fs.readFileSync(versionFile,"utf8")) { process.stdout.write(JSON.stringify({id:m.id,error:{code:-32603,message:"version conflict"}})+"\\n"); return; } + fs.writeFileSync(versionFile,String(Number(fs.readFileSync(versionFile,"utf8"))+1)); + fs.appendFileSync(file, "# TRUST-SAVED\\n"); + } + process.stdout.write(JSON.stringify({id:m.id,result})+"\\n"); +}); +`, { mode: 0o700 }); + return { root, cwd, config, calls, options: { + providerClis:{get:()=>({state:"available",provider:"codex",executable:cli,launcher:"native",environment:{CODEX_HOME:profile},checked:[]})}, + clientVersion:"test",cwd,backupDirectory:join(root,"backups") + }, messages: async()=> (await readFile(calls,"utf8")).trim().split("\n").map(JSON.parse) }; +} +const posix = { skip: process.platform === "win32" ? "native fixture launcher is POSIX" : false }; + +test("the host saves exactly the person's folder trust, with a version guard and private backup", posix, async t=>{ + const f = await fixture(t); const before = await readFile(f.config); + let confirmed=0; + await ensureCodexFolderTrust({...f.options,confirm:async folder=>{assert.equal(folder,f.cwd);confirmed++;return true;}}); + assert.equal(confirmed,1); + const writes=(await f.messages()).filter(m=>m.method === "config/batchWrite"); + assert.deepEqual(writes.map(m=>m.params),[{filePath:f.config,expectedVersion:"version-1",edits:[{ + keyPath:`projects.${JSON.stringify(f.cwd)}.trust_level`,value:"trusted",mergeStrategy:"upsert" + }]}]); + const [backup] = await readdir(f.options.backupDirectory); + assert.deepEqual(await readFile(join(f.options.backupDirectory,backup)),before); + assert.equal((await f.messages()).find(m=>m.method === "config/read").params.cwd,undefined,"untrusted project config is not loaded"); +}); + +test("declining trust never writes configuration or creates a backup", posix, async t=>{ + const f=await fixture(t); const before=await readFile(f.config); + await assert.rejects(ensureCodexFolderTrust({...f.options,confirm:async()=>false}),/not granted/); + assert.deepEqual(await readFile(f.config),before); + assert.equal((await f.messages()).some(m=>m.method === "config/batchWrite"),false); + await assert.rejects(readdir(f.options.backupDirectory),{code:"ENOENT"}); +}); + +test("a persisted trusted folder needs no new approval or write", posix, async t=>{ + const f=await fixture(t,{trusted:true}); + await ensureCodexFolderTrust({...f.options,confirm:async()=>{assert.fail("already trusted");}}); + assert.equal((await f.messages()).some(m=>m.method === "config/batchWrite"),false); +}); + +test("concurrent config changes fail closed without replacing settings", posix, async t=>{ + const f=await fixture(t,{conflict:true}); const before=await readFile(f.config); + await assert.rejects(ensureCodexFolderTrust({...f.options,confirm:async()=>true}),/protocol-error/); + assert.deepEqual(await readFile(f.config),before); +}); + +test("a fresh profile can persist trust without an existing config to back up", posix, async t=>{ + const f=await fixture(t,{fresh:true}); + await ensureCodexFolderTrust({...f.options,confirm:async()=>true}); + assert.match(await readFile(f.config,"utf8"),/TRUST-SAVED/); + await assert.rejects(readdir(f.options.backupDirectory),{code:"ENOENT"}); +}); + +test("the trust writer is reachable only from authenticated person IPC, never an agent gateway", async()=>{ + const source=await readFile(new URL("../src/main/ipc/registerIpc.ts",import.meta.url),"utf8"); + for(const channel of ["terminalCreate","terminalRestart","agentChatHistoryResume"]) { + const start=source.indexOf(`IPC.${channel},`),end=source.indexOf("ipcMain.",start); + const handler=source.slice(start,end); + assert.match(handler,/assertMainRenderer\(event, getMainWindow\)/); + assert.match(handler,channel === "agentChatHistoryResume" ? /agentChatHistory\.resume\(provider, id, position, trustCodexFolder\)/ : /await trustCodexFolder/); + if (channel === "terminalCreate") { + assert.match(handler,/!.*parentSessionId/); + assert.match(handler,/terminals\.create\(request, \{ beforeLocalCodexLaunch: trustCodexFolder \}\)/); + } + else if (channel === "terminalRestart") { + assert.match(handler,/localCodexFolderForTrust/); + assert.match(handler,/terminals\.restart\(id, \{[^\n]*beforeLocalCodexLaunch: trustCodexFolder/); + } + } + for(const name of ["services/AgentControlService.ts","services/agent-control/AgentControlGateway.ts","services/PluginSessions.ts"]) { + assert.doesNotMatch(await readFile(new URL(`../src/main/${name}`,import.meta.url),"utf8"),/ensureCodexFolderTrust|trustCodexFolder/); + } +}); + +test("configuration backups are private to the host in both protection layers",()=>{ + const root=join(tmpdir(),"canvas-private-data"); + assert.ok(privateAppData(root).includes(join(root,"codex-config-backups"))); + assert.ok(canvasTtyPrivateData(root).paths.includes(join(root,"codex-config-backups"))); +}); + + +test("serialized user configuration preserves an existing exact-folder trust decision", posix, async t=>{ + const f=await fixture(t,{trusted:true,serialized:true}); + await ensureCodexFolderTrust({...f.options,confirm:async()=>assert.fail("already trusted")}); + assert.equal((await f.messages()).some(m=>m.method === "config/batchWrite"),false); +}); + +for (const layerConfig of ["not-json", "null", "[]", "42", null, []]) { + test(`invalid user layer ${JSON.stringify(layerConfig)} cannot authorize a trust write`, posix, async t=>{ + const f=await fixture(t,{layerConfig}); + await assert.rejects(ensureCodexFolderTrust({...f.options,confirm:async()=>assert.fail("unsafe layer")}),/identified safely/); + assert.equal((await f.messages()).some(m=>m.method === "config/batchWrite"),false); + }); +} + +for (const state of [{fresh:true},{trusted:true},{trusted:false}]) { + test(`a symlinked profile supports folder trust (${JSON.stringify(state)})`, posix, async t=>{ + const f=await fixture(t,{...state,profileAlias:true}); + await ensureCodexFolderTrust({...f.options,confirm:async()=>{assert.notEqual(state.trusted,true);return true;}}); + const writes=(await f.messages()).filter(m=>m.method === "config/batchWrite"); + assert.equal(writes.length,state.trusted ? 0 : 1); + if(writes.length) assert.equal(writes[0].params.filePath,f.config); + }); +} + +test("a symlinked config file is matched, backed up and written through its canonical path", posix, async t=>{ + const f=await fixture(t), target=join(f.root,"actual-config.toml"); + const before=await readFile(f.config); + await rename(f.config,target);await symlink(target,f.config); + await ensureCodexFolderTrust({...f.options,confirm:async()=>true}); + const write=(await f.messages()).find(m=>m.method === "config/batchWrite"); + assert.equal(write.params.filePath,target); + const [backup]=await readdir(f.options.backupDirectory); + assert.deepEqual(await readFile(join(f.options.backupDirectory,backup)),before); +}); + + +test("an existing serialized empty config can save trust with a backup",posix,async t=>{ + const f=await fixture(t,{layerConfig:"{}"}); + await ensureCodexFolderTrust({...f.options,confirm:async()=>true}); + assert.equal((await f.messages()).filter(m=>m.method === "config/batchWrite").length,1); + assert.equal((await readdir(f.options.backupDirectory)).length,1); +}); + +test("a profile alias changed during confirmation cannot redirect a trust write",posix,async t=>{ + const f=await fixture(t,{profileAlias:true}), other=join(f.root,"other-profile"); + await mkdir(other); + await assert.rejects(ensureCodexFolderTrust({...f.options,confirm:async()=>{ + const alias=f.options.providerClis.get("codex").environment.CODEX_HOME; + await rm(alias);await symlink(other,alias,"dir");return true; + }}),/profile changed/); + assert.equal((await f.messages()).some(m=>m.method === "config/batchWrite"),false); +}); + + +test("concurrent person-approved launches reread the profile after each host trust write", posix, async t => { + const f = await fixture(t); + const other = join(f.root, "another-project"); + await mkdir(other); + let release; + const waiting = new Promise(resolve => { release = resolve; }); + let entered; + const confirming = new Promise(resolve => { entered = resolve; }); + const first = ensureCodexFolderTrust({ ...f.options, confirm: async () => { entered(); await waiting; return true; } }); + await confirming; + const alias = join(f.root, "profile-alias"); + await symlink(join(f.root, "profile"), alias, "dir"); + const cli = f.options.providerClis.get(); + const second = ensureCodexFolderTrust({ ...f.options, cwd: other, + providerClis: { get: () => ({ ...cli, environment: { CODEX_HOME: alias } }) }, confirm: async () => true }); + release(); + await Promise.all([first, second]); + const writes = (await f.messages()).filter(m => m.method === "config/batchWrite"); + assert.deepEqual(writes.map(m => m.params.expectedVersion), ["version-1", "version-2"]); + assert.deepEqual(writes.map(m => m.params.edits[0].keyPath), [f.cwd, other].map(folder => `projects.${JSON.stringify(folder)}.trust_level`)); +}); + +test("a declined launch does not poison the next queued profile trust operation", posix, async t => { + const f = await fixture(t); + const first = ensureCodexFolderTrust({ ...f.options, confirm: async () => false }); + const rejected = assert.rejects(first, /not granted/); + const second = ensureCodexFolderTrust({ ...f.options, confirm: async () => true }); + await Promise.all([rejected, second]); + assert.equal((await f.messages()).filter(m => m.method === "config/batchWrite").length, 1); +}); + + +test("resolved Codex config identity follows the user home unless CODEX_HOME selects it", async t => { + const root = await realpath(await mkdtemp(join(tmpdir(), "canvas-profile-identity-"))); + t.after(() => rm(root, { recursive: true, force: true })); + const home = join(root, "home"), other = join(root, "other"); + await mkdir(home); await mkdir(other); + const configHome = join(home, ".codex"); + assert.equal(await usesLocalCodexConfig({ CODEX_HOME: configHome }, { CODEX_HOME: configHome, EXTRA: "yes" }), true); + assert.equal(await usesLocalCodexConfig({ CODEX_HOME: configHome }, { CODEX_HOME: configHome, HOME: other, USERPROFILE: other }), true); + assert.equal(await usesLocalCodexConfig({ CODEX_HOME: configHome }, { CODEX_HOME: join(other, "new", "profile") }), false); + await assert.rejects(usesLocalCodexConfig({ CODEX_HOME: configHome }, { CODEX_HOME: "relative-profile" }), /absolute/); + if (process.platform === "win32") { + assert.equal(await usesLocalCodexConfig({}, { HOME: home, USERPROFILE: other }), true, "Windows uses the OS account profile"); + assert.equal(await usesLocalCodexConfig({}, { codex_home: join(userInfo().homedir, ".codex") }), true); + } else { + assert.equal(await usesLocalCodexConfig({ HOME: home }, { HOME: home, EXTRA: "yes" }), true); + assert.equal(await usesLocalCodexConfig({ HOME: home }, { HOME: other }), false); + assert.equal(await usesLocalCodexConfig({ HOME: home }, { HOME: other, CODEX_HOME: configHome }), true); + assert.equal(await usesLocalCodexConfig({ HOME: userInfo().homedir }, { HOME: "" }), true); + } +}); + +test("profile aliases and config-file aliases still identify the local Codex configuration", posix, async t => { + const f = await fixture(t); + const alias = join(f.root, "alias"); + await symlink(join(f.root, "profile"), alias, "dir"); + assert.equal(await usesLocalCodexConfig({ CODEX_HOME: join(f.root, "profile") }, { CODEX_HOME: alias }), true); + const other = join(f.root, "other-profile"); + await mkdir(other); + await symlink(f.config, join(other, "config.toml")); + assert.equal(await usesLocalCodexConfig({ CODEX_HOME: alias }, { CODEX_HOME: other }), true); +}); + + +for (const approve of [true, false]) { + test(`a missing Codex home supports the first person trust decision (${approve ? "approve" : "decline"})`, posix, async t => { + const f = await fixture(t, { fresh: true, missingHome: true }); + const profile = join(f.root, "profile"); + await assert.rejects(stat(profile), { code: "ENOENT" }); + let confirmed = 0; + const launch = ensureCodexFolderTrust({ ...f.options, confirm: async () => { confirmed++; return approve; } }); + if (approve) { + await launch; + assert.match(await readFile(f.config, "utf8"), /TRUST-SAVED/); + } else { + await assert.rejects(launch, /not granted/); + await assert.rejects(stat(f.config), { code: "ENOENT" }); + } + assert.equal(confirmed, 1); + assert.equal((await stat(profile)).mode & 0o777, 0o700); + assert.equal((await f.messages()).filter(m => m.method === "config/batchWrite").length, approve ? 1 : 0); + await assert.rejects(stat(f.options.backupDirectory), { code: "ENOENT" }); + }); +} diff --git a/tests/launch-contributors.test.mjs b/tests/launch-contributors.test.mjs index df2491a1e..ee49f7112 100644 --- a/tests/launch-contributors.test.mjs +++ b/tests/launch-contributors.test.mjs @@ -430,3 +430,176 @@ test("end to end: the example service prepares a launch over JSON-RPC", async (t const empty = await pipeline.prepare(context({ greeting: "" })); assert.match(empty.reason, /Launch Env: Value is empty/u); }); + + +for (const answer of [{ args: ["--no-alt-screen"] }, { env: { EXAMPLE_LAUNCH_HINT: "yes" } }]) { + test(`local Codex launch options await person trust after preparation (${answer.args ? "args" : "environment"})`, { timeout: 5_000 }, async t => { + const { pipeline, requests } = await pipelineFixture(t, { + contributors: [contributor("p.local")], answers: { "p.local": answer } + }); + const { manager, calls } = await managerFixture(t, pipeline); + let release, entered, trusted = false, wraps = 0; + manager.configureIsolation({ + containment: () => true, + decide: ({ profile }) => ({ apply: true, profile }), + wrap: ({ command, args, env }) => { + assert.equal(trusted, true, "host trust precedes isolation and PTY creation"); + wraps++; + return { command, args, env, cleanup() {} }; + } + }); + const waiting = new Promise(resolve => { release = resolve; }); + const checking = new Promise(resolve => { entered = resolve; }); + const created = manager.create({ provider: "codex", profile: "auto", cwd, position: at, + launchOptions: { "p.local": {} } }, { beforeLocalCodexLaunch: async folder => { + assert.equal(folder, cwd); + assert.equal(requests.length, 1, "plugin preparation finishes exactly once before trust"); + assert.equal(calls.length, 0, "no process starts before the person's decision"); + entered(); await waiting; trusted = true; + } }); + await checking; + assert.equal(manager.launchPending(created.id), true); + release(); + await waitFor(() => calls.length === 1); + if (answer.args) assert.ok(calls[0].args.includes("--no-alt-screen")); + else assert.equal(calls[0].options.env.EXAMPLE_LAUNCH_HINT, "yes"); + assert.equal(requests.length, 1); + assert.equal(wraps, 1); + }); +} + +test("a plugin-owned Codex profile does not grant trust to the unrelated local profile", async t => { + const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.account")], + answers: { "p.account": { env: { CODEX_HOME: "{launchFiles}/account-profile" }, files: [{ relPath: "account-profile/config.toml", content: "" }] } } }); + const { manager, calls } = await managerFixture(t, pipeline); + manager.create({ provider: "codex", profile: "normal", cwd, position: at, launchOptions: { "p.account": {} } }, + { beforeLocalCodexLaunch: async () => assert.fail("foreign account must not trust the local profile") }); + await waitFor(() => calls.length === 1); + assert.match(calls[0].options.env.CODEX_HOME, /account-profile$/); +}); + +test("declined contributed Codex trust starts no process and cleans generated launch files", async t => { + const { pipeline, runsRoot } = await pipelineFixture(t, { contributors: [contributor("p.local")], + answers: { "p.local": { env: { EXAMPLE_FILE: "{launchFiles}/hint.txt" }, files: [{ relPath: "hint.txt", content: "hint" }] } } }); + const { manager, calls } = await managerFixture(t, pipeline); + const created = manager.create({ provider: "codex", profile: "normal", cwd, position: at, + launchOptions: { "p.local": {} } }, { beforeLocalCodexLaunch: async () => { throw new Error("Folder trust was not granted."); } }); + await waitFor(() => card(manager, created.id).status === "failed"); + assert.equal(calls.length, 0); + assert.match(card(manager, created.id).failureDetails, /trust was not granted/); + await waitFor(async () => (await readdir(join(runsRoot, created.id))).length === 0); +}); + +test("closing a contributed card during person trust cannot start its process later", { timeout: 5_000 }, async t => { + const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.local")], answers: { "p.local": {} } }); + const { manager, calls } = await managerFixture(t, pipeline); + let release, entered; + const waiting = new Promise(resolve => { release = resolve; }); + const checking = new Promise(resolve => { entered = resolve; }); + const created = manager.create({ provider: "codex", profile: "normal", cwd, position: at, + launchOptions: { "p.local": {} } }, { beforeLocalCodexLaunch: async () => { entered(); await waiting; } }); + await checking; + manager.dispose(created.id); + release(); + await new Promise(resolve => setTimeout(resolve, 30)); + assert.equal(calls.length, 0); + assert.equal(card(manager, created.id), undefined); +}); + +for (const control of [{ origin: "control" }, { origin: "plugin", ownerPluginId: "p.owner" }]) { + test(`non-person contributed launches cannot invoke interactive trust (${control.origin})`, async t => { + const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.local")], answers: { "p.local": {} } }); + const { manager, calls } = await managerFixture(t, pipeline); + manager.create({ provider: "codex", profile: "normal", cwd, position: at, launchOptions: { "p.local": {} } }, + { ...control, beforeLocalCodexLaunch: async () => assert.fail("not person-authenticated") }); + await waitFor(() => calls.length === 1); + }); +} + +test("a contributed local Codex restart uses only that restart's person trust guard", async t => { + const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.local")], answers: { "p.local": {} } }); + const { manager, calls } = await managerFixture(t, pipeline); + let checked = 0; + const created = manager.create({ provider: "codex", profile: "normal", cwd, position: at, + launchOptions: { "p.local": {} } }, { beforeLocalCodexLaunch: async () => { checked++; } }); + await waitFor(() => calls.length === 1); + calls[0].exit(0); + await waitFor(() => card(manager, created.id).exitCode !== null); + manager.restart(created.id, { beforeLocalCodexLaunch: async () => { checked++; throw new Error("trust revoked"); } }); + await waitFor(() => card(manager, created.id).status === "failed"); + assert.equal(checked, 2); + assert.equal(calls.length, 1); + manager.restart(created.id); + await waitFor(() => calls.length === 2); + assert.equal(checked, 2, "person authority is never retained for subsequent launches"); +}); + +for (const local of [true, false]) { + test(`restored contributed Codex checks existing trust only for the local config (${local})`, async t => { + const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.account")], + answers: { "p.account": local ? {} : { env: { CODEX_HOME: "{launchFiles}/account-profile" }, files: [{ relPath: "account-profile/config.toml", content: "" }] } } }); + const storeDirectory = await mkdtemp(join(tmpdir(), "canvastty-contributed-trust-restore-")); + const { manager, calls } = await managerFixture(t, pipeline, { directory: storeDirectory }); + const created = manager.create({ provider: "codex", profile: "normal", cwd, position: at, + launchOptions: { "p.account": {} } }); + await waitFor(() => calls.length === 1); + await manager.shutdown(); + const restoredCalls = []; + const restored = new TerminalManager(() => undefined, registry(), undefined, undefined, true, spawner(restoredCalls)); + t.after(() => restored.shutdown()); + t.after(() => rm(storeDirectory, { recursive: true, force: true })); + restored.configureLaunchPipeline(pipeline); + restored.configureSessionPersistence(new TerminalSessionStore(storeDirectory), "continue"); + let checks = 0; + restored.configureCodexRestoreTrust(async () => { checks++; throw new Error("existing trust absent"); }); + await restored.restorePersistedSessions(); + await waitFor(() => local ? card(restored, created.id).status === "failed" : restoredCalls.length === 1); + assert.equal(checks, local ? 1 : 0); + assert.equal(restoredCalls.length, local ? 0 : 1); + }); +} + + +test("a subagent cannot use its caller's interactive Codex trust guard", async t => { + const definition = contributor("p.local"); + definition.launch.delegable = true; + const { pipeline } = await pipelineFixture(t, { contributors: [definition], answers: { "p.local": {} } }); + const { manager, calls } = await managerFixture(t, pipeline); + const parent = manager.create({ provider: "codex", profile: "normal", cwd, position: at, role: "orchestrator" }); + manager.create({ provider: "codex", profile: "normal", cwd, position: at, role: "subagent", parentSessionId: parent.id, + launchOptions: { "p.local": {} } }, { beforeLocalCodexLaunch: async () => assert.fail("delegated trust is forbidden") }); + await waitFor(() => calls.length === 2); +}); + +test("restored descendants cannot race their contributed parent's pending trust check", { timeout: 5_000 }, async t => { + const { pipeline } = await pipelineFixture(t, { contributors: [contributor("p.local")], answers: { "p.local": {} } }); + const storeDirectory = await mkdtemp(join(tmpdir(), "canvastty-contributed-family-trust-")); + const { manager, calls } = await managerFixture(t, pipeline, { directory: storeDirectory }); + const parent = manager.create({ provider: "codex", profile: "normal", cwd, position: at, + role: "orchestrator", launchOptions: { "p.local": {} } }); + await waitFor(() => calls.length === 1); + const child = manager.create({ provider: "codex", profile: "normal", cwd, position: at, + role: "subagent", parentSessionId: parent.id }); + await manager.shutdown(); + const restoredCalls = []; + const restored = new TerminalManager(() => undefined, registry(), undefined, undefined, true, spawner(restoredCalls)); + t.after(() => restored.shutdown()); + t.after(() => rm(storeDirectory, { recursive: true, force: true })); + restored.configureLaunchPipeline(pipeline); + restored.configureSessionPersistence(new TerminalSessionStore(storeDirectory), "continue"); + let release, entered; + const waiting = new Promise(resolve => { release = resolve; }); + const checking = new Promise(resolve => { entered = resolve; }); + restored.configureCodexRestoreTrust(async () => { entered(); await waiting; throw new Error("existing trust absent"); }); + const restoration = restored.restorePersistedSessions(); + await checking; + assert.equal(restoredCalls.length, 0); + assert.equal(card(restored, child.id), undefined, "the child waits for its parent's resolved launch"); + release(); + await restoration; + assert.equal(restoredCalls.length, 0); + for (const id of [parent.id, child.id]) { + assert.equal(card(restored, id).status, "failed"); + assert.match(card(restored, id).failureDetails, /existing trust absent/); + } +}); diff --git a/tests/limits-dispose.test.mjs b/tests/limits-dispose.test.mjs index b648b4c18..f1fa9d87c 100644 --- a/tests/limits-dispose.test.mjs +++ b/tests/limits-dispose.test.mjs @@ -42,7 +42,7 @@ require("node:readline").createInterface({ input: process.stdin }).on("line", (l if (message.id === undefined) return; const result = message.method === "account/rateLimits/read" ? { rateLimits: { limitId: "codex", primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1786160179 } } } - : {}; + : message.method === "account/read" ? { account: { type: "chatgpt" }, requiresOpenaiAuth: true } : {}; process.stdout.write(JSON.stringify({ id: message.id, result }) + "\\n"); }); `, { mode: 0o700 }); diff --git a/tests/omniroute-codex-limits.test.mjs b/tests/omniroute-codex-limits.test.mjs new file mode 100644 index 000000000..5150134d3 --- /dev/null +++ b/tests/omniroute-codex-limits.test.mjs @@ -0,0 +1,215 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, dirname } from "node:path"; +import { readOmniRouteCodexLimits } from "../src/main/services/OmniRouteCodexLimits.ts"; +import { LimitsService } from "../src/main/services/LimitsService.ts"; +import { selectHomeModel, formatResetCountdown } from "../src/renderer/src/features/home/homeModel.ts"; + +const CONNECTION = "fixture-connection"; +const ENV = { CANVASTTY_OMNIROUTE_BIN: process.execPath, CANVASTTY_OMNIROUTE_URL: "http://127.0.0.1:20128", + CANVASTTY_OMNIROUTE_CODEX_CONNECTION: CONNECTION }; +const USAGE = { quotas: { session: { used: 9, total: 100, remaining: 91, windowSeconds: 604800, + resetAt: "2030-01-10T12:00:00Z", displayName: "Weekly" } } }; +const provider = { id: CONNECTION, provider: "codex", isActive: true }; + +test("quota source is opt-in, read-only and bound to the configured active Codex connection", async () => { + const calls = []; + assert.equal(await readOmniRouteCodexLimits({}, () => assert.fail("must not run")), null); + const windows = await readOmniRouteCodexLimits(ENV, async (target, args) => { + calls.push(args); assert.equal(target.connectionId, CONNECTION); + return calls.length === 1 ? { connections: [provider] } : USAGE; + }); + assert.deepEqual(calls, [["api", "providers", "get-api-providers"], + ["api", "usage", "get-api-usage-connection-id-", "--connection-id", CONNECTION]]); + assert.equal(windows.length, 1); + assert.equal(windows[0].usedPercent, 9); + assert.equal(windows[0].windowMinutes, 10080, "source key session must not invent a 5h window"); + assert.equal(windows[0].resetsAt, Date.parse(USAGE.quotas.session.resetAt)); + assert.equal(formatResetCountdown(windows[0].resetsAt, windows[0].resetsAt - 3_600_000, "en"), "01:00"); + for (const changed of [{ ...provider, provider: "other" }, { ...provider, isActive: false }, { ...provider, isActive: undefined }, { ...provider, id: "other" }]) { + let count = 0; + await assert.rejects(readOmniRouteCodexLimits(ENV, async () => { count++; return [changed]; }), + (e) => e.reason === "not-authenticated"); + assert.equal(count, 1, "never query another connection"); + } +}); + +test("remote/credentialed URLs, partial config and synthetic or malformed quota data fail closed", async () => { + for (const change of [{ CANVASTTY_OMNIROUTE_URL: "http://example.com" }, + { CANVASTTY_OMNIROUTE_URL: "http://secret@localhost" }, { CANVASTTY_OMNIROUTE_URL: "http://localhost?key=secret" }, + { CANVASTTY_OMNIROUTE_BIN: "omniroute" }, { CANVASTTY_OMNIROUTE_CODEX_CONNECTION: "" }]) { + await assert.rejects(readOmniRouteCodexLimits({ ...ENV, ...change }, () => assert.fail("must not run"))); + } + for (const usage of [{ percentRemaining: 100 }, { quotas: {} }, { quotas: { x: { used: "9", total: 100 } } }, + { quotas: { x: { used: 0, total: 0 } } }, { quotas: { x: { used: 0, total: 100, unlimited: true } } }]) { + await assert.rejects(readOmniRouteCodexLimits(ENV, async (_target, args) => args[1] === "providers" ? [provider] : usage)); + } +}); + +test("an API-profile service displays real upstream quota through the installed CLI protocol", async (t) => { + const root = await mkdtemp(join(tmpdir(), "canvastty-omni-quota-")); + t.after(() => rm(root, { recursive: true, force: true })); + const script = join(root, "cli.mjs"); + await writeFile(script, `#!${process.platform === "win32" ? process.execPath : "/usr/bin/env node"} +import {createInterface} from "node:readline"; +if (process.argv.includes("--base-url")) { + console.log("\\x1b[2mCLI diagnostics\\x1b[0m".replaceAll("\\x1b", "\x1b")); + console.log(JSON.stringify(process.argv.includes("providers") ? {connections:${JSON.stringify([provider])}} : ${JSON.stringify(USAGE)})); +} else createInterface({input:process.stdin}).on("line", line => { + const request=JSON.parse(line); if(request.id===undefined)return; + const result=request.method==="account/read"?{account:null,requiresOpenaiAuth:false}:{}; + console.log(JSON.stringify({id:request.id,result})); +}); +`, { mode: 0o700 }); + let executable = script; + if (process.platform === "win32") { + executable = join(root, "cli.cmd"); + await writeFile(executable, `@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + } + const registry = { get(provider) { return { state:"available", provider, executable, + launcher:process.platform==="win32"?"batch":"native",commandPrompt:process.env.ComSpec,environment:{ PATH:dirname(process.execPath) },checked:[] }; } }; + const service = new LimitsService(registry, "test", { environment: { ...ENV, CANVASTTY_OMNIROUTE_BIN:executable, CODEX_HOME:root } }); + t.after(() => service.dispose()); + const oldPath = process.env.PATH; + process.env.PATH = process.platform === "win32" ? process.env.SystemRoot + "\\System32" : "/usr/bin:/bin"; + let snapshot; + try { snapshot = await service.getProviders(["codex"]); } + finally { if (oldPath === undefined) delete process.env.PATH; else process.env.PATH = oldPath; } + const [limits] = snapshot.providers; + assert.equal(limits.state, "available"); + assert.equal(limits.source, "omniroute-usage-api"); + assert.equal(limits.accountScope, null, "gateway quotas must not be attributed to the local ChatGPT account"); + const row = selectHomeModel([], snapshot, "ready", Date.parse("2030-01-09"), ["codex"]).limitRows[0]; + assert.equal(row.state, "available"); + assert.equal(row.window.usedPercent, 9); + assert.equal(row.window.windowMinutes, 10080); +}); + +test("review quotas do not replace the Codex model quota on HOME", async () => { + const now=Date.parse("2030-01-09"); + const windows=await readOmniRouteCodexLimits(ENV, async (_target,args)=> args[1]==="providers"?[provider]:{ + quotas:{...USAGE.quotas,code_review:{used:100,total:100,windowSeconds:31536000,resetAt:"2030-02-10T12:00:00Z"}} + }); + const snapshot={fetchedAt:now,providers:[{provider:"codex",state:"available",source:"omniroute-usage-api",fetchedAt:now,windows}]}; + const row=selectHomeModel([],snapshot,"ready",now,["codex"]).limitRows[0]; + assert.equal(row.window.usedPercent,9); +}); + + +async function quotaService(t) { + const root=await mkdtemp(join(tmpdir(),"canvas-quota-cache-")); + t.after(()=>rm(root,{recursive:true,force:true})); + const state=join(root,"state.json"), config=join(root,"config.toml"), script=join(root,"cli.mjs"); + const setState=(value)=>writeFile(state,JSON.stringify(value)); + await setState({});await writeFile(config,'model_provider = "api"\n'); + await writeFile(script,`#!${process.execPath} +import {readFileSync,writeFileSync} from "node:fs"; +import {createInterface} from "node:readline"; +const state=()=>JSON.parse(readFileSync(${JSON.stringify(state)},"utf8")); +if(process.argv.includes("--base-url")) { + const s=state(); + if(process.argv.includes("providers")) console.log(JSON.stringify(s.providers === "malformed" ? {} : s.inactive ? [] : [${JSON.stringify(provider)}])); + else if(s.switchAccount) { + writeFileSync(${JSON.stringify(join(root,"auth.json"))},JSON.stringify({tokens:{account_id:s.switchAccount}})); + console.log(JSON.stringify(s.quota === "malformed" ? {} : ${JSON.stringify(USAGE)})); + } + else if(s.quota === "timeout") setTimeout(()=>{},30000); + else console.log(JSON.stringify(s.quota === "malformed" ? {} : ${JSON.stringify(USAGE)})); +} else { + const api=readFileSync(${JSON.stringify(config)},"utf8").includes('"api"') && !state().nativeAccount; + createInterface({input:process.stdin}).on("line",line=>{ + const m=JSON.parse(line);if(m.id===undefined)return; + if(m.method === "account/rateLimits/read" && state().nativeError) return console.log(JSON.stringify({id:m.id,error:{code:-32603}})); + const result=m.method === "account/read" ? {account:api ? null : {type:"chatgpt"},requiresOpenaiAuth:!api} + : m.method === "account/rateLimits/read" ? {rateLimits:{limitId:"codex",primary:{usedPercent:35,windowDurationMins:300,resetsAt:1790000000}}} : {}; + console.log(JSON.stringify({id:m.id,result})); + }); +} +`,{mode:0o700}); + let executable=script; + if(process.platform === "win32") { + executable=join(root,"cli.cmd"); + await writeFile(executable,`@echo off\r\n"${process.execPath}" "${script}" %*\r\n`); + } + const registry={get:provider=>({state:"available",provider,executable, + launcher:process.platform === "win32" ? "batch" : "native",commandPrompt:process.env.ComSpec,environment:{},checked:[]})}; + const service=new LimitsService(registry,"test",{cacheTtlMs:0,environment:{...ENV,CODEX_HOME:root,CANVASTTY_OMNIROUTE_BIN:executable}}); + t.after(()=>service.dispose()); + return {config,auth:join(root,"auth.json"),setState,read:async()=>(await service.getProviders(["codex"])).providers[0]}; +} + +for(const [failure,reason] of [["malformed","protocol-error"],["timeout","timeout"],["malformed provider listing","protocol-error"]]) { + test(`gateway quota remains stale after a ${failure} reply and recovers`,async t=>{ + const f=await quotaService(t), first=await f.read(); + assert.equal(first.state,"available"); + await f.setState(failure === "malformed provider listing" ? {providers:"malformed"} : {quota:failure}); + const stale=await f.read(); + assert.equal(stale.state,"stale");assert.equal(stale.reason,reason); + assert.equal(stale.source,"omniroute-usage-api");assert.equal(stale.accountScope,null); + assert.equal(stale.fetchedAt,first.fetchedAt);assert.deepEqual(stale.windows,first.windows); + assert.ok(stale.failedAt >= first.fetchedAt); + await f.setState({});assert.equal((await f.read()).state,"available"); + }); +} + +test("changing between native and API profiles never reuses the other source's quota",async t=>{ + const f=await quotaService(t); + await writeFile(f.config,'model_provider = "native"\n'); + assert.equal((await f.read()).source,"codex-app-server"); + await writeFile(f.config,'model_provider = "api"\n');await f.setState({quota:"malformed"}); + const missing=await f.read();assert.equal(missing.state,"unavailable");assert.equal(missing.source,"omniroute-usage-api"); + await f.setState({});assert.equal((await f.read()).state,"available"); + await writeFile(f.config,'model_provider = "native"\n');await f.setState({nativeError:true}); + const native=await f.read();assert.equal(native.state,"unavailable");assert.equal(native.source,"codex-app-server"); + await f.setState({});assert.equal((await f.read()).source,"codex-app-server"); +}); + +test("revoking the bound connection discards its cached quota",async t=>{ + const f=await quotaService(t);assert.equal((await f.read()).state,"available"); + await f.setState({inactive:true}); + const revoked=await f.read();assert.equal(revoked.state,"unavailable");assert.equal(revoked.reason,"not-authenticated"); + await f.setState({quota:"malformed"});assert.equal((await f.read()).state,"unavailable"); +}); + + +test("invalid provider listings are protocol failures; valid missing bindings are authentication failures",async()=>{ + for(const [listing,reason] of [[{},"protocol-error"],[null,"protocol-error"], + [{connections:{}},"protocol-error"],[{providers:"invalid"},"protocol-error"], + [[],"not-authenticated"],[{connections:[]},"not-authenticated"]]) { + let calls=0; + await assert.rejects(readOmniRouteCodexLimits(ENV,async()=>{calls++;return listing;}),e=>e.reason === reason); + assert.equal(calls,1,"invalid or unauthenticated listings never query account usage"); + } +}); + + +for (const initialAccount of [null, "previous-account"]) { + test(`an auth-only account transition drops cached gateway quota (${initialAccount ?? "API key"})`, async t => { + const f = await quotaService(t); + if (initialAccount) await writeFile(f.auth, JSON.stringify({ tokens: { account_id: initialAccount } })); + assert.equal((await f.read()).source, "omniroute-usage-api"); + const config = await readFile(f.config, "utf8"); + await writeFile(f.auth, JSON.stringify({ tokens: { account_id: "new-account" } })); + await f.setState({ nativeAccount: true, nativeError: true }); + const failed = await f.read(); + assert.equal(failed.state, "unavailable"); + assert.equal(failed.source, "codex-app-server"); + assert.equal(await readFile(f.config, "utf8"), config, "only auth changed"); + await f.setState({ nativeAccount: true }); + assert.equal((await f.read()).source, "codex-app-server"); + }); +} + + +for (const quota of [undefined, "malformed"]) { + test(`an account switch during gateway usage reading never restores old quotas (${quota ?? "success"})`, async t => { + const f = await quotaService(t); + assert.equal((await f.read()).state, "available"); + await f.setState({ switchAccount: "changed-during-request", quota }); + const result = await f.read(); + assert.equal(result.state, "unavailable"); + assert.equal(result.reason, "protocol-error"); + }); +} diff --git a/tests/terminal-lifecycle.test.mjs b/tests/terminal-lifecycle.test.mjs index 8ca7f9c16..bdc112159 100644 --- a/tests/terminal-lifecycle.test.mjs +++ b/tests/terminal-lifecycle.test.mjs @@ -363,7 +363,7 @@ test("an exited PTY can restart in place without recreating its xterm card", asy readFile(terminalManagerPath, "utf8") ]); - assert.match(manager, /restart\(id: string, options: \{ resume\?: boolean \} = \{\}\): SessionSnapshot/); + assert.match(manager, /restart\(id: string, options: \{ resume\?: boolean[^\n]*\): SessionSnapshot/); assert.match(manager, /session\.metadata\.exitCode === null/); assert.match(manager, /session\.metadata\.status = initialSessionStatus\(session\.metadata\.provider\)/); assert.match(manager, /session\.metadata\.failureDetails = null/); diff --git a/tests/terminal-session-exact-resume.test.mjs b/tests/terminal-session-exact-resume.test.mjs index 97367b132..e878bd29c 100644 --- a/tests/terminal-session-exact-resume.test.mjs +++ b/tests/terminal-session-exact-resume.test.mjs @@ -223,3 +223,64 @@ test("local history reuses a running card before an exited card with the same co assert.equal(inst.findLocalConversation("codex", FIRST_THREAD).id, active.id); assert.equal(inst.findLocalConversation("hermes", FIRST_THREAD), null); }); + + +for (const trusted of [true, false]) { + test(`automatic local Codex restore checks trust before spawning (${trusted ? "trusted" : "missing trust"})`, async t => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restore-trust-")); + const initial = manager(directory, []); + const card = initial.create({ provider: "codex", profile: "auto", cwd: process.cwd(), position: { x: 0, y: 0 } }); + initial.applyProviderSignal(card.id, { kind: "lifecycle", state: "idle", threadId: FIRST_THREAD }); + await initial.shutdown(); + const calls = []; + const restored = manager(directory, calls); + t.after(async () => { await restored.shutdown(); await rm(directory, { recursive: true, force: true }); }); + let checked = 0; + restored.configureCodexRestoreTrust(async cwd => { + assert.equal(cwd, process.cwd()); + assert.equal(calls.length, 0, "trust is checked before any process starts"); + checked++; + if (!trusted) throw new Error("trust absent"); + }); + await restored.restorePersistedSessions(); + assert.equal(checked, 1); + assert.equal(calls.length, trusted ? 1 : 0); + const session = restored.list().find(s => s.id === card.id); + if (!trusted) { + assert.equal(session.status, "failed"); + assert.match(session.failureDetails, /Restart.*trust/); + assert.equal((await new TerminalSessionStore(directory).load())[0].threadId, FIRST_THREAD); + assert.equal(restored.localCodexFolderForTrust(card.id), process.cwd()); + } + }); +} + + +test("holding an untrusted restored parent also holds its descendants but leaves other providers running", async t => { + const directory = await mkdtemp(join(tmpdir(), "canvastty-restore-family-trust-")); + const calls = []; + const restored = manager(directory, calls); + t.after(() => restored.shutdown()); + t.after(() => rm(directory, { recursive: true, force: true })); + const base = { provider: "codex", profile: "auto", role: "orchestrator", title: "Saved", + titleCustomized: false, cwd: process.cwd(), position: { x: 0, y: 0 }, size: { width: 700, height: 430 }, + lastState: "running", restore: true }; + await new TerminalSessionStore(directory).replace([ + { ...base, id: "parent" }, + { ...base, id: "child", role: "subagent", parentSessionId: "parent" }, + { ...base, id: "other-provider", provider: "claude", role: "agent" }, + { ...base, id: "finished", lastState: "exited", exitCode: 0 } + ]); + let checks = 0; + restored.configureCodexRestoreTrust(async () => { checks++; throw new Error("trust absent"); }); + await restored.restorePersistedSessions(); + assert.equal(checks, 1, "children and finished cards do not request new trust"); + assert.equal(calls.length, 1); + assert.equal(calls[0].command, "/resolved/claude"); + for (const id of ["parent", "child"]) { + const session = restored.list().find(s => s.id === id); + assert.equal(session.status, "failed"); + assert.match(session.failureDetails, /Restart/); + } + assert.equal(restored.list().find(s => s.id === "finished").status, "done"); +}); diff --git a/tests/usage-account-scope.test.mjs b/tests/usage-account-scope.test.mjs index 787dc92f8..a53a6e8ab 100644 --- a/tests/usage-account-scope.test.mjs +++ b/tests/usage-account-scope.test.mjs @@ -59,12 +59,27 @@ import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } f import { createInterface } from "node:readline"; const root = ${JSON.stringify(root)}; appendFileSync(root + "/spawns", "spawn\\n"); +const configAtStart = existsSync(root + "/capture-config") + ? readFileSync(root + "/codex-home/config.toml", "utf8") : null; const reply = (id, result) => process.stdout.write(JSON.stringify({ id, result }) + "\\n"); createInterface({ input: process.stdin }).on("line", (line) => { const message = JSON.parse(line); if (typeof message.id !== "number") return; if (message.method === "initialize") return reply(message.id, {}); + if (message.method === "account/read") { + const file = root + "/account-state"; + const auth = configAtStart !== null + ? { account: configAtStart.includes("fixture-api") ? null : { type: "chatgpt" }, + requiresOpenaiAuth: !configAtStart.includes("fixture-api") } + : existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) + : { account: { type: "chatgpt" }, requiresOpenaiAuth: true }; + if (auth.error) return process.stdout.write(JSON.stringify({ id: message.id, error: auth.error }) + "\\n"); + return reply(message.id, auth); + } if (message.method !== "account/rateLimits/read") return process.stdout.write(JSON.stringify({ id: message.id, error: { code: -1 } }) + "\\n"); + appendFileSync(root + "/limit-reads", "read\\n"); + const failure = root + "/limits-error"; + if (existsSync(failure)) return process.stdout.write(JSON.stringify({ id: message.id, error: { code: -32600, message: "Synthetic request failure" } }) + "\\n"); const switchFile = root + "/switch-during-request"; if (existsSync(switchFile)) { writeFileSync(root + "/codex-home/auth.json", readFileSync(switchFile)); @@ -106,6 +121,68 @@ function assertNoCodexSecrets(value) { } } +test("Codex API profiles report unavailable subscription limits without requesting them", async () => { + for (const auth of [ + { account: { type: "apiKey" }, requiresOpenaiAuth: true }, + { account: null, requiresOpenaiAuth: false } + ]) await withRoot(async (root) => { + const codex = await fakeCodex(root); + await writeFile(join(root, "account-state"), JSON.stringify(auth)); + const service = new LimitsService(registry({ codex: codex.script }), "test", { + environment: { CODEX_HOME: codex.codexHome } + }); + try { + const [snapshot] = (await service.getProviders(["codex"])).providers; + assert.equal(snapshot.state, "unavailable"); + assert.equal(snapshot.reason, "api-profile"); + assert.equal("windows" in snapshot, false); + await assert.rejects(readFile(join(root, "limit-reads")), { code: "ENOENT" }); + } finally { service.dispose(); } + }); +}); + +test("Codex distinguishes signed-out, malformed and failed account responses from API profiles", async () => { + for (const [auth, reason] of [ + [{ account: null, requiresOpenaiAuth: true }, "not-authenticated"], + [{}, "protocol-error"], + [{ account: "invalid", requiresOpenaiAuth: true }, "protocol-error"], + [{ error: { code: -32600, message: "Synthetic account failure" } }, "protocol-error"] + ]) await withRoot(async (root) => { + const codex = await fakeCodex(root); + await writeFile(join(root, "account-state"), JSON.stringify(auth)); + const service = new LimitsService(registry({ codex: codex.script }), "test", { + environment: { CODEX_HOME: codex.codexHome } + }); + try { + const [snapshot] = (await service.getProviders(["codex"])).providers; + assert.equal(snapshot.state, "unavailable"); + assert.equal(snapshot.reason, reason); + } finally { service.dispose(); } + }); +}); + +test("switching to an API profile clears previous subscription limits", async () => { + await withRoot(async (root) => { + const codex = await fakeCodex(root); + const service = new LimitsService(registry({ codex: codex.script }), "test", { + environment: { CODEX_HOME: codex.codexHome }, cacheTtlMs: 0 + }); + try { + assert.equal((await service.getProviders(["codex"])).providers[0].state, "available"); + await writeFile(join(root, "account-state"), JSON.stringify({ account: null, requiresOpenaiAuth: false })); + const [api] = (await service.getProviders(["codex"])).providers; + assert.equal(api.state, "unavailable"); + assert.equal(api.reason, "api-profile"); + assert.equal("windows" in api, false); + await rm(join(root, "account-state")); + await writeFile(join(root, "limits-error"), "fail"); + const [failed] = (await service.getProviders(["codex"])).providers; + assert.equal(failed.state, "unavailable", "old subscription windows must not reappear as stale"); + assert.equal(failed.reason, "protocol-error"); + } finally { service.dispose(); } + }); +}); + test("Codex scope is a one-way fingerprint of tokens.account_id and is stable per account", async () => { await withRoot(async (root) => { const codex = await fakeCodex(root); @@ -343,3 +420,31 @@ test("plugin and companion views omit account scope without mutating the source assert.equal(view.providers[1].reason, "timeout"); assert.equal(snapshot.providers[0].accountScope, "abc123"); }); + +test("Codex reloads provider configuration with unchanged login and still reuses an unchanged profile", async () => { + await withRoot(async (root) => { + const codex = await fakeCodex(root); + await writeFile(join(codex.codexHome, "auth.json"), codexAuth(ACCOUNT_A)); + const config = join(codex.codexHome, "config.toml"); + await writeFile(config, 'model_provider = "openai"\n'); + await writeFile(join(root, "capture-config"), ""); + const service = new LimitsService(registry({ codex: codex.script }), "test", { + environment: { CODEX_HOME: codex.codexHome }, cacheTtlMs: 0 + }); + try { + assert.equal((await service.getProviders(["codex"])).providers[0].state, "available"); + await writeFile(config, 'model_provider = "fixture-api"\n'); + const [api] = (await service.getProviders(["codex"])).providers; + assert.equal(api.reason, "api-profile"); + assert.equal("windows" in api, false); + assert.equal(await codex.spawns(), 2); + await writeFile(config, 'model_provider = "openai"\n'); + const [restored] = (await service.getProviders(["codex"])).providers; + assert.equal(restored.state, "available"); + assert.equal(await codex.spawns(), 3); + await service.getProviders(["codex"]); + assert.equal(await codex.spawns(), 3, "unchanged configuration retains the existing app-server"); + assertNoCodexSecrets([api, restored]); + } finally { service.dispose(); } + }); +});