Compare commits

..
Author SHA1 Message Date
Cursor Agentand陈大猫 c849bf6a73 fix(plugins): preserve overwrite stash across retries and prefix deletes
Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:36:59 +00:00
Cursor Agentand陈大猫 0d9c4a6e02 fix(plugins): keep overwrite stash until restore write succeeds
Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:32:08 +00:00
Cursor Agentand陈大猫 acd5700284 fix(plugins): host-own sync provider bindings and clear failed stashes
Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:27:26 +00:00
Cursor Agentand陈大猫 05b33b5521 fix(plugins): make sync secret overwrite stash opt-in
Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:18:26 +00:00
Cursor Agentand陈大猫 786e5b48ef fix(plugins): restore overwritten sync secrets on rejected reconnect
Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:12:47 +00:00
Cursor Agentand陈大猫 6414c9e177 fix(plugins): address residual Codex sync secret and backup gaps
Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 11:05:39 +00:00
33 changed files with 688 additions and 2528 deletions

No files matched your search

-22
View File
@@ -2093,7 +2093,6 @@ jobs:
".cursor-runtime/followup-pending-snapshots.json",
".cursor-runtime/followup-verify-result.json",
".cursor-runtime/followup-test-comparison.json",
".cursor-runtime/followup-base-tests.log",
".cursor-runtime/followup-candidate-lint.log",
".cursor-runtime/followup-candidate-tests.log",
".cursor-runtime/followup-candidate-build.log",
@@ -2115,7 +2114,6 @@ jobs:
.cursor-runtime/followup-pending-snapshots.json
.cursor-runtime/followup-verify-result.json
.cursor-runtime/followup-test-comparison.json
.cursor-runtime/followup-base-tests.log
.cursor-runtime/followup-candidate-lint.log
.cursor-runtime/followup-candidate-tests.log
.cursor-runtime/followup-candidate-build.log
@@ -3381,7 +3379,6 @@ jobs:
".cursor-runtime/implement-pr-body.md",
".cursor-runtime/verify-result.json",
".cursor-runtime/test-comparison.json",
".cursor-runtime/base-tests.log",
".cursor-runtime/candidate-lint.log",
".cursor-runtime/candidate-tests.log",
".cursor-runtime/candidate-build.log",
@@ -3403,7 +3400,6 @@ jobs:
.cursor-runtime/implement-pr-body.out.md
.cursor-runtime/verify-result.json
.cursor-runtime/test-comparison.json
.cursor-runtime/base-tests.log
.cursor-runtime/candidate-lint.log
.cursor-runtime/candidate-tests.log
.cursor-runtime/candidate-build.log
@@ -3413,14 +3409,6 @@ jobs:
if: steps.patch.outputs.artifact_ready == 'true' && steps.verify.outputs.passed != 'true'
run: |
echo "Candidate patch was preserved, but candidate-specific verification failed." >&2
if [[ -f .cursor-runtime/test-comparison.json ]]; then
echo "Test comparison:" >&2
cat .cursor-runtime/test-comparison.json >&2 || true
fi
if [[ -f .cursor-runtime/verify-result.json ]]; then
echo "Verify result:" >&2
cat .cursor-runtime/verify-result.json >&2 || true
fi
exit 1
- name: Mark needs human on implement failure
@@ -4824,7 +4812,6 @@ jobs:
".cursor-runtime/fix.patch",
".cursor-runtime/fix-verify-result.json",
".cursor-runtime/fix-test-comparison.json",
".cursor-runtime/fix-base-tests.log",
".cursor-runtime/fix-candidate-lint.log",
".cursor-runtime/fix-candidate-tests.log",
".cursor-runtime/fix-candidate-build.log",
@@ -4844,7 +4831,6 @@ jobs:
.cursor-runtime/fix.patch
.cursor-runtime/fix-verify-result.json
.cursor-runtime/fix-test-comparison.json
.cursor-runtime/fix-base-tests.log
.cursor-runtime/fix-candidate-lint.log
.cursor-runtime/fix-candidate-tests.log
.cursor-runtime/fix-candidate-build.log
@@ -4854,14 +4840,6 @@ jobs:
if: steps.codex.outputs.action == 'fix' && steps.fixpatch.outputs.artifact_ready == 'true' && steps.fixverify.outputs.passed != 'true'
run: |
echo "Codex fix patch was preserved, but candidate-specific verification failed." >&2
if [[ -f .cursor-runtime/fix-test-comparison.json ]]; then
echo "Test comparison:" >&2
cat .cursor-runtime/fix-test-comparison.json >&2 || true
fi
if [[ -f .cursor-runtime/fix-verify-result.json ]]; then
echo "Verify result:" >&2
cat .cursor-runtime/fix-verify-result.json >&2 || true
fi
exit 1
- name: Mark needs human when no fix produced
-2
View File
@@ -443,8 +443,6 @@ export const enAiMessages: Messages = {
'ai.chat.newChat': 'New Chat',
'ai.chat.allSessions': 'All Sessions',
'ai.chat.loadEarlierMessages': 'Load earlier messages ({n} more)',
'ai.chat.jumpNav': 'Jump to message',
'ai.chat.jumpUntitled': '(empty message)',
'ai.chat.usedTools': 'Tools used: {n}',
'ai.chat.loadMoreSessions': 'Load more sessions ({n} more)',
'ai.chat.noSessions': 'No previous sessions',
-2
View File
@@ -395,8 +395,6 @@ export const ruAiMessages: Messages = {
'ai.chat.newChat': 'Новый чат',
'ai.chat.allSessions': 'Все сессии',
'ai.chat.loadEarlierMessages': 'Загрузить более ранние сообщения (ещё {n})',
'ai.chat.jumpNav': 'Перейти к сообщению',
'ai.chat.jumpUntitled': '(пустое сообщение)',
'ai.chat.usedTools': 'Использовано инструментов: {n}',
'ai.chat.loadMoreSessions': 'Загрузить больше сессий (ещё {n})',
'ai.chat.noSessions': 'Предыдущих сессий нет',
-2
View File
@@ -443,8 +443,6 @@ export const zhCNAiMessages: Messages = {
'ai.chat.newChat': '新对话',
'ai.chat.allSessions': '所有会话',
'ai.chat.loadEarlierMessages': '加载更早的消息(还有 {n} 条)',
'ai.chat.jumpNav': '跳转到消息',
'ai.chat.jumpUntitled': '(空消息)',
'ai.chat.usedTools': '已使用 {n} 个工具',
'ai.chat.loadMoreSessions': '加载更多会话(还有 {n} 条)',
'ai.chat.noSessions': '没有历史会话',
-2
View File
@@ -442,8 +442,6 @@ export const zhTWAiMessages: Messages = {
'ai.chat.newChat': '新對話',
'ai.chat.allSessions': '所有工作階段',
'ai.chat.loadEarlierMessages': '載入更早的訊息(還有 {n} 條)',
'ai.chat.jumpNav': '跳轉到訊息',
'ai.chat.jumpUntitled': '(空訊息)',
'ai.chat.usedTools': '已使用 {n} 個工具',
'ai.chat.loadMoreSessions': '載入更多工作階段(還有 {n} 條)',
'ai.chat.noSessions': '沒有歷史工作階段',
@@ -103,4 +103,35 @@ describe('storePluginSyncSecretsThenConnect', () => {
);
assert.deepEqual(deletedKeys, [['sync-credential']]);
});
it('keeps overwritten secrets when reconnect connect rejects', async () => {
const deletedKeys: string[][] = [];
const restoredKeys: string[][] = [];
await assert.rejects(
() => storePluginSyncSecretsThenConnect({
providerId: 'plugin:sync.example',
secrets: [{ secretKey: 'sync-credential', value: 'bad-password' }],
putSecret: async ({ key }) => ({
kind: 'secret',
id: 'ref-overwritten',
key,
created: false,
}),
deleteSecrets: async ({ keys }) => {
deletedKeys.push([...keys]);
return { deleted: keys.length };
},
restoreSecrets: async ({ keys }) => {
restoredKeys.push([...keys]);
return { restored: keys.length };
},
connect: async () => {
throw new Error('auth failed');
},
}),
/auth failed/,
);
assert.deepEqual(deletedKeys, []);
assert.deepEqual(restoredKeys, [['sync-credential']]);
});
});
+44 -2
View File
@@ -1,6 +1,8 @@
/**
* Persist plugin sync secrets then connect; roll back just-created secrets if
* connect (or a later put) fails so bad passwords are not left in OS storage.
* Overwritten keys are restored from the host-side overwrite stash so a
* rejected reconnect does not leave a broken SecretRef / rejected credential.
*/
export type PluginSyncSecretRef = { kind: 'secret'; id: string; key: string };
@@ -10,6 +12,11 @@ export interface PluginSyncConnectSecretInput {
value: string;
}
export interface PluginSyncPutSecretResult extends PluginSyncSecretRef {
/** False when the durable (plugin,key) row already existed and was overwritten. */
created?: boolean;
}
export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
secrets: readonly PluginSyncConnectSecretInput[];
@@ -19,11 +26,20 @@ export interface StorePluginSyncSecretsThenConnectParams {
providerId: string;
key: string;
value: string;
}) => Promise<PluginSyncSecretRef>;
}) => Promise<PluginSyncPutSecretResult>;
deleteSecrets: (params: {
providerId: string;
keys: string[];
}) => Promise<unknown>;
/**
* Restore host-stashed plaintext for overwritten keys (`discard: false`),
* or drop the stash after a successful connect (`discard: true`).
*/
restoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<unknown>;
connect: (credential: PluginSyncSecretRef | undefined) => Promise<void>;
}
@@ -31,6 +47,7 @@ export async function storePluginSyncSecretsThenConnect(
params: StorePluginSyncSecretsThenConnectParams,
): Promise<void> {
const createdKeys: string[] = [];
const overwrittenKeys: string[] = [];
let credential: PluginSyncSecretRef | undefined = params.existingCredential;
try {
@@ -42,13 +59,28 @@ export async function storePluginSyncSecretsThenConnect(
key: secret.secretKey,
value: secret.value,
});
createdKeys.push(secret.secretKey);
if (ref.created === false) {
overwrittenKeys.push(secret.secretKey);
} else {
createdKeys.push(secret.secretKey);
}
// SyncConnectPayload.credential carries the primary (first) secret;
// additional secrets remain addressable via secrets.get(key).
if (!credential) credential = ref;
}
}
await params.connect(credential);
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
discard: true,
});
} catch {
/* best-effort stash cleanup */
}
}
} catch (error) {
if (createdKeys.length > 0) {
try {
@@ -60,6 +92,16 @@ export async function storePluginSyncSecretsThenConnect(
/* best-effort; surface the original connect/put error */
}
}
if (overwrittenKeys.length > 0 && params.restoreSecrets) {
try {
await params.restoreSecrets({
providerId: params.providerId,
keys: [...overwrittenKeys],
});
} catch {
/* best-effort; surface the original connect/put error */
}
}
throw error;
}
}
+2 -3
View File
@@ -819,7 +819,7 @@ test("buildLocalVaultPayload includes last-known plugin sidecars for protective
}
});
test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty", async () => {
test("buildLocalVaultPayloadAsync prefers live empty over last-known for backups", async () => {
const previousWindow = (globalThis as { window?: unknown }).window;
Object.defineProperty(globalThis, "window", {
value: {
@@ -847,8 +847,7 @@ test("buildLocalVaultPayloadAsync keeps last-known when live collect is empty",
try {
const { buildLocalVaultPayloadAsync } = await import("./syncPayload.ts");
const payload = await buildLocalVaultPayloadAsync(vault([]));
assert.equal(payload.pluginSidecars?.entries?.length, 1);
assert.equal(payload.pluginSidecars?.entries?.[0].value, "dark");
assert.equal(payload.pluginSidecars?.entries?.length, 0);
} finally {
localStorage.removeItem("netcatty_plugin_sidecars_last_known_v1");
Object.defineProperty(globalThis, "window", {
+3 -9
View File
@@ -1049,15 +1049,9 @@ export async function buildLocalVaultPayloadAsync(
try {
const live = await collectPluginSyncSidecarsFromHost();
if (live) {
// Live collect may return authoritative-empty while still deferring the
// last-known cache wipe until a successful sync commit. Protective
// backups must keep the non-empty last-known already attached to `base`.
const liveEmpty = !Array.isArray(live.entries) || live.entries.length === 0;
const baseHasEntries = Array.isArray(base.pluginSidecars?.entries)
&& base.pluginSidecars.entries.length > 0;
if (liveEmpty && baseHasEntries) {
return base;
}
// Uploads may still defer wiping last-known until a successful sync
// commit, but protective/version-change backups must snapshot the live
// authoritative-empty bundle so restore cannot resurrect cleared plugin data.
return withPluginSyncSidecars(base, live);
}
} catch (error) {
+3 -8
View File
@@ -25,17 +25,12 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
/>
);
export const ConversationScrollButton = ({
className,
onClick,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
onClick?.(event);
const handleClick = useCallback(() => {
scrollToBottom();
}, [onClick, scrollToBottom]);
}, [scrollToBottom]);
if (isAtBottom) return null;
-142
View File
@@ -1,142 +0,0 @@
/**
* Floating jump list for long AI chat sessions (user-turn TOC).
*/
import { ListTree, X } from 'lucide-react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useStickToBottomContext } from 'use-stick-to-bottom';
import { useI18n } from '../../application/i18n/I18nProvider';
import type { ChatJumpEntry } from '../../domain/chatJumpNav';
import { cn } from '../../lib/utils';
export interface ChatJumpNavProps {
entries: ChatJumpEntry[];
activeMessageId: string | null;
/** When true, ignore transient isAtBottom flips from streaming resize. */
isStreaming?: boolean;
onSelect: (messageId: string) => void;
/** Fired after the user leaves the jump target and returns to the bottom. */
onReleasePin?: () => void;
}
const ChatJumpNav: React.FC<ChatJumpNavProps> = ({
entries,
activeMessageId,
isStreaming = false,
onSelect,
onReleasePin,
}) => {
const { t } = useI18n();
const { stopScroll, isAtBottom } = useStickToBottomContext();
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Only release after the viewport has left the bottom; avoids clearing a pin
// on the same tick as select while isAtBottom is still true.
const leftBottomWhilePinnedRef = useRef(false);
useEffect(() => {
if (!open) return;
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node | null;
if (target && rootRef.current && !rootRef.current.contains(target)) {
setOpen(false);
}
};
document.addEventListener('pointerdown', onPointerDown);
return () => document.removeEventListener('pointerdown', onPointerDown);
}, [open]);
useEffect(() => {
if (!activeMessageId) {
leftBottomWhilePinnedRef.current = false;
return;
}
if (!isAtBottom) {
leftBottomWhilePinnedRef.current = true;
return;
}
if (leftBottomWhilePinnedRef.current) {
// Streaming content growth / smooth resize can flip isAtBottom without the
// user intending to leave the jump target; keep the pin until streaming ends
// or they explicitly scroll to bottom via the scroll button.
if (isStreaming) return;
leftBottomWhilePinnedRef.current = false;
onReleasePin?.();
return;
}
// Jump target was already in the bottom window, so the viewport never left
// isAtBottom. Clear the pin after scrollIntoView has had a chance to run.
if (isStreaming) return;
const timer = window.setTimeout(() => {
onReleasePin?.();
}, 100);
return () => window.clearTimeout(timer);
}, [activeMessageId, isAtBottom, isStreaming, onReleasePin]);
const handleSelect = useCallback((messageId: string) => {
stopScroll();
onSelect(messageId);
setOpen(false);
}, [onSelect, stopScroll]);
if (entries.length === 0) return null;
return (
<div ref={rootRef} className="absolute top-3 right-3 z-20 flex flex-col items-end gap-1.5">
<button
type="button"
className={cn(
'h-7 w-7 rounded-full border border-border/40 bg-background/90 backdrop-blur-sm',
'flex items-center justify-center shadow-sm',
'text-muted-foreground hover:text-foreground hover:bg-muted transition-colors cursor-pointer',
open && 'text-foreground bg-muted',
)}
aria-label={t('ai.chat.jumpNav')}
aria-expanded={open}
title={t('ai.chat.jumpNav')}
onClick={() => setOpen((value) => !value)}
>
{open ? <X size={14} /> : <ListTree size={14} />}
</button>
{open && (
<div
className={cn(
'w-[min(220px,calc(100vw-2rem))] max-h-[min(320px,50vh)] overflow-y-auto',
'rounded-md border border-border/50 bg-background/95 backdrop-blur-sm shadow-md',
'py-1',
)}
role="listbox"
aria-label={t('ai.chat.jumpNav')}
>
{entries.map((entry) => {
const selected = entry.messageId === activeMessageId;
return (
<button
key={entry.messageId}
type="button"
role="option"
aria-selected={selected}
className={cn(
'flex w-full items-start gap-2 px-2.5 py-1.5 text-left text-[12px] leading-snug',
'hover:bg-muted/70 transition-colors cursor-pointer',
selected
? 'bg-muted text-foreground'
: 'text-foreground/80',
)}
onClick={() => handleSelect(entry.messageId)}
>
<span className="shrink-0 tabular-nums text-muted-foreground/70 w-4 text-right">
{entry.index}
</span>
<span className="min-w-0 flex-1 truncate">{entry.label}</span>
</button>
);
})}
</div>
)}
</div>
);
};
export default ChatJumpNav;
-57
View File
@@ -132,63 +132,6 @@ test("ChatMessageList only renders the recent message batch by default", () => {
assert.match(markup, /message-59/);
});
test("ChatMessageList exposes jump navigation once there are enough user turns", () => {
const messages: ChatMessage[] = [
{ id: "u1", role: "user", content: "first turn", timestamp: 1 },
{ id: "a1", role: "assistant", content: "ok", timestamp: 2 },
{ id: "u2", role: "user", content: "second turn", timestamp: 3 },
{ id: "a2", role: "assistant", content: "ok", timestamp: 4 },
{ id: "u3", role: "user", content: "third turn", timestamp: 5 },
{ id: "a3", role: "assistant", content: "ok", timestamp: 6 },
];
const markup = renderToStaticMarkup(
React.createElement(
I18nProvider,
{ locale: "en" },
React.createElement(
TooltipProvider,
null,
React.createElement(ChatMessageList, { messages }),
),
),
);
assert.match(markup, /aria-label="Jump to message"/);
assert.match(markup, /id="ai-chat-msg-u1"/);
assert.match(markup, /id="ai-chat-msg-u3"/);
});
test("jump pin release does not reset the loaded message tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
const releaseHandler = source.match(
/const handleReleaseJumpPin = useCallback\(\(\) => \{[\s\S]*?\}, \[\]\);/,
)?.[0] ?? "";
assert.match(releaseHandler, /setActiveJumpMessageId\(null\)/);
assert.match(releaseHandler, /setPendingJumpMessageId\(null\)/);
assert.doesNotMatch(releaseHandler, /setRenderedTailCount/);
});
test("load earlier advances from the effective pinned tail", () => {
const source = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(
source,
/setRenderedTailCount\(\(count\) =>\s*Math\.max\(count, effectiveTailCount\) \+ MESSAGE_RENDER_STEP\)/,
);
});
test("jump pin ignores streaming isAtBottom flips and releases on scroll button", () => {
const jumpSource = readFileSync(new URL("./ChatJumpNav.tsx", import.meta.url), "utf8");
const listSource = readFileSync(new URL("./ChatMessageList.tsx", import.meta.url), "utf8");
assert.match(jumpSource, /isStreaming\?: boolean/);
assert.match(jumpSource, /if \(isStreaming\) return;/);
assert.match(jumpSource, /window\.setTimeout/);
assert.match(listSource, /isStreaming=\{\!\!isStreaming\}/);
assert.match(listSource, /<ConversationScrollButton onClick=\{handleReleaseJumpPin\} \/>/);
});
test("ChatMessageList renders Codex activities and actual usage", () => {
const messages: ChatMessage[] = [{
id: "assistant-activity",
+5 -63
View File
@@ -73,12 +73,6 @@ import {
getAIPanelProfilerProps,
isAIPanelDiagnosticPartHidden,
} from './aiPanelDiagnostics';
import {
buildChatJumpEntries,
chatMessageDomId,
resolveTailCountForJumpTarget,
} from '../../domain/chatJumpNav';
import ChatJumpNav from './ChatJumpNav';
interface ChatMessageListProps {
messages: ChatMessage[];
@@ -360,13 +354,9 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
const hideMarkdown = isAIPanelDiagnosticPartHidden('markdown', hiddenParts);
const hideToolCalls = isAIPanelDiagnosticPartHidden('toolcalls', hiddenParts);
const [renderedTailCount, setRenderedTailCount] = useState(MESSAGE_RENDER_BATCH);
const [activeJumpMessageId, setActiveJumpMessageId] = useState<string | null>(null);
const [pendingJumpMessageId, setPendingJumpMessageId] = useState<string | null>(null);
useEffect(() => {
setRenderedTailCount(MESSAGE_RENDER_BATCH);
setActiveJumpMessageId(null);
setPendingJumpMessageId(null);
}, [activeSessionId]);
const visibleMessages = useMemo(
@@ -374,47 +364,11 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
[messages],
);
// While a jump target is active, re-resolve the tail against the current list
// so streaming appends cannot slide the window past the selected message.
const effectiveTailCount = activeJumpMessageId
? resolveTailCountForJumpTarget(visibleMessages, activeJumpMessageId, renderedTailCount)
: renderedTailCount;
const hiddenMessageCount = Math.max(0, visibleMessages.length - effectiveTailCount);
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
const displayedMessages = hiddenMessageCount > 0
? visibleMessages.slice(-effectiveTailCount)
? visibleMessages.slice(-renderedTailCount)
: visibleMessages;
const jumpEntries = useMemo(
() => buildChatJumpEntries(visibleMessages, {
emptyLabel: t('ai.chat.jumpUntitled'),
}),
[t, visibleMessages],
);
const handleJumpToMessage = useCallback((messageId: string) => {
setActiveJumpMessageId(messageId);
// Persist the expanded window so releasing the pin (or a spurious isAtBottom
// flip) cannot unmount the jump target. Load-earlier progress is preserved
// because we never reset renderedTailCount on pin release.
setRenderedTailCount((count) =>
resolveTailCountForJumpTarget(visibleMessages, messageId, count));
setPendingJumpMessageId(messageId);
}, [visibleMessages]);
const handleReleaseJumpPin = useCallback(() => {
setActiveJumpMessageId(null);
setPendingJumpMessageId(null);
}, []);
useEffect(() => {
if (!pendingJumpMessageId) return;
const target = document.getElementById(chatMessageDomId(pendingJumpMessageId));
if (!target) return;
target.scrollIntoView({ behavior: 'smooth', block: 'start' });
setPendingJumpMessageId(null);
}, [displayedMessages, pendingJumpMessageId]);
const resolvedToolCallIds = new Set(
displayedMessages
.filter((m) => m.role === 'tool')
@@ -529,8 +483,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
{hiddenMessageCount > 0 && (
<button
type="button"
onClick={() => setRenderedTailCount((count) =>
Math.max(count, effectiveTailCount) + MESSAGE_RENDER_STEP)}
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
className="w-full py-2 text-center text-[12px] text-muted-foreground/50 hover:text-muted-foreground transition-colors cursor-pointer"
>
{t('ai.chat.loadEarlierMessages').replace('{n}', String(hiddenMessageCount))}
@@ -638,11 +591,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
const isThisStreaming = isStreaming && isLastAssistant;
return (
<Message
key={message.id}
id={chatMessageDomId(message.id)}
from={message.role}
>
<Message key={message.id} from={message.role}>
<MessageContent from={message.role}>
{/* Thinking block */}
{!isUser && message.thinking && (
@@ -884,14 +833,7 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
</div>
)}
</ConversationContent>
<ChatJumpNav
entries={jumpEntries}
activeMessageId={activeJumpMessageId}
isStreaming={!!isStreaming}
onSelect={handleJumpToMessage}
onReleasePin={handleReleaseJumpPin}
/>
<ConversationScrollButton onClick={handleReleaseJumpPin} />
<ConversationScrollButton />
</Conversation>
{/* Image preview lightbox */}
@@ -181,11 +181,12 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
? stored as { kind: 'secret'; id: string; key: string }
: undefined;
if (credentialPlan.secrets.length > 0) {
const { putPluginSyncSecret, deletePluginSyncSecrets } = await import(
const { putPluginSyncSecret, deletePluginSyncSecrets, restorePluginSyncSecrets } = await import(
'../../infrastructure/services/adapters/pluginSyncIpcHost'
);
// Put secrets before connect; roll back just-created keys if connect fails
// so a rejected password/token is not left readable in plugin_secrets.
// Overwrites restore the previous plaintext from the host stash.
await storePluginSyncSecretsThenConnect({
providerId,
secrets: credentialPlan.secrets.map((secret) => ({
@@ -194,6 +195,7 @@ export const CloudSyncDashboardTabs: React.FC<CloudSyncDashboardTabsProps> = ({
})),
putSecret: putPluginSyncSecret,
deleteSecrets: deletePluginSyncSecrets,
restoreSecrets: restorePluginSyncSecrets,
connect: async (credential) => {
await sync.connectPluginProvider(
providerId,
@@ -1476,8 +1476,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
) {
return true;
}
// No xterm selection: pass Ctrl+C through for SIGINT, and Cmd+C for
// Kitty Super+C (nested TUIs). Other copy chords stay a safe no-op.
// When copy is bound specifically to Ctrl+C and there is no text
// selected, pass the event through so xterm can send SIGINT.
const shouldForwardCopyToTerminal =
shouldPassThroughCopyShortcut(action, term.hasSelection(), e);
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
@@ -3,7 +3,6 @@ import test from "node:test";
import {
isPlainCtrlCInterruptChord,
isPlainMetaCCopyChord,
shouldPassThroughCopyShortcut,
} from "./terminalCopyShortcut.ts";
@@ -28,20 +27,10 @@ test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy with no selection passes through for Kitty Super+C", () => {
const event = keyboardEvent("c", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(isPlainCtrlCInterruptChord(event), false);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("copy shortcut does not pass through when text is selected", () => {
const ctrlC = keyboardEvent("c", "KeyC", { ctrlKey: true });
const cmdC = keyboardEvent("c", "KeyC", { metaKey: true });
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
assert.equal(shouldPassThroughCopyShortcut("copy", true, ctrlC), false);
assert.equal(shouldPassThroughCopyShortcut("copy", true, cmdC), false);
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
});
test("copy shortcut does not pass through for shifted or alternate chords", () => {
@@ -49,26 +38,14 @@ test("copy shortcut does not pass through for shifted or alternate chords", () =
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { ctrlKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("C", "KeyC", { metaKey: true, shiftKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("l", "KeyL", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("copy", false, keyboardEvent("c", "KeyC", { metaKey: true, ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { ctrlKey: true })),
false,
);
assert.equal(
shouldPassThroughCopyShortcut("paste", false, keyboardEvent("c", "KeyC", { metaKey: true })),
false,
);
});
test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layouts", () => {
@@ -77,10 +54,3 @@ test("plain Ctrl+C copy passthrough follows the physical C key on non-Latin layo
assert.equal(isPlainCtrlCInterruptChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
test("plain Cmd+C copy passthrough follows the physical C key on non-Latin layouts", () => {
const event = keyboardEvent("\u0441", "KeyC", { metaKey: true });
assert.equal(isPlainMetaCCopyChord(event), true);
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
});
@@ -3,43 +3,18 @@ type CopyShortcutKeyEvent = Pick<
"key" | "code" | "ctrlKey" | "shiftKey" | "altKey" | "metaKey"
>;
function isPhysicalCKey(e: CopyShortcutKeyEvent): boolean {
return e.key.toLowerCase() === "c" || e.code === "KeyC";
}
export function isPlainCtrlCInterruptChord(e: CopyShortcutKeyEvent): boolean {
return e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& !e.metaKey
&& isPhysicalCKey(e);
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
}
/** macOS Cmd+C / Super+C — forward when there is no xterm selection. */
export function isPlainMetaCCopyChord(e: CopyShortcutKeyEvent): boolean {
return e.metaKey
&& !e.ctrlKey
&& !e.shiftKey
&& !e.altKey
&& isPhysicalCKey(e);
}
/**
* When copy matches with no xterm selection, pass the chord through instead of
* consuming an empty clipboard write.
*
* - Ctrl+C → SIGINT (or Kitty Ctrl+C)
* - Cmd+C → Kitty Super+C for nested TUIs (e.g. Herdr)
*
* Other no-selection copy bindings stay consumed as a safe no-op so keys like
* F5 / Ctrl+L are not forwarded to the remote (#1461).
*/
export function shouldPassThroughCopyShortcut(
action: string,
hasSelection: boolean,
e: CopyShortcutKeyEvent,
): boolean {
return action === "copy"
&& !hasSelection
&& (isPlainCtrlCInterruptChord(e) || isPlainMetaCCopyChord(e));
return action === "copy" && !hasSelection && isPlainCtrlCInterruptChord(e);
}
-71
View File
@@ -1,71 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildChatJumpEntries,
chatMessageDomId,
resolveTailCountForJumpTarget,
truncateChatJumpLabel,
} from './chatJumpNav.ts';
test('truncateChatJumpLabel collapses whitespace and ellipsizes', () => {
assert.equal(truncateChatJumpLabel(' hello world '), 'hello world');
assert.equal(
truncateChatJumpLabel('a'.repeat(40), 10),
`${'a'.repeat(7)}...`,
);
assert.equal(truncateChatJumpLabel(' '), '');
});
test('buildChatJumpEntries returns empty until the minimum user-turn threshold', () => {
const messages = [
{ id: 'u1', role: 'user', content: 'one' },
{ id: 'a1', role: 'assistant', content: 'ok' },
{ id: 'u2', role: 'user', content: 'two' },
];
assert.deepEqual(buildChatJumpEntries(messages), []);
assert.equal(
buildChatJumpEntries([
...messages,
{ id: 'u3', role: 'user', content: 'three' },
]).length,
3,
);
});
test('buildChatJumpEntries uses empty label fallback and skips non-user roles', () => {
const entries = buildChatJumpEntries(
[
{ id: 's', role: 'system', content: 'ignore' },
{ id: 'u1', role: 'user', content: ' ' },
{ id: 't', role: 'tool', content: 'tool' },
{ id: 'u2', role: 'user', content: 'second' },
{ id: 'u3', role: 'user', content: 'third' },
],
{ emptyLabel: '(empty)' },
);
assert.deepEqual(entries, [
{ messageId: 'u1', label: '(empty)', index: 1 },
{ messageId: 'u2', label: 'second', index: 2 },
{ messageId: 'u3', label: 'third', index: 3 },
]);
});
test('resolveTailCountForJumpTarget expands only when the target is outside the window', () => {
const visible = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
assert.equal(resolveTailCountForJumpTarget(visible, 'd', 2), 2);
assert.equal(resolveTailCountForJumpTarget(visible, 'b', 2), 4);
assert.equal(resolveTailCountForJumpTarget(visible, 'missing', 2), 2);
});
test('resolveTailCountForJumpTarget grows with appends so a pinned target stays mounted', () => {
const before = [{ id: 'a' }, { id: 'b' }, { id: 'c' }, { id: 'd' }, { id: 'e' }];
const afterJump = resolveTailCountForJumpTarget(before, 'b', 2);
assert.equal(afterJump, 4);
// slice(-4) on a longer list would drop 'b' unless the count is re-resolved.
const afterAppend = [...before, { id: 'f' }, { id: 'g' }];
assert.equal(resolveTailCountForJumpTarget(afterAppend, 'b', afterJump), 6);
});
test('chatMessageDomId prefixes message ids for DOM anchors', () => {
assert.equal(chatMessageDomId('msg-12'), 'ai-chat-msg-msg-12');
});
-72
View File
@@ -1,72 +0,0 @@
/**
* Pure helpers for AI chat in-session jump navigation (user-turn TOC).
*/
export const CHAT_JUMP_MIN_ENTRIES = 3;
export const CHAT_JUMP_LABEL_MAX = 36;
export type ChatJumpMessage = {
id: string;
role: string;
content?: string;
};
export type ChatJumpEntry = {
messageId: string;
label: string;
/** 1-based index among user turns */
index: number;
};
export function chatMessageDomId(messageId: string): string {
return `ai-chat-msg-${messageId}`;
}
export function truncateChatJumpLabel(text: string, maxLen = CHAT_JUMP_LABEL_MAX): string {
const normalized = text.replace(/\s+/g, ' ').trim();
if (!normalized) return '';
if (normalized.length <= maxLen) return normalized;
const ellipsis = '...';
return `${normalized.slice(0, Math.max(1, maxLen - ellipsis.length)).trimEnd()}${ellipsis}`;
}
export function buildChatJumpEntries(
messages: ReadonlyArray<ChatJumpMessage>,
options?: {
minEntries?: number;
maxLabelLen?: number;
emptyLabel?: string;
},
): ChatJumpEntry[] {
const minEntries = options?.minEntries ?? CHAT_JUMP_MIN_ENTRIES;
const maxLabelLen = options?.maxLabelLen ?? CHAT_JUMP_LABEL_MAX;
const emptyLabel = options?.emptyLabel ?? '...';
const entries: ChatJumpEntry[] = [];
for (const message of messages) {
if (message.role !== 'user') continue;
const label = truncateChatJumpLabel(message.content ?? '', maxLabelLen) || emptyLabel;
entries.push({
messageId: message.id,
label,
index: entries.length + 1,
});
}
return entries.length >= minEntries ? entries : [];
}
/**
* Expand the rendered tail window so a jump target is mounted in the DOM.
* Call again whenever the message list grows while the jump target remains
* active; a one-shot expand at jump time is not enough if the tail is
* `slice(-count)` and later appends would otherwise slide the window forward.
*/
export function resolveTailCountForJumpTarget(
visibleMessages: ReadonlyArray<{ id: string }>,
targetMessageId: string,
currentTailCount: number,
): number {
const targetIndex = visibleMessages.findIndex((message) => message.id === targetMessageId);
if (targetIndex < 0) return currentTailCount;
const requiredTail = visibleMessages.length - targetIndex;
return Math.max(currentTailCount, requiredTail);
}
+38 -439
View File
@@ -978,9 +978,6 @@ function takePendingCancel(transferId) {
const admittedActiveByResource = new Map();
let admittedTransferLimit = 2;
const isolatedDownloadChannelPools = new WeakMap();
// Sudo downloads share the browse channel; cap concurrent 64-READ fanout the
// same way isolated channels use FAST_DOWNLOAD_CHANNELS_PER_SESSION (#1507).
const sharedFastDownloadSlots = new WeakMap();
// Cache live SFTP clients where remote cp is known to be unavailable, so we
// skip repeated failed exec attempts without retaining closed session ids.
const cpUnavailableSet = new WeakSet();
@@ -1368,22 +1365,6 @@ function releaseIsolatedDownloadChannel(client, sftp, options = {}) {
scheduleIdleIsolatedDownloadChannel(client, sftp);
}
function tryAcquireSharedFastDownloadSlot(client) {
const inFlight = sharedFastDownloadSlots.get(client) || 0;
if (inFlight >= FAST_DOWNLOAD_CHANNELS_PER_SESSION) return false;
sharedFastDownloadSlots.set(client, inFlight + 1);
return true;
}
function releaseSharedFastDownloadSlot(client) {
const inFlight = sharedFastDownloadSlots.get(client) || 0;
if (inFlight <= 1) {
sharedFastDownloadSlots.delete(client);
return;
}
sharedFastDownloadSlots.set(client, inFlight - 1);
}
async function acquireIsolatedDownloadChannel(client, transfer) {
const pool = getIsolatedDownloadChannelPool(client);
if (transfer?.cancelled) return null;
@@ -1921,171 +1902,12 @@ async function uploadFile(
throw error;
}
/**
* Open a remote SFTP handle while transfer.abort can reject the wait without
* depending on sftp.end(). Isolated channels still pass abortChannel that ends
* the subsystem; shared/sudo channels pass a no-op end path and only reject.
* A late OPEN handle after cancel / channel error is closed best-effort when
* disposeChannel is false so the shared session does not leak handles.
* Channel `error` while OPEN is pending must reject here: callers only check
* recorded channelError after this await returns, so a dead channel that never
* invokes the OPEN callback would otherwise hang the transfer and SFTP lease.
* Shared write opens ("w" / "r+" / …) publish transfer.sharedWriteOpenDrain and
* keep it pending until the OPEN callback finishes (including late opens after
* a cancel settle timeout), so upload cleanup awaits the drain before deleting
* the remote stage. Channel-error / cancel-settle paths that never receive an
* OPEN callback arm a short drain force-complete so a dead channel cannot hold
* the transfer and SFTP lease forever.
*
* @param {{ disposeChannel?: boolean, abortChannel?: (() => void) | null }} [options]
*/
function openSftpHandleForTransfer(sftp, filePath, flags, transfer, options = {}) {
const disposeChannel = options.disposeChannel !== false;
const abortChannel = typeof options.abortChannel === "function"
? options.abortChannel
: null;
// ssh2 string flags: "r" is read-only; anything else can create/truncate.
const isWriteOpen = String(flags ?? "r") !== "r";
const trackSharedWriteDrain = !disposeChannel && isWriteOpen;
let resolveSharedWriteDrain = null;
if (trackSharedWriteDrain) {
transfer.sharedWriteOpenDrain = new Promise((resolve) => {
resolveSharedWriteDrain = resolve;
});
}
function openSftpHandle(sftp, filePath, flags) {
return new Promise((resolve, reject) => {
let settled = false;
let openDrainTimer = null;
let drainForceTimer = null;
const previousAbort = transfer.abort;
const clearDrainForceTimer = () => {
if (!drainForceTimer) return;
clearTimeout(drainForceTimer);
drainForceTimer = null;
};
const completeSharedWriteDrain = () => {
clearDrainForceTimer();
if (!resolveSharedWriteDrain) return;
const resolveDrain = resolveSharedWriteDrain;
resolveSharedWriteDrain = null;
resolveDrain();
};
// When OPEN wait settles without an OPEN callback (channel error, or cancel
// settle timeout), keep drain pending for a late truncating OPEN — but force
// complete if the callback never arrives so cleanup/lease cannot hang.
const armSharedWriteDrainForceComplete = () => {
if (!trackSharedWriteDrain || !resolveSharedWriteDrain || drainForceTimer) return;
drainForceTimer = setTimeout(() => {
drainForceTimer = null;
completeSharedWriteDrain();
}, 2000);
};
const settle = (fn, value) => {
if (settled) return;
settled = true;
if (openDrainTimer) {
clearTimeout(openDrainTimer);
openDrainTimer = null;
}
if (transfer.abort === abortDuringOpen) {
transfer.abort = previousAbort;
}
try { sftp.removeListener?.("error", onOpenChannelError); } catch { /* ignore */ }
fn(value);
};
const abortDuringOpen = () => {
transfer.cancelled = true;
// Shared write OPEN can truncate/create the remote path. A settle timeout
// unblocks the transfer UX, but sharedWriteOpenDrain stays pending until
// the OPEN callback finishes (or the drain force-complete) so cleanup
// cannot race a late truncating OPEN.
if (!disposeChannel && isWriteOpen && !settled) {
try { abortChannel?.(); } catch { /* ignore */ }
if (!openDrainTimer) {
openDrainTimer = setTimeout(() => {
settle(reject, new Error("Transfer cancelled"));
armSharedWriteDrainForceComplete();
}, 2000);
}
return;
}
// Settle before abortChannel so a synchronous OPEN callback from
// sftp.end() cannot win the promise with a channel-close error first.
settle(reject, new Error("Transfer cancelled"));
try { abortChannel?.(); } catch { /* ignore */ }
if (!trackSharedWriteDrain) completeSharedWriteDrain();
};
const onOpenChannelError = (error) => {
settle(reject, error || new Error("SFTP channel error"));
// Shared write: keep drain pending for a late OPEN close, but bound it so
// a dead channel that never invokes the OPEN callback cannot hang forever.
if (!trackSharedWriteDrain) {
completeSharedWriteDrain();
} else {
armSharedWriteDrainForceComplete();
}
};
if (transfer.cancelled) {
completeSharedWriteDrain();
reject(new Error("Transfer cancelled"));
return;
}
transfer.abort = abortDuringOpen;
sftp.on?.("error", onOpenChannelError);
try {
sftp.open(filePath, flags, (error, handle) => {
if (settled) {
// Cancel / channel error already settled (possibly via drain timeout).
// Still close a late shared handle, then release the drain barrier so
// cleanup can run.
if (!error && handle && !disposeChannel) {
closeSftpHandle(sftp, handle).then(
completeSharedWriteDrain,
completeSharedWriteDrain,
);
return;
}
completeSharedWriteDrain();
return;
}
if (error) {
settle(reject, error);
completeSharedWriteDrain();
return;
}
if (transfer.cancelled) {
const finishCancel = () => {
settle(reject, new Error("Transfer cancelled"));
completeSharedWriteDrain();
};
if (handle && !disposeChannel) {
// Close before settle/drain so shared write cleanup runs after the
// truncating OPEN handle is released.
closeSftpHandle(sftp, handle).then(finishCancel, finishCancel);
return;
}
finishCancel();
return;
}
settle(resolve, handle);
completeSharedWriteDrain();
});
} catch (error) {
// Sync throw (destroyed channel / bad state) must still settle and drop
// the error listener; otherwise the shared browse channel leaks listeners.
settle(reject, error);
completeSharedWriteDrain();
}
sftp.open(filePath, flags, (error, handle) => {
if (error) reject(error);
else resolve(handle);
});
});
}
@@ -2160,82 +1982,19 @@ async function verifyFastDownloadSamples(sftp, remoteHandle, localHandle, fileSi
Math.max(0, Math.floor((fileSize - sampleSize) / 2)),
Math.max(0, fileSize - sampleSize),
])];
// Verification READs sit outside runPausableConcurrentRanges. After ranges
// settle, the prior abort hook is a no-op, so shared/sudo cancel (or a
// channel error without a READ callback) would hang forever without a local
// force-settle — same 2s grace as forceSettleOnError for download READs.
let rejectPending = null;
let forceSettleTimer = null;
let verifyDone = false;
let channelError = null;
const previousAbort = transfer.abort;
const cancelError = () => new Error("Transfer cancelled");
const abortDuringVerify = () => {
transfer.cancelled = true;
try { previousAbort?.(); } catch { /* ignore */ }
if (verifyDone || forceSettleTimer) return;
forceSettleTimer = setTimeout(() => {
forceSettleTimer = null;
rejectPending?.(cancelError());
}, 2000);
};
const onVerifyChannelError = (error) => {
// Remember across sample gaps: an error between races (after one sample
// resolves, before the next rejectPending is installed) must still fail
// the next sample instead of letting readSftpRange hang forever.
channelError = channelError || error || new Error("SFTP channel error");
rejectPending?.(channelError);
};
transfer.abort = abortDuringVerify;
sftp.on?.("error", onVerifyChannelError);
try {
if (transfer.cancelled) throw cancelError();
if (channelError) throw channelError;
for (const position of offsets) {
if (transfer.cancelled) throw cancelError();
if (channelError) throw channelError;
const length = Math.min(sampleSize, fileSize - position);
const remoteBuffer = Buffer.allocUnsafe(length);
const localBuffer = Buffer.allocUnsafe(length);
await Promise.race([
(async () => {
await readSftpRange(sftp, remoteHandle, remoteBuffer, position, length);
await readLocalRange(localHandle, localBuffer, position, length);
if (!remoteBuffer.equals(localBuffer)) {
const error = new Error("Transfer source content changed during transfer");
error.noTransferFallback = true;
error.sourceChanged = true;
throw error;
}
})(),
new Promise((_, reject) => {
rejectPending = reject;
if (channelError) {
reject(channelError);
return;
}
if (transfer.cancelled) abortDuringVerify();
}),
]);
// Cancel during a responsive sample can win the race above without the
// force-settle timer firing. Recheck so we never report complete after
// cancel (especially the last sample on direct/non-staged downloads).
if (transfer.cancelled) throw cancelError();
if (channelError) throw channelError;
for (const position of offsets) {
if (transfer.cancelled) throw new Error("Transfer cancelled");
const length = Math.min(sampleSize, fileSize - position);
const remoteBuffer = Buffer.allocUnsafe(length);
const localBuffer = Buffer.allocUnsafe(length);
await readSftpRange(sftp, remoteHandle, remoteBuffer, position, length);
await readLocalRange(localHandle, localBuffer, position, length);
if (!remoteBuffer.equals(localBuffer)) {
const error = new Error("Transfer source content changed during transfer");
error.noTransferFallback = true;
error.sourceChanged = true;
throw error;
}
} finally {
verifyDone = true;
if (forceSettleTimer) {
clearTimeout(forceSettleTimer);
forceSettleTimer = null;
}
rejectPending = null;
if (transfer.abort === abortDuringVerify) {
transfer.abort = previousAbort;
}
try { sftp.removeListener?.("error", onVerifyChannelError); } catch { /* ignore */ }
}
}
@@ -2700,11 +2459,10 @@ async function runPausableConcurrentRanges({
if (settled) return;
if (error) terminalError = terminalError || error;
if (active > 0) {
// forceSettleOnError callers may abandon in-flight ranges after a
// short grace: isolated channels (after sftp.end), and shared
// downloads (READ-only — no remote WRITEs to drain). Shared uploads
// must keep forceSettleOnError false so outstanding WRITEs finish
// before the caller reuses or cleans up the remote path.
// Only an isolated channel may force-settle after aborting the
// subsystem. A shared channel cannot discard outstanding WRITE
// callbacks safely because the caller may clean up or reuse the
// same remote path while those requests are still in flight.
if (terminalError && forceSettleOnError) {
if (!forceFinishTimer) {
forceFinishTimer = setTimeout(() => {
@@ -2764,9 +2522,6 @@ async function runPausableConcurrentRanges({
void copyRange(position, length)
.then(() => {
// After force-settle, abandoned ranges must not publish progress
// or advance checkpoints into the caller's truncate/fallback window.
if (settled) return;
transferred += length;
completedRanges.set(position, position + length);
while (completedRanges.has(contiguousCheckpoint)) {
@@ -2776,12 +2531,8 @@ async function runPausableConcurrentRanges({
}
publishContiguousCheckpoint(false);
})
.catch((error) => {
if (settled) return;
abort(error);
})
.catch((error) => abort(error))
.finally(() => {
if (settled) return;
active -= 1;
settlePauseWaiters();
if (terminalError || transfer.cancelled) {
@@ -2868,9 +2619,8 @@ async function uploadFileConcurrent(
channelError = channelError || error;
};
sftp.on?.("error", onChannelError);
// Install cancel before OPEN so a stalled remote open can still settle:
// isolated channels end the subsystem; shared/sudo reject OPEN without end().
// (runPausableConcurrentRanges would install abort later, after OPEN.)
// Install cancel before OPEN so a stalled remote open can still end an
// isolated channel (runPausableConcurrentRanges would install this later).
const abortChannel = () => {
if (disposeChannel) {
try { sftp.end?.(); } catch { /* ignore */ }
@@ -2926,13 +2676,7 @@ async function uploadFileConcurrent(
digestHandle = await fs.promises.open(ephemeralDigestPath, "r");
}
if (transfer.cancelled) throw new Error("Transfer cancelled");
remoteHandle = await openSftpHandleForTransfer(
sftp,
remotePath,
checkpoint > 0 ? "r+" : "w",
transfer,
{ disposeChannel, abortChannel },
);
remoteHandle = await openSftpHandle(sftp, remotePath, checkpoint > 0 ? "r+" : "w");
if (channelError) throw channelError;
if (transfer.cancelled) throw new Error("Transfer cancelled");
@@ -2990,19 +2734,6 @@ async function uploadFileConcurrent(
}
throw error;
} finally {
// Shared write OPEN may still be in flight after a cancel settle timeout or
// channel error. Await the drain barrier before returning so
// runRemoteUploadTransaction cannot clean up the stage before a late
// truncating OPEN finishes (Codex P2 on 747847e7). openSftpHandleForTransfer
// force-completes the drain if the OPEN callback never arrives on a dead
// channel, so this await cannot hang forever.
const sharedWriteOpenDrain = transfer.sharedWriteOpenDrain;
if (sharedWriteOpenDrain) {
await sharedWriteOpenDrain.catch(() => {});
if (transfer.sharedWriteOpenDrain === sharedWriteOpenDrain) {
transfer.sharedWriteOpenDrain = null;
}
}
transfer.readStream = null;
transfer.waitForPause = null;
transfer.cancelPauseWait = null;
@@ -3057,11 +2788,6 @@ async function uploadFileConcurrent(
* durable byte; out-of-order range completion cannot advance past a hole.
* Once pause is requested, no new ranges are scheduled and we wait for every
* in-flight range before acknowledging it.
*
* @param {{ disposeChannel?: boolean }} [options]
* disposeChannel — when true (isolated channel), cancel/abort may call
* sftp.end(); the caller still returns healthy channels to the download pool.
* When false (shared browse / sudo session), never call sftp.end().
*/
async function downloadFileResumableFast(
remotePath,
@@ -3070,26 +2796,18 @@ async function downloadFileResumableFast(
fileSize,
transfer,
sendProgress,
options = {},
) {
const disposeChannel = options.disposeChannel !== false;
const checkpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
let channelError = null;
const onChannelError = (error) => {
channelError = channelError || error;
};
sftp.on?.("error", onChannelError);
// Install cancel before OPEN so a stalled remote open can still settle:
// isolated channels end the subsystem; shared/sudo reject OPEN without end().
// (runPausableConcurrentRanges would install abort later, after OPEN.)
const abortChannel = () => {
if (disposeChannel) {
try { sftp.end?.(); } catch { /* ignore */ }
}
};
// Install cancel before OPEN so a stalled remote open can still end the
// isolated channel (runPausableConcurrentRanges would install this later).
const abortEarly = () => {
transfer.cancelled = true;
abortChannel();
try { sftp.end?.(); } catch { }
};
transfer.abort = abortEarly;
@@ -3098,13 +2816,7 @@ async function downloadFileResumableFast(
let failed = false;
try {
if (transfer.cancelled) throw new Error("Transfer cancelled");
remoteHandle = await openSftpHandleForTransfer(
sftp,
remotePath,
"r",
transfer,
{ disposeChannel, abortChannel },
);
remoteHandle = await openSftpHandle(sftp, remotePath, "r");
if (channelError) throw channelError;
if (transfer.cancelled) throw new Error("Transfer cancelled");
localHandle = await fs.promises.open(localPath, checkpoint > 0 ? "r+" : "w+");
@@ -3122,11 +2834,8 @@ async function downloadFileResumableFast(
await writeLocalRange(localHandle, buffer, position, length);
},
sendProgress,
abortChannel,
abortChannel: () => sftp.end?.(),
sftp,
// Always force-settle on cancel/error: downloads have no remote WRITEs
// to drain. Shared/sudo paths cannot sftp.end() a stuck READ, so without
// this a hung read callback would hold the transfer and SFTP lease forever.
forceSettleOnError: true,
});
if (channelError) throw channelError;
@@ -3152,52 +2861,11 @@ async function downloadFileResumableFast(
}
}
let remoteCloseError = null;
// Close remote handles while the channel is still live. On disposeChannel
// cancel/failure the channel is about to be ended (or already dead); a
// CLOSE request would hang forever with no callback from ssh2.
if (remoteHandle) {
const skipClose = disposeChannel && (failed || transfer.cancelled);
if (!skipClose) {
if (failed || transfer.cancelled) {
// Shared cancel/failure already settled the transfer; do not await
// CLOSE (adds up to 2s on a dead channel). Best-effort close only.
closeSftpHandle(sftp, remoteHandle).catch(() => {});
} else {
// Success path: bound CLOSE so a hung callback cannot block lease
// release. Tag the watchdog so real server "timed out" CLOSE errors
// are not swallowed by message matching.
let closeTimeout = null;
try {
await Promise.race([
closeSftpHandle(sftp, remoteHandle),
new Promise((_, reject) => {
closeTimeout = setTimeout(() => {
const timeoutError = new Error("SFTP close timed out");
timeoutError.sftpCloseTimedOut = true;
reject(timeoutError);
}, 2000);
}),
]);
} catch (error) {
if (error?.sftpCloseTimedOut) {
console.warn(
"[transferBridge] SFTP CLOSE timed out; abandoning remote handle",
transfer.transferId || "",
disposeChannel ? "(isolated — dispose channel)" : "(shared — keep session)",
);
// Shared/sudo: timeout is non-fatal (session stays alive).
// Isolated: mark unhealthy so downloadFile disposes instead of
// returning a possibly-dead channel to the pool.
if (disposeChannel) {
remoteCloseError = error;
}
} else {
remoteCloseError = error;
}
} finally {
if (closeTimeout) clearTimeout(closeTimeout);
}
}
if (remoteHandle && !failed && !transfer.cancelled) {
try {
await closeSftpHandle(sftp, remoteHandle);
} catch (error) {
remoteCloseError = error;
}
}
if (!failed && !transfer.cancelled && !remoteCloseError && channelError) {
@@ -3210,12 +2878,9 @@ async function downloadFileResumableFast(
throw error;
}
if (!failed && !transfer.cancelled && remoteCloseError) {
if (disposeChannel) {
const error = new Error("The isolated SFTP channel failed while closing", { cause: remoteCloseError });
error.completedWithUnhealthyChannel = true;
throw error;
}
throw remoteCloseError;
const error = new Error("The isolated SFTP channel failed while closing", { cause: remoteCloseError });
error.completedWithUnhealthyChannel = true;
throw error;
}
}
}
@@ -3367,73 +3032,7 @@ async function downloadFile(
}
}
// Sudo cannot open an isolated secondary channel, so elevated downloads must
// pipeline READs on the shared browse session (#2719). Cap concurrent shared
// fanout with the same per-session budget as isolated fast downloads (#1507).
// Non-sudo isolated misses stay on serial createReadStream below.
if (
client.__netcattySudoMode
&& typeof sftp.open === "function"
&& typeof sftp.read === "function"
&& tryAcquireSharedFastDownloadSlot(client)
) {
try {
try {
transfer.downloadStrategy = "concurrent-shared";
logTransferDiag(transfer, "strategy", {
strategy: "concurrent-shared",
fields: {
chunk: formatDiagBytes(TRANSFER_CHUNK_SIZE),
concurrency: DOWNLOAD_TRANSFER_CONCURRENCY,
},
});
await downloadFileResumableFast(
remotePath,
localPath,
sftp,
fileSize,
transfer,
sendProgress,
{ disposeChannel: false },
);
if (initialSource) {
const latestSource = await runCancelablePreflight(() => client.stat(remotePath));
await assertDownloadSourceAfterTransfer(initialSource, latestSource, fileSize, {
localPath,
client,
remotePath,
signal: transfer.signal,
});
}
return;
} catch (err) {
if (transfer.cancelled) throw err;
if (err?.noTransferFallback) throw err;
// Concurrent ranges may leave sparse tails past the contiguous
// checkpoint; truncate the actual local target before stream resume.
const sharedCheckpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
try {
await fs.promises.truncate(localPath, sharedCheckpoint);
} catch (truncateError) {
if (!(sharedCheckpoint === 0 && truncateError?.code === "ENOENT")) {
throw truncateError;
}
}
sendProgress(sharedCheckpoint, fileSize, { force: true, checkpointBytes: sharedCheckpoint });
// Clear so the stream fallback below reports the strategy that actually ran.
transfer.downloadStrategy = null;
console.warn(
"[transferBridge] concurrent shared download failed, falling back to a compatible stream:",
err?.message || String(err),
);
}
} finally {
releaseSharedFastDownloadSlot(client);
}
}
// Fallback: sequential stream piping
transfer.downloadStrategy = transfer.downloadStrategy || "stream";
const checkpoint = Math.max(0, Math.min(transfer.checkpointBytes || 0, fileSize));
if (checkpoint >= fileSize) {
// Planned snapshot is already fully staged (including zero-byte snapshots).
File diff suppressed because it is too large. Load diff
+70 -4
View File
@@ -5,7 +5,7 @@ const fs = require("node:fs");
const path = require("node:path");
const { DatabaseSync } = require("node:sqlite");
const SCHEMA_VERSION = 2;
const SCHEMA_VERSION = 3;
const MAX_SECURITY_AUDIT_DETAILS_BYTES = 16 * 1024;
const MAX_SECURITY_AUDIT_RECORDS_PER_PLUGIN = 1_000;
const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
@@ -21,6 +21,8 @@ const REQUIRED_SCHEMA_COLUMNS = Object.freeze({
plugin_security_audit: ["id", "plugin_id", "event", "details_json", "created_at"],
// User-owned encrypted-sync sidecars: no FK cascade on package uninstall.
plugin_sync_sidecars: ["plugin_id", "kind", "key", "value_json", "updated_at"],
// Host-owned sync provider→plugin bindings (not plugin-writable secrets).
plugin_sync_provider_bindings: ["provider_id", "plugin_id", "created_at", "updated_at"],
});
function parseJson(text, label) {
@@ -164,12 +166,18 @@ class PluginDatabase {
);
CREATE INDEX plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
CREATE TABLE plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 1) {
// Pre-sidecar schema-1 databases only created the original tables.
// Migrate in place to schema 2 with the non-cascade sidecar table.
// Migrate in place to schema 3 with sidecar + provider binding tables.
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_sidecars (
@@ -182,7 +190,25 @@ class PluginDatabase {
);
CREATE INDEX IF NOT EXISTS plugin_sync_sidecars_lookup
ON plugin_sync_sidecars(plugin_id, kind, key);
PRAGMA user_version = 2;
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
} else if (version === 2) {
this.transaction(() => {
this.db.exec(`
CREATE TABLE IF NOT EXISTS plugin_sync_provider_bindings (
provider_id TEXT PRIMARY KEY,
plugin_id TEXT NOT NULL,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
PRAGMA user_version = 3;
`);
});
}
@@ -759,6 +785,46 @@ class PluginDatabase {
return Number(result?.changes) || 0;
}
/** Find secret rows by exact key across all plugins (sync provider cleanup). */
findSecretsByKey(key) {
return this.db.prepare(`
SELECT plugin_id, key, secret_ref, ciphertext, created_at, updated_at
FROM plugin_secrets WHERE key = ?
`).all(key).map((row) => this.#mapSecret(row));
}
upsertSyncProviderBinding(providerId, pluginId) {
const now = this.clock();
this.db.prepare(`
INSERT INTO plugin_sync_provider_bindings(provider_id, plugin_id, created_at, updated_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(provider_id) DO UPDATE SET
plugin_id = excluded.plugin_id,
updated_at = excluded.updated_at
`).run(providerId, pluginId, now, now);
}
getSyncProviderBinding(providerId) {
const row = this.db.prepare(`
SELECT provider_id, plugin_id, created_at, updated_at
FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).get(providerId);
if (!row) return null;
return {
providerId: row.provider_id,
pluginId: row.plugin_id,
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
};
}
deleteSyncProviderBinding(providerId) {
const result = this.db.prepare(`
DELETE FROM plugin_sync_provider_bindings WHERE provider_id = ?
`).run(providerId);
return Number(result?.changes) || 0;
}
recordSecurityAudit(pluginId, event, details) {
let detailsJson = JSON.stringify(details ?? {});
const originalBytes = Buffer.byteLength(detailsJson);
+11 -1
View File
@@ -59,7 +59,7 @@ test("obsolete unpublished v1 layouts fail with an explicit reset instruction",
);
});
test("complete schema-1 databases migrate in place to schema 2 with sidecar table", (context) => {
test("complete schema-1 databases migrate in place to schema 3 with sidecar and binding tables", (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v1-migrate-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
const file = path.join(root, "plugins.sqlite");
@@ -164,6 +164,10 @@ test("complete schema-1 databases migrate in place to schema 2 with sidecar tabl
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_provider_bindings)").all().map(({ name }) => name),
["provider_id", "plugin_id", "created_at", "updated_at"],
);
// Existing rows survive the in-place migration.
assert.equal(database.db.prepare("SELECT id FROM plugins").get().id, "com.example.v1");
database.setSyncSidecar("com.example.v1", "settings", "theme\0application\0application", "dark", 2);
@@ -171,6 +175,8 @@ test("complete schema-1 databases migrate in place to schema 2 with sidecar tabl
database.getSyncSidecar("com.example.v1", "settings", "theme\0application\0application")?.value,
"dark",
);
database.upsertSyncProviderBinding("com.example.v1.sync", "com.example.v1");
assert.equal(database.getSyncProviderBinding("com.example.v1.sync")?.pluginId, "com.example.v1");
database.close();
});
@@ -212,6 +218,10 @@ test("initial schema scopes runtime and crash state to immutable plugin versions
database.db.prepare("PRAGMA table_info(plugin_sync_sidecars)").all().map(({ name }) => name),
["plugin_id", "kind", "key", "value_json", "updated_at"],
);
assert.deepEqual(
database.db.prepare("PRAGMA table_info(plugin_sync_provider_bindings)").all().map(({ name }) => name),
["provider_id", "plugin_id", "created_at", "updated_at"],
);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_settings)").all(), []);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_view_state)").all(), []);
assert.deepEqual(database.db.prepare("PRAGMA foreign_key_list(plugin_sync_sidecars)").all(), []);
+77 -6
View File
@@ -46,6 +46,7 @@ const CHANNELS = Object.freeze({
syncDeleteObject: "netcatty:plugins:sync-delete-object",
syncPutSecret: "netcatty:plugins:sync-put-secret",
syncDeleteSecrets: "netcatty:plugins:sync-delete-secrets",
syncRestoreSecrets: "netcatty:plugins:sync-restore-secrets",
syncSidecarsCollect: "netcatty:plugins:sync-sidecars-collect",
syncSidecarsApply: "netcatty:plugins:sync-sidecars-apply",
hostAvailableSync: "netcatty:plugins:host-available-sync",
@@ -1054,7 +1055,25 @@ function registerPluginBridge(ipcMain, options) {
if (!match || typeof match.pluginId !== "string") {
throw new Error(`Plugin sync provider is unavailable: ${providerId}`);
}
return secretStore.set(match.pluginId, key, value);
const existing = typeof secretStore.getReference === "function"
? secretStore.getReference(match.pluginId, key)
: null;
const stored = typeof secretStore.set === "function"
? secretStore.set(match.pluginId, key, value, { stashPrevious: true })
: null;
if (!stored) {
throw new Error("Plugin sync secret storage is unavailable");
}
// Remember which plugin owns this provider so disconnect can clean secrets
// after the contribution is gone (disabled/uninstalled).
if (typeof secretStore.bindSyncProviderPlugin === "function") {
try {
secretStore.bindSyncProviderPlugin(match.pluginId, providerId);
} catch {
/* binding is best-effort; credential write already succeeded */
}
}
return Object.freeze({ ...stored, created: !existing });
});
ipcMain.handle(CHANNELS.syncDeleteSecrets, async (event, payload) => {
@@ -1071,8 +1090,12 @@ function registerPluginBridge(ipcMain, options) {
const match = Array.isArray(providers)
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
: null;
if (!match || typeof match.pluginId !== "string") {
// Provider may already be uninstalled; treat as no-op cleanup.
let pluginId = match && typeof match.pluginId === "string" ? match.pluginId : null;
if (!pluginId && typeof secretStore.resolveSyncProviderPlugin === "function") {
pluginId = secretStore.resolveSyncProviderPlugin(providerId) ?? null;
}
if (!pluginId) {
// No live contribution and no retained binding — nothing to delete.
return { deleted: 0 };
}
const keys = Array.isArray(payload?.keys) && payload.keys.length > 0
@@ -1080,10 +1103,14 @@ function registerPluginBridge(ipcMain, options) {
: null;
if (!keys) {
// Wipe every sync-credential* key (well-known + schema writeOnly fields).
let deleted = 0;
if (typeof secretStore.deleteByKeyPrefix === "function") {
const deleted = secretStore.deleteByKeyPrefix(match.pluginId, "sync-credential");
return { deleted: Number(deleted) || 0 };
deleted = Number(secretStore.deleteByKeyPrefix(pluginId, "sync-credential")) || 0;
}
if (typeof secretStore.unbindSyncProviderPlugin === "function") {
secretStore.unbindSyncProviderPlugin(pluginId, providerId);
}
return { deleted };
}
let deleted = 0;
for (const rawKey of keys ?? [
@@ -1096,7 +1123,7 @@ function registerPluginBridge(ipcMain, options) {
if (typeof rawKey !== "string" || rawKey.length < 1) continue;
const key = assertSecretKey(rawKey);
try {
secretStore.delete(match.pluginId, key);
secretStore.delete(pluginId, key);
deleted += 1;
} catch {
/* ignore missing / unavailable keys */
@@ -1105,6 +1132,50 @@ function registerPluginBridge(ipcMain, options) {
return { deleted };
});
ipcMain.handle(CHANNELS.syncRestoreSecrets, async (event, payload) => {
if (!isTrustedSender(event)) throw new Error("Untrusted plugin management sender");
if (!secretStore) {
throw new Error("Plugin sync secret restore is unavailable");
}
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
const providerId = payload?.providerId;
if (typeof providerId !== "string" || providerId.length < 1) {
throw new TypeError("Sync provider ID is invalid");
}
const keys = Array.isArray(payload?.keys) ? payload.keys : [];
if (keys.length < 1) return { restored: 0, discarded: 0 };
const providers = extensionProviderService.listProviders({ kind: "sync" });
const match = Array.isArray(providers)
? providers.find((entry) => entry?.provider?.id === providerId || entry?.id === providerId)
: null;
let pluginId = match && typeof match.pluginId === "string" ? match.pluginId : null;
if (!pluginId && typeof secretStore.resolveSyncProviderPlugin === "function") {
pluginId = secretStore.resolveSyncProviderPlugin(providerId) ?? null;
}
if (!pluginId) return { restored: 0, discarded: 0 };
const discard = payload?.discard === true;
let restored = 0;
let discarded = 0;
for (const rawKey of keys) {
if (typeof rawKey !== "string" || rawKey.length < 1) continue;
const key = assertSecretKey(rawKey);
try {
if (discard) {
if (typeof secretStore.clearOverwriteStash === "function") {
secretStore.clearOverwriteStash(pluginId, key);
discarded += 1;
}
} else if (typeof secretStore.restoreOverwrite === "function"
&& secretStore.restoreOverwrite(pluginId, key)) {
restored += 1;
}
} catch {
/* best-effort restore/discard */
}
}
return { restored, discarded };
});
handlePassive(CHANNELS.credentialCatalogUpdate, 0, async (_activeManager, payload) => {
if (!credentialResolver || typeof credentialResolver.update !== "function") {
throw new Error("Plugin Vault credential catalog is unavailable");
+50 -1
View File
@@ -1548,5 +1548,54 @@ test("sync read/write shuttle Uint8Array inline and stream large writes", async
key: "sync-credential",
value: "pw",
});
assert.deepEqual(secret, { kind: "secret", id: "sec-1", key: "sync-credential", pluginId: "com.example", value: "pw" });
assert.deepEqual(secret, {
kind: "secret",
id: "sec-1",
key: "sync-credential",
pluginId: "com.example",
value: "pw",
created: true,
});
});
test("syncDeleteSecrets uses retained provider binding when contribution is missing", async () => {
const deleted = [];
const unbound = [];
const ipcMain = createIpcMain();
registerPluginBridge(ipcMain, {
manager: { async initialize() {} },
extensionProviderService: {
listProviders() {
return [];
},
},
secretStore: {
set() {
throw new Error("set should not run");
},
delete(pluginId, key) {
deleted.push([pluginId, key]);
},
deleteByKeyPrefix(pluginId, prefix) {
deleted.push([pluginId, `prefix:${prefix}`]);
return 2;
},
resolveSyncProviderPlugin(providerId) {
assert.equal(providerId, "com.example.sync");
return "com.example";
},
unbindSyncProviderPlugin(pluginId, providerId) {
unbound.push([pluginId, providerId]);
},
},
env: { NETCATTY_PLUGIN_DEV: "1" },
isTrustedSender: () => true,
});
const event = { sender: { id: 99, once() {}, isDestroyed: () => false } };
const result = await ipcMain.handlers.get(CHANNELS.syncDeleteSecrets)(event, {
providerId: "com.example.sync",
});
assert.deepEqual(result, { deleted: 2 });
assert.deepEqual(deleted, [["com.example", "prefix:sync-credential"]]);
assert.deepEqual(unbound, [["com.example", "com.example.sync"]]);
});
+127 -7
View File
@@ -31,12 +31,19 @@ function assertSecretRef(secret) {
}
class PluginSecretStore {
/** @type {Map<string, { value: string, secretRef: string }>} */
#overwriteStash = new Map();
constructor(options) {
this.database = options.database;
this.safeStorage = options.safeStorage ?? null;
this.randomBytes = options.randomBytes ?? randomBytes;
}
#stashKey(pluginId, key) {
return `${pluginId}\0${key}`;
}
#assertAvailable() {
const backend = this.safeStorage?.getSelectedStorageBackend?.();
if (
@@ -67,33 +74,146 @@ class PluginSecretStore {
return record;
}
set(pluginId, key, value) {
set(pluginId, key, value, options = {}) {
this.#assertAvailable();
assertSecretKey(key);
if (typeof value !== "string" || Buffer.byteLength(value, "utf8") > MAX_SECRET_BYTES) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Plugin secret value is invalid or too large");
}
const secretRef = this.randomBytes(24).toString("base64url");
const ciphertext = this.safeStorage.encryptString(value);
const stashPrevious = options.stashPrevious === true;
const stashKey = this.#stashKey(pluginId, key);
let stashed = false;
if (stashPrevious && !this.#overwriteStash.has(stashKey)) {
// Keep an existing stash (e.g. after a failed restore) so a later retry
// still recovers the original SecretRef, not a rejected replacement.
const existing = this.getReference(pluginId, key);
if (existing) {
try {
this.#overwriteStash.set(stashKey, {
value: this.resolve(pluginId, existing),
// Keep the prior SecretRef id so saved provider connections still resolve.
secretRef: existing.id,
});
stashed = true;
} catch {
/* keep going; restore may be unavailable for this key */
}
}
}
try {
const secretRef = this.randomBytes(24).toString("base64url");
const ciphertext = this.safeStorage.encryptString(value);
if (!Buffer.isBuffer(ciphertext) || ciphertext.byteLength < 1) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "OS-backed plugin secret encryption failed");
}
this.database.upsertSecret({ pluginId, key, secretRef, ciphertext });
return Object.freeze({ kind: "secret", id: secretRef, key });
} catch (error) {
// Failed replacement must not leave prior plaintext stranded in memory.
if (stashed) this.#overwriteStash.delete(stashKey);
throw error;
}
}
/**
* Restore the plaintext (and SecretRef id) stashed by the last overwrite.
* Returns true when a stashed value was written back.
*/
restoreOverwrite(pluginId, key) {
this.#assertAvailable();
assertSecretKey(key);
const stashKey = this.#stashKey(pluginId, key);
const previous = this.#overwriteStash.get(stashKey);
if (!previous || typeof previous.value !== "string" || typeof previous.secretRef !== "string") {
return false;
}
const ciphertext = this.safeStorage.encryptString(previous.value);
if (!Buffer.isBuffer(ciphertext) || ciphertext.byteLength < 1) {
throw new PluginRpcError(RPC_ERRORS.unavailable, "OS-backed plugin secret encryption failed");
}
this.database.upsertSecret({ pluginId, key, secretRef, ciphertext });
return Object.freeze({ kind: "secret", id: secretRef, key });
this.database.upsertSecret({
pluginId,
key,
secretRef: previous.secretRef,
ciphertext,
});
// Drop the stash only after the prior value is durably written so a failed
// encrypt/upsert can still be retried.
this.#overwriteStash.delete(stashKey);
return true;
}
clearOverwriteStash(pluginId, key) {
assertSecretKey(key);
this.#overwriteStash.delete(this.#stashKey(pluginId, key));
}
delete(pluginId, key) {
assertSecretKey(key);
this.#overwriteStash.delete(this.#stashKey(pluginId, key));
this.database.deleteSecret(pluginId, key);
}
deleteByKeyPrefix(pluginId, prefix) {
assertSecretKey(prefix);
let deleted = 0;
if (typeof this.database.deleteSecretsByKeyPrefix !== "function") {
this.delete(pluginId, prefix);
return 1;
deleted = 1;
} else {
deleted = this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
}
return this.database.deleteSecretsByKeyPrefix(pluginId, prefix);
const pluginPrefix = `${pluginId}\0`;
for (const stashKey of [...this.#overwriteStash.keys()]) {
if (!stashKey.startsWith(pluginPrefix)) continue;
const secretKey = stashKey.slice(pluginPrefix.length);
if (secretKey === prefix || secretKey.startsWith(`${prefix}:`)) {
this.#overwriteStash.delete(stashKey);
}
}
return deleted;
}
/**
* 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.
*/
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}.`)
) {
throw new TypeError("Sync provider id is outside the plugin namespace");
}
if (typeof this.database.upsertSyncProviderBinding !== "function") {
throw new Error("Plugin sync provider binding storage is unavailable");
}
this.database.upsertSyncProviderBinding(providerId, pluginId);
}
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;
}
return pluginId;
}
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);
}
resolve(pluginId, secret) {
+142
View File
@@ -301,3 +301,145 @@ test("credential lease resolution cannot return plaintext after operation cancel
leaseStore.shutdown();
database.close();
});
test("overwrite stash restores previous plaintext and discard clears it", (context) => {
const database = createDatabase(context);
let random = 0;
const store = new PluginSecretStore({
database,
safeStorage: fakeSafeStorage(),
randomBytes: () => Buffer.alloc(24, ++random),
});
const first = store.set("com.example.one", "sync-credential", "good-password");
assert.equal(store.resolve("com.example.one", first), "good-password");
const second = store.set("com.example.one", "sync-credential", "bad-password", { stashPrevious: true });
assert.equal(store.resolve("com.example.one", second), "bad-password");
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), true);
const restored = store.getReference("com.example.one", "sync-credential");
assert.equal(restored.id, first.id, "restore must keep the prior SecretRef id");
assert.equal(store.resolve("com.example.one", first), "good-password");
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
store.set("com.example.one", "sync-credential", "another-bad", { stashPrevious: true });
store.clearOverwriteStash("com.example.one", "sync-credential");
assert.equal(store.restoreOverwrite("com.example.one", "sync-credential"), false);
assert.equal(
store.resolve("com.example.one", store.getReference("com.example.one", "sync-credential")),
"another-bad",
);
// Ordinary secrets.set-style overwrites must not stash plaintext by default.
store.set("com.example.one", "api-key", "one");
store.set("com.example.one", "api-key", "two");
assert.equal(store.restoreOverwrite("com.example.one", "api-key"), false);
database.close();
});
test("failed overwrite clears stashed plaintext", (context) => {
const database = createDatabase(context);
let failEncrypt = false;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: (value) => {
if (failEncrypt) return Buffer.alloc(0);
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
},
decryptString: (value) => {
const encoded = value.toString().slice("sealed:".length);
return Buffer.from(encoded, "base64").toString();
},
};
const store = new PluginSecretStore({ database, safeStorage });
store.set("com.example", "sync-credential", "good-password");
failEncrypt = true;
assert.throws(
() => store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true }),
(error) => error.code === RPC_ERRORS.unavailable,
);
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
failEncrypt = false;
const ref = store.getReference("com.example", "sync-credential");
assert.equal(store.resolve("com.example", ref), "good-password");
database.close();
});
test("sync provider bindings live outside plugin secrets and reject namespace mismatches", (context) => {
const database = createDatabase(context);
const store = new PluginSecretStore({
database,
safeStorage: fakeSafeStorage(),
});
store.bindSyncProviderPlugin("com.example", "com.example.sync");
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
assert.throws(
() => store.bindSyncProviderPlugin("com.example", "com.other.sync"),
/outside the plugin namespace/,
);
store.set("com.attacker", "sync-provider-map:com.example.sync", "com.attacker");
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
database.close();
});
test("failed restoreOverwrite keeps stash for retry", (context) => {
const database = createDatabase(context);
let failEncrypt = false;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: (value) => {
if (failEncrypt) return Buffer.alloc(0);
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
},
decryptString: (value) => {
const encoded = value.toString().slice("sealed:".length);
return Buffer.from(encoded, "base64").toString();
},
};
const store = new PluginSecretStore({ database, safeStorage });
const first = store.set("com.example", "sync-credential", "good-password");
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
failEncrypt = true;
assert.throws(
() => store.restoreOverwrite("com.example", "sync-credential"),
(error) => error.code === RPC_ERRORS.unavailable,
);
failEncrypt = false;
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
assert.equal(store.resolve("com.example", first), "good-password");
database.close();
});
test("existing overwrite stash is preserved across retry puts and cleared by prefix delete", (context) => {
const database = createDatabase(context);
let failEncrypt = false;
const safeStorage = {
isEncryptionAvailable: () => true,
encryptString: (value) => {
if (failEncrypt) return Buffer.alloc(0);
return Buffer.from(`sealed:${Buffer.from(value).toString("base64")}`);
},
decryptString: (value) => {
const encoded = value.toString().slice("sealed:".length);
return Buffer.from(encoded, "base64").toString();
},
};
const store = new PluginSecretStore({ database, safeStorage });
const first = store.set("com.example", "sync-credential", "good-password");
store.set("com.example", "sync-credential", "bad-password", { stashPrevious: true });
failEncrypt = true;
assert.throws(
() => store.restoreOverwrite("com.example", "sync-credential"),
(error) => error.code === RPC_ERRORS.unavailable,
);
failEncrypt = false;
// Retry put must not replace the original good-password stash with bad-password.
store.set("com.example", "sync-credential", "worse-password", { stashPrevious: true });
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), true);
assert.equal(store.resolve("com.example", first), "good-password");
store.set("com.example", "sync-credential", "temp", { stashPrevious: true });
store.deleteByKeyPrefix("com.example", "sync-credential");
assert.equal(store.restoreOverwrite("com.example", "sync-credential"), false);
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
database.close();
});
+1
View File
@@ -183,6 +183,7 @@ function createPreloadApi(ctx) {
},
pluginSyncPutSecret: (request) => ipcRenderer.invoke("netcatty:plugins:sync-put-secret", request ?? {}),
pluginSyncDeleteSecrets: (request) => ipcRenderer.invoke("netcatty:plugins:sync-delete-secrets", request ?? {}),
pluginSyncRestoreSecrets: (request) => ipcRenderer.invoke("netcatty:plugins:sync-restore-secrets", request ?? {}),
collectPluginSyncSidecars: () => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-collect", {}),
applyPluginSyncSidecars: (bundle) => ipcRenderer.invoke("netcatty:plugins:sync-sidecars-apply", bundle ?? { version: 1, entries: [] }),
/** True only when the main-process plugin host wired a sidecar service. */
Vendored
+6 -1
View File
@@ -434,11 +434,16 @@ declare global {
providerId: string;
key: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string }>;
}): Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }>;
pluginSyncDeleteSecrets?(request: {
providerId: string;
keys?: string[];
}): Promise<{ deleted: number }>;
pluginSyncRestoreSecrets?(request: {
providerId: string;
keys: string[];
discard?: boolean;
}): Promise<{ restored: number; discarded?: number }>;
collectPluginSyncSidecars?(): Promise<unknown>;
applyPluginSyncSidecars?(bundle: unknown): Promise<{ applied: boolean; count?: number; entries?: unknown }>;
pluginHostReady?(): boolean;
@@ -103,11 +103,16 @@ type ElectronPluginSyncApi = {
providerId: string;
key: string;
value: string;
}) => Promise<{ kind: 'secret'; id: string; key: string }>;
}) => Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }>;
pluginSyncDeleteSecrets?: (params: {
providerId: string;
keys?: string[];
}) => Promise<{ deleted: number }>;
pluginSyncRestoreSecrets?: (params: {
providerId: string;
keys: string[];
discard?: boolean;
}) => Promise<{ restored: number; discarded?: number }>;
};
function getPluginSyncApi(): ElectronPluginSyncApi | null {
@@ -387,7 +392,7 @@ export async function putPluginSyncSecret(params: {
providerId: string;
key?: string;
value: string;
}): Promise<{ kind: 'secret'; id: string; key: string }> {
}): Promise<{ kind: 'secret'; id: string; key: string; created?: boolean }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncPutSecret !== 'function') {
throw new Error('Plugin sync secret storage is unavailable');
@@ -413,3 +418,20 @@ export async function deletePluginSyncSecrets(params: {
...(params.keys ? { keys: [...params.keys] } : {}),
});
}
/** Restore host-stashed plaintext after a rejected overwrite during reconnect. */
export async function restorePluginSyncSecrets(params: {
providerId: string;
keys: string[];
discard?: boolean;
}): Promise<{ restored: number; discarded?: number }> {
const api = getPluginSyncApi();
if (typeof api?.pluginSyncRestoreSecrets !== 'function') {
return { restored: 0, discarded: 0 };
}
return api.pluginSyncRestoreSecrets({
providerId: params.providerId,
keys: [...params.keys],
...(params.discard === true ? { discard: true } : {}),
});
}
+36 -84
View File
@@ -241,111 +241,64 @@ function compareTapResults(baseline, candidate) {
missingBaselineFailures.push(failure.name);
}
const result = ({
passed,
kind,
newFailures = [],
candidateFailures = candidate.exitCode === 0 ? [] : candidate.failures,
}) => ({
passed,
kind,
baselineFailures: baseline.failures,
candidateFailures,
newFailures,
missingBaselineSuccesses,
missingBaselineFailures,
});
if (candidate.exitCode === 0) {
// Require every baseline failure title to reappear as a same-named success
// before accepting a red-to-green transition. Aggregate counts alone would allow
// deleting the failing test and adding an unrelated passer.
const baselineFailCounts = countFailures(baseline.failures);
const baselineSuccessCountsForFix = countFailures(baseline.successes);
const candidateSuccessCountsForFix = countFailures(candidate.successes);
const unresolvedBaselineFailures = [];
for (const [name, failCount] of baselineFailCounts) {
const required =
failCount + (baselineSuccessCountsForFix.get(name) || 0);
const available = candidateSuccessCountsForFix.get(name) || 0;
for (let i = 0; i < required - available; i += 1) {
unresolvedBaselineFailures.push(name);
}
}
// A fully green candidate may rename success titles when quantitative
// coverage does not shrink, but only against a complete baseline summary.
// An incomplete baseline cannot prove coverage was preserved, so fail closed.
const quantitativeClean =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
unresolvedBaselineFailures.length === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount >= baseline.testCount;
if (quantitativeClean) {
return result({ passed: true, kind: 'clean' });
}
const coverageShrunk =
const completeCleanRun =
baseline.complete &&
candidate.complete &&
candidate.failCount === 0 &&
candidate.cancelledCount === 0 &&
candidate.skippedCount <= baseline.skippedCount &&
candidate.todoCount <= baseline.todoCount &&
candidate.testCount < baseline.testCount &&
missingBaselineSuccesses.length > 0;
if (coverageShrunk) {
return result({
passed: false,
kind: 'missing_baseline_successes',
newFailures: missingBaselineSuccesses,
});
}
return result({
passed: false,
kind: 'unclassified_failure',
newFailures: unresolvedBaselineFailures.length
? unresolvedBaselineFailures
: missingBaselineSuccesses,
});
candidate.testCount >= baseline.testCount &&
missingBaselineSuccesses.length === 0 &&
missingBaselineFailures.length === 0;
return {
passed: completeCleanRun,
kind: completeCleanRun ? 'clean' : 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: [],
newFailures: [],
};
}
if (baseline.exitCode === 0) {
return result({
return {
passed: false,
kind: 'new_failures',
baselineFailures: [],
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (!baseline.complete || !candidate.complete) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (missingBaselineSuccesses.length) {
return result({
return {
passed: false,
kind: 'missing_baseline_successes',
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: missingBaselineSuccesses,
});
};
}
if (candidate.exitCode !== baseline.exitCode) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
const baselineNonTapCounts = countFailures(baseline.nonTapOutput);
@@ -354,21 +307,23 @@ function compareTapResults(baseline, candidate) {
([line, count]) => count > (baselineNonTapCounts.get(line) || 0),
);
if (addedNonTapOutput) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (candidate.failCount === 0 || candidate.testCount < baseline.testCount) {
return result({
return {
passed: false,
kind: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
if (
@@ -376,12 +331,13 @@ function compareTapResults(baseline, candidate) {
candidate.skippedCount > baseline.skippedCount ||
candidate.todoCount > baseline.todoCount
) {
return result({
return {
passed: false,
kind: 'cancelled_tests',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: candidate.failures,
});
};
}
const baselineCounts = countFailures(
@@ -408,16 +364,17 @@ function compareTapResults(baseline, candidate) {
const reportedFailures = newFailures.length
? newFailures
: missingBaselineFailures;
return result({
return {
passed,
kind: passed
? 'baseline_only'
: newFailures.length
? 'new_failures'
: 'unclassified_failure',
baselineFailures: baseline.failures,
candidateFailures: candidate.failures,
newFailures: reportedFailures,
});
};
}
function parseArgs(argv) {
@@ -461,11 +418,6 @@ function main(argv = process.argv.slice(2)) {
if (result.newFailures.length) {
console.error(`New failures:\n- ${result.newFailures.join('\n- ')}`);
}
if (!result.passed && result.missingBaselineSuccesses?.length) {
console.error(
`Missing baseline successes:\n- ${result.missingBaselineSuccesses.join('\n- ')}`,
);
}
return result.passed ? 0 : 1;
}
+9 -49
View File
@@ -36,14 +36,13 @@ test('rejects a zero-exit candidate without a complete clean TAP summary', () =>
assert.equal(result.kind, 'unclassified_failure');
});
test('accepts renaming a successful test when quantitative coverage does not shrink', () => {
test('rejects replacing an existing successful test with an unrelated one', () => {
const result = compareTapResults(
parseTapResult(tap({ successes: ['kept test', 'removed test'] }), 0),
parseTapResult(tap({ successes: ['kept test', 'unrelated replacement'] }), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
assert.equal(result.passed, false);
assert.equal(result.kind, 'unclassified_failure');
const issueNumberName = compareTapResults(
parseTapResult(tap({
@@ -53,55 +52,16 @@ test('accepts renaming a successful test when quantitative coverage does not shr
successes: ['accepts packages that ship upstream #9999'],
}), 0),
);
assert.equal(issueNumberName.passed, true);
assert.equal(issueNumberName.kind, 'clean');
assert.equal(issueNumberName.passed, false);
// Clean candidate that fixes a prior red must keep the failing test title.
const fixedFailure = compareTapResults(
parseTapResult(tap({ failures: ['broken test'], successes: ['other test'] }), 1),
parseTapResult(tap({ successes: ['broken test', 'other test'] }), 0),
const duplicateName = compareTapResults(
parseTapResult(tap({ failures: ['same name'], successes: ['same name'] }), 1),
parseTapResult(tap({ successes: ['same name', 'unrelated replacement'] }), 0),
);
assert.equal(fixedFailure.passed, true);
assert.equal(fixedFailure.kind, 'clean');
// Deleting the failing test and adding an unrelated passer is not a clean fix.
const deletedFailure = compareTapResults(
parseTapResult(tap({ failures: ['broken test'], successes: ['other test'] }), 1),
parseTapResult(tap({ successes: ['other test', 'unrelated replacement'] }), 0),
);
assert.equal(deletedFailure.passed, false);
assert.equal(deletedFailure.kind, 'unclassified_failure');
assert.deepEqual(deletedFailure.missingBaselineFailures, ['broken test']);
assert.equal(duplicateName.passed, false);
});
test('accepts a green candidate that renames one success while adding another', () => {
const result = compareTapResults(
parseTapResult(tap({
successes: ['alpha', 'beta', 'gamma'],
tests: 8886,
}), 0),
parseTapResult(tap({
successes: ['alpha', 'beta', 'delta', 'epsilon'],
tests: 8887,
}), 0),
);
assert.equal(result.passed, true);
assert.equal(result.kind, 'clean');
assert.deepEqual(result.missingBaselineSuccesses, ['gamma']);
});
test('rejects deleting successful coverage even when the run stays green', () => {
const result = compareTapResults(
parseTapResult(tap({ successes: ['kept test', 'removed test'], tests: 12 }), 0),
parseTapResult(tap({ successes: ['kept test'], tests: 11 }), 0),
);
assert.equal(result.passed, false);
assert.equal(result.kind, 'missing_baseline_successes');
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
assert.deepEqual(result.newFailures, ['removed test']);
});
test('rejects a clean candidate when the exact-base TAP summary is incomplete (fail closed)', () => {
test('rejects a clean candidate when the exact-base TAP summary is incomplete', () => {
const result = compareTapResults(
parseTapResult('base runner stopped early', 1),
parseTapResult(tap(), 0),
-3
View File
@@ -2293,9 +2293,6 @@ test('every code-writing Cursor path compares exact-base failures and preserves
assert.match(codex, /fix-test-comparison\.json/);
assert.match(codex, /steps\.fixpatch\.outputs\.artifact_ready == 'true'/);
assert.match(codex, /fix-candidate-tests\.log/);
assert.match(codex, /fix-base-tests\.log/);
assert.match(implement, /base-tests\.log/);
assert.match(followup, /followup-base-tests\.log/);
assert.ok(
codex.indexOf('name: Install test shell dependencies') <
codex.indexOf('name: Capture Codex-fix exact-base test baseline'),