mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-01 11:22:54 +00:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cdae7af9c | ||
|
|
3e17cdd62e | ||
|
|
e7d7a12a91 | ||
|
|
86d8c5279d | ||
|
|
e0cb6034fe | ||
|
|
b54c5e3a9b |
No files matched your search
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Новая папка',
|
||||
|
||||
@@ -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': '本地',
|
||||
|
||||
@@ -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,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()); }
|
||||
});
|
||||
@@ -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,
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -57,6 +57,7 @@ import { Switch } from "./ui/switch";
|
||||
import { TerminalFontSelect } from "./settings/TerminalFontSelect";
|
||||
import { useAvailableFonts } from "../application/state/fontStore";
|
||||
import { toast } from "./ui/toast";
|
||||
import { HostNotesEditor } from "./host/HostNotesEditor";
|
||||
import { GroupSshSettingsSection } from "./GroupSshSettingsSection";
|
||||
import { prepareProxyConfigForSave } from "./HostDetailsPanel.helpers";
|
||||
import { TerminalEncodingSelect } from "./TerminalEncodingSelect";
|
||||
@@ -547,6 +548,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
|
||||
const result: GroupConfig = {
|
||||
path: newPath,
|
||||
notes: form.notes?.trim() || undefined,
|
||||
// Only include SSH fields if SSH section is enabled
|
||||
...(sshEnabled && {
|
||||
protocol: 'ssh' as const,
|
||||
@@ -994,6 +996,12 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelPropsWithResize> = ({
|
||||
</HostDetailsSection>
|
||||
</>)}
|
||||
|
||||
<HostNotesEditor
|
||||
panelKey={groupPath}
|
||||
value={form.notes ?? ""}
|
||||
onChange={(notes) => update("notes", notes)}
|
||||
/>
|
||||
|
||||
{/* Add Protocol Button — always at the bottom */}
|
||||
{addableProtocols.length > 0 && (
|
||||
<Dropdown open={addProtocolOpen} onOpenChange={setAddProtocolOpen}>
|
||||
|
||||
@@ -168,6 +168,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
|
||||
children: {},
|
||||
hosts: [],
|
||||
}]}
|
||||
groupConfigs={[{ path: "production", notes: "# VPN instructions" }]}
|
||||
hosts={[]}
|
||||
expandedPaths={new Set<string>()}
|
||||
onTogglePath={() => undefined}
|
||||
@@ -188,6 +189,7 @@ test("HostTreeView shows selected groups in multi-select mode", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
assert.match(markup, /aria-label="Group notes"/);
|
||||
assert.match(markup, /data-selected="true"/);
|
||||
assert.match(markup, /data-group-path="production"/);
|
||||
assert.match(markup, /role="tree"/);
|
||||
|
||||
+18
-13
@@ -538,19 +538,24 @@ const TreeNode: React.FC<TreeNodeProps> = ({
|
||||
? <CheckSquare size={16} />
|
||||
: <Square size={16} />
|
||||
: undefined}
|
||||
labelActions={!isMultiSelectMode && (
|
||||
<button
|
||||
aria-label={`Edit ${node.name}`}
|
||||
tabIndex={-1}
|
||||
data-host-tree-group-edit-button={node.path}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
labelActions={(
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
|
||||
{!isMultiSelectMode && (
|
||||
<button
|
||||
aria-label={`Edit ${node.name}`}
|
||||
tabIndex={-1}
|
||||
data-host-tree-group-edit-button={node.path}
|
||||
className="flex h-5 w-5 shrink-0 items-center justify-center rounded opacity-0 transition-colors hover:bg-secondary/80 group-hover:opacity-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEditGroup(node.path);
|
||||
}}
|
||||
>
|
||||
<Edit2 size={12} />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { HostNotesIndicator } from "./HostNotesIndicator";
|
||||
|
||||
test("notes indicators distinguish groups from hosts and hide empty notes", () => {
|
||||
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="Host" />), /aria-label="Host notes"/);
|
||||
assert.match(renderToStaticMarkup(<HostNotesIndicator notes="# Project" label="Group notes" />), /aria-label="Group notes"/);
|
||||
assert.equal(renderToStaticMarkup(<HostNotesIndicator notes=" " label="Group notes" />), "");
|
||||
});
|
||||
@@ -7,11 +7,13 @@ import { HoverCard, HoverCardContent, HoverCardTrigger } from "../ui/hover-card"
|
||||
export interface HostNotesIndicatorProps {
|
||||
notes?: string;
|
||||
className?: string;
|
||||
label?: string;
|
||||
}
|
||||
|
||||
export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
|
||||
notes,
|
||||
className,
|
||||
label = "Host notes",
|
||||
}) => {
|
||||
const trimmed = notes?.trim();
|
||||
if (!trimmed) return null;
|
||||
@@ -25,7 +27,7 @@ export const HostNotesIndicator: React.FC<HostNotesIndicatorProps> = ({
|
||||
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-muted-foreground transition-colors hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
aria-label="Host notes"
|
||||
aria-label={label}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 }>;
|
||||
|
||||
@@ -893,6 +893,7 @@ export function VaultHostListSection({ ctx }: { ctx: VaultHostListSectionContext
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-semibold flex items-center gap-1.5 min-w-0">
|
||||
<span className="truncate">{node.name}</span>
|
||||
<HostNotesIndicator notes={groupConfigs.find((config) => config.path === node.path)?.notes} label="Group notes" />
|
||||
{!isMultiSelectMode && viewMode !== "grid" && renderGroupEditButton(node.path, true)}
|
||||
{managedGroupPaths.has(node.path) && (
|
||||
<span className="inline-flex items-center gap-1 text-[10px] font-medium px-1.5 py-0.5 rounded bg-primary/15 text-primary shrink-0">
|
||||
|
||||
@@ -581,3 +581,10 @@ test("applyGroupDefaults keeps host algorithm overrides instead of inheriting",
|
||||
);
|
||||
assert.deepEqual(result.algorithms, hostOverrides);
|
||||
});
|
||||
|
||||
test("group notes survive sanitization but are not connection defaults", () => {
|
||||
const config = { path: "Project", notes: "# VPN\nConnect before SSH.", username: "admin" };
|
||||
assert.equal(sanitizeGroupConfig(config).notes, config.notes);
|
||||
assert.equal(resolveGroupDefaults("Project", [config]).notes, undefined);
|
||||
assert.equal(resolveGroupDefaults("Project/Servers", [config]).notes, undefined);
|
||||
});
|
||||
@@ -116,7 +116,7 @@ export function resolveGroupDefaults(
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (key !== 'path' && value !== undefined) {
|
||||
if (key !== 'path' && key !== 'notes' && value !== undefined) {
|
||||
if (key === 'proxyProfileId') {
|
||||
delete merged.proxyConfig;
|
||||
}
|
||||
|
||||
@@ -412,6 +412,8 @@ export interface GroupNode {
|
||||
/** Default configuration for a group. Hosts in this group inherit these values when not explicitly set. */
|
||||
export interface GroupConfig {
|
||||
path: string;
|
||||
/** Markdown notes for this group only; not inherited by hosts or subgroups. */
|
||||
notes?: string;
|
||||
order?: number;
|
||||
username?: string;
|
||||
password?: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -620,3 +620,14 @@ test("mergeSyncPayloads keeps host telemetry off merged payloads", () => {
|
||||
assert.equal(unchanged.payload.hosts[0]?.lastConnectedAt, undefined);
|
||||
assert.equal(unchanged.summary.modified.local, 0);
|
||||
});
|
||||
|
||||
test("group notes sync edits and clearing without dropping connection settings", () => {
|
||||
const config = { path: "Project", notes: "# VPN\nOld instructions", username: "admin" };
|
||||
const base = payload({ customGroups: ["Project"], groupConfigs: [config] });
|
||||
const edited = payload({ customGroups: ["Project"], groupConfigs: [{ ...config, notes: "# VPN\nNew instructions" }] });
|
||||
const merged = mergeSyncPayloads(base, base, edited).payload;
|
||||
assert.equal(merged.groupConfigs?.[0].notes, "# VPN\nNew instructions");
|
||||
assert.equal(merged.groupConfigs?.[0].username, "admin");
|
||||
const cleared = payload({ customGroups: ["Project"], groupConfigs: [{ path: "Project", username: "admin" }] });
|
||||
assert.equal(mergeSyncPayloads(merged, merged, cleared).payload.groupConfigs?.[0].notes, undefined);
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -14,9 +14,9 @@ function buildLiveShellProbe(marker) {
|
||||
const { dispatcher } = bashHistoryScratchNames(marker);
|
||||
const clear = `[ -z "\${${dispatcher}-}" ]||$${dispatcher} unset ${dispatcher}`;
|
||||
const fallback = `[ "\${${dispatcher}-}" = command ]||{ ${cleanup}; };${clear}`;
|
||||
// Continuation lines stay within canonical input limits. Each echo carries
|
||||
// the marker so the renderer also hides continuation prompts.
|
||||
return ` true ${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`;
|
||||
// Start display suppression in the PTY before any continuation is read,
|
||||
// independently of PS2 and echo mode. Keep every later physical line short.
|
||||
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`;
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -34,19 +34,27 @@ test('probe wrapper keeps the start marker separate when terminal echo is disabl
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const pty = new EventEmitter();
|
||||
let job;
|
||||
let probing = true;
|
||||
let pendingInput = '';
|
||||
pty.write = (data) => {
|
||||
if (String(data).includes('command sh -c')) return;
|
||||
if (data === '\x03') return;
|
||||
if (data === "\x03") return;
|
||||
pendingInput += String(data);
|
||||
if (!pendingInput.endsWith('\n')) return;
|
||||
if (!pendingInput.endsWith('\n') || pendingInput.endsWith('\\\n')) return;
|
||||
if (probing) {
|
||||
// Wait for the complete probe, including paced chunks and continuations.
|
||||
if (!pendingInput.includes("printf '%s'")) return;
|
||||
pendingInput = '';
|
||||
probing = false;
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`));
|
||||
return;
|
||||
}
|
||||
if (!pendingInput.includes(`${job.marker}_E:`)) return;
|
||||
const script = pendingInput.replace(/^\x0b\x15/, '');
|
||||
pendingInput = '';
|
||||
const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' });
|
||||
queueMicrotask(() => pty.emit('data', `${job.marker}_QPROMPT> ${result.stdout}`));
|
||||
};
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 1000 });
|
||||
pty.emit('data', `${job.marker}_P:sh\n${job.marker}_Q`);
|
||||
job = startPtyJob(pty, 'printf no-newline-output', { probeLiveShell: true, timeoutMs: 3000 });
|
||||
const result = await job.resultPromise;
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
assert.match(result.stdout, /no-newline-output/);
|
||||
|
||||
@@ -418,7 +418,7 @@ function startPtyJob(ptyStream, command, options) {
|
||||
function finish(stdout, exitCode, error) {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
if (usesLiveShellProbe && !foundStart && typeof onProbeAborted === "function") {
|
||||
if (!foundStart && typeof onProbeAborted === "function") {
|
||||
try {
|
||||
onProbeAborted(marker);
|
||||
} catch {
|
||||
|
||||
@@ -902,16 +902,15 @@ test("posix wrapper isolates set -e failures from the active shell", () => {
|
||||
assert.doesNotMatch(result.stdout, /SHOULD_NOT_PRINT/);
|
||||
});
|
||||
|
||||
test("posix wrapper types multi-line commands as one physical line (no PS2 leak) and preserves semantics", () => {
|
||||
test("posix wrapper marks every continuation and preserves multi-line command semantics", () => {
|
||||
const marker = "__NCMCP_TEST__";
|
||||
const wrapped = buildWrappedCommand(
|
||||
"echo first\necho \"it's quoted\"\n\necho last",
|
||||
"posix",
|
||||
marker,
|
||||
);
|
||||
// A single physical line: the interactive shell must never show PS2
|
||||
// ("> ") continuation echoes, which would leak past the preload filter.
|
||||
assert.equal(wrapped.indexOf("\n"), wrapped.length - 1);
|
||||
// Every continuation carries the marker so its PS2 echo stays hidden.
|
||||
assert.ok(wrapped.trimEnd().split("\n").every(line => line.includes(marker)));
|
||||
|
||||
const result = spawnSync("sh", ["-c", wrapped], { encoding: "utf8" });
|
||||
assert.equal(result.error, undefined);
|
||||
@@ -926,10 +925,10 @@ test("long POSIX assignments preserve quotes, Unicode, and heredoc newlines on b
|
||||
const literal = "quote' \\ $HOME `false` " + "\u4e2d\u6587\ud83d\ude42".repeat(600);
|
||||
const command = `cat <<'SMOKE_END'\n${literal}\n\nsecond line\nSMOKE_END\nprintf tail`;
|
||||
const wrapped = buildWrappedCommand(command, "posix", marker, true);
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') < 1000));
|
||||
assert.ok(wrapped.split('\n').every(line => Buffer.byteLength(line, 'utf8') <= 480));
|
||||
const result = spawnSync('sh', ['-c', wrapped], { encoding: 'utf8' });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(result.stdout, `\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
assert.equal(result.stdout, `\n${marker}_I\n\n${marker}_S\n${literal}\n\nsecond line\ntail${marker}_E:0\n`);
|
||||
});
|
||||
|
||||
test("posix wrapper isolates explicit exit from the active shell and reports its code", () => {
|
||||
@@ -1377,6 +1376,22 @@ test("posix wrapper avoids history expansion in interactive zsh", (t) => {
|
||||
assert.doesNotMatch(result.stderr, /event not found/);
|
||||
});
|
||||
|
||||
|
||||
test("cancelled POSIX input without a live probe resets display suppression", async () => {
|
||||
const pty = new EventEmitter();
|
||||
const resets = [];
|
||||
pty.write = () => {};
|
||||
const job = startPtyJob(pty, "echo should-not-start", {
|
||||
shellKind: "posix", probeLiveShell: false, timeoutMs: 1000,
|
||||
expectedPrompt: "ready$ ", onProbeAborted: marker => resets.push(marker),
|
||||
});
|
||||
pty.emit("data", `${job.marker}_I\nCUSTOM> `);
|
||||
job.cancel();
|
||||
pty.emit("data", "\nready$ ");
|
||||
assert.equal((await job.resultPromise).error, "Cancelled");
|
||||
assert.deepEqual(resets, [job.marker]);
|
||||
});
|
||||
|
||||
test("startPtyJob types the wrapper one code point per write for strict bastions (#3146)", async (t) => {
|
||||
t.mock.timers.enable({ apis: ["setTimeout"] });
|
||||
const writes = [];
|
||||
|
||||
@@ -213,8 +213,20 @@ function buildBashHistoryCleanup(marker, keepDispatcher = false) {
|
||||
// Invocation-specific scratch names avoid readonly user variables. Clear the
|
||||
// history-bearing scratch through the verified dispatcher, never plain unset.
|
||||
const { entry, dispatcher } = bashHistoryScratchNames(marker);
|
||||
const unsetNames = keepDispatcher ? entry : `${entry} ${dispatcher}`;
|
||||
return `[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) ${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};$${dispatcher} history -d "\${${entry}%%[[:space:]]*}";${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*) continue;;esac;;esac;$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`;
|
||||
const previous = `${entry}_prev`;
|
||||
const unsetNames = `${entry} ${previous}${keepDispatcher ? '' : ` ${dispatcher}`}`;
|
||||
// With cmdhist disabled each physical line is a separate history entry.
|
||||
// Keep deleting marked tail entries only while their numbers decrease; a
|
||||
// shadowed history function that makes no progress must fall back, not loop.
|
||||
return [
|
||||
`[ "\${BASH_VERSION-}" ]&&{ for ${dispatcher} in command builtin;do ${entry}=$($${dispatcher} printf x);[ "$${entry}" = x ]||continue;`,
|
||||
`${entry}=$($${dispatcher} history 1);while :;do case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};${previous}=\${${entry}%%[[:space:]]*};$${dispatcher} history -d "$${previous}";`,
|
||||
`${entry}=$($${dispatcher} history 1);case "$${entry}" in *${marker}*)`,
|
||||
`${entry}=\${${entry}#"\${${entry}%%[^[:space:]]*}"};[ "\${${entry}%%[[:space:]]*}" -lt "$${previous}" ]||break;;*) break;;esac;;*) break;;esac;done;`,
|
||||
`case "$${entry}" in *${marker}*) continue;;esac;`,
|
||||
`$${dispatcher} unset ${unsetNames};break;done; } 2>/dev/null`,
|
||||
].join(` \\\n: "${marker}"; `);
|
||||
}
|
||||
|
||||
function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
@@ -223,9 +235,10 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
let cmdAssign = commandLines.length > 1
|
||||
? `${marker}_cmd=$(printf '%s\\n' ${commandLines.map((line) => `'${escapePosixSingleQuoted(line)}'`).join(" ")})`
|
||||
: `${marker}_cmd='${escapePosixSingleQuoted(command)}'`;
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 650) {
|
||||
// Canonical PTYs limit bytes per physical input line, regardless of write
|
||||
// pacing. Emit bounded quoted pieces inside one command substitution; each
|
||||
if (Buffer.byteLength(cmdAssign, 'utf8') > 350) {
|
||||
// Reserve room below BusyBox's 512-byte editor limit for the assignment,
|
||||
// marker and continuation syntax. Write pacing cannot bypass this limit.
|
||||
// Emit bounded quoted pieces inside one command substitution; each
|
||||
// continuation keeps the marker visible to the terminal echo filter.
|
||||
const writes = [];
|
||||
for (const [index, line] of commandLines.entries()) {
|
||||
@@ -234,7 +247,7 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
for (const character of line) {
|
||||
const quoted = escapePosixSingleQuoted(character);
|
||||
const size = Buffer.byteLength(quoted, 'utf8');
|
||||
if (bytes + size > 512) {
|
||||
if (bytes + size > 300) {
|
||||
writes.push(`printf '%s' '${chunk}'`);
|
||||
chunk = '';
|
||||
bytes = 0;
|
||||
@@ -247,16 +260,23 @@ function buildPosixWrapperBody(command, marker, startFormat) {
|
||||
cmdAssign = `${marker}_cmd=$(${writes.join(`; \\\n: '${marker}'; `)})`;
|
||||
}
|
||||
const historyCleanup = buildBashHistoryCleanup(marker);
|
||||
const prefix = `${marker}=0; ${cmdAssign}; { printf '${startFormat}' '${marker}_S'; trap ':' INT; ( ${noPager}eval "$${marker}_cmd" ); __NCMCP_rc=$?; trap - INT; printf '%s\\n' '${marker}_E:'\"$__NCMCP_rc\"`;
|
||||
const suffix = `${historyCleanup}; (exit $__NCMCP_rc); }`;
|
||||
const separator = prefix.length + suffix.length + 2 > 1000
|
||||
? `; \\\n: '${marker}'; ` : "; ";
|
||||
return `${prefix}${separator}${suffix}`;
|
||||
// OpenWrt builds BusyBox with a 512-byte interactive line editor. Keep
|
||||
// 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.
|
||||
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"`,
|
||||
historyCleanup,
|
||||
`(exit $__NCMCP_rc); }`,
|
||||
].join(`; \\\n: '${marker}'; `);
|
||||
}
|
||||
|
||||
function buildWrappedCommand(command, shellKind, marker, separateStartMarker = false) {
|
||||
// A live probe leaves its completion marker unterminated to hide the next
|
||||
// prompt. With terminal echo disabled, only the wrapper can end that line.
|
||||
// prompt. POSIX input also hides arbitrary PS2 prompts until a fresh start
|
||||
// marker line; keep the fish behavior controlled by separateStartMarker.
|
||||
const startFormat = separateStartMarker ? "\\n%s\\n" : "%s\\n";
|
||||
switch (shellKind) {
|
||||
case "powershell": {
|
||||
@@ -332,7 +352,7 @@ function buildWrappedCommand(command, shellKind, marker, separateStartMarker = f
|
||||
// can opt in by adding `HISTCONTROL=ignoreboth` to ~/.bashrc.
|
||||
// Without that config the prefix is harmless; it just doesn't
|
||||
// suppress history recording.
|
||||
return ` ${buildPosixWrapperBody(command, marker, startFormat)}\n`;
|
||||
return ` ${buildPosixWrapperBody(command, marker, "\\n%s\\n")}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
|
||||
for (const [customization, listHistory] of [
|
||||
[':', 'command builtin history'],
|
||||
['history() { builtin history "$@" | cat; }', 'command builtin history'],
|
||||
['command() { :; }', '\\builtin history'],
|
||||
]) {
|
||||
for (const probe of [false, true]) {
|
||||
test(`separate Bash history lines are removed (${customization}, probe=${probe})`, () => {
|
||||
const input = `HISTFILE=/dev/null; HISTCONTROL=; PS1=; PS2=\nshopt -u cmdhist\n${customization}\n${listHistory} -c\necho user_one\necho user_two\n`
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\nprintf "\\n"\n' + listHistory + '\nexit\n';
|
||||
const result = spawnSync('/bin/bash', ['--noprofile', '--norc', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const probe of [false, true]) {
|
||||
test(`zsh ignores both logical input commands with HIST_IGNORE_SPACE (probe=${probe})`, (t) => {
|
||||
const input = 'HISTFILE=/dev/null; HISTSIZE=100; setopt HIST_IGNORE_SPACE\necho user_one\n'
|
||||
+ (probe ? buildLiveShellProbe(marker) : '')
|
||||
+ buildWrappedCommand('echo command_ok', 'posix', marker, true)
|
||||
+ '\necho user_two\nfc -l 1\nexit\n';
|
||||
const result = spawnSync('zsh', ['-f', '-i'], {
|
||||
input, encoding: 'utf8', env: { ...process.env, TERM: 'dumb' }, timeout: 5000,
|
||||
});
|
||||
if (result.error?.code === 'ENOENT') {
|
||||
t.skip('zsh is not installed');
|
||||
return;
|
||||
}
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /command_ok/);
|
||||
const entries = result.stdout.split('\n').filter(line => /^\s*\d+\s/.test(line));
|
||||
assert.ok(entries.some(line => line.includes('echo user_one')), entries.join('\n'));
|
||||
assert.ok(entries.some(line => line.includes('echo user_two')), entries.join('\n'));
|
||||
assert.ok(entries.every(line => !line.includes(marker)), entries.join('\n'));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { buildWrappedCommand } = require('./ptyExecHelpers.cjs');
|
||||
const { buildLiveShellProbe } = require('./liveShellProbe.cjs');
|
||||
const { execViaPty } = require('./ptyExec.cjs');
|
||||
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
const reportedCommand = "head -5 /etc/openwrt_release 2>/dev/null; echo '---'; uname -a";
|
||||
const literal = "quote' \\ $HOME `false` " + '\u4e2d\u6587\ud83d\ude42'.repeat(100);
|
||||
const cases = [
|
||||
[reportedCommand, /DISTRIB_RELEASE='24\.10\.6'/, 0],
|
||||
["printf '%s' \"it's quoted\"", "it's quoted", 0],
|
||||
[`cat <<'END_LITERAL'\n${literal}\n\nlast\nEND_LITERAL`, `${literal}\n\nlast`, 0],
|
||||
["printf '%s' '" + 'x'.repeat(340) + "'", 'x'.repeat(340), 0],
|
||||
['printf failed; exit 7', 'failed', 7],
|
||||
];
|
||||
|
||||
test('OpenWrt input lines fit its 512-byte editor, including probe and cleanup', () => {
|
||||
for (const source of [buildLiveShellProbe(marker), ...cases.map(([cmd]) => buildWrappedCommand(cmd, 'posix', marker, true))]) {
|
||||
for (const line of source.trimEnd().split('\n')) {
|
||||
assert.ok(Buffer.byteLength(line) <= 480, `input line has ${Buffer.byteLength(line)} bytes: ${line}`);
|
||||
assert.ok(line.includes(marker), 'each continuation must remain hidden by the terminal echo filter');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Point at a disposable OpenWrt 24.10.6 Dropbear instance with blank root
|
||||
// password enabled (-B). No existing router configuration is read or modified.
|
||||
test('real OpenWrt Dropbear PTY executes commands with and without the live probe', {
|
||||
skip: !process.env.NETCATTY_OPENWRT_SSH_PORT,
|
||||
timeout: 60000,
|
||||
}, async () => {
|
||||
const { Client } = require('ssh2');
|
||||
const client = new Client();
|
||||
await new Promise((resolve, reject) => client.once('ready', resolve).once('error', reject).connect({
|
||||
host: '127.0.0.1', port: Number(process.env.NETCATTY_OPENWRT_SSH_PORT), username: 'root', password: '',
|
||||
}));
|
||||
try {
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
const stream = await new Promise((resolve, reject) => client.shell({ term: 'xterm', cols: 240, rows: 30 }, (error, value) => error ? reject(error) : resolve(value)));
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
let output = '';
|
||||
const timer = setTimeout(() => reject(new Error('OpenWrt prompt missing')), 5000);
|
||||
const onData = data => {
|
||||
output += data;
|
||||
if (output.includes(':~# ')) {
|
||||
clearTimeout(timer);
|
||||
stream.removeListener('data', onData);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
stream.on('data', onData);
|
||||
});
|
||||
for (const [command, expected, exitCode] of cases) {
|
||||
const result = await execViaPty(stream, command, {
|
||||
shellKind: 'posix', probeLiveShell, timeoutMs: 3000,
|
||||
});
|
||||
assert.equal(result.exitCode, exitCode, JSON.stringify({ probeLiveShell, result }));
|
||||
if (typeof expected === 'string') assert.equal(result.stdout, expected);
|
||||
else assert.match(result.stdout, expected);
|
||||
}
|
||||
} finally { stream.close(); }
|
||||
}
|
||||
} finally { client.end(); }
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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." },
|
||||
|
||||
@@ -95,7 +95,7 @@ const _mcpLineMetas = new Map(); // sessionId -> trailing fragment metadata
|
||||
const _mcpPendingMetas = new Map(); // sessionId -> metadata from filtered-empty chunks
|
||||
const _mcpFlushTimers = new Map(); // sessionId -> delayed-flush timer
|
||||
const _mcpDroppingWrappedLine = new Set(); // sessionIds with a split marker echo line in progress
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> probe marker awaiting its command
|
||||
const _mcpProbePrompts = new Map(); // sessionId -> input/probe marker awaiting command start
|
||||
const _mcpAbortedProbes = new Map(); // sessionId -> bounded set of cancelled probe markers
|
||||
const MAX_MCP_BUFFERED_LINE_CHARS = 64 * 1024;
|
||||
|
||||
@@ -122,7 +122,9 @@ function filterProbePromptFragment(sessionId, fragment) {
|
||||
if (_mcpProbePrompts.get(sessionId) === reset[1]) _mcpProbePrompts.delete(sessionId);
|
||||
return true;
|
||||
}
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_Q/);
|
||||
// I starts suppression before multiline input; Q keeps it active after
|
||||
// the live shell probe. Both end only at the matching command start/reset.
|
||||
const completion = fragment.match(/^\r?(__NCMCP_[A-Za-z0-9_]+)_[IQ]/);
|
||||
if (completion && !_mcpAbortedProbes.get(sessionId)?.has(completion[1])) {
|
||||
_mcpProbePrompts.set(sessionId, completion[1]);
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1780,3 +1780,96 @@ test("real PTY multiline prompt is displayed only after the AI command", {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenWrt bounded wrapper continuations stay hidden across fragmented echoes", () => {
|
||||
const { buildWrappedCommand } = require('./bridges/ai/ptyExecHelpers.cjs');
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
const sessionId = 'openwrt-echo';
|
||||
const marker = '__NCMCP_mttikd5b_ccbc892e865a115a80c88afdc77b96a6__';
|
||||
preload.api.onSessionData(sessionId, chunk => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId, data: `${marker}_I\n` });
|
||||
const wrapped = buildWrappedCommand("printf 'visible-output\\n'", 'posix', marker);
|
||||
const echo = wrapped.trimEnd().split('\n').map((line, index) => `${index ? '> ' : ''}${line}\r\n`).join('');
|
||||
const data = `${echo}${marker}_S\r\nvisible-output\r\n${marker}_E:0\r\n`;
|
||||
for (let offset = 0; offset < data.length; offset += 7) {
|
||||
preload.handlers.get('netcatty:data')({}, { sessionId, data: data.slice(offset, offset + 7) });
|
||||
}
|
||||
assert.equal(received.join(''), 'visible-output\r\n');
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("ordinary text resembling an OpenWrt continuation is released", async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("ordinary-continuation", chunk => received.push(chunk));
|
||||
preload.handlers.get("netcatty:data")({}, { sessionId: "ordinary-continuation", data: "> : '" });
|
||||
await sleep(120);
|
||||
assert.equal(received.join(""), "> : '");
|
||||
} finally {
|
||||
preload.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
for (const echo of [true, false]) {
|
||||
test(`real PTY hides custom multiline secondary prompts (echo=${echo})`, {
|
||||
skip: process.env.NETCATTY_LIVE_FISH_TEST !== '1', timeout: 10000,
|
||||
}, async () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
const terminal = require('node-pty').spawn('/bin/bash', ['--noprofile', '--norc', '--noediting'], {
|
||||
name: 'dumb', cols: 240, rows: 24,
|
||||
env: { ...process.env, TERM: 'dumb', PS1: 'READY> ', PS2: 'CUSTOM\nCONT> ', BASH_SILENCE_DEPRECATION_WARNING: '1' },
|
||||
});
|
||||
const received = [];
|
||||
let raw = '';
|
||||
const send = data => preload.handlers.get('netcatty:data')({}, { sessionId: 'custom-ps2', data });
|
||||
preload.api.onSessionData('custom-ps2', data => received.push(data));
|
||||
terminal.onData(data => { raw += data; send(data); });
|
||||
const ready = async () => {
|
||||
const deadline = Date.now() + 3000;
|
||||
while (!raw.includes('READY> ')) {
|
||||
if (Date.now() > deadline) throw new Error('Missing Bash prompt');
|
||||
await sleep(10);
|
||||
}
|
||||
raw = '';
|
||||
await sleep(120);
|
||||
};
|
||||
try {
|
||||
await ready();
|
||||
if (!echo) { terminal.write('stty -echo\r'); await ready(); }
|
||||
for (const probeLiveShell of [false, true]) {
|
||||
received.length = 0;
|
||||
const result = await require('./bridges/ai/ptyExec.cjs').execViaPty(terminal, "printf 'visible-result\\n'", {
|
||||
shellKind: 'posix', probeLiveShell, stripMarkers: true, timeoutMs: 3000,
|
||||
onProbeAborted: (marker) => send(`${marker}_R\n`),
|
||||
});
|
||||
assert.equal(result.exitCode, 0, JSON.stringify(result));
|
||||
await sleep(160);
|
||||
const display = received.join('');
|
||||
assert.match(display, /visible-result/);
|
||||
assert.doesNotMatch(display, /CUSTOM|CONT>|__NCMCP_/, JSON.stringify({ display, raw }));
|
||||
assert.match(display, /READY> /);
|
||||
}
|
||||
} finally { terminal.kill(); preload.cleanup(); }
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
test("aborted input releases custom prompts and ignores a late input marker", () => {
|
||||
const preload = loadPreloadWithFakeElectron();
|
||||
try {
|
||||
const received = [];
|
||||
preload.api.onSessionData("aborted-input", data => received.push(data));
|
||||
const send = data => preload.handlers.get("netcatty:data")({}, { sessionId: "aborted-input", data });
|
||||
send("__NCMCP_input___I\nCUSTOM\nCONT> ");
|
||||
send("__NCMCP_input___R\n");
|
||||
send("normal output\n");
|
||||
send("__NCMCP_input___I\nlate prompt\n");
|
||||
assert.equal(received.join(""), "normal output\nlate prompt\n");
|
||||
} finally { preload.cleanup(); }
|
||||
});
|
||||
@@ -18,8 +18,10 @@ class FakePty extends EventEmitter {
|
||||
|
||||
write(data) {
|
||||
this.writes.push(String(data));
|
||||
if (String(data).includes("command sh -c")) {
|
||||
const marker = String(data).match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
const input = this.writes.join("");
|
||||
if (!this.probeReplied && input.includes("command sh -c") && input.endsWith("_Q'\n")) {
|
||||
this.probeReplied = true;
|
||||
const marker = input.match(/(__NCMCP_[A-Za-z0-9_]+__)/)[1];
|
||||
queueMicrotask(() => this.emit("data", `${marker}_P:\n${marker}_Q`));
|
||||
}
|
||||
}
|
||||
@@ -52,9 +54,19 @@ function createFakeEvent() {
|
||||
};
|
||||
}
|
||||
|
||||
function extractMarker(writes) {
|
||||
const wrapper = writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
assert.ok(wrapper, "expected wrapped command to be written to the PTY");
|
||||
async function waitForWrapper(writes) {
|
||||
const deadline = Date.now() + 5000;
|
||||
while (Date.now() < deadline) {
|
||||
const input = writes.join("");
|
||||
const wrapper = input.slice(input.lastIndexOf("\x0b\x15"));
|
||||
if (wrapper.includes("_cmd") && wrapper.includes("_E:") && wrapper.endsWith("\n") && !wrapper.endsWith("\\\n")) return wrapper;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.fail("expected complete wrapped command to be written to the PTY");
|
||||
}
|
||||
|
||||
async function extractMarker(writes) {
|
||||
const wrapper = await waitForWrapper(writes);
|
||||
const match = wrapper.match(/(__NCMCP_[A-Za-z0-9_]+__)/);
|
||||
assert.ok(match, "expected command wrapper to contain an MCP marker");
|
||||
return match[1];
|
||||
@@ -144,7 +156,7 @@ test("worker AI background jobs start, poll, stop, and block overlapping exec",
|
||||
},
|
||||
]);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nready\r\n`);
|
||||
await nextTick();
|
||||
|
||||
@@ -207,7 +219,7 @@ test("worker chat cancellation stops matching background jobs", async () => {
|
||||
chatSessionId: "chat-1",
|
||||
commandTimeoutMs: 5000,
|
||||
});
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\nrunning\r\n`);
|
||||
await nextTick();
|
||||
|
||||
@@ -261,11 +273,11 @@ test("worker background job probes unset remote shellKind before wrapping", asyn
|
||||
// Login fish is a soft hint (not pinned); wrapper should be fish-native.
|
||||
assert.equal(sessions.get("ssh-fish").shellKind, undefined);
|
||||
assert.equal(sessions.get("ssh-fish")._loginShellKind, "fish");
|
||||
const wrapper = pty.writes.find((entry) => entry.includes("__NCMCP_") && !entry.includes("command sh -c"));
|
||||
const wrapper = await waitForWrapper(pty.writes);
|
||||
assert.match(wrapper, /set -l __NCMCP_.*_cmd/);
|
||||
assert.doesNotMatch(wrapper, / sh -c '/);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
@@ -305,7 +317,7 @@ test("worker background job reserves the session while shellKind probe is pendin
|
||||
const first = await firstStart;
|
||||
assert.equal(first.ok, true);
|
||||
|
||||
const marker = extractMarker(pty.writes);
|
||||
const marker = await extractMarker(pty.writes);
|
||||
pty.emit("data", `${marker}_S\r\n${marker}_E:0\r\n`);
|
||||
await nextTick();
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
Vendored
+3
-3
@@ -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
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user