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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions docs/agent-orchestration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions docs/codex-api-limits.md
Original file line number Diff line number Diff line change
@@ -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.
11 changes: 11 additions & 0 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -728,6 +729,15 @@ async function initializeServices(ipc: IpcRegistrar): Promise<void> {
// 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)
Expand Down Expand Up @@ -852,6 +862,7 @@ async function initializeServices(ipc: IpcRegistrar): Promise<void> {
return { availability, settings: updatedSettings };
},
terminals: terminalManager,
providerClis,
limits: limitsService,
agentChatHistory,
usageHistory,
Expand Down
46 changes: 41 additions & 5 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -66,6 +68,7 @@ interface Dependencies {
recheckProviderClis(): Promise<{ availability: AgentCliAvailability; settings: AppSettings }>;
terminals: TerminalManager;
agentChatHistory: AgentChatHistoryService;
providerClis: ProviderCliRegistry;
limits: LimitsService;
usageHistory: UsageHistoryService;
plugins: PluginManager;
Expand Down Expand Up @@ -206,6 +209,7 @@ export function registerIpc(ipcMain: IpcRegistrar, {
recheckProviderClis,
terminals,
agentChatHistory,
providerClis,
limits,
usageHistory,
plugins,
Expand Down Expand Up @@ -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<AppSettings>) => {
assertMainRenderer(event, getMainWindow);
Expand Down Expand Up @@ -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<void> => 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.
Expand Down
32 changes: 25 additions & 7 deletions src/main/services/AgentChatHistoryService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
type CachedHistory = HistoryRecords & { generation: number };

export class AgentChatHistoryService {
Expand Down Expand Up @@ -92,21 +93,21 @@ export class AgentChatHistoryService {
}
}

async resume(provider: AgentChatHistoryProviderId, id: string, position: Point): Promise<AgentChatHistoryResumeResult> {
try { return await this.resumeConversation(provider, id, position); }
async resume(provider: AgentChatHistoryProviderId, id: string, position: Point, beforeCodexLaunch?: CodexLaunchGuard): Promise<AgentChatHistoryResumeResult> {
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."
: error instanceof Error ? error.message : "The conversation could not be resumed." } };
}
}

private async resumeConversation(provider: AgentChatHistoryProviderId, id: string, position: Point): Promise<AgentChatHistoryResumeResult> {
private async resumeConversation(provider: AgentChatHistoryProviderId, id: string, position: Point, beforeCodexLaunch?: CodexLaunchGuard): Promise<AgentChatHistoryResumeResult> {
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.
Expand All @@ -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<AgentChatHistoryResumeResult> {
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." } };
}
Expand Down
Loading
Loading