mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
Hydrate extension settings forms from their own route; Telegram serves GET
Declarative extension settings forms rendered from the bare schema with an empty saved-values map, so every select showed its first option and every input was blank whatever was stored; Save then posted that blank form. For the bundled Telegram skill one press reset Command mode, Silent mode, Subagent cards and the notify toggles and, from the desktop shell or a local browser, wrote an empty TELEGRAM_CHAT_ID, unbinding the owner. Before rendering a form or action component the host now issues GET to the component's own route (Promise.all across all sections, behind the existing stale-load guard): a 2xx JSON object is the saved-values map for renderSafeField; 404/405 keeps the previous rendering with Save enabled, so plugins without a GET handler are unchanged; any other outcome renders the form with Save disabled and an inline note, so a blank form is never posted over values the client could not read. The Telegram skill registers settings/save for GET and POST: GET returns the form's own stored keys as strings (never the bot token), POST is unchanged, and loopback may still change or clear the owner binding on purpose. Four long option labels are shortened with their explanation moved into the field help. CREATING_SKILLS documents the contract for authors and ARCHITECTURE describes the flow. Tests: web/tests/extension_settings_hydration.test.js drives the real renderer through the three outcomes and the stale guard; tests/test_telegram_settings.py pins the GET key set and the unchanged POST; tests/test_extension_process_runner.py pins that the out-of-process request proxy carries the dispatched method.
This commit is contained in:
parent
8bb1e4bea9
commit
15de30ac06
7 changed files with 306 additions and 23 deletions
|
|
@ -831,6 +831,8 @@ Settings has Accounts, Secrets, Models, Agents, Behavior, Advanced, and About ta
|
|||
|
||||
The Settings client collects and validates the whole current draft before sending Save; any local error keeps all rows/values available for correction and sends no partial save. `settings_controls.js` separates pure custom-key collection from field-error painting and keeps dirty reads passive. Ordinary refresh and failed writes preserve current edits; leaving or explicitly reloading a dirty draft asks before discarding it. The existing write response still distinguishes saved, unsaved and unknown outcomes, with owner-only decisions on their own endpoints. No durable cross-page draft store or secret persistence is introduced.
|
||||
|
||||
Advanced also hosts the settings sections live extensions register, and those forms are hydrated before they are shown: the client reads each declarative `form`/`action` component's own `route` with `GET`, and a 2xx JSON object of saved values pre-fills exactly the fields it names through `renderSafeField` (passwords are never hydrated), so Save rewrites current values instead of posting first options over them. A `404`/`405` answer means the skill serves no current values and the form renders from its declared defaults with Save enabled; a transport or server failure renders it with Save disabled and an inline note pointing at Reload Settings, because a blank form must never overwrite values the client could not read.
|
||||
|
||||
Each provider card has one compact **Test** action backed by `POST /api/providers/test`: request exactly `{provider_id, overrides?}`, response exactly `{ok, error?}`. Overrides are request-local — an omitted field reads the saved value, an explicitly edited empty field stays empty (for the compatible card it suppresses the corresponding legacy OpenAI fallback) — and draft credentials never mutate Settings, process environment, or `LLMClient` caches. Model selection reuses a configured route, then the provider's maintained main default; only the generic OpenAI-compatible route performs bounded catalogue discovery when neither exists. A configured test is one bounded, physically accounted probe (`ouroboros/llm_probe.py`) with none of the normal-chat retry/fallback/tools/reasoning/web/cache/response-format/capability-learning paths. The card renders `Testing…`, `Works`, or `Not ready` with one controlled short reason; the tooltip notes the request may incur provider charges.
|
||||
|
||||
Desktop onboarding and the blocking web overlay are the same served `/onboarding` page — same steps, same backend normalization; context mode stays a separate owner setting. Startup readiness is structural (§2): a configured managed-model Main route can satisfy the gate without an API key; credential validity, entitlement, model availability, and local-process health remain runtime status, not onboarding admission. Every host completes through the single `POST /api/onboarding/complete` transaction (§2), so completion is all-or-nothing and install-time agent defaults are part of the same save.
|
||||
|
|
|
|||
|
|
@ -825,6 +825,13 @@ def register(api):
|
|||
# configuration writes and markdown/json for explanatory diagnostics.
|
||||
# Rich widget-only components (media, stream, map, kanban, module JS)
|
||||
# belong on the Widgets page, not Settings.
|
||||
# Hydration: before rendering a form/action the host issues GET on the
|
||||
# component's own route. Answer 2xx with a JSON object
|
||||
# {field_name: current_value} (strings) to pre-fill those fields; omit a
|
||||
# field to show its declared default/first option, and never return a
|
||||
# secret. 404/405 means "no current values", so the form renders from
|
||||
# defaults; a failed read makes the host disable Save and tell the owner
|
||||
# to reload. Reference: skills/telegram/plugin.py, GET+POST settings/save.
|
||||
api.register_settings_section(
|
||||
"config",
|
||||
title="Search settings",
|
||||
|
|
|
|||
|
|
@ -71,6 +71,16 @@ _VALID_COMMAND_MODES = frozenset({_COMMAND_MODE_STRICT, _COMMAND_MODE_SAFE, _COM
|
|||
# Which translation keys are available in safe mode (full_access forwards raw)
|
||||
_SAFE_TRANSLATION_KEYS = frozenset({"/status", "/bg status", "/bg"})
|
||||
|
||||
# The exact keys the declarative Settings form owns: POST accepts only these and
|
||||
# the GET that hydrates the form returns only these. The bot token belongs to
|
||||
# Secrets and is deliberately absent from both directions.
|
||||
_SETTINGS_FORM_KEYS = (
|
||||
"TELEGRAM_CHAT_ID", "TELEGRAM_MAX_UPDATES_PER_POLL", "TELEGRAM_MIRROR_MODE",
|
||||
"TELEGRAM_COMMAND_MODE", "TELEGRAM_LANGUAGE", "TELEGRAM_SILENT_MODE",
|
||||
"TELEGRAM_SUBAGENT_CARDS", "TELEGRAM_MIRROR_PROGRESS", "TELEGRAM_NOTIFY_TASKS",
|
||||
"TELEGRAM_NOTIFY_BUDGET", "TELEGRAM_MINIAPP_ENABLED",
|
||||
)
|
||||
|
||||
def _setting_int(settings: Dict[str, Any], key: str, default: int, *, minimum: int = 1, maximum: int = 100) -> int:
|
||||
try:
|
||||
value = int(settings.get(key) or default)
|
||||
|
|
@ -260,6 +270,17 @@ def _build_language_keyboard(lang: str = "en") -> tuple[str, list[list[dict]]]:
|
|||
|
||||
def _make_settings_save(api):
|
||||
async def _settings_save(request):
|
||||
if str(getattr(request, "method", "POST") or "POST").upper() == "GET":
|
||||
# Hydration read for the Settings form: only the form's own keys
|
||||
# that are actually stored, as strings, so the UI shows what is
|
||||
# saved instead of the schema's first option.
|
||||
try:
|
||||
stored = _load_settings(api)
|
||||
except TelegramSettingsError as exc:
|
||||
return JSONResponse({"ok": False, "message": str(exc)}, status_code=409)
|
||||
return JSONResponse(
|
||||
{key: str(stored[key]) for key in _SETTINGS_FORM_KEYS if key in stored}
|
||||
)
|
||||
try:
|
||||
data = await request.json()
|
||||
except (TypeError, ValueError):
|
||||
|
|
@ -272,8 +293,7 @@ def _make_settings_save(api):
|
|||
{"ok": False, "message": "Invalid Telegram settings payload."},
|
||||
status_code=400,
|
||||
)
|
||||
allowed = {"TELEGRAM_CHAT_ID", "TELEGRAM_MAX_UPDATES_PER_POLL", "TELEGRAM_MIRROR_MODE", "TELEGRAM_COMMAND_MODE", "TELEGRAM_LANGUAGE", "TELEGRAM_SILENT_MODE", "TELEGRAM_SUBAGENT_CARDS", "TELEGRAM_MIRROR_PROGRESS", "TELEGRAM_NOTIFY_TASKS", "TELEGRAM_NOTIFY_BUDGET", "TELEGRAM_MINIAPP_ENABLED"}
|
||||
payload = {key: data.get(key) for key in allowed if key in data}
|
||||
payload = {key: data.get(key) for key in _SETTINGS_FORM_KEYS if key in data}
|
||||
owner_ignored = False
|
||||
if "TELEGRAM_CHAT_ID" in payload and not request_may_change_owner(request):
|
||||
payload.pop("TELEGRAM_CHAT_ID", None)
|
||||
|
|
@ -1329,7 +1349,8 @@ def register(api):
|
|||
api.subscribe_event("chat.document", _make_document(api))
|
||||
api.subscribe_event("chat.links", _make_links(api))
|
||||
api.subscribe_event("chat.quiz", _make_quiz(api))
|
||||
api.register_route("settings/save", handler=_make_settings_save(api), methods=("POST",))
|
||||
# GET hydrates the declarative form with what is stored; POST saves it.
|
||||
api.register_route("settings/save", handler=_make_settings_save(api), methods=("GET", "POST"))
|
||||
api.register_route("miniapp/status", handler=_make_status(api), methods=("POST",))
|
||||
api.register_settings_section(
|
||||
"telegram",
|
||||
|
|
@ -1361,10 +1382,12 @@ def register(api):
|
|||
"placeholder": "en"},
|
||||
{"name": "TELEGRAM_COMMAND_MODE", "label": "Command mode", "type": "select",
|
||||
"options": [
|
||||
{"value": "full_access", "label": "Full access (default) — raw owner commands incl. /panic, /restart"},
|
||||
{"value": "safe_commands", "label": "Safe — allow /status, /bg status only"},
|
||||
{"value": "strict", "label": "Strict — block all slash commands from Telegram"},
|
||||
{"value": "full_access", "label": "Full access (default)"},
|
||||
{"value": "safe_commands", "label": "Safe commands only"},
|
||||
{"value": "strict", "label": "Strict"},
|
||||
],
|
||||
"help": "Full access forwards raw owner commands including /panic and /restart. "
|
||||
"Safe allows /status and /bg status only. Strict blocks every slash command from Telegram.",
|
||||
"placeholder": "full_access"},
|
||||
{"name": "TELEGRAM_MIRROR_MODE", "label": "Mirror mode", "type": "select",
|
||||
"options": [
|
||||
|
|
@ -1376,21 +1399,24 @@ def register(api):
|
|||
{"name": "TELEGRAM_MAX_UPDATES_PER_POLL", "label": "Max updates per poll", "type": "number", "placeholder": "20"},
|
||||
{"name": "TELEGRAM_SILENT_MODE", "label": "Silent mode (edit-in-place)", "type": "select",
|
||||
"options": [
|
||||
{"value": "off", "label": "Off — each thought is a new message"},
|
||||
{"value": "on", "label": "On — replace the previous thought in-place"},
|
||||
{"value": "off", "label": "Off"},
|
||||
{"value": "on", "label": "On (edit in place)"},
|
||||
],
|
||||
"help": "On replaces the previous thought in place instead of sending a new message.",
|
||||
"placeholder": "off"},
|
||||
{"name": "TELEGRAM_SUBAGENT_CARDS", "label": "Subagent cards", "type": "select",
|
||||
"options": [
|
||||
{"value": "on", "label": "On — one updating message per subagent"},
|
||||
{"value": "off", "label": "Off — hide subagent activity"},
|
||||
{"value": "on", "label": "On"},
|
||||
{"value": "off", "label": "Off"},
|
||||
],
|
||||
"help": "One updating message per subagent.",
|
||||
"placeholder": "on"},
|
||||
{"name": "TELEGRAM_MIRROR_PROGRESS", "label": "Mirror progress telemetry", "type": "select",
|
||||
"options": [
|
||||
{"value": "off", "label": "Off (default) — replies only (clean chat)"},
|
||||
{"value": "on", "label": "On — stream the main agent's progress"},
|
||||
{"value": "off", "label": "Off (default)"},
|
||||
{"value": "on", "label": "On"},
|
||||
],
|
||||
"help": "On streams the main agent's progress; Off keeps replies only.",
|
||||
"placeholder": "off"},
|
||||
{"name": "TELEGRAM_MINIAPP_ENABLED", "label": "Telegram Mini App", "type": "select",
|
||||
"options": [
|
||||
|
|
|
|||
|
|
@ -861,3 +861,18 @@ def test_native_risk_extension_gateway_ws_child_failure_is_log_message(tmp_path,
|
|||
assert payload["type"] == "log"
|
||||
assert "child failed" in payload["data"]["message"]
|
||||
assert "ws-child-boom" in payload["data"]["message"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("method", ["GET", "post", ""])
|
||||
def test_child_route_request_carries_the_dispatched_method(tmp_path, method):
|
||||
"""A route registered for GET+POST dispatches on `request.method`, so the
|
||||
child's request proxy must carry the host's method exactly like the
|
||||
in-process Starlette request does (empty falls back to GET)."""
|
||||
from ouroboros.extension_process_runner import _request_from_payload
|
||||
|
||||
request = asyncio.run(_request_from_payload(
|
||||
{"method": method, "path": "/api/extensions/x/settings/save"},
|
||||
tmp_path,
|
||||
pathlib.Path(__file__).resolve().parents[1],
|
||||
))
|
||||
assert request.method == (method.upper() or "GET")
|
||||
|
|
|
|||
|
|
@ -50,8 +50,9 @@ def _load_plugin():
|
|||
|
||||
|
||||
class _RouteRequest:
|
||||
def __init__(self, payload, *, host=None, marker="") -> None:
|
||||
def __init__(self, payload, *, host=None, marker="", method="POST") -> None:
|
||||
self.payload = payload
|
||||
self.method = method
|
||||
self.headers = {MINIAPP_MARKER_HEADER: marker}
|
||||
self.client = None if host is None else SimpleNamespace(host=host)
|
||||
|
||||
|
|
@ -192,6 +193,55 @@ def test_unmarked_loopback_route_may_change_and_reset_owner(tmp_path: Path) -> N
|
|||
assert gateway._lookup_session(token) is None
|
||||
|
||||
|
||||
def test_settings_route_get_hydrates_only_stored_form_keys(tmp_path: Path) -> None:
|
||||
"""The Settings form is rendered from THIS read, so it must carry exactly
|
||||
the form's own stored keys — never the token, never a foreign key, and
|
||||
never an absent key (whose runtime default is the schema's first option)."""
|
||||
plugin = _load_plugin()
|
||||
merge_settings(tmp_path, {
|
||||
"TELEGRAM_CHAT_ID": "42",
|
||||
"TELEGRAM_COMMAND_MODE": "strict",
|
||||
"TELEGRAM_MAX_UPDATES_PER_POLL": 20,
|
||||
"TELEGRAM_BOT_TOKEN": "12345678:" + "A" * 35,
|
||||
"UNRELATED_KEY": "keep-private",
|
||||
})
|
||||
handler = plugin._make_settings_save(_RouteApi(tmp_path))
|
||||
response = asyncio.run(handler(_RouteRequest(None, method="GET")))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {
|
||||
"TELEGRAM_CHAT_ID": "42",
|
||||
"TELEGRAM_MAX_UPDATES_PER_POLL": "20",
|
||||
"TELEGRAM_COMMAND_MODE": "strict",
|
||||
}
|
||||
assert b"TELEGRAM_BOT_TOKEN" not in response.body
|
||||
assert b"keep-private" not in response.body
|
||||
# A read is a read: the stored document is untouched and the owner binding
|
||||
# survives, exactly as before any form is submitted.
|
||||
assert load_settings(tmp_path)["TELEGRAM_CHAT_ID"] == "42"
|
||||
assert load_settings(tmp_path)["UNRELATED_KEY"] == "keep-private"
|
||||
|
||||
|
||||
def test_settings_route_get_on_empty_store_is_an_empty_document(tmp_path: Path) -> None:
|
||||
plugin = _load_plugin()
|
||||
response = asyncio.run(
|
||||
plugin._make_settings_save(_RouteApi(tmp_path))(_RouteRequest(None, method="GET"))
|
||||
)
|
||||
assert response.status_code == 200
|
||||
assert json.loads(response.body) == {}
|
||||
assert not (tmp_path / "settings.json").exists()
|
||||
|
||||
|
||||
def test_settings_route_get_reports_an_unreadable_store(tmp_path: Path) -> None:
|
||||
(tmp_path / "settings.json").write_text("{", encoding="utf-8")
|
||||
plugin = _load_plugin()
|
||||
response = asyncio.run(
|
||||
plugin._make_settings_save(_RouteApi(tmp_path))(_RouteRequest(None, method="GET"))
|
||||
)
|
||||
assert response.status_code == 409
|
||||
assert json.loads(response.body)["ok"] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route_request", [_InvalidJsonRequest({}), _RouteRequest([])])
|
||||
def test_settings_route_rejects_invalid_payload(tmp_path: Path, route_request) -> None:
|
||||
plugin = _load_plugin()
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { apiClient, apiFetch, cleanExtensionRoute, extensionRoutePath } from './
|
|||
import { claudexorStatus } from './claudexor_status_store.js';
|
||||
import { createModelRolesEditor, modelRoleMap } from './model_roles.js';
|
||||
import { PROCESSING_PREFERENCE_KEY, MODEL_PROCESSING_PREFERENCES_KEY } from './route_editor_primitives.js';
|
||||
import { collectSafeFieldValues, renderSafeField, setInlineStatus, revealNewRow } from './ui_helpers.js';
|
||||
import { collectSafeFieldValues, normalizeTone, renderSafeField, setInlineStatus, revealNewRow } from './ui_helpers.js';
|
||||
import { extensionActionStatus } from './extension_status_text.js';
|
||||
|
||||
let markSettingsDirty = () => {};
|
||||
|
|
@ -229,7 +229,32 @@ function renderRequestedSkillSecrets(root, skills, settings) {
|
|||
});
|
||||
}
|
||||
|
||||
function renderExtensionSettingsSections(root, sections) {
|
||||
// A declarative extension form must show what is STORED. Rendering the bare
|
||||
// schema and posting it overwrote real values with the schema's first option —
|
||||
// the bundled Telegram skill unbound its owner chat id that way. The host reads
|
||||
// the current values from the SAME route it posts to; a plugin with no GET
|
||||
// handler (404/405) keeps today's empty form, and any other outcome is an
|
||||
// unknown read whose Save must not overwrite what we could not see.
|
||||
const EXTENSION_VALUES_UNREADABLE = 'Current values could not be read; Save is disabled so it does not overwrite them. Use Reload Settings to retry.';
|
||||
|
||||
const extensionFormKey = (section, component, idx) =>
|
||||
`${section.key || `${section.skill}:${section.section_id}`}:${component.id || idx}`;
|
||||
|
||||
/** Read one form's stored values from its own route. Exported for node tests. */
|
||||
export async function readExtensionFormValues(skill, route) {
|
||||
try {
|
||||
const resp = await apiFetch(extensionRoutePath(skill, route));
|
||||
if (resp.status === 404 || resp.status === 405) return { values: {}, blocked: false };
|
||||
if (!resp.ok) return { values: {}, blocked: true };
|
||||
const data = await resp.json();
|
||||
if (!data || typeof data !== 'object' || Array.isArray(data)) return { values: {}, blocked: true };
|
||||
return { values: data, blocked: false };
|
||||
} catch {
|
||||
return { values: {}, blocked: true };
|
||||
}
|
||||
}
|
||||
|
||||
export async function renderExtensionSettingsSections(root, sections, { isCurrent = () => true } = {}) {
|
||||
const host = root.querySelector('#extension-settings-sections');
|
||||
if (!host) return;
|
||||
const items = Array.isArray(sections) ? sections : [];
|
||||
|
|
@ -237,6 +262,17 @@ function renderExtensionSettingsSections(root, sections) {
|
|||
host.innerHTML = '<div class="muted">No extension settings registered.</div>';
|
||||
return;
|
||||
}
|
||||
const hydrated = new Map();
|
||||
await Promise.all(items.flatMap((section) => (Array.isArray(section.render?.components) ? section.render.components : [])
|
||||
.map(async (component, idx) => {
|
||||
const rawRoute = component.route || component.api_route || '';
|
||||
if (!['form', 'action'].includes(String(component.type || '')) || !cleanExtensionRoute(rawRoute)) return;
|
||||
hydrated.set(extensionFormKey(section, component, idx),
|
||||
await readExtensionFormValues(section.skill || '', rawRoute));
|
||||
})));
|
||||
// A newer load or an owner edit may have landed while those reads were in
|
||||
// flight; a stale hydration must never overwrite the newer render.
|
||||
if (!isCurrent()) return;
|
||||
const formSpecs = new Map();
|
||||
const componentHtml = (section, component, idx) => {
|
||||
const type = String(component.type || '');
|
||||
|
|
@ -252,8 +288,9 @@ function renderExtensionSettingsSections(root, sections) {
|
|||
if (!cleanExtensionRoute(rawRoute)) {
|
||||
return '<div class="settings-inline-note">Invalid extension settings route.</div>';
|
||||
}
|
||||
const formKey = `${section.key || `${section.skill}:${section.section_id}`}:${component.id || idx}`;
|
||||
const formKey = extensionFormKey(section, component, idx);
|
||||
formSpecs.set(formKey, component);
|
||||
const { values = {}, blocked = false } = hydrated.get(formKey) || {};
|
||||
const disabled = Boolean(component.disabled);
|
||||
const fieldOptions = {
|
||||
disabled,
|
||||
|
|
@ -262,10 +299,10 @@ function renderExtensionSettingsSections(root, sections) {
|
|||
helpClass: 'settings-inline-note ui-field-help',
|
||||
};
|
||||
return `
|
||||
<form class="settings-extension-form" data-extension-settings-form data-extension-settings-key="${escapeHtml(formKey)}" data-skill="${escapeHtml(section.skill || '')}" data-route="${escapeHtml(rawRoute)}">
|
||||
<div class="form-grid two">${fields.map((field) => renderSafeField(field, {}, fieldOptions)).join('')}</div>
|
||||
<button class="btn btn-primary btn-sm" type="submit"${disabled ? ' disabled' : ''}>${escapeHtml(component.submit_label || component.label || 'Save')}</button>
|
||||
<div class="settings-inline-status" data-extension-settings-status></div>
|
||||
<form class="settings-extension-form" data-extension-settings-form data-extension-settings-key="${escapeHtml(formKey)}" data-skill="${escapeHtml(section.skill || '')}" data-route="${escapeHtml(rawRoute)}"${blocked ? ' data-extension-settings-blocked="1"' : ''}>
|
||||
<div class="form-grid two">${fields.map((field) => renderSafeField(field, values, fieldOptions)).join('')}</div>
|
||||
<button class="btn btn-primary btn-sm" type="submit"${disabled || blocked ? ' disabled' : ''}>${escapeHtml(component.submit_label || component.label || 'Save')}</button>
|
||||
<div class="settings-inline-status" data-extension-settings-status${blocked ? ` data-tone="${normalizeTone('warn')}"` : ''}>${blocked ? escapeHtml(EXTENSION_VALUES_UNREADABLE) : ''}</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
|
@ -296,7 +333,8 @@ function renderExtensionSettingsSections(root, sections) {
|
|||
const formKey = form.dataset.extensionSettingsKey || `${skill}:${route}`;
|
||||
const spec = formSpecs.get(formKey) || {};
|
||||
const requestKey = `${skill}:${route}`;
|
||||
if (!skill || !route || spec.disabled || pendingExtensionSettings.has(requestKey)) return;
|
||||
if (!skill || !route || spec.disabled || form.dataset.extensionSettingsBlocked
|
||||
|| pendingExtensionSettings.has(requestKey)) return;
|
||||
const values = collectSafeFieldValues(form, spec.fields || []);
|
||||
const button = form.querySelector('button[type="submit"]');
|
||||
const idleLabel = spec.submit_label || spec.label || 'Save';
|
||||
|
|
@ -728,7 +766,6 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
if (sequence !== loadSequence || revision !== draftRevision) return false;
|
||||
currentSettings = data;
|
||||
applySettings(data);
|
||||
renderExtensionSettingsSections(page, sections);
|
||||
renderRequestedSkillSecrets(page, extData.skills || [], data);
|
||||
renderCustomSecrets(page, data);
|
||||
// This confirmed document can already be edited and saved. Optional
|
||||
|
|
@ -741,7 +778,11 @@ export function initSettings({ state, setBeforePageLeave, ws } = {}) {
|
|||
armCleanBaselineOnStatusSettle(revision);
|
||||
_renderNetworkHint(data._meta);
|
||||
syncSettingsLoadState();
|
||||
await Promise.all([reloadReviewerSlots({ isCurrent: () => sequence === loadSequence && revision === draftRevision }), reloadSubagentsSection()]);
|
||||
// Extension settings forms read their stored values before rendering:
|
||||
// that is optional enrichment too, and it must not delay the clean
|
||||
// baseline above or absorb an owner edit made while it was pending.
|
||||
const isCurrent = () => sequence === loadSequence && revision === draftRevision;
|
||||
await Promise.all([renderExtensionSettingsSections(page, sections, { isCurrent }), reloadReviewerSlots({ isCurrent }), reloadSubagentsSection()]);
|
||||
if (sequence !== loadSequence || revision !== draftRevision) {
|
||||
updateSettingsDirtyState();
|
||||
return false;
|
||||
|
|
|
|||
142
web/tests/extension_settings_hydration.test.js
Normal file
142
web/tests/extension_settings_hydration.test.js
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
// A declarative extension settings form used to render the bare schema: every
|
||||
// select showed its FIRST option and every text field was empty, so pressing
|
||||
// Save posted that blank form over the stored values (the bundled Telegram
|
||||
// skill unbound its owner chat id that way). The host now reads the current
|
||||
// values from the same route it posts to; these pin the three outcomes of that
|
||||
// read against the real renderer.
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { readExtensionFormValues, renderExtensionSettingsSections } from '../modules/settings.js';
|
||||
|
||||
const UNREADABLE_NOTE = 'Current values could not be read; Save is disabled so it does not overwrite them. '
|
||||
+ 'Use Reload Settings to retry.';
|
||||
|
||||
const FORM = {
|
||||
type: 'form',
|
||||
id: 'settings-form',
|
||||
route: 'save',
|
||||
submit_label: 'Save',
|
||||
fields: [
|
||||
{
|
||||
name: 'mode', label: 'Mode', type: 'select', default: 'safe',
|
||||
options: [{ label: 'Safe', value: 'safe' }, { label: 'Fast', value: 'fast' }],
|
||||
},
|
||||
{ name: 'chat_id', label: 'Chat ID', type: 'text' },
|
||||
{ name: 'token', label: 'Token', type: 'password' },
|
||||
],
|
||||
};
|
||||
|
||||
const sections = (component = FORM) => [{
|
||||
skill: 'demo', section_id: 'config', key: 'demo:config', title: 'Demo', render: { components: [component] },
|
||||
}];
|
||||
|
||||
/** Minimal render target: the renderer only writes innerHTML and binds forms. */
|
||||
function fakeHost() {
|
||||
const host = { innerHTML: '', querySelectorAll: () => [] };
|
||||
return { host, root: { querySelector: (sel) => (sel === '#extension-settings-sections' ? host : null) } };
|
||||
}
|
||||
|
||||
function stubFetch(handler) {
|
||||
const calls = [];
|
||||
const prior = globalThis.fetch;
|
||||
globalThis.fetch = async (url, init) => {
|
||||
calls.push({ url, init });
|
||||
return handler(url, init);
|
||||
};
|
||||
return { calls, restore: () => { globalThis.fetch = prior; } };
|
||||
}
|
||||
|
||||
const jsonResponse = (body, status = 200) => ({
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
json: async () => body,
|
||||
});
|
||||
|
||||
async function render(handler, { component = FORM, isCurrent } = {}) {
|
||||
const { host, root } = fakeHost();
|
||||
const fetcher = stubFetch(handler);
|
||||
try {
|
||||
await renderExtensionSettingsSections(root, sections(component), isCurrent ? { isCurrent } : undefined);
|
||||
} finally {
|
||||
fetcher.restore();
|
||||
}
|
||||
return { html: host.innerHTML, calls: fetcher.calls };
|
||||
}
|
||||
|
||||
test('(a) a stored document hydrates the form, and a password never comes back', async () => {
|
||||
const { html, calls } = await render(() => jsonResponse({ mode: 'fast', chat_id: '42', token: 'leaked-secret' }));
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, '/api/extensions/demo/save');
|
||||
assert.ok(!calls[0].init?.method, 'the hydration read is a plain GET on the POST route');
|
||||
assert.match(html, /<option value="fast" selected>Fast<\/option>/);
|
||||
assert.doesNotMatch(html, /<option value="safe" selected>/);
|
||||
assert.match(html, /name="chat_id"[^>]* value="42"/);
|
||||
// Passwords are never hydrated by renderSafeField; nothing may leak into the DOM.
|
||||
assert.doesNotMatch(html, /leaked-secret/);
|
||||
assert.match(html, /type="submit">Save<\/button>/);
|
||||
assert.doesNotMatch(html, /data-extension-settings-blocked/);
|
||||
assert.doesNotMatch(html, new RegExp(UNREADABLE_NOTE.slice(0, 30)));
|
||||
});
|
||||
|
||||
test('(b) a plugin with no GET handler is unchanged: empty form, Save enabled', async () => {
|
||||
for (const status of [404, 405]) {
|
||||
const { html } = await render(() => jsonResponse({ error: 'nope' }, status));
|
||||
assert.match(html, /<option value="safe" selected>Safe<\/option>/, `status ${status}`);
|
||||
assert.match(html, /name="chat_id"[^>]* value=""/, `status ${status}`);
|
||||
assert.match(html, /type="submit">Save<\/button>/, `status ${status}`);
|
||||
assert.doesNotMatch(html, /data-extension-settings-blocked/, `status ${status}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('(c) an unreadable read disables Save and says why, in the shared warn tone', async () => {
|
||||
const unreadable = [
|
||||
['5xx', () => jsonResponse({}, 500)],
|
||||
['other 4xx', () => jsonResponse({}, 403)],
|
||||
['non-JSON body', () => ({ ok: true, status: 200, json: async () => { throw new SyntaxError('not json'); } })],
|
||||
['non-object body', () => jsonResponse([1, 2, 3])],
|
||||
['network error', () => { throw new TypeError('Failed to fetch'); }],
|
||||
];
|
||||
for (const [label, handler] of unreadable) {
|
||||
const { html } = await render(handler);
|
||||
assert.match(html, /data-extension-settings-blocked="1"/, label);
|
||||
assert.match(html, /type="submit" disabled>Save<\/button>/, label);
|
||||
assert.match(html, /data-extension-settings-status data-tone="warn">/, label);
|
||||
assert.ok(html.includes(UNREADABLE_NOTE), label);
|
||||
// The form still renders; only Save is withheld.
|
||||
assert.match(html, /<option value="safe" selected>Safe<\/option>/, label);
|
||||
}
|
||||
});
|
||||
|
||||
test('a stale load never overwrites a newer render', async () => {
|
||||
const { html, calls } = await render(() => jsonResponse({ mode: 'fast' }), { isCurrent: () => false });
|
||||
assert.equal(calls.length, 1, 'the read still happens; only the write is withheld');
|
||||
assert.equal(html, '');
|
||||
});
|
||||
|
||||
test('a component without a usable route is never read from', async () => {
|
||||
const { html, calls } = await render(() => jsonResponse({}), {
|
||||
component: { ...FORM, route: '../escape' },
|
||||
});
|
||||
assert.deepEqual(calls, []);
|
||||
assert.match(html, /Invalid extension settings route/);
|
||||
});
|
||||
|
||||
test('readExtensionFormValues classifies every outcome of the read', async () => {
|
||||
const outcomes = [
|
||||
[jsonResponse({ mode: 'fast' }), { values: { mode: 'fast' }, blocked: false }],
|
||||
[jsonResponse({}, 404), { values: {}, blocked: false }],
|
||||
[jsonResponse({}, 405), { values: {}, blocked: false }],
|
||||
[jsonResponse({}, 500), { values: {}, blocked: true }],
|
||||
[jsonResponse(null), { values: {}, blocked: true }],
|
||||
];
|
||||
for (const [response, expected] of outcomes) {
|
||||
const fetcher = stubFetch(() => response);
|
||||
try {
|
||||
assert.deepEqual(await readExtensionFormValues('demo', 'save'), expected);
|
||||
} finally {
|
||||
fetcher.restore();
|
||||
}
|
||||
}
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue