Compare commits

..
Author SHA1 Message Date
陈奇 2dc3fc3cd1 fix(settings): release MCP toggle before client discovery completes 2026-10-28 11:42:49 +08:00
陈奇 bb02f5cbd0 fix(settings): surface HTTP failures and align runtime status pills 2026-10-28 11:42:49 +08:00
陈奇 a6dda1771c test(settings): return void from guidance refresh act callback 2026-10-28 11:42:49 +08:00
陈奇 6620b85f2d test(settings): use isolated DOM storage in privacy tests 2026-10-28 11:42:49 +08:00
陈奇 942f106240 fix(settings): preserve slow client discovery during runtime polling 2026-10-28 11:42:49 +08:00
陈奇 0c0d2ca558 refactor(settings): simplify AI tool access configuration 2026-10-28 11:42:45 +08:00
陈大猫andJoseph Hau e98e236d54 fix(ai): recognize macOS zsh default prompts for PTY readiness (#3980)
Continuation of #3969 by @hexart (Joseph Hau). Preserves original commit recognition and adds spaces / %#→# coverage so idle macOS zsh terminals accept AI commands.

Codex CLEAN (gpt-6.1-terra) vs main. CI green. No release.

Co-authored-by: Joseph Hau <hexart@gmail.com>
2026-10-27 20:38:16 -07:00
陈大猫 1e063dfa3c fix(ai): stop automatic model discovery retries after failure (#4002)
Gate ModelSelector discovery on settled error; preserve explicit Refresh and config resets. Component regressions included.

Codex CLEAN (gpt-6.1-terra) vs main. CI green on tip. No release.
2026-10-27 20:38:13 -07:00
8b5b0c4416 fix(ai): keep dropped AI chats recoverable with dangling marking + /rebind; reuse workspace id on merge (#4019)
* fix(ai): keep dropped AI chats recoverable with dangling marking and /rebind

Closing a member terminal or dissolving a workspace used to clear the
chat's active pointer, making it invisible (scope rank 0) and
unrecoverable from the UI even though the record survived in storage.

- orphan cleanup now marks dropped chats `scopeDangling: true` and keeps
  record + active pointer intact; the flag clears when the target
  returns or the chat is rebound
- `/chats` lists dangling chats with a numbered inline affordance and
  hint line
- new `/rebind <n>` panel action rewrites `scope` to the panel's current
  scope, clears the marker and restores the active pointer
- i18n keys added for en / zh-CN / zh-TW / es / ru

Stacked on #4018. Refs #4015 (R2).

* fix(workspace): reuse existing workspace id when merging tabs with a workspace

createWorkspaceFromSessions silently no-opped when either merged tab
already belonged to a workspace (e.g. dragging a workspace pane onto a
standalone terminal), and the older dissolve->recreate behavior
regenerated the id that workspace-scoped AI chats bind to.

When one of the merged tabs has a workspace, the orphan tab now joins
that existing workspace (pane inserted next to the anchor session, tab
slot collapsed), so workspace-scoped chat bindings survive. Merging two
standalone tabs still creates a fresh workspace.

Stacked on #4018. Refs #4015 (R3).

* fix(ai): persist cleaned active map on dangling chat rebind

rebindSessionScope was updating React state to drop stale old-scope
pointers, then rebuilding localStorage from the pre-rebind snapshot and
resurrecting them. Persist the same cleaned map via rebindActiveSessionMap.

* fix(ai): persist rebind map inside setState; wire backup actions on terminal panel

- Write cleaned active-session map from inside the rebind updater so a
  deferred React setState cannot skip snapshot/localStorage persistence.
- Pass exportSessionsBackup/importSessionsBackup through TerminalLayerSupport
  and include them in AI_CHAT_SIDE_PANEL_AI_STATE_KEYS so history-drawer
  backup buttons work on the normal terminal path.

* fix(ai): guard backup restore races and use app-wide live targets for /chats

- Skip restore when in-memory sessions already exist; apply snapshot,
  storage write, and AI state emit atomically inside the empty-state path
  so peer useAIState instances see the restore.
- Thread validAIScopeTargetIds into AIChatSidePanel as liveScopeTargetIds
  so /chats does not mark chats bound to other open terminals as dangling.

* fix(ai): open rebound chat in panel view and refresh /chats listing

- rebindSessionScope also sets the panel view to the rebound session so an
  existing draft/other chat does not keep hiding the recovery.
- After /rebind, rewrite the stored listing instead of rebuilding from the
  stale render so the chat is no longer offered as dangling.

* fix(workspace): invert join split when dragged pane owns workspace; refresh /chats titles

- When merging a workspace-owned dragged pane onto a standalone tab, the
  orphan is inserted — invert the drop position so the previewed side matches.
- Compare workspaces in AIChatSidePanel memo so /chats picks up renames.

* fix(ai): validate live sessions array on boot; mark imported orphans dangling

- Reject parseable non-array store values (e.g. {}) via readLiveSessionsArray
  so boot restore is not blocked by a corrupt-but-truthy value.
- After manual import, re-run orphan cleanup with the last known live target
  set so chats bound to closed scopes become dangling and /rebind-able.

* fix(ai): merge imported sessions before orphan cleanup

Apply import merge to the snapshot/ref synchronously, then run dangling
marking, so a deferred setState cannot let cleanup rewrite over the import.

* fix(ai): mark restored/imported orphans dangling even with no live targets

Restore and import do not change live terminals/workspaces, so the orphan
cleanup effect will not re-run. Always call cleanup against the last known
live target set after those paths — an empty set is a valid observed state
(nothing mounted), not an unavailable snapshot — so chats bound to missing
scopes become dangling and /chats can offer /rebind.

* fix(ai): apply backup restore synchronously before dangling cleanup

Do not gate restore + orphan marking on a setState updater having run.
A deferred updater left didRestore false, skipped cleanup, and could leave
restored chats with missing scopes unmarked and unrebindable from /chats.

* fix(ai): complete /rebind from a partial slash picker selection

Selecting Rebind after typing `/re` left the composer unchanged, so a
following number became `/re 1` and could be sent to the agent. Replace the
slash query with `/rebind ` the same way Rename does.

* fix(ai): replace hostIds on rebind; skip non-array backup generations

- Rebind builds a fresh destination scope (including hostIds from the
  panel) instead of spreading the old scope, so history matching follows
  the new terminal/workspace host.
- readLatestGeneration treats valid JSON that is not a session array as
  unusable and continues to older generations, matching corrupt skips.

* fix(ai): revalidate dangling chat before /rebind from stale /chats listing

Skip rebind when the cached entry is deleted or already bound to a live
scope; drop stale listing entries so a second panel cannot steal the chat.

* fix(ai): reject malformed /rebind numbers

Require the entire trimmed argument to be digits so /rebind 1foo and
/rebind 1.5 show usage instead of silently targeting index 1.

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
Co-authored-by: 陈大猫 <16399091+binaricat@users.noreply.github.com>
2026-10-27 20:26:21 -07:00
GOUKI9999andNetcatty Maintainer afb0585016 feat(ai): /rename & /chats slash commands + AI session durability layer (#4018)
* feat(ai): add /rename and /chats slash commands plus a session durability layer

Panel slash commands (#4015 feedback):
- /rename <title> renames the current chat via updateSessionTitle; the
  command is intercepted in ChatInput's system-command branch and never
  reaches the agent. Empty title shows a usage hint. The history drawer
  gains an inline rename affordance.
- /chats renders a dismissible system-style note listing the panel's
  visible chats (title / agent backend / workspace-or-terminal scope with
  a dangling marker / relative last-active time; current chat marked).
  Deliberately not named /resume: session.resume already means
  flow-recovery-after-cancel. Listing logic is pure (aiChatsListing.ts)
  so a future rebind action can attach per-entry actions.
- i18n keys added for en/es/ru/zh-CN/zh-TW.

Durability layer (additive; cleanup/eviction semantics untouched):
- Every successful store write schedules a debounced (~2s) mirror of the
  exact persisted JSON to {userData}/ai-sessions-backup/ through a new
  aiSessionsBackupBridge: atomic temp+rename writes, 5 rolling
  generations, timestamped names.
- On boot, a missing/empty live store is restored from the newest
  parseable backup generation. The asymmetry is deliberate: empty ->
  restore, non-empty -> leave alone, so restored data can never
  resurrect explicitly deleted chats.
- Manual export/import in the history drawer via real file dialogs
  (fixed-path fallback where dialogs are unavailable); import merges by
  id with newer updatedAt winning.

Tests: bridge rotation/corruption/rejection, restore decisions, merge
semantics, debounce throttle, mirror-on-write, slash interception and
/chats listing seams. eslint clean; tsc unchanged vs baseline.

* fix(ai): repair sessions backup IPC and restore/import guards

- Forward write/export payloads through preload unchanged so the main
  bridge receives a string json (was double-wrapped → invalid-json).
- Restore from disk only when the live store is missing or corrupt;
  treat a valid [] as an intentional clear. Flush the debounced mirror
  on beforeunload so deletions land before quit.
- Require id + messages[] + finite updatedAt when parsing backup/
  import entries so malformed objects cannot reach pruneSessionsForStorage.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): complete /rename from partial slash and harden import shape

- Selecting Rename from the slash picker replaces the active slash token
  with `/rename ` so a following title is intercepted, not sent to the agent.
- parseBackupSessions also requires title, agentId, and a scope object with
  a string type before accepting an imported/backup entry.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): validate backup messages and skip prune on synced restore

- Drop backup/import sessions whose messages lack id/role/timestamp.
- When cloud AI-session sync is enabled, restore parsed sessions without
  pruneSessionsForStorage so local retention cannot truncate synced history.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): export prepareRestoredSessions helper

Missed in the previous commit; required by useAIState boot restore and tests.

Co-authored-by: Netcatty Maintainer <binaricat@users.noreply.github.com>

* fix(ai): require message content on backup import; raise disk cap to 64MB

* fix(ai): normalize corrupt sessions store to [] before boot restore

* fix(ai): skip non-array backup generations when reading latest

* fix(ai): emit sessions change on backup restore; skip unusable gens

* fix(ai): treat empty backup gen as wipe; align gen usability with parser

* fix(ai): seed backup on boot for live store; report durable import count

* fix(ai): emit sessions change after backup import

* docs(ai): clarify sessions backup recovers in-profile store only

---------

Co-authored-by: binaricat <binaricat@users.noreply.github.com>
2026-10-27 20:04:42 -07:00
Joseph Hau 6d65c89ecf fix(appearance): 修复 macOS 托盘面板毛玻璃效果 (#4028) 2026-10-28 10:43:02 +08:00
陈大猫 2f214b8725 feat(plugin-agent): reuse host global Agent runtime with sandbox Chat sample (#4012)
Merge global Agent host runtime + sandbox Chat sample on top of #4011.

Electron plugin smoke PASS at fb691318c (S0–S4; S5 BLOCKED-FIXTURE). Codex CLEAN. No release.
2026-10-27 19:37:38 -07:00
陈大猫 350917fa0e feat(plugins): consolidate foundation and permission settings (#4011)
Merge plugin foundation + permission settings.

Electron plugin smoke PASS at stacked tip fb691318c (S0–S4; S5 BLOCKED-FIXTURE). Codex CLEAN. Marketplace artifact lifecycle deferred.
2026-10-27 19:37:19 -07:00
陈大猫 fb691318c4 chore(ci): retrigger lint-and-test after runner shutdown 2026-10-28 09:45:20 +08:00
陈奇 9c1af5c73f ci(plugin-agent): retrigger lint-and-test after runner shutdown
Empty commit only. Prior tip 57e682dc lint-and-test was cancelled by
runner shutdown mid-suite (no failing assertions). Re-runs via Actions
API are blocked for the fine-grained PAT; push retriggers PR checks.
2026-10-28 09:45:12 +08:00
陈大猫 555f193d23 fix(sync): block convergent AI session shrink after merge (#4025)
* fix(sync): block convergent AI session shrink after merge

Related #4017 — convergent smartMerge could drop many AI sessions
post-merge (e.g. 46→7) and still report success; classic syncAll
already guarded this. Gate upload/apply with detectSuspiciousShrink.

* fix(sync): drop explicit any in convergent shrink override test

* Fix blocked chat shrink advancing sync baseline
2026-10-27 10:36:19 -07:00
陈大猫 88dd20a918 Fix Enter delay with large command history (#4026)
* Fix command history Enter delay (#4022)

* Fix cleared shell history race
2026-10-27 10:11:05 -07:00
陈奇 57e682dc83 fix(agent): publish correlated names for unnamed tool results 2026-10-28 00:57:50 +08:00
陈奇 2671cf9f20 test(plugin-ai): deliver pagehide before closing fixture 2026-10-27 15:05:30 +00:00
陈奇 672a2bf406 merge: integrate verified plugin foundation into global Agent 2026-10-27 14:56:42 +00:00
陈奇 9f09af3bda Merge selected-context workspace focus correction 2026-10-27 14:29:42 +00:00
陈奇 f5e2a64dfb fix(plugins): retain exact sample chat lease across modal reopen 2026-10-27 13:39:03 +00:00
陈奇 25e0c834da fix(plugins): preserve global chat transcript in compact modal 2026-10-27 12:51:07 +00:00
陈奇 f8616f5d3a fix(plugin-agent): clear stale status when conversation history changes 2026-10-27 12:11:17 +00:00
陈奇 0cab1046cf docs(plugin-agent): record composer correction package hash 2026-10-27 12:06:06 +00:00
陈奇 a56d1ab785 fix(plugins): skip destroyed WebContents during scope broadcasts 2026-10-27 12:04:34 +00:00
陈奇 cbe89995e6 fix(plugins): identify host operation and targets in Ask dialog 2026-10-27 12:02:41 +00:00
陈奇 72383a50c9 fix(plugin-agent): keep composer visible while transcript scrolls 2026-10-27 11:57:35 +00:00
陈奇 8bcad28f9d fix(plugin-agent): skip home config repair for delegated Claude turns 2026-10-27 10:18:51 +00:00
陈奇 59edc87ed4 fix(plugin-agent): wait for stopped driver before replacement turn 2026-10-27 09:30:21 +00:00
陈奇 5944a02238 fix(plugin-agent): bind startup and approved actions to current authority 2026-10-27 09:28:49 +00:00
陈奇 b17713ef95 merge: include foundation dialog handoff test gate 2026-10-27 09:10:09 +00:00
陈奇 56fefdd03f fix(plugin-agent): separate durable conversations from revoked runtime actors 2026-10-27 09:03:40 +00:00
陈奇 b7e4abb30b merge: carry isolated delegated transport review corrections into runtime
# Conflicts:
#	electron/bridges/mcpServerBridge.cjs
#	electron/bridges/mcpServerBridge.pluginDelegation.test.cjs
2026-10-27 08:50:20 +00:00
陈奇 f715a0764d feat(plugin-agent): wire core global conversations and minimal Chat sample 2026-10-27 08:49:54 +00:00
陈奇 5db0f90751 test(plugins): await Radix close handoff after the React commit 2026-10-27 08:47:23 +00:00
陈奇 63f0572e0b fix(plugin-agent): guard delegated cancellation and empty context paths 2026-10-27 08:45:57 +00:00
陈奇 ea619555ac feat(plugin-agent): bind delegated MCP and model transport to exact turns 2026-10-27 07:43:52 +00:00
陈奇 ba4d3fc223 fix(plugin-agent): keep RPC cancellation separate from scope revocation 2026-10-27 06:56:03 +00:00
陈奇 18377a1fd9 fix(plugin-agent): revoke failed owner scopes and bind Stop to its turn 2026-10-27 06:41:17 +00:00
陈奇 056164157f feat(plugin-agent): define source-bound global session authority and contract 2026-10-27 06:09:48 +00:00
152 changed files with 11156 additions and 702 deletions

No files matched your search

+2
View File
@@ -27,6 +27,7 @@ import { AppHostTreeLayer } from './AppHostTreeLayer';
import { AppHostEditorLayer } from './AppHostEditorLayer';
import { AppPluginKeybindingHost } from './AppPluginKeybindingHost';
import { PluginAIContextHost } from '../../components/plugins/PluginAIContextHost';
import { PluginAgentHost } from '../../components/plugins/PluginAgentHost';
import { shouldOpenHostEditOnWorkSurface } from './workTabSurface';
import { useConnectionLogsStore } from '../state/connectionLogsStore';
import {
@@ -858,6 +859,7 @@ function AppViewInner({ domains }: AppViewProps) {
workspaces={workspaces}
/>
<PluginAIContextHost sessions={sessions} workspaces={workspaces} />
<PluginAgentHost sessions={sessions} />
</div>
{/* Global "quick add / edit snippet" modal, triggered by the
+47
View File
@@ -525,6 +525,8 @@ export const enAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Always',
'ai.chat.slashStopDesc': 'Stop the current AI turn and cancel in-flight tools',
'ai.chat.slashCompactDesc': 'Summarize earlier conversation context',
'ai.chat.slashRenameDesc': 'Rename the current chat (/rename <title>)',
'ai.chat.slashChatsDesc': 'List the chats visible to this panel',
'ai.chat.reject': 'Reject',
'ai.chat.toolLabel': 'Tool',
'ai.chat.targetLabel': 'Target',
@@ -587,6 +589,28 @@ export const enAiMessages: Messages = {
'ai.chat.recent': 'Recent',
'ai.chat.viewAll': 'View All',
'ai.chat.untitled': 'Untitled',
'ai.chat.renameChat': 'Rename chat',
'ai.chat.renameUsage': 'Usage: /rename <new title>',
'ai.chat.renameNoActive': 'No active chat to rename.',
'ai.chat.renameDone': 'Chat renamed to "{title}".',
'ai.chat.exportSessions': 'Export AI chats',
'ai.chat.importSessions': 'Import AI chats',
'ai.chat.exportDone': 'AI chats exported to {path}.',
'ai.chat.exportDoneNoPath': 'AI chats exported.',
'ai.chat.exportUnavailable': 'AI chat export is unavailable in this environment.',
'ai.chat.importDone': 'Imported {n} AI chat(s).',
'ai.chat.importNone': 'No AI chats found in the selected file.',
'ai.chat.chatsHeader': 'Chats visible in this panel:',
'ai.chat.chatsEmpty': 'No chats visible in this panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Dissociated',
'ai.chat.slashRebindDesc': 'Rebind a disconnected chat to this panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Disconnected chats can be restored with /rebind <n>.',
'ai.chat.rebindUsage': 'Usage: /rebind <number from /chats>',
'ai.chat.rebindDone': 'Chat "{title}" rebound to this panel.',
'ai.chat.rebindUnavailable': 'No rebind target in this panel scope.',
'ai.chat.justNow': 'Just now',
'ai.chat.minutesAgo': '{n}m ago',
'ai.chat.hoursAgo': '{n}h ago',
@@ -795,4 +819,27 @@ export const enAiMessages: Messages = {
'zmodem.overwrite.skip': 'Skip',
'zmodem.overwrite.cancel': 'Cancel',
'settings.shortcuts.resetToDefault': 'Reset to default',
"ai.toolSettings.immediate": "Changes take effect immediately. Closing this window does not undo them.",
"ai.toolSettings.privacyOn": "Text redaction enabled",
"ai.toolSettings.privacyOff": "Text redaction disabled",
"ai.toolSettings.ruleSummary": "{cidrs} additional IP ranges · {patterns} custom patterns",
"ai.toolSettings.configurePrivacy": "Configure privacy",
"ai.toolSettings.unsaved": "Unsaved changes",
"ai.toolSettings.viewSkills": "View CLI guidance",
"ai.toolSettings.viewPrompt": "View setup prompt",
"ai.toolSettings.behavior": "MCP behavior",
"ai.toolSettings.configureBehavior": "Configure behavior",
"ai.toolSettings.configureClients": "Configure clients",
"ai.toolSettings.httpOn": "HTTP enabled",
"ai.toolSettings.httpOff": "HTTP disabled",
"ai.toolSettings.loopback": "Local connections only (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configure HTTP",
"ai.toolSettings.hideSecret": "Hide secret",
"ai.toolSettings.showSecret": "Show secret",
"ai.toolSettings.manualSetup": "Manual setup",
"ai.toolSettings.format": "Format",
"ai.toolSettings.connectionDetails": "Connection details",
"ai.toolSettings.conflict": "Settings changed in another window. Your draft is preserved. Cancel and reopen to load the saved settings.",
"ai.toolSettings.retry": "Retry",
"ai.toolSettings.applyingHttp": "Applying HTTP configuration...",
};
+47
View File
@@ -525,6 +525,8 @@ export const esAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Siempre',
'ai.chat.slashStopDesc': 'Detener el turno actual de IA y cancelar las herramientas en curso',
'ai.chat.slashCompactDesc': 'Resumir el contexto anterior de la conversación',
'ai.chat.slashRenameDesc': 'Renombrar el chat actual (/rename <título>)',
'ai.chat.slashChatsDesc': 'Listar los chats visibles en este panel',
'ai.chat.reject': 'Rechazar',
'ai.chat.toolLabel': 'Herramienta',
'ai.chat.targetLabel': 'Destino',
@@ -587,6 +589,28 @@ export const esAiMessages: Messages = {
'ai.chat.recent': 'Recientes',
'ai.chat.viewAll': 'Ver todo',
'ai.chat.untitled': 'Sin título',
'ai.chat.renameChat': 'Renombrar chat',
'ai.chat.renameUsage': 'Uso: /rename <nuevo título>',
'ai.chat.renameNoActive': 'No hay ningún chat activo para renombrar.',
'ai.chat.renameDone': 'Chat renombrado a «{title}».',
'ai.chat.exportSessions': 'Exportar chats de IA',
'ai.chat.importSessions': 'Importar chats de IA',
'ai.chat.exportDone': 'Chats de IA exportados a {path}.',
'ai.chat.exportDoneNoPath': 'Chats de IA exportados.',
'ai.chat.exportUnavailable': 'La exportación de chats de IA no está disponible en este entorno.',
'ai.chat.importDone': 'Se importaron {n} chat(s) de IA.',
'ai.chat.importNone': 'No se encontraron chats de IA en el archivo seleccionado.',
'ai.chat.chatsHeader': 'Chats visibles en este panel:',
'ai.chat.chatsEmpty': 'No hay chats visibles en este panel.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Terminal',
'ai.chat.chatsScopeGlobal': 'Global',
'ai.chat.chatsScopeDangling': 'Desasociado',
'ai.chat.slashRebindDesc': 'Reasociar un chat desconectado a este panel (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Los chats desconectados se pueden restaurar con /rebind <n>.',
'ai.chat.rebindUsage': 'Uso: /rebind <número de /chats>',
'ai.chat.rebindDone': 'Chat «{title}» reasociado a este panel.',
'ai.chat.rebindUnavailable': 'No hay destino de reasociación en el ámbito de este panel.',
'ai.chat.justNow': 'Ahora mismo',
'ai.chat.minutesAgo': 'hace {n} min',
'ai.chat.hoursAgo': 'hace {n} h',
@@ -795,4 +819,27 @@ export const esAiMessages: Messages = {
'zmodem.overwrite.skip': 'Omitir',
'zmodem.overwrite.cancel': 'Cancelar',
'settings.shortcuts.resetToDefault': 'Restablecer al predeterminado',
"ai.toolSettings.immediate": "Los cambios se aplican inmediatamente. Cerrar esta ventana no los deshace.",
"ai.toolSettings.privacyOn": "Ocultación de texto activada",
"ai.toolSettings.privacyOff": "Ocultación de texto desactivada",
"ai.toolSettings.ruleSummary": "{cidrs} rangos IP adicionales · {patterns} patrones personalizados",
"ai.toolSettings.configurePrivacy": "Configurar privacidad",
"ai.toolSettings.unsaved": "Cambios sin guardar",
"ai.toolSettings.viewSkills": "Ver instrucciones de CLI",
"ai.toolSettings.viewPrompt": "Ver instrucciones de conexión",
"ai.toolSettings.behavior": "Comportamiento de MCP",
"ai.toolSettings.configureBehavior": "Configurar comportamiento",
"ai.toolSettings.configureClients": "Configurar clientes",
"ai.toolSettings.httpOn": "HTTP activado",
"ai.toolSettings.httpOff": "HTTP desactivado",
"ai.toolSettings.loopback": "Solo conexiones locales (127.0.0.1)",
"ai.toolSettings.configureHttp": "Configurar HTTP",
"ai.toolSettings.hideSecret": "Ocultar secreto",
"ai.toolSettings.showSecret": "Mostrar secreto",
"ai.toolSettings.manualSetup": "Configuración manual",
"ai.toolSettings.format": "Formato",
"ai.toolSettings.connectionDetails": "Detalles de conexión",
"ai.toolSettings.conflict": "La configuración cambió en otra ventana. Se conserva el borrador. Cancela y vuelve a abrir para cargar la configuración guardada.",
"ai.toolSettings.retry": "Reintentar",
"ai.toolSettings.applyingHttp": "Aplicando la configuración HTTP...",
};
+47
View File
@@ -464,6 +464,8 @@ export const ruAiMessages: Messages = {
'ai.chat.alwaysAllow': 'Всегда разрешать',
'ai.chat.slashStopDesc': 'Остановить текущий ход AI и отменить выполняющиеся инструменты',
'ai.chat.slashCompactDesc': 'Сжать ранний контекст разговора',
'ai.chat.slashRenameDesc': 'Переименовать текущий чат (/rename <название>)',
'ai.chat.slashChatsDesc': 'Показать чаты, видимые в этой панели',
'ai.chat.reject': 'Отклонить',
'ai.chat.toolLabel': 'Инструмент',
'ai.chat.targetLabel': 'Цель',
@@ -526,6 +528,28 @@ export const ruAiMessages: Messages = {
'ai.chat.recent': 'Недавние',
'ai.chat.viewAll': 'Показать всё',
'ai.chat.untitled': 'Без названия',
'ai.chat.renameChat': 'Переименовать чат',
'ai.chat.renameUsage': 'Использование: /rename <новое название>',
'ai.chat.renameNoActive': 'Нет активного чата для переименования.',
'ai.chat.renameDone': 'Чат переименован в «{title}».',
'ai.chat.exportSessions': 'Экспортировать чаты ИИ',
'ai.chat.importSessions': 'Импортировать чаты ИИ',
'ai.chat.exportDone': 'Чаты ИИ экспортированы в {path}.',
'ai.chat.exportDoneNoPath': 'Чаты ИИ экспортированы.',
'ai.chat.exportUnavailable': 'Экспорт чатов ИИ недоступен в этой среде.',
'ai.chat.importDone': 'Импортировано чатов ИИ: {n}.',
'ai.chat.importNone': 'В выбранном файле не найдено чатов ИИ.',
'ai.chat.chatsHeader': 'Чаты, видимые в этой панели:',
'ai.chat.chatsEmpty': 'В этой панели нет видимых чатов.',
'ai.chat.chatsScopeWorkspace': 'Workspace',
'ai.chat.chatsScopeTerminal': 'Терминал',
'ai.chat.chatsScopeGlobal': 'Глобально',
'ai.chat.chatsScopeDangling': 'Связь потеряна',
'ai.chat.slashRebindDesc': 'Привязать потерянный чат к этой панели (/rebind <n>)',
'ai.chat.chatsRebindHint': 'Потерянные чаты можно восстановить командой /rebind <n>.',
'ai.chat.rebindUsage': 'Использование: /rebind <номер из /chats>',
'ai.chat.rebindDone': 'Чат «{title}» привязан к этой панели.',
'ai.chat.rebindUnavailable': 'В области этой панели нет цели для привязки.',
'ai.chat.justNow': 'Только что',
'ai.chat.minutesAgo': '{n}м назад',
'ai.chat.hoursAgo': '{n}ч назад',
@@ -724,4 +748,27 @@ export const ruAiMessages: Messages = {
'zmodem.overwrite.skip': 'Пропустить',
'zmodem.overwrite.cancel': 'Отмена',
'settings.shortcuts.resetToDefault': 'Сбросить по умолчанию',
"ai.toolSettings.immediate": "Изменения применяются сразу. Закрытие окна не отменяет их.",
"ai.toolSettings.privacyOn": "Скрытие текста включено",
"ai.toolSettings.privacyOff": "Скрытие текста выключено",
"ai.toolSettings.ruleSummary": "Дополнительные диапазоны IP: {cidrs} · Пользовательские шаблоны: {patterns}",
"ai.toolSettings.configurePrivacy": "Настроить приватность",
"ai.toolSettings.unsaved": "Есть несохранённые изменения",
"ai.toolSettings.viewSkills": "Инструкции CLI",
"ai.toolSettings.viewPrompt": "Инструкции подключения",
"ai.toolSettings.behavior": "Поведение MCP",
"ai.toolSettings.configureBehavior": "Настроить поведение",
"ai.toolSettings.configureClients": "Настроить клиенты",
"ai.toolSettings.httpOn": "HTTP включён",
"ai.toolSettings.httpOff": "HTTP выключен",
"ai.toolSettings.loopback": "Только локальные подключения (127.0.0.1)",
"ai.toolSettings.configureHttp": "Настроить HTTP",
"ai.toolSettings.hideSecret": "Скрыть секрет",
"ai.toolSettings.showSecret": "Показать секрет",
"ai.toolSettings.manualSetup": "Ручная настройка",
"ai.toolSettings.format": "Формат",
"ai.toolSettings.connectionDetails": "Параметры подключения",
"ai.toolSettings.conflict": "Настройки изменены в другом окне. Черновик сохранён. Отмените и откройте окно снова, чтобы загрузить сохранённые настройки.",
"ai.toolSettings.retry": "Повторить",
"ai.toolSettings.applyingHttp": "Применение настроек HTTP...",
};
+47
View File
@@ -525,6 +525,8 @@ export const zhCNAiMessages: Messages = {
'ai.chat.alwaysAllow': '始终',
'ai.chat.slashStopDesc': '停止当前 AI 回合并取消进行中的工具',
'ai.chat.slashCompactDesc': '压缩较早的会话上下文',
'ai.chat.slashRenameDesc': '重命名当前聊天(/rename <标题>)',
'ai.chat.slashChatsDesc': '列出此面板可见的聊天',
'ai.chat.reject': '拒绝',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目标',
@@ -587,6 +589,28 @@ export const zhCNAiMessages: Messages = {
'ai.chat.recent': '最近',
'ai.chat.viewAll': '查看全部',
'ai.chat.untitled': '无标题',
'ai.chat.renameChat': '重命名聊天',
'ai.chat.renameUsage': '用法:/rename <新标题>',
'ai.chat.renameNoActive': '当前没有可重命名的聊天。',
'ai.chat.renameDone': '聊天已重命名为「{title}」。',
'ai.chat.exportSessions': '导出 AI 会话',
'ai.chat.importSessions': '导入 AI 会话',
'ai.chat.exportDone': 'AI 会话已导出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 会话已导出。',
'ai.chat.exportUnavailable': '当前环境不支持导出 AI 会话。',
'ai.chat.importDone': '已导入 {n} 个 AI 会话。',
'ai.chat.importNone': '所选文件中没有可导入的 AI 会话。',
'ai.chat.chatsHeader': '此面板可见的聊天:',
'ai.chat.chatsEmpty': '此面板没有可见的聊天。',
'ai.chat.chatsScopeWorkspace': '工作区',
'ai.chat.chatsScopeTerminal': '终端',
'ai.chat.chatsScopeGlobal': '全局',
'ai.chat.chatsScopeDangling': '已断开关联',
'ai.chat.slashRebindDesc': '将断联的聊天重绑到此面板(/rebind <序号>)',
'ai.chat.chatsRebindHint': '断联的聊天可用 /rebind <序号> 恢复。',
'ai.chat.rebindUsage': '用法:/rebind <序号>',
'ai.chat.rebindDone': '聊天“{title}”已重绑到此面板。',
'ai.chat.rebindUnavailable': '当前面板范围内没有可用的重绑目标。',
'ai.chat.justNow': '刚刚',
'ai.chat.minutesAgo': '{n}分钟前',
'ai.chat.hoursAgo': '{n}小时前',
@@ -795,4 +819,27 @@ export const zhCNAiMessages: Messages = {
'zmodem.overwrite.skip': '跳过',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重置为默认',
"ai.toolSettings.immediate": "更改立即生效,关闭此窗口不会撤销。",
"ai.toolSettings.privacyOn": "已启用文本脱敏",
"ai.toolSettings.privacyOff": "未启用文本脱敏",
"ai.toolSettings.ruleSummary": "{cidrs} 个附加 IP 范围 · {patterns} 条自定义规则",
"ai.toolSettings.configurePrivacy": "配置隐私",
"ai.toolSettings.unsaved": "有未保存的更改",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示词",
"ai.toolSettings.behavior": "MCP 行为",
"ai.toolSettings.configureBehavior": "配置行为",
"ai.toolSettings.configureClients": "配置客户端",
"ai.toolSettings.httpOn": "HTTP 已启用",
"ai.toolSettings.httpOff": "HTTP 未启用",
"ai.toolSettings.loopback": "仅本地连接(127.0.0.1)",
"ai.toolSettings.configureHttp": "配置 HTTP",
"ai.toolSettings.hideSecret": "隐藏密钥",
"ai.toolSettings.showSecret": "显示密钥",
"ai.toolSettings.manualSetup": "手动配置",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "连接详情",
"ai.toolSettings.conflict": "设置已在另一窗口更改,当前草稿已保留。请取消并重新打开以加载已保存的设置。",
"ai.toolSettings.retry": "重试",
"ai.toolSettings.applyingHttp": "正在应用 HTTP 配置…",
};
+47
View File
@@ -515,6 +515,8 @@ export const zhTWAiMessages: Messages = {
'ai.chat.alwaysAllow': '始終',
'ai.chat.slashStopDesc': '停止目前 AI 回合並取消進行中的工具',
'ai.chat.slashCompactDesc': '壓縮較早的對話上下文',
'ai.chat.slashRenameDesc': '重新命名目前聊天(/rename <標題>)',
'ai.chat.slashChatsDesc': '列出此面板可見的聊天',
'ai.chat.reject': '拒絕',
'ai.chat.toolLabel': '工具',
'ai.chat.targetLabel': '目標',
@@ -577,6 +579,28 @@ export const zhTWAiMessages: Messages = {
'ai.chat.recent': '最近',
'ai.chat.viewAll': '檢視全部',
'ai.chat.untitled': '無標題',
'ai.chat.renameChat': '重新命名聊天',
'ai.chat.renameUsage': '用法:/rename <新標題>',
'ai.chat.renameNoActive': '目前沒有可重新命名的聊天。',
'ai.chat.renameDone': '聊天已重新命名為「{title}」。',
'ai.chat.exportSessions': '匯出 AI 會話',
'ai.chat.importSessions': '匯入 AI 會話',
'ai.chat.exportDone': 'AI 會話已匯出到 {path}。',
'ai.chat.exportDoneNoPath': 'AI 會話已匯出。',
'ai.chat.exportUnavailable': '目前環境不支援匯出 AI 會話。',
'ai.chat.importDone': '已匯入 {n} 個 AI 會話。',
'ai.chat.importNone': '所選檔案中沒有可匯入的 AI 會話。',
'ai.chat.chatsHeader': '此面板可見的聊天:',
'ai.chat.chatsEmpty': '此面板沒有可見的聊天。',
'ai.chat.chatsScopeWorkspace': '工作區',
'ai.chat.chatsScopeTerminal': '終端',
'ai.chat.chatsScopeGlobal': '全域',
'ai.chat.chatsScopeDangling': '已斷開關聯',
'ai.chat.slashRebindDesc': '將斷開關聯的聊天重新綁定到此面板(/rebind <序號>)',
'ai.chat.chatsRebindHint': '斷開關聯的聊天可用 /rebind <序號> 恢復。',
'ai.chat.rebindUsage': '用法:/rebind <序號>',
'ai.chat.rebindDone': '聊天「{title}」已重新綁定到此面板。',
'ai.chat.rebindUnavailable': '目前面板範圍內沒有可用的重新綁定目標。',
'ai.chat.justNow': '剛剛',
'ai.chat.minutesAgo': '{n}分鐘前',
'ai.chat.hoursAgo': '{n}小時前',
@@ -785,4 +809,27 @@ export const zhTWAiMessages: Messages = {
'zmodem.overwrite.skip': '跳過',
'zmodem.overwrite.cancel': '取消',
'settings.shortcuts.resetToDefault': '重設為預設值',
"ai.toolSettings.immediate": "變更立即生效,關閉此視窗不會復原。",
"ai.toolSettings.privacyOn": "已啟用文字遮蔽",
"ai.toolSettings.privacyOff": "未啟用文字遮蔽",
"ai.toolSettings.ruleSummary": "{cidrs} 個額外 IP 範圍 · {patterns} 條自訂規則",
"ai.toolSettings.configurePrivacy": "設定隱私",
"ai.toolSettings.unsaved": "有未儲存的變更",
"ai.toolSettings.viewSkills": "查看 CLI 指引",
"ai.toolSettings.viewPrompt": "查看接入提示詞",
"ai.toolSettings.behavior": "MCP 行為",
"ai.toolSettings.configureBehavior": "設定行為",
"ai.toolSettings.configureClients": "設定用戶端",
"ai.toolSettings.httpOn": "HTTP 已啟用",
"ai.toolSettings.httpOff": "HTTP 未啟用",
"ai.toolSettings.loopback": "僅本機連線(127.0.0.1)",
"ai.toolSettings.configureHttp": "設定 HTTP",
"ai.toolSettings.hideSecret": "隱藏金鑰",
"ai.toolSettings.showSecret": "顯示金鑰",
"ai.toolSettings.manualSetup": "手動設定",
"ai.toolSettings.format": "格式",
"ai.toolSettings.connectionDetails": "連線詳情",
"ai.toolSettings.conflict": "設定已在其他視窗變更,目前草稿已保留。請取消並重新開啟以載入已儲存的設定。",
"ai.toolSettings.retry": "重試",
"ai.toolSettings.applyingHttp": "正在套用 HTTP 設定…",
};
+1 -47
View File
@@ -2,14 +2,9 @@ import test from "node:test";
import assert from "node:assert/strict";
import type {
AIPanelView,
AISession,
} from "../../infrastructure/ai/types.ts";
import { createEmptyDraft } from "./aiDraftState.ts";
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
} from "./aiScopeCleanup.ts";
import { pruneInactiveScopedSessions } from "./aiScopeCleanup.ts";
function createSession(id: string, scope: AISession["scope"], externalSessionId?: string): AISession {
return {
@@ -24,47 +19,6 @@ function createSession(id: string, scope: AISession["scope"], externalSessionId?
};
}
test("pruneInactiveScopedTransientState removes closed workspace and terminal scope state", () => {
const activeSessionIdMap = {
"terminal:open-terminal": "session-open",
"terminal:closed-terminal": "session-closed-terminal",
"workspace:open-workspace": "session-open-workspace",
"workspace:closed-workspace": "session-closed-workspace",
};
const draftsByScope = {
"terminal:open-terminal": createEmptyDraft("catty"),
"terminal:closed-terminal": createEmptyDraft("catty"),
"workspace:open-workspace": createEmptyDraft("catty"),
"workspace:closed-workspace": createEmptyDraft("catty"),
};
const panelViewByScope = {
"terminal:open-terminal": { mode: "draft" },
"terminal:closed-terminal": { mode: "session", sessionId: "session-closed-terminal" },
"workspace:open-workspace": { mode: "draft" },
"workspace:closed-workspace": { mode: "session", sessionId: "session-closed-workspace" },
} satisfies Record<string, AIPanelView>;
const next = pruneInactiveScopedTransientState(
activeSessionIdMap,
draftsByScope,
panelViewByScope,
new Set(["open-terminal", "open-workspace"]),
);
assert.deepEqual(next.activeSessionIdMap, {
"terminal:open-terminal": "session-open",
"workspace:open-workspace": "session-open-workspace",
});
assert.deepEqual(next.draftsByScope, {
"terminal:open-terminal": draftsByScope["terminal:open-terminal"],
"workspace:open-workspace": draftsByScope["workspace:open-workspace"],
});
assert.deepEqual(next.panelViewByScope, {
"terminal:open-terminal": panelViewByScope["terminal:open-terminal"],
"workspace:open-workspace": panelViewByScope["workspace:open-workspace"],
});
});
test("pruneInactiveScopedSessions reports inactive targets without deleting persisted history", () => {
const sessions = [
createSession("terminal-restorable", {
-36
View File
@@ -6,7 +6,6 @@ import type {
type DraftsByScope = Partial<Record<string, AIDraft>>;
type PanelViewByScope = Partial<Record<string, AIPanelView>>;
type ActiveSessionIdMap = Record<string, string | null>;
function isInactiveScopedTarget(
scopeKey: string,
@@ -55,41 +54,6 @@ export function pruneInactiveScopedState(
};
}
export function pruneInactiveScopedTransientState(
activeSessionIdMap: ActiveSessionIdMap,
draftsByScope: DraftsByScope,
panelViewByScope: PanelViewByScope,
activeTargetIds: Set<string>,
): {
activeSessionIdMap: ActiveSessionIdMap;
draftsByScope: DraftsByScope;
panelViewByScope: PanelViewByScope;
} {
let activeSessionMapChanged = false;
const nextActiveSessionIdMap: ActiveSessionIdMap = {};
for (const [scopeKey, sessionId] of Object.entries(activeSessionIdMap)) {
if (isInactiveScopedTarget(scopeKey, activeTargetIds)) {
activeSessionMapChanged = true;
continue;
}
nextActiveSessionIdMap[scopeKey] = sessionId;
}
const nextScopedState = pruneInactiveScopedState(
draftsByScope,
panelViewByScope,
activeTargetIds,
);
return {
activeSessionIdMap: activeSessionMapChanged ? nextActiveSessionIdMap : activeSessionIdMap,
draftsByScope: nextScopedState.draftsByScope,
panelViewByScope: nextScopedState.panelViewByScope,
};
}
export function pruneInactiveScopedSessions(
sessions: AISession[],
activeTargetIds: Set<string>,
+147
View File
@@ -0,0 +1,147 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession } from '../../infrastructure/ai/types';
import {
DebouncedBackupMirror,
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
shouldRestoreFromBackup,
} from './aiSessionsBackup';
import { prepareRestoredSessions, pruneSessionsForStorage } from './aiStateSnapshots';
function makeSession(id: string, updatedAt = 1_000): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'workspace', targetId: 'ws-1' },
messages: [],
createdAt: updatedAt,
updatedAt,
} as never;
}
test('isMissingOrCorruptSessionsStore distinguishes missing or corrupt stores from valid arrays', () => {
assert.equal(isMissingOrCorruptSessionsStore(null), true);
assert.equal(isMissingOrCorruptSessionsStore(''), true);
assert.equal(isMissingOrCorruptSessionsStore('[]'), false);
assert.equal(isMissingOrCorruptSessionsStore('{corrupt'), true);
assert.equal(isMissingOrCorruptSessionsStore('{}'), true);
assert.equal(isMissingOrCorruptSessionsStore('[{"id":"s1"}]'), false);
});
test('readLiveSessionsArray normalizes missing and corrupt stores', () => {
for (const raw of [null, undefined, '', '{}', 'not-json']) {
assert.deepEqual(readLiveSessionsArray(raw), []);
}
const sessions = [makeSession('s1')];
assert.deepEqual(readLiveSessionsArray(JSON.stringify(sessions)), sessions);
});
test('parseBackupSessions ignores corrupt payloads and drops malformed entries', () => {
assert.equal(parseBackupSessions(null), null);
assert.equal(parseBackupSessions(''), null);
assert.equal(parseBackupSessions('not json'), null);
assert.equal(parseBackupSessions('{"id":"s1"}'), null);
assert.equal(parseBackupSessions(JSON.stringify([{ id: 's1' }])), null);
const sessions = parseBackupSessions(JSON.stringify([
{ id: 's1' },
{ nope: true },
{ id: 'missing-scope', title: 'Missing scope', agentId: 'catty', messages: [], updatedAt: 1000 },
{ ...makeSession('null-scope'), scope: null },
{ ...makeSession('missing-title'), title: undefined },
{ ...makeSession('missing-agent'), agentId: undefined },
{ ...makeSession('null-message'), messages: [null] },
{ ...makeSession('incomplete-message'), messages: [{ id: 'm' }] },
{ ...makeSession('missing-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000 }] },
{ ...makeSession('invalid-content'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: null }] },
{ ...makeSession('invalid-timestamp'), messages: [{ id: 'm', role: 'user', timestamp: null, content: 'hello' }] },
null,
{ ...makeSession('s2'), messages: [{ id: 'm', role: 'user', timestamp: 1000, content: 'hello' }] },
]));
assert.deepEqual(sessions?.map((session) => session.id), ['s2']);
});
test('restore preserves all sessions when cloud sync is enabled', () => {
const sessions = Array.from({ length: 51 }, (_, index) => makeSession(`s${index}`, index));
assert.deepEqual(prepareRestoredSessions(sessions, true), sessions);
const local = prepareRestoredSessions(sessions, false);
assert.equal(local.length, 50);
assert.equal(local.some(session => session.id === 's0'), false);
});
test('malformed imports cannot reach pruning or inject broken sessions', () => {
const current = [makeSession('live')];
const malformed = parseBackupSessions(JSON.stringify([{ id: 's1' }]));
assert.deepEqual(mergeImportedSessions(current, pruneSessionsForStorage(malformed ?? [])), current);
const valid = parseBackupSessions(JSON.stringify([{ id: 's1' }, makeSession('valid')]));
const merged = mergeImportedSessions(current, pruneSessionsForStorage(valid ?? []));
assert.deepEqual(new Set(merged.map((session) => session.id)), new Set(['live', 'valid']));
});
test('restore happens only on a missing or corrupt live store with a non-empty backup', () => {
const backup = JSON.stringify([makeSession('s1')]);
// Empty (or wiped) store -> restore.
assert.equal(shouldRestoreFromBackup(null, backup), true);
assert.equal(shouldRestoreFromBackup('', backup), true);
assert.equal(shouldRestoreFromBackup('[]', backup), false);
assert.equal(shouldRestoreFromBackup('{corrupt', backup), true);
// Non-empty live store -> never restore (explicit deletions must win).
assert.equal(shouldRestoreFromBackup('[{"id":"live"}]', backup), false);
// Empty/corrupt backup -> nothing to restore.
assert.equal(shouldRestoreFromBackup(null, null), false);
assert.equal(shouldRestoreFromBackup(null, '[]'), false);
assert.equal(shouldRestoreFromBackup(null, '{corrupt'), false);
});
test('mergeImportedSessions keys by id with the newer updatedAt winning', () => {
const current = [makeSession('keep', 100), makeSession('older', 100)];
const imported = [makeSession('newer', 200), makeSession('older', 50), makeSession('added', 1)];
const merged = mergeImportedSessions(current, imported);
const byId = new Map(merged.map((session) => [session.id, session]));
assert.equal(byId.get('older')?.updatedAt, 100);
assert.equal(byId.get('newer')?.updatedAt, 200);
assert.equal(byId.has('added'), true);
assert.equal(byId.has('keep'), true);
// Sorted newest-first for stable history ordering.
assert.deepEqual(
merged.map((session) => session.updatedAt),
[...merged.map((session) => session.updatedAt)].sort((a, b) => b - a),
);
});
test('DebouncedBackupMirror writes once per batch with the last payload', async () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 15);
mirror.schedule('{"gen":1}');
mirror.schedule('{"gen":2}');
await new Promise((resolve) => setTimeout(resolve, 30));
mirror.schedule('{"gen":3}');
mirror.schedule('{"gen":4}');
await new Promise((resolve) => setTimeout(resolve, 40));
assert.deepEqual(writes, ['{"gen":2}', '{"gen":4}']);
});
test('DebouncedBackupMirror flushNow writes the pending payload immediately', () => {
const writes: string[] = [];
const mirror = new DebouncedBackupMirror((json) => writes.push(json), 60_000);
mirror.schedule('{"gen":1}');
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
// A second flush with nothing pending is a no-op.
mirror.flushNow();
assert.deepEqual(writes, ['{"gen":1}']);
});
test('readLiveSessionsArray treats non-array JSON as empty (corrupt)', () => {
assert.deepEqual(readLiveSessionsArray(null), []);
assert.deepEqual(readLiveSessionsArray(''), []);
assert.deepEqual(readLiveSessionsArray('{}'), []);
assert.deepEqual(readLiveSessionsArray('{"sessions":[]}'), []);
assert.deepEqual(readLiveSessionsArray('not-json'), []);
assert.deepEqual(readLiveSessionsArray('[]'), []);
assert.equal(readLiveSessionsArray('[{"id":"a"}]').length, 1);
});
+156
View File
@@ -0,0 +1,156 @@
import type { AISession } from '../../infrastructure/ai/types';
/**
* Renderer-side logic for the AI session durability layer (issue #4015 R2
* follow-up): a disk mirror of the localStorage session store plus a
* restore-on-empty-store boot path. The IPC plumbing lives in
* `electron/bridges/aiSessionsBackupBridge.cjs`; this module keeps the
* decisions pure so they can be tested without Electron.
*/
/** Debounce window: at most one backup write per mutation burst (~2s). */
export const AI_SESSIONS_BACKUP_DEBOUNCE_MS = 2000;
export interface AiSessionsBackupBridge {
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
/** Restore only when the live store is missing or malformed. A valid empty
* array represents an intentional deletion and must remain authoritative. */
export function isMissingOrCorruptSessionsStore(raw: string | null | undefined): boolean {
if (raw == null || raw === '') return true;
try {
return !Array.isArray(JSON.parse(raw));
} catch {
return true;
}
}
/** Read the live sessions array from a raw localStorage string. Returns [] when
* missing/corrupt (including parseable non-arrays such as `{}`), so callers never
* treat a non-array as AISession[]. */
export function readLiveSessionsArray(raw: string | null | undefined): AISession[] {
if (isMissingOrCorruptSessionsStore(raw)) return [];
try {
const parsed = JSON.parse(raw as string);
return Array.isArray(parsed) ? parsed as AISession[] : [];
} catch {
return [];
}
}
/**
* Parse a backup payload into sessions. Returns null when the payload is
* corrupt or malformed (ignored gracefully); also drops entries without a
* usable required fields. Backup generations are written by the same serializer as the
* live store, so entries are expected to be full AISession objects.
*/
export function parseBackupSessions(json: string | null | undefined): AISession[] | null {
if (typeof json !== 'string' || json === '') return null;
let parsed: unknown;
try {
parsed = JSON.parse(json);
} catch {
return null;
}
if (!Array.isArray(parsed)) return null;
const sessions = parsed.filter(
(entry): entry is AISession => (
typeof entry === 'object' && entry !== null
&& typeof (entry as AISession).id === 'string'
&& typeof (entry as AISession).title === 'string'
&& typeof (entry as AISession).agentId === 'string'
&& typeof (entry as AISession).scope === 'object'
&& (entry as AISession).scope !== null
&& typeof (entry as AISession).scope.type === 'string'
&& Array.isArray((entry as AISession).messages)
&& (entry as AISession).messages.every(message => (
typeof message === 'object' && message !== null
&& typeof message.id === 'string'
&& typeof message.role === 'string'
&& typeof message.content === 'string'
&& typeof message.timestamp === 'number'
&& Number.isFinite(message.timestamp)
))
&& typeof (entry as AISession).updatedAt === 'number'
&& Number.isFinite((entry as AISession).updatedAt)
),
);
return sessions.length > 0 ? sessions : null;
}
/**
* Decide whether to restore on boot: only when the live store is missing or corrupt AND
* the backup actually contains at least one session.
*/
export function shouldRestoreFromBackup(
liveRaw: string | null | undefined,
backupJson: string | null | undefined,
): boolean {
if (!isMissingOrCorruptSessionsStore(liveRaw)) return false;
const sessions = parseBackupSessions(backupJson);
return sessions != null && sessions.length > 0;
}
/**
* Merge an imported backup into the current store: entries are keyed by id
* and the newer `updatedAt` wins. Explicit user action, so it may overwrite
* the live store (unlike the boot restore, which is empty-only).
*/
export function mergeImportedSessions(current: AISession[], imported: AISession[]): AISession[] {
const byId = new Map<string, AISession>();
for (const session of current) byId.set(session.id, session);
for (const session of imported) {
const existing = byId.get(session.id);
if (!existing || (session.updatedAt ?? 0) >= (existing.updatedAt ?? 0)) {
byId.set(session.id, session);
}
}
return Array.from(byId.values()).sort((a, b) => (b.updatedAt ?? 0) - (a.updatedAt ?? 0));
}
/**
* Debounced mirror scheduler. One rolling timer per process: the last
* mutation batch wins, and the write happens `debounceMs` after the batch
* settles (at most once per batch, per the durability spec).
*/
export class DebouncedBackupMirror {
private timer: ReturnType<typeof setTimeout> | null = null;
private pendingJson: string | null = null;
constructor(
private readonly write: (json: string) => void,
private readonly debounceMs: number = AI_SESSIONS_BACKUP_DEBOUNCE_MS,
) {}
schedule(json: string) {
this.pendingJson = json;
if (this.timer) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = null;
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}, this.debounceMs);
}
flushNow() {
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
const json = this.pendingJson;
this.pendingJson = null;
if (json != null) this.write(json);
}
reset() {
if (this.timer) clearTimeout(this.timer);
this.timer = null;
this.pendingJson = null;
}
}
+171 -1
View File
@@ -16,6 +16,7 @@ import {
latestAISessionsSnapshot,
setLatestAIActiveSessionMapSnapshot,
setLatestAISessionsSnapshot,
rebindActiveSessionMap,
} from './aiStateSnapshots';
test('orphan cleanup keeps durable Catty output while explicit deletion removes it', async () => {
@@ -90,7 +91,9 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
const previousWindow = globalThis.window;
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
@@ -122,8 +125,24 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
try {
cleanupOrphanedAISessions(new Set(['open', 'resumed-into']));
assert.equal(latestAISessionsSnapshot, sessions);
// Issue #4015 R2: the dropped chat is marked dangling but its record and
// its active pointer stay intact so /chats can offer a rebind.
const persistedSessions = latestAISessionsSnapshot;
assert.ok(persistedSessions);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-orphan')?.scopeDangling,
true,
);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-live')?.scopeDangling,
undefined,
);
assert.equal(
persistedSessions.find((session) => session.id === 'privacy-resumed')?.scopeDangling,
undefined,
);
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:closed': 'privacy-orphan',
'terminal:open': 'privacy-live',
'terminal:resumed-into': 'privacy-resumed',
});
@@ -158,6 +177,95 @@ test('orphan cleanup preserves resumable privacy maps until explicit chat deleti
}
});
test('orphan cleanup marks dangling chats instead of stripping them, and clears the flag when the target returns', (t) => {
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
const read = t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { dispatchEvent: () => true, netcatty: {} },
});
const dangling = {
...makeSession('dangling-chat', 1, []),
scope: { type: 'terminal' as const, targetId: 'closed-ws' },
};
const live = {
...makeSession('live-chat', 2, []),
scope: { type: 'terminal' as const, targetId: 'open' },
};
setLatestAISessionsSnapshot([dangling, live]);
setLatestAIActiveSessionMapSnapshot({
'terminal:closed-ws': 'dangling-chat',
'terminal:open': 'live-chat',
});
try {
cleanupOrphanedAISessions(new Set(['open']));
let persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, true);
assert.equal(persisted.find((s) => s.id === 'live-chat')?.scopeDangling, undefined);
// The active pointer survives the dangling marking.
assert.deepEqual(latestAIActiveSessionMapSnapshot, {
'terminal:closed-ws': 'dangling-chat',
'terminal:open': 'live-chat',
});
assert.ok(writeString.mock.callCount() > 0);
// The scope target becomes live again → the dangling flag clears.
read.mock.mockImplementation(() => null);
cleanupOrphanedAISessions(new Set(['open', 'closed-ws']));
persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'dangling-chat')?.scopeDangling, undefined);
} finally {
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('orphan cleanup with an empty live-target set marks all scoped chats dangling', (t) => {
const previousSessions = latestAISessionsSnapshot;
const previousActiveSessionMap = latestAIActiveSessionMapSnapshot;
t.mock.method(localStorageAdapter, 'read', () => null);
t.mock.method(localStorageAdapter, 'write', () => true);
t.mock.method(localStorageAdapter, 'writeString', () => true);
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: { dispatchEvent: () => true, netcatty: {} },
});
const orphan = {
...makeSession('orphan-chat', 1, []),
scope: { type: 'terminal' as const, targetId: 'gone' },
};
setLatestAISessionsSnapshot([orphan]);
setLatestAIActiveSessionMapSnapshot({ 'terminal:gone': 'orphan-chat' });
try {
// Empty is a valid observed live set (nothing mounted), not "unknown".
cleanupOrphanedAISessions(new Set());
const persisted = latestAISessionsSnapshot;
assert.ok(persisted);
assert.equal(persisted.find((s) => s.id === 'orphan-chat')?.scopeDangling, true);
} finally {
setLatestAISessionsSnapshot(previousSessions ?? []);
setLatestAIActiveSessionMapSnapshot(previousActiveSessionMap ?? {});
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('serializeSessionsForStorage strips oldest ciphertext before dropping visible sessions', async () => {
const { serializeSessionsForStorage } = await import('./aiStateSnapshots');
const ciphertext = 'gAAAA'.repeat(50000); // ~250 KB of ciphertext per message
@@ -817,3 +925,65 @@ for (const [budget, expectedCalls] of [[128 * 1024, 160], [0, 161]]) {
assert.equal(result.sessions.length, budget === 0 ? 1 : 4);
});
}
test('successful store writes schedule a throttled disk backup mirror', async (t) => {
const { writeSessionsForStorage, flushBackupMirror, __resetBackupMirrorForTests } =
await import('./aiStateSnapshots');
__resetBackupMirrorForTests();
const backupWrites: string[] = [];
const previousWindow = globalThis.window;
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: {
netcatty: {
aiSessionsBackupWrite: async (payload: { json: string }) => {
backupWrites.push(payload.json);
return { ok: true };
},
},
},
});
const sessions = freezeHistory(makePersistenceHistory(3, 10));
const writeString = t.mock.method(localStorageAdapter, 'writeString', () => true);
t.mock.method(localStorageAdapter, 'read', () => null);
try {
assert.equal(writeSessionsForStorage(sessions), true);
// Throttled: nothing has been written to disk yet.
assert.equal(backupWrites.length, 0);
flushBackupMirror();
assert.equal(backupWrites.length, 1);
// The mirror carries exactly the JSON that landed in localStorage.
assert.equal(backupWrites[0], writeString.mock.calls[0].arguments[1]);
// Failed store writes never schedule a mirror.
writeString.mock.mockImplementation(() => false);
assert.equal(writeSessionsForStorage(sessions), false);
flushBackupMirror();
assert.equal(backupWrites.length, 1);
} finally {
__resetBackupMirrorForTests();
Object.defineProperty(globalThis, 'window', {
configurable: true,
value: previousWindow,
});
}
});
test('rebindActiveSessionMap drops stale pointers and sets the new scope', () => {
const cleaned = rebindActiveSessionMap(
{
'terminal:old-ws': 'chat-1',
'terminal:other': 'chat-2',
'workspace:keep': 'chat-1',
},
'chat-1',
'terminal:new-ws',
);
assert.deepEqual(cleaned, {
'terminal:other': 'chat-2',
'terminal:new-ws': 'chat-1',
});
// Idempotent when already bound to the target scope with no other pointers.
assert.deepEqual(
rebindActiveSessionMap({ 'terminal:new-ws': 'chat-1' }, 'chat-1', 'terminal:new-ws'),
{ 'terminal:new-ws': 'chat-1' },
);
});
+116 -44
View File
@@ -20,10 +20,11 @@ import {
} from './aiDraftState';
import {
pruneInactiveScopedSessions,
pruneInactiveScopedTransientState,
pruneInactiveScopedState,
} from './aiScopeCleanup';
import { emitAIStateChanged } from './aiStateEvents';
import { getAgentRuntime } from '../../infrastructure/ai/harness/globalAgentRuntime';
import { DebouncedBackupMirror, readLiveSessionsArray } from './aiSessionsBackup';
/** Typed accessor for the Electron IPC bridge exposed on `window.netcatty`. */
export interface AIBridge {
@@ -35,6 +36,11 @@ export interface AIBridge {
aiMcpSetCommandBlocklist?: (blocklist: string[]) => Promise<unknown> | unknown;
aiMcpSetCommandTimeout?: (timeout: number) => Promise<unknown> | unknown;
aiMcpSetMaxIterations?: (maxIterations: number) => Promise<unknown> | unknown;
/** Durability mirror: write one timestamped backup generation to disk. */
aiSessionsBackupWrite?: (payload: { json: string }) => Promise<{ ok: boolean }>;
aiSessionsBackupReadLatest?: () => Promise<{ ok: boolean; json?: string }>;
aiSessionsBackupExport?: (payload: { json: string }) => Promise<{ success: boolean; filePath?: string }>;
aiSessionsBackupImport?: () => Promise<{ success: boolean; json?: string }>;
}
export function getAIBridge() {
@@ -82,10 +88,13 @@ function isScopeKeyActive(scopeKey: string, activeTargetIds: Set<string>) {
return activeTargetIds.has(targetId);
}
/** Last live terminal/workspace target ids observed by orphan cleanup. */
export let latestAIActiveTargetIdsSnapshot: Set<string> = new Set();
export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
latestAIActiveTargetIdsSnapshot = activeTargetIds;
const currentSessions = latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? [];
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
// Sessions shown by a still-live scope must be protected from cleanup
// even when their own `scope.targetId` points at a closed terminal —
@@ -111,65 +120,62 @@ export function cleanupOrphanedAISessions(activeTargetIds: Set<string>) {
cleanupSdkAgentSessions(nextSessionCleanup.orphanedSessionIds);
}
if (nextSessionCleanup.sessions !== currentSessions) {
setLatestAISessionsSnapshot(nextSessionCleanup.sessions);
writeSessionsForStorage(nextSessionCleanup.sessions);
// Issue #4015 R2: a dropped chat is recoverable now, so mark it dangling
// instead of stripping its record or pointer. `/chats` lists dangling chats
// with a rebind affordance; the flag clears when the chat is rebound or its
// scope target becomes live again.
const orphanedIds = new Set(nextSessionCleanup.orphanedSessionIds);
let sessionsMarked = false;
const markedSessions = currentSessions.map((session) => {
if (orphanedIds.has(session.id)) {
if (session.scopeDangling) return session;
sessionsMarked = true;
return { ...session, scopeDangling: true };
}
if (
session.scopeDangling
&& (!session.scope.targetId || activeTargetIds.has(session.scope.targetId))
) {
sessionsMarked = true;
const { scopeDangling: _cleared, ...rest } = session;
return rest as AISession;
}
return session;
});
if (sessionsMarked) {
setLatestAISessionsSnapshot(markedSessions);
writeSessionsForStorage(markedSessions);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
}
const activeSessionIdMap = preCleanupActiveSessionMap;
let activeSessionMapChanged = false;
const nextActiveSessionIdMap = { ...activeSessionIdMap };
for (const scopeKey of Object.keys(activeSessionIdMap)) {
if (isScopeKeyActive(scopeKey, activeTargetIds)) continue;
delete nextActiveSessionIdMap[scopeKey];
activeSessionMapChanged = true;
}
if (activeSessionMapChanged) {
setLatestAIActiveSessionMapSnapshot(nextActiveSessionIdMap);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, nextActiveSessionIdMap);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
const currentActiveSessionIdMap = activeSessionMapChanged
? nextActiveSessionIdMap
: activeSessionIdMap;
// Issue #4015 R2: keep active-pointer entries for scopes whose target
// closed. Clearing them used to make dangling chats invisible (rank 0) and
// unrecoverable from the UI; the pointer is rewritten on rebind instead.
const currentDraftsByScope = latestAIDraftsByScopeSnapshot ?? {};
const currentPanelViewByScope = latestAIPanelViewByScopeSnapshot ?? {};
const prunedScopedTransientState = pruneInactiveScopedTransientState(
currentActiveSessionIdMap,
const prunedScopedState = pruneInactiveScopedState(
currentDraftsByScope,
currentPanelViewByScope,
activeTargetIds,
);
if (prunedScopedTransientState.activeSessionIdMap !== currentActiveSessionIdMap) {
setLatestAIActiveSessionMapSnapshot(prunedScopedTransientState.activeSessionIdMap);
localStorageAdapter.write(
STORAGE_KEY_AI_ACTIVE_SESSION_MAP,
prunedScopedTransientState.activeSessionIdMap,
);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
}
if (prunedScopedTransientState.draftsByScope !== currentDraftsByScope) {
if (prunedScopedState.draftsByScope !== currentDraftsByScope) {
for (const scopeKey of Object.keys(currentDraftsByScope)) {
if (scopeKey in prunedScopedTransientState.draftsByScope) continue;
if (scopeKey in prunedScopedState.draftsByScope) continue;
bumpDraftMutationVersion(scopeKey);
bumpDraftUploadGeneration(scopeKey);
}
setLatestAIDraftsByScopeSnapshot(prunedScopedTransientState.draftsByScope);
setLatestAIDraftsByScopeSnapshot(prunedScopedState.draftsByScope);
emitAIStateChanged(AI_STATE_CHANGED_DRAFTS_BY_SCOPE);
}
if (prunedScopedTransientState.panelViewByScope !== currentPanelViewByScope) {
if (prunedScopedState.panelViewByScope !== currentPanelViewByScope) {
for (const scopeKey of Object.keys(currentPanelViewByScope)) {
if (scopeKey in prunedScopedTransientState.panelViewByScope) continue;
if (scopeKey in prunedScopedState.panelViewByScope) continue;
bumpDraftMutationVersion(scopeKey);
}
setLatestAIPanelViewByScopeSnapshot(prunedScopedTransientState.panelViewByScope);
setLatestAIPanelViewByScopeSnapshot(prunedScopedState.panelViewByScope);
emitAIStateChanged(AI_STATE_CHANGED_PANEL_VIEW_BY_SCOPE);
}
}
@@ -349,6 +355,10 @@ export function pruneSessionsForStorage(sessions: AISession[]): AISession[] {
});
}
export function prepareRestoredSessions(sessions: AISession[], syncEnabled: boolean): AISession[] {
return syncEnabled ? sessions : pruneSessionsForStorage(sessions);
}
/**
* Serialize sessions for localStorage under a byte budget, escalating pruning
* as needed. Returns the JSON to persist plus the (possibly) further-pruned
@@ -486,11 +496,47 @@ export function serializeSessionsForStorage(
* Returns true when the write succeeded; a false result means the payload
* could not be persisted even after escalation (it stays memory-only).
*/
// Durability mirror (issue #4015 follow-up): every successful store write
// also schedules a debounced disk backup so a wiped/recreated profile does
// not take the chat history with it. The exact JSON that landed in
// localStorage is mirrored (already byte-budgeted), throttled to at most one
// write per mutation batch.
const backupMirror = new DebouncedBackupMirror((json) => {
try {
void getAIBridge()?.aiSessionsBackupWrite?.({ json })?.catch(() => {});
} catch {
// Backup failures must never break chat persistence.
}
});
export function flushBackupMirror() {
try {
backupMirror.flushNow();
} catch {
// Unload and persistence must remain fail-safe.
}
}
export function __resetBackupMirrorForTests() {
backupMirror.reset();
}
export function scheduleBackupMirror(json: string) {
try {
backupMirror.schedule(json);
} catch {
// ignore
}
}
export function writeSessionsForStorage(sessions: AISession[]): boolean {
// Synced records are authoritative after restart: silently evicting a
// session or turn here would turn local retention into cloud deletion.
if (localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled) {
return localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
const syncedOk = localStorageAdapter.write(STORAGE_KEY_AI_SESSIONS, sessions);
if (syncedOk) scheduleBackupMirror(JSON.stringify(sessions));
return syncedOk;
}
let previousLength = Number.POSITIVE_INFINITY;
for (const configuredBudget of [MAX_SESSIONS_JSON_BYTES, ...RETRY_SESSIONS_JSON_BYTES]) {
@@ -505,7 +551,10 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
if (budget < 2) break;
const candidate = serializeSessionsForStorage(sessions, budget);
if (candidate.json.length >= previousLength) continue;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) return true;
if (localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, candidate.json)) {
scheduleBackupMirror(candidate.json);
return true;
}
previousLength = candidate.json.length;
}
// Last resort: attempt the smallest representation this serializer can
@@ -516,6 +565,7 @@ export function writeSessionsForStorage(sessions: AISession[]): boolean {
minimalCandidate.json.length < previousLength
&& localStorageAdapter.writeString(STORAGE_KEY_AI_SESSIONS, minimalCandidate.json)
) {
scheduleBackupMirror(minimalCandidate.json);
return true;
}
console.warn(
@@ -531,6 +581,28 @@ export let latestAIPanelViewByScopeSnapshot: PanelViewByScope | null = null;
let latestAIDraftMutationVersionByScopeSnapshot: Record<string, number> = {};
let latestAIDraftUploadGenerationByScopeSnapshot: Record<string, number> = {};
/**
* Issue #4015 R2: rewrite the active-session map when rebinding a dangling
* chat. Drops every pointer that still names `sessionId` under a different
* scope key, then sets the new scope as active. Callers must persist and
* publish the returned map (do not rebuild from a stale snapshot).
*/
export function rebindActiveSessionMap(
activeSessionIdMap: Record<string, string | null>,
sessionId: string,
scopeKey: string,
): Record<string, string | null> {
let next = activeSessionIdMap;
for (const [key, id] of Object.entries(activeSessionIdMap)) {
if (id !== sessionId || key === scopeKey) continue;
if (next === activeSessionIdMap) next = { ...activeSessionIdMap };
delete next[key];
}
if (next[scopeKey] === sessionId) return next;
return { ...next, [scopeKey]: sessionId };
}
export function setLatestAISessionsSnapshot(sessions: AISession[]) {
latestAISessionsSnapshot = sessions;
}
+182 -2
View File
@@ -20,6 +20,7 @@ import {
STORAGE_KEY_AI_AGENT_THINKING_MAP,
STORAGE_KEY_AI_WEB_SEARCH,
STORAGE_KEY_AI_QUICK_MESSAGES,
STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS,
} from '../../infrastructure/config/storageKeys';
import type { AIQuickMessage } from '../../infrastructure/ai/quickMessages';
import { sanitizeQuickMessages } from '../../infrastructure/ai/quickMessages';
@@ -63,12 +64,17 @@ import {
bumpDraftUploadGeneration,
cleanupDeletedAIChatSessions,
cleanupOrphanedAISessions,
latestAIActiveTargetIdsSnapshot,
getAIBridge,
flushBackupMirror,
getDraftUploadGeneration,
latestAIActiveSessionMapSnapshot,
latestAIDraftsByScopeSnapshot,
latestAIPanelViewByScopeSnapshot,
latestAISessionsSnapshot,
prepareRestoredSessions,
scheduleBackupMirror,
rebindActiveSessionMap,
writeSessionsForStorage,
setLatestAIActiveSessionMapSnapshot,
setLatestAIDraftsByScopeSnapshot,
@@ -77,6 +83,12 @@ import {
type DraftsByScope,
type PanelViewByScope,
} from './aiStateSnapshots';
import {
isMissingOrCorruptSessionsStore,
mergeImportedSessions,
parseBackupSessions,
readLiveSessionsArray,
} from './aiSessionsBackup';
import { AI_STATE_CHANGED_EVENT, emitAIStateChanged } from './aiStateEvents';
import {
persistCommandBlocklistSetting,
@@ -168,9 +180,76 @@ export function useAIState() {
// ── Sessions ──
const [sessions, setSessionsRaw] = useState<AISession[]>(() =>
latestAISessionsSnapshot
?? localStorageAdapter.read<AISession[]>(STORAGE_KEY_AI_SESSIONS)
?? []
?? readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))
);
// An explicit empty array is authoritative, even if the backup is stale.
// Only a missing or corrupt store can be restored from disk.
useEffect(() => {
window.addEventListener('beforeunload', flushBackupMirror);
return () => window.removeEventListener('beforeunload', flushBackupMirror);
}, []);
useEffect(() => {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) {
if (readLiveSessionsArray(liveRaw).length > 0 && liveRaw != null) scheduleBackupMirror(liveRaw);
return;
}
if (latestAISessionsSnapshot && latestAISessionsSnapshot.length > 0) return;
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupReadLatest) return;
let cancelled = false;
void (async () => {
try {
const liveRaw = localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS);
if (!isMissingOrCorruptSessionsStore(liveRaw)) return;
const backup = await bridge.aiSessionsBackupReadLatest?.();
if (cancelled || !backup?.ok) return;
// Re-check after the async gap: a session may have been created
// while the backup was being read; never restore over live state.
// Cover both persisted storage and in-memory snapshot/React state —
// createSession updates the snapshot before the debounced disk write.
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const restored = prepareRestoredSessions(parseBackupSessions(backup.json) ?? [], syncEnabled);
if (restored.length === 0) return;
// Apply restore synchronously (not inside a setState updater). A
// deferred updater would leave didRestore false and skip dangling
// marking even when the records later land in React state.
if ((latestAISessionsSnapshot?.length ?? 0) > 0) return;
if (sessionsRef.current.length > 0) return;
if (!isMissingOrCorruptSessionsStore(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS))) {
return;
}
setLatestAISessionsSnapshot(restored);
writeSessionsForStorage(restored);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Restore does not change live terminals/workspaces, so the orphan-
// cleanup effect will not run. Reuse the last known live target set
// (empty is valid: nothing live) so chats bound to missing scopes
// become dangling and /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions = latestAISessionsSnapshot ?? restored;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
console.info(
`[AIState] Restored ${restored.length} AI session(s) from the disk backup.`,
);
} catch {
// Backup restore must never break startup.
}
})();
return () => {
cancelled = true;
};
}, []);
// Ref that always holds the latest sessions for use inside debounced callbacks
const sessionsRef = useRef(sessions);
useEffect(() => {
@@ -866,6 +945,101 @@ export function useAIState() {
});
}, [persistSessions]);
// Issue #4015 R2: rebind a dangling chat to the panel's current scope.
// Rewrites `scope`, clears the dangling marker and makes the chat the
// active session of its new scope so it shows in the scoped history again.
const rebindSessionScope = useCallback((
sessionId: string,
scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] },
) => {
const scopeKey = `${scope.type}:${scope.targetId}`;
setSessionsRaw(prev => {
const next = prev.map(s => {
if (s.id !== sessionId) return s;
const { scopeDangling: _cleared, ...rest } = s;
// Replace scope entirely (including hostIds) so history matching follows
// the destination terminal/workspace rather than the chat's old host.
const nextScope: AISession['scope'] = {
type: scope.type,
targetId: scope.targetId,
};
if (scope.hostIds && scope.hostIds.length > 0) {
nextScope.hostIds = [...scope.hostIds];
}
return {
...rest,
scope: nextScope,
updatedAt: Date.now(),
} as AISession;
});
setLatestAISessionsSnapshot(next);
persistSessions(next);
return next;
});
// Drop stale pointers that still reference this chat under its previous
// scope key, then restore the active pointer for the new scope.
// Compute + persist inside the updater so a deferred React setState still
// writes the cleaned map (outer capture would stay null and skip persist).
setActiveSessionIdMapRaw(prev => {
const next = rebindActiveSessionMap(prev, sessionId, scopeKey);
setLatestAIActiveSessionMapSnapshot(next);
localStorageAdapter.write(STORAGE_KEY_AI_ACTIVE_SESSION_MAP, next);
emitAIStateChanged(STORAGE_KEY_AI_ACTIVE_SESSION_MAP);
return next;
});
// Open the rebound chat in this panel; active-map alone loses to an
// explicit draft/session panelView that still points elsewhere.
setPanelViewByScope((prev) => setSessionView(prev, scopeKey, sessionId));
}, [persistSessions, setPanelViewByScope]);
// Manual durability escape hatch (issue #4015 follow-up): export the
// current store to a user-chosen JSON file, or import one back in.
// Import is an explicit action, so it may overwrite the live store.
const exportSessionsBackup = useCallback(async (): Promise<string | null> => {
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupExport) return null;
const result = await bridge.aiSessionsBackupExport({
json: JSON.stringify(sessionsRef.current),
});
return result?.success ? (result.filePath ?? '') : null;
}, []);
const importSessionsBackup = useCallback(async (): Promise<number> => {
const bridge = getAIBridge();
if (!bridge?.aiSessionsBackupImport) return 0;
const result = await bridge.aiSessionsBackupImport();
if (!result?.success || !result.json) return 0;
const imported = parseBackupSessions(result.json);
if (!imported || imported.length === 0) return 0;
// Merge synchronously against the live ref so orphan cleanup sees the
// imported chats even if React would defer a setState updater.
const syncEnabled = !!localStorageAdapter.read<{ enabled?: boolean }>(STORAGE_KEY_CLOUD_SYNC_AI_SESSIONS)?.enabled;
const next = prepareRestoredSessions(mergeImportedSessions(sessionsRef.current, imported), syncEnabled);
if (!writeSessionsForStorage(next)) return 0;
const persisted = readLiveSessionsArray(localStorageAdapter.readString(STORAGE_KEY_AI_SESSIONS));
const importedIds = new Set(imported.map(session => session.id));
sessionsRef.current = persisted;
setLatestAISessionsSnapshot(persisted);
setSessionsRaw(persisted);
emitAIStateChanged(STORAGE_KEY_AI_SESSIONS);
// Import does not change live terminals/workspaces, so the orphan-cleanup
// effect will not run. Reuse the last known live target set (empty is
// valid: nothing live) to mark chats bound to missing scopes as dangling
// so /chats can offer /rebind.
cleanupOrphanedAISessions(latestAIActiveTargetIdsSnapshot);
const nextSessions =
latestAISessionsSnapshot
?? persisted;
sessionsRef.current = nextSessions;
setSessionsRaw(nextSessions);
setActiveSessionIdMapRaw(
latestAIActiveSessionMapSnapshot
?? localStorageAdapter.read<Record<string, string | null>>(STORAGE_KEY_AI_ACTIVE_SESSION_MAP)
?? {},
);
return persisted.filter(session => importedIds.has(session.id)).length;
}, []);
const updateSessionExternalSessionId = useCallback((sessionId: string, externalSessionId: string | undefined) => {
setSessionsRaw(prev => {
const next = prev.map(s => (
@@ -1370,6 +1544,9 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
@@ -1432,6 +1609,9 @@ export function useAIState() {
deleteSession,
deleteSessionsByTarget,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useRef, useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import type { PluginAgentHostTerminal } from '@netcatty/plugin-contract';
import { createTrustedPluginAgentRuntime } from '../../infrastructure/ai/pluginAgent/trustedAgentRuntime';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
import { getAppAppLockRuntime } from './appRuntimeBridge';
import { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } from '../../infrastructure/persistence/localStorageAdapter';
import { STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL, STORAGE_KEY_AI_AGENT_MODEL_MAP,
STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS } from '../../infrastructure/config/storageKeys';
type Choice = { items: PluginAgentHostTerminal[]; confirm: (ids: string[]) => void; cancel: () => void };
// All inventory remains in trusted application UI. Only confirmed metadata is
// passed to the main-owned worker bindings; focus never determines a target.
export function usePluginAgentHost(sessions: TerminalSession[]) {
const latest = useRef(sessions); latest.current = sessions;
const [choice, setChoice] = useState<Choice | null>(null);
useEffect(() => {
const api = netcattyBridge.get();
if (!api?.onPluginAgentHostRequest || !api.setPluginAgentHostReady || !api.respondPluginAgentHost) return;
const bridge = { subscribe: api.onPluginAgentHostRequest.bind(api), ready: api.setPluginAgentHostReady.bind(api), respond: api.respondPluginAgentHost.bind(api) };
let mounted = true;
let chooser: { runtimeId: string; cancel: () => void } | null = null;
const requests = new Map<string, { runtimeId: string; controller: AbortController }>();
const runtime = createTrustedPluginAgentRuntime({
isActive: () => mounted && document.visibilityState === 'visible' && getAppAppLockRuntime()?.locked === false,
chooseTargets: (signal, runtimeId) => new Promise((resolve, reject) => {
if (chooser) { reject(Error('A target selection is already pending')); return; }
const items = latest.current.filter((item) => item.status === 'connected').map((item) => ({
sessionId: item.id, hostId: item.hostId, hostname: (item.hostname || '').slice(0, 253),
label: (item.customName || item.hostLabel || item.id).slice(0, 160),
...(item.protocol ? { protocol: item.protocol } : {}), ...(item.username ? { username: item.username.slice(0, 160) } : {}), connected: true,
}));
const finish = (ids: string[] | null) => {
if (chooser?.cancel !== cancel) return;
chooser = null; signal.removeEventListener('abort', cancel); setChoice(null);
if (ids === null || signal.aborted || ids.length > 32 || new Set(ids).size !== ids.length) { reject(Error('Target selection cancelled')); return; }
const selected = ids.map((id) => items.find((item) => item.sessionId === id));
if (selected.some((item) => !item || !latest.current.some((session) => session.id === item.sessionId && session.status === 'connected'))) {
reject(Error('Selected terminal is no longer connected')); return;
}
resolve(selected as PluginAgentHostTerminal[]);
};
const cancel = () => finish(null);
chooser = { runtimeId, cancel }; signal.addEventListener('abort', cancel, { once: true });
setChoice({ items, confirm: finish, cancel });
if (signal.aborted) cancel();
}),
});
const unsubscribe = bridge.subscribe((request) => {
if (request.kind === 'request.cancel') {
const pending = requests.get(request.payload.requestId);
if (pending?.runtimeId === request.runtimeId) pending.controller.abort();
runtime.cancelRequest(request.payload.requestId, request.runtimeId); return;
}
if (request.kind === 'runtime.release') {
for (const item of requests.values()) if (item.runtimeId === request.runtimeId) item.controller.abort();
if (chooser?.runtimeId === request.runtimeId) chooser.cancel();
runtime.releaseRuntime(request.runtimeId); return;
}
if (request.kind === 'agent.close') { runtime.releaseChat(request.payload.chatSessionId, request.runtimeId); return; }
if (requests.has(request.requestId) || requests.size >= 64) return;
const controller = new AbortController(); requests.set(request.requestId, { runtimeId: request.runtimeId, controller });
void runtime.handle(request, controller.signal).then(
(result) => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, result }).catch(() =>
bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true })),
() => bridge.respond({ requestId: request.requestId, actionToken: request.actionToken, error: true }),
).catch(() => {}).finally(() => requests.delete(request.requestId));
});
const pageHide = () => { runtime.shutdown(); for (const item of requests.values()) item.controller.abort(); };
const configurationKeys = new Set([STORAGE_KEY_AI_PROVIDERS, STORAGE_KEY_AI_ACTIVE_PROVIDER, STORAGE_KEY_AI_ACTIVE_MODEL,
STORAGE_KEY_AI_AGENT_MODEL_MAP, STORAGE_KEY_AI_AGENT_PROVIDER_MAP, STORAGE_KEY_AI_EXTERNAL_AGENTS]);
const configurationChanged = (event: Event) => {
const key = event.type === 'storage' ? (event as StorageEvent).key : (event as CustomEvent<{ key: string }>).detail?.key;
if (key === null || (key && configurationKeys.has(key))) {
runtime.invalidateConfiguration();
// Invalidate main-owned backend leases too, even with no event read in
// flight. Re-advertising availability does not renew existing handles.
void bridge.ready(false).then(() => { if (mounted) return bridge.ready(true); }).catch(() => {});
}
};
window.addEventListener('pagehide', pageHide);
window.addEventListener('storage', configurationChanged); window.addEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
void bridge.ready(true).catch(() => {});
return () => {
mounted = false; chooser?.cancel(); unsubscribe(); window.removeEventListener('pagehide', pageHide);
window.removeEventListener('storage', configurationChanged); window.removeEventListener(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, configurationChanged);
for (const item of requests.values()) item.controller.abort(); requests.clear(); runtime.shutdown();
void bridge.ready(false).catch(() => {});
};
}, []);
return { choice };
}
+58 -24
View File
@@ -24,6 +24,7 @@ reorderWorkspaceFocusSessionOrder,
SplitDirection,
SplitHint,
updateWorkspaceSplitSizes,
invertSplitPosition,
} from '../../domain/workspace';
import { clearSessionFontSizeOverride as clearSessionFontSizeOverrideFields } from '../../domain/terminalAppearance';
import {
@@ -795,12 +796,68 @@ export const useSessionState = ({
return workspace.id;
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
// Merge two tabs into one workspace. When one of them already belongs to a
// workspace, that workspace (and its id) is reused instead of dissolving
// and recreating: the id is what workspace-scoped AI chats bind to, so
// regenerating it used to strand every conversation anchored to it
// (issue #4015 R3). Only two genuinely standalone tabs create a new id.
const createWorkspaceFromSessions = useCallback((
baseSessionId: string,
joiningSessionId: string,
hint: SplitHint
) => {
if (!hint || baseSessionId === joiningSessionId) return;
const baseSession = sessionsRef.current.find((s) => s.id === baseSessionId);
const joiningSession = sessionsRef.current.find((s) => s.id === joiningSessionId);
const anchorWorkspaceId = baseSession?.workspaceId ?? joiningSession?.workspaceId;
if (anchorWorkspaceId) {
const anchorSessionId = baseSession?.workspaceId ? baseSessionId : joiningSessionId;
const orphanSessionId = anchorSessionId === baseSessionId ? joiningSessionId : baseSessionId;
const orphanSession = sessionsRef.current.find((s) => s.id === orphanSessionId);
if (!orphanSession || orphanSession.workspaceId) return;
// Split next to the anchor's existing pane: the raw drop hint targets
// the orphan's pane, which is not part of this workspace tree yet.
// When the dragged (joining) pane already owns the workspace, the orphan
// is what gets inserted — invert the drop position so the previewed
// side still matches (hint describes where the dragged pane should sit).
const joinOwnsWorkspace = Boolean(joiningSession?.workspaceId) && !baseSession?.workspaceId;
const insertHint = {
...hint,
targetSessionId: anchorSessionId,
position: joinOwnsWorkspace ? invertSplitPosition(hint.position) : hint.position,
};
addSessionToWorkspace(
anchorWorkspaceId,
orphanSessionId,
insertHint,
);
// The orphan's standalone tab slot is replaced by the workspace tab.
setTabOrder((prevTabOrder) => prevTabOrder.filter((id) => id !== orphanSessionId));
return;
}
// Name the merged workspace after both tabs' connection labels (e.g.
// "01/02") so the window title and the rename dialog show the merged
// identity instead of the generic "Workspace". Falls back to the default
@@ -855,30 +912,7 @@ export const useSessionState = ({
return s;
});
});
}, [setActiveTabId]);
const addSessionToWorkspace = useCallback((
workspaceId: string,
sessionId: string,
hint: SplitHint
) => {
if (!hint) return;
setSessions(prevSessions => {
const session = prevSessions.find(s => s.id === sessionId);
if (!session || session.workspaceId) return prevSessions;
setWorkspaces(prevWorkspaces => {
const targetWorkspace = prevWorkspaces.find(w => w.id === workspaceId);
if (!targetWorkspace) return prevWorkspaces;
return insertWorkspacePaneIfMissing(prevWorkspaces, workspaceId, sessionId, hint);
});
setActiveTabId(workspaceId);
return prevSessions.map(s => s.id === sessionId ? { ...s, workspaceId } : s);
});
}, [setActiveTabId]);
}, [addSessionToWorkspace, setActiveTabId]);
// Add a host into an existing workspace by creating a new session for
// that host and appending it as the last pane at the workspace root.
@@ -0,0 +1,147 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { collectSessionIds } from "../../domain/workspace.ts";
import { useSessionState } from "./useSessionState.ts";
type SessionState = ReturnType<typeof useSessionState>;
test("merging a workspace tab with a standalone tab reuses the workspace id (issue #4015 R3)", async (t) => {
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, "window");
const eventTarget = new EventTarget() as EventTarget & Record<string, unknown>;
Object.assign(eventTarget, {
setTimeout,
clearTimeout,
});
Object.defineProperty(globalThis, "window", {
configurable: true,
value: eventTarget,
});
t.after(() => {
if (previousWindow) Object.defineProperty(globalThis, "window", previousWindow);
else Reflect.deleteProperty(globalThis, "window");
});
const actEnvironment = globalThis as typeof globalThis & {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
t.after(() => {
actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
});
let state: SessionState | null = null;
let renderer: ReactTestRenderer | null = null;
const current = (): SessionState => {
assert.ok(state);
return state;
};
function Probe() {
state = useSessionState({ persistSessionRestore: false });
return null;
}
await act(async () => {
renderer = create(React.createElement(Probe));
});
let baseId = "";
let memberId = "";
let standaloneId = "";
await act(async () => {
baseId = current().createLocalTerminal({ shellName: "01" });
memberId = current().createLocalTerminal({ shellName: "02" });
standaloneId = current().createLocalTerminal({ shellName: "03" });
});
await act(async () => {
current().createWorkspaceFromSessions(baseId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: baseId,
});
});
const workspaceId = current().workspaces[0]?.id;
assert.ok(workspaceId);
assert.equal(current().sessions.find((s) => s.id === standaloneId)?.workspaceId ?? "", "");
// Dropping the standalone tab onto a member of the workspace must join the
// existing workspace — regenerating the id would strand AI chats bound to it.
await act(async () => {
current().createWorkspaceFromSessions(standaloneId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: standaloneId,
});
});
assert.equal(current().workspaces.length, 1);
assert.equal(current().workspaces[0]?.id, workspaceId);
assert.equal(
current().sessions.find((s) => s.id === standaloneId)?.workspaceId,
workspaceId,
);
const memberIds = collectSessionIds(current().workspaces[0]!.root);
assert.ok(memberIds.includes(standaloneId));
assert.ok(memberIds.includes(memberId));
// The standalone tab slot is replaced by the workspace tab.
assert.ok(!current().orderedTabs.includes(standaloneId));
// Dropping a workspace member onto a standalone tab must insert the
// standalone on the opposite side of the drop hint (hint describes where
// the dragged member should appear relative to the standalone).
let orientationStandaloneId = "";
let orientationMemberId = "";
await act(async () => {
orientationStandaloneId = current().createLocalTerminal({ shellName: "orient-standalone" });
});
// memberId is already in workspaceId; drop member onto the new standalone with position "right"
// (preview: member to the right of standalone ⇒ standalone should end up LEFT of member).
await act(async () => {
current().createWorkspaceFromSessions(orientationStandaloneId, memberId, {
direction: "vertical",
position: "right",
targetSessionId: orientationStandaloneId,
});
});
{
const root = current().workspaces.find((ws) => ws.id === workspaceId)?.root;
assert.ok(root);
const ordered = collectSessionIds(root!);
const standaloneIdx = ordered.indexOf(orientationStandaloneId);
const memberIdx = ordered.indexOf(memberId);
assert.ok(standaloneIdx >= 0 && memberIdx >= 0);
assert.ok(
standaloneIdx < memberIdx,
`expected standalone left of member after inverted right-drop, got order ${ordered.join(",")}`,
);
}
// Two genuinely standalone tabs still create a fresh workspace.
let firstStandaloneId = "";
let secondStandaloneId = "";
await act(async () => {
firstStandaloneId = current().createLocalTerminal({ shellName: "04" });
secondStandaloneId = current().createLocalTerminal({ shellName: "05" });
});
await act(async () => {
current().createWorkspaceFromSessions(firstStandaloneId, secondStandaloneId, {
direction: "vertical",
position: "right",
targetSessionId: firstStandaloneId,
});
});
assert.equal(current().workspaces.length, 2);
const freshWorkspace = current().workspaces.find((candidate) => candidate.id !== workspaceId);
assert.ok(freshWorkspace);
assert.notEqual(freshWorkspace.id, workspaceId);
await act(async () => {
renderer?.unmount();
});
});
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { useToolAccessGuidanceState, type ToolAccessGuidanceState } from './useToolAccessGuidanceState';
import { AI_STATE_CHANGED_EVENT } from './aiStateEvents';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
function deferred<T>() {
let resolve!: (value: T) => void;
const promise = new Promise<T>((done) => { resolve = done; });
return { promise, resolve };
}
async function harness() {
const requests: ReturnType<typeof deferred<unknown>>[] = [];
const events = new EventTarget();
const values = new Map([[STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED, 'true']]);
const previous = new Map<string, PropertyDescriptor | undefined>();
const globals = {
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) },
window: {
netcatty: {
externalMcpGetStatus: () => { const request = deferred<unknown>(); requests.push(request); return request.promise; },
aiSkillsCliGetInvocation: async () => ({ ok: true, skillPath: '/fixture/skills/SKILL.md', commandPrefix: 'fixture-cli' }),
},
addEventListener: events.addEventListener.bind(events), removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events), setTimeout, clearTimeout,
},
};
for (const [key, value] of Object.entries(globals)) {
previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
Object.defineProperty(globalThis, key, { value, configurable: true, writable: true });
}
let result!: ToolAccessGuidanceState;
function Harness({ mode }: { mode: AIToolIntegrationMode }) { result = useToolAccessGuidanceState(mode); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(<Harness mode="mcp" />); });
return {
requests, events, values, result: () => result,
skills: async () => { await act(async () => renderer.update(<Harness mode="skills" />)); },
close: async () => {
await act(async () => renderer.unmount());
for (const [key, value] of previous) { if (value) Object.defineProperty(globalThis, key, value); else Reflect.deleteProperty(globalThis, key); }
},
};
}
const status = (path: string) => ({ ok: true, enabled: true, state: 'running', launcherPath: path, discoveryPath: `${path}.json` });
test('older MCP guidance response cannot overwrite a newer enable-state refresh', async () => {
const h = await harness();
try {
assert.equal(h.requests.length, 1);
await act(async () => { h.events.dispatchEvent(new CustomEvent(AI_STATE_CHANGED_EVENT, { detail: { key: STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } })); });
assert.equal(h.requests.length, 2);
await act(async () => h.requests[1].resolve(status('/fixture/new')));
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
await act(async () => h.requests[0].resolve(status('/fixture/old')));
assert.equal(h.result().mcpLauncherPath, '/fixture/new');
} finally { await h.close(); }
});
test('switching to Skills ignores pending MCP results and preserves resolved Skills guidance', async () => {
const h = await harness();
try {
await h.skills();
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
assert.equal(h.result().mcpLauncherPath, null);
assert.equal(h.result().skillPath, '/fixture/skills/SKILL.md');
assert.equal(h.result().commandPrefix, 'fixture-cli');
} finally { await h.close(); }
});
test('MCP guidance completion after unmount has no visible state or retry work', async () => {
const h = await harness();
const initial = h.result();
await h.close();
await act(async () => h.requests[0].resolve(status('/fixture/stale')));
assert.equal(h.result(), initial);
});
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AIToolIntegrationMode } from '../../infrastructure/ai/types';
import { STORAGE_KEY_AI_EXTERNAL_MCP_ENABLED } from '../../infrastructure/config/storageKeys';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
@@ -38,14 +38,15 @@ export type ToolAccessGuidanceState = {
* runtime is still starting or after an idle shutdown).
*/
export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAccessGuidanceState {
const requestGeneration = useRef(0);
const [skillPath, setSkillPath] = useState<string | null>(null);
const [commandPrefix, setCommandPrefix] = useState('');
const [mcpLauncherPath, setMcpLauncherPath] = useState<string | null>(null);
const [mcpDiscoveryPath, setMcpDiscoveryPath] = useState<string | null>(null);
const refreshMcpStatus = useCallback(async (): Promise<string | null | undefined> => {
const refreshMcpStatus = useCallback(async (isCurrent: () => boolean): Promise<string | null | undefined> => {
const status = await netcattyBridge.get()?.externalMcpGetStatus?.();
if (!status?.ok) return undefined;
if (!isCurrent() || !status?.ok) return undefined;
// Do not trust `enabled` alone: the main process flips `enabled` before
// host credentials are resolved and the discovery file is written, and it
// keeps reporting the configured discovery/launcher paths while starting.
@@ -93,9 +94,11 @@ export function useToolAccessGuidanceState(mode: AIToolIntegrationMode): ToolAcc
const refetch = async () => {
if (cancelled) return;
const generation = ++requestGeneration.current;
const isCurrent = () => !cancelled && generation === requestGeneration.current;
const storedEnabled = readExternalMcpStoredEnabled();
const readyLauncherPath = await refreshMcpStatus().catch(() => undefined);
if (cancelled) return;
const readyLauncherPath = await refreshMcpStatus(isCurrent).catch(() => undefined);
if (!isCurrent()) return;
// The persisted switch is off but the status check failed: drop any
// cached launcher/discovery paths. The stored credentials were revoked
// by the disable, and falling through to the comparison below would see
@@ -0,0 +1,63 @@
import assert from "node:assert/strict";
import test from "node:test";
import React from "react";
import { act, create, type ReactTestRenderer } from "react-test-renderer";
import { JSDOM } from "jsdom";
import { installBrowserLockFixture } from "../testSupport/browserLockFixture";
import { STORAGE_KEY_SHELL_HISTORY } from "../../infrastructure/config/storageKeys";
test("shell history append yields the input turn and persists merged entries", async () => {
const dom = new JSDOM("", { url: "http://localhost" });
const globals = ["window", "localStorage", "CustomEvent", "IS_REACT_ACT_ENVIRONMENT", "navigator"];
const previous = new Map(globals.map(key => [key, Object.getOwnPropertyDescriptor(globalThis, key)]));
Object.defineProperties(globalThis, {
window: { value: dom.window, configurable: true },
localStorage: { value: dom.window.localStorage, configurable: true },
CustomEvent: { value: dom.window.CustomEvent, configurable: true },
IS_REACT_ACT_ENVIRONMENT: { value: true, configurable: true },
});
installBrowserLockFixture();
const { useVaultState } = await import("./useVaultState");
let state: ReturnType<typeof useVaultState>;
let renderer: ReactTestRenderer | undefined;
const Probe = () => { state = useVaultState({ syncConnectionLogTerminalData: false }); return null; };
try {
await act(async () => { renderer = create(React.createElement(Probe)); });
for (let attempt = 0; !state!.isInitialized && attempt < 50; attempt++) {
await act(async () => { await new Promise(resolve => setTimeout(resolve, 10)); });
}
assert.equal(state!.isInitialized, true);
const entry = { command: " echo hello ", hostId: "local", hostLabel: "Local", sessionId: "one" };
act(() => {
state!.addShellHistoryEntry(entry);
state!.addShellHistoryEntry({ ...entry, sessionId: "two" });
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY), null);
assert.equal(state!.shellHistory.length, 0);
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!);
assert.equal(saved.length, 1);
assert.equal(saved[0].command, "echo hello");
assert.equal(saved[0].sessionId, "two");
assert.deepEqual(state!.shellHistory, saved);
for (const clear of [() => state!.clearShellHistory(), () => state!.clearAllCommandHistory()]) {
act(() => {
state!.addShellHistoryEntry(entry);
state!.addShellHistoryEntry({ ...entry, command: "another command" });
clear();
});
await act(async () => { await new Promise(resolve => setTimeout(resolve, 20)); });
assert.deepEqual(state!.shellHistory, []);
assert.deepEqual(JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_SHELL_HISTORY)!), []);
}
} finally {
await act(async () => { renderer?.unmount(); });
dom.window.close();
for (const key of globals) {
const descriptor = previous.get(key);
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
+15 -8
View File
@@ -317,6 +317,7 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const [noteGroups, setNoteGroups] = useState<string[]>([]);
const [knownHosts, setKnownHosts] = useState<KnownHost[]>([]);
const [shellHistory, setShellHistory] = useState<ShellHistoryEntry[]>([]);
const shellHistoryGenerationRef = useRef(0);
const [connectionLogs, setConnectionLogs] = useState<ConnectionLog[]>([]);
const [managedSources, setManagedSources] = useState<ManagedSource[]>([]);
const [groupConfigs, setGroupConfigs] = useState<GroupConfig[]>([]);
@@ -1445,19 +1446,25 @@ export const useVaultState = (options?: UseVaultStateOptions) => {
const addShellHistoryEntry = useCallback(
(entry: Omit<ShellHistoryEntry, "id" | "timestamp">) => {
setShellHistory((prev) => {
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
// Persist immediately so crash between commit and layout effect cannot drop history.
// Store republish is also done in useLayoutEffect for load/storage-event paths.
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
const generation = shellHistoryGenerationRef.current;
// Yield the input turn before history rendering and synchronous storage work.
// Persist in the next task to keep the crash-loss window small.
setTimeout(() => {
if (generation !== shellHistoryGenerationRef.current) return;
setShellHistory((prev) => {
if (generation !== shellHistoryGenerationRef.current) return prev;
const updated = mergeGlobalHistoryOnAppend(prev, entry);
if (updated === prev) return prev;
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, updated);
return updated;
});
}, 0);
},
[],
);
const clearShellHistory = useCallback(() => {
shellHistoryGenerationRef.current += 1;
setShellHistory([]);
localStorageAdapter.write(STORAGE_KEY_SHELL_HISTORY, []);
publishShellHistorySnapshot([]);
+32
View File
@@ -40,3 +40,35 @@ test('terminal side panel tools share one primary toolbar height', () => {
assert.match(source, /TERMINAL_SIDE_PANEL_INNER_HEADER_CLASS/);
}
});
test('/rename and /chats panel commands stay out of the agent message path', () => {
const sidePanel = preparingSource;
// Panel implements the commands with updateSessionTitle + a note state —
// never addMessageToSession, so the commands cannot leak into chat history.
assert.match(sidePanel, /handleRenameSession = useCallback/);
assert.match(sidePanel, /handleListChats = useCallback/);
assert.match(sidePanel, /updateSessionTitle\(sessionId, title\)/);
assert.match(sidePanel, /setPanelCommandNote\(formatAiChatsListingNote\(entries, t\)\)/);
const renameHandler = sidePanel.slice(
sidePanel.indexOf('handleRenameSession = useCallback'),
sidePanel.indexOf('const handleRenameSlashCommand'),
);
assert.doesNotMatch(renameHandler, /addMessageToSession/);
const chatsHandler = sidePanel.slice(
sidePanel.indexOf('handleListChats = useCallback'),
sidePanel.indexOf('const handleSend'),
);
assert.doesNotMatch(chatsHandler, /addMessageToSession/);
// Wiring reaches AIChatPanelContent.
assert.match(sidePanel, /onRenameActiveChat=\{handleRenameSlashCommand\}/);
assert.match(sidePanel, /onListChats=\{handleListChats\}/);
});
test('AIChatPanelContent renders the panel command note and wires rename/list props', () => {
assert.match(panelSource, /panelCommandNote \? \(/);
assert.match(panelSource, /role="status"/);
assert.match(panelSource, /onDismissPanelCommandNote/);
assert.match(panelSource, /onRenameSession=\{onRenameActiveChat\}/);
assert.match(panelSource, /onListChats=\{onListChats\}/);
assert.match(panelSource, /onRename=\{onRenameSession\}/);
});
+43
View File
@@ -57,6 +57,20 @@ interface AIChatPanelContentProps {
setInputValue: (value: string) => void;
handleSend: () => void;
handleCompact: () => void;
/** Rename a chat by id (history drawer edit affordance). Panel action, never agent content. */
onRenameSession?: (sessionId: string, title: string) => void;
/** `/rename <title>` for the current chat. Panel action, never agent content. */
onRenameActiveChat?: (title: string) => void;
/** `/chats`: list the chats visible to this panel. Panel action, never agent content. */
onListChats?: () => void;
/** `/rebind <n>`: rebind a dangling chat to this panel's scope. Panel action, never agent content. */
onRebindChat?: (arg: string) => void;
/** `/chats` listing output, rendered as a system-style note above the composer. */
panelCommandNote?: string | null;
onDismissPanelCommandNote?: () => void;
/** Manual durability escape hatch: export/import the whole session store. */
onExportSessions?: () => void;
onImportSessions?: () => void;
/** Undo last turn is offered at a safe boundary (never mid-tool / mid-compaction). */
canUndoLastTurn?: boolean;
handleUndoLastTurn?: () => void;
@@ -142,6 +156,14 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
setInputValue,
handleSend,
handleCompact,
onRenameSession,
onRenameActiveChat,
onListChats,
onRebindChat,
panelCommandNote,
onDismissPanelCommandNote,
onExportSessions,
onImportSessions,
canUndoLastTurn = false,
handleUndoLastTurn,
canCompact = false,
@@ -309,6 +331,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
activeSessionId={activeSessionId}
onSelect={sending ? () => undefined : handleSelectSession}
onDelete={handleDeleteSession}
onRename={onRenameSession}
onExportSessions={onExportSessions}
onImportSessions={onImportSessions}
onClose={() => setShowHistory(false)}
/>
</React.Profiler>
@@ -369,6 +394,21 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
{!hideInput && (
<React.Profiler {...getAIPanelProfilerProps('AIChatPanel.Input')}>
<div>
{panelCommandNote ? (
<div role="status" className="mx-3 mb-1.5 rounded-md border border-border/50 bg-muted/30 px-2.5 py-1.5 text-[11px] leading-4 text-muted-foreground">
<span className="block max-h-48 overflow-y-auto whitespace-pre-wrap break-words">{panelCommandNote}</span>
{onDismissPanelCommandNote && (
<button
type="button"
aria-label={t('common.close')}
onClick={onDismissPanelCommandNote}
className="mt-1 text-[11px] text-muted-foreground/60 underline-offset-2 transition-colors hover:text-foreground hover:underline cursor-pointer"
>
{t('common.close')}
</button>
)}
</div>
) : null}
{steerWarning ? (
<div role="status" className="mx-3 mb-1.5 rounded-md border border-amber-500/25 bg-amber-500/10 px-2.5 py-1.5 text-[11px] leading-4 text-amber-600 dark:text-amber-400">
{steerWarning}
@@ -396,6 +436,9 @@ export const AIChatPanelContent: React.FC<AIChatPanelContentProps> = ({
onChange={setInputValue}
onSend={handleSend}
onCompact={handleCompact}
onRenameSession={onRenameActiveChat}
onListChats={onListChats}
onRebindChat={onRebindChat}
canCompact={canCompact}
onSteer={handleSteer}
onStop={handleStop}
+113 -8
View File
@@ -1,5 +1,5 @@
import React, { useEffect, useId, useRef, useState } from 'react';
import { Search, Trash2, X } from 'lucide-react';
import { Download, Pencil, Search, Trash2, Upload, X } from 'lucide-react';
import type { AISession } from '../infrastructure/ai/types';
import { useI18n } from '../application/i18n/I18nProvider';
import { cn } from '../lib/utils';
@@ -19,6 +19,12 @@ interface SessionHistoryDrawerProps {
activeSessionId: string | null;
onSelect: (sessionId: string) => void;
onDelete: (e: React.MouseEvent, sessionId: string) => void;
/** Rename a chat inline (issue #4015 R5). Empty titles are ignored. */
onRename?: (sessionId: string, title: string) => void;
/** Manual durability escape hatch: export the whole store to a JSON file. */
onExportSessions?: () => void;
/** Import sessions from a JSON export; merges by id, newer wins. */
onImportSessions?: () => void;
onClose: () => void;
}
@@ -30,11 +36,17 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
activeSessionId,
onSelect,
onDelete,
onRename,
onExportSessions,
onImportSessions,
onClose,
}) => {
const { t } = useI18n();
const [renderCount, setRenderCount] = useState(SESSION_RENDER_BATCH);
const [searchQuery, setSearchQuery] = useState('');
const [renamingSessionId, setRenamingSessionId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState('');
const renameInputRef = useRef<HTMLInputElement>(null);
const searchHintId = useId();
const searchInputRef = useRef<HTMLInputElement>(null);
const { filteredSessions, isSearching } = useSessionHistorySearch(
@@ -52,16 +64,69 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
const displayedSessions = filteredSessions.slice(0, renderCount);
const hiddenSessionCount = Math.max(0, filteredSessions.length - renderCount);
useEffect(() => {
if (renamingSessionId) renameInputRef.current?.focus();
}, [renamingSessionId]);
const beginRename = (sessionId: string, currentTitle: string) => {
setRenamingSessionId(sessionId);
setRenameDraft(currentTitle);
};
const commitRename = () => {
if (renamingSessionId && renameDraft.trim()) {
onRename?.(renamingSessionId, renameDraft.trim());
}
setRenamingSessionId(null);
setRenameDraft('');
};
return (
<div className="flex-1 flex flex-col min-h-0">
<div className="px-4 py-2.5 flex items-center justify-between shrink-0 border-b border-border/30">
<span className="text-[13px] font-medium text-foreground/80">{t('ai.chat.allSessions')}</span>
<button
onClick={onClose}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={14} />
</button>
<div className="flex items-center gap-2">
{onImportSessions && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onImportSessions();
}}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
aria-label={t('ai.chat.importSessions')}
>
<Upload size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.importSessions')}</TooltipContent>
</Tooltip>
)}
{onExportSessions && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
onExportSessions();
}}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
aria-label={t('ai.chat.exportSessions')}
>
<Download size={14} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.exportSessions')}</TooltipContent>
</Tooltip>
)}
<button
onClick={onClose}
className="text-[12px] text-muted-foreground/60 hover:text-muted-foreground transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</div>
<div className="px-3 py-2 shrink-0 border-b border-border/30">
<div className="relative">
@@ -146,12 +211,52 @@ export const SessionHistoryDrawer: React.FC<SessionHistoryDrawerProps> = ({
)}
>
<span className={SESSION_HISTORY_ROW_CLASSNAMES.title}>
{session.title || t('ai.chat.untitled')}
{renamingSessionId === session.id ? (
<Input
ref={renameInputRef}
type="text"
value={renameDraft}
aria-label={t('ai.chat.renameChat')}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter') {
e.preventDefault();
commitRename();
} else if (e.key === 'Escape') {
e.preventDefault();
setRenamingSessionId(null);
setRenameDraft('');
}
}}
onChange={(e) => setRenameDraft(e.target.value)}
onBlur={commitRename}
className="h-6 text-[12px]"
/>
) : (
session.title || t('ai.chat.untitled')
)}
</span>
<div className={SESSION_HISTORY_ROW_CLASSNAMES.meta}>
<span className={SESSION_HISTORY_ROW_CLASSNAMES.time}>
{timeStr}
</span>
{onRename && renamingSessionId !== session.id && (
<Tooltip>
<TooltipTrigger asChild>
<button
onClick={(e) => {
e.stopPropagation();
beginRename(session.id, session.title || '');
}}
className={SESSION_HISTORY_ROW_CLASSNAMES.deleteButton}
>
<Pencil size={12} />
</button>
</TooltipTrigger>
<TooltipContent>{t('ai.chat.renameChat')}</TooltipContent>
</Tooltip>
)}
<Tooltip>
<TooltipTrigger asChild>
<button
+137
View File
@@ -64,6 +64,7 @@ import {
type DefaultTargetSessionHint,
} from '../application/state/useAIChatStreaming';
import { getScopedHistorySessions } from './ai/scopedHistorySessions';
import { buildAiChatsListingEntries, canRebindChat, formatAiChatsListingNote, resolveRebindTargetFromArg, type AiChatsListingEntry } from './ai/aiChatsListing';
import { resolveInheritedAIActiveSessionId } from '../domain/aiWorkspaceScopeInherit';
import { resolveUndoLastTurnBoundary } from '../domain/aiUndoLastTurn';
import { aiSessionIdSetEqual, exactScopeAISessionsEqual } from '../domain/aiSessionsForScope';
@@ -279,6 +280,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
undoLastTurnInSession,
deleteSession,
updateSessionTitle,
rebindSessionScope,
exportSessionsBackup,
importSessionsBackup,
updateSessionExternalSessionId,
addMessageToSession,
updateLastMessage,
@@ -312,7 +316,9 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
scopeHostIds,
scopeLabel,
focusedSessionId,
workspaces = [],
terminalSessions = [],
liveScopeTargetIds,
resolveExecutorContext,
isVisible = true,
notes = [],
@@ -327,6 +333,11 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const scopeKey = `${scopeType}:${scopeTargetId ?? ''}`;
const [showHistory, setShowHistory] = useState(false);
// Inline system-style note above the composer (e.g. `/chats` listing).
// Panel-command output, never agent content.
const [panelCommandNote, setPanelCommandNote] = useState<string | null>(null);
// Entries of the last `/chats` listing; `/rebind <n>` resolves against them.
const lastChatsListingRef = useRef<AiChatsListingEntry[]>([]);
const [isSending, setIsSending] = useState(false);
// Undo-last-turn is still
// running. Unlike the send flow this is not reflected in `isSending`, so it
@@ -484,6 +495,119 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
const activeSessionRef = useRef(activeSession);
activeSessionRef.current = activeSession;
/** Rename a chat (slash command + history drawer affordance). Panel action: nothing is sent to the agent. */
const handleRenameSession = useCallback((sessionId: string, rawTitle: string) => {
const title = rawTitle.trim();
if (!title) {
toast.warning(t('ai.chat.renameUsage'));
return;
}
if (!sessionId) {
toast.warning(t('ai.chat.renameNoActive'));
return;
}
updateSessionTitle(sessionId, title);
toast.success(t('ai.chat.renameDone').replace('{title}', title));
}, [t, updateSessionTitle]);
const handleRenameSlashCommand = useCallback((title: string) => {
handleRenameSession(activeSessionRef.current?.id ?? null, title);
}, [handleRenameSession]);
const handleListChats = useCallback(() => {
// Prefer the app-wide live target set so chats bound to other still-open
// terminals are not mislabelled dangling / offered for /rebind.
const liveTerminalIds = liveScopeTargetIds
?? (scopeType === 'workspace' && workspaceMemberTerminalIds
? workspaceMemberTerminalIds
: new Set(scopeTargetId ? [scopeTargetId] : []));
// Dangling chats no longer match any live scope (rank 0), so they must be
// appended explicitly for the listing — that is where they become visible
// and rebindable again (issue #4015 R2).
const listedSessionIds = new Set(historySessions.map((session) => session.id));
const danglingSessions = sessions.filter((session) => session.scopeDangling && !listedSessionIds.has(session.id));
const entries = buildAiChatsListingEntries({
sessions: [...historySessions, ...danglingSessions],
activeSessionId,
externalAgents,
workspaces,
liveTerminalIds,
currentScopeLabel: scopeLabel,
scopeTargetId,
t,
});
lastChatsListingRef.current = entries;
setPanelCommandNote(formatAiChatsListingNote(entries, t));
}, [activeSessionId, externalAgents, historySessions, liveScopeTargetIds, scopeLabel, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
// `/rebind <n>`: rebind the nth dangling chat from the last /chats listing
// to this panel's current scope. Panel action — no agent round-trip.
const handleRebindChat = useCallback((arg: string) => {
if (!scopeTargetId) {
toast.warning(t('ai.chat.rebindUnavailable'));
return;
}
const target = resolveRebindTargetFromArg(arg, lastChatsListingRef.current);
if (!target) {
toast.warning(t('ai.chat.rebindUsage'));
return;
}
const session = sessions.find((item) => item.id === target.sessionId);
const liveTargets = new Set(liveScopeTargetIds ?? workspaceMemberTerminalIds ?? []);
for (const workspace of workspaces) liveTargets.add(workspace.id);
liveTargets.add(scopeTargetId);
if (canRebindChat(session, liveTargets)) {
rebindSessionScope?.(target.sessionId, { type: scopeType, targetId: scopeTargetId, hostIds: scopeHostIds });
toast.success(t('ai.chat.rebindDone').replace('{title}', target.title));
} else {
toast.warning(t('ai.chat.rebindUsage'));
}
// Do not rebuild /chats from this render's stale sessions (still dangling).
// Drop the resolved entry from the stored listing so a repeat /rebind <n>
// cannot target it; the next explicit /chats refreshes from live state.
const remaining = lastChatsListingRef.current.filter(
(entry) => entry.sessionId !== target.sessionId,
);
let danglingCounter = 0;
for (const entry of remaining) {
if (!entry.isDangling) {
entry.rebindNumber = undefined;
continue;
}
danglingCounter += 1;
entry.rebindNumber = danglingCounter;
}
lastChatsListingRef.current = remaining;
setPanelCommandNote(formatAiChatsListingNote(remaining, t));
}, [liveScopeTargetIds, rebindSessionScope, scopeHostIds, scopeTargetId, scopeType, sessions, t, workspaces, workspaceMemberTerminalIds]);
// Manual durability actions (history drawer header). Export writes the
// whole store to a user-chosen JSON file; import merges by id.
const handleExportSessions = useCallback(() => {
void exportSessionsBackup?.().then((filePath) => {
if (filePath == null) {
toast.warning(t('ai.chat.exportUnavailable'));
return;
}
toast.success(
filePath
? t('ai.chat.exportDone').replace('{path}', filePath)
: t('ai.chat.exportDoneNoPath'),
);
});
}, [exportSessionsBackup, t]);
const handleImportSessions = useCallback(() => {
void importSessionsBackup?.().then((count) => {
if (count <= 0) {
toast.warning(t('ai.chat.importNone'));
return;
}
toast.success(t('ai.chat.importDone').replace('{n}', String(count)));
});
}, [importSessionsBackup, t]);
const defaultTargetSession = useMemo<DefaultTargetSessionHint | undefined>(() => {
const connectedSessions = terminalSessions.filter((session) => session.connected !== false);
@@ -2065,6 +2189,14 @@ const AIChatSidePanelActive: React.FC<AIChatSidePanelProps> = ({
setInputValue={setInputValue}
handleSend={handleSend}
handleCompact={handleCompact}
onRenameSession={handleRenameSession}
onRenameActiveChat={handleRenameSlashCommand}
onListChats={handleListChats}
onRebindChat={handleRebindChat}
panelCommandNote={panelCommandNote}
onDismissPanelCommandNote={() => setPanelCommandNote(null)}
onExportSessions={handleExportSessions}
onImportSessions={handleImportSessions}
canUndoLastTurn={canUndoLastTurn}
handleUndoLastTurn={undoLastTurnInSession ? handleUndoLastTurn : undefined}
handleSteer={handleSteer}
@@ -2143,6 +2275,9 @@ const AI_CHAT_SIDE_PANEL_AI_STATE_KEYS = [
'createSession',
'deleteSession',
'updateSessionTitle',
'rebindSessionScope',
'exportSessionsBackup',
'importSessionsBackup',
'updateSessionExternalSessionId',
'addMessageToSession',
'updateLastMessage',
@@ -2193,6 +2328,8 @@ export function aiChatSidePanelPropsAreEqual(
if ((prev.isVisible ?? true) !== (next.isVisible ?? true)) return false;
if (prev.scopeHostIds !== next.scopeHostIds) return false;
if (prev.terminalSessions !== next.terminalSessions) return false;
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
if (prev.workspaces !== next.workspaces) return false;
if (prev.resolveExecutorContext !== next.resolveExecutorContext) return false;
if (prev.notes !== next.notes) return false;
if (prev.hosts !== next.hosts) return false;
+10
View File
@@ -44,6 +44,12 @@ export interface AIChatSidePanelProps {
) => Promise<{ sessionId: string; restored: { text: string; attachments: import('../infrastructure/ai/types').UploadedFile[]; selectedUserSkillSlugs: string[] } } | null>;
deleteSession: (sessionId: string, scopeKey?: string) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
/** Issue #4015 R2: rebind a dangling chat to this panel's scope (panel action, never agent content). */
rebindSessionScope?: (sessionId: string, scope: { type: 'terminal' | 'workspace'; targetId: string; hostIds?: string[] }) => void;
/** Manual durability escape hatch: export the store to a JSON file (returns the path, '' when fixed-path). */
exportSessionsBackup?: () => Promise<string | null>;
/** Import sessions from a JSON export; merges by id. Returns the imported count. */
importSessionsBackup?: () => Promise<number>;
updateSessionExternalSessionId: (sessionId: string, externalSessionId: string | undefined) => void;
addMessageToSession: (sessionId: string, message: ChatMessage) => void;
updateLastMessage: (
@@ -103,6 +109,10 @@ export interface AIChatSidePanelProps {
scopeLabel?: string;
/** Workspace focused pane; preferred when inheriting AI chat after merge. */
focusedSessionId?: string;
/** Live workspaces, used to resolve workspace titles for `/chats`. */
workspaces?: Array<{ id: string; title: string }>;
/** All currently live terminal (and workspace) scope target ids. */
liveScopeTargetIds?: Set<string>;
// Terminal session context (from parent)
terminalSessions?: Array<{
+3 -1
View File
@@ -207,7 +207,9 @@ test('cold plugin host reaches the local fixture through the actual main provide
assert.deepEqual(sdkErrorNames, []);
} finally {
console.error = originalConsoleError;
panel?.window.close();
// JSDOM.close does not deliver the browser pagehide lifecycle. Invalidate
// the sample's pending callbacks before teardown closes its document.
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
await act(async () => root.unmount());
await service?.manager.shutdown(); aiBridge.cleanup();
publishNotesSnapshot(oldNotes); registerAppAppLockRuntime(oldLock);
+193
View File
@@ -0,0 +1,193 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
import { createRequire } from 'node:module';
import { EventEmitter, once } from 'node:events';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const require = createRequire(import.meta.url);
test('installed global Chat UI reuses actual Catty runtime, configured HTTP transport and selected worker dispatch', { timeout: 30_000 }, async () => {
// This Node/JSDOM harness proves installed app wiring, not a native sandbox or
// real model/Claude acceptance. No real shell, remote host or credentials.
const rootPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'netcatty-global-agent-ui-'));
const oldTmp = process.env.TMPDIR; process.env.TMPDIR = directory;
const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { pretendToBeVisual: true, url: 'http://localhost' });
const previous = new Map<string, PropertyDescriptor | undefined>();
const install = (key: string, value: unknown) => { previous.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); Object.defineProperty(globalThis, key, { configurable: true, writable: true, value }); };
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLSelectElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'DocumentFragment']) install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
install('localStorage', dom.window.localStorage); install('getComputedStyle', dom.window.getComputedStyle.bind(dom.window));
install('requestAnimationFrame', dom.window.requestAnimationFrame.bind(dom.window)); install('cancelAnimationFrame', dom.window.cancelAnimationFrame.bind(dom.window)); install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react'); const { createRoot } = await import('react-dom/client');
const { PluginAgentHost } = await import('./plugins/PluginAgentHost');
const { I18nProvider } = await import('../application/i18n/I18nProvider');
const { registerAppAppLockRuntime, getAppAppLockRuntime } = await import('../application/state/appRuntimeBridge');
const keys = await import('../infrastructure/config/storageKeys');
const { createGlobalAgentProviderFixture } = await import('../scripts/plugin-global-agent-provider-fixture.mjs');
const { createPluginHostService } = require('../electron/plugins/hostService.cjs');
const { inProcessBrowserRuntime } = require('../electron/plugins/fixtures/inProcessBrowserRuntime.cjs');
const { createPreloadApi } = require('../electron/preload/api.cjs');
const { registerProviderHandlers } = require('../electron/bridges/aiBridge/providerHandlers.cjs');
const { registerCattyExecHandlers } = require('../electron/bridges/aiBridge/cattyExecHandlers.cjs');
const { registerAgentProcessHandlers } = require('../electron/bridges/aiBridge/agentProcessHandlers.cjs');
const { registerPrivacyHandlers } = require('../electron/bridges/aiBridge/privacyHandlers.cjs');
const aiBridge = require('../electron/bridges/aiBridge.cjs'); const mcp = require('../electron/bridges/mcpServerBridge.cjs');
const privacy = require('../electron/shared/aiPrivacyService.cjs');
const { server, counts } = createGlobalAgentProviderFixture({ delayMs: 30 });
const oldLock = getAppAppLockRuntime(); const root = createRoot(document.getElementById('root')!);
let service: ReturnType<typeof createPluginHostService> | undefined; let panel: JSDOM | undefined;
const errors: string[] = []; const originalError = console.error;
let phase = 'setup';
// Flush the known Node sqlite import warning before observing safe categories.
await new Promise<void>((resolve) => setImmediate(resolve));
console.error = (value: unknown) => { errors.push(`${phase}:${typeof value === 'string' && value === '[Plugin Agent] Turn unavailable' ? 'DELEGATED_TURN_UNAVAILABLE' : typeof value === 'string' && /not wrapped in act/.test(value) ? 'REACT_ACT_WARNING' : 'SDK_OR_RENDERER_ERROR'}`); };
try {
privacy.configure({ enabled: false }); server.listen(0, '127.0.0.1'); await once(server, 'listening');
const address = server.address(); assert.ok(address && typeof address !== 'string'); const endpoint = `http://127.0.0.1:${address.port}/v1`;
const handlers = new Map<string, (...args: unknown[]) => unknown>();
const ipcMain = { handle: (name: string, handler: (...args: unknown[]) => unknown) => handlers.set(name, handler), removeHandler: (name: string) => handlers.delete(name) };
const ipcRenderer = new EventEmitter() as EventEmitter & { invoke: (channel: string, payload: unknown) => Promise<unknown> };
const trustedReplies: unknown[] = []; const pluginReplies: unknown[] = []; const workerCalls: string[] = [];
const sender = Object.assign(new EventEmitter(), { id: 7, isDestroyed: () => false, send: (channel: string, payload: unknown) => ipcRenderer.emit(channel, {}, structuredClone(payload)) });
const mainWindow = Object.assign(new EventEmitter(), { webContents: sender, isDestroyed: () => false, isVisible: () => true, isFocused: () => true });
const owner = (id: string) => id === 'synthetic-a' ? 7 : id === 'synthetic-b' ? 8 : null;
const worker = { getSessionOwnerWebContentsId: owner, getPluginSessionBinding: (id: string, actual: number) => actual === owner(id) ? { epoch: 'fixture-worker', generation: id === 'synthetic-a' ? 1 : 2 } : null,
send: () => {}, request: async (method: string, payload: { sessionId: string }, options?: { beforeExecute?: () => void }) => {
options?.beforeExecute?.(); if (method === 'netcatty:ai:exec') { workerCalls.push(payload.sessionId); return { ok: true, stdout: 'Synthetic worker output', stderr: '', exitCode: 0 }; } return [];
} };
mcp.init({ sessions: new Map(), terminalWorkerManager: worker, cliDiscoveryFilePath: path.join(directory, 'discovery.json') }); mcp.setPermissionMode('observer');
const common = { ipcMain, validateSender: (event: { sender: unknown }) => event.sender === sender,
validateSenderOrSettings: (event: { sender: unknown }) => event.sender === sender, mcpServerBridge: mcp, aiPrivacyService: privacy };
const providerContext = { ...common, require: createRequire(path.join(rootPath, 'electron/bridges/aiBridge.cjs')),
providerConfigs: [] as Array<{ id: string; baseURL?: string; apiKey?: string }>, webSearchApiHost: null,
injectApiKeyIntoRequest: (url: string, headers: Record<string, string>, providerId: string) => {
const provider = providerContext.providerConfigs.find((entry) => entry.id === providerId);
return { url, headers: Object.fromEntries(Object.entries(headers).map(([key, value]) => [key, value.replace('__IPC_SECURED__', provider?.apiKey ?? '')])) };
}, shouldSkipTLSVerify: () => false, streamRequest: aiBridge._streamRequestForTests,
// The production stream function owns this map. Turn release below uses
// its actual main lease; direct SDK abort still calls the ordinary route.
activeStreams: new Map() };
registerProviderHandlers(providerContext); registerCattyExecHandlers(common); registerAgentProcessHandlers(common); registerPrivacyHandlers(common);
ipcRenderer.invoke = async (channel, payload) => {
if (channel === 'netcatty:ai:chat:stream') {
const request = payload as { requestId: string; url: string }; assert.equal(new URL(request.url).origin, new URL(endpoint).origin);
assert.match(request.requestId, /^agent-model_/);
}
const handler = handlers.get(channel); assert.ok(handler, `Unexpected trusted IPC: ${channel}`);
const result = await handler({ sender }, structuredClone(payload));
if (channel === 'netcatty:plugins:agent-host-response') trustedReplies.push(payload);
return result;
};
const api = createPreloadApi({ ipcRenderer, webUtils: {} }); Object.defineProperty(dom.window, 'netcatty', { value: api });
registerAppAppLockRuntime({ locked: false } as NonNullable<ReturnType<typeof getAppAppLockRuntime>>);
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_PROVIDERS, JSON.stringify([{ id: 'fixture-provider', name: 'Synthetic fixture', providerId: 'custom', enabled: true,
baseURL: endpoint, defaultModel: 'global-agent-fixture', apiKey: 'FIXTURE_ONLY_API_MARKER' }]));
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_PROVIDER, 'fixture-provider');
service = createPluginHostService({ app: { getAppPath: () => rootPath, getPath: () => directory, getVersion: () => '0.0.0' }, env: {},
electron: { ipcMain }, getInteractionWindow: () => mainWindow, isAppLocked: () => false, getTerminalWorkerManager: () => worker });
service.runtimeSupervisor.runtimeFactories.browser = inProcessBrowserRuntime; await service.manager.initialize();
const sample = path.join(rootPath, 'examples/plugins/global-agent'); const staging = path.join(directory, 'package'); fs.mkdirSync(path.join(staging, 'dist'), { recursive: true });
for (const file of ['netcatty.plugin.json', 'panel.html', 'panel.css', 'panel.js', 'README.md']) fs.copyFileSync(path.join(sample, file), path.join(staging, file));
fs.copyFileSync(path.join(sample, 'src/index.js'), path.join(staging, 'dist/index.js'));
const { buildPluginPackage } = await import('@netcatty/plugin-cli'); const archive = path.join(directory, 'global-agent.ncpkg'); await buildPluginPackage(staging, archive);
await service.manager.install(archive, { enable: true });
await act(async () => root.render(<I18nProvider locale="en"><PluginAgentHost sessions={[
{ id: 'synthetic-a', hostId: 'host-a', hostLabel: 'Workspace A fixture', hostname: 'a.invalid', username: 'fixture', status: 'connected' },
{ id: 'synthetic-b', hostId: 'host-b', hostLabel: 'Workspace B fixture', hostname: 'b.invalid', username: 'fixture', status: 'connected' },
]} /></I18nProvider>));
const waitFor = async (condition: () => boolean) => { const until = Date.now() + 8_000; while (!condition()) {
if (Date.now() >= until) throw Error(`Global Agent UI fixture timed out; accepted=${counts.requests}, rejected=${counts.rejected}, effects=${workerCalls.length}, status=${panel?.window.document.getElementById('status')?.textContent}`);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 5)); });
} };
let viewOpen = true;
const mountPanel = async () => {
panel = new JSDOM(fs.readFileSync(path.join(sample, 'panel.html'), 'utf8'), { runScripts: 'outside-only' });
Object.defineProperty(panel.window, 'netcattyView', { value: { executeCommand: async (name: string, params: unknown) => {
// PluginViewHost.close removes sender authority before WebContents
// closes. A pagehide RPC cannot be relied on to reach this relay.
if (!viewOpen) throw Error('Untrusted plugin view sender');
const result = structuredClone(await service!.contributionService.executeCommand(name, structuredClone(params))); pluginReplies.push(result); return result;
}, onDidReceiveMessage: () => ({ dispose() {} }), onDidChangeEnvironment: () => ({ dispose() {} }) } });
await act(async () => panel!.window.eval(fs.readFileSync(path.join(sample, 'panel.js'), 'utf8')));
};
await mountPanel();
const element = (id: string) => panel!.window.document.getElementById(id)!;
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { for (const checkbox of document.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')) checkbox.click(); });
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
let prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic selected-target task';
const send = () => element('compose').dispatchEvent(new panel!.window.Event('submit', { cancelable: true }));
phase = 'complete'; await act(async () => { send(); }); await waitFor(() => element('status').textContent === 'Complete');
assert.deepEqual(workerCalls, ['synthetic-b']); assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
assert.equal(mcp.getPermissionMode(), 'observer'); assert.equal(counts.requests, 2); assert.equal(counts.rejected, 0);
assert.match(element('usage').textContent!, /input tokens/);
phase = 'stop'; prompt.value = 'fixture:wait'; await act(async () => { send(); });
await waitFor(() => element('transcript').textContent!.includes('Synthetic waiting output'));
await act(async () => element('stop').click()); await waitFor(() => element('status').textContent === 'Stopped' && counts.aborted === 1);
const stopped = element('transcript').textContent; await new Promise((resolve) => setTimeout(resolve, 75)); assert.equal(element('transcript').textContent, stopped);
phase = 'modal-reopen-history';
const reference = pluginReplies.find((value) => value && typeof value === 'object' && 'conversationRef' in value && 'chatHandle' in value) as { conversationRef: string };
const beforeReopen = counts.requests;
viewOpen = false;
panel!.window.dispatchEvent(new panel!.window.Event('pagehide')); panel!.window.close();
viewOpen = true; await mountPanel();
await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
(element('conversation') as HTMLSelectElement).value = reference.conversationRef;
await act(async () => element('conversation').dispatchEvent(new panel!.window.Event('change')));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click());
await waitFor(() => /^(Ready|Conversation unavailable)/.test(element('status').textContent!));
assert.match(element('status').textContent!, /^Ready/, 'ordinary modal reopen must release the prior exact chat even if pagehide cleanup never reaches the plugin');
assert.match(element('transcript').textContent!, /Synthetic global Agent result/);
assert.equal(service.agentService.chats.size, 1); assert.equal(counts.requests, beforeReopen, 'history reopen does not invoke a model');
prompt = element('prompt') as HTMLTextAreaElement; prompt.value = 'Synthetic continuation';
phase = 'deny'; const before = counts.requests; const plugin = service.database.getActivePlugin('com.netcatty.globalagent');
service.permissionEngine.setMode(plugin, 'ai.agent.actions', 'deny', service.permissionEngine.getPolicyRevision(plugin));
await act(async () => { send(); }); await waitFor(() => element('status').textContent!.startsWith('Request ended'));
assert.equal(counts.requests, before); assert.deepEqual(workerCalls, ['synthetic-b']);
phase = 'restart-history';
await service.manager.setEnabled('com.netcatty.globalagent', false);
await service.manager.setEnabled('com.netcatty.globalagent', true);
await act(async () => element('refresh').click()); await waitFor(() => element('status').textContent!.startsWith('Choose terminals'));
const conversation = element('conversation') as HTMLSelectElement; conversation.value = reference.conversationRef;
await act(async () => conversation.dispatchEvent(new panel!.window.Event('change')));
await act(async () => element('targets').click()); await waitFor(() => document.querySelectorAll('input[type="checkbox"]').length === 2);
await act(async () => { [...document.querySelectorAll('button')].find((button) => button.textContent === 'Use selected terminals')!.click(); });
await waitFor(() => element('status').textContent!.startsWith('Targets selected'));
await act(async () => element('open').click()); await waitFor(() => element('status').textContent!.startsWith('Ready'));
assert.match(element('transcript').textContent!, /Synthetic global Agent result/, 'owned core history survives runtime restart');
prompt.value = 'Synthetic model-only continuation'; await act(async () => { send(); });
await waitFor(() => element('status').textContent === 'Complete');
assert.equal(counts.requests, before + 1, 'Actions Deny allows a tool-free configured model turn after reopening');
assert.deepEqual(workerCalls, ['synthetic-b']);
phase = 'backend-revision';
const latestChat = [...pluginReplies].reverse().find((value) => value && typeof value === 'object' && 'chatHandle' in value) as { chatHandle: string };
const generation = service.agentService.host.generation; const requestsBeforeChange = counts.requests;
const { LOCAL_STORAGE_ADAPTER_CHANGED_EVENT } = await import('../infrastructure/persistence/localStorageAdapter');
dom.window.localStorage.setItem(keys.STORAGE_KEY_AI_ACTIVE_MODEL, 'changed-synthetic-model');
await act(async () => window.dispatchEvent(new CustomEvent(LOCAL_STORAGE_ADAPTER_CHANGED_EVENT, { detail: { key: keys.STORAGE_KEY_AI_ACTIVE_MODEL } })));
await waitFor(() => service!.agentService.host.generation > generation && service!.agentService.chats.size === 0);
await assert.rejects(service.contributionService.executeCommand('com.netcatty.globalagent.history', { chatHandle: latestChat.chatHandle }));
assert.equal(counts.requests, requestsBeforeChange, 'configuration invalidation never retries model inference');
assert.doesNotMatch(JSON.stringify(pluginReplies), /FIXTURE_ONLY_API_MARKER|apiKey|externalSessionId|plugin-agent:|opaque-owner|mcpServers|permissionMode/);
assert.doesNotMatch(JSON.stringify(trustedReplies), /FIXTURE_ONLY_API_MARKER|apiKey/);
assert.deepEqual(errors, []);
} finally {
console.error = originalError;
if (panel) { panel.window.dispatchEvent(new panel.window.Event('pagehide')); panel.window.close(); }
await act(async () => root.unmount()); await service?.manager.shutdown();
mcp.setPermissionMode('confirm'); mcp.cleanup(); aiBridge.cleanup(); privacy.configure({ enabled: false }); registerAppAppLockRuntime(oldLock);
server.closeAllConnections(); await new Promise<void>((resolve) => server.close(() => resolve())); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
if (oldTmp === undefined) delete process.env.TMPDIR; else process.env.TMPDIR = oldTmp;
fs.rmSync(directory, { recursive: true, force: true });
}
});
+15
View File
@@ -100,6 +100,21 @@ test('registered Ask uses one in-app dialog; policy controls apply Allow and Den
await act(async () => { button('Deny').click(); await displayed; await settle(); });
await new Promise<void>((resolve) => dom.window.queueMicrotask(resolve));
assert.equal(nativeVisibility.at(-1), true, 'dismissing Ask restores the existing view without closing it');
for (const selections of [[], [{ title: 'Synthetic terminal B', scope: 'session', version: 'opaque-worker-revision' }]]) {
await act(async () => {
displayed = host.request({ requestId: 'agent-empty-args', pluginId: plugin.id, permission: 'ai.agent.actions', resources: ['*'],
displayContext: { modelLabel: 'Synthetic configured Catty', action: { capabilityId: 'session.environment', text: '{}', label: 'PLUGIN_SUPPLIED_OPERATION_NAME' }, selections },
}); await settle();
});
const dialog = document.querySelector('[role="dialog"]')!;
assert.match(dialog.textContent!, /Operation:.*get_environment/);
assert.match(dialog.textContent!, /session\.environment/);
assert.equal(dialog.querySelector('pre')!.textContent, '{}');
if (selections.length) assert.match(dialog.textContent!, /Targets:.*Synthetic terminal B/);
else assert.match(dialog.textContent!, /Targets:.*No targets selected/);
assert.doesNotMatch(dialog.textContent!, /opaque-worker-revision|PLUGIN_SUPPLIED_OPERATION_NAME/);
await act(async () => { button('Deny').click(); await displayed; await settle(); });
}
} finally {
await act(async () => root.unmount()); stopVisibility(); host.shutdown(); engine.shutdown(); dom.window.close();
for (const [key, descriptor] of previous) { if (descriptor) Object.defineProperty(globalThis, key, descriptor); else Reflect.deleteProperty(globalThis, key); }
+58 -2
View File
@@ -4,7 +4,7 @@ import { readFileSync } from 'node:fs';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import ChatInput from './ChatInput';
import ChatInput, { replaceSlashQueryWithCommand, replaceSlashQueryWithRename } from './ChatInput';
import {
CHAT_INPUT_MAX_HEIGHT,
CHAT_INPUT_MIN_HEIGHT,
@@ -217,9 +217,46 @@ test('renders the Catty context usage ring after the model chip', () => {
assert.match(html, /aria-valuenow="50"/);
});
test('selecting rename completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithRename('/ren', { start: 0, end: 4 }), '/rename ');
assert.equal(replaceSlashQueryWithRename('/', { start: 0, end: 1 }), '/rename ');
assert.equal(replaceSlashQueryWithRename('before /ren after', { start: 7, end: 11 }), 'before /rename after');
assert.equal(replaceSlashQueryWithRename('old text', null), '/rename ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rename')"), source.indexOf("commitComposerText('');", source.indexOf("if (command === 'rename')")));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithRename\(readComposerText\(\), slashRange\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('selecting rebind completes a partial slash query without sending', () => {
assert.equal(replaceSlashQueryWithCommand('/re', { start: 0, end: 3 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('/', { start: 0, end: 1 }, 'rebind'), '/rebind ');
assert.equal(replaceSlashQueryWithCommand('before /re after', { start: 7, end: 10 }, 'rebind'), 'before /rebind after');
assert.equal(replaceSlashQueryWithCommand('old text', null, 'rebind'), '/rebind ');
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
const branch = source.slice(source.indexOf("if (command === 'rebind')"), source.indexOf("if (command === 'rename')"));
assert.match(branch, /commitComposerText\(replaceSlashQueryWithCommand\(readComposerText\(\), slashRange, 'rebind'\)\)/);
assert.match(branch, /closeAllMenus\(\);[\s\S]*?return;/);
assert.doesNotMatch(branch, /onSend/);
});
test('slash picker system commands clear the local composer', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);\s*commitComposerText\(''\);/s);
// /stop falls through to the shared clear; /rename, /chats and /rebind have
// their own branches above it (rename/rebind keep the composer so the user
// can type an argument).
assert.match(source, /if \(command === 'stop'\) onStop\?\.\(\);[\s\S]*?commitComposerText\(''\);/);
assert.match(source, /if \(command === 'chats'\) onListChats\?\.\(\);/);
assert.match(source, /if \(command === 'rename'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
assert.match(source, /if \(command === 'rebind'\) \{[\s\S]*?closeAllMenus\(\);[\s\S]*?return;/);
});
test('/rebind routes its argument to onRebindChat as a panel action', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
assert.match(source, /if \(systemCommand === 'rebind'\) \{[\s\S]*?onRebindChat\?\.\(getRebindSlashCommandArg\(submittedText\)\);/);
});
test('keeps thinking on a separate chip instead of mixing it into the model list', () => {
@@ -333,6 +370,25 @@ test('ChatInput wires /compact through getSystemSlashCommand and canCompact', ()
assert.match(source, /command\.slug !== 'compact' \|\| canCompact/);
});
test('ChatInput intercepts /rename and /chats as panel actions before onSend', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
// The interception happens in handleSubmit's system-command branch, which
// returns before onSend() — the commands never reach the agent.
assert.match(source, /systemCommand === 'rename'/);
assert.match(source, /systemCommand === 'chats'/);
assert.match(source, /onRenameSession\?\.\(getRenameSlashCommandTitle\(submittedText\)\)/);
assert.match(source, /onListChats\?\.\(\)/);
// Both commands clear the composer instead of falling through to onSend.
const renameBranchStart = source.indexOf("systemCommand === 'rename'");
const renameBranchEnd = source.indexOf('if (isStreaming && canSteer)', renameBranchStart);
const renameBranch = source.slice(renameBranchStart, renameBranchEnd);
assert.match(renameBranch, /commitComposerText\(''\)/);
assert.doesNotMatch(renameBranch, /onSend\(\)/);
// The slash picker registers the built-ins and keeps /rename text editable.
assert.match(source, /command === 'rename'/);
assert.match(source, /command === 'chats'/);
});
test('renders a Mention Note picker entry with a searchable note list', () => {
const source = readFileSync(new URL('./ChatInput.tsx', import.meta.url), 'utf8');
+55 -2
View File
@@ -33,6 +33,8 @@ import {
filterQuickMessages,
filterSystemSlashCommands,
filterUserSkillsForSlash,
getRebindSlashCommandArg,
getRenameSlashCommandTitle,
getSlashCommandItemKey,
getSystemSlashCommand,
SYSTEM_BUILTIN_SLASH_COMMANDS,
@@ -80,6 +82,19 @@ const MENU_VIEWPORT_GUTTER = 8;
const CONTEXT_USAGE_RING_RADIUS = 10;
const CONTEXT_USAGE_RING_CIRCUMFERENCE = 2 * Math.PI * CONTEXT_USAGE_RING_RADIUS;
export function replaceSlashQueryWithCommand(
text: string,
range: { start: number; end: number } | null,
command: string,
): string {
const replacement = `/${command} `;
return range ? `${text.slice(0, range.start)}${replacement}${text.slice(range.end)}` : replacement;
}
export function replaceSlashQueryWithRename(text: string, range: { start: number; end: number } | null): string {
return replaceSlashQueryWithCommand(text, range, 'rename');
}
function formatContextTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`;
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(tokens >= 100_000 ? 0 : 1)}K`;
@@ -266,6 +281,12 @@ interface ChatInputProps {
onThinkingLevelChange?: (level: string) => void;
/** Hidden retained panels must not leave body-portaled menus open. */
parked?: boolean;
/** `/rename <title>`: rename the current chat (panel action, never sent to the agent). */
onRenameSession?: (title: string) => void;
/** `/chats`: list the chats visible to this panel (panel action, never sent to the agent). */
onListChats?: () => void;
/** `/rebind <n>`: rebind a dangling chat to this panel's scope (panel action, never sent to the agent). */
onRebindChat?: (arg: string) => void;
}
const ChatInput: React.FC<ChatInputProps> = ({
@@ -313,6 +334,9 @@ const ChatInput: React.FC<ChatInputProps> = ({
thinkingLevel,
onThinkingLevelChange,
parked = false,
onRenameSession,
onListChats,
onRebindChat,
}) => {
const { t } = useI18n();
const hasTerminalSelectionAttachment = files.some((file) => file.terminalSelection || file.vaultNoteId);
@@ -750,6 +774,20 @@ const ChatInput: React.FC<ChatInputProps> = ({
onCompact?.();
}
if (command === 'stop') onStop?.();
if (command === 'chats') onListChats?.();
if (command === 'rebind') {
// Replace a partial slash query (e.g. `/re`) with `/rebind ` so the
// user can finish the dangling-chat number; never leave `/re 1` to
// be sent to the agent.
commitComposerText(replaceSlashQueryWithCommand(readComposerText(), slashRange, 'rebind'));
closeAllMenus();
return;
}
if (command === 'rename') {
commitComposerText(replaceSlashQueryWithRename(readComposerText(), slashRange));
closeAllMenus();
return;
}
commitComposerText('');
closeAllMenus();
return;
@@ -759,7 +797,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
return;
}
insertUserSkillToken(item.skill);
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onStop]);
}, [canCompact, closeAllMenus, commitComposerText, insertQuickMessage, insertUserSkillToken, onCompact, onListChats, onStop, readComposerText, slashRange]);
// Reset active highlight when a menu opens or when the *identity* of the
// visible items changes. Watching only `.length` misses cases where the
@@ -962,6 +1000,21 @@ const ChatInput: React.FC<ChatInputProps> = ({
commitComposerText('');
return;
}
if (systemCommand === 'rename') {
onRenameSession?.(getRenameSlashCommandTitle(submittedText));
commitComposerText('');
return;
}
if (systemCommand === 'chats') {
onListChats?.();
commitComposerText('');
return;
}
if (systemCommand === 'rebind') {
onRebindChat?.(getRebindSlashCommandArg(submittedText));
commitComposerText('');
return;
}
return;
}
if (isStreaming && canSteer) {
@@ -972,7 +1025,7 @@ const ChatInput: React.FC<ChatInputProps> = ({
// abort; the parent clears value only after the user message is accepted.
onSend();
},
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onSend, onSteer, onStop, readComposerText],
[canCompact, canSteer, commitComposerText, isStreaming, onCompact, onListChats, onRebindChat, onRenameSession, onSend, onSteer, onStop, readComposerText],
);
const status: PromptInputStatus = isStreaming ? 'streaming' : 'idle';
+186
View File
@@ -0,0 +1,186 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types.ts';
import {
buildAiChatsListingEntries,
canRebindChat,
formatAiChatsListingNote,
resolveChatAgentLabel,
resolveRebindTargetFromArg,
truncateChatTitle,
} from './aiChatsListing.ts';
test('rebind revalidates cached entries against current sessions and live targets', () => {
const liveTargets = new Set(['ws-live', 'term-live']);
assert.equal(canRebindChat(undefined, liveTargets), false);
assert.equal(canRebindChat(createSession('restored', { scope: { type: 'workspace', targetId: 'ws-live' } }), liveTargets), false);
assert.equal(canRebindChat(createSession('restored-terminal', { scope: { type: 'terminal', targetId: 'term-live' } }), liveTargets), false);
assert.equal(canRebindChat(createSession('dangling', { scopeDangling: true, scope: { type: 'workspace', targetId: 'ws-old' } }), liveTargets), true);
assert.equal(canRebindChat(createSession('unbound', { scope: { type: 'terminal', targetId: 'term-old' } }), liveTargets), true);
});
function createSession(
id: string,
overrides: Partial<AISession> = {},
): AISession {
return {
id,
title: id,
agentId: 'catty',
scope: { type: 'workspace', targetId: 'ws-1' },
messages: [],
createdAt: 1_000,
updatedAt: 1_000,
...overrides,
};
}
const t = (key: string) => key;
const externalAgents: ExternalAgentConfig[] = [
{ id: 'claude', name: 'Claude Code' } as ExternalAgentConfig,
];
function buildEntries(sessions: AISession[], activeSessionId: string | null = 's-live') {
return buildAiChatsListingEntries({
sessions,
activeSessionId,
externalAgents,
workspaces: [{ id: 'ws-1', title: 'Web farm' }],
liveTerminalIds: new Set(['term-1']),
scopeTargetId: 'ws-1',
t,
});
}
test('/chats listing marks the live workspace chat, terminal chat and dangling chat', () => {
const now = Date.now();
const sessions = [
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' }, updatedAt: now }),
createSession('s-term', { scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude', updatedAt: now }),
createSession('s-dangling', { scope: { type: 'workspace', targetId: 'ws-gone' }, updatedAt: now }),
];
const entries = buildEntries(sessions);
assert.equal(entries.length, 3);
assert.equal(entries[0].isCurrent, true);
assert.equal(entries[0].agentLabel, 'Catty Agent');
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
assert.equal(entries[0].isDangling, false);
assert.equal(entries[1].isCurrent, false);
assert.equal(entries[1].agentLabel, 'Claude Code');
assert.equal(entries[1].scopeLabel, 'ai.chat.chatsScopeTerminal');
assert.equal(entries[1].isDangling, false);
assert.equal(entries[2].isDangling, true);
assert.equal(entries[2].scopeLabel, 'ai.chat.chatsScopeDangling');
});
test('/chats listing numbers dangling entries for /rebind and leaves live ones unnumbered', () => {
const sessions = [
createSession('s-dangling-a', { scope: { type: 'workspace', targetId: 'ws-gone-a' } }),
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
createSession('s-dangling-b', { scope: { type: 'terminal', targetId: 'term-gone' } }),
createSession('s-dangling-c', { scope: { type: 'workspace', targetId: 'ws-gone-b' } }),
];
const entries = buildEntries(sessions, null);
assert.deepEqual(
entries.map((entry) => entry.rebindNumber ?? 0),
[1, 0, 2, 3],
);
});
test('/chats listing falls back to the panel scope label for the current workspace', () => {
const sessions = [
createSession('s-live', { scope: { type: 'workspace', targetId: 'ws-1' } }),
];
const entries = buildAiChatsListingEntries({
sessions,
activeSessionId: 's-live',
externalAgents,
workspaces: [],
liveTerminalIds: new Set<string>(),
currentScopeLabel: 'Web farm',
scopeTargetId: 'ws-1',
t,
});
assert.equal(entries[0].isDangling, false);
assert.equal(entries[0].scopeLabel, 'ai.chat.chatsScopeWorkspace: Web farm');
});
test('/chats note renders header, current marker and one line per chat', () => {
const sessions = [
createSession('s-live', { title: 'Fix deploy' }),
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
];
const entries = buildEntries(sessions);
const note = formatAiChatsListingNote(entries, t);
const lines = note.split('\n');
assert.equal(lines[0], 'ai.chat.chatsHeader');
// Three chat lines plus the dangling rebind hint.
assert.equal(lines.length, 5);
assert.match(lines[1], /^• Fix deploy — Catty Agent · /);
assert.match(lines[2], /^ {2}Logs — Claude Code · /);
assert.match(lines[3], /ai\.chat\.chatsScopeDangling/);
assert.ok(!lines[3].startsWith('•'));
// The dangling line carries an inline rebind affordance, and the note
// explains it.
assert.match(lines[3], /\/rebind 1/);
assert.equal(lines[4], 'ai.chat.chatsRebindHint');
});
test('/chats note omits the rebind hint when nothing is dangling', () => {
const sessions = [
createSession('s-live', { title: 'Fix deploy' }),
createSession('s-term', { title: 'Logs', scope: { type: 'terminal', targetId: 'term-1' }, agentId: 'claude' }),
];
const entries = buildEntries(sessions);
const note = formatAiChatsListingNote(entries, t);
assert.ok(!note.includes('rebind'));
});
test('resolveRebindTargetFromArg picks only dangling entries by their number', () => {
const sessions = [
createSession('s-live', { title: 'Fix deploy' }),
createSession('s-dangling', { title: 'Old', scope: { type: 'workspace', targetId: 'ws-gone' } }),
createSession('s-dangling-2', { title: 'Older', scope: { type: 'terminal', targetId: 'term-gone' } }),
];
const entries = buildEntries(sessions);
const dangling = entries.find((entry) => entry.isDangling);
assert.ok(dangling);
assert.equal(resolveRebindTargetFromArg('1', entries)?.sessionId, 's-dangling');
assert.equal(resolveRebindTargetFromArg(' 2 ', entries)?.sessionId, entries.filter((entry) => entry.isDangling)[1].sessionId);
assert.equal(resolveRebindTargetFromArg('3', entries), null);
assert.equal(resolveRebindTargetFromArg('1foo', entries), null);
assert.equal(resolveRebindTargetFromArg('1.5', entries), null);
assert.equal(resolveRebindTargetFromArg('0', entries), null);
assert.equal(resolveRebindTargetFromArg('-1', entries), null);
assert.equal(resolveRebindTargetFromArg('', entries), null);
assert.equal(resolveRebindTargetFromArg('abc', entries), null);
// A live entry is not rebindable even if its position would match.
assert.equal(resolveRebindTargetFromArg('1', [entries[0]]), null);
});
test('/chats note reports an empty panel', () => {
assert.equal(formatAiChatsListingNote([], t), 'ai.chat.chatsEmpty');
});
test('truncateChatTitle clamps long titles and keeps short ones intact', () => {
assert.equal(truncateChatTitle('short'), 'short');
assert.equal(truncateChatTitle(' '), '');
const long = 'x'.repeat(80);
const truncated = truncateChatTitle(long);
assert.equal(truncated.length, 42);
assert.ok(truncated.endsWith('…'));
});
test('resolveChatAgentLabel resolves external agents and falls back to the raw id', () => {
assert.equal(resolveChatAgentLabel('catty', externalAgents), 'Catty Agent');
assert.equal(resolveChatAgentLabel('claude', externalAgents), 'Claude Code');
assert.equal(resolveChatAgentLabel('mystery', externalAgents), 'mystery');
});
+161
View File
@@ -0,0 +1,161 @@
import type { AISession, ExternalAgentConfig } from '../../infrastructure/ai/types';
import { formatRelativeTime } from '../AIChatSessionHistoryDrawer';
/**
* Pure builder for the `/chats` panel note. Kept separate from the component
* so a future rebind action (issue #4015 R2) can attach per-entry actions
* without reworking the text rendering.
*/
export interface AiChatsListingInput {
/** Rank-sorted sessions visible to the current panel scope. */
sessions: AISession[];
activeSessionId: string | null;
externalAgents: ExternalAgentConfig[];
/** Live workspaces, used to resolve workspace scope titles. */
workspaces: Array<{ id: string; title: string }>;
/** Live terminal session ids visible to this panel. */
liveTerminalIds: Iterable<string>;
/** Fallback scope label for the panel's own scope (e.g. current workspace title). */
currentScopeLabel?: string;
scopeTargetId?: string;
t: (key: string) => string;
}
export interface AiChatsListingEntry {
sessionId: string;
title: string;
agentLabel: string;
scopeLabel: string;
/** Scope target no longer resolves to a live workspace/terminal. */
isDangling: boolean;
/** 1-based number among dangling entries, used by `/rebind <n>`. */
rebindNumber?: number;
isCurrent: boolean;
relativeTime: string;
}
const TITLE_TRUNCATE = 42;
export function truncateChatTitle(title: string): string {
const trimmed = title.trim() || '';
if (trimmed.length <= TITLE_TRUNCATE) return trimmed;
return `${trimmed.slice(0, TITLE_TRUNCATE - 1)}…`;
}
export function resolveChatAgentLabel(
agentId: string,
externalAgents: ExternalAgentConfig[],
): string {
if (agentId === 'catty') return 'Catty Agent';
return externalAgents.find((agent) => agent.id === agentId)?.name ?? agentId;
}
export function buildAiChatsListingEntries(input: AiChatsListingInput): AiChatsListingEntry[] {
const {
sessions,
activeSessionId,
externalAgents,
workspaces,
liveTerminalIds,
scopeTargetId,
t,
} = input;
const workspaceTitles = new Map(workspaces.map((workspace) => [workspace.id, workspace.title]));
const liveTerminals = new Set(liveTerminalIds);
const entries: AiChatsListingEntry[] = sessions.map((session) => {
const isCurrent = session.id === activeSessionId;
let scopeLabel = '';
let isDangling = false;
if (session.scope.type === 'workspace') {
const title = workspaceTitles.get(session.scope.targetId ?? '');
if (title != null) {
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${title}`;
} else {
// The panel's own scope may be labelled by the parent even when the
// workspace store has not been threaded through; treat the active
// scope target as live.
if (isCurrent && session.scope.targetId === scopeTargetId && input.currentScopeLabel) {
scopeLabel = `${t('ai.chat.chatsScopeWorkspace')}: ${input.currentScopeLabel}`;
} else {
isDangling = true;
scopeLabel = t('ai.chat.chatsScopeDangling');
}
}
} else if (session.scope.type === 'terminal') {
if (liveTerminals.has(session.scope.targetId ?? '')) {
scopeLabel = t('ai.chat.chatsScopeTerminal');
} else {
isDangling = true;
scopeLabel = t('ai.chat.chatsScopeDangling');
}
} else {
scopeLabel = t('ai.chat.chatsScopeGlobal');
}
return {
sessionId: session.id,
title: truncateChatTitle(session.title || t('ai.chat.untitled')),
agentLabel: resolveChatAgentLabel(session.agentId, externalAgents),
scopeLabel,
isDangling,
isCurrent,
relativeTime: formatRelativeTime(new Date(session.updatedAt), t),
};
});
// Number the dangling entries so the note can advertise `/rebind <n>`.
let danglingCounter = 0;
for (const entry of entries) {
if (!entry.isDangling) continue;
danglingCounter += 1;
entry.rebindNumber = danglingCounter;
}
return entries;
}
/**
* Resolve a `/rebind <n>` argument against a `/chats` listing. Only dangling
* entries are rebindable; a non-dangling target (or a bad index) yields null.
*/
export function resolveRebindTargetFromArg(
arg: string,
entries: AiChatsListingEntry[],
): AiChatsListingEntry | null {
const trimmed = arg.trim();
if (!/^\d+$/.test(trimmed)) return null;
const index = Number.parseInt(trimmed, 10);
if (!Number.isInteger(index) || index < 1) return null;
const dangling = entries.filter((entry) => entry.isDangling);
return dangling[index - 1] ?? null;
}
/** Revalidate a cached listing entry against the current session and live scopes. */
export function canRebindChat(session: AISession | undefined, liveScopeTargetIds: ReadonlySet<string>): boolean {
if (!session) return false;
return !!session.scopeDangling || !session.scope.targetId || !liveScopeTargetIds.has(session.scope.targetId);
}
export function formatAiChatsListingNote(
entries: AiChatsListingEntry[],
t: (key: string) => string,
): string {
if (entries.length === 0) {
return t('ai.chat.chatsEmpty');
}
let hasDangling = false;
const lines = entries.map((entry) => {
const marker = entry.isCurrent ? '•' : ' ';
const rebindHint = entry.isDangling && entry.rebindNumber != null
? ` · /rebind ${entry.rebindNumber}`
: '';
if (entry.isDangling) hasDangling = true;
return `${marker} ${entry.title} — ${entry.agentLabel} · ${entry.scopeLabel} · ${entry.relativeTime}${rebindHint}`;
});
if (hasDangling) {
lines.push(t('ai.chat.chatsRebindHint'));
}
return [t('ai.chat.chatsHeader'), ...lines].join('\n');
}
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import type { TerminalSession } from '../../domain/models';
import { usePluginAgentHost } from '../../application/state/usePluginAgentHost';
import { Button } from '../ui/button';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
export function PluginAgentHost({ sessions }: { sessions: TerminalSession[] }) {
const { choice } = usePluginAgentHost(sessions);
const [selected, setSelected] = useState<string[]>([]);
return <Dialog open={choice !== null} onOpenChange={(open) => { if (!open) choice?.cancel(); }}>
<DialogContent data-host-security-surface="" onOpenAutoFocus={() => setSelected([])}>
<DialogHeader><DialogTitle>Choose terminals for this Agent conversation</DialogTitle>
<DialogDescription>The conversation is independent of the active tab or workspace. Only the terminals selected here are eligible targets. Tool actions follow this plugin's Allow / Ask / Deny setting.</DialogDescription></DialogHeader>
<div className="max-h-72 space-y-2 overflow-y-auto">
{choice?.items.map((item) => <label key={item.sessionId} className="flex gap-2 text-sm">
<input type="checkbox" checked={selected.includes(item.sessionId)} disabled={selected.length >= 32 && !selected.includes(item.sessionId)}
onChange={(event) => setSelected((ids) => event.target.checked ? [...ids, item.sessionId] : ids.filter((id) => id !== item.sessionId))} />
<span>{item.label} <span className="text-muted-foreground">{item.hostname}</span></span>
</label>)}
{choice?.items.length === 0 && <p className="text-sm text-muted-foreground">No connected terminals. You can continue without targets.</p>}
</div>
<DialogFooter><Button variant="outline" onClick={() => choice?.cancel()}>Cancel</Button>
<Button onClick={() => choice?.confirm(selected)}>Use selected terminals</Button></DialogFooter>
</DialogContent>
</Dialog>;
}
+15 -3
View File
@@ -3,6 +3,7 @@ import { useI18n } from '../../application/i18n/I18nProvider';
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '../ui/dialog';
import { Button } from '../ui/button';
import globalAgentToolSpecs from '../../infrastructure/ai/harness/generated/globalAgentToolSpecs.json';
/** Rendered only by the trusted app, never inside a plugin view. */
export function PluginPermissionHost() {
@@ -12,6 +13,9 @@ export function PluginPermissionHost() {
if (!prompt) return null;
const description = describePluginPermission(prompt.permission, resolvedLocale);
const resources = prompt.resources.filter((resource) => resource !== '*' && !resource.startsWith('selected:'));
const action = prompt.displayContext?.action;
const operation = action && (globalAgentToolSpecs.find((tool) => tool.capabilityId === action.capabilityId)?.toolName ?? action.capabilityId);
const selections = prompt.displayContext?.selections ?? [];
return <Dialog open onOpenChange={(open) => { if (!open) void respond('cancel'); }}>
<DialogContent data-host-security-surface="" className="max-h-[85vh] overflow-y-auto sm:max-w-[480px]">
<DialogHeader>
@@ -20,9 +24,17 @@ export function PluginPermissionHost() {
</DialogHeader>
<p className="text-sm">{description.description}</p>
{prompt.displayContext?.modelLabel && <p className="text-sm break-words">{zh ? '模型' : 'Model'}: {prompt.displayContext.modelLabel}</p>}
{(prompt.displayContext?.selections ?? []).map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
{action && <div className="space-y-1">
<p className="text-sm break-words">{zh ? '操作' : 'Operation'}: <code>{operation}</code>{operation !== action.capabilityId && <> ({action.capabilityId})</>}</p>
<p className="text-sm">{zh ? '请求内容(控制字符以转义形式显示)' : 'Requested action (control characters shown as escapes)'}</p>
<pre className="max-h-48 overflow-auto whitespace-pre-wrap break-words rounded border border-border p-2 text-xs">{action.text}</pre>
</div>}
{(selections.length > 0 || prompt.permission === 'ai.agent.actions') && <div className="space-y-1">
<p className="text-sm">{zh ? '目标' : 'Targets'}:{selections.length === 0 && (zh ? ' 未选择目标' : ' No targets selected')}</p>
{selections.map((item, index) => <p key={index} className="text-sm break-words">
{item.title} <span className="text-xs text-muted-foreground">· {item.scope}</span>
</p>)}
</div>}
{(prompt.relatedPermissions ?? []).filter((item, index, items) => items.findIndex((other) => other.permission === item.permission) === index).map((item, index) => <div key={index} className="space-y-1 text-sm">
<p>{describePluginPermission(item.permission, resolvedLocale).description}</p>
<p className="text-xs text-muted-foreground break-all">{item.resources.filter((resource) => !resource.startsWith('selected:')).join(', ')}</p>
+3
View File
@@ -203,6 +203,7 @@ export const SettingCard: React.FC<SettingCardProps> = ({
interface SettingRowProps {
leading?: React.ReactNode;
label?: React.ReactNode;
className?: string;
description?: React.ReactNode;
children: React.ReactNode;
align?: "center" | "start";
@@ -213,6 +214,7 @@ interface SettingRowProps {
export const SettingRow: React.FC<SettingRowProps> = ({
leading,
label,
className,
description,
children,
align = "center",
@@ -225,6 +227,7 @@ export const SettingRow: React.FC<SettingRowProps> = ({
// Keep square corners: rounded rows bend divide-y separators at both ends.
"flex justify-between py-3 gap-4",
align === "start" ? "items-start" : "items-center",
className,
)}
>
{leading && <div className="shrink-0">{leading}</div>}
@@ -68,6 +68,11 @@ test('plugin manager is single-instance, closes with Escape, restores focus and
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
// Radix schedules close-focus cleanup after the React close commit. The
// settle inside click's act() can precede that commit; observe the actual
// handoff instead of assuming the cleanup timer already ran.
const handoffDeadline = Date.now() + 2_000;
while (!handedOff && Date.now() < handoffDeadline) await act(async () => { await settle(); });
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
function ControlledEntry() {
const [open, setOpen] = useState(false);
+28 -28
View File
@@ -1260,6 +1260,34 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</TabsContent>
<TabsContent value="tools" className="m-0 space-y-6">
<SettingsSection
anchorId="ai-tool-access-mode"
title={t('ai.toolAccess.title')}
>
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" description={t('ai.toolAccess.description')}>
<div className="flex flex-wrap justify-end gap-2">
<Select
value={toolIntegrationMode}
options={[
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
]}
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
className="w-48"
/>
<ToolAccessGuidance mode={toolIntegrationMode} />
</div>
</SettingRow>
</SettingCard>
</SettingsSection>
<ExternalMcpCard />
<AIPrivacySection />
<SettingsSection title={t('ai.chatShortcuts.title')}>
<SettingCard divided>
<SettingRow
@@ -1276,34 +1304,6 @@ const SettingsAITab: React.FC<SettingsAITabProps> = ({
</SettingCard>
</SettingsSection>
<SettingsSection
anchorId="ai-tool-access-mode"
title={t('ai.toolAccess.title')}
>
<SettingCard>
<SettingRow description={t('ai.toolAccess.description')}>
<Select
value={toolIntegrationMode}
options={[
{ value: 'mcp', label: t('ai.toolAccess.mode.mcp') },
{ value: 'skills', label: t('ai.toolAccess.mode.skills') },
]}
onChange={(value) => setToolIntegrationMode(value as AIToolIntegrationMode)}
className="w-48"
/>
</SettingRow>
<div className="pb-4">
<ToolAccessGuidance mode={toolIntegrationMode} />
</div>
</SettingCard>
</SettingsSection>
<AIPrivacySection />
<SettingsSection anchorId="ai-external-mcp" title={t('ai.externalMcp.title')}>
<ExternalMcpCard />
</SettingsSection>
<SettingsSection
anchorId="ai-user-skills"
title={t('ai.userSkills.title')}
@@ -1,183 +1,154 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { I18nProvider } from '../../../../application/i18n/I18nProvider';
import React, { act } from 'react';
import { JSDOM } from 'jsdom';
import { STORAGE_KEY_AI_PRIVACY } from '../../../../infrastructure/config/storageKeys';
import { AIPrivacySection } from './AIPrivacySection';
function installEnvironment(setConfig: (config: unknown) => Promise<unknown>) {
const values = new Map<string, string>();
const events = new EventTarget();
const globals = {
IS_REACT_ACT_ENVIRONMENT: true,
localStorage: {
getItem: (key: string) => values.get(key) ?? null,
setItem: (key: string, value: string) => values.set(key, value),
},
window: {
netcatty: { externalMcpSetConfig: setConfig },
addEventListener: events.addEventListener.bind(events),
removeEventListener: events.removeEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events),
},
};
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const [name, value] of Object.entries(globals)) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value, configurable: true, writable: true });
}
return {
values,
restore: () => {
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
},
};
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
for (const key of ['window', 'document', 'navigator', 'HTMLElement', 'HTMLInputElement', 'HTMLTextAreaElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
}
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
const { AIPrivacySection } = await import('./AIPrivacySection');
async function mount(setConfig: (config: unknown) => Promise<unknown> = async () => ({ ok: true }), saved?: string) {
dom.window.localStorage.clear();
if (saved) dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, saved);
Object.assign(window, { netcatty: { externalMcpSetConfig: setConfig } });
const root = createRoot(document.getElementById('root')!);
await act(async () => root.render(<I18nProvider locale="en"><AIPrivacySection /></I18nProvider>));
return async () => { await act(async () => root.unmount()); };
}
const button = (text: string) => [...document.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
const click = async (text: string) => { await act(async () => button(text).click()); };
async function input(index: number, value: string) {
const el = document.querySelectorAll('textarea')[index];
await act(async () => {
Object.getOwnPropertyDescriptor(dom.window.HTMLTextAreaElement.prototype, 'value')!.set!.call(el, value);
el.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
});
}
function view() {
return <I18nProvider locale="en"><AIPrivacySection /></I18nProvider>;
}
test('privacy settings default off and explain their limits and connection scope', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
test('privacy overview is persisted state; configuration preserves the full scope and atomic draft', async () => {
const unmount = await mount();
try {
await act(async () => { renderer = create(view()); });
const markup = JSON.stringify(renderer!.toJSON());
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === false).length, 2);
assert.match(markup, /cannot detect every secret/);
assert.match(markup, /AI privacy/);
assert.match(markup, /Catty, integrated agents and external MCP clients/);
assert.match(markup, /Only Netcatty-managed data and tools are covered/);
assert.match(markup, /native tools, direct file access and other MCP servers/);
assert.match(markup, /Binary tool responses are blocked/);
assert.match(markup, /one managed conversation or external MCP connection/);
assert.match(markup, /reconnect external MCP clients/);
assert.match(markup, /start a new managed conversation/);
assert.match(markup, /Additional IP ranges/);
assert.match(markup, /Custom regular expressions/);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
assert.equal(document.querySelector('textarea'), null);
assert.match(document.body.textContent!, /Text redaction disabled/);
await click('Configure privacy');
const dialog = document.querySelector('[role="dialog"]')!;
assert.match(dialog.textContent!, /cannot detect every secret/);
assert.match(dialog.textContent!, /Catty, integrated agents and external MCP clients/);
assert.match(dialog.textContent!, /Only Netcatty-managed data and tools are covered/);
assert.match(dialog.textContent!, /native tools, direct file access and other MCP servers/);
assert.match(dialog.textContent!, /Binary tool responses are blocked/);
assert.match(dialog.textContent!, /one managed conversation or external MCP connection/);
assert.match(dialog.textContent!, /reconnect external MCP clients/);
assert.equal(dialog.querySelectorAll('[role="switch"][aria-checked="false"]').length, 2);
assert.equal(button('Apply').disabled, true);
} finally { await unmount(); }
});
test('AI privacy is shared Tool Access UI, not nested under External MCP', async () => {
const settingsTab = await readFile(new URL('../SettingsAITab.tsx', import.meta.url), 'utf8');
const externalCard = await readFile(new URL('./ExternalMcpCard.tsx', import.meta.url), 'utf8');
const toolsTab = settingsTab.split('<TabsContent value="tools"')[1].split('</TabsContent>')[0];
assert.match(toolsTab, /<\/SettingsSection>\s*<AIPrivacySection \/>\s*<SettingsSection anchorId="ai-external-mcp"/);
assert.doesNotMatch(externalCard, /AIPrivacySection|ExternalMcpRedactionSection/);
});
test('privacy drafts apply together, retain rejected input and persist only validated settings', async () => {
test('rejected privacy draft stays open and accepted draft commits once and returns focus', async () => {
const calls: unknown[] = [];
let accepted = false;
const env = installEnvironment(async (config) => {
calls.push(config);
return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' };
});
let renderer: ReactTestRenderer | undefined;
const unmount = await mount(async (config) => { calls.push(config); return accepted ? { ok: true } : { ok: false, error: 'Invalid privacy rules.' }; });
try {
await act(async () => { renderer = create(view()); });
const root = renderer!.root;
const privacySwitch = () => root.findAllByType('button').find((node) => (
node.props['aria-label'] === 'Enable text redaction'
))!;
const apply = () => root.findAllByType('button').find((node) => node.props.children === 'Apply')!;
await act(async () => { privacySwitch().props.onClick(); });
await act(async () => {
root.findAllByType('textarea')[0].props.onChange({ currentTarget: { value: '192.0.2.0/24\n\n2001:db8::/32' } });
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: '[' } });
});
assert.equal(calls.length, 0, 'draft edits must not change runtime or storage');
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
await act(async () => { apply().props.onClick(); });
assert.equal(root.findAllByType('textarea')[1].props.value, '[');
assert.match(root.findByProps({ role: 'alert' }).children.join(''), /Invalid privacy rules/);
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
const trigger = button('Configure privacy');
trigger.focus();
await click('Configure privacy');
await act(async () => (document.querySelector('[aria-label="Enable text redaction"]') as HTMLButtonElement).click());
await input(0, '192.0.2.0/24\n\n2001:db8::/32');
await input(1, '[');
assert.equal(calls.length, 0);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
await click('Apply');
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid privacy rules/);
assert.equal(document.querySelectorAll('textarea')[1].value, '[');
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
accepted = true;
await act(async () => {
root.findAllByType('textarea')[1].props.onChange({ currentTarget: { value: 'INTERNAL_[A-Z0-9]+' } });
});
await act(async () => { apply().props.onClick(); });
const saved = JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!);
assert.deepEqual(saved, {
enabled: true,
redactPrivateIps: false,
cidrs: ['192.0.2.0/24', '2001:db8::/32'],
patterns: ['INTERNAL_[A-Z0-9]+'],
});
await input(1, 'INTERNAL_[A-Z0-9]+');
await click('Apply');
const saved = JSON.parse(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY)!);
assert.deepEqual(saved, { enabled: true, redactPrivateIps: false, cidrs: ['192.0.2.0/24', '2001:db8::/32'], patterns: ['INTERNAL_[A-Z0-9]+'] });
assert.deepEqual(calls[1], { redaction: saved });
assert.equal(root.findAllByProps({ role: 'alert' }).length, 0);
assert.match(root.findByProps({ role: 'status' }).children.join(''), /new managed conversation/);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.match(document.querySelector('[role="status"]')!.textContent!, /new managed conversation/);
await click('Configure privacy');
assert.equal(document.querySelectorAll('textarea')[1].value, 'INTERNAL_[A-Z0-9]+');
} finally { await unmount(); }
});
test('malformed saved privacy settings remain visible as a recoverable error', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
test('Cancel, X and Escape discard edits and reopening reads saved values', async () => {
let calls = 0;
const unmount = await mount(async () => { calls += 1; return { ok: true }; });
try {
env.values.set(STORAGE_KEY_AI_PRIVACY, '{');
await act(async () => { renderer = create(view()); });
assert.match(renderer!.root.findByProps({ role: 'alert' }).children.join(''), /Invalid saved AI privacy settings/);
assert.ok(renderer!.root.findAllByType('button').some((node) => node.props.children === 'Apply'));
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), '{');
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
for (const close of ['Cancel', 'X', 'Escape']) {
await click('Configure privacy');
await input(0, '192.0.2.0/24');
assert.match(document.querySelector('[role="status"]')!.textContent!, /Unsaved changes/);
if (close === 'Cancel') await click('Cancel');
else if (close === 'X') await act(async () => (document.querySelector('[data-dialog-close]:not([aria-hidden])') as HTMLButtonElement).click());
else await act(async () => { document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
assert.equal(document.querySelector('[role="dialog"]'), null);
await click('Configure privacy');
assert.equal(document.querySelector('textarea')!.value, '');
await click('Cancel');
}
assert.equal(calls, 0);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), null);
} finally { await unmount(); }
});
test('privacy apply disables draft controls until shared validation settles', async () => {
let resolveConfig!: (result: unknown) => void;
const env = installEnvironment(() => new Promise((resolve) => { resolveConfig = resolve; }));
let renderer: ReactTestRenderer | undefined;
test('busy Apply blocks duplicate submits, edits and Escape', async () => {
let resolve!: (value: unknown) => void;
let calls = 0;
const unmount = await mount(() => { calls += 1; return new Promise((done) => { resolve = done; }); });
try {
await act(async () => { renderer = create(view()); });
const root = renderer!.root;
await act(async () => {
root.findAllByType('button').find((node) => node.props['aria-label'] === 'Enable text redaction')!.props.onClick();
});
await act(async () => {
root.findAllByType('button').find((node) => node.props.children === 'Apply')!.props.onClick();
});
assert.ok(root.findAllByType('button').every((node) => node.props.disabled));
assert.ok(root.findAllByType('textarea').every((node) => node.props.disabled));
assert.equal(env.values.get(STORAGE_KEY_AI_PRIVACY), undefined);
await act(async () => { resolveConfig({ ok: true }); });
assert.ok(root.findAllByType('button').every((node) => !node.props.disabled));
assert.equal(JSON.parse(env.values.get(STORAGE_KEY_AI_PRIVACY)!).enabled, true);
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
await click('Configure privacy');
await input(0, '192.0.2.0/24');
await click('Apply');
assert.ok([...document.querySelectorAll('[role="dialog"] textarea, [role="dialog"] [role="switch"]')].every((el) => (el as HTMLInputElement).disabled));
assert.equal(button('Cancel').disabled, true);
await act(async () => { button('Applying...').click(); document.dispatchEvent(new dom.window.KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); });
assert.ok(document.querySelector('[role="dialog"]'));
assert.equal(calls, 1);
await act(async () => { resolve({ ok: true }); });
assert.equal(document.querySelector('[role="dialog"]'), null);
} finally { await unmount(); }
});
test('shared privacy controls refresh when another window changes the shared preference', async () => {
const env = installEnvironment(async () => ({ ok: true }));
let renderer: ReactTestRenderer | undefined;
test('malformed saved settings remain visible and repairable without being rewritten on open', async () => {
const unmount = await mount(undefined, '{');
try {
await act(async () => { renderer = create(view()); });
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
env.values.set(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
const event = new Event('storage');
Object.defineProperty(event, 'key', { value: STORAGE_KEY_AI_PRIVACY });
await act(async () => { window.dispatchEvent(event); });
assert.equal(renderer!.root.findAllByType('button').filter((node) => node.props['aria-checked'] === true).length, 2);
assert.equal(renderer!.root.findAllByType('textarea')[0].props.value, '192.0.2.0/24');
assert.equal(renderer!.root.findAllByType('textarea')[1].props.value, 'INTERNAL_.*');
} finally {
if (renderer) await act(async () => { renderer!.unmount(); });
env.restore();
}
assert.match(document.querySelector('[role="alert"]')!.textContent!, /Invalid saved AI privacy settings/);
await click('Configure privacy');
assert.equal(button('Apply').disabled, false);
assert.equal(dom.window.localStorage.getItem(STORAGE_KEY_AI_PRIVACY), '{');
} finally { await unmount(); }
});
test('cross-window changes refresh clean drafts but preserve dirty drafts with a conflict notice', async () => {
const unmount = await mount();
const settings = { enabled: true, redactPrivateIps: true, cidrs: ['192.0.2.0/24'], patterns: ['INTERNAL_.*'] };
const update = async () => {
dom.window.localStorage.setItem(STORAGE_KEY_AI_PRIVACY, JSON.stringify(settings));
await act(async () => { window.dispatchEvent(new dom.window.StorageEvent('storage', { key: STORAGE_KEY_AI_PRIVACY })); });
};
try {
await click('Configure privacy');
await update();
assert.equal(document.querySelector('textarea')!.value, '192.0.2.0/24');
await input(0, '198.51.100.0/24');
settings.cidrs = ['203.0.113.0/24'];
await update();
assert.equal(document.querySelector('textarea')!.value, '198.51.100.0/24');
assert.match(document.querySelector('[role="alert"]')!.textContent!, /another window/);
assert.equal(button('Apply').disabled, true);
await click('Cancel');
await click('Configure privacy');
assert.equal(document.querySelector('textarea')!.value, '203.0.113.0/24');
} finally { await unmount(); }
});
@@ -1,7 +1,8 @@
import React, { useEffect, useState } from "react";
import React, { useEffect, useRef, useState } from "react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { useAIPrivacyState } from "../../../../application/state/useExternalMcpToggleState";
import { Button } from "../../../ui/button";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { SettingCard, SettingRow, SettingsSection, Toggle } from "../../settings-ui";
export const AIPrivacySection: React.FC = () => {
@@ -12,8 +13,32 @@ export const AIPrivacySection: React.FC = () => {
const [cidrs, setCidrs] = useState(settings.cidrs.join("\n"));
const [patterns, setPatterns] = useState(settings.patterns.join("\n"));
const [applied, setApplied] = useState(false);
const [open, setOpen] = useState(false);
const errorRef = useRef<HTMLParagraphElement>(null);
useEffect(() => { if (open && error) errorRef.current?.focus(); }, [open, error]);
const [draftBase, setDraftBase] = useState(settings);
const [conflict, setConflict] = useState(false);
const dirty = enabled !== draftBase.enabled || privateIps !== draftBase.redactPrivateIps
|| cidrs !== draftBase.cidrs.join("\n") || patterns !== draftBase.patterns.join("\n");
const changeOpen = (next: boolean) => {
if (applying) return;
setDraftBase(settings);
setEnabled(settings.enabled);
setPrivateIps(settings.redactPrivateIps);
setCidrs(settings.cidrs.join("\n"));
setPatterns(settings.patterns.join("\n"));
setConflict(false);
setOpen(next);
};
const draftState = useRef({ open, dirty, applying });
draftState.current = { open, dirty, applying };
useEffect(() => {
if (draftState.current.open && draftState.current.dirty && !draftState.current.applying) {
setConflict(true);
return;
}
setDraftBase(settings);
setEnabled(settings.enabled);
setPrivateIps(settings.redactPrivateIps);
setCidrs(settings.cidrs.join("\n"));
@@ -21,6 +46,7 @@ export const AIPrivacySection: React.FC = () => {
}, [settings]);
const onApply = async () => {
if (applying || conflict) return;
setApplied(false);
const saved = await apply({
enabled,
@@ -29,13 +55,36 @@ export const AIPrivacySection: React.FC = () => {
patterns: patterns.split(/\r?\n/).filter((line) => line.trim().length > 0),
});
setApplied(saved);
if (saved) setOpen(false);
};
return (
<SettingsSection anchorId="ai-privacy" title={t("ai.privacy.title")}>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingCard>
<SettingRow
className="flex-col items-start md:flex-row md:items-center"
label={t(settings.enabled ? "ai.toolSettings.privacyOn" : "ai.toolSettings.privacyOff")}
description={`${t("ai.privacy.privateIps")}: ${t(settings.redactPrivateIps ? "common.enabled" : "common.disabled")} · ${t("ai.toolSettings.ruleSummary", { cidrs: String(settings.cidrs.length), patterns: String(settings.patterns.length) })}`}
>
<ToolSettingsDialog
title={t("ai.privacy.title")}
triggerLabel={t("ai.toolSettings.configurePrivacy")}
open={open}
onOpenChange={changeOpen}
busy={applying}
footer={<>
{dirty ? <span role="status" className="mr-auto self-center text-xs text-muted-foreground">{t("ai.toolSettings.unsaved")}</span> : null}
<Button type="button" variant="outline" onClick={() => changeOpen(false)} disabled={applying}>{t("common.cancel")}</Button>
<Button type="button" onClick={() => { void onApply(); }} disabled={applying || conflict || (!dirty && !error)}>
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
</Button>
</>}
>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.boundary")}</p>
<SettingCard divided className="border-0 rounded-none bg-transparent p-0">
<SettingRow label={t("ai.privacy.enable")}>
<Toggle
checked={enabled}
@@ -56,9 +105,9 @@ export const AIPrivacySection: React.FC = () => {
/>
</SettingRow>
</SettingCard>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
<div className="space-y-4">
<label className="block space-y-1.5">
<span className="text-xs font-medium">{t("ai.privacy.cidrs")}</span>
<span className="text-sm font-medium">{t("ai.privacy.cidrs")}</span>
<span className="block text-xs text-muted-foreground">{t("ai.privacy.cidrs.description")}</span>
<textarea
value={cidrs}
@@ -72,7 +121,7 @@ export const AIPrivacySection: React.FC = () => {
/>
</label>
<label className="block space-y-1.5">
<span className="text-xs font-medium">{t("ai.privacy.patterns")}</span>
<span className="text-sm font-medium">{t("ai.privacy.patterns")}</span>
<span className="block text-xs text-muted-foreground">{t("ai.privacy.patterns.description")}</span>
<textarea
value={patterns}
@@ -86,20 +135,15 @@ export const AIPrivacySection: React.FC = () => {
/>
</label>
<p className="text-xs text-muted-foreground leading-5">{t("ai.privacy.connectionScope")}</p>
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
<Button size="sm" variant="outline" onClick={() => { void onApply(); }} disabled={applying}>
{t(applying ? "ai.privacy.applying" : "ai.privacy.apply")}
</Button>
</div>
{error ? (
<p role="alert" className="text-xs text-destructive">
{t("ai.privacy.failed")} {error}
</p>
) : applied ? (
<p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p>
) : null}
<p className="text-xs text-amber-500">{t("ai.privacy.reconnect")}</p>
</div>
{conflict ? <p role="alert" className="text-xs text-destructive">{t("ai.toolSettings.conflict")}</p> : null}
{error ? <p ref={errorRef} tabIndex={-1} role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p> : null}
</ToolSettingsDialog>
</SettingRow>
</SettingCard>
{error && !open ? <p role="alert" className="text-xs text-destructive">{t("ai.privacy.failed")} {error}</p>
: applied ? <p role="status" className="text-xs text-emerald-500">{t("ai.privacy.applied")}</p> : null}
</SettingsSection>
);
};
@@ -1,10 +1,12 @@
import React from "react";
import { Check, Copy } from "lucide-react";
import { Check, Copy, Eye, EyeOff } from "lucide-react";
import { cn } from "../../../../lib/utils";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
type CopyableCodeBlockProps = {
label?: string;
sensitive?: boolean;
value: string;
copyKey: string;
copied: string | null;
@@ -17,6 +19,7 @@ type CopyableCodeBlockProps = {
export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
label,
sensitive = false,
value,
copyKey,
copied,
@@ -26,7 +29,9 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
emptyLabel,
className,
}) => {
const display = value || emptyLabel || "";
const { t } = useI18n();
const [revealed, setRevealed] = React.useState(false);
const display = sensitive && value && !revealed ? "••••••••" : value || emptyLabel || "";
const canCopy = Boolean(value);
const isCopied = copied === copyKey;
@@ -38,12 +43,15 @@ export const CopyableCodeBlock: React.FC<CopyableCodeBlockProps> = ({
<div className="group relative rounded-md border border-border/60 bg-muted/20">
<pre
className={cn(
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-11 font-mono text-xs leading-5",
"max-h-40 overflow-auto whitespace-pre-wrap break-all px-3 py-2.5 pr-20 font-mono text-xs leading-5",
!value && "text-muted-foreground",
)}
>
{display}
</pre>
{sensitive && value ? <Button type="button" variant="ghost" size="sm" className="absolute right-10 top-1.5 h-7 w-7 p-0" onClick={() => setRevealed(!revealed)} aria-label={t(revealed ? "ai.toolSettings.hideSecret" : "ai.toolSettings.showSecret")}>
{revealed ? <EyeOff size={14} /> : <Eye size={14} />}
</Button> : null}
<Button
type="button"
variant="ghost"
@@ -0,0 +1,133 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { act } from 'react';
import { JSDOM } from 'jsdom';
import { installAiToolAccessFixture } from '../../../../scripts/fixtures/ai-tool-access/bridge.cjs';
const dom = new JSDOM('<div id="root"></div>', { url: 'http://localhost/#/settings' });
for (const key of ['window', 'document', 'location', 'navigator', 'HTMLElement', 'HTMLInputElement', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent', 'FocusEvent', 'getComputedStyle', 'localStorage'] as const) {
Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true });
}
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: (fn: () => void) => setTimeout(fn, 0), cancelAnimationFrame: clearTimeout });
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
const { TooltipProvider } = await import('../../../ui/tooltip');
const { ExternalMcpCard } = await import('./ExternalMcpCard');
type FixtureControl = { rejectHttp: boolean; throwHttp: boolean; holdHttp: boolean; pendingHttp: (() => void)[]; pendingClients: (() => void)[]; rejectRotate: boolean; throwRotate: boolean; calls: { name: string; args: unknown[] }[] };
async function mount(options: { enabled?: boolean; holdClients?: boolean } = {}, openHttp = true) {
installAiToolAccessFixture({ locale: 'en', enabled: true, ...options });
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
const qa = (window as unknown as { __aiToolQa: FixtureControl }).__aiToolQa;
const root = createRoot(document.getElementById('root')!);
await act(async () => root.render(<I18nProvider locale="en"><TooltipProvider><ExternalMcpCard /></TooltipProvider></I18nProvider>));
if (openHttp) await click('Configure HTTP');
return { qa, close: async () => { await act(async () => root.unmount()); } };
}
const dialog = () => document.querySelector<HTMLElement>('[role="dialog"]')!;
const button = (text: string, scope: ParentNode = document) => [...scope.querySelectorAll<HTMLButtonElement>('button')].find((el) => el.textContent === text)!;
const click = async (text: string) => { await act(async () => button(text).click()); };
async function changePort(value: string) {
const port = dialog().querySelector<HTMLInputElement>('[aria-label="Port"]')!;
await act(async () => {
port.focus();
Object.getOwnPropertyDescriptor(dom.window.HTMLInputElement.prototype, 'value')!.set!.call(port, value);
port.dispatchEvent(new dom.window.Event('input', { bubbles: true }));
});
await act(async () => port.blur());
return port;
}
for (const failure of ['returned', 'thrown'] as const) {
test(`HTTP ${failure} configuration failure is visible inside the dialog, preserves input and can retry`, async () => {
const h = await mount();
try {
h.qa.rejectHttp = failure === 'returned';
h.qa.throwHttp = failure === 'thrown';
const port = await changePort('47401');
const message = failure === 'returned' ? 'Fixture: port unavailable.' : 'Fixture: HTTP configuration threw.';
const alert = dialog().querySelector('[role="alert"]')!;
assert.match(alert.textContent!, new RegExp(message.replaceAll('.', '\\.')));
assert.equal(document.activeElement, alert, 'the error must be reachable without scrolling back manually');
assert.equal(port.value, '47401');
assert.ok(document.querySelector('[role="dialog"]'));
assert.match(document.querySelector('[data-settings-anchor="ai-external-mcp-http"]')!.textContent!, /Fixture:/);
const beforeRetry = h.qa.calls.filter((call) => call.name === 'config').length;
h.qa.rejectHttp = false;
h.qa.throwHttp = false;
await click('Retry');
assert.equal(h.qa.calls.filter((call) => call.name === 'config').length, beforeRetry + 1);
assert.equal(dialog().querySelector('[role="alert"]'), null);
assert.equal(port.value, '47401');
} finally { await h.close(); }
});
}
for (const failure of ['returned', 'thrown'] as const) {
test(`HTTP ${failure} token rotation failure stays in the active dialog and retries explicitly`, async () => {
const h = await mount();
try {
await act(async () => (dialog().querySelector('[aria-label="Enable HTTP"]') as HTMLButtonElement).click());
await act(async () => dialog().querySelector('summary')!.click());
h.qa.rejectRotate = failure === 'returned';
h.qa.throwRotate = failure === 'thrown';
await click('Regenerate');
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture: token rotation/);
assert.equal(dialog().textContent!.includes('SYNTHETIC_FIXTURE_TOKEN_NEVER_REAL'), false);
h.qa.rejectRotate = false;
h.qa.throwRotate = false;
await click('Retry');
assert.equal(dialog().querySelector('[role="alert"]'), null);
assert.equal(dialog().textContent!.includes('SYNTHETIC_ROTATED_FIXTURE_TOKEN'), false);
} finally { await h.close(); }
});
}
test('HTTP clipboard failure is visible in the dialog and clears only after another copy attempt', async () => {
const h = await mount();
try {
await act(async () => dialog().querySelector('summary')!.click());
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => { throw new Error('Fixture clipboard failure'); } } });
const copy = () => dialog().querySelector<HTMLButtonElement>('[aria-label="Copy"]:not([disabled])')!;
await act(async () => copy().click());
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /copy/i);
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: async () => {} } });
await act(async () => copy().click());
assert.equal(dialog().querySelector('[role="alert"]'), null);
} finally { await h.close(); }
});
for (const failure of ['returned', 'thrown'] as const) {
test(`a completed runtime poll cannot suppress a pending HTTP ${failure} failure`, async () => {
const h = await mount();
try {
h.qa.holdHttp = true;
h.qa.rejectHttp = failure === 'returned';
h.qa.throwHttp = failure === 'thrown';
const beforePoll = h.qa.calls.filter((call) => call.name === 'status').length;
const port = await changePort('47402');
assert.match(dialog().querySelector('[role="status"]')!.textContent!, /Applying HTTP/);
await act(async () => { await new Promise((resolve) => setTimeout(resolve, 3200)); });
assert.ok(h.qa.calls.filter((call) => call.name === 'status').length > beforePoll);
await act(async () => h.qa.pendingHttp.splice(0).forEach((resolve) => resolve()));
assert.match(dialog().querySelector('[role="alert"]')!.textContent!, /Fixture:/);
assert.equal(port.value, '47402');
assert.equal(dialog().querySelector('[role="status"]'), null);
} finally { await h.close(); }
});
}
test('completed MCP enable can be turned off while unrelated client discovery is still pending', async () => {
const h = await mount({ enabled: false, holdClients: true }, false);
try {
const toggle = document.querySelector<HTMLButtonElement>('[role="switch"][aria-label="External MCP"]')!;
await act(async () => toggle.click());
assert.equal(toggle.getAttribute('aria-checked'), 'true');
assert.ok(h.qa.pendingClients.length > 0, 'client discovery remains intentionally unresolved');
assert.equal(toggle.disabled, false, 'successful enable must release the switch before client discovery finishes');
await act(async () => toggle.click());
assert.equal(toggle.getAttribute('aria-checked'), 'false');
assert.equal(toggle.disabled, false);
assert.deepEqual(h.qa.calls.filter((call) => call.name === 'enable').map((call) => call.args[0]), [true, false]);
} finally { await h.close(); }
});
+195 -177
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from "react";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { HelpCircle, RefreshCw } from "lucide-react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import {
@@ -31,8 +31,13 @@ import {
} from "../../../../application/state/useExternalMcpToggleState";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../../ui/tabs";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../ui/tooltip";
import { Select, SettingCard, SettingRow, Toggle } from "../../../settings/settings-ui";
import { Select, SettingCard, SettingRow, SettingsSection, Toggle } from "../../../settings/settings-ui";
import { ToolStatusPill, type ToolStatusTone } from "./ToolStatusPill";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
import { SettingsAnchor } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
import { ExternalMcpHttpSection, type ExternalMcpHttpViewStatus } from "./ExternalMcpHttpSection";
import { getBridge } from "./types";
@@ -72,23 +77,13 @@ type StatusView = {
className: string;
};
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): StatusView {
if (!enabled) {
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
}
if (!status || !status.ok) {
return { labelKey: "ai.externalMcp.status.unavailable", className: "text-amber-500" };
}
if (status.state === "running") {
return { labelKey: "ai.externalMcp.status.running", className: "text-emerald-500" };
}
if (status.state === "starting") {
return { labelKey: "ai.externalMcp.status.starting", className: "text-amber-500" };
}
if (status.state === "error") {
return { labelKey: "ai.externalMcp.status.error", className: "text-destructive" };
}
return { labelKey: "ai.externalMcp.status.disabled", className: "text-muted-foreground" };
function getBridgeStatusView(status: ExternalMcpStatus | null, enabled: boolean): { labelKey: string; tone: ToolStatusTone } {
if (!enabled) return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
if (!status || !status.ok) return { labelKey: "ai.externalMcp.status.unavailable", tone: status?.error ? "error" : "inactive" };
if (status.state === "running") return { labelKey: "ai.externalMcp.status.running", tone: "active" };
if (status.state === "starting") return { labelKey: "ai.externalMcp.status.starting", tone: "inactive" };
if (status.state === "error") return { labelKey: "ai.externalMcp.status.error", tone: "error" };
return { labelKey: "ai.externalMcp.status.disabled", tone: "inactive" };
}
/** Map bridge permissionMode to a Safety i18n key for display. */
@@ -252,6 +247,15 @@ export function buildCursorSnippet(launcherPath: string, discoveryPath?: string
export const ExternalMcpCard: React.FC = () => {
const { t } = useI18n();
const [behaviorOpen, setBehaviorOpen] = useState(false);
const focus = useOptionalSettingsFocus();
useEffect(() => {
if (focus?.request?.anchorId === "ai-external-mcp-approval-alert") setBehaviorOpen(true);
}, [focus?.request]);
const statusGeneration = useRef(0);
const clientStatusGeneration = useRef(0);
const httpOperationGeneration = useRef(0);
useEffect(() => () => { statusGeneration.current += 1; clientStatusGeneration.current += 1; httpOperationGeneration.current += 1; }, []);
const { enabled, setEnabled, error: configError } = useExternalMcpToggleState();
const [mode, setModeRaw] = useState<ExternalMcpMode>(() => readExternalMcpMode());
const [idleTimeoutMinutes, setIdleTimeoutRaw] = useState<number>(() => readExternalMcpIdleTimeoutMinutes());
@@ -268,8 +272,11 @@ export const ExternalMcpCard: React.FC = () => {
listenAddress: readExternalMcpHttpListenAddress(),
clientHost: readExternalMcpHttpClientHost(),
}));
const [isApplyingHttp, setIsApplyingHttp] = useState(false);
const [isToggling, setIsToggling] = useState(false);
const [isRotatingHttpToken, setIsRotatingHttpToken] = useState(false);
const [status, setStatus] = useState<ExternalMcpStatus | null>(null);
const [manualFormat, setManualFormat] = useState("command");
const [selectedClient, setSelectedClient] = useState<ExternalMcpClient>("codex");
const [codexStatus, setCodexStatus] = useState<ClientSetupStatus | null>(null);
const [claudeStatus, setClaudeStatus] = useState<ClientSetupStatus | null>(null);
@@ -280,6 +287,8 @@ export const ExternalMcpCard: React.FC = () => {
const [isAddingClaude, setIsAddingClaude] = useState(false);
const [isAddingGrok, setIsAddingGrok] = useState(false);
const [copied, setCopied] = useState<string | null>(null);
const [httpCopyError, setHttpCopyError] = useState<string | null>(null);
const [httpActionError, setHttpActionError] = useState<{ kind: "config" | "rotate"; message: string } | null>(null);
const [actionMessage, setActionMessage] = useState<{ tone: "error" | "warning" | "success"; text: string } | null>(null);
const bridgeUnavailableMessage = t("ai.externalMcp.bridgeUnavailable");
@@ -324,6 +333,10 @@ export const ExternalMcpCard: React.FC = () => {
}, []);
const applyHttpSettings = useCallback(async (next: ExternalMcpHttpSettings) => {
const operation = ++httpOperationGeneration.current;
const generation = ++statusGeneration.current;
setHttpActionError(null);
setIsApplyingHttp(true);
const normalized: ExternalMcpHttpSettings = {
enabled: writeExternalMcpHttpEnabled(next.enabled),
port: writeExternalMcpHttpPort(next.port),
@@ -332,36 +345,56 @@ export const ExternalMcpCard: React.FC = () => {
clientHost: writeExternalMcpHttpClientHost(next.clientHost),
};
setHttpSettings(normalized);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
});
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
try {
const nextStatus = await getBridge()?.externalMcpSetConfig?.({
httpEnabled: normalized.enabled,
httpPort: normalized.port,
httpAllowCustomBind: normalized.allowCustomBind,
httpListenAddress: normalized.listenAddress,
httpClientHost: normalized.clientHost,
}) as ExternalMcpStatus | undefined;
if (operation !== httpOperationGeneration.current) return;
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
setHttpActionError({ kind: "config", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
return;
}
if (generation === statusGeneration.current) setStatus(nextStatus);
} catch (error) {
if (operation === httpOperationGeneration.current) {
setHttpActionError({ kind: "config", message: error instanceof Error ? error.message : String(error) });
}
} finally {
if (operation === httpOperationGeneration.current) setIsApplyingHttp(false);
}
}, []);
}, [bridgeUnavailableMessage]);
const rotateHttpToken = useCallback(async () => {
const bridge = getBridge();
if (!bridge?.externalMcpSetConfig) return;
const operation = ++httpOperationGeneration.current;
const generation = ++statusGeneration.current;
setIsRotatingHttpToken(true);
setActionMessage(null);
setHttpActionError(null);
try {
const nextStatus = await bridge.externalMcpSetConfig({ rotateHttpToken: true });
if (nextStatus && typeof nextStatus === "object") {
setStatus(nextStatus as ExternalMcpStatus);
const nextStatus = await getBridge()?.externalMcpSetConfig?.({ rotateHttpToken: true }) as ExternalMcpStatus | undefined;
if (operation !== httpOperationGeneration.current) return;
if (!nextStatus?.ok || nextStatus.error || nextStatus.http?.error) {
setHttpActionError({ kind: "rotate", message: nextStatus?.error || nextStatus?.http?.error || bridgeUnavailableMessage });
return;
}
if (generation === statusGeneration.current) setStatus(nextStatus);
} catch (error) {
if (operation === httpOperationGeneration.current) {
setHttpActionError({ kind: "rotate", message: error instanceof Error ? error.message : String(error) });
}
} finally {
setIsRotatingHttpToken(false);
}
}, []);
}, [bridgeUnavailableMessage]);
const refreshStatus = useCallback(async (options?: { quiet?: boolean; clients?: boolean }) => {
const generation = ++statusGeneration.current;
const bridge = getBridge();
const includeClients = options?.clients !== false;
const clientGeneration = includeClients ? ++clientStatusGeneration.current : clientStatusGeneration.current;
if (
!bridge?.externalMcpGetStatus
|| !bridge?.externalMcpCodexGetStatus
@@ -411,10 +444,15 @@ export const ExternalMcpCard: React.FC = () => {
bridge.externalMcpGetUniversalSetupPrompt?.()
?? Promise.resolve({ ok: false, prompt: "" }),
]);
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
// Runtime polling and CLI/client discovery have separate lifetimes.
// A fast quiet poll must not discard a slow, still-current client batch.
if (generation === statusGeneration.current) {
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
if (clientGeneration !== clientStatusGeneration.current) return;
setCodexStatus(nextCodexStatus as ClientSetupStatus);
setClaudeStatus(nextClaudeStatus as ClientSetupStatus);
setGrokStatus(nextGrokStatus as ClientSetupStatus);
@@ -425,13 +463,14 @@ export const ExternalMcpCard: React.FC = () => {
);
} else {
const nextStatus = await bridge.externalMcpGetStatus();
if (generation !== statusGeneration.current) return;
setStatus(nextStatus as ExternalMcpStatus);
if (enabled && nextStatus?.ok && !nextStatus.enabled) {
setEnabled(false);
}
}
} finally {
if (!options?.quiet) setIsRefreshing(false);
if (!options?.quiet && clientGeneration === clientStatusGeneration.current) setIsRefreshing(false);
}
}, [bridgeUnavailableMessage, enabled, setEnabled]);
@@ -481,8 +520,10 @@ export const ExternalMcpCard: React.FC = () => {
const canAddToClaude = claudeStatus?.state === "not_configured";
const canAddToGrok = grokStatus?.state === "not_configured";
const copyText = useCallback(async (key: string, text: string) => {
const copyText = useCallback(async (key: string, text: string, scope: "client" | "http" = "client") => {
if (!text) return;
setCopied((current) => current === key ? null : current);
if (scope === "http") setHttpCopyError(null);
try {
await navigator.clipboard.writeText(text);
setCopied(key);
@@ -490,7 +531,8 @@ export const ExternalMcpCard: React.FC = () => {
setCopied((current) => (current === key ? null : current));
}, 1200);
} catch {
setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
if (scope === "http") setHttpCopyError(t("ai.externalMcp.copyFailed"));
else setActionMessage({ tone: "error", text: t("ai.externalMcp.copyFailed") });
}
}, [t]);
@@ -634,8 +676,13 @@ export const ExternalMcpCard: React.FC = () => {
]);
return (
<div className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-4">
<SettingsSection
anchorId="ai-external-mcp"
title={t("ai.externalMcp.title")}
actions={<ToolStatusPill tone={bridgeStatusView.tone}>{t(bridgeStatusView.labelKey)}</ToolStatusPill>}
>
<div className="space-y-4">
<div className="flex items-start gap-4">
<div className="min-w-0 flex items-start gap-1.5">
<p className="min-w-0 text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.description")}
@@ -665,12 +712,10 @@ export const ExternalMcpCard: React.FC = () => {
</TooltipContent>
</Tooltip>
</div>
<div className={cn("text-xs font-medium shrink-0", bridgeStatusView.className)}>
{t(bridgeStatusView.labelKey)}
</div>
</div>
<div className="flex items-center justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<SettingCard divided>
<div className="flex items-center justify-between gap-4 py-3">
<div className="min-w-0">
<div className="text-sm font-medium">{t("ai.externalMcp.title")}</div>
<div className="text-xs text-muted-foreground">
@@ -679,17 +724,21 @@ export const ExternalMcpCard: React.FC = () => {
</div>
<Toggle
checked={enabled}
disabled={isToggling}
ariaLabel={t("ai.externalMcp.title")}
onChange={(nextEnabled) => {
statusGeneration.current += 1;
setActionMessage(null);
void setEnabled(nextEnabled).then(() => refreshStatus());
setIsToggling(true);
void setEnabled(nextEnabled).then(() => { void refreshStatus(); }).finally(() => setIsToggling(false));
}}
/>
</div>
{/* Permission mode is controlled in Safety settings; surface it here so External MCP
users see why write tools may still prompt (confirm) or run freely (auto). */}
<div className="flex items-start justify-between gap-4 rounded-md border border-border/60 bg-background/70 px-3 py-2">
<div className="min-w-0 space-y-1">
<div className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0 flex-1 space-y-1">
<div className="text-sm font-medium">{t("ai.externalMcp.permissionMode.label")}</div>
<div className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.permissionMode.hint")}
@@ -706,9 +755,11 @@ export const ExternalMcpCard: React.FC = () => {
</div>
</div>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingsAnchor anchorId="ai-external-mcp-approval-alert">
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.toolSettings.behavior")} description={t(mode === "persistent" ? "ai.externalMcp.mode.persistent" : "ai.externalMcp.mode.temporary")}>
<ToolSettingsDialog title={t("ai.toolSettings.behavior")} triggerLabel={t("ai.toolSettings.configureBehavior")} immediate open={behaviorOpen} onOpenChange={setBehaviorOpen}>
<SettingCard divided className="border-0 bg-transparent p-0">
<SettingRow
anchorId="ai-external-mcp-approval-alert"
label={t("ai.externalMcp.approvalAlert")}
description={t("ai.externalMcp.approvalAlert.description")}
>
@@ -803,6 +854,10 @@ export const ExternalMcpCard: React.FC = () => {
<span className="text-xs text-muted-foreground">{t("ai.externalMcp.idleTimeout.minutes")}</span>
</div>
</SettingRow>
</SettingCard>
</ToolSettingsDialog>
</SettingRow>
</SettingsAnchor>
</SettingCard>
<ExternalMcpHttpSection
@@ -811,93 +866,36 @@ export const ExternalMcpCard: React.FC = () => {
status={status?.http || null}
copied={copied}
rotating={isRotatingHttpToken}
onCopy={copyText}
onCopy={(key, text) => { void copyText(key, text, "http"); }}
applying={isApplyingHttp}
operationError={httpActionError?.message ?? null}
copyError={httpCopyError}
onRetry={httpActionError ? () => {
if (httpActionError.kind === "rotate") void rotateHttpToken();
else void applyHttpSettings(httpSettings);
} : undefined}
onChange={(next) => { void applyHttpSettings(next); }}
onRotateToken={() => { void rotateHttpToken(); }}
/>
<div className="space-y-2">
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</div>
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t("ai.externalMcp.clientConfiguration")} description={t("ai.externalMcp.clientConfiguration.description")}>
<ToolSettingsDialog title={t("ai.externalMcp.clientConfiguration")} triggerLabel={t("ai.toolSettings.configureClients")}>
<div className="space-y-2">
<div className="space-y-1">
<div className="text-sm font-semibold text-foreground">
{t("ai.externalMcp.clientConfiguration")}
</div>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.externalMcp.clientConfiguration.description")}
</p>
</div>
<div
role="tablist"
aria-label={t("ai.externalMcp.clientConfiguration")}
className="grid grid-cols-5 gap-1 rounded-md bg-muted p-1"
>
{CLIENT_TABS.map((client) => {
const active = selectedClient === client;
return (
<button
key={client}
type="button"
role="tab"
aria-selected={active}
onClick={() => {
setSelectedClient(client);
setActionMessage(null);
}}
className={cn(
"inline-flex h-8 items-center justify-center rounded-sm px-2 text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring",
active
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground",
)}
>
{t(`ai.externalMcp.client.${client}`)}
</button>
);
})}
</div>
<Tabs value={selectedClient} onValueChange={(value) => { setSelectedClient(value as ExternalMcpClient); setManualFormat(value === "other" || value === "cursor" ? "snippet" : "command"); setActionMessage(null); }}>
<TabsList aria-label={t("ai.externalMcp.clientConfiguration")} className="flex h-auto flex-wrap justify-start">
{CLIENT_TABS.map((client) => <TabsTrigger key={client} value={client}>{t(`ai.externalMcp.client.${client}`)}</TabsTrigger>)}
</TabsList>
<TabsContent value={selectedClient}>
<div className="space-y-3 rounded-md border border-border/60 bg-background/50 p-3">
<div className="space-y-4">
{selectedClientMeta.kind === "installable" && selectedClientMeta.statusView ? (
<div className="flex items-center justify-between gap-3">
<div className={cn("text-xs font-medium", selectedClientMeta.statusView.className)}>
@@ -924,57 +922,76 @@ export const ExternalMcpCard: React.FC = () => {
</p>
)}
{selectedClientMeta.kind === "installable" ? (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.cliCommand")}
value={selectedClientMeta.command}
copyKey="command"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="snippet"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</>
) : (
<>
<CopyableCodeBlock
label={t("ai.externalMcp.configSnippet")}
value={selectedClientMeta.snippet}
copyKey="cursor"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{selectedClient === "other" ? (
<CopyableCodeBlock
label={t("ai.externalMcp.universalSetupPrompt")}
value={universalSetupPrompt}
copyKey="universal-setup-prompt"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
) : null}
</>
)}
<details className="space-y-3">
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.manualSetup")}</summary>
<label className="flex flex-wrap items-center justify-between gap-2 text-sm">
{t("ai.toolSettings.format")}
<select aria-label={t("ai.toolSettings.format")} className="h-9 max-w-full rounded-md border border-input bg-background px-3 text-sm" value={manualFormat} onChange={(event) => setManualFormat(event.currentTarget.value)}>
{selectedClientMeta.kind === "installable" ? <option value="command">{t("ai.externalMcp.cliCommand")}</option> : null}
<option value="snippet">{t("ai.externalMcp.configSnippet")}</option>
{selectedClient === "other" ? <option value="prompt">{t("ai.externalMcp.universalSetupPrompt")}</option> : null}
</select>
</label>
<CopyableCodeBlock
label={t(manualFormat === "command" ? "ai.externalMcp.cliCommand" : manualFormat === "prompt" ? "ai.externalMcp.universalSetupPrompt" : "ai.externalMcp.configSnippet")}
value={manualFormat === "command" ? selectedClientMeta.command : manualFormat === "prompt" ? universalSetupPrompt : selectedClientMeta.snippet}
copyKey={manualFormat}
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</details>
</div>
</TabsContent>
</Tabs>
</div>
<details className="space-y-3">
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
<div className="flex min-h-8 items-center justify-between gap-2">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.discovery")}</div>
<Button
variant="outline"
size="sm"
onClick={() => void refreshStatus()}
disabled={isRefreshing}
>
<RefreshCw size={14} className={cn("mr-1.5", isRefreshing && "animate-spin")} />
{t("ai.externalMcp.refresh")}
</Button>
</div>
<div className="space-y-3">
<CopyableCodeBlock
label={t("ai.externalMcp.launcher")}
value={launcherPath || ""}
copyKey="launcher"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
<CopyableCodeBlock
label={t("ai.externalMcp.discovery")}
value={status?.discoveryPath || ""}
copyKey="discovery"
copied={copied}
onCopy={copyText}
copyLabel={t("ai.externalMcp.copy")}
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
{!enabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.enableForLauncher")}</p>
) : null}
</div>
</details>
{actionMessage ? <p role={actionMessage.tone === "error" ? "alert" : "status"} className={cn("text-xs", actionMessage.tone === "error" ? "text-destructive" : "text-muted-foreground")}>{actionMessage.text}</p> : null}
</ToolSettingsDialog>
</SettingRow>
</SettingCard>
{actionMessage ? (
<div
@@ -991,6 +1008,7 @@ export const ExternalMcpCard: React.FC = () => {
{configError || status?.error ? (
<div role="alert" className="text-xs text-destructive">{configError || status?.error}</div>
) : null}
</div>
</div>
</SettingsSection>
);
};
@@ -1,4 +1,4 @@
import React, { useState } from "react";
import React, { useEffect, useId, useRef, useState } from "react";
import {
buildClaudeHttpAddCommand,
buildCodexHttpEnvLine,
@@ -8,9 +8,12 @@ import {
DEFAULT_EXTERNAL_MCP_HTTP_PORT,
} from "../../../../domain/externalMcpHttp";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { cn } from "../../../../lib/utils";
import { Button } from "../../../ui/button";
import { SettingCard, SettingRow, Toggle } from "../../settings-ui";
import { ToolStatusPill } from "./ToolStatusPill";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { useOptionalSettingsFocus } from "../../SettingsFocusContext";
import { SettingsAnchor } from "../../settings-ui";
import { CopyableCodeBlock } from "./CopyableCodeBlock";
export type ExternalMcpHttpViewStatus = {
@@ -41,6 +44,10 @@ type ExternalMcpHttpSectionProps = {
status: ExternalMcpHttpViewStatus | null;
copied: string | null;
rotating: boolean;
applying?: boolean;
operationError?: string | null;
copyError?: string | null;
onRetry?: () => void;
onCopy: (key: string, text: string) => void;
onChange: (next: ExternalMcpHttpViewSettings) => void;
onRotateToken: () => void;
@@ -73,11 +80,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
status,
copied,
rotating,
applying,
operationError,
copyError,
onRetry,
onCopy,
onChange,
onRotateToken,
}) => {
const { t } = useI18n();
const operationErrorRef = useRef<HTMLDivElement>(null);
const errorId = useId();
const [open, setOpen] = useState(false);
useEffect(() => {
if (open && (operationError || copyError)) operationErrorRef.current?.focus();
}, [open, operationError, copyError]);
const focus = useOptionalSettingsFocus();
useEffect(() => {
if (focus?.request?.anchorId === "ai-external-mcp-http") setOpen(true);
}, [focus?.request]);
const [portDraft, setPortDraft] = useState(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
const [addressDraft, setAddressDraft] = useState(settings.listenAddress || "0.0.0.0");
const [clientHostDraft, setClientHostDraft] = useState(settings.clientHost || "");
@@ -85,6 +106,16 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
const [addressError, setAddressError] = useState(false);
const [clientHostError, setClientHostError] = useState(false);
const changeOpen = (next: boolean) => {
setPortDraft(String(settings.port || DEFAULT_EXTERNAL_MCP_HTTP_PORT));
setAddressDraft(settings.listenAddress || "0.0.0.0");
setClientHostDraft(settings.clientHost || "");
setPortError(false);
setAddressError(false);
setClientHostError(false);
setOpen(next);
};
const listenAddress = settings.allowCustomBind
? (status?.listenAddress || settings.listenAddress)
: "127.0.0.1";
@@ -97,14 +128,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
);
const token = status?.bearerToken || "";
const listening = Boolean(status?.listening);
const statusLabel = listening
? t("ai.externalMcp.http.listening")
: t("ai.externalMcp.http.notListening");
const statusClass = status?.errorCode
? "text-destructive"
: listening
? "text-emerald-500"
: "text-muted-foreground";
const hasRuntimeError = Boolean(operationError || status?.errorCode || status?.error);
const statusLabel = hasRuntimeError ? t("ai.externalMcp.status.error")
: t(listening ? "ai.externalMcp.http.listening" : "ai.externalMcp.http.notListening");
let errorText = "";
if (portError) errorText = t("ai.externalMcp.http.invalidPort");
@@ -116,6 +142,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
} else if (status?.errorCode === "invalid_address") errorText = t("ai.externalMcp.http.invalidAddress");
else if (status?.errorCode === "invalid_port") errorText = t("ai.externalMcp.http.invalidPort");
else if (status?.errorCode === "listen_failed") errorText = status.error || t("ai.externalMcp.http.listenFailed");
else if (status?.error) errorText = status.error;
const commitPort = () => {
const port = parsePort(portDraft);
@@ -151,16 +178,25 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
};
return (
<div className="space-y-2">
<SettingsAnchor anchorId="ai-external-mcp-http" className="space-y-2">
<div className="flex items-center justify-between gap-3">
<div className="text-sm font-semibold text-foreground">{t("ai.externalMcp.http.title")}</div>
<div className={cn("text-xs font-medium", statusClass)}>{statusLabel}</div>
<ToolStatusPill tone={hasRuntimeError ? "error" : listening ? "active" : "inactive"}>{statusLabel}</ToolStatusPill>
</div>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.legacySseNote")}</p>
<SettingCard divided className="rounded-md border-border/60 bg-background/70">
<SettingCard>
<SettingRow className="flex-col items-start md:flex-row md:items-center" label={t(settings.enabled ? "ai.toolSettings.httpOn" : "ai.toolSettings.httpOff")} description={settings.allowCustomBind ? t("ai.externalMcp.http.warning") : t("ai.toolSettings.loopback")}>
<ToolSettingsDialog title={t("ai.externalMcp.http.title")} triggerLabel={t("ai.toolSettings.configureHttp")} immediate open={open} onOpenChange={changeOpen}>
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
{operationError || copyError ? <div ref={operationErrorRef} role="alert" tabIndex={-1} className="space-y-2 text-xs text-destructive">
{operationError ? <p>{operationError}</p> : null}
{copyError ? <p>{copyError}</p> : null}
{operationError && onRetry ? <Button type="button" variant="outline" size="sm" disabled={rotating || applying} onClick={onRetry}>{t("ai.toolSettings.retry")}</Button> : null}
</div> : null}
<p className="text-xs text-muted-foreground leading-5">{t("ai.externalMcp.http.description")}</p>
<SettingCard divided className="border-0 bg-transparent p-0">
<SettingRow
anchorId="ai-external-mcp-http"
label={t("ai.externalMcp.http.enable")}
>
<Toggle
@@ -176,6 +212,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<input
type="number"
aria-label={t("ai.externalMcp.http.port")}
aria-invalid={portError}
aria-describedby={portError ? errorId : undefined}
min={1024}
max={65535}
value={portDraft}
@@ -207,6 +245,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<input
type="text"
aria-label={t("ai.externalMcp.http.listenAddress")}
aria-invalid={addressError}
aria-describedby={addressError ? errorId : undefined}
placeholder={t("ai.externalMcp.http.listenAddress.placeholder")}
value={addressDraft}
onChange={(event) => {
@@ -226,6 +266,8 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<input
type="text"
aria-label={t("ai.externalMcp.http.clientHost")}
aria-invalid={clientHostError}
aria-describedby={clientHostError ? errorId : undefined}
placeholder={t("ai.externalMcp.http.clientHost.placeholder")}
value={clientHostDraft}
onChange={(event) => {
@@ -245,8 +287,9 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
{settings.enabled && !externalMcpEnabled ? (
<p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p>
) : null}
{errorText ? <p className="text-xs text-destructive">{errorText}</p> : null}
<div className="space-y-2.5 rounded-md border border-border/60 bg-background/50 p-3">
{errorText ? <p id={errorId} role="alert" className="text-xs text-destructive">{errorText}</p> : null}
<details className="space-y-4">
<summary className="cursor-pointer text-sm font-medium">{t("ai.toolSettings.connectionDetails")}</summary>
<CopyableCodeBlock
label={t("ai.externalMcp.http.url")}
value={url}
@@ -259,6 +302,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.token")}
value={token}
sensitive
copyKey="http-token"
copied={copied}
onCopy={onCopy}
@@ -272,7 +316,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
type="button"
variant="outline"
size="sm"
disabled={!externalMcpEnabled || !settings.enabled || rotating}
disabled={!externalMcpEnabled || !settings.enabled || rotating || applying}
onClick={onRotateToken}
>
{t("ai.externalMcp.http.regenerate")}
@@ -290,6 +334,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.envLine")}
value={token ? buildCodexHttpEnvLine(token) : ""}
sensitive
copyKey="http-env"
copied={copied}
onCopy={onCopy}
@@ -300,6 +345,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.claudeCommand")}
value={token ? buildClaudeHttpAddCommand(url, token) : ""}
sensitive
copyKey="http-claude"
copied={copied}
onCopy={onCopy}
@@ -310,6 +356,7 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
<CopyableCodeBlock
label={t("ai.externalMcp.http.genericSnippet")}
value={token ? buildGenericHttpMcpJson(url, token) : ""}
sensitive
copyKey="http-json"
copied={copied}
onCopy={onCopy}
@@ -317,7 +364,14 @@ export const ExternalMcpHttpSection: React.FC<ExternalMcpHttpSectionProps> = ({
copiedLabel={t("ai.externalMcp.copied")}
emptyLabel={t("ai.externalMcp.unavailable")}
/>
</div>
</div>
</details>
</ToolSettingsDialog>
</SettingRow>
</SettingCard>
{settings.enabled && !externalMcpEnabled ? <p className="text-xs text-amber-500">{t("ai.externalMcp.http.requiresExternalMcp")}</p> : null}
{applying ? <p role="status" className="text-xs text-muted-foreground">{t("ai.toolSettings.applyingHttp")}</p> : null}
{copyError ? <p role="alert" className="text-xs text-destructive">{copyError}</p> : null}
{operationError || status?.error ? <p role="alert" className="text-xs text-destructive">{operationError || status?.error}</p> : null}
</SettingsAnchor>
);
};
@@ -0,0 +1,177 @@
import assert from 'node:assert/strict';
import test, { after } from 'node:test';
import React from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { JSDOM } from 'jsdom';
const dom = new JSDOM('<!doctype html><html><body></body></html>', { url: 'http://localhost/' });
const previous = new Map<string, PropertyDescriptor | undefined>();
for (const name of ['window', 'document', 'navigator', 'HTMLElement', 'Element', 'Node', 'MutationObserver', 'CustomEvent', 'Event'] as const) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value: dom.window[name], configurable: true });
}
for (const [name, value] of Object.entries({ IS_REACT_ACT_ENVIRONMENT: true, fetch: () => { throw new Error('Network is forbidden in model discovery tests'); } })) {
previous.set(name, Object.getOwnPropertyDescriptor(globalThis, name));
Object.defineProperty(globalThis, name, { value, configurable: true });
}
after(() => {
dom.window.close();
for (const [name, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, name, descriptor);
else Reflect.deleteProperty(globalThis, name);
}
});
const { I18nProvider } = await import('../../../../application/i18n/I18nProvider');
const { TooltipProvider } = await import('../../../ui/tooltip');
const { ModelSelector } = await import('./ModelSelector');
type Response = { ok: boolean; status?: number; data: string; error?: string };
function deferred() {
let resolve!: (value: Response) => void;
let reject!: (error: Error) => void;
const promise = new Promise<Response>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
type Props = React.ComponentProps<typeof ModelSelector>;
const baseProps: Props = {
value: '', onChange: () => {}, baseURL: 'http://127.0.0.1:9217/v1',
providerId: 'custom', style: 'openai', apiKey: 'SYNTHETIC_FIXTURE_KEY', customHeaders: {},
};
async function mount() {
const requests: ({ url: string; method: string; body: string | undefined } & ReturnType<typeof deferred>)[] = [];
const allowlistCalls: string[] = [];
let overflow = false;
Object.defineProperty(window, 'netcatty', { configurable: true, value: {
aiAllowlistAddHost: async (baseURL: string) => { allowlistCalls.push(baseURL); return { ok: true }; },
aiFetch: (url: string, method: string, _headers: unknown, body: string | undefined) => {
// Hold instead of rejecting if a regression exceeds the budget, so even a
// broken auto-fetch effect cannot create an uncontrolled test loop.
if (requests.length >= 4) { overflow = true; return new Promise<Response>(() => {}); }
const pending = deferred();
requests.push({ url, method, body, ...pending });
return pending.promise;
},
} });
let renderer!: ReactTestRenderer;
const view = (updates: Partial<Props> = {}) => <I18nProvider locale="en"><TooltipProvider>
<ModelSelector {...baseProps} {...updates} />
</TooltipProvider></I18nProvider>;
await act(async () => { renderer = create(view()); });
const input = () => renderer.root.findByType('input');
const open = async () => { await act(async () => input().props.onFocus()); };
const close = async () => { await act(async () => input().props.onBlur()); };
const refresh = async () => {
const button = renderer.root.findAllByType('button').find((node) => typeof node.props.onClick === 'function')!;
await act(async () => button.props.onClick({ defaultPrevented: false }));
};
const markup = () => JSON.stringify(renderer.toJSON());
return {
requests, allowlistCalls, input, open, close, refresh, markup,
update: async (updates: Partial<Props>) => { await act(async () => renderer.update(view(updates))); },
assertBounded: () => assert.equal(overflow, false, 'mock request budget was not exceeded'),
unmount: async () => { await act(async () => renderer.unmount()); },
};
}
const notFound: Response = { ok: false, status: 404, data: '{"error":"fixture has no model-list route"}' };
const models = (id: string): Response => ({ ok: true, status: 200, data: JSON.stringify({ data: [{ id }] }) });
for (const failure of ['404', 'transport', 'invalid-json'] as const) {
test(`model discovery ${failure} failure stays visible without auto-retry; Refresh retries once`, async () => {
const h = await mount();
const reject = (index: number) => {
if (failure === 'transport') h.requests[index].reject(new Error('Synthetic model-list transport failure'));
else h.requests[index].resolve(failure === '404' ? notFound : { ok: true, status: 200, data: '{not json' });
};
try {
assert.equal(h.requests.length, 0);
await h.open();
assert.equal(h.requests.length, 1);
assert.equal(h.requests[0].url, 'http://127.0.0.1:9217/v1/models');
assert.equal(h.requests[0].method, 'GET');
assert.equal(h.requests[0].body, undefined);
await act(async () => reject(0));
assert.equal(h.requests.length, 1, 'failure must settle instead of triggering the loading effect again');
assert.match(h.markup(), /text-destructive/);
assert.doesNotMatch(h.markup(), /Loading models/);
await h.close();
await h.open();
await h.update({ customHeaders: {} });
assert.equal(h.requests.length, 1, 'reopen or an equal-config render must not retry a failure');
assert.match(h.markup(), /text-destructive/);
await h.refresh();
assert.equal(h.requests.length, 2);
await act(async () => reject(1));
assert.equal(h.requests.length, 2);
assert.match(h.markup(), /text-destructive/);
assert.equal(h.allowlistCalls.length, 2);
h.assertBounded();
} finally { await h.unmount(); }
});
}
test('changing provider configuration clears a settled error and permits one fresh discovery', async () => {
const h = await mount();
try {
await h.open();
await act(async () => h.requests[0].resolve(notFound));
assert.equal(h.requests.length, 1);
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1', providerId: 'openai' });
assert.equal(h.requests.length, 2);
assert.equal(h.requests[1].url, 'http://127.0.0.1:9217/second-provider/v1/models');
await act(async () => h.requests[1].resolve(models('second-model')));
assert.equal(h.requests.length, 2);
assert.match(h.markup(), /second-model/);
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
h.assertBounded();
} finally { await h.unmount(); }
});
test('a provider switch ignores an older pending discovery failure', async () => {
const h = await mount();
try {
await h.open();
await h.update({ baseURL: 'http://127.0.0.1:9217/second-provider/v1' });
assert.equal(h.requests.length, 2);
await act(async () => h.requests[1].resolve(models('current-model')));
await act(async () => h.requests[0].resolve(notFound));
assert.equal(h.requests.length, 2);
assert.match(h.markup(), /current-model/);
assert.doesNotMatch(h.markup(), /Failed to fetch models/);
h.assertBounded();
} finally { await h.unmount(); }
});
test('successful discovery remains cached across reopen and manual model entry remains controlled', async () => {
const h = await mount();
try {
await h.open();
await act(async () => h.requests[0].resolve(models('listed-model')));
await h.close();
await h.open();
assert.equal(h.requests.length, 1);
assert.match(h.markup(), /listed-model/);
const changed: string[] = [];
await h.update({ onChange: (value) => { changed.push(value); } });
await act(async () => h.input().props.onChange({ target: { value: 'manual-model' } }));
await h.update({ value: changed[0] });
assert.deepEqual(changed, ['manual-model']);
assert.equal(h.input().props.value, 'manual-model');
assert.equal(h.requests.length, 1);
h.assertBounded();
} finally { await h.unmount(); }
});
test('closing and unmounting with a pending discovery do not trigger another request', async () => {
const h = await mount();
try {
await h.open();
await h.close();
await act(async () => h.requests[0].resolve(notFound));
assert.equal(h.requests.length, 1);
await h.refresh();
assert.equal(h.requests.length, 2);
h.assertBounded();
} finally { await h.unmount(); }
await act(async () => h.requests[1].resolve(notFound));
assert.equal(h.requests.length, 2);
});
@@ -175,12 +175,12 @@ export const ModelSelector: React.FC<{
}
}, [baseURL, effectiveModelsEndpoint, apiKey, customHeaders, resolvedStyle, skipTLSVerify, discoveryKey]);
// Auto-fetch when dropdown first opens
// Failed discovery stays settled until explicit refresh or a new discovery key.
useEffect(() => {
if (isOpen && canFetch && !hasFetched && !isLoading) {
if (isOpen && canFetch && !hasFetched && !isLoading && error === null) {
void fetchModels();
}
}, [isOpen, canFetch, hasFetched, isLoading, fetchModels]);
}, [isOpen, canFetch, hasFetched, isLoading, error, fetchModels]);
// Filter preset and discovered models by current input value (inline autocomplete).
const suggestions = useMemo(() => {
@@ -4,6 +4,7 @@ import { useI18n } from "../../../../application/i18n/I18nProvider";
import type { AIToolIntegrationMode } from "../../../../infrastructure/ai/types";
import { cn } from "../../../../lib/utils";
import { useToolAccessGuidanceState } from "../../../../application/state/useToolAccessGuidanceState";
import { ToolSettingsDialog } from "./ToolSettingsDialog";
import { EXTERNAL_MCP_DISCOVERY_ENV_VAR } from "./ExternalMcpCard";
/** Build a ready-to-paste prompt so an external AI client can register Netcatty MCP itself. */
@@ -45,16 +46,20 @@ type CopyRowProps = {
const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel, testId }) => {
const [copied, setCopied] = useState(false);
const [copyError, setCopyError] = useState(false);
const { t } = useI18n();
const canCopy = Boolean(value);
const handleCopy = async () => {
if (!value) return;
setCopyError(false);
setCopied(false);
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1200);
} catch {
// Clipboard may be unavailable; the text stays selectable in the block.
setCopyError(true);
}
};
@@ -82,6 +87,7 @@ const CopyRow: React.FC<CopyRowProps> = ({ value, label, copyLabel, copiedLabel,
{copied ? <Check size={14} className="text-emerald-500" /> : <Copy size={14} />}
</button>
</div>
{copyError ? <p role="alert" className="text-xs text-destructive">{t("ai.externalMcp.copyFailed")}</p> : null}
</div>
);
};
@@ -93,7 +99,7 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
if (mode === "skills") {
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<ToolSettingsDialog title={t("ai.toolAccess.skills.file")} triggerLabel={t("ai.toolSettings.viewSkills")}>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.skills.description")}
</p>
@@ -110,12 +116,12 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
{commandPrefix ? (
<p className="text-xs text-muted-foreground/80 font-mono break-all">{commandPrefix}</p>
) : null}
</div>
</ToolSettingsDialog>
);
}
return (
<div className="rounded-md border border-border/60 bg-background/50 p-3 space-y-2">
<ToolSettingsDialog title={t("ai.toolAccess.mcpPrompt.title")} triggerLabel={t("ai.toolSettings.viewPrompt")}>
<p className="text-xs text-muted-foreground leading-5">
{t("ai.toolAccess.mcpPrompt.description")}
</p>
@@ -129,6 +135,6 @@ export const ToolAccessGuidance: React.FC<{ mode: AIToolIntegrationMode }> = ({
{!mcpLauncherPath ? (
<p className="text-xs text-amber-500">{t("ai.toolAccess.mcpPrompt.enableHint")}</p>
) : null}
</div>
</ToolSettingsDialog>
);
};
@@ -0,0 +1,47 @@
import React from "react";
import { useI18n } from "../../../../application/i18n/I18nProvider";
import { Button } from "../../../ui/button";
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../../../ui/dialog";
/** Shared, bounded settings dialog. Callers retain their original commit semantics. */
export const ToolSettingsDialog: React.FC<{
title: string;
triggerLabel: string;
children: React.ReactNode;
immediate?: boolean;
open?: boolean;
onOpenChange?: (open: boolean) => void;
footer?: React.ReactNode;
busy?: boolean;
}> = ({ title, triggerLabel, children, immediate, open, onOpenChange, footer, busy }) => {
const { t } = useI18n();
const [localOpen, setLocalOpen] = React.useState(false);
const changeOpen = (next: boolean) => {
if (busy) return;
setLocalOpen(next);
onOpenChange?.(next);
};
return (
<Dialog open={open ?? localOpen} onOpenChange={changeOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">{triggerLabel}</Button>
</DialogTrigger>
<DialogContent
className="flex max-h-[calc(100dvh-2rem)] w-[calc(100vw-2rem)] max-w-2xl flex-col gap-0 overflow-hidden p-0"
aria-busy={busy || undefined}
hideCloseButton={busy}
onEscapeKeyDown={(event) => { if (busy) event.preventDefault(); }}
onInteractOutside={(event) => { if (busy) event.preventDefault(); }}
>
<DialogHeader className="shrink-0 px-6 pb-4 pt-6 pr-12 text-left">
<DialogTitle>{title}</DialogTitle>
{immediate ? <p className="text-xs text-muted-foreground">{t("ai.toolSettings.immediate")}</p> : null}
</DialogHeader>
<div className="min-h-0 overflow-y-auto px-6 pb-6 space-y-4">{children}</div>
<DialogFooter className="shrink-0 gap-2 border-t px-6 py-4">
{footer ?? <Button type="button" variant="outline" onClick={() => changeOpen(false)}>{t("common.close")}</Button>}
</DialogFooter>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,14 @@
import React from "react";
import { cn } from "../../../../lib/utils";
export type ToolStatusTone = "active" | "inactive" | "error";
/** Non-interactive runtime status, shared by the MCP and HTTP section headers. */
export const ToolStatusPill: React.FC<{ tone: ToolStatusTone; children: React.ReactNode }> = ({ tone, children }) => (
<span role="status" className={cn(
"inline-flex shrink-0 items-center rounded-full px-2.5 py-0.5 text-xs font-medium leading-5",
tone === "active" ? "bg-primary/10 text-primary"
: tone === "error" ? "bg-destructive/10 text-destructive"
: "bg-muted text-muted-foreground",
)}>{children}</span>
);
@@ -283,6 +283,8 @@ interface AIChatPanelsHostProps {
mountedTabIds: string[];
activeTabId: string | null;
activeSidePanelTab: SidePanelTab | null;
/** All live terminal+workspace target ids (for /chats dangling detection). */
liveScopeTargetIds: Set<string>;
contextsByTabId: Map<string, AIPanelContext>;
resolveExecutorContext: (scope: {
type: 'terminal' | 'workspace';
@@ -298,6 +300,8 @@ interface AIChatPanelsHostProps {
onOpenVaultHostFromChat?: (hostId: string) => void;
onOpenVaultSectionFromChat?: (section: 'notes' | 'hosts' | 'snippets') => void;
onOpenVaultSnippetFromChat?: (snippetId: string) => void;
/** Live workspaces, threaded to AI chats for `/chats` scope titles. */
workspaces?: Workspace[] | null;
}
const EMPTY_WORKSPACES: Workspace[] = [];
@@ -450,6 +454,7 @@ function aiChatPanelsHostAreEqual(
next: AIChatPanelsHostProps,
): boolean {
if (prev.mountedTabIds !== next.mountedTabIds) return false;
if (prev.liveScopeTargetIds !== next.liveScopeTargetIds) return false;
if (prev.contextsByTabId !== next.contextsByTabId) return false;
if (prev.activeSidePanelTab !== next.activeSidePanelTab) return false;
if (prev.pendingTerminalSelection !== next.pendingTerminalSelection) return false;
@@ -462,6 +467,7 @@ function aiChatPanelsHostAreEqual(
if (prev.onOpenVaultHostFromChat !== next.onOpenVaultHostFromChat) return false;
if (prev.onOpenVaultSectionFromChat !== next.onOpenVaultSectionFromChat) return false;
if (prev.onOpenVaultSnippetFromChat !== next.onOpenVaultSnippetFromChat) return false;
if (prev.workspaces !== next.workspaces) return false;
if (prev.activeTabId === next.activeTabId) return true;
for (let i = 0; i < prev.mountedTabIds.length; i += 1) {
@@ -489,6 +495,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
mountedTabIds,
activeTabId,
activeSidePanelTab,
liveScopeTargetIds,
contextsByTabId,
resolveExecutorContext,
pendingTerminalSelection,
@@ -500,6 +507,7 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
onOpenVaultHostFromChat,
onOpenVaultSectionFromChat,
onOpenVaultSnippetFromChat,
workspaces,
}) => {
const aiConfig = useContext(AIConfigContext);
@@ -517,6 +525,10 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
showDraftView,
updateDraft,
} = aiConfig;
const aiPanelWorkspaces = useMemo(
() => (workspaces ?? []).map((workspace) => ({ id: workspace.id, title: workspace.title })),
[workspaces],
);
useEffect(() => {
if (!pendingTerminalSelection) return;
@@ -588,6 +600,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
undoLastTurnInSession={aiConfig.undoLastTurnInSession}
deleteSession={aiConfig.deleteSession}
updateSessionTitle={aiConfig.updateSessionTitle}
rebindSessionScope={aiConfig.rebindSessionScope}
exportSessionsBackup={aiConfig.exportSessionsBackup}
importSessionsBackup={aiConfig.importSessionsBackup}
updateSessionExternalSessionId={aiConfig.updateSessionExternalSessionId}
addMessageToSession={aiConfig.addMessageToSession}
updateLastMessage={aiConfig.updateLastMessage}
@@ -621,7 +636,9 @@ const AIChatPanelsHostInner: React.FC<AIChatPanelsHostProps> = ({
scopeHostIds={context.scopeHostIds}
scopeLabel={context.scopeLabel}
focusedSessionId={context.focusedSessionId}
workspaces={aiPanelWorkspaces}
terminalSessions={context.terminalSessions}
liveScopeTargetIds={liveScopeTargetIds}
resolveExecutorContext={resolveExecutorContext}
isVisible={isVisible}
notes={notes}
@@ -654,6 +654,7 @@ function SidePanelAiSlotInner({
mountedTabIds={mountedAiTabIds}
activeTabId={activeTabId}
activeSidePanelTab={isVisible ? 'ai' : null}
liveScopeTargetIds={validAIScopeTargetIds}
contextsByTabId={aiContextsByTabId}
resolveExecutorContext={resolveAIExecutorContext}
pendingTerminalSelection={pendingTerminalSelectionForAI}
@@ -665,6 +666,7 @@ function SidePanelAiSlotInner({
onOpenVaultHostFromChat={onOpenVaultHostFromChat}
onOpenVaultSectionFromChat={onOpenVaultSectionFromChat}
onOpenVaultSnippetFromChat={onOpenVaultSnippetFromChat}
workspaces={workspaces}
/>
</AISidePanelStateRoot>
);
+92
View File
@@ -0,0 +1,92 @@
# Global Agent continuation: bounded QA recipe
This is a separate increment after the plugin platform foundation. The transport
review checkpoint is #4012 at `ea619555`; its isolated correction is `63f0572e0`.
The runtime continuation carries the correction through an ordinary feature-branch
merge. It is not merged to main or accepted for release.
## Reproduce the installable sample
From the exact runtime head reported in the handoff:
```sh
npm ci
npm run build:plugin-runtime-deps
node examples/plugins/global-agent/build.mjs
node packages/plugin-cli/dist/cli.js pack examples/plugins/global-agent/.package --out /tmp/netcatty-global-agent.ncpkg
sha256sum /tmp/netcatty-global-agent.ncpkg
```
The reproducible sample SHA-256 is
`f0edf43523845847d5dbb74b0c4d8cd3bfe8e0f89ca193f28140e6b47dc45ed0`.
Two independent package builds matched. Build scripts and tests are excluded
from the six-file installed package.
The sample reserves transcript space inside the actual 798-by-548-pixel plugin
viewport measured during native QA. In a scheduled native test environment, run
`npm run test:plugin-global-agent-layout` for the bounded render regression. It
loads the real sample HTML/CSS with synthetic history, including the fixture
notice, and checks transcript scrolling, reachable configuration, and visible
mouse Stop. It does not load the plugin script or start an Agent/provider. This
render check remains pending where the normal browser sandbox cannot start;
do not disable the sandbox to run it. Also verify the installed sample visually
at the 1188-by-848-pixel app window before accepting the viewport correction.
For the modal-history lifecycle regression, stop the synthetic stream, close
the modal with the host X, and reopen it without disabling/restarting the
plugin or waiting for five-minute lease expiry. Select the owned conversation,
confirm fresh empty targets, and Open. History must load without another model
request. Repeat with a new conversation. The sample command runtime retains
only its last host-issued chat handle and releases that exact lease on explicit
Open; it does not change core expiry, source ownership or duplicate-open guards.
## Native fixture acceptance (parent-scheduled GUI only)
1. Use an isolated synthetic profile, no real Notes, hosts, histories or keys.
Start `node scripts/plugin-global-agent-provider-fixture.mjs --port 9218`.
It performs no inference or outbound request and logs no bodies/headers.
2. Through normal host settings, configure an OpenAI-compatible Chat Completions
provider at `http://127.0.0.1:9218/v1`, model `global-agent-fixture`, with the
fixture-only literal `FIXTURE_ONLY_API_MARKER`. Do not use real credentials,
paid services, allowlist edits, debugger injection or the builtin AI sidebar.
3. Install the `.ncpkg` in Settings → Plugins and open its contributed Global
Agent Chat view. Verify light/dark/accent updates using existing theme tokens.
4. Choose Catty. Choose no targets, or only owned synthetic terminal fixtures,
then Open conversation. The fixture's default tool is `get_environment`; it
never requests a terminal command. Record Send → streamed result/usage and
`GET /fixture/status` counters. This proves a deterministic pipeline, not
real model inference.
5. Send `fixture:wait`, wait for output, press Stop. Verify aborted count rises
once and no late text appears. Capture screenshot/video with fixture labels.
6. Exercise Actions Ask using the existing in-app policy dialog: approve and
deny the scoped environment tool separately. Actions Deny permits model-only
chat with no tools. Agent-use Deny prevents dispatch. A policy change revokes
existing handles; reselect/reopen explicitly before another Send.
7. Close/reopen and disable/re-enable only this synthetic plugin. Reselect its
targets and reopen its owned conversation. History should survive, while the
private per-open actor ID is fresh. At five-minute expiry, the sample asks for
explicit reselection and keeps the draft, without resending or renewing scope.
8. Revoke/disable or lock while streaming and verify cleanup/no late events.
Disconnect/re-own a selected synthetic worker during selection and a turn;
stale selections must fail instead of binding a replacement connection.
Do not activate a configured real Claude backend in this fixture recipe. Claude
MCP's code/handler boundary is covered with synthetic tests; authenticated service
and genuine CLI acceptance remain unverified and require separately approved
access. Other backends are explicitly unsupported/unavailable. No background
service, blanket OS/Vault access, merge or release is implied.
## Evidence and limits
- Installed Catty path uses actual `AgentRuntime`, driver, main provider admission,
source policy and worker dispatcher, with JSDOM/in-process plugin transport and
synthetic workers. It proves selected B dispatch, stream, Stop, revocation and
history after runtime restart. This is not a native BrowserWindow sandbox test.
- Main/core tests cover source/session/resource substitution, scope expiry,
approval waits, worker lifecycle, cancellation, late completion, stable source
budgets, source-isolated history and no key serialization. Filesystem API reads
cannot access the core history root. Explicitly granted terminal commands still
have that terminal's native OS authority.
- No GUI ran in this executor. Native screenshots/video and independent runtime
review remain parent-owned gates. The exact-head CI result must be reported
separately from focused local evidence.
+309
View File
@@ -0,0 +1,309 @@
# Global Agent plugin bridge (work in progress)
This stack starts from `integration/plugin-ask-routing-acceptance` at
`2433cf0130d1c3274c56c39e5765349c2945de01`. The parent confirmed this dependency;
its native Ask acceptance and integrated CI are still pending. This branch does
not modify the frozen candidate, #3986, #3998, or #4002.
The user approved a global conversation independent of a terminal or workspace,
with a plugin-provided chat UI and selectable existing Agent backends. Core owns
Agent execution, history, target authority, native approvals, credentials and
audit. Global is not unrestricted inventory access or a background service.
## Stage 1: dispatch authority
`electron/plugins/agent/delegatedScope.cjs` is a host-only authority. It does not
register plugin APIs, start an Agent or implement an orchestration loop.
- `issue(context, hostSnapshot)` authenticates with the existing source binding.
Host snapshots contain backend identity/revision, target kind/id/owner/revision,
synchronous current-owner checks, and a supported capability subset. The public
result contains only an opaque handle and expiry. Plugin parameters must never
be used as `hostSnapshot`.
- `begin(handle, context)` returns an internal turn ticket and AbortController.
- `dispatch(ticket, context, resolvedAction, execute)` runs the common host policy
adapter once, checks current target revisions after any approval wait, invokes
the real dispatcher, and rejects late results. `resolvedAction` must come from
canonical host capability/argument resolution, not an unchecked plugin object.
- `assertTurn` guards outgoing events. `finish` and `stop` invalidate the exact
ticket. Runtime/plugin release, lock/hide invalidation and shutdown abort work.
- Handles expire in five minutes. There are at most eight per runtime owner and
64 overall, with at most 32 explicit resources per scope. Expiry never silently
refreshes a selection or revives an old turn.
The focused tests use the actual `PluginAISourceBinding` with synthetic host
registries, two terminal owners representing different workspaces, controlled
time, and deferred approval/native completion. They cover source/runtime/target
substitution, focus changes, revision changes across approval, policy denial,
Stop, release, lock, late completion and bounds. They do not yet prove actual MCP
or AgentRuntime dispatch; those remain integration gates.
Initial execution failed before test discovery because the new worktree has no
dependencies (`ajv/dist/2020` missing). Running with the existing local worktree's
read-only CommonJS dependency path passes all 12 cases. No provider, CLI Agent,
paid credentials or GUI was used.
The adjacent run then passed 49 tests and failed one installed-entrypoint test
because ESM could not resolve `@netcatty/plugin-cli` from the new worktree. Standard
`npm ci --ignore-scripts` and `build:plugin-runtime-deps` repaired the setup. These
failures are setup evidence, not failed-before product regressions.
The core conversation repository stores the existing global `AISession` document
shape in a host-only directory, namespaced by the hash of source/private-owner.
Native session identity and compaction remain private. Commits serialize a cloned
snapshot, revalidate provenance before atomic rename, and retain previous history
when that check fails. Files use mode 0600 and no-follow reads. The repository
does not register a plugin storage API or change the source database schema.
The canonical schema and SDK now declare the proposed session interface and
permissions. Runtime methods and the supported-feature advertisement are not yet
registered. This stage must not be described as an installable working Agent
bridge. Focused scope/history/wire and permission-copy tests pass 20 cases;
contract/SDK/CLI tests pass 91. Focused lint and generated drift checks pass.
## Independent stage-1 correction
The independent review of exact `056164157f34ea779ed6cf51199909082345eca4`
confirmed two helper defects before any runtime acceptance claim: source failure
during the initial owner check rejected late work without aborting the active
native signal; Stop could acquire a replacement turn after awaiting identity.
Both reproduce against that exact helper using the real `PluginAISourceBinding`:
the focused before run is 13 passed / 2 failed. The correction revokes the captured
scope when its own host-authenticated identity fails, and captures the intended
turn before Stop's first await. A foreign runtime cannot use a failed lookup to
revoke another caller's scope. Focused after: 15 passed / 0 failed. The complete
foundation/permission group passes 23 on Node 24.19.0 and Node 22.23.3; focused lint
and diff checks pass. Logs: `/tmp/netcatty-global-agent-review-before.log`,
`/tmp/netcatty-global-agent-review-after.log`,
`/tmp/netcatty-global-agent-review-node22.log`.
The first CI run at 0561641, test `40459522087`, Linux job `122040565759`, failed
after an explicit runner shutdown and operation cancellation, with no assertion
failure in its log and no complete repository test total. Package workflow
`40459521971` and both Windows jobs passed. The parent authorized one job rerun;
it was requested once, producing Linux job `122048349748`, still running at this
checkpoint. Original runner failure remains historical evidence. Neither that
rerun nor the helper fix establishes runtime, native or independent-review
acceptance; the correction still needs reviewer recheck.
### Follow-up: cancellation is not source revocation
The independent recheck confirmed the first two repairs and found a remaining
Stop race: an aborted Stop(A) RPC could enter the generic owner-failure cleanup
after B had started, revoking B's scope. The same failure was reproduced at both
owner-validation await paths using the actual source binding. Exact-18377 source
with the new 18-case scope suite yields 16 passed / 2 failed; evidence is retained
in `/tmp/netcatty-global-agent-stop-rpc-exact183-before.log`.
The minimal follow-up distinguishes this request's abort/deadline from verified
owner or resource failure, rethrows the access failure, and leaves the existing
turn-capture rule intact. Cancellation of Stop(A) cannot revoke B. A separate
regression confirms that a verified source mismatch still aborts native work
even when the RPC is concurrently cancelled. Foreign caller protections remain.
The full foundation/permission group passes 26 tests on Node 22.23.3 and Node
24.19.0; focused lint and diff checks pass. Logs:
`/tmp/netcatty-global-agent-stop-rpc-final.log` and
`/tmp/netcatty-global-agent-stop-rpc-node22.log`. Independent recheck is pending.
The authorized original-head Linux rerun `122048349748` ended cancelled, with an
operation-cancelled log and no assertion failure. No second rerun was requested.
At 18377, test workflow `40462595842`, Linux job `122050816182`, also received an
explicit runner shutdown; `PluginLogSettings.test.tsx` was SIGKILLed immediately
afterward. That interrupted stage reports 34 passed / 1 failed, not a complete
repository total or an assertion failure. Both Windows jobs passed. No blind
rerun of that job was made. Package workflow `40462595846` was still running when
checked. These records are not substituted for exact-head green CI.
## Planned public contract and adapters
### Independent transport correction
The exact `ea619555` internal-handler reproductions fail all four targeted cases:
both no-session and empty-selection environment reads serialize global tool hints;
stale chat-wide cleanup cancels replacement B; and caller-supplied builtin chat
text can downgrade cancellation of a main-owned delegated request. The outer
sender gate still accepts only the current trusted main window. These are trusted
application lifecycle defects, not arbitrary plugin IPC exposure or installed
runtime exploit evidence.
Cancellation now resolves ownership from the main request map, rejects a
conflicting caller chat, checks the recorded sender and current turn lease, and
cancels only that native request. The legacy chat-wide cleanup IPC is unavailable
for reserved delegated conversations; main-owned exact-turn release remains the
cleanup authority. All delegated environment return paths omit global tool hints.
An additional Node 22 regression is independently reproduced: inline arrow
properties at the beginning of `module.exports` prevent namespace detection of
existing exports such as `init`, breaking the real Catty attachment harness.
Identifier exports preserve that existing interface. Correction tests pass 94 on
Node 22.23.3, including the actual Catty attachment consumer; focused lint passes.
Failed-before logs are `/tmp/netcatty-global-agent-transport-review-four-failed-before.log`
and `/tmp/netcatty-global-agent-ea619-namespace-before.log`. The transport PR's
review checkpoint remains `ea619555` until the parent integrates/rechecks this
separate correction. No native/backend/full-CI acceptance is implied.
### Stage 2A: trusted native dispatch boundary
The independent bounded recheck of foundation `ba4d3fc` passed all 20 reviewer
cases. This resolves its reported helper findings; it does not establish full CI,
runtime, backend or native acceptance.
The additive transport slice gives managed subprocesses a separate per-turn MCP
credential, permanently binds their TCP connection to that turn, filters their
tool list, and routes actual tool execution through the source-bound host policy
adapter. Catty's trusted-app IPC enters the same dispatcher. Branded approval
contexts skip the legacy Agent approval only after that host adapter grants the
action; process-global permission mode is unchanged. Selected environment reads
return the scoped session metadata without querying global tunnel inventory.
Managed Claude is restricted to its existing MCP mode with builtin tools disabled,
strict MCP configuration and no inherited settings sources. The main native entry
rejects unverified backends and Skills mode for reserved delegated conversations.
Each model stream and native SDK request carries a main-owned turn tag; releasing
that lease aborts its HTTP/native work. Old credentials, cancellation requests and
disposers cannot acquire or cancel the next turn.
The focused boundary suite passes 116 cases, including an actual local TCP/main
handler with synthetic workers, plus two new actual-HTTP/renderer-transport lease
tests. These are deterministic transport tests, not real model inference or
authenticated Claude service/CLI acceptance. Public runtime registration, core
conversation integration and the custom chat sample are still separate work in
progress; this transport slice alone is not a usable plugin Agent feature.
At exact `ba4d3fc`, package CI `40464058910` passed; test workflow `40464058908`
is red. Linux job `122055141575` failed a real assertion in
`PluginManagementDialog.interaction.test.tsx:71`: the external view handoff had
not run when asserted. That stage reports 34 passed / 1 failed; earlier runtime
and contract groups passed 720 and 5. Both Windows jobs passed. No blind rerun was
requested. This unresolved assertion must not be classified as runner shutdown.
The wider initial local transport run reports 697 passed / 10 failed / 3 skipped.
All ten failures are Cursor discovery expectations after `npm ci --ignore-scripts`
left SQLite's native binding absent, making the bundled Cursor SDK import fail.
The standard rebuild first failed using unwritable default caches; rebuilding
with task-local npm/node-gyp cache paths succeeded. The failed totals are retained
in `/tmp/netcatty-global-agent-transport-expanded.log`; the after-setup run is
recorded separately. No GUI or provider credentials were used.
After the dependency repair, the expanded transport/native-handler/preload/SDK
suite passes 709 tests, 0 failures, 3 skips. Log:
`/tmp/netcatty-global-agent-transport-expanded-after-setup.log`. Focused lint passes.
The proposed feature is `ai.agent-sessions`, with `ai.agent.use` for conversation
operations and `ai.agent.actions` for actual Agent tool actions. Permission mode
comes from the existing source-bound Allow/Ask/Deny engine. These are additive
names, separate from the existing tools-disabled `ai.invoke` broker.
The API will provide backend listing, explicit target selection, owned chat
listing/opening, sending, bounded event reads, history, Stop and close. Backend
references are opaque host-issued configuration revisions. Conversation references
identify only histories owned by the calling source/private-owner; runtime handles
are separate and cannot substitute builtin chat IDs or native SDK resume IDs.
Messages cannot supply permissionMode, CLI/env/MCP configuration or owner fields.
Core will reuse `AISession`, `AgentRuntime`, both existing turn drivers, event and
compaction handling, SDK resume identity validation, and `stopAgentTurn`. The first
eligible backends are Catty and configured Claude Code in MCP mode. Other backend
entries must return explicit unsupported/unconfigured status until their native
tool and approval boundaries are verified. Codex SDK's current unrestricted native
configuration must not be treated as eligible merely because it has a driver.
The terminal worker already exposes a suitable main-owned resource binding:
`getPluginSessionBinding(sessionId, ownerWebContentsId)` returns worker epoch and
session generation. Use this for dispatch-time targets; do not use menu IDs,
focused tab, display labels, or a renderer-provided session ID as authority.
The MCP dispatcher and Catty direct execution route need the same delegated
session guard. A delegated call must not update process-global permission mode,
or request both an Agent approval and a plugin approval for the same action.
Policy changes, disable/restart/source replacement, lock and host disconnection
must cancel scoped native work and invalidate outgoing event tickets. Existing
Agent trace and plugin audit/log facilities will receive redacted correlation
metadata, not credentials, provider diagnostics or full unreviewed tool payloads.
## Remaining acceptance gates
### Separate runtime/sample implementation checkpoint
The runtime continuation wires the nine `context.agent` methods through the
registered default host, dedicated trusted-app preload/IPC and `PluginAgentHost`
mounted in `AppView`. It reuses the existing `AgentRuntime`, Catty/external turn
drivers, core `AISession` documents, compaction, model/provider transport, native
cancellation, selected worker epoch/generation checks and permission/audit engine.
No source database, Vault history or builtin conversation ownership change is
included. Plugin input cannot supply internal session IDs, source/owner, CLI/env,
MCP configuration or permission mode.
The first eligible profiles are configured Catty and Claude MCP. Other known
backends report unsupported/unavailable. Claude has builtin tools disabled,
strict MCP, no inherited settings sources, at most 12 native turns and no native
session persistence/resume; its existing driver rehydrates from core-owned history.
This code path has handler/driver/MCP fixture coverage. Authenticated Claude
service/native CLI acceptance has not run and must not be inferred from mocks.
Actions use one source-bound Allow / Ask / Deny decision path. Actions Deny permits
model-only conversation with an empty tool profile; Agent-use Deny blocks host
dispatch. Explicit target choice is independent of focus/workspace. Worker
lifecycle changes cancel a pending chooser and revoke stale target leases;
source/runtime/policy/lock invalidation aborts native work and rejects late events.
Core history is source/private-owner namespaced and read/write protected through
the plugin filesystem broker. A user-granted terminal command still has that
terminal's native OS authority; this is not an OS filesystem sandbox.
Bounds include six sends/source/minute, eight scopes/runtime and 64 total, 256
retained turn handles, 32 tool actions and 32 Catty HTTP calls per turn, 12 Catty
iterations, a 120-second turn lifetime, 60-second model HTTP/15-second streaming
idle deadlines, 128 KiB HTTP responses, 256 KiB projected turn output and 4 KiB
text events. Usage is projected; no hard total native-Claude token budget is
claimed. Handles expire after five minutes without silent target reselection.
The installable `examples/plugins/global-agent` sample provides backend/owned
conversation choice, explicit targets, history, Send/Stop and existing host theme
tokens. Its deterministic local provider fixture performs no model inference,
paid API calls, real terminal execution or request/body/header logging. The
installed Node/JSDOM pipeline test uses actual Catty orchestration, configured
HTTP admission and main dispatch to synthetic workers. It proves one selected-B
tool cycle, streamed result, Stop and revoked-handle denial; it is not native
browser sandbox or real model acceptance. A failed-before JSON-snapshot regression
is retained at `/tmp/netcatty-global-agent-snapshot-failed-before.log`.
Recorded validation: plugin runtime 732 passed/0 failed/1 skipped; affected group
105 passed/0 failed/3 skipped; examples 6 passed/0 failed/1 skipped; repaired setup
paths 284 passed. Build, codegen and lint passed before the final review corrections.
Typecheck is still red: 774 diagnostics on both unchanged foundation and this
continuation, with equal per-file/error-code counts; it is not a clean typecheck.
The repaired Node 22 full run reports 15,354 passed/9 failed/46 skipped. Eight are
missing xterm install patches, subsequently applied; the ninth is the transport
CJS namespace regression fixed separately. Exact final-head validation is pending.
The earlier pre-setup full run remains pending after its timed-out unpatched FIDO
test worker kept a socket open. Reading `/proc/319596/cwd` to confirm that worker's
identity was denied (EACCES); no signal was sent and no alternate route attempted.
Its log is retained without invented final totals. `pack:dir` first failed using
the unwritable default npm cache; a standard task-local cache repaired that step,
then native rebuild failed because installed `@electron/rebuild` hardcodes
`/home/agent/.electron-gyp` outside writable roots. No home override or sandbox
bypass was attempted. Native screenshots/video and independent runtime review
remain parent-owned gates; nothing is merged or released.
Final focused Node 22 validation passes 205 cases, including all nine failure
paths from the broad run after the documented setup/correction changes. The
installed pipeline additionally proves disable/re-enable, owned history reopening
and a fresh model-only turn under Actions Deny. `conversationRef` is durable;
each open receives a fresh private runtime actor so revoked privacy/native/tool
state cannot be revived. A typed, host-only close control clears its owned
runtime caches after that actor settles. Build, lint (seven existing warnings)
and generated drift checks pass. Typecheck still has the same 774 per-file/code
diagnostic counts as the unchanged foundation. Logs:
`/tmp/netcatty-global-agent-lifecycle-final-tests.log`,
`/tmp/netcatty-global-agent-lifecycle-final-build.log`, and
`/tmp/netcatty-global-agent-lifecycle-final-typecheck.log`.
The native recipe and reproducible package hash are in
[`global-agent-qa.md`](global-agent-qa.md). Remaining gates are independent
transport/runtime review, CI on the final integrated head, parent-scheduled native
screenshots/video, genuine Claude compatibility acceptance with separately
approved access, and native packaging on a machine with supported writable
rebuild caches. The initial full-run pending worker and previous red totals above
remain recorded. No GUI ownership, merge or release is granted to this executor.
+2
View File
@@ -3,6 +3,8 @@
// Presentation only. Admission and grants remain owned by the canonical schema
// and permission engine. Tests require exact coverage of its permission enum.
const PERMISSION_DESCRIPTIONS = {
"ai.agent.use": [true, "Agent conversations", "Use configured host Agents and read this plugin's own chat history. Model or Agent service usage may incur costs.", "Agent 会话", "使用宿主已配置的 Agent,并读取此插件自己的聊天历史;模型或 Agent 服务可能产生费用。"],
"ai.agent.actions": [true, "Agent actions", "Authorize Agent tools against explicitly selected targets under this plugin's policy. Commands can change remote systems.", "Agent 操作", "根据插件策略授权 Agent 工具操作明确选中的目标;命令可能更改远程系统。"],
storage: [false, "Plugin storage", "Save and read this plugin's local data.", "插件存储", "保存和读取此插件自己的本地数据。"],
"runtime.advanced": [true, "Full device access", "Run Node code with filesystem and network access outside broker restrictions. Developer mode only.", "完整设备访问", "运行 Node 代码,可直接访问文件系统和网络,不受代理权限限制。仅限开发者模式。"],
"settings.read": [false, "Read plugin settings", "Read this plugin's configured preferences.", "读取插件设置", "读取此插件自己的设置。"],
+10
View File
@@ -10,6 +10,16 @@ export type SplitHint = {
targetSessionId?: string;
};
export const invertSplitPosition = (position: SplitPosition): SplitPosition => {
switch (position) {
case 'left': return 'right';
case 'right': return 'left';
case 'top': return 'bottom';
case 'bottom': return 'top';
}
};
export const pruneWorkspaceNode = (node: WorkspaceNode, targetSessionId: string): WorkspaceNode | null => {
if (node.type === 'pane') {
return node.sessionId === targetSessionId ? null : node;
@@ -36,6 +36,79 @@ test('PTY sends no bytes while a slow shell is starting, then runs once ready',
assert.equal(pty.writes.length, 1);
});
test('macOS default zsh prompt is recognized as an idle prompt', async () => {
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'hexiao@JosephdeMacBook-Pro-2 ~ % ';
trackSessionIdlePrompt(session, `Last login: Mon Oct 26\r\n${prompt}`);
assert.equal(session.lastIdlePrompt, prompt);
assert.equal(readyPrompt(session), prompt);
const job = startPtyJob(pty, 'echo OK', {
shellKind: 'posix',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
});
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(pty.writes.length, 1);
});
test('zsh prompt with pending input does not become ready', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, 'hexiao@JosephdeMacBook-Pro-2 ~ % echo half-typed');
assert.equal(readyPrompt(session), '');
const job = startPtyJob(pty, 'echo OK', {
shellKind: 'posix',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 50,
timeoutMs: 1000,
});
t.mock.timers.tick(50);
const result = await job.resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY');
assert.deepEqual(pty.writes, []);
});
test('zsh default prompts allow directory spaces and the privileged marker', async () => {
for (const prompt of ['alice@MacBook-Pro My Project % ', 'root@MacBook-Pro ~ # ', 'root@MacBook-Pro My Project # ']) {
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, `Last login\r\n${prompt}`);
assert.equal(getFreshIdlePrompt(session), prompt);
assert.equal(getVisibleIdlePrompt(session), prompt);
const result = await startPtyJob(pty, 'echo OK', {
shellKind: 'posix',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
}).resultPromise;
assert.equal(result.ok, true, prompt);
assert.equal(pty.writes.length, 1, prompt);
}
});
test('zsh spaced-directory prompts with pending input stay unready', async () => {
for (const line of ['alice@MacBook-Pro My Project % echo %', 'root@MacBook-Pro My Project # echo #']) {
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, line);
assert.equal(readyPrompt(session), '', line);
const result = await startPtyJob(pty, 'echo forbidden', {
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 25,
timeoutMs: 1000,
}).resultPromise;
assert.equal(result.code, 'SHELL_NOT_READY', line);
assert.deepEqual(pty.writes, [], line);
}
});
test('PTY readiness timeout does not send command or interrupt to a busy program', async () => {
const pty = new TestPty();
const job = startPtyJob(pty, 'echo OK', { getReadyPrompt:()=>'', promptWaitTimeoutMs:25, timeoutMs:1000 });
+15
View File
@@ -312,6 +312,21 @@ test("recognizes PowerShell on Linux/macOS prompts (`PS /home/alice>`)", () => {
);
});
test("recognizes the macOS zsh default prompt without changing PowerShell/cmd detection", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "hexiao@JosephdeMacBook-Pro-2 ~ % "),
"posix",
);
assert.equal(
resolveEffectiveShellKind(undefined, "hexiao@MacBook-Pro ~/git %", { loginShellHint: "powershell" }),
"posix",
);
assert.equal(
resolveEffectiveShellKind("posix", "hexiao@MacBook-Pro ~ % echo half-typed"),
"posix",
);
});
test("ignores ANSI-coloured PowerShell prompts when detecting the shell", () => {
assert.equal(
resolveEffectiveShellKind(undefined, "PS C:\\Users\\alice>"),
@@ -41,6 +41,16 @@ test("prompt suffix is found after a very large flood of CRLF output", () => {
assert.equal(hasExpectedPromptSuffix(`${flood}${COLORED_PROMPT}`, "root@3160T:~# "), true);
});
test("macOS zsh default prompt suffix matches only when the prompt is idle", () => {
const prompt = "hexiao@JosephdeMacBook-Pro-2 ~ % ";
assert.equal(hasExpectedPromptSuffix(`Last login\r\n${prompt}`, prompt), true);
assert.equal(hasExpectedPromptSuffix(`Last login\r\n${prompt}echo hello`, prompt), false);
assert.equal(
hasExpectedPromptSuffix(`Last login\r\n${prompt}`, "hexiao@JosephdeMacBook-Pro-2 ~ %"),
false,
);
});
test("live PTY CR bytes match a cached two-line idle prompt", () => {
// Bash writes the PS1 newline as CR and ONLCR adds another, so the bytes
// are `\r\r\n`. The idle-prompt cache keeps those as blank lines. Suffix
+9 -1
View File
@@ -69,6 +69,13 @@ const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// macOS ships zsh with `%n@%m %1~ %#`, which renders as
// `user@host ~ %` / `user@host My Project %`; privileged shells end in `#`.
// Allow spaces in the path, but keep prompt delimiters excluded so a prompt
// followed by input (`user@host ~ % echo %`) cannot count as idle. Requiring
// whitespace before the marker also rejects output such as `user@host 100%`.
const ZSH_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+ [^\s%#$][^%#$\r\n]* [%#]$/;
// Host line of a two-line bash PS1 (`\u@\h: \w` then a following `$` or `#`).
// Same user@host[:path] class as POSIX_PROMPT_PATTERN's first alternative,
// without a prompt marker on this line. Oh My Zsh `ys` uses a different host
@@ -103,7 +110,8 @@ function isDefaultPosixPromptLine(line) {
const withoutEnvPrefix = normalized.replace(/^\([^()\n\r]+\)\s+/, "");
return (
POSIX_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix)
ALPINE_PROMPT_PATTERN.test(withoutEnvPrefix) ||
ZSH_PROMPT_PATTERN.test(withoutEnvPrefix)
);
}
+58
View File
@@ -151,6 +151,64 @@ test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
assert.equal(isDefaultPosixPromptLine(""), false);
});
test("isDefaultPosixPromptLine matches the macOS zsh default prompt", () => {
for (const prompt of [
"hexiao@JosephdeMacBook-Pro-2 ~ %",
"hexiao@JosephdeMacBook-Pro-2 ~ % ",
"alice@MacBook-Pro ~/src % ",
"alice@MacBook-Pro /tmp %",
"alice@MacBook-Pro My Project % ",
"root@MacBook-Pro ~ # ",
"root@MacBook-Pro My Project # ",
"(venv) alice@MacBook-Pro ~/src % ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("zsh default prompt recognition rejects pending input and non-prompt output", () => {
for (const line of [
"user@host ~ % echo",
"user@host ~ %echo",
"user@host ~ % printf busy%",
"user@host My Project % echo %",
"root@host My Project # echo #",
"user@host My Project % echo #",
"user@host My Project % echo $",
"user@host 100%",
"user@host 100#",
"progress 50 %",
"user@host %",
"#",
"%",
"progress@build 50%",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("extracts the macOS zsh default prompt and preserves trailing whitespace", () => {
assert.equal(
extractTrailingIdlePrompt("Last login: Mon Oct 26\r\nhexiao@MacBook-Pro ~ % "),
"hexiao@MacBook-Pro ~ % ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~/git %"),
"hexiao@MacBook-Pro ~/git %",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nalice@MacBook-Pro My Project % "),
"alice@MacBook-Pro My Project % ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nroot@MacBook-Pro My Project # "),
"root@MacBook-Pro My Project # ",
);
assert.equal(extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~ % echo"), "");
assert.equal(extractTrailingIdlePrompt("welcome\r\nhexiao@MacBook-Pro ~ %\r\n"), "");
});
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
for (const prompt of [
"localhost:~#",
+18 -2
View File
@@ -611,11 +611,22 @@ function withContentLength(headers, body) {
}
async function streamRequest(url, options, event, requestId, skipTLS) {
if (requestId.startsWith("agent-model_") && !/^agent-model_[0-9a-f-]{36}_[0-9a-f-]{36}_sdk_/.test(requestId)) {
throw new Error("Delegated model request identity invalid");
}
const parsedUrl = new URL(url);
// Register cancellation before any await so Stop during PAC/proxy lookup works.
const controller = new AbortController();
const pluginRuntimeId = /^plugin-model_([A-Za-z0-9-]{1,128})_/.exec(requestId)?.[1];
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId
const delegatedMatch = /^agent-model_([0-9a-f-]{36})_([0-9a-f-]{36})_sdk_/.exec(requestId);
const delegatedId = delegatedMatch?.[1];
const delegationTag = delegatedMatch?.[2];
const delegatedBridge = delegatedId ? require("./mcpServerBridge.cjs") : null;
const assertDelegation = () => delegatedBridge?.assertPluginAgentCaller(`plugin-agent:${delegatedId}`, event.sender.id, delegationTag);
assertDelegation();
if (delegatedId) delegatedBridge.beginPluginAgentModelRequest(`plugin-agent:${delegatedId}`, event.sender.id, delegationTag);
const detachDelegation = delegatedBridge?.onPluginAgentRelease(`plugin-agent:${delegatedId}`, delegationTag, () => controller.abort(createAbortError()));
const { idleTimeoutMs, totalTimeoutMs } = pluginRuntimeId || delegatedId
? { idleTimeoutMs: Math.min(15_000, Math.max(1, Number(options.idleTimeoutMs) || 15_000)),
totalTimeoutMs: Math.min(60_000, Math.max(1, Number(options.totalTimeoutMs) || 60_000)) }
: resolveAIStreamTimeouts(options);
@@ -629,6 +640,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
clearTimeout(idleTimer);
activeStreams.delete(requestId);
pluginModelStreamOwners.delete(requestId);
detachDelegation?.();
};
const noteStreamActivity = () => {
if (lifecycleFinished) return;
@@ -648,6 +660,9 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
throw controller.signal.reason instanceof Error ? controller.signal.reason : createAbortError();
}
try { assertDelegation(); }
catch (error) { finishLifecycle(); throw error; }
const { resolveOutboundHttpAgent } = require("./httpNetworkProxyAgent.cjs");
let proxyAgent;
try {
@@ -731,6 +746,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
if (proxyAgent) reqOpts.agent = proxyAgent;
try {
assertDelegation();
req = lib.request(parsedUrl, reqOpts,
(res) => {
if (streamFinished) {
@@ -795,7 +811,7 @@ async function streamRequest(url, options, event, requestId, skipTLS) {
res.on("data", (chunk) => {
if (streamFinished) return;
noteStreamActivity();
if (pluginRuntimeId) {
if (pluginRuntimeId || delegatedId) {
pluginResponseBytes += Buffer.byteLength(chunk);
if (pluginResponseBytes > 131_072) {
failStream(new Error("Plugin model stream byte budget exceeded"));
+56
View File
@@ -46,6 +46,7 @@ function loadBridgeWithMocks(options = {}) {
clearPendingApprovals() {},
cleanupScopedMetadata: async () => {},
cleanup() {},
...options.mcpServerBridge,
},
"../cli/discoveryPath.cjs": {
getCliLauncherPath: () => "/tmp/netcatty-tool-cli",
@@ -1725,6 +1726,61 @@ async function withCapturingServer(t, options, respond) {
};
}
test("delegated model stream release aborts its actual HTTP request and stale turns fail before HTTP", { timeout: 5_000 }, async (t) => {
const { randomUUID } = require("node:crypto");
const id = randomUUID(); const tag = randomUUID(); const chatId = `plugin-agent:${id}`;
let live = true; let release; let detached = 0; let requests = 0;
let resolveAccepted; const accepted = new Promise((resolve) => { resolveAccepted = resolve; });
const ctx = await withCapturingServer(t, { mcpServerBridge: {
beginPluginAgentModelRequest: () => {},
assertPluginAgentCaller: (actual, sender, actualTag) => {
assert.equal(actual, chatId); assert.equal(sender, 1);
if (!live || actualTag !== tag) throw Error("Agent turn unavailable");
},
onPluginAgentRelease: (_id, _tag, cancel) => { release = cancel; return () => detached++; },
} }, (res) => {
requests++; res.writeHead(200, { "content-type": "text/event-stream" });
res.write('data: {"synthetic":true}\n\n'); resolveAccepted();
});
try {
const invoke = (turnTag) => ctx.ipcMain.handlers.get("netcatty:ai:chat:stream")({ sender: ctx.sender }, {
requestId: `agent-model_${id}_${turnTag}_sdk_fixture`, url: `${ctx.baseURL}/v1/chat/completions`,
headers: { "content-type": "application/json" }, body: '{"stream":true}', providerId: "content-length",
});
const ended = new Promise((resolve) => ctx.onStreamEvent((channel) => {
if (channel === "netcatty:ai:stream:error" || channel === "netcatty:ai:stream:end") resolve();
}));
const pending = invoke(tag); await accepted; live = false; release();
await pending; await ended;
assert.equal(detached, 1); assert.equal(requests, 1);
const stale = await invoke(randomUUID());
assert.equal(stale.ok, false); assert.equal(requests, 1);
} finally { ctx.restore(); }
});
test("delegated compaction fetch uses the same exact-turn cancellation", { timeout: 5_000 }, async (t) => {
const { randomUUID } = require("node:crypto"); const id = randomUUID(); const tag = randomUUID();
let live = true; let release; let detached = 0; let requests = 0;
let accepted; const started = new Promise((resolve) => { accepted = resolve; });
const ctx = await withCapturingServer(t, { mcpServerBridge: {
beginPluginAgentModelRequest: () => {},
assertPluginAgentCaller: (chatId, sender, actualTag) => {
assert.equal(chatId, `plugin-agent:${id}`); assert.equal(sender, 1);
if (!live || actualTag !== tag) throw Error("Agent unavailable");
},
onPluginAgentRelease: (_id, _tag, cancel) => { release = cancel; return () => detached++; },
} }, (res) => { requests++; res.writeHead(200, { "content-type": "application/json" }); res.write('{"synthetic":'); accepted(); });
try {
const invoke = () => ctx.ipcMain.handlers.get("netcatty:ai:fetch")({ sender: ctx.sender }, {
requestId: `agent-model_${id}_${tag}_fetch_fixture`, url: `${ctx.baseURL}/v1/chat/completions`, method: "POST",
headers: { "content-type": "application/json" }, body: '{"messages":[]}', providerId: "content-length",
});
const pending = invoke(); await started; assert.equal(ctx.received.body.toString(), '{"messages":[]}');
live = false; release(); assert.equal((await pending).ok, false); assert.equal(detached, 1);
assert.equal((await invoke()).ok, false); assert.equal(requests, 1); assert.equal(ctx.ipcMain.handlers.has("netcatty:ai:chat:cancel"), true);
} finally { ctx.restore(); }
});
test("streaming AI requests send Content-Length instead of chunked encoding", { timeout: 5_000 }, async (t) => {
const ctx = await withCapturingServer(t, {}, (res) => {
res.writeHead(200, { "content-type": "text/event-stream" });
@@ -4,8 +4,12 @@ function registerAgentProcessHandlers(ctx) {
const maxCommandTimeoutSeconds = 24 * 60 * 60;
// ── MCP Server session metadata ──
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId }) => {
ipcMain.handle("netcatty:ai:mcp:update-sessions", async (event, { sessions: sessionList, chatSessionId, delegationTag }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
try { mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag); }
catch { return { ok: false, error: "Agent scope update unavailable" }; }
}
const list = Array.isArray(sessionList) ? sessionList : [];
const externalId = mcpServerBridge.EXTERNAL_MCP_CHAT_SESSION_ID;
if (chatSessionId === externalId) {
@@ -87,11 +87,20 @@ async function proxyCattyExecToWorker({
function registerCattyExecHandlers(ctx) {
const privacyService = ctx.aiPrivacyService || defaultAiPrivacyService;
with (ctx) {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId }) => {
ipcMain.handle("netcatty:ai:exec", async (event, { sessionId, command, chatSessionId, delegationTag }) => {
// Validate IPC sender (Issue #17)
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
try {
return await mcpServerBridge.dispatchBuiltinRpc("netcatty/exec", { sessionId, command, chatSessionId }, {
trustedAgentSenderId: event.sender.id,
trustedAgentTurnTag: delegationTag,
beforeExecute: () => assertCattyPrivacySession(privacyService, chatSessionId),
});
} catch { return { ok: false, error: "Delegated Agent execution unavailable" }; }
}
// Block execution in observer mode (Issue #11)
if (mcpServerBridge.getPermissionMode() === "observer") {
return { ok: false, error: "Execution blocked: permission mode is 'observer'" };
@@ -293,10 +302,14 @@ function registerCattyExecHandlers(ctx) {
});
// Cancel in-flight Catty Agent command executions for a chat session
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId }) => {
ipcMain.handle("netcatty:ai:catty:cancel", async (event, { chatSessionId, delegationTag }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
try { mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag); }
catch { return { ok: false, error: "Agent cancellation unavailable" }; }
}
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
void mcpServerBridge.cancelSftpOpsForSession?.(chatSessionId);
if (typeof mcpServerBridge.cancelWorkerBackgroundJobsForSession === "function") {
@@ -313,7 +326,7 @@ function registerCattyExecHandlers(ctx) {
return { ok: true };
});
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled }) => {
ipcMain.handle("netcatty:ai:chat-session:set-cancelled", async (event, { chatSessionId, cancelled, delegationTag }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
@@ -321,13 +334,14 @@ function registerCattyExecHandlers(ctx) {
return { ok: false, error: "chatSessionId is required" };
}
try {
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
return await mcpServerBridge.applyChatSessionCancelled(chatSessionId, cancelled !== false);
} catch (err) {
return { ok: false, error: err?.message || String(err) };
}
});
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId }) => {
ipcMain.handle("netcatty:ai:capability", async (event, { rpcMethod, params, chatSessionId, delegationTag }) => {
if (!validateSender(event)) {
return { ok: false, error: "Unauthorized IPC sender" };
}
@@ -340,7 +354,7 @@ function registerCattyExecHandlers(ctx) {
return await mcpServerBridge.dispatchBuiltinRpc(rpcMethod, {
...(params || {}),
chatSessionId,
}, { beforeExecute });
}, { beforeExecute, trustedAgentSenderId: event.sender.id, trustedAgentTurnTag: delegationTag });
} catch (err) {
return { ok: false, error: err?.message || "AI privacy protection is unavailable." };
}
+30 -4
View File
@@ -400,7 +400,7 @@ function registerProviderHandlers(ctx) {
});
// Non-streaming request (for model listing, validation, etc.)
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify }) => {
ipcMain.handle("netcatty:ai:fetch", async (event, { url, method, headers, body, providerId, skipHostCheck, followRedirects, skipTLSVerify, requestId }) => {
// Validate IPC sender — settings window needs this for model listing
if (!validateSenderOrSettings(event)) {
return { ok: false, status: 0, data: "", error: "Unauthorized IPC sender" };
@@ -428,7 +428,25 @@ function registerProviderHandlers(ctx) {
return { ok: false, status: 0, data: "", error: "URL host is not in the allowed list" };
}
const MAX_RESPONSE_SIZE = 10 * 1024 * 1024; // 10MB safety limit
const delegated = typeof requestId === "string" && /^agent-model_([0-9a-f-]{36})_([0-9a-f-]{36})_fetch_/.exec(requestId);
if (requestId !== undefined && !delegated) return { ok: false, status: 0, data: "", error: "Delegated request identity invalid" };
const controller = delegated ? new AbortController() : null;
const delegatedBridge = delegated ? require("./mcpServerBridge.cjs") : null;
const assertDelegation = () => {
controller?.signal.throwIfAborted();
if (delegated) delegatedBridge.assertPluginAgentCaller(`plugin-agent:${delegated[1]}`, event.sender.id, delegated[2]);
};
let detach; let totalTimer;
if (delegated) {
try {
assertDelegation();
delegatedBridge.beginPluginAgentModelRequest(`plugin-agent:${delegated[1]}`, event.sender.id, delegated[2]);
detach = delegatedBridge.onPluginAgentRelease(`plugin-agent:${delegated[1]}`, delegated[2], () => controller.abort());
activeStreams.set(requestId, controller);
totalTimer = setTimeout(() => controller.abort(), 60_000);
} catch { return { ok: false, status: 0, data: "", error: "Delegated Agent unavailable" }; }
}
const MAX_RESPONSE_SIZE = delegated ? 131_072 : 10 * 1024 * 1024;
const MAX_REDIRECTS = followRedirects ? 5 : 0;
async function doFetch(fetchUrl, redirectsLeft) {
@@ -437,14 +455,18 @@ function registerProviderHandlers(ctx) {
const skipTLS = Boolean(skipTLSVerify || shouldSkipTLSVerify(providerId));
let proxyAgent;
try {
assertDelegation();
proxyAgent = await resolveOutboundHttpAgent(fetchUrl, {
session: electronModule?.session?.defaultSession,
rejectUnauthorized: skipTLS ? false : undefined,
});
} catch {
} catch (error) {
if (delegated) throw error;
proxyAgent = undefined;
}
assertDelegation();
return new Promise((resolve) => {
const parsedUrl = new URL(fetchUrl);
const isHttps = parsedUrl.protocol === "https:";
@@ -457,6 +479,8 @@ function registerProviderHandlers(ctx) {
};
if (skipTLS && isHttps) fetchOpts.rejectUnauthorized = false;
if (proxyAgent) fetchOpts.agent = proxyAgent;
if (controller) fetchOpts.signal = controller.signal;
assertDelegation();
const req = lib.request(parsedUrl, fetchOpts,
(res) => {
// Handle redirects
@@ -505,7 +529,9 @@ function registerProviderHandlers(ctx) {
});
}
return doFetch(resolvedUrl, MAX_REDIRECTS);
try { return await doFetch(resolvedUrl, MAX_REDIRECTS); }
catch (error) { if (!delegated) throw error; return { ok: false, status: 0, data: "", error: "Delegated Agent unavailable" }; }
finally { if (delegated) { clearTimeout(totalTimer); detach?.(); activeStreams.delete(requestId); } }
});
}
@@ -0,0 +1,51 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
const { createRequire, wrap } = require("node:module");
// Execute the real driver/registry source with only config repair and SDK
// inference replaced. No user config is read and no SDK process is launched.
function loadSource(name, replacements) {
const filename = require.resolve(name); const local = createRequire(filename);
const module = { exports: {} };
const wrapper = vm.runInThisContext(wrap(fs.readFileSync(filename, "utf8")), { filename });
wrapper(module.exports, (id) => replacements[id] ?? local(id), module, filename, path.dirname(filename));
return module.exports;
}
function fixture() {
const repairs = []; const sends = []; const errors = [];
const driver = loadSource("./claudeDriver.cjs", { "./claudeConfig.cjs": { ensureClaudeConfig: () => repairs.push("repair") } });
const run = driver.runClaudeTurn;
driver.runClaudeTurn = (args) => run({ ...args, queryFn: ({ options }) => {
sends.push(options);
return (async function* () { yield { type: "stream_event", event: { type: "content_block_delta", delta: { type: "text_delta", text: "Synthetic" } } }; })();
} });
const registry = loadSource("./index.cjs", { "./claudeDriver.cjs": driver });
const emitter = new Proxy({ emitError: (error) => errors.push(error) }, { get: (target, key) => target[key] ?? (() => {}) });
const env = Object.freeze({ CLAUDE_CONFIG_DIR: "/synthetic/selected-profile", ANTHROPIC_BASE_URL: "http://127.0.0.1:1", ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" });
const ctx = { prompt: "Synthetic", env, emitter, toolIntegrationMode: "mcp", cwd: "/synthetic/turn", binPath: "/synthetic/official-claude",
abortController: new AbortController(), assertSessionValid: () => {}, injectedMcpServers: [{ name: "selected-only", command: "host-managed", args: [], env: [] }] };
return { repairs, sends, errors, env, ctx, run: (overrides) => registry.getDriver("claude").runTurn({ ...ctx, ...overrides }) };
}
for (const explicitProfile of [true, false]) test(`delegated registry turn skips config repair with ${explicitProfile ? "explicit" : "default"} host profile`, async () => {
const f = fixture(); const env = explicitProfile ? f.env : { ANTHROPIC_API_KEY: "FIXTURE_ONLY_MARKER" };
await f.run({ delegatedAgent: true, env });
assert.deepEqual(f.repairs, []); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
const options = f.sends[0]; assert.equal(options.env, env);
assert.equal(options.cwd, f.ctx.cwd); assert.equal(options.pathToClaudeCodeExecutable, f.ctx.binPath);
assert.deepEqual(options.tools, []); assert.deepEqual(options.settingSources, []);
assert.equal(options.strictMcpConfig, true); assert.equal(options.persistSession, false);
assert.deepEqual(Object.keys(options.mcpServers), ["selected-only"]);
});
for (const delegatedAgent of [undefined, false]) test(`builtin registry turn retains repair when delegated flag is ${String(delegatedAgent)}`, async () => {
const f = fixture(); await f.run({ delegatedAgent });
assert.deepEqual(f.repairs, ["repair"]); assert.equal(f.sends.length, 1); assert.deepEqual(f.errors, []);
assert.equal(f.sends[0].env, f.env); assert.equal(f.sends[0].settingSources, undefined);
});
+17 -4
View File
@@ -5,7 +5,7 @@
*
* - Spawns the user's system `claude` binary via an ABSOLUTE pathToClaudeCodeExecutable
* (SDK existsSync-checks it; PATH is not resolved — issue #205).
* - Repairs ~/.claude.json before spawn (ensureClaudeConfig).
* - Repairs ~/.claude.json before builtin turns only (ensureClaudeConfig).
* - Bypasses the SDK's built-in permission system and BLOCKS built-in
* side-effect tools so the agent can only act through the injected netcatty
* MCP server (approval/scope/blocklist enforced there).
@@ -101,7 +101,9 @@ function mergeClaudeEffortSettings(settings, effort) {
function buildClaudeQueryOptions({
cwd, model, env, pathToClaudeCodeExecutable, abortController, injectedMcpServers, settings, resume,
toolIntegrationMode,
delegatedAgent = false,
}) {
if (delegatedAgent && toolIntegrationMode !== "mcp") throw Error("Delegated Claude requires MCP mode");
const { model: resolvedModel, effort } = splitClaudeModelSelection(model);
const options = {
cwd,
@@ -130,8 +132,17 @@ function buildClaudeQueryOptions({
options.pathToClaudeCodeExecutable = pathToClaudeCodeExecutable;
}
// Optional settings.json path / inline object — additive to CLAUDE_CONFIG_DIR.
const parsedSettings = mergeClaudeEffortSettings(parseClaudeSettings(settings), effort);
const parsedSettings = mergeClaudeEffortSettings(delegatedAgent ? undefined : parseClaudeSettings(settings), effort);
if (parsedSettings !== undefined) options.settings = parsedSettings;
if (delegatedAgent) {
options.strictMcpConfig = true;
options.settingSources = [];
options.maxTurns = 12;
// Global plugin history belongs to core's source namespace. Rehydrate from
// that history each turn instead of creating a second native history store.
options.persistSession = false;
delete options.resume;
}
return options;
}
@@ -233,8 +244,10 @@ function buildClaudePromptInput(prompt, attachments) {
* @param {object} args.emitter createStreamEmitter(...)
* @param {Function} [args.queryFn] inject @anthropic-ai/claude-agent-sdk query (for tests)
*/
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid }) {
ensureClaudeConfig();
async function runClaudeTurn({ prompt, attachments, options, emitter, queryFn, assertSessionValid, delegatedAgent = false }) {
// A delegated turn may use the selected host profile for authentication, but
// must not repair or delete unrelated files in the main process's home.
if (!delegatedAgent) ensureClaudeConfig();
let query = queryFn;
if (!query) {
let sdk;
@@ -1,6 +1,12 @@
const test = require("node:test");
const assert = require("node:assert/strict");
// SDK queries in this file are synthetic. Never repair real user config.
const configPath = require.resolve("./claudeConfig.cjs");
const priorConfigModule = require.cache[configPath];
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
const { translateClaudeMessage, buildClaudeQueryOptions, buildClaudePromptInput, classifyClaudeSpawnError, listClaudeModels, mapClaudeModels, parseClaudeSettings, splitClaudeModelSelection } = require("./claudeDriver.cjs");
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
else delete require.cache[configPath];
function collector() {
const events = [];
@@ -333,3 +339,19 @@ test("listClaudeModels aborts a hung SDK query and returns it for cleanup", asyn
assert.equal(queryAbortSignal.aborted, true);
assert.equal(returnCount, 1);
});
test("delegated Claude uses only explicit MCP configuration and cannot inherit local settings/tool mode", () => {
const options = buildClaudeQueryOptions({
delegatedAgent: true, toolIntegrationMode: "mcp", settings: { mcpServers: { unwanted: {} } },
injectedMcpServers: [{ name: "scoped-netcatty", command: "host-managed", args: [], env: [] }],
});
assert.equal(options.strictMcpConfig, true);
assert.equal(options.maxTurns, 12);
assert.equal(options.persistSession, false);
assert.equal(options.resume, undefined);
assert.deepEqual(options.settingSources, []);
assert.deepEqual(options.tools, []);
assert.equal(options.settings, undefined);
assert.deepEqual(Object.keys(options.mcpServers), ["scoped-netcatty"]);
assert.throws(() => buildClaudeQueryOptions({ delegatedAgent: true, toolIntegrationMode: "skills" }), /requires MCP/);
});
+3 -1
View File
@@ -44,8 +44,10 @@ const DRIVER_REGISTRY = {
settings: ctx.claudeSettings,
resume: ctx.resumeSessionId,
toolIntegrationMode: ctx.toolIntegrationMode,
delegatedAgent: ctx.delegatedAgent === true,
});
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter, assertSessionValid: ctx.assertSessionValid });
return claude.runClaudeTurn({ prompt: ctx.prompt, attachments: ctx.attachments, options, emitter: ctx.emitter,
assertSessionValid: ctx.assertSessionValid, delegatedAgent: ctx.delegatedAgent === true });
},
async listModels(ctx) {
return claude.listClaudeModels({
@@ -13,6 +13,7 @@ async function buildInjectedMcpServers({
mcpServerBridge,
chatSessionId,
toolIntegrationMode,
delegationTag,
}) {
try {
// Start the netcatty control host for BOTH modes. getOrCreateHost binds the
@@ -30,8 +31,22 @@ async function buildInjectedMcpServers({
scopedIds,
chatSessionId,
);
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) {
const delegated = mcpServerBridge.getPluginAgentMcpConfiguration(chatSessionId, delegationTag);
// Replace, never append alongside, the app-wide credential. These values
// stay in the managed subprocess configuration and never reach the plugin.
netcattyMcpConfig.env = netcattyMcpConfig.env.filter((entry) => ![
"NETCATTY_MCP_TOKEN", "NETCATTY_MCP_DEBUG", "NETCATTY_MCP_PERMISSION_MODE",
].includes(entry.name));
netcattyMcpConfig.env.push(
{ name: "NETCATTY_MCP_TOKEN", value: delegated.token },
{ name: "NETCATTY_MCP_PERMISSION_MODE", value: "confirm" },
{ name: "NETCATTY_MCP_DELEGATED_TOOLS", value: JSON.stringify(delegated.tools) },
);
}
return [netcattyMcpConfig];
} catch (err) {
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) throw Error("Delegated Agent MCP is unavailable");
console.error("[sdk] Failed to ensure netcatty host / inject MCP server:", err?.message || err);
return [];
}
@@ -3,6 +3,14 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createAiPrivacyService } = require("../../../shared/aiPrivacyService.cjs");
// Load the query-only Claude fixture with config repair replaced. Its live
// prompt guard tests must never read or repair the executor's actual profile.
const configPath = require.resolve("./claudeConfig.cjs");
const priorConfigModule = require.cache[configPath];
require.cache[configPath] = { id: configPath, filename: configPath, loaded: true, exports: { ensureClaudeConfig() {} } };
const claudeDriver = require("./claudeDriver.cjs");
if (priorConfigModule) require.cache[configPath] = priorConfigModule;
else delete require.cache[configPath];
function fixture() {
const privacy = createAiPrivacyService();
@@ -172,7 +180,7 @@ for (const pausedMethod of ["initialize", "session/new"]) {
test("query and argv drivers reject invalid sessions at their outbound handoff", async (t) => {
const cases = [
["claude", (f) => require("./claudeDriver.cjs").runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["claude", (f) => claudeDriver.runClaudeTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["codebuddy", (f) => require("./codebuddyDriver.cjs").runCodebuddyTurn({ ...f, options: {}, queryFn: (input) => { f.sends.push(input); } })],
["cursor-cli", (f) => require("./cursorCliDriver.cjs").runCursorCliTurn({ ...f, binPath: "/bin/cursor", workspaceCwd: "/tmp", spawnImpl: (...input) => { f.sends.push(input); } })],
["grok-cli", (f) => require("./grokDriver.cjs").runGrokTurn({ ...f, binPath: "/bin/grok", spawnImpl: (...input) => { f.sends.push(input); } })],
@@ -744,6 +744,22 @@ function registerSdkStreamHandlers(ctx) {
} = payload;
const backendKey = resolveBackendKey(sdkBackend);
const delegatedAgent = mcpServerBridge.isPluginAgentConversation?.(chatSessionId) === true;
const delegationTag = delegatedAgent ? /^plugin-agent_([0-9a-f-]{36})_/.exec(requestId)?.[1] : undefined;
if (delegatedAgent && (backendKey !== "claude" || toolIntegrationMode !== "mcp")) {
throw Error("This backend is not eligible for delegated Agent sessions");
}
if (delegatedAgent) {
mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
mcpServerBridge.getPluginAgentMcpConfiguration(chatSessionId, delegationTag);
}
let delegatedCwd;
if (delegatedAgent) {
const nativePath = require("node:path");
const name = require("node:crypto").createHash("sha256").update(chatSessionId).digest("hex");
delegatedCwd = nativePath.join(tempDirBridge.getTempDir(), "plugin-agent", name);
require("node:fs").mkdirSync(delegatedCwd, { recursive: true, mode: 0o700 });
}
if (!backendKey) {
safeSend(event.sender, "netcatty:ai:sdk-agent:error", {
requestId, error: `Unknown SDK backend: ${sdkBackend}`,
@@ -752,8 +768,11 @@ function registerSdkStreamHandlers(ctx) {
}
const abortController = new AbortController();
const detachDelegatedCancellation = delegatedAgent
? mcpServerBridge.onPluginAgentRelease(chatSessionId, delegationTag, () => abortController.abort()) : null;
sdkActiveStreams.set(requestId, abortController);
sdkRequestSessions.set(requestId, chatSessionId);
if (delegatedAgent) sdkRequestRuntimes.set(requestId, { backendKey, delegationTag, senderId: event.sender.id });
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, false);
let privacyEnabled = false;
@@ -776,6 +795,7 @@ function registerSdkStreamHandlers(ctx) {
mcpServerBridge,
chatSessionId,
toolIntegrationMode: effectiveMode,
delegationTag,
});
// NETCATTY_CLAUDE_SETTINGS is a netcatty marker carrying the claude SDK
@@ -850,6 +870,7 @@ function registerSdkStreamHandlers(ctx) {
const sessionRuntime = backendKey === "grok" ? grokRuntime : codexRuntime;
sdkRequestRuntimes.set(requestId, {
backendKey,
...(delegatedAgent ? { delegationTag, senderId: event.sender.id } : {}),
codexRuntime,
grokRuntime,
sessionRuntime,
@@ -1026,9 +1047,14 @@ function registerSdkStreamHandlers(ctx) {
chatSessionId,
// Drivers recheck after their own asynchronous startup, at the
// final Netcatty-owned prompt handoff (including disabled turns).
assertSessionValid: () => privacyService.checkSession(chatSessionId),
assertSessionValid: () => {
if (delegatedAgent) mcpServerBridge.assertPluginAgentCaller(chatSessionId, event.sender.id, delegationTag);
abortController.signal.throwIfAborted();
return privacyService.checkSession(chatSessionId);
},
delegatedAgent,
...promptPlacement,
cwd: cwd || process.cwd(),
cwd: delegatedCwd || cwd || process.cwd(),
model: model || undefined,
permissionMode: permissionMode || "confirm",
env,
@@ -1086,6 +1112,7 @@ function registerSdkStreamHandlers(ctx) {
emitter.emitError(err?.message || String(err));
return { ok: false, error: err?.message || String(err) };
} finally {
detachDelegatedCancellation?.();
sdkActiveStreams.delete(requestId);
sdkRequestSessions.delete(requestId);
sdkRequestRuntimes.delete(requestId);
@@ -1388,7 +1415,19 @@ function registerSdkStreamHandlers(ctx) {
ipcMain.handle("netcatty:ai:sdk-agent:cancel", async (event, { requestId, chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
const effectiveChatSessionId = chatSessionId || sdkRequestSessions.get(requestId);
const ownedChatSessionId = sdkRequestSessions.get(requestId);
// Caller text cannot select the legacy route for a delegated request.
if (ownedChatSessionId && chatSessionId && ownedChatSessionId !== chatSessionId) return { ok: false, error: "Agent request ownership mismatch" };
const effectiveChatSessionId = ownedChatSessionId || chatSessionId;
if (mcpServerBridge.isPluginAgentConversation?.(effectiveChatSessionId)) {
const native = sdkRequestRuntimes.get(requestId);
if (!native?.delegationTag || native.senderId !== event.sender.id
|| sdkRequestSessions.get(requestId) !== effectiveChatSessionId) return { ok: false, error: "Agent turn unavailable" };
try { mcpServerBridge.assertPluginAgentCaller(effectiveChatSessionId, event.sender.id, native.delegationTag); }
catch { return { ok: false, error: "Agent turn unavailable" }; }
sdkActiveStreams.get(requestId)?.abort();
return { ok: true };
}
mcpServerBridge.setChatSessionCancelled?.(effectiveChatSessionId, true);
mcpServerBridge.cancelPtyExecsForSession(effectiveChatSessionId);
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(effectiveChatSessionId);
@@ -1405,6 +1444,9 @@ function registerSdkStreamHandlers(ctx) {
ipcMain.handle("netcatty:ai:sdk-agent:cleanup", async (event, { chatSessionId }) => {
if (!validateSender(event)) return { ok: false, error: "Unauthorized IPC sender" };
// This legacy route has no per-request identity. Delegated lifecycle is
// owned by main's exact-turn lease, never a delayed chat-wide UI cleanup.
if (mcpServerBridge.isPluginAgentConversation?.(chatSessionId)) return { ok: false, error: "Delegated Agent cleanup is main-owned" };
mcpServerBridge.setChatSessionCancelled?.(chatSessionId, true);
mcpServerBridge.cancelPtyExecsForSession(chatSessionId);
mcpServerBridge.cancelWorkerBackgroundJobsForSession?.(chatSessionId);
@@ -2192,3 +2192,111 @@ test("SDK adapter passes a live privacy guard through asynchronous driver startu
assert.equal(sent, false);
assert.equal(h.ctx.sdkActiveStreams.size, 0);
});
test("delegated Agent entry rejects unverified runtimes and Skills before native/environment work", async () => {
let validated = 0;
const { handlers } = registerWithStubbedCtx({
mcpServerBridge: {
isPluginAgentConversation: () => true,
assertPluginAgentCaller: () => {},
getPluginAgentMcpConfiguration: () => { validated++; throw Error("No current delegation"); },
},
});
const handler = handlers.get("netcatty:ai:sdk-agent:stream");
for (const sdkBackend of ["codex", "copilot", "cursor", "codebuddy", "opencode", "grok", "mimo", "arbitrary-cli"]) {
await assert.rejects(handler({ sender: { id: 7 } }, {
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend, toolIntegrationMode: "mcp",
}), /not eligible/);
}
await assert.rejects(handler({ sender: { id: 7 } }, {
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend: "claude", toolIntegrationMode: "skills",
}), /not eligible/);
assert.equal(validated, 0);
await assert.rejects(handler({ sender: { id: 7 } }, {
requestId: "synthetic", chatSessionId: "plugin-agent:synthetic", sdkBackend: "claude", toolIntegrationMode: "mcp",
}), /No current delegation/);
assert.equal(validated, 1);
});
test("actual SDK entry derives delegated mode from main authority, ignoring caller flag spoofing", async (t) => {
const { PluginAgentMcpDelegations } = require("../../../plugins/agent/mcpDelegation.cjs");
const registry = new PluginAgentMcpDelegations(); t.after(() => registry.shutdown());
const chat = "plugin-agent:11111111-1111-4111-8111-111111111111";
const tag = "22222222-2222-4222-8222-222222222222";
registry.register(chat, { turnTag: tag, senderId: 7, tools: [], assertCurrent() {}, dispatch() {} });
const h = createPrivacyStreamHarness(createTestPrivacy(false));
const directory = fs.mkdtempSync(nodePath.join(os.tmpdir(), "netcatty-claude-flag-"));
t.after(() => fs.rmSync(directory, { recursive: true, force: true }));
h.ctx.tempDirBridge = { getTempDir: () => directory };
Object.assign(h.ctx.mcpServerBridge, {
isPluginAgentConversation: (id) => registry.isReserved(id),
assertPluginAgentCaller: (...args) => registry.assertCaller(...args),
getPluginAgentMcpConfiguration: (...args) => registry.configuration(...args),
onPluginAgentRelease: (...args) => registry.onRelease(...args),
});
const handler = h.handlers.get("netcatty:ai:sdk-agent:stream");
const payload = { requestId: `plugin-agent_${tag}_fixture`, chatSessionId: chat, sdkBackend: "claude", toolIntegrationMode: "mcp", prompt: "Synthetic", delegatedAgent: false };
assert.equal((await handler({ sender: { id: 7 } }, payload)).ok, true);
assert.equal(h.turns[0].delegatedAgent, true, "caller cannot turn off delegated protection");
assert.equal((await h.stream({ delegatedAgent: true })).ok, true);
assert.equal(h.turns[1].delegatedAgent, false, "builtin caller cannot opt into the delegated branch");
await assert.rejects(handler({ sender: { id: 99 } }, { ...payload, requestId: `plugin-agent_${tag}_foreign`, delegatedAgent: true }));
assert.equal(h.turns.length, 2, "a spoofed flag cannot replace current sender/turn authority");
});
test("delegated native cancellation is exact-request and cannot mark replacement chat work cancelled", async () => {
const broad = [];
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
isPluginAgentConversation: () => true,
assertPluginAgentCaller: () => {},
setChatSessionCancelled: () => broad.push('chat'), cancelPtyExecsForSession: () => broad.push('pty'),
clearPendingApprovals: () => broad.push('approval'), cancelWorkerBackgroundJobsForSession: () => broad.push('worker'),
} });
const first = new AbortController(); const second = new AbortController();
const chat = 'plugin-agent:synthetic';
ctx.sdkActiveStreams.set('native-a', first); ctx.sdkRequestSessions.set('native-a', chat);
ctx.sdkRequestRuntimes.set('native-a', { delegationTag: 'tag-a', senderId: 7 });
ctx.sdkActiveStreams.set('native-b', second); ctx.sdkRequestSessions.set('native-b', chat);
ctx.sdkRequestRuntimes.set('native-b', { delegationTag: 'tag-b', senderId: 7 });
const cancel = handlers.get('netcatty:ai:sdk-agent:cancel');
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: 'native-a', chatSessionId: chat })).ok, true);
assert.equal(first.signal.aborted, true); assert.equal(second.signal.aborted, false);
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: '', chatSessionId: chat })).ok, false);
assert.equal((await cancel({ sender: { id: 99 } }, { requestId: 'native-b', chatSessionId: chat })).ok, false);
assert.equal(second.signal.aborted, false); assert.deepEqual(broad, []);
});
test("legacy chat-wide cleanup cannot abort replacement delegated turn B or remove its metadata", async () => {
const effects = [];
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
isPluginAgentConversation: (id) => id?.startsWith('plugin-agent:'),
setChatSessionCancelled: () => effects.push('cancelled'), cancelPtyExecsForSession: () => effects.push('pty'),
cancelWorkerBackgroundJobsForSession: () => effects.push('worker'), cleanupScopedMetadata: async () => effects.push('metadata'),
} });
const chat = 'plugin-agent:11111111-1111-4111-8111-111111111111';
const replacement = new AbortController();
ctx.sdkActiveStreams.set('native-b', replacement); ctx.sdkRequestSessions.set('native-b', chat);
ctx.sdkRequestRuntimes.set('native-b', { backendKey: 'claude', delegationTag: 'turn-b', senderId: 7 });
const result = await handlers.get('netcatty:ai:sdk-agent:cleanup')({ sender: { id: 7 } }, { chatSessionId: chat, delegationTag: 'turn-a' });
assert.equal(replacement.signal.aborted, false); assert.equal(ctx.sdkActiveStreams.has('native-b'), true);
assert.equal(ctx.sdkRequestSessions.get('native-b'), chat); assert.equal(ctx.sdkRequestRuntimes.get('native-b').delegationTag, 'turn-b');
assert.deepEqual(effects, []); assert.equal(result.ok, false);
});
test("caller-supplied builtin chat cannot downgrade cancellation of a main-owned delegated request", async () => {
const effects = []; const chat = 'plugin-agent:11111111-1111-4111-8111-111111111111';
const { handlers, ctx } = registerWithStubbedCtx({ mcpServerBridge: {
isPluginAgentConversation: (id) => id?.startsWith('plugin-agent:'),
assertPluginAgentCaller: (id, sender, tag) => { if (id !== chat || sender !== 7 || tag !== 'turn-b') throw Error('Unavailable'); },
setChatSessionCancelled: () => effects.push('cancelled'), cancelPtyExecsForSession: () => effects.push('pty'),
clearPendingApprovals: () => effects.push('approval'), cancelWorkerBackgroundJobsForSession: () => effects.push('worker'),
} });
const replacement = new AbortController();
ctx.sdkActiveStreams.set('native-b', replacement); ctx.sdkRequestSessions.set('native-b', chat);
ctx.sdkRequestRuntimes.set('native-b', { backendKey: 'claude', delegationTag: 'turn-b', senderId: 7 });
const cancel = handlers.get('netcatty:ai:sdk-agent:cancel');
const result = await cancel({ sender: { id: 99 } }, { requestId: 'native-b', chatSessionId: 'builtin-chat' });
assert.equal(replacement.signal.aborted, false); assert.equal(result.ok, false); assert.deepEqual(effects, []);
assert.equal((await cancel({ sender: { id: 7 } }, { requestId: 'native-b', chatSessionId: chat })).ok, true);
assert.equal(replacement.signal.aborted, true);
});
+278
View File
@@ -0,0 +1,278 @@
/**
* AI Sessions Backup Bridge - durable disk mirror of the renderer's AI chat
* session store (`netcatty_ai_sessions_v1` in localStorage).
*
* localStorage lives inside the Electron profile directory, so reinstalling,
* moving or recreating the app takes the chat history with it, and in-app
* cleanup sweeps / cap eviction can also shrink the live store. This bridge
* keeps timestamped JSON generations under
* `{userData}/ai-sessions-backup/` so the newest store can be restored on
* boot when the live store is missing or empty (never on top of a non-empty
* store — see useAIState).
*
* Generations are capped (default 5) and each write is atomic
* (temp file + rename). All handlers never throw: a backup failure must not
* break chat persistence.
*/
const fs = require("node:fs");
const path = require("node:path");
let backupDir = null;
let electronDialog = null;
const BACKUP_DIR_NAME = "ai-sessions-backup";
const FILE_PREFIX = "ai-sessions-";
const EXPORT_PREFIX = "export-";
const FILE_SUFFIX = ".json";
/** Keep at most this many rolling generations (plus dated export files). */
const MAX_GENERATIONS = 5;
/**
* Hard cap for a mirrored payload. localStorage itself targets ~2 MB, but when
* cloud AI-session sync is enabled the renderer persists the full unpruned
* history for the disk mirror — that can exceed 4 MB. Keep a generous disk
* ceiling so synced histories still get rolling backups, while still rejecting
* runaway payloads.
*/
const MAX_BACKUP_JSON_BYTES = 64 * 1024 * 1024;
/**
* The automatic mirror recovers from in-profile localStorage loss or corruption
* (quota, empty or corrupt JSON, or accidental clear). Profile deletion or
* recreation is out of scope; use the manual export/import buttons for that.
*/
function ensureBackupDir() {
if (backupDir) return backupDir;
try {
let userDataPath = null;
try {
const { app } = require("electron");
userDataPath = app?.getPath?.("userData") ?? null;
} catch {
userDataPath = null;
}
if (!userDataPath) return null;
backupDir = path.join(userDataPath, BACKUP_DIR_NAME);
if (!fs.existsSync(backupDir)) {
fs.mkdirSync(backupDir, { recursive: true });
}
return backupDir;
} catch {
return null;
}
}
function listGenerationFiles(dir) {
try {
return fs
.readdirSync(dir)
.filter((file) => file.startsWith(FILE_PREFIX) && file.endsWith(FILE_SUFFIX))
.sort()
.reverse(); // timestamped names sort lexicographically newest-first
} catch {
return [];
}
}
function listExportFiles(dir) {
try {
return fs
.readdirSync(dir)
.filter((file) => file.startsWith(EXPORT_PREFIX) && file.endsWith(FILE_SUFFIX))
.sort()
.reverse();
} catch {
return [];
}
}
function timestampToken(now = new Date()) {
return `${now.toISOString().replace(/[-:]/g, "").replace(".", "-")}`;
}
/**
* Write one generation atomically (temp + rename) and prune older ones.
* Returns { ok, file } and never throws.
*/
function writeGeneration(json) {
const dir = ensureBackupDir();
if (!dir) return { ok: false, error: "no-backup-dir" };
if (typeof json !== "string" || json.length === 0) {
return { ok: false, error: "invalid-json" };
}
if (Buffer.byteLength(json, "utf-8") > MAX_BACKUP_JSON_BYTES) {
return { ok: false, error: "payload-too-large" };
}
const file = `${FILE_PREFIX}${timestampToken()}${FILE_SUFFIX}`;
const filePath = path.join(dir, file);
const tmpPath = `${filePath}.tmp`;
try {
fs.writeFileSync(tmpPath, json, "utf-8");
fs.renameSync(tmpPath, filePath);
} catch (error) {
try {
fs.unlinkSync(tmpPath);
} catch {
// ignore
}
return { ok: false, error: String(error) };
}
// Keep only the newest MAX_GENERATIONS generations.
const generations = listGenerationFiles(dir);
for (const stale of generations.slice(MAX_GENERATIONS)) {
try {
fs.unlinkSync(path.join(dir, stale));
} catch {
// skip
}
}
return { ok: true, file };
}
/**
* Read the newest usable session array. Invalid generations are skipped and
* older ones are tried, so one bad write never blocks a restore.
*/
function readLatestGeneration() {
const dir = ensureBackupDir();
if (!dir) return { ok: false };
for (const file of listGenerationFiles(dir)) {
try {
const json = fs.readFileSync(path.join(dir, file), "utf-8");
const parsed = JSON.parse(json);
// Structurally unusable payloads can fall back to an older session array.
if (!Array.isArray(parsed)) continue;
// An empty array is an intentional wipe and must not resurrect older chats.
if (parsed.length === 0) return { ok: true, json, file };
if (!parsed.some((entry) => (
entry !== null && typeof entry === "object"
&& typeof entry.id === "string"
&& typeof entry.title === "string"
&& typeof entry.agentId === "string"
&& entry.scope !== null && typeof entry.scope === "object"
&& typeof entry.scope.type === "string"
&& Array.isArray(entry.messages)
&& entry.messages.every((message) => (
message !== null && typeof message === "object"
&& typeof message.id === "string"
&& typeof message.role === "string"
&& typeof message.content === "string"
&& typeof message.timestamp === "number" && Number.isFinite(message.timestamp)
))
&& typeof entry.updatedAt === "number" && Number.isFinite(entry.updatedAt)
))) continue;
return { ok: true, json, file };
} catch {
// try the next older generation
}
}
return { ok: false };
}
async function exportToFile(json) {
const dir = ensureBackupDir();
if (!dir) return { success: false, error: "no-backup-dir" };
if (typeof json !== "string" || json.length === 0) {
return { success: false, error: "invalid-json" };
}
try {
let result;
if (electronDialog?.showSaveDialog) {
result = await electronDialog.showSaveDialog({
defaultPath: `${EXPORT_PREFIX}${timestampToken()}${FILE_SUFFIX}`,
filters: [
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (result.canceled || !result.filePath) {
return { success: false, canceled: true };
}
} else {
// Fallback when no dialog is available (tests / unusual hosts): fixed
// path next to the rolling generations.
return await writeExportFixed(dir, json);
}
await fs.promises.writeFile(result.filePath, json, "utf-8");
return { success: true, filePath: result.filePath };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function writeExportFixed(dir, json) {
const filePath = path.join(dir, `${EXPORT_PREFIX}${timestampToken()}${FILE_SUFFIX}`);
try {
await fs.promises.writeFile(filePath, json, "utf-8");
return { success: true, filePath };
} catch (error) {
return { success: false, error: String(error) };
}
}
async function importFromFile() {
try {
if (electronDialog?.showOpenDialog) {
const result = await electronDialog.showOpenDialog({
properties: ["openFile"],
filters: [
{ name: "JSON", extensions: ["json"] },
{ name: "All Files", extensions: ["*"] },
],
});
if (result.canceled || !result.filePaths || result.filePaths.length === 0) {
return { success: false, canceled: true };
}
const json = await fs.promises.readFile(result.filePaths[0], "utf-8");
return { success: true, json };
}
// No dialog available: import the newest fixed-path export instead of
// failing outright.
const dir = ensureBackupDir();
if (!dir) return { success: false, error: "no-backup-dir" };
const exports = listExportFiles(dir);
if (exports.length === 0) return { success: false, error: "no-export-file" };
const json = await fs.promises.readFile(path.join(dir, exports[0]), "utf-8");
return { success: true, json };
} catch (error) {
return { success: false, error: String(error) };
}
}
function registerHandlers(ipcMain) {
ipcMain.handle("netcatty:aiSessionsBackup:write", (_event, payload) =>
writeGeneration(payload?.json));
ipcMain.handle("netcatty:aiSessionsBackup:readLatest", () => readLatestGeneration());
ipcMain.handle("netcatty:aiSessionsBackup:export", (_event, payload) =>
exportToFile(payload?.json));
ipcMain.handle("netcatty:aiSessionsBackup:import", () => importFromFile());
}
const __internals = {
BACKUP_DIR_NAME,
FILE_PREFIX,
EXPORT_PREFIX,
MAX_GENERATIONS,
MAX_BACKUP_JSON_BYTES,
listGenerationFiles,
listExportFiles,
timestampToken,
writeGeneration,
readLatestGeneration,
writeExportFixed,
// Tests point the bridge at a temp directory without Electron.
_setBackupDir(dir) {
backupDir = dir;
},
};
module.exports = {
init(deps) {
electronDialog = deps?.electronModule?.dialog ?? null;
ensureBackupDir();
},
registerHandlers,
__internals,
};
@@ -0,0 +1,172 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const bridge = require("./aiSessionsBackupBridge.cjs");
const session = { id: "s1", title: "Session", agentId: "agent", scope: { type: "global" }, messages: [], updatedAt: 1 };
function makeTempDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-ai-backup-"));
}
test("writeGeneration writes atomically and rotates generations to the cap", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
const { MAX_GENERATIONS } = bridge.__internals;
for (let i = 0; i < MAX_GENERATIONS + 2; i++) {
const result = bridge.__internals.writeGeneration(JSON.stringify({ gen: i }));
assert.equal(result.ok, true);
// Small delay so timestamped names strictly increase.
const stamp = bridge.__internals.timestampToken();
while (bridge.__internals.timestampToken() === stamp) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 5);
}
}
const generations = bridge.__internals.listGenerationFiles(dir);
assert.equal(generations.length, MAX_GENERATIONS);
// Newest generation wins the lexicographic sort.
assert.deepEqual(JSON.parse(fs.readFileSync(path.join(dir, generations[0]), "utf-8")), {
gen: MAX_GENERATIONS + 1,
});
// No temp files left behind by the atomic write.
assert.deepEqual(fs.readdirSync(dir).filter((f) => f.endsWith(".tmp")), []);
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("readLatestGeneration skips corrupt generations and reads the newest parseable one", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
fs.writeFileSync(path.join(dir, "ai-sessions-20260101T000000-000Z.json"), "not json");
fs.writeFileSync(
path.join(dir, "ai-sessions-20260102T000000-000Z.json"),
JSON.stringify([session]),
);
const latest = bridge.__internals.readLatestGeneration();
assert.equal(latest.ok, true);
assert.deepEqual(JSON.parse(latest.json), [session]);
// A corrupt-only directory reports failure instead of throwing.
const corruptOnly = makeTempDir();
bridge.__internals._setBackupDir(corruptOnly);
try {
fs.writeFileSync(path.join(corruptOnly, "ai-sessions-20260103T000000-000Z.json"), "{oops");
assert.equal(bridge.__internals.readLatestGeneration().ok, false);
} finally {
bridge.__internals._setBackupDir(dir);
fs.rmSync(corruptOnly, { recursive: true, force: true });
}
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("readLatestGeneration skips non-array generations", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
const olderFile = "ai-sessions-20260101T000000-000Z.json";
const sessions = [session];
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify(sessions));
fs.writeFileSync(path.join(dir, "ai-sessions-20260102T000000-000Z.json"), JSON.stringify({ sessions }));
const latest = bridge.__internals.readLatestGeneration();
assert.equal(latest.ok, true);
assert.equal(latest.file, olderFile);
assert.deepEqual(JSON.parse(latest.json), sessions);
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("readLatestGeneration skips arrays without usable sessions", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
const olderFile = "ai-sessions-20260101T000000-000Z.json";
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify([session]));
fs.writeFileSync(path.join(dir, "ai-sessions-20260102T000000-000Z.json"), JSON.stringify([{ id: "bad" }]));
const latest = bridge.__internals.readLatestGeneration();
assert.equal(latest.ok, true);
assert.equal(latest.file, olderFile);
assert.deepEqual(JSON.parse(latest.json), [session]);
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("readLatestGeneration treats an empty generation as an authoritative wipe", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
const newestFile = "ai-sessions-20260102T000000-000Z.json";
fs.writeFileSync(path.join(dir, "ai-sessions-20260101T000000-000Z.json"), JSON.stringify([session]));
fs.writeFileSync(path.join(dir, newestFile), "[]");
const latest = bridge.__internals.readLatestGeneration();
assert.equal(latest.ok, true);
assert.equal(latest.file, newestFile);
assert.deepEqual(JSON.parse(latest.json), []);
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("readLatestGeneration skips sessions with malformed messages", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
const olderFile = "ai-sessions-20260101T000000-000Z.json";
const newestFile = "ai-sessions-20260102T000000-000Z.json";
fs.writeFileSync(path.join(dir, olderFile), JSON.stringify([session]));
for (const message of [null, {}, { id: "m", role: "user", content: "hello", timestamp: null }]) {
fs.writeFileSync(path.join(dir, newestFile), JSON.stringify([{ ...session, messages: [message] }]));
const latest = bridge.__internals.readLatestGeneration();
assert.equal(latest.ok, true);
assert.equal(latest.file, olderFile);
assert.deepEqual(JSON.parse(latest.json), [session]);
}
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("writeGeneration rejects invalid or oversized payloads", () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
assert.equal(bridge.__internals.writeGeneration("").ok, false);
assert.equal(bridge.__internals.writeGeneration(null).ok, false);
const oversized = "x".repeat(bridge.__internals.MAX_BACKUP_JSON_BYTES + 1);
assert.equal(bridge.__internals.writeGeneration(oversized).ok, false);
assert.deepEqual(bridge.__internals.listGenerationFiles(dir), []);
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("export falls back to a fixed path when no dialog is available", async () => {
const dir = makeTempDir();
bridge.__internals._setBackupDir(dir);
try {
const result = await bridge.__internals.writeExportFixed(dir, JSON.stringify([{ id: "s1" }]));
assert.equal(result.success, true);
assert.ok(path.basename(result.filePath).startsWith("export-"));
assert.equal(fs.existsSync(result.filePath), true);
} finally {
bridge.__internals._setBackupDir(null);
fs.rmSync(dir, { recursive: true, force: true });
}
});
@@ -439,6 +439,7 @@ function ensureTrayPanelWindow() {
// see-through. On Windows, disable OS rounding so it does not stack under
// the CSS radius (#2505 / Electron #46468).
...windowsCssRoundedOverlayChromeOptions(),
...(process.platform === "darwin" ? { visualEffectState: "active" } : {}),
webPreferences: {
preload: path.join(__dirname, "../preload.cjs"),
contextIsolation: true,
@@ -450,6 +451,8 @@ function ensureTrayPanelWindow() {
},
});
require("./windowManager.cjs").applyTrayPanelBackgroundBlur(trayPanelWindow);
trayPanelWindow.webContents.on("console-message", (_event, level, message) => {
// Forward renderer logs to main process output for easy debugging.
console.log(`[TrayPanel:renderer:${level}] ${message}`);
+68 -15
View File
@@ -10,6 +10,8 @@ const net = require("node:net");
const crypto = require("node:crypto");
const fs = require("node:fs");
const path = require("node:path");
const { PluginAgentMcpDelegations } = require("../plugins/agent/mcpDelegation.cjs");
const pluginAgentDelegations = new PluginAgentMcpDelegations();
const { existsSync } = require("node:fs");
const { toUnpackedAsarPath, getFreshIdlePrompt, formatSyntheticEcho } = require("./ai/shellUtils.cjs");
@@ -1423,7 +1425,7 @@ function handleConnection(socket) {
consumedUntil = newlineIdx + 1;
searchFrom = consumedUntil;
if (!line.trim()) continue;
debugLog("Incoming line", line);
debugLog("Incoming message", { bytes: Buffer.byteLength(line) });
handleMessage(socket, line);
}
if (consumedUntil > 0) buffer = buffer.slice(consumedUntil);
@@ -1455,7 +1457,8 @@ async function handleMessage(socket, line) {
const isExternalToken = Boolean(
presentedToken && externalAuthToken && presentedToken === externalAuthToken,
);
if (method === "auth/verify" && (isCattyToken || isExternalToken)) {
const isDelegatedToken = method === "auth/verify" && pluginAgentDelegations.authenticate(presentedToken, socket);
if (method === "auth/verify" && (isCattyToken || isExternalToken || isDelegatedToken)) {
if (isExternalToken && !externalMcpActivityHook?.isEnabled?.()) {
const response = JSON.stringify({
jsonrpc: "2.0",
@@ -1496,6 +1499,13 @@ async function handleMessage(socket, line) {
try {
const callParams = { ...(params || {}) };
const delegatedChat = pluginAgentDelegations.boundChat(socket);
if (delegatedChat) {
if (callParams.chatSessionId && callParams.chatSessionId !== delegatedChat) throw Error("Agent connection cannot change its session");
callParams.chatSessionId = delegatedChat;
} else if (pluginAgentDelegations.isReserved(callParams.chatSessionId)) {
throw Error("Delegated Agent credentials required");
}
// External-token sockets always operate under the reserved app-wide scope so
// callers cannot omit chatSessionId and widen access via scopedSessionIds.
if (externalMcpSockets.has(socket)) {
@@ -1531,7 +1541,7 @@ async function handleMessage(socket, line) {
managedPrivacyScopes.set(socket, callParams.chatSessionId);
}
result = await createManagedPrivacyBoundary(
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, context),
(rpcMethod, rpcParams, context) => dispatch(rpcMethod, rpcParams, { ...context, externalSocket: socket }),
aiPrivacyService,
)(method, callParams);
}
@@ -1653,6 +1663,7 @@ const dispatchCapabilityRpc = createCapabilityRpcDispatcher({
return invokeVaultAgentFn(...args);
},
evaluatePermissionWithGrants,
isDelegatedApproval: (context) => pluginAgentDelegations.isApproved(context),
get permissionMode() {
return permissionMode;
},
@@ -1967,7 +1978,7 @@ function getWorkerJob(jobId, chatSessionId) {
const WORKER_JOB_NOT_FOUND_ERROR = "Background job not found";
async function handleWorkerJobPoll(params = {}) {
async function handleWorkerJobPoll(params = {}, beforeExecute) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getWorkerJob(jobId, chatSessionId || null);
@@ -1979,7 +1990,7 @@ async function handleWorkerJobPoll(params = {}) {
const scopeErr = validateSessionScope(job.sessionId, chatSessionId || null, scopedSessionIds);
if (scopeErr) return { ok: false, error: scopeErr };
}
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, {});
const result = await terminalWorkerManager.request("netcatty:ai:jobPoll", params, typeof beforeExecute === "function" ? { beforeExecute } : {});
if (result?.ok === false && result.error === WORKER_JOB_NOT_FOUND_ERROR) {
// The route is still here but the worker no longer knows the job (pruned or restarted).
emitJobDiag("job-stop-miss", { jobId, chatSessionId: chatSessionId || null, op: "poll", reason: "worker-job-missing" });
@@ -1990,7 +2001,7 @@ async function handleWorkerJobPoll(params = {}) {
return result;
}
async function handleWorkerJobStop(params = {}) {
async function handleWorkerJobStop(params = {}, beforeExecute) {
const { jobId, chatSessionId, scopedSessionIds } = params || {};
if (!jobId) throw new Error("jobId is required");
const job = getWorkerJob(jobId, chatSessionId || null);
@@ -2001,7 +2012,7 @@ async function handleWorkerJobStop(params = {}) {
if (Array.isArray(scopedSessionIds) && job.sessionId && !scopedSessionIds.includes(job.sessionId)) {
return { ok: false, error: `Session "${job.sessionId}" is not in the current scope.` };
}
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, {});
const result = await terminalWorkerManager.request("netcatty:ai:jobStop", params, typeof beforeExecute === "function" ? { beforeExecute } : {});
if (result?.ok === false && result.error === WORKER_JOB_NOT_FOUND_ERROR) {
// The route is still here but the worker no longer knows the job (pruned or restarted).
emitJobDiag("job-stop-miss", { jobId, chatSessionId: chatSessionId || null, op: "stop", reason: "worker-job-missing" });
@@ -2148,7 +2159,17 @@ function getBuiltinRpcHandlerRegistry() {
}
async function dispatch(method, params, approvalContext = {}) {
debugLog("dispatch", { method, params, permissionMode });
if (pluginAgentDelegations.isReserved(params?.chatSessionId)) {
return pluginAgentDelegations.dispatch(method, params, approvalContext,
(approved) => dispatchCore(method, params, approved));
}
return dispatchCore(method, params, approvalContext);
}
async function dispatchCore(method, params, approvalContext = {}) {
const delegated = pluginAgentDelegations.isApproved(approvalContext);
if (delegated) debugLog("Delegated dispatch", { method });
else debugLog("dispatch", { method, params, permissionMode });
if (!method.startsWith("netcatty/")) {
const capabilityResult = await dispatchCapabilityRpc(method, params || {}, approvalContext);
@@ -2164,7 +2185,8 @@ async function dispatch(method, params, approvalContext = {}) {
pruneCompletedBackgroundJobs();
const approvalTarget = getApprovalTarget(params);
const permission = evaluatePermissionWithGrants({
const permission = delegated ? { allowed: !isChatSessionCancelled(params?.chatSessionId), requiresApproval: false }
: evaluatePermissionWithGrants({
rpcMethod: method,
surface: CAPABILITY_SURFACES.BUILTIN,
permissionMode,
@@ -2266,10 +2288,10 @@ async function dispatch(method, params, approvalContext = {}) {
return await handleWorkerJobStart(params, approvalContext.beforeExecute);
}
if (capability?.id === "terminal.poll" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobPoll(params);
return await handleWorkerJobPoll(params, approvalContext.beforeExecute);
}
if (capability?.id === "terminal.stop" && workerBackgroundJobs.has(params?.jobId)) {
return await handleWorkerJobStop(params);
return await handleWorkerJobStop(params, approvalContext.beforeExecute);
}
return await handler(params, approvalContext);
} finally {
@@ -2284,10 +2306,12 @@ async function dispatch(method, params, approvalContext = {}) {
// ── Handler: getContext ──
async function handleGetContext(params) {
debugLog("handleGetContext:start", { params, sessionCount: sessions?.size || 0 });
const toolHints = buildMcpToolHints();
async function handleGetContext(params, approvalContext) {
const delegated = pluginAgentDelegations.isApproved(approvalContext);
debugLog("handleGetContext:start", delegated ? { delegated: true } : { params, sessionCount: sessions?.size || 0 });
const toolHints = delegated ? null : buildMcpToolHints();
if (!sessions) {
if (delegated) return { environment: "netcatty-terminal", description: "No host-selected terminal sessions are available.", hosts: [], hostCount: 0 };
return {
hosts: [],
instructions: "No sessions available.",
@@ -2333,7 +2357,7 @@ async function handleGetContext(params) {
description: "No hosts are available in the current scope.",
hosts: [],
hostCount: 0,
tools: toolHints,
...(delegated ? {} : { tools: toolHints }),
};
}
for (const [sessionId, session] of sessions.entries()) {
@@ -2383,6 +2407,12 @@ async function handleGetContext(params) {
}
}
if (pluginAgentDelegations.isApproved(approvalContext)) {
// Do not query or serialize app-wide tunnel/tool inventory for a delegated
// conversation. Its core-owned scope already selected the session metadata.
return { environment: "netcatty-terminal", description: "Use only these host-selected session IDs.",
hosts, hostCount: hosts.length };
}
let activePortForwardTunnels = [];
try {
activePortForwardTunnels = await listActivePortForwards();
@@ -2532,12 +2562,35 @@ const configAndCleanupApi = createConfigAndCleanupApi({
const { resolveMcpServerRuntimeCommand, buildMcpServerConfig, cleanupScopedMetadata } = configAndCleanupApi;
function cleanup() {
pluginAgentDelegations.shutdown();
try { disposeWorkerSessionClosed?.dispose?.(); } catch { /* ignore */ }
disposeWorkerSessionClosed = null;
shutdownHost();
}
// Keep identifier exports so Node 22's CJS namespace analysis also preserves the
// existing named exports consumed by renderer-side harness tests.
const registerPluginAgentDelegation = (...args) => pluginAgentDelegations.register(...args);
const getPluginAgentMcpConfiguration = (id, tag) => pluginAgentDelegations.configuration(id, tag);
const isPluginAgentConversation = (id) => pluginAgentDelegations.isReserved(id);
const assertPluginAgentCaller = (id, senderId, tag) => pluginAgentDelegations.assertCaller(id, senderId, tag);
const onPluginAgentRelease = (id, tag, cancel) => pluginAgentDelegations.onRelease(id, tag, cancel);
const beginPluginAgentModelRequest = (id, senderId, tag) => pluginAgentDelegations.beginModelRequest(id, senderId, tag);
function getPluginAgentJobTarget(jobId, chatSessionId) {
if (!pluginAgentDelegations.isReserved(chatSessionId)) return null;
const job = workerBackgroundJobs.get(jobId) ?? backgroundJobs.get(jobId);
return job?.chatSessionId === chatSessionId && typeof job.sessionId === "string" ? job.sessionId : null;
}
module.exports = {
getPluginAgentJobTarget,
beginPluginAgentModelRequest,
registerPluginAgentDelegation,
getPluginAgentMcpConfiguration,
isPluginAgentConversation,
assertPluginAgentCaller,
onPluginAgentRelease,
init,
setCommandBlocklist,
getCommandBlocklist,
@@ -0,0 +1,110 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const net = require("node:net");
const { randomUUID } = require("node:crypto");
const { createNdjsonRpcClient } = require("../capabilities/rpcTransport.cjs");
const { DelegatedAgentScopes } = require("../plugins/agent/delegatedScope.cjs");
const { buildInjectedMcpServers } = require("./aiBridge/sdk/injectMcp.cjs");
const bridge = require("./mcpServerBridge.cjs");
const privacy = require("../shared/aiPrivacyService.cjs");
test("actual main TCP gate binds delegated credentials, scope and policy at worker dispatch without global mode changes", { timeout: 10_000 }, async (t) => {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-agent-mcp-test-"));
const clients = []; const registrations = [];
let effects = 0; let inventoryRequests = 0; let decisions = 0; let allowed = true; let targetRevision = "v1";
let beforeWorker = () => {}; let authorize = async () => {};
t.after(() => {
for (const registration of registrations) registration.dispose();
for (const client of clients) client.close();
bridge.setPermissionMode("confirm"); bridge.cleanup();
privacy.configure({ enabled: false });
fs.rmSync(directory, { recursive: true, force: true });
});
privacy.configure({ enabled: false });
bridge.init({ sessions: new Map(), cliDiscoveryFilePath: path.join(directory, "discovery.json"),
terminalWorkerManager: { request: async (method, _payload, options) => {
if (method !== "netcatty:ai:exec") { inventoryRequests++; return []; }
beforeWorker(); options?.beforeExecute?.(); effects++;
return { ok: true, stdout: "synthetic output", stderr: "", exitCode: 0 };
} } });
bridge.setPermissionMode("observer");
const port = await bridge.getOrCreateHost();
const coreId = `plugin-agent:${randomUUID()}`;
const turnTag = randomUUID();
const owner = Object.freeze({ runtimeId: "r1", pluginId: "com.example.assistant", privateOwner: "opaque-source-a", sourceKey: "source-a" });
const context = {};
const scopes = new DelegatedAgentScopes({ resolveOwner: async () => owner, assertHostActive: async () => {},
authorizeAction: async () => { decisions++; await authorize(); if (!allowed) throw Error("Denied by source policy"); } });
const target = { kind: "terminal", id: "synthetic-a", owner: "window-7", revision: "v1",
isCurrent: (snapshot) => snapshot.revision === targetRevision };
const scope = await scopes.issue(context, { backend: { key: "configured-claude", revision: "1", isCurrent: () => true },
targets: [target], capabilities: ["terminal.execute", "session.environment"] });
let ticket = await scopes.begin(scope.handle, context);
registrations.push(bridge.registerPluginAgentDelegation(coreId, {
senderId: 7, turnTag, tools: ["get_environment", "terminal_execute"],
assertCurrent: () => { if (ticket.controller.signal.aborted || targetRevision !== target.revision) throw Error("Stale delegation"); },
dispatch: (method, params, execute) => {
const capabilityId = method === "netcatty/exec" ? "terminal.execute" : method === "netcatty/getContext" ? "session.environment" : "unsupported";
const targets = params.sessionId ? [{ ...target, id: params.sessionId }] : [];
return scopes.dispatch(ticket, context, { capabilityId, targets }, execute);
},
}));
bridge.updateSessionMetadata([{ sessionId: target.id, hostname: "fixture.invalid", label: "Synthetic A", protocol: "ssh" }], coreId);
const appToken = bridge.buildMcpServerConfig(port, [], coreId).env.find((entry) => entry.name === "NETCATTY_MCP_TOKEN").value;
const injected = await buildInjectedMcpServers({ mcpServerBridge: bridge, chatSessionId: coreId, toolIntegrationMode: "mcp", delegationTag: turnTag });
const environment = Object.fromEntries(injected[0].env.map((entry) => [entry.name, entry.value]));
assert.notEqual(environment.NETCATTY_MCP_TOKEN, appToken);
assert.equal(environment.NETCATTY_MCP_PERMISSION_MODE, "confirm");
assert.deepEqual(JSON.parse(environment.NETCATTY_MCP_DELEGATED_TOOLS), ["get_environment", "terminal_execute"]);
async function connect(token) {
const socket = net.createConnection({ host: "127.0.0.1", port });
await new Promise((resolve, reject) => { socket.once("connect", resolve); socket.once("error", reject); });
const client = createNdjsonRpcClient({ socket }); clients.push(client);
await client.call("auth/verify", { token }); return client;
}
const app = await connect(appToken);
await assert.rejects(app.call("netcatty/getContext", { chatSessionId: coreId }), /Delegated Agent credentials/);
const client = await connect(environment.NETCATTY_MCP_TOKEN);
await assert.rejects(client.call("netcatty/getContext", { chatSessionId: "builtin-chat" }), /cannot change/);
const selected = await client.call("netcatty/getContext", {});
assert.deepEqual(Object.keys(selected).sort(), ["description", "environment", "hostCount", "hosts"]);
assert.deepEqual(selected.hosts.map((item) => item.sessionId), ["synthetic-a"]);
assert.equal(inventoryRequests, 0, "delegated context must not query app-wide tunnel inventory");
await assert.rejects(client.call("netcatty/getStatus", {}), /outside the Agent delegation/);
const run = (sessionId = target.id) => client.call("netcatty/exec", { sessionId, command: "printf synthetic" });
const beforeDecision = decisions;
assert.equal((await run()).stdout, "synthetic output");
assert.equal(effects, 1); assert.equal(decisions, beforeDecision + 1); assert.equal(bridge.getPermissionMode(), "observer");
await assert.rejects(run("unselected-session")); assert.equal(effects, 1);
allowed = false; await assert.rejects(run(), /Denied by source policy/); assert.equal(effects, 1);
allowed = true;
const entered = new Promise((resolve) => { authorize = async () => { resolve(); await new Promise((done) => { authorize = done; }); }; });
const pending = run(); await entered;
scopes.finish(ticket); authorize(); await assert.rejects(pending); assert.equal(effects, 1);
ticket = await scopes.begin(scope.handle, context); authorize = async () => {};
beforeWorker = () => { targetRevision = "v2"; };
await assert.rejects(run()); assert.equal(effects, 1);
registrations[0].dispose();
await assert.rejects(app.call("netcatty/getContext", { chatSessionId: coreId }));
assert.throws(() => bridge.getPluginAgentMcpConfiguration(coreId));
});
for (const sessions of [null, new Map()]) test(`delegated empty environment omits global tool inventory (${sessions === null ? "uninitialized" : "empty scope"})`, async (t) => {
bridge.init({ sessions });
const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
const registration = bridge.registerPluginAgentDelegation(id, { senderId: 7, turnTag: tag, tools: ["get_environment"],
assertCurrent: () => {}, dispatch: (_method, _params, execute) => execute() });
t.after(() => { registration.dispose(); bridge.cleanup(); });
const value = await bridge.dispatchBuiltinRpc("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag });
assert.equal(Object.hasOwn(value, "tools"), false); assert.deepEqual(value.hosts, []);
});
test("MCP bridge retains existing CJS namespace exports for actual Catty harness consumers", async () => {
const namespace = await import("./mcpServerBridge.cjs");
for (const name of ["init", "cleanup", "dispatchBuiltinRpc", "registerPluginAgentDelegation"]) assert.equal(typeof namespace[name], "function", name);
});
@@ -157,7 +157,9 @@ function createCapabilityRpcDispatcher(deps) {
hostname: host?.hostname || "",
};
}
const permission = evaluatePermissionWithGrants({
const permission = deps.isDelegatedApproval?.(approvalContext)
? { allowed: !isChatSessionCancelled(params?.chatSessionId), requiresApproval: false }
: evaluatePermissionWithGrants({
rpcMethod,
surface,
permissionMode: deps.permissionMode,
@@ -0,0 +1,29 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const vm = require("node:vm");
test("actual plugin broadcast skips destroyed WebContents while its BrowserWindow is still alive", () => {
// Evaluate the actual registrar callback with narrow window fixtures. This
// models the destruction-event ordering, not an installed Electron crash.
const source = fs.readFileSync(path.join(__dirname, "../main/registerBridges.cjs"), "utf8");
const callbacks = [...source.matchAll(/broadcast: \(channel, payload\) => \{([\s\S]*?)\n \},/g)];
assert.equal(callbacks.length, 1);
const received = [];
const deadWindow = { isDestroyed: () => true, get webContents() { throw Error("Destroyed BrowserWindow getter"); } };
const closingWindow = { isDestroyed: () => false, webContents: {
isDestroyed: () => true, send() { throw Error("Object has been destroyed"); },
} };
const liveWindow = { isDestroyed: () => false, webContents: {
isDestroyed: () => false, send: (...args) => received.push(args),
} };
const broadcast = vm.runInNewContext(`(channel, payload) => {${callbacks[0][1]}\n}`, {
electronModule: { BrowserWindow: { getAllWindows: () => [deadWindow, closingWindow, liveWindow] } },
});
const payload = { host: [], session: [] };
assert.doesNotThrow(() => broadcast("netcatty:plugins:scope-catalog-changed", payload));
assert.deepEqual(received, [["netcatty:plugins:scope-catalog-changed", payload]]);
});
+12
View File
@@ -432,6 +432,7 @@ function applyWindowOpacity(opacity) {
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
applyTrayPanelBackgroundBlur(getGlobalShortcutBridge().getTrayPanelWindow?.());
return currentWindowOpacity;
}
@@ -441,6 +442,15 @@ function isWindowBackgroundBlurActive() {
&& currentWindowOpacity < 1;
}
function applyTrayPanelBackgroundBlur(win) {
if (process.platform !== "darwin" || !win || win.isDestroyed?.()) return;
try {
win.setVibrancy?.(isWindowBackgroundBlurActive() ? "hud" : null);
} catch {
// ignore platform-specific chrome failures
}
}
function applyWindowBackgroundChromeToWindow(win, options = {}) {
if (!win || win.isDestroyed?.()) return;
try {
@@ -492,6 +502,7 @@ function applyWindowBackgroundBlur(enabled) {
if (settingsWindow && !settingsWindow.isDestroyed()) {
applyWindowOpacityToWindow(settingsWindow);
}
applyTrayPanelBackgroundBlur(getGlobalShortcutBridge().getTrayPanelWindow?.());
return supported;
}
@@ -1662,4 +1673,5 @@ module.exports = {
clampWindowOpacity,
applyWindowOpacity,
applyWindowOpacityToWindow,
applyTrayPanelBackgroundBlur,
};
@@ -5,7 +5,7 @@ const { createRequire } = require('node:module');
const vm = require('node:vm');
const test = require('node:test');
function loadWindowManager(platform) {
function loadWindowManager(platform, trayWindow = null) {
const filename = path.resolve(__dirname, '../windowManager.cjs');
const loadedModule = { exports: {} };
const context = vm.createContext({
@@ -19,7 +19,11 @@ function loadWindowManager(platform) {
`(function(exports, require, module, __filename, __dirname) { ${fs.readFileSync(filename, 'utf8')}\n})`,
context,
);
load(loadedModule.exports, createRequire(filename), loadedModule, filename, path.dirname(filename));
const originalRequire = createRequire(filename);
const requireWithTray = (name) => name === './globalShortcutBridge.cjs'
? { getTrayPanelWindow: () => trayWindow }
: originalRequire(name);
load(loadedModule.exports, requireWithTray, loadedModule, filename, path.dirname(filename));
const handlers = new Map();
loadedModule.exports.registerWindowHandlers({
handle: (channel, handler) => handlers.set(channel, handler),
@@ -35,9 +39,61 @@ function loadWindowManager(platform) {
};
loadedModule.exports.registerMainWindow(win);
const invoke = (name, value) => handlers.get(`netcatty:${name}`)({}, value);
return { invoke, state };
return { invoke, state, windowManager: loadedModule.exports };
}
test('macOS tray blur follows the saved preference and opacity without repainting its clear backing', () => {
const materials = [];
const backgrounds = [];
const opacities = [];
const trayWindow = {
isDestroyed: () => false,
setVibrancy: (value) => materials.push(value),
setBackgroundColor: (value) => backgrounds.push(value),
setOpacity: (value) => opacities.push(value),
};
const { invoke, windowManager } = loadWindowManager('darwin', trayWindow);
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
assert.equal(materials.at(-1), 'hud');
invoke('setWindowOpacity', 1);
assert.equal(materials.at(-1), null);
invoke('setWindowOpacity', 0.85);
assert.equal(materials.at(-1), 'hud');
invoke('setWindowBackgroundBlur', false);
assert.equal(materials.at(-1), null);
assert.deepEqual(backgrounds, []);
assert.deepEqual(opacities, []);
assert.equal(windowManager.getAppContentWindows().includes(trayWindow), false);
});
test('a tray created after blur is enabled receives the current native material', () => {
const { invoke, windowManager } = loadWindowManager('darwin');
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
const materials = [];
windowManager.applyTrayPanelBackgroundBlur({
isDestroyed: () => false,
setVibrancy: (value) => materials.push(value),
});
assert.deepEqual(materials, ['hud']);
windowManager.applyTrayPanelBackgroundBlur({
isDestroyed: () => true,
setVibrancy: () => assert.fail('destroyed tray must not be updated'),
});
});
test('tray vibrancy is untouched on Windows and Linux', () => {
for (const platform of ['win32', 'linux']) {
const { invoke } = loadWindowManager(platform, {
isDestroyed: () => false,
setVibrancy: () => assert.fail('non-macOS tray must not use vibrancy'),
});
invoke('setWindowOpacity', 0.7);
invoke('setWindowBackgroundBlur', true);
}
});
test('macOS blur keeps native opacity at one and restores normal opacity on disable', () => {
const { invoke, state } = loadWindowManager('darwin');
assert.equal(state.background, '#123456');
+1 -1
View File
@@ -61,7 +61,7 @@ const HARNESS_CAPABILITIES = [
},
{
id: "harness.terminal.read_context",
agentKinds: ["sidebar"],
agentKinds: ["sidebar", "global"],
domain: "harness",
status: CAPABILITY_STATUS.IMPLEMENTED,
description: "Read a bounded slice of the current terminal screen or scrollback from the active AI scope.",
+4 -1
View File
@@ -240,7 +240,7 @@ function createBridgeRegistrar(context) {
isTrustedSender: createTrustedPluginBridgeSender({ devServerUrl: effectiveDevServerUrl }),
broadcast: (channel, payload) => {
for (const window of electronModule.BrowserWindow.getAllWindows()) {
if (!window.isDestroyed()) window.webContents.send(channel, payload);
if (!window.isDestroyed() && !window.webContents.isDestroyed()) window.webContents.send(channel, payload);
}
},
});
@@ -545,6 +545,9 @@ function createBridgeRegistrar(context) {
aiBridge.registerHandlers(ipcMain);
httpNetworkProxyBridge.registerHandlers(ipcMain, electronModule);
crashLogBridge.registerHandlers(ipcMain);
const aiSessionsBackupBridge = require("../bridges/aiSessionsBackupBridge.cjs");
aiSessionsBackupBridge.init({ electronModule });
aiSessionsBackupBridge.registerHandlers(ipcMain);
vaultBackupBridge.registerHandlers(ipcMain, electronModule);
appLockController?.registerHandlers?.(ipcMain);
+11 -2
View File
@@ -69,6 +69,7 @@ const CHAT_SESSION_ID = process.env.NETCATTY_MCP_CHAT_SESSION_ID || null;
// External MCP clients may keep a long-lived stdio process; re-read discovery so
// Settings → AI → Safety changes apply without restarting the client.
function readPermissionMode() {
if (process.env.NETCATTY_MCP_DELEGATED_TOOLS !== undefined) return "confirm";
const discoveryPath = process.env.NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE;
if (discoveryPath) {
try {
@@ -342,12 +343,20 @@ server.resource(
);
// Start with core tools; load specialized catalog groups through load_netcatty_tools.
registerProgressiveMcpTools(server, {
const toolDependencies = {
rpcCall: exposedRpcCall,
scopeParams,
guardWriteOperation,
catalogDescription,
});
};
if (process.env.NETCATTY_MCP_DELEGATED_TOOLS !== undefined) {
const { listMcpTools } = require("../capabilities/codegen/toolSurfaces.cjs");
const { registerMcpTools } = require("../capabilities/codegen/mcpToolRegistry.cjs");
const names = JSON.parse(process.env.NETCATTY_MCP_DELEGATED_TOOLS);
if (!Array.isArray(names) || names.length > 128 || names.some((name) => typeof name !== "string")) throw Error("Invalid delegated tool profile");
const allowed = new Set(names);
registerMcpTools(server, toolDependencies, listMcpTools().filter((tool) => allowed.has(tool.mcpTool)));
} else registerProgressiveMcpTools(server, toolDependencies);
// ── Start ──
+412
View File
@@ -0,0 +1,412 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const { createDefinitionValidator } = require("../contractValidator.cjs");
const { normalizePermissionDeclarations } = require("../permissionResources.cjs");
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
const { DelegatedAgentScopes, isRequestCancellation } = require("./delegatedScope.cjs");
const { listMcpTools } = require("../../capabilities/codegen/toolSurfaces.cjs");
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Plugin Agent operation unavailable");
const FEATURE = require("../generated/plugin-contract.schema.json").$defs.PluginAgentFeature.const;
const CAPABILITIES = Object.freeze(require("../generated/plugin-contract.schema.json").$defs.PluginAgentCapabilities.const);
const TOOLS = listMcpTools().filter((tool) => CAPABILITIES.includes(tool.capabilityId));
const ROUTES = [
["listBackends", "PluginAIEmptyParams", "PluginAgentBackendsResult"],
["selectTargets", "PluginAIEmptyParams", "PluginAgentTargetsResult"],
["listChats", "PluginAIEmptyParams", "PluginAgentChatsResult"],
["openChat", "PluginAgentOpenParams", "PluginAgentChatResult"],
["send", "PluginAgentSendParams", "PluginAgentTurnResult"],
["read", "PluginAgentTurnRef", "PluginAgentEvent"],
["history", "PluginAgentChatRef", "PluginAgentHistoryResult"],
["stop", "PluginAgentTurnRef", "PluginAIBooleanResult"],
["closeChat", "PluginAgentChatRef", "PluginAIBooleanResult"],
];
class PluginAgentService {
constructor(options) {
this.source = new PluginAISourceBinding(options);
this.database = options.database;
this.host = options.hostAdapter;
this.permission = options.permissionEngine;
this.store = options.conversationStore;
this.getSessionBinding = options.getSessionBinding;
this.getMcpBridge = options.getMcpBridge;
this.getTargetManager = options.getTargetManager ?? (() => null);
this.clock = options.clock ?? Date.now;
this.backends = new Map(); this.selections = new Map(); this.chats = new Map(); this.turns = new Map();
this.budgets = new Map(); this.pendingOpens = new Set(); this.registrations = []; this.closed = false;
this.pendingSelections = new Map(); this.startupTurns = new Map(); this.targetManager = null; this.targetSubscriptions = [];
this.eventValidator = createDefinitionValidator("PluginAgentEvent");
this.sessionValidator = createDefinitionValidator("PluginAgentHostSession");
this.scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => this.#guard(ctx),
assertHostActive: (ctx) => this.host.assertActive(ctx), clock: this.clock,
authorizeAction: async (ctx, owner, action, signal) => {
const decisionContext = { ...ctx, signal, permissionOperation: Object.freeze({}), sourceOwner: owner.privateOwner };
await this.permission.authorize(decisionContext, {
permission: "ai.agent.actions", resources: action.targets.length
? action.targets.map((target) => `agent-target:${target.kind}:${target.id}:${target.owner}:${target.revision}`) : ["*"],
reason: "Use a delegated Agent tool on selected resources", sessionId: ctx.agentChatId,
displayContext: { modelLabel: ctx.agentBackendLabel, selections: action.targets.map((target) => ({
title: ctx.agentTargetLabels.get(target.id) ?? target.id, scope: "session", version: target.revision,
})), action: { capabilityId: action.capabilityId, text: ctx.agentToolPreview } },
});
} });
this.revocation = this.permission.onDidRevoke((event) => this.releasePlugin(event.pluginId));
this.hostInvalidation = this.host.onDidInvalidate(() => this.releaseAll());
}
async #guard(context) {
try {
if (this.closed) throw fail("unavailable");
const owner = await this.source.resolve(context);
await this.host.assertActive(context); this.source.assertCurrent(owner);
return owner;
} catch (error) {
const owner = this.source.bindings.get(context.runtimeId);
if (!isRequestCancellation(error, context) && owner?.pluginId === context.pluginId
&& owner.pluginVersion === context.pluginVersion) this.releaseRuntime(context.runtimeId);
throw error;
}
}
#assertNow(turn) {
if (this.closed || turn.done || this.turns.get(turn.handle) !== turn || turn.chat.activeTurn !== turn
|| turn.controller.signal.aborted || turn.chat.expiresAt <= this.clock()
|| turn.ticket?.controller.signal.aborted
|| this.host.generation !== turn.chat.hostGeneration) throw fail("cancelled");
this.source.assertCurrent(turn.owner);
if (this.host.getSenderId() !== turn.senderId) throw fail("cancelled");
if (this.permission.getPolicyRevision(this.database.getActivePlugin(turn.owner.pluginId)) !== turn.policyRevision) throw fail("cancelled");
for (const target of turn.chat.selection.targets) if (!target.isCurrent(target)) throw fail("cancelled");
}
async #chat(params, context) {
const owner = await this.#guard(context); const chat = this.chats.get(params.chatHandle);
if (!chat || chat.owner !== owner || chat.expiresAt <= this.clock() || chat.hostGeneration !== this.host.generation) throw fail("permissionDenied");
return chat;
}
async #turn(params, context) {
const owner = await this.#guard(context); const turn = this.turns.get(params.turnHandle);
if (!turn || turn.owner !== owner || turn.expiresAt <= this.clock()) throw fail("permissionDenied");
return turn;
}
async #backendCurrent(backend, context) {
if (backend.expiresAt <= this.clock() || backend.hostGeneration !== this.host.generation
|| !(await this.host.requestAgent("agent.current", { hostHandle: backend.hostHandle }, context)).current) throw fail("failedPrecondition");
}
#prune() {
for (const map of [this.backends, this.selections]) for (const [key, entry] of map) if (entry.expiresAt <= this.clock()) map.delete(key);
for (const [key, turn] of this.turns) if (turn.expiresAt <= this.clock()) { this.#end(turn); this.turns.delete(key); }
for (const [key, chat] of this.chats) if (chat.expiresAt <= this.clock()) {
this.#forgetChat(chat); this.chats.delete(key);
}
}
#observeTargets() {
const manager = this.getTargetManager();
if (manager === this.targetManager) return;
for (const entry of this.targetSubscriptions) entry.dispose(); this.targetSubscriptions = [];
this.targetManager = manager;
const invalidate = () => {
for (const controller of this.pendingSelections.keys()) controller.abort();
for (const [key, chat] of this.chats) if (chat.selection.targets.some((target) => !target.isCurrent(target))) {
this.#forgetChat(chat); this.chats.delete(key);
}
};
invalidate();
for (const method of ["onSessionOwned", "onSessionClosed", "onWorkerExit"]) {
if (typeof manager?.[method] === "function") this.targetSubscriptions.push(manager[method](invalidate));
}
}
#charge(owner) {
const key = JSON.stringify([owner.sourceKey, owner.privateOwner]); const now = this.clock();
for (const [id, entry] of this.budgets) if (now - entry.start >= 60_000) this.budgets.delete(id);
const budget = this.budgets.get(key) ?? { start: now, requests: 0 };
if (budget.requests >= 6 || (!this.budgets.has(key) && this.budgets.size >= 256)) throw fail("resourceExhausted");
budget.requests++; this.budgets.set(key, budget);
}
registerRpcCapabilities(registry) {
const startupLifetime = async (_context, next) => next();
startupLifetime.prepareAuthorization = (context) => {
if (context.method !== "agent.send") return undefined;
// Registry cleanup runs after its final authority check. Until then the
// caller still owns startup cancellation because no handle was delivered.
return () => {
const turn = this.startupTurns.get(context.permissionOperation);
if (!turn) return;
try {
if (!turn.handoffReady || context.signal.aborted) throw fail("cancelled");
this.#assertNow(turn);
} catch { this.#end(turn); }
turn.detachStartup();
};
};
this.registrations.push(registry.use(startupLifetime));
for (const [operation, paramsType, resultType] of ROUTES) {
const input = createDefinitionValidator(paramsType); const output = createDefinitionValidator(resultType);
this.registrations.push(registry.registerRequest(`agent.${operation}`, async (params, context) => {
const result = await this[operation](params, context); await this.#guard(context);
if (!output(result)) throw fail("internal");
if (operation === "send") {
const turn = this.startupTurns.get(context.permissionOperation);
if (!turn || turn.handle !== result.turnHandle) throw fail("cancelled");
turn.handoffReady = true;
}
return result;
}, { metadata: { capability: "plugin-agent", mutating: !["listBackends", "listChats", "history", "read"].includes(operation), permission: "ai.agent.use" },
validateParams: (params) => { if (!input(params)) throw fail("invalidArgument"); return Object.freeze(structuredClone(params)); },
authorization: async (params, context) => {
await this.#guard(context);
const chat = operation === "send" ? await this.#chat(params, context) : null;
return { permission: "ai.agent.use", resources: ["*"], reason: "Use this plugin's configured global Agent conversation",
...(operation !== "send" ? { deferDecision: true } : {}),
...(chat ? { displayContext: { modelLabel: chat.backend.label,
selections: chat.selection.items.map((item) => ({ title: item.title, scope: "session", version: "selected" })),
action: { capabilityId: "agent.send", text: params.text } } } : {}),
};
},
}));
}
}
async listBackends(_params, context) {
const owner = await this.#guard(context); this.#prune();
const result = await this.host.requestAgent("agent.backends", {}, context); await this.#guard(context);
const items = [];
for (const entry of result.backends) {
let lease = [...this.backends.values()].find((item) => item.owner === owner && item.hostHandle === entry.hostHandle);
if (!lease) {
if (this.backends.size >= 256) throw fail("resourceExhausted");
lease = { ...entry, backendRef: randomUUID(), owner, hostGeneration: this.host.generation, expiresAt: this.clock() + 300_000 };
this.backends.set(lease.backendRef, lease);
}
const eligible = ["catty", "claude"].includes(entry.backend);
items.push({ backendRef: lease.backendRef, backend: entry.backend, label: entry.label,
status: eligible ? entry.status : entry.status === "unavailable" ? "unavailable" : "unsupported",
reason: eligible || entry.status === "unavailable" ? entry.reason : "native-boundary-unverified" });
}
return { items };
}
async selectTargets(_params, context) {
const owner = await this.#guard(context); this.#prune();
this.#observeTargets();
if (this.selections.size >= 64) throw fail("resourceExhausted");
const controller = new AbortController(); this.pendingSelections.set(controller, owner);
let result;
try {
result = await this.host.requestAgent("agent.targets", {}, { ...context,
signal: context.signal ? AbortSignal.any([controller.signal, context.signal]) : controller.signal });
await this.#guard(context); controller.signal.throwIfAborted();
} finally { this.pendingSelections.delete(controller); }
const targets = result.targets.map((item) => {
const binding = this.getSessionBinding(item.sessionId);
if (!binding || !binding.isCurrent(binding)) throw fail("failedPrecondition");
return Object.freeze({ ...binding, kind: "terminal", id: item.sessionId });
});
if (new Set(targets.map((target) => target.id)).size !== targets.length) throw fail("invalidArgument");
const selectionHandle = randomUUID(); const expiresAt = this.clock() + 300_000;
const items = result.targets.map((item) => ({ kind: "terminal", title: item.label }));
this.selections.set(selectionHandle, { owner, targets, terminalSessions: structuredClone(result.targets), items, expiresAt });
return { selectionHandle, expiresAt, items };
}
async listChats(_params, context) {
const owner = await this.#guard(context);
const rows = await this.store.list(owner, () => this.#guard(context));
return { items: rows.map((row) => ({ conversationRef: row.conversationRef, title: row.title, backend: row.agentId, updatedAt: row.updatedAt })) };
}
async openChat(params, context) {
const owner = await this.#guard(context); this.#prune();
const backend = this.backends.get(params.backendRef); const selection = this.selections.get(params.selectionHandle);
if (!backend || backend.owner !== owner || !["catty", "claude"].includes(backend.backend) || backend.status !== "supported"
|| !selection || selection.owner !== owner || selection.expiresAt <= this.clock()) throw fail("permissionDenied");
if (this.chats.size + this.pendingOpens.size >= 64 || [...this.chats.values()].filter((chat) => chat.owner === owner).length >= 8) throw fail("resourceExhausted");
if (params.conversationRef && [...this.pendingOpens].some((item) => item.owner === owner && item.conversationRef === params.conversationRef)) throw fail("alreadyExists");
const opening = { owner, conversationRef: params.conversationRef, active: true }; this.pendingOpens.add(opening); let scope;
try {
await this.#backendCurrent(backend, context);
let record = params.conversationRef ? await this.store.read(owner, params.conversationRef, () => this.#guard(context)) : null;
if (params.conversationRef && !record) throw fail("notFound");
if (record && [...this.chats.values()].some((chat) => chat.owner === owner && chat.record.conversationRef === record.conversationRef)) throw fail("alreadyExists");
const now = this.clock();
record ??= { conversationRef: randomUUID(), id: `plugin-agent:${randomUUID()}`, title: "Global Agent chat", agentId: backend.backend,
scope: { type: "global" }, messages: [], createdAt: now, updatedAt: now };
// Durable conversation identity is conversationRef. A fresh runtime actor
// never reuses revoked privacy/native/tool state when history is reopened.
record.id = `plugin-agent:${randomUUID()}`;
record.agentId = backend.backend; delete record.externalSessionId;
const declared = normalizePermissionDeclarations(context.manifest).has("ai.agent.actions");
const capabilities = declared && this.permission.getMode(context, "ai.agent.actions") !== "deny" ? CAPABILITIES : [];
scope = await this.scopes.issue(context, { backend: { key: backend.backendRef, revision: backend.hostHandle,
isCurrent: () => backend.expiresAt > this.clock() && backend.hostGeneration === this.host.generation }, targets: selection.targets, capabilities });
if (!opening.active || await this.#guard(context) !== owner) throw fail("cancelled");
await this.store.save(owner, record, async () => { if (!opening.active) throw fail("cancelled"); await this.#guard(context); });
if (!opening.active || await this.#guard(context) !== owner) throw fail("cancelled");
const chatHandle = randomUUID();
this.chats.set(chatHandle, { handle: chatHandle, owner, backend, selection, record, scope, capabilities,
expiresAt: Math.min(scope.expiresAt, selection.expiresAt, backend.expiresAt), hostGeneration: this.host.generation, activeTurn: null, generation: 0 });
return { chatHandle, conversationRef: record.conversationRef, backendLabel: backend.label,
expiresAt: this.chats.get(chatHandle).expiresAt };
} catch (error) { if (scope) this.scopes.discard(scope.handle, owner); throw error; }
finally { this.pendingOpens.delete(opening); }
}
async #dispatchTool(turn, method, params, execute) {
this.#assertNow(turn);
if (++turn.toolCalls > 32) { this.#end(turn, "budget-exceeded"); throw fail("resourceExhausted"); }
const spec = TOOLS.find((item) => item.rpcMethod === method);
if (!spec || !turn.chat.capabilities.includes(spec.capabilityId)) throw fail("permissionDenied");
let ids = [];
if (spec.capabilityId === "session.environment") ids = turn.chat.selection.targets.map((target) => target.id);
else if (["terminal.poll", "terminal.stop"].includes(spec.capabilityId)) {
const id = this.getMcpBridge().getPluginAgentJobTarget(params.jobId, turn.chat.record.id);
if (!id) throw fail("permissionDenied"); ids = [id];
} else { if (typeof params.sessionId !== "string") throw fail("invalidArgument"); ids = [params.sessionId]; }
const targets = ids.map((id) => {
const target = turn.chat.selection.targets.find((item) => item.id === id);
if (!target) throw fail("permissionDenied"); return target;
});
const preview = JSON.stringify(Object.fromEntries(Object.entries(params).filter(([key]) => !["chatSessionId", "scopedSessionIds"].includes(key))), null, 2);
if (Buffer.byteLength(preview) > 16_384) throw fail("resourceExhausted");
const ctx = { ...turn.context, agentChatId: turn.chat.record.id, agentBackendLabel: turn.chat.backend.label,
agentTargetLabels: new Map(turn.chat.selection.terminalSessions.map((item) => [item.sessionId, item.label])), agentToolPreview: preview };
try { await this.#backendCurrent(turn.chat.backend, ctx); }
catch (error) { this.#end(turn, "unavailable"); throw error; }
return this.scopes.dispatch(turn.ticket, ctx, { capabilityId: spec.capabilityId, targets }, async (execution) => {
this.#assertNow(turn);
// Ask may have waited while the configured backend was edited. Check the
// trusted renderer's current revision again before the selected effect.
try { await this.#backendCurrent(turn.chat.backend, ctx); }
catch (error) { this.#end(turn, "unavailable"); throw error; }
this.#assertNow(turn); const result = await execute(execution); this.#assertNow(turn); return result;
});
}
async send(params, context) {
const chat = await this.#chat(params, context); const owner = chat.owner;
this.#prune();
if (!params.text.trim()) throw fail("invalidArgument");
if (this.turns.size >= 256 || chat.activeTurn || Buffer.byteLength(params.text) > 16_384 || chat.record.messages.length > 198) throw fail("resourceExhausted");
const controller = new AbortController(); const turn = { handle: randomUUID(), owner, chat, controller, context: { ...context, signal: controller.signal },
done: false, reading: false, bytes: 0, toolCalls: 0, generation: ++chat.generation, expiresAt: this.clock() + 300_000, hostHandle: null,
policyRevision: this.permission.getPolicyRevision(this.database.getActivePlugin(owner.pluginId)), senderId: this.host.getSenderId() };
chat.activeTurn = turn; this.turns.set(turn.handle, turn);
turn.context = { ...context, signal: controller.signal, assertActive: async () => this.#assertNow(turn) };
const cancelStartup = () => this.#end(turn);
turn.detachStartup = () => {
context.signal?.removeEventListener("abort", cancelStartup);
if (this.startupTurns.get(context.permissionOperation) === turn) this.startupTurns.delete(context.permissionOperation);
};
this.startupTurns.set(context.permissionOperation, turn);
context.signal?.addEventListener("abort", cancelStartup, { once: true });
try {
context.signal?.throwIfAborted();
this.#charge(owner); await this.#backendCurrent(chat.backend, context);
turn.ticket = await this.scopes.begin(chat.scope.handle, context);
turn.ticket.controller.signal.addEventListener("abort", () => this.#end(turn), { once: true });
this.#assertNow(turn);
const mcp = this.getMcpBridge();
turn.registration = mcp.registerPluginAgentDelegation(chat.record.id, { turnTag: turn.handle, senderId: turn.senderId,
tools: TOOLS.filter((tool) => chat.capabilities.includes(tool.capabilityId)).map((tool) => tool.mcpTool),
assertCurrent: () => this.#assertNow(turn), dispatch: (method, payload, execute) => this.#dispatchTool(turn, method, payload, execute) });
mcp.setChatSessionCancelled(chat.record.id, false);
turn.timer = setTimeout(() => this.#end(turn, "budget-exceeded"), Math.max(1, Math.min(120_000, chat.expiresAt - this.clock()))); turn.timer.unref?.();
const prior = structuredClone(chat.record);
chat.record.messages.push({ id: randomUUID(), role: "user", content: params.text, timestamp: this.clock() });
chat.record.updatedAt = this.clock();
if (chat.record.title === "Global Agent chat") chat.record.title = params.text.trim().split(/\r?\n/u)[0].slice(0, 80);
prior.title = chat.record.title;
await this.store.save(owner, chat.record, async () => { await this.#guard(context); this.#assertNow(turn); });
const result = await this.host.requestAgent("agent.run", { backendHandle: chat.backend.hostHandle, delegationTag: turn.handle,
session: prior, capabilities: chat.capabilities, terminalSessions: chat.selection.terminalSessions, text: params.text, maxIterations: 12 }, turn.context);
this.#assertNow(turn); turn.hostHandle = result.hostHandle;
return { turnHandle: turn.handle, expiresAt: turn.expiresAt };
} catch (error) { this.#end(turn); throw error; }
}
async read(params, context) {
const turn = await this.#turn(params, context);
if (turn.done) return turn.endEvent;
if (turn.reading) throw fail("alreadyExists"); turn.reading = true;
try {
this.#assertNow(turn);
await this.#backendCurrent(turn.chat.backend, context);
const result = await this.host.requestAgent("agent.next", { hostHandle: turn.hostHandle },
{ ...context, signal: AbortSignal.any([context.signal ?? new AbortController().signal, turn.controller.signal]) });
this.#assertNow(turn);
if (result.session) {
if (!this.sessionValidator(result.session) || result.session.id !== turn.chat.record.id
|| result.session.conversationRef !== turn.chat.record.conversationRef || result.session.agentId !== turn.chat.backend.backend) throw fail("internal");
turn.chat.record = structuredClone(result.session);
await this.store.save(turn.owner, turn.chat.record, async () => { await this.#guard(context); this.#assertNow(turn); });
}
const event = result.done ? { kind: "end", reason: "completed" } : result.event;
if (!this.eventValidator(event)) throw fail("internal");
turn.bytes += Buffer.byteLength(JSON.stringify(event));
if (turn.bytes > 262_144) throw fail("resourceExhausted");
if (event.kind === "end") this.#end(turn, event.reason);
return event;
} catch (error) { if (turn.done) return turn.endEvent; this.#end(turn, error.code === RPC_ERRORS.resourceExhausted ? "budget-exceeded" : "unavailable"); throw error; }
finally { turn.reading = false; }
}
async history(params, context) {
const chat = await this.#chat(params, context); const items = []; let bytes = 0;
for (const message of [...chat.record.messages].reverse()) {
if (!["user", "assistant"].includes(message?.role) || typeof message.content !== "string") continue;
const text = message.content.slice(0, 32_768); bytes += Buffer.byteLength(text);
if (bytes > 131_072) break; items.unshift({ role: message.role, text });
}
return { items };
}
#end(turn, reason = "cancelled") {
if (turn.done) return;
const wasCurrent = turn.chat.activeTurn === turn;
turn.done = true; turn.detachStartup?.(); turn.endEvent = { kind: "end", reason }; clearTimeout(turn.timer); turn.controller.abort();
if (turn.ticket) this.scopes.finish(turn.ticket);
turn.registration?.dispose();
if (wasCurrent) {
turn.chat.activeTurn = null;
try { this.getMcpBridge().applyChatSessionCancelled(turn.chat.record.id, true); } catch {}
}
if (turn.hostHandle) void this.host.requestAgent("agent.release", { hostHandle: turn.hostHandle },
// Cleanup authenticates the owning source, independently of the ended
// turn or its completed Send request. The host still checks app lifetime.
{ ...turn.context, signal: undefined, assertActive: async () => this.source.assertCurrent(turn.owner) }).catch(() => {});
}
async stop(params, context) { const turn = await this.#turn(params, context); this.#end(turn); return { ok: true }; }
async closeChat(params, context) {
const chat = await this.#chat(params, context);
this.#forgetChat(chat); this.chats.delete(chat.handle); return { ok: true };
}
#forgetChat(chat) {
if (chat.activeTurn) this.#end(chat.activeTurn);
this.scopes.discard(chat.scope.handle, chat.owner);
try { this.host.releaseAgentChat?.(chat.record.id, chat.owner.runtimeId); } catch {}
try { void this.getMcpBridge().cleanupScopedMetadata?.(chat.record.id)?.catch(() => {}); } catch {}
}
releaseRuntime(runtimeId) {
for (const [controller, owner] of this.pendingSelections) if (owner.runtimeId === runtimeId) controller.abort();
for (const turn of this.turns.values()) if (turn.owner.runtimeId === runtimeId) this.#end(turn);
for (const chat of this.chats.values()) if (chat.owner.runtimeId === runtimeId) this.#forgetChat(chat);
for (const opening of this.pendingOpens) if (opening.owner.runtimeId === runtimeId) opening.active = false;
for (const map of [this.backends, this.selections, this.chats, this.turns]) for (const [id, entry] of map) if (entry.owner.runtimeId === runtimeId) map.delete(id);
this.scopes.releaseRuntime(runtimeId); this.source.releaseRuntime(runtimeId); this.host.releaseRuntime(runtimeId);
}
releasePlugin(pluginId) {
const ids = new Set([...this.source.bindings.values()].filter((owner) => owner.pluginId === pluginId).map((owner) => owner.runtimeId));
for (const id of ids) this.releaseRuntime(id);
}
releaseAll() { for (const id of [...this.source.bindings.keys()]) this.releaseRuntime(id); }
shutdown() {
this.releaseAll(); this.closed = true; this.scopes.shutdown(); this.source.shutdown(); this.revocation.dispose(); this.hostInvalidation();
for (const item of this.registrations) item.dispose(); this.host.shutdown();
for (const item of this.targetSubscriptions) item.dispose(); this.targetSubscriptions = [];
}
}
module.exports = { PluginAgentService, PLUGIN_AGENT_FEATURE: FEATURE, PLUGIN_AGENT_CAPABILITIES: CAPABILITIES };
@@ -0,0 +1,241 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { PluginAgentService } = require("./agentService.cjs");
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
const { PluginAgentMcpDelegations } = require("./mcpDelegation.cjs");
const { PluginPermissionEngine } = require("../permissionEngine.cjs");
const { PluginHostRpcRegistry } = require("../hostRpcRegistry.cjs");
function deferred() { let resolve; const promise = new Promise((done) => { resolve = done; }); return { promise, resolve }; }
function fixture(t) {
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-agent-service-"));
const manifest = { id: "com.example.agent", publisher: "example", version: "1.0.0", permissions: { required: ["ai.agent.use", "ai.agent.actions"] } };
let identity = { pluginId: manifest.id, pluginVersion: manifest.version, runtimeId: "r1", runtimeKind: "browser", manifest };
let sourceId = "source-a"; let time = 1; let locked = false; const modes = new Map(); let focus = "a"; let effects = 0;
const audits = []; const decisions = []; const hostRequests = []; const releasedHandles = []; const invalidations = new Set();
const plugin = () => ({ id: manifest.id, enabled: true, activeVersion: manifest.version, manifest,
source: { id: sourceId, kind: "third-party", origin: "https://synthetic.invalid" }, runtime: { status: "running" } });
const database = { getActivePlugin: (id) => id === manifest.id ? plugin() : null,
getRetainedPluginSource: () => plugin().source, privateOwner: () => `opaque-owner:${sourceId}`,
publicPluginId: (owner) => owner === `opaque-owner:${sourceId}` ? manifest.id : null,
getPermissionMode: (_id, permission) => modes.get(permission) ?? "allow",
listPermissionGrants: () => [], recordSecurityAudit: (_id, event, value) => audits.push({ event, ...value }) };
let approve = async (request) => ({ requestId: request.requestId, decision: "allow", scope: "once" });
const permission = new PluginPermissionEngine({ database, requestDecision: (request) => { decisions.push(request); return approve(request); } });
const targetState = new Map([["a", { owner: "7", revision: "worker-a:1" }], ["b", { owner: "8", revision: "worker-a:2" }]]);
const backendHandle = randomUUID(); const externalHandle = randomUUID();
const sessions = new Map(); const events = [];
let run = async (payload) => { const handle = randomUUID(); sessions.set(handle, structuredClone(payload.session)); return { hostHandle: handle }; };
let current = true;
let choose = async () => ({ targets: [...targetState.keys()].map((id) => ({ sessionId: id, hostname: `${id}.invalid`, label: `Synthetic ${id}`, connected: true })) });
const resourceListeners = new Set();
const watch = (callback) => { resourceListeners.add(callback); return { dispose: () => resourceListeners.delete(callback) }; };
const targetManager = { onSessionOwned: watch, onSessionClosed: watch, onWorkerExit: watch };
const host = { generation: 1, getSenderId: () => 7,
assertActive: async (context) => { await context.assertActive(); if (locked) throw Error("Locked"); },
onDidInvalidate: (callback) => { invalidations.add(callback); return () => invalidations.delete(callback); },
requestAgent: async (kind, payload, context) => {
hostRequests.push({ kind, payload: structuredClone(payload) });
if (kind === "agent.backends") return { backends: [
{ hostHandle: backendHandle, backend: "catty", label: "Catty fixture", status: "supported", reason: "ready" },
{ hostHandle: externalHandle, backend: "claude", label: "Claude fixture", status: "supported", reason: "ready" },
{ hostHandle: randomUUID(), backend: "codex", label: "Codex fixture", status: "supported", reason: "ready" },
] };
if (kind === "agent.current") return { current };
if (kind === "agent.targets") return choose();
if (kind === "agent.run") return run(payload);
if (kind === "agent.next") return { done: false, event: events.shift() ?? { kind: "end", reason: "completed" }, session: sessions.get(payload.hostHandle) };
if (kind === "agent.release") { await context.assertActive(); releasedHandles.push(payload.hostHandle); }
return { ok: true };
}, releaseRuntime: () => {}, shutdown: () => {} };
const delegation = new PluginAgentMcpDelegations();
const mcp = { registerPluginAgentDelegation: (...args) => delegation.register(...args),
setChatSessionCancelled: () => {}, applyChatSessionCancelled: () => {}, getPluginAgentJobTarget: () => null };
const service = new PluginAgentService({ database, permissionEngine: permission, hostAdapter: host,
conversationStore: new PluginAgentConversationStore(directory), getRuntimeIdentity: () => identity, getMcpBridge: () => mcp, clock: () => time,
getTargetManager: () => targetManager,
getSessionBinding: (id) => { const snapshot = targetState.get(id); return snapshot && { ...snapshot,
isCurrent: (target) => targetState.get(id)?.owner === target.owner && targetState.get(id)?.revision === target.revision }; } });
const registry = new PluginHostRpcRegistry(); registry.use(permission.createMiddleware()); service.registerRpcCapabilities(registry);
const call = (method, params = {}, overrides = {}) => registry.createRoutes(identity).requestHandlers[`agent.${method}`](params, { signal: new AbortController().signal, ...overrides });
const open = async (backend = "catty", conversationRef) => {
const backends = await call("listBackends"); const selection = await call("selectTargets");
return call("openChat", { backendRef: backends.items.find((item) => item.backend === backend).backendRef, selectionHandle: selection.selectionHandle, ...(conversationRef ? { conversationRef } : {}) });
};
const dispatch = async (turn, id = "a") => {
const internal = service.turns.get(turn.turnHandle); if (!internal) throw Error("Turn unavailable");
return delegation.dispatch("netcatty/exec", { chatSessionId: internal.chat.record.id, sessionId: id, command: "printf synthetic" },
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, (context) => { context.beforeExecute(); effects++; return "synthetic"; });
};
t.after(() => { service.shutdown(); delegation.shutdown(); permission.shutdown(); fs.rmSync(directory, { recursive: true, force: true }); });
return { service, call, open, dispatch, events, sessions, hostRequests, releasedHandles, decisions, audits, host, delegation, targetState,
effects: () => effects, focusTo: (id) => { focus = id; }, focus: () => focus, modeTo: (value, permission = "ai.agent.actions") => { modes.set(permission, value); },
approve: (fn) => { approve = fn; }, run: (fn) => { run = fn; }, tick: (ms) => { time += ms; },
choose: (fn) => { choose = fn; }, resourceChanged: () => { for (const callback of resourceListeners) callback(); },
replaceSource: () => { sourceId = "source-b"; }, currentTo: (value) => { current = value; },
restart: () => { service.releaseRuntime(identity.runtimeId); identity = { ...identity, runtimeId: randomUUID() }; },
lock: () => { locked = true; host.generation++; for (const callback of invalidations) callback(); } };
}
test("registered Agent routes validate opaque authority, report unsupported modes and preserve core-only native history", async (t) => {
const f = fixture(t); const backends = await f.call("listBackends");
assert.deepEqual(f.hostRequests.map((request) => request.kind), ["agent.backends"], "public backend listing never starts native model discovery");
await assert.rejects(f.call("listBackends", { delegatedAgent: true }));
assert.equal(backends.items.find((entry) => entry.backend === "codex").status, "unsupported");
await assert.rejects(f.call("openChat", { backendRef: randomUUID(), selectionHandle: randomUUID(), permissionMode: "auto" }));
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic conversation" });
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Spoof delegated mode", delegatedAgent: false }));
const [handle, session] = [...f.sessions][0];
session.messages = [{ role: "user", content: "Synthetic conversation" }, { role: "assistant", content: "Synthetic answer" }];
session.externalSessionId = "NATIVE_PRIVATE_SESSION"; f.events.push({ kind: "text", text: "Synthetic answer" });
assert.deepEqual(await f.call("read", { turnHandle: turn.turnHandle }), { kind: "text", text: "Synthetic answer" });
await f.call("read", { turnHandle: turn.turnHandle });
assert.deepEqual(await f.call("history", { chatHandle: chat.chatHandle }), { items: [
{ role: "user", text: "Synthetic conversation" }, { role: "assistant", text: "Synthetic answer" } ] });
const summaries = await f.call("listChats"); assert.doesNotMatch(JSON.stringify({ chat, turn, summaries }), /NATIVE_|opaque-owner|source-a|plugin-agent:/);
await f.call("closeChat", { chatHandle: chat.chatHandle });
const reopened = await f.open("claude", chat.conversationRef);
assert.equal(f.service.chats.get(reopened.chatHandle).record.externalSessionId, undefined);
assert.equal((await f.call("history", { chatHandle: reopened.chatHandle })).items.length, 2);
assert.ok(handle); f.restart(); f.replaceSource();
assert.deepEqual(await f.call("listChats"), { items: [] });
await assert.rejects(f.open("catty", chat.conversationRef));
});
test("actual source policy decides once per tool and targets never follow focus across workspaces", async (t) => {
const f = fixture(t); f.modeTo("ask"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic action" }); f.focusTo("unselected");
assert.equal(await f.dispatch(turn, "b"), "synthetic"); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 1);
assert.equal(f.decisions[0].permission, "ai.agent.actions"); assert.equal(f.decisions[0].displayContext.action.capabilityId, "terminal.execute");
await assert.rejects(f.dispatch(turn, f.focus())); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 1);
f.approve(async (request) => ({ requestId: request.requestId, decision: "deny" }));
await assert.rejects(f.dispatch(turn)); assert.equal(f.effects(), 1); assert.equal(f.decisions.length, 2);
const entered = deferred(); const resume = deferred();
f.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
const pending = f.dispatch(turn); const rejection = assert.rejects(pending); await entered.promise;
f.targetState.set("a", { owner: "new-window", revision: "new-generation" }); resume.resolve(); await rejection;
assert.equal(f.effects(), 1);
});
test("a duplicate owned-history Open cannot replace or cancel an active core chat", async (t) => {
const f = fixture(t); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic active chat" });
const active = f.service.turns.get(turn.turnHandle);
await assert.rejects(f.open("catty", chat.conversationRef), { code: -32006 });
assert.equal(f.service.chats.size, 1); assert.equal(active.chat.activeTurn, active);
assert.equal(active.controller.signal.aborted, false); assert.equal(await f.dispatch(turn), "synthetic");
});
test("backend revision changed during Ask cannot execute the already selected target without any pending event read", async (t) => {
const f = fixture(t); f.modeTo("ask"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending approval" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
const entered = deferred(); const resume = deferred();
f.approve(async (request) => { entered.resolve(); await resume.promise; return { requestId: request.requestId, decision: "allow", scope: "once" }; });
const pending = f.dispatch(turn); await entered.promise;
const checksBeforeApproval = f.hostRequests.filter((item) => item.kind === "agent.current").length;
f.currentTo(false); resume.resolve();
const result = await pending.then(() => "executed", () => "rejected");
assert.equal(f.effects(), 0); assert.equal(result, "rejected");
assert.ok(f.hostRequests.filter((item) => item.kind === "agent.current").length > checksBeforeApproval);
assert.equal(native.signal.aborted, true); assert.equal(internal.controller.signal.aborted, true);
assert.equal(f.decisions.length, 1);
});
test("empty-argument environment Ask carries the canonical host operation and selected-target disclosure", async (t) => {
const f = fixture(t); f.choose(async () => ({ targets: [] })); f.modeTo("ask");
const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic environment" });
const internal = f.service.turns.get(turn.turnHandle);
await f.delegation.dispatch("netcatty/getContext", { chatSessionId: internal.chat.record.id },
{ trustedAgentSenderId: 7, trustedAgentTurnTag: turn.turnHandle }, () => ({ hosts: [], hostCount: 0 }));
assert.equal(f.decisions.length, 1);
assert.deepEqual(f.decisions[0].displayContext.action, { capabilityId: "session.environment", text: "{}" });
assert.deepEqual(f.decisions[0].displayContext.selections, []);
});
test("Actions Deny keeps model conversation available with no tools; Agent-use Deny prevents host dispatch", async (t) => {
const f = fixture(t); f.modeTo("deny"); const chat = await f.open();
const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic model-only intent" });
const internal = f.service.turns.get(turn.turnHandle);
assert.deepEqual(f.delegation.configuration(internal.chat.record.id, turn.turnHandle).tools, []);
await assert.rejects(f.dispatch(turn)); assert.equal(f.effects(), 0); assert.equal(f.decisions.length, 0);
await f.call("stop", { turnHandle: turn.turnHandle });
f.modeTo("deny", "ai.agent.use"); const count = f.hostRequests.length;
await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Denied" })); assert.equal(f.hostRequests.length, count);
});
test("source replacement, lock and backend changes revoke native work and reject late events", async (t) => {
for (const kind of ["source", "lock", "backend"]) {
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
if (kind === "source") f.replaceSource(); else if (kind === "lock") f.lock(); else f.currentTo(false);
await assert.rejects(f.dispatch(turn)); assert.equal(native.signal.aborted, true); assert.equal(f.effects(), 0);
if (kind !== "backend") await assert.rejects(f.call("read", { turnHandle: turn.turnHandle }));
}
});
test("per-source turn budget survives runtime restart and is isolated from a different source", async (t) => {
const f = fixture(t); let chat = await f.open();
for (let i = 0; i < 6; i++) { const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic" }); await f.call("stop", { turnHandle: turn.turnHandle }); }
f.restart(); chat = await f.open(); await assert.rejects(f.call("send", { chatHandle: chat.chatHandle, text: "Over budget" }));
f.restart(); f.replaceSource(); chat = await f.open();
assert.ok((await f.call("send", { chatHandle: chat.chatHandle, text: "Isolated source" })).turnHandle);
});
test("late start/open completion after release cannot publish handles or affect a replacement turn", async (t) => {
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
const starting = f.call("send", { chatHandle: chat.chatHandle, text: "Delayed synthetic start" }); const rejected = assert.rejects(starting);
await entered.promise; f.restart(); resume.resolve(); await rejected; assert.equal(f.service.chats.size, 0); assert.equal(f.service.turns.size, 0);
const next = await f.open(); f.tick(300_001);
await assert.rejects(f.call("send", { chatHandle: next.chatHandle, text: "Expired" }));
assert.ok((await f.open()).chatHandle); assert.equal(f.service.chats.size, 1);
});
test("registered send cancellation during trusted startup revokes the exact turn before a handle can be handed off", async (t) => {
const f = fixture(t); const chat = await f.open(); const entered = deferred(); const resume = deferred();
const caller = new AbortController();
f.run(async () => { entered.resolve(); await resume.promise; return { hostHandle: randomUUID() }; });
const pending = f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic cancelled startup" }, { signal: caller.signal });
const rejected = assert.rejects(pending); await entered.promise;
const internal = [...f.service.turns.values()][0]; const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, internal.handle, () => native.abort());
caller.abort(); resume.resolve(); await rejected;
assert.equal(internal.chat.activeTurn, null); assert.equal(internal.controller.signal.aborted, true);
assert.equal(f.delegation.has(internal.chat.record.id), false); assert.equal(native.signal.aborted, true);
assert.equal(internal.hostHandle, null, "late startup completion cannot publish an orphan handle");
assert.equal(f.service.startupTurns.size, 0);
});
test("completed send detaches request lifetime from its live turn and cannot cancel replacement B", async (t) => {
const f = fixture(t); const chat = await f.open(); const caller = new AbortController();
const first = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic completed handoff" }, { signal: caller.signal });
const a = f.service.turns.get(first.turnHandle); caller.abort();
assert.equal(f.service.startupTurns.size, 0);
assert.equal(a.controller.signal.aborted, false); assert.equal(await f.dispatch(first), "synthetic");
await f.call("stop", { turnHandle: first.turnHandle });
await new Promise((resolve) => setImmediate(resolve));
assert.ok(f.releasedHandles.includes(a.hostHandle), "completed request lifetime cannot block exact host-handle cleanup");
const second = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic replacement B" });
const b = f.service.turns.get(second.turnHandle); caller.abort();
assert.equal(b.controller.signal.aborted, false); assert.equal(await f.dispatch(second), "synthetic");
});
test("worker lifecycle during target confirmation cannot silently bind a replacement connection or leave native work running", async (t) => {
const f = fixture(t); const chat = await f.open(); const turn = await f.call("send", { chatHandle: chat.chatHandle, text: "Synthetic pending work" });
const internal = f.service.turns.get(turn.turnHandle); const native = new AbortController();
f.delegation.onRelease(internal.chat.record.id, turn.turnHandle, () => native.abort());
const entered = deferred(); const resume = deferred();
f.choose(async () => { entered.resolve(); await resume.promise; return { targets: [{ sessionId: "a", hostname: "a.invalid", label: "Synthetic a", connected: true }] }; });
const pending = f.call("selectTargets"); const rejected = assert.rejects(pending); await entered.promise;
f.targetState.set("a", { owner: "replacement-window", revision: "replacement-generation" }); f.resourceChanged();
assert.equal(native.signal.aborted, true); assert.equal(f.service.chats.size, 0);
const count = f.service.selections.size; resume.resolve(); await rejected; assert.equal(f.service.selections.size, count);
});
+47
View File
@@ -0,0 +1,47 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { randomUUID } = require("node:crypto");
const { createDefinitionValidator } = require("../contractValidator.cjs");
test("Agent wire accepts only opaque host configuration/scope refs, never raw backend authority", () => {
const open = createDefinitionValidator("PluginAgentOpenParams");
const params = { backendRef: randomUUID(), selectionHandle: randomUUID() };
assert.equal(open(params), true);
for (const [key, value] of Object.entries({ permissionMode: "auto", owner: "source:forged", privateOwner: "forged",
source: "official", model: "chosen", agentId: "claude", apiKey: "synthetic-not-a-key", cli: "node",
env: {}, mcpServers: {}, sessionIds: ["focused-session"], externalSessionId: "native-session" })) {
assert.equal(open({ ...params, [key]: value }), false, key);
}
for (const key of ["backendRef", "selectionHandle", "conversationRef"]) {
assert.equal(open({ ...params, [key]: "internal-or-path-id" }), false, key);
}
});
test("Agent messages and events are bounded and do not serialize raw tools/configuration", () => {
const send = createDefinitionValidator("PluginAgentSendParams");
const params = { chatHandle: randomUUID(), text: "Synthetic user instruction" };
assert.equal(send(params), true);
assert.equal(send({ ...params, text: "x".repeat(16_385) }), false);
assert.equal(send({ ...params, text: "a\0b" }), false);
assert.equal(send({ ...params, turnId: "builtin" }), false);
const event = createDefinitionValidator("PluginAgentEvent");
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started" }), true);
assert.equal(event({ kind: "activity", capabilityId: "terminal.execute", state: "started", args: { command: "raw" } }), false);
assert.equal(event({ kind: "usage", inputTokens: 2, outputTokens: 4 }), true);
assert.equal(event({ kind: "usage", inputTokens: -1, outputTokens: 4 }), false);
assert.equal(event({ kind: "text", text: "x".repeat(4097) }), false);
assert.equal(event({ kind: "provider-error", message: "private diagnostics" }), false);
});
test("public chat/history summaries do not contain backend/native identifiers, prompts or tool results", () => {
const chats = createDefinitionValidator("PluginAgentChatsResult");
const chat = { conversationRef: randomUUID(), title: "Synthetic chat", backend: "claude", updatedAt: 1 };
assert.equal(chats({ items: [chat] }), true);
assert.equal(chats({ items: [{ ...chat, externalSessionId: "native" }] }), false);
const history = createDefinitionValidator("PluginAgentHistoryResult");
assert.equal(history({ items: [{ role: "assistant", text: "Summary" }] }), true);
assert.equal(history({ items: [{ role: "tool", text: "Raw tool result" }] }), false);
assert.equal(history({ items: [{ role: "assistant", text: "Summary", apiKey: "synthetic" }] }), false);
});
@@ -0,0 +1,122 @@
"use strict";
const fs = require("node:fs/promises");
const path = require("node:path");
const { createHash, randomUUID } = require("node:crypto");
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u;
const MAX_BYTES = 1_048_576;
const MAX_CONVERSATIONS = 64;
// Core-owned AISession documents, outside plugin filesystem/storage roots.
// The caller must authenticate provenance before supplying the opaque owner.
// Native resume identity, compaction and tool history stay in this repository;
// only a separate public projection is returned through the plugin API.
class PluginAgentConversationStore {
constructor(directory) {
if (!path.isAbsolute(directory)) throw new TypeError("Absolute host history directory required");
this.directory = directory;
this.pending = new Map();
}
#ownerDirectory(owner) {
if (!owner || typeof owner.privateOwner !== "string" || !owner.privateOwner
|| typeof owner.sourceKey !== "string" || !owner.sourceKey) throw new TypeError("Host provenance required");
return path.join(this.directory, createHash("sha256").update(JSON.stringify([owner.sourceKey, owner.privateOwner])).digest("hex"));
}
#filename(owner, id) {
if (typeof id !== "string" || !UUID.test(id)) throw new TypeError("Invalid owned conversation reference");
return path.join(this.#ownerDirectory(owner), `${id}.json`);
}
async #prepare(owner) {
await fs.mkdir(this.directory, { recursive: true, mode: 0o700 });
if (!(await fs.lstat(this.directory)).isDirectory() || (await fs.lstat(this.directory)).isSymbolicLink()) throw Error("History root unavailable");
const directory = this.#ownerDirectory(owner);
await fs.mkdir(directory, { recursive: true, mode: 0o700 });
const stat = await fs.lstat(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw Error("History owner unavailable");
return directory;
}
#validate(record) {
if (!record || typeof record !== "object" || !UUID.test(record.conversationRef)
|| typeof record.id !== "string" || !record.id.startsWith("plugin-agent:")
|| record.scope?.type !== "global" || record.scope.targetId !== undefined
|| typeof record.agentId !== "string" || typeof record.title !== "string" || record.title.length > 160
|| !Array.isArray(record.messages) || record.messages.length > 200
|| !Number.isSafeInteger(record.createdAt) || !Number.isSafeInteger(record.updatedAt)) throw new TypeError("Invalid core Agent history");
const text = JSON.stringify(record);
if (Buffer.byteLength(text) > MAX_BYTES) throw new RangeError("Agent history budget exceeded");
return text;
}
async #serial(owner, operation) {
const key = this.#ownerDirectory(owner);
const previous = this.pending.get(key) ?? Promise.resolve();
const current = previous.catch(() => {}).then(operation);
this.pending.set(key, current);
try { return await current; }
finally { if (this.pending.get(key) === current) this.pending.delete(key); }
}
async list(owner, assertCurrent) {
await assertCurrent();
const directory = await this.#prepare(owner);
const files = await fs.readdir(directory);
const records = [];
for (const file of files) {
const id = file.slice(0, -5);
if (!file.endsWith(".json") || !UUID.test(id)) continue;
if (records.length >= MAX_CONVERSATIONS) break;
const record = await this.read(owner, id, assertCurrent);
if (record) records.push(record);
}
await assertCurrent();
return records.sort((a, b) => b.updatedAt - a.updatedAt);
}
async read(owner, id, assertCurrent) {
await assertCurrent();
const filename = this.#filename(owner, id);
await this.#prepare(owner);
let file;
try {
// O_NOFOLLOW avoids following a replaced final path. Core owns ancestors.
const { constants } = require("node:fs");
file = await fs.open(filename, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
const stat = await file.stat();
if (!stat.isFile() || stat.size > MAX_BYTES) throw Error("Agent history unavailable");
const record = JSON.parse(await file.readFile("utf8"));
this.#validate(record);
if (record.conversationRef !== id) throw Error("Agent history identity mismatch");
await assertCurrent();
return record;
} catch (error) {
if (error.code === "ENOENT") { await assertCurrent(); return null; }
throw error;
} finally { await file?.close(); }
}
async save(owner, record, assertCurrent) {
// Clone/serialize before any await so caller mutation cannot change commit.
const text = this.#validate(record);
const id = record.conversationRef;
return this.#serial(owner, async () => {
await assertCurrent();
const directory = await this.#prepare(owner);
const filename = this.#filename(owner, id);
const entries = (await fs.readdir(directory)).filter((name) => name.endsWith(".json"));
if (!entries.includes(`${id}.json`) && entries.length >= MAX_CONVERSATIONS) throw new RangeError("Agent conversation budget exceeded");
const temporary = path.join(directory, `.${randomUUID()}.tmp`);
try {
await fs.writeFile(temporary, text, { encoding: "utf8", mode: 0o600, flag: "wx" });
await assertCurrent();
await fs.rename(temporary, filename);
} finally { await fs.rm(temporary, { force: true }); }
});
}
}
module.exports = { PluginAgentConversationStore };
@@ -0,0 +1,68 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { randomUUID } = require("node:crypto");
const { PluginAgentConversationStore } = require("./conversationStore.cjs");
const owner = (source) => ({ privateOwner: `opaque:${source}:same-plugin-id`, sourceKey: `source:${source}` });
const current = async () => {};
function record() {
const conversationRef = randomUUID();
return { conversationRef, id: `plugin-agent:${randomUUID()}`, title: "Synthetic chat", agentId: "catty",
scope: { type: "global" }, messages: [{ id: randomUUID(), role: "user", content: "Synthetic only", timestamp: 1 }], createdAt: 1, updatedAt: 1 };
}
async function fixture(t) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-agent-history-test-"));
t.after(() => fs.rm(root, { recursive: true, force: true }));
return { root, store: new PluginAgentConversationStore(path.join(root, "core-history")) };
}
test("core history survives reopening, uses existing AISession shape and isolates the same plugin ID across sources", async (t) => {
const { root, store } = await fixture(t); const chat = record();
await store.save(owner("a"), chat, current);
assert.deepEqual(await store.read(owner("a"), chat.conversationRef, current), chat);
const [directory] = await fs.readdir(path.join(root, "core-history"));
assert.doesNotMatch(directory, /opaque|same-plugin/);
const mode = (await fs.stat(path.join(root, "core-history", directory, `${chat.conversationRef}.json`))).mode;
if (process.platform !== "win32") assert.equal(mode & 0o777, 0o600);
assert.equal(await store.read(owner("b"), chat.conversationRef, current), null);
assert.deepEqual(await store.list(owner("b"), current), []);
const reopened = new PluginAgentConversationStore(path.join(root, "core-history"));
assert.deepEqual(await reopened.list(owner("a"), current), [chat]);
});
test("source revoked before async rename leaves previous history intact and cleans temporary files", async (t) => {
const { store, root } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
const next = { ...chat, title: "Must not commit" }; let checks = 0;
await assert.rejects(store.save(owner("a"), next, async () => { if (++checks === 2) throw Error("Revoked"); }));
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, chat.title);
const [directory] = await fs.readdir(path.join(root, "core-history"));
assert.deepEqual(await fs.readdir(path.join(root, "core-history", directory)), [`${chat.conversationRef}.json`]);
});
test("history snapshots cannot mutate across await, and traversal/builtin IDs/oversized records are rejected", async (t) => {
const { store } = await fixture(t); const chat = record(); const savedTitle = chat.title;
const saved = store.save(owner("a"), chat, current); chat.title = "Changed after call"; await saved;
assert.equal((await store.read(owner("a"), chat.conversationRef, current)).title, savedTitle);
for (const id of ["../outside", "builtin-chat", "source:owner", ""]) await assert.rejects(store.read(owner("a"), id, current));
await assert.rejects(store.save({ privateOwner: "bare-plugin" }, record(), current));
await assert.rejects(store.save(owner("a"), { ...record(), id: "builtin-chat" }, current));
await assert.rejects(store.save(owner("a"), { ...record(), scope: { type: "workspace", targetId: "active" } }, current));
await assert.rejects(store.save(owner("a"), { ...record(), messages: Array(201).fill({}) }, current));
await assert.rejects(store.save(owner("a"), { ...record(), messages: [{ content: "x".repeat(1_048_576) }] }, current));
});
test("symlinked history roots and files do not become history authority", async (t) => {
const { root, store } = await fixture(t); const chat = record(); await store.save(owner("a"), chat, current);
const history = path.join(root, "core-history"); const [directory] = await fs.readdir(history);
const filename = path.join(history, directory, `${chat.conversationRef}.json`);
const unrelated = path.join(root, "unrelated.json"); await fs.writeFile(unrelated, JSON.stringify(chat));
await fs.rm(filename); await fs.symlink(unrelated, filename);
await assert.rejects(store.read(owner("a"), chat.conversationRef, current));
const linked = path.join(root, "linked"); await fs.symlink(history, linked, "dir");
await assert.rejects(new PluginAgentConversationStore(linked).list(owner("a"), current));
});
+189
View File
@@ -0,0 +1,189 @@
"use strict";
const { randomUUID } = require("node:crypto");
const { PluginRpcError, RPC_ERRORS } = require("../rpcRouter.cjs");
const fail = (code) => new PluginRpcError(RPC_ERRORS[code], "Delegated Agent scope is unavailable");
const key = (target) => JSON.stringify([target.kind, target.id]);
const isRequestCancellation = (error, context) => context.signal?.aborted === true
&& (error === context.signal.reason || error?.name === "AbortError" || error?.name === "TimeoutError"
|| error?.code === RPC_ERRORS.cancelled || error?.code === RPC_ERRORS.deadlineExceeded);
// Host-only authority, not a plugin RPC interface or an Agent engine. The host
// supplies snapshots after resolving configured backends and choosing targets.
// Plugins never supply owner, revision, capability policy or executable adapters.
class DelegatedAgentScopes {
constructor({ resolveOwner, assertHostActive, authorizeAction, clock = Date.now }) {
for (const fn of [resolveOwner, assertHostActive, authorizeAction, clock]) {
if (typeof fn !== "function") throw new TypeError("Host scope adapters are required");
}
this.resolveOwner = resolveOwner;
this.assertHostActive = assertHostActive;
this.authorizeAction = authorizeAction;
this.clock = clock;
this.scopes = new Map();
this.closed = false;
}
async #owner(context) {
if (this.closed) throw fail("unavailable");
const owner = await this.resolveOwner(context);
await this.assertHostActive(context);
context.signal?.throwIfAborted();
if (this.closed || await this.resolveOwner(context) !== owner) throw fail("permissionDenied");
return owner;
}
// The snapshot is host-owned. A public schema must only accept returned handles.
async issue(context, { backend, targets, capabilities }) {
const owner = await this.#owner(context);
if (!backend || typeof backend.key !== "string" || typeof backend.revision !== "string"
|| typeof backend.isCurrent !== "function" || !Array.isArray(targets) || targets.length > 32
|| !Array.isArray(capabilities) || capabilities.length > 128
|| capabilities.some((id) => typeof id !== "string" || !id || id.length > 160)) throw fail("invalidArgument");
const captured = new Map();
for (const target of targets) {
if (!target || !["terminal", "host", "note"].includes(target.kind)
|| typeof target.id !== "string" || !target.id || target.id.length > 256
|| typeof target.revision !== "string" || !target.revision
|| typeof target.owner !== "string" || !target.owner
|| typeof target.isCurrent !== "function" || captured.has(key(target))) throw fail("invalidArgument");
captured.set(key(target), Object.freeze({ ...target }));
}
const scope = { handle: randomUUID(), owner, backend: Object.freeze({ ...backend }), targets: captured,
capabilities: new Set(capabilities), expiresAt: this.clock() + 300_000, activeTurn: null };
this.#assertSnapshots(scope);
if (await this.#owner(context) !== owner) throw fail("permissionDenied");
this.#assertSnapshots(scope);
for (const entry of this.scopes.values()) if (entry.expiresAt <= this.clock()) this.#revoke(entry);
const existing = [...this.scopes.values()].filter((entry) => entry.owner === owner);
if (existing.length >= 8 || this.scopes.size >= 64) throw fail("resourceExhausted");
this.scopes.set(scope.handle, scope);
return Object.freeze({ handle: scope.handle, expiresAt: scope.expiresAt });
}
#assertSnapshots(scope) {
// Current-owner/revision checks are synchronous registry lookups, so the
// final dispatch check and native call have no asynchronous focus gap.
if (scope.backend.isCurrent(scope.backend) !== true) throw fail("failedPrecondition");
for (const target of scope.targets.values()) {
if (target.isCurrent(target) !== true) throw fail("failedPrecondition");
}
}
async #resolve(handle, context) {
const scope = this.scopes.get(handle);
let owner;
try { owner = await this.#owner(context); }
catch (error) {
// Context identity comes from the authenticated host transport. Revoke
// only its own captured scope, never a victim referenced by another caller.
if (!isRequestCancellation(error, context) && scope && scope.owner.runtimeId === context.runtimeId && scope.owner.pluginId === context.pluginId
&& scope.owner.pluginVersion === context.pluginVersion) this.#revoke(scope);
throw error;
}
if (!scope || scope.owner !== owner) throw fail("permissionDenied");
if (scope.expiresAt <= this.clock()) { this.#revoke(scope); throw fail("permissionDenied"); }
try {
if (await this.#owner(context) !== owner || this.scopes.get(handle) !== scope
|| scope.expiresAt <= this.clock()) throw fail("permissionDenied");
this.#assertSnapshots(scope);
} catch (error) {
// A failed individual RPC has no authority to revoke another turn. Real
// owner/target failure still invalidates the scope, even if a request also
// happens to be cancelled at that time.
if (!isRequestCancellation(error, context)) this.#revoke(scope);
throw error;
}
return scope;
}
async begin(handle, context) {
const scope = await this.#resolve(handle, context);
if (scope.activeTurn) throw fail("alreadyExists");
const ticket = Object.freeze({ scope, controller: new AbortController(), id: randomUUID() });
scope.activeTurn = ticket;
return ticket;
}
async assertTurn(ticket, context) {
if (!ticket?.scope || ticket.controller.signal.aborted) throw fail("cancelled");
const scope = await this.#resolve(ticket.scope.handle, context);
if (scope.activeTurn !== ticket) throw fail("cancelled");
ticket.controller.signal.throwIfAborted();
return scope;
}
// Called inside the actual tool dispatcher, after canonical argument/target
// resolution. It must not be used merely when displaying the model tool list.
async dispatch(ticket, context, action, execute) {
const scope = await this.assertTurn(ticket, context);
if (!action || !scope.capabilities.has(action.capabilityId) || !Array.isArray(action.targets)
|| action.targets.length > 32 || typeof execute !== "function") throw fail("permissionDenied");
const targets = action.targets.map((target) => {
const captured = target && scope.targets.get(key(target));
if (!captured || target.revision !== captured.revision || target.owner !== captured.owner) throw fail("permissionDenied");
return captured;
});
const request = Object.freeze({ capabilityId: action.capabilityId, targets: Object.freeze(targets) });
// The adapter combines plugin policy and native Agent restrictions once.
// No caller can select a process-global permission mode through this API.
await this.authorizeAction(context, scope.owner, request, ticket.controller.signal);
await this.assertTurn(ticket, context);
this.#assertSnapshots(scope);
ticket.controller.signal.throwIfAborted();
const result = await execute(Object.freeze({ signal: ticket.controller.signal,
assertCurrent: () => this.assertTurn(ticket, context) }));
await this.assertTurn(ticket, context);
return result;
}
finish(ticket) {
if (!ticket || ticket.scope?.activeTurn !== ticket) return false;
ticket.scope.activeTurn = null;
ticket.controller.abort();
return true;
}
async stop(handle, context) {
// Capture before the first await. A prior Stop cannot acquire authority over
// a turn that replaces the one the caller intended to stop.
const candidate = this.scopes.get(handle);
const ticket = candidate?.activeTurn;
const scope = await this.#resolve(handle, context);
return scope === candidate ? this.finish(ticket) : false;
}
#revoke(scope) {
this.finish(scope.activeTurn);
this.scopes.delete(scope.handle);
}
releaseRuntime(runtimeId) {
for (const scope of this.scopes.values()) if (scope.owner.runtimeId === runtimeId) this.#revoke(scope);
}
// Main-owned cleanup after a pending operation loses its lease. Exact owner
// object matching prevents old cleanup from deleting a replacement source.
discard(handle, owner) {
const scope = this.scopes.get(handle);
if (!scope || scope.owner !== owner) return false;
this.#revoke(scope);
return true;
}
releasePlugin(pluginId) {
for (const scope of this.scopes.values()) if (scope.owner.pluginId === pluginId) this.#revoke(scope);
}
releaseAll() {
for (const scope of this.scopes.values()) this.#revoke(scope);
}
shutdown() {
this.closed = true;
this.releaseAll();
}
}
module.exports = { DelegatedAgentScopes, isRequestCancellation };
@@ -0,0 +1,229 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { DelegatedAgentScopes } = require("./delegatedScope.cjs");
const { PluginAISourceBinding } = require("../ai/sourceBinding.cjs");
function deferred() {
let resolve;
const promise = new Promise((finish) => { resolve = finish; });
return { promise, resolve };
}
function fixture() {
let time = 1; let locked = false; let focus = "terminal-a";
let source = { id: "source-a", kind: "third-party", origin: "https://example.invalid" };
let privateOwner = "source:opaque-owner-a:com.example.assistant";
let identity = { pluginId: "com.example.assistant", pluginVersion: "1", runtimeId: "r1", runtimeKind: "browser" };
let authorize = async () => {};
const context = { ...identity, assertActive: async () => {} };
const database = {
getActivePlugin: () => ({ source, enabled: true, activeVersion: "1", runtime: { status: "running" } }),
getRetainedPluginSource: () => source,
privateOwner: () => privateOwner,
publicPluginId: (value) => value === privateOwner ? identity.pluginId : null,
};
const binding = new PluginAISourceBinding({ database, getRuntimeIdentity: () => identity });
let backendRevision = "configured-1";
const currentTargets = new Map([
["terminal-a", { owner: "window-1", revision: "session-a-v1" }],
["terminal-b", { owner: "window-2", revision: "session-b-v1" }],
]);
const backend = { key: "host-configured-claude", revision: backendRevision,
isCurrent: (snapshot) => snapshot.revision === backendRevision };
const target = (id) => ({ kind: "terminal", id, ...currentTargets.get(id),
isCurrent: (snapshot) => { const current = currentTargets.get(snapshot.id);
return current?.owner === snapshot.owner && current?.revision === snapshot.revision; } });
const scopes = new DelegatedAgentScopes({ resolveOwner: (ctx) => binding.resolve(ctx),
assertHostActive: async () => { if (locked) throw Error("Locked"); },
authorizeAction: (...args) => authorize(...args), clock: () => time });
return { scopes, context, target, targets: currentTargets, database,
issue: (targets = [target("terminal-a"), target("terminal-b")]) => scopes.issue(context,
{ backend, targets, capabilities: ["terminal.execute", "session.environment"] }),
action: (id = "terminal-a") => ({ capabilityId: "terminal.execute", targets: [target(id)] }),
tick: (ms) => { time += ms; }, lock: () => { locked = true; scopes.releaseAll(); },
focusTo: (id) => { focus = id; }, focus: () => focus,
authorize: (fn) => { authorize = fn; }, backendTo: (value) => { backendRevision = value; },
replaceSource: () => { source = { ...source, id: "source-b" }; privateOwner = "source:opaque-owner-b:com.example.assistant"; },
runtimeTo: (id) => { identity = { ...identity, runtimeId: id }; },
};
}
test("host-issued scope exposes only opaque identity and expiry; cross-workspace focus does not change explicit targets", async () => {
const f = fixture(); const ref = await f.issue();
assert.deepEqual(Object.keys(ref).sort(), ["expiresAt", "handle"]);
assert.doesNotMatch(JSON.stringify(ref), /window-|terminal-|source:|configured/);
const ticket = await f.scopes.begin(ref.handle, f.context);
f.focusTo("unselected-terminal");
let calls = 0;
assert.equal(await f.scopes.dispatch(ticket, f.context, f.action("terminal-b"), async () => ++calls), 1);
assert.equal(f.focus(), "unselected-terminal");
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "terminal.execute", targets: [{ kind: "terminal", id: f.focus(), revision: "1", owner: "window-1" }] }, async () => ++calls));
assert.equal(calls, 1);
});
test("forged runtime, cross-runtime session and source replacement cannot use a scope", async () => {
const f = fixture(); const ref = await f.issue();
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "forged" }));
f.runtimeTo("r2");
await assert.rejects(f.scopes.begin(ref.handle, { ...f.context, runtimeId: "r2" }));
f.runtimeTo("r1"); f.replaceSource();
await assert.rejects(f.scopes.begin(ref.handle, f.context));
});
test("one host policy decision; target owner/revision is revalidated after approval waits", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let decisions = 0; let effects = 0;
f.authorize(async (_context, owner, action) => {
decisions++; assert.equal(owner.privateOwner, "source:opaque-owner-a:com.example.assistant");
assert.equal(action.targets[0].id, "terminal-a"); entered.resolve(); await release.promise;
});
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
await entered.promise;
f.targets.set("terminal-a", { owner: "new-window", revision: "session-a-v2" });
release.resolve(); await assert.rejects(result);
assert.equal(decisions, 1); assert.equal(effects, 0); assert.equal(ticket.controller.signal.aborted, true);
});
test("copied target ID with substituted owner or revision and undelegated capabilities fail before policy/execute", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
let decisions = 0; let effects = 0; f.authorize(async () => { decisions++; });
for (const patch of [{ owner: "attacker" }, { revision: "old" }]) {
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "terminal.execute", targets: [{ ...f.target("terminal-a"), ...patch }] }, async () => effects++));
}
await assert.rejects(f.scopes.dispatch(ticket, f.context,
{ capabilityId: "vault.host.delete", targets: [] }, async () => effects++));
assert.equal(decisions, 0); assert.equal(effects, 0);
});
test("Deny and revoked source during authorization never dispatch a tool", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
let effects = 0;
f.authorize(async () => { throw Error("Denied"); });
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
f.authorize(async () => { f.replaceSource(); });
await assert.rejects(f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++));
assert.equal(effects, 0);
});
test("Stop while approval waits cancels the exact turn and prevents invocation", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let effects = 0;
f.authorize(async (_context, _owner, _action, signal) => {
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise;
});
const result = f.scopes.dispatch(ticket, f.context, f.action(), async () => effects++);
await entered.promise; assert.equal(await f.scopes.stop(ref.handle, f.context), true);
release.resolve(); await assert.rejects(result); assert.equal(effects, 0);
const next = await f.scopes.begin(ref.handle, f.context);
assert.equal(f.scopes.finish(ticket), false); assert.equal(next.controller.signal.aborted, false);
});
for (const reason of ["runtime", "plugin", "lock", "stop"]) {
test(`${reason} cancellation drops late native results and invalidates the old event ticket`, async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred();
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
assert.equal(signal, ticket.controller.signal); entered.resolve(); await release.promise; return "late";
});
await entered.promise;
if (reason === "runtime") f.scopes.releaseRuntime(f.context.runtimeId);
else if (reason === "plugin") f.scopes.releasePlugin(f.context.pluginId);
else if (reason === "lock") f.lock();
else await f.scopes.stop(ref.handle, f.context);
release.resolve(); await assert.rejects(result); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(ticket.controller.signal.aborted, true);
});
}
test("backend config changes and expiry invalidate scopes; host snapshots cannot silently refresh them", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
f.backendTo("new-config"); await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(ticket.controller.signal.aborted, true);
const g = fixture(); const next = await g.issue(); const active = await g.scopes.begin(next.handle, g.context);
g.tick(300_000); await assert.rejects(g.scopes.assertTurn(active, g.context));
assert.equal(active.controller.signal.aborted, true); assert.equal(g.scopes.scopes.size, 0);
});
test("source replacement detected during owner authentication aborts already-running native work", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred();
const result = f.scopes.dispatch(ticket, f.context, f.action(), async ({ signal }) => {
entered.resolve(signal); await release.promise; return "late native result";
});
const signal = await entered.promise;
try {
f.replaceSource();
await assert.rejects(f.scopes.assertTurn(ticket, f.context));
assert.equal(signal.aborted, true, "source detection must abort native work, not only reject its result");
assert.equal(f.scopes.scopes.size, 0);
} finally { release.resolve(); await assert.rejects(result); }
});
test("Stop captures turn A before owner validation and cannot cancel replacement turn B", async () => {
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); let blocked = false;
const stopContext = { ...f.context, assertActive: async () => {
if (!blocked) { blocked = true; entered.resolve(); await release.promise; }
} };
const stopping = f.scopes.stop(ref.handle, stopContext);
await entered.promise; assert.equal(f.scopes.finish(first), true);
const second = await f.scopes.begin(ref.handle, f.context);
release.resolve();
assert.equal(await stopping, false);
assert.equal(second.controller.signal.aborted, false, "an earlier Stop must not cancel a later turn");
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
});
for (const phase of [1, 5]) {
test(`cancelled Stop(A) during owner check ${phase} cannot revoke replacement turn B`, { timeout: 2_000 }, async () => {
const f = fixture(); const ref = await f.issue(); const first = await f.scopes.begin(ref.handle, f.context);
const entered = deferred(); const release = deferred(); const rpc = new AbortController(); let checks = 0;
const stopContext = { ...f.context, signal: rpc.signal, assertActive: async () => {
if (++checks === phase) { entered.resolve(); await release.promise; }
rpc.signal.throwIfAborted();
} };
const stopping = f.scopes.stop(ref.handle, stopContext);
await entered.promise; f.scopes.finish(first);
const second = await f.scopes.begin(ref.handle, f.context);
rpc.abort(phase === 1 ? new DOMException("Stop cancelled", "AbortError") : new DOMException("Stop deadline", "TimeoutError"));
release.resolve();
await assert.rejects(stopping, (error) => error === rpc.signal.reason);
assert.equal(second.controller.signal.aborted, false, "a cancelled RPC does not revoke source authority or stop a later turn");
assert.equal(f.scopes.scopes.size, 1);
assert.equal(await f.scopes.assertTurn(second, f.context), second.scope);
});
}
test("verified source failure still aborts native work when the RPC is concurrently cancelled", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
const rpc = new AbortController(); const retained = f.database.getRetainedPluginSource;
f.database.getRetainedPluginSource = () => {
rpc.abort(new DOMException("Concurrent RPC cancellation", "AbortError"));
return { ...retained(), id: "different-retained-source" };
};
await assert.rejects(f.scopes.assertTurn(ticket, { ...f.context, signal: rpc.signal }),
(error) => error !== rpc.signal.reason && error.code === -32007);
assert.equal(ticket.controller.signal.aborted, true);
assert.equal(f.scopes.scopes.size, 0);
});
test("a foreign runtime cannot revoke a victim scope by failing authentication with its handle", async () => {
const f = fixture(); const ref = await f.issue(); const ticket = await f.scopes.begin(ref.handle, f.context);
await assert.rejects(f.scopes.stop(ref.handle, { ...f.context, runtimeId: "foreign-runtime" }));
assert.equal(ticket.controller.signal.aborted, false);
assert.equal(f.scopes.scopes.size, 1);
assert.equal(await f.scopes.assertTurn(ticket, f.context), ticket.scope);
});
test("bounded host scopes reject malformed snapshots without registering partial targets", async () => {
const f = fixture(); await assert.rejects(f.issue([null])); assert.equal(f.scopes.scopes.size, 0);
await assert.rejects(f.issue([f.target("terminal-a"), f.target("terminal-a")]));
for (let i = 0; i < 8; i++) await f.issue();
await assert.rejects(f.issue());
f.tick(300_000); await f.issue(); assert.equal(f.scopes.scopes.size, 1);
f.scopes.shutdown(); await assert.rejects(f.issue());
});
+113
View File
@@ -0,0 +1,113 @@
"use strict";
const { randomBytes } = require("node:crypto");
const PREFIX = "plugin-agent:";
const { listMcpTools } = require("../../capabilities/codegen/toolSurfaces.cjs");
const toolsByMethod = new Map(listMcpTools().map((tool) => [tool.rpcMethod, tool.mcpTool]));
// Main-only per-turn MCP credentials. A managed Agent subprocess receives this
// credential instead of the app-wide token; no token crosses the plugin API.
class PluginAgentMcpDelegations {
constructor() {
this.sessions = new Map();
this.tokens = new Map();
this.sockets = new WeakMap();
this.approvedContexts = new WeakSet();
}
register(chatSessionId, { dispatch, assertCurrent, senderId, tools, turnTag }) {
if (typeof chatSessionId !== "string" || !chatSessionId.startsWith(PREFIX)
|| typeof dispatch !== "function" || typeof assertCurrent !== "function"
|| typeof turnTag !== "string" || !/^[0-9a-f-]{36}$/u.test(turnTag)
|| !Number.isSafeInteger(senderId) || !Array.isArray(tools) || tools.length > 128
|| tools.some((tool) => typeof tool !== "string" || !tool || tool.length > 128)
|| this.sessions.has(chatSessionId) || this.sessions.size >= 64) throw Error("Invalid Agent delegation");
const token = randomBytes(32).toString("hex");
const entry = { chatSessionId, turnTag, token, dispatch, assertCurrent, senderId, modelRequests: 0, tools: Object.freeze([...tools]), sockets: new Set(), cancellations: new Set() };
this.sessions.set(chatSessionId, entry); this.tokens.set(token, entry);
return Object.freeze({ dispose: () => { if (this.sessions.get(chatSessionId) === entry) this.release(chatSessionId); } });
}
#assert(entry) {
if (!entry || this.sessions.get(entry.chatSessionId) !== entry) throw Error("Agent delegation unavailable");
try { entry.assertCurrent(); }
catch (error) {
// This callback is a synchronous main-owned authority check, not an RPC
// signal. Discovering revocation must abort already-running native work.
if (this.sessions.get(entry.chatSessionId) === entry) this.release(entry.chatSessionId);
throw error;
}
return entry;
}
authenticate(token, socket) {
const entry = this.tokens.get(token);
if (!entry) return false;
try { this.#assert(entry); } catch { return false; }
this.sockets.set(socket, entry); entry.sockets.add(socket);
socket.once?.("close", () => entry.sockets.delete(socket));
return true;
}
boundChat(socket) {
const entry = this.sockets.get(socket);
return entry ? this.#assert(entry).chatSessionId : null;
}
configuration(chatSessionId, turnTag) {
const entry = this.#assert(this.sessions.get(chatSessionId));
if (entry.turnTag !== turnTag) throw Error("Agent turn unavailable");
return Object.freeze({ token: entry.token, tools: entry.tools });
}
has(chatSessionId) { return this.sessions.has(chatSessionId); }
assertCaller(chatSessionId, senderId, turnTag) {
const entry = this.#assert(this.sessions.get(chatSessionId));
if (entry.senderId !== senderId || entry.turnTag !== turnTag) throw Error("Agent caller unavailable");
}
onRelease(chatSessionId, turnTag, cancel) {
const entry = this.#assert(this.sessions.get(chatSessionId));
if (entry.turnTag !== turnTag) throw Error("Agent turn unavailable");
if (typeof cancel !== "function") throw Error("Native cancellation callback required");
entry.cancellations.add(cancel);
return () => entry.cancellations.delete(cancel);
}
beginModelRequest(chatSessionId, senderId, turnTag) {
this.assertCaller(chatSessionId, senderId, turnTag);
const entry = this.sessions.get(chatSessionId);
if (++entry.modelRequests > 32) { this.release(chatSessionId); throw Error("Agent model request budget exceeded"); }
}
isReserved(chatSessionId) { return typeof chatSessionId === "string" && chatSessionId.startsWith(PREFIX); }
isApproved(context) { return this.approvedContexts.has(context); }
async dispatch(method, params, context, execute) {
const entry = this.#assert(this.sessions.get(params.chatSessionId));
if (!entry.tools.includes(toolsByMethod.get(method))) throw Error("Tool is outside the Agent delegation");
const socketEntry = context.externalSocket && this.sockets.get(context.externalSocket);
if (socketEntry !== entry && (context.trustedAgentSenderId !== entry.senderId || context.trustedAgentTurnTag !== entry.turnTag)) throw Error("Agent caller unavailable");
if (socketEntry && socketEntry !== entry) throw Error("Agent connection cannot change its session");
return entry.dispatch(method, params, async () => {
this.#assert(entry);
const approved = { ...context, beforeExecute: () => {
this.#assert(entry); context.beforeExecute?.();
} };
this.approvedContexts.add(approved);
try { return await execute(approved); }
finally { this.approvedContexts.delete(approved); }
});
}
release(chatSessionId) {
const entry = this.sessions.get(chatSessionId);
if (!entry) return;
this.sessions.delete(chatSessionId); this.tokens.delete(entry.token);
for (const cancel of entry.cancellations) { try { cancel(); } catch {} }
entry.cancellations.clear();
for (const socket of entry.sockets) { try { socket.destroy(); } catch {} }
entry.sockets.clear();
}
shutdown() { for (const id of this.sessions.keys()) this.release(id); }
}
module.exports = { PluginAgentMcpDelegations };
@@ -0,0 +1,71 @@
"use strict";
const test = require("node:test");
const assert = require("node:assert/strict");
const { EventEmitter } = require("node:events");
const { randomUUID } = require("node:crypto");
const { PluginAgentMcpDelegations } = require("./mcpDelegation.cjs");
const options = (turnTag, overrides = {}) => ({ turnTag, senderId: 7, tools: ["get_environment"],
assertCurrent: () => {}, dispatch: (_method, _params, execute) => execute(), ...overrides });
function socket() {
const value = new EventEmitter(); value.destroyed = false;
value.destroy = () => { value.destroyed = true; value.emit("close"); }; return value;
}
test("old turn disposer, credentials and late cancellation registration cannot acquire replacement turn B", () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`;
const a = randomUUID(); const b = randomUUID();
const first = registry.register(id, options(a));
const tokenA = registry.configuration(id, a).token; const connectionA = socket();
assert.equal(registry.authenticate(tokenA, connectionA), true);
let cancelledA = 0; let cancelledB = 0;
registry.onRelease(id, a, () => cancelledA++); first.dispose();
const second = registry.register(id, options(b)); registry.onRelease(id, b, () => cancelledB++);
first.dispose(); assert.equal(cancelledB, 0); assert.equal(cancelledA, 1);
assert.equal(connectionA.destroyed, true);
assert.equal(registry.authenticate(tokenA, socket()), false);
assert.throws(() => registry.configuration(id, a));
assert.throws(() => registry.onRelease(id, a, () => cancelledB++));
assert.throws(() => registry.assertCaller(id, 7, a));
assert.throws(() => registry.assertCaller(id, 99, b));
assert.doesNotThrow(() => registry.assertCaller(id, 7, b));
assert.equal(cancelledB, 0); second.dispose(); assert.equal(cancelledB, 1);
});
test("discovered owner revocation aborts native work and one faulty callback cannot interrupt cleanup", () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
let current = true;
registry.register(id, options(tag, { assertCurrent: () => { if (!current) throw Error("Source revoked"); } }));
const token = registry.configuration(id, tag).token; const connection = socket(); registry.authenticate(token, connection);
const native = new AbortController();
registry.onRelease(id, tag, () => { throw Error("Faulty cleanup listener"); });
registry.onRelease(id, tag, () => native.abort());
current = false; assert.throws(() => registry.assertCaller(id, 7, tag), /Source revoked/);
assert.equal(native.signal.aborted, true); assert.equal(connection.destroyed, true);
assert.equal(registry.has(id), false); assert.equal(registry.authenticate(token, socket()), false);
});
test("tool profile and trusted caller tag are enforced before invoking the host policy callback", async () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
let decisions = 0; let effects = 0;
registry.register(id, options(tag, { dispatch: (_method, _params, execute) => { decisions++; return execute(); } }));
await assert.rejects(registry.dispatch("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: randomUUID() }, () => effects++));
await assert.rejects(registry.dispatch("netcatty/exec", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag }, () => effects++));
assert.equal(decisions, 0); assert.equal(effects, 0);
await registry.dispatch("netcatty/getContext", { chatSessionId: id }, { trustedAgentSenderId: 7, trustedAgentTurnTag: tag }, (context) => {
assert.equal(registry.isApproved(context), true); context.beforeExecute(); effects++;
});
assert.equal(decisions, 1); assert.equal(effects, 1);
registry.shutdown();
});
test("model request limits cannot be reset or consumed by a different turn or caller", () => {
const registry = new PluginAgentMcpDelegations(); const id = `plugin-agent:${randomUUID()}`; const tag = randomUUID();
registry.register(id, options(tag)); let cancelled = 0; registry.onRelease(id, tag, () => cancelled++);
for (let i = 0; i < 32; i++) {
assert.throws(() => registry.beginModelRequest(id, 8, tag));
assert.throws(() => registry.beginModelRequest(id, 7, randomUUID()));
registry.beginModelRequest(id, 7, tag);
}
assert.throws(() => registry.beginModelRequest(id, 7, tag), /budget/); assert.equal(cancelled, 1); assert.equal(registry.has(id), false);
});
+9
View File
@@ -51,6 +51,15 @@ class PluginAISourceBinding {
return binding;
}
// Main-owned final dispatch guard. Only a previously authenticated binding
// object can reach this path; it is never reconstructed from plugin fields.
assertCurrent(binding) {
if (!binding || this.bindings.get(binding.runtimeId) !== binding) throw denied();
const current = this.#snapshot(binding);
if (current.sourceKey !== binding.sourceKey || current.privateOwner !== binding.privateOwner
|| current.pluginId !== binding.pluginId || current.pluginVersion !== binding.pluginVersion) throw denied();
}
releaseRuntime(runtimeId) { this.bindings.delete(runtimeId); }
releasePlugin(pluginId) {
for (const [runtimeId, binding] of this.bindings) if (binding.pluginId === pluginId) this.bindings.delete(runtimeId);
@@ -39,3 +39,12 @@ test("source/owner changes during asynchronous authentication cannot commit a bi
const context = { ...f.context, assertActive: async () => { if (++checks === 2) f.ownerTo("changed-owner"); } };
await assert.rejects(f.binding.resolve(context)); assert.equal(f.binding.bindings.size, 0);
});
test("final dispatch checks require the exact authenticated binding and current host provenance", async () => {
const f = fixture(); const binding = await f.binding.resolve(f.context);
assert.doesNotThrow(() => f.binding.assertCurrent(binding));
assert.throws(() => f.binding.assertCurrent({ ...binding }));
f.ownerTo("changed-owner"); assert.throws(() => f.binding.assertCurrent(binding));
f.ownerTo("source:opaque-a:com.example.assistant");
f.binding.releaseRuntime("r1"); assert.throws(() => f.binding.assertCurrent(binding));
});
+27 -9
View File
@@ -11,6 +11,12 @@ const CHANNELS = Object.freeze({ request: "netcatty:plugins:ai-host-request", re
// supplied by the shared contract task; no provider config or key crosses it.
class PluginAITrustedHostAdapter {
constructor(options) {
// Separate trusted Agent transport instances reuse the same sender/nonce
// guards. These construction options never come from a plugin request.
this.channels = options.channels ?? CHANNELS;
this.requestDefinition = options.requestDefinition ?? "PluginAIHostRequest";
this.responseDefinition = options.responseDefinition ?? "PluginAIHostResponse";
this.maxEnvelopeBytes = Math.min(options.maxEnvelopeBytes ?? 24_576, 1_048_576);
this.getWindow = options.getWindow;
this.isLocked = options.isLocked ?? (() => true);
this.dialog = options.dialog;
@@ -25,11 +31,11 @@ class PluginAITrustedHostAdapter {
registerIpc(ipcMain) {
if (this.ipcRegistration) throw new Error("Trusted AI host IPC is already registered");
this.validateRequest ??= createDefinitionValidator("PluginAIHostRequest");
this.validateResponse ??= createDefinitionValidator("PluginAIHostResponse");
ipcMain.handle(CHANNELS.ready, (event, ready) => this.setReady(event.sender, ready));
ipcMain.handle(CHANNELS.response, (event, response) => this.respond(event.sender, response));
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(CHANNELS.ready); ipcMain.removeHandler(CHANNELS.response); } };
this.validateRequest ??= createDefinitionValidator(this.requestDefinition);
this.validateResponse ??= createDefinitionValidator(this.responseDefinition);
ipcMain.handle(this.channels.ready, (event, ready) => this.setReady(event.sender, ready));
ipcMain.handle(this.channels.response, (event, response) => this.respond(event.sender, response));
this.ipcRegistration = { dispose: () => { ipcMain.removeHandler(this.channels.ready); ipcMain.removeHandler(this.channels.response); } };
return this.ipcRegistration;
}
@@ -68,7 +74,7 @@ class PluginAITrustedHostAdapter {
#envelope(kind, payload, runtimeId) {
const request = { requestId: randomUUID(), actionToken: randomBytes(24).toString("base64url"), kind, payload, runtimeId };
assertPluginJsonValue(request, { maxBytes: 24_576 });
assertPluginJsonValue(request, { maxBytes: this.maxEnvelopeBytes });
if (!this.validateRequest?.(request)) throw fail("internal", "Trusted AI host request is invalid");
return request;
}
@@ -80,7 +86,7 @@ class PluginAITrustedHostAdapter {
#cleanup(kind, payload, runtimeId, sender = this.owner) {
if (!sender || sender !== this.getWindow?.()?.webContents || sender.isDestroyed?.()) return;
try { sender.send(CHANNELS.request, this.#envelope(kind, payload, runtimeId)); } catch {}
try { sender.send(this.channels.request, this.#envelope(kind, payload, runtimeId)); } catch {}
}
async #request(kind, payload, context) {
@@ -102,14 +108,14 @@ class PluginAITrustedHostAdapter {
this.pending.set(request.requestId, entry);
context.signal?.addEventListener("abort", entry.abort, { once: true });
if (context.signal?.aborted) { entry.abort(); return; }
try { sender.send(CHANNELS.request, request); }
try { sender.send(this.channels.request, request); }
catch { entry.finish(null, fail("unavailable", "Trusted AI host disconnected")); }
});
}
async respond(sender, response) {
if (sender !== this.#owner()) throw fail("permissionDenied", "AI host response sender is invalid");
assertPluginJsonValue(response, { maxBytes: 24_576 });
assertPluginJsonValue(response, { maxBytes: this.maxEnvelopeBytes });
if (!this.validateResponse?.(response)) throw fail("invalidArgument", "AI host response is invalid");
const entry = this.pending.get(response.requestId);
if (!entry || entry.sender !== sender || entry.generation !== this.generation) return false;
@@ -126,6 +132,18 @@ class PluginAITrustedHostAdapter {
} catch { entry.finish(null, fail("cancelled", "Trusted AI host response became stale")); return false; }
}
// Host-only, canonical typed request path for a separate Agent instance.
requestAgent(kind, payload, context) {
if (this.requestDefinition !== "PluginAgentHostRequest" || !kind.startsWith("agent.")) throw fail("unsupported", "Agent host route unavailable");
return this.#request(kind, payload, context);
}
getSenderId() { return this.#owner().id; }
releaseAgentChat(chatSessionId, runtimeId) {
if (this.requestDefinition !== "PluginAgentHostRequest" || !/^plugin-agent:[0-9a-f-]{36}$/u.test(chatSessionId)) throw fail("invalidArgument", "Agent conversation unavailable");
this.#cleanup("agent.close", { chatSessionId }, runtimeId);
}
async selectModel(context) {
const selected = await this.#request("model.select", {}, context);
if (typeof selected?.hostHandle !== "string" || typeof selected.label !== "string" || selected.label.length > 160) throw fail("internal", "Selected model is invalid");
+24 -1
View File
@@ -188,6 +188,7 @@ class PluginFilesystemBroker {
constructor(options = {}) {
this.quotaManager = options.quotaManager ?? null;
this.protectedDirectories = options.protectedDirectories ?? [];
this.protectedReadDirectories = options.protectedReadDirectories ?? [];
this.protectedFiles = options.protectedFiles ?? [];
this.fileSystem = options.fileSystem ?? fsp;
this.openDirectoryHandle = options.openDirectoryHandle ?? null;
@@ -241,8 +242,10 @@ class PluginFilesystemBroker {
async readFile(params, context) {
const value = assertReadParams(params);
await this.#assertReadable(value.path);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
await this.#assertReadable(canonical);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isFile()) throw invalidArgument("Plugin filesystem read target is not a file");
if (stats.size > value.maxBytes) {
@@ -259,7 +262,9 @@ class PluginFilesystemBroker {
if (openedStats.size > value.maxBytes) {
throw new PluginRpcError(RPC_ERRORS.resourceExhausted, "Plugin filesystem file changed or is too large");
}
assertAuthorizedPath(context, "filesystem.read", await this.fileSystem.realpath(canonical));
const current = await this.fileSystem.realpath(canonical);
await this.#assertReadable(current);
assertAuthorizedPath(context, "filesystem.read", current);
const bytes = await readBoundedFileHandle(handle, value.maxBytes);
this.quotaManager?.chargeBytes(context.runtimeId, "filesystem", bytes.byteLength);
await context.assertActive();
@@ -290,6 +295,20 @@ class PluginFilesystemBroker {
}
}
async #assertReadable(candidate) {
for (const directory of this.protectedReadDirectories) {
const variants = [path.resolve(directory)];
try { variants.push(await this.fileSystem.realpath(directory)); }
catch (error) { if (error?.code !== "ENOENT") throw error; }
for (const parent of variants) {
const relative = path.relative(parent, candidate);
if (relative === "" || (!path.isAbsolute(relative) && relative !== ".." && !relative.startsWith(`..${path.sep}`))) {
throw new PluginRpcError(RPC_ERRORS.permissionDenied, "Private host conversation storage is protected");
}
}
}
}
async writeFile(params, context) {
const value = assertSecureWriteMode(assertWriteParams(params));
await this.#assertWritable(path.resolve(value.path));
@@ -323,8 +342,10 @@ class PluginFilesystemBroker {
async stat(params, context) {
const value = assertPathParams(params);
await this.#assertReadable(value.path);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
await this.#assertReadable(canonical);
assertAuthorizedPath(context, "filesystem.read", canonical);
await context.assertActive();
return {
@@ -336,8 +357,10 @@ class PluginFilesystemBroker {
async readDirectory(params, context) {
const value = this.validateDirectory(params);
await this.#assertReadable(value.path);
assertAuthorizedPath(context, "filesystem.read", value.path);
const { canonical, stats } = await resolveExistingPath(value.path, this.fileSystem);
await this.#assertReadable(canonical);
assertAuthorizedPath(context, "filesystem.read", canonical);
if (!stats.isDirectory()) throw invalidArgument("Plugin filesystem directory target is not a directory");
const directory = await this.openDirectoryHandle(canonical, { signal: context.signal });
@@ -93,6 +93,24 @@ test("filesystem broker uses canonical authorization for bounded read, write, st
]);
});
test("host conversation roots are unreadable through explicit filesystem grants, including symlink aliases", async (context) => {
const root = createRoot(context); const privateRoot = path.join(root, "agent-chats");
await fsp.mkdir(privateRoot); const file = path.join(privateRoot, "synthetic.json"); await fsp.writeFile(file, "PRIVATE_OTHER_SOURCE_HISTORY");
const broker = new PluginFilesystemBroker({ protectedReadDirectories: [privateRoot], openDirectoryHandle: openTestDirectoryHandle });
const authorize = (candidate, kind = "exact") => runtimeContext(broker.describeReadAuthorization({ path: candidate }, kind));
await assert.rejects(broker.readFile({ path: file }, authorize(file)), (error) => error.code === RPC_ERRORS.permissionDenied);
await assert.rejects(broker.stat({ path: privateRoot }, authorize(privateRoot)), (error) => error.code === RPC_ERRORS.permissionDenied);
await assert.rejects(broker.readDirectory({ path: privateRoot }, authorize(privateRoot, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
if (process.platform !== "win32") {
const alias = path.join(root, "alias.json"); await fsp.symlink(file, alias);
await assert.rejects(broker.readFile({ path: alias }, authorize(root, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
const directoryAlias = path.join(root, "directory-alias"); await fsp.symlink(privateRoot, directoryAlias);
await assert.rejects(broker.readDirectory({ path: directoryAlias }, authorize(root, "directory")), (error) => error.code === RPC_ERRORS.permissionDenied);
}
const ordinary = path.join(root, "ordinary.txt"); await fsp.writeFile(ordinary, "Synthetic public file");
assert.deepEqual(await broker.readFile({ path: ordinary }, authorize(ordinary)), { data: "Synthetic public file" });
});
test("filesystem authorization removes trailing directory separators before permission", async (context) => {
const root = createRoot(context);
const requestedPath = `${root}${path.sep}`;
File diff suppressed because it is too large. Load diff
+6
View File
@@ -0,0 +1,6 @@
"use strict";
require("./agent/delegatedScope.test.cjs");
require("./agent/conversationStore.test.cjs");
require("./agent/contract.test.cjs");
require("./agent/mcpDelegation.test.cjs");
require("./agent/agentService.test.cjs");
Loaded 100 of 152 files, more files were not shown because too many files have changed in this diff. Show more