mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-11-06 22:40:09 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dd08d92e3 | ||
|
|
42eb659dda | ||
|
|
8bbead1261 | ||
|
|
c3a7a4758b | ||
|
|
9a3d96be76 | ||
|
|
69df81613b | ||
|
|
583c75b107 | ||
|
|
ae74f25588 |
No files matched your search
@@ -5361,8 +5361,7 @@ jobs:
|
||||
},
|
||||
);
|
||||
const forceRetry =
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch';
|
||||
if (
|
||||
process.env.GITHUB_EVENT_NAME === 'workflow_dispatch'; if (
|
||||
!forceRetry &&
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
existingComments: comments,
|
||||
|
||||
@@ -409,6 +409,9 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
targetType: task.direction === "download" ? "local" : "sftp",
|
||||
sourceSftpId: task.direction === "download" ? sftpId : undefined,
|
||||
targetSftpId: task.direction === "upload" ? sftpId : undefined,
|
||||
// Keep host-scoped path gates across session reopen (Codex P1).
|
||||
sourceHostId: task.sourceHostId,
|
||||
targetHostId: task.targetHostId,
|
||||
totalBytes: task.totalBytes,
|
||||
resumable: task.resumable !== false,
|
||||
checkpointBytes,
|
||||
@@ -420,7 +423,30 @@ export function useAppStartupEffects(ctx: StartupEffectsContext) {
|
||||
});
|
||||
},
|
||||
);
|
||||
if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
// Same-id retry stole ownership; wait for the live owner's terminal
|
||||
// status instead of treating this invoke as completed (Codex P2).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for live owner terminal status only (no fixed deadline).
|
||||
for (;;) {
|
||||
const latest = sftpTransferCenterStore.getSnapshot().tasks.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed" || status === "cancelled" || status === "failed") {
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({
|
||||
type: "cancelled",
|
||||
transferId: task.id,
|
||||
endedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
// completed: events already applied; cancelled handled above.
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (result?.cancelled || result?.error === "Transfer cancelled") {
|
||||
sftpTransferCenterStore.ingestBackgroundEvent({ type: "cancelled", transferId: task.id, endedAt: Date.now() });
|
||||
} else if (result?.error) {
|
||||
throw new Error(result.error);
|
||||
|
||||
@@ -404,6 +404,14 @@ export const enAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Reject',
|
||||
'ai.chat.toolLabel': 'Tool',
|
||||
'ai.chat.targetLabel': 'Target',
|
||||
'ai.chat.rawCommand': 'Command',
|
||||
'ai.chat.copyCommand': 'Copy',
|
||||
'ai.chat.commandCopied': 'Copied',
|
||||
'ai.chat.approvalSession': 'Session',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Cwd',
|
||||
'ai.chat.approvalReason': 'Reason',
|
||||
'ai.chat.approvalInvocation': 'Invocation',
|
||||
'ai.chat.permissionRequired': 'Permission Required',
|
||||
'ai.chat.permissionDescription': 'The AI agent wants to execute a tool call that requires your approval.',
|
||||
'ai.chat.commandBlocked': 'This command is blocked by your security policy and cannot be executed.',
|
||||
|
||||
@@ -197,6 +197,9 @@ export const enCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Add timestamps',
|
||||
'settings.sessionLogs.timestampsDesc': 'Prefix each line in plain text and HTML logs with the local time.',
|
||||
'settings.sessionLogs.hint': 'Session logs capture all terminal output for troubleshooting and auditing purposes.',
|
||||
'settings.sessionLogs.clearAll': 'Clear all logs',
|
||||
'settings.sessionLogs.clearAllDesc': 'Delete all saved session log files and host subdirectories from the save directory.',
|
||||
'settings.sessionLogs.clearConfirm': 'This will permanently delete all session log files in the save directory. This cannot be undone. Continue?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH Debug Logs',
|
||||
|
||||
@@ -356,6 +356,14 @@ export const ruAiMessages: Messages = {
|
||||
'ai.chat.reject': 'Отклонить',
|
||||
'ai.chat.toolLabel': 'Инструмент',
|
||||
'ai.chat.targetLabel': 'Цель',
|
||||
'ai.chat.rawCommand': 'Команда',
|
||||
'ai.chat.copyCommand': 'Копировать',
|
||||
'ai.chat.commandCopied': 'Скопировано',
|
||||
'ai.chat.approvalSession': 'Сессия',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': 'Каталог',
|
||||
'ai.chat.approvalReason': 'Причина',
|
||||
'ai.chat.approvalInvocation': 'Вызов',
|
||||
'ai.chat.permissionRequired': 'Требуется разрешение',
|
||||
'ai.chat.permissionDescription': 'AI-агент хочет выполнить вызов инструмента, для которого требуется ваше одобрение.',
|
||||
'ai.chat.commandBlocked': 'Эта команда заблокирована вашей политикой безопасности и не может быть выполнена.',
|
||||
|
||||
@@ -185,6 +185,9 @@ export const ruCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': 'Добавлять метки времени',
|
||||
'settings.sessionLogs.timestampsDesc': 'Добавлять локальное время в начало каждой строки в текстовых и HTML-журналах.',
|
||||
'settings.sessionLogs.hint': 'Журналы сессий сохраняют весь вывод терминала для диагностики и аудита.',
|
||||
'settings.sessionLogs.clearAll': 'Очистить все журналы',
|
||||
'settings.sessionLogs.clearAllDesc': 'Удалить все сохранённые файлы журналов сессий и вложенные папки хостов из папки сохранения.',
|
||||
'settings.sessionLogs.clearConfirm': 'Это приведёт к безвозвратному удалению всех файлов журналов сессий в папке сохранения. Это действие нельзя отменить. Продолжить?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'Отладочные журналы SSH',
|
||||
|
||||
@@ -404,6 +404,14 @@ export const zhCNAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒绝',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目标',
|
||||
'ai.chat.rawCommand': '命令',
|
||||
'ai.chat.copyCommand': '复制',
|
||||
'ai.chat.commandCopied': '已复制',
|
||||
'ai.chat.approvalSession': '会话',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目录',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '调用详情',
|
||||
'ai.chat.permissionRequired': '需要权限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望执行一个需要你批准的工具调用。',
|
||||
'ai.chat.commandBlocked': '此命令已被安全策略拦截,无法执行。',
|
||||
|
||||
@@ -194,6 +194,9 @@ export const zhCNCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '添加时间戳',
|
||||
'settings.sessionLogs.timestampsDesc': '为纯文本和 HTML 日志的每一行添加本地时间。',
|
||||
'settings.sessionLogs.hint': '会话日志用于记录终端输出,便于故障排查和审计。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日志',
|
||||
'settings.sessionLogs.clearAllDesc': '删除保存目录中所有会话日志文件及主机子目录。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作将永久删除保存目录中的所有会话日志文件,且无法撤销,是否继续?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 调试日志',
|
||||
|
||||
@@ -403,6 +403,14 @@ export const zhTWAiMessages: Messages = {
|
||||
'ai.chat.reject': '拒絕',
|
||||
'ai.chat.toolLabel': '工具',
|
||||
'ai.chat.targetLabel': '目標',
|
||||
'ai.chat.rawCommand': '指令',
|
||||
'ai.chat.copyCommand': '複製',
|
||||
'ai.chat.commandCopied': '已複製',
|
||||
'ai.chat.approvalSession': '工作階段',
|
||||
'ai.chat.approvalShell': 'Shell',
|
||||
'ai.chat.approvalCwd': '目錄',
|
||||
'ai.chat.approvalReason': '原因',
|
||||
'ai.chat.approvalInvocation': '呼叫詳情',
|
||||
'ai.chat.permissionRequired': '需要權限',
|
||||
'ai.chat.permissionDescription': 'AI Agent 希望執行一個需要你批准的工具呼叫。',
|
||||
'ai.chat.commandBlocked': '此指令已被安全策略攔截,無法執行。',
|
||||
|
||||
@@ -194,6 +194,9 @@ export const zhTWCoreMessages: Messages = {
|
||||
'settings.sessionLogs.timestamps': '新增時間戳',
|
||||
'settings.sessionLogs.timestampsDesc': '為純文字和 HTML 日誌的每一行新增本機時間。',
|
||||
'settings.sessionLogs.hint': '工作階段日誌用於記錄終端輸出,便於故障排查和審計。',
|
||||
'settings.sessionLogs.clearAll': '清空所有日誌',
|
||||
'settings.sessionLogs.clearAllDesc': '刪除儲存目錄中所有工作階段日誌檔案及主機子目錄。',
|
||||
'settings.sessionLogs.clearConfirm': '此操作將永久刪除儲存目錄中的所有工作階段日誌檔案,且無法復原,是否繼續?',
|
||||
|
||||
// Settings > SSH Debug Logs
|
||||
'settings.sshDebugLogs.title': 'SSH 除錯日誌',
|
||||
|
||||
@@ -343,3 +343,26 @@ test("the race guards are wired into the real settings paths", () => {
|
||||
assert.match(popupSource, /terminalTheme=\{settings\.currentTerminalTheme\}/);
|
||||
assert.match(popupSource, /followAppTerminalTheme=\{settings\.followAppTerminalTheme\}/);
|
||||
});
|
||||
|
||||
test("cloud sync rehydrate picks up follow-app terminal theme from storage", () => {
|
||||
// applySyncPayload writes followAppTerminalTheme to localStorage, then calls
|
||||
// rehydrateAllFromStorage. Without reading TERM_FOLLOW_APP_THEME there, the
|
||||
// open window keeps the pre-sync follow-app flag while terminalThemeId updates
|
||||
// — black default vs synced yellow theme flicker (#2757).
|
||||
const stateSource = readFileSync(new URL("./useSettingsState.ts", import.meta.url), "utf8");
|
||||
const rehydrateIndex = stateSource.indexOf("const rehydrateAllFromStorage = useCallback(() => {");
|
||||
assert.ok(rehydrateIndex >= 0, "rehydrateAllFromStorage must exist");
|
||||
const nextCallbackIndex = stateSource.indexOf("}, [applyIncomingCustomKeyBindings", rehydrateIndex);
|
||||
assert.ok(nextCallbackIndex > rehydrateIndex, "rehydrate callback body must be bounded");
|
||||
const rehydrateBody = stateSource.slice(rehydrateIndex, nextCallbackIndex);
|
||||
assert.match(
|
||||
rehydrateBody,
|
||||
/STORAGE_KEY_TERM_FOLLOW_APP_THEME/,
|
||||
"rehydrate must read the follow-app terminal theme key written by applySyncableSettings",
|
||||
);
|
||||
assert.match(
|
||||
rehydrateBody,
|
||||
/setFollowAppTerminalThemeState/,
|
||||
"rehydrate must update React follow-app state from storage after cloud sync",
|
||||
);
|
||||
});
|
||||
@@ -77,7 +77,7 @@ export function shouldApplyCustomAccentRecord(
|
||||
|
||||
/**
|
||||
* Decide whether a custom-accent state change should be rebroadcast to peer
|
||||
* windows. Incoming IPC/storage updates must not notify again — otherwise a
|
||||
* windows. Incoming IPC/storage updates must not notify again - otherwise a
|
||||
* fast native color-picker drag in the settings window ping-pongs with the
|
||||
* main window and the accent CSS variables oscillate (see #2743, same class
|
||||
* as window-opacity #2018).
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
isMissingDirectoryReplacePathError,
|
||||
promoteDirectoryReplaceStage as promoteDirectoryReplacePaths,
|
||||
} from "./directoryReplacePromotion";
|
||||
import { sftpTransferCenterStore } from "../sftpTransferCenterStore";
|
||||
|
||||
export interface DedicatedResumeDeps {
|
||||
hosts: readonly Host[];
|
||||
@@ -708,8 +709,19 @@ async function resumeSingleFileWithDedicatedSession(
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
if (streamResult?.error) {
|
||||
throw new Error(streamResult.error);
|
||||
if (streamResult?.superseded === true) {
|
||||
// Live same-id owner still running; wait for terminal events only.
|
||||
for (;;) {
|
||||
if (shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
}, { retries: 1, delayMs: 600 });
|
||||
return { transferId: task.id };
|
||||
@@ -1258,7 +1270,17 @@ async function resumeDirectoryWithDedicatedSession(
|
||||
skipAdmission: true,
|
||||
});
|
||||
|
||||
if (streamResult?.error || streamResult?.cancelled) {
|
||||
if (streamResult?.superseded === true) {
|
||||
for (;;) {
|
||||
if (options?.shouldAbort?.()) throw new Error("Transfer cancelled");
|
||||
const latest = sftpTransferCenterStore.getTask(childBase.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") throw new Error(latest?.error || "Transfer failed");
|
||||
if (status === "cancelled") throw new Error("Transfer cancelled");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
} else if (streamResult?.error || streamResult?.cancelled) {
|
||||
throw new Error(streamResult.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
|
||||
@@ -357,13 +357,40 @@ export function useSftpDirectoryTransferOps({
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
})();
|
||||
let result: { error?: string; cancelled?: boolean } | undefined;
|
||||
let result: { error?: string; cancelled?: boolean; superseded?: boolean } | undefined;
|
||||
try {
|
||||
result = await transferPromise;
|
||||
} finally {
|
||||
watchPaused = false;
|
||||
await pauseWatch.catch(() => {});
|
||||
}
|
||||
// Same-id retry stole ownership while OPEN was pending. The live
|
||||
// owner still drives progress/completion events; this invoke must
|
||||
// not mark the child completed or failed (Codex P2 on b17f64e9).
|
||||
if (result?.superseded === true) {
|
||||
// Wait for the live same-id owner only — no fixed deadline so
|
||||
// long transfers are not failed while still running (Codex P2).
|
||||
for (;;) {
|
||||
if (
|
||||
cancelledTasksRef.current.has(task.id)
|
||||
|| cancelledTasksRef.current.has(rootTaskId)
|
||||
) {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
const latest = sftpTransferCenterStore.getTask(task.id)
|
||||
?? transfersRef.current.find((candidate) => candidate.id === task.id);
|
||||
const status = latest?.status;
|
||||
if (status === "completed") break;
|
||||
if (status === "failed") {
|
||||
throw new Error(latest?.error || "Transfer failed");
|
||||
}
|
||||
if (status === "cancelled") {
|
||||
throw new Error("Transfer cancelled");
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (result?.error || result?.cancelled) {
|
||||
throw new Error(result.error || "Transfer cancelled");
|
||||
}
|
||||
|
||||
@@ -190,7 +190,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
});
|
||||
// Track the OS color scheme preference (updated by matchMedia listener)
|
||||
const [systemPreference, setSystemPreference] = useState<'light' | 'dark'>(getSystemPreference);
|
||||
// resolvedTheme is always 'light' or 'dark' — derived synchronously from theme + OS preference
|
||||
// resolvedTheme is always 'light' or 'dark' - derived synchronously from theme + OS preference
|
||||
const resolvedTheme: 'light' | 'dark' = theme === 'system' ? systemPreference : theme;
|
||||
const [lightUiThemeId, setLightUiThemeId] = useState<string>(() => {
|
||||
const stored = readStoredString(STORAGE_KEY_UI_THEME_LIGHT);
|
||||
@@ -418,7 +418,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
);
|
||||
});
|
||||
// Folder transfer concurrency is renderer-only (runSftpTransferWorkers).
|
||||
// Do not push it into main-process host admission — that made multi-select
|
||||
// Do not push it into main-process host admission - that made multi-select
|
||||
// top-level files queue against each other and against folder children.
|
||||
|
||||
const [sshTransportIdleTtlMs, setSshTransportIdleTtlMsState] = useState<number>(() => {
|
||||
@@ -582,7 +582,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const explorerContextMenuEnabledRef = useRef(explorerContextMenuEnabled);
|
||||
const explorerContextMenuSetRequestIdRef = useRef(0);
|
||||
|
||||
// Fix 1: Mount guard — skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Fix 1: Mount guard - skip redundant IPC broadcasts & localStorage writes on initial mount.
|
||||
// Set to true by the LAST useEffect declaration; all persist effects see false on first render.
|
||||
const persistMountedRef = useRef(false);
|
||||
const appearanceTransitionModeRef = useRef<ThemeTransitionMode>('view');
|
||||
@@ -726,7 +726,7 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
const clamped = Math.max(1, Math.min(16, Math.round(value)));
|
||||
setSftpTransferConcurrencyState(clamped);
|
||||
localStorageAdapter.writeString(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, String(clamped));
|
||||
// Intentionally not calling setGlobalTransferConcurrency — this setting
|
||||
// Intentionally not calling setGlobalTransferConcurrency - this setting
|
||||
// only caps files inside a single folder transfer job.
|
||||
notifySettingsChanged(STORAGE_KEY_SFTP_TRANSFER_CONCURRENCY, clamped);
|
||||
}, [notifySettingsChanged]);
|
||||
@@ -833,6 +833,13 @@ export const useSettingsState = (options: { enableSettingsSync?: boolean; enable
|
||||
// Terminal
|
||||
const storedTermTheme = readStoredString(STORAGE_KEY_TERM_THEME);
|
||||
if (storedTermTheme) setTerminalThemeId(storedTermTheme);
|
||||
// Cloud sync writes follow-app via applySyncableSettings; without this the
|
||||
// open window keeps the pre-sync flag while terminalThemeId updates, which
|
||||
// flickers between the local default theme and the synced one (#2757).
|
||||
const storedFollowAppTermTheme = readStoredString(STORAGE_KEY_TERM_FOLLOW_APP_THEME);
|
||||
if (storedFollowAppTermTheme === 'true' || storedFollowAppTermTheme === 'false') {
|
||||
setFollowAppTerminalThemeState(storedFollowAppTermTheme === 'true');
|
||||
}
|
||||
const storedTermThemeDark = readStoredString(STORAGE_KEY_TERM_THEME_DARK);
|
||||
if (storedTermThemeDark) setTerminalThemeDarkId(storedTermThemeDark);
|
||||
const storedTermThemeLight = readStoredString(STORAGE_KEY_TERM_THEME_LIGHT);
|
||||
|
||||
@@ -139,6 +139,56 @@ test("sync payloads preserve opaque plugin hosts without requiring the plugin to
|
||||
assert.deepEqual(imported?.hosts, [pluginHost]);
|
||||
});
|
||||
|
||||
test("applySyncPayload preserves host startup command and appearance overrides (#2757)", async () => {
|
||||
const host = {
|
||||
id: "host-1",
|
||||
label: "Prod",
|
||||
hostname: "prod.example",
|
||||
username: "root",
|
||||
port: 22,
|
||||
protocol: "ssh" as const,
|
||||
tags: [],
|
||||
os: "linux" as const,
|
||||
startupCommand: "tmux attach || tmux",
|
||||
startupCommandRunMode: "lineDelay" as const,
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
};
|
||||
const groupConfigs = [
|
||||
{
|
||||
path: "prod",
|
||||
startupCommand: "cd /srv && exec bash -l",
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
},
|
||||
];
|
||||
const payload = buildSyncPayload({
|
||||
...vault(),
|
||||
hosts: [host],
|
||||
groupConfigs,
|
||||
});
|
||||
|
||||
assert.equal(payload.hosts[0]?.startupCommand, "tmux attach || tmux");
|
||||
assert.equal(payload.hosts[0]?.theme, "solarized-dark");
|
||||
assert.equal(payload.hosts[0]?.themeOverride, true);
|
||||
assert.equal(payload.groupConfigs?.[0]?.startupCommand, "cd /srv && exec bash -l");
|
||||
|
||||
let imported: Record<string, unknown> | null = null;
|
||||
await applySyncPayload(payload, {
|
||||
importVaultData: (json) => { imported = JSON.parse(json); },
|
||||
});
|
||||
|
||||
const importedHosts = imported?.hosts as typeof payload.hosts;
|
||||
assert.equal(importedHosts?.[0]?.startupCommand, "tmux attach || tmux");
|
||||
assert.equal(importedHosts?.[0]?.startupCommandRunMode, "lineDelay");
|
||||
assert.equal(importedHosts?.[0]?.theme, "solarized-dark");
|
||||
assert.equal(importedHosts?.[0]?.themeOverride, true);
|
||||
assert.equal(
|
||||
(imported?.groupConfigs as typeof groupConfigs)?.[0]?.startupCommand,
|
||||
"cd /srv && exec bash -l",
|
||||
);
|
||||
});
|
||||
|
||||
test("buildCloudSyncPayload includes notes and note groups", async () => {
|
||||
const payload = await buildCloudSyncPayload({
|
||||
...vault([]),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* Sync Payload Builders — Single source of truth for constructing and applying
|
||||
* Sync Payload Builders - Single source of truth for constructing and applying
|
||||
* the encrypted cloud-sync payload.
|
||||
*
|
||||
* Both the main window (App.tsx) and the settings window (SettingsSyncTab.tsx)
|
||||
@@ -384,7 +384,7 @@ const mergeAiProvidersPreservingLocalApiKeys = (
|
||||
|
||||
/**
|
||||
* Same rationale as `mergeAiProvidersPreservingLocalApiKeys`. Only restores the
|
||||
* local apiKey when the incoming config still points at the same providerId —
|
||||
* local apiKey when the incoming config still points at the same providerId -
|
||||
* switching providers must not silently leak a key meant for a different one.
|
||||
*/
|
||||
const mergeWebSearchConfigPreservingLocalApiKey = (
|
||||
@@ -494,7 +494,7 @@ export function collectSyncableSettings(): SyncPayload['settings'] {
|
||||
const defaultViewMode = localStorageAdapter.readString(STORAGE_KEY_SFTP_DEFAULT_VIEW_MODE);
|
||||
if (defaultViewMode === 'list' || defaultViewMode === 'tree') settings.sftpDefaultViewMode = defaultViewMode;
|
||||
|
||||
// SFTP Bookmarks (global only — local bookmarks are device-specific)
|
||||
// SFTP Bookmarks (global only - local bookmarks are device-specific)
|
||||
const globalBookmarks = localStorageAdapter.read<SftpBookmark[]>(STORAGE_KEY_SFTP_GLOBAL_BOOKMARKS);
|
||||
if (globalBookmarks && Array.isArray(globalBookmarks)) settings.sftpGlobalBookmarks = globalBookmarks;
|
||||
|
||||
@@ -663,7 +663,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
localStorageAdapter.writeString(STORAGE_KEY_TERMINAL_SIDE_PANEL_AUTO_OPEN_TAB, settings.terminalSidePanelAutoOpenTab);
|
||||
}
|
||||
|
||||
// Terminal settings — merge with existing to preserve platform-specific keys
|
||||
// Terminal settings - merge with existing to preserve platform-specific keys
|
||||
if (settings.terminalSettings) {
|
||||
let existing: Record<string, unknown> = {};
|
||||
const raw = localStorageAdapter.readString(STORAGE_KEY_TERM_SETTINGS);
|
||||
@@ -803,7 +803,7 @@ async function applySyncableSettings(settings: NonNullable<SyncPayload['settings
|
||||
// After all AI writes, reconcile per-agent bindings against the final
|
||||
// provider list. Sync payloads can land with a new `providers` set but
|
||||
// no `agentProviderMap`, or with a stale `agentProviderMap` that
|
||||
// points at ids the synced provider set doesn't include — either way
|
||||
// points at ids the synced provider set doesn't include - either way
|
||||
// we'd leak overrides bound to ghost providers. Mirrors the same
|
||||
// cleanup `removeProvider` does for explicit user deletes.
|
||||
pruneOrphanPerAgentBindings();
|
||||
@@ -868,7 +868,7 @@ function pruneOrphanPerAgentBindings(): void {
|
||||
nextProviderMap[agentId] = providerId;
|
||||
} else {
|
||||
providerChanged = true;
|
||||
// Drop the saved model too — that id belonged to the now-missing
|
||||
// Drop the saved model too - that id belonged to the now-missing
|
||||
// provider and isn't trustworthy against any other binding.
|
||||
if (agentId in nextModelMap) {
|
||||
delete nextModelMap[agentId];
|
||||
|
||||
@@ -1,12 +1,24 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import {
|
||||
approvalArgsHaveExtraContext,
|
||||
approvalCommandWasUnwrapped,
|
||||
extractApprovalExecutionContext,
|
||||
extractDisplayCommand,
|
||||
isNestedInteractiveApprovalTarget,
|
||||
MAX_TOOL_COMMAND_TOOLTIP_CHARS,
|
||||
truncateToolCommandTooltip,
|
||||
} from './tool-call';
|
||||
|
||||
const toolCallSource = readFileSync(
|
||||
join(dirname(fileURLToPath(import.meta.url)), 'tool-call.tsx'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
// Codex (SDK) emits command_execution.command as a STRING that wraps the real
|
||||
// command in `<shell> -lc '<full>'`. Under Skills + CLI the real command is a
|
||||
// netcatty-tool-cli call. The title must unwrap the shell layer first, else the
|
||||
@@ -44,6 +56,30 @@ test('raw (unwrapped) netcatty-tool-cli exec still works', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cjs wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli.cjs" exec --session X -- "uptime"'`,
|
||||
}),
|
||||
'uptime',
|
||||
);
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"/Resources/netcatty-tool-cli.cjs" exec --session X -- "df -h"`,
|
||||
}),
|
||||
'df -h',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli.cmd wrapper still unwraps to remote command', () => {
|
||||
assert.equal(
|
||||
extractDisplayCommand({
|
||||
command: `"C:\\\\App\\\\netcatty-tool-cli.cmd" exec --session X -- "whoami"`,
|
||||
}),
|
||||
'whoami',
|
||||
);
|
||||
});
|
||||
|
||||
test('netcatty-tool-cli env -> list sessions', () => {
|
||||
assert.equal(extractDisplayCommand({ command: 'netcatty-tool-cli env' }), 'netcatty: list sessions');
|
||||
});
|
||||
@@ -70,3 +106,116 @@ test('empty / missing args -> null', () => {
|
||||
assert.equal(extractDisplayCommand(undefined), null);
|
||||
assert.equal(extractDisplayCommand({ command: '' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces session/cwd/shell without rewriting command', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
sessionId: 'term-1',
|
||||
cwd: '/var/log',
|
||||
command: ['zsh', '-lc', 'df -h | sort'],
|
||||
}),
|
||||
{ sessionId: 'term-1', cwd: '/var/log', shell: 'zsh', reason: undefined },
|
||||
);
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/bash -lc 'uptime'`,
|
||||
}),
|
||||
{ sessionId: undefined, cwd: undefined, shell: 'bash', reason: undefined },
|
||||
);
|
||||
assert.equal(extractApprovalExecutionContext({ path: '/tmp' }), null);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext reads --session from netcatty-tool-cli wrappers', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session term-9 --chat-session chat-1 -- "uptime"'`,
|
||||
}),
|
||||
{ sessionId: 'term-9', cwd: undefined, shell: 'zsh', reason: undefined },
|
||||
);
|
||||
});
|
||||
|
||||
test('extractApprovalExecutionContext surfaces Codex reason for approval review', () => {
|
||||
assert.deepEqual(
|
||||
extractApprovalExecutionContext({
|
||||
command: 'rm -rf /tmp/x',
|
||||
cwd: '/tmp',
|
||||
reason: 'Clean stale build artifacts',
|
||||
commandActions: [{ type: 'delete' }],
|
||||
}),
|
||||
{
|
||||
sessionId: undefined,
|
||||
cwd: '/tmp',
|
||||
shell: undefined,
|
||||
reason: 'Clean stale build artifacts',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test('approvalCommandWasUnwrapped detects Skills+CLI display unwrap', () => {
|
||||
const args = {
|
||||
command: `/bin/zsh -lc '"/abs/netcatty-tool-cli" exec --session X -- "uptime"'`,
|
||||
};
|
||||
const display = extractDisplayCommand(args);
|
||||
assert.equal(display, 'uptime');
|
||||
assert.equal(approvalCommandWasUnwrapped(args, display), true);
|
||||
assert.equal(approvalCommandWasUnwrapped({ command: 'uptime' }, 'uptime'), false);
|
||||
});
|
||||
|
||||
test('approvalArgsHaveExtraContext keeps commandActions visible beside the command block', () => {
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({
|
||||
command: 'echo hi',
|
||||
reason: 'demo',
|
||||
commandActions: [{ type: 'read' }],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
approvalArgsHaveExtraContext({ command: 'echo hi', cwd: '/tmp', reason: 'demo' }),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('isNestedInteractiveApprovalTarget ignores Enter on Copy/Expand review controls', () => {
|
||||
const card = { id: 'card' };
|
||||
const copyBtn = {
|
||||
closest: (selector: string) => (selector.includes('button') ? copyBtn : null),
|
||||
};
|
||||
const plainSpan = {
|
||||
closest: () => null,
|
||||
};
|
||||
|
||||
assert.equal(isNestedInteractiveApprovalTarget(copyBtn, card), true);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(plainSpan, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(card, card), false);
|
||||
assert.equal(isNestedInteractiveApprovalTarget(null, card), false);
|
||||
});
|
||||
|
||||
// Pending approvals with no display command (Codex file-change/permissions,
|
||||
// write tools with JSON-only args) only render the overflow args <pre>. Wheel /
|
||||
// trackpad scroll must re-arm idle the same way the command block does.
|
||||
test('args overflow block re-arms review timers on scroll/wheel while pending', () => {
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/JSON\.stringify\(args,\s*null,\s*2\)[\s\S]{0,200}?<\/pre>/,
|
||||
);
|
||||
// Both review surfaces (command + args) wire markReviewing for continuous scroll.
|
||||
const markReviewingScrollBindings = toolCallSource.match(
|
||||
/onScroll=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
const markReviewingWheelBindings = toolCallSource.match(
|
||||
/onWheel=\{(?:isPendingApproval \? markReviewing : undefined|markReviewing)\}/g,
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingScrollBindings && markReviewingScrollBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onScroll',
|
||||
);
|
||||
assert.ok(
|
||||
markReviewingWheelBindings && markReviewingWheelBindings.length >= 2,
|
||||
'command and args overflow blocks must both call markReviewing onWheel',
|
||||
);
|
||||
assert.match(
|
||||
toolCallSource,
|
||||
/onScroll=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}onWheel=\{isPendingApproval \? markReviewing : undefined\}[\s\S]{0,80}JSON\.stringify\(args/,
|
||||
);
|
||||
});
|
||||
@@ -1,13 +1,36 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { HTMLAttributes } from 'react';
|
||||
import { cn } from '../../lib/utils';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Check, ChevronDown, ChevronRight, CheckCircle2, Copy, Loader2, ShieldAlert, X, XCircle, Slash } from 'lucide-react';
|
||||
import { Button } from '../ui/button';
|
||||
import { Badge } from '../ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
|
||||
import { useI18n } from '../../application/i18n/I18nProvider';
|
||||
import { cancelApprovalTimeout } from '../../infrastructure/ai/shared/approvalGate';
|
||||
|
||||
export const MAX_TOOL_COMMAND_TOOLTIP_CHARS = 240;
|
||||
/** Collapsed approval command block max height (px). Full text remains scrollable. */
|
||||
export const APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX = 144;
|
||||
/** Expanded approval command block max height (px). */
|
||||
export const APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX = 384;
|
||||
/** Prefer expand control when the raw command exceeds this many characters. */
|
||||
export const APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD = 180;
|
||||
|
||||
const NESTED_INTERACTIVE_SELECTOR = 'button, a, input, textarea, select, [role="button"]';
|
||||
|
||||
/**
|
||||
* Enter on the pending-card root means Approve Once. Enter on nested review
|
||||
* controls (Copy / Expand / action buttons) must not approve — those controls
|
||||
* also stopPropagation on Enter so the card handler is a second line of defense.
|
||||
*/
|
||||
export function isNestedInteractiveApprovalTarget(
|
||||
target: { closest?: (selector: string) => unknown } | null,
|
||||
currentTarget: unknown,
|
||||
): boolean {
|
||||
if (!target || target === currentTarget) return false;
|
||||
if (typeof target.closest !== 'function') return false;
|
||||
return Boolean(target.closest(NESTED_INTERACTIVE_SELECTOR));
|
||||
}
|
||||
|
||||
export function truncateToolCommandTooltip(
|
||||
command: string,
|
||||
@@ -72,10 +95,14 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
if (strWrap) cmdString = strWrap[2];
|
||||
|
||||
// Netcatty CLI wrapper extraction.
|
||||
const cliIdx = cmdString.indexOf('netcatty-tool-cli');
|
||||
// Packaged / Windows paths may be `netcatty-tool-cli.cjs` or `.cmd`; strip the
|
||||
// optional extension so the subcommand after the binary is still found.
|
||||
const cliIdx = cmdString.search(/netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
if (cliIdx >= 0) {
|
||||
const cliMatch = cmdString.slice(cliIdx).match(/^netcatty-tool-cli(?:\.(?:cjs|cmd|exe|js))?/i);
|
||||
const cliTokenLen = cliMatch?.[0]?.length ?? 'netcatty-tool-cli'.length;
|
||||
const afterCli = cmdString
|
||||
.slice(cliIdx + 'netcatty-tool-cli'.length)
|
||||
.slice(cliIdx + cliTokenLen)
|
||||
.replace(/^["']?\s*/, '');
|
||||
const subMatch = afterCli.match(/^(\S+)/);
|
||||
const sub = subMatch ? subMatch[1] : '';
|
||||
@@ -106,6 +133,110 @@ export function extractDisplayCommand(args: Record<string, unknown> | undefined)
|
||||
return cmdString;
|
||||
}
|
||||
|
||||
export interface ApprovalExecutionContext {
|
||||
sessionId?: string;
|
||||
cwd?: string;
|
||||
shell?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
function rawCommandString(args: Record<string, unknown> | undefined): string | null {
|
||||
if (!args) return null;
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (typeof raw === 'string') return raw || null;
|
||||
if (Array.isArray(raw) && raw.length > 0) return raw.map((p) => String(p)).join(' ');
|
||||
return null;
|
||||
}
|
||||
|
||||
const APPROVAL_CONTEXT_ARG_KEYS = new Set([
|
||||
'command',
|
||||
'cwd',
|
||||
'working_directory',
|
||||
'workdir',
|
||||
'workingDirectory',
|
||||
'sessionId',
|
||||
'shell',
|
||||
'reason',
|
||||
]);
|
||||
|
||||
/**
|
||||
* True when pending args still carry review-relevant fields beyond the
|
||||
* command block / execution-context strip (e.g. commandActions).
|
||||
*/
|
||||
export function approvalArgsHaveExtraContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): boolean {
|
||||
if (!args) return false;
|
||||
return Object.keys(args).some((key) => !APPROVAL_CONTEXT_ARG_KEYS.has(key));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the reviewable display command was unwrapped from a Skills+CLI /
|
||||
* shell wrapper — the pending card should still surface target flags.
|
||||
*/
|
||||
export function approvalCommandWasUnwrapped(
|
||||
args: Record<string, unknown> | undefined,
|
||||
displayCommand: string | null,
|
||||
): boolean {
|
||||
if (!displayCommand) return false;
|
||||
const raw = rawCommandString(args);
|
||||
if (!raw || raw === displayCommand) return false;
|
||||
return raw.includes('netcatty-tool-cli') || /(?:^|\/)(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/.test(raw)
|
||||
|| (Array.isArray(args?.command) && args.command.length >= 3);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort execution context for approval review (session / cwd / shell).
|
||||
* Never invents host names; only surfaces fields already present on tool args
|
||||
* or explicit netcatty-tool-cli flags in the command string.
|
||||
*/
|
||||
export function extractApprovalExecutionContext(
|
||||
args: Record<string, unknown> | undefined,
|
||||
): ApprovalExecutionContext | null {
|
||||
if (!args) return null;
|
||||
|
||||
let sessionId = typeof args.sessionId === 'string' && args.sessionId.trim()
|
||||
? args.sessionId.trim()
|
||||
: undefined;
|
||||
|
||||
const cwdCandidate = [args.cwd, args.working_directory, args.workdir, args.workingDirectory]
|
||||
.find((value) => typeof value === 'string' && value.trim());
|
||||
const cwd = typeof cwdCandidate === 'string' ? cwdCandidate.trim() : undefined;
|
||||
|
||||
let shell = typeof args.shell === 'string' && args.shell.trim()
|
||||
? args.shell.trim()
|
||||
: undefined;
|
||||
|
||||
const reason = typeof args.reason === 'string' && args.reason.trim()
|
||||
? args.reason.trim()
|
||||
: undefined;
|
||||
|
||||
const raw = (args as { command?: unknown }).command;
|
||||
if (!shell) {
|
||||
if (Array.isArray(raw) && raw.length >= 2) {
|
||||
const first = String(raw[0] ?? '');
|
||||
const shellMatch = first.match(/(?:^|\/)(sh|bash|zsh|fish|ash|dash)$/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
} else if (typeof raw === 'string') {
|
||||
const shellMatch = raw.match(/^(?:\S*\/)?(sh|bash|zsh|fish|ash|dash)\s+-l?c\s+/);
|
||||
if (shellMatch) shell = shellMatch[1];
|
||||
}
|
||||
}
|
||||
|
||||
// Skills+CLI wrappers keep the Netcatty target only on CLI flags after unwrap.
|
||||
if (!sessionId) {
|
||||
const cmd = rawCommandString(args);
|
||||
if (cmd && cmd.includes('netcatty-tool-cli')) {
|
||||
const sessionMatch = cmd.match(/--session(?:\s+|=)(?:"([^"]+)"|'([^']+)'|(\S+))/);
|
||||
const fromFlag = sessionMatch?.[1] ?? sessionMatch?.[2] ?? sessionMatch?.[3];
|
||||
if (fromFlag) sessionId = fromFlag;
|
||||
}
|
||||
}
|
||||
|
||||
if (!sessionId && !cwd && !shell && !reason) return null;
|
||||
return { sessionId, cwd, shell, reason };
|
||||
}
|
||||
|
||||
/**
|
||||
* Format tool result for display. Extracts stdout/stderr from structured
|
||||
* command results for terminal-like output.
|
||||
@@ -149,6 +280,8 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
isInterrupted?: boolean;
|
||||
/** Approval state for this tool call (from the approval gate). */
|
||||
approvalStatus?: 'pending' | 'approved' | 'denied';
|
||||
/** Pending approval id used to cancel the auto-deny timer on review. */
|
||||
approvalId?: string;
|
||||
/** Called when user approves this tool call. */
|
||||
onApprove?: () => void;
|
||||
/** Called when user rejects this tool call. */
|
||||
@@ -161,18 +294,72 @@ export interface ToolCallProps extends HTMLAttributes<HTMLDivElement> {
|
||||
alwaysAllowLabel?: string;
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
}
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
try {
|
||||
if (typeof document === 'undefined') return false;
|
||||
const el = document.createElement('textarea');
|
||||
el.value = text;
|
||||
el.setAttribute('readonly', '');
|
||||
el.style.position = 'fixed';
|
||||
el.style.left = '-9999px';
|
||||
document.body.appendChild(el);
|
||||
el.select();
|
||||
const ok = document.execCommand('copy');
|
||||
document.body.removeChild(el);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export const ToolCall = ({
|
||||
name, args, result, isError, isLoading, isInterrupted,
|
||||
approvalStatus, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
approvalStatus, approvalId, onApprove, onReject, onApproveOnce, onAlwaysAllow, alwaysAllowLabel,
|
||||
className, ...props
|
||||
}: ToolCallProps) => {
|
||||
const { t } = useI18n();
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [commandExpanded, setCommandExpanded] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [frozenCommand, setFrozenCommand] = useState<string | null>(null);
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const approveBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const [responded, setResponded] = useState(false);
|
||||
|
||||
const isPendingApproval = approvalStatus === 'pending' && !responded;
|
||||
const liveDisplayCommand = extractDisplayCommand(args);
|
||||
const reviewCommand = isPendingApproval
|
||||
? (frozenCommand ?? liveDisplayCommand)
|
||||
: liveDisplayCommand;
|
||||
const executionContext = extractApprovalExecutionContext(args);
|
||||
const showApprovalCommand = Boolean(isPendingApproval && reviewCommand);
|
||||
const showArgsAlongsideCommand = Boolean(
|
||||
showApprovalCommand
|
||||
&& args
|
||||
&& Object.keys(args).length > 0
|
||||
&& (approvalCommandWasUnwrapped(args, reviewCommand) || approvalArgsHaveExtraContext(args)),
|
||||
);
|
||||
const commandNeedsExpand = Boolean(
|
||||
reviewCommand
|
||||
&& (reviewCommand.length > APPROVAL_COMMAND_EXPAND_CHAR_THRESHOLD
|
||||
|| reviewCommand.includes('\n')),
|
||||
);
|
||||
|
||||
// Each review interaction re-arms the Catty idle window (capped by hard
|
||||
// deadline). Do not one-shot cancel — subsequent focus/scroll/key events
|
||||
// must keep extending idle while the user is still deciding.
|
||||
const markReviewing = useCallback(() => {
|
||||
if (!isPendingApproval || !approvalId) return;
|
||||
cancelApprovalTimeout(approvalId);
|
||||
}, [approvalId, isPendingApproval]);
|
||||
|
||||
const handleApproveOnce = useCallback(() => {
|
||||
if (!isPendingApproval) return;
|
||||
@@ -192,28 +379,68 @@ export const ToolCall = ({
|
||||
onReject?.();
|
||||
}, [isPendingApproval, onReject]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending)
|
||||
const handleCopyCommand = useCallback(async () => {
|
||||
if (!reviewCommand) return;
|
||||
markReviewing();
|
||||
const ok = await copyTextToClipboard(reviewCommand);
|
||||
if (!ok) return;
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
}, [markReviewing, reviewCommand]);
|
||||
|
||||
// Keyboard: Enter = approve, Escape = reject (when pending).
|
||||
// Ignore Enter from nested controls (Copy / Expand / action buttons) so it
|
||||
// activates that control instead of approving the pending command.
|
||||
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
|
||||
if (!isPendingApproval) return;
|
||||
if (e.key === 'Enter') { e.preventDefault(); handleApproveOnce(); }
|
||||
else if (e.key === 'Escape') { e.preventDefault(); handleReject(); }
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject]);
|
||||
if (e.key === 'Enter') {
|
||||
if (isNestedInteractiveApprovalTarget(e.target as HTMLElement | null, e.currentTarget)) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
handleApproveOnce();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleReject();
|
||||
} else {
|
||||
// Typing / navigation while reviewing cancels the idle auto-deny timer.
|
||||
markReviewing();
|
||||
}
|
||||
}, [isPendingApproval, handleApproveOnce, handleReject, markReviewing]);
|
||||
|
||||
// Auto-focus and auto-scroll when approval is pending
|
||||
// Auto-focus and auto-scroll when approval is pending.
|
||||
// Do not treat this programmatic expand/focus as user review (timeout stays armed).
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || !cardRef.current) return;
|
||||
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
||||
// Small delay to let the UI render, then expand and focus
|
||||
setExpanded(true);
|
||||
const focusTimer = setTimeout(() => approveBtnRef.current?.focus(), 100);
|
||||
return () => clearTimeout(focusTimer);
|
||||
}, [isPendingApproval]);
|
||||
|
||||
// Reset responded state when approvalStatus changes (e.g. new approval)
|
||||
// Freeze the reviewable command for the life of this pending approval.
|
||||
// Do not reset review/timeout state when args identity churns while still pending.
|
||||
useEffect(() => {
|
||||
if (approvalStatus === 'pending') setResponded(false);
|
||||
if (approvalStatus === 'pending') {
|
||||
setResponded(false);
|
||||
setCommandExpanded(false);
|
||||
setFrozenCommand(extractDisplayCommand(args));
|
||||
return;
|
||||
}
|
||||
setFrozenCommand(null);
|
||||
setCommandExpanded(false);
|
||||
// Intentionally depend only on approvalStatus so late arg patches cannot
|
||||
// replace the command the user is already reviewing.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- freeze on pending enter
|
||||
}, [approvalStatus]);
|
||||
|
||||
// If the first pending paint had no command yet, accept the first non-empty one.
|
||||
useEffect(() => {
|
||||
if (!isPendingApproval || frozenCommand) return;
|
||||
const next = extractDisplayCommand(args);
|
||||
if (next) setFrozenCommand(next);
|
||||
}, [args, frozenCommand, isPendingApproval]);
|
||||
|
||||
// Border/bg color based on approval status
|
||||
const borderClass = approvalStatus === 'pending'
|
||||
? 'border-yellow-500/30 bg-yellow-500/[0.04]'
|
||||
@@ -236,46 +463,48 @@ export const ToolCall = ({
|
||||
<CheckCircle2 size={12} className={cn('text-green-400/70', statusIconClass)} />
|
||||
) : null;
|
||||
|
||||
const headerCommand = reviewCommand ?? liveDisplayCommand;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={cardRef}
|
||||
tabIndex={isPendingApproval ? 0 : undefined}
|
||||
onKeyDown={isPendingApproval ? handleKeyDown : undefined}
|
||||
onPointerDownCapture={isPendingApproval ? markReviewing : undefined}
|
||||
className={cn('min-w-0 rounded-md border overflow-hidden text-[12px] outline-none', borderClass, className)}
|
||||
{...props}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
onClick={() => {
|
||||
if (isPendingApproval) markReviewing();
|
||||
setExpanded((e) => !e);
|
||||
}}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 hover:bg-muted/20 transition-colors cursor-pointer"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
: <ChevronRight size={12} className="text-muted-foreground/40 shrink-0" />
|
||||
}
|
||||
{(() => {
|
||||
const displayCmd = extractDisplayCommand(args);
|
||||
if (displayCmd) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{displayCmd}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(displayCmd)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return <span className="font-mono text-muted-foreground/70 truncate">{name}</span>;
|
||||
})()}
|
||||
{headerCommand ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="font-mono text-muted-foreground/70 truncate cursor-default">
|
||||
<span className="text-muted-foreground/40">$ </span>{headerCommand}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
align="start"
|
||||
collisionPadding={12}
|
||||
className="w-[calc(100vw-24px)] max-w-[420px] whitespace-pre-wrap break-words font-mono text-[11px] leading-relaxed [overflow-wrap:anywhere]"
|
||||
>
|
||||
{truncateToolCommandTooltip(headerCommand)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="font-mono text-muted-foreground/70 truncate">{name}</span>
|
||||
)}
|
||||
<span className="flex-1" />
|
||||
{/* Approval badge for resolved approvals */}
|
||||
{approvalStatus === 'approved' && (
|
||||
@@ -293,10 +522,103 @@ export const ToolCall = ({
|
||||
|
||||
{expanded && (
|
||||
<div className="border-t border-border/20">
|
||||
{args && Object.keys(args).length > 0 && (
|
||||
{showApprovalCommand && reviewCommand && (
|
||||
<div className="px-3 py-2 space-y-1.5">
|
||||
{executionContext && (
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-[10px] text-muted-foreground/45">
|
||||
<span className="font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.targetLabel')}
|
||||
</span>
|
||||
{executionContext.sessionId && (
|
||||
<span className="font-mono truncate" title={executionContext.sessionId}>
|
||||
{t('ai.chat.approvalSession')}: {executionContext.sessionId}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.shell && (
|
||||
<span className="font-mono">
|
||||
{t('ai.chat.approvalShell')}: {executionContext.shell}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.cwd && (
|
||||
<span className="font-mono truncate" title={executionContext.cwd}>
|
||||
{t('ai.chat.approvalCwd')}: {executionContext.cwd}
|
||||
</span>
|
||||
)}
|
||||
{executionContext.reason && (
|
||||
<span className="truncate" title={executionContext.reason}>
|
||||
{t('ai.chat.approvalReason')}: {executionContext.reason}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30">
|
||||
{t('ai.chat.rawCommand')}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{commandNeedsExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => {
|
||||
markReviewing();
|
||||
setCommandExpanded((v) => !v);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Keep Enter on this control; let Escape bubble to card reject.
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
{commandExpanded ? t('ai.chat.collapse') : t('ai.chat.expand')}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-1 text-[10px] text-muted-foreground/50 hover:text-muted-foreground px-1.5 py-0.5 rounded hover:bg-muted/30"
|
||||
onClick={() => { void handleCopyCommand(); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
<Copy size={10} className="shrink-0" />
|
||||
{copied ? t('ai.chat.commandCopied') : t('ai.chat.copyCommand')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre
|
||||
className={cn(
|
||||
'overflow-auto rounded-md border border-border/25 bg-muted/20 px-2.5 py-2',
|
||||
'text-[11px] font-mono leading-relaxed text-foreground/80',
|
||||
'whitespace-pre-wrap break-words [overflow-wrap:anywhere]',
|
||||
)}
|
||||
style={{
|
||||
maxHeight: commandExpanded
|
||||
? APPROVAL_COMMAND_EXPANDED_MAX_HEIGHT_PX
|
||||
: APPROVAL_COMMAND_COLLAPSED_MAX_HEIGHT_PX,
|
||||
}}
|
||||
onScroll={markReviewing}
|
||||
onWheel={markReviewing}
|
||||
>
|
||||
{reviewCommand}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{args && Object.keys(args).length > 0 && (!showApprovalCommand || showArgsAlongsideCommand) && (
|
||||
<div className="px-3 py-2">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">Arguments</div>
|
||||
<pre className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]">
|
||||
<div className="text-[10px] font-medium uppercase tracking-wider text-muted-foreground/30 mb-1">
|
||||
{showArgsAlongsideCommand ? t('ai.chat.approvalInvocation') : 'Arguments'}
|
||||
</div>
|
||||
{/*
|
||||
Args-only approvals (Codex file-change/permissions, write tools with
|
||||
JSON args) have no command pre — wheel/trackpad scroll must re-arm
|
||||
idle the same way the command overflow block does.
|
||||
*/}
|
||||
<pre
|
||||
className="max-h-64 overflow-auto text-[11px] font-mono text-muted-foreground/50 whitespace-pre [overflow-wrap:normal]"
|
||||
onScroll={isPendingApproval ? markReviewing : undefined}
|
||||
onWheel={isPendingApproval ? markReviewing : undefined}
|
||||
>
|
||||
{JSON.stringify(args, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -478,6 +478,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
args={request.args}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={approvalId}
|
||||
onApproveOnce={() => handleApproveOnce(approvalId)}
|
||||
onAlwaysAllow={request.allowSession === false
|
||||
? undefined
|
||||
@@ -509,6 +510,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isInterrupted={options.historical ? !isPending : undefined}
|
||||
isLoading={options.historical ? undefined : Boolean(options.isToolRunning && !isPending)}
|
||||
approvalStatus={approvalStatus}
|
||||
approvalId={isPending ? toolCall.id : undefined}
|
||||
onApproveOnce={() => handleApproveOnce(toolCall.id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(toolCall.id, pendingRequest ?? {
|
||||
toolCallId: toolCall.id,
|
||||
@@ -813,6 +815,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus={'pending'}
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
@@ -832,6 +835,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={approvalId}
|
||||
onApproveOnce={() => handleApproveOnce(approvalId)}
|
||||
onAlwaysAllow={request.allowSession === false
|
||||
? undefined
|
||||
|
||||
@@ -100,6 +100,7 @@ export const ExternalMcpApprovalsHost: React.FC = () => {
|
||||
isLoading={false}
|
||||
isInterrupted={false}
|
||||
approvalStatus="pending"
|
||||
approvalId={id}
|
||||
onApproveOnce={() => handleApproveOnce(id)}
|
||||
onAlwaysAllow={() => handleAlwaysAllow(id, req)}
|
||||
onReject={() => handleReject(id)}
|
||||
|
||||
@@ -175,6 +175,8 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
const [crashLogClearResult, setCrashLogClearResult] = useState<{ deletedCount: number } | null>(null);
|
||||
const [sshDebugLogInfo, setSshDebugLogInfo] = useState<SshDebugLogInfo | null>(null);
|
||||
const [isLoadingSshDebugLogInfo, setIsLoadingSshDebugLogInfo] = useState(false);
|
||||
const [isClearingSessionLogs, setIsClearingSessionLogs] = useState(false);
|
||||
const [sessionLogsClearResult, setSessionLogsClearResult] = useState<{ deletedCount: number; failedCount: number } | null>(null);
|
||||
|
||||
const [appVersion, setAppVersion] = useState('');
|
||||
|
||||
@@ -355,6 +357,25 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
}
|
||||
}, [sessionLogsDir]);
|
||||
|
||||
const handleClearSessionLogs = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!sessionLogsDir || !bridge?.clearSessionLogsDir) return;
|
||||
if (!window.confirm(t("settings.sessionLogs.clearConfirm"))) return;
|
||||
|
||||
setIsClearingSessionLogs(true);
|
||||
setSessionLogsClearResult(null);
|
||||
try {
|
||||
const result = await bridge.clearSessionLogsDir(sessionLogsDir);
|
||||
if (result.success) {
|
||||
setSessionLogsClearResult({ deletedCount: result.deletedCount, failedCount: result.failedCount });
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[SettingsSystemTab] Failed to clear session logs:", err);
|
||||
} finally {
|
||||
setIsClearingSessionLogs(false);
|
||||
}
|
||||
}, [sessionLogsDir, t]);
|
||||
|
||||
const handleOpenSshDebugLogDir = useCallback(async () => {
|
||||
const bridge = netcattyBridge.get();
|
||||
if (!bridge?.openSshDebugLogDir) return;
|
||||
@@ -984,6 +1005,34 @@ const SettingsSystemTab: React.FC<SettingsSystemTabProps> = ({
|
||||
onChange={setSessionLogsTimestampsEnabled}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Clear All Logs */}
|
||||
<div className="space-y-2 pt-2 border-t border-border/60">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">{t("settings.sessionLogs.clearAll")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("settings.sessionLogs.clearAllDesc")}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleClearSessionLogs}
|
||||
disabled={isClearingSessionLogs || !sessionLogsDir}
|
||||
className="gap-1.5 shrink-0 text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{isClearingSessionLogs ? t("settings.system.clearing") : t("settings.sessionLogs.clearAll")}
|
||||
</Button>
|
||||
</div>
|
||||
{sessionLogsClearResult && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("settings.system.clearResult", {
|
||||
deleted: sessionLogsClearResult.deletedCount,
|
||||
failed: sessionLogsClearResult.failedCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</SettingCard>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
@@ -18,6 +21,11 @@ import {
|
||||
import type { SftpPane } from "../../application/state/sftp/types.ts";
|
||||
import { TooltipProvider } from "../ui/tooltip.tsx";
|
||||
|
||||
const toolbarSource = fs.readFileSync(
|
||||
path.join(path.dirname(fileURLToPath(import.meta.url)), "SftpPaneToolbar.tsx"),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("single SFTP view-mode button toggles to the other mode", () => {
|
||||
assert.equal(getNextSftpViewMode("list"), "tree");
|
||||
assert.equal(getNextSftpViewMode("tree"), "list");
|
||||
@@ -36,7 +44,7 @@ test("narrow SFTP toolbar spills non-pinned show items into overflow without cha
|
||||
assert.ok(!narrow.inlineIds.includes("newFolder"));
|
||||
assert.ok(narrow.overflowIds.includes("newFolder"));
|
||||
assert.ok(narrow.overflowIds.includes("encoding"));
|
||||
// hide is already excluded from shown/collapsed by partition — not reintroduced here
|
||||
// hide is already excluded from shown/collapsed by partition - not reintroduced here
|
||||
assert.ok(!narrow.inlineIds.includes("encoding"));
|
||||
});
|
||||
|
||||
@@ -132,7 +140,6 @@ test("toolbar renders one view-mode toggle instead of separate list and tree but
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
startTransition: (callback: () => void) => callback(),
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
@@ -211,7 +218,6 @@ test("toolbar exposes copy-current-path action for the active directory", () =>
|
||||
handlePathKeyDown: () => {},
|
||||
handlePathDoubleClick: () => {},
|
||||
handlePathSubmit: () => {},
|
||||
startTransition: (callback: () => void) => callback(),
|
||||
getNextUntitledName: () => "untitled",
|
||||
setNewFileName: () => {},
|
||||
setFileNameError: () => {},
|
||||
@@ -277,6 +283,105 @@ test("copy-current-path action reports clipboard failures", async () => {
|
||||
assert.equal(errorMessage, "Could not copy current path");
|
||||
});
|
||||
|
||||
test("SFTP filter input guards CJK IME composition instead of deferring controlled value writes", () => {
|
||||
assert.match(toolbarSource, /onCompositionStart=\{/);
|
||||
assert.match(toolbarSource, /onCompositionEnd=\{/);
|
||||
assert.match(toolbarSource, /shouldCommitImeControlledChange/);
|
||||
assert.match(toolbarSource, /value=\{filterDraft\}/);
|
||||
assert.doesNotMatch(
|
||||
toolbarSource,
|
||||
/onChange=\{\(e\) => startTransition\(\(\) => onSetFilter\(e\.target\.value\)\)\}/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter honors an external filter change over a stale draft when composition ends", () => {
|
||||
// If navigation clears pane.filter mid-composition, compositionEnd must adopt the
|
||||
// external value instead of committing the stale draft, preserving the invariant
|
||||
// that different-directory navigation clears the filter.
|
||||
assert.match(toolbarSource, /filterAtComposeStartRef\.current = pane\.filter;/);
|
||||
assert.match(toolbarSource, /filterPathAtComposeStartRef\.current = pane\.connection\?\.currentPath/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/pane\.filter !== filterAtComposeStartRef\.current\s*\|\|\s*pathChangedDuringCompose\s*\|\|\s*filterCompositionSupersededRef\.current/,
|
||||
);
|
||||
assert.match(toolbarSource, /filterCompositionSupersededRef\.current = true;/);
|
||||
assert.match(toolbarSource, /setFilterDraft\(pane\.filter\);/);
|
||||
});
|
||||
|
||||
test("SFTP filter adopts external navigation-cleared filters during an open IME composition", () => {
|
||||
// Sync path must pass valueAtComposeStart while composing so pane.filter="" from
|
||||
// different-directory navigation supersedes the draft mid-composition.
|
||||
assert.match(toolbarSource, /valueAtComposeStart:\s*composing/);
|
||||
assert.match(toolbarSource, /filterAtComposeStartRef\.current/);
|
||||
assert.match(toolbarSource, /pathChangedDuringCompose/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/filterCompositionSupersededRef\.current = true;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter supersedes IME draft on path navigation even when committed filter was already empty", () => {
|
||||
// When the filter was already "" at compose start, navigation sets filter to ""
|
||||
// again - pane.filter does not change - so path-at-compose-start must drive
|
||||
// supersede; otherwise compositionend commits the stale draft.
|
||||
assert.match(toolbarSource, /filterPathAtComposeStartRef/);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/currentPath !== filterPathAtComposeStartRef\.current/,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/\[pane\.filter, pane\.connection\?\.currentPath\]/,
|
||||
);
|
||||
// Hide-filter reset must still clear composing + supersede and resync draft.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter suppresses the post-composition onChange after external supersede", () => {
|
||||
// Browsers may fire onChange with composing=false after compositionend; that event
|
||||
// must not re-commit the stale composed draft over a navigation-cleared filter.
|
||||
assert.match(toolbarSource, /resolveSupersededImeInputEvent/);
|
||||
assert.match(toolbarSource, /superseded\.ignoreEventValue/);
|
||||
assert.match(toolbarSource, /compositionExternallySuperseded:\s*filterCompositionSupersededRef\.current/);
|
||||
});
|
||||
|
||||
test("SFTP filter clears the supersede latch if no post-composition change arrives", () => {
|
||||
// Some IME/browser paths never fire the post-compositionend onChange that would
|
||||
// clear filterCompositionSupersededRef; without a deferred clear, the next
|
||||
// ordinary keystroke is treated as the stale supersede follow-up and dropped.
|
||||
// Guard the clear so an intervening onChange clear or new compositionstart wins.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/filterCompositionSupersededRef\.current = true;\s*setFilterDraft\(pane\.filter\);\s*window\.setTimeout\(\(\) => \{/,
|
||||
);
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/if \(\s*filterCompositionSupersededRef\.current\s*&&\s*!filterComposingRef\.current\s*\) \{\s*filterCompositionSupersededRef\.current = false;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter commit path clears the IME composing guard so clears/commits can't leave it stuck", () => {
|
||||
// commitFilterValue backs composition end, Escape, inline clear and close; it must
|
||||
// drop the guard so a programmatic clear mid-composition isn't blocked or undone.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/const commitFilterValue = useCallback\(\(value: string\) => \{[\s\S]*?filterComposingRef\.current = false;[\s\S]*?filterCompositionSupersededRef\.current = false;/,
|
||||
);
|
||||
});
|
||||
|
||||
test("SFTP filter clears the IME composing guard and resyncs the draft when the filter bar closes", () => {
|
||||
// If the input unmounts mid-composition, compositionend never fires; the guard
|
||||
// must be reset (or every later onSetFilter is blocked) and the draft resynced to
|
||||
// the committed filter so a reopened bar never shows stale, uncommitted text.
|
||||
assert.match(
|
||||
toolbarSource,
|
||||
/if \(!showFilterBar\) \{\s*filterComposingRef\.current = false;\s*filterCompositionSupersededRef\.current = false;\s*setFilterDraft\(pane\.filter\);/,
|
||||
);
|
||||
});
|
||||
|
||||
test("toolbar display path keeps the previous confirmed path while loading the same connection", () => {
|
||||
assert.equal(
|
||||
getNextSftpToolbarDisplayPath({
|
||||
|
||||
@@ -18,6 +18,11 @@ import type { SftpFilenameEncoding } from "../../types";
|
||||
import type { SftpPane } from "../../application/state/sftp/types";
|
||||
import type { SftpBookmark } from "../../domain/models";
|
||||
import { isWindowsPath } from "../../application/state/sftp/utils";
|
||||
import {
|
||||
resolveSupersededImeInputEvent,
|
||||
shouldAdoptExternalImeControlledValue,
|
||||
shouldCommitImeControlledChange,
|
||||
} from "../../domain/imeControlledInput";
|
||||
import { toast } from "../ui/toast";
|
||||
|
||||
export const SFTP_TOOLBAR_ITEM_IDS = [
|
||||
@@ -193,7 +198,6 @@ interface SftpPaneToolbarProps {
|
||||
handlePathKeyDown: (e: React.KeyboardEvent) => void;
|
||||
handlePathDoubleClick: () => void;
|
||||
handlePathSubmit: (pathOverride?: string) => void;
|
||||
startTransition: React.TransitionStartFunction;
|
||||
getNextUntitledName: (existingNames: string[]) => string;
|
||||
setNewFileName: (value: string) => void;
|
||||
setFileNameError: (value: string | null) => void;
|
||||
@@ -303,7 +307,6 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
handlePathKeyDown,
|
||||
handlePathDoubleClick,
|
||||
handlePathSubmit,
|
||||
startTransition,
|
||||
getNextUntitledName,
|
||||
setNewFileName,
|
||||
setFileNameError,
|
||||
@@ -327,6 +330,16 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
onListDrives,
|
||||
}) => {
|
||||
const [displayPath, setDisplayPath] = useState(pane.connection?.currentPath ?? "");
|
||||
const [filterDraft, setFilterDraft] = useState(pane.filter);
|
||||
const filterComposingRef = useRef(false);
|
||||
const filterAtComposeStartRef = useRef(pane.filter);
|
||||
// Directory at compositionstart. Navigation that leaves the filter already ""
|
||||
// does not change pane.filter, so path is the signal that must supersede the
|
||||
// draft (compositionend would otherwise commit stale IME text).
|
||||
const filterPathAtComposeStartRef = useRef(pane.connection?.currentPath ?? "");
|
||||
// Set when pane.filter / path changes externally during (or just after)
|
||||
// composition so the post-compositionend onChange cannot re-commit a stale draft.
|
||||
const filterCompositionSupersededRef = useRef(false);
|
||||
const prevDisplayConnectionIdRef = useRef(pane.connection?.id);
|
||||
const toolbarLayout = useToolbarItemLayout(
|
||||
STORAGE_KEY_SFTP_TOOLBAR_LAYOUT,
|
||||
@@ -347,6 +360,60 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
);
|
||||
}, [pane.connection?.currentPath, pane.connection?.id, pane.loading]);
|
||||
|
||||
useEffect(() => {
|
||||
setFilterDraft((draftValue) => {
|
||||
const composing = filterComposingRef.current;
|
||||
const currentPath = pane.connection?.currentPath ?? "";
|
||||
const pathChangedDuringCompose = composing
|
||||
&& currentPath !== filterPathAtComposeStartRef.current;
|
||||
const shouldAdopt = shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: composing,
|
||||
draftValue,
|
||||
externalValue: pane.filter,
|
||||
// Allow navigation-cleared filters to supersede an open IME composition so
|
||||
// compositionend / post-composition onChange cannot resurrect the draft.
|
||||
valueAtComposeStart: composing
|
||||
? filterAtComposeStartRef.current
|
||||
: undefined,
|
||||
}) || pathChangedDuringCompose;
|
||||
if (
|
||||
shouldAdopt
|
||||
&& composing
|
||||
&& (
|
||||
pane.filter !== filterAtComposeStartRef.current
|
||||
|| pathChangedDuringCompose
|
||||
)
|
||||
) {
|
||||
filterCompositionSupersededRef.current = true;
|
||||
}
|
||||
return shouldAdopt ? pane.filter : draftValue;
|
||||
});
|
||||
}, [pane.filter, pane.connection?.currentPath]);
|
||||
|
||||
// The filter input only mounts while the bar is open, so a composition that is
|
||||
// still active when the bar closes never fires `compositionend`. Clear the guard
|
||||
// (otherwise a stuck `true` blocks every later commit) and resync the draft to the
|
||||
// committed filter so a reopened bar never shows stale, uncommitted text.
|
||||
useEffect(() => {
|
||||
if (!showFilterBar) {
|
||||
filterComposingRef.current = false;
|
||||
filterCompositionSupersededRef.current = false;
|
||||
setFilterDraft(pane.filter);
|
||||
}
|
||||
}, [showFilterBar, pane.filter]);
|
||||
|
||||
const commitFilterValue = useCallback((value: string) => {
|
||||
// Committing/clearing finalizes any IME session, so drop the guard here. This
|
||||
// covers every commit path (composition end, Escape, inline clear, close) so a
|
||||
// stale composing flag can't block later commits or resurrect a pre-clear value.
|
||||
filterComposingRef.current = false;
|
||||
filterCompositionSupersededRef.current = false;
|
||||
setFilterDraft(value);
|
||||
// Keep parent filter in lockstep with the input. Deferred updates against a
|
||||
// controlled value fight CJK IME composition and can echo/drop candidates.
|
||||
onSetFilter(value);
|
||||
}, [onSetFilter]);
|
||||
|
||||
const handleNewFolder = useCallback(() => {
|
||||
setNewFolderName("");
|
||||
setShowNewFolderDialog(true);
|
||||
@@ -984,23 +1051,91 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
/>
|
||||
<Input
|
||||
ref={filterInputRef}
|
||||
value={pane.filter}
|
||||
onChange={(e) => startTransition(() => onSetFilter(e.target.value))}
|
||||
value={filterDraft}
|
||||
onChange={(e) => {
|
||||
const superseded = resolveSupersededImeInputEvent({
|
||||
compositionExternallySuperseded: filterCompositionSupersededRef.current,
|
||||
isComposingSession: filterComposingRef.current,
|
||||
nativeEventIsComposing: e.nativeEvent.isComposing,
|
||||
});
|
||||
if (superseded.ignoreEventValue) {
|
||||
// Keep draft on the external filter (e.g. navigation clear). Do not
|
||||
// apply the stale composed text from a post-compositionend change.
|
||||
if (superseded.clearSupersedeLatch) {
|
||||
filterCompositionSupersededRef.current = false;
|
||||
}
|
||||
setFilterDraft(pane.filter);
|
||||
return;
|
||||
}
|
||||
const next = e.target.value;
|
||||
setFilterDraft(next);
|
||||
if (
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: filterComposingRef.current,
|
||||
nativeEventIsComposing: e.nativeEvent.isComposing,
|
||||
compositionExternallySuperseded: filterCompositionSupersededRef.current,
|
||||
})
|
||||
) {
|
||||
onSetFilter(next);
|
||||
}
|
||||
}}
|
||||
onCompositionStart={() => {
|
||||
filterComposingRef.current = true;
|
||||
filterCompositionSupersededRef.current = false;
|
||||
filterAtComposeStartRef.current = pane.filter;
|
||||
filterPathAtComposeStartRef.current = pane.connection?.currentPath ?? "";
|
||||
}}
|
||||
onCompositionEnd={(e) => {
|
||||
filterComposingRef.current = false;
|
||||
// We never self-commit while composing, so any change to pane.filter
|
||||
// or directory during the session is external (e.g. follow-CWD
|
||||
// navigation). Honor it and drop the stale composed draft instead of
|
||||
// letting the commit overwrite the navigation-cleared filter. Path
|
||||
// matters when the committed filter was already "" before compose -
|
||||
// navigation sets filter to "" again, so filter-only checks miss it.
|
||||
// Keep the supersede latch armed so a browser post-compositionend
|
||||
// onChange cannot reassert the composed text with composing=false.
|
||||
// Some IME paths never fire that follow-up change; disarm on the
|
||||
// next macrotask so ordinary keystrokes commit. Guard the clear so
|
||||
// an intervening post-composition onChange or a new compositionstart
|
||||
// is not clobbered.
|
||||
const pathChangedDuringCompose =
|
||||
(pane.connection?.currentPath ?? "") !== filterPathAtComposeStartRef.current;
|
||||
if (
|
||||
pane.filter !== filterAtComposeStartRef.current
|
||||
|| pathChangedDuringCompose
|
||||
|| filterCompositionSupersededRef.current
|
||||
) {
|
||||
filterCompositionSupersededRef.current = true;
|
||||
setFilterDraft(pane.filter);
|
||||
window.setTimeout(() => {
|
||||
if (
|
||||
filterCompositionSupersededRef.current
|
||||
&& !filterComposingRef.current
|
||||
) {
|
||||
filterCompositionSupersededRef.current = false;
|
||||
}
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
commitFilterValue(e.currentTarget.value);
|
||||
}}
|
||||
placeholder={t("sftp.filter.placeholder")}
|
||||
className="h-6 w-full pl-7 pr-7 text-xs bg-background"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
if (pane.filter) {
|
||||
startTransition(() => onSetFilter(""));
|
||||
if (filterComposingRef.current || e.nativeEvent.isComposing) return;
|
||||
if (filterDraft || pane.filter) {
|
||||
commitFilterValue("");
|
||||
} else {
|
||||
setShowFilterBar(false);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{pane.filter && (
|
||||
{filterDraft && (
|
||||
<button
|
||||
onClick={() => startTransition(() => onSetFilter(""))}
|
||||
onClick={() => commitFilterValue("")}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<X size={12} />
|
||||
@@ -1014,7 +1149,7 @@ export const SftpPaneToolbar: React.FC<SftpPaneToolbarProps> = React.memo(({
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => {
|
||||
startTransition(() => onSetFilter(""));
|
||||
commitFilterValue("");
|
||||
setShowFilterBar(false);
|
||||
}}
|
||||
>
|
||||
@@ -1102,7 +1237,7 @@ function SftpBookmarkPopoverBody({
|
||||
);
|
||||
}
|
||||
|
||||
/** Nested bookmark opener inside ⋮ — keeps overflow open until a leaf action. */
|
||||
/** Nested bookmark opener inside ⋮ - keeps overflow open until a leaf action. */
|
||||
function SftpOverflowNestedBookmark({
|
||||
menuItemClass,
|
||||
bookmarkButtonLabel,
|
||||
|
||||
@@ -551,7 +551,6 @@ const SftpPaneViewInner: React.FC<SftpPaneViewProps> = ({
|
||||
handlePathKeyDown={handlePathKeyDown}
|
||||
handlePathDoubleClick={handlePathDoubleClick}
|
||||
handlePathSubmit={handlePathSubmit}
|
||||
startTransition={startTransition}
|
||||
getNextUntitledName={getNextUntitledName}
|
||||
setNewFileName={setNewFileName}
|
||||
setFileNameError={setFileNameError}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
resolveSupersededImeInputEvent,
|
||||
shouldAdoptExternalImeControlledValue,
|
||||
shouldCommitImeControlledChange,
|
||||
} from "./imeControlledInput.ts";
|
||||
|
||||
test("does not commit controlled changes while an IME composition session is open", () => {
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: true,
|
||||
nativeEventIsComposing: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: true,
|
||||
nativeEventIsComposing: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not commit when the native event still reports composing", () => {
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("commits ordinary keystrokes outside composition", () => {
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("does not commit when composition was externally superseded", () => {
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: false,
|
||||
compositionExternallySuperseded: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: true,
|
||||
nativeEventIsComposing: false,
|
||||
compositionExternallySuperseded: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("adopts external value into draft only when not composing and values differ", () => {
|
||||
assert.equal(
|
||||
shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: false,
|
||||
draftValue: "sou",
|
||||
externalValue: "",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: true,
|
||||
draftValue: "sou",
|
||||
externalValue: "",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: false,
|
||||
draftValue: "搜",
|
||||
externalValue: "搜",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("adopts external navigation-clear mid-composition when compose-start baseline is provided", () => {
|
||||
// Parent cleared filter for different-directory navigation while IME was open.
|
||||
assert.equal(
|
||||
shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: true,
|
||||
draftValue: "sou",
|
||||
externalValue: "",
|
||||
valueAtComposeStart: "old",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// External value still matches the compose-start baseline - keep draft for IME.
|
||||
assert.equal(
|
||||
shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: true,
|
||||
draftValue: "sou",
|
||||
externalValue: "old",
|
||||
valueAtComposeStart: "old",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Draft already matches external after a prior adopt - no-op.
|
||||
assert.equal(
|
||||
shouldAdoptExternalImeControlledValue({
|
||||
isComposingSession: true,
|
||||
draftValue: "",
|
||||
externalValue: "",
|
||||
valueAtComposeStart: "old",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("suppresses post-composition onChange after external supersede and clears the latch once ended", () => {
|
||||
// Mid-composition after navigation clear: ignore event, keep latch armed.
|
||||
assert.deepEqual(
|
||||
resolveSupersededImeInputEvent({
|
||||
compositionExternallySuperseded: true,
|
||||
isComposingSession: true,
|
||||
nativeEventIsComposing: true,
|
||||
}),
|
||||
{ ignoreEventValue: true, clearSupersedeLatch: false },
|
||||
);
|
||||
|
||||
// Post-compositionend follow-up change (composing=false): ignore once and clear.
|
||||
assert.deepEqual(
|
||||
resolveSupersededImeInputEvent({
|
||||
compositionExternallySuperseded: true,
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: false,
|
||||
}),
|
||||
{ ignoreEventValue: true, clearSupersedeLatch: true },
|
||||
);
|
||||
|
||||
// No supersede: ordinary path.
|
||||
assert.deepEqual(
|
||||
resolveSupersededImeInputEvent({
|
||||
compositionExternallySuperseded: false,
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: false,
|
||||
}),
|
||||
{ ignoreEventValue: false, clearSupersedeLatch: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("ordinary commits remain blocked only while the supersede latch is armed", () => {
|
||||
// Documents the stuck-latch failure mode: if compositionend arms the latch and
|
||||
// no post-composition onChange clears it, shouldCommitImeControlledChange stays
|
||||
// false for ordinary keystrokes. UI must clear via onChange or a deferred fallback.
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: false,
|
||||
compositionExternallySuperseded: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCommitImeControlledChange({
|
||||
isComposingSession: false,
|
||||
nativeEventIsComposing: false,
|
||||
compositionExternallySuperseded: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Pure helpers for controlled text inputs that must not fight CJK IME composition.
|
||||
*
|
||||
* Deferred parent updates (e.g. startTransition) against `value={external}` reset
|
||||
* the DOM mid-composition and break Windows IMEs (candidate dismiss / pinyin echo).
|
||||
*/
|
||||
|
||||
export function shouldCommitImeControlledChange(input: {
|
||||
isComposingSession: boolean;
|
||||
nativeEventIsComposing?: boolean;
|
||||
/**
|
||||
* When true, the open (or just-ended) composition was discarded by an external
|
||||
* value change (e.g. navigation cleared the filter). Suppress commits so a
|
||||
* post-compositionend `onChange` cannot reassert the stale composed draft.
|
||||
*/
|
||||
compositionExternallySuperseded?: boolean;
|
||||
}): boolean {
|
||||
if (input.compositionExternallySuperseded) return false;
|
||||
return !input.isComposingSession && input.nativeEventIsComposing !== true;
|
||||
}
|
||||
|
||||
export function shouldAdoptExternalImeControlledValue(input: {
|
||||
isComposingSession: boolean;
|
||||
draftValue: string;
|
||||
externalValue: string;
|
||||
/**
|
||||
* Committed external value captured at compositionstart. When provided during
|
||||
* an open composition, an external change relative to this baseline (e.g.
|
||||
* different-directory navigation setting filter to "") supersedes the draft
|
||||
* so compositionend cannot re-commit stale text over the navigation clear.
|
||||
*/
|
||||
valueAtComposeStart?: string;
|
||||
}): boolean {
|
||||
if (input.draftValue === input.externalValue) return false;
|
||||
if (!input.isComposingSession) return true;
|
||||
// Mid-composition: only adopt when parent moved away from the compose-start
|
||||
// baseline. Without a baseline, never fight the IME for self-driven drafts.
|
||||
if (input.valueAtComposeStart !== undefined) {
|
||||
return input.externalValue !== input.valueAtComposeStart;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an input event when a composition may have been externally superseded
|
||||
* (navigation clear mid-IME). Browsers often re-fire the composed text via
|
||||
* `onChange` with `isComposing=false` immediately after `compositionend`; that
|
||||
* follow-up must not reassert the stale draft.
|
||||
*
|
||||
* - While still composing after supersede: keep ignoring event values (draft stays
|
||||
* on the external value) and keep the latch armed.
|
||||
* - Once composition has fully ended: ignore that one post-composition change and
|
||||
* clear the latch so subsequent ordinary typing commits normally.
|
||||
*/
|
||||
export function resolveSupersededImeInputEvent(input: {
|
||||
compositionExternallySuperseded: boolean;
|
||||
isComposingSession: boolean;
|
||||
nativeEventIsComposing?: boolean;
|
||||
}): {
|
||||
ignoreEventValue: boolean;
|
||||
clearSupersedeLatch: boolean;
|
||||
} {
|
||||
if (!input.compositionExternallySuperseded) {
|
||||
return { ignoreEventValue: false, clearSupersedeLatch: false };
|
||||
}
|
||||
const compositionFullyEnded =
|
||||
!input.isComposingSession && input.nativeEventIsComposing !== true;
|
||||
return {
|
||||
ignoreEventValue: true,
|
||||
clearSupersedeLatch: compositionFullyEnded,
|
||||
};
|
||||
}
|
||||
@@ -176,6 +176,53 @@ test("proxy profile shrink is protected like other synced vault entities", () =>
|
||||
}
|
||||
});
|
||||
|
||||
test("wiping group configs while other vault data remains is suspicious (#2757)", () => {
|
||||
// Group defaults often hold startup commands / host appearance for a small
|
||||
// number of folders. A hydration race that uploads hosts + groupConfigs:[]
|
||||
// must not clear the cloud copy just because lost < BULK_SHRINK_MIN_ABSOLUTE.
|
||||
const base = payload({
|
||||
hosts: hosts(1),
|
||||
groupConfigs: [
|
||||
{
|
||||
path: "prod",
|
||||
startupCommand: "tmux attach || tmux",
|
||||
theme: "solarized-dark",
|
||||
themeOverride: true,
|
||||
},
|
||||
],
|
||||
} as Partial<SyncPayload>);
|
||||
const out = payload({ hosts: hosts(1), groupConfigs: [] });
|
||||
const result = detectSuspiciousShrink(out, base);
|
||||
assert.equal(result.suspicious, true);
|
||||
if (result.suspicious) {
|
||||
assert.equal(result.entityType, "groupConfigs");
|
||||
assert.equal(result.lost, 1);
|
||||
assert.equal(result.reason, "bulk-shrink");
|
||||
}
|
||||
});
|
||||
|
||||
test("wiping the last snippet while hosts remain is still a normal delete", () => {
|
||||
// The groupConfigs complete-wipe guard must not broaden to every entity —
|
||||
// deleting your only snippet with hosts present is intentional.
|
||||
const snippets = (n: number) =>
|
||||
Array.from({ length: n }, (_, i) => ({ id: `s${i}`, label: `s${i}`, command: "" })) as SyncPayload["snippets"];
|
||||
const base = payload({ hosts: hosts(1), snippets: snippets(1) });
|
||||
const out = payload({ hosts: hosts(1), snippets: snippets(0) });
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("a fully empty outgoing snapshot is not a partial wipe (#2757)", () => {
|
||||
// Empty-everything against a trusted baseline is a real deletion path
|
||||
// (convergent migration). Do not flag complete wipes when no other vault
|
||||
// content remains on the outgoing payload.
|
||||
const base = payload({
|
||||
hosts: hosts(1),
|
||||
groupConfigs: [{ path: "prod", startupCommand: "echo hi" }],
|
||||
} as Partial<SyncPayload>);
|
||||
const out = payload();
|
||||
assert.deepEqual(detectSuspiciousShrink(out, base), { suspicious: false });
|
||||
});
|
||||
|
||||
test("knownHosts shrink is ignored because known hosts are local-only", () => {
|
||||
const kh = (n: number) => Array.from({ length: n }, (_, i) => ({ id: `kh${i}`, hostname: `h${i}`, port: 22, keyType: "rsa", fingerprint: "x" })) as unknown as SyncPayload["knownHosts"];
|
||||
const base = payload({ knownHosts: kh(12) });
|
||||
|
||||
@@ -52,6 +52,28 @@ export function detectSuspiciousShrink(
|
||||
const lost = baseCount - outgoingCount;
|
||||
if (lost <= 0) continue;
|
||||
|
||||
// groupConfigs often hold startup commands / host appearance for only one
|
||||
// or two folders. A hydration race that uploads hosts + groupConfigs:[]
|
||||
// falls below the bulk/ratio gates (#2757). Only flag this entity — wiping
|
||||
// the last snippet/note/etc. while hosts remain stays a normal delete.
|
||||
// Fully empty outgoing snapshots remain allowed as real deletions.
|
||||
if (
|
||||
entityType === 'groupConfigs'
|
||||
&& outgoingCount === 0
|
||||
&& baseCount > 0
|
||||
&& CHECKED_ENTITIES.some((other) => other !== entityType && countOf(outgoing, other) > 0)
|
||||
) {
|
||||
return {
|
||||
suspicious: true,
|
||||
reason: lost >= LARGE_SHRINK_ABSOLUTE ? 'large-shrink' : 'bulk-shrink',
|
||||
entityType,
|
||||
baseCount,
|
||||
outgoingCount,
|
||||
lost,
|
||||
...(viaRemote ? { viaRemote: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (lost >= LARGE_SHRINK_ABSOLUTE) {
|
||||
return {
|
||||
suspicious: true,
|
||||
|
||||
@@ -131,6 +131,13 @@ function registerAgentProcessHandlers(ctx) {
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, approved);
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
// Cancel MCP approval auto-deny after the user starts reviewing the card.
|
||||
ipcMain.handle("netcatty:ai:mcp:approval-cancel-timeout", async (event, { approvalId }) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const cancelled = mcpServerBridge.cancelApprovalTimeoutFromRenderer?.(approvalId) === true;
|
||||
return { ok: true, cancelled };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -740,9 +740,30 @@ class CodexAppServerRuntime {
|
||||
const timeoutMs = kind === "user-input" && Number(params.autoResolutionMs) > 0
|
||||
? Number(params.autoResolutionMs)
|
||||
: INTERACTION_TIMEOUT_MS;
|
||||
const timer = setTimeout(() => {
|
||||
this.#resolveInteraction(interactionId, kind === "user-input" ? { answers: {} } : { decision: "reject" });
|
||||
}, timeoutMs);
|
||||
// Hard ceiling from creation — review can cancel the idle timer but must
|
||||
// re-arm the absolute remainder (Catty/MCP pattern; never unbounded).
|
||||
const absoluteExpiresAt = Date.now() + timeoutMs;
|
||||
const armTimer = (ms) => {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending) return;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
if (ms <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, ms);
|
||||
};
|
||||
this.pendingInteractions.set(interactionId, {
|
||||
interactionId,
|
||||
connection,
|
||||
@@ -750,8 +771,11 @@ class CodexAppServerRuntime {
|
||||
kind,
|
||||
params,
|
||||
context,
|
||||
timer,
|
||||
timer: null,
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
});
|
||||
armTimer(timeoutMs);
|
||||
|
||||
const payload = {
|
||||
interactionId,
|
||||
@@ -840,6 +864,37 @@ class CodexAppServerRuntime {
|
||||
return this.#resolveInteraction(interactionId, response);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the idle auto-reject timer after the user starts reviewing an approval card.
|
||||
* Re-arms the absolute creation deadline so a late approve cannot outlive the
|
||||
* original timeout window (matches Catty/MCP approval cancel semantics).
|
||||
*/
|
||||
cancelInteractionTimeout(interactionId, sender) {
|
||||
const pending = this.pendingInteractions.get(interactionId);
|
||||
if (!pending || pending.idleCancelled) return false;
|
||||
if (sender && pending.context?.sender && pending.context.sender !== sender) return false;
|
||||
pending.idleCancelled = true;
|
||||
if (pending.timer) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.timer = null;
|
||||
}
|
||||
const remainingMs = Math.max(0, (pending.absoluteExpiresAt ?? 0) - Date.now());
|
||||
if (remainingMs <= 0) {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
pending.timer = setTimeout(() => {
|
||||
this.#resolveInteraction(
|
||||
interactionId,
|
||||
pending.kind === "user-input" ? { answers: {} } : { decision: "reject" },
|
||||
);
|
||||
}, remainingMs);
|
||||
return true;
|
||||
}
|
||||
|
||||
#clearInteractionsForContext(context, decision) {
|
||||
for (const [interactionId, pending] of Array.from(this.pendingInteractions)) {
|
||||
if (pending.context === context) {
|
||||
|
||||
@@ -311,6 +311,116 @@ test("runtime routes native approvals and request_user_input responses", async (
|
||||
await run;
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout re-arms the absolute approval deadline (Catty/MCP style)", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const realNow = Date.now;
|
||||
let now = 5_000_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
try {
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-cancel",
|
||||
chatSessionId: "chat-timeout-cancel",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 90,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "question-timeout",
|
||||
questions: [{ id: "choice", question: "Choose", header: "Mode", isOther: true, isSecret: false, options: null }],
|
||||
autoResolutionMs: 100,
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
|
||||
// Jump close to the absolute ceiling, then cancel idle. Remaining absolute ~30ms.
|
||||
now += 70;
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 15));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
false,
|
||||
"must stay pending before absolute expiry",
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
assert.equal(
|
||||
connection.responses.some((response) => response.id === 90),
|
||||
true,
|
||||
"absolute deadline must auto-reject after remaining time elapses",
|
||||
);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 90,
|
||||
result: { answers: {} },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
} finally {
|
||||
Date.now = realNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("cancelInteractionTimeout still allows explicit approve before absolute expiry", async () => {
|
||||
let connection;
|
||||
let interaction;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
connectionFactory: (options) => (connection = new FakeConnection(options)),
|
||||
sendInteractionRequest: (payload) => { interaction = payload; return true; },
|
||||
});
|
||||
const run = runtime.runTurn({
|
||||
requestId: "request-timeout-approve",
|
||||
chatSessionId: "chat-timeout-approve",
|
||||
prompt: "ask",
|
||||
permissionMode: "confirm",
|
||||
env: {},
|
||||
binPath: "/bin/codex",
|
||||
injectedMcpServers: [],
|
||||
emitter: createEmitter(),
|
||||
});
|
||||
await waitFor(() => connection?.requests.some((request) => request.method === "turn/start"));
|
||||
|
||||
await connection.serverRequest({
|
||||
id: 91,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-approve",
|
||||
command: "echo ok",
|
||||
cwd: "/tmp",
|
||||
reason: "demo",
|
||||
},
|
||||
});
|
||||
assert.ok(interaction?.interactionId);
|
||||
assert.equal(runtime.cancelInteractionTimeout(interaction.interactionId), true);
|
||||
assert.equal(runtime.respondInteraction(interaction.interactionId, { decision: "once" }), true);
|
||||
assert.deepEqual(connection.responses.at(-1), {
|
||||
id: 91,
|
||||
result: { decision: "accept" },
|
||||
});
|
||||
|
||||
connection.notify({ method: "turn/completed", params: { threadId: "thread-1", turn: { id: "turn-1", status: "completed", error: null } } });
|
||||
await run;
|
||||
});
|
||||
|
||||
test("runtime steers the active turn with text, local images, and a stable user message id", async () => {
|
||||
let connection;
|
||||
const runtime = new CodexAppServerRuntime({
|
||||
|
||||
@@ -1054,6 +1054,15 @@ function registerSdkStreamHandlers(ctx) {
|
||||
return ok ? { ok: true } : { ok: false, error: "Interaction not found" };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex-app-server:interaction-cancel-timeout", async (event, payload) => {
|
||||
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
||||
const cancelled = codexAppServerRuntime.cancelInteractionTimeout(
|
||||
payload?.interactionId,
|
||||
event.sender,
|
||||
) === true;
|
||||
return { ok: true, cancelled };
|
||||
});
|
||||
|
||||
ipcMain.handle("netcatty:ai:codex-app-server:status", async (event, payload) => {
|
||||
if (!validateSenderOrSettings(event)) return { ok: false, available: false, error: "Unauthorized IPC sender" };
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { setTimeout: delay } = require("node:timers/promises");
|
||||
|
||||
const mcpServerBridge = require("./mcpServerBridge.cjs");
|
||||
|
||||
function createFakeWindow() {
|
||||
const contents = new EventEmitter();
|
||||
contents.id = Math.floor(Math.random() * 1e6);
|
||||
contents.send = (channel, payload) => {
|
||||
contents.emit("send", channel, payload);
|
||||
};
|
||||
contents.isDestroyed = () => false;
|
||||
return {
|
||||
isDestroyed: () => false,
|
||||
webContents: contents,
|
||||
};
|
||||
}
|
||||
|
||||
function stubNow(t, startMs) {
|
||||
const realNow = Date.now;
|
||||
let now = startMs;
|
||||
Date.now = () => now;
|
||||
t.after(() => {
|
||||
Date.now = realNow;
|
||||
});
|
||||
return {
|
||||
advance: (deltaMs) => {
|
||||
now += deltaMs;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("cancelApprovalTimeoutFromRenderer keeps the absolute MCP deadline armed", async (t) => {
|
||||
const win = createFakeWindow();
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
const cleared = [];
|
||||
win.webContents.on("send", (channel, payload) => {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
|
||||
if (channel === "netcatty:ai:mcp:approval-cleared") {
|
||||
cleared.push(...(payload?.approvalIds || []));
|
||||
}
|
||||
});
|
||||
|
||||
const clock = stubNow(t, 1_000_000);
|
||||
const approvalPromise = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"terminal_execute",
|
||||
{ command: "echo hi" },
|
||||
"chat-mcp-absolute",
|
||||
);
|
||||
|
||||
assert.equal(requests.length, 1);
|
||||
const approvalId = requests[0].approvalId;
|
||||
|
||||
// Jump close to the 110s absolute ceiling, then cancel idle. Remaining absolute ~40ms.
|
||||
clock.advance(109_960);
|
||||
assert.equal(mcpServerBridge.cancelApprovalTimeoutFromRenderer(approvalId), true);
|
||||
assert.equal(mcpServerBridge.cancelApprovalTimeoutFromRenderer(approvalId), false);
|
||||
|
||||
await delay(20);
|
||||
assert.equal(cleared.includes(approvalId), false, "must stay pending before absolute expiry");
|
||||
|
||||
const outcome = await Promise.race([
|
||||
approvalPromise.then((approved) => ({ approved })),
|
||||
delay(120).then(() => ({ approved: "timeout-wait" })),
|
||||
]);
|
||||
assert.deepEqual(outcome, { approved: false });
|
||||
assert.ok(cleared.includes(approvalId));
|
||||
});
|
||||
|
||||
test("cancelApprovalTimeoutFromRenderer still allows explicit approve before absolute expiry", async (t) => {
|
||||
const win = createFakeWindow();
|
||||
mcpServerBridge.setMainWindowGetter(() => win);
|
||||
t.after(() => {
|
||||
mcpServerBridge.clearPendingApprovals();
|
||||
mcpServerBridge.setMainWindowGetter(() => null);
|
||||
});
|
||||
|
||||
const requests = [];
|
||||
win.webContents.on("send", (channel, payload) => {
|
||||
if (channel === "netcatty:ai:mcp:approval-request") requests.push(payload);
|
||||
});
|
||||
|
||||
stubNow(t, 2_000_000);
|
||||
const approvalPromise = mcpServerBridge.requestApprovalFromRenderer(
|
||||
"sftp_write",
|
||||
{ path: "/tmp/x" },
|
||||
"chat-mcp-approve",
|
||||
);
|
||||
const approvalId = requests[0].approvalId;
|
||||
|
||||
assert.equal(mcpServerBridge.cancelApprovalTimeoutFromRenderer(approvalId), true);
|
||||
mcpServerBridge.resolveApprovalFromRenderer(approvalId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
});
|
||||
@@ -199,22 +199,49 @@ function requestApprovalFromRenderer(toolName, args, chatSessionId) {
|
||||
return;
|
||||
}
|
||||
const approvalId = `mcp_approval_${++approvalIdCounter}_${Date.now()}`;
|
||||
// Hard ceiling from creation — must stay below external SDK tool-call
|
||||
// timeouts (~120s). Review can cancel the idle timer but not this bound.
|
||||
const absoluteExpiresAt = Date.now() + APPROVAL_TIMEOUT_MS;
|
||||
let timerId = null;
|
||||
|
||||
// Auto-deny after timeout so SDK/MCP tool calls don't hang indefinitely
|
||||
const timerId = setTimeout(() => {
|
||||
if (pendingApprovals.has(approvalId)) {
|
||||
pendingApprovals.delete(approvalId);
|
||||
resolve(false);
|
||||
// Notify renderer(s) to remove the stale approval card
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
const denyTimedOut = () => {
|
||||
if (!pendingApprovals.has(approvalId)) return;
|
||||
pendingApprovals.delete(approvalId);
|
||||
resolve(false);
|
||||
// Notify renderer(s) to remove the stale approval card
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-cleared', { approvalIds: [approvalId] });
|
||||
};
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerId != null) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
}, APPROVAL_TIMEOUT_MS);
|
||||
};
|
||||
|
||||
const armTimer = (ms) => {
|
||||
clearTimer();
|
||||
if (ms <= 0) {
|
||||
denyTimedOut();
|
||||
return;
|
||||
}
|
||||
timerId = setTimeout(denyTimedOut, ms);
|
||||
};
|
||||
|
||||
// Auto-deny after timeout so SDK/MCP tool calls don't hang indefinitely.
|
||||
// Idle engagement cancels this arm and re-arms the absolute remainder
|
||||
// (never auto-approves; never extends past absoluteExpiresAt).
|
||||
armTimer(APPROVAL_TIMEOUT_MS);
|
||||
|
||||
pendingApprovals.set(approvalId, {
|
||||
resolve: (approved) => {
|
||||
clearTimeout(timerId);
|
||||
clearTimer();
|
||||
resolve(approved);
|
||||
},
|
||||
clearTimer,
|
||||
armAbsoluteTimeout: () => armTimer(absoluteExpiresAt - Date.now()),
|
||||
absoluteExpiresAt,
|
||||
idleCancelled: false,
|
||||
chatSessionId: chatSessionId || null,
|
||||
});
|
||||
broadcastApprovalEvent('netcatty:ai:mcp:approval-request', {
|
||||
@@ -237,6 +264,20 @@ function resolveApprovalFromRenderer(approvalId, approved) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the idle auto-deny timer after the user starts reviewing an approval card.
|
||||
* Re-arms the absolute creation deadline so a late approve cannot outlive the
|
||||
* external agent's tool-call timeout window.
|
||||
*/
|
||||
function cancelApprovalTimeoutFromRenderer(approvalId) {
|
||||
const entry = pendingApprovals.get(approvalId);
|
||||
if (!entry || entry.idleCancelled) return false;
|
||||
entry.idleCancelled = true;
|
||||
entry.clearTimer?.();
|
||||
entry.armAbsoluteTimeout?.();
|
||||
return true;
|
||||
}
|
||||
|
||||
function notifyRendererApprovalCleared(approvalIds) {
|
||||
if (!Array.isArray(approvalIds) || approvalIds.length === 0) return;
|
||||
broadcastApprovalEvent("netcatty:ai:mcp:approval-cleared", { approvalIds });
|
||||
@@ -1999,7 +2040,9 @@ module.exports = {
|
||||
revokeExternalMcpAuthToken,
|
||||
getExternalMcpAuthToken,
|
||||
syncLiveSessionsToExternalScope,
|
||||
requestApprovalFromRenderer,
|
||||
resolveApprovalFromRenderer,
|
||||
cancelApprovalTimeoutFromRenderer,
|
||||
clearPendingApprovals,
|
||||
reserveSessionExecution,
|
||||
releaseSessionExecution,
|
||||
|
||||
@@ -483,6 +483,20 @@ function hasStream(sessionId) {
|
||||
return activeStreams.has(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Absolute paths of files currently open by active log streams.
|
||||
* Used by clear-all so it never unlinks a live write target.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getActiveLogPaths() {
|
||||
const paths = [];
|
||||
for (const entry of activeStreams.values()) {
|
||||
if (!entry?.filePath || entry.disabled) continue;
|
||||
paths.push(path.resolve(entry.filePath));
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup all active streams (called on app quit).
|
||||
*/
|
||||
@@ -500,5 +514,6 @@ module.exports = {
|
||||
registerProgrammaticCommandLogRewrite,
|
||||
stopStream,
|
||||
hasStream,
|
||||
getActiveLogPaths,
|
||||
cleanupAll,
|
||||
};
|
||||
@@ -253,6 +253,226 @@ async function openSessionLogsDir(event, payload) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-save writes `{hostDir}/{YYYY-MM-DDTHH-MM-SS}.{txt|log|html}`.
|
||||
// Manual export / continuous logs commonly write `{label}_{YYYY-MM-DDTHH-MM-SS}.{ext}`.
|
||||
const SESSION_LOG_TIMESTAMP_FILE = /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.(txt|log|html)$/i;
|
||||
const SESSION_LOG_LABELED_FILE = /^.+_\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}\.(txt|log|html)$/i;
|
||||
|
||||
/**
|
||||
* True when a basename matches a Netcatty session-log artifact filename.
|
||||
* Used so "clear all" never wipes unrelated files in a shared save directory.
|
||||
*/
|
||||
function isSessionLogArtifactName(name) {
|
||||
const base = path.basename(String(name || ""));
|
||||
return SESSION_LOG_TIMESTAMP_FILE.test(base) || SESSION_LOG_LABELED_FILE.test(base);
|
||||
}
|
||||
|
||||
/**
|
||||
* Live write targets from this process's sessionLogStreamManager.
|
||||
* Main and terminal worker each own a separate module instance.
|
||||
* @returns {string[]}
|
||||
*/
|
||||
function getLocalActiveLogPaths() {
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
if (typeof sessionLogStreamManager.getActiveLogPaths !== "function") return [];
|
||||
return sessionLogStreamManager.getActiveLogPaths()
|
||||
.filter((p) => typeof p === "string" && p.length > 0)
|
||||
.map((p) => path.resolve(p));
|
||||
}
|
||||
|
||||
/**
|
||||
* True when the terminal worker process is already running (or the manager
|
||||
* does not expose a probe — unit-test mocks that only provide request()).
|
||||
* Avoids cold-starting the utilityProcess solely for clear-all.
|
||||
* @param {object|null} terminalWorkerManager
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTerminalWorkerRunning(terminalWorkerManager) {
|
||||
if (!terminalWorkerManager) return false;
|
||||
if (typeof terminalWorkerManager.isRunning === "function") {
|
||||
return Boolean(terminalWorkerManager.isRunning());
|
||||
}
|
||||
// Mocks that only supply request() — treat as queryable.
|
||||
return typeof terminalWorkerManager.request === "function";
|
||||
}
|
||||
|
||||
/**
|
||||
* Union of active log paths from the main process and the terminal worker.
|
||||
* Worker-owned auto-save streams are invisible to the main-process manager.
|
||||
* @param {object|null} terminalWorkerManager
|
||||
* @returns {Promise<Set<string>>}
|
||||
*/
|
||||
async function collectActiveLogPaths(terminalWorkerManager = null) {
|
||||
const activeLogPaths = new Set(getLocalActiveLogPaths());
|
||||
|
||||
if (!isTerminalWorkerRunning(terminalWorkerManager)) {
|
||||
return activeLogPaths;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await terminalWorkerManager.request(
|
||||
"netcatty:sessionLogs:getActivePaths",
|
||||
{},
|
||||
{},
|
||||
);
|
||||
const workerPaths = Array.isArray(result)
|
||||
? result
|
||||
: (Array.isArray(result?.paths) ? result.paths : []);
|
||||
for (const p of workerPaths) {
|
||||
if (typeof p === "string" && p.length > 0) {
|
||||
activeLogPaths.add(path.resolve(p));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Worker unavailable or channel missing — main-process paths only.
|
||||
}
|
||||
|
||||
return activeLogPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete known session-log artifacts inside the configured save directory
|
||||
* (used by the "clear all logs" action in Settings).
|
||||
*
|
||||
* Only removes:
|
||||
* - Top-level files matching session-log filename patterns
|
||||
* - Host subdirectories that exclusively contain session-log files
|
||||
* - Session-log files inside mixed host subdirectories (other entries kept)
|
||||
*
|
||||
* Never deletes unrelated top-level files/folders (e.g. Documents/Downloads
|
||||
* when the user pointed the save directory at a shared folder).
|
||||
*
|
||||
* Active auto-save / continuous-log write targets are skipped so clear-all
|
||||
* never unlinks a live stream (orphan inode / silent stop). Paths include
|
||||
* both main-process streams and worker-owned streams when a terminal worker
|
||||
* is running.
|
||||
*/
|
||||
async function clearSessionLogsDir(event, payload = {}, terminalWorkerManager = null) {
|
||||
const { directory } = payload;
|
||||
|
||||
if (!directory) {
|
||||
return { success: false, deletedCount: 0, failedCount: 0, error: "No directory specified" };
|
||||
}
|
||||
|
||||
let deletedCount = 0;
|
||||
let failedCount = 0;
|
||||
|
||||
// Live write targets (main + worker) — skip these paths.
|
||||
const activeLogPaths = await collectActiveLogPaths(terminalWorkerManager);
|
||||
const isActiveLogPath = (filePath) => activeLogPaths.has(path.resolve(filePath));
|
||||
|
||||
try {
|
||||
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(directory, entry.name);
|
||||
|
||||
// Skip anything that is not a plain file or directory (symlinks, sockets, …).
|
||||
// Use dirent type when available; fall back to lstat for exotic FS entries.
|
||||
let isFile = entry.isFile();
|
||||
let isDirectory = entry.isDirectory();
|
||||
if ((!isFile && !isDirectory) || entry.isSymbolicLink()) {
|
||||
// Dirent may report the target type for some platforms; re-check with lstat
|
||||
// so we never follow or delete symlink targets outside the save directory.
|
||||
try {
|
||||
const st = await fs.promises.lstat(entryPath);
|
||||
if (st.isSymbolicLink()) continue;
|
||||
isFile = st.isFile();
|
||||
isDirectory = st.isDirectory();
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (!isFile && !isDirectory) continue;
|
||||
|
||||
try {
|
||||
if (isFile) {
|
||||
if (!isSessionLogArtifactName(entry.name)) continue;
|
||||
if (isActiveLogPath(entryPath)) continue;
|
||||
await fs.promises.rm(entryPath, { force: true });
|
||||
deletedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Host subdirectory created by auto-save: only touch known log files.
|
||||
const nested = await fs.promises.readdir(entryPath, { withFileTypes: true });
|
||||
const logFiles = [];
|
||||
let hasNonLogEntry = false;
|
||||
let hasActiveLog = false;
|
||||
const resolvedEntryPath = path.resolve(entryPath);
|
||||
|
||||
// txt/html streams may not create a file until the first snapshot flush, so
|
||||
// the active path may be absent from readdir. Any registered active path
|
||||
// whose parent is this host dir still counts as live work.
|
||||
for (const activePath of activeLogPaths) {
|
||||
if (path.dirname(activePath) === resolvedEntryPath) {
|
||||
hasActiveLog = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const nestedEntry of nested) {
|
||||
const nestedPath = path.join(entryPath, nestedEntry.name);
|
||||
let nestedIsFile = nestedEntry.isFile();
|
||||
if (nestedEntry.isSymbolicLink() || (!nestedIsFile && !nestedEntry.isDirectory())) {
|
||||
try {
|
||||
const st = await fs.promises.lstat(nestedPath);
|
||||
if (st.isSymbolicLink() || !st.isFile()) {
|
||||
hasNonLogEntry = true;
|
||||
continue;
|
||||
}
|
||||
nestedIsFile = true;
|
||||
} catch {
|
||||
hasNonLogEntry = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (nestedIsFile && isSessionLogArtifactName(nestedEntry.name)) {
|
||||
if (isActiveLogPath(nestedPath)) {
|
||||
hasActiveLog = true;
|
||||
} else {
|
||||
logFiles.push(nestedPath);
|
||||
}
|
||||
} else {
|
||||
hasNonLogEntry = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (logFiles.length === 0 && !hasActiveLog) {
|
||||
// Not an app host-log folder (or empty / only non-log content) — leave it alone.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!hasNonLogEntry && !hasActiveLog) {
|
||||
// Pure session-log host folder with no live streams: remove as one artifact.
|
||||
await fs.promises.rm(entryPath, { recursive: true, force: true });
|
||||
deletedCount++;
|
||||
} else {
|
||||
// Mixed directory and/or active streams: only delete inactive known log files.
|
||||
for (const logPath of logFiles) {
|
||||
try {
|
||||
await fs.promises.rm(logPath, { force: true });
|
||||
deletedCount++;
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.error(`[SessionLogs] Could not delete ${path.basename(logPath)}:`, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.error(`[SessionLogs] Could not delete ${entry.name}:`, err.message);
|
||||
}
|
||||
}
|
||||
return { success: true, deletedCount, failedCount };
|
||||
} catch (err) {
|
||||
if (err?.code === "ENOENT") {
|
||||
return { success: true, deletedCount: 0, failedCount: 0 };
|
||||
}
|
||||
console.error("[SessionLogs] Failed to clear session logs directory:", err);
|
||||
return { success: false, deletedCount, failedCount, error: err.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function startManualSessionLog(event, payload = {}) {
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
const { sessionId, sessionName, preferredDirectory, initialLine } = payload;
|
||||
@@ -376,14 +596,30 @@ async function getManualSessionLogStatus(event, payload = {}) {
|
||||
return { success: true, isLogging: sessionLogStreamManager.hasStream(sessionId) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker-side handlers only. The terminal utilityProcess has its own
|
||||
* sessionLogStreamManager instance; main queries these paths before clear-all.
|
||||
*/
|
||||
function registerWorkerHandlers(ipcMain) {
|
||||
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
|
||||
}
|
||||
|
||||
/**
|
||||
* Register IPC handlers for session logs operations
|
||||
*/
|
||||
function registerHandlers(ipcMain, options = {}) {
|
||||
const terminalWorkerManager = options.terminalWorkerManager || null;
|
||||
|
||||
ipcMain.handle("netcatty:sessionLogs:export", exportSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLogs:selectDir", selectSessionLogsDir);
|
||||
ipcMain.handle("netcatty:sessionLogs:autoSave", autoSaveSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLogs:openDir", openSessionLogsDir);
|
||||
ipcMain.handle(
|
||||
"netcatty:sessionLogs:clear",
|
||||
(event, payload) => clearSessionLogsDir(event, payload, terminalWorkerManager),
|
||||
);
|
||||
// Main can also answer this (manual / script streams) for symmetry / tests.
|
||||
ipcMain.handle("netcatty:sessionLogs:getActivePaths", async () => getLocalActiveLogPaths());
|
||||
ipcMain.handle("netcatty:sessionLog:manualStart", startManualSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStop", stopManualSessionLog);
|
||||
ipcMain.handle("netcatty:sessionLog:manualStatus", getManualSessionLogStatus);
|
||||
@@ -397,7 +633,7 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
// mirrors every output chunk to the main process for script output buffers;
|
||||
// feed that same stream into the main-process log streams. appendData() is
|
||||
// a no-op for sessions without an active main-process stream.
|
||||
options.terminalWorkerManager?.addOutputTap?.((sessionId, data) => {
|
||||
terminalWorkerManager?.addOutputTap?.((sessionId, data) => {
|
||||
if (typeof data !== "string" || data.length === 0) return;
|
||||
require("./sessionLogStreamManager.cjs").appendData(sessionId, data);
|
||||
});
|
||||
@@ -405,10 +641,15 @@ function registerHandlers(ipcMain, options = {}) {
|
||||
|
||||
module.exports = {
|
||||
registerHandlers,
|
||||
registerWorkerHandlers,
|
||||
exportSessionLog,
|
||||
selectSessionLogsDir,
|
||||
autoSaveSessionLog,
|
||||
openSessionLogsDir,
|
||||
clearSessionLogsDir,
|
||||
collectActiveLogPaths,
|
||||
getLocalActiveLogPaths,
|
||||
isSessionLogArtifactName,
|
||||
startManualSessionLog,
|
||||
stopManualSessionLog,
|
||||
getManualSessionLogStatus,
|
||||
|
||||
@@ -6,6 +6,15 @@ const Module = require("node:module");
|
||||
|
||||
const TEMP_ROOT = path.join(__dirname, ".tmp-session-logs-bridge-tests");
|
||||
|
||||
async function waitForPath(targetPath, { timeoutMs = 2000 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (fs.existsSync(targetPath)) return;
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(`Timed out waiting for path: ${targetPath}`);
|
||||
}
|
||||
|
||||
function loadBridgeWithDialog(dialogMock) {
|
||||
const originalLoad = Module._load;
|
||||
Module._load = function patchedLoad(request, parent, isMain) {
|
||||
@@ -107,6 +116,291 @@ test("auto-save host directory falls back when the sanitized host label is empty
|
||||
}
|
||||
});
|
||||
|
||||
test("isSessionLogArtifactName matches auto-save and labeled export filenames", () => {
|
||||
const { isSessionLogArtifactName } = loadBridgeWithDialog({});
|
||||
|
||||
assert.equal(isSessionLogArtifactName("2026-01-01T00-00-00.txt"), true);
|
||||
assert.equal(isSessionLogArtifactName("2026-01-01T00-00-00.log"), true);
|
||||
assert.equal(isSessionLogArtifactName("2026-01-01T00-00-00.html"), true);
|
||||
assert.equal(isSessionLogArtifactName("my-host_2026-01-01T00-00-00.txt"), true);
|
||||
assert.equal(isSessionLogArtifactName("生产_服务器_2026-08-05T15-33-00.log"), true);
|
||||
assert.equal(isSessionLogArtifactName("stray.log"), false);
|
||||
assert.equal(isSessionLogArtifactName("notes.txt"), false);
|
||||
assert.equal(isSessionLogArtifactName("2026-01-01.txt"), false);
|
||||
assert.equal(isSessionLogArtifactName("readme.md"), false);
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir deletes host log folders and labeled exports only", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
try {
|
||||
const hostDir = path.join(directory, "my-host");
|
||||
fs.mkdirSync(hostDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.txt"), "hello\n");
|
||||
fs.writeFileSync(path.join(hostDir, "2026-01-02T12-30-00.log"), "raw\n");
|
||||
// Labeled export-style artifact at the save-dir root (manual export / continuous log).
|
||||
fs.writeFileSync(path.join(directory, "my-host_2026-01-03T01-02-03.txt"), "export\n");
|
||||
// Unrelated user content in a shared save directory must survive clear-all.
|
||||
fs.writeFileSync(path.join(directory, "stray.log"), "stray\n");
|
||||
fs.writeFileSync(path.join(directory, "notes.txt"), "keep me\n");
|
||||
fs.mkdirSync(path.join(directory, "Photos"), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "Photos", "vacation.jpg"), "jpeg");
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.deletedCount, 2); // pure host folder + labeled export file
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), ["Photos", "notes.txt", "stray.log"]);
|
||||
assert.equal(fs.readFileSync(path.join(directory, "stray.log"), "utf8"), "stray\n");
|
||||
assert.equal(fs.readFileSync(path.join(directory, "Photos", "vacation.jpg"), "utf8"), "jpeg");
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir only removes log files inside mixed host directories", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-mixed-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
try {
|
||||
const hostDir = path.join(directory, "shared-host-folder");
|
||||
fs.mkdirSync(hostDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.txt"), "log\n");
|
||||
fs.writeFileSync(path.join(hostDir, "user-notes.md"), "do not delete\n");
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.deletedCount, 1);
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.deepEqual(fs.readdirSync(directory), ["shared-host-folder"]);
|
||||
assert.deepEqual(fs.readdirSync(hostDir), ["user-notes.md"]);
|
||||
assert.equal(fs.readFileSync(path.join(hostDir, "user-notes.md"), "utf8"), "do not delete\n");
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir preserves active auto-save stream files and host dirs", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-active-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const sessionId = `session-active-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
try {
|
||||
const startToken = sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: "live-host",
|
||||
hostname: "live.example",
|
||||
directory,
|
||||
format: "raw",
|
||||
startTime: Date.UTC(2026, 0, 2, 3, 4, 5),
|
||||
});
|
||||
assert.ok(startToken);
|
||||
sessionLogStreamManager.appendData(sessionId, "live body\n");
|
||||
|
||||
// Prefer stream manager paths: createWriteStream open is async, so the
|
||||
// file may not appear in readdir until the open/write settles.
|
||||
const activePaths = sessionLogStreamManager.getActiveLogPaths();
|
||||
assert.equal(activePaths.length, 1);
|
||||
const activePath = activePaths[0];
|
||||
const hostDir = path.dirname(activePath);
|
||||
assert.equal(path.basename(hostDir), "live-host");
|
||||
|
||||
// Wait for the live target to materialize on disk (async open).
|
||||
await waitForPath(activePath);
|
||||
// Stale sibling log in the same host folder should still be cleared.
|
||||
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.log"), "stale\n");
|
||||
// Inactive pure host folder should still be removed.
|
||||
const staleHostDir = path.join(directory, "stale-host");
|
||||
fs.mkdirSync(staleHostDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleHostDir, "2026-01-03T00-00-00.txt"), "old\n");
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.equal(result.deletedCount, 2); // stale sibling log + pure stale-host folder
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), ["live-host"]);
|
||||
assert.ok(fs.existsSync(activePath), "active stream path must survive clear-all");
|
||||
assert.deepEqual(fs.readdirSync(hostDir), [path.basename(activePath)]);
|
||||
|
||||
// Stream must still accept writes after clear-all (not unlinked/orphaned).
|
||||
sessionLogStreamManager.appendData(sessionId, "after clear\n");
|
||||
assert.equal(sessionLogStreamManager.hasStream(sessionId), true);
|
||||
const stoppedPath = await sessionLogStreamManager.stopStream(sessionId, startToken);
|
||||
assert.equal(path.resolve(stoppedPath), path.resolve(activePath));
|
||||
const content = fs.readFileSync(activePath, "utf8");
|
||||
assert.match(content, /live body/);
|
||||
assert.match(content, /after clear/);
|
||||
} finally {
|
||||
await sessionLogStreamManager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir unions worker-owned active paths with main-process streams", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-worker-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
try {
|
||||
const hostDir = path.join(directory, "worker-host");
|
||||
fs.mkdirSync(hostDir, { recursive: true });
|
||||
// Simulate a live worker-owned auto-save file (invisible to main manager).
|
||||
const workerLivePath = path.join(hostDir, "2026-01-05T06-07-08.log");
|
||||
fs.writeFileSync(workerLivePath, "worker live body\n");
|
||||
// Stale sibling in the same host dir should still be cleared.
|
||||
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.log"), "stale\n");
|
||||
// Unrelated pure host folder still cleared.
|
||||
const staleHostDir = path.join(directory, "stale-worker-host");
|
||||
fs.mkdirSync(staleHostDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleHostDir, "2026-01-03T00-00-00.txt"), "old\n");
|
||||
|
||||
let requestedChannel = null;
|
||||
const terminalWorkerManager = {
|
||||
isRunning: () => true,
|
||||
request: async (channel) => {
|
||||
requestedChannel = channel;
|
||||
return [workerLivePath];
|
||||
},
|
||||
};
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory }, terminalWorkerManager);
|
||||
|
||||
assert.equal(requestedChannel, "netcatty:sessionLogs:getActivePaths");
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.equal(result.deletedCount, 2); // stale sibling + pure stale host
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), ["worker-host"]);
|
||||
assert.ok(fs.existsSync(workerLivePath), "worker-owned active stream must survive clear-all");
|
||||
assert.deepEqual(fs.readdirSync(hostDir), [path.basename(workerLivePath)]);
|
||||
assert.equal(fs.readFileSync(workerLivePath, "utf8"), "worker live body\n");
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir does not cold-start a stopped terminal worker", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-no-worker-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
try {
|
||||
fs.mkdirSync(directory, { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "host_2026-01-01T00-00-00.txt"), "export\n");
|
||||
|
||||
let requestCalls = 0;
|
||||
const terminalWorkerManager = {
|
||||
isRunning: () => false,
|
||||
request: async () => {
|
||||
requestCalls += 1;
|
||||
return [];
|
||||
},
|
||||
};
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory }, terminalWorkerManager);
|
||||
|
||||
assert.equal(requestCalls, 0, "must not request worker paths when worker is stopped");
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.deletedCount, 1);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir preserves snapshot-only host dirs before the first file exists", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-snap-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const sessionId = `session-snap-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
const sessionLogStreamManager = require("./sessionLogStreamManager.cjs");
|
||||
|
||||
try {
|
||||
// txt streams keep renderer state in memory and only materialize the file
|
||||
// on snapshot flush — clear-all must still treat the registered path as live.
|
||||
const startToken = sessionLogStreamManager.startStream(sessionId, {
|
||||
hostLabel: "snap-host",
|
||||
hostname: "snap.example",
|
||||
directory,
|
||||
format: "txt",
|
||||
startTime: Date.UTC(2026, 0, 4, 5, 6, 7),
|
||||
});
|
||||
assert.ok(startToken);
|
||||
|
||||
const activePaths = sessionLogStreamManager.getActiveLogPaths();
|
||||
assert.equal(activePaths.length, 1);
|
||||
const activePath = activePaths[0];
|
||||
const hostDir = path.dirname(activePath);
|
||||
assert.equal(path.basename(hostDir), "snap-host");
|
||||
assert.equal(fs.existsSync(activePath), false, "snapshot file must not exist yet");
|
||||
|
||||
// Sibling inactive log would make the host dir look like a pure log folder
|
||||
// if we only inspected readdir and missed the not-yet-created active path.
|
||||
fs.writeFileSync(path.join(hostDir, "2026-01-01T00-00-00.txt"), "stale\n");
|
||||
const staleHostDir = path.join(directory, "stale-snap-host");
|
||||
fs.mkdirSync(staleHostDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(staleHostDir, "2026-01-03T00-00-00.txt"), "old\n");
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.equal(result.deletedCount, 2); // stale sibling + pure stale host
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), ["snap-host"]);
|
||||
assert.ok(fs.existsSync(hostDir), "active snapshot host dir must survive clear-all");
|
||||
assert.deepEqual(fs.readdirSync(hostDir), []);
|
||||
|
||||
// Stream must still be able to write its first snapshot after clear-all.
|
||||
sessionLogStreamManager.appendData(sessionId, "first snapshot body\n");
|
||||
const stoppedPath = await sessionLogStreamManager.stopStream(sessionId, startToken);
|
||||
assert.equal(path.resolve(stoppedPath), path.resolve(activePath));
|
||||
await waitForPath(activePath);
|
||||
assert.match(fs.readFileSync(activePath, "utf8"), /first snapshot body/);
|
||||
} finally {
|
||||
await sessionLogStreamManager.cleanupAll();
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir leaves unrelated empty or non-log folders alone", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-unrelated-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
try {
|
||||
fs.mkdirSync(path.join(directory, "empty-folder"), { recursive: true });
|
||||
fs.mkdirSync(path.join(directory, "docs"), { recursive: true });
|
||||
fs.writeFileSync(path.join(directory, "docs", "readme.md"), "hi\n");
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory });
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.deletedCount, 0);
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.deepEqual(fs.readdirSync(directory).sort(), ["docs", "empty-folder"]);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir is a no-op when the directory does not exist", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `clear-missing-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
const result = await clearSessionLogsDir(null, { directory });
|
||||
|
||||
assert.deepEqual(result, { success: true, deletedCount: 0, failedCount: 0 });
|
||||
});
|
||||
|
||||
test("clearSessionLogsDir reports an error when no directory is specified", async () => {
|
||||
const { clearSessionLogsDir } = loadBridgeWithDialog({});
|
||||
|
||||
const result = await clearSessionLogsDir(null, {});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.deletedCount, 0);
|
||||
assert.equal(result.failedCount, 0);
|
||||
assert.ok(result.error);
|
||||
});
|
||||
|
||||
test("manual session logs survive tokenless stale stops and stop through the bridge", async () => {
|
||||
const directory = path.join(TEMP_ROOT, `manual-token-${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
const filePath = path.join(directory, "manual.log");
|
||||
|
||||
@@ -1980,6 +1980,10 @@ function createTerminalWorkerManager(options = {}) {
|
||||
|
||||
return {
|
||||
ensureStarted,
|
||||
/** True when the utilityProcess child is currently alive. */
|
||||
isRunning() {
|
||||
return Boolean(child);
|
||||
},
|
||||
request,
|
||||
send,
|
||||
startExternalSession,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -213,6 +213,10 @@ class PluginDatabase {
|
||||
});
|
||||
}
|
||||
this.#assertSchemaLayout();
|
||||
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
||||
// (hostService seeds after package initialize). Running here would promote
|
||||
// sole map candidates before recovered manifests are visible, then skip
|
||||
// re-evaluation because bindings already exist (Codex P2 on cded4c8a).
|
||||
}
|
||||
|
||||
#assertSchemaLayout() {
|
||||
@@ -328,6 +332,20 @@ class PluginDatabase {
|
||||
`).all().map((row) => this.#mapPlugin(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed package version (active and inactive), with parsed manifests.
|
||||
* Used for ownership recovery when an older version still identifies a sync
|
||||
* provider that the active manifest no longer contributes.
|
||||
*/
|
||||
listInstalledVersions() {
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, version, manifest_json, archive_sha256,
|
||||
package_relative_path, installed_at
|
||||
FROM plugin_versions
|
||||
ORDER BY plugin_id COLLATE BINARY, installed_at DESC, version COLLATE BINARY
|
||||
`).all().map((row) => this.#mapVersion(row));
|
||||
}
|
||||
|
||||
#mapVersion(row) {
|
||||
return {
|
||||
pluginId: row.plugin_id,
|
||||
@@ -825,6 +843,181 @@ class PluginDatabase {
|
||||
return Number(result?.changes) || 0;
|
||||
}
|
||||
|
||||
/** Distinct plugin ids that own secrets with key === prefix or key LIKE prefix:%. */
|
||||
listPluginIdsWithSecretKeyPrefix(prefix) {
|
||||
const escaped = String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
||||
return this.db.prepare(`
|
||||
SELECT DISTINCT plugin_id
|
||||
FROM plugin_secrets
|
||||
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
||||
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote leftover sync-provider-map:* secret rows into the host-owned binding
|
||||
* table. Idempotent.
|
||||
*
|
||||
* Host map keys under this prefix are migration markers from an intermediate
|
||||
* build (pluginId-namespaced provider ids only). After a successful promote,
|
||||
* those map rows are deleted so constructor-time backfill cannot resurrect a
|
||||
* binding after the user disconnects/unbinds. Multiple plugins may hold map
|
||||
* rows for the same provider (parent + nested id): group by provider, never
|
||||
* overwrite an existing host binding (including empty-plugin_id unbind
|
||||
* tombstones). Among candidates, only plugins that still hold sync-credential*
|
||||
* secrets are eligible. Auto-bind only when exactly one distinct
|
||||
* credential-backed candidate exists; if several credential-backed plugins
|
||||
* map the same provider (e.g. com.example and com.example.sync both hold
|
||||
* credentials), skip entirely — longest pluginId is not a reliable owner
|
||||
* signal (the shorter parent can be the legitimate owner). Map-only leftovers
|
||||
* never invent ownership (validation only requires
|
||||
* providerId.startsWith(pluginId + ".")).
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLegacySecrets() {
|
||||
const legacyPrefix = "sync-provider-map:";
|
||||
const escaped = legacyPrefix.replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
||||
const legacyRows = this.db.prepare(`
|
||||
SELECT plugin_id, key
|
||||
FROM plugin_secrets
|
||||
WHERE key LIKE ? ESCAPE '\\'
|
||||
`).all(`${escaped}%`);
|
||||
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
||||
const byProvider = new Map();
|
||||
for (const row of legacyRows) {
|
||||
const pluginId = row.plugin_id;
|
||||
const key = row.key;
|
||||
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
||||
if (!key.startsWith(legacyPrefix)) continue;
|
||||
const providerId = key.slice(legacyPrefix.length);
|
||||
if (!providerId || providerId.includes("\0")) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
continue;
|
||||
}
|
||||
const list = byProvider.get(providerId) || [];
|
||||
list.push({ pluginId, key });
|
||||
byProvider.set(providerId, list);
|
||||
}
|
||||
// Credential presence is the signal for real ownership. A longer nested map
|
||||
// row without credentials must not steal bind from a parent that still owns
|
||||
// sync-credential* secrets for the provider namespace.
|
||||
const credentialOwners = new Set(
|
||||
this.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
// Same single-provider guard as live backfill: one shared sync-credential*
|
||||
// row must not seed bindings for every historical map provider under that
|
||||
// plugin (disconnecting a stale provider would wipe the live credentials).
|
||||
// Count both legacy map rows and installed-manifest sync providers so a
|
||||
// stale map cannot promote when the live manifest also declares another
|
||||
// provider under the same credential owner (Codex P2 on 8ae60205).
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const providersByCredentialOwner = new Map();
|
||||
for (const [providerId, candidates] of byProvider) {
|
||||
for (const c of candidates) {
|
||||
if (!credentialOwners.has(c.pluginId)) continue;
|
||||
const set = providersByCredentialOwner.get(c.pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByCredentialOwner.set(c.pluginId, set);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const versions = typeof this.listInstalledVersions === "function"
|
||||
? this.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !credentialOwners.has(pluginId)) continue;
|
||||
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const set = providersByCredentialOwner.get(pluginId) || new Set();
|
||||
set.add(provider.id);
|
||||
providersByCredentialOwner.set(pluginId, set);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore catalog probe failures */
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [providerId, candidates] of byProvider) {
|
||||
// Any host row means a prior decision: active bind or explicit unbind
|
||||
// tombstone (empty plugin_id). Never resurrect from leftover map secrets.
|
||||
if (this.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
// Only credential-backed map candidates may win; map-only leftovers stay
|
||||
// unbound rather than inventing ownership from namespace length alone.
|
||||
// Multiple distinct credential-backed candidates is ambiguous (parent vs
|
||||
// nested can both hold credentials for the same provider namespace) — skip
|
||||
// entirely rather than picking longest pluginId.
|
||||
const uniqueCredentialOwners = [
|
||||
...new Set(
|
||||
candidates
|
||||
.filter((c) => credentialOwners.has(c.pluginId))
|
||||
.map((c) => c.pluginId),
|
||||
),
|
||||
];
|
||||
if (uniqueCredentialOwners.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
const chosenOwner = uniqueCredentialOwners[0];
|
||||
if ((providersByCredentialOwner.get(chosenOwner)?.size ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
// Constructor-time map promote runs before hostService's installed-manifest
|
||||
// conflict check. Skip when another installed package would also claim this
|
||||
// provider (live nested plugin without credentials yet) so we do not bind
|
||||
// a legacy parent that later disconnect cannot clear (Codex P2 on 5bc1b60d).
|
||||
let hasManifestConflict = false;
|
||||
try {
|
||||
const versions = typeof this.listInstalledVersions === "function"
|
||||
? this.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || pluginId === chosenOwner) continue;
|
||||
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (provider.id === providerId) {
|
||||
hasManifestConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasManifestConflict) break;
|
||||
}
|
||||
} catch {
|
||||
/* ignore catalog probe failures */
|
||||
}
|
||||
if (hasManifestConflict) {
|
||||
continue;
|
||||
}
|
||||
this.upsertSyncProviderBinding(providerId, chosenOwner);
|
||||
// Consume legacy map markers for this provider so a later unbind + reopen
|
||||
// cannot re-promote from leftover secrets. Only delete namespaced map keys
|
||||
// we accepted as candidates (not arbitrary plugin secrets). Consume all
|
||||
// candidate maps (including map-only losers) once a credential-backed
|
||||
// owner is bound, so orphans cannot re-run later.
|
||||
for (const candidate of candidates) {
|
||||
this.deleteSecret(candidate.pluginId, candidate.key);
|
||||
}
|
||||
promoted += 1;
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential-row prefix inference is intentionally not used.
|
||||
*
|
||||
* A shorter parent plugin id (e.g. com.example with sync-credential rows) can
|
||||
* namespace-prefix a provider owned by a different/removed plugin
|
||||
* (com.example.backup.sync). Falling back to that parent would let
|
||||
* syncDeleteSecrets wipe unrelated credentials. Ownership must come from the
|
||||
* host binding table (including rows promoted by
|
||||
* backfillSyncProviderBindingsFromLegacySecrets) or a live contribution.
|
||||
*/
|
||||
inferPluginIdForSyncProvider(_providerId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
recordSecurityAudit(pluginId, event, details) {
|
||||
let detailsJson = JSON.stringify(details ?? {});
|
||||
const originalBytes = Buffer.byteLength(detailsJson);
|
||||
|
||||
@@ -474,3 +474,362 @@ test("activating a new version resets runtime quarantine without forgiving the s
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.quarantinedAt, 1_000);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("schema upgrade backfills bindings from legacy sync-provider-map secrets", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v2-backfill-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
// Bootstrap a schema-2 DB then open with PluginDatabase to migrate to v3.
|
||||
const seed = new PluginDatabase(file);
|
||||
// Force downgrade-like state: empty bindings + a legacy map secret row.
|
||||
seed.db.exec("DELETE FROM plugin_sync_provider_bindings");
|
||||
const now = Date.now();
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.close();
|
||||
|
||||
const database = new PluginDatabase(file);
|
||||
// Constructor no longer promotes; hostService seeds after package recovery.
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "com.example");
|
||||
// Consumed map marker so later unbind + reopen cannot re-promote.
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promoted map markers must be deleted",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not overwrite an existing host binding", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Correct owner already bound.
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "com.example.sync");
|
||||
// Stale parent leftover map secret that would steal ownership if upserted.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "must not promote over an existing binding");
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"existing binding must be preserved",
|
||||
);
|
||||
// Skipped promote leaves non-winning map rows in place (only winners are consumed).
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"non-promoted leftover map rows stay until unbind consumes them",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when no candidate holds sync credentials", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Map-only leftovers: longest namespace must not invent ownership without
|
||||
// sync-credential* evidence (parent may be the true owner later).
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Unbound maps stay so a later credential-backed promote or live put can resolve.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill binds sole credential-backed owner when maps conflict", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Parent has leftover map only; nested owner still holds credentials.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "real-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"sole credential-backed owner must win over map-only parent",
|
||||
);
|
||||
// All candidate map markers for the promoted provider are consumed.
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not let longest map override credential-backed parent", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// True owner is the shorter parent (still has credentials). Nested plugin has
|
||||
// only a stale map row - longest map alone must not steal the binding.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example",
|
||||
"credential-backed parent must win over longer map-only nested id",
|
||||
);
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when multiple credential-backed candidates exist", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Both parent and nested hold credentials + map for the same provider.
|
||||
// Longest pluginId is not a reliable owner signal — the shorter parent may
|
||||
// be the legitimate owner — so leave unbound rather than guessing.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "nested-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Maps stay so a live put / explicit bind can resolve ownership later.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill still consumes maps after unbind tombstone blocks promote", (context) => {
|
||||
// Verify prior fix: explicit unbind tombstone blocks re-promote; maps may remain
|
||||
// until unbind/delete paths consume them (backfill itself skips and does not
|
||||
// delete when promote is blocked).
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"",
|
||||
"unbind tombstone must block credential-backed map promote",
|
||||
);
|
||||
// Map row stays; disconnect/unbind consumption is responsible for cleanup.
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"skipped promote leaves map markers for unbind to consume",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill deletes promoted map markers so unbind cannot resurrect", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
// Credential evidence required to promote; non-map secrets must survive map consume.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.custom")?.pluginId, "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.custom"),
|
||||
null,
|
||||
"promoted map markers must be deleted to stop post-unbind resurrection",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not resurrect after explicit unbind tombstone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Pre-seed a leftover map after an explicit empty-plugin_id unbind tombstone.
|
||||
database.upsertSyncProviderBinding("com.example.sync", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync")?.pluginId,
|
||||
"",
|
||||
"empty-plugin_id unbind tombstone must block re-promotion",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
test("listInstalledVersions includes inactive package manifests", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Install two versions under one plugin id via raw rows if helpers are heavy.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES (?, 1, ?, ?, ?)
|
||||
`).run("com.example", "2.0.0", now, now);
|
||||
for (const [version, providerId] of [
|
||||
["1.0.0", "com.example.legacy-sync"],
|
||||
["2.0.0", "com.example.sync"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_versions(
|
||||
plugin_id, version, manifest_json, archive_sha256, package_relative_path, installed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
version,
|
||||
JSON.stringify({
|
||||
id: "com.example",
|
||||
version,
|
||||
contributes: { providers: [{ id: providerId, kind: "sync", label: providerId }] },
|
||||
}),
|
||||
"a".repeat(64),
|
||||
`com.example/${version}/package`,
|
||||
now,
|
||||
);
|
||||
}
|
||||
const versions = database.listInstalledVersions();
|
||||
assert.equal(versions.length, 2);
|
||||
const providerIds = new Set();
|
||||
for (const v of versions) {
|
||||
for (const p of v.manifest?.contributes?.providers ?? []) {
|
||||
if (p.kind === "sync") providerIds.add(p.id);
|
||||
}
|
||||
}
|
||||
assert.ok(providerIds.has("com.example.legacy-sync"));
|
||||
assert.ok(providerIds.has("com.example.sync"));
|
||||
// Active list only has 2.0.0
|
||||
assert.equal(database.listPlugins()[0]?.activeVersion, "2.0.0");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("inferPluginIdForSyncProvider never guesses from credential key prefixes", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-credential"],
|
||||
["com.example.backup", "sync-credential"],
|
||||
["com.other", "sync-credential"],
|
||||
["com.example.sync", "sync-credential"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
}
|
||||
// Parent prefix alone is not enough: a removed plugin may have owned
|
||||
// com.example.backup.sync while only com.example still has credentials.
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.backup.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.other.cloud"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
@@ -46,6 +46,37 @@ function getElectronProcessMetrics(app, pid) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full installed-manifest catalog of sync providers (active + inactive versions).
|
||||
* Shared by startup and enable-time backfill so ambiguity checks always see
|
||||
* cross-plugin / nested-namespace claims.
|
||||
* @param {{ listInstalledVersions?: () => Array<{ pluginId?: string, manifest?: { contributes?: { providers?: Array<{ kind?: string, id?: string }> } } }> }} database
|
||||
* @returns {Array<{ pluginId: string, provider: { id: string } }>}
|
||||
*/
|
||||
function collectInstalledSyncProviderCatalog(database) {
|
||||
const installedSyncProviders = [];
|
||||
const seen = new Set();
|
||||
const versions = typeof database.listInstalledVersions === "function"
|
||||
? database.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !version.manifest) continue;
|
||||
for (const provider of version.manifest.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const key = `${pluginId}\0${provider.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
installedSyncProviders.push({
|
||||
pluginId,
|
||||
provider: { id: provider.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
return installedSyncProviders;
|
||||
}
|
||||
|
||||
function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
@@ -227,7 +258,47 @@ function createPluginHostService(options) {
|
||||
} catch {
|
||||
// Hydration is best-effort; enable must still succeed.
|
||||
}
|
||||
return originalOnPluginEnabled(pluginId);
|
||||
const enabled = await originalOnPluginEnabled(pluginId);
|
||||
try {
|
||||
// Use the same installed-manifest catalog as startup so cross-plugin /
|
||||
// inactive-version ambiguity checks still apply when only this plugin
|
||||
// was just enabled (Codex P2 on d3a9b94c).
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; enable must still succeed.
|
||||
}
|
||||
return enabled;
|
||||
};
|
||||
// Live-provider seed must wait until PackageStore.recover() has restored
|
||||
// missing plugin_versions. Construction-time seed can bind a stale owner
|
||||
// against an incomplete catalog; post-recovery seed then skips it as
|
||||
// existing (Codex P2 on f2b1b5d8). syncDeleteSecrets already awaits
|
||||
// resolveManager() → package initialize, so cleanup sees the post-recovery
|
||||
// seed without racing an early incomplete bind.
|
||||
const seedSyncBindings = () => {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders !== "function") return;
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
};
|
||||
// PackageStore.recover() repopulates plugin_versions; only then promote
|
||||
// legacy maps and seed live bindings (Codex P2 on dd7aad70 / f2b1b5d8).
|
||||
const originalPackageInitialize = packageStore.initialize.bind(packageStore);
|
||||
packageStore.initialize = async () => {
|
||||
const result = await originalPackageInitialize();
|
||||
try {
|
||||
if (typeof database.backfillSyncProviderBindingsFromLegacySecrets === "function") {
|
||||
database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
}
|
||||
seedSyncBindings();
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// Startup activation goes through contributionService.initialize() → #startPlugin
|
||||
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
|
||||
@@ -241,6 +312,16 @@ function createPluginHostService(options) {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
}
|
||||
// Re-seed after any package catalog changes during init.
|
||||
try {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
return originalInitialize();
|
||||
};
|
||||
const terminalDataPipelineService = options.electron.MessageChannelMain
|
||||
|
||||
@@ -1082,6 +1082,13 @@ function registerPluginBridge(ipcMain, options) {
|
||||
throw new Error("Plugin sync secret storage is unavailable");
|
||||
}
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
// Wait for package recovery + binding seed before resolving ownership so a
|
||||
// schema-2 disconnect during startup cannot miss retained bindings (Codex P2).
|
||||
try {
|
||||
await resolveManager();
|
||||
} catch {
|
||||
// Runtime disabled: continue with whatever secretStore already has.
|
||||
}
|
||||
const providerId = payload?.providerId;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) {
|
||||
throw new TypeError("Sync provider ID is invalid");
|
||||
|
||||
@@ -178,16 +178,16 @@ class PluginSecretStore {
|
||||
* Durable providerId → pluginId binding so disconnect can wipe sync secrets
|
||||
* after the contribution disappears (disabled/uninstalled plugin).
|
||||
* Stored in a host-owned table, not plugin-writable secrets.
|
||||
* Overwrites any prior empty-plugin_id unbind tombstone.
|
||||
*/
|
||||
bindSyncProviderPlugin(pluginId, providerId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) {
|
||||
throw new TypeError("Plugin id is invalid");
|
||||
}
|
||||
assertSecretKey(providerId);
|
||||
if (
|
||||
providerId !== pluginId
|
||||
&& !providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
// Contribution provider ids live under the owning plugin namespace
|
||||
// (`pluginId.`…); they are never equal to the plugin id itself.
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
throw new TypeError("Sync provider id is outside the plugin namespace");
|
||||
}
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") {
|
||||
@@ -198,22 +198,135 @@ class PluginSecretStore {
|
||||
|
||||
resolveSyncProviderPlugin(providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.getSyncProviderBinding !== "function") return undefined;
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) return undefined;
|
||||
if (providerId !== pluginId && !providerId.startsWith(`${pluginId}.`)) {
|
||||
return undefined;
|
||||
if (typeof this.database.getSyncProviderBinding === "function") {
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (
|
||||
typeof pluginId === "string"
|
||||
&& pluginId.length > 0
|
||||
&& providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
return pluginId;
|
||||
}
|
||||
}
|
||||
return pluginId;
|
||||
// Do not infer ownership from sync-credential* key prefixes: a parent
|
||||
// plugin id may namespace-prefix another plugin's provider and would cause
|
||||
// disconnect to delete the wrong secrets. Pre-binding installs recover via
|
||||
// (1) backfillSyncProviderBindingsFromLegacySecrets for intermediate map
|
||||
// rows, (2) backfillSyncProviderBindingsFromLiveProviders using host
|
||||
// contribution metadata at startup, or (3) the next successful put.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed missing provider→plugin bindings from contribution metadata.
|
||||
*
|
||||
* Real schema-2 installs only stored plugin-scoped `sync-credential*` rows
|
||||
* (no per-provider ownership). After upgrade, seed a binding only when a
|
||||
* plugin with credentials has exactly one sync provider id across the
|
||||
* catalog: binding every historical/active provider would let disconnecting
|
||||
* a stale provider wipe the shared credential prefix used by the live one.
|
||||
* Multi-provider plugins wait for the next successful put (or map backfill).
|
||||
*
|
||||
* @param {Array<{ pluginId?: string, id?: string, provider?: { id?: string } }>} providers
|
||||
* @returns {number} number of newly written bindings
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLiveProviders(providers) {
|
||||
if (!Array.isArray(providers)) return 0;
|
||||
if (typeof this.database.listPluginIdsWithSecretKeyPrefix !== "function") return 0;
|
||||
const credentialOwners = new Set(
|
||||
this.database.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
if (credentialOwners.size === 0) return 0;
|
||||
/** @type {Map<string, Set<string>>} pluginId -> provider ids it declares */
|
||||
const providersByPlugin = new Map();
|
||||
/** @type {Map<string, Set<string>>} providerId -> plugins claiming it */
|
||||
const pluginsByProvider = new Map();
|
||||
for (const entry of providers) {
|
||||
const pluginId = entry?.pluginId;
|
||||
const providerId = entry?.provider?.id ?? entry?.id;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) continue;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) continue;
|
||||
// Count every manifest claim for conflict detection — including live
|
||||
// plugins that have not stored credentials yet. Binding only a
|
||||
// credential-backed legacy parent would orphan credentials when a later
|
||||
// disconnect prefers the live nested owner (Codex P2 on 8d64ea20).
|
||||
const claimants = pluginsByProvider.get(providerId) || new Set();
|
||||
claimants.add(pluginId);
|
||||
pluginsByProvider.set(providerId, claimants);
|
||||
// Only credential-backed plugins are eligible to become the binding owner.
|
||||
if (!credentialOwners.has(pluginId)) continue;
|
||||
const set = providersByPlugin.get(pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByPlugin.set(pluginId, set);
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [pluginId, providerIds] of providersByPlugin) {
|
||||
if (providerIds.size !== 1) continue;
|
||||
const providerId = [...providerIds][0];
|
||||
// Two credential-backed plugins can both declare the same provider via
|
||||
// nested namespaces (legacy disabled parent + live child). Binding the
|
||||
// first writer would make a later missing-provider disconnect delete the
|
||||
// wrong sync-credential* rows. Skip any cross-plugin claim conflict.
|
||||
const claimants = pluginsByProvider.get(providerId);
|
||||
if (!claimants || claimants.size !== 1) continue;
|
||||
// Skip active binds and explicit-unbind tombstones alike.
|
||||
if (typeof this.database.getSyncProviderBinding === "function"
|
||||
&& this.database.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.bindSyncProviderPlugin(pluginId, providerId);
|
||||
promoted += 1;
|
||||
} catch {
|
||||
/* invalid contribution ids stay unbound */
|
||||
}
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
unbindSyncProviderPlugin(pluginId, providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.deleteSyncProviderBinding !== "function") return;
|
||||
const existing = this.database.getSyncProviderBinding(providerId);
|
||||
if (existing && existing.pluginId !== pluginId) return;
|
||||
this.database.deleteSyncProviderBinding(providerId);
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") return;
|
||||
const existing = typeof this.database.getSyncProviderBinding === "function"
|
||||
? this.database.getSyncProviderBinding(providerId)
|
||||
: null;
|
||||
// Do not steal another plugin's active binding. Empty plugin_id is an
|
||||
// unbind tombstone and may be refreshed idempotently.
|
||||
if (
|
||||
existing
|
||||
&& typeof existing.pluginId === "string"
|
||||
&& existing.pluginId.length > 0
|
||||
&& existing.pluginId !== pluginId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Consume leftover intermediate-build map markers so they cannot re-seed
|
||||
// a binding if the tombstone is ever cleared. Delete under this plugin and
|
||||
// any residual map rows for the same provider across plugins.
|
||||
const mapKey = `sync-provider-map:${providerId}`;
|
||||
if (typeof this.database.deleteSecret === "function") {
|
||||
try {
|
||||
this.database.deleteSecret(pluginId, mapKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof this.database.findSecretsByKey === "function" && typeof this.database.deleteSecret === "function") {
|
||||
for (const row of this.database.findSecretsByKey(mapKey) || []) {
|
||||
if (typeof row?.pluginId !== "string" || typeof row?.key !== "string") continue;
|
||||
try {
|
||||
this.database.deleteSecret(row.pluginId, row.key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tombstone with empty plugin_id so leftover sync-provider-map:* secret
|
||||
// rows cannot re-promote this provider on the next database open.
|
||||
this.database.upsertSyncProviderBinding(providerId, "");
|
||||
}
|
||||
|
||||
resolve(pluginId, secret) {
|
||||
|
||||
@@ -378,6 +378,45 @@ test("sync provider bindings live outside plugin secrets and reject namespace mi
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Tombstone remains so legacy map backfill cannot resurrect after disconnect.
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
// Unbind also consumes map markers (including attacker leftovers).
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.attacker", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Reconnect clears the tombstone.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("unbind deletes owner map markers and blocks legacy re-promotion", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Credential evidence is required for map promote; simulate a real owner plus
|
||||
// an intermediate-build map row that backfill would otherwise promote.
|
||||
store.set("com.example", "sync-credential", "secret");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promote consumes the map marker",
|
||||
);
|
||||
// User disconnects; reintroduce a map row as if something wrote it back.
|
||||
// Tombstone still blocks re-promotion even with credentials + map present.
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
database.close();
|
||||
});
|
||||
|
||||
@@ -443,3 +482,92 @@ test("existing overwrite stash is preserved across retry puts and cleared by pre
|
||||
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("resolveSyncProviderPlugin does not infer ownership from credential prefixes alone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Without a binding (or live-provider backfill), credentials alone are not enough.
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Explicit bind still works for disconnect cleanup.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill seeds bindings for v2 credential-only upgrades", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Real schema-2 path: credentials exist, binding table empty, no map rows.
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.set("com.disabled", "sync-credential", "pw2");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Host should pass installed manifests including disabled plugins.
|
||||
const promoted = store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
{ pluginId: "com.disabled", provider: { id: "com.disabled.sync" } },
|
||||
// No credentials under this plugin — skip.
|
||||
{ pluginId: "com.other", provider: { id: "com.other.cloud" } },
|
||||
// Wrong namespace alone would be multi with sync below — still skip evil.
|
||||
]);
|
||||
assert.equal(promoted, 2);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.disabled.sync"), "com.disabled");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.other.cloud"), undefined);
|
||||
// Idempotent when binding already exists.
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
// Multi-provider plugins must not all bind from one shared credential row.
|
||||
store.set("com.multi", "sync-credential", "shared");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.old" } },
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.new" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.old"), undefined);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.new"), undefined);
|
||||
// Cross-plugin claim on the same providerId must not bind the first writer.
|
||||
store.set("com.example.sync", "sync-credential", "nested");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync.foo" } },
|
||||
{ pluginId: "com.example.sync", provider: { id: "com.example.sync.foo" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync.foo"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill does not resurrect explicit unbind tombstones", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
@@ -1302,6 +1302,8 @@ function createPreloadApi(ctx) {
|
||||
ipcRenderer.invoke("netcatty:sessionLogs:autoSave", payload),
|
||||
openSessionLogsDir: (directory) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLogs:openDir", { directory }),
|
||||
clearSessionLogsDir: (directory) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLogs:clear", { directory }),
|
||||
startManualSessionLog: (payload) =>
|
||||
ipcRenderer.invoke("netcatty:sessionLog:manualStart", payload),
|
||||
stopManualSessionLog: (payload) =>
|
||||
@@ -1622,6 +1624,9 @@ function createPreloadApi(ctx) {
|
||||
respondMcpApproval: async (approvalId, approved) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-response", { approvalId, approved });
|
||||
},
|
||||
cancelMcpApprovalTimeout: async (approvalId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:mcp:approval-cancel-timeout", { approvalId });
|
||||
},
|
||||
// MCP approval cleared: main process timed out or cancelled an approval
|
||||
onMcpApprovalCleared: (cb) => {
|
||||
const handler = (_event, payload) => cb(payload);
|
||||
@@ -1697,6 +1702,9 @@ function createPreloadApi(ctx) {
|
||||
respondCodexAppServerInteraction: async (payload) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex-app-server:interaction-response", payload);
|
||||
},
|
||||
cancelCodexAppServerInteractionTimeout: async (interactionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:codex-app-server:interaction-cancel-timeout", { interactionId });
|
||||
},
|
||||
aiSdkAgentCancel: async (requestId, chatSessionId) => {
|
||||
return ipcRenderer.invoke("netcatty:ai:sdk-agent:cancel", { requestId, chatSessionId });
|
||||
},
|
||||
|
||||
@@ -298,6 +298,9 @@ function main() {
|
||||
parentPort,
|
||||
sessionLogStreamManager,
|
||||
});
|
||||
// Expose worker-owned active log paths so main-process clear-all can
|
||||
// skip live auto-save streams (separate module instance from main).
|
||||
require("../bridges/sessionLogsBridge.cjs").registerWorkerHandlers(ipcMain);
|
||||
const { createSystemManagerBridge } = require("../bridges/systemManagerBridge.cjs");
|
||||
createSystemManagerBridge({
|
||||
getSessions: () => sessions,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildCattyStreamTimeouts } from './streamTimeouts';
|
||||
import { CATTY_APPROVAL_TIMEOUT_MS } from '../shared/approvalConstants';
|
||||
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
|
||||
|
||||
describe('buildCattyStreamTimeouts', () => {
|
||||
it('keeps stream budgets from undercutting the configured command timeout', () => {
|
||||
@@ -16,9 +16,9 @@ describe('buildCattyStreamTimeouts', () => {
|
||||
assert.ok(timeouts.totalMs > oneDayMs);
|
||||
});
|
||||
|
||||
it('includes confirm-mode approval time in long command stream budgets', () => {
|
||||
it('includes confirm-mode hard approval deadline in long command stream budgets', () => {
|
||||
const commandTimeoutMs = 60 * 1000;
|
||||
const expectedMinimum = commandTimeoutMs + CATTY_APPROVAL_TIMEOUT_MS + (90 * 1000);
|
||||
const expectedMinimum = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
permissionMode: 'confirm',
|
||||
commandTimeoutMs,
|
||||
@@ -32,7 +32,7 @@ describe('buildCattyStreamTimeouts', () => {
|
||||
|
||||
it('scales the total stream budget for multi-step long command turns', () => {
|
||||
const commandTimeoutMs = 10 * 60 * 1000;
|
||||
const singleStepBudgetMs = commandTimeoutMs + CATTY_APPROVAL_TIMEOUT_MS + (90 * 1000);
|
||||
const singleStepBudgetMs = commandTimeoutMs + CATTY_APPROVAL_HARD_DEADLINE_MS + (90 * 1000);
|
||||
const timeouts = buildCattyStreamTimeouts({
|
||||
permissionMode: 'confirm',
|
||||
commandTimeoutMs,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { AIPermissionMode } from '../types';
|
||||
import { CATTY_APPROVAL_TIMEOUT_MS } from '../shared/approvalConstants';
|
||||
import { CATTY_APPROVAL_HARD_DEADLINE_MS } from '../shared/approvalConstants';
|
||||
|
||||
const THIRTY_MINUTES_MS = 30 * 60 * 1000;
|
||||
const TEN_MINUTES_MS = 10 * 60 * 1000;
|
||||
@@ -18,7 +18,8 @@ export interface BuildCattyStreamTimeoutsInput {
|
||||
export function buildCattyStreamTimeouts(
|
||||
input: BuildCattyStreamTimeoutsInput = {},
|
||||
) {
|
||||
const approvalBudgetMs = input.permissionMode === 'confirm' ? CATTY_APPROVAL_TIMEOUT_MS : 0;
|
||||
// Budget the hard approval deadline so a mid-review re-arm is not cut off by toolMs.
|
||||
const approvalBudgetMs = input.permissionMode === 'confirm' ? CATTY_APPROVAL_HARD_DEADLINE_MS : 0;
|
||||
const stepCount =
|
||||
Number.isFinite(input.maxIterations) && input.maxIterations != null && input.maxIterations > 0
|
||||
? Math.max(1, Math.floor(input.maxIterations))
|
||||
@@ -33,7 +34,7 @@ export function buildCattyStreamTimeouts(
|
||||
totalMs,
|
||||
stepMs: Math.max(TEN_MINUTES_MS, commandTimeoutBudgetMs),
|
||||
chunkMs: Math.max(TWO_MINUTES_MS, commandTimeoutBudgetMs),
|
||||
toolMs: Math.max(CATTY_APPROVAL_TIMEOUT_MS + NINETY_SECONDS_MS, commandTimeoutBudgetMs),
|
||||
toolMs: Math.max(CATTY_APPROVAL_HARD_DEADLINE_MS + NINETY_SECONDS_MS, commandTimeoutBudgetMs),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,35 @@
|
||||
/** Renderer-side Catty tool approval timeout (5 minutes). */
|
||||
/** Renderer-side Catty tool approval idle timeout (5 minutes). */
|
||||
export const CATTY_APPROVAL_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* Hard ceiling from approval creation. Review activity re-arms the idle timer
|
||||
* but never past this bound (3× idle for the default 5m → 15m).
|
||||
*/
|
||||
export const CATTY_APPROVAL_HARD_DEADLINE_MS = 15 * 60 * 1000;
|
||||
|
||||
/** Absolute upper bound for any Catty approval hard deadline (30 minutes). */
|
||||
export const CATTY_APPROVAL_HARD_DEADLINE_MAX_MS = 30 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* MCP / external SDK approval timeout aligned with Codex MCP limits (~110s).
|
||||
* Kept separate from Catty because external agents block on main-process IPC.
|
||||
*/
|
||||
export const MCP_APPROVAL_TIMEOUT_MS = 110 * 1000;
|
||||
|
||||
/**
|
||||
* Resolve idle vs hard deadline for a Catty approval request.
|
||||
* hardDeadlineMs is always >= idleMs and capped by the 30m global max when
|
||||
* the 3× multiplier would exceed it (unless idle itself is larger).
|
||||
*/
|
||||
export function resolveCattyApprovalDeadlines(timeoutMs: number = CATTY_APPROVAL_TIMEOUT_MS): {
|
||||
idleMs: number;
|
||||
hardDeadlineMs: number;
|
||||
} {
|
||||
const idleMs = Math.max(0, Number.isFinite(timeoutMs) ? timeoutMs : CATTY_APPROVAL_TIMEOUT_MS);
|
||||
const scaled = idleMs * 3;
|
||||
const hardDeadlineMs = Math.max(
|
||||
idleMs,
|
||||
Math.min(scaled, CATTY_APPROVAL_HARD_DEADLINE_MAX_MS),
|
||||
);
|
||||
return { idleMs, hardDeadlineMs };
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import {
|
||||
cancelApprovalTimeout,
|
||||
clearAllPendingApprovals,
|
||||
onApprovalCleared,
|
||||
requestApproval,
|
||||
resolveApproval,
|
||||
} from './approvalGate';
|
||||
import { resolveCattyApprovalDeadlines } from './approvalConstants';
|
||||
|
||||
function stubNow(startMs: number): { advance: (deltaMs: number) => void; restore: () => void } {
|
||||
const realNow = Date.now;
|
||||
let now = startMs;
|
||||
Date.now = () => now;
|
||||
return {
|
||||
advance: (deltaMs: number) => {
|
||||
now += deltaMs;
|
||||
},
|
||||
restore: () => {
|
||||
Date.now = realNow;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test('resolveCattyApprovalDeadlines keeps hard deadline >= idle and 3x by default', () => {
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(100), { idleMs: 100, hardDeadlineMs: 300 });
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(5 * 60 * 1000), {
|
||||
idleMs: 5 * 60 * 1000,
|
||||
hardDeadlineMs: 15 * 60 * 1000,
|
||||
});
|
||||
// Cap at 30m when 3× would exceed, unless idle itself is larger.
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(20 * 60 * 1000), {
|
||||
idleMs: 20 * 60 * 1000,
|
||||
hardDeadlineMs: 30 * 60 * 1000,
|
||||
});
|
||||
assert.deepEqual(resolveCattyApprovalDeadlines(40 * 60 * 1000), {
|
||||
idleMs: 40 * 60 * 1000,
|
||||
hardDeadlineMs: 40 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout re-arms a fresh idle window, not the original absolute remainder', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(1_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-idle-rearm-${Date.now()}`;
|
||||
// idle 100ms, hard 300ms
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
100,
|
||||
);
|
||||
|
||||
// Review near end of first idle window — previously this left ~30ms absolute
|
||||
// remainder and denied while the user was still deciding.
|
||||
clock.advance(70);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Fresh idle (100ms) should keep the approval pending well past the old
|
||||
// absolute mark at t=100.
|
||||
await delay(50);
|
||||
assert.equal(cleared.includes(toolCallId), false, 'must stay pending through re-armed idle');
|
||||
|
||||
// Still before hard deadline (300): jump clock so remaining hard is short.
|
||||
clock.advance(220); // now = start+290; remaining hard = 10ms; re-arm idle capped to 10
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
const outcome = await Promise.race([
|
||||
approvalPromise.then((approved) => ({ approved })),
|
||||
delay(120).then(() => ({ approved: 'timeout-wait' as const })),
|
||||
]);
|
||||
assert.deepEqual(outcome, { approved: false });
|
||||
assert.ok(cleared.includes(toolCallId));
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout survives past the idle deadline while reviewing', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(3_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-past-idle-${Date.now()}`;
|
||||
const idleMs = 80;
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
idleMs,
|
||||
);
|
||||
|
||||
// Review near end of first idle — re-arms a full idleMs from now (hard = 3x).
|
||||
clock.advance(idleMs - 20);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Wall-clock past the original idle mark; re-armed idle still has ~idleMs left.
|
||||
await delay(40);
|
||||
assert.equal(cleared.includes(toolCallId), false, 'active review must outlive original idle');
|
||||
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout still allows explicit approve before hard Catty deadline', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const clock = stubNow(2_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-approve-${Date.now()}`;
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'sftp_write',
|
||||
{ path: '/tmp/x' },
|
||||
'chat-1',
|
||||
200,
|
||||
);
|
||||
|
||||
clock.advance(150);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
} finally {
|
||||
clock.restore();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout rejects expired Catty approvals synchronously after hard deadline', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(4_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-hard-sync-${Date.now()}`;
|
||||
// idle 50ms → hard 150ms
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
50,
|
||||
);
|
||||
|
||||
clock.advance(160); // past hard deadline
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Must already be denied — no setTimeout(0) race window for approve.
|
||||
assert.ok(cleared.includes(toolCallId), 'must clear synchronously when hard deadline elapsed');
|
||||
assert.equal(await approvalPromise, false);
|
||||
|
||||
// Late approve after hard-deadline deny must not resurrect the request.
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, false);
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('repeated cancelApprovalTimeout re-arms idle on each review interaction', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
const clock = stubNow(5_000_000);
|
||||
|
||||
try {
|
||||
const toolCallId = `timeout-multi-rearm-${Date.now()}`;
|
||||
const idleMs = 100;
|
||||
const approvalPromise = requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
idleMs,
|
||||
);
|
||||
|
||||
// Simulate successive review events (focus, scroll, key) — each re-arms.
|
||||
clock.advance(60);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
clock.advance(60);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
clock.advance(60);
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
|
||||
// Still before hard deadline (300ms); original idle marks have long passed.
|
||||
await delay(20);
|
||||
assert.equal(cleared.includes(toolCallId), false, 'each re-arm must keep approval pending');
|
||||
|
||||
resolveApproval(toolCallId, true);
|
||||
assert.equal(await approvalPromise, true);
|
||||
} finally {
|
||||
clock.restore();
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
|
||||
test('idle approval timeout still auto-denies when the user never reviews', async () => {
|
||||
clearAllPendingApprovals();
|
||||
const cleared: string[] = [];
|
||||
const unsub = onApprovalCleared((ids) => {
|
||||
cleared.push(...ids);
|
||||
});
|
||||
|
||||
const toolCallId = `timeout-fire-${Date.now()}`;
|
||||
const approved = await requestApproval(
|
||||
toolCallId,
|
||||
'terminal_execute',
|
||||
{ sessionId: 's1', command: 'echo hi' },
|
||||
'chat-1',
|
||||
30,
|
||||
);
|
||||
|
||||
assert.equal(approved, false);
|
||||
assert.ok(cleared.includes(toolCallId));
|
||||
|
||||
unsub();
|
||||
clearAllPendingApprovals();
|
||||
});
|
||||
|
||||
test('cancelApprovalTimeout asks main to drop Codex App Server interaction timers', () => {
|
||||
clearAllPendingApprovals();
|
||||
const calls: string[] = [];
|
||||
const previous = (globalThis as { window?: unknown }).window;
|
||||
(globalThis as { window?: unknown }).window = {
|
||||
netcatty: {
|
||||
cancelCodexAppServerInteractionTimeout: async (id: string) => {
|
||||
calls.push(id);
|
||||
return { ok: true, cancelled: true };
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
const toolCallId = `codex_interaction_1_${Date.now()}`;
|
||||
cancelApprovalTimeout(toolCallId);
|
||||
assert.deepEqual(calls, [toolCallId]);
|
||||
} finally {
|
||||
(globalThis as { window?: unknown }).window = previous;
|
||||
clearAllPendingApprovals();
|
||||
}
|
||||
});
|
||||
@@ -17,7 +17,10 @@
|
||||
* interference when stopping or cancelling sessions.
|
||||
*/
|
||||
|
||||
import { CATTY_APPROVAL_TIMEOUT_MS } from './approvalConstants';
|
||||
import {
|
||||
CATTY_APPROVAL_TIMEOUT_MS,
|
||||
resolveCattyApprovalDeadlines,
|
||||
} from './approvalConstants';
|
||||
import { localStorageAdapter } from '../../persistence/localStorageAdapter';
|
||||
import { STORAGE_KEY_AI_PERMISSION_GRANTS } from '../../config/storageKeys';
|
||||
import { globalTraceStore } from '../harness/traceStore';
|
||||
@@ -96,6 +99,8 @@ export function registerGrantPersister(persister: GrantPersister): () => void {
|
||||
const pendingApprovals = new Map<string, {
|
||||
resolve: (resolution: ApprovalResolution) => void;
|
||||
request: ApprovalRequest;
|
||||
/** Clears the auto-deny timer without resolving the approval. */
|
||||
cancelTimeout?: () => void;
|
||||
}>();
|
||||
|
||||
// Subscribers for approval request events (UI listens here)
|
||||
@@ -163,8 +168,10 @@ function isGrantedByRules(request: ApprovalRequest): boolean {
|
||||
* Returns a Promise<boolean> that resolves to `true` (approved) or `false` (denied).
|
||||
* The UI is notified via the listener system to render approval buttons.
|
||||
*
|
||||
* If the user does not respond within `timeoutMs` (default 5 minutes), the
|
||||
* approval is auto-denied to prevent the session from hanging indefinitely.
|
||||
* Idle auto-deny uses `timeoutMs` (default 5 minutes). Review activity re-arms
|
||||
* a fresh idle window from *now*, but never past a hard deadline from creation
|
||||
* (default 3× idle, capped at 30 minutes) so late review is not cut off at the
|
||||
* original idle mark while still staying bounded.
|
||||
*/
|
||||
export function requestApproval(
|
||||
toolCallId: string,
|
||||
@@ -190,26 +197,61 @@ export function requestApproval(
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let timerId: ReturnType<typeof setTimeout> | null = null;
|
||||
const { idleMs, hardDeadlineMs } = resolveCattyApprovalDeadlines(timeoutMs);
|
||||
// Hard ceiling from creation — review re-arms idle from now for idleMs
|
||||
// but never past this bound.
|
||||
const hardDeadlineAt = Date.now() + hardDeadlineMs;
|
||||
|
||||
const clearTimer = () => {
|
||||
if (timerId) {
|
||||
clearTimeout(timerId);
|
||||
timerId = null;
|
||||
}
|
||||
};
|
||||
|
||||
const wrappedResolve = (resolution: ApprovalResolution) => {
|
||||
if (timerId) { clearTimeout(timerId); timerId = null; }
|
||||
clearTimer();
|
||||
resolve(resolution.approved);
|
||||
};
|
||||
|
||||
pendingApprovals.set(toolCallId, { resolve: wrappedResolve, request });
|
||||
|
||||
// Auto-deny after timeout so the session doesn't hang indefinitely
|
||||
timerId = setTimeout(() => {
|
||||
if (pendingApprovals.has(toolCallId)) {
|
||||
pendingApprovals.delete(toolCallId);
|
||||
wrappedResolve({ approved: false, scope: 'once', cancelled: false });
|
||||
emitApprovalEvent('approval_resolved', request, { outcome: 'timeout' });
|
||||
// Notify UI to remove the stale card
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl([toolCallId]); } catch { /* ignore */ }
|
||||
}
|
||||
const denyTimedOut = () => {
|
||||
const entry = pendingApprovals.get(toolCallId);
|
||||
if (!entry) return;
|
||||
pendingApprovals.delete(toolCallId);
|
||||
wrappedResolve({ approved: false, scope: 'once', cancelled: false });
|
||||
emitApprovalEvent('approval_resolved', request, { outcome: 'timeout' });
|
||||
for (const cl of clearedListeners) {
|
||||
try { cl([toolCallId]); } catch { /* ignore */ }
|
||||
}
|
||||
}, timeoutMs);
|
||||
};
|
||||
|
||||
const armTimer = (ms: number) => {
|
||||
clearTimer();
|
||||
if (ms <= 0) {
|
||||
// Hard deadline already elapsed — reject synchronously so a late
|
||||
// approve cannot race a deferred setTimeout(0) deny.
|
||||
denyTimedOut();
|
||||
return;
|
||||
}
|
||||
timerId = setTimeout(denyTimedOut, ms);
|
||||
};
|
||||
|
||||
// Initial arm is the idle window. Review re-arms idle (capped by hard deadline).
|
||||
armTimer(idleMs);
|
||||
|
||||
pendingApprovals.set(toolCallId, {
|
||||
resolve: wrappedResolve,
|
||||
request,
|
||||
cancelTimeout: () => {
|
||||
const remainingHard = hardDeadlineAt - Date.now();
|
||||
if (remainingHard <= 0) {
|
||||
armTimer(0);
|
||||
return;
|
||||
}
|
||||
// Re-arm a full idle window from now, never past the hard deadline.
|
||||
armTimer(Math.min(idleMs, remainingHard));
|
||||
},
|
||||
});
|
||||
|
||||
// Notify all UI listeners
|
||||
for (const listener of listeners) {
|
||||
@@ -218,6 +260,32 @@ export function requestApproval(
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel / reset the idle auto-deny timer after the user starts reviewing or
|
||||
* interacting with the card. Catty local approvals re-arm idle from *now* for
|
||||
* a fresh idleMs window, never past the hard creation deadline.
|
||||
* Timeout never auto-approves.
|
||||
*/
|
||||
export function cancelApprovalTimeout(toolCallId: string): void {
|
||||
const entry = pendingApprovals.get(toolCallId);
|
||||
// Keep cancelTimeout registered so further review activity can re-arm idle.
|
||||
entry?.cancelTimeout?.();
|
||||
|
||||
// MCP / Codex App Server approvals are timed in the main process.
|
||||
// MCP cancel drops idle but keeps the absolute creation deadline.
|
||||
if (toolCallId.startsWith('mcp_approval_')) {
|
||||
const bridge = (window as unknown as {
|
||||
netcatty?: { cancelMcpApprovalTimeout?: (id: string) => Promise<unknown> };
|
||||
}).netcatty;
|
||||
void bridge?.cancelMcpApprovalTimeout?.(toolCallId);
|
||||
} else if (toolCallId.startsWith('codex_interaction_')) {
|
||||
const bridge = (window as unknown as {
|
||||
netcatty?: { cancelCodexAppServerInteractionTimeout?: (id: string) => Promise<unknown> };
|
||||
}).netcatty;
|
||||
void bridge?.cancelCodexAppServerInteractionTimeout?.(toolCallId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called from the UI when the user approves or rejects a tool execution.
|
||||
* Handles both SDK tool calls (local Promise) and MCP tool calls (IPC to main process).
|
||||
|
||||
@@ -1805,18 +1805,80 @@ function buildExternalCodexRerequestComment(headSha) {
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a comment body is a Codex review *request* (not a clean summary).
|
||||
* Matches "@codex review" as a mention token, not "Codex Review: …" summaries.
|
||||
*/
|
||||
function isCodexReviewRequestBody(body) {
|
||||
const text = String(body || '');
|
||||
if (!/(^|[^A-Za-z0-9_@])@codex\s+review\b/i.test(text)) return false;
|
||||
// Connector clean/noise posts never use the @codex request form.
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Authors whose @codex review posts count for same-head dedupe.
|
||||
* Includes maintainers/GITHUB_TOKEN and Cursor's PR bot (often posts a plain
|
||||
* @codex review before our own_rerequest job lands).
|
||||
*/
|
||||
function isCodexRequestDedupeAuthor(login, { ownActors } = {}) {
|
||||
if (isTrustedAutomationControlAuthor(login, { ownActors })) return true;
|
||||
const name = String(login || '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/^@/, '');
|
||||
return name === 'cursor[bot]' || name === 'cursor';
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip posting another @codex review when this head was already requested.
|
||||
*
|
||||
* Historical bug: only `cursor-external-codex:SHA` counted. Automation
|
||||
* comments that pin `cursor-codex-head:SHA` (and maintainer requests built the
|
||||
* same way) did not, so a second synchronize-path request could fire for the
|
||||
* same SHA while an earlier job was still in flight — clean issue comment from
|
||||
* one job, findings review from the other.
|
||||
*
|
||||
* Skip when a trusted/dedupe author already requested this head via:
|
||||
* - `cursor-external-codex:SHA`, or
|
||||
* - `cursor-codex-head:SHA` pin matching headSha.
|
||||
*
|
||||
* Plain unpinned "@codex review" is intentionally ignored: without a head pin
|
||||
* we cannot tell which SHA it targeted, and timestamp heuristics (age, commit
|
||||
* date, PR.updated_at) all have false-positive races. Prefer always planting
|
||||
* a head pin (buildCodexReviewRequestComment) so re-request is safe.
|
||||
*
|
||||
* `notBefore` is accepted but unused (kept so older workflow callers do not
|
||||
* throw if they still pass it).
|
||||
*/
|
||||
function shouldSkipExternalCodexRerequest({
|
||||
existingComments = [],
|
||||
headSha,
|
||||
ownActors,
|
||||
notBefore: _notBefore = null,
|
||||
notBeforeSlackMs: _notBeforeSlackMs = 5_000,
|
||||
} = {}) {
|
||||
const marker = `<!-- cursor-external-codex:${sanitizeUntrustedText(headSha, 64)} -->`;
|
||||
return existingComments.some(
|
||||
(c) =>
|
||||
isTrustedAutomationControlAuthor(c?.user?.login || c?.login, {
|
||||
ownActors,
|
||||
}) && String(c.body || '').includes(marker),
|
||||
);
|
||||
const want = String(headSha || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
if (!want) return false;
|
||||
const externalMarker = `<!-- cursor-external-codex:${sanitizeUntrustedText(want, 64)} -->`;
|
||||
|
||||
return existingComments.some((c) => {
|
||||
const login = c?.user?.login || c?.login;
|
||||
if (!isCodexRequestDedupeAuthor(login, { ownActors })) return false;
|
||||
const body = String(c?.body || '');
|
||||
if (!isCodexReviewRequestBody(body)) return false;
|
||||
|
||||
// Explicit external marker for this SHA (legacy + current automation).
|
||||
if (body.includes(externalMarker)) return true;
|
||||
|
||||
// Automation / human request that pins this head.
|
||||
const pinned = extractRequestedHeadSha(body);
|
||||
if (pinned && commitShasMatch(want, pinned)) return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
function buildSlackPayload({
|
||||
|
||||
@@ -4139,6 +4139,101 @@ test('shouldSkipExternalCodexRerequest matches trusted head sha marker only', ()
|
||||
);
|
||||
});
|
||||
|
||||
test('shouldSkipExternalCodexRerequest honors head pins; ignores plain unpinned @codex', () => {
|
||||
const sha = 'deadbeefcafebabe000000000000000000000001';
|
||||
const short = sha.slice(0, 12);
|
||||
// Automation request with head pin but no external marker still dedupes.
|
||||
assert.equal(
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
headSha: sha,
|
||||
ownActors: 'binaricat,netcatty-bot,github-actions[bot]',
|
||||
existingComments: [
|
||||
{
|
||||
user: { login: 'binaricat' },
|
||||
body: auto.buildCodexReviewRequestComment(1, sha),
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Short SHA pin matches full head.
|
||||
assert.equal(
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
headSha: sha,
|
||||
ownActors: 'binaricat',
|
||||
existingComments: [
|
||||
{
|
||||
user: { login: 'binaricat' },
|
||||
body: `<!-- cursor-automation -->\n\n@codex review\n\n<!-- cursor-codex-head:${short} -->`,
|
||||
},
|
||||
],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
// Plain unpinned @codex never suppresses — cannot know which SHA it meant.
|
||||
assert.equal(
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
headSha: sha,
|
||||
ownActors: 'binaricat',
|
||||
notBefore: '2026-08-05T14:00:00Z',
|
||||
existingComments: [
|
||||
{
|
||||
user: { login: 'cursor[bot]' },
|
||||
created_at: '2026-08-05T14:00:30Z',
|
||||
body: '@codex review',
|
||||
},
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
headSha: sha,
|
||||
ownActors: 'binaricat',
|
||||
existingComments: [
|
||||
{
|
||||
user: { login: 'binaricat' },
|
||||
created_at: '2026-08-05T14:00:10Z',
|
||||
body: '@codex review',
|
||||
},
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Connector clean summary is not a request.
|
||||
assert.equal(
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
headSha: sha,
|
||||
ownActors: 'binaricat',
|
||||
existingComments: [
|
||||
{
|
||||
user: { login: 'chatgpt-codex-connector[bot]' },
|
||||
created_at: '2026-08-05T14:00:59Z',
|
||||
body: "Codex Review: Didn't find any major issues.\n\n**Reviewed commit:** `deadbeef`",
|
||||
},
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Different head pin does not skip.
|
||||
assert.equal(
|
||||
auto.shouldSkipExternalCodexRerequest({
|
||||
headSha: sha,
|
||||
ownActors: 'binaricat',
|
||||
existingComments: [
|
||||
{
|
||||
user: { login: 'binaricat' },
|
||||
body: auto.buildCodexReviewRequestComment(
|
||||
1,
|
||||
'ffffffffffffffffffffffffffffffffffffffff',
|
||||
),
|
||||
},
|
||||
],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('parseCodexReviewOutcome accepts clean reaction without summary text', () => {
|
||||
const outcome = auto.parseCodexReviewOutcome({
|
||||
summaryText: '',
|
||||
|
||||
Vendored
+1
@@ -179,6 +179,7 @@ declare global {
|
||||
onCodexAppServerInteractionRequest?(cb: (payload: Record<string, unknown>) => void): () => void;
|
||||
onCodexAppServerInteractionCleared?(cb: (payload: { interactionIds: string[]; chatSessionId?: string }) => void): () => void;
|
||||
respondCodexAppServerInteraction?(payload: Record<string, unknown>): Promise<{ ok: boolean; error?: string }>;
|
||||
cancelCodexAppServerInteractionTimeout?(interactionId: string): Promise<{ ok: boolean; cancelled?: boolean; error?: string }>;
|
||||
aiCattyCancelExec?(chatSessionId: string): Promise<unknown>;
|
||||
aiSetChatSessionCancelled?(chatSessionId: string, cancelled?: boolean): Promise<{ ok: boolean; error?: string }>;
|
||||
aiMcpSyncPermissionGrants?(grants: Array<Record<string, unknown>>): Promise<{ ok: boolean; count?: number; error?: string }>;
|
||||
|
||||
+1
@@ -97,6 +97,7 @@ declare global {
|
||||
directory: string;
|
||||
}): Promise<{ success: boolean; error?: string; filePath?: string }>;
|
||||
openSessionLogsDir?(directory: string): Promise<{ success: boolean; error?: string }>;
|
||||
clearSessionLogsDir?(directory: string): Promise<{ success: boolean; deletedCount: number; failedCount: number; error?: string }>;
|
||||
startManualSessionLog?(payload: {
|
||||
sessionId: string;
|
||||
sessionName?: string;
|
||||
|
||||
Reference in new issue
Block a user