Compare commits

..
Author SHA1 Message Date
bincxz 05ee57a05a fix(plugins): defer live binding seed until after package recovery
Construction-time live-provider backfill can bind a stale owner against an
incomplete catalog before PackageStore.recover() restores plugin_versions.
Seed only after recovery; syncDeleteSecrets already awaits manager init.
2026-08-06 03:48:19 +08:00
bincxz f2b1b5d874 fix(plugins): defer legacy-map backfill until after package recovery
Constructor no longer promotes sole map candidates before recovered
manifests are visible. hostService re-runs backfill after package init.
2026-08-06 03:33:04 +08:00
bincxz cded4c8a21 fix(plugins): re-run legacy-map backfill after package recovery
After PackageStore.recover repopulates versions, re-run legacy-map promote
so recovered manifests participate in ownership conflict checks.
2026-08-06 03:23:26 +08:00
bincxz dd7aad7001 fix(plugins): await manager init before syncDeleteSecrets ownership
Block secret cleanup until package recovery and binding seed finish so
early disconnect cannot miss retained sync-credential rows.
2026-08-06 03:14:57 +08:00
bincxz 9d1e8e25b0 fix(plugins): re-seed sync bindings after package store recovery
PackageStore.recover can add installed versions after the constructor
seed; re-run the catalog backfill when package initialize finishes.
2026-08-06 03:05:00 +08:00
bincxz bb74147a52 fix(plugins): seed sync bindings at host construction
Run installed-manifest backfill when the plugin host is created, before
async manager.initialize, so early syncDeleteSecrets can resolve ownership.
2026-08-06 02:54:57 +08:00
bincxz 0633d19039 fix(plugins): backfill sync bindings before startup activation
Seed installed-manifest bindings before contributionService.initialize so
an early syncDeleteSecrets cannot miss ownership and leave orphan credentials.
2026-08-06 02:46:19 +08:00
bincxz e30d8c2288 fix(plugins): count installed manifest providers in legacy-map guard
A sole stale map row must not promote when the credential owner's live
manifest also declares another sync provider under the same credentials.
2026-08-06 02:20:30 +08:00
bincxz 8ae60205ad fix(plugins): require single-provider maps for legacy-map promotion
A plugin with one sync-credential row and maps for multiple historical
providers must not bind all of them — disconnecting a stale provider
would delete the live credentials via the shared prefix.
2026-08-06 02:09:31 +08:00
bincxz 87157496c5 fix(plugins): skip legacy map promote on installed-manifest conflicts
Constructor-time map backfill must not bind a sole credential-backed
parent when another installed package also declares the same sync
provider, or later disconnect cannot clear the stale binding.
2026-08-06 01:47:13 +08:00
bincxz 5bc1b60d01 fix(plugins): count non-credential manifest claims in backfill conflicts
Live nested plugins without credentials yet must still block binding a
credential-backed legacy parent to the same providerId, or disconnect can
prefer the live owner while the stale binding remains.
2026-08-06 01:37:11 +08:00
bincxz 8d64ea2032 fix(plugins): use full installed catalog for enable-time sync backfill
Enable-time binding must see the same active+inactive manifest catalog as
startup so cross-plugin nested provider ambiguity is not missed when only
the newly enabled plugin's live providers would be passed.
2026-08-06 01:18:00 +08:00
bincxz d3a9b94c55 fix(plugins): skip cross-plugin provider conflicts in live backfill
When multiple credential-backed plugins declare the same providerId via
nested namespaces, do not bind the first writer. A later missing-provider
disconnect would otherwise delete the wrong sync-credential rows.
2026-08-06 00:59:08 +08:00
bincxz 297192bb63 fix(plugins): skip ambiguous multi-credential legacy map backfill
Only auto-bind when exactly one credential-backed map candidate exists.
When parent and nested plugins both hold credentials for the same provider,
longest pluginId is not a reliable owner signal — leave unbound.
2026-08-06 00:18:23 +08:00
bincxz dd6edb3585 fix(plugins): prefer credential-backed owners for legacy map backfill
Longest sync-provider-map namespace alone could bind a nested plugin over a
parent that still owns sync-credential* secrets. Only promote candidates with
credentials; pick the longest among those, and skip when none or ambiguous.
2026-08-06 00:07:19 +08:00
bincxz 4e8f8a94e0 fix(plugins): stop resurrecting sync bindings after unbind
Consume promoted sync-provider-map markers and leave an unbound
tombstone on disconnect so constructor backfill cannot re-bind a
provider the user already disconnected.
2026-08-05 23:57:14 +08:00
netcatty-bot 5bce7f5e1a fix: address Codex review on PR #2752 2026-08-05 15:44:53 +00:00
bincxz 3af4b61f51 fix(plugins): only auto-bind single-provider plugins from credentials
Plugin-scoped sync-credential* rows are shared across providers. Binding
every historical provider would let disconnect of a stale one wipe the
active provider's secrets. Seed bindings only when exactly one sync
provider is known for that plugin.
2026-08-05 22:51:29 +08:00
bincxz e020a7dc9c fix(plugins): seed sync bindings from all installed version manifests
Active-only listPlugins() misses providers removed/renamed on upgrade
while credentials remain under the plugin. Walk every plugin_versions
manifest so disconnect can still resolve ownership after schema upgrade.
2026-08-05 22:33:43 +08:00
bincxz b8ce5bd2ce fix(plugins): seed sync bindings from installed manifests, not only enabled
listProviders skips disabled/quarantined plugins. Walk installed plugin
manifests at host init so schema-2 credential rows under disabled plugins
still get provider bindings for disconnect cleanup.
2026-08-05 22:12:11 +08:00
bincxz 51b283fbe3 fix(plugins): seed sync bindings from live provider metadata
Schema-2 installs only stored sync-credential* rows. After upgrade, seed
missing provider bindings from the host contribution catalog when those
credentials exist, instead of guessing from secret key prefixes.
2026-08-05 22:04:39 +08:00
bincxz a95251ff5c fix(plugins): never delete secrets during sync-provider-map backfill
The map key prefix is not reserved for host internals; plugins may store
ordinary secrets under the same shape. Only seed missing host bindings
from candidate rows and leave plugin_secrets untouched.
2026-08-05 21:56:12 +08:00
bincxz a51b71d3dd fix(plugins): pick longest owner when legacy map rows conflict
Group sync-provider-map rows by provider before binding so a shorter
parent plugin cannot win just because SQLite returned it first. Still
preserve existing host bindings and delete all leftover map secrets.
2026-08-05 21:28:54 +08:00
bincxz 4d8a10a2e4 fix(plugins): do not overwrite existing sync provider bindings on backfill
Legacy sync-provider-map rows from a parent plugin must not steal a
provider that already has a host binding. Skip upsert when a binding
exists; still delete the stale map secret.
2026-08-05 21:21:17 +08:00
bincxz dfd24d11d7 fix(plugins): stop inferring sync owner from credential prefixes
Parent plugin ids can namespace-prefix another plugin's provider id.
Guessing ownership from sync-credential rows would let disconnect wipe
the wrong secrets. Keep host bindings and legacy map backfill as the
only ownership sources when no live contribution is present.
2026-08-05 20:54:29 +08:00
Cursor Agentand陈大猫 dc825cc243 fix(plugins): infer sync provider owners only via pluginId. namespace
Exact pluginId===providerId is not ownership for contribution provider
ids; prefer the longest pluginId. prefix when backfilling disconnect cleanup.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:40:13 +00:00
Cursor Agentand陈大猫 ec5080f6a8 fix(plugins): backfill sync provider bindings after schema upgrade
Promote legacy sync-provider-map secrets into the host binding table on
open, and infer plugin ownership from sync-credential rows so disconnect
still cleans credentials when bindings were never written.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 12:33:56 +00:00
2ea5efacba fix(plugins): address residual Codex sync secret and backup gaps (#2748)
* fix(plugins): address residual Codex sync secret and backup gaps

Retain provider→plugin secret bindings so disconnect can wipe credentials
after a plugin is disabled, keep overwritten secrets on failed reconnect,
and snapshot live-empty sidecars in protective backups.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): restore overwritten sync secrets on rejected reconnect

Stash prior plaintext and SecretRef id on overwrite, restore them when
connect fails, and discard the stash after a successful reconnect so
saved provider credentials keep resolving.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): make sync secret overwrite stash opt-in

Only the syncPutSecret path stashes prior plaintext/SecretRef so ordinary
secrets.set overwrites do not leave credentials in process memory.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): host-own sync provider bindings and clear failed stashes

Move provider→plugin bindings into a dedicated DB table outside
plugin-writable secrets, validate provider namespace ownership, and drop
overwrite stashes when replacement writes fail.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): keep overwrite stash until restore write succeeds

Only drop the prior plaintext after encrypt+upsert succeeds so a failed
restore can still retry and recover the saved SecretRef.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(plugins): preserve overwrite stash across retries and prefix deletes

Do not replace an existing restore stash on later puts, and clear matching
stash entries when deleteByKeyPrefix wipes sync credentials on disconnect.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 19:44:35 +08:00
9c5da74486 fix(terminal): forward Cmd+C to Kitty when there is no xterm selection (#2749)
macOS copy defaults to Cmd+C. With no terminal selection, the shortcut
handler used to consume the chord as an empty clipboard write, so nested
TUIs that expect Kitty Super+C (e.g. Herdr) never received the key.

Pass plain Meta+C through the same no-selection path as Ctrl+C, while
keeping other unbound copy chords as a safe no-op (#1461).

Closes #2728

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 19:18:57 +08:00
7bdbbe2e16 fix(sftp): pipelined sudo download READ fanout on shared channel (#2720)
* fix(#2719): automated Cursor CLI fix

* fix: address Codex review on PR #2720

* fix(sftp): settle shared OPEN cancel without ending sudo session

Codex P2 on PR #2720: disposeChannel:false made pre-OPEN abort a no-op
while openSftpHandle had no cancel reject, so cancel during a stalled
OPEN hung forever and held the SFTP lease. openSftpHandleForTransfer
rejects the pending OPEN on cancel without sftp.end() on shared channels,
closes late handles best-effort, and keeps isolated-channel end behavior.

* fix(sftp): force-settle cancelled shared download READs

Codex P2 on d855e69c: shared/sudo downloads passed forceSettleOnError
false, so cancel while an sftp.read callback never arrived hung forever
(abortChannel cannot end the shared session). Downloads have no remote
WRITEs to drain, so always force-settle on cancel/error without calling
sftp.end(). Shared uploads still wait for in-flight WRITEs.

* fix(sftp): harden sudo shared download settle and fanout gates

Address Codex P2 (channel error during stalled OPEN never settled) and
multi-agent review: keep concurrent-shared READ sudo-only with a
per-session fast slot, settle OPEN sync throws without leaking listeners,
force-settle cancel without awaiting shared CLOSE, and report stream
strategy after fallback.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): dispose isolated channel on CLOSE timeout

Codex P2: success-path CLOSE timeout with disposeChannel:true was
non-fatal and returned the channel to the pool. Shared/sudo stays
non-fatal; isolated downloads now surface completedWithUnhealthyChannel
so the channel is ended instead of reused.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): drain shared write OPEN before cancel cleanup

Codex P2: cancelling a pending shared upload OPEN settled immediately,
so stage cleanup could run before a late truncating OPEN recreated the
remote path. Shared write opens now wait for the OPEN callback (or a
drain timeout), close the handle, then settle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): keep shared write OPEN drain past cancel timeout

Codex P2: the 2s cancel settle unblocked UX but cleanup could still race
a later truncating OPEN. Publish sharedWriteOpenDrain until the OPEN
callback finishes (including late opens), and await it in upload finally
before returning to stage cleanup.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): await shared write OPEN drain without timeout

Codex P2: the 10s drain cap still let cleanup race a slower late OPEN.
Await sharedWriteOpenDrain unbounded so stage cleanup cannot run until
the write OPEN callback has closed its handle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): keep shared write OPEN drain pending on channel error

Codex P2: channel error completed sharedWriteOpenDrain before the OPEN
callback, allowing cleanup/fallback to race a late truncating OPEN.
Match the cancel path — settle the wait, but release the drain only after
the OPEN callback closes any late handle.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): force-settle stalled shared verification READs on cancel

After concurrent download ranges finish, sample verification still awaited
bare sftp.read outside forceSettleOnError. Shared/sudo cancel cannot end
the session, so a hung verification callback held the transfer and lease.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): recheck cancel after verification sample race

A responsive last-sample READ can finish after cancel before the 2s
force-settle fires; without a post-race cancelled check the download
was reported complete.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): retain verification channel errors across sample gaps

A shared-channel error between verification sample races was dropped
when rejectPending was null, so the next read could hang forever.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(sftp): bound shared write OPEN drain after channel error

Channel error kept sharedWriteOpenDrain pending for a late OPEN, but a
dead channel that never invokes the callback hung the transfer and lease.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:05:32 +08:00
4747c45a99 feat(ai): add in-chat jump list for long sessions (#2733)
* fix(#2679): automated Cursor CLI fix

* fix: address Codex review on PR #2733

* fix: address Codex review on PR #2733

* fix(ai): keep chat jump pin from collapsing loaded history

Preserve the rendered message window across jump release, ignore streaming isAtBottom flips, and make load-earlier advance from the effective pinned tail.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-05 19:02:09 +08:00
e40aace189 fix(ci): stop green Codex fixes failing baseline name matching (#2747)
* fix(ci): stop green Codex fixes failing baseline name matching

Accept exit-0 candidates when quantitative coverage does not shrink,
even if individual TAP success titles were renamed. Keep rejecting
true coverage loss, upload baseline test logs, and surface comparison
details on rejected verification runs.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): require baseline failures to be fixed by name

Address Codex P1: a red→green candidate must keep each failing test
title as a success, so deleting the failing test and adding an unrelated
passer cannot pass the quantitative clean gate.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): keep comparator comments ASCII-only

Replace the non-ASCII arrow in the red-to-green comment so the file
stays consistent with the repo ASCII convention.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* fix(ci): fail closed on incomplete baseline TAP summaries

Require a complete exact-base summary before accepting a quantitative
clean candidate, so a truncated red baseline cannot be bypassed by
deleting crashing coverage.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

* test(ci): clarify incomplete-baseline fail-closed coverage

Keep the comparator fail-closed on truncated exact-base TAP summaries
and re-request Codex review on the current head.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-05 19:00:32 +08:00
25 changed files with 3389 additions and 114 deletions

No files matched your search

+22
View File
@@ -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
+2
View File
@@ -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',
+2
View File
@@ -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': 'Предыдущих сессий нет',
+2
View File
@@ -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': '没有历史会话',
+2
View File
@@ -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': '沒有歷史工作階段',
+8 -3
View File
@@ -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;
+142
View File
@@ -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;
+57
View File
@@ -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",
+63 -5
View File
@@ -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));
}
+71
View File
@@ -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');
});
+72
View File
@@ -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);
}
+439 -38
View File
@@ -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
+193
View File
@@ -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);
+359
View File
@@ -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();
});
+82 -1
View File
@@ -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
+7
View File
@@ -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");
+128 -15
View File
@@ -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) {
+128
View File
@@ -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();
});
+84 -36
View File
@@ -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;
}
+49 -9
View File
@@ -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),
+3
View File
@@ -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'),