mirror of
https://github.com/binaricat/Netcatty.git
synced 2026-10-31 19:02:31 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05ee57a05a | ||
|
|
f2b1b5d874 | ||
|
|
cded4c8a21 | ||
|
|
dd7aad7001 | ||
|
|
9d1e8e25b0 | ||
|
|
bb74147a52 | ||
|
|
0633d19039 | ||
|
|
e30d8c2288 | ||
|
|
8ae60205ad | ||
|
|
87157496c5 | ||
|
|
5bc1b60d01 | ||
|
|
8d64ea2032 | ||
|
|
d3a9b94c55 | ||
|
|
297192bb63 | ||
|
|
dd6edb3585 | ||
|
|
4e8f8a94e0 | ||
|
|
5bce7f5e1a | ||
|
|
3af4b61f51 | ||
|
|
e020a7dc9c | ||
|
|
b8ce5bd2ce | ||
|
|
51b283fbe3 | ||
|
|
a95251ff5c | ||
|
|
a51b71d3dd | ||
|
|
4d8a10a2e4 | ||
|
|
dfd24d11d7 | ||
|
|
dc825cc243 | ||
|
|
ec5080f6a8 | ||
|
|
2ea5efacba | ||
|
|
9c5da74486 | ||
|
|
7bdbbe2e16 | ||
|
|
4747c45a99 | ||
|
|
e40aace189 |
No files matched your search
@@ -2093,6 +2093,7 @@ 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",
|
||||
@@ -2114,6 +2115,7 @@ 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
|
||||
@@ -3379,6 +3381,7 @@ 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",
|
||||
@@ -3400,6 +3403,7 @@ 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
|
||||
@@ -3409,6 +3413,14 @@ 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
|
||||
@@ -4812,6 +4824,7 @@ 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",
|
||||
@@ -4831,6 +4844,7 @@ 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
|
||||
@@ -4840,6 +4854,14 @@ 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
|
||||
|
||||
@@ -443,6 +443,8 @@ 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',
|
||||
|
||||
@@ -395,6 +395,8 @@ 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': 'Предыдущих сессий нет',
|
||||
|
||||
@@ -443,6 +443,8 @@ 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': '没有历史会话',
|
||||
|
||||
@@ -442,6 +442,8 @@ 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': '沒有歷史工作階段',
|
||||
|
||||
@@ -25,12 +25,17 @@ export const ConversationContent = ({ className, ...props }: ConversationContent
|
||||
/>
|
||||
);
|
||||
|
||||
export const ConversationScrollButton = ({ className, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
onClick,
|
||||
...props
|
||||
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
const handleClick = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onClick?.(event);
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
}, [onClick, scrollToBottom]);
|
||||
|
||||
if (isAtBottom) return null;
|
||||
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* 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;
|
||||
@@ -132,6 +132,63 @@ 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",
|
||||
|
||||
@@ -73,6 +73,12 @@ import {
|
||||
getAIPanelProfilerProps,
|
||||
isAIPanelDiagnosticPartHidden,
|
||||
} from './aiPanelDiagnostics';
|
||||
import {
|
||||
buildChatJumpEntries,
|
||||
chatMessageDomId,
|
||||
resolveTailCountForJumpTarget,
|
||||
} from '../../domain/chatJumpNav';
|
||||
import ChatJumpNav from './ChatJumpNav';
|
||||
|
||||
interface ChatMessageListProps {
|
||||
messages: ChatMessage[];
|
||||
@@ -354,9 +360,13 @@ 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(
|
||||
@@ -364,11 +374,47 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
[messages],
|
||||
);
|
||||
|
||||
const hiddenMessageCount = Math.max(0, visibleMessages.length - renderedTailCount);
|
||||
// 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 displayedMessages = hiddenMessageCount > 0
|
||||
? visibleMessages.slice(-renderedTailCount)
|
||||
? visibleMessages.slice(-effectiveTailCount)
|
||||
: 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')
|
||||
@@ -483,7 +529,8 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
{hiddenMessageCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRenderedTailCount((count) => count + MESSAGE_RENDER_STEP)}
|
||||
onClick={() => setRenderedTailCount((count) =>
|
||||
Math.max(count, effectiveTailCount) + 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))}
|
||||
@@ -591,7 +638,11 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
const isThisStreaming = isStreaming && isLastAssistant;
|
||||
|
||||
return (
|
||||
<Message key={message.id} from={message.role}>
|
||||
<Message
|
||||
key={message.id}
|
||||
id={chatMessageDomId(message.id)}
|
||||
from={message.role}
|
||||
>
|
||||
<MessageContent from={message.role}>
|
||||
{/* Thinking block */}
|
||||
{!isUser && message.thinking && (
|
||||
@@ -833,7 +884,14 @@ const ChatMessageList: React.FC<ChatMessageListProps> = ({
|
||||
</div>
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
<ChatJumpNav
|
||||
entries={jumpEntries}
|
||||
activeMessageId={activeJumpMessageId}
|
||||
isStreaming={!!isStreaming}
|
||||
onSelect={handleJumpToMessage}
|
||||
onReleasePin={handleReleaseJumpPin}
|
||||
/>
|
||||
<ConversationScrollButton onClick={handleReleaseJumpPin} />
|
||||
</Conversation>
|
||||
|
||||
{/* Image preview lightbox */}
|
||||
|
||||
@@ -1476,8 +1476,8 @@ export const createXTermRuntime = (ctx: CreateXTermRuntimeContext): XTermRuntime
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// When copy is bound specifically to Ctrl+C and there is no text
|
||||
// selected, pass the event through so xterm can send SIGINT.
|
||||
// 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.
|
||||
const shouldForwardCopyToTerminal =
|
||||
shouldPassThroughCopyShortcut(action, term.hasSelection(), e);
|
||||
if (shouldForwardCopyToTerminal && !kittySequenceForKeyDown) return true;
|
||||
|
||||
@@ -3,6 +3,7 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
isPlainCtrlCInterruptChord,
|
||||
isPlainMetaCCopyChord,
|
||||
shouldPassThroughCopyShortcut,
|
||||
} from "./terminalCopyShortcut.ts";
|
||||
|
||||
@@ -27,10 +28,20 @@ test("plain Ctrl+C copy with no selection passes through for SIGINT", () => {
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", false, event), true);
|
||||
});
|
||||
|
||||
test("copy shortcut does not pass through when text is selected", () => {
|
||||
const event = keyboardEvent("c", "KeyC", { ctrlKey: true });
|
||||
test("plain Cmd+C copy with no selection passes through for Kitty Super+C", () => {
|
||||
const event = keyboardEvent("c", "KeyC", { metaKey: true });
|
||||
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, event), false);
|
||||
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 });
|
||||
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, ctrlC), false);
|
||||
assert.equal(shouldPassThroughCopyShortcut("copy", true, cmdC), false);
|
||||
});
|
||||
|
||||
test("copy shortcut does not pass through for shifted or alternate chords", () => {
|
||||
@@ -38,14 +49,26 @@ 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", () => {
|
||||
@@ -54,3 +77,10 @@ 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,18 +3,43 @@ 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
|
||||
&& (e.key.toLowerCase() === "c" || e.code === "KeyC");
|
||||
&& isPhysicalCKey(e);
|
||||
}
|
||||
|
||||
/** 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);
|
||||
return action === "copy"
|
||||
&& !hasSelection
|
||||
&& (isPlainCtrlCInterruptChord(e) || isPlainMetaCCopyChord(e));
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
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');
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
@@ -978,6 +978,9 @@ 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();
|
||||
@@ -1365,6 +1368,22 @@ 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;
|
||||
@@ -1902,12 +1921,171 @@ async function uploadFile(
|
||||
throw error;
|
||||
}
|
||||
|
||||
function openSftpHandle(sftp, filePath, flags) {
|
||||
return new Promise((resolve, reject) => {
|
||||
sftp.open(filePath, flags, (error, handle) => {
|
||||
if (error) reject(error);
|
||||
else resolve(handle);
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1982,19 +2160,82 @@ async function verifyFastDownloadSamples(sftp, remoteHandle, localHandle, fileSi
|
||||
Math.max(0, Math.floor((fileSize - sampleSize) / 2)),
|
||||
Math.max(0, fileSize - sampleSize),
|
||||
])];
|
||||
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;
|
||||
|
||||
// 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;
|
||||
}
|
||||
} 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 */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2459,10 +2700,11 @@ async function runPausableConcurrentRanges({
|
||||
if (settled) return;
|
||||
if (error) terminalError = terminalError || error;
|
||||
if (active > 0) {
|
||||
// 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.
|
||||
// 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.
|
||||
if (terminalError && forceSettleOnError) {
|
||||
if (!forceFinishTimer) {
|
||||
forceFinishTimer = setTimeout(() => {
|
||||
@@ -2522,6 +2764,9 @@ 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)) {
|
||||
@@ -2531,8 +2776,12 @@ async function runPausableConcurrentRanges({
|
||||
}
|
||||
publishContiguousCheckpoint(false);
|
||||
})
|
||||
.catch((error) => abort(error))
|
||||
.catch((error) => {
|
||||
if (settled) return;
|
||||
abort(error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (settled) return;
|
||||
active -= 1;
|
||||
settlePauseWaiters();
|
||||
if (terminalError || transfer.cancelled) {
|
||||
@@ -2619,8 +2868,9 @@ async function uploadFileConcurrent(
|
||||
channelError = channelError || error;
|
||||
};
|
||||
sftp.on?.("error", onChannelError);
|
||||
// Install cancel before OPEN so a stalled remote open can still end an
|
||||
// isolated channel (runPausableConcurrentRanges would install this later).
|
||||
// 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 */ }
|
||||
@@ -2676,7 +2926,13 @@ async function uploadFileConcurrent(
|
||||
digestHandle = await fs.promises.open(ephemeralDigestPath, "r");
|
||||
}
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
remoteHandle = await openSftpHandle(sftp, remotePath, checkpoint > 0 ? "r+" : "w");
|
||||
remoteHandle = await openSftpHandleForTransfer(
|
||||
sftp,
|
||||
remotePath,
|
||||
checkpoint > 0 ? "r+" : "w",
|
||||
transfer,
|
||||
{ disposeChannel, abortChannel },
|
||||
);
|
||||
if (channelError) throw channelError;
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
|
||||
@@ -2734,6 +2990,19 @@ 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;
|
||||
@@ -2788,6 +3057,11 @@ 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,
|
||||
@@ -2796,18 +3070,26 @@ 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 end the
|
||||
// isolated channel (runPausableConcurrentRanges would install this later).
|
||||
// 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 */ }
|
||||
}
|
||||
};
|
||||
const abortEarly = () => {
|
||||
transfer.cancelled = true;
|
||||
try { sftp.end?.(); } catch { }
|
||||
abortChannel();
|
||||
};
|
||||
transfer.abort = abortEarly;
|
||||
|
||||
@@ -2816,7 +3098,13 @@ async function downloadFileResumableFast(
|
||||
let failed = false;
|
||||
try {
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
remoteHandle = await openSftpHandle(sftp, remotePath, "r");
|
||||
remoteHandle = await openSftpHandleForTransfer(
|
||||
sftp,
|
||||
remotePath,
|
||||
"r",
|
||||
transfer,
|
||||
{ disposeChannel, abortChannel },
|
||||
);
|
||||
if (channelError) throw channelError;
|
||||
if (transfer.cancelled) throw new Error("Transfer cancelled");
|
||||
localHandle = await fs.promises.open(localPath, checkpoint > 0 ? "r+" : "w+");
|
||||
@@ -2834,8 +3122,11 @@ async function downloadFileResumableFast(
|
||||
await writeLocalRange(localHandle, buffer, position, length);
|
||||
},
|
||||
sendProgress,
|
||||
abortChannel: () => sftp.end?.(),
|
||||
abortChannel,
|
||||
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;
|
||||
@@ -2861,11 +3152,52 @@ async function downloadFileResumableFast(
|
||||
}
|
||||
}
|
||||
let remoteCloseError = null;
|
||||
if (remoteHandle && !failed && !transfer.cancelled) {
|
||||
try {
|
||||
await closeSftpHandle(sftp, remoteHandle);
|
||||
} catch (error) {
|
||||
remoteCloseError = error;
|
||||
// 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 (!failed && !transfer.cancelled && !remoteCloseError && channelError) {
|
||||
@@ -2878,9 +3210,12 @@ async function downloadFileResumableFast(
|
||||
throw error;
|
||||
}
|
||||
if (!failed && !transfer.cancelled && remoteCloseError) {
|
||||
const error = new Error("The isolated SFTP channel failed while closing", { cause: remoteCloseError });
|
||||
error.completedWithUnhealthyChannel = true;
|
||||
throw error;
|
||||
if (disposeChannel) {
|
||||
const error = new Error("The isolated SFTP channel failed while closing", { cause: remoteCloseError });
|
||||
error.completedWithUnhealthyChannel = true;
|
||||
throw error;
|
||||
}
|
||||
throw remoteCloseError;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3032,7 +3367,73 @@ 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
@@ -213,6 +213,10 @@ class PluginDatabase {
|
||||
});
|
||||
}
|
||||
this.#assertSchemaLayout();
|
||||
// Legacy-map backfill is deferred until PackageStore.recover() has finished
|
||||
// (hostService seeds after package initialize). Running here would promote
|
||||
// sole map candidates before recovered manifests are visible, then skip
|
||||
// re-evaluation because bindings already exist (Codex P2 on cded4c8a).
|
||||
}
|
||||
|
||||
#assertSchemaLayout() {
|
||||
@@ -328,6 +332,20 @@ class PluginDatabase {
|
||||
`).all().map((row) => this.#mapPlugin(row));
|
||||
}
|
||||
|
||||
/**
|
||||
* Every installed package version (active and inactive), with parsed manifests.
|
||||
* Used for ownership recovery when an older version still identifies a sync
|
||||
* provider that the active manifest no longer contributes.
|
||||
*/
|
||||
listInstalledVersions() {
|
||||
return this.db.prepare(`
|
||||
SELECT plugin_id, version, manifest_json, archive_sha256,
|
||||
package_relative_path, installed_at
|
||||
FROM plugin_versions
|
||||
ORDER BY plugin_id COLLATE BINARY, installed_at DESC, version COLLATE BINARY
|
||||
`).all().map((row) => this.#mapVersion(row));
|
||||
}
|
||||
|
||||
#mapVersion(row) {
|
||||
return {
|
||||
pluginId: row.plugin_id,
|
||||
@@ -825,6 +843,181 @@ class PluginDatabase {
|
||||
return Number(result?.changes) || 0;
|
||||
}
|
||||
|
||||
/** Distinct plugin ids that own secrets with key === prefix or key LIKE prefix:%. */
|
||||
listPluginIdsWithSecretKeyPrefix(prefix) {
|
||||
const escaped = String(prefix).replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
||||
return this.db.prepare(`
|
||||
SELECT DISTINCT plugin_id
|
||||
FROM plugin_secrets
|
||||
WHERE key = ? OR key LIKE ? ESCAPE '\\'
|
||||
`).all(prefix, `${escaped}:%`).map((row) => row.plugin_id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promote leftover sync-provider-map:* secret rows into the host-owned binding
|
||||
* table. Idempotent.
|
||||
*
|
||||
* Host map keys under this prefix are migration markers from an intermediate
|
||||
* build (pluginId-namespaced provider ids only). After a successful promote,
|
||||
* those map rows are deleted so constructor-time backfill cannot resurrect a
|
||||
* binding after the user disconnects/unbinds. Multiple plugins may hold map
|
||||
* rows for the same provider (parent + nested id): group by provider, never
|
||||
* overwrite an existing host binding (including empty-plugin_id unbind
|
||||
* tombstones). Among candidates, only plugins that still hold sync-credential*
|
||||
* secrets are eligible. Auto-bind only when exactly one distinct
|
||||
* credential-backed candidate exists; if several credential-backed plugins
|
||||
* map the same provider (e.g. com.example and com.example.sync both hold
|
||||
* credentials), skip entirely — longest pluginId is not a reliable owner
|
||||
* signal (the shorter parent can be the legitimate owner). Map-only leftovers
|
||||
* never invent ownership (validation only requires
|
||||
* providerId.startsWith(pluginId + ".")).
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLegacySecrets() {
|
||||
const legacyPrefix = "sync-provider-map:";
|
||||
const escaped = legacyPrefix.replace(/[%_\\]/g, (ch) => `\\${ch}`);
|
||||
const legacyRows = this.db.prepare(`
|
||||
SELECT plugin_id, key
|
||||
FROM plugin_secrets
|
||||
WHERE key LIKE ? ESCAPE '\\'
|
||||
`).all(`${escaped}%`);
|
||||
/** @type {Map<string, Array<{ pluginId: string, key: string }>>} */
|
||||
const byProvider = new Map();
|
||||
for (const row of legacyRows) {
|
||||
const pluginId = row.plugin_id;
|
||||
const key = row.key;
|
||||
if (typeof pluginId !== "string" || typeof key !== "string") continue;
|
||||
if (!key.startsWith(legacyPrefix)) continue;
|
||||
const providerId = key.slice(legacyPrefix.length);
|
||||
if (!providerId || providerId.includes("\0")) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
continue;
|
||||
}
|
||||
const list = byProvider.get(providerId) || [];
|
||||
list.push({ pluginId, key });
|
||||
byProvider.set(providerId, list);
|
||||
}
|
||||
// Credential presence is the signal for real ownership. A longer nested map
|
||||
// row without credentials must not steal bind from a parent that still owns
|
||||
// sync-credential* secrets for the provider namespace.
|
||||
const credentialOwners = new Set(
|
||||
this.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
// Same single-provider guard as live backfill: one shared sync-credential*
|
||||
// row must not seed bindings for every historical map provider under that
|
||||
// plugin (disconnecting a stale provider would wipe the live credentials).
|
||||
// Count both legacy map rows and installed-manifest sync providers so a
|
||||
// stale map cannot promote when the live manifest also declares another
|
||||
// provider under the same credential owner (Codex P2 on 8ae60205).
|
||||
/** @type {Map<string, Set<string>>} */
|
||||
const providersByCredentialOwner = new Map();
|
||||
for (const [providerId, candidates] of byProvider) {
|
||||
for (const c of candidates) {
|
||||
if (!credentialOwners.has(c.pluginId)) continue;
|
||||
const set = providersByCredentialOwner.get(c.pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByCredentialOwner.set(c.pluginId, set);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const versions = typeof this.listInstalledVersions === "function"
|
||||
? this.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !credentialOwners.has(pluginId)) continue;
|
||||
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const set = providersByCredentialOwner.get(pluginId) || new Set();
|
||||
set.add(provider.id);
|
||||
providersByCredentialOwner.set(pluginId, set);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore catalog probe failures */
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [providerId, candidates] of byProvider) {
|
||||
// Any host row means a prior decision: active bind or explicit unbind
|
||||
// tombstone (empty plugin_id). Never resurrect from leftover map secrets.
|
||||
if (this.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
// Only credential-backed map candidates may win; map-only leftovers stay
|
||||
// unbound rather than inventing ownership from namespace length alone.
|
||||
// Multiple distinct credential-backed candidates is ambiguous (parent vs
|
||||
// nested can both hold credentials for the same provider namespace) — skip
|
||||
// entirely rather than picking longest pluginId.
|
||||
const uniqueCredentialOwners = [
|
||||
...new Set(
|
||||
candidates
|
||||
.filter((c) => credentialOwners.has(c.pluginId))
|
||||
.map((c) => c.pluginId),
|
||||
),
|
||||
];
|
||||
if (uniqueCredentialOwners.length !== 1) {
|
||||
continue;
|
||||
}
|
||||
const chosenOwner = uniqueCredentialOwners[0];
|
||||
if ((providersByCredentialOwner.get(chosenOwner)?.size ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
// Constructor-time map promote runs before hostService's installed-manifest
|
||||
// conflict check. Skip when another installed package would also claim this
|
||||
// provider (live nested plugin without credentials yet) so we do not bind
|
||||
// a legacy parent that later disconnect cannot clear (Codex P2 on 5bc1b60d).
|
||||
let hasManifestConflict = false;
|
||||
try {
|
||||
const versions = typeof this.listInstalledVersions === "function"
|
||||
? this.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || pluginId === chosenOwner) continue;
|
||||
for (const provider of version.manifest?.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (provider.id === providerId) {
|
||||
hasManifestConflict = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (hasManifestConflict) break;
|
||||
}
|
||||
} catch {
|
||||
/* ignore catalog probe failures */
|
||||
}
|
||||
if (hasManifestConflict) {
|
||||
continue;
|
||||
}
|
||||
this.upsertSyncProviderBinding(providerId, chosenOwner);
|
||||
// Consume legacy map markers for this provider so a later unbind + reopen
|
||||
// cannot re-promote from leftover secrets. Only delete namespaced map keys
|
||||
// we accepted as candidates (not arbitrary plugin secrets). Consume all
|
||||
// candidate maps (including map-only losers) once a credential-backed
|
||||
// owner is bound, so orphans cannot re-run later.
|
||||
for (const candidate of candidates) {
|
||||
this.deleteSecret(candidate.pluginId, candidate.key);
|
||||
}
|
||||
promoted += 1;
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credential-row prefix inference is intentionally not used.
|
||||
*
|
||||
* A shorter parent plugin id (e.g. com.example with sync-credential rows) can
|
||||
* namespace-prefix a provider owned by a different/removed plugin
|
||||
* (com.example.backup.sync). Falling back to that parent would let
|
||||
* syncDeleteSecrets wipe unrelated credentials. Ownership must come from the
|
||||
* host binding table (including rows promoted by
|
||||
* backfillSyncProviderBindingsFromLegacySecrets) or a live contribution.
|
||||
*/
|
||||
inferPluginIdForSyncProvider(_providerId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
recordSecurityAudit(pluginId, event, details) {
|
||||
let detailsJson = JSON.stringify(details ?? {});
|
||||
const originalBytes = Buffer.byteLength(detailsJson);
|
||||
|
||||
@@ -474,3 +474,362 @@ test("activating a new version resets runtime quarantine without forgiving the s
|
||||
assert.equal(database.getActivePlugin(first.id).runtime.quarantinedAt, 1_000);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("schema upgrade backfills bindings from legacy sync-provider-map secrets", (context) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-v2-backfill-"));
|
||||
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const file = path.join(root, "plugins.sqlite");
|
||||
// Bootstrap a schema-2 DB then open with PluginDatabase to migrate to v3.
|
||||
const seed = new PluginDatabase(file);
|
||||
// Force downgrade-like state: empty bindings + a legacy map secret row.
|
||||
seed.db.exec("DELETE FROM plugin_sync_provider_bindings");
|
||||
const now = Date.now();
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-legacy-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("sealed-cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
seed.close();
|
||||
|
||||
const database = new PluginDatabase(file);
|
||||
// Constructor no longer promotes; hostService seeds after package recovery.
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "com.example");
|
||||
// Consumed map marker so later unbind + reopen cannot re-promote.
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promoted map markers must be deleted",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not overwrite an existing host binding", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Correct owner already bound.
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "com.example.sync");
|
||||
// Stale parent leftover map secret that would steal ownership if upserted.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-stale-parent-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "must not promote over an existing binding");
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"existing binding must be preserved",
|
||||
);
|
||||
// Skipped promote leaves non-winning map rows in place (only winners are consumed).
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"non-promoted leftover map rows stay until unbind consumes them",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when no candidate holds sync credentials", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Map-only leftovers: longest namespace must not invent ownership without
|
||||
// sync-credential* evidence (parent may be the true owner later).
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("sealed"), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "map-only candidates must not bind without credentials");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Unbound maps stay so a later credential-backed promote or live put can resolve.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill binds sole credential-backed owner when maps conflict", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Parent has leftover map only; nested owner still holds credentials.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "real-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example.sync",
|
||||
"sole credential-backed owner must win over map-only parent",
|
||||
);
|
||||
// All candidate map markers for the promoted provider are consumed.
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not let longest map override credential-backed parent", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// True owner is the shorter parent (still has credentials). Nested plugin has
|
||||
// only a stale map row - longest map alone must not steal the binding.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 1);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"com.example",
|
||||
"credential-backed parent must win over longer map-only nested id",
|
||||
);
|
||||
assert.equal(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.equal(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"), null);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill skips when multiple credential-backed candidates exist", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Both parent and nested hold credentials + map for the same provider.
|
||||
// Longest pluginId is not a reliable owner signal — the shorter parent may
|
||||
// be the legitimate owner — so leave unbound rather than guessing.
|
||||
for (const [pluginId, key, ciphertext] of [
|
||||
["com.example", "sync-provider-map:com.example.sync.foo", "map-parent"],
|
||||
["com.example", "sync-credential", "parent-secret"],
|
||||
["com.example.sync", "sync-provider-map:com.example.sync.foo", "map-nested"],
|
||||
["com.example.sync", "sync-credential", "nested-secret"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}-${key}`, Buffer.from(ciphertext), now, now);
|
||||
}
|
||||
const promoted = database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
assert.equal(promoted, 0, "ambiguous credential-backed candidates must not auto-bind");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync.foo"), null);
|
||||
// Maps stay so a live put / explicit bind can resolve ownership later.
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-provider-map:com.example.sync.foo"));
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
assert.ok(database.getSecretByKey("com.example.sync", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill still consumes maps after unbind tombstone blocks promote", (context) => {
|
||||
// Verify prior fix: explicit unbind tombstone blocks re-promote; maps may remain
|
||||
// until unbind/delete paths consume them (backfill itself skips and does not
|
||||
// delete when promote is blocked).
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.upsertSyncProviderBinding("com.example.sync.foo", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync.foo",
|
||||
"ref-tombstone-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred",
|
||||
Buffer.from("secret"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync.foo")?.pluginId,
|
||||
"",
|
||||
"unbind tombstone must block credential-backed map promote",
|
||||
);
|
||||
// Map row stays; disconnect/unbind consumption is responsible for cleanup.
|
||||
assert.ok(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync.foo"),
|
||||
"skipped promote leaves map markers for unbind to consume",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill deletes promoted map markers so unbind cannot resurrect", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.custom",
|
||||
"ref-plugin-owned",
|
||||
Buffer.from("plugin-owned-payload"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
// Credential evidence required to promote; non-map secrets must survive map consume.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-credential",
|
||||
"ref-cred-keep",
|
||||
Buffer.from("cred"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.custom")?.pluginId, "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.custom"),
|
||||
null,
|
||||
"promoted map markers must be deleted to stop post-unbind resurrection",
|
||||
);
|
||||
assert.ok(database.getSecretByKey("com.example", "sync-credential"));
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("legacy map backfill does not resurrect after explicit unbind tombstone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Pre-seed a leftover map after an explicit empty-plugin_id unbind tombstone.
|
||||
database.upsertSyncProviderBinding("com.example.sync", "");
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
"sync-provider-map:com.example.sync",
|
||||
"ref-leftover-map",
|
||||
Buffer.from("sealed"),
|
||||
now,
|
||||
now,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(
|
||||
database.getSyncProviderBinding("com.example.sync")?.pluginId,
|
||||
"",
|
||||
"empty-plugin_id unbind tombstone must block re-promotion",
|
||||
);
|
||||
database.close();
|
||||
});
|
||||
|
||||
|
||||
test("listInstalledVersions includes inactive package manifests", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
// Install two versions under one plugin id via raw rows if helpers are heavy.
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugins(id, enabled, active_version, installed_at, updated_at)
|
||||
VALUES (?, 1, ?, ?, ?)
|
||||
`).run("com.example", "2.0.0", now, now);
|
||||
for (const [version, providerId] of [
|
||||
["1.0.0", "com.example.legacy-sync"],
|
||||
["2.0.0", "com.example.sync"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_versions(
|
||||
plugin_id, version, manifest_json, archive_sha256, package_relative_path, installed_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
"com.example",
|
||||
version,
|
||||
JSON.stringify({
|
||||
id: "com.example",
|
||||
version,
|
||||
contributes: { providers: [{ id: providerId, kind: "sync", label: providerId }] },
|
||||
}),
|
||||
"a".repeat(64),
|
||||
`com.example/${version}/package`,
|
||||
now,
|
||||
);
|
||||
}
|
||||
const versions = database.listInstalledVersions();
|
||||
assert.equal(versions.length, 2);
|
||||
const providerIds = new Set();
|
||||
for (const v of versions) {
|
||||
for (const p of v.manifest?.contributes?.providers ?? []) {
|
||||
if (p.kind === "sync") providerIds.add(p.id);
|
||||
}
|
||||
}
|
||||
assert.ok(providerIds.has("com.example.legacy-sync"));
|
||||
assert.ok(providerIds.has("com.example.sync"));
|
||||
// Active list only has 2.0.0
|
||||
assert.equal(database.listPlugins()[0]?.activeVersion, "2.0.0");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("inferPluginIdForSyncProvider never guesses from credential key prefixes", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const now = Date.now();
|
||||
for (const [pluginId, key] of [
|
||||
["com.example", "sync-credential"],
|
||||
["com.example.backup", "sync-credential"],
|
||||
["com.other", "sync-credential"],
|
||||
["com.example.sync", "sync-credential"],
|
||||
]) {
|
||||
database.db.prepare(`
|
||||
INSERT INTO plugin_secrets(plugin_id, key, secret_ref, ciphertext, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(pluginId, key, `ref-${pluginId}`, Buffer.from("x"), now, now);
|
||||
}
|
||||
// Parent prefix alone is not enough: a removed plugin may have owned
|
||||
// com.example.backup.sync while only com.example still has credentials.
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.backup.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.example.sync"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.other.cloud"), undefined);
|
||||
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
@@ -46,6 +46,37 @@ function getElectronProcessMetrics(app, pid) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Full installed-manifest catalog of sync providers (active + inactive versions).
|
||||
* Shared by startup and enable-time backfill so ambiguity checks always see
|
||||
* cross-plugin / nested-namespace claims.
|
||||
* @param {{ listInstalledVersions?: () => Array<{ pluginId?: string, manifest?: { contributes?: { providers?: Array<{ kind?: string, id?: string }> } } }> }} database
|
||||
* @returns {Array<{ pluginId: string, provider: { id: string } }>}
|
||||
*/
|
||||
function collectInstalledSyncProviderCatalog(database) {
|
||||
const installedSyncProviders = [];
|
||||
const seen = new Set();
|
||||
const versions = typeof database.listInstalledVersions === "function"
|
||||
? database.listInstalledVersions()
|
||||
: [];
|
||||
for (const version of versions) {
|
||||
const pluginId = version?.pluginId;
|
||||
if (typeof pluginId !== "string" || !version.manifest) continue;
|
||||
for (const provider of version.manifest.contributes?.providers ?? []) {
|
||||
if (provider?.kind !== "sync") continue;
|
||||
if (typeof provider.id !== "string" || provider.id.length < 1) continue;
|
||||
const key = `${pluginId}\0${provider.id}`;
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
installedSyncProviders.push({
|
||||
pluginId,
|
||||
provider: { id: provider.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
return installedSyncProviders;
|
||||
}
|
||||
|
||||
function createPluginHostService(options) {
|
||||
const paths = createPluginPaths(options.app.getPath("userData"));
|
||||
const appRoot = options.appRoot ?? options.app.getAppPath();
|
||||
@@ -227,7 +258,47 @@ function createPluginHostService(options) {
|
||||
} catch {
|
||||
// Hydration is best-effort; enable must still succeed.
|
||||
}
|
||||
return originalOnPluginEnabled(pluginId);
|
||||
const enabled = await originalOnPluginEnabled(pluginId);
|
||||
try {
|
||||
// Use the same installed-manifest catalog as startup so cross-plugin /
|
||||
// inactive-version ambiguity checks still apply when only this plugin
|
||||
// was just enabled (Codex P2 on d3a9b94c).
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; enable must still succeed.
|
||||
}
|
||||
return enabled;
|
||||
};
|
||||
// Live-provider seed must wait until PackageStore.recover() has restored
|
||||
// missing plugin_versions. Construction-time seed can bind a stale owner
|
||||
// against an incomplete catalog; post-recovery seed then skips it as
|
||||
// existing (Codex P2 on f2b1b5d8). syncDeleteSecrets already awaits
|
||||
// resolveManager() → package initialize, so cleanup sees the post-recovery
|
||||
// seed without racing an early incomplete bind.
|
||||
const seedSyncBindings = () => {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders !== "function") return;
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
};
|
||||
// PackageStore.recover() repopulates plugin_versions; only then promote
|
||||
// legacy maps and seed live bindings (Codex P2 on dd7aad70 / f2b1b5d8).
|
||||
const originalPackageInitialize = packageStore.initialize.bind(packageStore);
|
||||
packageStore.initialize = async () => {
|
||||
const result = await originalPackageInitialize();
|
||||
try {
|
||||
if (typeof database.backfillSyncProviderBindingsFromLegacySecrets === "function") {
|
||||
database.backfillSyncProviderBindingsFromLegacySecrets();
|
||||
}
|
||||
seedSyncBindings();
|
||||
} catch {
|
||||
// Best-effort
|
||||
}
|
||||
return result;
|
||||
};
|
||||
// Startup activation goes through contributionService.initialize() → #startPlugin
|
||||
// without onPluginEnabled. Hydrate every enabled plugin before that path runs.
|
||||
@@ -241,6 +312,16 @@ function createPluginHostService(options) {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
}
|
||||
// Re-seed after any package catalog changes during init.
|
||||
try {
|
||||
if (typeof secretStore.backfillSyncProviderBindingsFromLiveProviders === "function") {
|
||||
secretStore.backfillSyncProviderBindingsFromLiveProviders(
|
||||
collectInstalledSyncProviderCatalog(database),
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort; startup must continue.
|
||||
}
|
||||
return originalInitialize();
|
||||
};
|
||||
const terminalDataPipelineService = options.electron.MessageChannelMain
|
||||
|
||||
@@ -1082,6 +1082,13 @@ function registerPluginBridge(ipcMain, options) {
|
||||
throw new Error("Plugin sync secret storage is unavailable");
|
||||
}
|
||||
if (!extensionProviderService) throw new Error("Plugin sync Providers are unavailable");
|
||||
// Wait for package recovery + binding seed before resolving ownership so a
|
||||
// schema-2 disconnect during startup cannot miss retained bindings (Codex P2).
|
||||
try {
|
||||
await resolveManager();
|
||||
} catch {
|
||||
// Runtime disabled: continue with whatever secretStore already has.
|
||||
}
|
||||
const providerId = payload?.providerId;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) {
|
||||
throw new TypeError("Sync provider ID is invalid");
|
||||
|
||||
@@ -178,16 +178,16 @@ class PluginSecretStore {
|
||||
* Durable providerId → pluginId binding so disconnect can wipe sync secrets
|
||||
* after the contribution disappears (disabled/uninstalled plugin).
|
||||
* Stored in a host-owned table, not plugin-writable secrets.
|
||||
* Overwrites any prior empty-plugin_id unbind tombstone.
|
||||
*/
|
||||
bindSyncProviderPlugin(pluginId, providerId) {
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) {
|
||||
throw new TypeError("Plugin id is invalid");
|
||||
}
|
||||
assertSecretKey(providerId);
|
||||
if (
|
||||
providerId !== pluginId
|
||||
&& !providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
// Contribution provider ids live under the owning plugin namespace
|
||||
// (`pluginId.`…); they are never equal to the plugin id itself.
|
||||
if (!providerId.startsWith(`${pluginId}.`)) {
|
||||
throw new TypeError("Sync provider id is outside the plugin namespace");
|
||||
}
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") {
|
||||
@@ -198,22 +198,135 @@ class PluginSecretStore {
|
||||
|
||||
resolveSyncProviderPlugin(providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.getSyncProviderBinding !== "function") return undefined;
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) return undefined;
|
||||
if (providerId !== pluginId && !providerId.startsWith(`${pluginId}.`)) {
|
||||
return undefined;
|
||||
if (typeof this.database.getSyncProviderBinding === "function") {
|
||||
const row = this.database.getSyncProviderBinding(providerId);
|
||||
const pluginId = row?.pluginId;
|
||||
if (
|
||||
typeof pluginId === "string"
|
||||
&& pluginId.length > 0
|
||||
&& providerId.startsWith(`${pluginId}.`)
|
||||
) {
|
||||
return pluginId;
|
||||
}
|
||||
}
|
||||
return pluginId;
|
||||
// Do not infer ownership from sync-credential* key prefixes: a parent
|
||||
// plugin id may namespace-prefix another plugin's provider and would cause
|
||||
// disconnect to delete the wrong secrets. Pre-binding installs recover via
|
||||
// (1) backfillSyncProviderBindingsFromLegacySecrets for intermediate map
|
||||
// rows, (2) backfillSyncProviderBindingsFromLiveProviders using host
|
||||
// contribution metadata at startup, or (3) the next successful put.
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Seed missing provider→plugin bindings from contribution metadata.
|
||||
*
|
||||
* Real schema-2 installs only stored plugin-scoped `sync-credential*` rows
|
||||
* (no per-provider ownership). After upgrade, seed a binding only when a
|
||||
* plugin with credentials has exactly one sync provider id across the
|
||||
* catalog: binding every historical/active provider would let disconnecting
|
||||
* a stale provider wipe the shared credential prefix used by the live one.
|
||||
* Multi-provider plugins wait for the next successful put (or map backfill).
|
||||
*
|
||||
* @param {Array<{ pluginId?: string, id?: string, provider?: { id?: string } }>} providers
|
||||
* @returns {number} number of newly written bindings
|
||||
*/
|
||||
backfillSyncProviderBindingsFromLiveProviders(providers) {
|
||||
if (!Array.isArray(providers)) return 0;
|
||||
if (typeof this.database.listPluginIdsWithSecretKeyPrefix !== "function") return 0;
|
||||
const credentialOwners = new Set(
|
||||
this.database.listPluginIdsWithSecretKeyPrefix("sync-credential")
|
||||
.filter((id) => typeof id === "string" && id.length > 0),
|
||||
);
|
||||
if (credentialOwners.size === 0) return 0;
|
||||
/** @type {Map<string, Set<string>>} pluginId -> provider ids it declares */
|
||||
const providersByPlugin = new Map();
|
||||
/** @type {Map<string, Set<string>>} providerId -> plugins claiming it */
|
||||
const pluginsByProvider = new Map();
|
||||
for (const entry of providers) {
|
||||
const pluginId = entry?.pluginId;
|
||||
const providerId = entry?.provider?.id ?? entry?.id;
|
||||
if (typeof pluginId !== "string" || pluginId.length < 1) continue;
|
||||
if (typeof providerId !== "string" || providerId.length < 1) continue;
|
||||
if (!providerId.startsWith(`${pluginId}.`)) continue;
|
||||
// Count every manifest claim for conflict detection — including live
|
||||
// plugins that have not stored credentials yet. Binding only a
|
||||
// credential-backed legacy parent would orphan credentials when a later
|
||||
// disconnect prefers the live nested owner (Codex P2 on 8d64ea20).
|
||||
const claimants = pluginsByProvider.get(providerId) || new Set();
|
||||
claimants.add(pluginId);
|
||||
pluginsByProvider.set(providerId, claimants);
|
||||
// Only credential-backed plugins are eligible to become the binding owner.
|
||||
if (!credentialOwners.has(pluginId)) continue;
|
||||
const set = providersByPlugin.get(pluginId) || new Set();
|
||||
set.add(providerId);
|
||||
providersByPlugin.set(pluginId, set);
|
||||
}
|
||||
let promoted = 0;
|
||||
for (const [pluginId, providerIds] of providersByPlugin) {
|
||||
if (providerIds.size !== 1) continue;
|
||||
const providerId = [...providerIds][0];
|
||||
// Two credential-backed plugins can both declare the same provider via
|
||||
// nested namespaces (legacy disabled parent + live child). Binding the
|
||||
// first writer would make a later missing-provider disconnect delete the
|
||||
// wrong sync-credential* rows. Skip any cross-plugin claim conflict.
|
||||
const claimants = pluginsByProvider.get(providerId);
|
||||
if (!claimants || claimants.size !== 1) continue;
|
||||
// Skip active binds and explicit-unbind tombstones alike.
|
||||
if (typeof this.database.getSyncProviderBinding === "function"
|
||||
&& this.database.getSyncProviderBinding(providerId)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
this.bindSyncProviderPlugin(pluginId, providerId);
|
||||
promoted += 1;
|
||||
} catch {
|
||||
/* invalid contribution ids stay unbound */
|
||||
}
|
||||
}
|
||||
return promoted;
|
||||
}
|
||||
|
||||
unbindSyncProviderPlugin(pluginId, providerId) {
|
||||
assertSecretKey(providerId);
|
||||
if (typeof this.database.deleteSyncProviderBinding !== "function") return;
|
||||
const existing = this.database.getSyncProviderBinding(providerId);
|
||||
if (existing && existing.pluginId !== pluginId) return;
|
||||
this.database.deleteSyncProviderBinding(providerId);
|
||||
if (typeof this.database.upsertSyncProviderBinding !== "function") return;
|
||||
const existing = typeof this.database.getSyncProviderBinding === "function"
|
||||
? this.database.getSyncProviderBinding(providerId)
|
||||
: null;
|
||||
// Do not steal another plugin's active binding. Empty plugin_id is an
|
||||
// unbind tombstone and may be refreshed idempotently.
|
||||
if (
|
||||
existing
|
||||
&& typeof existing.pluginId === "string"
|
||||
&& existing.pluginId.length > 0
|
||||
&& existing.pluginId !== pluginId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Consume leftover intermediate-build map markers so they cannot re-seed
|
||||
// a binding if the tombstone is ever cleared. Delete under this plugin and
|
||||
// any residual map rows for the same provider across plugins.
|
||||
const mapKey = `sync-provider-map:${providerId}`;
|
||||
if (typeof this.database.deleteSecret === "function") {
|
||||
try {
|
||||
this.database.deleteSecret(pluginId, mapKey);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
if (typeof this.database.findSecretsByKey === "function" && typeof this.database.deleteSecret === "function") {
|
||||
for (const row of this.database.findSecretsByKey(mapKey) || []) {
|
||||
if (typeof row?.pluginId !== "string" || typeof row?.key !== "string") continue;
|
||||
try {
|
||||
this.database.deleteSecret(row.pluginId, row.key);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
// Tombstone with empty plugin_id so leftover sync-provider-map:* secret
|
||||
// rows cannot re-promote this provider on the next database open.
|
||||
this.database.upsertSyncProviderBinding(providerId, "");
|
||||
}
|
||||
|
||||
resolve(pluginId, secret) {
|
||||
|
||||
@@ -378,6 +378,45 @@ test("sync provider bindings live outside plugin secrets and reject namespace mi
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Tombstone remains so legacy map backfill cannot resurrect after disconnect.
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
// Unbind also consumes map markers (including attacker leftovers).
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.attacker", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
);
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Reconnect clears the tombstone.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("unbind deletes owner map markers and blocks legacy re-promotion", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Credential evidence is required for map promote; simulate a real owner plus
|
||||
// an intermediate-build map row that backfill would otherwise promote.
|
||||
store.set("com.example", "sync-credential", "secret");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 1);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(
|
||||
database.getSecretByKey("com.example", "sync-provider-map:com.example.sync"),
|
||||
null,
|
||||
"promote consumes the map marker",
|
||||
);
|
||||
// User disconnects; reintroduce a map row as if something wrote it back.
|
||||
// Tombstone still blocks re-promotion even with credentials + map present.
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.set("com.example", "sync-provider-map:com.example.sync", "com.example");
|
||||
assert.equal(database.backfillSyncProviderBindingsFromLegacySecrets(), 0);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
database.close();
|
||||
});
|
||||
|
||||
@@ -443,3 +482,92 @@ test("existing overwrite stash is preserved across retry puts and cleared by pre
|
||||
assert.equal(store.getReference("com.example", "sync-credential"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("resolveSyncProviderPlugin does not infer ownership from credential prefixes alone", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Without a binding (or live-provider backfill), credentials alone are not enough.
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
// Explicit bind still works for disconnect cleanup.
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill seeds bindings for v2 credential-only upgrades", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
// Real schema-2 path: credentials exist, binding table empty, no map rows.
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.set("com.disabled", "sync-credential", "pw2");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync"), null);
|
||||
// Host should pass installed manifests including disabled plugins.
|
||||
const promoted = store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
{ pluginId: "com.disabled", provider: { id: "com.disabled.sync" } },
|
||||
// No credentials under this plugin — skip.
|
||||
{ pluginId: "com.other", provider: { id: "com.other.cloud" } },
|
||||
// Wrong namespace alone would be multi with sync below — still skip evil.
|
||||
]);
|
||||
assert.equal(promoted, 2);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), "com.example");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.disabled.sync"), "com.disabled");
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.other.cloud"), undefined);
|
||||
// Idempotent when binding already exists.
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
// Multi-provider plugins must not all bind from one shared credential row.
|
||||
store.set("com.multi", "sync-credential", "shared");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.old" } },
|
||||
{ pluginId: "com.multi", provider: { id: "com.multi.new" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.old"), undefined);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.multi.new"), undefined);
|
||||
// Cross-plugin claim on the same providerId must not bind the first writer.
|
||||
store.set("com.example.sync", "sync-credential", "nested");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync.foo" } },
|
||||
{ pluginId: "com.example.sync", provider: { id: "com.example.sync.foo" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync.foo"), undefined);
|
||||
database.close();
|
||||
});
|
||||
|
||||
test("live provider backfill does not resurrect explicit unbind tombstones", (context) => {
|
||||
const database = createDatabase(context);
|
||||
const store = new PluginSecretStore({
|
||||
database,
|
||||
safeStorage: fakeSafeStorage(),
|
||||
});
|
||||
store.set("com.example", "sync-credential", "pw");
|
||||
store.bindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
store.unbindSyncProviderPlugin("com.example", "com.example.sync");
|
||||
assert.equal(database.getSyncProviderBinding("com.example.sync")?.pluginId, "");
|
||||
assert.equal(
|
||||
store.backfillSyncProviderBindingsFromLiveProviders([
|
||||
{ pluginId: "com.example", provider: { id: "com.example.sync" } },
|
||||
]),
|
||||
0,
|
||||
);
|
||||
assert.equal(store.resolveSyncProviderPlugin("com.example.sync"), undefined);
|
||||
database.close();
|
||||
});
|
||||
@@ -241,64 +241,111 @@ 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) {
|
||||
const completeCleanRun =
|
||||
// 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 =
|
||||
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 &&
|
||||
missingBaselineFailures.length === 0;
|
||||
return {
|
||||
passed: completeCleanRun,
|
||||
kind: completeCleanRun ? 'clean' : 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: [],
|
||||
newFailures: [],
|
||||
};
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
if (baseline.exitCode === 0) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'new_failures',
|
||||
baselineFailures: [],
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: candidate.failures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (!baseline.complete || !candidate.complete) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: candidate.failures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (missingBaselineSuccesses.length) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
kind: 'missing_baseline_successes',
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: missingBaselineSuccesses,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (candidate.exitCode !== baseline.exitCode) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: candidate.failures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const baselineNonTapCounts = countFailures(baseline.nonTapOutput);
|
||||
@@ -307,23 +354,21 @@ function compareTapResults(baseline, candidate) {
|
||||
([line, count]) => count > (baselineNonTapCounts.get(line) || 0),
|
||||
);
|
||||
if (addedNonTapOutput) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: candidate.failures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (candidate.failCount === 0 || candidate.testCount < baseline.testCount) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: candidate.failures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -331,13 +376,12 @@ function compareTapResults(baseline, candidate) {
|
||||
candidate.skippedCount > baseline.skippedCount ||
|
||||
candidate.todoCount > baseline.todoCount
|
||||
) {
|
||||
return {
|
||||
return result({
|
||||
passed: false,
|
||||
kind: 'cancelled_tests',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: candidate.failures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const baselineCounts = countFailures(
|
||||
@@ -364,17 +408,16 @@ function compareTapResults(baseline, candidate) {
|
||||
const reportedFailures = newFailures.length
|
||||
? newFailures
|
||||
: missingBaselineFailures;
|
||||
return {
|
||||
return result({
|
||||
passed,
|
||||
kind: passed
|
||||
? 'baseline_only'
|
||||
: newFailures.length
|
||||
? 'new_failures'
|
||||
: 'unclassified_failure',
|
||||
baselineFailures: baseline.failures,
|
||||
candidateFailures: candidate.failures,
|
||||
newFailures: reportedFailures,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
@@ -418,6 +461,11 @@ 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;
|
||||
}
|
||||
|
||||
|
||||
@@ -36,13 +36,14 @@ test('rejects a zero-exit candidate without a complete clean TAP summary', () =>
|
||||
assert.equal(result.kind, 'unclassified_failure');
|
||||
});
|
||||
|
||||
test('rejects replacing an existing successful test with an unrelated one', () => {
|
||||
test('accepts renaming a successful test when quantitative coverage does not shrink', () => {
|
||||
const result = compareTapResults(
|
||||
parseTapResult(tap({ successes: ['kept test', 'removed test'] }), 0),
|
||||
parseTapResult(tap({ successes: ['kept test', 'unrelated replacement'] }), 0),
|
||||
);
|
||||
assert.equal(result.passed, false);
|
||||
assert.equal(result.kind, 'unclassified_failure');
|
||||
assert.equal(result.passed, true);
|
||||
assert.equal(result.kind, 'clean');
|
||||
assert.deepEqual(result.missingBaselineSuccesses, ['removed test']);
|
||||
|
||||
const issueNumberName = compareTapResults(
|
||||
parseTapResult(tap({
|
||||
@@ -52,16 +53,55 @@ test('rejects replacing an existing successful test with an unrelated one', () =
|
||||
successes: ['accepts packages that ship upstream #9999'],
|
||||
}), 0),
|
||||
);
|
||||
assert.equal(issueNumberName.passed, false);
|
||||
assert.equal(issueNumberName.passed, true);
|
||||
assert.equal(issueNumberName.kind, 'clean');
|
||||
|
||||
const duplicateName = compareTapResults(
|
||||
parseTapResult(tap({ failures: ['same name'], successes: ['same name'] }), 1),
|
||||
parseTapResult(tap({ successes: ['same name', 'unrelated replacement'] }), 0),
|
||||
// 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),
|
||||
);
|
||||
assert.equal(duplicateName.passed, false);
|
||||
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']);
|
||||
});
|
||||
|
||||
test('rejects a clean candidate when the exact-base TAP summary is incomplete', () => {
|
||||
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)', () => {
|
||||
const result = compareTapResults(
|
||||
parseTapResult('base runner stopped early', 1),
|
||||
parseTapResult(tap(), 0),
|
||||
|
||||
@@ -2293,6 +2293,9 @@ 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'),
|
||||
|
||||
Reference in new issue
Block a user