Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eee7bf95a | ||
|
|
b2406ec8a5 | ||
|
|
5fde9c2d61 | ||
|
|
06a6a0ac12 | ||
|
|
024e60ead1 | ||
|
|
fe71790f0a | ||
|
|
9371b3d01b | ||
|
|
5a1d279efd | ||
|
|
8b0cbf02c3 | ||
|
|
d19fe45a14 | ||
|
|
344946b096 | ||
|
|
fcd15707d2 | ||
|
|
42c82e46ea | ||
|
|
0e1c3b621a | ||
|
|
3cd3bbaaf7 | ||
|
|
8bfb50fcbb | ||
|
|
c39ef879c3 | ||
|
|
b3d5785477 | ||
|
|
05de49f7da |
@@ -270,6 +270,17 @@ const en: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': 'Bracketed paste mode',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'Wrap pasted text with escape sequences so the shell can distinguish paste from typed input. Disable if you see ^[[200~ artifacts.',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 clipboard',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'Allow remote programs (tmux, vim, etc.) to access the local clipboard via OSC-52 escape sequences.',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': 'Disabled',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': 'Write only',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': 'Read & Write',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': 'Write + Prompt on Read',
|
||||
'terminal.osc52.readPrompt.title': 'Clipboard Read Request',
|
||||
'terminal.osc52.readPrompt.desc': 'A remote program is requesting to read your clipboard. Allow?',
|
||||
'terminal.osc52.readPrompt.allow': 'Allow',
|
||||
'terminal.osc52.readPrompt.deny': 'Deny',
|
||||
'settings.terminal.behavior.scrollOnInput': 'Scroll on input',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': 'Scroll terminal to bottom when typing',
|
||||
'settings.terminal.behavior.scrollOnOutput': 'Scroll on output',
|
||||
|
||||
@@ -1146,6 +1146,17 @@ const zhCN: Messages = {
|
||||
'settings.terminal.behavior.bracketedPaste': '括号粘贴模式',
|
||||
'settings.terminal.behavior.bracketedPaste.desc':
|
||||
'粘贴文本时使用转义序列包裹,以便终端区分粘贴和键入。如果出现 ^[[200~ 字样请关闭此选项。',
|
||||
'settings.terminal.behavior.osc52Clipboard': 'OSC-52 剪贴板',
|
||||
'settings.terminal.behavior.osc52Clipboard.desc':
|
||||
'允许远程程序(tmux、vim 等)通过 OSC-52 转义序列访问本地剪贴板。',
|
||||
'settings.terminal.behavior.osc52Clipboard.off': '关闭',
|
||||
'settings.terminal.behavior.osc52Clipboard.writeOnly': '仅写入',
|
||||
'settings.terminal.behavior.osc52Clipboard.readWrite': '读写',
|
||||
'settings.terminal.behavior.osc52Clipboard.prompt': '写入 + 读取时询问',
|
||||
'terminal.osc52.readPrompt.title': '剪贴板读取请求',
|
||||
'terminal.osc52.readPrompt.desc': '远程程序正在请求读取您的剪贴板,是否允许?',
|
||||
'terminal.osc52.readPrompt.allow': '允许',
|
||||
'terminal.osc52.readPrompt.deny': '拒绝',
|
||||
'settings.terminal.behavior.scrollOnInput': '输入时自动滚动',
|
||||
'settings.terminal.behavior.scrollOnInput.desc': '输入时将终端滚动到底部',
|
||||
'settings.terminal.behavior.scrollOnOutput': '输出时自动滚动',
|
||||
|
||||
@@ -28,6 +28,7 @@ interface SnippetsManagerProps {
|
||||
hotkeyScheme: HotkeyScheme;
|
||||
keyBindings: KeyBinding[];
|
||||
onSave: (snippet: Snippet) => void;
|
||||
onBulkSave: (snippets: Snippet[]) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onPackagesChange: (packages: string[]) => void;
|
||||
onRunSnippet?: (snippet: Snippet, targetHosts: Host[]) => void;
|
||||
@@ -51,6 +52,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
hotkeyScheme,
|
||||
keyBindings,
|
||||
onSave,
|
||||
onBulkSave,
|
||||
onDelete,
|
||||
onPackagesChange,
|
||||
onRunSnippet,
|
||||
@@ -486,11 +488,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
// Update packages first, then save snippets
|
||||
onPackagesChange(keep);
|
||||
|
||||
// Only save snippets that were actually modified
|
||||
const modifiedSnippets = updatedSnippets.filter((s, index) =>
|
||||
s.package !== snippets[index].package
|
||||
);
|
||||
modifiedSnippets.forEach(onSave);
|
||||
// Bulk-save all snippets to avoid stale-closure overwrites
|
||||
onBulkSave(updatedSnippets);
|
||||
|
||||
// Reset selected package if it was deleted
|
||||
if (selectedPackage && (selectedPackage === path || selectedPackage.startsWith(path + '/'))) {
|
||||
@@ -527,7 +526,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
if (selectedPackage === source) setSelectedPackage(newPath);
|
||||
};
|
||||
|
||||
@@ -568,8 +567,8 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate: duplicate (case-insensitive)
|
||||
const existingPackage = packages.find(p => p.toLowerCase() === newPath.toLowerCase());
|
||||
// Validate: duplicate (case-insensitive), excluding the package being renamed
|
||||
const existingPackage = packages.find(p => p !== renamingPackagePath && p.toLowerCase() === newPath.toLowerCase());
|
||||
if (existingPackage) {
|
||||
setRenameError(t('snippets.renameDialog.error.duplicate'));
|
||||
return;
|
||||
@@ -595,7 +594,7 @@ const SnippetsManager: React.FC<SnippetsManagerProps> = ({
|
||||
});
|
||||
|
||||
onPackagesChange(Array.from(new Set(updatedPackages)));
|
||||
updatedSnippets.forEach(onSave);
|
||||
onBulkSave(updatedSnippets);
|
||||
|
||||
// Update selected package if it was renamed
|
||||
if (selectedPackage === renamingPackagePath) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { SerializeAddon } from "@xterm/addon-serialize";
|
||||
import { SearchAddon } from "@xterm/addon-search";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { Cpu, HardDrive, Maximize2, MemoryStick, Radio, ArrowDownToLine, ArrowUpFromLine } from "lucide-react";
|
||||
import React, { memo, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// flushSync removed - no longer needed
|
||||
import { useI18n } from "../application/i18n/I18nProvider";
|
||||
import { logger } from "../lib/logger";
|
||||
@@ -371,6 +371,27 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
const [pendingHostKeyInfo, setPendingHostKeyInfo] = useState<HostKeyInfo | null>(null);
|
||||
const pendingConnectionRef = useRef<(() => void) | null>(null);
|
||||
|
||||
// OSC-52 clipboard read prompt
|
||||
const [osc52ReadPromptVisible, setOsc52ReadPromptVisible] = useState(false);
|
||||
const osc52ReadResolverRef = useRef<((allowed: boolean) => void) | null>(null);
|
||||
const handleOsc52ReadRequest = useCallback((): Promise<boolean> => {
|
||||
// Reject if terminal is not visible (background tab) — user can't see the prompt
|
||||
if (!isVisibleRef.current) return Promise.resolve(false);
|
||||
// Reject if another prompt is already pending (avoid resolver overwrite)
|
||||
if (osc52ReadResolverRef.current) return Promise.resolve(false);
|
||||
return new Promise((resolve) => {
|
||||
osc52ReadResolverRef.current = resolve;
|
||||
setOsc52ReadPromptVisible(true);
|
||||
});
|
||||
}, []);
|
||||
const handleOsc52ReadResponse = useCallback((allowed: boolean) => {
|
||||
setOsc52ReadPromptVisible(false);
|
||||
osc52ReadResolverRef.current?.(allowed);
|
||||
osc52ReadResolverRef.current = null;
|
||||
// Restore focus to terminal
|
||||
termRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Subscribe to custom theme changes so editing triggers re-render
|
||||
const customThemes = useCustomThemes();
|
||||
|
||||
@@ -502,6 +523,7 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
serialLocalEcho: serialConfig?.localEcho,
|
||||
serialLineMode: serialConfig?.lineMode,
|
||||
serialLineBufferRef,
|
||||
onOsc52ReadRequest: handleOsc52ReadRequest,
|
||||
});
|
||||
|
||||
xtermRuntimeRef.current = runtime;
|
||||
@@ -1678,6 +1700,29 @@ const TerminalComponent: React.FC<TerminalProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OSC-52 clipboard read prompt */}
|
||||
{osc52ReadPromptVisible && (
|
||||
<div
|
||||
className="absolute inset-0 z-40 flex items-center justify-center bg-background/60"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') handleOsc52ReadResponse(false);
|
||||
}}
|
||||
>
|
||||
<div className="rounded-lg border bg-card p-4 shadow-lg max-w-sm space-y-3">
|
||||
<p className="text-sm font-medium">{t("terminal.osc52.readPrompt.title")}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("terminal.osc52.readPrompt.desc")}</p>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={() => handleOsc52ReadResponse(false)}>
|
||||
{t("terminal.osc52.readPrompt.deny")}
|
||||
</Button>
|
||||
<Button size="sm" autoFocus onClick={() => handleOsc52ReadResponse(true)}>
|
||||
{t("terminal.osc52.readPrompt.allow")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Connection dialog: skip for local/serial during connecting phase, but show on error */}
|
||||
{status !== "connected" && !needsHostKeyVerification && !(
|
||||
(isLocalConnection || isSerialConnection) && status === "connecting"
|
||||
|
||||
@@ -2201,6 +2201,7 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
|
||||
: [...snippets, s],
|
||||
)
|
||||
}
|
||||
onBulkSave={onUpdateSnippets}
|
||||
onDelete={(id) =>
|
||||
onUpdateSnippets(snippets.filter((s) => s.id !== id))
|
||||
}
|
||||
|
||||
@@ -575,6 +575,23 @@ export default function SettingsTerminalTab(props: {
|
||||
<Toggle checked={!terminalSettings.disableBracketedPaste} onChange={(v) => updateTerminalSetting("disableBracketedPaste", !v)} />
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.osc52Clipboard")}
|
||||
description={t("settings.terminal.behavior.osc52Clipboard.desc")}
|
||||
>
|
||||
<Select
|
||||
value={terminalSettings.osc52Clipboard ?? 'write-only'}
|
||||
options={[
|
||||
{ value: "off", label: t("settings.terminal.behavior.osc52Clipboard.off") },
|
||||
{ value: "write-only", label: t("settings.terminal.behavior.osc52Clipboard.writeOnly") },
|
||||
{ value: "read-write", label: t("settings.terminal.behavior.osc52Clipboard.readWrite") },
|
||||
{ value: "prompt", label: t("settings.terminal.behavior.osc52Clipboard.prompt") },
|
||||
]}
|
||||
onChange={(v) => updateTerminalSetting("osc52Clipboard", v as "off" | "write-only" | "read-write" | "prompt")}
|
||||
className="w-40"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label={t("settings.terminal.behavior.scrollOnInput")}
|
||||
description={t("settings.terminal.behavior.scrollOnInput.desc")}
|
||||
|
||||
@@ -35,6 +35,11 @@ export const ModelSelector: React.FC<{
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
// Temporarily allow the provider's host in the backend fetch allowlist
|
||||
// so model listing works for URLs not yet synced from the main window.
|
||||
if (bridge.aiAllowlistAddHost && baseURL) {
|
||||
await bridge.aiAllowlistAddHost(baseURL);
|
||||
}
|
||||
const url = `${baseURL.replace(/\/+$/, "")}${modelsEndpoint}`;
|
||||
const headers: Record<string, string> = {};
|
||||
if (apiKey) {
|
||||
|
||||
@@ -50,6 +50,7 @@ export interface FetchedModel {
|
||||
|
||||
export interface FetchBridge {
|
||||
aiFetch?: (url: string, method?: string, headers?: Record<string, string>, body?: string) => Promise<{ ok: boolean; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?: (baseURL: string) => Promise<{ ok: boolean }>;
|
||||
}
|
||||
|
||||
export interface NetcattyAiBridge {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SSHKey } from '../../types';
|
||||
import { Button } from '../ui/button';
|
||||
import { Input } from '../ui/input';
|
||||
import { Label } from '../ui/label';
|
||||
import { Dropdown, DropdownContent, DropdownTrigger } from '../ui/dropdown';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
|
||||
|
||||
export type TerminalAuthMethod = 'password' | 'key' | 'certificate';
|
||||
@@ -265,25 +266,34 @@ export const TerminalAuthDialog: React.FC<TerminalAuthDialogProps> = ({
|
||||
<Button variant="secondary" onClick={onCancel}>
|
||||
{t("common.close")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button disabled={!isValid} onClick={onSubmit}>
|
||||
{t("terminal.auth.continueSave")}
|
||||
<ChevronDown size={14} className="ml-2" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmitWithoutSave ?? onSubmit}
|
||||
<Dropdown>
|
||||
<div className="flex items-center rounded-md bg-primary text-primary-foreground">
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
onClick={onSubmit}
|
||||
className="rounded-r-none bg-transparent hover:bg-white/10 shadow-none"
|
||||
>
|
||||
{t("terminal.auth.continueSave")}
|
||||
</Button>
|
||||
<DropdownTrigger asChild>
|
||||
<Button
|
||||
disabled={!isValid}
|
||||
className="px-2 rounded-l-none bg-transparent hover:bg-white/10 border-l border-primary-foreground/20 shadow-none"
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
</div>
|
||||
<DropdownContent className="w-44 p-1 z-50" align="end">
|
||||
<button
|
||||
className="w-full px-3 py-2 text-sm text-left hover:bg-secondary rounded-md"
|
||||
onClick={onSubmitWithoutSave ?? onSubmit}
|
||||
disabled={!isValid}
|
||||
>
|
||||
{t("common.continue")}
|
||||
</button>
|
||||
</DropdownContent>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
} from "../../../domain/credentials";
|
||||
import { resolveHostAuth } from "../../../domain/sshAuth";
|
||||
|
||||
/** Timeout of distro detection task */
|
||||
const DISTRO_DETECT_TIMEOUT = 8000; // ms
|
||||
|
||||
type TerminalBackendApi = {
|
||||
backendAvailable: () => boolean;
|
||||
telnetAvailable: () => boolean;
|
||||
@@ -215,7 +218,7 @@ const attachSessionToTerminal = (
|
||||
|
||||
const runDistroDetection = async (
|
||||
ctx: TerminalSessionStartersContext,
|
||||
auth: { username: string; password?: string; key?: SSHKey },
|
||||
auth: { username: string; password?: string; key?: SSHKey; passphrase?: string },
|
||||
) => {
|
||||
if (!ctx.terminalBackend.execAvailable()) return;
|
||||
try {
|
||||
@@ -225,8 +228,9 @@ const runDistroDetection = async (
|
||||
port: ctx.host.port || 22,
|
||||
password: auth.password,
|
||||
privateKey: auth.key?.privateKey,
|
||||
passphrase: auth.passphrase ?? auth.key?.passphrase,
|
||||
command: "cat /etc/os-release 2>/dev/null || uname -a",
|
||||
timeout: 8000,
|
||||
timeout: DISTRO_DETECT_TIMEOUT,
|
||||
});
|
||||
const data = `${res.stdout || ""}\n${res.stderr || ""}`;
|
||||
const idMatch = data.match(/^ID="?([\w-]+)"?$/im);
|
||||
@@ -573,6 +577,7 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
|
||||
username: effectiveUsername,
|
||||
password: usedPassword,
|
||||
key: usedKey,
|
||||
passphrase: effectivePassphrase,
|
||||
}),
|
||||
600,
|
||||
);
|
||||
|
||||
@@ -94,6 +94,9 @@ export type CreateXTermRuntimeContext = {
|
||||
|
||||
// Callback when shell reports CWD change via OSC 7
|
||||
onCwdChange?: (cwd: string) => void;
|
||||
|
||||
// Callback when remote requests clipboard read in 'prompt' mode; resolves to user's decision
|
||||
onOsc52ReadRequest?: () => Promise<boolean>;
|
||||
};
|
||||
|
||||
const detectPlatform = (): XTermPlatform => {
|
||||
@@ -614,6 +617,78 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
return true; // Indicate we handled the sequence
|
||||
});
|
||||
|
||||
// OSC 52 — clipboard integration
|
||||
// Format: 52;<target>;<base64-data> (write) or 52;<target>;? (query/read)
|
||||
// <target> is typically "c" (clipboard) or "p" (primary selection)
|
||||
// Controlled by terminalSettings.osc52Clipboard: 'off' | 'write-only' | 'read-write'
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const settings = ctx.terminalSettingsRef.current;
|
||||
const mode = settings?.osc52Clipboard ?? 'write-only';
|
||||
if (mode === 'off') return true;
|
||||
|
||||
try {
|
||||
const semi = data.indexOf(';');
|
||||
if (semi < 0) return true;
|
||||
const target = data.substring(0, semi);
|
||||
// Only handle clipboard target ('c'); reject unsupported targets like 'p' (PRIMARY)
|
||||
if (target !== 'c' && target !== '') return true;
|
||||
const payload = data.substring(semi + 1);
|
||||
|
||||
if (payload === '?') {
|
||||
// Read request — allowed in read-write mode, or prompt user in prompt mode
|
||||
if (mode !== 'read-write' && mode !== 'prompt') {
|
||||
logger.debug('[XTerm] OSC 52 read request ignored (mode:', mode, ')');
|
||||
return true;
|
||||
}
|
||||
const sessionId = ctx.sessionRef.current;
|
||||
if (!sessionId) return true;
|
||||
// Use Electron bridge as primary, fall back to navigator.clipboard
|
||||
const readClipboard = async (): Promise<string> => {
|
||||
try {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (bridge?.readClipboardText) return await bridge.readClipboardText();
|
||||
} catch {}
|
||||
return navigator.clipboard.readText();
|
||||
};
|
||||
const doRead = async () => {
|
||||
// In prompt mode, ask user first
|
||||
if (mode === 'prompt') {
|
||||
const allowed = ctx.onOsc52ReadRequest ? await ctx.onOsc52ReadRequest() : false;
|
||||
if (!allowed) {
|
||||
logger.debug('[XTerm] OSC 52 read denied by user');
|
||||
return;
|
||||
}
|
||||
}
|
||||
const text = await readClipboard();
|
||||
// Chunked base64 encoding to avoid stack overflow on large payloads
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i += 8192) {
|
||||
binary += String.fromCharCode(...bytes.subarray(i, i + 8192));
|
||||
}
|
||||
const b64 = btoa(binary);
|
||||
ctx.terminalBackend.writeToSession(sessionId, `\x1b]52;${target};${b64}\x07`);
|
||||
};
|
||||
doRead().catch((err) => {
|
||||
logger.warn('[XTerm] OSC 52 clipboard read failed:', err);
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Write: payload is base64-encoded UTF-8 text
|
||||
const binary = atob(payload);
|
||||
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
|
||||
const text = new TextDecoder().decode(bytes);
|
||||
navigator.clipboard.writeText(text).catch((err) => {
|
||||
logger.warn('[XTerm] OSC 52 clipboard write failed:', err);
|
||||
});
|
||||
logger.debug('[XTerm] OSC 52 clipboard write', { length: text.length });
|
||||
} catch (err) {
|
||||
logger.warn('[XTerm] Failed to handle OSC 52:', err);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
let resizeTimeout: NodeJS.Timeout | null = null;
|
||||
const resizeDebounceMs = XTERM_PERFORMANCE_CONFIG.resize.debounceMs;
|
||||
term.onResize(({ cols, rows }) => {
|
||||
@@ -639,6 +714,7 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
cleanupMiddleClick?.();
|
||||
keywordHighlighter.dispose();
|
||||
osc7Disposable.dispose();
|
||||
osc52Disposable.dispose();
|
||||
try {
|
||||
term.dispose();
|
||||
} catch (err) {
|
||||
|
||||
@@ -434,6 +434,9 @@ export interface TerminalSettings {
|
||||
// Paste
|
||||
disableBracketedPaste: boolean; // Disable bracketed paste mode (avoid ^[[200~ artifacts)
|
||||
|
||||
// Clipboard
|
||||
osc52Clipboard: 'off' | 'write-only' | 'read-write' | 'prompt'; // OSC-52 clipboard access: off, write-only (default), read-write, or prompt on read
|
||||
|
||||
// Rendering
|
||||
rendererType: 'auto' | 'webgl' | 'canvas'; // Terminal renderer: auto (detect based on hardware), webgl, or canvas
|
||||
}
|
||||
@@ -541,6 +544,7 @@ export const DEFAULT_TERMINAL_SETTINGS: TerminalSettings = {
|
||||
showServerStats: true, // Show server stats by default
|
||||
serverStatsRefreshInterval: 5, // Refresh every 5 seconds
|
||||
disableBracketedPaste: false, // Bracketed paste enabled by default
|
||||
osc52Clipboard: 'write-only', // OSC-52: allow remote programs to write clipboard by default
|
||||
rendererType: 'auto', // Auto-detect best renderer based on hardware
|
||||
};
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ const SYNCABLE_TERMINAL_KEYS = [
|
||||
'scrollOnInput', 'scrollOnOutput', 'scrollOnKeyPress', 'scrollOnPaste',
|
||||
'rightClickBehavior', 'copyOnSelect', 'middleClickPaste', 'wordSeparators',
|
||||
'linkModifier', 'keywordHighlightEnabled', 'keywordHighlightRules',
|
||||
'keepaliveInterval', 'disableBracketedPaste',
|
||||
'keepaliveInterval', 'disableBracketedPaste', 'osc52Clipboard',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
|
||||
@@ -165,25 +165,51 @@ function init(deps) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an IPC event sender is the main window's webContents.
|
||||
* Validate that an IPC event sender is the main window.
|
||||
* Returns true if valid, false otherwise.
|
||||
*/
|
||||
function validateSender(event) {
|
||||
// Lazily resolve mainWebContentsId if not yet set
|
||||
if (mainWebContentsId == null) {
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
mainWebContentsId = mainWin.webContents?.id ?? null;
|
||||
}
|
||||
} catch {
|
||||
// Cannot resolve — reject for safety
|
||||
return false;
|
||||
return _validateSenderImpl(event, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an IPC event sender is a trusted window (main or settings).
|
||||
* Use this for handlers that the settings window legitimately needs access to
|
||||
* (e.g. model listing, provider sync, Codex login, agent discovery).
|
||||
*/
|
||||
function validateSenderOrSettings(event) {
|
||||
return _validateSenderImpl(event, true);
|
||||
}
|
||||
|
||||
function _validateSenderImpl(event, allowSettings) {
|
||||
try {
|
||||
const windowManager = require("./windowManager.cjs");
|
||||
|
||||
// Always resolve the current main window id to handle window recreation
|
||||
const mainWin = windowManager.getMainWindow?.();
|
||||
if (mainWin && !mainWin.isDestroyed?.()) {
|
||||
mainWebContentsId = mainWin.webContents?.id ?? null;
|
||||
}
|
||||
|
||||
const senderId = event.sender?.id;
|
||||
if (senderId == null) return false;
|
||||
|
||||
// Allow main window
|
||||
if (mainWebContentsId != null && senderId === mainWebContentsId) return true;
|
||||
|
||||
// Allow settings window only for designated handlers
|
||||
if (allowSettings) {
|
||||
const settingsWin = windowManager.getSettingsWindow?.();
|
||||
if (settingsWin && !settingsWin.isDestroyed?.()) {
|
||||
if (senderId === settingsWin.webContents?.id) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
} catch {
|
||||
// Cannot resolve — reject for safety
|
||||
return false;
|
||||
}
|
||||
if (mainWebContentsId == null) return false;
|
||||
return event.sender?.id === mainWebContentsId;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -331,7 +357,7 @@ function streamRequest(url, options, event, requestId) {
|
||||
function registerHandlers(ipcMain) {
|
||||
// ── Provider config sync (renderer → main, keys stay encrypted) ──
|
||||
ipcMain.handle("netcatty:ai:sync-providers", async (event, { providers }) => {
|
||||
if (!validateSender(event)) return { ok: false };
|
||||
if (!validateSenderOrSettings(event)) return { ok: false };
|
||||
if (Array.isArray(providers)) {
|
||||
providerConfigs = providers;
|
||||
rebuildProviderFetchHosts();
|
||||
@@ -339,6 +365,72 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Temporarily add a host to the fetch allowlist (used by settings model listing).
|
||||
// Entries are auto-removed after 30 seconds unless they belong to a synced provider.
|
||||
const TEMP_ALLOWLIST_TTL = 30_000;
|
||||
// Track temporarily added entries so cleanup can distinguish them from synced ones
|
||||
const tempAllowedHosts = new Set();
|
||||
const tempAllowedPorts = new Set();
|
||||
|
||||
/** Check if a host is owned by a currently synced provider config */
|
||||
function isHostInProviderConfigs(host) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try { if (new URL(config.baseURL).hostname === host) return true; } catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
/** Check if a localhost port is owned by a currently synced provider config */
|
||||
function isPortInProviderConfigs(port) {
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
const p = new URL(config.baseURL);
|
||||
if ((p.hostname === "localhost" || p.hostname === "127.0.0.1") &&
|
||||
Number(p.port || (p.protocol === "https:" ? 443 : 80)) === port) return true;
|
||||
} catch {}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
ipcMain.handle("netcatty:ai:allowlist:add-host", async (event, { baseURL }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
if (typeof baseURL !== "string") return { ok: false, error: "baseURL must be a string" };
|
||||
try {
|
||||
const parsed = new URL(baseURL);
|
||||
const host = parsed.hostname;
|
||||
if (host === "localhost" || host === "127.0.0.1") {
|
||||
const port = parsed.port ? Number(parsed.port) : (parsed.protocol === "https:" ? 443 : 80);
|
||||
if (!ALLOWED_LOCALHOST_PORTS.has(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
tempAllowedPorts.add(port);
|
||||
setTimeout(() => {
|
||||
// Only remove if still temporary (not built-in and not synced by a provider)
|
||||
if (!BUILTIN_LOCALHOST_PORTS.includes(port) && !isPortInProviderConfigs(port)) {
|
||||
ALLOWED_LOCALHOST_PORTS.delete(port);
|
||||
}
|
||||
tempAllowedPorts.delete(port);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
} else {
|
||||
if (!providerFetchHosts.has(host)) {
|
||||
providerFetchHosts.add(host);
|
||||
tempAllowedHosts.add(host);
|
||||
setTimeout(() => {
|
||||
// Only remove if not owned by a synced provider config
|
||||
if (!isHostInProviderConfigs(host)) {
|
||||
providerFetchHosts.delete(host);
|
||||
}
|
||||
tempAllowedHosts.delete(host);
|
||||
}, TEMP_ALLOWLIST_TTL);
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
} catch {
|
||||
return { ok: false, error: "Invalid URL" };
|
||||
}
|
||||
});
|
||||
|
||||
// URL allowlist: only permit requests to known AI provider domains + HTTPS
|
||||
const BUILTIN_FETCH_HOSTS = new Set([
|
||||
"api.openai.com",
|
||||
@@ -358,6 +450,9 @@ function registerHandlers(ipcMain) {
|
||||
// Reset localhost ports to built-in defaults, then add provider-configured ones
|
||||
ALLOWED_LOCALHOST_PORTS.clear();
|
||||
for (const port of BUILTIN_LOCALHOST_PORTS) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
// Re-add any still-active temporary entries so a sync doesn't wipe them
|
||||
for (const host of tempAllowedHosts) providerFetchHosts.add(host);
|
||||
for (const port of tempAllowedPorts) ALLOWED_LOCALHOST_PORTS.add(port);
|
||||
for (const config of providerConfigs) {
|
||||
if (!config.baseURL) continue;
|
||||
try {
|
||||
@@ -447,7 +542,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Cancel an active stream
|
||||
ipcMain.handle("netcatty:ai:chat:cancel", async (_event, { requestId }) => {
|
||||
ipcMain.handle("netcatty:ai:chat:cancel", async (event, { requestId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const controller = activeStreams.get(requestId);
|
||||
if (controller) {
|
||||
controller.abort();
|
||||
@@ -459,8 +555,8 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// Non-streaming request (for model listing, validation, etc.)
|
||||
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId }) => {
|
||||
// Validate IPC sender (Issue #17)
|
||||
if (!validateSender(event)) {
|
||||
// Validate IPC sender — settings window needs this for model listing
|
||||
if (!validateSenderOrSettings(event)) {
|
||||
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
|
||||
}
|
||||
|
||||
@@ -840,7 +936,8 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
|
||||
// Discover external agents from PATH, plus the bundled Codex CLI if present.
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async () => {
|
||||
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const agents = [];
|
||||
const knownAgents = [
|
||||
{
|
||||
@@ -909,7 +1006,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Resolve a CLI binary path (auto-detect or validate custom path)
|
||||
ipcMain.handle("netcatty:ai:resolve-cli", async (_event, { command, customPath }) => {
|
||||
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const shellEnv = await getShellEnv();
|
||||
let resolvedPath = null;
|
||||
|
||||
@@ -937,7 +1035,8 @@ function registerHandlers(ipcMain) {
|
||||
return { path: resolvedPath, version, available: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async () => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-integration", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const result = await runCodexCli(["login", "status"]);
|
||||
const rawOutput = [result.stdout, result.stderr]
|
||||
@@ -987,7 +1086,8 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:start-login", async () => {
|
||||
ipcMain.handle("netcatty:ai:codex:start-login", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const existingSession = getActiveCodexLoginSession();
|
||||
if (existingSession) {
|
||||
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
|
||||
@@ -1051,7 +1151,8 @@ function registerHandlers(ipcMain) {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:get-login-session", async (_event, { sessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:codex:get-login-session", async (event, { sessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const session = codexLoginSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: false, error: "Codex login session not found" };
|
||||
@@ -1059,7 +1160,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true, session: toCodexLoginSessionResponse(session) };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:cancel-login", async (_event, { sessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:codex:cancel-login", async (event, { sessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const session = codexLoginSessions.get(sessionId);
|
||||
if (!session) {
|
||||
return { ok: true, found: false };
|
||||
@@ -1075,7 +1177,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true, found: true, session: toCodexLoginSessionResponse(session) };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async () => {
|
||||
ipcMain.handle("netcatty:ai:codex:logout", async (event) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
const logoutResult = await runCodexCli(["logout"]);
|
||||
invalidateCodexValidationCache();
|
||||
@@ -1249,12 +1352,14 @@ function registerHandlers(ipcMain) {
|
||||
|
||||
// ── MCP Server session metadata ──
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (_event, { sessions: sessionList, chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
mcpServerBridge.updateSessionMetadata(sessionList || [], chatSessionId);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (_event, { blocklist }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-blocklist", async (event, { blocklist }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Validate: must be an array of strings, each a valid regex pattern
|
||||
if (!Array.isArray(blocklist)) {
|
||||
return { ok: false, error: "blocklist must be an array" };
|
||||
@@ -1273,7 +1378,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (_event, { timeout }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-command-timeout", async (event, { timeout }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const value = Number(timeout);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 3600) {
|
||||
return { ok: false, error: "timeout must be a number between 1 and 3600" };
|
||||
@@ -1282,7 +1388,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (_event, { maxIterations }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-max-iterations", async (event, { maxIterations }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const value = Number(maxIterations);
|
||||
if (!Number.isFinite(value) || value < 1 || value > 100) {
|
||||
return { ok: false, error: "maxIterations must be a number between 1 and 100" };
|
||||
@@ -1291,7 +1398,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (_event, { mode }) => {
|
||||
ipcMain.handle("netcatty:ai:mcp:set-permission-mode", async (event, { mode }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const validModes = ["observer", "confirm", "autonomous"];
|
||||
if (!validModes.includes(mode)) {
|
||||
return { ok: false, error: `mode must be one of: ${validModes.join(", ")}` };
|
||||
@@ -1523,7 +1631,8 @@ function registerHandlers(ipcMain) {
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (_event, { requestId }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:cancel", async (event, { requestId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
// Cancel any active PTY executions (send Ctrl+C)
|
||||
mcpServerBridge.cancelAllPtyExecs();
|
||||
const controller = acpActiveStreams.get(requestId);
|
||||
@@ -1536,7 +1645,8 @@ function registerHandlers(ipcMain) {
|
||||
});
|
||||
|
||||
// Cleanup a specific ACP session (when chat session is deleted)
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (_event, { chatSessionId }) => {
|
||||
ipcMain.handle("netcatty:ai:acp:cleanup", async (event, { chatSessionId }) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
cleanupAcpProvider(chatSessionId);
|
||||
mcpServerBridge.cleanupScopedMetadata(chatSessionId);
|
||||
return { ok: true };
|
||||
|
||||
@@ -123,36 +123,46 @@ async function findAllDefaultPrivateKeys(options = {}) {
|
||||
return results.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a Windows named pipe exists (non-blocking).
|
||||
* Works for OpenSSH Agent, Bitwarden SSH Agent, 1Password, etc.
|
||||
*/
|
||||
function windowsPipeExists(pipePath) {
|
||||
try {
|
||||
fs.statSync(pipePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* Check if a Windows named pipe is connectable.
|
||||
* fs.statSync is unreliable for named pipes (returns EBUSY even when the
|
||||
* pipe is usable), so we attempt an actual net.connect() which is the
|
||||
* authoritative check.
|
||||
* @param {string} pipePath
|
||||
* @param {number} [timeoutMs=1000]
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function windowsPipeConnectable(pipePath, timeoutMs = 1000) {
|
||||
const net = require("net");
|
||||
return new Promise((resolve) => {
|
||||
const socket = net.connect(pipePath);
|
||||
let settled = false;
|
||||
const finish = (ok) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.destroy(); } catch {}
|
||||
resolve(ok);
|
||||
};
|
||||
socket.setTimeout(timeoutMs);
|
||||
socket.once("connect", () => finish(true));
|
||||
socket.once("timeout", () => finish(false));
|
||||
socket.once("error", () => finish(false));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an SSH agent is available on Windows.
|
||||
* Instead of checking the OpenSSH Authentication Agent *service*, we probe
|
||||
* the well-known named pipe directly. This supports any agent that provides
|
||||
* the pipe — Bitwarden, 1Password, gpg-agent, etc.
|
||||
* Probes the well-known named pipe via net.connect(). This supports any
|
||||
* agent that provides the pipe — Bitwarden, 1Password, gpg-agent, etc.
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
function checkWindowsSshAgentRunning() {
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve(true);
|
||||
return;
|
||||
}
|
||||
resolve(windowsPipeExists(WIN_SSH_AGENT_PIPE));
|
||||
});
|
||||
if (process.platform !== "win32") {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
return windowsPipeConnectable(WIN_SSH_AGENT_PIPE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -143,29 +143,33 @@ async function findAllDefaultPrivateKeys() {
|
||||
const WIN_SSH_AGENT_PIPE = "\\\\.\\pipe\\openssh-ssh-agent";
|
||||
|
||||
/**
|
||||
* Check if an SSH agent is available on Windows by probing the well-known
|
||||
* named pipe. This detects any agent that provides the pipe — OpenSSH Agent
|
||||
* service, Bitwarden, 1Password, gpg-agent, etc.
|
||||
* Check if an SSH agent is available on Windows by connecting to the
|
||||
* well-known named pipe. fs.statSync is unreliable for named pipes (returns
|
||||
* EBUSY even when usable), so we use net.connect() as the authoritative check.
|
||||
* @returns {Promise<{ running: boolean, startupType: string | null, error: string | null }>}
|
||||
*/
|
||||
function checkWindowsSshAgent() {
|
||||
if (process.platform !== "win32") {
|
||||
return Promise.resolve({ running: true, startupType: null, error: null });
|
||||
}
|
||||
const net = require("net");
|
||||
return new Promise((resolve) => {
|
||||
if (process.platform !== "win32") {
|
||||
resolve({ running: true, startupType: null, error: null });
|
||||
return;
|
||||
}
|
||||
let pipeExists = false;
|
||||
try {
|
||||
fs.statSync(WIN_SSH_AGENT_PIPE);
|
||||
pipeExists = true;
|
||||
} catch {
|
||||
// pipe not found
|
||||
}
|
||||
resolve({
|
||||
running: pipeExists,
|
||||
startupType: pipeExists ? "running" : "stopped",
|
||||
error: pipeExists ? null : "SSH Agent pipe not found",
|
||||
});
|
||||
const socket = net.connect(WIN_SSH_AGENT_PIPE);
|
||||
let settled = false;
|
||||
const finish = (ok, error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
try { socket.destroy(); } catch {}
|
||||
resolve({
|
||||
running: ok,
|
||||
startupType: ok ? "running" : "stopped",
|
||||
error: ok ? null : (error || "SSH Agent pipe not connectable"),
|
||||
});
|
||||
};
|
||||
socket.setTimeout(1000);
|
||||
socket.once("connect", () => finish(true, null));
|
||||
socket.once("timeout", () => finish(false, "SSH Agent pipe connect timeout"));
|
||||
socket.once("error", (err) => finish(false, err.message));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1003,6 +1003,9 @@ const api = {
|
||||
aiFetch: async (url, method, headers, body, providerId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:fetch", { url, method, headers, body, providerId });
|
||||
},
|
||||
aiAllowlistAddHost: async (baseURL) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:allowlist:add-host", { baseURL });
|
||||
},
|
||||
aiExec: async (sessionId, command) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:exec", { sessionId, command });
|
||||
},
|
||||
|
||||
2
global.d.ts
vendored
2
global.d.ts
vendored
@@ -189,6 +189,7 @@ declare global {
|
||||
port?: number;
|
||||
password?: string;
|
||||
privateKey?: string;
|
||||
passphrase?: string;
|
||||
command: string;
|
||||
timeout?: number;
|
||||
enableKeyboardInteractive?: boolean;
|
||||
@@ -617,6 +618,7 @@ declare global {
|
||||
aiChatStream?(requestId: string, url: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; statusCode?: number; statusText?: string; error?: string }>;
|
||||
aiChatCancel?(requestId: string): Promise<boolean>;
|
||||
aiFetch?(url: string, method?: string, headers?: Record<string, string>, body?: string, providerId?: string): Promise<{ ok: boolean; status: number; data: string; error?: string }>;
|
||||
aiAllowlistAddHost?(baseURL: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiExec?(sessionId: string, command: string): Promise<{ ok: boolean; stdout?: string; stderr?: string; exitCode?: number | null; error?: string }>;
|
||||
aiTerminalWrite?(sessionId: string, data: string): Promise<{ ok: boolean; error?: string }>;
|
||||
aiDiscoverAgents?(): Promise<Array<{
|
||||
|
||||
Reference in New Issue
Block a user