Compare commits

...
Author SHA1 Message Date
陈奇 a2a20a725d docs(plugins): classify aggregate failures against the frozen baseline 2026-10-26 07:15:21 +00:00
陈奇 cac7ff1f3e docs(plugins): define declarative theme pack scope and acceptance gates 2026-10-26 07:10:34 +00:00
陈奇 9af123e5d7 feat(plugins): inspect and revoke grants and align network lease contracts 2026-10-26 07:03:14 +00:00
Codex e5bd294cdd fix(plugins): validate and prepare registry snapshots before publication 2026-10-26 06:32:35 +00:00
Codex b48567d448 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client
# Conflicts:
#	application/i18n/locales/en/core.ts
#	application/i18n/locales/es/core.ts
#	application/i18n/locales/ru/core.ts
#	application/i18n/locales/zh-CN/core.ts
#	application/i18n/locales/zh-TW/core.ts
2026-10-26 06:27:10 +00:00
Codex 933bc93fc3 feat(plugins): enrich plugin cards with author and descriptions 2026-10-26 06:26:36 +00:00
Codex 45ed193579 feat(plugins): expose manual community catalog refresh 2026-10-26 06:19:26 +00:00
Codex f471a17a75 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:17:13 +00:00
Codex b9293a7bb5 fix(plugins): keep sparse installed grids compact 2026-10-26 06:16:55 +00:00
Codex 257660bcc1 fix(plugins): keep normalized README URLs pinned to reviewed commits 2026-10-26 06:15:49 +00:00
Codex eaf8c9a385 test(plugins): include registry source in catalog fixture 2026-10-26 06:10:18 +00:00
Codex ff42d0c1b9 Merge branch 'feat/plugin-brand-icons' into feat/plugin-registry-client 2026-10-26 06:09:32 +00:00
Codex 0adb7b822e feat(plugins): show installed plugins in a compact grid 2026-10-26 06:09:20 +00:00
Codex 311765eaa1 feat(plugins): read validated official registry snapshots 2026-10-26 06:06:26 +00:00
Codex d2a8398b7a fix(plugins): parent permission prompts to focused settings window 2026-10-26 06:01:13 +00:00
Codex 69307cee16 fix(plugins): validate settings fixture activation and polish manager controls 2026-10-26 05:49:24 +00:00
Codex 4445c292ea fix(plugins): bound icon archive reads and reject prototype glyph lookups 2026-10-26 05:45:53 +00:00
Codex 6fad272afe style(plugins): use theme-accent status pills for enable controls 2026-10-26 05:35:45 +00:00
Codex a7f8480da1 test(plugins): check packaging against the current workspace revision 2026-10-26 05:35:45 +00:00
Codex e7e57f2a52 fix(plugins): align root workspace dependencies with API revision 2026-10-26 05:31:23 +00:00
Codex ab2f95837e test(plugins): add static settings view handoff fixture 2026-10-26 05:29:34 +00:00
Codex f3d04a1912 chore(plugins): pin reviewed Starter brand preview to source snapshot 2026-10-26 05:27:55 +00:00
Codex f4a6b205ad feat(plugins): add versioned offline brand icons and author guidance 2026-10-26 05:26:25 +00:00
Codex 614b7c997a fix(plugins): release manager modal before opening settings views 2026-10-26 05:21:00 +00:00
Codex aaef0d2ee1 feat(plugins): open management in a dedicated dialog 2026-10-26 05:13:37 +00:00
Codex 5128b51702 fix(plugins): soften selection colors and label historical errors 2026-10-26 04:13:20 +00:00
Codex f5c951f9a1 fix(plugins): restore narrow community browsing scroll position 2026-10-26 04:04:49 +00:00
Codex 8de33d9b5a feat(plugins): integrate responsive community list and detail layout 2026-10-26 04:02:33 +00:00
Codex c727cca643 fix(plugins): use managed catalog temp storage and lint browser example 2026-10-26 03:49:32 +00:00
Codex ba4fab5679 test(plugins): account for Starter runtime fixture build 2026-10-26 03:42:27 +00:00
Codex 27e5a10bfc chore(plugins): pin Starter panel example source 2026-10-26 03:41:29 +00:00
Codex 3efabf2b0f fix(plugins): expose a visible Starter panel and bundle shared browser helpers 2026-10-26 03:41:21 +00:00
Codex 74a2f99b08 fix(plugins): preserve sync sidecar coordinates on Node 22 2026-10-26 03:35:17 +00:00
Codex 80525d9b2d test(plugins): enforce shared policy and permission UI coverage in CI 2026-10-26 03:27:55 +00:00
Codex 741ea4e9c7 feat(plugins): pin the minimal starter and updated example documentation 2026-10-26 03:25:56 +00:00
Codex 4336f8fd53 feat(plugins): open browser-only settings with AI-style subpages and safe starter 2026-10-26 03:25:40 +00:00
Codex aa8989c515 Merge branch 'feat/plugin-settings-manager' into feat/plugin-curated-catalog 2026-10-26 03:15:25 +00:00
Codex b6b45fc11a fix(plugins): complete manager locale parity and local CLI assertions 2026-10-26 03:15:24 +00:00
Codex d5fd56ecdf feat(plugins): add curated browser-only catalog and safe README details 2026-10-26 03:10:48 +00:00
Codex d601d3d238 feat(plugins): add installed package management to settings 2026-10-26 02:59:36 +00:00
Codex e20f2a9986 fix(plugins): disable incompatible plugins before lazy contributions 2026-10-26 02:54:41 +00:00
Netcatty-Botandgithub-actions[bot] 0256e7471d Update Nix release metadata for v1.1.89 (#3931)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-10-26 09:45:44 +08:00
github-actions[bot]andgithub-actions[bot] a4b1009c2a chore: update star history charts [skip ci] (#3932)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-10-26 00:10:10 +00:00
陈大猫 54b6f85b09 fix(fonts): include SFMono Nerd Font in the terminal font picker (#3930)
Add `sfmono` to KNOWN_MONOSPACE_FONTS so glued typographic family names like "SFMono Nerd Font" appear in the Settings → Terminal font picker. Matching logic unchanged.

Fixes #3929
2026-10-25 22:46:09 +08:00
陈大猫 62798393c3 fix(mcp): send commands when a PowerShell prompt is only visible (#3928)
Fixes #3927

Gate: Codex thrash (mid-CSI truncate edge) after real P2 alt-screen fix; CI lint-and-test red only on unrelated TERM-SSHD flake; smoke PASS /workspace/netcatty-smoke-shots/pr-3928-20261025-223624.
2026-10-25 22:42:09 +08:00
FtLOi d382f7bd4f fix(ai): recognize Alpine default shell prompts (#3925)
Recognize Alpine ash default host:path prompts (e.g. localhost:~# ) so AI/pty exec readiness accepts idle Alpine sessions without changing PS1.

CI lint-and-test red on tip was unrelated: TERM-SSHD failed transfer releases the physical channel (transferBridge.termSshd.test.cjs). Local shellUtils + readiness suite and synthetic Alpine prompt smoke PASS.
2026-10-25 18:16:06 +08:00
陈大猫 ff36dc7bce fix(settings): truncate long Agent CLI paths in AI cards (#3924)
Fixes #3923

Settings content column and Agent CLI path rows use min-w-0 so long paths ellipsize inside the pane instead of overflowing the window.
2026-10-25 14:43:26 +08:00
陈大猫 d45fb75370 fix(settings): use in-app confirm for SFTP file association delete (#3921)
Replace native confirm() with ConfirmDialog when deleting an SFTP file association so Windows Settings inputs keep focus.

Fixes #3920
2026-10-25 13:12:04 +08:00
陈大猫 5a1b50a407 Merge pull request #3916 from binaricat/automation/nix-release-v1.1.88 2026-10-25 11:23:44 +08:00
copilot-swe-agent[bot]andbinaricat 0e7ba69edb Merge remote-tracking branch 'origin/main' into automation/nix-release-v1.1.88
# Conflicts:
#	nix/release.nix

Co-authored-by: binaricat <16399091+binaricat@users.noreply.github.com>
2026-10-25 03:22:33 +00:00
陈大猫 babf97e2c1 Merge pull request #3917 from difagume/fix/es-locale-parity 2026-10-25 11:21:59 +08:00
陈大猫 622ffec2cd Merge pull request #3913 from binaricat/automation/nix-release-v1.1.87 2026-10-25 11:21:09 +08:00
陈大猫 598de9cda0 fix(ssh): negotiate OpenSSH host certificates (Fixes #3918)
Offer OpenSSH host-certificate algorithms in ssh2 KEX so cert-only bastions (e.g. Instabox ssh-ed25519-cert-v01@openssh.com) can negotiate. Accept underlying key signature types, keep certs in legacy/skip-ECDSA lists, reject RSA cert signature downgrades, and normalize SK cert types before ECDSA verify.

Fixes #3918
2026-10-25 10:03:10 +08:00
Diego Fabricio 3c1afa9b7d fix(i18n): restore Spanish locale parity with English
The Spanish locale had drifted 163 keys behind English, so those strings fell back to English through the default-locale resolution in I18nProvider. English was also missing 42 of the 43 shortcut binding labels that DEFAULT_KEY_BINDINGS declares.

Add the missing Spanish messages for App Lock, FIDO2 security keys, Kubernetes statuses, host connection test, AI provider headers, OpenCode 2, keychain generation, launch at login, terminal layout and progress, and system manager runtime errors. Add the missing English shortcut labels using the canonical label field in DEFAULT_KEY_BINDINGS.

Harden the locale tests so this cannot drift silently again: include es and zh-TW in the App Lock coverage check, include en in the shortcut label check, and add a parity test that pins es to en for keys and interpolation placeholders while keeping the remaining gaps for ru, zh-CN and zh-TW tracked in an explicit pending list.
2026-10-24 13:20:16 -05:00
github-actions[bot] 36d9a7bd7f Update Nix release metadata for v1.1.88 2026-10-24 18:17:59 +00:00
陈大猫 c44633231a fix(mcp): recognize RHEL-family bash prompts as idle (#3915)
Extend POSIX_PROMPT_PATTERN for Oracle/RHEL/CentOS/Fedora [user@host path][#$] (incl. whitespace and ] in \W). Fixes #3914.
2026-10-25 02:01:23 +08:00
162 changed files with 6683 additions and 321 deletions

No files matched your search

+6
View File
@@ -49,6 +49,12 @@ jobs:
- name: Verify Codex App Server schema
run: npm run check:codex-app-server-schema
- name: Verify plugin contract and runtime boundaries
run: |
npm run check:plugin-contract
npm run test:plugin-runtime
node --test --import tsx components/settings/tabs/Plugin*.test.tsx
- name: Test
run: npm test
+6 -3
View File
@@ -106,10 +106,13 @@ Placement rules (`resolveAgentKinds` in `toolSurfaces.cjs`):
**Harness domain (`catalog/harness.cjs`):** Catty executes these locally in `capabilityTools.executeLocalCattyCapability`. The read-only `harness.terminal.read_context` also exposes `netcatty/readContext` to MCP, with scope validation before and after the existing renderer bridge call. It reuses the sidebar reader through the screen snapshot registry (80 rows by default, maximum 300); no script or command runs. Other harness tools remain Catty-only. `harness.web.search` is omitted when web search is not configured.
## Plugin host runtime (internal preview)
## Plugin host runtime
The phase-2 plugin host lives under `electron/plugins/` and is disabled unless
`NETCATTY_PLUGIN_DEV=1` is present at application launch. Public wire and package
The plugin host under `electron/plugins/` exposes browser-only packages in normal
Settings. `NETCATTY_PLUGIN_DEV=1` admits advanced development packages, without
granting their permissions. Admission and default placement share
`domain/pluginRuntimePolicy.cjs`; permission explanations shared with native
prompts live in `domain/pluginPermissionDescriptions.cjs`. Public wire and package
types still come only from `packages/plugin-contract/schema/`; do not add a
second private RPC shape when extending the host.
+103 -3
View File
@@ -964,9 +964,53 @@ Highlight the focused split pane:
'settings.shortcuts.category.terminal': 'Terminal',
'settings.shortcuts.category.navigation': 'Navigation',
'settings.shortcuts.category.app': 'App',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.category.sftp': 'SFTP',
// Settings > Shortcuts -> key bindings
'settings.shortcuts.binding.switch-tab-1-9': 'Switch to Tab [1...9]',
'settings.shortcuts.binding.next-tab': 'Next Tab',
'settings.shortcuts.binding.prev-tab': 'Previous Tab',
'settings.shortcuts.binding.close-tab': 'Close Tab',
'settings.shortcuts.binding.close-session': 'Close Session Pane',
'settings.shortcuts.binding.new-tab': 'New Local Tab',
'settings.shortcuts.binding.copy': 'Copy from Terminal',
'settings.shortcuts.binding.paste': 'Paste to Terminal',
'settings.shortcuts.binding.paste-selection': 'Paste Selection to Terminal',
'settings.shortcuts.binding.select-all': 'Select All in Terminal',
'settings.shortcuts.binding.clear-buffer': 'Clear Terminal Buffer',
'settings.shortcuts.binding.search-terminal': 'Open Terminal Search',
'settings.shortcuts.binding.increase-terminal-font-size': 'Increase Terminal Font Size',
'settings.shortcuts.binding.decrease-terminal-font-size': 'Decrease Terminal Font Size',
'settings.shortcuts.binding.reset-terminal-font-size': 'Reset Terminal Font Size',
'settings.shortcuts.binding.move-focus': 'Move focus between Split View panes',
'settings.shortcuts.binding.split-horizontal': 'Split Horizontal',
'settings.shortcuts.binding.split-vertical': 'Split Vertical',
'settings.shortcuts.binding.toggle-pane-zoom': 'Toggle Pane Zoom',
'settings.shortcuts.binding.open-hosts': 'Open Hosts Page',
'settings.shortcuts.binding.open-local': 'Open Local Terminal',
'settings.shortcuts.binding.open-sftp': 'Open SFTP',
'settings.shortcuts.binding.port-forwarding': 'Open Port Forwarding',
'settings.shortcuts.binding.command-palette': 'Open Command Palette',
'settings.shortcuts.binding.quick-switch': 'Quick Switch',
'settings.shortcuts.binding.new-workspace': 'New Workspace',
'settings.shortcuts.binding.snippets': 'Open Snippets',
'settings.shortcuts.binding.broadcast': 'Switch the Broadcast Mode',
'settings.shortcuts.binding.toggle-side-panel': 'Toggle Side Panel',
'settings.shortcuts.binding.toggle-host-tree': 'Toggle Host List',
'settings.shortcuts.binding.open-settings': 'Open Settings',
'settings.shortcuts.binding.toggle-opacity': 'Toggle Window Transparency',
'settings.shortcuts.binding.sftp-copy': 'Copy Files',
'settings.shortcuts.binding.sftp-cut': 'Cut Files',
'settings.shortcuts.binding.sftp-paste': 'Paste Files',
'settings.shortcuts.binding.sftp-select-all': 'Select All Files',
'settings.shortcuts.binding.sftp-rename': 'Rename File',
'settings.shortcuts.binding.sftp-delete': 'Delete Files',
'settings.shortcuts.binding.sftp-refresh': 'Refresh',
'settings.shortcuts.binding.sftp-new-folder': 'New Folder',
'settings.shortcuts.binding.sftp-open': 'Open File / Enter Directory',
'settings.shortcuts.binding.sftp-go-parent': 'Go to Parent Directory',
'settings.shortcuts.binding.sftp-navigate-to': 'Navigate to Selected Directory',
// Context menus / common actions
'action.newHost': 'New Host',
'action.newSubfolder': 'New Subfolder',
@@ -1191,8 +1235,64 @@ Highlight the focused split pane:
'deepLink.jms.invalid': 'Invalid JumpServer link',
'deepLink.jms.unsupported': 'Unsupported JumpServer protocol: {{protocol}}',
'settings.tab.plugins': 'Plugins',
'settings.plugins.title': 'Plugin settings',
'settings.plugins.description': 'Native, validated settings contributed by development plugins.',
"settings.plugins.manager.installed": "Installed plugins",
"settings.plugins.manager.import": "Import .ncpkg",
"settings.plugins.manager.preview": "Imported packages stay disabled until you review and enable them.",
"settings.plugins.manager.empty": "Import a local plugin package to get started.",
"settings.plugins.manager.enabled": "Enabled",
"settings.plugins.manager.disabled": "Disabled",
"settings.plugins.manager.enable": "Enable",
"settings.plugins.manager.disable": "Disable",
"settings.plugins.manager.remove": "Uninstall",
"settings.plugins.manager.removeNote": "Uninstall removes package code and local KV data. User-owned settings, secrets and sync sidecars are retained.",
"settings.plugins.permissions.title": "Active grants",
"settings.plugins.permissions.refresh": "Refresh",
"settings.plugins.permissions.loading": "Loading grants...",
"settings.plugins.permissions.empty": "No active grants. One-use decisions are not retained.",
"settings.plugins.permissions.always": "Until revoked",
"settings.plugins.permissions.application": "Until Netcatty exits",
"settings.plugins.permissions.session": "Until the session closes",
"settings.plugins.permissions.audit": "Recent security events (up to 100)",
"settings.plugins.permissions.revoke": "Disable and revoke all grants",
"settings.plugins.permissions.revokeNote": "This disables the plugin, cancels its work, and removes saved grants. Enable it again to request new access.",
"settings.plugins.permissions.unavailable": "Plugin permission management is unavailable",
"settings.plugins.manager.permissions": "Declared permissions",
"settings.plugins.manager.permissionNote": "From the manifest, not a grant. Enabled allows lazy activation; required permissions are requested before first activation and optional permissions when used. Sensitive operations still pass host checks.",
"settings.plugins.manager.noPermissions": "No privileged permissions declared.",
"settings.plugins.manager.required": "Required",
"settings.plugins.manager.optional": "Optional",
"settings.plugins.manager.browser": "Browser runtime: privileged access goes through host permission brokers.",
"settings.plugins.manager.advanced": "Advanced runtime: full Node filesystem and network authority. Broker permissions cannot restrict this access. Development preview only.",
"settings.plugins.manager.data": "Local KV stays on this device. Only non-secret settings explicitly marked for sync participate in encrypted cloud sidecars. Packages and permission grants do not sync.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Last recorded error",
"settings.plugins.market.offline": "Registry unavailable. Showing the last valid catalog or bundled examples. Installation is unavailable until refresh succeeds.",
"settings.plugins.manager.details": "View details",
"settings.plugins.manager.noDescription": "No description provided.",
"settings.plugins.market.installed": "Installed",
"settings.plugins.market.sourceBadge": "Source example",
"settings.plugins.market.title": "Marketplace",
"settings.plugins.market.note": "Curated open-source plugins. Review is not a security certification. Updates are manual.",
"settings.plugins.market.back": "All plugins",
"settings.plugins.market.install": "Install this version (disabled)",
"settings.plugins.market.installNote": "This replaces and stops the installed version. Review its permissions in Installed plugins before enabling.",
"settings.plugins.market.sourceOnly": "First-party source example. Build with the local CLI and import the .ncpkg; no downloadable release has been published.",
"settings.plugins.market.readmeNote": "Pinned to the commit above. HTML is inert and remote images are not loaded. Written by the plugin author.",
"settings.plugins.manager.noVault": "System key storage is unavailable. Secret and credential operations are blocked; browsing and non-secret plugins still work.",
"settings.plugins.manager.sensitive": "Sensitive access: review the data and resource scope before granting.",
"settings.plugins.manager.dual": "Browser is the default runtime. This package also includes a Node entrypoint with full device authority; developer mode only.",
"settings.plugins.manager.advancedDeclaration": "Browser entrypoint with advanced permission declarations. Not eligible for normal browser-only mode; review every declared permission.",
"settings.plugins.market.retry": "Retry",
"settings.plugins.market.empty": "No catalog entries are available. You can still import a local browser plugin.",
"settings.plugins.market.emptyReadme": "This README is empty.",
"settings.plugins.market.search": "Search name, author or description",
"settings.plugins.market.noResults": "No matching plugins. Try another search.",
"settings.plugins.market.manifest": "Compatibility and permissions",
"settings.plugins.manager.unbounded": "No narrower resource scope is declared. Check the actual scope in the permission prompt.",
"settings.plugins.openView": "Open",
"settings.plugins.openManager": "Open plugin manager",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Manage your plugins and explore the catalog.",
'settings.plugins.loading': 'Loading plugin settings…',
'settings.plugins.empty': 'No enabled plugin contributes settings.',
'settings.plugins.scopeContext': 'Open this setting in its {scope} context.',
+20
View File
@@ -90,6 +90,15 @@ export const esAiMessages: Messages = {
'ai.providers.advancedParams.hint': 'Déjalo en blanco para usar los valores predeterminados del proveedor.',
'ai.providers.advancedParams.maxTokens.placeholder': 'p. ej. 4096',
'ai.providers.advancedParams.default': 'Predeterminado del proveedor',
'ai.providers.headers': 'Cabeceras HTTP personalizadas',
'ai.providers.headers.help': 'Cabeceras opcionales para este proveedor. Los valores se ocultan y se cifran al guardarlos.',
'ai.providers.headers.name': 'Nombre de la cabecera',
'ai.providers.headers.value': 'Valor de la cabecera',
'ai.providers.headers.add': 'Agregar cabecera',
'ai.providers.headers.remove': 'Quitar cabecera',
'ai.providers.headers.invalid': 'Usa cabeceras HTTP válidas y únicas. Content-Length y Transfer-Encoding se gestionan automáticamente.',
'ai.providers.headers.loadError': 'No se pudieron desbloquear las cabeceras guardadas. Cancela y vuelve a intentarlo después de desbloquear el llavero del sistema.',
'ai.providers.headers.saveError': 'No se pudo guardar de forma segura. Comprueba que el llavero del sistema esté disponible y vuelve a intentarlo.',
// AI Codex
'ai.codex': 'Codex',
@@ -280,6 +289,14 @@ export const esAiMessages: Messages = {
'ai.opencode.customPathPlaceholder': 'p. ej. /usr/local/bin/opencode',
'ai.opencode.check': 'Verificar',
'ai.opencode.resetPath': 'Restablecer',
'ai.opencode.v2.startupTimeout': 'OpenCode 2 no estuvo listo a tiempo. Confirma que `opencode serve` funciona en una terminal y vuelve a intentarlo.',
'ai.opencode.v2.exited': 'OpenCode 2 se detuvo antes de que el chat pudiera iniciarse.',
'ai.opencode.v2.unauthorized': 'OpenCode 2 rechazó la conexión. Vuelve a intentar el chat.',
'ai.opencode.v2.unavailable': 'No se pudo contactar con OpenCode 2. Comprueba que esté instalado y vuelve a intentarlo.',
'ai.opencode.v2.sessionFailed': 'OpenCode 2 no pudo completar este turno.',
'ai.opencode.v2.empty': 'OpenCode 2 devolvió una respuesta vacía. Ejecuta `opencode` en una terminal para configurar la autenticación y los modelos.',
'ai.opencode.v2.contextSkipped': 'OpenCode 2 no pudo aplicar el contexto de la conversación de Netcatty.',
'ai.opencode.v2.mcpSkipped': 'OpenCode 2 no pudo adjuntar una herramienta MCP. El chat continuará sin ella.',
// AI MiMo Code (CLI de Xiaomi MiMo Code — fork de OpenCode)
'ai.mimo.title': 'MiMo Code',
@@ -694,6 +711,9 @@ export const esAiMessages: Messages = {
'terminal.layer.splitHorizontal': 'Dividir arriba y abajo',
'terminal.layer.splitVertical': 'Dividir izquierda y derecha',
'terminal.layer.openInNewSplit': 'Abrir en una división nueva',
'terminal.layer.saveLayoutAsDefault': 'Guardar diseño como predeterminado',
'terminal.layer.layoutSavedAsDefault': 'Diseño guardado como predeterminado',
'terminal.layer.layoutSaveFailed': 'No se pudo guardar el diseño',
'terminal.layer.hostTree.search': 'Buscar hosts...',
'terminal.layer.hostTree.searchButton': 'Buscar',
'terminal.layer.hostTree.tagsButton': 'Filtrar por etiquetas',
+143 -2
View File
@@ -84,6 +84,32 @@ export const esCoreMessages: Messages = {
'credentials.protectionUnavailable.message': 'Las contraseñas y claves guardadas no se pueden descifrar automáticamente en este dispositivo. Vuelve a ingresar las credenciales antes de conectarte.',
'credentials.protectionUnavailable.action': 'Abrir configuración',
// App Lock
'appLock.title': 'Netcatty está bloqueado',
'appLock.reason.default': 'Ingresa tu contraseña de bloqueo para continuar.',
'appLock.reason.startup': 'Netcatty se bloquea cada vez que se abre esta ventana.',
'appLock.reason.idle': 'Netcatty se bloqueó tras el tiempo de inactividad.',
'appLock.reason.manual': 'Netcatty se bloqueó manualmente.',
'appLock.passwordLabel': 'Contraseña de bloqueo',
'appLock.passwordPlaceholder': 'Ingresa la contraseña para desbloquear',
'appLock.unlock': 'Desbloquear',
'appLock.unlocking': 'Desbloqueando...',
'appLock.usePassword': 'Usar contraseña',
'appLock.error.emptyPassword': 'Ingresa la contraseña de bloqueo.',
'appLock.error.incorrectPassword': 'La contraseña de bloqueo es incorrecta.',
'appLock.systemUnlock.unlockWith': 'Desbloquear con {label}',
'appLock.systemUnlock.preparing': 'Preparando {label}...',
'appLock.systemUnlock.verifying': 'Esperando a {label}...',
'appLock.systemUnlock.error': 'No se completó el desbloqueo del sistema. Usa la contraseña de bloqueo para continuar.',
'appLock.logoLabel': 'Netcatty',
'appLock.reset.title': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.description': 'Esto elimina la contraseña de bloqueo actual y desactiva el bloqueo de la aplicación. Crea una nueva contraseña desde Configuración cuando la necesites.',
'appLock.reset.cancel': 'Cancelar',
'appLock.reset.confirm': 'Restablecer el bloqueo de la aplicación',
'appLock.reset.resetting': 'Restableciendo...',
'appLock.reset.error': 'No se pudo restablecer el bloqueo de la aplicación. Inténtalo de nuevo.',
'topTabs.lockApp': 'Bloquear Netcatty',
// Settings shell
'settings.title': 'Configuración',
'settings.tab.application': 'Aplicación',
@@ -119,6 +145,65 @@ export const esCoreMessages: Messages = {
'settings.system.credentials.unavailableHint': 'Las credenciales cifradas en otro perfil de usuario o máquina no se pueden descifrar aquí. Vuelve a ingresar y guarda las credenciales en este dispositivo.',
'settings.system.credentials.portabilityHint': 'La sincronización en la nube es portátil porque usa tu cifrado de clave maestra. El cifrado local de safeStorage está limitado al dispositivo y al usuario.',
// Settings > System > App Lock
'settings.appLock.title': 'Bloqueo de la aplicación',
'settings.appLock.description': 'Solicita una contraseña de bloqueo local cuando Netcatty se abre, tras un periodo de inactividad o cuando lo bloqueas manualmente.',
'settings.appLock.setupTitle': 'Define una contraseña de bloqueo',
'settings.appLock.setupDescription': 'Crea la contraseña a continuación. El bloqueo de la aplicación se activa en cuanto se guarda.',
'settings.appLock.manageTitle': 'El bloqueo de la aplicación está listo',
'settings.appLock.enabledStatus': 'Activado. Netcatty se bloquea al iniciar, tras un periodo de inactividad y cuando lo bloqueas manualmente.',
'settings.appLock.disabledStatus': 'Contraseña guardada, pero el bloqueo de la aplicación está desactivado.',
'settings.appLock.disableTitle': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disableDescription': 'Ingresa la contraseña de bloqueo actual para dejar de bloquear Netcatty. La contraseña guardada se eliminará; para volver a activar el bloqueo tendrás que crear una nueva.',
'settings.appLock.disable': 'Desactivar el bloqueo de la aplicación',
'settings.appLock.disabling': 'Desactivando...',
'settings.appLock.setupPasswordTitle': 'Crear contraseña de bloqueo',
'settings.appLock.setupPasswordDescription': 'Esta contraseña es necesaria para desbloquear Netcatty y para cambiar o desactivar el bloqueo de la aplicación más adelante.',
'settings.appLock.changePasswordTitle': 'Cambiar la contraseña de bloqueo',
'settings.appLock.changePasswordDescription': 'Ingresa la contraseña de bloqueo actual y luego elige una nueva.',
'settings.appLock.enable': 'Activar el bloqueo de la aplicación',
'settings.appLock.enableDesc': 'Bloquea al iniciar y tras el tiempo de inactividad seleccionado.',
'settings.appLock.timeout': 'Tiempo de inactividad',
'settings.appLock.timeoutDesc': 'Bloquea tras un periodo de inactividad o al cerrar la ventana en la bandeja. Nunca omite ambos casos.',
'settings.appLock.timeout.0': 'Nunca bloquear automáticamente',
'settings.appLock.timeout.1': '1 minuto',
'settings.appLock.timeout.5': '5 minutos',
'settings.appLock.timeout.15': '15 minutos',
'settings.appLock.timeout.30': '30 minutos',
'settings.appLock.timeout.60': '60 minutos',
'settings.appLock.systemUnlock.label': 'Desbloqueo con {label}',
'settings.appLock.systemUnlock.desc': 'Usa {label} como forma alternativa de desbloquear Netcatty en este dispositivo.',
'settings.appLock.systemUnlock.unavailableDesc': '{label} no está disponible en este dispositivo ahora mismo.',
'settings.appLock.systemUnlock.unavailable': 'El desbloqueo del sistema no está disponible en este dispositivo.',
'settings.appLock.systemUnlock.locked': 'Desbloquea Netcatty antes de cambiar el desbloqueo del sistema.',
'settings.appLock.systemUnlock.autoPrompt.label': 'Abrir {label} automáticamente',
'settings.appLock.systemUnlock.autoPrompt.desc': 'Muestra el aviso de {label} automáticamente cuando Netcatty está esperando para desbloquearse.',
'settings.appLock.currentPassword': 'Contraseña actual',
'settings.appLock.currentPasswordPlaceholder': 'Necesaria para desactivar o reemplazar',
'settings.appLock.currentPasswordForDisablePlaceholder': 'Contraseña actual para desactivar el bloqueo de la aplicación',
'settings.appLock.currentPasswordForChangePlaceholder': 'Contraseña actual para cambiarla',
'settings.appLock.newPassword': 'Nueva contraseña',
'settings.appLock.newPasswordPlaceholder': 'Define la contraseña de bloqueo',
'settings.appLock.confirmPassword': 'Confirmar contraseña',
'settings.appLock.confirmPasswordPlaceholder': 'Repite la contraseña',
'settings.appLock.savePassword': 'Definir y activar',
'settings.appLock.savingPassword': 'Guardando...',
'settings.appLock.passwordSet': 'Contraseña configurada',
'settings.appLock.replacePassword': 'Reemplazar contraseña',
'settings.appLock.enableAfterPassword': 'Define una contraseña de bloqueo antes de activar el bloqueo de la aplicación.',
'settings.appLock.localOnlyHint': 'Bloqueo de privacidad local del dispositivo. No se sincroniza y no es una barrera de seguridad del sistema operativo.',
'settings.appLock.validation.currentRequired': 'Ingresa la contraseña de bloqueo actual.',
'settings.appLock.validation.newRequired': 'Ingresa una nueva contraseña de bloqueo.',
'settings.appLock.validation.confirmRequired': 'Confirma la nueva contraseña de bloqueo.',
'settings.appLock.validation.mismatch': 'Las contraseñas nuevas no coinciden.',
'settings.appLock.validation.incorrect': 'La contraseña de bloqueo actual es incorrecta.',
// Settings > System > Auto Launch
'settings.autoLaunch.title': 'Abrir al iniciar sesión',
'settings.autoLaunch.enabled': 'Iniciar al arrancar el sistema',
'settings.autoLaunch.enabledDesc': 'Inicia Netcatty automáticamente al iniciar sesión, oculto en la bandeja del sistema.',
'settings.autoLaunch.unsupportedDesc': 'No disponible en esta plataforma ni en una compilación de desarrollo.',
// Settings > System > Network Proxy
'settings.system.networkProxy.title': 'Proxy de red',
'settings.system.networkProxy.description': 'Proxy HTTP(S) para la sincronización en la nube y los proveedores de IA. Esto es independiente de los perfiles de ProxyJump / ProxyCommand de SSH.',
@@ -1150,8 +1235,64 @@ Resalta el panel dividido enfocado:
'deepLink.jms.invalid': 'Enlace JumpServer inválido',
'deepLink.jms.unsupported': 'Protocolo JumpServer no compatible: {{protocol}}',
'settings.tab.plugins': 'Plugins',
'settings.plugins.title': 'Configuración de plugins',
'settings.plugins.description': 'Ajustes nativos y validados que aportan los plugins de desarrollo.',
"settings.plugins.manager.installed": "Plugins instalados",
"settings.plugins.manager.import": "Importar .ncpkg",
"settings.plugins.manager.preview": "Los paquetes importados permanecen desactivados hasta que los revises y actives.",
"settings.plugins.manager.empty": "Importa un paquete local para empezar.",
"settings.plugins.manager.enabled": "Activado",
"settings.plugins.manager.disabled": "Desactivado",
"settings.plugins.manager.enable": "Activar",
"settings.plugins.manager.disable": "Desactivar",
"settings.plugins.manager.remove": "Desinstalar",
"settings.plugins.manager.removeNote": "La desinstalación elimina el código y los datos KV locales. Se conservan los ajustes del usuario, los secretos y los datos auxiliares de sincronización.",
"settings.plugins.permissions.title": "Autorizaciones activas",
"settings.plugins.permissions.refresh": "Actualizar",
"settings.plugins.permissions.loading": "Cargando autorizaciones...",
"settings.plugins.permissions.empty": "No hay autorizaciones activas. Las decisiones de un solo uso no se conservan.",
"settings.plugins.permissions.always": "Hasta su revocación",
"settings.plugins.permissions.application": "Hasta cerrar Netcatty",
"settings.plugins.permissions.session": "Hasta cerrar la sesión",
"settings.plugins.permissions.audit": "Eventos de seguridad recientes (hasta 100)",
"settings.plugins.permissions.revoke": "Desactivar y revocar todas las autorizaciones",
"settings.plugins.permissions.revokeNote": "Desactiva el complemento, cancela su trabajo y elimina las autorizaciones guardadas. Actívelo para solicitar acceso de nuevo.",
"settings.plugins.permissions.unavailable": "La gestión de permisos no está disponible",
"settings.plugins.manager.permissions": "Permisos declarados",
"settings.plugins.manager.permissionNote": "Del manifiesto, no es una autorización. Activado permite la activación diferida; los permisos obligatorios se solicitan antes del primer uso y los opcionales cuando se necesitan. Las operaciones sensibles siguen verificándose.",
"settings.plugins.manager.noPermissions": "No se han declarado permisos privilegiados.",
"settings.plugins.manager.required": "Obligatorio",
"settings.plugins.manager.optional": "Opcional",
"settings.plugins.manager.browser": "Entorno de navegador: el acceso privilegiado pasa por los controles de permisos del anfitrión.",
"settings.plugins.manager.advanced": "Entorno avanzado: acceso completo de Node al sistema de archivos y a la red. Los permisos del intermediario no restringen este acceso. Solo para desarrollo.",
"settings.plugins.manager.data": "Los datos KV permanecen en este dispositivo. Solo los ajustes no secretos marcados para sincronización participan en los datos auxiliares cifrados. Los paquetes y las autorizaciones no se sincronizan.",
"settings.plugins.market.results": "{count} plugins",
"settings.plugins.manager.lastError": "Último error registrado",
"settings.plugins.market.offline": "El registro no está disponible. Se muestra el último catálogo válido o los ejemplos incluidos. La instalación requiere una actualización correcta.",
"settings.plugins.manager.details": "Ver detalles",
"settings.plugins.manager.noDescription": "No se proporcionó una descripción.",
"settings.plugins.market.installed": "Instalado",
"settings.plugins.market.sourceBadge": "Ejemplo de código",
"settings.plugins.market.title": "Catálogo",
"settings.plugins.market.note": "Plugins de código abierto seleccionados. La revisión no certifica su seguridad. Las actualizaciones son manuales.",
"settings.plugins.market.back": "Todos los plugins",
"settings.plugins.market.install": "Instalar esta versión (desactivada)",
"settings.plugins.market.installNote": "Esto reemplaza y detiene la versión instalada. Revisa sus permisos antes de activarla.",
"settings.plugins.market.sourceOnly": "Ejemplo propio con código fuente. Compílalo con la CLI local e importa el .ncpkg; no hay versión publicada para descargar.",
"settings.plugins.market.readmeNote": "Fijado al commit indicado. El HTML no se ejecuta y las imágenes remotas no se cargan. Contenido del autor.",
"settings.plugins.manager.noVault": "El almacén de claves del sistema no está disponible. Se bloquean secretos y credenciales; el catálogo y los plugins que no los usan siguen disponibles.",
"settings.plugins.manager.sensitive": "Acceso sensible: revisa los datos y el alcance antes de autorizar.",
"settings.plugins.manager.dual": "El navegador es la opción predeterminada. El paquete también incluye una entrada Node con acceso completo al dispositivo; solo para desarrollo.",
"settings.plugins.manager.advancedDeclaration": "Entrada de navegador con permisos avanzados declarados. No se admite en el modo normal; revisa cada permiso.",
"settings.plugins.market.retry": "Reintentar",
"settings.plugins.market.empty": "No hay entradas en el catálogo. Puedes importar un plugin local de navegador.",
"settings.plugins.market.emptyReadme": "Este README está vacío.",
"settings.plugins.market.search": "Buscar nombre, autor o descripción",
"settings.plugins.market.noResults": "No hay plugins coincidentes. Prueba otra búsqueda.",
"settings.plugins.market.manifest": "Compatibilidad y permisos",
"settings.plugins.manager.unbounded": "No se declara un alcance de recursos más limitado. Revisa el alcance real en la solicitud de permiso.",
"settings.plugins.openView": "Abrir",
"settings.plugins.openManager": "Abrir gestor de plugins",
"settings.plugins.title": "Plugins",
"settings.plugins.description": "Gestiona tus plugins y explora el catálogo.",
'settings.plugins.loading': 'Cargando ajustes del plugin…',
'settings.plugins.empty': 'Ningún plugin activado aporta ajustes.',
'settings.plugins.scopeContext': 'Abre este ajuste en su contexto de {scope}.',
@@ -33,6 +33,9 @@ export const esSystemManagerMessages: Messages = {
'systemManager.errors.loadPorts': 'No se pudieron cargar los puertos en escucha',
'systemManager.errors.loadServices': 'No se pudieron cargar los servicios de systemd',
'systemManager.errors.sshChannelUnavailable': 'El servidor se negó a abrir un nuevo canal de ejecución. Inténtalo de nuevo más tarde o vuelve a conectar este host.',
'systemManager.errors.nerdctlUnreachable': 'nerdctl no puede conectarse a containerd',
'systemManager.errors.clusterUnreachable': 'No se puede conectar al clúster para el contexto {{context}}',
'systemManager.errors.clusterUnreachableGeneric': 'No se puede conectar al clúster',
'systemManager.unsupportedRemote': 'Este host no permite canales SSH de ejecución adicionales, por lo que el administrador del sistema no está disponible. Abrir este panel ya no desconectará la terminal.',
'systemManager.overview.empty': 'Aún no hay datos de resumen del sistema.',
@@ -212,6 +215,20 @@ export const esSystemManagerMessages: Messages = {
'systemManager.k8s.restarts': '{{count}} reinicios',
'systemManager.k8s.chooseContainer': 'Elegir un contenedor',
'systemManager.k8s.noContext': 'No hay un contexto de Kubernetes en este host',
'systemManager.k8s.status.Running': 'En ejecución',
'systemManager.k8s.status.Pending': 'Pendiente',
'systemManager.k8s.status.Completed': 'Completado',
'systemManager.k8s.status.Succeeded': 'Con éxito',
'systemManager.k8s.status.Failed': 'Fallido',
'systemManager.k8s.status.Unknown': 'Desconocido',
'systemManager.k8s.status.CrashLoopBackOff': 'Bucle de fallos',
'systemManager.k8s.status.ContainerCreating': 'Creando contenedor',
'systemManager.k8s.status.Terminating': 'Terminando',
'systemManager.k8s.status.ImagePullBackOff': 'Reintentando descarga de imagen',
'systemManager.k8s.status.ErrImagePull': 'Error al descargar la imagen',
'systemManager.k8s.status.Error': 'Error',
'systemManager.k8s.status.Evicted': 'Desalojado',
'systemManager.k8s.status.OOMKilled': 'Sin memoria',
'systemManager.ports.unavailable': 'No se detectaron herramientas de puertos en escucha (ss / netstat) en este host.',
'systemManager.ports.loading': 'Cargando puertos en escucha…',
+40
View File
@@ -219,6 +219,7 @@ export const esTerminalMessages: Messages = {
'terminal.progress.timeoutIn': 'Tiempo de espera en {seconds}s',
'terminal.progress.waitingForUserInput': 'Esperando entrada del usuario',
'terminal.progress.disconnected': 'Desconectado',
'terminal.progress.connected': 'Conectado',
'terminal.progress.cancelling': 'Cancelando...',
'terminal.progress.startOver': 'Empezar de nuevo',
'terminal.progress.enterReconnectHint': 'Presiona Enter para reconectar',
@@ -675,6 +676,45 @@ export const esTerminalMessages: Messages = {
'keychain.keyDialog.editTitle': 'Editar clave',
'keychain.keyDialog.editDesc': 'Actualizar esta clave SSH',
'keychain.keyDialog.updateKey': 'Actualizar clave',
'keychain.generate.standardKeys': 'Claves estándar',
'keychain.generate.securityKeys': 'Llaves de seguridad · FIDO2',
'keychain.generate.residentDesc': 'Permite recuperar las credenciales desde esta llave de seguridad.',
'keychain.generate.verifyRequiredDesc': 'Solicita el PIN del dispositivo antes de firmar.',
'keychain.generate.generating': 'Generando…',
'keychain.generate.fidoHint': 'Conecta una llave de seguridad FIDO2. Es posible que se te solicite su PIN o un toque.',
'keychain.generate.resident': 'Guardar las credenciales en la llave',
'keychain.generate.verifyRequired': 'Verificar el PIN cada vez',
'keychain.action.loadResident': 'Cargar claves residentes desde la llave de seguridad',
// FIDO2 security key prompts and recovery states
'fido.prompt.pinTouchDesc': 'Ingresa tu PIN y luego toca la llave para confirmar.',
'fido.prompt.details': 'Detalles de la solicitud',
'fido.prompt.showPin': 'Mostrar PIN',
'fido.prompt.hidePin': 'Ocultar PIN',
'fido.prompt.verifying': 'Verificando…',
'fido.prompt.pinTitle': 'PIN de la llave de seguridad',
'fido.prompt.touchTitle': 'Toca tu llave de seguridad',
'fido.prompt.pinDesc': 'Ingresa el PIN de tu llave de seguridad para continuar.',
'fido.prompt.touchDesc': 'Toca tu llave de seguridad para confirmar tu identidad.',
'fido.prompt.pinLabel': 'PIN',
'fido.prompt.touchWaiting': 'Esperando tu llave…',
'fido.prompt.touchAutomatic': 'Continúa automáticamente después de que la toques',
'fido.prompt.cancelled': 'Se canceló la autenticación con llave de seguridad.',
'fido.prompt.touchDone': 'Ya la toqué',
'fido.prompt.timeout': 'El aviso de FIDO agotó el tiempo de espera. Intenta conectarte de nuevo.',
'fido.recovery.device-unavailable.title': 'Conecta la llave de seguridad correspondiente',
'fido.recovery.device-unavailable.desc': 'Conecta el dispositivo que contiene la credencial de esta llave y vuelve a intentarlo.',
'fido.recovery.timeout.title': 'Se agotó el tiempo de confirmación de la llave de seguridad',
'fido.recovery.timeout.desc': 'La operación del dispositivo se detuvo. Comprueba que tu llave esté conectada, vuelve a intentarlo y sigue el aviso de toque o verificación.',
'fido.recovery.pin-failed.title': 'No se aceptó el PIN de la llave de seguridad',
'fido.recovery.pin-failed.desc': 'Verifica el PIN FIDO2 de este dispositivo antes de reintentar. Varios intentos incorrectos pueden bloquear el dispositivo.',
'fido.recovery.failed.title': 'No se completó la verificación de la llave de seguridad',
'fido.recovery.failed.desc': 'Comprueba que el dispositivo correspondiente esté conectado y admita esta llave. Reintenta o cancela para revisar el dispositivo y la configuración de OpenSSH.',
'fido.recovery.retry': 'Reintentar verificación',
'fido.recovery.usePassword': 'Usar la contraseña configurada',
'fido.recovery.passwordFallback': 'Probando la contraseña configurada para este host',
'fido.error.opensshMissing': 'Se requiere OpenSSH con FIDO/libfido2. En macOS: brew install openssh libfido2.',
'fido.error.agentUnavailable': 'No se pudo iniciar un agente SSH compatible con FIDO.',
// Tabs
'tabs.closeSessionAria': 'Cerrar sesión',
+12
View File
@@ -1044,6 +1044,18 @@ export const esVaultMessages: Messages = {
'hostDetails.addTag': 'Agregar una etiqueta...',
'hostDetails.createTag': 'Crear etiqueta',
'hostDetails.createGroup': 'Crear grupo',
// Host connection test
'hostConnectionTest.title': 'Probar conexión',
'hostConnectionTest.test': 'Probar',
'hostConnectionTest.chainOf': 'Cadena {current} de {total}',
'hostConnectionTest.success': 'Conexión correcta',
'hostConnectionTest.successHint': 'El host aceptó tus credenciales. Puedes cerrar esta ventana.',
'hostConnectionTest.connecting': 'Conectando… tiempo de espera en {seconds}s',
'hostConnectionTest.failed': 'No se pudo conectar.',
'hostConnectionTest.retry': 'Reintentar',
'hostConnectionTest.credentialsUnavailable': 'Las credenciales guardadas no se pueden descifrar en este dispositivo. Vuelve a ingresarlas abajo.',
// Entradas de shell local (CMD) en la bóveda
'localShell.subtitle': 'Shell local',
'localShell.panel.title': 'Nueva shell local',
@@ -0,0 +1,120 @@
import assert from "node:assert/strict";
import test from "node:test";
import { DEFAULT_KEY_BINDINGS } from "../../../domain/models/keyBindings.ts";
import en from "./en.ts";
import es from "./es.ts";
import ru from "./ru.ts";
import zhCN from "./zh-CN.ts";
import zhTW from "./zh-TW.ts";
// Locales that are not at full key parity with `en` yet. They are tracked here
// so the remaining debt stays visible instead of silently rendering in English
// through the `DEFAULT_UI_LOCALE` fallback in `I18nProvider.resolveMessage`.
// Move a locale out of this list once it covers every `en` key.
const LOCALES_PENDING_PARITY = ["ru", "zh-CN", "zh-TW"] as const;
const hasKey = (messages: Record<string, string>, key: string) =>
Object.hasOwn(messages, key);
const interpolationTokens = (value: string) =>
[...value.matchAll(/\{\{\s*\w+\s*\}\}|\{\s*\w+\s*\}/g)]
.map((match) => match[0].replace(/[{}]/g, "").trim())
.sort();
test("es defines every message key that en defines", () => {
const missing = Object.keys(en).filter((key) => !hasKey(es, key));
assert.deepEqual(
missing,
[],
`es is missing ${missing.length} message keys`,
);
});
test("es defines no message keys that en does not define", () => {
const extra = Object.keys(es).filter((key) => !hasKey(en, key));
assert.deepEqual(
extra,
[],
`es defines ${extra.length} keys that no longer exist in en`,
);
});
test("es keeps the same interpolation placeholders as en", () => {
const mismatched: string[] = [];
for (const key of Object.keys(es)) {
if (!hasKey(en, key)) continue;
const expected = interpolationTokens(en[key] ?? "").join(",");
const actual = interpolationTokens(es[key] ?? "").join(",");
if (expected !== actual) mismatched.push(key);
}
assert.deepEqual(
mismatched,
[],
`es changes interpolation placeholders for ${mismatched.length} keys`,
);
});
test("every default shortcut has a label in en and es", () => {
const locales = [
{ name: "en", messages: en },
{ name: "es", messages: es },
];
for (const locale of locales) {
const missing = DEFAULT_KEY_BINDINGS.map(
(binding) => `settings.shortcuts.binding.${binding.id}`,
).filter((key) => !hasKey(locale.messages, key));
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} shortcut labels`,
);
}
});
test("en shortcut labels stay identical to DEFAULT_KEY_BINDINGS", () => {
// SettingsShortcutsTab falls back to `binding.label` only while the locale
// resolves the key to itself. Once `en` defines the key, the locale becomes
// the rendered source, so the two must not drift apart.
for (const binding of DEFAULT_KEY_BINDINGS) {
const key = `settings.shortcuts.binding.${binding.id}`;
assert.equal(
en[key],
binding.label,
`en label drifted from DEFAULT_KEY_BINDINGS for ${binding.id}`,
);
}
});
test("every locale outside the pending list is at full parity with en", () => {
const locales = [
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
for (const locale of locales) {
if ((LOCALES_PENDING_PARITY as readonly string[]).includes(locale.name)) {
continue;
}
const missing = Object.keys(en).filter(
(key) => !hasKey(locale.messages, key),
);
assert.deepEqual(
missing,
[],
`${locale.name} is missing ${missing.length} message keys`,
);
}
});
+58 -2
View File
@@ -1130,8 +1130,64 @@ export const ruCoreMessages: Messages = {
'deepLink.jms.unsupported': 'Неподдерживаемый протокол JumpServer: {{protocol}}',
'settings.tab.plugins': 'Плагины',
'settings.plugins.title': 'Настройки плагинов',
'settings.plugins.description': 'Проверенные нативные настройки от плагинов разработки.',
"settings.plugins.manager.installed": "Установленные плагины",
"settings.plugins.manager.import": "Импорт .ncpkg",
"settings.plugins.manager.preview": "Импортированные пакеты отключены до проверки и включения.",
"settings.plugins.manager.empty": "Импортируйте локальный пакет плагина.",
"settings.plugins.manager.enabled": "Включён",
"settings.plugins.manager.disabled": "Отключён",
"settings.plugins.manager.enable": "Включить",
"settings.plugins.manager.disable": "Отключить",
"settings.plugins.manager.remove": "Удалить",
"settings.plugins.manager.removeNote": "Удаление очищает код пакета и локальные данные KV. Настройки пользователя, секреты и данные синхронизации сохраняются.",
"settings.plugins.permissions.title": "Действующие разрешения",
"settings.plugins.permissions.refresh": "Обновить",
"settings.plugins.permissions.loading": "Загрузка разрешений...",
"settings.plugins.permissions.empty": "Действующих разрешений нет. Одноразовые решения не сохраняются.",
"settings.plugins.permissions.always": "До отзыва",
"settings.plugins.permissions.application": "До выхода из Netcatty",
"settings.plugins.permissions.session": "До закрытия сеанса",
"settings.plugins.permissions.audit": "Последние события безопасности (до 100)",
"settings.plugins.permissions.revoke": "Отключить и отозвать все разрешения",
"settings.plugins.permissions.revokeNote": "Плагин будет отключён, его задачи отменены, а сохранённые разрешения удалены. Включите его для нового запроса доступа.",
"settings.plugins.permissions.unavailable": "Управление разрешениями недоступно",
"settings.plugins.manager.permissions": "Заявленные разрешения",
"settings.plugins.manager.permissionNote": "Из манифеста, а не предоставленные права. Включение допускает отложенную активацию; обязательные разрешения запрашиваются перед ней, необязательные — при использовании. Чувствительные операции проверяются приложением.",
"settings.plugins.manager.noPermissions": "Привилегированные разрешения не заявлены.",
"settings.plugins.manager.required": "Обязательно",
"settings.plugins.manager.optional": "Необязательно",
"settings.plugins.manager.browser": "Браузерная среда: привилегированный доступ проходит через проверку разрешений приложения.",
"settings.plugins.manager.advanced": "Расширенная среда: полный доступ Node к файловой системе и сети. Разрешения посредника не ограничивают этот доступ. Только для разработки.",
"settings.plugins.manager.data": "Данные KV остаются на устройстве. Синхронизируются только явно отмеченные несекретные настройки в зашифрованных данных. Пакеты и разрешения не синхронизируются.",
"settings.plugins.market.results": "Плагинов: {count}",
"settings.plugins.manager.lastError": "Последняя записанная ошибка",
"settings.plugins.market.offline": "Реестр недоступен. Показан последний корректный каталог или встроенные примеры. Для установки необходимо успешно обновить каталог.",
"settings.plugins.manager.details": "Подробнее",
"settings.plugins.manager.noDescription": "Описание не предоставлено.",
"settings.plugins.market.installed": "Установлен",
"settings.plugins.market.sourceBadge": "Пример исходного кода",
"settings.plugins.market.title": "Каталог",
"settings.plugins.market.note": "Отобранные плагины с открытым кодом. Проверка не гарантирует безопасность. Обновления выполняются вручную.",
"settings.plugins.market.back": "Все плагины",
"settings.plugins.market.install": "Установить эту версию (отключённой)",
"settings.plugins.market.installNote": "Установленная версия будет заменена и остановлена. Проверьте разрешения перед включением.",
"settings.plugins.market.sourceOnly": "Пример из этого проекта. Соберите локальной CLI и импортируйте .ncpkg; готовый пакет для скачивания не опубликован.",
"settings.plugins.market.readmeNote": "Зафиксирован указанный коммит. HTML не выполняется, внешние изображения не загружаются. Текст автора плагина.",
"settings.plugins.manager.noVault": "Системное хранилище ключей недоступно. Операции с секретами и учётными данными заблокированы; каталог и плагины без секретов доступны.",
"settings.plugins.manager.sensitive": "Чувствительный доступ: проверьте данные и область ресурсов перед разрешением.",
"settings.plugins.manager.dual": "По умолчанию используется браузерная среда. Пакет также содержит вход Node с полным доступом к устройству; только для разработчиков.",
"settings.plugins.manager.advancedDeclaration": "Браузерная точка входа с заявленными расширенными разрешениями. Не допускается в обычном режиме; проверьте все разрешения.",
"settings.plugins.market.retry": "Повторить",
"settings.plugins.market.empty": "Каталог пуст. Можно импортировать локальный браузерный плагин.",
"settings.plugins.market.emptyReadme": "Этот README пуст.",
"settings.plugins.market.search": "Поиск по имени, автору или описанию",
"settings.plugins.market.noResults": "Совпадений нет. Измените запрос.",
"settings.plugins.market.manifest": "Совместимость и разрешения",
"settings.plugins.manager.unbounded": "Более узкая область ресурсов не заявлена. Проверьте фактическую область в запросе разрешения.",
"settings.plugins.openView": "Открыть",
"settings.plugins.openManager": "Открыть управление плагинами",
"settings.plugins.title": "Плагины",
"settings.plugins.description": "Управляйте плагинами и просматривайте каталог.",
'settings.plugins.loading': 'Загрузка настроек плагинов…',
'settings.plugins.empty': 'Включённые плагины не предоставляют настройки.',
'settings.plugins.scopeContext': 'Откройте эту настройку в контексте {scope}.',
@@ -18,12 +18,19 @@ const LOCALIZED_SETTINGS_LOCALES = [
const APP_LOCK_LOCALES = [
{ name: "en", messages: en },
{ name: "zh-CN", messages: zhCN },
{ name: "es", messages: es },
{ name: "ru", messages: ru },
{ name: "zh-CN", messages: zhCN },
{ name: "zh-TW", messages: zhTW },
];
const ALL_SETTINGS_LOCALES = [
{ name: "en", messages: en },
...LOCALIZED_SETTINGS_LOCALES,
];
test("localized settings include names for every default shortcut", () => {
for (const locale of LOCALIZED_SETTINGS_LOCALES) {
for (const locale of ALL_SETTINGS_LOCALES) {
const missing = DEFAULT_KEY_BINDINGS
.map((binding) => `settings.shortcuts.binding.${binding.id}`)
.filter((key) => !locale.messages[key]);
+58 -2
View File
@@ -1150,8 +1150,64 @@ Vaults 标签保持扁平,不使用这块背景。
'qs.localShells': '本地 Shell',
'qs.default': '默认',
'settings.tab.plugins': '插件',
'settings.plugins.title': '插件设置',
'settings.plugins.description': '由开发插件提供、经 Netcatty 原生组件验证的设置。',
"settings.plugins.manager.installed": "已安装插件",
"settings.plugins.manager.import": "导入 .ncpkg",
"settings.plugins.manager.preview": "导入的插件保持停用,请检查后再启用。",
"settings.plugins.manager.empty": "导入本地插件包以开始使用。",
"settings.plugins.manager.enabled": "已启用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "启用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸载",
"settings.plugins.manager.removeNote": "卸载会移除插件代码和本地 KV 数据,保留用户设置、秘密存储和同步 sidecar。",
"settings.plugins.permissions.title": "实际授权",
"settings.plugins.permissions.refresh": "刷新",
"settings.plugins.permissions.loading": "正在读取授权...",
"settings.plugins.permissions.empty": "没有有效授权。单次决定不会保留。",
"settings.plugins.permissions.always": "直到撤销",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到会话关闭",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 条)",
"settings.plugins.permissions.revoke": "停用并撤销全部授权",
"settings.plugins.permissions.revokeNote": "此操作会停用插件、取消其工作并删除已保存的授权。重新启用后可申请新授权。",
"settings.plugins.permissions.unavailable": "插件授权管理不可用",
"settings.plugins.manager.permissions": "声明的权限",
"settings.plugins.manager.permissionNote": "来自 manifest,不代表已授权。启用表示允许按需激活;必需权限在首次激活前申请,可选权限在用到时申请。敏感操作仍受宿主检查。",
"settings.plugins.manager.noPermissions": "未声明特权权限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可选",
"settings.plugins.manager.browser": "浏览器运行时:特权访问通过宿主权限代理。",
"settings.plugins.manager.advanced": "高级运行时:拥有完整 Node 文件系统和网络能力,代理权限无法限制这些访问。仅供开发预览。",
"settings.plugins.manager.data": "本地 KV 仅保留在此设备。只有明确标记为同步的非秘密设置进入加密云 sidecar。插件包和权限授权不参与同步。",
"settings.plugins.market.results": "{count} 个插件",
"settings.plugins.manager.lastError": "上次记录的错误",
"settings.plugins.market.offline": "官方目录暂时不可用,正在显示上次有效目录或内置示例。刷新成功前不能从市场安装。",
"settings.plugins.manager.details": "查看详情",
"settings.plugins.manager.noDescription": "作者暂未提供功能简介。",
"settings.plugins.market.installed": "已安装",
"settings.plugins.market.sourceBadge": "源码示例",
"settings.plugins.market.title": "插件市场",
"settings.plugins.market.note": "精选开源插件。审核不代表安全认证,更新由你手动确认。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安装此版本(保持停用)",
"settings.plugins.market.installNote": "这会替换并停止已安装版本。请在已安装列表检查权限后再启用。",
"settings.plugins.market.sourceOnly": "第一方源码示例。请用本地 CLI 构建并导入 .ncpkg;尚未发布可下载发行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不执行,远程图片不加载。内容由插件作者提供。",
"settings.plugins.manager.noVault": "系统密钥存储不可用。秘密和凭据操作被阻止;浏览市场和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感访问:授权前请检查涉及的数据和资源范围。",
"settings.plugins.manager.dual": "默认使用浏览器运行时,但包中还包含拥有完整设备权限的 Node 入口;仅限开发者模式。",
"settings.plugins.manager.advancedDeclaration": "浏览器入口声明了高级权限。不适用于普通浏览器插件模式;请逐项检查声明权限。",
"settings.plugins.market.retry": "重试",
"settings.plugins.market.empty": "目录暂无插件,仍可导入本地浏览器插件。",
"settings.plugins.market.emptyReadme": "README 内容为空。",
"settings.plugins.market.search": "搜索名称、作者或描述",
"settings.plugins.market.noResults": "没有匹配插件,请尝试其他关键词。",
"settings.plugins.market.manifest": "兼容性与权限",
"settings.plugins.manager.unbounded": "未声明更细的资源范围。请在权限提示中检查实际授权范围。",
"settings.plugins.openView": "打开",
"settings.plugins.openManager": "打开插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安装插件,浏览精选目录。",
'settings.plugins.loading': '正在加载插件设置…',
'settings.plugins.empty': '当前没有已启用插件提供设置。',
'settings.plugins.scopeContext': '请在对应的 {scope} 上下文中打开此设置。',
+58 -2
View File
@@ -1153,9 +1153,65 @@ Vaults 分頁維持扁平,不使用這塊背景。
'qs.default': '預設',
'settings.tab.plugins': '外掛程式',
'settings.plugins.title': '外掛程式設定',
'settings.plugins.description': '由開發外掛程式提供、經 Netcatty 原生元件驗證的設定。',
"settings.plugins.manager.installed": "已安裝插件",
"settings.plugins.manager.import": "導入 .ncpkg",
"settings.plugins.manager.preview": "導入的插件保持停用,請檢查後再啟用。",
"settings.plugins.manager.empty": "導入本地插件包以開始使用。",
"settings.plugins.manager.enabled": "已啟用",
"settings.plugins.manager.disabled": "已停用",
"settings.plugins.manager.enable": "啟用",
"settings.plugins.manager.disable": "停用",
"settings.plugins.manager.remove": "卸載",
"settings.plugins.manager.removeNote": "卸載會移除插件代碼和本地 KV 數據,保留用戶設置、秘密存儲和同步 sidecar。",
"settings.plugins.permissions.title": "實際授權",
"settings.plugins.permissions.refresh": "重新整理",
"settings.plugins.permissions.loading": "正在讀取授權...",
"settings.plugins.permissions.empty": "沒有有效授權。單次決定不會保留。",
"settings.plugins.permissions.always": "直到撤銷",
"settings.plugins.permissions.application": "直到退出 Netcatty",
"settings.plugins.permissions.session": "直到工作階段關閉",
"settings.plugins.permissions.audit": "最近的安全事件(最多 100 筆)",
"settings.plugins.permissions.revoke": "停用並撤銷全部授權",
"settings.plugins.permissions.revokeNote": "此操作會停用外掛、取消其工作並刪除已儲存的授權。重新啟用後可申請新授權。",
"settings.plugins.permissions.unavailable": "外掛授權管理無法使用",
"settings.plugins.manager.permissions": "聲明的權限",
"settings.plugins.manager.permissionNote": "來自 manifest,不代表已授權。啟用表示允許按需激活;必需權限在首次激活前申請,可選權限在用到時申請。敏感操作仍受宿主檢查。",
"settings.plugins.manager.noPermissions": "未聲明特權權限。",
"settings.plugins.manager.required": "必需",
"settings.plugins.manager.optional": "可選",
"settings.plugins.manager.browser": "瀏覽器運行時:特權訪問通過宿主權限代理。",
"settings.plugins.manager.advanced": "高級運行時:擁有完整 Node 文件系統和網絡能力,代理權限無法限制這些訪問。僅供開發預覽。",
"settings.plugins.manager.data": "本地 KV 僅保留在此設備。只有明確標記為同步的非秘密設置進入加密雲 sidecar。插件包和權限授權不參與同步。",
"settings.plugins.manager.unbounded": "未宣告更細的資源範圍。請在權限提示中檢查實際授權範圍。",
"settings.plugins.openView": "開啟",
"settings.plugins.openManager": "開啟插件管理",
"settings.plugins.title": "插件",
"settings.plugins.description": "管理已安裝插件,瀏覽精選目錄。",
'settings.plugins.loading': '正在載入外掛程式設定…',
"settings.plugins.market.results": "{count} 個插件",
"settings.plugins.manager.lastError": "上次記錄的錯誤",
"settings.plugins.market.offline": "官方目錄暫時無法使用,正在顯示上次有效目錄或內建範例。重新整理成功前無法從市場安裝。",
"settings.plugins.manager.details": "查看詳情",
"settings.plugins.manager.noDescription": "作者尚未提供功能簡介。",
"settings.plugins.market.installed": "已安裝",
"settings.plugins.market.sourceBadge": "原始碼範例",
"settings.plugins.market.title": "插件市場",
"settings.plugins.market.note": "精選開源插件。審核不代表安全認證,更新由你手動確認。",
"settings.plugins.market.back": "所有插件",
"settings.plugins.market.install": "安裝此版本(保持停用)",
"settings.plugins.market.installNote": "這會替換並停止已安裝版本。請在已安裝列表檢查權限後再啟用。",
"settings.plugins.market.sourceOnly": "第一方源碼示例。請用本地 CLI 構建並導入 .ncpkg;尚未發佈可下載發行包。",
"settings.plugins.market.readmeNote": "固定到上面的提交。HTML 不執行,遠程圖片不加載。內容由插件作者提供。",
"settings.plugins.manager.noVault": "系統密鑰存儲不可用。秘密和憑據操作被阻止;瀏覽市場和不使用秘密的插件仍可使用。",
"settings.plugins.manager.sensitive": "敏感訪問:授權前請檢查涉及的數據和資源範圍。",
"settings.plugins.manager.dual": "默認使用瀏覽器運行時,但包中還包含擁有完整設備權限的 Node 入口;僅限開發者模式。",
"settings.plugins.manager.advancedDeclaration": "瀏覽器入口聲明瞭高級權限。不適用於普通瀏覽器插件模式;請逐項檢查聲明權限。",
"settings.plugins.market.retry": "重試",
"settings.plugins.market.empty": "目錄暫無插件,仍可導入本地瀏覽器插件。",
"settings.plugins.market.emptyReadme": "README 內容為空。",
"settings.plugins.market.search": "搜索名稱、作者或描述",
"settings.plugins.market.noResults": "沒有匹配插件,請嘗試其他關鍵詞。",
"settings.plugins.market.manifest": "兼容性與權限",
'settings.plugins.empty': '目前沒有已啟用外掛程式提供設定。',
'settings.plugins.scopeContext': '請在對應的 {scope} 情境中開啟此設定。',
'settings.plugins.pressKeybinding': '請按下組合鍵',
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type Raster = { light: string; dark?: string };
export function usePluginBrandIcon(plugin?: NetcattyInstalledPlugin): Raster | null {
const pluginId = plugin?.id;
const kind = plugin?.manifest?.icon?.kind;
const key = JSON.stringify([pluginId, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, plugin?.manifest?.icon]);
const [loaded, setLoaded] = useState<{ key: string; value: Raster | null } | null>(null);
useEffect(() => {
if (!pluginId || kind !== 'package') return;
let cancelled = false;
void netcattyBridge.get()?.getPluginBrandIcon?.(pluginId).then((value) => {
if (!cancelled) setLoaded({ key, value });
}).catch(() => { if (!cancelled) setLoaded({ key, value: null }); });
return () => { cancelled = true; };
}, [key, pluginId, kind]);
return loaded?.key === key ? loaded.value : null;
}
@@ -0,0 +1,84 @@
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 { usePluginCatalog } from './usePluginCatalog';
function deferred<T>() {
let resolve!: (value: T) => void;
let reject!: (cause: Error) => void;
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; });
return { promise, resolve, reject };
}
test('README failures clear on retry/switch and stale requests cannot replace the selected entry', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const calls: Array<{ id: string; request: ReturnType<typeof deferred<string>> }> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
netcatty: {
listPluginCatalog: async () => ({ entries: [], source: "builtin", error: null }),
getPluginCatalogReadme: (id: string) => { const request = deferred<string>(); calls.push({ id, request }); return request.promise; },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
let renders = 0;
function Probe({ id }: { id: string }) { state = usePluginCatalog(id); renders++; return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe, { id: 'first' })); });
assert.equal(state.readmeStatus, 'loading');
await act(async () => calls[0].request.reject(new Error('temporary failure')));
assert.equal(state.readmeStatus, 'error');
assert.match(state.readmeError!, /temporary failure/);
await act(async () => state.retryReadme());
assert.equal(state.readmeStatus, 'loading'); assert.equal(state.readmeError, null);
await act(async () => calls[1].request.resolve(''));
assert.equal(state.readmeStatus, 'ready'); assert.equal(state.readme, ''); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.update(React.createElement(Probe, { id: 'second' })));
assert.equal(state.readmeError, null);
await act(async () => calls[3].request.resolve('# Second'));
await act(async () => calls[2].request.reject(new Error('stale failure')));
assert.equal(state.readme, '# Second'); assert.equal(state.readmeError, null);
await act(async () => state.retryReadme());
await act(async () => renderer.unmount());
const unmountedRenders = renders;
await act(async () => calls[4].request.resolve('late content'));
assert.equal(renders, unmountedRenders);
});
test('offline snapshot stays browsable, retry clears warning, and operations bind the selected token', async (context) => {
const original = Object.getOwnPropertyDescriptor(globalThis, 'window');
const entry = { id: 'com.test.registry', selectionToken: 'first' } as NetcattyPluginCatalogEntry;
let offline = true;
const reads: unknown[][] = []; const installs: unknown[][] = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: {
dispatchEvent() {}, netcatty: {
listPluginCatalog: async () => ({ entries: [{ ...entry, selectionToken: offline ? 'first' : 'second' }], source: offline ? 'cached' : 'registry', error: offline ? 'offline' : null }),
getPluginCatalogReadme: async (...args: unknown[]) => { reads.push(args); return '# Pinned'; },
installCatalogPlugin: async (...args: unknown[]) => { installs.push(args); },
},
} });
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
context.after(() => {
if (original) Object.defineProperty(globalThis, 'window', original); else Reflect.deleteProperty(globalThis, 'window');
Reflect.deleteProperty(globalThis, 'IS_REACT_ACT_ENVIRONMENT');
});
let state!: ReturnType<typeof usePluginCatalog>;
function Probe() { state = usePluginCatalog(entry.id); return null; }
let renderer!: ReactTestRenderer;
await act(async () => { renderer = create(React.createElement(Probe)); });
assert.equal(state.entries.length, 1); assert.equal(state.listStatus, 'ready'); assert.equal(state.listError, 'offline');
assert.equal(state.source, 'cached');
offline = false;
await act(async () => state.retryList());
assert.equal(state.listError, null); assert.equal(state.source, 'registry');
assert.deepEqual(reads.at(-1), [entry.id, 'second']);
await act(async () => state.install(entry.id));
assert.deepEqual(installs, [[entry.id, 'second']]);
await act(async () => renderer.unmount());
});
+70
View File
@@ -0,0 +1,70 @@
import { useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
type ReadmeState = { id: string; token?: string; attempt: number; status: 'loading' | 'ready' | 'error'; text: string; error: string | null };
export function usePluginCatalog(selectedId: string | null) {
const [entries, setEntries] = useState<NetcattyPluginCatalogEntry[]>([]);
const [listStatus, setListStatus] = useState<'loading' | 'ready' | 'error'>('loading');
const [source, setSource] = useState<NetcattyPluginCatalogSnapshot['source']>('builtin');
const [listError, setListError] = useState<string | null>(null);
const [listAttempt, setListAttempt] = useState(0);
const [readmeState, setReadmeState] = useState<ReadmeState | null>(null);
const [readmeAttempt, setReadmeAttempt] = useState(0);
const [installError, setInstallError] = useState<{ id: string; message: string } | null>(null);
const [busy, setBusy] = useState(false);
const installing = useRef(false);
const mounted = useRef(false);
useEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
useEffect(() => {
let active = true;
setListStatus('loading'); setListError(null);
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.listPluginCatalog) throw new Error('Catalog browsing unavailable');
const list = await bridge.listPluginCatalog();
if (active) { setEntries(list.entries); setSource(list.source); setListError(list.error); setListStatus('ready'); }
} catch (cause) { if (active) { setListStatus('error'); setListError(String(cause)); } }
})();
return () => { active = false; };
}, [listAttempt]);
const selectionToken = entries.find((entry) => entry.id === selectedId)?.selectionToken;
useEffect(() => {
if (!selectedId) return;
let active = true;
const base = { id: selectedId, token: selectionToken, attempt: readmeAttempt, text: '', error: null };
setReadmeState({ ...base, status: 'loading' });
void (async () => {
try {
const bridge = netcattyBridge.get();
if (!bridge?.getPluginCatalogReadme) throw new Error('README retrieval unavailable');
const text = await bridge.getPluginCatalogReadme(selectedId, selectionToken);
if (active) setReadmeState({ ...base, status: 'ready', text });
} catch (cause) { if (active) setReadmeState({ ...base, status: 'error', error: String(cause) }); }
})();
return () => { active = false; };
}, [selectedId, selectionToken, readmeAttempt]);
const currentReadme = readmeState?.id === selectedId && readmeState.token === selectionToken && readmeState.attempt === readmeAttempt ? readmeState : null;
return {
entries, listStatus, listError, source,
readme: currentReadme?.status === 'ready' ? currentReadme.text : null,
readmeStatus: currentReadme?.status ?? (selectedId ? 'loading' : 'idle'),
readmeError: currentReadme?.error ?? null,
error: installError?.id === selectedId ? installError.message : null,
busy,
retryList: () => setListAttempt((attempt) => attempt + 1),
retryReadme: () => setReadmeAttempt((attempt) => attempt + 1),
async install(id: string) {
if (installing.current) return;
installing.current = true;
setBusy(true); setInstallError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.installCatalogPlugin) throw new Error('Catalog installation unavailable');
await bridge.installCatalogPlugin(id, entries.find((entry) => entry.id === id)?.selectionToken);
window.dispatchEvent(new Event('netcatty:plugin-packages-changed'));
} catch (cause) { if (mounted.current) setInstallError({ id, message: String(cause) }); }
finally { installing.current = false; if (mounted.current) setBusy(false); }
},
};
}
+59
View File
@@ -0,0 +1,59 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../infrastructure/services/netcattyBridge';
export function usePluginManager() {
const [plugins, setPlugins] = useState<NetcattyInstalledPlugin[]>([]);
const [available, setAvailable] = useState(false);
const [credentialStorageAvailable, setCredentialStorageAvailable] = useState<boolean | undefined>();
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const generation = useRef(0);
const mutation = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
const bridge = netcattyBridge.get();
const status = await bridge?.getPluginRuntimeStatus?.();
const list = status?.available ? await bridge?.listPlugins?.() ?? [] : [];
if (current !== generation.current) return;
setAvailable(status?.available === true);
setCredentialStorageAvailable(status?.credentialStorageAvailable);
setPlugins(list);
}, []);
useEffect(() => {
const update = () => { void refresh().catch((cause) => setError(String(cause))); };
update();
const unsubscribe = netcattyBridge.get()?.onPluginContributionsChanged?.(update);
window.addEventListener('netcatty:plugin-packages-changed', update);
const refreshGeneration = generation;
return () => { refreshGeneration.current++; unsubscribe?.(); window.removeEventListener('netcatty:plugin-packages-changed', update); };
}, [refresh]);
const run = useCallback(async (action: (bridge: NetcattyBridge) => Promise<unknown>) => {
if (mutation.current) return;
mutation.current = true;
setBusy(true);
setError(null);
try { await action(netcattyBridge.require()); }
catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally {
try { await refresh(); } catch (cause) { setError(String(cause)); }
mutation.current = false;
setBusy(false);
}
}, [refresh]);
return {
plugins, available, busy, error, credentialStorageAvailable, refresh,
importPackage: () => run(async (bridge) => {
if (!bridge.selectFile || !bridge.installPluginPackage) throw new Error('Plugin import unavailable');
const path = await bridge.selectFile('Import plugin package', undefined, [{ name: 'Netcatty plugin', extensions: ['ncpkg'] }]);
if (path) await bridge.installPluginPackage(path, { enable: false });
}),
setEnabled: (id: string, enabled: boolean) => run(async (bridge) => {
if (!bridge.setPluginEnabled) throw new Error('Plugin management unavailable');
return bridge.setPluginEnabled(id, enabled);
}),
uninstall: (id: string) => run(async (bridge) => {
if (!bridge.uninstallPlugin) throw new Error('Plugin management unavailable');
return bridge.uninstallPlugin(id);
}),
};
}
@@ -151,3 +151,24 @@ test('plugin palette items keep repeated placements independently addressable',
commandId: 'com.example.palette.run',
}]);
});
test('packaged Starter contributions are discoverable through the real palette builder', async (context) => {
const { createRequire } = await import('node:module');
const { mkdtempSync, rmSync } = await import('node:fs');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const require = createRequire(import.meta.url);
const { PluginDatabase } = require('../electron/plugins/database.cjs');
const { PluginContributionService } = require('../electron/plugins/contributionService.cjs');
const manifest = JSON.parse(readFileSync(new URL('../examples/plugins/starter-netcatty/netcatty.plugin.json', import.meta.url), 'utf8'));
const root = mkdtempSync(join(tmpdir(), 'starter-palette-'));
const database = new PluginDatabase(join(root, 'plugins.sqlite'));
context.after(() => { database.close(); rmSync(root, { recursive: true, force: true }); });
database.installVersion({ pluginId: manifest.id, version: manifest.version, manifest, archiveSha256: 'a'.repeat(64), packageRelativePath: 'starter' }, { enable: true });
const service = new PluginContributionService({ database, runtimeSupervisor: {}, secretStore: {}, getLocale: () => 'en' });
const items = buildPluginPaletteItems(service.snapshot().plugins, 'Starter');
assert.ok(items.some((item) => item.type === 'plugin-command' && item.commandId === 'com.netcatty.starter.greet'));
assert.ok(items.some((item) => item.type === 'plugin-view' && item.id === 'com.netcatty.starter.panel'));
database.setEnabled(manifest.id, false);
assert.equal(buildPluginPaletteItems(service.snapshot().plugins, 'Starter').length, 0);
});
+2 -1
View File
@@ -523,7 +523,8 @@ const SettingsPageContent: React.FC<{ settings: SettingsState; appLock?: AppLock
</TabsList>
</div>
<div className="flex-1 h-full flex flex-col min-h-0 bg-muted/10">
{/* min-w-0: row flex item must shrink so long Agent paths truncate inside the pane. */}
<div className="flex-1 h-full flex flex-col min-h-0 min-w-0 bg-muted/10">
{mountedTabs.has("application") && (
<SettingsLazyTab value="application">
<LazySettingsApplicationTab
+26
View File
@@ -0,0 +1,26 @@
import React, { useState } from 'react';
import { Package } from 'lucide-react';
import { usePluginBrandIcon } from '../../application/state/usePluginBrandIcon';
import { PluginContributionIcon } from './PluginContributionIcon';
import { resolveCatalogBrandIcon } from './catalogBrandIcons';
export function PluginBrandIcon({ plugin, entry, size = 32, className = '' }: {
plugin?: NetcattyInstalledPlugin;
entry?: NetcattyPluginCatalogEntry;
size?: number;
className?: string;
}) {
const manifest = plugin?.manifest ?? entry?.manifest;
const icon = manifest?.icon;
const key = JSON.stringify([plugin?.id, plugin?.activeVersion, plugin?.archiveSha256, plugin?.runtime?.quarantinedAt, icon]);
const loaded = usePluginBrandIcon(plugin);
const [failed, setFailed] = useState<string | null>(null);
const raster = plugin ? loaded : entry ? resolveCatalogBrandIcon(entry) : null;
const imageKey = JSON.stringify([key, raster]);
if (icon?.kind === 'theme') return <PluginContributionIcon icon={icon} size={size} className={className} />;
if (!raster || failed === imageKey) return <Package size={size} className={className} aria-hidden="true" data-plugin-brand="fallback" />;
return <span className={`inline-flex shrink-0 ${className}`} style={{ width: size, height: size }} aria-hidden="true" data-plugin-brand="package">
<img src={raster.light} alt="" width={size} height={size} className={raster.dark ? 'h-full w-full object-contain dark:hidden' : 'h-full w-full object-contain'} onError={() => setFailed(imageKey)} />
{raster.dark && <img src={raster.dark} alt="" width={size} height={size} className="hidden h-full w-full object-contain dark:block" onError={() => setFailed(imageKey)} />}
</span>;
}
@@ -60,7 +60,7 @@ export function PluginContributionIcon({
const packageIcon = usePluginContributionIcon(pluginId, icon);
if (icon?.kind === 'theme') {
const Icon = THEME_ICONS[icon.name] ?? Puzzle;
const Icon = Object.hasOwn(THEME_ICONS, icon.name) ? THEME_ICONS[icon.name] : Puzzle;
return <Icon size={size} className={className} aria-hidden="true" data-plugin-icon-kind="theme" />;
}
if (packageIcon) {
@@ -0,0 +1,17 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
},
"light": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2d200cQRAAmwuAGJAcF3E4BsfhuCwRAwEYf9lGyz5mdqZf01WfSNydqJreYbUMIgAAAFCRJ8s3e3v//WH5fpl5eX6YuFF9E4TPQysIlRdFvB6zQ5j6Yoi3Y1YIU14E8X6MhvAY/QDI92X05z8UAPJjMOLh1vhAfFx6LwndEwD5sen10xUA8nPQ42l4Ewi5aQ6A1Z+LVl9NASA/Jy3eLgNAfm6u/LEHKM5pAKz+NTjzyAQozmEArP61OPLJBCjObgCs/jXZ88oEKA4BFIcAivMlAK7/a7P1ywQoDgEUhwCKQwDFIYDiEEBxCKA4YQL49v2n90coSYgA/sonAnvcA9hKJwJbXAM4kk0EdrgFcCWZCGxwCaBVLhHoYx5Ar1Qi0MU0gLsyiUAP0wB+/Xi9/b1EoIP5JYAIYuGyCSSCOLj9GkgEMXC9EUQE/rjfCiYCX9wDECECT0IEIEIEXoQJQIQIPAgVgAgRWBMuABEisCRkACJEYEXYAESIwILQAYgQgTbhAxAhAk1SBCBCBFqkCUCECDRIFYDI/QhG4lmZdAGI9MtE/jEpAxBpl4r8c9IGwKPlc0gZAI+WzyNdADxaPpdUAYxIZC+wT5oAkK9DigCQr0f4AJCvS+gAkK9P2ACQb0PIAJBvR7gAkG9LqACQb0+YAJDvQ4gAkO+HewDI9yXkOYEtIH8OYc8JPAP58wh9TuAeyJ9L+HMCP4P8+aQ4J1AE+VqkOCcQ+Xo8bb9g8Y8jeyaBhXzvx8WsA395fvzzHvqcQFa+PmHPCUS+DSHPCUS+He63greykW+LewAi/6Uj354QAYgg34swAYAPBFAclxtB4Iv7jSCIAwEUhwCKQwDFIYDiEEBxCKA4BFCcLwF8vkkA67H1ywQoDgEUhwCKsxsA+4A12fPKBCjOYQBMgbU48skEKM5pAEyBNTjzyAQozmUATIHcXPlrmgBEkJMWb82XACLIRasv9gDF6QqAKZCDHk/dE4AIYtPrZ0gmf0QSh7sLc2gPwDSIwYiH4U0gEfgy+vOfKo9Lgh2zFp7K6iUEPWZPXNXxTQjz0LrUml6/CaId9lYAAKDMHwpGNG+oRdPRAAAAAElFTkSuQmCC",
"lightSha256": "ec7bcd5e470ee3d3d9f7f77d120a4de7496a9c0ba984094f84e560d240bea947",
"dark": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAADMElEQVR4nO2cO07EMBQAH4iSk1FyBmokToJEzRkoOdn2UCHQko8d+/3yZvrdrJjxi4kiiwAAAEBFbiwvdv/w/GV5vcxcPt9M3KheBOHz0ApC5UsRr8fsEKZ+GeLtmBXClC9BvB+jIdyO/gDk+zL69x8KAPkxGPFwaHwgPi69t4TuCYD82PT66QoA+Tno8TS8CYTcNAfA6s9Fq6+mAJCfkxZvuwEgPzd7/tgDFGczAFb/OdjyyAQozmoArP5zseaTCVCcxQBY/edkySsToDgEUBwCKM6/ALj/n5trv0yA4hBAcQigOARQHAIoDgEUhwCKEyaAj/dX759QkhAB/MgnAnvcA7iWTgS2uAawJpsI7HALYE8yEdjgEkCrXCLQxzyAXqlEoItpAEdlEoEepgE8Pr0c/iwR6GB+CyCCWLhsAokgDm7/BhJBDFwfBBGBP+6PgonAF/cARIjAkxABiBCBF2ECECECD0IFIEIE1oQLQIQILAkZgAgRWBE2ABEisCB0ACJEoE34AESIQJMUAYgQgRZpAhAhAg1SBSByPIKReM5MugBE+mUif52UAYi0S0X+NmkD4NXyOaQMgFfL55EuAF4tn0uqAEYkshdYJk0AyNchRQDI1yN8AMjXJXQAyNcnbADItyFkAMi3I1wAyLclVADItydMAMj3IUQAyPfDPQDk+xLynMAWkD+HsOcEboH8eYQ+J3AJ5M8l/DmBf0H+fFKcEyiCfC1SnBOIfD3urC/4+PTSNQks5Hu/LuYZeOhzAln5+oQ9JxD5NoQ8JxD5drg/Cr6WjXxb3AMQ+ZWOfHtCBCCCfC/CBAA+EEBxzB8ERaTy7YcJUBwCKA4BFIcAikMAxSGA4hBAcQigOP8CuHy+3Xj8ELDh2i8ToDgEUBwCKM5iAOwDzsmSVyZAcVYDYAqcizWfTIDibAbAFDgHWx6ZAMXZDYApkJs9f00TgAhy0uKt+RZABLlo9cUeoDhdATAFctDjqXsCEEFsev0Mybx/eP4a+TzM4+jCHNoDMA1iMOJheBNIBL6M/v2nyuOWYMeshaeyeglBj9kTV3V8E8I8tG61pvdvgmiHvRUAACjzDQ0NL/vgx29SAAAAAElFTkSuQmCC",
"darkSha256": "7a897a67ae6b652339c77a113d1c759919776fead82d2eefd0238d3c05ba87fe"
}
]
+9
View File
@@ -0,0 +1,9 @@
import previews from './catalog-brand-icons.json';
/** Only bundled reviewed assets; catalog data never becomes an image URL. */
export function resolveCatalogBrandIcon(entry: NetcattyPluginCatalogEntry): { light: string; dark?: string } | null {
const preview = previews.find((item) => item.id === entry.id && item.version === entry.version
&& item.repository === entry.repository && item.commit === entry.commit
&& JSON.stringify(item.icon) === JSON.stringify(entry.manifest.icon));
return preview ? { light: preview.light, dark: preview.dark } : null;
}
@@ -0,0 +1,70 @@
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 { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { createHash } from 'node:crypto';
import { inspectPluginBrandIcon } from '@netcatty/plugin-contract';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { resolveCatalogBrandIcon } from '../../plugins/catalogBrandIcons';
import previews from '../../plugins/catalog-brand-icons.json';
const preview = previews[0];
const entry = { ...preview, manifest: { icon: preview.icon } } as unknown as NetcattyPluginCatalogEntry;
test('reviewed catalog brand previews bind version, source and declaration, never arbitrary URLs', () => {
assert.ok(resolveCatalogBrandIcon(entry));
const catalog = JSON.parse(readFileSync(new URL('../../../electron/plugins/catalog.json', import.meta.url), 'utf8')) as NetcattyPluginCatalogEntry[];
assert.ok(resolveCatalogBrandIcon(catalog.find((item) => item.id === preview.id)!));
for (const replacement of [{ version: '9.0.0' }, { commit: '0'.repeat(40) }, { repository: 'untrusted/plugin' }, { manifest: { icon: { kind: 'package', light: 'https://attacker.invalid/a.png' } } }]) {
assert.equal(resolveCatalogBrandIcon({ ...entry, ...replacement } as NetcattyPluginCatalogEntry), null);
}
for (const theme of ['light', 'dark'] as const) {
const bytes = Buffer.from(preview[theme].split(',')[1], 'base64');
assert.equal(createHash('sha256').update(bytes).digest('hex'), preview[`${theme}Sha256`]);
assert.equal(inspectPluginBrandIcon(bytes, preview.icon[theme]).width, 128);
assert.deepEqual(bytes, readFileSync(new URL(`../../../examples/plugins/starter-netcatty/${preview.icon[theme]}`, import.meta.url)));
}
const html = renderToStaticMarkup(<PluginBrandIcon entry={entry} />);
assert.match(html, /data-plugin-brand="package"/);
assert.match(html, /dark:hidden/);
assert.match(html, /dark:block/);
assert.doesNotMatch(html, /src="https?:/);
assert.match(renderToStaticMarkup(<PluginBrandIcon />), /data-plugin-brand="fallback"/);
});
test('installed brand uses metadata only, rejects stale responses, and falls back after image errors', async () => {
const oldWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
const previousAct = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT;
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const requests: Array<(v: { light: string }) => void> = [];
Object.defineProperty(globalThis, 'window', { configurable: true, value: { netcatty: { getPluginBrandIcon: () => new Promise((resolve) => requests.push(resolve)) } } });
const plugin = { id: 'com.netcatty.starter', enabled: false, activeVersion: '1.0.0', archiveSha256: 'a', runtime: {}, manifest: { icon: preview.icon } } as NetcattyInstalledPlugin;
let renderer: ReactTestRenderer | undefined;
try {
await act(async () => { renderer = create(<PluginBrandIcon plugin={plugin} />); });
assert.equal(requests.length, 1);
await act(async () => { renderer!.update(<PluginBrandIcon plugin={{ ...plugin, archiveSha256: 'b' }} />); });
await act(async () => requests[0]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => requests[1]({ light: preview.light }));
assert.equal(renderer!.root.findAllByType('img').length, 1);
await act(async () => renderer!.root.findByType('img').props.onError());
assert.equal(renderer!.root.findAllByType('img').length, 0);
await act(async () => { renderer!.update(<PluginBrandIcon />); });
assert.equal(renderer!.root.findAllByType('img').length, 0);
} finally {
await act(async () => renderer?.unmount());
if (oldWindow) Object.defineProperty(globalThis, 'window', oldWindow); else Reflect.deleteProperty(globalThis, 'window');
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = previousAct;
}
});
test('disabled brand theme names cannot resolve Object prototype members', () => {
for (const name of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty']) {
const plugin = { id: 'com.test.theme', enabled: false, activeVersion: '1.0.0', runtime: {}, manifest: { icon: { kind: 'theme', name } } } as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<PluginBrandIcon plugin={plugin} />);
assert.match(html, /lucide-puzzle/);
assert.doesNotMatch(html, /<img/);
}
});
@@ -0,0 +1,58 @@
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 { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('installed grid filters, opens one detail and restores search, scroll and card focus without pill navigation', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const raf = globalThis.requestAnimationFrame;
const frames: FrameRequestCallback[] = [];
globalThis.requestAnimationFrame = (fn) => { frames.push(fn); return frames.length; };
const selections: unknown[] = []; const toggles: unknown[] = []; const focus: string[] = [];
const pane = { scrollTop: 160 };
const plugins = ['Alpha', 'Beta'].map((name) => ({ id: `com.test.${name.toLowerCase()}`, enabled: false, activeVersion: '1.0.0',
manifest: { name, publisher: 'Example', description: `${name} safely formats synthetic greetings without accessing hosts or secrets.`, license: 'MIT', main: { browser: 'index.js' }, engines: { api: '0.1.0-internal', netcatty: '*' } },
runtime: { lastError: name === 'Beta' ? 'Incompatible version' : null },
})) as NetcattyInstalledPlugin[];
const manager = { available: true, plugins, busy: false, setEnabled: async (...args: unknown[]) => { toggles.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer!: ReactTestRenderer;
try {
await act(async () => { renderer = create(<I18nProvider locale="en"><PluginPackageManager manager={manager} onSelectionChange={(id) => selections.push(id)} /></I18nProvider>, {
createNodeMock: (element) => element.type === 'section' ? {
closest: () => pane,
querySelector: () => ({ focus: () => focus.push('detail') }),
querySelectorAll: () => plugins.map((plugin) => ({ dataset: { pluginCard: plugin.id }, focus: () => focus.push(plugin.id) })),
} : null,
}); });
const cards = () => renderer.root.findAllByType('button').filter((node) => node.props['data-plugin-card']);
assert.equal(cards().length, 2);
const alphaCard = renderer.root.findAllByType('article')[0];
assert.match(JSON.stringify(renderer.toJSON()), /Alpha safely formats synthetic greetings/);
assert.ok(alphaCard.findAllByType('p').some((node) => node.children.includes('Example')), 'author has its own line');
assert.ok(alphaCard.findAllByType('p').some((node) => node.props.className?.includes('line-clamp-3')), 'card description is bounded');
assert.match(JSON.stringify(renderer.toJSON()), /MIT/);
assert.match(JSON.stringify(renderer.toJSON()), /Incompatible version/);
assert.ok(renderer.root.findAllByType('div').some((node) => node.props.className?.includes('repeat(auto-fill')));
const pill = renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
await act(async () => pill.props.onClick());
assert.deepEqual(toggles, [[plugins[0].id, true]]); assert.deepEqual(selections, []);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.deepEqual(selections, [plugins[0].id]); assert.equal(pane.scrollTop, 0); assert.equal(focus.at(-1), 'detail');
assert.equal(renderer.root.findAllByType('input').length, 0);
assert.match(JSON.stringify(renderer.toJSON()), /Declared permissions/);
assert.ok(renderer.root.findByType('article').findAllByType('p').some((node) => node.props.className?.includes('whitespace-pre-wrap')), 'detail shows the complete description');
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('Installed plugins'))!;
await act(async () => back.props.onClick()); frames.splice(0).forEach((fn) => fn(0));
assert.equal(renderer.root.findByType('input').props.value, 'Alpha'); assert.equal(cards().length, 1);
assert.equal(pane.scrollTop, 160); assert.equal(focus.at(-1), plugins[0].id);
await act(async () => renderer.root.findByType('input').props.onChange({ target: { value: 'none' } }));
assert.equal(cards().length, 0);
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; globalThis.requestAnimationFrame = raf; }
});
@@ -0,0 +1,80 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
// Exercise the real Radix portal/focus lifecycle, rather than a mocked Dialog.
test('plugin manager is single-instance, closes with Escape, restores focus and remounts on reopen', async () => {
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', 'Element', 'Node', 'NodeFilter', 'MutationObserver', 'CustomEvent', 'Event', 'KeyboardEvent', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
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, useEffect } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginManagementDialog } = await import('./PluginManagementDialog');
const root = createRoot(document.getElementById('root')!);
let mounts = 0;
let unmounts = 0;
function Content() {
useEffect(() => { mounts++; return () => { unmounts++; }; }, []);
return <input aria-label="Plugin search" defaultValue="" />;
}
const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
const click = async (element: HTMLElement) => { await act(async () => { element.click(); await settle(); }); };
try {
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
const trigger = document.querySelector<HTMLButtonElement>('button')!;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(mounts, 0, 'closed settings entry does not mount manager subscriptions');
trigger.focus();
await click(trigger);
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(mounts, 1);
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search');
assert.equal(document.querySelector('[role="dialog"]')?.contains(document.querySelector('[data-settings-scroll-pane]')), true);
await act(async () => { root.render(<I18nProvider locale="en"><PluginManagementDialog><Content /></PluginManagementDialog></I18nProvider>); });
assert.equal(document.querySelectorAll('[role="dialog"]').length, 1);
assert.equal(mounts, 1, 'rerender does not spawn another manager');
const closeButton = document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!;
await act(async () => {
closeButton.focus();
closeButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true, cancelable: true }));
});
assert.equal(document.activeElement?.getAttribute('aria-label'), 'Plugin search', 'Tab wraps inside the modal');
await act(async () => { document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); await settle(); });
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 1);
await click(trigger);
assert.equal(mounts, 2);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button[data-dialog-close]:not([aria-hidden])')!);
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.equal(document.activeElement, trigger);
assert.equal(unmounts, 2);
let handedOff = false;
await act(async () => root.render(<I18nProvider locale="en"><PluginManagementDialog>{(handoff) => <button onClick={() => handoff(() => {
handedOff = true;
assert.equal(document.querySelector('[role="dialog"]'), null);
assert.notEqual(document.body.style.pointerEvents, 'none');
})}>Open contributed settings view</button>}</PluginManagementDialog></I18nProvider>));
await click(trigger);
await click(document.querySelector<HTMLButtonElement>('[role="dialog"] button:not([data-dialog-close])')!);
assert.equal(handedOff, true, 'external view opens only after modal ownership is released');
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,38 @@
import React, { useRef, useState } from 'react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '../../ui/dialog';
/** Uses the existing renderer and host bridge; opening the manager never starts a plugin. */
export function PluginManagementDialog({ children }: {
children: React.ReactNode | ((handoff: (action: () => void) => void) => React.ReactNode);
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const pendingHandoff = useRef<(() => void) | null>(null);
const handoff = (action: () => void) => { pendingHandoff.current = action; setOpen(false); };
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="outline">{t('settings.plugins.openManager')}</Button>
</DialogTrigger>
<DialogContent onCloseAutoFocus={(event) => {
const action = pendingHandoff.current;
pendingHandoff.current = null;
if (action) {
event.preventDefault();
// Wait for Radix to release modal pointer/focus ownership before opening
// the existing external plugin view host (including exit animations).
queueMicrotask(action);
}
}} className="flex h-[min(88dvh,900px)] w-[calc(100vw-2rem)] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
<DialogTitle>{t('settings.plugins.title')}</DialogTitle>
</DialogHeader>
<div data-settings-scroll-pane className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-4 sm:p-6">
{typeof children === 'function' ? children(handoff) : children}
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,146 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React, { useState } from 'react';
import { act, create, type ReactTestRenderer } from 'react-test-renderer';
import { renderToStaticMarkup } from 'react-dom/server';
import { readFileSync } from 'node:fs';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginMarketplaceContent } from './PluginMarketplace';
import type { usePluginCatalog } from '../../../application/state/usePluginCatalog';
const entries = ['Alpha', 'Beta'].map((name) => ({
id: `com.example.${name.toLowerCase()}`, name, author: 'Example', version: '1.0.0', license: 'MIT',
description: `${name} local utility`, repository: 'example/plugins', commit: 'a'.repeat(40),
manifest: { manifestVersion: 1, id: `com.example.${name.toLowerCase()}`, name, version: '1.0.0', publisher: 'Example', engines: { netcatty: '>=1.0.0', api: '0.1.0-internal' }, main: { browser: 'index.js' }, permissions: { required: ['settings.read'] } },
})) as NetcattyPluginCatalogEntry[];
const catalog = {
entries, source: 'registry', listStatus: 'ready', listError: null, readme: '# Author documentation', readmeStatus: 'ready', readmeError: null,
error: null, busy: false, retryList() {}, retryReadme() {}, async install() {},
} as ReturnType<typeof usePluginCatalog>;
test('catalog keeps search during selection/back, switches detail, and displays real installed state', async () => {
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
let renderer: ReactTestRenderer;
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected}
installed={[{ id: entries[0].id, activeVersion: '0.9.0', enabled: false, runtime: { lastError: 'Incompatible with this host' } } as NetcattyInstalledPlugin]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />); });
const cards = () => renderer.root.findAll((node) => node.type === 'button' && Boolean(node.props['data-plugin-id']));
const input = () => renderer.root.findByType('input');
assert.equal(cards().length, 2);
await act(async () => input().props.onChange({ target: { value: 'Alpha' } }));
assert.equal(cards().length, 1);
await act(async () => cards()[0].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Alpha');
assert.equal(cards()[0].props['aria-current'], 'true');
const detailText = JSON.stringify(renderer.toJSON());
assert.match(detailText, /0.9.0/);
assert.match(detailText, /Disabled/);
assert.match(detailText, /Incompatible with this host/);
assert.match(detailText, /Last recorded error/);
assert.match(cards()[0].props.className, /bg-primary\/10/);
assert.match(cards()[0].props.className, /border-primary\/40/);
assert.doesNotMatch(cards()[0].props.className, /bg-accent|text-accent-foreground/);
assert.match(detailText, /1 plugins/);
assert.equal(renderer.root.findAllByType('details').length, 0, 'permissions are not hidden in a disclosure');
assert.equal(renderer.root.findAll((node) => node.type === 'button' && node.children.includes('Install this version (disabled)')).length, 0, 'source-only cards have no install action');
const back = () => renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back().props.onClick());
assert.equal(renderer.root.findAllByType('article').length, 0);
assert.equal(input().props.value, 'Alpha');
await act(async () => input().props.onChange({ target: { value: '' } }));
await act(async () => cards()[0].props.onClick());
await act(async () => cards()[1].props.onClick());
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta');
assert.equal(cards()[1].props['aria-current'], 'true');
assert.equal(cards()[0].props['aria-current'], undefined);
await act(async () => input().props.onChange({ target: { value: 'no match' } }));
assert.equal(cards().length, 0);
assert.equal(renderer.root.findByType('article').props['aria-label'], 'Beta', 'filtering does not erase the selected detail');
await act(async () => back().props.onClick());
assert.equal(input().props.value, 'no match');
await act(async () => renderer.unmount());
} finally {
globalThis.requestAnimationFrame = previousRaf;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('README failure leaves the permissions and source visible, before the failure message', () => {
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, readmeStatus: 'error', readme: null, readmeError: 'Network unavailable' }}
selected={entries[0].id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /settings.read/);
assert.match(html, /not a grant/);
assert.match(html, /Build with the local CLI/);
assert.ok(html.indexOf('settings.read') < html.indexOf('Network unavailable'));
assert.match(html, /Retry/);
assert.doesNotMatch(html, /<details/);
});
test('responsive layout is scoped to marketplace content width, with a narrow detail-only fallback', () => {
const css = readFileSync(new URL('./plugin-marketplace.css', import.meta.url), 'utf8');
assert.match(css, /\.plugin-marketplace \{ container-type: inline-size;/);
assert.match(css, /@container \(min-width: 820px\)/);
assert.match(css, /grid-template-columns: 248px minmax\(0, 1fr\)/);
assert.match(css, /plugin-marketplace-browser--selected \.plugin-marketplace-catalog \{ display: none;/);
});
test('narrow detail starts at the top and back restores the settings scroll and card focus', async () => {
const previousRaf = globalThis.requestAnimationFrame;
globalThis.requestAnimationFrame = (callback) => { callback(0); return 0; };
const actGlobal = globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
const pane = { scrollTop: 450 };
let focused = false;
const list = { scrollTop: 0, closest: (selector: string) => selector === '.plugin-marketplace' ? { clientWidth: 700 } : pane,
querySelectorAll: () => [{ dataset: { pluginId: entries[0].id }, focus: () => { focused = true; } }] };
let renderer!: ReactTestRenderer;
function Harness() {
const [selected, setSelected] = useState<string | null>(null);
return <I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={selected} onSelect={setSelected} installed={[]} /></I18nProvider>;
}
try {
await act(async () => { renderer = create(<Harness />, { createNodeMock: (element) => element.type === 'div' ? list : { scrollTo() {}, focus() {} } }); });
const card = renderer.root.findAll((node) => node.type === 'button' && node.props['data-plugin-id'] === entries[0].id)[0];
await act(async () => card.props.onClick());
assert.equal(pane.scrollTop, 0);
const back = renderer.root.findAllByType('button').find((node) => node.children.includes('All plugins'))!;
await act(async () => back.props.onClick());
assert.equal(pane.scrollTop, 450);
assert.equal(focused, true);
await act(async () => renderer.unmount());
} finally {
globalThis.requestAnimationFrame = previousRaf;
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test('offline catalog warning keeps details visible and disables market installation', () => {
const downloadable = { ...entries[0], artifact: { url: 'https://github.com/example/plugin/releases/download/v1/plugin.ncpkg', sha256: 'a'.repeat(64) } };
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent
catalog={{ ...catalog, entries: [downloadable], listError: 'Registry offline', source: 'cached' }}
selected={downloadable.id} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(html, /Registry offline/);
assert.match(html, /Installation is unavailable until refresh succeeds/);
assert.match(html, /Compatibility and permissions/);
assert.match(html, /<button[^>]*disabled=""[^>]*>Install this version/);
assert.match(html, /Retry/);
});
test('manual refresh remains available after success and is disabled while loading', () => {
const ready = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={catalog} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(ready, />Refresh<\/button>/);
const loading = renderToStaticMarkup(<I18nProvider locale="en"><PluginMarketplaceContent catalog={{ ...catalog, listStatus: 'loading' }} selected={null} onSelect={() => {}} installed={[]} /></I18nProvider>);
assert.match(loading, /<button[^>]*disabled=""[^>]*>Refresh<\/button>/);
});
@@ -0,0 +1,9 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { filterPluginCatalog } from './PluginMarketplace';
test('catalog search matches name, author and description without case sensitivity', () => {
const entries = [{ id: 'a', name: 'Starter', author: 'Netcatty', description: 'Local greeting' }] as NetcattyPluginCatalogEntry[];
for (const query of ['starter', 'NETCATTY', ' greeting ']) assert.equal(filterPluginCatalog(entries, query).length, 1);
assert.equal(filterPluginCatalog(entries, 'unrelated').length, 0);
assert.equal(filterPluginCatalog(entries, '').length, 1);
});
@@ -0,0 +1,107 @@
import React, { useRef, useState } from 'react';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { usePluginCatalog } from '../../../application/state/usePluginCatalog';
import { Input } from '../../ui/input';
import { Button } from '../../ui/button';
import { SettingCard } from '../settings-ui';
import { PluginPackageDetails } from './PluginPackageManager';
import { PluginReadme } from './PluginReadme';
export function PluginMarketplace({ installed = [] }: { installed?: NetcattyInstalledPlugin[] }) {
const [selected, setSelected] = useState<string | null>(null);
const catalog = usePluginCatalog(selected);
return <PluginMarketplaceContent catalog={catalog} installed={installed} selected={selected} onSelect={setSelected} />;
}
// The browser owns only presentation state; installation and README policy stay in the existing hook.
export function PluginMarketplaceContent({ catalog, installed, selected, onSelect }: {
catalog: ReturnType<typeof usePluginCatalog>;
installed: NetcattyInstalledPlugin[];
selected: string | null;
onSelect: (id: string | null) => void;
}) {
const { t } = useI18n();
const [search, setSearch] = useState('');
const listRef = useRef<HTMLDivElement>(null);
const detailRef = useRef<HTMLElement>(null);
const lastSelection = useRef<string | null>(null);
const listScroll = useRef(0);
const pageScroll = useRef(0);
const entry = catalog.entries.find((item) => item.id === selected);
const filtered = filterPluginCatalog(catalog.entries, search);
const installedEntry = installed.find((item) => item.id === entry?.id);
const choose = (id: string) => {
listScroll.current = listRef.current?.scrollTop ?? 0;
if (!entry) pageScroll.current = listRef.current?.closest('[data-settings-scroll-pane]')?.scrollTop ?? 0;
lastSelection.current = id;
onSelect(id);
requestAnimationFrame(() => {
detailRef.current?.scrollTo?.(0, 0);
const container = listRef.current?.closest<HTMLElement>('.plugin-marketplace');
const pane = listRef.current?.closest('[data-settings-scroll-pane]');
if (container && container.clientWidth < 820 && pane) pane.scrollTop = 0;
detailRef.current?.focus({ preventScroll: true });
});
};
const back = () => {
onSelect(null);
requestAnimationFrame(() => {
if (listRef.current) {
listRef.current.scrollTop = listScroll.current;
const pane = listRef.current.closest('[data-settings-scroll-pane]');
if (pane) pane.scrollTop = pageScroll.current;
const button = Array.from(listRef.current.querySelectorAll<HTMLButtonElement>('[data-plugin-id]'))
.find((item) => item.dataset.pluginId === lastSelection.current);
button?.focus({ preventScroll: true });
}
});
};
return <section className="plugin-marketplace space-y-4" aria-label={t('settings.plugins.market.title')}>
<div className="flex items-start justify-between gap-3"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.note')}</p><Button variant="outline" size="sm" disabled={catalog.listStatus === 'loading'} onClick={catalog.retryList}>{t('common.refresh')}</Button></div>
{catalog.listStatus === 'ready' && catalog.listError && <div role="status" className="space-y-2 rounded-lg border p-3 text-xs text-muted-foreground"><p>{t('settings.plugins.market.offline')}</p><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.error && <p role="alert" className="text-xs text-destructive">{catalog.error}</p>}
<div className={`plugin-marketplace-browser${entry ? ' plugin-marketplace-browser--selected' : ''}`}>
<div className="plugin-marketplace-catalog space-y-3" ref={listRef}>
<Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />
{catalog.listStatus === 'ready' && <p className="text-xs text-muted-foreground" aria-live="polite">{t('settings.plugins.market.results', { count: filtered.length })}</p>}
{catalog.listStatus === 'ready' && catalog.entries.length > 0 && filtered.length === 0 && <p className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{catalog.listStatus === 'loading' && <p className="text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.listStatus === 'error' && <div role="alert" className="space-y-2 text-xs text-destructive"><p>{catalog.listError}</p><Button variant="outline" size="sm" onClick={catalog.retryList}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.listStatus === 'ready' && catalog.entries.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.empty')}</p>}
<div className="plugin-marketplace-grid">{filtered.map((item) => {
const isInstalled = installed.some((plugin) => plugin.id === item.id);
return <button key={item.id} type="button" data-plugin-id={item.id} aria-current={selected === item.id ? 'true' : undefined} onClick={() => choose(item.id)}
className={`min-w-0 flex flex-col gap-3 rounded-lg border p-4 text-left ${entry ? '' : 'min-h-[200px]'} transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${selected === item.id ? 'border-primary/40 bg-primary/10 text-foreground' : 'border-border bg-card hover:bg-muted/50'}`}>
<div className="flex items-start gap-2"><PluginBrandIcon entry={item} size={32} className="mt-0.5 shrink-0 text-muted-foreground" /><span className="min-w-0 break-words text-sm font-semibold">{item.name}</span></div>
<p className="break-words text-xs text-muted-foreground">{item.author}</p>
<p className="line-clamp-3 break-words text-sm leading-5 text-muted-foreground">{item.description}</p>
<div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1 text-xs text-muted-foreground"><span>{item.version} · {item.license}</span><span className="text-foreground">{t('settings.plugins.manager.details')}</span></div>
<div className="flex flex-wrap gap-2 text-xs text-muted-foreground">{isInstalled && <span className="rounded bg-muted px-2 py-0.5 text-foreground">{t('settings.plugins.market.installed')}</span>}{!item.artifact && <span className="rounded border px-2 py-0.5">{t('settings.plugins.market.sourceBadge')}</span>}</div>
</button>;
})}</div>
</div>
{entry && <article key={entry.id} ref={detailRef} tabIndex={-1} aria-label={entry.name} className="plugin-marketplace-detail min-w-0 space-y-5 rounded-lg border bg-card p-4 focus:outline-none">
<Button variant="ghost" size="sm" onClick={back}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.market.back')}</Button>
<div className="space-y-2"><div className="flex items-center gap-3"><PluginBrandIcon entry={entry} size={48} /><h4 className="break-words text-xl font-semibold">{entry.name}</h4></div><p className="text-xs text-muted-foreground">{entry.author} · {entry.version} · {entry.license}</p>
{installedEntry && <p className="text-xs text-muted-foreground">{t('settings.plugins.market.installed')} · {installedEntry.activeVersion} · {t(installedEntry.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}</p>}
</div>
{installedEntry?.runtime?.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {installedEntry.runtime.lastError}</p>}
<p className="text-sm leading-6 text-muted-foreground">{entry.description}</p>
<a className="block break-all text-xs text-primary underline underline-offset-4" href={`https://github.com/${entry.repository}/tree/${entry.commit}`} target="_blank" rel="noopener noreferrer">{entry.repository} @ {entry.commit.slice(0, 12)}</a>
{entry.artifact ? <div className="space-y-2"><p className="text-xs text-muted-foreground">{t('settings.plugins.market.installNote')}</p><Button size="sm" disabled={catalog.busy || Boolean(catalog.listError)} onClick={() => void catalog.install(entry.id)}>{t('settings.plugins.market.install')}</Button></div> : <p className="rounded-lg bg-muted/40 p-3 text-xs leading-5">{t('settings.plugins.market.sourceOnly')}</p>}
<SettingCard padded><h4 className="mb-3 text-sm font-semibold">{t('settings.plugins.market.manifest')}</h4><PluginPackageDetails plugin={{ id: entry.id, activeVersion: entry.version, enabled: false, manifest: entry.manifest, archiveSha256: entry.artifact?.sha256, runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null } }} /></SettingCard>
<div className="min-w-0 border-t pt-4"><h4 className="text-sm font-semibold">README</h4><p className="mt-2 text-xs text-muted-foreground">{t('settings.plugins.market.readmeNote')}</p>{catalog.readmeStatus === 'loading' && <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.loading')}</p>}
{catalog.readmeStatus === 'error' && <div role="alert" className="mt-3 space-y-2 text-xs text-destructive"><p>{catalog.readmeError}</p><Button variant="outline" size="sm" onClick={catalog.retryReadme}>{t('settings.plugins.market.retry')}</Button></div>}
{catalog.readmeStatus === 'ready' && (catalog.readme?.trim() ? <PluginReadme markdown={catalog.readme} /> : <p className="mt-3 text-xs text-muted-foreground">{t('settings.plugins.market.emptyReadme')}</p>)}
</div>
</article>}
</div>
</section>;
}
export function filterPluginCatalog(entries: NetcattyPluginCatalogEntry[], query: string) {
const needle = query.trim().toLocaleLowerCase();
return entries.filter((entry) => `${entry.name} ${entry.author} ${entry.description}`.toLocaleLowerCase().includes(needle));
}
@@ -0,0 +1,27 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageDetails } from './PluginPackageManager';
test('package detail renders manifest resources as text and explains advanced authority', () => {
const plugin = {
id: 'com.example.synthetic', enabled: false, activeVersion: '1.0.0',
archiveSha256: 'a'.repeat(64),
manifest: {
manifestVersion: 1, id: 'com.example.synthetic', name: 'synthetic', version: '1.0.0', publisher: 'example',
engines: { netcatty: '>=1.0.0 <2.0.0', api: '0.1.0-internal' },
main: { node: 'index.js' },
permissions: { required: [{ permission: 'network', resources: ['https://example.com'], reason: '<script>attack()</script>' }] },
},
runtime: { status: 'stopped', kind: null, lastError: null, quarantinedAt: null },
} as NetcattyInstalledPlugin;
const html = renderToStaticMarkup(<I18nProvider locale="en"><PluginPackageDetails plugin={plugin} /></I18nProvider>);
assert.match(html, /full Node filesystem and network authority/);
assert.match(html, /https:\/\/example.com/);
assert.match(html, /&lt;script&gt;/);
assert.doesNotMatch(html, /<script>/);
assert.match(html, /not a grant/);
assert.match(html, /aaaaaaaaaaaaaaaa/);
});
@@ -0,0 +1,122 @@
import { describePluginPermission } from '@/domain/pluginPermissionDescriptions.cjs';
import { describePluginRuntime } from '@/domain/pluginRuntimePolicy.cjs';
import React, { useRef, useState } from 'react';
import { PluginPermissionPanel } from './PluginPermissionPanel';
import { PluginBrandIcon } from '../../plugins/PluginBrandIcon';
import { ArrowLeft } from 'lucide-react';
import { usePluginManager } from '../../../application/state/usePluginManager';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
export function PluginPackageDetails({ plugin }: { plugin: NetcattyInstalledPlugin }) {
const { t, resolvedLocale } = useI18n();
const manifest = plugin.manifest;
if (!manifest) return null;
const permissions = [
...(manifest.permissions?.required ?? []).map((value) => ({ value, required: true })),
...(manifest.permissions?.optional ?? []).map((value) => ({ value, required: false })),
];
const policy = describePluginRuntime(manifest);
const runtimeKind = plugin.runtime.kind ?? policy.defaultRuntimeKind;
const runtimeLabel = runtimeKind === 'utility' ? 'advanced'
: policy.hasNode ? 'dual' : policy.declaresAdvanced ? 'advancedDeclaration' : 'browser';
return <div className="space-y-4 border-t border-border/60 pt-4 text-sm">
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-xs">
<dt className="text-muted-foreground">ID</dt><dd className="break-all font-mono">{plugin.id}</dd>
<dt className="text-muted-foreground">Netcatty</dt><dd>{manifest.engines.netcatty}</dd>
<dt className="text-muted-foreground">API</dt><dd>{manifest.engines.api}</dd>
<dt className="text-muted-foreground">SHA-256</dt><dd className="break-all font-mono">{plugin.archiveSha256 ?? '-'}</dd>
</dl>
<p className={!policy.browserOnly ? 'text-destructive' : 'text-muted-foreground'}>{t(`settings.plugins.manager.${runtimeLabel}`)}</p>
<div className="space-y-2">
<h4 className="font-medium">{t('settings.plugins.manager.permissions')}</h4>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.permissionNote')}</p>
{permissions.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.manager.noPermissions')}</p>}
{permissions.map(({ value, required }, index) => {
const id = typeof value === 'string' ? value : value.permission;
const description = describePluginPermission(id, resolvedLocale);
return <div key={index} className="rounded-md bg-muted/40 p-3">
<div className="flex justify-between gap-3"><span className="text-sm font-medium">{description.label}</span><span className="text-xs text-muted-foreground">{t(required ? 'settings.plugins.manager.required' : 'settings.plugins.manager.optional')}</span></div>
<p className="mt-1 text-xs text-muted-foreground">{description.description}</p>
<code className="mt-1 block break-all text-[10px] text-muted-foreground">{id}</code>
{description.sensitive && <p className="mt-1 text-xs font-medium text-destructive">{t('settings.plugins.manager.sensitive')}</p>}
{(typeof value === 'string' || value.resources.length === 0) && <p className="mt-1 text-xs">{t('settings.plugins.manager.unbounded')}</p>}
{typeof value !== 'string' && <><p className="mt-1 whitespace-pre-wrap break-all text-xs">{value.resources.join('\n')}</p>{value.reason && <p className="mt-1 text-xs text-muted-foreground">{value.reason}</p>}</>}
</div>; })}
</div>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.data')}</p>
</div>;
}
export function PluginPackageManager({ manager, onSelectionChange }: { manager: ReturnType<typeof usePluginManager>; onSelectionChange?: (id: string | null) => void }) {
const { t, resolvedLocale } = useI18n();
const [selected, setSelected] = useState<string | null>(null);
const [search, setSearch] = useState('');
const rootRef = useRef<HTMLElement>(null);
const scrollTop = useRef(0);
const lastSelection = useRef<string | null>(null);
const selectedPlugin = manager.plugins.find((plugin) => plugin.id === selected);
const nameOf = (plugin: NetcattyInstalledPlugin) => typeof plugin.manifest?.displayName === 'string' ? plugin.manifest.displayName : plugin.manifest?.displayName?.[resolvedLocale] ?? plugin.manifest?.displayName?.en ?? plugin.manifest?.name ?? plugin.id;
const descriptionOf = (plugin: NetcattyInstalledPlugin) => {
const value = plugin.manifest?.description;
return typeof value === 'string' ? value : value?.[resolvedLocale] ?? value?.en ?? '';
};
const choose = (id: string | null) => {
const pane = rootRef.current?.closest<HTMLElement>('[data-settings-scroll-pane]');
if (id) { scrollTop.current = pane?.scrollTop ?? 0; lastSelection.current = id; }
setSelected(id); setRemoving(null); onSelectionChange?.(id);
requestAnimationFrame(() => {
if (pane) pane.scrollTop = id ? 0 : scrollTop.current;
if (id) rootRef.current?.querySelector<HTMLElement>('[data-plugin-detail]')?.focus({ preventScroll: true });
else Array.from(rootRef.current?.querySelectorAll<HTMLButtonElement>('[data-plugin-card]') ?? []).find((button) => button.dataset.pluginCard === lastSelection.current)?.focus({ preventScroll: true });
});
};
const visible = selectedPlugin ? [selectedPlugin] : manager.plugins.filter((plugin) => [nameOf(plugin), plugin.manifest?.publisher, plugin.manifest?.description, plugin.id].some((value) => JSON.stringify(value ?? '').toLocaleLowerCase().includes(search.trim().toLocaleLowerCase())));
const [removing, setRemoving] = useState<string | null>(null);
if (!manager.available) return null;
return <section ref={rootRef} className="space-y-4" aria-label={t('settings.plugins.manager.installed')}>
<p className="text-xs text-muted-foreground">{t('settings.plugins.manager.preview')}</p>
{selectedPlugin && <Button variant="ghost" size="sm" onClick={() => choose(null)}><ArrowLeft size={14} className="mr-2" />{t('settings.plugins.manager.installed')}</Button>}
{!selectedPlugin && manager.plugins.length > 0 && <Input value={search} onChange={(event) => setSearch(event.target.value)} placeholder={t('settings.plugins.market.search')} aria-label={t('settings.plugins.market.search')} />}
{!selectedPlugin && manager.plugins.length > 0 && visible.length === 0 && <p className="p-6 text-center text-sm text-muted-foreground">{t('settings.plugins.market.noResults')}</p>}
{manager.error && <p role="alert" className="text-sm text-destructive">{manager.error}</p>}
{!manager.plugins.length && <div className="rounded-xl border border-dashed border-border p-8 text-center text-sm text-muted-foreground">{t('settings.plugins.manager.empty')}</div>}
<div className={selectedPlugin ? 'space-y-4' : 'grid grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3'}>
{visible.map((plugin) => <article key={plugin.id} data-plugin-detail={selectedPlugin ? true : undefined} tabIndex={selectedPlugin ? -1 : undefined} aria-label={nameOf(plugin)} className={`min-w-0 rounded-lg border bg-card p-4 focus:outline-none ${selectedPlugin ? 'space-y-4' : 'flex min-h-[200px] flex-col gap-3'}`}>
<div className="flex flex-wrap items-center gap-3">
<div className="flex h-12 w-12 shrink-0 items-center justify-center"><PluginBrandIcon plugin={plugin} size={32} /></div>
<button type="button" data-plugin-card={plugin.id} className="min-w-0 flex-1 rounded text-left focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" aria-expanded={selected === plugin.id} onClick={() => choose(selected === plugin.id ? null : plugin.id)}>
<div className="break-words text-sm font-medium">{nameOf(plugin)}</div>
</button>
<Button variant="ghost" size="sm" type="button"
className={`h-7 shrink-0 rounded-full border-0 px-3 text-xs focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background ${plugin.enabled ? 'bg-primary/10 hover:bg-primary/15' : 'bg-muted/60 text-muted-foreground hover:bg-muted hover:text-foreground'}`}
style={plugin.enabled ? { color: 'color-mix(in srgb, hsl(var(--primary)) 40%, hsl(var(--foreground)))' } : undefined}
aria-pressed={plugin.enabled} aria-busy={manager.busy}
aria-label={`${t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')} ${plugin.manifest?.name ?? plugin.id}`}
title={t(plugin.enabled ? 'settings.plugins.manager.disable' : 'settings.plugins.manager.enable')}
disabled={manager.busy} onClick={() => void manager.setEnabled(plugin.id, !plugin.enabled)}>
{t(plugin.enabled ? 'settings.plugins.manager.enabled' : 'settings.plugins.manager.disabled')}
</Button>
</div>
{plugin.manifest?.publisher && <p className="break-words text-xs text-muted-foreground">{plugin.manifest.publisher}</p>}
<p className={`break-words text-sm leading-5 text-muted-foreground ${selectedPlugin ? 'whitespace-pre-wrap' : 'line-clamp-3'}`}>{descriptionOf(plugin) || t('settings.plugins.manager.noDescription')}</p>
{!selectedPlugin ? <div className="mt-auto flex flex-wrap items-center justify-between gap-2 pt-1">
<p className="break-words text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => choose(plugin.id)} aria-label={`${t('settings.plugins.manager.details')} ${nameOf(plugin)}`}>{t('settings.plugins.manager.details')}</Button>
</div> : <p className="text-xs text-muted-foreground">{plugin.activeVersion}{plugin.manifest?.license ? ` · ${plugin.manifest.license}` : ''}</p>}
{plugin.runtime.lastError && <p role="status" className="break-words text-xs text-destructive">{t('settings.plugins.manager.lastError')}: {plugin.runtime.lastError}</p>}
{selected === plugin.id && <>
<PluginPackageDetails plugin={plugin} />
<PluginPermissionPanel key={plugin.id} pluginId={plugin.id} onChanged={async () => { await manager.refresh(); }} />
{removing === plugin.id ? <div className="space-y-2 rounded-lg border border-destructive/30 p-3">
<p className="text-xs">{t('settings.plugins.manager.removeNote')}</p>
<Button variant="destructive" size="sm" disabled={manager.busy} onClick={() => { void manager.uninstall(plugin.id); setRemoving(null); }}>{t('settings.plugins.manager.remove')}</Button>
<Button variant="ghost" size="sm" onClick={() => setRemoving(null)}>{t('common.cancel')}</Button>
</div> : <Button variant="ghost" size="sm" disabled={manager.busy} onClick={() => setRemoving(plugin.id)}>{t('settings.plugins.manager.remove')}</Button>}
</>}
</article>)}
</div>
</section>;
}
@@ -0,0 +1,69 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { JSDOM } from 'jsdom';
test('permission panel refreshes real grants, revokes once, and keeps stale reads from replacing new results', async () => {
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', 'Element', 'Node', 'Event', 'MouseEvent']) {
install(key, key === 'window' ? dom.window : dom.window[key as keyof typeof dom.window]);
}
install('IS_REACT_ACT_ENVIRONMENT', true);
const { default: React, act } = await import('react');
const { createRoot } = await import('react-dom/client');
const { I18nProvider } = await import('../../../application/i18n/I18nProvider');
const { PluginPermissionPanel } = await import('./PluginPermissionPanel');
const root = createRoot(document.getElementById('root')!);
const initial = {
grants: [{ permission: 'network', resource: 'https://synthetic.invalid/<text>', resourceKind: 'exact', scope: 'always' as const, sessionId: null, grantedAt: 1 }],
audit: [{ event: 'permission.used', createdAt: 2, permission: 'network' }],
};
let reads = 0;
let revokes = 0;
let changed = 0;
let staleRead: (value: typeof initial) => void = () => {};
let releaseRevoke: () => void = () => {};
const revokePending = new Promise<void>((resolve) => { releaseRevoke = resolve; });
Object.assign(dom.window, { netcatty: {
async getPluginPermissions(id: string) {
assert.equal(id, 'com.example.synthetic');
reads++;
if (reads === 2) return new Promise<typeof initial>((resolve) => { staleRead = resolve; });
return reads > 2 ? { grants: [], audit: [] } : initial;
},
async revokePluginPermissions(id: string) { assert.equal(id, 'com.example.synthetic'); revokes++; await revokePending; },
} });
const button = (text: string) => Array.from(document.querySelectorAll<HTMLButtonElement>('button')).find((item) => item.textContent === text)!;
try {
await act(async () => root.render(<I18nProvider locale="en"><PluginPermissionPanel pluginId="com.example.synthetic" onChanged={async () => { changed++; }} /></I18nProvider>));
assert.match(document.body.textContent!, /synthetic.invalid/);
assert.equal(document.querySelector('text'), null, 'resource is rendered as plain text');
assert.match(document.body.textContent!, /Until revoked/);
await act(async () => { button('Refresh').click(); });
await act(async () => {
button('Disable and revoke all grants').click();
button('Disable and revoke all grants').click();
});
assert.equal(revokes, 1);
assert.equal(button('Refresh').disabled, true);
await act(async () => { releaseRevoke(); });
assert.equal(changed, 1);
assert.match(document.body.textContent!, /No active grants/);
await act(async () => { staleRead(initial); });
assert.doesNotMatch(document.body.textContent!, /synthetic.invalid/);
Object.assign(dom.window, { netcatty: { async getPluginPermissions() { throw new Error('synthetic read failed'); } } });
await act(async () => { button('Refresh').click(); });
assert.match(document.querySelector('[role="alert"]')?.textContent ?? '', /synthetic read failed/);
} finally {
await act(async () => root.unmount());
dom.window.close();
for (const [key, descriptor] of previous) {
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
else Reflect.deleteProperty(globalThis, key);
}
}
});
@@ -0,0 +1,75 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { netcattyBridge } from '../../../infrastructure/services/netcattyBridge';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { describePluginPermission } from '../../../domain/pluginPermissionDescriptions.cjs';
import { Button } from '../../ui/button';
/** Host UI only. Plugin views cannot query or mutate this security state. */
export function PluginPermissionPanel({ pluginId, onChanged }: {
pluginId: string;
onChanged: () => Promise<void>;
}) {
const { t, resolvedLocale } = useI18n();
const [snapshot, setSnapshot] = useState<NetcattyPluginPermissionSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const generation = useRef(0);
const pending = useRef(false);
const refresh = useCallback(async () => {
const current = ++generation.current;
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.getPluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
const result = await bridge.getPluginPermissions(pluginId);
if (current === generation.current) setSnapshot(result);
} catch (cause) {
if (current === generation.current) setError(cause instanceof Error ? cause.message : String(cause));
}
}, [pluginId, t]);
useEffect(() => {
const requestGeneration = generation;
void refresh();
return () => { requestGeneration.current++; };
}, [refresh]);
const revoke = async () => {
if (pending.current) return;
pending.current = true;
setBusy(true);
setError(null);
try {
const bridge = netcattyBridge.require();
if (!bridge.revokePluginPermissions) throw new Error(t('settings.plugins.permissions.unavailable'));
await bridge.revokePluginPermissions(pluginId);
await onChanged();
await refresh();
} catch (cause) { setError(cause instanceof Error ? cause.message : String(cause)); }
finally { pending.current = false; setBusy(false); }
};
return <section className="space-y-3 border-t border-border/60 pt-4 text-sm" aria-label={t('settings.plugins.permissions.title')}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h4 className="font-medium">{t('settings.plugins.permissions.title')}</h4>
<Button variant="ghost" size="sm" disabled={busy} onClick={() => void refresh()}>{t('settings.plugins.permissions.refresh')}</Button>
</div>
{error && <p role="alert" className="text-xs text-destructive">{error}</p>}
{!snapshot && !error && <p role="status">{t('settings.plugins.permissions.loading')}</p>}
{snapshot && <>
{snapshot.grants.length === 0 && <p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.empty')}</p>}
{snapshot.grants.map((grant, index) => <div key={index} className="rounded-md bg-muted/40 p-3 text-xs">
<p className="font-medium">{describePluginPermission(grant.permission, resolvedLocale).label}</p>
<p className="whitespace-pre-wrap break-all font-mono">{grant.resource}</p>
<p>{t(`settings.plugins.permissions.${grant.scope}`)}{grant.sessionId ? ` · ${grant.sessionId}` : ''}</p>
<time dateTime={new Date(grant.grantedAt).toISOString()}>{new Date(grant.grantedAt).toLocaleString(resolvedLocale)}</time>
</div>)}
<details className="text-xs">
<summary className="cursor-pointer">{t('settings.plugins.permissions.audit')}</summary>
<ol className="mt-2 space-y-1">{snapshot.audit.map((entry, index) => <li key={index} className="break-words">
<time dateTime={new Date(entry.createdAt).toISOString()}>{new Date(entry.createdAt).toLocaleString(resolvedLocale)}</time>{' · '}
{entry.event}{entry.permission ? ` · ${entry.permission}` : ''}{entry.scope ? ` · ${entry.scope}` : ''}
</li>)}</ol>
</details>
<p className="text-xs text-muted-foreground">{t('settings.plugins.permissions.revokeNote')}</p>
<Button variant="destructive" size="sm" disabled={busy} onClick={() => void revoke()}>{t('settings.plugins.permissions.revoke')}</Button>
</>}
</section>;
}
@@ -0,0 +1,14 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import React from 'react';
import { renderToStaticMarkup } from 'react-dom/server';
import { PluginReadme } from './PluginReadme';
test('README never creates active HTML, remote media or script links', () => {
const html = renderToStaticMarkup(<PluginReadme markdown={'# Example\n\n<script>alert(1)</script>\n\n<img src="https://tracker.example/pixel">\n\n![track](https://tracker.example/x.svg)\n\n[run](javascript:alert%281%29)\n\n[docs](https://example.com)\n\n```html\n<iframe src="file:///secret">\n```'} />);
assert.doesNotMatch(html, /<(script|img|iframe|svg)\b/);
assert.doesNotMatch(html, /href="(javascript|file):/);
assert.match(html, /href="https:\/\/example.com"/);
assert.match(html, /rel="noopener noreferrer"/);
assert.match(html, /&lt;script&gt;/);
});
+37
View File
@@ -0,0 +1,37 @@
import React, { useMemo } from 'react';
import { fromMarkdown } from 'mdast-util-from-markdown';
import type { RootContent, Root } from 'mdast';
function renderNode(node: Root | RootContent, key: number, depth = 0): React.ReactNode {
if (depth > 24) return null;
const children = 'children' in node ? node.children.map((child, index) => renderNode(child as RootContent, index, depth + 1)) : null;
switch (node.type) {
case 'root': return <React.Fragment key={key}>{children}</React.Fragment>;
case 'heading': return <h4 key={key} className="mt-5 font-semibold">{children}</h4>;
case 'paragraph': return <p key={key} className="my-2 leading-relaxed">{children}</p>;
case 'text': return node.value;
case 'strong': return <strong key={key}>{children}</strong>;
case 'emphasis': return <em key={key}>{children}</em>;
case 'inlineCode': return <code key={key} className="rounded bg-muted px-1">{node.value}</code>;
case 'code': return <pre key={key} className="my-3 overflow-auto rounded-lg bg-muted p-3 text-xs"><code>{node.value}</code></pre>;
case 'list': return <ul key={key} className="my-2 list-disc pl-5">{children}</ul>;
case 'listItem': return <li key={key}>{children}</li>;
case 'blockquote': return <blockquote key={key} className="border-l-2 pl-3 text-muted-foreground">{children}</blockquote>;
case 'break': return <br key={key} />;
case 'thematicBreak': return <hr key={key} />;
case 'link': {
let safe = false;
try { const url = new URL(node.url); safe = url.protocol === 'https:' && !url.username && !url.password; } catch { /* relative and unsafe links stay text */ }
return safe ? <a key={key} href={node.url} target="_blank" rel="noopener noreferrer" className="underline">{children}</a> : <span key={key}>{children}</span>;
}
// Never embed HTML, media, SVG, iframes, remote images, or reference URLs.
case 'image': return <span key={key} className="text-muted-foreground">[{node.alt ?? 'image'}]</span>;
case 'html': return <span key={key}>{node.value}</span>;
default: return <React.Fragment key={key}>{children}</React.Fragment>;
}
}
export function PluginReadme({ markdown }: { markdown: string }) {
const tree = useMemo(() => fromMarkdown(markdown.slice(0, 256 * 1024)), [markdown]);
return <div className="break-words text-sm">{renderNode(tree, 0)}</div>;
}
@@ -0,0 +1,43 @@
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 { I18nProvider } from '../../../application/i18n/I18nProvider';
import { PluginPackageManager } from './PluginPackageManager';
import type { usePluginManager } from '../../../application/state/usePluginManager';
test('state pill exposes actual state, opposite action, focus styling and pending disable without granting permissions', async () => {
const globals = globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean };
const previous = globals.IS_REACT_ACT_ENVIRONMENT; globals.IS_REACT_ACT_ENVIRONMENT = true;
const calls: unknown[] = [];
const plugin = { id: 'com.test.pill', activeVersion: '1.0.0', enabled: true, manifest: { name: 'Pill' }, runtime: {} } as NetcattyInstalledPlugin;
const manager = { available: true, plugins: [plugin], busy: false, setEnabled: async (...args: unknown[]) => { calls.push(args); } } as unknown as ReturnType<typeof usePluginManager>;
let renderer: ReactTestRenderer;
const render = (enabled: boolean, busy = false) => <I18nProvider locale="en"><PluginPackageManager manager={{ ...manager, busy, plugins: [{ ...plugin, enabled }] }} /></I18nProvider>;
try {
await act(async () => { renderer = create(render(true)); });
const pill = () => renderer.root.findAllByType('button').find((node) => node.props['aria-pressed'] !== undefined)!;
assert.equal(pill().props['aria-pressed'], true);
const logoSlot = renderer.root.findAllByType('div').find((node) => node.props.className === 'flex h-12 w-12 shrink-0 items-center justify-center');
assert.ok(logoSlot, 'logo keeps its 48px layout slot without an extra decorative background');
assert.equal(pill().props['aria-label'], 'Disable Pill');
assert.ok(pill().children.includes('Enabled'));
assert.match(pill().props.className, /rounded-full/);
assert.match(pill().props.className, /border-0/);
assert.doesNotMatch(pill().props.className, /border-primary|border-border/);
assert.match(pill().props.className, /bg-primary\/10/);
assert.match(pill().props.className, /focus-visible:ring-2/);
assert.match(pill().props.style.color, /--primary/);
await act(async () => pill().props.onClick());
assert.deepEqual(calls, [['com.test.pill', false]]);
await act(async () => renderer.update(render(false, true)));
assert.equal(pill().props['aria-pressed'], false);
assert.equal(pill().props['aria-label'], 'Enable Pill');
assert.ok(pill().children.includes('Disabled'));
assert.equal(pill().props.disabled, true);
assert.equal(pill().props['aria-busy'], true);
assert.match(pill().props.className, /bg-muted/);
assert.equal(pill().props.style, undefined);
await act(async () => renderer.unmount());
} finally { globals.IS_REACT_ACT_ENVIRONMENT = previous; }
});
@@ -8,3 +8,40 @@ test("SFTP settings imports the shared Toggle used by its option rows", () => {
assert.match(source, /<Toggle\b/);
assert.match(source, /Toggle,\s*\n\s*Select,/);
});
function functionBody(functionName: string): string {
const start = source.indexOf(`const ${functionName} = useCallback`);
assert.notEqual(start, -1, `${functionName} should exist`);
const next = source.indexOf("\n const ", start + 1);
return source.slice(start, next === -1 ? source.length : next);
}
test("removing an SFTP file association confirms with the in-app dialog", () => {
assert.match(source, /import \{ ConfirmDialog \} from "\.\.\/\.\.\/ui\/confirm-dialog";/);
assert.doesNotMatch(source, /window\.confirm|globalThis\.confirm|\bconfirm\(/);
assert.match(
source,
/const \[removeConfirmExt, setRemoveConfirmExt\] = useState<string \| null>\(null\);/,
);
const handleRemove = functionBody("handleRemove");
assert.match(handleRemove, /setRemoveConfirmExt\(extension\)/);
assert.doesNotMatch(handleRemove, /removeAssociation\(/);
const handleConfirm = functionBody("handleConfirmRemove");
assert.match(handleConfirm, /if \(!removeConfirmExt\) return;/);
assert.match(handleConfirm, /removeAssociation\(removeConfirmExt\);\s*setRemoveConfirmExt\(null\);/);
const dialogStart = source.indexOf("<ConfirmDialog");
assert.notEqual(dialogStart, -1, "ConfirmDialog should be rendered");
const dialog = source.slice(dialogStart);
assert.match(dialog, /open=\{removeConfirmExt !== null\}/);
assert.match(dialog, /settings\.sftpFileAssociations\.removeConfirm/);
assert.match(dialog, /confirmLabel=\{t\('action\.remove'\)\}/);
assert.match(dialog, /\bdestructive\b/);
assert.match(dialog, /onConfirm=\{handleConfirmRemove\}/);
const onOpenChange = dialog.slice(dialog.indexOf("onOpenChange"), dialog.indexOf("onConfirm"));
assert.match(onOpenChange, /if \(!open\) setRemoveConfirmExt\(null\);/);
assert.doesNotMatch(onOpenChange, /removeAssociation\(/);
});
@@ -9,6 +9,7 @@ import { useSettingsState } from "../../../application/state/useSettingsState";
import type { FileOpenerType, SystemAppInfo } from "../../../lib/sftpFileUtils";
import { netcattyBridge } from "../../../infrastructure/services/netcattyBridge";
import { Button } from "../../ui/button";
import { ConfirmDialog } from "../../ui/confirm-dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip";
import {
SectionHeader,
@@ -52,6 +53,7 @@ export default function SettingsFileAssociationsTab() {
const defaultOpener = getDefaultOpener();
const [editingExtension, setEditingExtension] = useState<string | null>(null);
const [isSelectingDefaultApp, setIsSelectingDefaultApp] = useState(false);
const [removeConfirmExt, setRemoveConfirmExt] = useState<string | null>(null);
const defaultOpenerValue = useMemo(() => {
if (!defaultOpener) return 'ask';
@@ -60,10 +62,14 @@ export default function SettingsFileAssociationsTab() {
}, [defaultOpener]);
const handleRemove = useCallback((extension: string) => {
if (confirm(t('settings.sftpFileAssociations.removeConfirm', { ext: extension === 'file' ? t('sftp.opener.noExtension') : extension }))) {
removeAssociation(extension);
}
}, [removeAssociation, t]);
setRemoveConfirmExt(extension);
}, []);
const handleConfirmRemove = useCallback(() => {
if (!removeConfirmExt) return;
removeAssociation(removeConfirmExt);
setRemoveConfirmExt(null);
}, [removeAssociation, removeConfirmExt]);
const handleSelectDefaultSystemApp = useCallback(async () => {
setIsSelectingDefaultApp(true);
@@ -372,6 +378,16 @@ export default function SettingsFileAssociationsTab() {
</table>
</div>
)}
<ConfirmDialog
open={removeConfirmExt !== null}
title={removeConfirmExt ? t('settings.sftpFileAssociations.removeConfirm', { ext: removeConfirmExt === 'file' ? t('sftp.opener.noExtension') : removeConfirmExt }) : ''}
confirmLabel={t('action.remove')}
destructive
onOpenChange={(open) => {
if (!open) setRemoveConfirmExt(null);
}}
onConfirm={handleConfirmRemove}
/>
</SettingsTabContent>
);
}
+44 -17
View File
@@ -1,9 +1,14 @@
import { usePluginManager } from '../../../application/state/usePluginManager';
import { PluginManagementDialog } from './PluginManagementDialog';
import { PluginMarketplace } from './PluginMarketplace';
import { PluginPackageManager } from './PluginPackageManager';
import { FolderOpen } from 'lucide-react';
import React, { useEffect, useMemo, useState } from 'react';
import { normalizePluginKeyboardEvent } from '../../../application/state/pluginKeybindings';
import { usePluginContributions } from '../../../application/state/usePluginContributions';
import { useI18n } from '../../../application/i18n/I18nProvider';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '../../ui/tabs';
import { Button } from '../../ui/button';
import { Input } from '../../ui/input';
import { SettingsAnchor, SettingsTabContent } from '../settings-ui';
@@ -278,8 +283,11 @@ export function PluginSettingField({
);
}
export default function SettingsPluginsTab() {
function PluginManagementContent({ handoff }: { handoff: (action: () => void) => void }) {
const { t } = useI18n();
const manager = usePluginManager();
const [selectedInstalled, setSelectedInstalled] = useState<string | null>(null);
const [pluginView, setPluginView] = useState<'installed' | 'marketplace'>('installed');
const availableFonts = useAvailableFonts();
const scopeCatalog = usePluginSettingScopeCatalog();
const [scopeIds, setScopeIds] = useState<Partial<Record<NetcattyPluginSettingScopeKind, string>>>({});
@@ -292,21 +300,26 @@ export default function SettingsPluginsTab() {
}), [scopeIds]);
const contributions = usePluginContributions(query);
const contextualScopes = useMemo(() => new Set(contributions.snapshot.plugins
.filter((plugin) => plugin.id === selectedInstalled)
.flatMap((plugin) => plugin.settings.map((setting) => setting.scope))
.filter((scope): scope is NetcattyPluginSettingScopeKind => scope !== 'application')),
[contributions.snapshot.plugins]);
const hasVisibleContributions = contributions.snapshot.plugins.some((plugin) => (
plugin.settings.some((setting) => setting.visible)
|| plugin.views.some((view) => view.visible && view.location === 'settings')
));
[contributions.snapshot.plugins, selectedInstalled]);
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="mx-auto w-full max-w-3xl space-y-6 px-8 py-8">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
<div className="space-y-5">
<Tabs value={pluginView} onValueChange={(value) => { setSelectedInstalled(null); setPluginView(value as 'installed' | 'marketplace'); }} className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<TabsList className="h-auto flex-wrap justify-start bg-muted/50">
<TabsTrigger value="installed">{t('settings.plugins.manager.installed')} <span className="ml-2 text-muted-foreground">{manager.plugins.length}</span></TabsTrigger>
<TabsTrigger value="marketplace">{t('settings.plugins.market.title')}</TabsTrigger>
</TabsList>
<Button variant="outline" size="sm" disabled={!manager.available || manager.busy} onClick={() => { setPluginView('installed'); void manager.importPackage(); }}><FolderOpen size={14} className="mr-2" />{t('settings.plugins.manager.import')}</Button>
</div>
{manager.credentialStorageAvailable === false && <p role="status" className="text-xs text-muted-foreground">{t('settings.plugins.manager.noVault')}</p>}
<TabsContent value="marketplace" className="m-0 space-y-6"><PluginMarketplace installed={manager.plugins} /></TabsContent>
<TabsContent value="installed" className="m-0 space-y-6">
<PluginPackageManager manager={manager} onSelectionChange={setSelectedInstalled} />
{contextualScopes.size > 0 && (
<section className="grid gap-3 rounded-lg border border-border/70 bg-muted/10 p-4 sm:grid-cols-2" aria-label={t('settings.plugins.scopeTargets')}>
{[...contextualScopes].map((kind) => (
@@ -326,7 +339,7 @@ export default function SettingsPluginsTab() {
)}
{contributions.loading && <p className="text-sm text-muted-foreground">{t('settings.plugins.loading')}</p>}
{contributions.error && <p role="alert" className="text-sm text-destructive">{contributions.error.message}</p>}
{contributions.snapshot.plugins.map((plugin) => {
{contributions.snapshot.plugins.filter((plugin) => plugin.id === selectedInstalled).map((plugin) => {
const settings = plugin.settings.filter((setting) => setting.visible);
const views = plugin.views.filter((view) => view.visible && view.location === 'settings');
if (!settings.length && !views.length) return null;
@@ -356,18 +369,32 @@ export default function SettingsPluginsTab() {
<div className="font-mono text-[10px] text-muted-foreground">{view.id}</div>
</div>
</div>
<Button type="button" variant="outline" size="sm" onClick={() => requestOpenPluginView({
<Button type="button" variant="outline" size="sm" onClick={() => handoff(() => requestOpenPluginView({
viewId: view.id,
context: { 'netcatty.surface': 'settings' },
})}>{t('common.open')}</Button>
}))}>{t('settings.plugins.openView')}</Button>
</div>
))}
</section>
);
})}
{!contributions.loading && contributions.available && !hasVisibleContributions && (
<p className="text-sm text-muted-foreground">{t('settings.plugins.empty')}</p>
)}
</TabsContent>
</Tabs>
</div>
);
}
export default function SettingsPluginsTab() {
const { t } = useI18n();
return (
<SettingsTabContent value="plugins">
<SettingsAnchor anchorId="plugins-root" className="space-y-5">
<div>
<h2 className="text-xl font-semibold">{t('settings.plugins.title')}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t('settings.plugins.description')}</p>
</div>
<PluginManagementDialog>{(handoff) => <PluginManagementContent handoff={handoff} />}</PluginManagementDialog>
</SettingsAnchor>
</SettingsTabContent>
);
@@ -77,13 +77,13 @@ export const ClaudeCodeCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.claude.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -102,7 +102,7 @@ export const ClaudeCodeCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.claude.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -117,13 +117,13 @@ export const CodebuddyCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.codebuddy.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -142,7 +142,7 @@ export const CodebuddyCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codebuddy.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -108,13 +108,13 @@ export const CodexConnectionCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t('ai.codex.path')}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -133,7 +133,7 @@ export const CodexConnectionCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t('ai.codex.customPathPlaceholder')}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!customPath.trim()}>
<RefreshCw size={14} className="mr-1.5" />
@@ -61,13 +61,13 @@ export const CopilotCliCard: React.FC<{
</div>
{found && (
<div className="flex items-center gap-2 text-xs">
<span className="text-muted-foreground">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground truncate">{pathInfo.path}</span>
<div className="flex items-center gap-2 text-xs min-w-0">
<span className="text-muted-foreground shrink-0">{t(`${i18nPrefix}.path`)}</span>
<span className="font-mono text-foreground min-w-0 flex-1 truncate" title={pathInfo.path ?? undefined}>{pathInfo.path}</span>
{pathInfo.version && (
<>
<span className="text-muted-foreground">|</span>
<span className="text-muted-foreground">{pathInfo.version}</span>
<span className="text-muted-foreground shrink-0">|</span>
<span className="text-muted-foreground shrink-0">{pathInfo.version}</span>
</>
)}
</div>
@@ -87,7 +87,7 @@ export const CopilotCliCard: React.FC<{
value={customPath}
onChange={(e) => onCustomPathChange(e.target.value)}
placeholder={t(`${i18nPrefix}.customPathPlaceholder`)}
className="flex-1 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
className="flex-1 min-w-0 h-8 rounded-md border border-input bg-background px-3 text-sm font-mono placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
/>
)}
<Button variant="outline" size="sm" onClick={onRecheckPath} disabled={!allowEmptyCheck && !customPath.trim()}>
@@ -0,0 +1,132 @@
import test from "node:test";
import assert from "node:assert/strict";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { ClaudeCodeCard } from "./ClaudeCodeCard";
import { CodebuddyCard } from "./CodebuddyCard";
import { CodexConnectionCard } from "./CodexConnectionCard";
import { CopilotCliCard } from "./CopilotCliCard";
const LONG_PATH = "C:\\Users\\70541\\AppData\\Local\\Microsoft\\WinGet\\Packages\\Anthropic.ClaudeCode_Microsoft.Winget.Source_8wekyb3d8bbwe\\claude.exe";
const VERSION = "9.9.9-test";
function classList(tag: string): Set<string> {
const match = tag.match(/class="([^"]*)"/);
assert.ok(match, `missing class on ${tag}`);
return new Set(match[1].split(/\s+/));
}
function assertClasses(tag: string, expected: string[]) {
const have = classList(tag);
for (const name of expected) {
assert.ok(have.has(name), `expected ${name} on ${tag}`);
}
}
function openingTagContaining(markup: string, text: string): string {
const idx = markup.indexOf(`>${text}<`);
assert.ok(idx >= 0, `missing text node ${text}`);
const start = markup.lastIndexOf("<", idx);
return markup.slice(start, idx + 1);
}
function pathRowClass(markup: string): string {
const idx = markup.indexOf(LONG_PATH);
assert.ok(idx >= 0, "missing path text");
const matches = [...markup.slice(0, idx).matchAll(/<div\b[^>]*class="([^"]*)"/g)];
assert.ok(matches.length > 0, "missing path row");
return matches[matches.length - 1][1];
}
function assertLongPathTruncates(markup: string) {
const pathTag = openingTagContaining(markup, LONG_PATH);
assertClasses(pathTag, ["truncate", "min-w-0", "flex-1"]);
assert.match(pathTag, new RegExp(`title="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
assert.ok(pathRowClass(markup).split(/\s+/).includes("min-w-0"), "path row missing min-w-0");
const versionTag = openingTagContaining(markup, VERSION);
assertClasses(versionTag, ["shrink-0"]);
const input = markup.match(/<input\b[^>]*>/);
assert.ok(input, "missing custom path input");
assertClasses(input[0], ["flex-1", "min-w-0"]);
assert.match(input[0], new RegExp(`value="${LONG_PATH.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"`));
}
test("Claude Code card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<ClaudeCodeCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
configDir=""
onConfigDirChange={() => {}}
settingsPath=""
onSettingsPathChange={() => {}}
envText=""
onEnvTextChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Codex card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CodexConnectionCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
integration={null}
loginSession={null}
isLoading={false}
error={null}
onRefresh={() => {}}
onConnect={() => {}}
onCancel={() => {}}
onOpenUrl={() => {}}
onLogout={() => {}}
appServerRuntime="sdk"
appServerStatus={null}
onAppServerRuntimeChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Codebuddy card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CodebuddyCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
internetEnv=""
onInternetEnvChange={() => {}}
envText=""
onEnvTextChange={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
test("Copilot CLI card truncates a long CLI path inside the card", () => {
const markup = renderToStaticMarkup(
<CopilotCliCard
pathInfo={{ path: LONG_PATH, version: VERSION, available: true }}
isResolvingPath={false}
customPath={LONG_PATH}
onCustomPathChange={() => {}}
onRecheckPath={() => {}}
onResetPath={() => {}}
/>,
);
assertLongPathTruncates(markup);
});
@@ -0,0 +1,13 @@
/* Container breakpoints account for the Settings sidebar, unlike viewport breakpoints. */
.plugin-marketplace { container-type: inline-size; }
.plugin-marketplace-browser { display: grid; gap: 16px; min-width: 0; }
.plugin-marketplace-catalog { min-width: 0; }
.plugin-marketplace-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr)); gap: 12px; }
.plugin-marketplace-browser--selected .plugin-marketplace-catalog { display: none; }
.plugin-marketplace-detail { overflow-wrap: anywhere; }
@container (min-width: 820px) {
.plugin-marketplace-browser--selected { grid-template-columns: 248px minmax(0, 1fr); align-items: start; }
.plugin-marketplace-browser--selected .plugin-marketplace-catalog { display: block; max-height: min(680px, 65vh); overflow-y: auto; padding: 2px; }
.plugin-marketplace-browser--selected .plugin-marketplace-grid { grid-template-columns: minmax(0, 1fr); }
.plugin-marketplace-browser--selected .plugin-marketplace-detail { max-height: min(680px, 65vh); overflow-y: auto; }
}
+9 -12
View File
@@ -31,20 +31,17 @@
<text x="85" y="85.0" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="223.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="220.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="291.4" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="430.9" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="570.5" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="710.1" y="488" fill="#e6edf3" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#e6edf3" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#30363d" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#0d1117" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#fff" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -31,20 +31,17 @@
<text x="85" y="85.0" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">7K</text>
<text x="43" y="271.5" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" transform="rotate(-90, 43, 271.5)">GitHub Stars</text>
<text x="90.0" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jan 2026</text>
<text x="223.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="296.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="366.6" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">May 2026</text>
<text x="439.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="509.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jul 2026</text>
<text x="582.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="654.8" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Sep 2026</text>
<text x="725.2" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="220.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Mar 2026</text>
<text x="291.4" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Apr 2026</text>
<text x="430.9" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Jun 2026</text>
<text x="570.5" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Aug 2026</text>
<text x="710.1" y="488" fill="#000" font-size="16" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Oct 2026</text>
<text x="50%" y="525" fill="#000" font-size="17" font-family="Handlee, cursive" font-weight="bold" text-anchor="middle">Date</text>
<line x1="90" y1="80" x2="90" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<line x1="90" y1="463" x2="765" y2="463" stroke="#222" stroke-width="2.5" filter="url(#xkcdify)"/>
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 92.0,461.8 92.3,461.7 C 93.1,461.6 93.9,461.4 94.7,461.2 C 95.5,461.1 96.2,460.8 97.0,460.8 C 97.8,460.7 98.6,460.7 99.4,460.7 C 100.2,460.7 100.9,460.6 101.7,460.6 C 102.5,460.5 103.3,460.5 104.1,460.4 C 104.8,460.4 105.2,460.3 106.4,460.3 C 107.6,460.2 109.9,460.1 111.1,460.1 C 112.3,460.1 112.7,460.0 113.4,460.0 C 114.2,460.0 115.0,460.0 115.8,459.9 C 116.6,459.9 117.3,459.9 118.1,459.9 C 118.9,459.9 119.7,459.9 120.5,459.8 C 121.2,459.8 122.0,459.7 122.8,459.7 C 123.6,459.6 124.4,459.6 125.2,459.4 C 125.9,459.3 126.7,459.0 127.5,458.7 C 128.3,458.3 129.1,457.6 129.8,457.3 C 130.6,457.1 131.4,457.2 132.2,457.1 C 133.0,457.1 133.8,457.1 134.5,457.0 C 135.3,457.0 135.7,457.0 136.9,456.9 C 138.0,456.9 140.0,456.9 141.6,456.9 C 143.1,456.8 145.1,456.8 146.2,456.8 C 147.4,456.7 147.8,456.7 148.6,456.7 C 149.4,456.6 150.2,456.6 150.9,456.5 C 151.7,456.5 152.5,456.5 153.3,456.4 C 154.1,456.4 154.8,456.4 155.6,456.4 C 156.4,456.4 156.8,456.4 158.0,456.3 C 159.1,456.3 160.7,456.2 162.7,456.2 C 164.6,456.2 167.7,456.2 169.7,456.2 C 171.6,456.1 172.0,456.2 174.4,456.1 C 176.7,456.1 181.8,455.9 183.8,455.9 C 185.7,455.8 184.9,455.9 186.1,455.8 C 187.3,455.8 188.8,455.8 190.8,455.8 C 192.7,455.8 193.9,455.7 197.8,455.7 C 201.7,455.6 211.1,455.5 214.2,455.5 C 217.3,455.5 215.8,455.5 216.6,455.4 C 217.3,455.4 217.7,456.1 218.9,455.4 C 220.1,454.7 222.4,454.1 223.6,451.3 C 224.8,448.4 225.2,440.7 225.9,438.3 C 226.7,435.9 227.5,437.3 228.3,436.9 C 229.1,436.5 229.8,436.3 230.6,436.0 C 231.4,435.8 232.2,435.6 233.0,435.4 C 233.8,435.3 234.5,435.3 235.3,435.2 C 236.1,435.1 236.9,434.9 237.7,434.8 C 238.4,434.8 239.2,434.8 240.0,434.8 C 240.8,434.7 241.6,434.5 242.3,434.4 C 243.1,434.3 243.9,434.3 244.7,434.2 C 245.5,434.1 246.2,433.7 247.0,433.6 C 247.8,433.5 248.6,433.5 249.4,433.5 C 250.2,433.4 250.5,433.2 251.7,433.1 C 252.9,433.0 255.2,433.0 256.4,432.9 C 257.6,432.9 258.0,432.8 258.8,432.7 C 259.5,432.7 260.3,432.7 261.1,432.6 C 261.9,432.5 262.7,432.2 263.4,432.1 C 264.2,431.9 265.0,432.6 265.8,431.7 C 266.6,430.8 267.3,427.6 268.1,426.6 C 268.9,425.5 269.7,425.7 270.5,425.5 C 271.2,425.3 272.0,425.5 272.8,425.4 C 273.6,425.3 274.4,424.9 275.2,424.6 C 275.9,424.4 276.7,424.2 277.5,423.7 C 278.3,423.2 279.1,422.2 279.8,421.6 C 280.6,421.0 281.4,420.4 282.2,420.0 C 283.0,419.5 283.8,419.2 284.5,419.0 C 285.3,418.7 286.1,418.6 286.9,418.5 C 287.7,418.3 288.4,418.4 289.2,418.2 C 290.0,418.1 290.8,417.9 291.6,417.8 C 292.3,417.7 293.1,417.8 293.9,417.7 C 294.7,417.6 295.5,417.6 296.2,417.5 C 297.0,417.4 297.8,417.4 298.6,417.0 C 299.4,416.6 300.2,415.5 300.9,415.0 C 301.7,414.5 302.5,414.3 303.3,414.0 C 304.1,413.6 304.8,414.3 305.6,412.8 C 306.4,411.2 307.2,406.4 308.0,404.6 C 308.8,402.7 309.5,402.4 310.3,401.7 C 311.1,400.9 311.9,400.4 312.7,399.9 C 313.4,399.3 314.2,398.8 315.0,398.4 C 315.8,398.0 316.6,397.6 317.3,397.3 C 318.1,397.1 318.9,397.0 319.7,396.8 C 320.5,396.6 321.2,396.3 322.0,396.0 C 322.8,395.7 323.6,395.2 324.4,395.0 C 325.2,394.7 325.9,394.8 326.7,394.5 C 327.5,394.2 328.3,393.8 329.1,393.5 C 329.8,393.1 330.6,392.9 331.4,392.6 C 332.2,392.4 333.0,392.2 333.8,392.0 C 334.5,391.9 335.3,391.8 336.1,391.7 C 336.9,391.6 337.7,391.4 338.4,391.2 C 339.2,391.0 340.0,390.9 340.8,390.7 C 341.6,390.5 342.3,390.3 343.1,390.0 C 343.9,389.6 344.7,389.0 345.5,388.8 C 346.2,388.5 347.0,388.4 347.8,388.2 C 348.6,388.1 349.4,387.9 350.2,387.8 C 350.9,387.7 351.7,387.6 352.5,387.5 C 353.3,387.4 354.1,387.2 354.8,387.2 C 355.6,387.1 356.4,387.1 357.2,387.0 C 358.0,386.9 358.8,386.7 359.5,386.4 C 360.3,386.1 361.1,385.6 361.9,385.3 C 362.7,385.0 363.4,384.6 364.2,384.4 C 365.0,384.2 365.8,384.1 366.6,384.0 C 367.3,383.9 368.1,383.8 368.9,383.6 C 369.7,383.3 370.5,382.8 371.2,382.6 C 372.0,382.4 372.8,382.4 373.6,382.3 C 374.4,382.2 375.2,382.0 375.9,381.8 C 376.7,381.6 377.5,381.4 378.3,381.3 C 379.1,381.1 379.8,380.8 380.6,380.7 C 381.4,380.5 382.2,380.4 383.0,380.2 C 383.8,379.9 384.5,379.6 385.3,379.3 C 386.1,379.1 386.9,379.0 387.7,378.7 C 388.4,378.5 389.2,378.2 390.0,377.9 C 390.8,377.6 391.6,377.4 392.3,377.1 C 393.1,376.8 393.9,376.8 394.7,376.4 C 395.5,376.0 396.2,375.1 397.0,374.7 C 397.8,374.3 398.6,374.3 399.4,374.1 C 400.2,374.0 400.9,374.0 401.7,373.9 C 402.5,373.8 403.3,373.7 404.1,373.5 C 404.8,373.4 405.6,373.6 406.4,373.3 C 407.2,372.9 408.0,371.8 408.8,371.4 C 409.5,371.0 410.3,371.1 411.1,370.8 C 411.9,370.6 412.7,370.1 413.4,369.9 C 414.2,369.7 415.0,369.8 415.8,369.6 C 416.6,369.4 417.3,368.9 418.1,368.7 C 418.9,368.5 419.7,368.5 420.5,368.4 C 421.2,368.3 422.0,368.3 422.8,368.1 C 423.6,367.9 424.4,367.6 425.2,367.1 C 425.9,366.6 426.7,365.5 427.5,365.0 C 428.3,364.5 429.1,364.2 429.8,363.9 C 430.6,363.5 431.4,363.4 432.2,362.7 C 433.0,361.9 433.8,360.2 434.5,359.4 C 435.3,358.6 436.1,358.3 436.9,357.8 C 437.7,357.3 438.4,357.2 439.2,356.5 C 440.0,355.8 440.8,354.2 441.6,353.5 C 442.3,352.8 443.1,352.7 443.9,352.3 C 444.7,351.9 445.5,351.5 446.2,351.1 C 447.0,3Line truncated
<circle cx="765.0" cy="99.0" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6652</text>
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<path d="M 90.0,462.2 C 90.4,462.1 91.9,461.8 92.3,461.7 C 93.1,461.6 93.8,461.4 94.6,461.2 C 95.3,461.1 96.1,460.8 96.9,460.8 C 97.6,460.7 98.4,460.7 99.2,460.7 C 99.9,460.7 100.7,460.6 101.4,460.6 C 102.2,460.5 103.0,460.5 103.7,460.4 C 104.5,460.4 104.9,460.3 106.0,460.3 C 107.2,460.2 109.4,460.1 110.6,460.1 C 111.7,460.1 112.1,460.0 112.9,460.0 C 113.6,460.0 114.4,460.0 115.2,459.9 C 115.9,459.9 116.7,459.9 117.5,459.9 C 118.2,459.9 119.0,459.9 119.7,459.8 C 120.5,459.8 121.3,459.7 122.0,459.7 C 122.8,459.6 123.6,459.6 124.3,459.4 C 125.1,459.3 125.8,459.0 126.6,458.7 C 127.4,458.3 128.1,457.6 128.9,457.3 C 129.7,457.1 130.4,457.2 131.2,457.1 C 131.9,457.1 132.7,457.1 133.5,457.0 C 134.2,457.0 134.6,457.0 135.8,456.9 C 136.9,456.9 138.8,456.9 140.3,456.9 C 141.9,456.8 143.8,456.8 144.9,456.8 C 146.1,456.7 146.4,456.7 147.2,456.7 C 148.0,456.6 148.7,456.6 149.5,456.5 C 150.3,456.5 151.0,456.5 151.8,456.4 C 152.5,456.4 153.3,456.4 154.1,456.4 C 154.8,456.4 155.2,456.4 156.4,456.3 C 157.5,456.3 159.0,456.2 160.9,456.2 C 162.8,456.2 165.9,456.2 167.8,456.2 C 169.7,456.1 170.1,456.2 172.4,456.1 C 174.7,456.1 179.6,455.9 181.5,455.9 C 183.4,455.8 182.7,455.9 183.8,455.8 C 185.0,455.8 186.5,455.8 188.4,455.8 C 190.3,455.8 191.4,455.7 195.3,455.7 C 199.1,455.6 208.2,455.5 211.3,455.5 C 214.3,455.5 212.8,455.5 213.6,455.4 C 214.3,455.4 214.7,456.1 215.8,455.4 C 217.0,454.7 219.3,454.1 220.4,451.3 C 221.6,448.4 221.9,440.7 222.7,438.3 C 223.5,435.9 224.2,437.3 225.0,436.9 C 225.8,436.5 226.5,436.3 227.3,436.0 C 228.1,435.8 228.8,435.6 229.6,435.4 C 230.3,435.3 231.1,435.3 231.9,435.2 C 232.6,435.1 233.4,434.9 234.2,434.8 C 234.9,434.8 235.7,434.8 236.4,434.8 C 237.2,434.7 238.0,434.5 238.7,434.4 C 239.5,434.3 240.3,434.3 241.0,434.2 C 241.8,434.1 242.5,433.7 243.3,433.6 C 244.1,433.5 244.8,433.5 245.6,433.5 C 246.4,433.4 246.7,433.2 247.9,433.1 C 249.0,433.0 251.3,433.0 252.5,432.9 C 253.6,432.9 254.0,432.8 254.7,432.7 C 255.5,432.7 256.3,432.7 257.0,432.6 C 257.8,432.5 258.6,432.2 259.3,432.1 C 260.1,431.9 260.8,432.6 261.6,431.7 C 262.4,430.8 263.1,427.6 263.9,426.6 C 264.7,425.5 265.4,425.7 266.2,425.5 C 266.9,425.3 267.7,425.5 268.5,425.4 C 269.2,425.3 270.0,424.9 270.8,424.6 C 271.5,424.4 272.3,424.2 273.1,423.7 C 273.8,423.2 274.6,422.2 275.3,421.6 C 276.1,421.0 276.9,420.5 277.6,420.0 C 278.4,419.6 279.2,419.3 279.9,419.0 C 280.7,418.8 281.4,418.6 282.2,418.5 C 283.0,418.4 283.7,418.4 284.5,418.3 C 285.3,418.2 286.0,418.0 286.8,417.9 C 287.5,417.8 288.3,417.8 289.1,417.8 C 289.8,417.7 290.6,417.7 291.4,417.5 C 292.1,417.4 292.9,417.5 293.6,417.0 C 294.4,416.6 295.2,415.6 295.9,415.1 C 296.7,414.6 297.5,414.4 298.2,414.0 C 299.0,413.7 299.7,414.4 300.5,412.8 C 301.3,411.3 302.0,406.5 302.8,404.6 C 303.6,402.8 304.3,402.5 305.1,401.7 C 305.8,400.9 306.6,400.4 307.4,399.9 C 308.1,399.3 308.9,398.8 309.7,398.4 C 310.4,398.0 311.2,397.6 311.9,397.3 C 312.7,397.1 313.5,397.0 314.2,396.8 C 315.0,396.6 315.8,396.3 316.5,396.0 C 317.3,395.7 318.1,395.2 318.8,395.0 C 319.6,394.7 320.3,394.8 321.1,394.5 C 321.9,394.2 322.6,393.8 323.4,393.5 C 324.2,393.1 324.9,392.9 325.7,392.6 C 326.4,392.4 327.2,392.2 328.0,392.0 C 328.7,391.9 329.5,391.8 330.3,391.7 C 331.0,391.6 331.8,391.4 332.5,391.2 C 333.3,391.0 334.1,390.9 334.8,390.7 C 335.6,390.5 336.4,390.3 337.1,390.0 C 337.9,389.6 338.6,389.0 339.4,388.8 C 340.2,388.5 340.9,388.4 341.7,388.2 C 342.5,388.1 343.2,387.9 344.0,387.8 C 344.7,387.7 345.5,387.6 346.3,387.5 C 347.0,387.4 347.8,387.2 348.6,387.2 C 349.3,387.1 350.1,387.1 350.8,387.0 C 351.6,386.9 352.4,386.7 353.1,386.4 C 353.9,386.1 354.7,385.6 355.4,385.3 C 356.2,385.0 356.9,384.6 357.7,384.4 C 358.5,384.2 359.2,384.1 360.0,384.0 C 360.8,383.9 361.5,383.8 362.3,383.6 C 363.1,383.3 363.8,382.8 364.6,382.6 C 365.3,382.4 366.1,382.4 366.9,382.3 C 367.6,382.2 368.4,382.0 369.2,381.8 C 369.9,381.6 370.7,381.4 371.4,381.3 C 372.2,381.1 373.0,380.8 373.7,380.7 C 374.5,380.5 375.3,380.4 376.0,380.2 C 376.8,379.9 377.5,379.6 378.3,379.3 C 379.1,379.1 379.8,379.0 380.6,378.7 C 381.4,378.5 382.1,378.2 382.9,377.9 C 383.6,377.6 384.4,377.4 385.2,377.1 C 385.9,376.8 386.7,376.8 387.5,376.4 C 388.2,376.0 389.0,375.1 389.7,374.7 C 390.5,374.3 391.3,374.3 392.0,374.1 C 392.8,374.0 393.6,374.0 394.3,373.9 C 395.1,373.8 395.8,373.7 396.6,373.5 C 397.4,373.4 398.1,373.6 398.9,373.3 C 399.7,372.9 400.4,371.8 401.2,371.4 C 401.9,371.0 402.7,371.1 403.5,370.8 C 404.2,370.6 405.0,370.1 405.8,369.9 C 406.5,369.7 407.3,369.8 408.1,369.6 C 408.8,369.4 409.6,368.9 410.3,368.7 C 411.1,368.5 411.9,368.5 412.6,368.4 C 413.4,368.3 414.2,368.3 414.9,368.1 C 415.7,367.9 416.4,367.6 417.2,367.1 C 418.0,366.6 418.7,365.5 419.5,365.0 C 420.3,364.5 421.0,364.2 421.8,363.9 C 422.5,363.5 423.3,363.4 424.1,362.7 C 424.8,361.9 425.6,360.2 426.4,359.4 C 427.1,358.6 427.9,358.3 428.6,357.8 C 429.4,357.4 430.2,357.3 430.9,356.6 C 431.7,355.9 432.5,354.2 433.2,353.5 C 434.0,352.8 434.7,352.7 435.5,352.3 C 436.3,351.9 437.0,351.6 437.8,351.1 C 438.6,35Line truncated
<circle cx="765.0" cy="90.8" r="5" fill="#dd4528" stroke="#fff" stroke-width="2" filter="url(#xkcdify)"/>
<text x="753.0" y="77.0" fill="#dd4528" font-size="18" font-family="Handlee, cursive" font-weight="bold" text-anchor="end">6802</text>
<a href="https://github.com/carsteneu/mystarhistory" target="_blank" rel="noopener"><text x="763" y="527" font-size="13" font-family="Handlee, cursive" fill="#000" text-anchor="end">Made with carsteneu/mystarhistory</text></a>
</svg>

Before

Width:  |  Height:  |  Size: 36 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -0,0 +1,98 @@
# Batch 1 aggregate failure comparison
Frozen base: `e5bd294cddbf58dfe04c646b6291933d980498fd`. Current:
`9af123e5d7bdb15771d69fb427faacf32c46dfb3`. Same Node 24.19.0 executable,
same installed dependency tree, same sandbox and environment. Tests use existing
synthetic fixtures. No home/cache permission or native-build bypass was added.
The 39 entries below were observed before stopping the local full suite.
Later runner failures caused by that explicit termination are excluded. This
is not a successful aggregate run and is not proof that every full-suite test
was executed. The full suite was stopped after existing/environmental failures
to avoid presenting an incomplete run as green.
## Bounded comparison result
Targeted test-name patterns selected the original 39 failures across 15 files.
Thirty-eight fail on both frozen base and current with matching failure titles
and key error signatures. The persistence-volume assertion passes on both.
Three additional paired repetitions of that assertion also pass on both: four
passing runs per side including the initial comparison. Its code is unchanged;
the aggregate-only result remains a timing-sensitive limitation, not an
established current-only regression.
The first baseline rerun reproduced 11 entries: rows 1-2 and 4-12. It did not
reproduce row 3. No new source change fixes unrelated baseline failures.
| Rows | Signature and classification |
| --- | --- |
| 1-2, 14-16, 27-33, 35-39 | Host temp-dir creation fails outside writable roots (`ENOENT` from `tempDirBridge`). Both revisions fail. Environment constraint |
| 4-9 | Markdown comparison falls back to source; expected rich-preview elements are absent. Both revisions fail. Existing baseline/dependency behavior; no source repair in this scope |
| 10-12 | Native `pty.node` missing from normal build/prebuild lookup. Both revisions fail. Dependency/native setup constraint |
| 13 | UI algorithm list includes `ssh-ed25519-cert-v01@openssh.com` but installed ssh2 rejects it. Both revisions fail. Existing baseline/dependency behavior |
| 17-26 | Cursor discovery/SDK expectations differ (`false` vs `true`, then expected installation/auth DTO). Both revisions fail with matching assertion signatures. Existing environment/dependency behavior; no real account accessed |
| 34 | Electron bootstrap fetch fails and the binary is not installed. Both revisions fail. Dependency/network setup constraint |
| 3 | Persistence writes exceed aggregate threshold; isolated baseline/current and three repetitions each pass. Aggregate-only timing-sensitive assertion; no current-only failure reproduced |
## Original titles and first error
| # | Test file | Original title | First key error | Paired result |
| --- | --- | --- | --- | --- |
| 1 | `application/state/sftp/dedicatedTransferResume.test.ts` | large-history replace recovery retransfers retained completed children into its empty stage: late=false | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 2 | `application/state/sftp/dedicatedTransferResume.test.ts` | large-history replace recovery retransfers retained completed children into its empty stage: late=true | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 3 | `application/state/sftpTransferCenterStore.test.ts` | large directory progress coalesces synchronous persistence work | AssertionError [ERR_ASSERTION]: expected bounded persistence volume, wrote 2638624 characters for a 588644-character snapshot | base/current pass; aggregate-only |
| 4 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | literal Markdown comparisons render without changing the note source | AssertionError [ERR_ASSERTION]: a comparison must render as ordinary text | base/current same failure |
| 5 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | comparison variants render in paragraphs, tables and alongside HTML | assert.ok(!querySourceFallback(rootNode)) is false | base/current same failure |
| 6 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | issue 3205 author note survives source, preview and edit without rewriting | AssertionError [ERR_ASSERTION]: preview must render the author's note | base/current same failure |
| 7 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | editing a comparison emits Markdown that can be reopened | assert.ok(editable): actual null | base/current same failure |
| 8 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | preview task checkboxes still save explicit changes without rewriting comparisons | assert.ok(task): actual null | base/current same failure |
| 9 | `components/notes/InlineMarkdownEditor.unrenderableMarkdown.test.tsx` | switching between a comparison note and an unsupported note keeps the fallback scoped | assert.ok(!querySourceFallback(rootNode)) is false | base/current same failure |
| 10 | `components/terminal/runtime/serialInputSequences.test.ts` | components/terminal/runtime/serialInputSequences.test.ts | Failed to load native module: pty.node | base/current same failure |
| 11 | `components/terminal/runtime/terminalLinePaste.test.ts` | components/terminal/runtime/terminalLinePaste.test.ts | Failed to load native module: pty.node | base/current same failure |
| 12 | `components/terminal/runtime/terminalPasteCancellation.test.ts` | components/terminal/runtime/terminalPasteCancellation.test.ts | Failed to load native module: pty.node | base/current same failure |
| 13 | `domain/sshAlgorithmList.test.ts` | SUPPORTED_ALGORITHMS_BY_CATEGORY only lists algorithms ssh2 will actually accept | AssertionError [ERR_ASSERTION]: ssh-ed25519-cert-v01@openssh.com (serverHostKey) is in the UI list but ssh2 would reject it | base/current same failure |
| 14 | `electron/bridges/ai/liveShellProbe.test.cjs` | Bash invoked as sh cleans probe history | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 15 | `electron/bridges/ai/liveShellProbe.test.cjs` | Bash invoked as renamed-bash cleans probe history | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 16 | `electron/bridges/aiBridge.externalContext.test.cjs` | electron/bridges/aiBridge.externalContext.test.cjs | ENOENT creating host temp directory from sshBridge import | base/current same failure |
| 17 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports Cursor Agent CLI on PATH as installed without CLI login | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 18 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports Cursor SDK installed but unavailable without an API key | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 19 | `electron/bridges/aiBridge.test.cjs` | resolve-cli separates Cursor SDK installation from API key availability | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 20 | `electron/bridges/aiBridge.test.cjs` | resolve-cli ignores custom Cursor paths and stores the SDK sentinel path | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 21 | `electron/bridges/aiBridge.test.cjs` | resolve-cli exposes Cursor SDK support when installed and authenticated | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 22 | `electron/bridges/aiBridge.test.cjs` | resolve-cli exposes Cursor SDK support when API key is saved in settings | AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: | base/current same failure |
| 23 | `electron/bridges/aiBridge.test.cjs` | resolve-cli reports settings as Cursor auth source when settings and env keys both exist | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 24 | `electron/bridges/aiBridge.test.cjs` | resolve-cli can refresh shell env before resolving Cursor | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 25 | `electron/bridges/aiBridge.test.cjs` | discover exposes Cursor SDK support when API key is saved in settings | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 26 | `electron/bridges/aiBridge.test.cjs` | discover can refresh shell env before scanning Cursor | AssertionError [ERR_ASSERTION]: Expected values to be strictly equal: | base/current same failure |
| 27 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | listCopilotModels resolves a Windows .cmd shim to the copilot JS entry before the SDK sees it | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 28 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | listCopilotModels retains the official loader when its platform runtime is unavailable | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 29 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | resolveCopilotPlatformExecutable resolves the platform package exe from the loader root | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 30 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | resolveCopilotExecutableForSdk resolves npm's copilot.ps1 wrapper to the JS entry on Windows | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 31 | `electron/bridges/aiBridge/sdk/copilotDriver.test.cjs` | connectCopilotStdioPath directly selects the official loader's native runtime | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 32 | `electron/bridges/aiBridge/sdk/opencodeDriver.startup.test.cjs` | OpenCode releases the process-env gate after spawning separate catalog profiles | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 33 | `electron/bridges/aiBridge/sdk/opencodeDriver.test.cjs` | createOpenCodeProcessEnv prefers an existing opencode binary | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 34 | `electron/bridges/aiEchoSuppressionOrdering.test.cjs` | electron/bridges/aiEchoSuppressionOrdering.test.cjs | fetch failed; Electron failed to install correctly | base/current same failure |
| 35 | `electron/bridges/autoUpdateBridge.test.cjs` | recognizes packaged Linux FPM formats as auto-update capable | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 36 | `electron/bridges/autoUpdateBridge.test.cjs` | allows the update check to reach electron-updater for a packaged Linux deb | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 37 | `electron/bridges/autoUpdateBridge.test.cjs` | keeps the manual-update fallback for an unmarked Linux package | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 38 | `electron/bridges/autoUpdateBridge.test.cjs` | keeps AppImage auto-update support on Linux | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
| 39 | `electron/bridges/autoUpdateBridge.test.cjs` | install handler reports package-manager failures and restores the app state | ENOENT creating host temp directory from tempDirBridge | base/current same failure |
## Reproduction method
For each file above, run the existing test with the same title pattern in each
checkout. Pass the title pattern as an argument, without interpolating it as
shell code. Native/import failures occur before the title filter can execute.
```sh
node --test --import tsx --test-reporter=tap --test-name-pattern "<escaped original title>" <test file>
```
The comparison used bounded 90-second subprocess deadlines. None reached that
deadline. The persistence repeat used the same existing test and a 60-second
deadline. It added no load generator or attack payload.
All 15 test files and the business modules responsible for these signatures
are unchanged by batch 1. This classification does not resolve the two
independently reported grant-management review findings; those receive
separate source fixes and tests.
@@ -0,0 +1,82 @@
# Sandbox capability coverage
This matrix tracks expansion from Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`. Each accepted item requires a
canonical schema, generated SDK, broker, permission policy, quota and audit,
host adapter, lifecycle cleanup, synthetic boundary tests, review, and exact-SHA
CI. Enumeration in a manifest is not a working API. Status `planned` does not
mean complete. Existing APIs remain available.
## Coverage matrix
| Subsystem | Working API at baseline | Enumeration or missing adapter | Accepted bounded entry | Data and action risk | Authorization resource | Revocation, audit, quota | Verification and disposition |
| --- | --- | --- | --- | --- | --- | --- | --- |
| Authorization management | Engine `listGrants`, `revokeAll`, database audit | Manager shows declarations only | Host-only grant list, bounded audit summary, disable and revoke | Paths, session IDs, grant metadata are sensitive | Exact plugin ID, trusted app sender | Serialized manager mutation, disable before revoke, runtime teardown, retained audit | Batch 1 implemented: host/manager/UI tests. Native acceptance pending. No plugin RPC for managing grants |
| Network and credential leases | `network.request`, `credentials.createLease`, single-use host redemption | SDK request omits lease and authorization fields | Canonical request types matching broker | Network mutation and credentials | Exact origin plus secret reference and operation | Existing timeout, redirects, byte quotas, runtime checks, lease TTL | Batch 1 implemented: canonical schema and SDK, synthetic fixture and lease tests, feature negotiation. Native acceptance pending. No plaintext secret API |
| Commands, menus, toolbar, status bar, views | `commands.execute`, settings, context keys, view state and messages, UI contributions | No editor or SFTP menu slots | Existing contribution system | Plugin text and arguments may be sensitive | Namespaced contributions and permissions | Existing lifecycle, rate and message limits | Existing tests. New context menu slots planned with bounded context DTOs |
| Terminal providers | Decorations, links, completion, snapshots through provider requests | Session change delivery follows prior provider request and non-once grant | Preserve providers. Standalone selected-session watch planned | Hostnames, session state, endpoint identity | Host-selected session, runtime identity | Session-close and revoke cleanup, bounded event rate | Existing provider tests. Ordinary views cannot independently subscribe today |
| Current context and host picker | Host context keys are boolean state | `vault.metadata` enumeration is not generic SDK | Selected host/session DTO from an explicit whitelist | Labels and endpoint metadata are sensitive | Host-owned user-action context, selected host/session | TTL, runtime and session invalidation, redacted audit | Planned. Never reuse agent `sanitizeHostForAgent` as plugin DTO |
| Terminal selection and input draft | Provider-supplied snapshots, completion suggestions | No explicit selection-share or draft API | User shares selection, plugin proposes a visible editable draft | Terminal plaintext, accidental execution | Host action token plus exact live session/endpoint | Single-use token, size cap, no plaintext audit, active identity before commit | Planned. Reject Enter/control characters for draft insertion. No full history or direct shell |
| Native interactions and clipboard | Native permission dialogs | Clipboard enum only. No quick-pick/input/progress/toast SDK | Host-owned prompts, explicit copy/paste with preview | Clipboard and input may contain secrets, prompt spam | Host action context plus clipboard direction | Cancellation, request count and byte caps, runtime cleanup | Planned. No background reads or trusted plugin `userGesture` boolean |
| Local filesystem | Read file, stat, overwrite existing regular file | Directory listing lacks same-handle native adapter | Keep current boundary, evaluate file picker handles | Arbitrary local file content and writes | Canonical exact path or selected file handle | Inode checks, no symlink alias, bounded bytes, active runtime | Existing filesystem tests. Directory enumeration and new arbitrary-path creation deferred until portable safe native adapter exists |
| SFTP and remote files | No generic plugin API | `sftp.read` and `sftp.write` enumerated only | Host-selected session/path/mode/TTL handles, read and explicit save/upload | Remote secrets and destructive overwrite | Live endpoint identity, exact path, read/write split | Cancel streams, byte quota, overwrite confirmation, close/revoke cleanup | Batch 2 planned. Never forward arbitrary MCP catalog or session ID alone |
| Editor and Notes | No plugin document API | No versioned snapshot or edit preview | Explicit snapshot and versioned proposed edit | Document secrets, lost updates, executable files | Selected document plus revision and action token | Version conflict check, preview, bounded bytes, revocation | Planned evaluation after shared action context. No unattended edits |
| Snippets and scripts | Provider import drafts only | No generic read/run API | Explicit selection and create draft | Script contents may contain secrets. Running executes commands | Selected item, read versus draft write | Preview, byte cap, no automatic execution | Planned evaluation. Script `run` and onConnect modification deferred |
| Connections and authentication | Connection/authentication providers, opaque credentials, streams | Generic host open/connect absent | Existing providers remain | Connect can execute saved onConnect scripts | Provider operation plus live endpoint and credential lease | Existing provider lifecycle, streams and quotas | Existing tests. Generic connect is a write/execution capability, deferred pending explicit execution design |
| Sync and storage | Local namespaced KV, encrypted-object sync providers, opt-in setting sidecars | No general KV cloud sync | Existing opt-in provider protocol | Conflicts, deletes, credential access | Provider/account/object, settings scope | Existing limits and lifecycle, non-cascade metadata | Existing sync tests. General KV sync deferred until conflict/delete/quota/migration policy is defined |
| Vault and keychain | Credential references and import preview/commit | Metadata/write enums do not grant a generic Vault SDK | Only user-selected whitelist DTOs and drafts | Notes, environmentVariables, startupCommand, keys and credentials | Selected object plus field/direction | Explicit preview, identity checks, redacted audit | Full Vault export and secret reads deferred indefinitely. No agent field-exclusion DTO reuse |
| System manager and Docker | No plugin system API | Agent/MCP adapters are separate authority | Typed read-only summaries excluding args/env/inspect | Process command lines and Docker inspect can expose secrets | Selected live session and summary kind | Bounded result, endpoint identity, cancellation | Planned evaluation. Arbitrary process/inspect forwarding deferred |
| Port forwarding | No generic plugin API | Existing agent start is write-sensitive | Selected rule status and open host form | Rule endpoints and local exposure | Selected rule plus host action | Bounded DTO, session close and revoke | Planned evaluation. Direct start deferred until binding/listener approval design |
| Themes, settings, appearance, i18n | Terminal theme/background Providers, isolated view CSS, scoped settings and environment updates | No host global theme contribution. Personal customCSS is not plugin authority | Accepted ThemePack stage: static palette registry, source-labelled selector, window-local preview, revision-safe apply/cancel/reset, static terminal linkage | Unreadable colors and misleading security UI. Nineteen UiThemeTokens fields are not independent: primary/accent/ring and foregrounds derive from the effective accent | User explicitly selects a theme. Separate static contribution enablement from code permissions; no generic appearance.write or runtime activation | Immutable version/hash, bounded JSON, preview owner/token/revision, disable/update/uninstall fallback, no implicit sync, fixed host security appearance | [ThemePack design](./theme-packs-design.md) accepted for implementation after current A work; unfinished until contract, security boundary, lifecycle, contrast, samples and native gates pass. No arbitrary DOM/CSS, remote assets, free layout or security UI customization |
| Transfer center | Host transfer queue exists, no plugin entry | No ownership-bound task API | Observe and cancel only plugin-created tasks | Paths and host endpoints reveal user data. Cancel may leave partial files | Own transfer handle plus source/destination authority | Shared backpressure, cancel/close cleanup, honest partial status | Planned with batch 2. Do not broadcast or cancel other tasks |
| AI, MCP, automation | Host agent catalog and harness exist separately | No generic plugin AI or tool forwarding | Selection-to-proposal workflow through the same controlled primitives | Content disclosure, account authority and model cost | Explicit content sharing plus service origin/budget | No inherited history, tools, account, or hidden cron | Controlled workflow planned. Generic host AI and visible schedules deferred to distinct product decision |
| Known hosts, app lock and global security | Host security management UI | No plugin management API | Future allowlisted security-page navigation | Host-key trust, unlock, keys and security policy affect all sessions | Host navigation action only | No mutation or sensitive DTO | Security policy mutation, private-key reads, auto-trust and unlocking deferred indefinitely |
| Plugin blob/cache | Local KV exists | No exclusive blob cache | Evaluate bounded plugin-owned cache when a real workflow needs it | Disk growth and lifecycle ambiguity | Plugin namespace, no arbitrary path | Size quota, eviction and uninstall policy required | Deferred until a concrete larger-than-KV workflow establishes value and retention policy |
| Distribution and runtimes | Browser-only public catalog, immutable packages, advanced developer gate | Signing/trust and release remain separate phase | Retain existing boundary | Package execution and publisher trust | Pinned catalog selection, hash, engine/features | Existing install transaction, quarantine and cleanup | No merge/release. Do not extend to arbitrary Node, IPC, shell, or global capabilities |
## Delivery order and stopping rule
1. Batch 1: actual authorization management plus network contract consistency.
Keep the existing manager layout. Add one independent permission panel.
2. Batch 1b: host action context, native interactions, selected context and terminal
sharing/drafts. A plugin-supplied gesture is never authority.
3. Batch 2: SFTP selected-file handles and explicit transfer destinations.
4. Deliver the separately reviewed [declarative ThemePack stage](./theme-packs-design.md), including protected security surfaces, contrast gates, revision-safe preview/apply/reset, and safe fallback.
5. Reassess editor, Notes, snippets, menus, system summaries, and port forwarding
using the tested host-action and resource primitives.
Stop only when every row is implemented and tested, reasonably deferred with a
specific boundary and rationale, or awaiting a concrete user decision. A
planned row remains unfinished. Native screenshots and recordings belong to
the parent thread. Real credentials, hosts, or transfers require approval for a
specific target there. Synthetic checks do not establish native acceptance.
## Batch 1 evidence and next workflow
The independent permission panel adds only an import and a detail mount to
`components/settings/tabs/PluginPackageManager.tsx`. It uses the manager hook's
new `refresh()` after host revocation. Marketplace layout remains owned by the
parallel UI layer. Five locale files contain the panel strings.
The next usable workflow is a browser plugin that asks a host quick-pick/input
question, then explicitly copies its result or asks the user to share clipboard
text. The host displays the plugin source and action before reading or writing.
A canonical `HostInteractionRequest` and bounded result carry only text/choice
IDs. The host owns request IDs, requester webContents, runtime/version, TTL,
cancellation, and the consent result. The renderer cannot reply for another
window. Plugin `userGesture` values are ignored. Runtime/revoke shutdown aborts
all outstanding prompts and ignores late replies. A shared prompt primitive
then supports selected host/session context, selection share, and input drafts.
Before proceeding to the sensitive context/terminal and SFTP entries, the
native path must prove consent, cancellation, quota, and identity binding with
synthetic data. UI screenshots and real-resource approval remain parent-owned.
Baseline code references: `electron/plugins/hostService.cjs`,
`secureCapabilities.cjs`, `permissionEngine.cjs`, `terminalProviderService.cjs`,
`filesystemBroker.cjs`, `extensionProviderService.cjs`,
`packages/plugin-sdk/src/index.ts`, and
`packages/plugin-contract/schema/plugin-contract.schema.json`. Host business
implementations under `electron/capabilities/catalog/` are reference material,
not plugin authority. `infrastructure/ai/vaultAgentBridgeClient.ts` is not a
plugin DTO sanitizer.
+106
View File
@@ -0,0 +1,106 @@
# Curated plugin catalog
The catalog lives in `electron/plugins/catalog.json` in this repository. There
is no new marketplace account service or external directory repository. It is
shipped with the app; catalog changes become available with an app update.
Normal Settings exposes browser-only management and catalog browsing. Advanced
local packages require `NETCATTY_PLUGIN_DEV=1`; that flag never grants permissions.
Native acceptance and security/distribution review remain required before merge.
## Submit or update a plugin
Open a PR changing one entry. Provide:
- Stable reverse-domain plugin ID, author, name and short description.
- Public GitHub source repository and explicit license matching the package.
- Exact version, full 40-character source commit and relative README `.md` path.
- The complete validated manifest: engine/API ranges, required/optional permissions,
bounded resources, features and data/sync declarations must match package bytes.
- Optional published artifact URL and SHA-256. No artifact means a source-only
example: the UI does not offer installation. Do not invent an artifact URL.
- A concise change log, permission diff, supported OS test results, and synthetic
reproduction instructions. Current public runtime eligibility is browser-only
on the host platforms; native/platform-specific companions are excluded.
Use the local CLI from the repository root:
```sh
npm ci
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/hello-netcatty
node packages/plugin-cli/dist/cli.js pack examples/plugins/hello-netcatty --out /tmp/hello-netcatty.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/hello-netcatty.ncpkg
```
The catalog contains first-party source examples: Plugin Starter for minimal
settings/command use, and Hello Netcatty for terminal provider demonstrations.
No downloadable release is fabricated or published by this change. A maintainer
must verify real artifact provenance before adding the artifact object.
## Enforced install boundary
The renderer sends only a catalog ID. The main process selects the shipped
entry, downloads over HTTPS from approved GitHub hosts with bounded size/time
and redirects, checks SHA-256, and hands a private file to the same serialized
PluginManager and PackageStore used for local imports. The immutable staged
snapshot is hashed again before any version switch. Its exact ID/version,
license and full manifest must match the catalog. Unknown permissions fail
canonical schema validation. Browser-only eligibility rejects Node entrypoints,
companions, raw terminal interceptors and advanced runtime declarations.
The directory is a curated integrity assertion, not a publisher signature.
Unsigned runtime grants remain bound to package digest. No latest fallback and
no background code updates exist. Installing an update stops the old version;
the new version remains disabled until reviewed and explicitly enabled. Required
permissions are mediated again by the existing runtime permission engine.
Local advanced development packages require the developer flag and use the existing permission model;
this does not grant advanced privileges or make Node a sandbox.
README is fetched only from the selected commit, on opening details. Redirects
are refused and responses are limited to 256 KiB. Rendering constructs a small
allowlist of React elements from Markdown; HTML remains escaped text and images
are not fetched. Only explicit HTTPS links can be opened. README content is
untrusted author documentation; permissions come from the manifest.
## Review and maintenance
CI now runs canonical plugin contract drift checks and the runtime suite,
including catalog validation and install-boundary regression tests.
Run `node --test electron/plugins/catalogService.test.cjs` and the plugin
contract/runtime tests for every entry change. Review actual package/source
provenance and permission/resource differences manually on first submission and
sensitive updates. Packaging validation includes traversal/integrity checks but
is not a malware audit. Human review is mandatory before adding an artifact;
there is no automated acceptance bot in this slice.
Authors maintain their plugins, document data handling and respond to security
reports. Maintainers may remove entries or decline updates. Directory checks
are limited checks, not a safety certification, legal waiver or a promise to
repair third-party plugins. No third-party terms are copied here.
## Data and lifecycle
Local KV is per-plugin and does not sync. Only explicitly syncable non-secret
settings enter encrypted sidecars; machine paths, grants, packages, runtime
state and secrets do not become cloud authority. Existing sidecar conflict and
account isolation mechanisms are reused. A generic KV sync API remains a separate
schema/migration decision.
Incompatible host/API/features disable plugins before lazy contributions are
published, retain package/data and record a reason. A compatible downgrade never
automatically re-enables them. Disable stops runtime and contributions; uninstall
removes package code and local KV, while user-owned settings/secrets/sidecars
remain. Existing crash quarantine and generation-bound cancellation apply.
Missing OS-backed key storage blocks secrets/credentials, without blocking
catalog browsing or non-secret plugins. The Settings manager reports this state;
never switch to plaintext storage or disable Chromium sandboxing for a demo.
## When permission prompts appear
Enabled is an availability switch, not a security review or a grant. A lazy
plugin can become enabled without starting code. Its first command/provider use
starts the runtime; required permissions are authorized at that activation
boundary. Optional permissions are requested when a broker operation needs them.
Missing/denied decisions fail closed. Never interpret an Enabled badge as proof
that terminal, credential, network or filesystem access has been approved.
+16 -1
View File
@@ -1,6 +1,6 @@
# Netcatty plugin contract and SDK
Status: internal preview (`0.1.0-internal`)
Status: internal preview (`0.1.0-internal.1`; legacy `0.1.0-internal` remains offered)
Tracking issue: [#2269](https://github.com/binaricat/Netcatty/issues/2269)
Phases 2 and 3 consume this contract in the isolated host runtime and secure
@@ -615,3 +615,18 @@ npm run build:plugin-packages
The complete application checks remain mandatory because workspace and root
dependency changes affect installation and release builds.
## Plugin brand icons (internal revision 0.1.0-internal.1)
Optional top-level `icon` reuses `IconReference` from contributions. A package
using it must require `metadata.brand-icon` and an API range accepting
`0.1.0-internal.1` but excluding `0.1.0-internal`. See
[the author guide](plugin-icons.md) for bounds and packaging commands.
The host still offers the previous wire revision to plugins with no top-level
icon whose range matches only that revision. Compatibility selection returns the
negotiated API version and runtime initialization uses and verifies that exact
version. Host version bounds remain independently enforced; unknown required
features still fail closed. No old manifest is rewritten and no permission grant
is migrated by this revision.
+47
View File
@@ -0,0 +1,47 @@
# Native Electron handoff
Repository: binaricat/Netcatty. Frozen baseline: 0256e7471d66bbbca077c361ef379d7b11ce8465.
Stack: feat/plugin-compatibility-review -> feat/plugin-settings-manager -> feat/plugin-curated-catalog.
Use the final catalog branch for all three slices. Do not merge or release.
1. Check out the branch on the approved desktop. Run npm ci and npm run build.
2. Build the first-party sample:
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
node packages/plugin-cli/dist/cli.js validate /tmp/starter-netcatty.ncpkg
3. Launch the actual Electron binary without NETCATTY_PLUGIN_DEV and with a new disposable
--user-data-dir path, keeping Chromium sandbox enabled. Use the desktop's working
display and safe configuration; never --no-sandbox or real profiles/accounts.
4. Settings > Plugins: record Community plugins list and Plugin Starter details.
Open README: actual pinned first-party GitHub retrieval; HTML inert, no remote images.
It intentionally says source-only: no downloadable artifact/release was invented.
5. Import /tmp/starter-netcatty.ncpkg. It must remain disabled. Expand the installed
item: manifest permissions/resources, sensitive markers, Netcatty/API ranges,
SHA-256. Enable allows lazy activation; it does not grant permissions.
First command/view use prompts before runtime creation. Denying must not run code.
6. With approved non-advanced permissions for the first-party example, edit Greeting
and open Starter: Greeting Panel (入门:问候面板) in the command palette.
Click Show greeting (显示问候语) and record the preference rendered in the panel.
No real host, cloud account, terminal credentials, runtime.advanced or third-party code.
Use Starter 0.1.1 or later. Its menus/views permissions support only the declared
command and sandboxed panel; do not expect terminal output.
7. Disable, re-enable, close/restart the app with the same synthetic profile. Confirm
settings persist. Re-import same .ncpkg and verify it stops and stays disabled.
8. Uninstall only the synthetic plugin using the two-step confirmation. Code/local KV
are removed; user-owned settings/secrets/sync sidecars have documented retention.
9. On machines without a usable OS vault, the manager shows a downgrade notice;
secrets remain blocked but browsing and non-secret examples work.
Compatibility upgrade/downgrade, required-feature changes, integrity mismatch,
manifest mismatch, Node rejection, cleanup and traversal have automated synthetic
coverage. Do not claim the above native steps were executed on this executor.
This executor's native launch failed at Chromium's SUID sandbox configuration:
chrome-sandbox is not root-owned mode 4755. No sandbox weakening was attempted.
Native video is pending on the parent task's working desktop.
10. Use the same AI-settings-style subpages: Installed and Community. Search by
name/author/description, open details and return. Verify README error/retry UI
if network is unavailable; do not bypass the network restriction.
11. Inspect human-readable permission name, effect, scope, required/optional and
risk. Enabled means eligible for lazy activation, not permissions granted.
@@ -0,0 +1,54 @@
# Plugin manager window and brand icons
Status: historical design investigation. The manager dialog and the accepted brand-icon design are now implemented. See [the current author guide](plugin-icons.md) for the shipped contract and bounds. The sections below preserve the original investigation and recommendations.
## Manager window
Settings > Plugins keeps an explicit Open plugin manager entry. The existing Radix Dialog opens a modal in the current renderer, up to 1180px wide and 88dvh high. Installed and Community, local import, manifest permissions and README remain inside it. Closing releases the mounted manager hooks; reopening starts at Installed. Detail Back stays inside the manager and preserves the Community search. Escape closes the manager and restores focus to its trigger.
This uses the existing theme, dialog focus handling, plugin host and trusted bridge. A separate Electron BrowserWindow would require another renderer lifecycle, preload/context provisioning and trusted-sender review without providing a needed capability here. No window IPC, plugin runtime or grants are added.
## Existing icon support
- `packages/plugin-contract/schema/plugin-contract.schema.json`: `IconReference` is either a host-owned theme icon or a package icon with `light` and optional `dark` paths. It exists on commands, menus and views. `PluginManifest` has no top-level icon and rejects unknown properties.
- `packages/plugin-cli/src/manifest.ts` validates contribution icon paths. `archive.ts` checks the declared resources during packaging and archive inspection. Missing files and unsafe paths are rejected.
- `electron/plugins/contributionIconService.cjs` checks declaration ownership and current installed version before and after loading. It requires enabled/non-quarantined status, but does not require a running runtime. It reads a verified package root through `pluginProtocol.cjs` containment/realpath checks. Current input/output limits are 512 KiB, source edge 4096px, 16 megapixels, output edge 64px. Formats include PNG/WebP and SVG, JPEG, GIF, ICO.
- `contributionIconRasterizer.cjs` decodes in a disposable sandboxed, network-denied BrowserWindow and returns bounded PNG data. Its SVG inspection is not a general SVG sanitizer; isolation/CSP/rasterization remain essential.
- `application/state/usePluginContributionIcon.ts` discards stale requests. `components/plugins/PluginContributionIcon.tsx` selects light/dark images or a Puzzle fallback.
- `PluginMarketplace.tsx` currently uses the host Package glyph. The catalog has no separately verified logo asset. Installed/detail surfaces do not expose a plugin brand logo.
An author can already add a contribution icon (inside a command/menu/view):
```json
"icon": { "kind": "package", "light": "assets/action.png", "dark": "assets/action-dark.png" }
```
Include both files in the package and run CLI validate/pack. This changes that contribution's icon, not its market listing logo.
## Proposed minimal brand-icon contract
Reuse `IconReference`, adding an **optional** `PluginManifest.icon`; do not invent a URL/logo string format. A package brand icon would look like this after support ships:
```json
"icon": {
"kind": "package",
"light": "assets/logo.png",
"dark": "assets/logo-dark.webp"
}
```
Recommended first version:
- Static PNG or WebP, transparent background if desired; recommend a square 128–256px image. Enforce 256 KiB per source and a 512px maximum edge, validate MIME/signature and decoded dimensions, reject animated input, output bounded raster PNG. A single `light` asset works in both themes; `dark` is optional. Host theme icons can reuse the existing allowlist without package I/O.
- Relative package paths only: no URL, absolute/drive/UNC paths, traversal, backslash, NUL, symlink escape or archive special entries. Do not read arbitrary renderer-supplied paths. Verify exact package hash/version and declaration on each uncached resolution; key caches by package identity/hash/theme, and discard stale results on update/uninstall.
- Old plugins omit the field and continue to work with one shared host fallback. Per `contract-and-sdk.md`, adding this optional field requires an API/schema revision. New packages using it must declare the matching API range and required feature, plus a minimum host version that understands it: older strict schemas will reject an unknown field. Do not promise new-manifest compatibility with older hosts.
- Extend the single contract and regenerate its consumers. Extend CLI path enumeration and both pack/archive checks; enforce image bounds during validation/import, not only in the UI. Reuse contained-file reads and the bounded rasterizer instead of adding file:// or runtime resource access in the application renderer.
- Separate brand-image availability from contribution availability. Verified installed packages may show a logo while disabled without enabling or activating code. Do not loosen the existing contribution-icon enabled gate globally. Share the decoding implementation through a narrow brand-image operation that resolves the active installed declaration itself.
- For uninstalled market entries, do not execute or install a plugin to obtain its logo. Use a small, catalog-reviewed static asset bundled with Netcatty, with its source commit and digest bound to that catalog version; absent assets use the same fallback. In particular the two current source-only examples have no downloadable artifact to extract. A later remote asset transport would need its own bounded allowlist/hash/redirect/cache policy and is outside this first proposal.
- Use one brand-icon component in catalog cards, catalog details and installed lists/details. On decode/read failure keep the fallback and metadata/actions usable; do not fall back to a remote URL. Icons are decorative, with plugin names remaining accessible text.
Do not add brand SVG support initially. Existing contribution SVG support is not removed. SVG brings XML/CSS/external-reference, filter and decoding complexity; supporting it for distribution means retaining isolated offline rasterization, bounded dimensions/time/concurrency and malformed/hostile-source tests. Raster PNG/WebP covers the initial need with less format complexity, though image decoders still require limits and isolation.
## Validation required before implementing brand icons
Contract/CLI tests should cover old manifests, optional fields, missing files, both themes, spoofed MIME, animation, oversized/zero dimensions, archive traversal and symlinks. Host tests should cover disabled installed packages without activation, source-only catalog fallback, stale update/uninstall results and no remote fetch. UI/native checks should cover all three surfaces, broken-image fallback, light/dark themes and accessible names. No new host, filesystem, network or terminal permission should be granted for displaying a verified logo.
+81
View File
@@ -0,0 +1,81 @@
# Add a plugin brand icon
Brand icons appear on Community cards/details and Installed lists/details. They
are optional; plugins without them retain a host-owned fallback. An icon does
not activate code or grant plugin permissions.
## Declare the icon
Use the existing `IconReference` shape at the top level of `netcatty.plugin.json`:
```json
{
"engines": { "netcatty": ">=0.0.0", "api": ">=0.1.0-internal.1 <0.2.0" },
"features": { "required": ["metadata.brand-icon"] },
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.webp"
}
}
```
This is a manifest fragment. Preserve the required ID, name, version, publisher,
main and other fields. The host-version value above is for this repository's
unreleased development build (version 0.0.0); use the actual first supporting
Netcatty release as your lower bound when it is assigned. Do not invent a release
number. The API range and required feature enforce support in the meantime.
Alternatively use a host-owned theme glyph, for example
`"icon": { "kind": "theme", "name": "terminal" }`, with the same API/feature
requirements. Theme glyphs use the existing host allowlist; unknown names fall back safely.
## Prepare files
Use a static PNG or WebP. A square transparent 128×128 or 256×256 image is
recommended. Each source must be at most 256 KiB, with both dimensions between
1 and 512 pixels. `dark` is optional; otherwise `light` is used in both themes.
Use lowercase `.png` / `.webp` extensions matching the encoded bytes. Animated
PNG/WebP and SVG are not accepted for brand icons. Existing contribution icons
are unchanged, including their separately isolated SVG support.
Paths are relative to the package root. URLs, absolute paths, traversal,
backslashes and symlinks escaping the package are not supported. Include each
referenced file in the package. No external image URL is loaded as a fallback.
The host performs offline isolated decoding and displays a bounded PNG result;
invalid or unreadable images fall back to the host glyph.
## Validate and package
From this repository, after building your plugin:
```sh
npm run build:plugin-packages
node packages/plugin-cli/dist/cli.js validate examples/plugins/starter-netcatty
node packages/plugin-cli/dist/cli.js compatibility examples/plugins/starter-netcatty --netcatty 0.0.0 --api 0.1.0-internal.1 --features metadata.brand-icon
node packages/plugin-cli/dist/cli.js pack examples/plugins/starter-netcatty --out /tmp/starter-netcatty.ncpkg
```
Directory validation, packaging and imported-archive validation check declared
brand files and bounds. The host reads installed metadata by plugin ID and
verified package identity, not a renderer-provided file path. Disabled packages
can display their icon; quarantined, missing or changed packages fall back. The
existing enabled-only contribution icon service gate is not relaxed.
## Community previews
For an uninstalled plugin, the directory maintainer includes a reviewed raster
preview in the application bundle. The preview binds the plugin ID, version,
repository, source commit and icon declaration. Its bytes and SHA-256 are checked
against the reviewed source files in tests. The current Starter preview uses its
first-party source assets; source-only listings do not pretend to have a release
artifact. Missing or mismatched previews use the same fallback, without fetching
an image from the author's server. Submit the source assets and updated directory
metadata through the existing repository PR review process.
## Compatibility
This is API/schema revision `0.1.0-internal.1`. An older host rejects the unknown
manifest field or missing required feature. The new host continues to offer
`0.1.0-internal` for legacy plugins without the new field, including exact old
API ranges. Existing plugins need no manifest or data migration.
+56
View File
@@ -0,0 +1,56 @@
# Official registry client
The Community page refreshes the fixed endpoint
`https://raw.githubusercontent.com/binaricat/netcatty-plugins/main/catalog.json`
in the main process. The renderer cannot choose a registry URL. This endpoint
must be published separately; client implementation and synthetic tests do not
prove the repository or an online artifact is available.
## Catalog contract
`catalog.json` is an array of at most 1,000 current entries. Each entry supplies
`id`, `version`, `name`, `description`, `author`, `repository` (`owner/repository`),
`commit` (full 40-character SHA), `license`, `readme` (relative Markdown path),
and `manifest`. Optional `artifact` contains `url` and SHA-256 `sha256`.
Unknown catalog-entry and artifact fields are rejected. The whole JSON value,
including manifest defaults, uses the existing 128-level / 100,000-value budget
and finite-number validation. The existing manifest schema, browser-only distribution policy, identity/version
and license checks apply. Source-only entries remain build-and-import examples.
No download counts, ratings, or timestamps are invented. Brand previews remain
bundled and reviewed; arbitrary author image URLs are never loaded.
Reads are anonymous HTTPS, redirect-free for the registry, bounded to 2 MiB,
and time out after 30 seconds. All entries must validate, safely clone, and generate every selection token before
atomically replacing the current snapshot. Invalid JSON/schema, network failure, and oversized responses
leave the previous good snapshot intact. Errors and Retry remain visible. The Community header also has Refresh, including
when the last refresh succeeded; it is disabled while a request is pending.
The last good catalog is retained **in memory for this application session**;
a fresh offline launch uses bundled examples. There is no new disk cache,
database migration, cloud sync record, or credential requirement.
## Selection and installation
Each returned entry has a host-generated `selectionToken` covering its complete
JSON contents. This is a concurrency check, **not a signature or trust proof**.
README requests must still match that selection and fetch its pinned source
commit. A changed entry requires refresh and renewed review.
A manual install must first refresh the official catalog successfully and match
the selected token again. Failed refresh is browse-only, even if the cached
entry has an artifact. Withdrawal, version changes, permission changes, README
changes, or artifact hash changes cannot silently replace the selected package.
Archive download then uses the existing approved-host, size, exact SHA-256,
manifest and package validation pipeline. Installation leaves the plugin disabled;
existing authorization gates still apply on activation/use.
`index.json` release history and withdrawal records are not consumed by this
first client. Removing an entry does **not** disable, uninstall, update, or delete
data from an already installed plugin. There are no background code updates.
## Verification boundary
Service regressions use synthetic network responses and package bytes; the
existing package manager tests cover archive admission. No third-party plugin,
real credential, real sync account, or live registry write is used. Native QA
must separately verify the visible offline/retry state and eventual real catalog
availability once the repository is published.
+79
View File
@@ -0,0 +1,79 @@
# Plugin platform implementation review
Baseline: main `0256e7471d66bbbca077c361ef379d7b11ce8465` (2026-10-26).
## Existing capabilities and gaps
| Area | Implemented source | Remaining boundary |
| --- | --- | --- |
| Contract, SDK, package CLI | `packages/plugin-contract/schema`, `packages/plugin-sdk`, `packages/plugin-cli` | Internal API `0.1.0-internal`; no public API stability promise |
| Runtime | `electron/plugins/browserPluginRuntime.cjs`, `utilityPluginRuntime.cjs`, `hostBootstrap.cjs` | Entire host remains development opt-in; advanced utility is ambient Node authority, not a fine-grained sandbox |
| Permissions | `permissionEngine.cjs`, `hostRpcRegistry.cjs`, `secureCapabilities.cjs` | Broker checks at privileged boundary; unsigned principal bound to package digest; verified publisher/distribution not implemented |
| UI contributions | `contributionService.cjs`, `components/plugins`, `SettingsPluginsTab.tsx` | Commands, menus, keybindings, settings and views exist; current Settings page is contributed settings, not a package manager/marketplace |
| Terminal | `terminalProviderService.cjs`, `terminalDataPipelineService.cjs` | Completion/link/visual providers and lifecycle; interceptors require privileged utility placement; not arbitrary terminal object access |
| Connection, authentication, importer | `extensionProviderService.cjs`, `application/state/pluginExtensionBridge.ts` | Existing operation-bound provider integration; not general vault/SFTP/clipboard CRUD |
| Sync | `pluginSyncSidecarService.cjs`, `application/pluginSyncSidecarBridge.ts` | Opt-in non-secret settings and account/CRDT sidecars; ordinary KV is local, package code/permissions/secrets are not cloud authority |
| Compatibility | `plugin-cli/src/compatibility.ts`, `packageStore.cjs`, `runtimeSupervisor.cjs` | Install/start checks existed; startup reconciliation and lazy re-enable checks missing |
| Local package | `packageStore.cjs`, CLI `pack`, first-party `examples/plugins/hello-netcatty` | Immutable snapshot, hash/integrity validation and safe extraction exist; no ordinary import management UI |
| Distribution | No public catalog implementation | Version/hash/pinned README, review workflow, manual update UI still needed |
Historical merged steps: #2276 contract/SDK; #2303 runtime; #2315 permission/security;
#2324 UI contributions; #2338 terminal providers; #2402 privileged terminal pipeline;
#2413 connection/auth/importers; #2713 sync; #2748 and #2752 sync follow-up fixes.
The repository AGENTS.md runtime section describes an earlier phase and must not
be mistaken for a complete current capability inventory. No repository `.agents/skills`
directory was present at this baseline.
## Small implementation slices
1. Reconcile host/API/features compatibility before contribution enumeration;
disable incompatible plugins and retain package/data, record the reason;
block lazy re-enable. Do not auto-enable after a host downgrade.
2. Reuse the existing Settings Plugins tab and management IPC for installed
packages, manifest-derived permissions, version/hash and lifecycle actions;
local import starts disabled for review. Keep native permission mediation.
3. Add a lightweight first-party catalog and pinned safe README detail renderer,
shared package verification, digest-bound manual update flow and author docs.
Do not introduce remote accounts, reviews, payments or a new repository.
4. Reproduce the actual Electron flow with synthetic data and the first-party
sample; transfer source/build instructions if GUI is unavailable here.
## Decisions and explicit release gates
User approved public source plus a clear license for catalog entries, and manual
confirmation of plugin updates. Private local packages remain separate.
User-approved public v1 eligibility: browser runtime only. Utility Node, native
companions and raw terminal interceptors retain the development preview path.
This excludes advanced provider implementations requiring Node/native binaries;
ordinary browser contributions, broker-backed networking and providers remain
possible. No existing local advanced development path is removed.
After product review, normal Settings now admits browser-only packages. The
`NETCATTY_PLUGIN_DEV=1` flag gates advanced local packages only; their existing
permission prompts remain mandatory. Shared host/UI classification preserves
browser-first placement for dual-entry packages while excluding them from normal
admission. Native acceptance remains pending. Passing tests is not proof of
security isolation. Do not describe utility processes as broker-constrained.
A future KV sync feature needs an explicit manifest opt-in schema, size limits,
namespace and schema-version migrations, deterministic conflict/deletion rules,
account switching semantics and secret exclusion. It is deliberately not silently
enabled in this iteration. Reuse existing settings sidecars for small preferences.
Catalog entries should bind ID, repository, license, author, version, commit,
artifact SHA-256 and URL, platform/engine ranges, permissions and README revision.
A hash mismatch must fail rather than fall back to latest. README must not execute
HTML or automatically fetch remote media. Directory PR checks are a bounded
review process, not a security certification or a guarantee that maintainers will
repair third-party plugins.
## Validation at the implementation stage
The full repository TypeScript check has 773 diagnostics on both the frozen main
and this implementation. After normalizing worktree paths and line shifts, no
new diagnostics were introduced. This existing debt includes plugin integration
types and should be addressed in a separate API-drift cleanup rather than being
hidden by changing public contracts opportunistically. Canonical contract drift
checking and plugin runtime tests pass for this implementation; native Electron
UI acceptance is tracked separately in `native-acceptance.md`.
@@ -226,3 +226,30 @@ after durable user data can exist.
- PR 9 supplies signed publisher principals and trust policy through placement;
signed identity changes force a fresh grant instead of widening an unsigned
grant silently.
## Inspect and revoke actual grants
The installed package detail separates declared permissions from **Active
grants**. The host-only panel lists resource, scope, session (when applicable),
and grant time. One-use decisions do not appear as retained grants. Refresh
reads the engine again. Recent security events include at most 100 entries and
project only event, time, permission, and scope. Arbitrary database details,
operation IDs, terminal content, and credential material never cross this UI
boundary.
**Disable and revoke all grants** runs through the manager mutation queue. It
persists disabled state before revoking grants, invalidates contributions, and
waits for runtime shutdown. Existing runtime teardown cancels RPC, streams,
leases, providers, and companions. A containment failure is reported as a
failure and leaves the plugin disabled. Explicit enable requests fresh access.
This management bridge is available only to the trusted app renderer. It is
not a plugin RPC method and grants no plugin authority over another package.
`PluginNetworkRequest` and `PluginNetworkResponse` now derive from the canonical
schema. `credentialLease` and `authorization` describe the existing host lease
injection path. Hosts advertise `network.credential-lease` for discovery without
changing existing API versions or disabling legacy requests. Plugins may
require that feature or handle its absence. Optional authorization defaults to
Bearer. Basic authorization accepts a bounded username. Plaintext is redeemed
only inside the host after origin permission and operation binding checks.
See the [synthetic fixture](../../examples/plugins/lease-http/README.md).
+215
View File
@@ -0,0 +1,215 @@
# Declarative theme packs
Status: proposed implementation plan. No theme contribution or theme permission
is implemented by this document. The baseline is Draft #3951 at
`e5bd294cddbf58dfe04c646b6291933d980498fd`; batch 1 grants and network contracts
are reviewed separately in Draft #3955.
## Scope and existing behavior
A theme pack supplies validated color data. Users preview, apply, cancel, or
reset it through the host Appearance UI. Selecting a theme never starts a
plugin runtime or approves its function permissions. This extends whole-app
appearance alongside existing `terminal.theme` and `terminal.background`
Providers and isolated view styles.
Personal `customCSS` is a user-owned advanced setting, not a third-party plugin
surface. This plan introduces no stylesheet, selector, DOM, font URL, image URL,
script, layout, notification, or background permission.
`UiThemeTokens` has 19 fields. They are not independent controls:
`buildAppThemeCssVars()` assigns one effective accent to primary, accent, and
ring and computes both accent foregrounds. The initial theme contract preserves
this relationship. It must not accept independent primary, primaryForeground,
accentForeground, or ring overrides and then silently discard them.
## Contract and package admission
The proposed feature is `appearance.theme-packs`. Its schema definitions,
generated SDK exports, runtime/package compatibility, and documentation move
together. Plan an internal schema/API revision with legacy negotiation retained;
do not change the current batch 1 API just to publish this proposal. Old hosts
reject a pack that requires the unknown feature before registration.
A proposed `contributes.themes` entry contains:
| Field | Meaning and validation |
| --- | --- |
| `id` | Contribution ID under the owning plugin namespace |
| `label` | Existing bounded localized-text contract |
| `variant` | `light`, `dark`, `hc-light`, or `hc-dark` |
| `path` | Canonical package-relative JSON path inside the immutable installed version |
A theme JSON document contains a schema revision, a variant-matching built-in
base ID, bounded color overrides, and an optional static terminal palette.
It contains no cross-plugin includes, arbitrary file references, inheritance
chains, or remote resources. Resolve the validated base and overrides into one
immutable complete palette before registration. Never retain partially valid
entries from a failed theme document.
The initial allowed UI overrides are `background`, `foreground`, `card`,
`cardForeground`, `popover`, `popoverForeground`, `secondary`,
`secondaryForeground`, `muted`, `mutedForeground`, `accent`, `destructive`,
`destructiveForeground`, `border`, and `input`. Accept only opaque `#RRGGBB`
colors. The host converts them to its internal HSL representation. Reject
unknown keys, alpha, CSS expressions, `url()`, `var()`, `@import`, non-finite
values, and custom properties. Do not pass author strings to CSS parsing.
The optional terminal palette uses the existing `TerminalThemeColorName`
allowlist. It merges with a host default and is validated independently.
Static terminal colors require neither a session snapshot nor a Provider call.
Initial bounds for implementation and tests are 32 themes per package,
64 KiB per JSON file, 2 MiB total theme JSON, depth 8, and one active preview per
window. These are proposed defaults, not existing limits. The schema and host
must share the final constants. No font, image, or video assets enter v1.
### Static admission must remain distinct from code activation
The present manifest requires `main` and browser-only policy expects a browser
entrypoint. Removing either check without a separate package variant is unsafe.
For the first usable slice, a sample can retain a no-op browser entrypoint.
Static theme registration must not invoke it. Theme enablement has a separate
host-owned state from function/code enablement. Installing a pack keeps both
off until a user opts into the corresponding contribution. Enabling its themes
never sets the code-enabled flag, grants permissions, or runs activation events.
Disabling the package disables its theme contributions as well as its code.
A later exclusive data-only manifest variant can remove the no-op entrypoint.
It must reject `main`, activation events, permissions, companions, providers,
commands, and every non-theme contribution. Update canonical validation,
semantic admission, package inspection, catalog policy, manager behavior, and
compatibility tests together. The public catalog must explicitly recognize
this inert variant while retaining browser-only admission for executable packs.
A theme-only exception must not admit Node or an executable mixed package.
## Host registry and persistence
A host registry reads only installed, immutable package versions through the
existing package-store path boundary. Verify real paths, file kinds, byte caps,
and package ownership. Preserve the validated source version and content hash.
No plugin RPC can register, apply, or replace global palette data.
A committed selection references plugin ID, theme ID, variant, package version,
and validated theme hash. Store per-variant selections and a monotonic commit
revision through the settings/persistence layer. Do not persist plugin CSS or
turn this into implicit KV/cloud synchronization. Preserve legacy built-in theme
IDs and existing user settings when no plugin theme is selected.
Theme availability records and applied preferences are user state, separate
from runtime KV and function permissions. Define retained missing-selection
metadata and uninstall behavior explicitly. Missing code or theme files must
not trigger downloads, runtime activation, or new grants.
## Preview, apply, cancel, and reset
A single appearance resolver feeds existing chrome/terminal effects. Do not add
a second root effect that races `useAppThemeStyle`, `useThemeRuntime`, or active
terminal chrome selection.
| Host action | Required behavior |
| --- | --- |
| Preview | Create a host-owned token bound to window, preview owner, selected immutable theme, and current committed revision. Render locally without storage writes, sync events, runtime activation, or other-window changes |
| Apply | Verify owner, token, version/hash, availability, and expected commit revision. Commit the selected theme and explicitly chosen terminal linkage as one settings action. A stale preview produces a conflict, not an overwrite |
| Cancel or close | Clear only the matching local preview token. Render the latest committed state. Never restore a captured old settings snapshot over a newer commit |
| Reset | Clear the selected variant's plugin reference using the same revision check and apply the host default. Preserve font, accent, per-host terminal, and accessibility preferences |
| Another window applies | Advance the shared commit revision. End the old preview or mark it stale; later cancel cannot alter the new selection |
| Disable/uninstall/update | Invalidate matching previews and registry entries. Immediately resolve affected selections to a safe built-in variant, retain a bounded missing-reference notice, and never silently adopt unreviewed replacement bytes |
| Application exit or window destruction | Release local preview ownership. Only committed preferences survive startup |
The preview UI always identifies plugin source/version and has host-controlled
Apply, Cancel, and Restore default controls. Those controls live inside the
security appearance boundary. Theme data cannot control their labels, order,
visibility, click behavior, or layout.
Applying a theme changes only the selected palette and explicit terminal linkage.
It does not overwrite user font family/size, accent override, host terminal
override, reduced-motion choice, or OS accessibility preferences.
## Appearance precedence and terminal behavior
Resolve appearance in this order:
1. Host security surfaces and OS accessibility requirements.
2. Explicit user font, size, accent, and per-host/per-terminal overrides.
3. The selected and validated theme pack.
4. Built-in defaults.
Define one effective accent using the current host rule. Reject impossible
contrast combinations, including a user accent override against the effective
surface, or show an explicit host correction before apply. Do not report an
ignored author field as a successful override.
Follow App may use the pack's static terminal palette. Manual terminal choices
and per-host overrides retain precedence. Reuse the existing active-chrome
selection rather than resetting app appearance on tab changes. Test Vault,
SFTP, terminal, editor, Notes, logs, split panes, and detached windows.
High-contrast flags in plugin environments are not proof that the host already
has complete high-contrast themes. Missing HC variants require a host-owned HC
fallback. Do not downgrade a forced-colors/high-contrast preference to an
ordinary light/dark theme. Respect reduced motion and font scaling.
## Security appearance and contrast gates
Native Electron permission dialogs already sit outside renderer CSS. React
security surfaces need a new explicit boundary before a theme pack ships.
Cover AppLock, host-key trust, credential selection, grant/revoke management,
dangerous confirmations, and theme preview/apply controls. Use host-controlled
colors, font, icons, and layout there. Package colors never style those surfaces.
The existing code has no verified shared boundary covering all of them.
Color validation checks complete resolved/composited pairs, not only body text:
foreground/background, card text/card, popover text/popover, secondary/muted
text, accent/button text, destructive/button text, focus rings, borders, inputs,
and selected/disabled states. Ordinary text needs 4.5:1, large text 3:1, and
required non-text indicators 3:1 against adjacent surfaces. Reject a failing
palette with a named pair and ratio, or show an explicit host correction.
Tests must prove the effective UI, not only a helper's arithmetic.
These gates follow [WCAG text contrast](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
and [non-text contrast](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html).
Theme screenshot review supplements contract, lifecycle, and accessibility
checks. It does not establish safe ownership, persistence, or revocation.
## Delivery stages and acceptance
Current batch A grants and SDK consistency reaches review/CI first. Complete
the controlled native interaction/context work as planned; theme work remains
a separate small stage from marketplace card/layout changes.
| Stage | Implementation and evidence |
| --- | --- |
| Contract and pure validation | Canonical schema/generated SDK, feature/revision negotiation, strict color parser, derived-accent semantics, static terminal palette, bounds, named contrast diagnostics, old manifest compatibility |
| Registry and security boundary | Immutable package ownership, static registration without runtime, independent theme/code enablement, fixed security appearance, same-variant/HC fallback, unavailable/tampered version behavior |
| Product workflow | Appearance grouping/source, local preview ownership, revision-safe apply/cancel/reset, safe defaults, system theme changes, terminal linkage and overrides, persistence and window synchronization |
| Samples and final acceptance | Light/dark no-op-browser sample, normal settings import without advanced mode, no runtime/grants on theme apply, later data-only admission only if fully validated, exact-SHA tests/build/package/CI, Codex review/fix/review, parent-owned native recording |
Boundary tests include unknown fields/feature, invalid or excessive JSON,
unsupported variant/base, non-package and changed paths, unreadable colors,
preview cancellation and window close, two-window apply/cancel ordering,
update/disable/uninstall/failure fallback, startup restoration, host and accent
overrides, forced colors, reduced motion, Chinese fonts/fallback, and every
protected security surface. A pending gate keeps the theme row unfinished.
Later controlled candidates are known-font recommendations, radius/density
presets after component support exists, declarative file/folder icons, and
package-local static backgrounds on fixed surfaces with contrast limits. They
need separate contracts and acceptance. Global CSS, free layout, remote fonts,
HTML/video backgrounds, and security-UI customization remain excluded.
## Baseline source references
- `infrastructure/config/uiThemes.ts`: token fields and built-in registry.
- `application/state/settingsStateDefaults.ts`: accent derivation and built-in ID checks.
- `application/state/appearanceSync.ts`, `appearanceChromeStore.ts`, and
`useSettingsState.ts`: persistence and cross-window behavior.
- `application/app/useAppThemeStyle.ts`, `activeChromeTheme.ts`,
`application/state/useThemeRuntime.ts`, and `domain/terminalAppearance.ts`:
effective UI and terminal precedence.
- `packages/plugin-sdk/src/index.ts`: existing terminal theme/background types.
- `electron/plugins/pluginProtocol.cjs`: isolated view CSS/font/network CSP.
- `lib/customCss.ts`: personal user CSS, excluded from plugin authority.
- `domain/pluginRuntimePolicy.cjs` and the canonical manifest schema: executable admission.
- `electron/plugins/nativePermissionDecision.cjs`: native permission prompts.
+44
View File
@@ -0,0 +1,44 @@
"use strict";
// 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 = {
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.", "读取插件设置", "读取此插件自己的设置。"],
"settings.write": [false, "Change plugin settings", "Update this plugin's preferences.", "修改插件设置", "修改此插件自己的设置。"],
commands: [false, "Plugin commands", "Register and run this plugin's own commands.", "插件命令", "注册和执行此插件自己的命令。"],
menus: [false, "Menu entries", "Add this plugin's actions to host menus.", "菜单入口", "将此插件的操作加入应用菜单。"],
views: [false, "Plugin panels", "Display plugin-owned panels inside the app.", "插件面板", "在应用中显示插件自己的面板。"],
"clipboard.read": [true, "Read clipboard", "Read copied text, which may contain passwords or other sensitive data.", "读取剪贴板", "读取复制的文本,其中可能包含密码或其他敏感数据。"],
"clipboard.write": [true, "Write clipboard", "Replace clipboard content that you may paste elsewhere.", "写入剪贴板", "替换剪贴板内容,可能影响你随后粘贴的数据。"],
"terminal.metadata": [true, "Terminal metadata", "Read terminal/session details that may identify hosts and activity.", "终端元数据", "读取终端和会话信息,可能暴露主机及操作活动。"],
"terminal.output": [true, "Read terminal output", "Receive terminal output through supported providers; it may contain credentials or private data.", "读取终端输出", "通过支持的提供器接收终端输出,其中可能包含凭据或隐私信息。"],
"terminal.input": [true, "Send terminal input", "Send input to a terminal; input may execute commands or change a remote host.", "发送终端输入", "向终端发送输入,可能执行命令或更改远程主机。"],
"terminal.decorate": [false, "Terminal presentation", "Provide annotations and visual changes to terminal content.", "终端展示", "为终端内容提供标注和视觉效果。"],
"terminal.complete": [true, "Terminal completion", "Receive completion context and suggest terminal input.", "终端补全", "接收补全上下文并建议终端输入。"],
"terminal.intercept.input": [true, "Rewrite terminal input", "Inspect and modify input before delivery. Not every secret/no-echo input can be detected. Developer mode only.", "改写终端输入", "检查并修改发送前的输入。无法识别所有秘密或无回显输入。仅限开发者模式。"],
"terminal.intercept.output": [true, "Rewrite terminal output", "Inspect and modify output, including information shown as command results. Developer mode only.", "改写终端输出", "检查并修改输出,包括呈现为命令结果的信息。仅限开发者模式。"],
"vault.metadata": [true, "Host inventory", "Read host metadata that can reveal infrastructure details.", "主机清单", "读取主机元数据,可能暴露基础设施信息。"],
"vault.write": [true, "Change host inventory", "Request changes to stored host records.", "修改主机清单", "请求更改保存的主机记录。"],
"vault.credentials": [true, "Use host credentials", "Use approved host credential references through operation-bound leases; this can authenticate to hosts.", "使用主机凭据", "通过绑定操作的租约使用已批准的主机凭据引用,可能用于向主机认证。"],
"sftp.read": [true, "Read remote files", "Read remote file content through supported host APIs.", "读取远程文件", "通过支持的宿主 API 读取远程文件内容。"],
"sftp.write": [true, "Change remote files", "Write or change remote files through supported host APIs.", "修改远程文件", "通过支持的宿主 API 写入或修改远程文件。"],
network: [true, "Network requests", "Contact approved origins. Data sent to them leaves this device.", "网络请求", "访问已批准的网络来源。发送的数据会离开此设备。"],
"filesystem.read": [true, "Read local files", "Read approved local paths, which may contain sensitive files.", "读取本地文件", "读取已批准的本地路径,其中可能包含敏感文件。"],
"filesystem.write": [true, "Change local files", "Write to approved local paths; existing content may be changed.", "修改本地文件", "写入已批准的本地路径,可能改变现有文件内容。"],
secrets: [true, "Plugin secrets", "Store and use this plugin's secrets through OS-backed storage and controlled leases.", "插件秘密", "通过系统密钥存储与受控租约保存和使用此插件的秘密。"],
"companion.execute": [true, "Run native programs", "Launch digest-verified companion executables with device authority. Developer mode only.", "运行本机程序", "启动经过摘要校验的辅助可执行程序,拥有设备权限。仅限开发者模式。"],
"provider.terminal": [true, "Terminal providers", "Provide terminal features; separate permissions govern access to terminal data.", "终端提供器", "提供终端扩展功能;终端数据访问由额外权限控制。"],
"provider.connection": [true, "Connection provider", "Implement a connection backend and handle its session data.", "连接提供器", "实现连接后端并处理对应会话数据。"],
"provider.authentication": [true, "Authentication provider", "Participate in login challenges using approved credential leases.", "认证提供器", "使用已批准的凭据租约参与登录验证。"],
"provider.sync": [true, "Encrypted sync storage", "Store encrypted sync objects; this permission does not provide the master key or plaintext vault.", "加密同步存储", "存储加密同步对象;此权限不提供主密钥或明文凭据库。"],
"provider.importer": [true, "Import provider", "Parse user-selected files into drafts for host review; input files may be sensitive.", "导入提供器", "解析用户选择的文件并生成供宿主确认的草稿;输入文件可能包含敏感数据。"],
};
function describePluginPermission(permission, locale = "en") {
const entry = Object.hasOwn(PERMISSION_DESCRIPTIONS, permission) ? PERMISSION_DESCRIPTIONS[permission] : null;
const chinese = locale.startsWith("zh");
if (!entry) return { known: false, sensitive: true, label: chinese ? "无法识别的权限" : "Unrecognized permission", description: chinese ? "此版本无法识别该权限,拒绝授权。" : "This version does not recognize this permission. Access is denied." };
return { known: true, sensitive: entry[0], label: entry[chinese ? 3 : 1], description: entry[chinese ? 4 : 2] };
}
module.exports = { PERMISSION_DESCRIPTIONS, describePluginPermission };
@@ -0,0 +1,15 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const schema = require('../packages/plugin-contract/schema/plugin-contract.schema.json');
const { PERMISSION_DESCRIPTIONS, describePluginPermission } = require('./pluginPermissionDescriptions.cjs');
test('permission explanation coverage cannot drift from the canonical permission catalog', () => {
assert.deepEqual(Object.keys(PERMISSION_DESCRIPTIONS).sort(), [...schema.$defs.PluginPermission.enum].sort());
for (const id of schema.$defs.PluginPermission.enum) {
for (const locale of ['en', 'zh-CN']) {
const description = describePluginPermission(id, locale);
assert.equal(description.known, true); assert.ok(description.description.length > 5);
}
}
assert.equal(describePluginPermission('unknown.permission').sensitive, true);
assert.equal(describePluginPermission('unknown.permission').known, false);
});
+36
View File
@@ -0,0 +1,36 @@
"use strict";
// Shared by host placement, release admission, and the installed-package UI.
// A Node entrypoint does not change default placement, but is not admitted to
// the normal browser-only product path.
function describePluginRuntime(manifest) {
const providers = manifest?.contributes?.providers ?? [];
const permissions = [...(manifest?.permissions?.required ?? []), ...(manifest?.permissions?.optional ?? [])];
const permissionIds = permissions.map((entry) => typeof entry === "string" ? entry : entry.permission);
const hasCompanions = Boolean(manifest?.companionExecutables?.length);
const hasInterceptors = providers.some((provider) => (
provider.kind === "terminal.interceptor.input" || provider.kind === "terminal.interceptor.output"
));
const declaresAdvanced = permissionIds.some((permission) => [
"runtime.advanced", "companion.execute", "terminal.intercept.input", "terminal.intercept.output",
].includes(permission));
const hasBrowser = Boolean(manifest?.main?.browser);
const hasNode = Boolean(manifest?.main?.node);
const defaultRuntimeKind = hasBrowser && !hasCompanions && !hasInterceptors ? "browser" : "utility";
return {
defaultRuntimeKind,
hasNode,
declaresAdvanced,
browserOnly: hasBrowser && !hasNode && !hasCompanions && !hasInterceptors && !declaresAdvanced,
};
}
function assertBrowserOnlyPlugin(manifest) {
if (!describePluginRuntime(manifest).browserOnly) {
const error = new Error("This package requires advanced developer mode; normal plugin management accepts browser-only packages");
error.code = "PLUGIN_UNSUPPORTED_RUNTIME";
throw error;
}
}
module.exports = { describePluginRuntime, assertBrowserOnlyPlugin };
+21
View File
@@ -0,0 +1,21 @@
const assert = require('node:assert/strict');
const test = require('node:test');
const { describePluginRuntime, assertBrowserOnlyPlugin } = require('./pluginRuntimePolicy.cjs');
test('dual entrypoints retain browser default placement but require developer admission', () => {
const manifest = { main: { browser: 'browser.js', node: 'node.js' } };
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'browser');
assert.equal(describePluginRuntime(manifest).hasNode, true);
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
test('normal release admission rejects advanced declarations and privileged providers', () => {
assert.doesNotThrow(() => assertBrowserOnlyPlugin({ main: { browser: 'index.js' }, permissions: { required: ['settings.read', 'commands'] } }));
for (const permission of ['runtime.advanced', 'companion.execute', 'terminal.intercept.input', 'terminal.intercept.output']) {
for (const kind of ['required', 'optional']) {
assert.throws(() => assertBrowserOnlyPlugin({ main: { browser: 'index.js' }, permissions: { [kind]: [permission] } }), /developer mode/);
}
}
const manifest = { main: { browser: 'index.js', node: 'node.js' }, contributes: { providers: [{ kind: 'terminal.interceptor.input' }] } };
assert.equal(describePluginRuntime(manifest).defaultRuntimeKind, 'utility');
assert.throws(() => assertBrowserOnlyPlugin(manifest), /developer mode/);
});
+10
View File
@@ -47,6 +47,16 @@ test("effectiveDefaultAlgorithms (modern) includes chacha20-poly1305", () => {
assert.ok(result.cipher.includes("chacha20-poly1305@openssh.com"));
});
test("effectiveDefaultAlgorithms offers OpenSSH host certificates before plain host keys", () => {
const cert = "ssh-ed25519-cert-v01@openssh.com";
const modern = effectiveDefaultAlgorithms(false);
assert.ok(modern.serverHostKey.includes(cert));
assert.ok(modern.serverHostKey.indexOf(cert) < modern.serverHostKey.indexOf("ssh-ed25519"));
const legacy = effectiveDefaultAlgorithms(true);
assert.ok(legacy.serverHostKey.includes(cert));
assert.ok(legacy.serverHostKey.includes("ssh-dss"));
});
test("effectiveDefaultAlgorithms (legacy) appends sha1 KEX, CBC, and ssh-dss", () => {
const modern = effectiveDefaultAlgorithms(false);
const legacy = effectiveDefaultAlgorithms(true);
+14
View File
@@ -77,6 +77,13 @@ export const SUPPORTED_HMAC_ALGORITHMS: readonly string[] = [
];
export const SUPPORTED_SERVER_HOST_KEY_ALGORITHMS: readonly string[] = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
@@ -148,6 +155,13 @@ const MODERN_DEFAULT_ALGORITHMS: Readonly<Record<SSHAlgorithmCategory, readonly
"hmac-sha1",
],
serverHostKey: [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"ecdsa-sha2-nistp256",
"ecdsa-sha2-nistp384",
+2
View File
@@ -68,6 +68,8 @@ module.exports = {
// Renderer and Mosh's main-process bootstrap share the same
// fail-closed prompt classifier at runtime.
'domain/terminalPromptSecurity.shared.cjs',
'domain/pluginRuntimePolicy.cjs',
'domain/pluginPermissionDescriptions.cjs',
// Main-process SSH, SFTP, and port-forward bridges share this matcher.
// TypeScript under domain/ is excluded below, so the .cjs must be listed.
'domain/systemManager/containerRuntime.shared.cjs',
@@ -2,6 +2,18 @@ const test = require('node:test');
const assert = require('node:assert/strict');
const { EventEmitter } = require('node:events');
const { startPtyJob } = require('./ptyExec.cjs');
const { getFreshIdlePrompt, getVisibleIdlePrompt, trackSessionIdlePrompt } = require('./shellUtils.cjs');
const { terminalCursorLine } = require('../terminalLogSanitizer.cjs');
function readyPrompt(session) {
return getFreshIdlePrompt(session) || getVisibleIdlePrompt(session);
}
// PSReadLine echoes the PowerShell clear-prefix keys, then erases them with
// cursor-column + ED without rewriting the prompt into the byte stream.
function powershellEraseRedraw(prompt) {
return `${prompt}ggd2147483647d\x1b[20G\x1b[0J`;
}
class TestPty extends EventEmitter {
writes = [];
@@ -158,3 +170,116 @@ test('wall deadline during authorization reports authorization failure rather th
assert.match(result.error, /authorization timed out/);
assert.deepEqual(pty.writes, []);
});
test('idle PowerShell whose raw tail was CSI-erased still receives the command', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'PS C:\\Users\\alice> ';
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${powershellEraseRedraw(prompt)}`);
assert.equal(getFreshIdlePrompt(session), '');
const job = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
});
t.mock.timers.tick(200);
const result = await job.resultPromise;
assert.equal(result.ok, true);
assert.equal(result.stdout, 'OK');
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test('the next PowerShell exec runs after the clear-prefix echo is erased on screen', async () => {
const prompt = 'PS C:\\Users\\alice> ';
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, prompt);
const pty = new EventEmitter();
pty.writes = [];
pty.write = (data) => {
const text = String(data);
pty.writes.push(text);
const marker = text.match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
if (!marker) return;
queueMicrotask(() => {
const chunk = `${marker}_S\r\nok\r\n${marker}_E:0\r\n${powershellEraseRedraw(prompt)}`;
trackSessionIdlePrompt(session, chunk);
pty.emit('data', chunk);
});
};
const first = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
timeoutMs: 1000,
typedInput: true,
});
const firstResult = await first.resultPromise;
assert.equal(firstResult.ok, true);
assert.equal(firstResult.stdout, 'ok');
assert.equal(getFreshIdlePrompt(session), '');
assert.equal(getVisibleIdlePrompt(session), prompt);
const second = startPtyJob(pty, 'Write-Output next', {
shellKind: 'powershell',
getReadyPrompt: () => readyPrompt(session),
promptWaitTimeoutMs: 200,
timeoutMs: 1000,
typedInput: true,
});
const secondResult = await second.resultPromise;
assert.equal(secondResult.ok, true);
assert.equal(secondResult.stdout, 'ok');
assert.equal(pty.writes.length, 2);
});
test('alt-screen paint is not ready after the entry sequence leaves the prompt tail', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
const prompt = 'PS C:\\Users\\alice> ';
// A full-screen app can emit more than the 4096-byte prompt tail. Once the
// alternate-screen entry scrolls off, a painted idle prompt must not unlock
// ptyExec and type into the still-running program.
trackSessionIdlePrompt(session, prompt);
trackSessionIdlePrompt(session, `\x1b[?1049h${'x'.repeat(4200)}`);
trackSessionIdlePrompt(session, `\r\n${prompt}\r`);
assert.equal(session._promptTrackTail.includes('\x1b[?1049h'), false);
assert.equal(terminalCursorLine(session._promptTrackTail).trimEnd(), prompt.trimEnd());
assert.equal(getFreshIdlePrompt(session), '');
assert.equal(getVisibleIdlePrompt(session), '');
assert.equal(readyPrompt(session), '');
const job = startPtyJob(pty, 'Write-Output ok', {
shellKind: 'powershell',
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.match(result.error, /no command was sent/);
assert.deepEqual(pty.writes, []);
});
test('real pending PowerShell input is still not sent', async (t) => {
t.mock.timers.enable({ apis: ['Date', 'setTimeout'] });
const pty = new TestPty();
const session = { _promptTrackTail: '', lastIdlePrompt: '' };
trackSessionIdlePrompt(session, 'PS C:\\Users\\alice> Write-Output ok');
const job = startPtyJob(pty, 'echo OK', {
shellKind: 'powershell',
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.match(result.error, /no command was sent/);
assert.deepEqual(pty.writes, []);
});
+60 -8
View File
@@ -61,10 +61,18 @@ const POWERSHELL_PROMPT_PATTERN = /^PS(?:\s+\S.*)?>$/;
// PowerShell's `PS C:\...>` (handled by POWERSHELL_PROMPT_PATTERN first).
const CMD_PROMPT_PATTERN = /^[A-Za-z]:(?:\\[^<>"|]*)?>$/;
// Classic `user@host:...$` / `user@host:...#` login prompt (bash/zsh in WSL,
// remote Linux, etc.). Intentionally narrow so custom / fish prompts do not
// flip a Windows DefaultShell soft hint.
const POSIX_PROMPT_PATTERN = /^[^\s@]+@[^\s:]+(?::[^\n\r]*)?[#$]$/;
// Default login prompts, kept narrow so custom / fish / starship prompts do
// not flip a Windows DefaultShell soft hint:
// - bash/zsh `user@host[:path][#$]` (colon before the path; `root@host ~#` stays rejected)
// - RHEL-family `[\u@\h \W]\$` -> `[user@host path][#$]`, or `[user@host][#$]`
// with no path. `\W` may contain whitespace or `]` (`[user@host My [Project]]$`);
// the final `]` before `$`/`#` is the delimiter.
const POSIX_PROMPT_PATTERN = /^(?:[^\s@]+@[^\s:]+(?::[^\n\r]*)?|\[[^\s@\]]+@[^\s\]:]+(?:\s+[^\n\r]+)?\])[#$]$/;
// Alpine's default ash PS1 omits the username: `\h:\w\$ ` -> `localhost:~# `.
// Require a hostname and a home/absolute path, excluding prompt delimiters
// within the path so partially entered commands do not count as idle prompts.
const ALPINE_PROMPT_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9_.-]*:(?:~(?:\/[^#$\n\r]*)?|\/[^#$\n\r]*)[#$]$/;
function isDefaultPowerShellPromptLine(line) {
return POWERSHELL_PROMPT_PATTERN.test(String(line || ""));
@@ -75,7 +83,8 @@ function isDefaultCmdPromptLine(line) {
}
function isDefaultPosixPromptLine(line) {
return POSIX_PROMPT_PATTERN.test(String(line || "").replace(/\s+$/, ""));
const normalized = String(line || "").replace(/\s+$/, "");
return POSIX_PROMPT_PATTERN.test(normalized) || ALPINE_PROMPT_PATTERN.test(normalized);
}
function extractTrailingIdlePrompt(output) {
@@ -134,7 +143,20 @@ function looksLikeIdleAutoLogout(outputTail) {
function trackSessionIdlePrompt(session, chunk) {
if (!session || typeof chunk !== "string" || !chunk) return "";
const nextTail = `${session._promptTrackTail || ""}${chunk}`.slice(-MAX_PROMPT_TRACK_TAIL);
const previousTail = session._promptTrackTail || "";
const combined = `${previousTail}${chunk}`;
const overflow = combined.length - MAX_PROMPT_TRACK_TAIL;
if (overflow > 0) {
// The kept tail no longer starts where the previous tail started. Replay
// the dropped prefix so an alternate-screen enter or leave that scrolls
// off is still the mode at the first kept byte.
const { alternateScreenActiveAfter } = require("../terminalLogSanitizer.cjs");
session._promptTrackAltScreenAtTailStart = alternateScreenActiveAfter(
combined.slice(0, overflow),
session._promptTrackAltScreenAtTailStart === true,
);
}
const nextTail = overflow > 0 ? combined.slice(overflow) : combined;
session._promptTrackTail = nextTail;
const prompt = extractTrailingIdlePrompt(nextTail);
@@ -149,8 +171,9 @@ function trackSessionIdlePrompt(session, chunk) {
// Return `session.lastIdlePrompt` only if the PTY's recent rolling tail
// still ends with it. The cached prompt is updated only when
// extractTrailingIdlePrompt recognizes a known shape (PowerShell, cmd.exe,
// or `user@host[:path][#$]`); a remote shell switch into another shell, an
// oh-my-posh / starship / custom PS1, or any unrecognized prompt would
// `user@host[:path][#$]`, RHEL `[user@host path][#$]`, or Alpine `host:path[#$]`);
// a remote shell switch into another shell, an oh-my-posh / starship / custom
// PS1, or any unrecognized prompt would
// otherwise leave a stale value behind, which `resolveEffectiveShellKind`
// would then keep using to coerce future commands into a PowerShell
// wrapper. By re-checking the live tail we self-correct: if the visible
@@ -172,6 +195,34 @@ function getFreshIdlePrompt(session) {
return normalizedTail.endsWith(normalizedCached) ? cached : "";
}
// Readiness companion to getFreshIdlePrompt. PSReadLine echoes keys (including
// the pending-input clear prefix) and then erases them with cursor-column +
// ED/EL without appending a fresh copy of the prompt. stripAnsi removes the
// erase, so the raw tail no longer ends with the cached prompt while the
// screen is still an idle default prompt (#3927). Collapse those cursor and
// erase operations, then accept only the line the cursor is actually on.
function getVisibleIdlePrompt(session) {
const tail = session?._promptTrackTail;
if (typeof tail !== "string" || !tail) return "";
const { terminalCursorLine } = require("../terminalLogSanitizer.cjs");
const cursorLine = terminalCursorLine(tail, {
alternateScreenActive: session._promptTrackAltScreenAtTailStart === true,
});
const rightTrimmed = cursorLine.replace(/\s+$/, "");
if (!rightTrimmed) return "";
if (
!isDefaultPowerShellPromptLine(rightTrimmed)
&& !isDefaultPosixPromptLine(rightTrimmed)
&& !isDefaultCmdPromptLine(rightTrimmed)
) {
return "";
}
// Keep one trailing space when the visible prompt has one. Erase-to-end
// padding must not become part of the expected prompt.
return /\s$/.test(cursorLine) ? `${rightTrimmed} ` : rightTrimmed;
}
// ── URL helpers ──
function isLocalhostHostname(hostname) {
@@ -1112,6 +1163,7 @@ module.exports = {
formatSyntheticEcho,
extractTrailingIdlePrompt,
getFreshIdlePrompt,
getVisibleIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
+167
View File
@@ -8,6 +8,7 @@ const {
extractTrailingIdlePrompt,
formatSyntheticEcho,
getFreshIdlePrompt,
getVisibleIdlePrompt,
isDefaultPowerShellPromptLine,
isDefaultCmdPromptLine,
isDefaultPosixPromptLine,
@@ -133,14 +134,91 @@ test("isDefaultCmdPromptLine matches drive-letter cmd prompts only", () => {
test("isDefaultPosixPromptLine matches classic user@host prompts", () => {
assert.equal(isDefaultPosixPromptLine("alice@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("user@host:~$"), true);
assert.equal(isDefaultPosixPromptLine("alice@wsl:/mnt/c$"), true);
assert.equal(isDefaultPosixPromptLine("root@box:/#"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$"), true);
assert.equal(isDefaultPosixPromptLine("[opc@instance-20260905-1821 ~]$ "), true);
assert.equal(isDefaultPosixPromptLine("[root@host ~]#"), true);
assert.equal(isDefaultPosixPromptLine("[user@host]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My Project]$ "), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$"), true);
assert.equal(isDefaultPosixPromptLine("[user@host My [Project]]$ "), true);
assert.equal(isDefaultPosixPromptLine("root@host ~#"), false);
assert.equal(isDefaultPosixPromptLine("PS C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine("C:\\Users\\alice>"), false);
assert.equal(isDefaultPosixPromptLine(""), false);
});
test("isDefaultPosixPromptLine matches Alpine host:path prompts without a username", () => {
for (const prompt of [
"localhost:~#",
"localhost:~# ",
"alpine:~$ ",
"alpine-host:~/projects$",
"alpine-host:/# ",
"alpine.example:/srv/My Project$ ",
]) {
assert.equal(isDefaultPosixPromptLine(prompt), true, prompt);
}
});
test("Alpine prompt recognition rejects output and partially entered commands", () => {
for (const line of [
"#",
"$",
"status: ready#",
"progress:100%$",
":~#",
"alpine:#",
"alpine:relative$",
"alpine:~user$",
"localhost:~# printf busy",
"localhost:/tmp# printf busy#",
"alpine:/tmp$ printf busy$",
]) {
assert.equal(isDefaultPosixPromptLine(line), false, line);
assert.equal(extractTrailingIdlePrompt(`welcome\r\n${line}`), "", line);
}
});
test("extracts an Alpine idle prompt while preserving its trailing whitespace", () => {
assert.equal(
extractTrailingIdlePrompt("Welcome to Alpine!\r\n\x1b[32mlocalhost:~# \x1b[0m"),
"localhost:~# ",
);
assert.equal(extractTrailingIdlePrompt("alpine:/srv$ "), "alpine:/srv$ ");
assert.equal(extractTrailingIdlePrompt("localhost:~# \r\n"), "");
});
test("extracts a trailing RHEL-family bracketed idle prompt", () => {
// Oracle Linux / RHEL / CentOS / Fedora default PS1 is `[\u@\h \W]\$ `.
// Trailing space must round-trip, same as the PowerShell prompt capture.
assert.equal(
extractTrailingIdlePrompt("[opc@instance-20260905-1821 ~]$ "),
"[opc@instance-20260905-1821 ~]$ ",
);
assert.equal(
extractTrailingIdlePrompt("Last login: Fri Oct 24\r\n[root@host ~]#"),
"[root@host ~]#",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\nuser@host:~$ "),
"user@host:~$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My Project]$ "),
"[user@host My Project]$ ",
);
assert.equal(
extractTrailingIdlePrompt("welcome\r\n[user@host My [Project]]$ "),
"[user@host My [Project]]$ ",
);
// Unbracketed space form is still not a recognized default prompt.
assert.equal(extractTrailingIdlePrompt("welcome\r\nroot@host ~#"), "");
});
test("isPlausibleCliVersionOutput rejects stack traces and file URLs", () => {
assert.equal(isPlausibleCliVersionOutput("2.1.123 (Claude Code)"), true);
assert.equal(isPlausibleCliVersionOutput("codex-cli 0.125.0"), true);
@@ -674,6 +752,95 @@ test("getFreshIdlePrompt returns empty string when the session has no cached pro
);
});
test("tracks split Alpine prompts and expires them while the shell is busy", () => {
const session = {};
assert.equal(trackSessionIdlePrompt(session, "Welcome to Alpine!\r\nlocalhost:"), "");
assert.equal(trackSessionIdlePrompt(session, "~# "), "localhost:~# ");
assert.equal(getFreshIdlePrompt(session), "localhost:~# ");
assert.equal(trackSessionIdlePrompt(session, "printf busy"), "");
assert.equal(getFreshIdlePrompt(session), "");
trackSessionIdlePrompt(session, "\r\nbusy\r\nlocalhost:/srv# ");
assert.equal(getFreshIdlePrompt(session), "localhost:/srv# ");
trackSessionIdlePrompt(session, "printf working\r\nworking...");
assert.equal(getFreshIdlePrompt(session), "");
});
test("trackSessionIdlePrompt and getFreshIdlePrompt accept an Oracle/RHEL bracketed bash prompt", () => {
const session = {};
trackSessionIdlePrompt(session, "Last login...\r\n[opc@instance-20260905-1821 ~]$ ");
assert.equal(session.lastIdlePrompt, "[opc@instance-20260905-1821 ~]$ ");
assert.equal(getFreshIdlePrompt(session), "[opc@instance-20260905-1821 ~]$ ");
const rootSession = {};
trackSessionIdlePrompt(rootSession, "[root@host ~]#");
assert.equal(getFreshIdlePrompt(rootSession), "[root@host ~]#");
const classic = {};
trackSessionIdlePrompt(classic, "user@host:~$ ");
assert.equal(getFreshIdlePrompt(classic), "user@host:~$ ");
const unbracketed = {};
assert.equal(trackSessionIdlePrompt(unbracketed, "root@host ~#"), "");
assert.equal(getFreshIdlePrompt(unbracketed), "");
const spacedPath = {};
trackSessionIdlePrompt(spacedPath, "Last login...\r\n[user@host My Project]$ ");
assert.equal(spacedPath.lastIdlePrompt, "[user@host My Project]$ ");
assert.equal(getFreshIdlePrompt(spacedPath), "[user@host My Project]$ ");
const bracketedPath = {};
trackSessionIdlePrompt(bracketedPath, "Last login...\r\n[user@host My [Project]]$ ");
assert.equal(bracketedPath.lastIdlePrompt, "[user@host My [Project]]$ ");
assert.equal(getFreshIdlePrompt(bracketedPath), "[user@host My [Project]]$ ");
});
test("visible idle prompt survives a PSReadLine erase that leaves the raw tail dirty", () => {
// PSReadLine echoes the pending-input clear prefix (`ggd2147483647d` / `i`),
// then homes the cursor and erases those keys with CSI. stripAnsi drops the
// erase but keeps the keys, so the cached prompt no longer matches the tail.
// The screen is still an idle default PowerShell prompt (#3927).
const session = {};
const prompt = "PS C:\\Users\\alice> ";
trackSessionIdlePrompt(session, `Windows PowerShell\r\n${prompt}ggd2147483647d\x1b[20G\x1b[0J`);
assert.equal(getFreshIdlePrompt(session), "");
assert.equal(extractTrailingIdlePrompt(session._promptTrackTail), "");
assert.equal(getVisibleIdlePrompt(session), prompt);
const backspaced = {};
trackSessionIdlePrompt(backspaced, `${prompt}\x08`);
assert.equal(getFreshIdlePrompt(backspaced), "");
assert.equal(getVisibleIdlePrompt(backspaced), prompt);
const crHomed = {};
trackSessionIdlePrompt(crHomed, `${prompt}\r`);
assert.equal(getFreshIdlePrompt(crHomed), "");
assert.equal(getVisibleIdlePrompt(crHomed), prompt);
});
test("visible idle prompt stays empty for pending input, a following line, and an unrecognized prompt", () => {
const pending = {};
trackSessionIdlePrompt(pending, "PS C:\\Users\\alice> Write-Output ok");
assert.equal(getFreshIdlePrompt(pending), "");
assert.equal(getVisibleIdlePrompt(pending), "");
const movedOn = {};
trackSessionIdlePrompt(movedOn, "PS C:\\Users\\alice> \r\n");
assert.equal(getVisibleIdlePrompt(movedOn), "");
const custom = {};
trackSessionIdlePrompt(custom, "PS C:\\Users\\alice>\r\nexit\r\n❯ ");
assert.equal(getVisibleIdlePrompt(custom), "");
const fullscreen = {};
trackSessionIdlePrompt(fullscreen, "PS C:\\Users\\alice> \x1b[?1049h\x1b[Hvim");
assert.equal(getVisibleIdlePrompt(fullscreen), "");
assert.equal(getVisibleIdlePrompt({ _promptTrackTail: "" }), "");
assert.equal(getVisibleIdlePrompt(null), "");
});
test("getFreshIdlePrompt and trackSessionIdlePrompt round-trip through a real PTY-like flow", () => {
// (1) Remote PowerShell prompt arrives — lastIdlePrompt is captured.
const session = {};
@@ -3,7 +3,7 @@
// runs under `with (ctx)` where bare `require` resolves to ctx.require
// (based in electron/bridges/). Requiring here keeps the path unambiguous.
const { emitTerminalSessionData } = require("../emitTerminalSessionData.cjs");
const { formatSyntheticEcho } = require("../ai/shellUtils.cjs");
const { formatSyntheticEcho, getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { remoteDisallowsExecChannelProbe, sessionDisallowsExtraSshChannel, ensureSessionShellKindForExec } = require("../ai/sessionShellKind.cjs");
const defaultAiPrivacyService = require("../../shared/aiPrivacyService.cjs");
@@ -217,7 +217,7 @@ function registerCattyExecHandlers(ctx) {
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => {
const contents = electronModule?.webContents?.fromId?.(session.webContentsId);
@@ -329,6 +329,43 @@ test("expired local and unknown jobs do not claim a lost worker route", (t) => {
});
test("MCP exec sends on an idle PowerShell prompt hidden by a CSI erase", async () => {
const pty = new FakePty();
pty.write = (data) => {
pty.writes.push(String(data));
const marker = String(data).match(/(__NCMCP_[0-9a-z]+_[0-9a-f]+__)/i)?.[1];
if (!marker) return;
queueMicrotask(() => pty.emit("data", `${marker}_S\r\nok\r\n${marker}_E:0\r\n`));
};
const { trackSessionIdlePrompt } = require("./ai/shellUtils.cjs");
const session = {
protocol: "ssh",
pty,
shellKind: "powershell",
_promptTrackTail: "",
lastIdlePrompt: "",
};
trackSessionIdlePrompt(session, "PS C:\\Users\\alice> ggd2147483647d\x1b[20G\x1b[0J");
const ctx = createExecHandlerTestContext({
sessions: new Map([["ps", session]]),
backgroundJobs: new Map(),
});
ctx.commandTimeoutMs = 200;
ctx.startPtyJob = require("./ai/ptyExec.cjs").startPtyJob;
ctx.execViaPty = require("./ai/ptyExec.cjs").execViaPty;
ctx.getFreshIdlePrompt = require("./ai/shellUtils.cjs").getFreshIdlePrompt;
const api = createExecHandlerApi(ctx);
const result = await api.handleExec({
sessionId: "ps",
chatSessionId: "ps-chat",
command: "Write-Output ok",
});
assert.equal(result.ok, true);
assert.equal(result.stdout, "ok");
assert.equal(pty.writes.length, 1);
assert.match(pty.writes[0], /\$__NCMCP_/);
});
test("MCP background readiness timeout survives job polling without PTY writes", async (t) => {
t.mock.timers.enable({ apis: ["Date", "setTimeout"] });
const pty = new FakePty();
@@ -2,6 +2,7 @@
// Module-level require: code inside createExecHandlerApi runs under `with (ctx)`
// where bare `require` resolves to ctx.require (based in electron/bridges/).
const { clearSessionFlowState } = require("../terminalFlowAck.cjs");
const { getVisibleIdlePrompt } = require("../ai/shellUtils.cjs");
const { emitJobDiag } = require("../ai/jobStartupDiagnostics.cjs");
const {
ensureSessionShellKind,
@@ -159,7 +160,7 @@ function createExecHandlerApi(ctx) {
onEchoSuppressionPrime: (marker) => echoCommandToSession(session, sessionId, `${marker}_I`, { syntheticEcho: false }),
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
chatSessionId,
@@ -341,7 +342,7 @@ function createExecHandlerApi(ctx) {
chatSessionId,
expectedPrompt: getFreshIdlePrompt(session),
getReadyPrompt: typeof session._promptTrackTail === "string"
? () => getFreshIdlePrompt(session) : undefined,
? () => getFreshIdlePrompt(session) || getVisibleIdlePrompt(session) : undefined,
typedInput: true,
echoCommand: (rawCommand) => echoCommandToSession(session, sessionId, rawCommand),
maxBufferedChars: MAX_BACKGROUND_JOB_OUTPUT_CHARS,
@@ -0,0 +1,289 @@
"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 { execFileSync } = require("node:child_process");
const {
DEFAULT_SERVER_HOST_KEY,
SUPPORTED_SERVER_HOST_KEY,
} = require("ssh2/lib/protocol/constants.js");
const {
parseDERKey,
parseKey,
} = require("ssh2/lib/protocol/keyParser.js");
const {
hostKeyBlobTypeAccepted,
hostKeySignatureTypeAccepted,
} = require("ssh2/lib/protocol/kex.js");
// Manual live check (optional; needs network and real Instabox credentials):
// ssh -vv -p 2222 USER@ssh.instabox.sh
// Instabox advertises only ssh-ed25519-cert-v01@openssh.com and signs the
// exchange hash as ssh-ed25519. After this patch, host-key negotiation should
// succeed; authentication still depends on the real certificate credentials.
const HOST_CERT = "ssh-ed25519-cert-v01@openssh.com";
const HOST_CERT_ALGORITHMS = [
HOST_CERT,
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
];
function sshKeygenSkipReason(error) {
if (error && error.code === "ENOENT")
return "ssh-keygen is not installed";
return false;
}
function detectSshKeygenSkip() {
try {
execFileSync("ssh-keygen", ["-h"], { stdio: "ignore" });
return false;
} catch (error) {
return sshKeygenSkipReason(error);
}
}
const sshKeygenSkip = detectSshKeygenSkip();
function mintEd25519HostCert(dir) {
const ca = path.join(dir, "ca");
const host = path.join(dir, "host");
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", ca]);
execFileSync("ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-f", host]);
execFileSync("ssh-keygen", [
"-q",
"-s",
ca,
"-I",
"netcatty-hostcert",
"-h",
"-n",
"127.0.0.1",
"-V",
"+52w",
`${host}.pub`,
]);
const certLine = fs.readFileSync(`${host}-cert.pub`, "utf8").trim();
const [type, b64] = certLine.split(/\s+/);
const privateKey = parseKey(fs.readFileSync(host));
return {
type,
certBlob: Buffer.from(b64, "base64"),
privateKeyPem: fs.readFileSync(host),
privateKey,
};
}
test("ssh2 default and supported host key lists offer OpenSSH host certificates", () => {
for (const algo of HOST_CERT_ALGORITHMS) {
assert.equal(DEFAULT_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from defaults`);
assert.equal(SUPPORTED_SERVER_HOST_KEY.includes(algo), true, `${algo} missing from supported`);
}
assert.ok(
DEFAULT_SERVER_HOST_KEY.indexOf(HOST_CERT) < DEFAULT_SERVER_HOST_KEY.indexOf("ssh-ed25519"),
"host certificates should be offered before the matching plain key",
);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-ed25519"), true);
assert.equal(DEFAULT_SERVER_HOST_KEY.includes("ssh-rsa"), true);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ssh-ed25519-cert-v01@openssh.com"),
true,
);
assert.equal(
SUPPORTED_SERVER_HOST_KEY.includes("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com"),
true,
);
});
test("missing ssh-keygen skips host-certificate minting instead of failing", () => {
const missing = Object.assign(new Error("spawn ssh-keygen ENOENT"), { code: "ENOENT" });
assert.equal(sshKeygenSkipReason(missing), "ssh-keygen is not installed");
const present = Object.assign(new Error("usage"), { status: 1 });
assert.equal(sshKeygenSkipReason(present), false);
assert.equal(sshKeygenSkipReason(null), false);
});
test("parseDERKey reads an OpenSSH ed25519 host certificate the way KEX does", { skip: sshKeygenSkip }, () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-"));
try {
const minted = mintEd25519HostCert(dir);
assert.equal(minted.type, HOST_CERT);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const typeLen = minted.certBlob.readUInt32BE(0);
const type = minted.certBlob.subarray(4, 4 + typeLen).toString();
const keyMaterial = minted.certBlob.subarray(4 + typeLen);
const parsed = parseDERKey(keyMaterial, type);
assert.equal(parsed instanceof Error, false, parsed instanceof Error ? parsed.message : "");
assert.equal(parsed.type, HOST_CERT);
const message = Buffer.from("kex-exchange-hash");
const signature = minted.privateKey.sign(message);
assert.equal(signature instanceof Error, false, signature instanceof Error ? signature.message : "");
assert.equal(parsed.verify(message, signature), true);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test("host key signature type accepts the underlying algorithm for host certificates", () => {
assert.equal(typeof hostKeySignatureTypeAccepted, "function");
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, HOST_CERT), true);
assert.equal(hostKeySignatureTypeAccepted(HOST_CERT, "ssh-rsa"), false);
assert.equal(
hostKeySignatureTypeAccepted("ecdsa-sha2-nistp256-cert-v01@openssh.com", "ecdsa-sha2-nistp256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "rsa-sha2-512"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-256-cert-v00@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "rsa-sha2-256"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-256"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "rsa-sha2-512"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v01@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeySignatureTypeAccepted("ssh-rsa-cert-v00@openssh.com", "ssh-rsa"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa-cert-v01@openssh.com"),
true,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-256-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(
hostKeyBlobTypeAccepted("rsa-sha2-512-cert-v01@openssh.com", "ssh-rsa"),
false,
);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-256", "ssh-rsa"), true);
assert.equal(hostKeyBlobTypeAccepted("rsa-sha2-512", "ssh-rsa"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", "ssh-ed25519"), true);
assert.equal(hostKeySignatureTypeAccepted("ssh-ed25519", HOST_CERT), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-256", "ssh-rsa"), false);
assert.equal(hostKeySignatureTypeAccepted("rsa-sha2-512", "rsa-sha2-256"), false);
});
function loadSsh2WithCertHostKey(pem, certBlob) {
// ssh2.Server only accepts PEM text and then advertises privateKey.type.
// Reload it after wrapping parseKey so this one key is presented as an
// OpenSSH host certificate while signing still uses the embedded key.
const keyParser = require("ssh2/lib/protocol/keyParser.js");
const originalParseKey = keyParser.parseKey;
keyParser.parseKey = function parseCertHostKey(data, passphrase) {
const parsed = originalParseKey(data, passphrase);
if (!(parsed instanceof Error) && data === pem) {
parsed.type = HOST_CERT;
parsed.getPublicSSH = () => certBlob;
}
return parsed;
};
const reloadIds = ["ssh2/lib/server.js", "ssh2/lib/index.js", "ssh2"];
const resolved = reloadIds.map((id) => require.resolve(id));
const previous = resolved.map((filename) => require.cache[filename]);
for (const filename of resolved) delete require.cache[filename];
const ssh2 = require("ssh2");
for (let i = 0; i < resolved.length; i++) {
if (previous[i]) require.cache[resolved[i]] = previous[i];
else delete require.cache[resolved[i]];
}
keyParser.parseKey = originalParseKey;
return ssh2;
}
test("ssh2 client completes KEX against a cert-only ed25519 host key", { skip: sshKeygenSkip }, async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-hostcert-kex-"));
const minted = mintEd25519HostCert(dir);
assert.equal(minted.privateKey instanceof Error, false, minted.privateKey.message);
const { Client, Server } = loadSsh2WithCertHostKey(minted.privateKeyPem, minted.certBlob);
const server = new Server({ hostKeys: [minted.privateKeyPem] }, (client) => {
client.on("error", () => {});
client.on("authentication", (ctx) => ctx.reject());
});
await new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
try {
const authError = await new Promise((resolve, reject) => {
const client = new Client();
const timer = setTimeout(() => {
client.end();
reject(new Error("timed out waiting for KEX/auth"));
}, 10000);
client.on("ready", () => {
clearTimeout(timer);
client.end();
reject(new Error("authentication unexpectedly succeeded"));
});
client.on("error", (err) => {
clearTimeout(timer);
client.end();
resolve(err);
});
client.connect({
host: "127.0.0.1",
port: server.address().port,
username: "netcatty",
password: "not-the-password",
readyTimeout: 8000,
});
});
assert.match(authError.message, /authentication/i);
assert.doesNotMatch(
authError.message,
/Handshake failed|no matching host key|Wrong signature type|Unsupported OpenSSH|signature verification failed|Host key does not match/,
);
} finally {
await new Promise((resolve) => server.close(resolve));
fs.rmSync(dir, { recursive: true, force: true });
}
});
+88
View File
@@ -2,10 +2,12 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const { createHash, generateKeyPairSync, sign } = require("node:crypto");
const {
parseKey,
getOpenSSHCertSignatureAlgorithm,
} = require("ssh2/lib/protocol/keyParser.js");
const { convertSignature } = require("ssh2/lib/protocol/utils.js");
function buildSkEd25519PublicLine() {
const type = Buffer.from("sk-ssh-ed25519@openssh.com");
@@ -146,3 +148,89 @@ test("ssh2 agent protocol preserves SK signature framing, flags and counter", as
}
}
});
function sshString(value) {
const body = Buffer.isBuffer(value) ? value : Buffer.from(value);
const out = Buffer.alloc(4 + body.length);
out.writeUInt32BE(body.length, 0);
body.copy(out, 4);
return out;
}
function opensshPublicLine(typeName, fields) {
const blob = Buffer.concat([
sshString(typeName),
...fields.map((field) => sshString(field)),
]);
return `${typeName} ${blob.toString("base64")} netcatty@sk`;
}
function skEcdsaSignature(rawSig, flags, counter) {
const out = Buffer.alloc(4 + rawSig.length + 5);
out.writeUInt32BE(rawSig.length, 0);
rawSig.copy(out, 4);
const pos = 4 + rawSig.length;
out[pos] = flags;
out.writeUInt32BE(counter, pos + 1);
return out;
}
test("ssh2 verifies an SK ECDSA certificate with the same P-256 signature as the plain key", () => {
const { publicKey, privateKey } = generateKeyPairSync("ec", {
namedCurve: "prime256v1",
});
const jwk = publicKey.export({ format: "jwk" });
const point = Buffer.concat([
Buffer.from([0x04]),
Buffer.from(jwk.x, "base64url"),
Buffer.from(jwk.y, "base64url"),
]);
const application = "ssh:";
const plain = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256@openssh.com", [
"nistp256",
point,
application,
]));
const cert = parseKey(opensshPublicLine("sk-ecdsa-sha2-nistp256-cert-v01@openssh.com", [
Buffer.alloc(16, 3),
"nistp256",
point,
application,
]));
assert.equal(plain instanceof Error, false, plain instanceof Error ? plain.message : "");
assert.equal(cert instanceof Error, false, cert instanceof Error ? cert.message : "");
assert.equal(cert.type, "sk-ecdsa-sha2-nistp256-cert-v01@openssh.com");
assert.equal(cert.getApplication(), application);
const message = Buffer.from("kex-exchange-hash");
const flags = 0x01;
const counter = 0x12345678;
const appHash = createHash("sha256").update(application).digest();
const dataHash = createHash("sha256").update(message).digest();
const signedData = Buffer.alloc(appHash.length + 1 + 4 + dataHash.length);
let offset = 0;
appHash.copy(signedData, offset);
offset += appHash.length;
signedData[offset++] = flags;
signedData.writeUInt32BE(counter, offset);
offset += 4;
dataHash.copy(signedData, offset);
const der = sign("sha256", signedData, privateKey);
const rawSig = convertSignature(der, "ecdsa-sha2-nistp256");
assert.ok(Buffer.isBuffer(rawSig));
const signature = skEcdsaSignature(rawSig, flags, counter);
const plainResult = plain.verify(message, signature);
assert.equal(
plainResult,
true,
plainResult instanceof Error ? plainResult.message : String(plainResult),
);
const certResult = cert.verify(message, signature);
assert.equal(
certResult,
true,
certResult instanceof Error ? certResult.message : String(certResult),
);
});
+16 -3
View File
@@ -114,6 +114,13 @@ function applyLegacyAlgorithms(algorithms) {
]),
);
algorithms.serverHostKey = [
"ssh-ed25519-cert-v01@openssh.com",
"ecdsa-sha2-nistp256-cert-v01@openssh.com",
"ecdsa-sha2-nistp384-cert-v01@openssh.com",
"ecdsa-sha2-nistp521-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521",
"rsa-sha2-512", "rsa-sha2-256",
"ssh-rsa", "ssh-dss",
@@ -150,10 +157,16 @@ function applyLegacyHmacAlgorithms(algorithms) {
}
// Mirror of ssh2's DEFAULT_SERVER_HOST_KEY (see ssh2/lib/protocol/constants.js)
// minus every ecdsa-sha2-*. Used when skipEcdsaHostKey is on but legacy mode
// is off, so the field would otherwise be undefined and ssh2 would fall back
// to its built-in defaults (which still include ecdsa-sha2-*).
// minus every ecdsa-sha2-* (plain keys and host certificates). Used when
// skipEcdsaHostKey is on but legacy mode is off, so the field would otherwise
// be undefined and ssh2 would fall back to its built-in defaults (which still
// include ecdsa-sha2-*). Non-ECDSA host certificates stay so a cert-only
// bastion can still be reached.
const MODERN_SERVER_HOST_KEY_WITHOUT_ECDSA = Object.freeze([
"ssh-ed25519-cert-v01@openssh.com",
"rsa-sha2-512-cert-v01@openssh.com",
"rsa-sha2-256-cert-v01@openssh.com",
"ssh-rsa-cert-v01@openssh.com",
"ssh-ed25519",
"rsa-sha2-512",
"rsa-sha2-256",
+20
View File
@@ -269,6 +269,19 @@ for (const [label, buildAlgorithms] of [
["SSH", sshBridge.buildAlgorithms],
["SFTP", sftpBridge.buildSftpAlgorithms],
]) {
test(`${label} legacy serverHostKey offers OpenSSH host certificates before plain keys`, () => {
withAlgorithmRuntime({}, () => {
const keys = buildAlgorithms(true).serverHostKey;
assert.ok(keys.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(keys.indexOf("ssh-ed25519-cert-v01@openssh.com") < keys.indexOf("ssh-ed25519"));
assert.ok(keys.includes("ecdsa-sha2-nistp256-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(keys.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-rsa-cert-v01@openssh.com"));
assert.ok(keys.includes("ssh-dss"));
});
});
test(`${label} skipEcdsaHostKey removes every ecdsa-sha2-* from serverHostKey (legacy on)`, () => {
withAlgorithmRuntime({}, () => {
const algorithms = buildAlgorithms(true, { skipEcdsaHostKey: true });
@@ -280,6 +293,9 @@ for (const [label, buildAlgorithms] of [
assert.ok(algorithms.serverHostKey.includes("ssh-rsa"));
assert.ok(algorithms.serverHostKey.includes("ssh-dss"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519"));
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
@@ -293,6 +309,10 @@ for (const [label, buildAlgorithms] of [
for (const algo of algorithms.serverHostKey) {
assert.ok(!algo.startsWith("ecdsa-sha2-"), `${algo} should be filtered out`);
}
assert.ok(algorithms.serverHostKey.includes("ssh-ed25519-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-512-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("rsa-sha2-256-cert-v01@openssh.com"));
assert.ok(algorithms.serverHostKey.includes("ssh-rsa-cert-v01@openssh.com"));
});
});
+90
View File
@@ -76,6 +76,11 @@ class TerminalTextRenderer {
return this.toString();
}
getCursorLine() {
const line = this.lines[this.row] || [];
return line.map((cell) => cell?.ch || " ").join("");
}
toString({ includePendingClearedScreen = false, formatLine = (line) => line } = {}) {
const lines = includePendingClearedScreen ? this.#linesWithPendingClearedScreen() : this.lines;
return lines
@@ -515,6 +520,89 @@ function terminalDataToPlainText(terminalData) {
return renderer.finish();
}
function terminalCursorLine(terminalData, options) {
// Prompt tracking keeps a rolling tail, so the alternate-screen entry may
// already have scrolled off. Seed the mode that was active at the first
// byte still in that tail; a fresh renderer would otherwise treat TUI paint
// as the main screen.
const renderer = new TerminalTextRenderer({
alternateScreenActive: options?.alternateScreenActive === true,
});
renderer.feed(terminalData || "");
renderer.finish();
// A full-screen program owns the display. The main buffer may still hold
// the shell prompt from before the alternate screen was entered.
if (renderer.alternateScreenActive) return "";
return renderer.getCursorLine();
}
// Alternate-screen transitions from TerminalTextRenderer, without a text
// buffer. Callers replay the bytes a rolling tail dropped so truncation
// cannot clear the guard.
function alternateScreenActiveAfter(text, alternateScreenActive) {
let active = alternateScreenActive === true;
if (!text) return active;
let state = "normal";
let escapeBuffer = "";
for (const ch of text) {
if (state === "esc") {
if (ch === "[") {
state = "csi";
escapeBuffer = "";
continue;
}
if (ch === "]") {
state = "osc";
continue;
}
if (ch === "c") active = false;
state = "normal";
continue;
}
if (state === "csi") {
escapeBuffer += ch;
if (CSI_FINAL_RE.test(ch)) {
active = alternateScreenActiveFromCsi(escapeBuffer, active);
state = "normal";
escapeBuffer = "";
}
continue;
}
if (state === "osc") {
if (ch === "\x07") state = "normal";
else if (ch === "\x1b") state = "oscEsc";
continue;
}
if (state === "oscEsc") {
state = ch === "\\" ? "normal" : "osc";
continue;
}
if (ch === "\x1b") {
state = "esc";
continue;
}
if (ch === "\x9b") {
state = "csi";
escapeBuffer = "";
}
}
return active;
}
function alternateScreenActiveFromCsi(sequence, active) {
const final = sequence.at(-1);
if (final !== "h" && final !== "l") return active;
const params = sequence.slice(0, -1);
if (!params.includes("?")) return active;
for (const part of params.replace(/[?><=]/g, "").split(";")) {
if (part === "") continue;
const mode = Number.parseInt(part, 10);
if (mode === 47 || mode === 1047 || mode === 1049) return final === "h";
}
return active;
}
function terminalDataToHtmlContent(terminalData) {
const renderer = new TerminalTextRenderer();
renderer.feed(terminalData || "");
@@ -531,6 +619,8 @@ module.exports = {
createTerminalTextRenderer,
terminalDataToHtmlContent,
terminalDataToPlainText,
terminalCursorLine,
alternateScreenActiveAfter,
};
function createDefaultStyle() {
+13 -2
View File
@@ -148,8 +148,8 @@ function createBridgeRegistrar(context) {
} = require("../plugins/pluginBridge.cjs");
let terminalWorkerManager = null;
const { toElectronAccelerator } = require("../plugins/keybindings.cjs");
const { startDevelopmentPluginHost } = require("../plugins/hostBootstrap.cjs");
const pluginHostService = startDevelopmentPluginHost({
const { startPluginHost } = require("../plugins/hostBootstrap.cjs");
const pluginHostService = startPluginHost({
env: process.env,
createService: () => {
const { createPluginHostService } = require("../plugins/hostService.cjs");
@@ -166,8 +166,15 @@ function createBridgeRegistrar(context) {
electron: electronModule,
safeStorage,
requestPermissionDecision: createNativePermissionDecisionProvider({
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
dialog: electronModule.dialog,
window: win,
getWindow: () => {
const settingsWindow = getWindowManager().getSettingsWindow?.();
return settingsWindow && !settingsWindow.isDestroyed() && settingsWindow.isFocused()
? settingsWindow
: win;
},
}),
getLocale: () => getWindowManager().getCurrentLanguage?.() ?? "en",
requestTerminalInterceptorSelection: async ({ direction, providers }) => {
@@ -210,6 +217,9 @@ function createBridgeRegistrar(context) {
});
registerPluginBridge(ipcMain, {
manager: pluginHostService?.manager,
permissionEngine: pluginHostService?.permissionEngine,
database: pluginHostService?.database,
catalogService: pluginHostService?.catalogService,
contributionService: pluginHostService?.contributionService,
terminalProviderService: pluginHostService?.terminalProviderService,
terminalDataPipelineService: pluginHostService?.terminalDataPipelineService,
@@ -227,6 +237,7 @@ function createBridgeRegistrar(context) {
});
return result.canceled ? null : result.filePaths[0] ?? null;
},
resolveBrandIcon: (pluginId) => pluginHostService?.contributionIconService?.resolveBrand(pluginId),
resolveContributionIcon: (payload) => pluginHostService?.contributionIconService?.resolve(payload),
viewHost: pluginHostService?.viewHost,
env: process.env,
+6
View File
@@ -23,3 +23,9 @@ require("./plugins/runtimeSupervisor.test.cjs");
require("./plugins/shutdownCoordinator.test.cjs");
require("./plugins/streamRouter.test.cjs");
require("./plugins/utilityPluginRuntime.test.cjs");
require('./plugins/brandIconService.test.cjs');
require('./plugins/settingsViewFixture.test.cjs');
require('./plugins/registryCatalogService.test.cjs');
@@ -0,0 +1,38 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs/promises');
const path = require('node:path');
const os = require('node:os');
const { PluginContributionIconService } = require('./contributionIconService.cjs');
async function fixture(t, rasterize) {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'brand-icon-'));
t.after(() => fs.rm(root, { recursive: true, force: true }));
await fs.copyFile(path.join(__dirname, '../..', 'examples/plugins/starter-netcatty/assets/logo-light.png'), path.join(root, 'logo.png'));
let plugin = { id: 'com.test.brand', activeVersion: '1.0.0', archiveSha256: 'a', enabled: false, runtime: {}, manifest: { icon: { kind: 'package', light: 'logo.png' } } };
const service = new PluginContributionIconService({ database: { getActivePlugin: () => plugin }, packageStore: { preparePackageRoot: async () => root }, rasterizeIcon: rasterize ?? (async () => {
const output = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(output); output.write('IHDR', 12); output.writeUInt32BE(32, 16); output.writeUInt32BE(32, 20); return output;
}) });
return { service, plugin, setPlugin: (value) => { plugin = value; } };
}
test('disabled installed branding is readable without runtime activation or changing contribution admission', async (t) => {
const { service, plugin } = await fixture(t);
assert.match((await service.resolveBrand(plugin.id)).light, /^data:image\/png;base64,/);
await assert.rejects(service.resolve({ pluginId: plugin.id, icon: plugin.manifest.icon }), /not declared/);
plugin.manifest.contributes = { views: [{ icon: plugin.manifest.icon }] };
await assert.rejects(service.resolve({ pluginId: plugin.id, icon: plugin.manifest.icon }), /unavailable/);
plugin.runtime.quarantinedAt = 1;
assert.equal(await service.resolveBrand(plugin.id), null);
});
test('brand results cannot survive uninstall or a changed package hash during decoding', async (t) => {
for (const change of ['uninstall', 'hash']) {
let release; let entered;
const started = new Promise((resolve) => { entered = resolve; });
const gate = new Promise((resolve) => { release = resolve; });
const f = await fixture(t, async () => { entered(); await gate; const b = Buffer.alloc(24); Buffer.from([137,80,78,71,13,10,26,10]).copy(b); b.write('IHDR',12); b.writeUInt32BE(32,16); b.writeUInt32BE(32,20); return b; });
const pending = f.service.resolveBrand(f.plugin.id);
await started;
if (change === 'uninstall') f.setPlugin(null); else f.plugin.archiveSha256 = 'b';
release();
await assert.rejects(pending, /ownership changed/);
}
});
+195
View File
@@ -0,0 +1,195 @@
[
{
"id": "com.netcatty.starter",
"version": "0.1.2",
"name": "Plugin Starter",
"description": "First-party greeting panel, local settings and commands, without terminal or secret access.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "f4a6b205adca486d4e9cda0270e13a9ff0afd7be",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/starter-netcatty/README.md",
"manifest": {
"manifestVersion": 1,
"id": "com.netcatty.starter",
"name": "starter-netcatty",
"displayName": {
"en": "Plugin Starter",
"zh-CN": "插件入门"
},
"description": "First-party settings and command example. No terminal, network, filesystem or secret access.",
"version": "0.1.2",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal.1 <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.starter.greet",
"onView:com.netcatty.starter.panel"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"views"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.starter.greeting",
"label": {
"en": "Greeting",
"zh-CN": "问候语"
},
"description": "A local preference for the example command.",
"control": "text",
"scope": "application",
"default": "Hello, synthetic world!"
}
],
"commands": [
{
"id": "com.netcatty.starter.greet",
"title": {
"en": "Starter: Read Greeting",
"zh-CN": "入门:读取问候语"
}
}
],
"menus": [
{
"command": "com.netcatty.starter.greet",
"location": "commandPalette",
"group": "examples"
}
],
"views": [
{
"id": "com.netcatty.starter.panel",
"title": {
"en": "Starter: Greeting Panel",
"zh-CN": "入门:问候面板"
},
"location": "modal",
"entry": "panel.html"
}
]
},
"features": {
"required": [
"metadata.brand-icon"
]
},
"icon": {
"kind": "package",
"light": "assets/logo-light.png",
"dark": "assets/logo-dark.png"
}
}
},
{
"id": "com.netcatty.hello",
"version": "0.1.0",
"name": "Hello Netcatty",
"description": "First-party example: greeting settings, commands and terminal providers. Build locally and import.",
"author": "Netcatty",
"repository": "binaricat/Netcatty",
"commit": "4336f8fd53a05a48d6bbc9ad3ce0ca88b121862f",
"license": "GPL-3.0-or-later",
"readme": "examples/plugins/hello-netcatty/README.md",
"manifest": {
"$schema": "../../../packages/plugin-contract/schema/plugin-contract.schema.json",
"manifestVersion": 1,
"id": "com.netcatty.hello",
"name": "hello-netcatty",
"displayName": {
"en": "Hello Netcatty",
"zh-CN": "你好,Netcatty"
},
"description": "Minimal plugin contract and SDK example.",
"version": "0.1.0",
"publisher": "netcatty",
"license": "GPL-3.0-or-later",
"engines": {
"netcatty": ">=0.0.0",
"api": ">=0.1.0-internal <0.2.0"
},
"main": {
"browser": "dist/index.js"
},
"activationEvents": [
"onCommand:com.netcatty.hello.sayHello",
"onProvider:com.netcatty.hello.completion",
"onProvider:com.netcatty.hello.decoration",
"onProvider:com.netcatty.hello.theme"
],
"permissions": {
"required": [
"commands",
"settings.read",
"menus",
"provider.terminal",
"terminal.complete",
"terminal.output",
"terminal.decorate"
],
"optional": []
},
"contributes": {
"settings": [
{
"id": "com.netcatty.hello.greeting",
"label": "Greeting",
"description": "Text used by the example command.",
"control": "text",
"scope": "application",
"default": "Hello from Netcatty"
}
],
"commands": [
{
"id": "com.netcatty.hello.sayHello",
"title": "Say Hello",
"category": "Examples"
}
],
"menus": [
{
"command": "com.netcatty.hello.sayHello",
"location": "commandPalette",
"group": "examples",
"order": 10
}
],
"providers": [
{
"id": "com.netcatty.hello.completion",
"label": "Hello completion",
"description": "Suggests the example hello command.",
"kind": "terminal.completion"
},
{
"id": "com.netcatty.hello.decoration",
"label": "Hello decoration",
"description": "Highlights the example greeting in terminal output.",
"kind": "terminal.decoration"
},
{
"id": "com.netcatty.hello.theme",
"label": "Hello theme",
"description": "Adds a subtle cursor color to the example terminal theme.",
"kind": "terminal.theme"
}
]
}
}
}
]
+13
View File
@@ -0,0 +1,13 @@
"use strict";
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
function assertCatalogManifest(manifest, expected) {
if (manifest.id !== expected.id || manifest.version !== expected.version) {
throw new Error("Catalog package identity/version mismatch");
}
assertBrowserOnlyPlugin(manifest);
if (!manifest.license || manifest.license !== expected.license) throw new Error("Catalog package license mismatch");
}
module.exports = { assertCatalogManifest };
+130
View File
@@ -0,0 +1,130 @@
"use strict";
const { createHash } = require("node:crypto");
const fs = require("node:fs/promises");
const path = require("node:path");
const catalog = require("./catalog.json");
const { getTempFilePath } = require("../bridges/tempDirBridge.cjs");
const { createDefinitionValidator, formatValidationErrors } = require("./contractValidator.cjs");
const validateManifest = createDefinitionValidator("PluginManifest");
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
const { assertPluginJsonValue } = require("./jsonBoundary.cjs");
const MAX_CATALOG_BYTES = 2 * 1024 * 1024;
const CATALOG_FIELDS = new Set(["id", "version", "name", "description", "author", "repository", "commit", "license", "readme", "manifest", "artifact"]);
const DOWNLOAD_HOSTS = new Set(["github.com", "raw.githubusercontent.com", "release-assets.githubusercontent.com", "objects.githubusercontent.com"]);
function assertDownloadUrl(value) {
const url = new URL(value);
if (url.protocol !== "https:" || url.username || url.password || url.port || !DOWNLOAD_HOSTS.has(url.hostname)) {
throw new Error("Catalog download URL is not an approved HTTPS GitHub source");
}
return url.href;
}
function pinnedReadmeUrl(entry) {
if (typeof entry.readme !== "string" || /[\u0000-\u001f\u007f\\%?#]/u.test(entry.readme)
|| entry.readme.split("/").some((segment) => !segment || segment === "." || segment === "..")) {
throw new Error("Invalid or unpinned README path");
}
const prefix = `/${entry.repository}/${entry.commit}/`;
const url = new URL(`https://raw.githubusercontent.com${prefix}${entry.readme.split("/").map(encodeURIComponent).join("/")}`);
if (!url.pathname.startsWith(prefix)) throw new Error("README URL escapes the pinned commit");
return url.href;
}
function validateCatalog(entries) {
assertPluginJsonValue(entries, { maxBytes: MAX_CATALOG_BYTES });
if (!Array.isArray(entries) || entries.length > 1000) throw new Error("Invalid plugin catalog");
const ids = new Set();
for (const entry of entries) {
if (!entry || Object.keys(entry).some((key) => !CATALOG_FIELDS.has(key)) || ["id", "version", "name", "description", "author", "repository", "commit", "license", "readme"].some((key) => typeof entry[key] !== "string" || entry[key].length > 4096) || !/^[a-z0-9][a-z0-9.-]+$/u.test(entry.id) || ids.has(entry.id)
|| !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(entry.repository)
|| !/^[a-f0-9]{40}$/u.test(entry.commit)
|| !entry.license || !entry.author || !entry.name
|| typeof entry.readme !== "string" || !entry.readme.endsWith(".md")
|| entry.readme.split("/").some((p) => !p || p === "." || p === "..") || /[\\%?#\0]/u.test(entry.readme)) {
throw new Error("Invalid or unpinned plugin catalog entry");
}
pinnedReadmeUrl(entry);
if (!validateManifest(entry.manifest)) throw new Error(`Catalog manifest is invalid: ${formatValidationErrors(validateManifest.errors)}`);
assertCatalogManifest(entry.manifest, entry);
ids.add(entry.id);
if (Object.hasOwn(entry, "artifact")) {
if (!entry.artifact || typeof entry.artifact !== "object" || Array.isArray(entry.artifact)
|| Object.keys(entry.artifact).some((key) => key !== "url" && key !== "sha256")
|| typeof entry.artifact.url !== "string" || typeof entry.artifact.sha256 !== "string") {
throw new Error("Invalid catalog artifact metadata");
}
assertDownloadUrl(entry.artifact.url);
if (!/^[a-f0-9]{64}$/u.test(entry.artifact.sha256)) throw new Error("Catalog artifact requires SHA-256");
}
}
return entries;
}
async function fetchBounded(url, limit, fetchImpl = fetch, maxRedirects = 4) {
const signal = AbortSignal.timeout(30_000);
for (let redirects = 0; redirects <= maxRedirects; redirects++) {
const response = await fetchImpl(assertDownloadUrl(url), { redirect: "manual", signal, credentials: "omit", referrerPolicy: "no-referrer" });
if ([301, 302, 303, 307, 308].includes(response.status)) {
await response.body?.cancel();
url = new URL(response.headers.get("location"), url).href;
continue;
}
if (!response.ok) { await response.body?.cancel(); throw new Error(`Catalog download failed (${response.status})`); }
if (Number(response.headers.get("content-length")) > limit) {
await response.body?.cancel(); throw new Error("Catalog download exceeds size limit");
}
const reader = response.body?.getReader();
if (!reader) throw new Error("Catalog download has no body");
const chunks = [];
let size = 0;
try {
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) throw new Error("Catalog download exceeds size limit");
chunks.push(Buffer.from(value));
}
return Buffer.concat(chunks, size);
} finally { await reader.cancel(); }
}
throw new Error("Too many catalog redirects");
}
class PluginCatalogService {
constructor(options) {
this.entries = structuredClone(validateCatalog(options.entries ?? catalog));
this.fetch = options.fetch ?? fetch;
this.getTempFilePath = options.getTempFilePath ?? getTempFilePath;
this.manager = options.manager;
this.installing = false;
}
list() { return structuredClone(this.entries); }
get(id) {
const entry = this.entries.find((item) => item.id === id);
if (!entry) throw new Error("Plugin is not in the curated catalog");
return entry;
}
async readme(id) {
const entry = this.get(id);
const url = pinnedReadmeUrl(entry);
return (await fetchBounded(url, 256 * 1024, this.fetch, 0)).toString("utf8");
}
async install(id) {
const entry = this.get(id);
if (!entry.artifact) throw new Error("This source example has no published package; build and import locally");
if (this.installing) throw new Error("A catalog installation is already in progress");
this.installing = true;
let directory;
try {
const bytes = await fetchBounded(entry.artifact.url, 32 * 1024 * 1024, this.fetch);
if (createHash("sha256").update(bytes).digest("hex") !== entry.artifact.sha256) throw new Error("Catalog package SHA-256 mismatch");
directory = await fs.mkdtemp(this.getTempFilePath("plugin-catalog-"));
const archive = path.join(directory, "package.ncpkg");
await fs.writeFile(archive, bytes, { mode: 0o600, flag: "wx" });
return await this.manager.install(archive, { enable: false, catalog: entry });
} finally {
this.installing = false;
if (directory) await fs.rm(directory, { recursive: true, force: true });
}
}
}
module.exports = { PluginCatalogService, assertDownloadUrl, fetchBounded, validateCatalog, pinnedReadmeUrl, MAX_CATALOG_BYTES };
+74
View File
@@ -0,0 +1,74 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { createHash } = require("node:crypto");
const fs = require("node:fs/promises");
const os = require("node:os");
const path = require("node:path");
const { PluginCatalogService, validateCatalog, fetchBounded } = require("./catalogService.cjs");
const { assertCatalogManifest } = require("./catalogPolicy.cjs");
const entries = require("./catalog.json");
test("curated catalog uses pinned public first-party source and rejects advanced manifests", () => {
assert.equal(validateCatalog(entries).length, 2);
for (const main of [{ node: "index.js" }, { browser: "index.js", node: "node.js" }]) {
const entry = structuredClone(entries[0]); entry.manifest.main = main;
assert.throws(() => validateCatalog([entry]), /browser-only packages/);
}
const entry = structuredClone(entries[0]);
entry.manifest.permissions.optional.push("runtime.advanced");
assert.throws(() => assertCatalogManifest(entry.manifest, entry), /advanced developer mode/);
for (const readme of ["../README.md", "a/%2e%2e/README.md", "a\\README.md"]) {
assert.throws(() => validateCatalog([{ ...entries[0], readme }]), /unpinned/);
}
assert.throws(() => validateCatalog([{ ...entries[0], commit: "main" }]), /unpinned/);
});
test("README retrieval is bounded and pinned, never follows a redirect to latest", async () => {
const requested = [];
const service = new PluginCatalogService({ entries, fetch: async (url) => {
requested.push(url); return new Response("# First-party example");
} });
assert.equal(await service.readme(entries[0].id), "# First-party example");
assert.equal(requested[0], `https://raw.githubusercontent.com/${entries[0].repository}/${entries[0].commit}/${entries[0].readme}`);
service.fetch = async () => new Response(null, { status: 302, headers: { location: "https://raw.githubusercontent.com/a/b/main/README.md" } });
await assert.rejects(service.readme(entries[0].id), /redirects/);
await assert.rejects(fetchBounded("https://raw.githubusercontent.com/a/b/c", 3, async () => new Response("1234")), /size limit/);
await assert.rejects(fetchBounded("https://127.0.0.1/a", 10), /approved HTTPS/);
await assert.rejects(fetchBounded("https://github.com/a", 10, async () => new Response(null, { status: 302, headers: { location: "http://localhost/secret" } })), /approved HTTPS/);
});
test("catalog install verifies the digest, shares manager installation, and never auto-enables", async (context) => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "netcatty-catalog-test-"));
context.after(() => fs.rm(root, { recursive: true, force: true }));
const bytes = Buffer.from("synthetic bytes: package validation belongs to PackageStore");
const entry = { ...structuredClone(entries[0]), artifact: { url: "https://github.com/example/repo/releases/download/v1/plugin.ncpkg", sha256: createHash("sha256").update(bytes).digest("hex") } };
let installs = 0;
const service = new PluginCatalogService({ entries: [entry], getTempFilePath: (name) => {
assert.equal(name, "plugin-catalog-");
return path.join(root, name);
}, fetch: async () => new Response(bytes), manager: {
async install(archive, options) {
installs++;
assert.equal(path.dirname(path.dirname(archive)), root);
assert.equal((await fs.stat(path.dirname(archive))).mode & 0o777, 0o700);
assert.deepEqual(await fs.readFile(archive), bytes);
assert.equal(options.enable, false);
assert.equal(options.catalog.artifact.sha256, entry.artifact.sha256);
return { id: entry.id };
},
} });
await service.install(entry.id);
assert.equal(installs, 1);
assert.deepEqual(await fs.readdir(root), []);
service.fetch = async () => new Response("changed");
await assert.rejects(service.install(entry.id), /SHA-256 mismatch/);
assert.equal(installs, 1);
await assert.rejects(service.install("unknown"), /curated catalog/);
});
test("catalog rejects unknown permissions instead of presenting an unimplemented grant", () => {
const entry = structuredClone(entries[0]);
entry.manifest.permissions.optional.push("future.unknown");
assert.throws(() => validateCatalog([entry]), /manifest is invalid/);
});
+1 -1
View File
@@ -2,7 +2,7 @@
const PLUGIN_DEV_ENV = "NETCATTY_PLUGIN_DEV";
const PLUGIN_PROTOCOL_SCHEME = "netcatty-plugin";
const PLUGIN_API_VERSION = "0.1.0-internal";
const PLUGIN_API_VERSION = "0.1.0-internal.1";
const PLUGIN_ACTIVATION_TIMEOUT_MS = 5_000;
const PLUGIN_DEACTIVATION_TIMEOUT_MS = 2_000;
const PLUGIN_UTILITY_TERMINATION_GRACE_MS = 500;
+32 -2
View File
@@ -231,12 +231,42 @@ class PluginContributionIconService {
return result;
}
async #readIcon(packageRoot, packagePath) {
// Branding is inert installed metadata. It does not share the enabled-only
// contribution admission path, and accepts no renderer-provided resource path.
async resolveBrand(pluginId) {
if (typeof pluginId !== "string" || pluginId.length > 256) throw new TypeError("Invalid plugin ID");
const plugin = this.database.getActivePlugin(pluginId);
if (!plugin?.activeVersion || plugin.runtime?.quarantinedAt != null) return null;
const icon = plugin.manifest?.icon;
if (icon?.kind !== "package") return null;
const identity = JSON.stringify([plugin.activeVersion, plugin.archiveSha256, icon]);
const key = JSON.stringify(["brand", pluginId, identity]);
if (this.cache.has(key)) return this.cache.get(key);
if (this.pending.has(key)) return this.pending.get(key);
const pending = (async () => {
const root = await this.packageStore.preparePackageRoot(plugin);
const result = { light: await this.#readIcon(root, icon.light, true),
...(icon.dark ? { dark: await this.#readIcon(root, icon.dark, true) } : {}) };
const current = this.database.getActivePlugin(pluginId);
if (!current || current.runtime?.quarantinedAt != null || identity !== JSON.stringify([current.activeVersion, current.archiveSha256, current.manifest?.icon])) {
throw new Error("Plugin brand icon ownership changed while loading");
}
if (this.cache.size >= 256) this.cache.clear();
this.cache.set(key, result);
return result;
})().finally(() => this.pending.delete(key));
this.pending.set(key, pending);
return pending;
}
async #readIcon(packageRoot, packagePath, brand = false) {
const segments = packagePath.split("/");
const file = await readContainedFile(packageRoot, segments);
if (file.body.byteLength > MAX_ICON_SOURCE_BYTES) throw new Error("Plugin contribution icon is too large");
const extension = path.extname(file.filePath).toLowerCase();
const inspected = inspectContributionIconSource(file.body, extension);
const inspected = brand
? (await import("@netcatty/plugin-contract")).inspectPluginBrandIcon(file.body, packagePath)
: inspectContributionIconSource(file.body, extension);
const png = await this.rasterizeIcon({
body: file.body,
...inspected,
+4
View File
@@ -47,6 +47,10 @@ class PluginCredentialCatalog {
}
}
isAvailable() {
try { this.#assertAvailable(); return true; } catch { return false; }
}
update(entries) {
if (!Array.isArray(entries) || entries.length > MAX_CREDENTIAL_CATALOG_ENTRIES) {
throw new PluginRpcError(RPC_ERRORS.invalidArgument, "Vault credential catalog is invalid or too large");
@@ -66,3 +66,9 @@ test("Vault credential catalog fails closed when secure storage is unavailable",
/unavailable|not found/i,
);
});
test("availability reporting does not unlock a missing OS vault", () => {
const catalog = new PluginCredentialCatalog({ safeStorage: { isEncryptionAvailable: () => false } });
assert.equal(catalog.isAvailable(), false);
assert.throws(() => catalog.update([]), /OS-backed Vault/);
});
+6 -4
View File
@@ -614,14 +614,15 @@ class PluginDatabase {
listSyncSidecars(pluginId) {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
FROM plugin_sync_sidecars
WHERE plugin_id = ?
ORDER BY kind COLLATE BINARY, key COLLATE BINARY
`).all(pluginId).map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
// Node 22 truncates TEXT results at NUL; settings keys contain NUL separators.
key: Buffer.from(row.key_bytes).toString("utf8"),
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
@@ -629,13 +630,14 @@ class PluginDatabase {
listAllSyncSidecars() {
return this.db.prepare(`
SELECT plugin_id, kind, key, value_json, updated_at
SELECT plugin_id, kind, CAST(key AS BLOB) AS key_bytes, value_json, updated_at
FROM plugin_sync_sidecars
ORDER BY plugin_id COLLATE BINARY, kind COLLATE BINARY, key COLLATE BINARY
`).all().map((row) => ({
pluginId: row.plugin_id,
kind: row.kind,
key: row.key,
// Node 22 truncates TEXT results at NUL; settings keys contain NUL separators.
key: Buffer.from(row.key_bytes).toString("utf8"),
value: parseJson(row.value_json, "sync sidecar value"),
updatedAt: Number(row.updated_at),
}));
+11
View File
@@ -833,3 +833,14 @@ test("inferPluginIdForSyncProvider never guesses from credential key prefixes",
assert.equal(database.inferPluginIdForSyncProvider("com.missing.sync"), undefined);
database.close();
});
test("sync sidecar enumeration preserves NUL-separated setting coordinates", (context) => {
const database = createDatabase(context);
context.after(() => database.close());
const key = "com.example.test.theme\0application\0application";
database.setSyncSidecar("com.example.test", "settings", key, "dark", 10);
assert.equal(database.listSyncSidecars("com.example.test")[0].key, key);
assert.equal(database.listAllSyncSidecars()[0].key, key);
database.replaceAllSyncSidecars(database.listAllSyncSidecars());
assert.equal(database.getSyncSidecar("com.example.test", "settings", key).value, "dark");
});
@@ -1,6 +1,6 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal/plugin-contract.schema.json",
"$id": "https://netcatty.com/schemas/plugins/0.1.0-internal.1/plugin-contract.schema.json",
"title": "Netcatty Plugin Contract",
"description": "Canonical public contract for Netcatty plugin manifests and host RPC messages.",
"$ref": "#/$defs/PluginManifest",
@@ -231,6 +231,131 @@
},
"additionalProperties": false
},
"NetworkCredentialLeaseFeature": {
"const": "network.credential-lease"
},
"PluginNetworkAuthorization": {
"type": "object",
"properties": {
"scheme": {
"type": "string",
"enum": [
"Basic",
"Bearer"
]
},
"username": {
"type": "string",
"maxLength": 8192,
"pattern": "^[^\\u0000-\\u001f\\u007f]*$"
}
},
"additionalProperties": false
},
"PluginNetworkRequest": {
"description": "Origin-authorized HTTP request. The host consumes a one-use credentialLease for network:<canonical origin>; plaintext credentials never return to the plugin.",
"type": "object",
"required": [
"url"
],
"properties": {
"url": {
"type": "string",
"minLength": 1,
"maxLength": 8192
},
"method": {
"type": "string",
"enum": [
"GET",
"POST",
"PUT",
"PATCH",
"DELETE",
"HEAD"
]
},
"headers": {
"type": "object",
"maxProperties": 64,
"additionalProperties": {
"type": "string",
"maxLength": 8192
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"enum": [
"utf8",
"base64"
]
},
"data": {
"type": "string"
}
},
"additionalProperties": false
},
"timeoutMs": {
"type": "integer",
"minimum": 1,
"maximum": 300000
},
"credentialLease": {
"$ref": "#/$defs/SecretLeaseRef"
},
"authorization": {
"$ref": "#/$defs/PluginNetworkAuthorization"
}
},
"additionalProperties": false
},
"PluginNetworkResponse": {
"type": "object",
"required": [
"url",
"status",
"headers",
"body"
],
"properties": {
"url": {
"type": "string"
},
"status": {
"type": "integer"
},
"headers": {
"type": "object",
"additionalProperties": {
"type": "string"
}
},
"body": {
"type": "object",
"required": [
"encoding",
"data"
],
"properties": {
"encoding": {
"const": "base64"
},
"data": {
"type": "string"
}
},
"additionalProperties": false
}
},
"additionalProperties": false
},
"SemanticVersion": {
"type": "string",
"pattern": "^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)(?:-(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|\\d*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$"
@@ -2949,6 +3074,9 @@
"type": "string",
"maxLength": 128
},
"icon": {
"$ref": "#/$defs/IconReference"
},
"homepage": {
"type": "string",
"format": "uri"
+2 -5
View File
@@ -1,9 +1,6 @@
"use strict";
const { isPluginDevelopmentEnabled } = require("./constants.cjs");
function startDevelopmentPluginHost(options) {
if (!isPluginDevelopmentEnabled(options.env)) return null;
function startPluginHost(options) {
const reportFailure = (message, error) => {
try { options.logger?.error?.(message, error); } catch {}
};
@@ -30,4 +27,4 @@ function startDevelopmentPluginHost(options) {
}
}
module.exports = { startDevelopmentPluginHost };
module.exports = { startPluginHost };
+11 -11
View File
@@ -3,24 +3,24 @@
const assert = require("node:assert/strict");
const test = require("node:test");
const { startDevelopmentPluginHost } = require("./hostBootstrap.cjs");
const { startPluginHost } = require("./hostBootstrap.cjs");
test("disabled development host does not construct plugin infrastructure", () => {
test("normal release constructs the browser plugin host without a developer flag", async () => {
let created = 0;
const service = startDevelopmentPluginHost({
const service = { manager: { async initialize() {}, async shutdown() {} } };
assert.equal(startPluginHost({
env: {},
createService() { created += 1; },
createService() { created += 1; return service; },
registerShutdown() {},
logger: { error() {} },
});
assert.equal(service, null);
assert.equal(created, 0);
}), service);
assert.equal(created, 1);
});
test("synchronous plugin host construction failure leaves Netcatty available", () => {
const errors = [];
let registered = 0;
const service = startDevelopmentPluginHost({
const service = startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService() { throw new Error("corrupt plugin database"); },
registerShutdown() { registered += 1; },
@@ -41,7 +41,7 @@ test("created plugin host initializes and registers one shutdown owner", async (
async shutdown() { shutdown += 1; },
},
};
assert.equal(startDevelopmentPluginHost({
assert.equal(startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService: () => service,
registerShutdown(handler) { shutdownHandler = handler; },
@@ -63,7 +63,7 @@ test("asynchronous initialization failure closes and leaves the host unavailable
shutdown: async () => { shutdown += 1; },
},
};
assert.equal(startDevelopmentPluginHost({
assert.equal(startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService: () => service,
registerShutdown(handler) { shutdownHandler = handler; },
@@ -79,7 +79,7 @@ test("asynchronous initialization failure closes and leaves the host unavailable
test("failure while registering shutdown closes the constructed host", async () => {
let shutdown = 0;
const errors = [];
const service = startDevelopmentPluginHost({
const service = startPluginHost({
env: { NETCATTY_PLUGIN_DEV: "1" },
createService: () => ({
manager: {
+12 -3
View File
@@ -1,8 +1,10 @@
"use strict";
const path = require("node:path");
const NETWORK_CREDENTIAL_LEASE_FEATURE = require("./generated/plugin-contract.schema.json").$defs.NetworkCredentialLeaseFeature.const;
const { PLUGIN_API_VERSION } = require("./constants.cjs");
const { assertBrowserOnlyPlugin } = require("../../domain/pluginRuntimePolicy.cjs");
const { isPluginDevelopmentEnabled, PLUGIN_API_VERSION } = require("./constants.cjs");
const { PluginDatabase } = require("./database.cjs");
const { PluginCompanionSupervisor } = require("./companionSupervisor.cjs");
const { PluginCredentialBroker, assertLeaseParams } = require("./credentialBroker.cjs");
@@ -16,6 +18,7 @@ const {
createUtilityModuleMappings,
normalizePluginModuleResources,
} = require("./moduleResources.cjs");
const { RegistryCatalogService } = require("./registryCatalogService.cjs");
const { PackageStore } = require("./packageStore.cjs");
const { createPluginPaths } = require("./paths.cjs");
const { PluginManager } = require("./pluginManager.cjs");
@@ -78,17 +81,19 @@ function collectInstalledSyncProviderCatalog(database) {
}
function createPluginHostService(options) {
const allowAdvancedPlugins = isPluginDevelopmentEnabled(options.env ?? process.env);
const paths = createPluginPaths(options.app.getPath("userData"));
const appRoot = options.appRoot ?? options.app.getAppPath();
const runtimeDirectory = options.runtimeDirectory ?? path.join(__dirname, "runtime");
const database = new PluginDatabase(paths.database);
try {
const packageStore = new PackageStore({
allowAdvancedPlugins,
paths,
database,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, ...(options.supportedFeatures ?? [])])],
});
const contributionIconService = new PluginContributionIconService({
database,
@@ -178,11 +183,13 @@ function createPluginHostService(options) {
}
const requestedRuntimeResolver = options.resolveRuntimeKind ?? resolveDefaultRuntimeKind;
const resolveRuntimeKind = async (context) => {
if (!allowAdvancedPlugins) assertBrowserOnlyPlugin(context.plugin.manifest);
const kind = await requestedRuntimeResolver(context);
if (
(kind !== "browser" && kind !== "utility")
|| !context.availableKinds.includes(kind)
) throw new Error(`Plugin runtime selection is unavailable: ${String(kind)}`);
if (!allowAdvancedPlugins && kind !== "browser") throw new Error("Advanced plugin runtime requires developer mode");
await permissionEngine.authorizeRequired(context.plugin, {
securityPrincipal: context.securityPrincipal,
signal: context.signal,
@@ -217,7 +224,7 @@ function createPluginHostService(options) {
paths,
netcattyVersion: options.app.getVersion(),
apiVersion: PLUGIN_API_VERSION,
supportedFeatures: options.supportedFeatures ?? [],
supportedFeatures: [...new Set(["metadata.brand-icon", NETWORK_CREDENTIAL_LEASE_FEATURE, ...(options.supportedFeatures ?? [])])],
runtimeDirectory,
appRoot,
rpcRegistry,
@@ -347,6 +354,7 @@ function createPluginHostService(options) {
})
: null;
const manager = new PluginManager({
permissionEngine,
database,
packageStore,
runtimeSupervisor,
@@ -363,6 +371,7 @@ function createPluginHostService(options) {
},
});
return {
catalogService: new RegistryCatalogService({ manager, fetch: options.electron.net?.fetch?.bind(options.electron.net) }),
companionSupervisor,
contributionIconService,
contributionService,
+15
View File
@@ -16,6 +16,7 @@ function createOptions(root, configureRpcRegistry) {
getPath: () => root,
getVersion: () => "0.0.0",
},
env: { NETCATTY_PLUGIN_DEV: "1" },
electron: {},
configureRpcRegistry,
};
@@ -386,3 +387,17 @@ test("custom host methods without an explicit authorization classification are d
/no authorization policy/,
);
});
test("normal release blocks a custom utility placement before permission prompts", async (context) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "netcatty-plugin-normal-mode-"));
context.after(() => fs.rmSync(root, { recursive: true, force: true }));
let prompts = 0;
const service = createPluginHostService({ ...createOptions(root), env: {}, resolveRuntimeKind: () => "utility", requestPermissionDecision: () => { prompts++; } });
context.after(() => service.database.close());
await assert.rejects(service.runtimeSupervisor.resolveRuntimeKind({
plugin: { id: "com.example.safe", manifest: { main: { browser: "index.js" } } },
availableKinds: ["browser", "utility"],
signal: new AbortController().signal,
}), /requires developer mode/);
assert.equal(prompts, 0);
});
@@ -0,0 +1,47 @@
"use strict";
const assert = require("node:assert/strict");
const test = require("node:test");
const { MessageChannel } = require("node:worker_threads");
const { PluginRpcRouter } = require("./rpcRouter.cjs");
test("browser lease example negotiates its feature and forwards only an opaque lease", async () => {
const { startPluginRuntime } = await import("./runtime/runtimePeer.mjs");
const fixture = await import("../../examples/plugins/lease-http/browser.js");
const calls = [];
const secret = { kind: "secret", id: "s".repeat(32), key: "synthetic-token" };
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: Date.now() + 30_000 };
const { port1, port2 } = new MessageChannel();
const host = new PluginRpcRouter({
pluginId: "com.netcatty.lease-http",
send: (message) => port1.postMessage(message),
handlers: {
"secrets.set": async (params) => { calls.push("secret"); assert.equal(params.key, secret.key); return { secret }; },
"credentials.createLease": async (params) => {
calls.push("lease"); assert.deepEqual(params.secret, secret); assert.equal(params.operationId, lease.operationId); return lease;
},
"network.request": async (params) => {
calls.push("network");
assert.deepEqual(params, { url: "https://example.invalid/synthetic", credentialLease: lease, authorization: { scheme: "Bearer" } });
assert.equal(JSON.stringify(params).includes("synthetic-acceptance-token"), false);
return { status: 200, url: params.url, headers: {}, body: { encoding: "base64", data: "" } };
},
},
});
port1.on("message", (message) => { void host.accept(message); });
const peer = await startPluginRuntime({
port: port2,
config: { pluginId: "com.netcatty.lease-http", pluginVersion: "0.1.0", netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", enabledFeatures: ["network.credential-lease"] },
loadPlugin: async () => fixture,
});
try {
const initialized = await host.request("plugin.initialize", { netcattyVersion: "0.0.0", apiVersion: "0.1.0-internal.1", supportedFeatures: ["network.credential-lease"] });
assert.deepEqual(initialized.enabledFeatures, ["network.credential-lease"]);
await host.request("plugin.activate", {});
assert.deepEqual(await host.request("plugin.command.execute", { command: "com.netcatty.lease-http.request", invocation: { source: "palette" } }), { status: 200 });
assert.deepEqual(calls, ["secret", "lease", "network"]);
} finally {
await peer.dispose();
host.close();
port1.close();
}
});
+10 -3
View File
@@ -1,5 +1,7 @@
"use strict";
const { describePluginPermission } = require("../../domain/pluginPermissionDescriptions.cjs");
const MAX_VISIBLE_RESOURCES = 20;
const MAX_VISIBLE_FIELD_LENGTH = 1_024;
const UNSAFE_DISPLAY_CHARACTERS = /[\u0000-\u001f\u007f-\u009f\u2028\u2029\u202a-\u202e\u2066-\u2069]/gu;
@@ -14,7 +16,8 @@ function escapePermissionText(value, maxLength = MAX_VISIBLE_FIELD_LENGTH) {
});
}
function describePermissionRequest(request) {
function describePermissionRequest(request, locale = "en") {
const permission = describePluginPermission(request.permission, locale);
const identity = request.pluginName
? `${escapePermissionText(request.pluginName)} (${escapePermissionText(request.pluginId)})`
: escapePermissionText(request.pluginId);
@@ -31,6 +34,7 @@ function describePermissionRequest(request) {
`Plugin: ${identity}`,
...(request.publisher ? [`Publisher: ${escapePermissionText(request.publisher)}`] : []),
`Permission: ${escapePermissionText(request.permission)}`,
`${permission.label}: ${permission.description}`,
`Reason: ${escapePermissionText(request.reason)}`,
...(visibleResources.length ? ["Resources:", ...visibleResources] : []),
].join("\n");
@@ -41,9 +45,9 @@ function createNativePermissionDecisionProvider(options) {
throw new TypeError("Native plugin permission decisions require an Electron dialog");
}
const dialog = options.dialog;
const window = options.window;
return async (request, context = {}) => {
context.signal?.throwIfAborted();
if (!describePluginPermission(request.permission).known) return Object.freeze({ requestId: request.requestId, decision: "deny" });
const allowedScopes = Array.isArray(request.allowedScopes)
? new Set(request.allowedScopes)
: null;
@@ -65,13 +69,16 @@ function createNativePermissionDecisionProvider(options) {
type: "warning",
title: "Plugin permission request",
message: `${escapePermissionText(request.pluginName ?? request.pluginId)} requests ${escapePermissionText(request.permission)}`,
detail: describePermissionRequest(request),
detail: describePermissionRequest(request, options.getLocale?.() ?? "en"),
buttons: choices.map(({ label }) => label),
defaultId: 0,
cancelId: 0,
noLink: true,
...(context.signal ? { signal: context.signal } : {}),
};
// Choose a host-owned parent when the prompt opens, not once at bootstrap.
const candidate = options.getWindow?.() ?? options.window;
const window = candidate?.isDestroyed?.() ? undefined : candidate;
const result = window
? await dialog.showMessageBox(window, messageBoxOptions)
: await dialog.showMessageBox(messageBoxOptions);
@@ -131,3 +131,36 @@ test("native permission dialogs visibly escape control and bidi characters", asy
assert.equal(captured.detail.split("\n").filter((line) => line.startsWith("Permission:")).length, 1);
assert.equal(escapePermissionText("x".repeat(MAX_VISIBLE_FIELD_LENGTH + 20)).length, MAX_VISIBLE_FIELD_LENGTH);
});
test("native prompts share human-readable permission explanations and deny unknown permission IDs", async () => {
assert.match(describePermissionRequest(permissionRequest({ permission: "terminal.output" }), "zh-CN"), /读取终端输出/);
assert.match(describePermissionRequest(permissionRequest({ permission: "terminal.output" })), /credentials or private data/);
let prompts = 0;
const provider = createNativePermissionDecisionProvider({ dialog: { showMessageBox: async () => { prompts++; return { response: 1 }; } } });
assert.deepEqual(await provider(permissionRequest({ permission: "unknown.permission" })), { requestId: "request-1", decision: "deny" });
assert.equal(prompts, 0);
});
test("permission parent is resolved at prompt time without forcing focus", async () => {
const main = { name: "main" };
const settings = { name: "settings", isDestroyed: () => false };
let active = main;
const parents = [];
const provider = createNativePermissionDecisionProvider({
window: main,
getWindow: () => active,
dialog: { async showMessageBox(...args) {
parents.push(args.length === 2 ? args[0] : null);
return { response: 0 };
} },
});
await provider(permissionRequest());
active = settings;
await provider(permissionRequest());
active = { isDestroyed: () => true };
await provider(permissionRequest());
active = null;
await provider(permissionRequest());
assert.deepEqual(parents, [main, settings, null, main]);
});
+6 -10
View File
@@ -1,5 +1,9 @@
"use strict";
const { createDefinitionValidator } = require("./contractValidator.cjs");
const validateLease = createDefinitionValidator("SecretLeaseRef");
const validateAuthorization = createDefinitionValidator("PluginNetworkAuthorization");
const { PluginRpcError, RPC_ERRORS } = require("./rpcRouter.cjs");
const { PLUGIN_RPC_MAX_RAW_BYTES } = require("./constants.cjs");
const { canonicalizeNetworkOrigin } = require("./permissionResources.cjs");
@@ -180,15 +184,7 @@ class PluginNetworkBroker {
// network.request lease injection would become dead code.
if (params?.credentialLease !== undefined) {
const lease = params.credentialLease;
if (
!lease
|| typeof lease !== "object"
|| Array.isArray(lease)
|| lease.kind !== "secret-lease"
|| typeof lease.id !== "string"
|| typeof lease.operationId !== "string"
|| !Number.isFinite(lease.expiresAt)
) {
if (!validateLease(lease)) {
throw invalidArgument("Plugin network credential lease is invalid");
}
validated.credentialLease = Object.freeze({
@@ -200,7 +196,7 @@ class PluginNetworkBroker {
}
if (params?.authorization !== undefined) {
const authorization = params.authorization;
if (!authorization || typeof authorization !== "object" || Array.isArray(authorization)) {
if (!validateAuthorization(authorization)) {
throw invalidArgument("Plugin network authorization is invalid");
}
const scheme = authorization.scheme === "Basic" ? "Basic" : "Bearer";
+15 -1
View File
@@ -157,7 +157,10 @@ test("network response streaming stops above the byte cap", async () => {
);
});
test("network broker reports its own timeout as a stable deadline error", async () => {
test("network broker reports its own timeout as a stable deadline error", async (context) => {
// A real request owns a socket; this pending fake must also keep the loop alive.
const keepAlive = setInterval(() => {}, 1_000);
context.after(() => clearInterval(keepAlive));
const broker = new PluginNetworkBroker({
fetch: async (_url, options) => new Promise((_resolve, reject) => {
options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true });
@@ -169,3 +172,14 @@ test("network broker reports its own timeout as a stable deadline error", async
(error) => error.code === RPC_ERRORS.deadlineExceeded,
);
});
test("credential field validation follows the canonical contract and keeps Bearer default", () => {
const broker = new PluginNetworkBroker({ permissionEngine: {} });
const lease = { kind: "secret-lease", id: "l".repeat(32), operationId: "network:https://example.invalid", expiresAt: 1000 };
const request = { url: "https://example.invalid", credentialLease: lease, authorization: {} };
assert.deepEqual(broker.validate(request).authorization, { scheme: "Bearer" });
for (const authorization of [{ scheme: "Digest" }, { scheme: "Basic", username: "name\n" }, { password: "synthetic" }]) {
assert.throws(() => broker.validate({ ...request, authorization }), /authorization is invalid/);
}
assert.throws(() => broker.validate({ ...request, credentialLease: { ...lease, id: "short" } }), /lease is invalid/);
});
Loaded 100 of 162 files, more files were not shown because too many files have changed in this diff. Show more