Compare commits

..
Author SHA1 Message Date
bincxz f9ad1ddfe0 fix(sftp): retain selection reveal after explicit view changes 2026-09-12 17:17:20 +08:00
bincxz ce86e8e426 feat(sftp): refresh visible sidebar after terminal commands 2026-09-12 17:07:35 +08:00
陈大猫andnetcatty-bot e7d7a12a91 fix: prevent AI command truncation on OpenWrt BusyBox (#3342)
* fix: bound AI command input for OpenWrt BusyBox

* fix: address Codex review on PR #3342

* fix: preserve custom prompts and split command history

* fix: preserve zsh history suppression for split AI input

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-09-12 13:52:09 +08:00
86d8c5279d fix(port-forwarding): add per-rule auto-reconnect on disconnect (#3328)
* fix(#3326): automated AI fix

* fix: address Codex review on PR #3328

* fix: address Codex review on PR #3328

* fix(port-forwarding): honor explicit reconnect preference

* fix(port-forwarding): save auto-start alongside reconnect preference

* fix(port-forwarding): coordinate tray starts in main window

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:47:34 +08:00
e0cb6034fe fix(ai): surface Codex config.toml third-party models in chat picker (#3345)
* fix(#3344): automated AI fix

* fix: address Codex review on PR #3345

* fix: address Codex review on PR #3345

* fix(ai): honor Codex config models before sends in both runtimes

* fix(ai): use managed Codex environment for login actions

* fix(ai): reuse Codex auth checks within the effective environment

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:45:19 +08:00
b54c5e3a9b fix(sftp): report empty exec responses distinctly and log SCP fallback reason (#3355)
* fix(#3354): automated AI fix

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix: address Codex review on PR #3355

* fix(sftp): preserve fallback diagnostics across worker boundary

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-09-12 13:36:58 +08:00
60 changed files with 1486 additions and 176 deletions

No files matched your search

+2 -1
View File
@@ -9,6 +9,7 @@ import { sanitizeHostIconFields } from '../../domain/hostIcon';
import { resolveEffectiveTerminalProtocol } from '../../domain/terminalProtocol';
import { getTerminalPassthroughActions } from '../state/useGlobalHotkeys';
import { tabShortcutDigitFromEvent } from '../../domain/models/keyBindings';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { buildNumberShortcutTabTargets } from './tabShortcutTargets';
import { captureInheritedCwd } from '../state/inheritedCwd';
import { fromEditorTabId, isEditorTabId } from '../state/activeTabStore';
@@ -153,7 +154,7 @@ export function handleTrayTogglePortForwardImpl(getCtx: AppContextGetter, ruleId
const effectiveHost = resolveEffectiveHost(host);
void startTunnel(rule, effectiveHost, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, knownHosts);
}, isPortForwardingAutoReconnectEnabled(rule), terminalSettings, knownHosts);
}
return;
}
+3
View File
@@ -266,6 +266,8 @@ export const enVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Stop and Delete',
'pf.form.autoStart': 'Auto Start',
'pf.form.autoStartDesc': 'Automatically start this rule when the app launches',
'pf.form.autoReconnect': 'Auto Reconnect',
'pf.form.autoReconnectDesc': 'Automatically reconnect when the connection drops',
// SFTP
'sftp.newFolder': 'New Folder',
@@ -415,6 +417,7 @@ export const enVaultMessages: Messages = {
'sftp.goUp': 'Go up',
'sftp.goToTerminalCwd': 'Go to terminal directory',
'sftp.locatePathInTerminal': 'Open path in terminal',
'sftp.refreshAfterCommand': 'Refresh after terminal commands',
'sftp.followTerminalCwd': 'Follow terminal directory',
'sftp.followTerminalCwd.enable': 'Enable follow terminal directory',
'sftp.followTerminalCwd.disable': 'Disable follow terminal directory',
+2
View File
@@ -266,6 +266,8 @@ export const esVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Detener y eliminar',
'pf.form.autoStart': 'Inicio automático',
'pf.form.autoStartDesc': 'Inicia automáticamente esta regla cuando se abra la aplicación',
'pf.form.autoReconnect': 'Reconexión automática',
'pf.form.autoReconnectDesc': 'Vuelve a conectar automáticamente si se pierde la conexión',
// SFTP
'sftp.newFolder': 'Nueva carpeta',
+2
View File
@@ -304,6 +304,8 @@ export const ruVaultMessages: Messages = {
'pf.deleteActive.confirm': 'Остановить и удалить',
'pf.form.autoStart': 'Автозапуск',
'pf.form.autoStartDesc': 'Автоматически запускать это правило при запуске приложения',
'pf.form.autoReconnect': 'Автопереподключение',
'pf.form.autoReconnectDesc': 'Автоматически восстанавливать соединение при обрыве',
// SFTP
'sftp.newFolder': 'Новая папка',
+1
View File
@@ -1046,6 +1046,7 @@ export const zhCNCoreMessages: Messages = {
'sftp.goUp': '上一级',
'sftp.goToTerminalCwd': '定位到终端当前目录',
'sftp.locatePathInTerminal': '定位路径到终端',
'sftp.refreshAfterCommand': '终端命令完成后刷新',
'sftp.followTerminalCwd': '追随终端目录',
'sftp.followTerminalCwd.enable': '开启追随终端目录',
'sftp.followTerminalCwd.disable': '关闭追随终端目录',
+2
View File
@@ -980,6 +980,8 @@ export const zhCNVaultMessages: Messages = {
'pf.deleteActive.confirm': '关闭并删除',
'pf.form.autoStart': '自动启动',
'pf.form.autoStartDesc': '应用启动时自动开启此规则',
'pf.form.autoReconnect': '自动重连',
'pf.form.autoReconnectDesc': '连接断开后自动尝试重新连接',
// SFTP (pane + conflict)
'sftp.pane.local': '本地',
+1
View File
@@ -1048,6 +1048,7 @@ export const zhTWCoreMessages: Messages = {
'sftp.goUp': '上一級',
'sftp.goToTerminalCwd': '定位到終端目前目錄',
'sftp.locatePathInTerminal': '定位路徑到終端',
'sftp.refreshAfterCommand': '終端命令完成後重新整理',
'sftp.followTerminalCwd': '追隨終端目錄',
'sftp.followTerminalCwd.enable': '開啟追隨終端目錄',
'sftp.followTerminalCwd.disable': '關閉追隨終端目錄',
+2
View File
@@ -980,6 +980,8 @@ export const zhTWVaultMessages: Messages = {
'pf.deleteActive.confirm': '關閉並刪除',
'pf.form.autoStart': '自動啟動',
'pf.form.autoStartDesc': '應用程式啟動時自動開啟此規則',
'pf.form.autoReconnect': '自動重連',
'pf.form.autoReconnectDesc': '連線中斷後自動嘗試重新連線',
// SFTP (pane + conflict)
'sftp.pane.local': '本機',
@@ -0,0 +1,60 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { publishTerminalCommandCompletion } from '../terminalCommandCompletion';
import { useSftpCommandRefresh } from './useSftpCommandRefresh';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('completion refresh is coalesced, visible, idle and bound to its original pane', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let count = 0;
let options = {
sessionId: 'terminal-1', enabled: true, visible: true, busy: false,
matchesTerminal: true, paneId: 'pane-1', connectionId: 'connection-1',
path: '/browsed', connected: true, refresh: async () => { count += 1; },
};
function Probe() { useSftpCommandRefresh(options); return null; }
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
const update = async (patch: Partial<typeof options>) => {
options = { ...options, ...patch };
await act(async () => { renderer.update(React.createElement(Probe)); });
};
const finish = async () => { await act(async () => { t.mock.timers.tick(250); }); };
publishTerminalCommandCompletion('another-terminal');
await finish();
assert.equal(count, 0);
publishTerminalCommandCompletion('terminal-1');
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
for (const patch of [{ enabled: false }, { visible: false }, { connected: false }, { matchesTerminal: false }]) {
await update(patch);
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ enabled: true, visible: true, busy: false, connected: true, matchesTerminal: true });
}
await update({ busy: true });
publishTerminalCommandCompletion('terminal-1');
await finish();
assert.equal(count, 1);
await update({ busy: false });
await finish();
assert.equal(count, 2);
for (const patch of [{ path: '/new' }, { paneId: 'pane-2' }, { connectionId: 'connection-2' }, { sessionId: 'terminal-2' }]) {
publishTerminalCommandCompletion(options.sessionId);
await update(patch);
await finish();
assert.equal(count, 2);
}
publishTerminalCommandCompletion(options.sessionId);
await act(async () => { renderer.unmount(); });
await finish();
assert.equal(count, 2);
});
@@ -0,0 +1,56 @@
import { useEffect, useRef } from 'react';
import { subscribeTerminalCommandCompletion } from '../terminalCommandCompletion';
interface Options {
sessionId: string | null;
enabled: boolean;
visible: boolean;
busy: boolean;
matchesTerminal: boolean;
paneId: string;
connectionId: string | null;
path: string | null;
connected: boolean;
refresh: () => Promise<void>;
}
/** Coalesce completion bursts; never poll or carry refreshes across pane changes. */
export function useSftpCommandRefresh(options: Options): void {
const latest = useRef(options);
latest.current = options;
const drain = useRef(() => {});
useEffect(() => {
if (!options.enabled || !options.visible || !options.sessionId) return;
let timer: ReturnType<typeof setTimeout> | undefined;
let pending = false;
let refreshing = false;
let disposed = false;
const eligible = () => {
const live = latest.current;
return !disposed && live.enabled && live.visible && live.connected && live.matchesTerminal
&& live.sessionId === options.sessionId && live.paneId === options.paneId
&& live.connectionId === options.connectionId && live.path === options.path;
};
const schedule = () => {
if (!pending || refreshing || latest.current.busy || !eligible()) return;
clearTimeout(timer);
timer = setTimeout(() => {
if (!eligible() || latest.current.busy) return;
pending = false;
refreshing = true;
void latest.current.refresh().catch(() => {}).finally(() => {
refreshing = false;
schedule();
});
}, 250);
};
drain.current = schedule;
const unsubscribe = subscribeTerminalCommandCompletion((sessionId) => {
if (sessionId !== options.sessionId || !eligible()) return;
pending = true;
schedule();
});
return () => { disposed = true; clearTimeout(timer); unsubscribe(); drain.current = () => {}; };
}, [options.enabled, options.visible, options.sessionId, options.paneId, options.connectionId, options.path]);
useEffect(() => { drain.current(); }, [options.busy]);
}
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useSftpPaneActions } from './useSftpPaneActions';
import { createEmptyPane, type SftpPane } from './types';
import type { SftpFileEntry } from '../../../types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('command refresh keeps current directory/filter/selection and drops deleted selections', async () => {
const file = (name: string): SftpFileEntry => ({ name, type: 'file', size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '1970-01-01' });
let result = [file('kept'), file('new')];
const selection = new Set(['kept']);
const pane: SftpPane = { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', status: 'connected', currentPath: '/browsed', homeDir: '/', isLocal: true }, files: [file('kept')], selectedFiles: selection, filter: 'ke' };
const left = { current: { tabs: [pane], activeTabId: pane.id } };
const calls: string[] = [];
let actions: ReturnType<typeof useSftpPaneActions>;
const update: Parameters<typeof useSftpPaneActions>[0]['updateTab'] = (_side, id, updater) => {
left.current.tabs = left.current.tabs.map((p) => p.id === id ? updater(p) : p);
};
function Probe() {
actions = useSftpPaneActions({
hosts: [], getActivePane: () => left.current.tabs[0], updateTab: update,
updateActiveTab: (side, updater) => update(side, pane.id, updater),
leftTabsRef: left, rightTabsRef: { current: { tabs: [], activeTabId: null } },
navSeqRef: { current: { left: 0, right: 0 } }, dirCacheRef: { current: new Map() },
sftpSessionsRef: { current: new Map() }, lastConnectedHostRef: { current: { left: null, right: null } },
connectionCacheKeyMapRef: { current: new Map() }, makeCacheKey: (id, path) => id + path,
clearCacheForConnection() {}, listLocalFiles: async (path) => { calls.push(path); return result; },
listRemoteFiles: async () => [], handleSessionError() {}, releaseConnection: async () => {},
isSessionError: () => false, clearSelectionsExcept() {}, dirCacheTtlMs: 1000,
});
return null;
}
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
await actions!.refresh('left', { tabId: pane.id, preserveSelection: true });
assert.deepEqual(calls, ['/browsed']);
assert.equal(left.current.tabs[0].selectedFiles, selection);
assert.equal(left.current.tabs[0].filter, 'ke');
assert.deepEqual(left.current.tabs[0].files.map((f) => f.name), ['kept', 'new']);
result = [file('new')];
await actions!.refresh('left', { preserveSelection: true });
assert.equal(left.current.tabs[0].selectedFiles.size, 0);
assert.equal(left.current.tabs[0].connection?.currentPath, '/browsed');
await act(async () => { renderer.unmount(); });
});
+15 -6
View File
@@ -27,6 +27,13 @@ import {
/** Shared empty set for navigation resets — never mutate this. */
const EMPTY_SET = new Set<string>();
/** Retain selection identity when no selected entry disappeared. */
export function retainSftpSelection(selected: Set<string>, files: SftpFileEntry[]): Set<string> {
const names = new Set(files.map((file) => file.name));
const retained = new Set([...selected].filter((name) => names.has(name)));
return retained.size === selected.size ? selected : retained;
}
interface UseSftpPaneActionsParams {
hosts: Host[];
getActivePane: (side: "left" | "right") => SftpPane | null;
@@ -53,13 +60,14 @@ interface UseSftpPaneActionsParams {
export type SftpNavigateResult = "reached" | "failed" | "aborted" | "superseded";
export type SftpNavigateOptions = {
force?: boolean;
preserveSelection?: boolean;
tabId?: string;
shouldApply?: () => boolean;
};
interface UseSftpPaneActionsResult {
navigateTo: (side: "left" | "right", path: string, options?: SftpNavigateOptions) => Promise<SftpNavigateResult>;
refresh: (side: "left" | "right", options?: { tabId?: string }) => Promise<void>;
refresh: (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => Promise<void>;
navigateUp: (side: "left" | "right") => Promise<void>;
openEntry: (side: "left" | "right", entry: SftpFileEntry) => Promise<void>;
toggleSelection: (side: "left" | "right", fileName: string, multiSelect: boolean) => void;
@@ -179,6 +187,7 @@ export const useSftpPaneActions = ({
pane.connection.homeDir,
);
const preserveSelection = options?.preserveSelection && normalizedPath === pane.connection.currentPath;
const connectionId = pane.connection.id;
const requestId = ++navSeqRef.current[side];
const cacheKey = makeCacheKey(connectionId, normalizedPath, pane.filenameEncoding);
@@ -281,7 +290,7 @@ export const useSftpPaneActions = ({
connection: prev.connection
? { ...prev.connection, currentPath: normalizedPath }
: null,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? prev.selectedFiles : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
loading: true,
error: null,
@@ -369,7 +378,7 @@ export const useSftpPaneActions = ({
connectionId,
path: normalizedPath,
files,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(getTargetPane()?.selectedFiles ?? EMPTY_SET, files) : EMPTY_SET,
filter: nextConfirmedFilter,
});
@@ -380,7 +389,7 @@ export const useSftpPaneActions = ({
: null,
files,
loading: false,
selectedFiles: EMPTY_SET,
selectedFiles: preserveSelection ? retainSftpSelection(prev.selectedFiles, files) : EMPTY_SET,
filter: clearFilterForPathChange ? "" : prev.filter,
}));
if (!pane.connection.isLocal) {
@@ -445,7 +454,7 @@ export const useSftpPaneActions = ({
);
const refresh = useCallback(
async (side: "left" | "right", options?: { tabId?: string }) => {
async (side: "left" | "right", options?: { tabId?: string; preserveSelection?: boolean }) => {
const sideTabs = side === "left" ? leftTabsRef.current : rightTabsRef.current;
const pane = options?.tabId
? sideTabs.tabs.find((t) => t.id === options.tabId) ?? null
@@ -476,7 +485,7 @@ export const useSftpPaneActions = ({
}
return;
}
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId });
await navigateTo(side, pane.connection.currentPath, { force: true, tabId: options?.tabId, preserveSelection: options?.preserveSelection });
} else if (!pane?.connection && pane?.error) {
// For background tabs, don't trigger reconnection (it operates on
// the active tab). Just leave the error state for the user to see
@@ -0,0 +1,12 @@
type Listener = (sessionId: string) => void;
const listeners = new Set<Listener>();
/** Renderer-local fan-out of the terminal's existing completion detection. */
export function publishTerminalCommandCompletion(sessionId: string): void {
for (const listener of listeners) listener(sessionId);
}
export function subscribeTerminalCommandCompletion(listener: Listener): () => void {
listeners.add(listener);
return () => { listeners.delete(listener); };
}
@@ -0,0 +1,63 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { useCodexConfigModel } from './useCodexConfigModel';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const agent: ExternalAgentConfig = {
id: 'discovered_codex', name: 'Codex', command: 'codex', sdkBackend: 'codex', enabled: true,
env: { CODEX_HOME: '/fixture/custom' },
};
for (const codexRuntime of ['sdk', 'app-server'] as const) {
test(`${codexRuntime}: quick send waits for the picker probe and receives its custom model`, async () => {
let resolve!: (result: unknown) => void;
const calls: unknown[] = [];
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: {
aiCodexGetIntegration: (opts: unknown) => { calls.push(opts); return new Promise(done => { resolve = done; }); },
} } });
let result!: ReturnType<typeof useCodexConfigModel>;
function Harness({ config }: { config: ExternalAgentConfig }) {
result = useCodexConfigModel(config, true);
return null;
}
let root!: TestRenderer.ReactTestRenderer;
try {
const config = { ...agent, codexRuntime };
await act(async () => { root = TestRenderer.create(React.createElement(Harness, { config })); });
assert.equal(result.model, null);
let sent = false;
const sending = result.loadModel().then(model => { sent = true; return model; });
await Promise.resolve();
assert.equal(sent, false);
assert.equal(calls.length, 1);
assert.deepEqual((calls[0] as { agentEnv: unknown }).agentEnv, agent.env);
await act(async () => { resolve({ customConfig: { model: 'glm-5' } }); await sending; });
assert.equal(await sending, 'glm-5');
assert.equal(result.model, 'glm-5');
// A different agent must not inherit the previous home/model or promise.
await act(async () => { root.update(React.createElement(Harness, { config: { ...config, env: { CODEX_HOME: '/fixture/other' } } })); });
assert.equal(result.model, null);
assert.equal(calls.length, 2);
await act(async () => { resolve({ customConfig: null }); await result.loadModel(); });
assert.equal(result.model, null);
} finally {
await act(async () => root?.unmount());
if (originalWindow) Object.defineProperty(globalThis, 'window', originalWindow);
else Reflect.deleteProperty(globalThis, 'window');
}
});
}
test('non-Codex agents keep their model flow without probing Codex', async () => {
let result!: ReturnType<typeof useCodexConfigModel>;
function Harness() { result = useCodexConfigModel(undefined, true); return null; }
let root!: TestRenderer.ReactTestRenderer;
try {
await act(async () => { root = TestRenderer.create(React.createElement(Harness)); });
assert.equal(await result.loadModel(), null);
} finally { await act(async () => root.unmount()); }
});
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useMemo, useState } from 'react';
import { getNetcattyBridge } from '../../infrastructure/ai/aiChatStreamingSupport';
import { getManualAgentCommand, matchesManagedAgentConfig } from '../../infrastructure/ai/managedAgents';
import type { ExternalAgentConfig } from '../../infrastructure/ai/types';
/** Share the config probe between the picker and sends, scoped to this agent. */
export function useCodexConfigModel(agent: ExternalAgentConfig | undefined, isVisible: boolean) {
const loadModel = useMemo(() => {
let pending: Promise<string | null> | undefined;
return () => {
pending ??= Promise.resolve().then(async () => {
if (!isVisible || !agent || !matchesManagedAgentConfig(agent, 'codex')) return null;
const info = await getNetcattyBridge()?.aiCodexGetIntegration?.({
codexPath: getManualAgentCommand(agent),
agentEnv: agent.env,
});
return info?.customConfig?.model || null;
}).catch(() => null);
return pending;
};
// Reopening the panel refreshes config changed outside Netcatty.
}, [agent, isVisible]);
const [result, setResult] = useState<{ loadModel: typeof loadModel; model: string | null }>();
useEffect(() => {
if (!isVisible) return;
let cancelled = false;
void loadModel().then(model => {
if (!cancelled) setResult({ loadModel, model });
});
return () => { cancelled = true; };
}, [isVisible, loadModel]);
return { model: result?.loadModel === loadModel ? result.model : null, loadModel };
}
@@ -7,6 +7,7 @@ import {
getAutoStartRuleBlockReason,
isAutoStartProxyReady,
isPortForwardingAutoStartEnabled,
isPortForwardingReconnectRequested,
} from "./usePortForwardingAutoStart.ts";
import type { GroupConfig, Host, PortForwardingRule, ProxyProfile } from "../../domain/models.ts";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys.ts";
@@ -177,6 +178,15 @@ test("reconnect eligibility follows the current auto-start setting", () => {
assert.equal(isPortForwardingAutoStartEnabled([], "rule-1"), false);
});
test("reconnect is requested for auto-start and auto-reconnect rules only", () => {
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true })], "rule-1"), true);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: true, autoReconnect: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: true })], "rule-1"), true);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false, autoReconnect: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([rule({ autoStart: false })], "rule-1"), false);
assert.equal(isPortForwardingReconnectRequested([], "rule-1"), false);
});
test("network recovery starts an exhausted auto-start rule through the real service path", async (t) => {
const env = installStorage();
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
@@ -8,6 +8,7 @@ import { GroupConfig, Host, Identity, KnownHost, PortForwardingRule, ProxyProfil
import { resolveGroupDefaults, applyGroupDefaults } from "../../domain/groupConfig";
import { materializeHostProxyProfile } from "../../domain/proxyProfiles";
import { STORAGE_KEY_PORT_FORWARDING } from "../../infrastructure/config/storageKeys";
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect";
import { localStorageAdapter } from "../../infrastructure/persistence/localStorageAdapter";
import {
getActiveConnection,
@@ -103,6 +104,11 @@ export const isPortForwardingAutoStartEnabled = (
ruleId: string,
): boolean => rules.some((rule) => rule.id === ruleId && rule.autoStart === true);
export const isPortForwardingReconnectRequested = (
rules: PortForwardingRule[],
ruleId: string,
): boolean => rules.some((rule) => rule.id === ruleId && isPortForwardingAutoReconnectEnabled(rule));
export const shouldStartPortForwardingAutoStartRule = (
rule: PortForwardingRule,
connection?: { status: PortForwardingRule["status"] },
@@ -110,6 +116,18 @@ export const shouldStartPortForwardingAutoStartRule = (
!connection || connection.status === "inactive" || connection.status === "error"
);
/**
* Network-recovery counterpart of `shouldStartPortForwardingAutoStartRule`:
* unlike launch-time auto-start, rules that only opt in via `autoReconnect`
* are also restarted after network restoration.
*/
export const shouldRestartPortForwardingReconnectRule = (
rule: PortForwardingRule,
connection?: { status: PortForwardingRule["status"] },
): boolean => isPortForwardingAutoReconnectEnabled(rule) && (
!connection || connection.status === "inactive" || connection.status === "error"
);
export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
restartAutoStartRules: (recoverableRuleIds: ReadonlySet<string>) => Promise<void>,
): Promise<void> => {
@@ -118,7 +136,7 @@ export const recoverPortForwardingAutoStartAfterNetworkRestore = async (
) ?? [];
const recoverableRuleIds = new Set<string>();
for (const rule of rules) {
if (rule.autoStart && resetReconnectAttempts(rule.id)) {
if (isPortForwardingAutoReconnectEnabled(rule) && resetReconnectAttempts(rule.id)) {
recoverableRuleIds.add(rule.id);
}
}
@@ -184,7 +202,7 @@ export const runPortForwardingAutoStart = async ({
recoveryRuleIds,
}: RunPortForwardingAutoStartOptions): Promise<void> => {
await syncWithBackend({
shouldReconnect: (ruleId) => isPortForwardingAutoStartEnabled(
shouldReconnect: (ruleId) => isPortForwardingReconnectRequested(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
),
@@ -197,9 +215,11 @@ export const runPortForwardingAutoStart = async ({
STORAGE_KEY_PORT_FORWARDING,
) ?? [];
const autoStartRules = rules.filter((rule) =>
(!recoveryRuleIds || recoveryRuleIds.has(rule.id)) &&
(!recoveryRuleIds || isReconnectRecoveryEligible(rule.id)) &&
shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
recoveryRuleIds
? recoveryRuleIds.has(rule.id) &&
isReconnectRecoveryEligible(rule.id) &&
shouldRestartPortForwardingReconnectRule(rule, getActiveConnection(rule.id))
: shouldStartPortForwardingAutoStartRule(rule, getActiveConnection(rule.id)),
);
if (autoStartRules.length === 0) return;
@@ -385,7 +405,7 @@ export const usePortForwardingAutoStart = ({
return startPortForward(rule, host, resolveEffectiveHosts(hostsRef.current), keysRef.current, identitiesRef.current, onStatusChange, true, terminalSettingsRef.current, knownHostsRef.current);
};
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingAutoStartEnabled(
setReconnectCallback(handleReconnect, (ruleId) => isPortForwardingReconnectRequested(
localStorageAdapter.read<PortForwardingRule[]>(STORAGE_KEY_PORT_FORWARDING) ?? [],
ruleId,
));
+6
View File
@@ -32,6 +32,11 @@ export const useTrayPanelBackend = () => {
await bridge?.connectToHostFromTrayPanel?.(hostId);
}, []);
const startPortForwardFromTrayPanel = useCallback(async (ruleId: string) => {
const bridge = netcattyBridge.get();
return bridge?.startPortForwardFromTrayPanel?.(ruleId);
}, []);
const onTrayPanelCloseRequest = useCallback((callback: () => void) => {
const bridge = netcattyBridge.get();
return bridge?.onTrayPanelCloseRequest?.(callback);
@@ -71,6 +76,7 @@ export const useTrayPanelBackend = () => {
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
startPortForwardFromTrayPanel,
connectToHostFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
+13 -42
View File
@@ -48,7 +48,7 @@ import {
isInlineTextAttachment,
} from '../application/state/terminalSelectionAttachment';
import { createVaultNoteAttachment, isVaultNoteAttachment, vaultNoteReferencesFit } from '../application/state/vaultNoteAttachment';
import type { CodexIntegrationStatus } from './settings/tabs/ai/types';
import { useCodexConfigModel } from '../application/state/useCodexConfigModel';
import {
useAIChatStreaming,
getNetcattyBridge,
@@ -843,35 +843,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
[currentAgentConfig],
);
const [codexConfigModel, setCodexConfigModel] = useState<string | null>(null);
const [codexCustomConfigResolved, setCodexCustomConfigResolved] = useState(false);
useEffect(() => {
if (!isVisible) return;
setCodexCustomConfigResolved(false);
if (!isCodexManagedAgent) {
setCodexConfigModel(null);
return;
}
const bridge = getNetcattyBridge();
if (!bridge?.aiCodexGetIntegration) return;
let cancelled = false;
void Promise.resolve(
bridge.aiCodexGetIntegration({ codexPath: getManualAgentCommand(currentAgentConfig) }) as Promise<CodexIntegrationStatus>,
).then((info) => {
if (cancelled) return;
const hasCustom = info?.state === 'connected_custom_config';
setCodexConfigModel(info?.customConfig?.model ?? null);
setCodexCustomConfigResolved(hasCustom);
}).catch(() => {
if (!cancelled) {
setCodexConfigModel(null);
setCodexCustomConfigResolved(false);
}
});
return () => {
cancelled = true;
};
}, [isVisible, isCodexManagedAgent, currentAgentId, currentAgentConfig]);
const { model: codexConfigModel, loadModel: loadCodexConfigModel } = useCodexConfigModel(
currentAgentConfig, isVisible,
);
const agentModelMapRef = useRef(agentModelMap);
agentModelMapRef.current = agentModelMap;
@@ -1035,18 +1009,12 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const isCodexAppServer = isCodexManagedAgent && currentAgentConfig?.codexRuntime === 'app-server';
const canSteerCurrentTurn = Boolean(activeSessionId && isStreaming && isCodexAppServer);
const hasCodexCustomConfig = codexCustomConfigResolved && isCodexManagedAgent && !isCodexAppServer;
const hasCodexCustomConfig = Boolean(codexConfigModel) && isCodexManagedAgent;
const agentModelPresets = useMemo(() => {
const runtimePresets = runtimeAgentModelPresets[currentAgentId];
if (hasCodexCustomConfig) {
if (runtimePresets) {
return runtimePresets;
}
if (codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
return [];
if (hasCodexCustomConfig && codexConfigModel) {
return [{ id: codexConfigModel, name: codexConfigModel }];
}
if (runtimePresets) return runtimePresets;
const presets = getAgentModelPresets(
@@ -1236,8 +1204,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
if (sendBridge?.aiSyncWebSearch) {
await sendBridge.aiSyncWebSearch(webSearchConfig?.apiHost || null, webSearchConfig?.apiKey || null);
}
let sendSelectedAgentModel = selectedAgentModel;
if (currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
// A quick send must await the same config probe as the picker. Its model
// also takes precedence over App Server's built-in model catalog.
const configModel = await loadCodexConfigModel();
let sendSelectedAgentModel = configModel ?? selectedAgentModel;
if (!configModel && currentAgentConfig && shouldLoadSdkRuntimeModels(currentAgentConfig)) {
const runtimeTarget = buildExternalAgentRuntimeModelTarget(currentAgentConfig);
if (runtimeTarget) {
const catalog = await loadSdkRuntimeModelCatalog(runtimeTarget);
@@ -1421,7 +1392,7 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
}
}
}, [
validateNoteMentions,
validateNoteMentions, loadCodexConfigModel,
isStreaming, activeProvider, effectiveActiveProvider, effectiveActiveModelId, selectedCattyThinking, scopeKey, currentAgentId,
activeModelId, externalAgents,
createSession, addMessageToSession, updateMessageById, updateLastMessage,
+6 -1
View File
@@ -30,6 +30,7 @@ import {
isPortForwardingRuleStartable,
isPortForwardingRuleStoppable,
} from "../domain/portForwardingBulkActions";
import { isPortForwardingAutoReconnectEnabled } from "../domain/portForwardingReconnect";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { cn } from "../lib/utils";
import SelectHostPanel from "./SelectHostPanel";
@@ -207,7 +208,7 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
);
}
},
rule.autoStart, // Enable reconnect for auto-start rules
isPortForwardingAutoReconnectEnabled(rule), // Enable reconnect for auto-reconnect rules (auto-start implies reconnect)
terminalSettings,
knownHosts,
);
@@ -516,6 +517,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
remoteHost: newFormDraft.remoteHost,
remotePort: newFormDraft.remotePort,
hostId: newFormDraft.hostId,
autoStart: newFormDraft.autoStart ?? false,
autoReconnect: isPortForwardingAutoReconnectEnabled(newFormDraft),
});
setShowNewForm(false);
@@ -714,6 +717,8 @@ const PortForwarding: React.FC<PortForwardingProps> = ({
remoteHost: draftRule.remoteHost,
remotePort: draftRule.remotePort,
hostId: draftRule.hostId,
autoStart: draftRule.autoStart ?? false,
autoReconnect: isPortForwardingAutoReconnectEnabled(draftRule),
});
}
+21
View File
@@ -1,3 +1,4 @@
import { useSftpCommandRefresh } from "../application/state/sftp/useSftpCommandRefresh";
/**
* SftpSidePanel - SFTP file browser rendered as a resizable side panel
*
@@ -1619,6 +1620,22 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
|| (sftp.activeExternalEditCount ?? 0) > 0;
const connectionId = sftp.leftPane.connection?.id ?? null;
const connectionPath = sftp.leftPane.connection?.currentPath ?? null;
const [autoRefresh, setAutoRefresh] = useState(true);
useSftpCommandRefresh({
sessionId: focusedSessionId ?? activeSessionId,
enabled: autoRefresh,
visible: isVisible && ownerPanelOpen,
busy: hasActiveWork || sftp.leftPane.loading,
matchesTerminal: !!activeHost && !!connectedHostObjRef.current
&& sftpHostEndpointsEqual(activeHost, connectedHostObjRef.current)
&& sftp.leftPane.connection?.hostId === activeHost.id,
paneId: sftp.leftPane.id,
connectionId,
path: connectionPath,
connected: sftp.leftPane.connection?.status === "connected",
refresh: () => sftpRef.current.refresh("left", { tabId: sftp.leftPane.id, preserveSelection: true }),
});
const {
handleGoToTerminalCwd,
@@ -1860,6 +1877,10 @@ const SftpSidePanelInteractiveBody: React.FC<SftpSidePanelInteractiveBodyProps>
data-section="terminal-sftp-panel"
onClick={handlePaneFocus}
>
<label className="flex shrink-0 items-center gap-2 border-b border-border/50 px-3 py-1 text-xs text-muted-foreground">
<input type="checkbox" checked={autoRefresh} onChange={(event) => setAutoRefresh(event.target.checked)} />
{t("sftp.refreshAfterCommand")}
</label>
{showWorkspaceHostHeader && displayHost && (
<div
className={`${TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS} border-b border-border/50 bg-muted/20 px-3 flex items-center`}
+3 -1
View File
@@ -1,3 +1,4 @@
import { publishTerminalCommandCompletion } from "../application/state/terminalCommandCompletion";
import { createTerminalReflowReadingPosition } from "./terminal/terminalReflowReadingPosition";
import { resolveHostOs } from '../domain/host';
import { Terminal as XTerm } from "@xterm/xterm";
@@ -2367,9 +2368,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
onCommandSubmitted?.(...args);
}, [onCommandSubmitted, onTerminalCwdChange, sessionId, terminalCwdTracker]);
const pluginAwareOnCommandCompleted = useCallback(() => {
publishTerminalCommandCompletion(sessionId);
pluginTerminalLifecycle.onCommandCompleted();
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
}, [pluginTerminalLifecycle]);
}, [pluginTerminalLifecycle, sessionId]);
const pluginAwareOnTerminalCwdChange = useCallback((
changedSessionId: string,
cwd: string | null,
+8 -25
View File
@@ -10,10 +10,8 @@ import { useI18n } from "../application/i18n/I18nProvider";
import { I18nProvider } from "../application/i18n/I18nProvider";
import { useTrayPanelBackend } from "../application/state/useTrayPanelBackend";
import { useActiveTabId } from "../application/state/activeTabStore";
import { resolveGroupDefaults, applyGroupDefaults } from "../domain/groupConfig";
import { materializeHostProxyProfile } from "../domain/proxyProfiles";
import { upsertKnownHost } from "../domain/knownHosts";
import type { Host, KnownHost } from "../domain/models";
import type { KnownHost } from "../domain/models";
import { getEffectiveKnownHosts } from "../infrastructure/syncHelpers";
import { PortForwardHostKeyTrayPrompt } from "./port-forwarding";
import { X, Maximize2, ChevronRight, ChevronDown, Power } from "lucide-react";
@@ -136,11 +134,7 @@ const WorkspaceGroup: React.FC<{
);
};
interface TrayPanelContentProps {
terminalSettings?: { verifyHostKeys: boolean; keepaliveInterval: number; keepaliveCountMax: number };
}
const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings }) => {
const TrayPanelContent: React.FC = () => {
const { t } = useI18n();
const {
hideTrayPanel,
@@ -148,27 +142,23 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
quitApp,
jumpToSession,
closeSessionFromTrayPanel,
startPortForwardFromTrayPanel,
onTrayPanelCloseRequest,
onTrayPanelRefresh,
onTrayPanelMenuData,
} = useTrayPanelBackend();
const { hosts, keys, identities, proxyProfiles, groupConfigs, knownHosts, updateKnownHosts } = useVaultState();
const { hosts, knownHosts, updateKnownHosts } = useVaultState();
// TrayPanel runs in its own BrowserWindow, so this hook's session state is
// independent from (and typically empty compared to) the main App's — it's
// used here only for its storage-sync side effects, never for closeSession.
useSessionState({ persistSessionRestore: false });
const {
rules: portForwardingRules,
startTunnel,
stopTunnel,
hasRuntimeTunnel,
} = usePortForwardingState();
const activeTabId = useActiveTabId();
const proxyProfileIdSet = useMemo(
() => new Set(proxyProfiles.map((profile) => profile.id)),
[proxyProfiles],
);
const effectiveKnownHosts = useMemo(
() => getEffectiveKnownHosts(knownHosts) ?? [],
[knownHosts],
@@ -444,16 +434,9 @@ const TrayPanelContent: React.FC<TrayPanelContentProps> = ({ terminalSettings })
if (!result.success && result.error) toast.error(result.error);
});
} else {
const resolveEffectiveHost = (host: Host) => {
const withGroupDefaults = host.group
? applyGroupDefaults(host, resolveGroupDefaults(host.group, groupConfigs, { validProxyProfileIds: proxyProfileIdSet }), { validProxyProfileIds: proxyProfileIdSet })
: applyGroupDefaults(host, {}, { validProxyProfileIds: proxyProfileIdSet });
return materializeHostProxyProfile(withGroupDefaults, proxyProfiles);
};
const host = resolveEffectiveHost(rawHost);
void startTunnel(rule, host, hosts.map(resolveEffectiveHost), keys, identities, (status, error) => {
if (status === "error" && error) toast.error(error);
}, rule.autoStart, terminalSettings, effectiveKnownHosts);
void startPortForwardFromTrayPanel(rule.id).then((result) => {
if (result && !result.success && result.error) toast.error(result.error);
});
}
}}
className={cn(
@@ -519,7 +502,7 @@ type SettingsState = AppLockGateRenderContext["settings"];
const TrayPanel: React.FC<{ settings: SettingsState }> = ({ settings }) => {
return (
<I18nProvider locale={settings.uiLanguage}>
<TrayPanelContent terminalSettings={settings.terminalSettings} />
<TrayPanelContent />
</I18nProvider>
);
};
+13
View File
@@ -4,6 +4,7 @@
*/
import { ChevronDown,Copy,Trash2 } from 'lucide-react';
import React from 'react';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule } from '../../domain/models';
import { DistroAvatar } from '../DistroAvatar';
@@ -182,6 +183,18 @@ export const EditPanel: React.FC<EditPanelProps> = ({
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
{/* Auto Reconnect Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
</div>
<Switch
checked={isPortForwardingAutoReconnectEnabled(draft)}
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
@@ -4,6 +4,7 @@
*/
import { ChevronDown,Zap } from 'lucide-react';
import React from 'react';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { useI18n } from '../../application/i18n/I18nProvider';
import { Host,PortForwardingRule,PortForwardingType } from '../../domain/models';
import { cn } from '../../lib/utils';
@@ -182,6 +183,18 @@ export const NewFormPanel: React.FC<NewFormPanelProps> = ({
onCheckedChange={checked => onDraftChange({ autoStart: checked })}
/>
</div>
{/* Auto Reconnect Toggle */}
<div className="flex items-center justify-between py-2">
<div className="space-y-0.5">
<Label className="text-sm font-medium">{t('pf.form.autoReconnect')}</Label>
<p className="text-[10px] text-muted-foreground">{t('pf.form.autoReconnectDesc')}</p>
</div>
<Switch
checked={isPortForwardingAutoReconnectEnabled(draft)}
onCheckedChange={checked => onDraftChange({ autoReconnect: checked })}
/>
</div>
</AsidePanelContent>
<AsidePanelFooter className="space-y-2">
<Button
+11 -10
View File
@@ -645,6 +645,11 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
}, 900);
}, []);
const codexManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
[externalAgents],
);
const refreshCodexIntegration = useCallback(async (opts?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => {
const bridge = getBridge();
if (!bridge?.aiCodexGetIntegration) return;
@@ -655,7 +660,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(true);
setCodexError(null);
try {
const integration = await bridge.aiCodexGetIntegration(opts);
const integration = await bridge.aiCodexGetIntegration({ ...opts, agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
setCodexIntegration(integration);
} catch (err) {
@@ -667,7 +672,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, []);
}, [codexManagedAgent?.env]);
const codexCommittedPath = useMemo(
() => getManagedAgentCommandPath(externalAgents, "codex") || codexPathInfo?.path || undefined,
@@ -682,10 +687,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
codexCommittedPath
), [codexCommittedPath]);
const codexManagedAgent = useMemo(
() => externalAgents.find((agent) => agent.id === "discovered_codex"),
[externalAgents],
);
const codexRuntime = codexManagedAgent?.codexRuntime ?? 'sdk';
const refreshCodexAppServerStatus = useCallback(async () => {
@@ -861,7 +862,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride() });
const result = await bridge.aiCodexStartLogin({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
if (!result.ok || !result.session) {
throw new Error(result.error || "Failed to start Codex login");
@@ -876,7 +877,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, [getCodexPathOverride]);
}, [getCodexPathOverride, codexManagedAgent?.env]);
const handleCancelCodexLogin = useCallback(async () => {
const bridge = getBridge();
@@ -912,7 +913,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setCodexError(null);
setIsCodexLoading(true);
try {
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride() });
const result = await bridge.aiCodexLogout({ codexPath: getCodexPathOverride(), agentEnv: codexManagedAgent?.env });
if (!isCurrentRequest()) return;
if (!result.ok) {
throw new Error(result.error || "Failed to log out from Codex");
@@ -928,7 +929,7 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
setIsCodexLoading(false);
}
}
}, [getCodexPathOverride, refreshCodexIntegration]);
}, [getCodexPathOverride, refreshCodexIntegration, codexManagedAgent?.env]);
const refreshUserSkillsStatus = useCallback(async () => {
const bridge = getBridge();
+3 -3
View File
@@ -152,11 +152,11 @@ export interface FetchBridge {
export interface NetcattyAiBridge {
aiDiscoverAgents?: (options?: { refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<Array<AgentPathInfo & { command: string }>>;
aiPrewarmShellEnv?: () => Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetIntegration?: (options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }) => Promise<CodexIntegrationStatus>;
aiCodexStartLogin?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexGetLoginSession?: (sessionId: string) => Promise<{ ok: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexCancelLogin?: (sessionId: string) => Promise<{ ok: boolean; found?: boolean; session?: CodexLoginSession; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiCodexLogout?: (options?: { codexPath?: string; agentEnv?: Record<string, string> }) => Promise<{ ok: boolean; state?: CodexIntegrationState; isConnected?: boolean; rawOutput?: string; logoutOutput?: string; error?: string }>;
aiResolveCli?: (params: { command: string; customPath?: string; refreshShellEnv?: boolean; apiKeyPresent?: boolean }) => Promise<AgentPathInfo>;
aiSdkAgentListModels?: (sdkBackend: string, cwd?: string, providerId?: string, chatSessionId?: string, agentEnv?: Record<string, string>, agentCommand?: string, codexRuntime?: 'sdk' | 'app-server') => Promise<{ ok: boolean; models?: Array<{ id: string; name: string; description?: string; thinkingLevels?: string[]; defaultThinkingLevel?: string }>; currentModelId?: string | null; error?: string }>;
codexAppServerGetStatus?: (agentCommand?: string, agentEnv?: Record<string, string>) => Promise<{ ok: boolean; available: boolean; error?: string }>;
@@ -0,0 +1,52 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { SftpPaneFileList } from './SftpPaneFileList';
import { TooltipProvider } from '../ui/tooltip';
import { installDomEnvironment } from '../test-support/renderReactDom';
import { createEmptyPane } from '../../application/state/sftp/types';
const environment = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = environment.IS_REACT_ACT_ENVIRONMENT;
environment.IS_REACT_ACT_ENVIRONMENT = true;
after(() => { environment.IS_REACT_ACT_ENVIRONMENT = previous; });
test('refresh keeps scroll, while explicit sorting and filters reveal the selection', async (t) => {
const dom = installDomEnvironment();
t.after(() => dom.cleanup());
let scrolls = 0;
const container = { querySelectorAll: () => [{ dataset: { entryName: 'selected' }, scrollIntoView: () => { scrolls += 1; } }] };
const noop = () => {};
const selected = { name: 'selected', type: 'file' as const, size: 1, sizeFormatted: '1 B', lastModified: 0, lastModifiedFormatted: '' };
let props: React.ComponentProps<typeof SftpPaneFileList> = {
t: (key) => key, pane: { ...createEmptyPane('pane'), connection: { id: 'conn', hostId: 'host', hostLabel: 'Host', isLocal: false, status: 'connected', currentPath: '/browsed' }, selectedFiles: new Set(['selected']), files: [selected] },
side: 'left', isPaneFocused: true,
sorting: { sortField: 'name', sortOrder: 'asc', directoriesFirst: true,
columnWidths: { name: 50, modified: 24, size: 7, type: 9, owner: 10 },
visibleColumns: { name: true, modified: true, size: true, type: true, owner: true },
handleSort: noop, handleResizeStart: noop, toggleColumnVisibility: noop, toggleDirectoriesFirst: noop },
fileListRef: { current: container as unknown as HTMLDivElement },
handleFileListScroll: noop, shouldVirtualize: false, totalHeight: 0, sortedDisplayFiles: [selected],
isDragOverPane: false, draggedFiles: null, onRefresh: noop, onNavigateTo: noop, onClearSelection: noop,
setShowNewFolderDialog: noop, setShowNewFileDialog: noop, getNextUntitledName: () => '', setNewFileName: noop, setFileNameError: noop,
dragOverEntry: null, handleRowSelect: noop, handleRowOpen: noop, handleFileDragStart: noop,
onDragEnd: noop, handleEntryDragOver: noop, handleRowDragLeave: noop, handleEntryDrop: noop,
onCopyToOtherPane: noop, onMoveEntriesToPath: async () => {}, openRenameDialog: noop, openDeleteConfirm: noop,
rowHeight: 28, visibleRows: [],
};
const render = () => React.createElement(TooltipProvider, null, React.createElement(SftpPaneFileList, props));
let renderer: ReactTestRenderer;
await act(async () => { renderer = create(render(), { createNodeMock: (element) => element.props['data-section'] === 'terminal-sftp-list' ? container : null }); });
assert.equal(scrolls, 1);
props = { ...props, pane: { ...props.pane, files: [...props.pane.files] }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 1, 'refresh must not pull the viewport back to the selection');
props = { ...props, sorting: { ...props.sorting, sortOrder: 'desc' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 2, 'sorting retains the prior reveal-selection behavior');
props = { ...props, pane: { ...props.pane, filter: 'selected' }, sortedDisplayFiles: [...props.sortedDisplayFiles] };
await act(async () => { renderer.update(render()); });
assert.equal(scrolls, 3, 'filter changes retain the prior reveal-selection behavior');
await act(async () => { renderer.unmount(); });
});
+17 -1
View File
@@ -196,7 +196,23 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
return () => sftpListOrderStore.clearPane(pane.id);
}, [sortedDisplayFiles, pane.id]);
const selectionScrollContext = useMemo(() => ({
paneId: pane.id, filter: pane.filter, showHiddenFiles: pane.showHiddenFiles,
sortField, sortOrder, directoriesFirst, rowHeight, shouldVirtualize,
}), [
pane.id, pane.filter, pane.showHiddenFiles, sortField, sortOrder,
directoriesFirst, rowHeight, shouldVirtualize,
]);
const lastScrolledSelectionRef = useRef<{
selection: Set<string>;
context: object;
} | null>(null);
useEffect(() => {
// Refreshes preserve selection identity; explicit sorting/filter/layout
// changes still reveal the selected row using the usual behavior.
const last = lastScrolledSelectionRef.current;
if (last?.selection === pane.selectedFiles && last.context === selectionScrollContext) return;
lastScrolledSelectionRef.current = { selection: pane.selectedFiles, context: selectionScrollContext };
if (pane.selectedFiles.size !== 1) return;
const selectedName = Array.from(pane.selectedFiles)[0];
if (!selectedName) return;
@@ -220,7 +236,7 @@ export const SftpPaneFileList: React.FC<SftpPaneFileListProps> = React.memo(({
currentScrollTop: container.scrollTop,
viewportHeight: container.clientHeight,
});
}, [fileListRef, pane.selectedFiles, rowHeight, shouldVirtualize, sortedDisplayFiles]);
}, [fileListRef, pane.selectedFiles, rowHeight, selectionScrollContext, shouldVirtualize, sortedDisplayFiles]);
// Use refs for frequently-changing values in context-menu actions
const selectedFilesRef = useRef(pane.selectedFiles);
@@ -1,3 +1,4 @@
import { publishTerminalCommandCompletion } from "../../application/state/terminalCommandCompletion";
/* eslint-disable @typescript-eslint/no-explicit-any, react-hooks/exhaustive-deps */
import { useRef } from 'react';
import { publishPluginTerminalRuntimeLifecycleEvent } from '../../application/state/pluginTerminalRuntimeLifecycle';
@@ -189,6 +190,7 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
void xtermRuntimeRef.current?.pluginProviderHost?.commandSubmitted(args[0]);
};
const pluginAwareOnCommandCompleted = () => {
publishTerminalCommandCompletion(sessionId);
publishPluginTerminalRuntimeLifecycleEvent(pluginTerminalLifecycle, 'commandCompleted');
void xtermRuntimeRef.current?.pluginProviderHost?.commandCompleted();
};
+3
View File
@@ -27,6 +27,9 @@ export interface PortForwardingRule {
hostId?: string;
// Auto-start: if true, this rule will automatically start when the app launches
autoStart?: boolean;
// Auto-reconnect: if true, the tunnel automatically reconnects (with bounded
// retries) after an unexpected disconnect, regardless of auto-start
autoReconnect?: boolean;
/**
* Runtime projection for the UI. Authoritative phase lives in the Electron
* main-process registry; this field is rebuilt from snapshots / events and
+16 -10
View File
@@ -23,6 +23,15 @@ const parsePort = (value: unknown, name: string): Result<number> => {
: { ok: false, error: `${name} must be an integer between 1 and 65535.` };
};
const parseToggle = (value: unknown, name: string): Result<boolean | undefined> => {
if (value === undefined) return { ok: true, value: undefined };
if (typeof value === 'boolean') return { ok: true, value };
const normalized = String(value).trim().toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) return { ok: true, value: true };
if (['false', '0', 'no'].includes(normalized)) return { ok: true, value: false };
return { ok: false, error: `${name} must be true or false.` };
};
export const validatePortForwardingHost = (
hosts: Host[],
hostId: string | undefined,
@@ -61,16 +70,12 @@ function buildRule(
remotePort = parsedRemotePort.value;
}
if (!existing && !newRule) return { ok: false, error: 'New rule id and timestamp are required.' };
let autoStart = existing?.autoStart ?? false;
if (source.autoStart !== undefined) {
if (typeof source.autoStart === 'boolean') autoStart = source.autoStart;
else {
const normalized = String(source.autoStart).trim().toLowerCase();
if (['true', '1', 'yes'].includes(normalized)) autoStart = true;
else if (['false', '0', 'no'].includes(normalized)) autoStart = false;
else return { ok: false, error: 'autoStart must be true or false.' };
}
}
const autoStartToggle = parseToggle(source.autoStart, 'autoStart');
if ('error' in autoStartToggle) return autoStartToggle;
const autoStart = autoStartToggle.value ?? existing?.autoStart ?? false;
const autoReconnectToggle = parseToggle(source.autoReconnect, 'autoReconnect');
if ('error' in autoReconnectToggle) return autoReconnectToggle;
const autoReconnect = autoReconnectToggle.value ?? existing?.autoReconnect;
return {
ok: true,
value: {
@@ -82,6 +87,7 @@ function buildRule(
...(type === 'dynamic' ? {} : { remoteHost, remotePort }),
hostId,
autoStart,
...(autoReconnect === undefined ? {} : { autoReconnect }),
status: existing?.status ?? 'inactive',
error: existing?.error,
lastUsedAt: existing?.lastUsedAt,
+23
View File
@@ -0,0 +1,23 @@
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { isPortForwardingAutoReconnectEnabled } from "./portForwardingReconnect";
describe("isPortForwardingAutoReconnectEnabled", () => {
it("returns true when autoReconnect is enabled", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: true }), true);
});
it("returns true for auto-start rules (pre-existing behavior)", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true }), true);
});
it("honors explicit disable even for auto-start rules", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: true, autoReconnect: false }), false);
});
it("returns false when neither flag opts in", () => {
assert.equal(isPortForwardingAutoReconnectEnabled({}), false);
assert.equal(isPortForwardingAutoReconnectEnabled({ autoStart: false }), false);
assert.equal(isPortForwardingAutoReconnectEnabled({ autoReconnect: false }), false);
});
});
+12
View File
@@ -0,0 +1,12 @@
import type { PortForwardingRule } from "./models";
/**
* Whether a port-forwarding rule should automatically reconnect (with bounded
* retries) after an unexpected disconnect.
*
* Legacy rules without `autoReconnect` retain their auto-start reconnect
* behavior. An explicit choice always takes precedence.
*/
export const isPortForwardingAutoReconnectEnabled = (
rule: Pick<PortForwardingRule, "autoStart" | "autoReconnect">,
): boolean => rule.autoReconnect ?? rule.autoStart === true;
+13 -1
View File
@@ -262,9 +262,21 @@ function parseCodexConfigToml(text) {
* because the config.toml is a strong signal the user doesn't want that.
*/
function readCodexCustomProviderConfig(shellEnv) {
// Codex honors $CODEX_HOME and reads $CODEX_HOME/config.toml (see
// codexAppServer/protocol.schema.json), so prefer it over the legacy
// ~/.codex location — otherwise a custom provider configured under
// CODEX_HOME would be missed (or an unrelated default-home config could
// shadow what the CLI actually runs).
const codexHome = typeof shellEnv?.CODEX_HOME === "string" ? shellEnv.CODEX_HOME.trim() : "";
const home = shellEnv?.HOME || shellEnv?.USERPROFILE || os.homedir();
if (codexHome) {
return readCodexCustomProviderConfigFrom(path.join(codexHome, "config.toml"), shellEnv);
}
if (!home) return null;
const configPath = path.join(home, ".codex", "config.toml");
return readCodexCustomProviderConfigFrom(path.join(home, ".codex", "config.toml"), shellEnv);
}
function readCodexCustomProviderConfigFrom(configPath, shellEnv) {
if (!existsSync(configPath)) return null;
let text;
+98
View File
@@ -1,5 +1,8 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {
MAX_CODEX_LOGIN_OUTPUT_CHARS,
@@ -13,6 +16,7 @@ const {
extractCodexError,
isCodexAuthError,
normalizeCodexIntegrationState,
readCodexCustomProviderConfig,
recordCodexLoginSession,
stopCodexLoginProcess,
} = require("./codexHelpers.cjs");
@@ -177,3 +181,97 @@ test("extractCodexError handles circular structured errors", () => {
assert.equal(normalized.message, '{"status":500,"self":"[Circular]"}');
});
test("readCodexCustomProviderConfig parses a switcher-style third-party config.toml", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".codex", "config.toml"),
[
'model_provider = "ccs"',
'model = "glm-5"',
"",
"[model_providers.ccs]",
'name = "Coding Plan"',
'base_url = "https://example.invalid/v1"',
'wire_api = "responses"',
'env_key = "CODING_PLAN_API_KEY"',
"",
].join("\n"),
"utf8",
);
const config = readCodexCustomProviderConfig({
HOME: tempDir,
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
});
assert.equal(config?.providerName, "ccs");
assert.equal(config?.displayName, "Coding Plan");
assert.equal(config?.baseUrl, "https://example.invalid/v1");
assert.equal(config?.model, "glm-5");
assert.equal(config?.envKey, "CODING_PLAN_API_KEY");
assert.equal(config?.envKeyPresent, true);
});
test("readCodexCustomProviderConfig returns null for the default openai provider", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-config-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".codex", "config.toml"),
'model_provider = "openai"\n',
"utf8",
);
assert.equal(readCodexCustomProviderConfig({ HOME: tempDir }), null);
});
test("readCodexCustomProviderConfig honors CODEX_HOME over ~/.codex", (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-home-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
// An unrelated config in the default home must be ignored when CODEX_HOME
// points elsewhere.
fs.mkdirSync(path.join(tempDir, ".codex"), { recursive: true });
fs.writeFileSync(
path.join(tempDir, ".codex", "config.toml"),
'model_provider = "stale"\n\n[model_providers.stale]\nname = "Stale"\n',
"utf8",
);
const codexHome = path.join(tempDir, "custom-home");
fs.mkdirSync(codexHome, { recursive: true });
fs.writeFileSync(
path.join(codexHome, "config.toml"),
[
'model_provider = "ccs"',
'model = "glm-5"',
"",
"[model_providers.ccs]",
'name = "Coding Plan"',
'base_url = "https://example.invalid/v1"',
'env_key = "CODING_PLAN_API_KEY"',
"",
].join("\n"),
"utf8",
);
const config = readCodexCustomProviderConfig({
HOME: tempDir,
CODEX_HOME: codexHome,
CODING_PLAN_API_KEY: "not-a-real-secret-just-presence",
});
assert.equal(config?.providerName, "ccs");
assert.equal(config?.model, "glm-5");
});
+2 -6
View File
@@ -1,6 +1,6 @@
"use strict";
const { buildBashHistoryCleanup, bashHistoryScratchNames, buildInlineHistoryDrop } = require("./ptyExecHelpers.cjs");
const { buildBashHistoryCleanup, bashHistoryScratchNames } = require("./ptyExecHelpers.cjs");
// Both fish and POSIX shells accept this command. Inspect the parent of a
// short-lived sh in the interactive PTY, rather than the SSH login shell.
@@ -16,11 +16,7 @@ function buildLiveShellProbe(marker) {
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
// Start display suppression in the PTY before any continuation is read,
// independently of PS2 and echo mode. Keep every later physical line short.
// The standalone first line crosses a prompt boundary, so delete its history
// entry inline (buildInlineHistoryDrop) before an immediate-history hook
// such as PROMPT_COMMAND='history -a' can persist it — same reason as the
// wrapper input marker in ptyExecHelpers.cjs.
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
return ` true ${marker}; printf '\\n%s\\n' '${marker}_I'\n : '${marker}'; command sh -c '${script}' 2>/dev/null; \\\n: '${marker}'; \\command eval '${cleanup}' 2>/dev/null || true; \\\n: '${marker}'; \\eval '${fallback}' 2>/dev/null || true; \\\n: '${marker}'; \\command eval '${clear}' 2>/dev/null || true; printf '%s' '${marker}_Q'\n`;
}
+1 -25
View File
@@ -229,25 +229,6 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
].join(` \\\n: "${marker}"; `);
}
// Delete the just-read interactive Bash history entry for a standalone
// internal line (the `_I` input marker) before a prompt-boundary
// PROMPT_COMMAND='history -a' can persist it. Only an entry whose text
// carries this invocation's marker is deleted, so a customization that
// suppressed the line (HISTCONTROL=ignorespace) leaves the user's previous
// entry untouched — never trust $HISTCMD here. Bash-guarded and
// shadowing-safe (verified dispatcher, mirroring buildBashHistoryCleanup);
// other POSIX shells leave their entry for the tail cleanup to handle.
function buildInlineHistoryDrop(marker) {
const { entry, dispatcher } = bashHistoryScratchNames(marker);
const inlineEntry = `${entry}_i`;
const inlineDispatcher = `${dispatcher}_i`;
return [
`[ "\${BASH_VERSION-}" ]&&{ for ${inlineDispatcher} in command builtin;do ${inlineEntry}=$($${inlineDispatcher} printf x);[ "$${inlineEntry}" = x ]||continue;`,
`${inlineEntry}=$($${inlineDispatcher} history 1);${inlineEntry}=\${${inlineEntry}#"\${${inlineEntry}%%[^[:space:]]*}"};case "$${inlineEntry}" in *'${marker}'*)`,
`$${inlineDispatcher} history -d "\${${inlineEntry}%%[[:space:]]*}";;esac;break;done; } 2>/dev/null`,
].join(` \\\n: '${marker}'; `);
}
function buildPosixWrapperBody(command, marker, startFormat) {
const noPager = "PAGER=cat SYSTEMD_PAGER= GIT_PAGER=cat LESS= ";
const commandLines = String(command || "").replace(/\r\n?/g, "\n").split("\n");
@@ -283,11 +264,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
// every physical line below that limit, even for short user commands.
// Emit a short standalone input marker before the shell reads any PS2
// continuation. A leading newline separates it from concurrent PTY echo.
// Because this line ends at a real prompt boundary, an immediate-history
// hook (PROMPT_COMMAND='history -a') can persist it before the wrapper's
// tail cleanup runs — so delete the entry inline while it is still only in
// memory (buildInlineHistoryDrop).
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'; ${buildInlineHistoryDrop(marker)}\n` + [
return `${marker}=0; printf '\\n%s\\n' '${marker}_I'\n` + [
` : '${marker}'; ${cmdAssign}`,
`{ printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT`,
`printf '%s\\n' '${marker}_E:'"$__NCMCP_rc"`,
@@ -544,7 +521,6 @@ module.exports = {
buildPendingInputClearPrefix,
buildWrappedCommand,
buildBashHistoryCleanup,
buildInlineHistoryDrop,
bashHistoryScratchNames,
findEndMarker,
normalizePtyOutput,
+2
View File
@@ -29,6 +29,7 @@ const { registerProviderHandlers } = require("./aiBridge/providerHandlers.cjs"),
const { createVaultAgentBridge } = require("./aiBridge/vaultAgentBridge.cjs");
const { registerAgentDiscoveryHandlers } = require("./aiBridge/agentDiscoveryHandlers.cjs"), { registerAgentProcessHandlers } = require("./aiBridge/agentProcessHandlers.cjs"), { registerSdkStreamHandlers } = require("./aiBridge/sdk/sdkStreamHandlers.cjs");
const { probeClaudeAuth, probeCopilotAuth, probeCodexAuth, probeCodebuddyAuth, probeCursorCliAuth, probeGrokAuth } = require("./aiBridge/agentAuthProbes.cjs");
const { buildSdkAgentEnv } = require("./aiBridge/sdk/env.cjs");
// ── Extracted modules ──
const {
@@ -946,6 +947,7 @@ function createHandlerContext(ipcMain) {
killTrackedProcessTree,
safeSend,
withCliDiscoveryEnv,
buildSdkAgentEnv,
validateSender,
validateSenderOrSettings,
envPairsToObject,
+186 -1
View File
@@ -109,6 +109,9 @@ function loadBridgeWithMocks(options = {}) {
"./ai/codexHelpers.cjs": {
codexLoginSessions: new Map(),
appendCodexLoginOutput() {},
createCodexLoginOutputDecoder: () => ({ write() {}, end() {} }),
clearCodexLoginKillTimer() {},
recordCodexLoginSession: (session) => options.recordCodexLoginSession?.(session),
toCodexLoginSessionResponse: (session) => ({ sessionId: session.id, codexPath: session.codexPath }),
getActiveCodexLoginSession: () =>
typeof options.getActiveCodexLoginSession === "function"
@@ -120,7 +123,10 @@ function loadBridgeWithMocks(options = {}) {
: realNormalizeCodexIntegrationState(...args),
appendCodexChatGptValidationFailure: (rawOutput, validationError) =>
`${rawOutput}\n\nChatGPT auth validation failed:\n${validationError}`.trim(),
readCodexCustomProviderConfig: () => null,
readCodexCustomProviderConfig: (...args) =>
typeof options.readCodexCustomProviderConfig === "function"
? options.readCodexCustomProviderConfig(...args)
: null,
getCodexCustomConfigPreflightError: () => null,
extractCodexError: (err) => ({ message: err?.message || String(err) }),
isCodexAuthError: (...args) =>
@@ -971,6 +977,84 @@ test("codex login does not reuse an active session from a different resolved pat
}
});
for (const homeVariable of ["HOME", "CODEX_HOME"]) {
for (const sameHome of [true, false]) {
test(`codex login ${sameHome ? "reuses" : "rejects"} an active session with ${sameHome ? "matching" : "changed"} ${homeVariable}`, async () => {
const originalHome = path.join(os.tmpdir(), "codex-login-original");
const requestedHome = sameHome ? originalHome : path.join(os.tmpdir(), "codex-login-other");
const existingSession = {
id: "codex_login_existing",
state: "running",
process: { killed: false },
codexPath: "/fixture/codex",
credentialHomeKey: homeVariable === "HOME" ? path.join(originalHome, ".codex") : originalHome,
};
const { bridge, restore } = loadBridgeWithMocks({
shellEnv: { HOME: originalHome },
resolveCliFromPathAsync: () => existingSession.codexPath,
getActiveCodexLoginSession: () => existingSession,
prepareCommandForSpawn: () => { throw new Error("An active login must not spawn another process"); },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const result = await ipcMain.handlers.get("netcatty:ai:codex:start-login")(
{ sender: { id: 1 } },
{ agentEnv: { [homeVariable]: requestedHome } },
);
assert.equal(result.ok, sameHome, JSON.stringify(result));
if (sameHome) {
assert.equal(result.session.sessionId, existingSession.id);
} else {
assert.match(result.error, /different credential home/);
assert.equal(result.session, undefined);
}
} finally {
restore();
}
});
}
}
for (const action of ["start-login", "logout"]) {
test(`codex ${action} child processes use the requested agent home`, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-agent-home-"));
t.after(() => fs.rmSync(tempDir, { recursive: true, force: true }));
const codexPath = path.join(tempDir, "codex.cjs");
const observedPath = path.join(tempDir, "observed.jsonl");
fs.writeFileSync(codexPath, `
require('node:fs').appendFileSync(${JSON.stringify(observedPath)}, JSON.stringify({
args: process.argv.slice(2), HOME: process.env.HOME, CODEX_HOME: process.env.CODEX_HOME,
}) + '\\n');
console.log('Not logged in');
`);
const agentEnv = { HOME: path.join(tempDir, "agent-home"), CODEX_HOME: path.join(tempDir, "agent-codex") };
let finishLogin;
const loginFinished = new Promise((resolve) => { finishLogin = resolve; });
const { bridge, restore } = loadBridgeWithMocks({
shellEnv: { HOME: path.join(tempDir, "default-home"), CODEX_HOME: path.join(tempDir, "default-codex") },
prepareCommandForSpawn: (command, args) => ({ command: process.execPath, args: [command, ...args], shell: false }),
recordCodexLoginSession: (session) => {
if (session.state !== "running") finishLogin(session.state);
},
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const result = await ipcMain.handlers.get(`netcatty:ai:codex:${action}`)({ sender: { id: 1 } }, { codexPath, agentEnv });
assert.equal(result.ok, true, JSON.stringify(result));
if (action === "start-login") assert.equal(await loginFinished, "success");
const observed = fs.readFileSync(observedPath, "utf8").trim().split("\n").map((line) => JSON.parse(line));
assert.deepEqual(observed, (action === "start-login" ? [["login"]] : [["logout"], ["login", "status"]])
.map((args) => ({ args, ...agentEnv })));
} finally {
restore();
}
});
}
test("codex integration keeps ChatGPT connected when the SDK validation probe fails", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
@@ -1014,6 +1098,107 @@ test("codex integration keeps ChatGPT connected when the SDK validation probe fa
}
});
test("codex integration surfaces config.toml provider even when auth.json reports an API-key login", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex");
fs.writeFileSync(
codexPath,
`#!${process.execPath}\nconsole.log('Logged in using an API key');\n`,
{ mode: 0o755 },
);
const customConfig = {
providerName: "ccs",
displayName: "Coding Plan",
baseUrl: "https://example.invalid/v1",
envKey: null,
envKeyPresent: false,
hasHardcodedApiKey: true,
model: "glm-5",
authHash: "hash",
};
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: (value) => value,
shellEnv: { HOME: tempDir },
readCodexCustomProviderConfig: () => customConfig,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
const result = await handler({ sender: { id: 1 } }, { codexPath });
assert.equal(result.state, "connected_custom_config", JSON.stringify(result));
assert.equal(result.isConnected, true);
assert.equal(result.customConfig?.model, "glm-5");
assert.equal(result.customConfig?.providerName, "ccs");
} finally {
restore();
}
});
test("codex integration keeps a validated ChatGPT login visible but still returns config.toml provider", async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-codex-integration-"));
t.after(() => {
fs.rmSync(tempDir, { recursive: true, force: true });
});
const codexPath = path.join(tempDir, "codex");
fs.writeFileSync(
codexPath,
`#!${process.execPath}\nconsole.log('Logged in using ChatGPT');\n`,
{ mode: 0o755 },
);
const customConfig = {
providerName: "ccs",
displayName: "Coding Plan",
baseUrl: null,
envKey: null,
envKeyPresent: false,
hasHardcodedApiKey: true,
model: "glm-5",
authHash: "hash",
};
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: (value) => value,
shellEnv: { HOME: tempDir },
readCodexCustomProviderConfig: () => customConfig,
});
const ipcMain = createIpcMainStub();
bridge.init({
sessions: new Map(),
sftpClients: new Map(),
electronModule: { app: { getPath: () => process.cwd() } },
});
bridge.registerHandlers(ipcMain);
try {
const handler = ipcMain.handlers.get("netcatty:ai:codex:get-integration");
const result = await handler({ sender: { id: 1 } }, { codexPath });
assert.equal(result.state, "connected_chatgpt", JSON.stringify(result));
assert.equal(result.isConnected, true);
assert.equal(result.customConfig?.model, "glm-5");
} finally {
restore();
}
});
test("resolve-cli probes Windows cmd paths with spaces", { skip: process.platform !== "win32" }, async (t) => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty codex resolve "));
t.after(() => {
+25 -8
View File
@@ -1,4 +1,5 @@
/* eslint-disable no-undef */
const { createHash } = require("node:crypto");
const { StringDecoder } = require("node:string_decoder");
const DEFAULT_CODEX_CLI_TIMEOUT_MS = 10_000;
@@ -8,6 +9,7 @@ const MAX_AGENT_CLI_BUFFER_CHARS = 10 * 1024 * 1024;
function createAgentCliHelpers(ctx) {
with (ctx) {
const codexAuthValidationInFlight = new Map();
let codexValidationKey = null;
async function runCommand(command, args, options) {
return await new Promise((resolve, reject) => {
let settled = false;
@@ -143,9 +145,15 @@ function createAgentCliHelpers(ctx) {
throw new Error(`Codex CLI path not found: ${requestedPath}`);
}
const codexCliPath = configuredPath || await resolveCliFromPathAsync("codex", shellEnv) || "codex";
// Callers probing on behalf of a specific agent pass `options.env` (the
// merged shell + agent env, as built for SDK spawns) so CODEX_HOME/HOME
// overrides select the same config the agent turn will use.
const env = options?.env && typeof options.env === "object" && Object.keys(options.env).length > 0
? options.env
: shellEnv;
return await runCommand(codexCliPath, args, {
cwd: options?.cwd?.trim() || undefined,
env: shellEnv,
env,
timeoutMs: Number.isFinite(options?.timeoutMs)
? Number(options.timeoutMs)
: DEFAULT_CODEX_CLI_TIMEOUT_MS,
@@ -166,10 +174,21 @@ function createAgentCliHelpers(ctx) {
}
async function validateCodexChatGptAuth(options) {
const shellEnv = options?.env || await getShellEnv();
// Hash sorted entries so equivalent env objects share probes without
// retaining raw credentials in cache keys.
const envKey = createHash("sha256")
.update(JSON.stringify(Object.entries(shellEnv).sort(([a], [b]) => a.localeCompare(b))))
.digest("hex");
const maxAgeMs = options?.maxAgeMs ?? 30000;
const now = Date.now();
const rawRequestedCodexPath = String(options?.codexPath || "").trim();
const requestedCodexPath = rawRequestedCodexPath ? normalizeCliPathForPlatform?.(rawRequestedCodexPath) : null;
const validationKey = JSON.stringify([requestedCodexPath, envKey]);
const cacheValidation = (result) => {
codexValidationKey = validationKey;
setCodexValidationCache(result);
};
if (rawRequestedCodexPath && !requestedCodexPath) {
const result = {
ok: false,
@@ -178,24 +197,22 @@ function createAgentCliHelpers(ctx) {
error: `Codex CLI path not found: ${rawRequestedCodexPath}`,
code: "ENOENT",
};
setCodexValidationCache(result);
return result;
}
const cached = getCodexValidationCache();
if (cached && now - cached.checkedAt < maxAgeMs && (cached.codexPath || null) === requestedCodexPath) return cached;
const inFlightKey = requestedCodexPath || "__auto__";
if (codexValidationKey === validationKey && cached && now - cached.checkedAt < maxAgeMs) return cached;
const inFlightKey = validationKey;
const existingValidation = codexAuthValidationInFlight.get(inFlightKey);
if (existingValidation) return existingValidation;
const validationPromise = (async () => {
const shellEnv = await getShellEnv();
const rawCodexPath = requestedCodexPath || await resolveSdkBinPathAsync("codex", shellEnv);
const codexPath = rawCodexPath && typeof resolveCodexExecutableForSdk === "function"
? resolveCodexExecutableForSdk(rawCodexPath) || null
: rawCodexPath;
if (!codexPath) {
const result = { ok: false, checkedAt: now, codexPath: requestedCodexPath, error: "codex binary not found", code: "ENOENT" };
setCodexValidationCache(result);
cacheValidation(result);
return result;
}
@@ -244,12 +261,12 @@ function createAgentCliHelpers(ctx) {
await Promise.race([probePromise, timeoutPromise]);
const result = { ok: true, checkedAt: now, codexPath, error: null };
setCodexValidationCache(result);
cacheValidation(result);
return result;
} catch (error) {
const normalized = extractCodexError(error);
const result = { ok: false, checkedAt: now, codexPath, error: normalized.message, code: normalized.code };
setCodexValidationCache(result);
cacheValidation(result);
return result;
} finally {
if (timeoutId) clearTimeout(timeoutId);
@@ -125,7 +125,7 @@ test("runCodexCli omits an incomplete UTF-8 suffix at its byte limit", async ()
assert.doesNotMatch(result.stdout, /�/u);
});
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout, ...overrides }) {
return createAgentCliHelpers({
getCodexValidationCache: () => null,
setCodexValidationCache() {},
@@ -138,6 +138,147 @@ function createValidationHelpers({ loadCodexSdk, setTimeout, clearTimeout }) {
loadCodexSdk,
...(setTimeout ? { setTimeout } : {}),
...(clearTimeout ? { clearTimeout } : {}),
...overrides,
});
}
test("agent auth validation does not reuse results from a different environment", async () => {
const defaultEnv = { HOME: "/default-home" };
const agentEnv = { HOME: "/agent-home", CODEX_HOME: "/agent-codex", CUSTOM_KEY: "test-value" };
const defaultFailure = { ok: false, checkedAt: Date.now(), codexPath: "/fake/codex", error: "default login expired" };
let cached = defaultFailure;
const receivedEnvs = [];
const helpers = createValidationHelpers({
getShellEnv: async () => defaultEnv,
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
constructor(options) { receivedEnvs.push(options.env); }
startThread() {
return { async runStreamed() {
return { events: (async function* () { yield { type: "turn.completed" }; })() };
} };
}
},
}),
});
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: agentEnv });
assert.equal(agentResult.ok, true);
assert.deepEqual(receivedEnvs, [agentEnv]);
assert.equal(cached, agentResult);
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
assert.equal(defaultResult.ok, true);
assert.deepEqual(receivedEnvs, [agentEnv, defaultEnv]);
assert.equal(cached, defaultResult);
});
test("agent auth failure cannot become the default environment's cached result", async () => {
let cached = null;
const receivedHomes = [];
const helpers = createValidationHelpers({
getShellEnv: async () => ({ HOME: "/default-home" }),
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
constructor(options) { this.home = options.env.HOME; receivedHomes.push(this.home); }
startThread() {
const home = this.home;
return { async runStreamed() {
if (home === "/agent-home") throw new Error("agent login expired");
return { events: (async function* () { yield { type: "turn.completed" }; })() };
} };
}
},
}),
});
const agentResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex", env: { HOME: "/agent-home" } });
assert.equal(agentResult.ok, false);
assert.equal(cached, agentResult);
const defaultResult = await helpers.validateCodexChatGptAuth({ codexPath: "/fake/codex" });
assert.equal(defaultResult.ok, true);
assert.deepEqual(receivedHomes, ["/agent-home", "/default-home"]);
assert.equal(cached, defaultResult);
});
test("an invalid explicit CLI path does not poison automatic discovery auth validation", async () => {
let cached = null;
let probeCount = 0;
const helpers = createValidationHelpers({
getShellEnv: async () => ({ HOME: "/agent-home" }),
normalizeCliPathForPlatform: () => null,
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
startThread() {
probeCount += 1;
return { async runStreamed() {
return { events: (async function* () { yield { type: "turn.completed" }; })() };
} };
}
},
}),
});
const invalid = await helpers.validateCodexChatGptAuth({ codexPath: "/missing/codex" });
assert.equal(invalid.ok, false);
assert.equal(invalid.code, "ENOENT");
assert.equal(cached, null);
assert.equal(probeCount, 0);
const discovered = await helpers.validateCodexChatGptAuth({});
assert.equal(discovered.ok, true);
assert.equal(discovered.codexPath, "/fake/codex");
assert.equal(probeCount, 1);
assert.equal(await helpers.validateCodexChatGptAuth({}), discovered);
assert.equal(probeCount, 1);
});
for (const codexPath of ["/fake/codex", undefined]) {
test(`equivalent auth environments coalesce and cache with ${codexPath ? "explicit" : "discovered"} CLI path; credential rotation probes again`, async () => {
const defaultEnv = { HOME: "/agent-home", CUSTOM_KEY: "old-test-key" };
let cached = null;
let runCount = 0;
let release;
const gate = new Promise((resolve) => { release = resolve; });
const helpers = createValidationHelpers({
getShellEnv: async () => ({ ...defaultEnv }),
getCodexValidationCache: () => cached,
setCodexValidationCache: (result) => { cached = result; },
loadCodexSdk: async () => ({
Codex: class {
startThread() {
return { async runStreamed() {
runCount += 1;
return { events: (async function* () {
await gate;
yield { type: "turn.completed" };
})() };
} };
}
},
}),
});
const options = { codexPath };
const first = helpers.validateCodexChatGptAuth({ ...options, env: defaultEnv });
const second = helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } });
const third = helpers.validateCodexChatGptAuth(options);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(runCount, 1);
release();
const results = await Promise.all([first, second, third]);
assert.equal(results[0].ok, true);
assert.equal(results[0], results[1]);
assert.equal(results[0], results[2]);
assert.equal(await helpers.validateCodexChatGptAuth(options), results[0]);
assert.equal(await helpers.validateCodexChatGptAuth({ ...options, env: { CUSTOM_KEY: "old-test-key", HOME: "/agent-home" } }), results[0]);
assert.equal(runCount, 1);
const rotated = await helpers.validateCodexChatGptAuth({ ...options, env: { ...defaultEnv, CUSTOM_KEY: "new-test-key" } });
assert.equal(rotated.ok, true);
assert.notEqual(rotated, results[0]);
assert.equal(runCount, 2);
});
}
@@ -78,6 +78,14 @@ async function probeCursorSdkAvailability(shellEnv, options = {}) {
function registerAgentDiscoveryHandlers(ctx) {
with (ctx) {
async function getCodexAgentEnv(options) {
return buildSdkAgentEnv({
shellEnv: await getShellEnv(),
requestedAgentEnv: normalizeAgentEnv(options?.agentEnv),
withCliDiscoveryEnv,
});
}
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (options?.refreshShellEnv) {
@@ -265,7 +273,9 @@ function registerAgentDiscoveryHandlers(ctx) {
invalidateShellEnvCache();
}
try {
const codexCliOptions = { codexPath: options?.codexPath };
// Probe the same config and credentials as the managed agent's turns.
const effectiveEnv = await getCodexAgentEnv(options);
const codexCliOptions = { codexPath: options?.codexPath, env: effectiveEnv };
const result = await runCodexCli(["login", "status"], codexCliOptions);
const rawOutput = [result.stdout, result.stderr]
.filter((chunk) => chunk.trim().length > 0)
@@ -275,7 +285,7 @@ function registerAgentDiscoveryHandlers(ctx) {
let effectiveRawOutput = rawOutput;
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath, env: effectiveEnv });
if (!validation.ok) {
if (isCodexAuthError(validation)) {
try {
@@ -299,17 +309,21 @@ function registerAgentDiscoveryHandlers(ctx) {
// functional from the CLI but would look "not_logged_in" here. Probe
// config.toml so we can surface that as a valid ready state instead of
// pushing the user into the ChatGPT login flow.
//
// Probe even when auth.json reports a login: provider switcher tools
// (cc-switch, ccs) write an API key into auth.json while config.toml's
// `model_provider` actually selects a third-party provider, and
// config.toml is what Codex uses. Keep a validated ChatGPT login as the
// displayed state, but still return customConfig so the chat model
// picker can surface the configured third-party model.
let customConfig = null;
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
try {
const shellEnv = await getShellEnv();
customConfig = readCodexCustomProviderConfig(shellEnv);
if (customConfig) {
state = "connected_custom_config";
}
} catch {
customConfig = null;
try {
customConfig = readCodexCustomProviderConfig(effectiveEnv);
if (customConfig && state !== "connected_chatgpt") {
state = "connected_custom_config";
}
} catch {
customConfig = null;
}
return {
@@ -342,16 +356,21 @@ function registerAgentDiscoveryHandlers(ctx) {
}
try {
const shellEnv = await getShellEnv();
const shellEnv = await getCodexAgentEnv(options);
const codexCliPath = requestedCodexPath
|| await resolveCliFromPathAsync("codex", shellEnv)
|| "codex";
const credentialHomeKey = shellEnv.CODEX_HOME?.trim()
|| require("node:path").join(shellEnv.HOME || shellEnv.USERPROFILE || require("node:os").homedir(), ".codex");
const existingSession = getActiveCodexLoginSession();
if (existingSession) {
const existingPath = existingSession.codexPath || null;
if (existingPath && codexCliPath !== existingPath) {
return { ok: false, error: "A Codex login is already running for a different CLI path." };
}
if (existingSession.credentialHomeKey !== credentialHomeKey) {
return { ok: false, error: "A Codex login is already running for a different credential home." };
}
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
}
@@ -373,6 +392,7 @@ function registerAgentDiscoveryHandlers(ctx) {
error: null,
exitCode: null,
codexPath: codexCliPath,
credentialHomeKey,
};
const stdoutDecoder = createCodexLoginOutputDecoder(session);
@@ -454,7 +474,7 @@ function registerAgentDiscoveryHandlers(ctx) {
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
try {
const codexCliOptions = { codexPath: options?.codexPath };
const codexCliOptions = { codexPath: options?.codexPath, env: await getCodexAgentEnv(options) };
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
invalidateCodexValidationCache();
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
+15
View File
@@ -1271,6 +1271,21 @@ function registerHandlers(ipcMain) {
return { success: true };
});
ipcMain.handle("netcatty:trayPanel:startPortForward", async (_event, ruleId) => {
const win = await openMainWindowReady();
if (!win) return { success: false, error: "Main window is not available" };
if (isAppRuntimeLocked()) {
sendOrQueuePortForwardToggle(win, ruleId, true);
return { success: true };
}
const delivered = await sendToMainWindow("netcatty:trayPanel:startPortForward", ruleId, {
focus: false,
});
return delivered
? { success: true }
: { success: false, error: "Main window is not ready" };
});
ipcMain.handle("netcatty:trayPanel:closeSession", async (_event, sessionId) => {
const delivered = await sendToMainWindow("netcatty:trayPanel:closeSession", sessionId, {
focus: false,
@@ -1770,3 +1770,55 @@ test("setCloseToTray(false) still destroys an unpinned tray as before", async ()
bridge.cleanup();
}
});
test("tray panel forwarding start reaches the main renderer coordinator", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const sentMessages = [];
const mainWin = new FakeWindow();
mainWin.webContents = { send: (...args) => sentMessages.push(args) };
electronModule.BrowserWindow.getAllWindows = () => [mainWin];
bridge.init({ electronModule, getMainWindow: () => mainWin });
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
const result = await ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "reconnect-rule");
assert.deepEqual(result, { success: true });
assert.ok(sentMessages.some((args) =>
args[0] === "netcatty:trayPanel:startPortForward" && args[1] === "reconnect-rule"));
});
});
test("tray panel forwarding waits for a newly created main renderer", async () => {
await withPlatform("darwin", async () => {
const bridge = loadBridge();
const electronModule = createElectronStub();
const mainWin = new FakeWindow();
let main = null;
let releaseReady;
let delivered = false;
const ready = new Promise((resolve) => { releaseReady = resolve; });
electronModule.BrowserWindow.getAllWindows = () => main ? [main] : [];
bridge.init({
electronModule,
getMainWindow: () => main,
ensureMainWindow: async () => { main = mainWin; return mainWin; },
sendWhenRendererReady: async (win, channel, ruleId) => {
assert.equal(win, mainWin);
assert.equal(channel, "netcatty:trayPanel:startPortForward");
assert.equal(ruleId, "new-main-rule");
await ready;
delivered = true;
return { success: true };
},
});
const ipcMain = createIpcMainStub();
bridge.registerHandlers(ipcMain);
const pending = ipcMain.handlers.get("netcatty:trayPanel:startPortForward")(null, "new-main-rule");
assert.equal(delivered, false);
releaseReady();
assert.deepEqual(await pending, { success: true });
assert.equal(delivered, true);
});
});
+26 -1
View File
@@ -55,6 +55,7 @@ let sftpClients = null;
let electronModule = null;
let sessions = null;
let reportOpenedSessionActivity = null;
let reportSuppressedError = null;
const rendererSftpSourceSessions = new Map();
const REMOTE_DELETE_EXEC_OPEN_TIMEOUT_MS = 15_000;
const REMOTE_DELETE_EXEC_RUN_TIMEOUT_MS = 10 * 60_000;
@@ -734,6 +735,9 @@ function init(deps) {
reportOpenedSessionActivity = typeof deps.reportOpenedSessionActivity === "function"
? deps.reportOpenedSessionActivity
: null;
reportSuppressedError = typeof deps.reportSuppressedError === "function"
? deps.reportSuppressedError
: null;
rendererSftpSourceSessions.clear();
}
@@ -2015,10 +2019,14 @@ async function openSftpForSession(_event, payload) {
}
try {
await requireSftpChannel(client, {
// This is a fresh client: retain the initial channel-open error instead
// of passing through recovery, which replaces it with a generic error.
const channel = await tryOpenSftpChannel(client, {
signal: payload?.abortSignal,
timeoutMs: payload?.timeoutMs,
});
if (!hasSftpChannelApi(channel)) throw new Error("SFTP channel unavailable");
client.sftp = channel;
client.__netcattyFileProtocol = "sftp";
} catch (sftpErr) {
if (fileProtocol === "sftp") throw sftpErr;
@@ -2027,6 +2035,23 @@ async function openSftpForSession(_event, payload) {
`[SFTP] openSftpForSession SFTP channel failed for ${sessionId}; falling back to SCP mode:`,
sftpErr?.message || String(sftpErr),
);
// The terminal worker has no Electron app: forward to main's logger.
try {
const source = "sftpBridge.openSftpForSession";
const message = `SFTP channel failed for ${sourceSessionId}; falling back to SCP mode (${fileProtocol})`;
if (reportSuppressedError) {
reportSuppressedError(source, sftpErr, message);
} else {
require("./crashLogBridge.cjs").captureDiagnostic(source, message, {
sessionId: sourceSessionId,
fileProtocol,
reason: sftpErr?.message || String(sftpErr),
reasonCode: sftpErr?.code ?? sftpErr?.level ?? undefined,
});
}
} catch {
// Diagnostic failures must not prevent the fallback.
}
client.__netcattyFileProtocol = "scp";
client.sftp = null;
try {
@@ -0,0 +1,47 @@
"use strict";
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const test = require("node:test");
const bridge = require("./sftpBridge.cjs");
for (const loggerThrows of [false, true]) {
test(`Auto fallback reports the channel failure; loggerThrows=${loggerThrows}`, async () => {
const failure = Object.assign(new Error("subsystem request rejected"), { code: "CHANNEL_OPEN_FAILURE" });
const reports = [];
const conn = {
sftp(callback) { callback(failure); },
exec(command, callback) {
const stream = new EventEmitter();
stream.stderr = new EventEmitter();
stream.close = () => {};
callback(null, stream);
queueMicrotask(() => {
if (!command.includes("command -v scp")) {
stream.emit("data", Buffer.from("RAW:/home/test\n"));
}
stream.emit("close", 0);
});
},
};
bridge.init({
electronModule: { webContents: { fromId: () => null } },
sessions: new Map([["fallback-session", { conn }]]),
sftpClients: new Map(),
reportSuppressedError(...args) {
reports.push(args);
if (loggerThrows) throw new Error("logger unavailable");
},
});
const opened = await bridge.openSftpForSession(null, { sessionId: "fallback-session" });
try {
assert.equal(opened.fileProtocol, "scp");
assert.equal(reports.length, 1);
assert.equal(reports[0][0], "sftpBridge.openSftpForSession");
assert.equal(reports[0][1], failure);
assert.match(reports[0][2], /fallback-session.*SCP mode \(auto\)/);
} finally {
await bridge.closeSftp(null, { sftpId: opened.sftpId });
}
});
}
+10 -2
View File
@@ -190,8 +190,16 @@ function createScpBackend(deps = {}) {
async function stat(remotePath, options = {}) {
const signal = options.signal || null;
const encoding = options.encoding || "utf-8";
const result = await runOrThrow(buildStatCommand(remotePath, encoding), { signal });
return parseStatRecord(result.stdout);
// allowNonZero: parseStatRecord needs the exit code + stderr to tell a real
// ENOENT (exit 2) apart from an empty exec-channel response.
const result = await runOrThrow(buildStatCommand(remotePath, encoding), {
allowNonZero: true,
signal,
});
return parseStatRecord(result.stdout, {
stderr: result.stderr,
exitCode: result.code,
});
}
async function mkdir(remotePath, options = {}) {
@@ -175,6 +175,137 @@ describe("scpBackend browse/manage with fake exec", () => {
assert.match(statCommand, /\[ ! -L "\$p" \]/, "broken symlinks must not be reported as missing");
});
it("reports ENOENT when the stat command exits 2 with an ENOENT marker", async () => {
const missingBackend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "ENOENT\n", code: 2 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => missingBackend.stat("/home/test/gone.txt"),
(err) => err.code === "ENOENT" && err.message === "No such file",
);
});
it("reports ENOENT when a shell banner precedes the marker on stderr", async () => {
// Codex: non-interactive shells may print warnings before the command, so
// the ENOENT marker can appear on a later stderr line.
const bannerBackend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "warning: env cleared\nENOENT\n", code: 2 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => bannerBackend.stat("/home/test/gone.txt"),
(err) => err.code === "ENOENT" && err.message === "No such file",
);
});
it("reports ENOENT when stdout contains a login banner", async () => {
const bannerBackend = createScpBackend({
exec: async () => ({ stdout: "Welcome\n", stderr: "ENOENT\n", code: 2 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => bannerBackend.stat("/home/test/gone.txt"),
(err) => err.code === "ENOENT",
);
});
it("does not mask an empty exec response as ENOENT in stat", async () => {
const emptyBackend = createScpBackend({
// Simulates the reporter's case: the exec channel returns no output at
// all (e.g. channel negotiation failure) — not a missing file.
exec: async () => ({ stdout: "", stderr: "", code: 0 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => emptyBackend.stat("/home/test/readme.txt"),
(err) => {
assert.equal(err.code, "EMPTY_RESPONSE");
assert.match(err.message, /empty response/);
assert.ok(!(err instanceof Error && err.message === "No such file"));
return true;
},
);
});
it("surfaces stderr and exit code when the stat command fails without output", async () => {
const failingBackend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 1 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => failingBackend.stat("/home/test/readme.txt"),
(err) => {
assert.equal(err.code, "EMPTY_RESPONSE");
assert.match(err.message, /exit 1/);
assert.match(err.message, /syntax error/);
assert.equal(err.exitCode, 1);
assert.equal(err.stderr, "sh: syntax error");
return true;
},
);
});
it("does not treat exit 2 without an ENOENT marker as a missing file", async () => {
// Codex: a remote shell failure (e.g. syntax/usage error) can also exit 2;
// without the ENOENT marker it must surface the real stderr, not ENOENT.
const shellFailBackend = createScpBackend({
exec: async () => ({ stdout: "", stderr: "sh: syntax error\n", code: 2 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => shellFailBackend.stat("/home/test/readme.txt"),
(err) => {
assert.equal(err.code, "EMPTY_RESPONSE");
assert.match(err.message, /exit 2/);
assert.match(err.message, /syntax error/);
assert.equal(err.exitCode, 2);
assert.equal(err.stderr, "sh: syntax error");
return true;
},
);
});
it("does not treat a stdout ENOENT marker with a nonzero, non-2 exit as missing", async () => {
// Codex: a forced-command wrapper can print "ENOENT" on stdout before
// failing with another status; it must not be reported as ENOENT.
const wrapperBackend = createScpBackend({
exec: async () => ({ stdout: "ENOENT\n", stderr: "denied\n", code: 126 }),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => wrapperBackend.stat("/home/test/readme.txt"),
(err) => {
assert.notEqual(err.code, "ENOENT");
assert.equal(err.exitCode, 126);
return true;
},
);
});
it("rejects stat metadata forwarded by a wrapper that exits nonzero", async () => {
// Codex: a forced-command wrapper can forward the stat record but exit
// nonzero; the metadata is untrustworthy and must not be returned.
const wrapperBackend = createScpBackend({
exec: async () => ({
stdout: "f|-rw-r--r--|5|1700000000|/home/test/readme.txt|12345\n",
stderr: "forced command failed\n",
code: 1,
}),
execStream: async () => createMockStream(),
});
await assert.rejects(
() => wrapperBackend.stat("/home/test/readme.txt"),
(err) => {
assert.match(err.message, /exited 1/);
assert.match(err.message, /forced command failed/);
assert.equal(err.exitCode, 1);
assert.equal(err.stderr, "forced command failed");
return true;
},
);
});
it("resolves home directory", async () => {
const home = await backend.homeDir();
assert.equal(home, "/home/test");
+45 -4
View File
@@ -327,16 +327,57 @@ function parseLsLaOutput(stdout, { basePath = "" } = {}) {
return results;
}
function parseStatRecord(stdout) {
function parseStatRecord(stdout, { stderr = "", exitCode = null } = {}) {
const line = String(stdout || "").trim().split(/\r?\n/)[0] || "";
const stderrText = String(stderr || "").trim();
// A missing path emits an ENOENT marker and exits 2. Shell startup
// messages may precede it on either stream; exit status alone is not enough.
const hasMissingMarker = line === "ENOENT"
|| stderrText.split(/\r?\n/).some((entry) => entry.trim() === "ENOENT");
if (hasMissingMarker && (exitCode == null || exitCode === 2)) {
throw new ScpShellError("No such file", "ENOENT");
}
if (!line || line === "ENOENT") {
const err = new ScpShellError("No such file", "ENOENT");
err.code = "ENOENT";
const detail = [
"exec channel returned empty response",
exitCode != null ? `exit ${exitCode}` : null,
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
].filter(Boolean).join("; ");
const err = new ScpShellError(detail, "EMPTY_RESPONSE");
err.code = "EMPTY_RESPONSE";
err.exitCode = exitCode;
err.stderr = stderrText;
throw err;
}
// Any other nonzero status is a remote execution failure — even when stdout
// looks like a valid record (e.g. a forced-command wrapper that forwards the
// stat record before failing). Such metadata is untrustworthy, so reject it
// instead of letting the caller proceed on it.
if (exitCode != null && exitCode !== 0) {
const detail = [
`stat command exited ${exitCode}`,
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
].filter(Boolean).join("; ");
const err = new ScpShellError(detail);
err.exitCode = exitCode;
err.stderr = stderrText;
throw err;
}
const parts = line.split("|");
if (parts.length < 5) {
throw new ScpShellError(`Malformed stat record: ${line.slice(0, 80)}`);
// Non-empty stdout that isn't a stat record: the remote command may have
// failed (nonzero exit, e.g. a restricted shell printing a banner) while
// still writing to stdout. Preserve exit code and stderr so failures with
// output aren't reduced to a bare "malformed" message.
const detail = [
`Malformed stat record: ${line.slice(0, 80)}`,
exitCode != null ? `exit ${exitCode}` : null,
stderrText ? `stderr: ${stderrText.slice(0, 200)}` : null,
].filter(Boolean).join("; ");
const err = new ScpShellError(detail);
err.exitCode = exitCode;
err.stderr = stderrText;
throw err;
}
const [t, modeStr, sizeStr, mtimeStr, abs, inoStr] = parts;
const ino = inoStr && /^\d+$/.test(String(inoStr).trim())
@@ -327,6 +327,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
remotePort: { type: "number", optional: true, description: "Required except for dynamic forwarding." },
hostId: { type: "string", description: "Vault host ID used for the tunnel." },
autoStart: { type: "string", optional: true, description: "true or false." },
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
},
"portforward.rules.update": {
ruleId: { type: "string", description: "Port forwarding rule ID." },
@@ -338,6 +339,7 @@ const TOOL_INPUT_FIELDS = Object.freeze({
remotePort: { type: "number", optional: true, description: "Remote port." },
hostId: { type: "string", optional: true, description: "Vault host ID used for the tunnel." },
autoStart: { type: "string", optional: true, description: "true or false." },
autoReconnect: { type: "string", optional: true, description: "true or false. Reconnect automatically after unexpected disconnects." },
},
"portforward.rules.duplicate": {
ruleId: { type: "string", description: "Port forwarding rule ID to copy." },
+8 -1
View File
@@ -1571,8 +1571,13 @@ function createPreloadApi(ctx) {
},
onTrayTogglePortForward: (callback) => {
const handler = (_event, ruleId, start) => callback(ruleId, start);
const startHandler = (_event, ruleId) => callback(ruleId, true);
ipcRenderer.on("netcatty:tray:togglePortForward", handler);
return () => ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
ipcRenderer.on("netcatty:trayPanel:startPortForward", startHandler);
return () => {
ipcRenderer.removeListener("netcatty:tray:togglePortForward", handler);
ipcRenderer.removeListener("netcatty:trayPanel:startPortForward", startHandler);
};
},
// Tray panel actions forwarded to main window
@@ -1600,6 +1605,8 @@ function createPreloadApi(ctx) {
ipcRenderer.invoke("netcatty:trayPanel:jumpToSession", sessionId),
connectToHostFromTrayPanel: (hostId) =>
ipcRenderer.invoke("netcatty:trayPanel:connectToHost", hostId),
startPortForwardFromTrayPanel: (ruleId) =>
ipcRenderer.invoke("netcatty:trayPanel:startPortForward", ruleId),
closeSessionFromTrayPanel: (sessionId) =>
ipcRenderer.invoke("netcatty:trayPanel:closeSession", sessionId),
onTrayPanelCloseRequest: (callback) => {
+1
View File
@@ -301,6 +301,7 @@ function main() {
selectZmodemUploadFiles,
selectZmodemDownloadDirectory,
terminalDataPipeline,
reportSuppressedError: reportWorkerError,
};
runtime = createTerminalWorkerRuntime({
@@ -1993,6 +1993,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
@@ -2056,6 +2061,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
@@ -2077,6 +2077,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
@@ -2141,6 +2146,11 @@
"type": "string",
"optional": true,
"description": "true or false."
},
"autoReconnect": {
"type": "string",
"optional": true,
"description": "true or false. Reconnect automatically after unexpected disconnects."
}
},
"policy": {
+3 -1
View File
@@ -59,6 +59,7 @@ import {
updatePortForwardingRule,
validatePortForwardingHost,
} from '../../domain/portForwardingAgentOps';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { deleteGroup, upsertGroup } from '../../domain/vaultGroupAgentOps';
import {
remapSnippetTargetGroupPaths,
@@ -182,6 +183,7 @@ export function sanitizePortForwardRuleForAgent(rule: PortForwardingRule): Recor
remotePort: rule.remotePort,
hostId: rule.hostId,
autoStart: rule.autoStart,
autoReconnect: rule.autoReconnect,
status: rule.status,
error: rule.error,
createdAt: rule.createdAt,
@@ -1393,7 +1395,7 @@ export async function handleVaultAgentOp(
deps.keys,
deps.identities,
undefined,
false,
isPortForwardingAutoReconnectEnabled(rule),
deps.terminalSettings,
deps.knownHosts,
);
@@ -1,4 +1,5 @@
import test from "node:test";
import { isPortForwardingAutoReconnectEnabled } from "../../domain/portForwardingReconnect.ts";
import assert from "node:assert/strict";
import type { Host, PortForwardingRule, SSHKey } from "../../domain/models.ts";
@@ -2317,3 +2318,45 @@ test("startAllPortForwards skips tracked error runtimes and stopAll stops them",
stopAndCleanupRule("bulk-idle-after-error");
await new Promise<void>((resolve) => setImmediate(resolve));
});
for (const type of ["local", "remote", "dynamic"] as const) {
test(`${type} reconnect follows live rule choices and respects manual stop`, async (t) => {
const liveRule = rule({ id: `reconnect-choice-${type}`, type, autoStart: true });
let listener: ((status: PortForwardingRule["status"], error?: string) => void) | undefined;
Object.defineProperty(globalThis, "window", {
configurable: true,
value: { netcatty: {
startPortForward: async () => ({ success: true }),
onPortForwardStatus: (_id: string, callback: typeof listener) => {
listener = callback;
return () => undefined;
},
stopPortForwardByRuleId: async () => ({ stopped: 1, failed: 0, errors: [] }),
} },
});
setReconnectCallback(async () => ({ success: true }), () => isPortForwardingAutoReconnectEnabled(liveRule));
t.after(async () => {
setReconnectCallback(null);
await stopAndCleanupRuleAndWait(liveRule.id);
});
const start = async () => {
await startPortForward(liveRule, host(), [], [], [], () => undefined,
isPortForwardingAutoReconnectEnabled(liveRule));
listener?.("active");
};
// Legacy auto-start still reconnects, until explicitly turned off while active.
await start();
liveRule.autoReconnect = false;
listener?.("error", "network dropped");
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
await stopAndCleanupRuleAndWait(liveRule.id);
// Enabling reconnect while active takes effect without restarting the tunnel.
liveRule.autoStart = false;
await start();
liveRule.autoReconnect = true;
listener?.("error", "network dropped");
assert.ok(getActiveConnection(liveRule.id)?.reconnectTimerCallback);
await stopPortForward(liveRule.id, () => undefined);
assert.equal(getActiveConnection(liveRule.id)?.reconnectTimerCallback, undefined);
});
}
@@ -12,6 +12,7 @@ import {
selectStoppablePortForwardingRules,
} from '../../domain/portForwardingBulkActions';
import { isEncryptedCredentialPlaceholder, sanitizeCredentialValue } from '../../domain/credentials';
import { isPortForwardingAutoReconnectEnabled } from '../../domain/portForwardingReconnect';
import { resolveBridgeKeyAuth, resolveBridgeSshAgentAuth, resolveHostAuth } from '../../domain/sshAuth';
import { resolveHostKeepalive } from '../../domain/host';
import { resolveHostSshConnectionTimeouts } from '../../domain/sshConnectionTimeouts';
@@ -215,11 +216,21 @@ const scheduleReconnectIfNeeded = (
enableReconnect: boolean,
onStatusChange: (status: PortForwardingRule['status'], error?: string) => void,
): boolean => {
if (!enableReconnect || !reconnectCallback || shouldReconnectRule?.(ruleId) === false) {
// The rule's autoReconnect setting may change while the tunnel is running
// (e.g. toggled in the edit panel). The start-time flag alone would keep
// rejecting reconnects after such a change, so also consult the dynamic
// rule gate, which reads fresh rule state.
const effectiveEnableReconnect =
enableReconnect || shouldReconnectRule?.(ruleId) === true;
if (
!effectiveEnableReconnect ||
!reconnectCallback ||
shouldReconnectRule?.(ruleId) === false
) {
return false;
}
if (rulesPendingCleanup.has(ruleId)) {
deferredReconnects.set(ruleId, { enableReconnect, onStatusChange });
deferredReconnects.set(ruleId, { enableReconnect: effectiveEnableReconnect, onStatusChange });
return true;
}
@@ -1392,7 +1403,7 @@ export const startAllPortForwards = async (
keys,
identities,
(status, error) => onStatusChange(rule.id, status, error),
Boolean(rule.autoStart),
isPortForwardingAutoReconnectEnabled(rule),
terminalSettings,
knownHosts,
);
+3 -3
View File
@@ -29,7 +29,7 @@ declare global {
acpArgs?: string[];
}>>;
aiPrewarmShellEnv?(): Promise<{ ok: boolean; error?: string }>;
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string }): Promise<{
aiCodexGetIntegration?(options?: { refreshShellEnv?: boolean; validateChatGptAuth?: boolean; codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
state: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected: boolean;
rawOutput: string;
@@ -45,7 +45,7 @@ declare global {
authHash: string | null;
} | null;
}>;
aiCodexStartLogin?(options?: { codexPath?: string }): Promise<{
aiCodexStartLogin?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
ok: boolean;
session?: {
sessionId: string;
@@ -85,7 +85,7 @@ declare global {
};
error?: string;
}>;
aiCodexLogout?(options?: { codexPath?: string }): Promise<{
aiCodexLogout?(options?: { codexPath?: string; agentEnv?: Record<string, string> }): Promise<{
ok: boolean;
state?: 'connected_chatgpt' | 'connected_api_key' | 'connected_custom_config' | 'not_logged_in' | 'unknown';
isConnected?: boolean;
+1
View File
@@ -108,6 +108,7 @@ declare global {
quitApp?(): Promise<{ success: boolean }>;
jumpToSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
connectToHostFromTrayPanel?(hostId: string): Promise<{ success: boolean }>;
startPortForwardFromTrayPanel?(ruleId: string): Promise<{ success: boolean; error?: string }>;
closeSessionFromTrayPanel?(sessionId: string): Promise<{ success: boolean }>;
onTrayPanelCloseRequest?(callback: () => void): () => void;
onTrayPanelRefresh?(callback: () => void): () => void;