Compare commits

...
Author SHA1 Message Date
Cursor Agentand陈大猫 5dd08d92e3 fix(sync): narrow complete-wipe shrink guard to groupConfigs
Only treat empty groupConfigs with other vault data present as suspicious.
Wiping the last snippet/note/etc. while hosts remain stays a normal delete.

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 03:06:26 +00:00
Cursor Agentand陈大猫 42eb659dda fix(sync): rehydrate follow-app theme and guard partial groupConfig wipes
Second devices were keeping the local follow-app terminal theme flag after
cloud sync wrote peer appearance settings, which flickered black vs yellow.
Also block uploading an empty groupConfigs list while other vault data
remains so hydration races cannot clear startup-command group defaults.

Fixes #2757

Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
2026-08-06 02:57:33 +00:00
陈大猫andCursor 8bbead1261 fix(sftp): serialize write OPENs and harden late/supersede cleanup (#2750)
* fix(sftp): keep cancel shared write OPEN drain gated on callback

Cancel settle must not force-complete sharedWriteOpenDrain — only channel
error does — so stage cleanup cannot race a late truncating OPEN after #2720.

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

* fix(sftp): bound cancel OPEN drain and unlink late truncates

Restore cancel drain force-complete so stalled shared OPENs cannot hang
the lease, and best-effort unlink late truncating "w" opens after close so
force-complete cannot leave a recreated stage orphan.

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

* fix(sftp): only unlink generated stages on cancel OPEN

Gate cancel-time truncating OPEN unlink on generatedStagePath so in-place
and symlink destinations keep their final path; cover with a late-OPEN test.

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

* fix(sftp): unlink late staged OPEN after channel-error settle

Late truncating generated-stage OPENs after settle must unlink even when
transfer.cancelled is false, so channel-error force-complete cannot leave
a recreated .netcatty-upload orphan.

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

* fix(sftp): skip late OPEN unlink when same-id retry owns stage

After shared write OPEN drain force-complete, a late truncating "w"
callback still closed and unlinked generated stages. Resumable retries
reuse `.netcatty-<transferId>.part`, so a stale callback could delete
the new attempt's stage. Gate unlink on activeTransfers ownership
(generated stages + truncating only), matching the in-place final
target guard.

* fix(sftp): only skip late OPEN unlink for reusable resume stages

Same-id retry ownership must not suppress unlink for every generated
stage path. Non-resumable uploads use unique .netcatty-upload-* paths;
a stale late OPEN on those must still clean orphans. Only deterministic
.netcatty-<transferId>.part resume stages are protected.

* fix(sftp): gate late OPEN unlink skip on actual staged path

Only suppress late generated-stage unlink when the same-id retry's
active.stagedRemote.path matches. Do not treat resumable+targetPath or
path shape alone as ownership for in-place retries.

* fix(sftp): compare late OPEN stage paths in the same representation

OPEN filePath may be a session-encoded Buffer while stagedRemote.path is the
logical string, so strict equality failed and could unlink a same-id retry
stage. Normalize via encodePathForSession / Buffer equals before ownership skip.

* fix(sftp): gate truncating shared OPEN against same-path retries

Serialize truncating shared-write OPENs per remote path so a same-id
retry cannot issue OPEN "w" while a prior attempt is still unsettled.
When a drain force-complete already released the gate and a late stale
OPEN re-truncates a retry-owned resume stage, invalidate that retry
instead of allowing a sparse promote. Keep late-OPEN unlink skip for
retry-owned stages.

* fix(sftp): bound best-effort unlink so cancel cannot hang

Shared/sudo cancel after OPEN "w" waited on unlink before finishCancel.
A dead channel that never invokes the unlink callback left the transfer
and its session lease active indefinitely. Cap unlink at the same 2s
grace used for shared-write drain force-complete.

* fix(sftp): make truncating shared OPEN path-gate waits cancelable

A same-path upload queued behind a prior truncating OPEN could keep its
lease/admission slot forever if cancelled before runOpen. Race the gate
wait with transfer.abort and release the gate + drain on cancel.

* fix(sftp): scope truncating OPEN path gates per SFTP session

A module-global path-only key serialized unrelated hosts that shared the
same remote pathname. Include transfer/sftp session identity in the gate
key so only same-endpoint opens wait on each other.

* fix(sftp): refuse stage promote after stale OPEN truncation

A late force-completed OPEN w from a prior same-id attempt can zero the
deterministic stage after concurrent write finishes. Latch
staleOpenTruncatedStage through assertCanPromote and uploadFileConcurrent
so promotion cannot rename a corrupt stage.

* fix(sftp): keep path gate chained when canceling a queued OPEN wait

Releasing a cancelled waiter's gate immediately cleared the barrier while
the prior truncating OPEN was still in flight. Chain the waiter's release
to waitForPrior so later same-path attempts stay serialized.

* fix(sftp): hold reusable-stage path gate past OPEN force-complete

Force-completing drain no longer frees the path gate for generated stages
immediately. Same-id retries stay serialized until the late OPEN settles
(or a 10s hard cap) so promotion cannot race a still-processing OPEN w.

* test(sftp): wait for generated-stage path gate hold on retry OPEN

After force-complete, generated stages keep the path gate for 10s more.
Align the late-OPEN retry test wait with that hold.

* fix(sftp): hold truncating OPEN path gates for in-place targets too

In-place shared OPEN force-complete no longer frees the path gate
immediately, and late OPEN invalidation applies to final-path retries so
a stale truncating OPEN cannot wipe an in-flight same-id rewrite.

* fix(sftp): keep path gate held until OPEN settles (no hard cap)

Remove the post-force-complete gate timeout so a different transferId
cannot reopen the same target while a stale truncating OPEN is pending.

* test(sftp): accept normal {transferId,totalBytes} retry success

startTransferNow returns transferId/totalBytes on success, not success:true.

* fix(sftp): reject path-gate waits when the SFTP channel errors

Queued same-path OPEN waiters now observe channel error while waiting on
the prior gate, so a dead session cannot pin lease/admission forever.

* fix(sftp): treat superseded OPEN as cancelled, not failed

Late OPEN rejections after a same-id retry must not broadcast a terminal
failed event that marks the live retry task failed in transfer center.

* fix(sftp): emit cancelled channel for superseded OPEN failures

Worker fan-out only maps transfer:error as cancelled for cancel messages.
Superseded attempts now use transfer:cancelled so retries are not failed.

* fix(sftp): silence superseded OPEN terminal events for live retries

When a same-id retry already owns activeTransfers, do not emit cancelled
or failed for the stale superseded attempt or the transfer center sticks.

* fix(sftp): release path gate on OPEN force-complete for dead channels

When the OPEN callback never arrives, free same-path waiters after drain
force-complete so later uploads do not hang forever on the gate.

* fix(sftp): hold path gate until OPEN settles; 30s dead-channel grace

Force-complete no longer frees the gate immediately. Late OPEN release
keeps retries serialized; a 30s grace frees dead channels.

* fix(sftp): never force-release path gate on OPEN drain force-complete

A dead-channel force-complete must free the transfer/lease, but keep the
path gate held until the OPEN callback actually settles. Force-releasing
lets a same-path retry write then get wiped by a late truncating OPEN.

* fix(sftp): skip upload fallback when OPEN is superseded by a retry

A same-id retry can already own the transfer while the old OPEN callback
still rejects. Mark that rejection noTransferFallback so we never truncate
the shared stage via prepareUploadFallbackCheckpoint under the retry.

* fix(sftp): bound late OPEN close before releasing path gate

After drain force-complete, a late truncating OPEN must still close the
handle before releasing the path gate — but a dead channel may never
invoke CLOSE. Race close with a 2s timeout so same-path uploads cannot
wait forever on pathGate.waitForPrior.

* chore: re-trigger Codex review automation

* fix(sftp): always suppress terminal events for superseded OPEN attempts

Superseded means this attempt lost ownership. Do not require the retry to
still own activeTransfers — if the retry already completed, emitting
cancelled for the same transferId rewrites a completed transfer.

* chore: re-trigger Codex for path-gate head

* chore: nudge Codex automation after cancelled run

* fix(sftp): keep SFTP leases when suppressing superseded attempts

Superseded OPEN cleanup must not release transferId leases — a same-id
retry shares that lease set entry and would lose its hold if we release.

* fix(sftp): do not return result.error for superseded transfer attempts

Renderer directory ops treat any startStreamTransfer error as failure.
Superseded same-id OPEN attempts should return a non-error marker so the
live retry is not marked failed.

* fix(sftp): bound CLOSE on pre-settle cancel OPEN path

Reuse the 2s close timeout for cancel-before-settle so a dead channel
cannot leave the path gate held after finishCancel never runs.

* fix(sftp): hold path gate until unlink callback settles

Remove the 2s force-complete on late shared OPEN unlink so a delayed
server unlink cannot race a same-id retry that already reopened the stage.

* fix(sftp): path-gate r+ resume OPENs; release only unused superseded leases

Serialize all shared write opens including resume r+. On superseded, release
only SFTP leases not also held by the live same-id retry.

* fix(sftp): serialize shared and isolated OPENs by host+path

Path gates keyed only by SFTP object let an isolated-channel retry race a
stale shared truncating OPEN on the same host path. Prefer targetHostId so
both channels wait on the same gate.

* fix(sftp): path-gate isolated writes; revalidate checkpoint after gate

Gate all write OPENs including isolated channels so a shared stale OPEN "w"
cannot race. After waiting on the path gate, re-stat the stage and shrink
the resume checkpoint (and flags) before issuing r+/w.

* fix(sftp): dynamic truncating flags; cross-id stale OPEN; bound unlink

Resolve r+/w at OPEN/unlink time so afterPathGate flag getters still unlink
generated stages on cancel. Invalidate any same-path active transfer (not only
same-id) when a late truncating OPEN lands, and force checkpoint restart when
the stage was wiped before OPEN accepted. Bound best-effort unlink so a dead
channel cannot hang cancel settle.

* fix(sftp): clear staleOpenTruncatedStage after gated full restart

When a late truncating OPEN zeros the stage while a retry waits on the path
gate, afterPathGate restarts from offset zero. Consume the stale flag there so
a successful full rewrite is not failed by the post-upload guard.

* fix(sftp): keep cancel active during post-gate checkpoint revalidation

After the path gate opens, afterPathGate may stat a remote stage. Keep the
transfer abort hook and channel-error listener live for that await so cancel
or a dead channel can still release the path gate and lease.

* fix(sftp): hold path gate for isolated write OPENs until settle

Isolated truncating OPENs previously released the host/path gate on cancel
as soon as sftp.end() was called, allowing a same-path retry to race a still
in-flight OPEN that can truncate the stage without invalidation. Keep the gate
until the OPEN callback (or a bounded force-release after end), and run late
OPEN invalidation for isolated channels too.

* fix(sftp): close late shared read OPEN handles after settle

Restores close of late shared/sudo OPEN "r" handles after cancel or channel
error settle. Write-only gate invalidation remains for isolated late OPENs.

* fix(sftp): copy host IDs onto transfer for path-gate keys

dedicatedTransferResume passes targetHostId with a new targetSftpId. Keep
those host IDs on the transfer object so sharedWriteOpenSessionKey stays
host-scoped across session replacement instead of falling back to sftpId.

* fix(sftp): do not complete directory children on superseded invoke

When a same-id retry steals ownership mid-OPEN, the old startStreamTransfer
returns { superseded: true }. Wait for the live owner's terminal task status
instead of treating that result as a successful child completion.

* fix(sftp): pass host IDs on background restart; scope stale-OPEN invalidation

Background agent resume now includes source/target host IDs so path gates stay
host-scoped after openSftpForSession. Stale OPEN invalidation requires a
matching host or sftp session instead of treating missing host as a wildcard.

* fix(sftp): do not unlink stage after same-transfer fastPut fallback

When concurrent isolated OPEN dies without a callback and upload falls back
to fastPut on the same generated stage, a late OPEN must not unlink that
stage. Skip unlink when the same transfer already accepted an OPEN, owns
stagedRemote, or moved to a fastPut/stream strategy.

* fix(sftp): narrow fastPut unlink skip; handle superseded in all callers

Only suppress late-stage unlink when the same transfer moved to
fastPut/stream — ordinary cancel still unlinks. Background restart and
dedicated resume wait for the live same-id owner instead of treating
superseded invokes as completed.

* fix(sftp): never force-release isolated write path gates

Hold host/path gates for isolated truncating OPENs until the OPEN callback
settles, matching shared-channel behavior. Force-releasing after sftp.end()
allowed a late OPEN to wipe a completed same-path destination with no live
transfer left to invalidate.

* fix(sftp): hold path gate until late unlink settles

Transfer cancel can settle after a 2s unlink grace so dead channels do not
hang invokes, but the host/path gate stays held until the unlink callback
actually fires so a delayed unlink cannot delete a same-path retry stage.

* fix(sftp): wait for live owner without fixed superseded deadline

Superseded invokes now wait only on the live same-id transfer's terminal
status (or cancel). A 30-minute timeout could mark a still-running long
transfer failed and stick that status after the owner later completed.

* fix(sftp): classify superseded before cancelled cleanup

When a cancelled attempt loses ownership to a same-id retry mid-OPEN, the
error is Transfer superseded while cancelled is still true. Handle supersede
first so cancel cleanup cannot delete the retry stage, free its leases, or
emit a terminal cancelled event for the live owner.

* fix(sftp): wait for pending write OPEN before fastPut fallback

Expose pendingWriteOpenPathGate on the transfer so non-resumable concurrent
isolated failures wait for their own truncating OPEN to settle before
fastPut rewrites the same stage.

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-06 09:16:59 +08:00
c3a7a4758b feat(settings): 支持一键清空所有会话日志 (#2738)
* feat(settings): 支持一键清空所有会话日志

在系统设置的会话日志卡片中新增清空按钮,删除保存目录下的
所有日志文件及主机子目录;操作前弹窗二次确认,完成后展示
删除/失败数量。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* fix(session-logs): restrict clear-all to known log artifacts

Clearing the session logs directory no longer recursively deletes every
top-level entry. Only files matching Netcatty auto-save / export naming
patterns and host folders that exclusively contain those artifacts are
removed, so a shared Documents/Downloads save path cannot wipe unrelated
user files (Codex P1 on #2738).

* fix(session-logs): skip active auto-save streams during clear-all

Clear-all must not rm host dirs or log files still open by
sessionLogStreamManager write streams (orphan inode / silent stop).
Expose getActiveLogPaths and preserve those targets while still
deleting inactive siblings and pure host folders.

* fix(session-logs): preserve active snapshot-only host dirs on clear-all

txt/html streams may not create a file until the first snapshot, so treat
any active log path whose parent is the host directory as live work before
removing pure session-log folders.

* fix(session-logs): preserve worker-owned active streams on clear-all

Clear-all only queried the main-process sessionLogStreamManager, so
worker-owned auto-save files were invisible and could be deleted while
live. Union active paths from a running terminal worker via
netcatty:sessionLogs:getActivePaths, and skip cold-starting a stopped
worker.

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-08-06 08:54:03 +08:00
9a3d96be76 fix(ai): improve approval command readability and cancel idle timeout on review (#2734)
* fix(#2697): automated Cursor CLI fix

* fix: address Codex review on PR #2734

* fix(ai): keep MCP absolute approval deadline and Escape reject

Canceling idle review no longer leaves MCP approvals unbounded past the
external agent tool-call window, and Enter-only key stopPropagation lets
Escape still reject from Copy/Expand controls.

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

* fix(ai): keep wrapped CLI target context on approval cards

Parse --session from netcatty-tool-cli wrappers into execution context and
still show invocation args when the pending card displays an unwrapped command.

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

* fix(ai): surface Codex reason on pending approval cards

Keep agent justification visible in the execution-context strip and still
show extra invocation args such as commandActions beside the command block.

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

* fix(ai): bound Catty approval cancel with absolute deadline

Keep Catty confirm-mode approvals from going unbounded after review:
cancelApprovalTimeout re-arms the creation absolute deadline (MCP-style)
so a late approve cannot outlive the original timeout/stream tool budget.

Also harden pending-card Enter: extract isNestedInteractiveApprovalTarget
so Copy/Expand (and other nested controls) never Approve Once via bubble.

* fix(ai): bound Codex approval cancel and unwrap CLI wrappers

cancelInteractionTimeout now re-arms the absolute creation deadline
(Catty/MCP style) instead of leaving review unbounded. Also treat
netcatty-tool-cli.cjs/.cmd as CLI wrappers so approval Command/Copy
shows the remote command after --.

* fix: address Codex review on PR #2734

* fix(ai): separate Catty approval idle timer from hard deadline

Review activity re-arms a full idle window from now instead of the original
absolute remainder, capped by a hard deadline (3x idle, max 30m). Stream
tool budgets use the hard deadline so mid-review approvals are not cut off.

* fix(ai): re-arm Catty approval idle and sync-deny hard deadline

Review interactions kept a one-shot timeoutCancelledRef, so later
cancelApprovalTimeout calls no longer re-armed idle. Remove the one-shot
and deny synchronously when the hard deadline has already elapsed so a
late approve cannot race setTimeout(0).

* fix(ai): re-arm approval idle on args-only scroll review

Pending approvals without a display command (Codex file-change/permissions,
write tools with JSON args) only show the overflow args block. Wire the same
onScroll/onWheel markReviewing path used by the command pre so continuous
wheel/trackpad review re-arms Catty and MCP/Codex idle timers.

---------

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-06 08:53:36 +08:00
69df81613b fix(sftp): keep filter input IME-safe during CJK composition (#2746)
* fix(#2745): automated Cursor CLI fix

* fix(sftp): reset IME composing guard when filter bar closes

If the filter input unmounts mid-composition (filter bar toggled closed),
compositionend never fires, leaving filterComposingRef stuck at true and
blocking every later onSetFilter commit. Clear the guard when the bar hides.

Also drop the now-unused startTransition prop from the toolbar (its only
use was the removed deferred filter path).

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

* fix(sftp): resync filter draft to committed filter when bar closes

Resetting only the composing guard left filterDraft diverged from pane.filter
when the bar closed mid-composition, so reopening showed stale uncommitted text.
Resync the draft to pane.filter on close.

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

* fix(sftp): clear IME composing guard in commitFilterValue

Centralize the guard reset so every commit/clear path (composition end,
Escape, inline clear button, close button) finalizes the IME session. The
inline clear previously left filterComposingRef stuck true, blocking later
onChange commits and letting a delayed compositionend undo the clear.

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

* fix(sftp): honor external filter change over stale draft on composition end

If navigation (e.g. follow-terminal-CWD) clears pane.filter mid-composition,
onCompositionEnd previously re-committed the stale composed draft, overwriting
the navigation-clear invariant. Capture pane.filter at composition start and
adopt any external change on end instead of committing the stale draft.

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

* fix(sftp): supersede IME composition when navigation clears filter

Navigation can clear pane.filter mid-CJK composition. Adopt that external
clear into the draft while composing, arm a supersede latch so the common
post-compositionend onChange cannot re-commit the stale draft, and reset
composing/supersede state when the filter bar unmounts or a commit path runs.

* fix(sftp): supersede IME filter draft when path navigates

When the committed filter was already empty before composition,
directory navigation sets filter to "" again so pane.filter does not
change. Track path at composition start and supersede the draft when
the path changes so compositionend cannot re-commit stale IME text.

* fix: address Codex review on PR #2746

* fix(sftp): clear IME supersede latch if no post-change arrives

After compositionend arms the supersede latch, schedule a macrotask
fallback clear so ordinary keystrokes work when the browser never
fires the post-composition onChange that would clear it.

* fix: use ASCII punctuation in PR comments and tests

Replace em dashes with ASCII hyphens in comments/tests. Keep the guarded
setTimeout supersede-latch clear from the prior IME fix.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
Co-authored-by: bincxz <16399091+binaricat@users.noreply.github.com>
2026-08-06 08:53:19 +08:00
583c75b107 fix(plugins): backfill sync provider bindings after schema upgrade (#2752)
* 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>

* 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>

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* fix: address Codex review on PR #2752

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

* 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.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: 陈大猫 <binaricat@users.noreply.github.com>
Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-06 08:53:03 +08:00
陈大猫 ae74f25588 fix(ci): dedupe same-head @codex review requests (#2754)
* fix(ci): dedupe same-head @codex review requests

Plain maintainer/Cursor-bot @codex review comments did not plant the
cursor-external-codex marker, so own_rerequest on synchronize posted a
second request for the same SHA. That concurrent pair produced a clean
issue comment from one job and a findings review from the other.

Skip re-request when any trusted or Cursor-bot author already requested
this head via external marker, cursor-codex-head pin, or a recent plain
@codex review.

* fix(ci): only dedupe plain @codex against the current head push

Plain unpinned requests no longer skip solely by age — that blocked
re-request after a new synchronize when an older plain @codex still
looked "recent". Callers pass the head commit time as notBefore so
only post-push plain requests suppress a duplicate.

* fix(ci): use PR head-change time for plain @codex dedupe

Commit author/committer dates predate cherry-picks and rebases, so a
plain @codex for the previous head could suppress re-request after a
new synchronize. Pass pull_request.updated_at (moves with the push)
as notBefore instead.

* fix(ci): dedupe @codex only via head-pinned markers

Drop plain-unpinned timestamp heuristics — they cannot safely know which
SHA a bare @codex review targeted. Skip re-request when a trusted author
already pinned this head with cursor-codex-head or cursor-external-codex.
2026-08-05 23:40:51 +08:00
58 changed files with 5780 additions and 194 deletions

No files matched your search

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