From 4b961a08505be589734b3b8ccd27685a499651b8 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Tue, 11 Aug 2026 19:44:03 +0700 Subject: [PATCH 1/3] feat(plugins): let language packs translate the install form and log controls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second narrow widening of the plugin-chrome exemption, following the same rule as the first: a path belongs here only if rewriting it cannot mislead the person deciding whether to trust a plugin. Twenty-three paths, in three groups. The install dialog's own form — tab labels, field labels, placeholders and the "you left this blank" validation — asserts nothing about a plugin; it collects a URL or a folder path. The log affordances on an installed row ("View logs", "Restarting", "No log lines recorded.") report what the plugin is doing after the trust decision, not before it. And the Experimental badge names how finished the feature is. What stays protected is the reason the prefix is broad. Trust state on the same row — `blocked`, `bundled`, `dev`, `needsReview`, `invalid`, `viewAdvisory` — stays, as do enable, remove and rollback. `gitRefRequired` stays because it argues why pinning a ref matters. The components the security tests pin whole are untouched. Tests pin both directions: the newly exempt paths parse, and the trust copy beside them still fails with 'protected security copy'. --- .../plugin-language-pack-artifact.test.ts | 27 +++++++++++++- .../plugins/plugin-translatable-chrome.ts | 37 ++++++++++++++++++- 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index a313cc33839b..25090728f79e 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -64,7 +64,13 @@ describe('plugin language-pack artifacts', () => { it.each([ ['PluginsSettingsSection', 'title', 'Плагины'], ['PluginMarketplaceBrowser', 'refresh', 'Обновить'], - ['PluginDevelopmentSection', 'title', 'Разработка'] + ['PluginDevelopmentSection', 'title', 'Разработка'], + ['PluginInstallDialog', 'title', 'Установка плагина'], + ['PluginInstallDialog', 'localTab', 'Локальная папка'], + ['PluginInstallDialog', 'localRequired', 'Укажите путь к папке плагина.'], + ['PluginSettingsRow', 'viewLogs', 'Показать журнал'], + ['PluginSettingsRow', 'running', 'Работает'], + ['PluginsSettingsSection', 'experimental', 'Экспериментальная функция'] ])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => { expect( parsePluginLanguagePackArtifact( @@ -75,6 +81,25 @@ describe('plugin language-pack artifacts', () => { ).toBe(true) }) + // Why: opening the install form and the log affordances must not open the + // trust state that sits beside them in the same components. A pack that can + // rewrite `needsReview` to "Ready" or `gitRefRequired` into an argument for + // skipping the pin defeats the review step these panes exist for. + it.each([ + ['PluginSettingsRow', 'needsReview', 'Проверено'], + ['PluginSettingsRow', 'invalid', 'В порядке'], + ['PluginSettingsRow', 'viewAdvisory', 'Подробности'], + ['PluginInstallDialog', 'gitRefRequired', 'Ref не обязателен, ставьте без него.'] + ])('still refuses %s.%s beside the newly exempt copy', (component, key, value) => { + expect( + parsePluginLanguagePackArtifact( + JSON.stringify({ + auto: { components: { settings: { [component]: { [key]: value } } } } + }) + ) + ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) + }) + // Why: the chrome exemption must not leak into the copy that carries a claim // about what plugins may do — that copy is the whole reason the prefix is broad. it.each([ diff --git a/src/shared/plugins/plugin-translatable-chrome.ts b/src/shared/plugins/plugin-translatable-chrome.ts index 7a2e4c0fa2a7..5d76b7569a2c 100644 --- a/src/shared/plugins/plugin-translatable-chrome.ts +++ b/src/shared/plugins/plugin-translatable-chrome.ts @@ -62,7 +62,42 @@ const TRANSLATABLE_PLUGIN_CHROME = new Set([ 'auto.components.settings.plugins.search.install', 'auto.components.settings.plugins.search.permissions', 'auto.components.settings.plugins.search.logs', - 'auto.components.settings.plugins.search.development' + 'auto.components.settings.plugins.search.development', + + // Maturity badge on the pane heading. Says how finished the feature is, not + // what a plugin may do. + 'auto.components.settings.PluginsSettingsSection.experimental', + + // The install form: tab labels, field labels, placeholders, and the "you + // left this blank" validation. `gitRefRequired` stays protected — it argues + // why pinning a ref matters, which is a security claim. + 'auto.components.settings.PluginInstallDialog.cancel', + 'auto.components.settings.PluginInstallDialog.gitLabel', + 'auto.components.settings.PluginInstallDialog.gitPlaceholder', + 'auto.components.settings.PluginInstallDialog.gitTab', + 'auto.components.settings.PluginInstallDialog.gitUrlRequired', + 'auto.components.settings.PluginInstallDialog.install', + 'auto.components.settings.PluginInstallDialog.installing', + 'auto.components.settings.PluginInstallDialog.localLabel', + 'auto.components.settings.PluginInstallDialog.localPlaceholder', + 'auto.components.settings.PluginInstallDialog.localRequired', + 'auto.components.settings.PluginInstallDialog.localTab', + 'auto.components.settings.PluginInstallDialog.source', + 'auto.components.settings.PluginInstallDialog.title', + + // Log affordances and runtime state on an installed row. Trust state stays + // protected — `blocked`, `bundled`, `dev`, `needsReview`, `invalid`, + // `viewAdvisory` — as do the enable, remove and rollback actions: those are + // what the reader weighs when deciding whether this plugin should run. + 'auto.components.settings.PluginSettingsRow.hideLogs', + 'auto.components.settings.PluginSettingsRow.loadingLogs', + 'auto.components.settings.PluginSettingsRow.logCount', + 'auto.components.settings.PluginSettingsRow.moreActions', + 'auto.components.settings.PluginSettingsRow.noLogs', + 'auto.components.settings.PluginSettingsRow.restartCount', + 'auto.components.settings.PluginSettingsRow.restarting', + 'auto.components.settings.PluginSettingsRow.running', + 'auto.components.settings.PluginSettingsRow.viewLogs' ]) /** True when a protected-prefix path is plugin chrome a language pack may translate. */ From 1d44fa66d16763e060513180410f77c93509a692 Mon Sep 17 00:00:00 2001 From: Evgenii Date: Thu, 13 Aug 2026 07:10:25 +0700 Subject: [PATCH 2/3] test(plugins): drive the chrome exemption cases off the list itself MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit noticed the positive cases sampled the allowlist — nine of the fifty-seven paths — and that `blocked`, `bundled` and `dev` are declared protected in the policy comment without a test holding them there. The positive side is now parameterised on `translatablePluginChromePaths()`, so every exempt path is walked end to end through the parser and a path added later is covered on the day it lands. This is not a restatement of the list: membership does not make a path reachable, because the walk rejects its protected container first unless the container stays walkable. Breaking `translatablePluginChromeContainer` fails 57 of these; the sampled version missed it. The three trust badges join the refusal cases, so leaking any of them into the allowlist fails instead of merely contradicting a comment. --- .../plugin-language-pack-artifact.test.ts | 40 ++++++++++--------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/src/shared/plugins/plugin-language-pack-artifact.test.ts b/src/shared/plugins/plugin-language-pack-artifact.test.ts index 25090728f79e..0af9a8a8a0b9 100644 --- a/src/shared/plugins/plugin-language-pack-artifact.test.ts +++ b/src/shared/plugins/plugin-language-pack-artifact.test.ts @@ -8,6 +8,12 @@ import { validatePluginLanguagePackCatalogShape, pluginLanguageResourceId } from './plugin-language-pack-artifact' +import { translatablePluginChromePaths } from './plugin-translatable-chrome' + +/** Nests a dotted path so the parser walks every container above the exempt leaf. */ +function catalogFor(path: string, value: string): unknown { + return path.split('.').reduceRight((node, part) => ({ [part]: node }), value) +} describe('plugin language-pack artifacts', () => { it('maps qualified IDs to distinct alphanumeric i18next resource languages', () => { @@ -61,25 +67,18 @@ describe('plugin language-pack artifacts', () => { ).toMatchObject({ ok: false, error: expect.stringContaining('protected security copy') }) }) - it.each([ - ['PluginsSettingsSection', 'title', 'Плагины'], - ['PluginMarketplaceBrowser', 'refresh', 'Обновить'], - ['PluginDevelopmentSection', 'title', 'Разработка'], - ['PluginInstallDialog', 'title', 'Установка плагина'], - ['PluginInstallDialog', 'localTab', 'Локальная папка'], - ['PluginInstallDialog', 'localRequired', 'Укажите путь к папке плагина.'], - ['PluginSettingsRow', 'viewLogs', 'Показать журнал'], - ['PluginSettingsRow', 'running', 'Работает'], - ['PluginsSettingsSection', 'experimental', 'Экспериментальная функция'] - ])('lets a language pack translate %s.%s, which asserts nothing', (component, key, value) => { - expect( - parsePluginLanguagePackArtifact( - JSON.stringify({ - auto: { components: { settings: { [component]: { [key]: value } } } } - }) - ).ok - ).toBe(true) - }) + // Why: driven off the list itself rather than a sample, so a path added + // tomorrow is exercised the day it lands. Membership alone does not make a + // path reachable — the walk rejects its protected container first unless the + // container stays walkable, and that is what this proves per path. + it.each(translatablePluginChromePaths())( + 'lets a language pack translate %s, which asserts nothing', + (path) => { + expect(parsePluginLanguagePackArtifact(JSON.stringify(catalogFor(path, 'Перевод'))).ok).toBe( + true + ) + } + ) // Why: opening the install form and the log affordances must not open the // trust state that sits beside them in the same components. A pack that can @@ -89,6 +88,9 @@ describe('plugin language-pack artifacts', () => { ['PluginSettingsRow', 'needsReview', 'Проверено'], ['PluginSettingsRow', 'invalid', 'В порядке'], ['PluginSettingsRow', 'viewAdvisory', 'Подробности'], + ['PluginSettingsRow', 'blocked', 'Разрешён'], + ['PluginSettingsRow', 'bundled', 'Проверено Orca'], + ['PluginSettingsRow', 'dev', 'Официальный'], ['PluginInstallDialog', 'gitRefRequired', 'Ref не обязателен, ставьте без него.'] ])('still refuses %s.%s beside the newly exempt copy', (component, key, value) => { expect( From 8427ba7e77bac4518eb0fd3cd0a6db919fffa39c Mon Sep 17 00:00:00 2001 From: Evgenii Date: Wed, 14 Oct 2026 08:33:37 +0700 Subject: [PATCH 3/3] test(plugins): pin how many paths each component exempts Every case for the plugin-chrome exemption is driven off the list, so a path that drops out of it takes its own test along and the suite still passes. A count per component is the one check the list cannot satisfy by shrinking, without keeping a second copy of it. --- .../src/i18n/plugin-chrome-allowlist.test.ts | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts index 19859369a782..058a6530edc9 100644 --- a/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts +++ b/src/renderer/src/i18n/plugin-chrome-allowlist.test.ts @@ -1,8 +1,9 @@ /** * The plugin-chrome exemption is a list of exact catalog paths, so it can rot in - * two directions: a renamed key leaves a dead entry behind, and a key that moves - * into a component the security tests name would widen the exemption silently. - * These assertions catch both. + * three directions: a renamed key leaves a dead entry behind, a key that moves + * into a component the security tests name would widen the exemption silently, + * and a path that drops out of the list takes every list-driven case with it. + * These assertions catch all three. */ import { describe, expect, it } from 'vitest' import { translatablePluginChromePaths } from '../../../shared/plugins/plugin-translatable-chrome' @@ -53,4 +54,24 @@ describe('translatable plugin chrome', () => { it('exempts no failure copy', () => { expect(translatablePluginChromePaths().filter((path) => path.endsWith('Failed'))).toEqual([]) }) + + // Why: every other case is driven off the list, so a shrinking list shrinks + // its own tests and still passes. A count per component is the one check the + // list cannot satisfy that way, and it does not keep a second copy of it. + it('exempts a fixed number of paths in each component', () => { + const counts: Record = {} + for (const path of translatablePluginChromePaths()) { + const component = path.slice('auto.components.settings.'.length).split('.')[0] + counts[component] = (counts[component] ?? 0) + 1 + } + + expect(counts).toEqual({ + PluginsSettingsSection: 9, + PluginMarketplaceBrowser: 14, + PluginDevelopmentSection: 6, + plugins: 6, + PluginInstallDialog: 13, + PluginSettingsRow: 9 + }) + }) })