Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
1360b4a
fix(browser): keep saving tabs after one failed write
BIackFIame Sep 28, 2026
90308ce
fix(ipc): accept settings, plugin, secret and terminal calls only fro…
BIackFIame Sep 28, 2026
a5bb450
fix(windows): escape batch provider arguments for the shim's second c…
BIackFIame Sep 28, 2026
14112d8
fix(agent-control): bound request receipts without locking out contro…
BIackFIame Sep 28, 2026
37c2ab5
fix(windows): handle pipe host stdio errors instead of crashing the m…
BIackFIame Sep 28, 2026
2dcff05
fix(gateways): recover from a failed Windows pipe host and clean up a…
BIackFIame Sep 28, 2026
0141276
fix(windows): pass the real search path to plugin environment wrappers
BIackFIame Sep 28, 2026
d4dde5d
fix(orchestration): let cancel and disconnect reach the running tool …
BIackFIame Sep 28, 2026
232a9fd
fix(plugins): run only the service entry bytes that match the trusted…
BIackFIame Sep 28, 2026
7d52290
fix(agent-runtime): close hook connections that never send their message
BIackFIame Sep 28, 2026
6a77cb6
fix(storage): remove the temporary file when an atomic write fails
BIackFIame Sep 28, 2026
ebdbba7
fix(browser-audit): recover from a record cut off during a write
BIackFIame Sep 28, 2026
754dffe
fix(browser-audit): hash records with a locale-independent key order
BIackFIame Sep 28, 2026
37f80d9
fix(windows): find cmd.exe the same way for the terminal and batch pr…
BIackFIame Sep 28, 2026
9c64479
fix(github-auth): keep polling the device code through network errors
BIackFIame Sep 28, 2026
89be9d5
fix(settings): apply provider availability in write order
BIackFIame Sep 28, 2026
4315658
fix(limits): do not start `kimi web` after the limits service was dis…
BIackFIame Sep 28, 2026
03fdaa7
fix(plugin-media): do not follow a link at the playlist temp name
BIackFIame Sep 28, 2026
b16bee2
fix(plugin-cards): do not count hidden badges of revoked plugins
BIackFIame Sep 28, 2026
4839f0a
fix(plugins): do not wipe plugin storage when it cannot be read
BIackFIame Sep 28, 2026
1c58f2a
fix(plugins): keep the service entry guard out of the main bundle's C…
BIackFIame Sep 28, 2026
4d616f7
fix(plugins): check the main module node resolves, not only the path …
BIackFIame Sep 28, 2026
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: 3 additions & 1 deletion src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -676,7 +676,9 @@ async function initializeServices(): Promise<void> {
// (the launch dialog enables it right before launching an orchestrator).
await applyAgentControlSetting(next.agentControlEnabled);
agentBrowserBridge?.setEnabled(next.browserAgentAccess);
browserService?.setRestoreTabs(next.browserRestoreTabs);
browserService?.setRestoreTabs(next.browserRestoreTabs).catch((error: unknown) => {
console.warn("CanvasTTY browser tab restore setting could not be applied.", error);
});
browserService?.cancelCanvasNavigationGesture();
browserService?.setCanvasWheelCaptureMode(next.canvasWheelCaptureMode);
canvasNavigationInput?.setBindings({
Expand Down
138 changes: 78 additions & 60 deletions src/main/ipc/registerIpc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { extname } from "node:path";
import { readFile, stat } from "node:fs/promises";
import { realpath } from "node:fs/promises";
import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron";
import type { IpcMainEvent, IpcMainInvokeEvent, OpenDialogOptions } from "electron";
import type {
Expand Down Expand Up @@ -34,15 +33,7 @@ import { PluginBrowserOpenBroker } from "./PluginBrowserOpenBroker";
import type { GithubAuthService } from "../services/GithubAuthService";
import type { HermesHudService } from "../services/HermesHudService";
import { normalizeExternalUrl } from "../../shared/externalUrl";

const MAX_MEDIA_BYTES = 25 * 1024 * 1024;
const MEDIA_MIME: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif"
};
import { readHomeMedia } from "../services/homeMedia";

interface Dependencies {
settings: SettingsStore;
Expand Down Expand Up @@ -141,7 +132,8 @@ export function registerIpc({
assertMainRenderer(event, getMainWindow);
return recheckProviderClis();
});
ipcMain.handle(IPC.settingsUpdate, async (_event, patch: Partial<AppSettings>) => {
ipcMain.handle(IPC.settingsUpdate, async (event, patch: Partial<AppSettings>) => {
assertMainRenderer(event, getMainWindow);
const next = await settings.update(patch);
await applyBrowserSettings(next);
return next;
Expand Down Expand Up @@ -190,15 +182,19 @@ export function registerIpc({
const result = owner
? await dialog.showOpenDialog(owner, options)
: await dialog.showOpenDialog(options);
const path = result.filePaths[0];
if (result.canceled || !path) return null;
return { path, dataUrl: await readMedia(path) };
const picked = result.filePaths[0];
if (result.canceled || !picked) return null;
// Save the file the person picked, not a link to it, so a later read is
// not redirected by changing the link.
const path = await realpath(picked);
return { path, dataUrl: await readHomeMedia(path) };
});

ipcMain.handle(IPC.mediaRead, async (_event, path: string) => {
ipcMain.handle(IPC.mediaRead, async (event, path: string) => {
assertMainRenderer(event, getMainWindow);
if (typeof path !== "string" || settings.get().mediaPath !== path) return null;
try {
return await readMedia(path);
return await readHomeMedia(path);
} catch (error) {
console.warn("CanvasTTY media could not be read.", error);
return null;
Expand Down Expand Up @@ -246,25 +242,29 @@ export function registerIpc({
closePluginWindows(pluginId);
return plugins.updatePlugin(pluginId);
});
ipcMain.handle(IPC.pluginsPreviewInstall, (_event, sourceUrl: string) => {
ipcMain.handle(IPC.pluginsPreviewInstall, (event, sourceUrl: string) => {
assertMainRenderer(event, getMainWindow);
if (typeof sourceUrl !== "string") throw new Error("GitHub URL is required.");
return plugins.previewInstall(sourceUrl);
});
ipcMain.handle(IPC.pluginsInstall, (_event, token: string, selectedModules?: string[]) => {
ipcMain.handle(IPC.pluginsInstall, (event, token: string, selectedModules?: string[]) => {
assertMainRenderer(event, getMainWindow);
if (typeof token !== "string") throw new Error("Plugin preview token is invalid.");
if (selectedModules !== undefined && (
!Array.isArray(selectedModules) || selectedModules.some((item) => typeof item !== "string")
)) throw new Error("Plugin module selection is invalid.");
return plugins.install(token, selectedModules);
});
ipcMain.handle(IPC.pluginsSetModules, async (_event, pluginId: string, selectedModules: string[]) => {
ipcMain.handle(IPC.pluginsSetModules, async (event, pluginId: string, selectedModules: string[]) => {
assertMainRenderer(event, getMainWindow);
if (!Array.isArray(selectedModules) || selectedModules.some((item) => typeof item !== "string")) {
throw new Error("Plugin module selection is invalid.");
}
closePluginWindows(pluginId);
return plugins.setModules(pluginId, selectedModules);
});
ipcMain.handle(IPC.pluginsSetEnabled, async (_event, pluginId: string, enabled: boolean) => {
ipcMain.handle(IPC.pluginsSetEnabled, async (event, pluginId: string, enabled: boolean) => {
assertMainRenderer(event, getMainWindow);
if (typeof enabled !== "boolean") throw new Error("Plugin enabled state is invalid.");
try {
return await plugins.setEnabled(pluginId, enabled);
Expand Down Expand Up @@ -327,7 +327,8 @@ export function registerIpc({
if (typeof pluginId !== "string" || typeof provider !== "string") throw new Error("Launch option request is invalid.");
return launchFieldOptions(pluginId, provider as ProviderId);
});
ipcMain.handle(IPC.pluginsUninstall, async (_event, pluginId: string) => {
ipcMain.handle(IPC.pluginsUninstall, async (event, pluginId: string) => {
assertMainRenderer(event, getMainWindow);
closePluginWindows(pluginId);
await pluginSecrets.revokeAll(pluginId);
await pluginMedia.revokeAll(pluginId);
Expand All @@ -353,7 +354,8 @@ export function registerIpc({
ipcMain.handle(IPC.pluginsOpenWindow, (_event, pluginId: string, contributionId: string) => (
openPluginWindow(pluginId, contributionId)
));
ipcMain.handle(IPC.pluginsOpenExternal, async (_event, pluginId: string, value: string) => {
ipcMain.handle(IPC.pluginsOpenExternal, async (event, pluginId: string, value: string) => {
assertMainRenderer(event, getMainWindow);
plugins.assertPermission(pluginId, "external:open");
const url = normalizeExternalUrl(value);
await shell.openExternal(url);
Expand All @@ -369,22 +371,30 @@ export function registerIpc({
await plugins.storageSet(pluginId, key, value);
broadcastPluginStorageChange(pluginId, key, value);
});
ipcMain.handle(IPC.pluginsSecretsGet, (_event, pluginId: string, key: string) => (
pluginSecrets.get(pluginId, key)
));
ipcMain.handle(IPC.pluginsSecretsSet, (_event, pluginId: string, key: string, value: string) => (
pluginSecrets.set(pluginId, key, value)
));
ipcMain.handle(IPC.pluginsSecretsDelete, (_event, pluginId: string, key: string) => (
pluginSecrets.delete(pluginId, key)
));
ipcMain.handle(IPC.providerSecretsStatus, () => providerSecrets.status());
ipcMain.handle(IPC.providerSecretsSet, (_event, secretId: string, value: string) => (
providerSecrets.set(providerSecretValue(secretId), value)
));
ipcMain.handle(IPC.providerSecretsClear, (_event, secretId: string) => (
providerSecrets.delete(providerSecretValue(secretId))
));
ipcMain.handle(IPC.pluginsSecretsGet, (event, pluginId: string, key: string) => {
assertMainRenderer(event, getMainWindow);
return pluginSecrets.get(pluginId, key);
});
ipcMain.handle(IPC.pluginsSecretsSet, (event, pluginId: string, key: string, value: string) => {
assertMainRenderer(event, getMainWindow);
return pluginSecrets.set(pluginId, key, value);
});
ipcMain.handle(IPC.pluginsSecretsDelete, (event, pluginId: string, key: string) => {
assertMainRenderer(event, getMainWindow);
return pluginSecrets.delete(pluginId, key);
});
ipcMain.handle(IPC.providerSecretsStatus, (event) => {
assertMainRenderer(event, getMainWindow);
return providerSecrets.status();
});
ipcMain.handle(IPC.providerSecretsSet, (event, secretId: string, value: string) => {
assertMainRenderer(event, getMainWindow);
return providerSecrets.set(providerSecretValue(secretId), value);
});
ipcMain.handle(IPC.providerSecretsClear, (event, secretId: string) => {
assertMainRenderer(event, getMainWindow);
return providerSecrets.delete(providerSecretValue(secretId));
});
ipcMain.handle(IPC.pluginsMediaPickLibrary, (event, pluginId: string) => (
pickPluginMediaLibrary(event, pluginId, plugins, pluginMedia)
));
Expand Down Expand Up @@ -677,21 +687,30 @@ export function registerIpc({
if (typeof id !== "string") throw new Error("Terminal session ID is required.");
return terminals.readBuffer(id);
});
ipcMain.handle(IPC.terminalCreate, (_event, request: CreateSessionRequest) => terminals.create(request));
ipcMain.handle(IPC.terminalRestart, (_event, id: string, options?: { resume?: unknown }) => (
terminals.restart(id, { resume: options?.resume === true })
));
ipcMain.on(IPC.terminalInput, (_event, id: string, data: string) => terminals.input(id, data));
ipcMain.handle(IPC.terminalCreate, (event, request: CreateSessionRequest) => {
assertMainRenderer(event, getMainWindow);
return terminals.create(request);
});
ipcMain.handle(IPC.terminalRestart, (event, id: string, options?: { resume?: unknown }) => {
assertMainRenderer(event, getMainWindow);
return terminals.restart(id, { resume: options?.resume === true });
});
ipcMain.on(IPC.terminalInput, (event, id: string, data: string) => {
// Fire-and-forget: a foreign sender is dropped instead of throwing into the IPC layer.
if (!isMainRenderer(event, getMainWindow)) return;
terminals.input(id, data);
});
ipcMain.on(IPC.terminalResize, (_event, id: string, cols: number, rows: number) => {
terminals.resize(id, cols, rows);
});
ipcMain.on(IPC.terminalBounds, (_event, id: string, bounds: SessionBounds) => terminals.setBounds(id, bounds));
ipcMain.handle(IPC.terminalRename, (_event, id: string, title: string) => terminals.rename(id, title));
ipcMain.handle(IPC.terminalSetRestore, (_event, id: string, restore: boolean) => terminals.setRestore(id, restore));
ipcMain.handle(IPC.terminalDispose, (_event, id: string, options?: { keepEnvironmentData?: unknown }) => (
ipcMain.handle(IPC.terminalDispose, (event, id: string, options?: { keepEnvironmentData?: unknown }) => {
assertMainRenderer(event, getMainWindow);
// Environment data is kept unless the person explicitly chose Remove.
terminals.dispose(id, { keepEnvironmentData: options?.keepEnvironmentData !== false })
));
return terminals.dispose(id, { keepEnvironmentData: options?.keepEnvironmentData !== false });
});
// Fire-and-forget, like the other stream-reporting channels: a malformed
// report is ignored rather than rejecting into the renderer.
ipcMain.on(IPC.terminalSetVisible, (_event, id: unknown, visible: unknown) => {
Expand Down Expand Up @@ -740,6 +759,18 @@ function isCanvasNavigationPointerBindingInput(
&& typeof input.shiftKey === "boolean";
}

function isMainRenderer(
event: IpcMainEvent | IpcMainInvokeEvent,
getMainWindow: () => BrowserWindow | null
): boolean {
try {
assertMainRenderer(event, getMainWindow);
return true;
} catch {
return false;
}
}

function assertMainRenderer(
event: IpcMainEvent | IpcMainInvokeEvent,
getMainWindow: () => BrowserWindow | null
Expand Down Expand Up @@ -844,19 +875,6 @@ function providerValue(value: unknown): ProviderId {
throw new Error("Plugin requested an unknown launcher provider.");
}

async function readMedia(path: string): Promise<string> {
const mime = MEDIA_MIME[extname(path).toLowerCase()];
if (!mime) throw new Error("Unsupported media type.");

const metadata = await stat(path);
if (!metadata.isFile() || metadata.size > MAX_MEDIA_BYTES) {
throw new Error("Media must be a file smaller than 25 MB.");
}

const content = await readFile(path);
return `data:${mime};base64,${content.toString("base64")}`;
}

function providerSecretValue(value: string): ProviderSecretId {
if ((PROVIDER_SECRET_IDS as readonly string[]).includes(value)) return value as ProviderSecretId;
throw new Error("Provider secret id is unknown.");
Expand Down
29 changes: 20 additions & 9 deletions src/main/services/BrowserService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,8 +317,7 @@ export class BrowserService {
this.restoreTabsEnabled = enabled;
if (enabled) await this.persistRuntime();
else {
await this.store.clear();
this.persisted = this.store.get();
await this.clearSavedTabs();
}
}

Expand Down Expand Up @@ -431,8 +430,7 @@ export class BrowserService {
]);
this.downloads = [];
this.pendingDialogs.clear();
await this.store.clear();
this.persisted = this.store.get();
await this.clearSavedTabs();
if (this.visible) return this.newTab();
this.emit();
return this.getState();
Expand Down Expand Up @@ -469,10 +467,7 @@ export class BrowserService {

private async initialize(): Promise<void> {
this.persisted = await this.store.load();
if (!this.restoreTabsEnabled) {
await this.store.clear();
this.persisted = this.store.get();
}
if (!this.restoreTabsEnabled) await this.clearSavedTabs();
this.activeTabId = this.persisted.activeTabId;
await mkdir(this.policy.downloadRoot, { recursive: true });
this.configureSession();
Expand Down Expand Up @@ -1000,7 +995,23 @@ export class BrowserService {
const tabs = [...this.tabs.values()]
.map((tab) => ({ id: tab.id, url: this.tabUrl(tab) }))
.filter((tab) => isSafeBrowserUrl(tab.url));
this.persisted = await this.store.replace(tabs, this.activeTabId);
try {
this.persisted = await this.store.replace(tabs, this.activeTabId);
} catch (error) {
// The tabs on screen stay as they are; only the copy restored at the next
// start is stale. The store already holds the new state in memory.
this.persisted = this.store.get();
console.warn("CanvasTTY browser tabs could not be saved.", error);
}
}

private async clearSavedTabs(): Promise<void> {
try {
await this.store.clear();
} catch (error) {
console.warn("CanvasTTY saved browser tabs could not be cleared.", error);
}
this.persisted = this.store.get();
}

private destroyRuntimeTabs(): void {
Expand Down
44 changes: 35 additions & 9 deletions src/main/services/GithubAuthService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,16 +226,36 @@ export class GithubAuthService {
device_code: deviceCode,
grant_type: "urn:ietf:params:oauth:grant-type:device_code"
});
const response = await this.request("https://github.com/login/oauth/access_token", {
method: "POST",
headers: oauthHeaders(),
body: body.toString()
}, signal);
if (!response.ok) continue;
const payload: unknown = await response.json();
if (!isRecord(payload)) continue;
// A dropped connection, the 15 s request timeout or a GitHub 5xx is not
// the end of the flow: the person may still approve the code. Back off
// (RFC 8628 section 3.5) and poll again until the code expires.
let payload: unknown;
try {
const response = await this.request("https://github.com/login/oauth/access_token", {
method: "POST",
headers: oauthHeaders(),
body: body.toString()
}, signal);
if (!response.ok) {
interval = backedOff(interval);
continue;
}
payload = await response.json();
} catch (error) {
if (signal.aborted) throw error;
interval = backedOff(interval);
continue;
}
if (!isRecord(payload)) {
interval = backedOff(interval);
continue;
}
if (payload.error === "authorization_pending" || payload.error === "slow_down") {
if (payload.error === "slow_down") interval += 5;
if (payload.error === "slow_down") {
// +5 s for this and later polls; GitHub may name a longer interval.
const requested = typeof payload.interval === "number" && Number.isFinite(payload.interval) ? payload.interval : 0;
interval = Math.max(interval + 5, Math.min(requested, MAX_POLL_INTERVAL_SECONDS));
}
continue;
}
if (payload.error === "access_denied" || payload.error === "expired_token") return;
Expand Down Expand Up @@ -411,3 +431,9 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function isMissingFile(error: unknown): boolean {
return error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT";
}

const MAX_POLL_INTERVAL_SECONDS = 60;

function backedOff(interval: number): number {
return Math.min(Math.max(interval * 2, interval + 1), MAX_POLL_INTERVAL_SECONDS);
}
3 changes: 3 additions & 0 deletions src/main/services/LimitsService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,9 @@ class KimiWebUsageClient {
private async startChild(): Promise<void> {
if (!this.cli) throw new LimitsAdapterError("cli-not-found");
const port = await reserveLoopbackPort();
// dispose() during the await found no child to stop; starting one now would
// leave `kimi web` (a local server with a token in its URL) running.
if (this.disposed) throw new LimitsAdapterError("protocol-error");
const launch = providerChildProcessLaunch(
this.cli,
["web", "--no-open", "--port", String(port), "--log-level", "silent"]
Expand Down
6 changes: 6 additions & 0 deletions src/main/services/PluginCards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,12 @@ export class PluginCards {
throw new Error(`badge.tooltip must be text of at most ${MAX_BADGE_TOOLTIP} characters.`);
}
const perCard = this.badges.get(sessionId) ?? new Map<string, PluginCardBadge>();
// Badges of plugins that are no longer trusted are hidden; they must not
// keep a trusted plugin out of the card's slots.
const trusted = this.deps.trustedPlugins();
for (const owner of [...perCard.keys()]) {
if (owner !== pluginId && !trusted.has(owner)) perCard.delete(owner);
}
if (!perCard.has(pluginId) && perCard.size >= MAX_BADGES_PER_CARD) throw new Error("This card already shows the most plugin badges.");
perCard.set(pluginId, {
pluginId,
Expand Down
Loading
Loading