Release — scoped extension Configure hook (#7111) (#7116)

* Add scoped extension Configure hook

* docs: fail-closed optional chaining in Configure consumer example + old-Core regression

The documented consumer used ext?.settings.registerConfigure(...), which throws
TypeError on an older E0 Core where settings exists but registerConfigure does not.
Use full optional chaining (ext?.settings?.registerConfigure?.()) and add a Node
regression proving the consumer survives a Core lacking the capability.

Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>

---------

Co-authored-by: Frank Song <franksong2702@gmail.com>
Co-authored-by: n <a@n>
Co-authored-by: franksong2702 <franksong2702@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-08-18 05:30:27 +00:00
1 parent 5e8937981d
commit b1c2bea92e
7 files changed
+695 -23

No files matched your search

+2
View File
@@ -509,6 +509,8 @@
### Added
- **Extensions can register a native "Configure" entry point under Settings → Extensions → Installed.** For complex editors that don't fit scalar `settings_schema` fields, a boot-trusted extension can call `ext.settings.registerConfigure(handler)` on the E0 handle to add one native Configure button for its current effective-enabled Installed row, while keeping the editor UI, validation, state, and persistence fully extension-owned. Registration is trust-gated and quarantine-aware, the first handler per extension wins (duplicates return `null`), and it returns an idempotent unregister; the button never appears in Diagnostics and is removed immediately on disable/uninstall. Thanks @franksong2702. (#7111)
- **Each settled assistant turn's footer now shows the model that actually served it.** In transparent-stream mode the turn footer gains a compact model chip (e.g. `8s · claude-opus-4-8 · TTFT 640ms · …`), read *after* the turn completes so a mid-turn fallback shows the real model that answered rather than the one originally requested. The label renders exactly once per turn — a gateway/failover turn keeps its existing routing chip and the additive chip is suppressed — and the footer wraps gracefully at narrow widths and on mobile instead of stretching. The used-model is persisted with the session so it survives a reload. Thanks @franksong2702. (#6113, #6068)
### Performance
+42
View File
@@ -300,6 +300,48 @@ sandbox, isolation mechanism, capability or permission system, or security
boundary. Extensions still execute with the full WebUI session authority
described above.
### Custom Configure editors
Extensions with structured configuration that does not fit scalar
`settings_schema` fields can register one custom editor entry point on their
scoped E0 settings handle:
```js
const ext = window.hermesExt?.register?.("dictionary-manager");
const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
openDictionaryManager({ opener, restoreFocus });
});
```
`registerConfigure(handler)` is available only through a valid boot-trusted E0
handle. It does not require `settings_schema` or extension-owned storage. The
first handler registered for an extension wins; duplicates return `null` and do
not replace it. A successful registration returns an idempotent unregister
function.
Core shows one **Configure** button only for the extension's current
effective-enabled row under **Settings → Extensions → Installed**. Diagnostics
never shows the button. Late registration updates an already-mounted Installed
row. Disable or uninstall status removes the entry point immediately; uninstall
followed by a same-ID reinstall cannot revive the old page-local handler until a
full WebUI reload injects and registers the new extension script.
Core enters a pending state before invoking the handler and passes the activated
button as `opener` plus a one-shot `restoreFocus()` callback. The extension owns
its dialog, validation, persistence, and close lifecycle. It must either call
`restoreFocus()` when its UI closes or return a thenable whose settlement means
the Configure UI is closed. Both paths end pending and converge on Core's
one-shot focus restoration. A synchronous non-thenable handler remains disabled
while unsettled. If it never calls `restoreFocus()`, it remains disabled until reload.
Core does not guess dialog lifetime with a timeout.
Synchronous throws, thenable-access failures, and asynchronous rejections are
isolated, logged with the extension ID, and surfaced as a generic failure. Focus
returns to the connected opener when possible, otherwise to the current visible
Configure button or the Installed tab without forcing navigation. The hook does
not let an extension return DOM for Core to render, add a backend settings route,
or change the trusted same-origin extension model.
### Turn lifecycle events
A registered extension can react when a session turn observed by the current page
+197 -3
View File
@@ -11,7 +11,12 @@
const registrations=new Map();
const turnLifecycleListeners=new Map();
const turnLifecycleStates=new Map();
const configureRegistrations=new Map();
const configureQuarantinedIds=new Set();
const configureChangeListeners=new Set();
const currentExtensionStatus=new Map();
let trustedSeeded=false;
let extensionStatusSeeded=false;
function extensionId(value){
return String(value||'').trim();
@@ -116,6 +121,7 @@
name:text(entry&&entry.name,id),
storage_owned:storageOwned,
settings_schema:storageOwned?normalizeSchema(entry&&entry.settings_schema):[],
effective_enabled:!(entry&&entry.effective_enabled===false),
});
}
return entries;
@@ -135,6 +141,20 @@
}
trustedSeeded=true;
}
const previousStatus=new Map(currentExtensionStatus);
const nextIds=new Set(entries.map(entry=>entry.id));
if(extensionStatusSeeded){
for(const id of previousStatus.keys()){
if(nextIds.has(id)) continue;
configureQuarantinedIds.add(id);
notifyConfigureChange(id,'quarantine');
}
}
currentExtensionStatus.clear();
for(const entry of entries){
currentExtensionStatus.set(entry.id,{effective_enabled:entry.effective_enabled===true});
}
extensionStatusSeeded=true;
schemas.clear();
for(const entry of entries){
const trusted=trustedExtensions.get(entry.id);
@@ -146,6 +166,14 @@
settings_schema:trusted.storage_owned===true?trusted.settings_schema:[],
});
}
for(const [id,record] of configureRegistrations){
if(!record||!record.active) continue;
const before=previousStatus.get(id);
const after=currentExtensionStatus.get(id);
if(!before||!after||before.effective_enabled!==after.effective_enabled){
notifyConfigureChange(id,'status');
}
}
}
function safeReadState(key){
@@ -233,7 +261,7 @@
return !!(meta&&meta.storage_owned&&Array.isArray(meta.settings_schema)&&meta.settings_schema.length);
}
function settingsAccessor(clean,meta,isTrusted){
function settingsAccessor(clean,meta,isTrusted,allowConfigureRegistration){
const schema=supportsSettings(meta)?meta.settings_schema:[];
const key=settingsKey(clean);
function current(){
@@ -249,7 +277,7 @@
const saved=safeWrite(key,overridesFromValues(schema,checked.values));
return {ok:saved,values:checked.values,errors:saved?{}:{storage:'unavailable'}};
}
return {
const accessor={
extensionId:clean,
trusted:isTrusted,
storageOwned:!!meta.storage_owned,
@@ -278,6 +306,10 @@
return true;
},
};
if(allowConfigureRegistration===true){
accessor.registerConfigure=handler=>registerConfigureHandler(clean,handler);
}
return accessor;
}
function settingsForExtension(id){
@@ -350,6 +382,165 @@
});
}
function notifyConfigureChange(id,reason){
const change=Object.freeze({id,reason});
for(const listener of [...configureChangeListeners]){
try{
listener(change);
}catch(error){
if(typeof console!=='undefined'&&typeof console.error==='function'){
try{console.error('[Hermes extensions] Configure change listener failed:',error);}catch(_loggingError){}
}
}
}
}
function onConfigureChange(listener){
if(typeof listener!=='function') return null;
configureChangeListeners.add(listener);
let active=true;
return function unsubscribe(){
if(!active) return false;
active=false;
configureChangeListeners.delete(listener);
return true;
};
}
function registerConfigureHandler(clean,handler){
if(typeof handler!=='function'||!trustedExtensions.has(clean)||configureQuarantinedIds.has(clean)) return null;
if(configureRegistrations.has(clean)) return null;
const record={handler,pending:false,active:true};
configureRegistrations.set(clean,record);
notifyConfigureChange(clean,'registration');
let active=true;
return function unregister(){
if(!active) return false;
active=false;
record.active=false;
if(configureRegistrations.get(clean)===record) configureRegistrations.delete(clean);
notifyConfigureChange(clean,'registration');
return true;
};
}
function configureStateForExtension(id){
const clean=extensionId(id);
const record=configureRegistrations.get(clean);
const status=currentExtensionStatus.get(clean);
const available=!!(
clean&&record&&record.active&&!configureQuarantinedIds.has(clean)
&&status&&status.effective_enabled===true
);
return {available,pending:available&&record.pending===true};
}
function focusableConfigureTarget(node){
if(!node||typeof node.focus!=='function'||node.isConnected===false||node.hidden===true||node.disabled===true) return false;
if(typeof node.getAttribute==='function'&&node.getAttribute('aria-disabled')==='true') return false;
if(typeof node.closest==='function'&&node.closest('[hidden]')) return false;
return true;
}
function focusConfigureTarget(clean,opener){
const candidates=[];
if(focusableConfigureTarget(opener)) candidates.push(opener);
if(typeof document!=='undefined'&&document){
if(typeof document.querySelectorAll==='function'){
const buttons=document.querySelectorAll('[data-extension-configure-id]');
for(const button of buttons){
if(button&&button.dataset&&button.dataset.extensionConfigureId===clean&&focusableConfigureTarget(button)){
candidates.push(button);
break;
}
}
}
if(typeof document.querySelector==='function'){
const installedTab=document.querySelector('[data-extensions-tab="installed"]');
if(focusableConfigureTarget(installedTab)) candidates.push(installedTab);
}
}
for(const candidate of candidates){
try{
candidate.focus({preventScroll:true});
return true;
}catch(_focusOptionsError){
try{
candidate.focus();
return true;
}catch(_focusError){}
}
}
return false;
}
function reportConfigureFailure(clean,error,onError){
if(typeof console!=='undefined'&&typeof console.error==='function'){
try{console.error(`[Hermes extensions] ${clean} Configure handler failed:`,error);}catch(_loggingError){}
}
if(typeof onError==='function'){
try{onError(error);}catch(callbackError){
if(typeof console!=='undefined'&&typeof console.error==='function'){
try{console.error(`[Hermes extensions] ${clean} Configure failure reporter failed:`,callbackError);}catch(_loggingError){}
}
}
}
}
function invokeConfigure(id,options){
const clean=extensionId(id);
const state=configureStateForExtension(clean);
const record=configureRegistrations.get(clean);
if(!state.available||state.pending||!record) return false;
const opener=options&&options.opener;
const onError=options&&options.onError;
let settled=false;
let failureReported=false;
record.pending=true;
notifyConfigureChange(clean,'pending');
function settle(){
if(settled) return false;
settled=true;
record.pending=false;
notifyConfigureChange(clean,'pending');
focusConfigureTarget(clean,opener);
return true;
}
function fail(error){
if(!failureReported){
failureReported=true;
reportConfigureFailure(clean,error,onError);
}
settle();
}
let result;
try{
result=record.handler(Object.freeze({opener,restoreFocus:settle}));
}catch(error){
fail(error);
return true;
}
let then;
try{
then=result!==null&&(typeof result==='object'||typeof result==='function')?result.then:null;
}catch(error){
fail(error);
return true;
}
if(typeof then==='function'){
try{
then.call(result,settle,fail);
}catch(error){
fail(error);
}
}
return true;
}
function turnLifecycleKey(sessionId,streamId){
return `${sessionId}\u0000${streamId}`;
}
@@ -416,7 +607,7 @@
if(!trusted) return null;
const handle=Object.freeze({
id:clean,
settings:settingsAccessor(clean,trusted,true),
settings:settingsAccessor(clean,trusted,true,true),
storage:storageAccessor(clean,trusted),
events:eventAccessor(clean),
});
@@ -431,6 +622,9 @@
settingsForExtension,
storageForExtension,
_dispatchTurnLifecycle:dispatchTurnLifecycle,
_configureStateForExtension:configureStateForExtension,
_invokeConfigure:invokeConfigure,
_onConfigureChange:onConfigureChange,
resetSettingsForExtension(id){return settingsForExtension(id).reset();},
clearStorageForExtension(id){return storageForExtension(id).clear();},
};
+80 -14
View File
@@ -9881,7 +9881,18 @@ function _extensionSettingsControls(entry){
</div>`;
}
function _extensionInstalledList(extensions,extensionDirConfigured){
function _extensionConfigureButton(entry,surface){
if(surface!=='installed'||!(entry&&entry.effective_enabled)) return '';
const id=(entry&&entry.id)||'';
const runtime=window.HermesExtensionSettings;
if(!id||!runtime||typeof runtime._configureStateForExtension!=='function') return '';
const state=runtime._configureStateForExtension(id);
if(!state||!state.available) return '';
const pending=state.pending===true;
return `<button class="sm-btn extension-configure-btn" type="button" data-extension-configure-id="${esc(id)}" aria-busy="${pending?'true':'false'}"${pending?' disabled':''}>${pending?'Opening…':'Configure'}</button>`;
}
function _extensionInstalledList(extensions,extensionDirConfigured,surface){
const list=Array.isArray(extensions)?extensions:[];
if(!list.length){
if(!extensionDirConfigured) return '<div class="extension-url-empty">No extension directory is configured.</div>';
@@ -9898,6 +9909,7 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
const note=canToggle
? 'Toggles the WebUI-managed override for the next app load.'
: 'Manifest-disabled entries cannot be enabled from WebUI.';
const configureButton=_extensionConfigureButton(entry,surface);
return `<div class="extension-installed-row" data-extension-id="${esc(id)}">
<div class="extension-installed-main">
<div class="extension-installed-title-row">
@@ -9907,7 +9919,10 @@ function _extensionInstalledList(extensions,extensionDirConfigured){
<div class="extension-installed-meta"><code>${esc(id)}</code><span>${esc(note)}</span></div>
${_extensionSettingsControls(entry)}
</div>
<button class="sm-btn extension-toggle-btn" type="button" data-extension-toggle-id="${esc(id)}" data-extension-next-enabled="${nextEnabled}"${disabledAttr}>${esc(buttonText)}</button>
<div class="extension-installed-actions">
${configureButton}
<button class="sm-btn extension-toggle-btn" type="button" data-extension-toggle-id="${esc(id)}" data-extension-next-enabled="${nextEnabled}"${disabledAttr}>${esc(buttonText)}</button>
</div>
</div>`;
}).join('')}</div>`;
}
@@ -10115,6 +10130,7 @@ function _renderExtensionsPanel(data,seq){
const copyBtn=$('extensionsCopyDiagnosticsBtn');
if(!target) return;
_extensionsStatusData=data||null;
if(_extensionsGalleryData) _extensionsGalleryData.statusData=data||null;
_configureExtensionSettingsFromStatus(data);
if(copyBtn) copyBtn.disabled=!data;
const manifest=(data&&data.manifest)||{};
@@ -10165,7 +10181,7 @@ function _renderExtensionsPanel(data,seq){
</div>
</div>
<div class="provider-card-body extension-card-body">
${_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured))}
${_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')}
</div>
</div>
<div class="provider-card extension-assets-card">
@@ -10201,6 +10217,37 @@ function _renderExtensionsPanel(data,seq){
_monitorExtensionSidecars(sidecars,seq);
}
function _bindExtensionConfigureButtons(root){
if(!root) return;
root.querySelectorAll('[data-extension-configure-id]').forEach(btn=>{
btn.addEventListener('click',()=>handleExtensionConfigure(btn));
});
}
function _syncExtensionConfigureButtonState(id){
const runtime=window.HermesExtensionSettings;
if(!runtime||typeof runtime._configureStateForExtension!=='function') return;
const state=runtime._configureStateForExtension(id);
document.querySelectorAll('[data-extension-configure-id]').forEach(btn=>{
if(!btn.dataset||btn.dataset.extensionConfigureId!==id) return;
const pending=!!(state&&state.available&&state.pending);
btn.disabled=pending;
btn.setAttribute('aria-busy',pending?'true':'false');
btn.textContent=pending?'Opening…':'Configure';
});
}
function handleExtensionConfigure(btn){
if(!btn||btn.disabled) return;
const id=btn.dataset.extensionConfigureId||'';
const runtime=window.HermesExtensionSettings;
if(!id||!runtime||typeof runtime._invokeConfigure!=='function') return;
runtime._invokeConfigure(id,{
opener:btn,
onError:()=>showToast('Extension configuration failed.',4200,'error'),
});
}
function _bindExtensionToggleButtons(root){
if(!root) return;
root.querySelectorAll('[data-extension-toggle-id]').forEach(btn=>{
@@ -10362,6 +10409,21 @@ function switchExtensionsTab(tab){
if(tab==='gallery'&&!_extensionsGalleryLoaded) loadExtensionsGallery();
}
function _handleExtensionConfigureChange(change){
if(!change||!change.id) return;
if(change.reason==='pending'){
_syncExtensionConfigureButtonState(change.id);
return;
}
if(_extensionsGalleryData&&_extensionsGalleryData.statusData){
_renderInstalledExtensionsSurface(_extensionsGalleryData.statusData);
}
}
if(window.HermesExtensionSettings&&typeof window.HermesExtensionSettings._onConfigureChange==='function'){
window.HermesExtensionSettings._onConfigureChange(_handleExtensionConfigureChange);
}
function _extensionSafeHttpUrl(value){
if(!value) return '';
const raw=String(value).trim();
@@ -10517,6 +10579,19 @@ function _extensionPostInstallNote(entry,isInstalled){
</div>`;
}
function _renderInstalledExtensionsSurface(statusData){
const installedEl=$('extensionsInstalled');
if(!installedEl) return;
installedEl.innerHTML=_extensionInstalledList(
statusData&&statusData.extensions,
!!(statusData&&statusData.extension_dir_configured),
'installed'
);
_bindExtensionToggleButtons(installedEl);
_bindExtensionSettingsButtons(installedEl);
_bindExtensionConfigureButtons(installedEl);
}
async function loadExtensionsGallery(){
_extensionsGalleryLoaded=true;
const galleryEl=$('extensionsGallery');
@@ -10540,7 +10615,6 @@ async function loadExtensionsGallery(){
function _renderExtensionsGallery(entries,statusData){
const galleryEl=$('extensionsGallery');
const installedEl=$('extensionsInstalled');
_configureExtensionSettingsFromStatus(statusData);
const installedIds=new Set();
if(statusData&&statusData.gallery_installed){
@@ -10551,11 +10625,7 @@ function _renderExtensionsGallery(entries,statusData){
}
if(!Array.isArray(entries)||entries.length===0){
if(galleryEl) galleryEl.innerHTML='<div class="extensions-empty">No extensions found in the registry.</div>';
if(installedEl){
installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured));
_bindExtensionToggleButtons(installedEl);
_bindExtensionSettingsButtons(installedEl);
}
_renderInstalledExtensionsSurface(statusData);
return;
}
const galleryCards=[];
@@ -10599,11 +10669,7 @@ function _renderExtensionsGallery(entries,statusData){
galleryCards.push(card);
}
if(galleryEl) galleryEl.innerHTML=galleryCards.length?galleryCards.join(''):'<div class="extensions-empty">No extensions found.</div>';
if(installedEl){
installedEl.innerHTML=_extensionInstalledList(statusData&&statusData.extensions,!!(statusData&&statusData.extension_dir_configured));
_bindExtensionToggleButtons(installedEl);
_bindExtensionSettingsButtons(installedEl);
}
_renderInstalledExtensionsSurface(statusData);
_bindExtensionGalleryButtons(entries);
}
+5 -3
View File
@@ -5491,8 +5491,9 @@ main.main > #mainPlugin{display:none;}
.extension-installed-title{font-size:13px;font-weight:600;color:var(--text);overflow-wrap:anywhere;min-width:0;}
.extension-installed-meta{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:11px;color:var(--muted);}
.extension-installed-meta code{font-family:var(--font-mono);font-size:11px;color:var(--text);overflow-wrap:anywhere;word-break:break-word;}
.extension-toggle-btn{white-space:nowrap;flex:0 0 auto;}
.extension-toggle-btn:disabled{opacity:.55;cursor:not-allowed;}
.extension-installed-actions{display:flex;align-items:center;justify-content:flex-end;gap:6px;flex:0 0 auto;flex-wrap:wrap;}
.extension-toggle-btn,.extension-configure-btn{white-space:nowrap;flex:0 0 auto;}
.extension-toggle-btn:disabled,.extension-configure-btn:disabled{opacity:.55;cursor:not-allowed;}
.extension-settings-box{margin-top:8px;padding:9px 10px;border:1px solid var(--border);border-radius:8px;background:var(--card-bg,var(--sidebar-bg));}
.extension-settings-head{display:flex;align-items:flex-start;justify-content:space-between;gap:10px;margin-bottom:8px;}
.extension-settings-title{font-size:12px;font-weight:700;color:var(--text);}
@@ -5565,7 +5566,8 @@ main.main > #mainPlugin{display:none;}
.extensions-copy-diagnostics-btn{width:100%;}
.extension-summary-grid{grid-template-columns:1fr;}
.extension-installed-row{align-items:flex-start;flex-direction:column;}
.extension-toggle-btn{width:100%;}
.extension-installed-actions{width:100%;}
.extension-installed-actions .extension-toggle-btn,.extension-installed-actions .extension-configure-btn{width:auto;flex:1 1 140px;}
.extension-sidecar-row-head{align-items:flex-start;flex-direction:column;}
.extension-sidecar-fields>div{flex-direction:column;gap:2px;}
.extension-sidecar-fields span{flex:0 0 auto;}
+252
View File
@@ -275,3 +275,255 @@ def test_hermes_ext_register_runtime_identity_and_reload_lifecycle():
"""
)
_run_node(script)
def test_hermes_ext_configure_registration_invocation_and_quarantine():
script = textwrap.dedent(
f"""
const fs = require('fs');
const assert = require('assert');
const store = new Map();
const diagnostics = [];
const changes = [];
const replacementButton = {{
dataset: {{extensionConfigureId: 'alpha.ext'}},
isConnected: true,
disabled: false,
hidden: false,
focusCount: 0,
getAttribute(name) {{ return name === 'aria-disabled' ? 'false' : null; }},
closest() {{ return null; }},
focus() {{ this.focusCount += 1; }},
}};
const installedTab = {{
isConnected: true,
disabled: false,
hidden: false,
focusCount: 0,
getAttribute() {{ return null; }},
closest() {{ return null; }},
focus() {{ this.focusCount += 1; }},
}};
global.document = {{
querySelectorAll(selector) {{
return selector === '[data-extension-configure-id]' ? [replacementButton] : [];
}},
querySelector(selector) {{
return selector === '[data-extensions-tab="installed"]' ? installedTab : null;
}},
}};
global.window = {{
__HERMES_EXTENSION_CONFIG__: {{
extensions: [{{id: 'alpha.ext', name: 'Alpha'}}, {{id: 'beta.ext', name: 'Beta'}}]
}},
localStorage: {{
getItem(key) {{ return store.has(key) ? store.get(key) : null; }},
setItem(key, value) {{ store.set(key, String(value)); }},
removeItem(key) {{ store.delete(key); }}
}}
}};
const originalConsoleError = console.error;
console.error = (...args) => diagnostics.push(args);
(async () => {{
eval(fs.readFileSync({str(EXTENSION_SETTINGS_JS)!r}, 'utf8'));
const runtime = window.HermesExtensionSettings;
const alpha = window.hermesExt.register('alpha.ext');
const beta = window.hermesExt.register('beta.ext');
assert.ok(alpha);
assert.ok(beta);
assert.strictEqual(typeof alpha.settings.registerConfigure, 'function');
assert.strictEqual(
window.HermesExtensionSettings.settingsForExtension('alpha.ext').registerConfigure,
undefined,
'legacy global accessors must not gain registration authority'
);
assert.strictEqual(window.hermesExt.register('unknown.ext'), null);
const stopChanges = runtime._onConfigureChange(change => changes.push(change));
let alphaCalls = 0;
let pendingInsideHandler = false;
const unregisterAlpha = alpha.settings.registerConfigure(({{opener, restoreFocus}}) => {{
alphaCalls += 1;
pendingInsideHandler = runtime._configureStateForExtension('alpha.ext').pending;
assert.strictEqual(opener.extensionId, 'alpha.ext');
restoreFocus();
restoreFocus();
}});
assert.strictEqual(typeof unregisterAlpha, 'function');
assert.strictEqual(alpha.settings.registerConfigure(() => {{}}), null);
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: true, pending: false}});
assert.ok(changes.some(change => change.id === 'alpha.ext' && change.reason === 'registration'));
const connectedOpener = {{
extensionId: 'alpha.ext',
isConnected: true,
disabled: false,
hidden: false,
focusCount: 0,
getAttribute() {{ return null; }},
closest() {{ return null; }},
focus() {{ this.focusCount += 1; }},
}};
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
assert.strictEqual(alphaCalls, 1);
assert.strictEqual(pendingInsideHandler, true);
assert.strictEqual(connectedOpener.focusCount, 1);
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: true, pending: false}});
assert.strictEqual(unregisterAlpha(), true);
assert.strictEqual(unregisterAlpha(), false);
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
let releasePending;
const unregisterPending = alpha.settings.registerConfigure(() => new Promise(resolve => {{ releasePending = resolve; }}));
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true);
releasePending();
await Promise.resolve();
await Promise.resolve();
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
assert.strictEqual(connectedOpener.focusCount, 2);
assert.strictEqual(unregisterPending(), true);
let restoreExplicit;
const unregisterExplicit = alpha.settings.registerConfigure(({{restoreFocus}}) => {{
restoreExplicit = restoreFocus;
}});
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true);
assert.strictEqual(restoreExplicit(), true);
assert.strictEqual(restoreExplicit(), false);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
assert.strictEqual(connectedOpener.focusCount, 3);
assert.strictEqual(unregisterExplicit(), true);
const unregisterMissing = alpha.settings.registerConfigure(() => undefined);
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), true);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, true);
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
assert.strictEqual(connectedOpener.focusCount, 3);
assert.strictEqual(unregisterMissing(), true);
const reported = [];
const unregisterThrowing = alpha.settings.registerConfigure(() => {{ throw new Error('sync boom'); }});
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => reported.push(error.message)}}), true);
assert.deepStrictEqual(reported, ['sync boom']);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
assert.ok(diagnostics.some(args => String(args[0]).includes('alpha.ext') && String(args[0]).includes('Configure handler failed')));
assert.strictEqual(unregisterThrowing(), true);
const rejected = [];
const unregisterRejected = alpha.settings.registerConfigure(() => Promise.reject(new Error('async boom')));
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => rejected.push(error.message)}}), true);
await Promise.resolve();
await Promise.resolve();
assert.deepStrictEqual(rejected, ['async boom']);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
assert.strictEqual(unregisterRejected(), true);
const assimilated = [];
const unregisterBadThenable = alpha.settings.registerConfigure(() => Object.defineProperty({{}}, 'then', {{
get() {{ throw new Error('then getter boom'); }}
}}));
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener, onError: error => assimilated.push(error.message)}}), true);
assert.deepStrictEqual(assimilated, ['then getter boom']);
assert.strictEqual(runtime._configureStateForExtension('alpha.ext').pending, false);
assert.strictEqual(unregisterBadThenable(), true);
const detachedOpener = {{
extensionId: 'alpha.ext',
isConnected: false,
disabled: false,
hidden: false,
focusCount: 0,
getAttribute() {{ return null; }},
closest() {{ return null; }},
focus() {{ this.focusCount += 1; }},
}};
const unregisterDetached = alpha.settings.registerConfigure(({{restoreFocus}}) => restoreFocus());
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: detachedOpener}}), true);
assert.strictEqual(detachedOpener.focusCount, 0);
assert.strictEqual(replacementButton.focusCount, 1);
assert.strictEqual(installedTab.focusCount, 0);
let betaCalls = 0;
const unregisterBeta = beta.settings.registerConfigure(({{restoreFocus}}) => {{ betaCalls += 1; restoreFocus(); }});
runtime.primeFromStatus({{extensions: [
{{id: 'alpha.ext', name: 'Alpha', effective_enabled: false}},
{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}
]}});
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
assert.strictEqual(runtime._invokeConfigure('beta.ext', {{opener: connectedOpener}}), true);
assert.strictEqual(betaCalls, 1, 'another extension remains usable after alpha failures');
runtime.primeFromStatus({{extensions: [{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}]}});
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
assert.strictEqual(unregisterDetached(), true);
assert.strictEqual(alpha.settings.registerConfigure(() => {{}}), null, 'uninstalled IDs stay quarantined');
runtime.primeFromStatus({{extensions: [
{{id: 'alpha.ext', name: 'Alpha reinstalled', effective_enabled: true}},
{{id: 'beta.ext', name: 'Beta', effective_enabled: true}}
]}});
assert.deepStrictEqual(runtime._configureStateForExtension('alpha.ext'), {{available: false, pending: false}});
assert.strictEqual(runtime._invokeConfigure('alpha.ext', {{opener: connectedOpener}}), false);
assert.strictEqual(unregisterBeta(), true);
assert.strictEqual(stopChanges(), true);
assert.strictEqual(stopChanges(), false);
}})().then(
() => {{ console.error = originalConsoleError; }},
error => {{ console.error = originalConsoleError; throw error; }}
);
"""
)
_run_node(script)
def test_documented_configure_consumer_is_safe_on_older_core_without_register_configure():
"""The EXTENSIONS.md consumer pattern must not throw on an OLDER E0 Core
build where ``register()`` and ``settings`` exist but ``registerConfigure``
does not — the optional-chained form ``ext?.settings?.registerConfigure?.()``
degrades to ``undefined`` instead of a TypeError, so extension init survives.
Regression for the doc example at docs/EXTENSIONS.md (#7111 gate)."""
script = textwrap.dedent(
"""
const assert = require('assert');
// Simulate an OLDER boot-trusted E0 handle: register() returns an ext
// whose settings object has NO registerConfigure (the capability the
// current Core adds). This is exactly what a pre-#7111 Core exposes.
global.window = {
hermesExt: {
register(id) {
return { id, settings: { get() { return null; }, set() { return false; } } };
}
}
};
let initReached = false;
// The documented consumer pattern, verbatim from docs/EXTENSIONS.md.
const ext = window.hermesExt?.register?.("dictionary-manager");
const unregister = ext?.settings?.registerConfigure?.(({ opener, restoreFocus }) => {
// never called on old Core
return true;
});
initReached = true; // must be reached — no throw above
assert.strictEqual(initReached, true, 'extension init must survive missing registerConfigure');
assert.strictEqual(unregister, undefined, 'registerConfigure absent => optional call yields undefined');
// Sanity: the UNGUARDED form WOULD throw, proving the guard is load-bearing.
let threw = false;
try {
ext.settings.registerConfigure(() => {});
} catch (e) {
threw = true;
}
assert.strictEqual(threw, true, 'unguarded call is expected to throw on old Core (guard is required)');
"""
)
_run_node(script)
+117 -3
View File
@@ -1,6 +1,10 @@
"""Regression tests for the Settings → Extensions diagnostics and toggles."""
from pathlib import Path
import re
import shutil
import subprocess
import pytest
ROOT = Path(__file__).parent.parent
@@ -50,6 +54,21 @@ def _contains_post_method(block: str) -> bool:
return bool(re.search(r"\bmethod\s*:\s*([\"'`])POST\1", block))
def _run_node(script: str):
node = shutil.which("node")
if not node:
pytest.skip("node is required for extension settings panel runtime tests")
result = subprocess.run(
[node, "-e", script],
cwd=ROOT,
text=True,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
timeout=20,
)
assert result.returncode == 0, result.stderr + result.stdout
def test_settings_sidebar_has_extensions_section_and_pane():
assert 'data-settings-section="extensions"' in INDEX_HTML
assert "switchSettingsSection('extensions',{fromSidebarItem:true})" in INDEX_HTML
@@ -138,7 +157,7 @@ def test_extensions_panel_renders_sanitized_status_payload():
assert "data&&data.extensions" in render_block
assert "counts,'manifest_extensions'" in render_block
assert "counts,'user_disabled'" in render_block
assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured))" in render_block
assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')" in render_block
assert "_extensionSidecarCard(sidecars)" in render_block
assert "data&&data.warnings" in render_block
assert "esc(url)" in asset_block
@@ -251,6 +270,7 @@ def test_extensions_installed_settings_route_through_shared_accessor():
settings_block = _between("function _configureExtensionSettingsFromStatus", "function _extensionInstalledList")
bind_block = _between("function _bindExtensionSettingsButtons", "async function loadExtensionsPanel")
gallery_block = _between("function _renderExtensionsGallery", "function _bindExtensionGalleryButtons")
installed_surface_block = _between("function _renderInstalledExtensionsSurface", "async function loadExtensionsGallery")
assert "entry&&entry.storage_owned" in settings_block
assert "window.HermesExtensionSettings.settingsForExtension(id)" in settings_block
@@ -268,8 +288,81 @@ def test_extensions_installed_settings_route_through_shared_accessor():
assert "api('/api/extensions/status')" not in bind_block
assert "api('/api/settings'" not in bind_block
assert "localStorage" not in bind_block
assert "_extensionInstalledList(statusData&&statusData.extensions" in gallery_block
assert "_bindExtensionSettingsButtons(installedEl)" in gallery_block
assert "_renderInstalledExtensionsSurface(statusData)" in gallery_block
assert "_bindExtensionSettingsButtons(installedEl)" in installed_surface_block
def test_extension_configure_hook_is_scoped_to_installed_rows_and_current_status():
installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge")
configure_block = _between("function _extensionConfigureButton", "function _extensionInstalledList")
bind_block = _between("function _bindExtensionConfigureButtons", "async function handleExtensionToggle")
diagnostics_block = _between("function _renderExtensionsPanel", "function _bindExtensionToggleButtons")
gallery_block = _between("function _renderInstalledExtensionsSurface", "function _bindExtensionGalleryButtons")
assert "surface!=='installed'" in configure_block
assert "entry&&entry.effective_enabled" in configure_block
assert "_configureStateForExtension(id)" in configure_block
assert "state.available" in configure_block
assert "data-extension-configure-id" in configure_block
assert "aria-busy" in configure_block
assert "_extensionConfigureButton(entry,surface)" in installed_block
assert "_extensionInstalledList(extensions,!!(data&&data.extension_dir_configured),'diagnostics')" in diagnostics_block
assert "statusData&&statusData.extensions" in gallery_block
assert "'installed'" in gallery_block
assert "_bindExtensionConfigureButtons(installedEl)" in gallery_block
assert "_invokeConfigure(id,{" in bind_block
assert "opener:btn" in bind_block
assert "Extension configuration failed." in bind_block
assert "showToast" in bind_block
assert "data-extension-configure-id" in bind_block
def test_extension_configure_rendered_surface_runtime():
configure_block = _between("function _extensionConfigureButton", "function _extensionInstalledList")
installed_block = _between("function _extensionInstalledList", "function _extensionSidecarHealthBadge")
script = "\n".join(
[
"const assert = require('assert');",
"const states = new Map([['alpha.ext', {available:true,pending:false}], ['beta.ext', {available:true,pending:false}], ['gamma.ext', {available:false,pending:false}]]);",
"const window = {HermesExtensionSettings:{_configureStateForExtension(id){return states.get(id)||{available:false,pending:false};}}};",
"function esc(value){return String(value??'');}",
"function _extensionEntryBadge(){return '';}",
"function _extensionSettingsControls(){return '';}",
configure_block,
installed_block,
"const entries=[",
" {id:'alpha.ext',name:'Alpha',effective_enabled:true,can_toggle:true,user_enabled:true},",
" {id:'beta.ext',name:'Beta',effective_enabled:false,can_toggle:true,user_enabled:false},",
" {id:'gamma.ext',name:'Gamma',effective_enabled:true,can_toggle:true,user_enabled:true},",
"];",
"const installed=_extensionInstalledList(entries,true,'installed');",
"const diagnostics=_extensionInstalledList(entries,true,'diagnostics');",
"assert.strictEqual((installed.match(/data-extension-configure-id=/g)||[]).length,1);",
"assert.ok(installed.includes('data-extension-configure-id=\"alpha.ext\"'));",
"assert.strictEqual((diagnostics.match(/data-extension-configure-id=/g)||[]).length,0);",
"states.set('alpha.ext',{available:true,pending:true});",
"const pending=_extensionInstalledList(entries,true,'installed');",
"assert.ok(pending.includes('aria-busy=\"true\" disabled'));",
"assert.ok(pending.includes('Opening…'));",
"states.set('alpha.ext',{available:false,pending:false});",
"assert.strictEqual((_extensionInstalledList(entries,true,'installed').match(/data-extension-configure-id=/g)||[]).length,0);",
"states.set('alpha.ext',{available:true,pending:false});",
"assert.strictEqual((_extensionInstalledList(entries,true,'installed').match(/data-extension-configure-id=/g)||[]).length,1);",
]
)
_run_node(script)
def test_extension_configure_late_registration_rerenders_only_installed_surface():
listener_block = _between("function _handleExtensionConfigureChange", "function _extensionSafeHttpUrl")
diagnostics_block = _between("function _renderExtensionsPanel", "function _bindExtensionToggleButtons")
assert "_onConfigureChange(_handleExtensionConfigureChange)" in listener_block
assert "change.reason==='pending'" in listener_block
assert "_syncExtensionConfigureButtonState(change.id)" in listener_block
assert "_renderInstalledExtensionsSurface" in listener_block
assert "extensionsDiagnostics" not in listener_block
assert "_extensionsGalleryData.statusData=data||null" in diagnostics_block
def test_extensions_gallery_renders_post_install_guidance():
@@ -353,6 +446,8 @@ def test_extensions_styles_are_scoped_to_extensions_panel():
assert ".extension-toggle-btn" in STYLE_CSS
assert ".extension-settings-box" in STYLE_CSS
assert ".extension-settings-actions" in STYLE_CSS
assert ".extension-installed-actions" in STYLE_CSS
assert ".extension-configure-btn" in STYLE_CSS
assert ".extension-sidecar-list" in STYLE_CSS
assert ".extension-sidecar-runtime" in STYLE_CSS
assert ".extension-sidecar-status-badge" in STYLE_CSS
@@ -453,3 +548,22 @@ def test_extensions_docs_mentions_settings_panel_without_install_or_proxy_claims
assert "do **not**" in diagnostics_section
assert "return `HERMES_WEBUI_EXTENSION_DIR`" in diagnostics_section
assert "override state-file path" in diagnostics_section
def test_extensions_docs_define_scoped_configure_editor_contract():
configure_section = DOCS_EXTENSIONS[
DOCS_EXTENSIONS.index("### Custom Configure editors"):
DOCS_EXTENSIONS.index("### Turn lifecycle events")
]
assert "ext?.settings?.registerConfigure?." in configure_section
assert "does not require `settings_schema` or extension-owned storage" in configure_section
assert "Settings → Extensions → Installed" in configure_section
assert "Diagnostics" in configure_section
assert "Late registration" in configure_section
assert "same-ID reinstall" in configure_section
assert "one-shot `restoreFocus()`" in configure_section
assert "return a thenable" in configure_section
assert "remains disabled until reload" in configure_section
assert "Synchronous throws" in configure_section
assert "generic failure" in configure_section