feat(channels): configure sidecar adapters (telegram/ntfy) from dashboard#5252
Conversation
…ter self-description
…tiating; strip per-adapter --describe guards
…across secrets.env & config.toml
… bridge re-init path
Deploying librefang-docs with
|
| Latest commit: |
d2207f6
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://e05e6781.librefang-docs-web.pages.dev |
| Branch Preview URL: | https://feat-sidecar-channel-configu.librefang-docs-web.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: d2207f6f6e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let home = librefang_home(); | ||
| let secrets_path = home.join("secrets.env"); |
There was a problem hiding this comment.
Use the running kernel home for sidecar saves
When the daemon is running with a configured KernelConfig.home_dir that differs from LIBREFANG_HOME/~/.librefang, this endpoint writes secrets.env and config.toml under the recomputed default home, while reload_config() and reload_channels_from_disk() read from state.kernel.home_dir(). The request can return a save/reload error or leave the actual sidecar config unchanged even though the dashboard submitted valid values; use the kernel's home directory from AppState for these paths.
Useful? React with 👍 / 👎.
…n, tighter secret validation - configure_sidecar_channel now acquires state.config_write_lock for the secrets.env + config.toml write phase (issue #3183 parity with legacy configure_channel). Two concurrent POST /api/channels/sidecar/{a,b}/configure calls can no longer lost-update on config.toml or secrets.env. Guard is dropped before reload_config().await so hot-reload does not gate other handlers. - Register routes::configure_sidecar_channel and the three schema types (ConfigureSidecarBody, SidecarSchema, SidecarSchemaField) in openapi.rs; openapi.json regenerated. - upsert_secret rejects CR, NUL, leading/trailing whitespace, and values starting with a quote — all shapes the dotenv reader would silently corrupt on round-trip. Test renamed to upsert_rejects_invalid_value_chars with 4 sub-assertions; legacy 'newline' substring check preserved.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 14a0daf19f
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if f.field_type == "secret" { | ||
| super::secrets_env::upsert_secret(&secrets_path, &f.key, trimmed).map_err( |
There was a problem hiding this comment.
Load newly saved sidecar secrets before restarting sidecars
When a sidecar schema marks a field as secret (for example TELEGRAM_BOT_TOKEN or NTFY_TOKEN), this path writes it only to secrets.env and omits it from the [[sidecar_channels]].env table, then immediately hot-reloads the bridge. The sidecar subprocess only receives ctx.env plus the daemon's already-loaded process environment (crates/librefang-channels/src/sidecar.rs:407-409), while load_dotenv() is a boot-time Once loader (crates/librefang-extensions/src/dotenv.rs:38-42), so a freshly saved token is not visible to the child until a daemon restart or manual env export. In the dashboard save flow this makes first-party Telegram fail to start even though the endpoint reports status: saved; either update the live env used for this child or include the saved secret in the spawned sidecar environment before the reload.
Useful? React with 👍 / 👎.
The dotenv loader's priority is system env > vault > .env > secrets.env, so if the operator already exported (e.g.) TELEGRAM_BOT_TOKEN in the shell that launched the daemon, the saved secrets.env value never reaches the sidecar child. The save still mechanically succeeds — the operator only finds out by debugging why the new token isn't applied. configure_sidecar_channel now compares each non-empty secret field's key against std::env::var (subtracting keys already present in the on-disk secrets.env, which would otherwise count as our own previous write). Matches are returned under `shadowed_secrets` in the 200 response. SidecarForm renders an additional warning toast (error variant + 'Warning:' prefix, since the toast store has no warning variant) when the array is non-empty. Regression covered by the new channels_routes_test configure_sidecar_warns_on_shell_env_shadow; the existing happy-path test now also asserts shadowed_secrets is empty when no shadow is set. OpenAPI regenerated (response description already enumerated the new field).
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 347cf764a3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| let out = tokio::time::timeout(Duration::from_secs(5), fut) | ||
| .await | ||
| .map_err(|_| format!("`{command} ...--describe` timed out after 5s"))? | ||
| .map_err(|e| format!("spawn failed: {e}"))?; |
There was a problem hiding this comment.
Kill timed-out describe subprocesses
When an adapter hangs before producing --describe output, this timeout returns after 5 seconds but the spawned process was not configured with kill_on_drop(true) or explicitly killed, so cancelling the Command::output() future can leave the Python sidecar running after daemon boot continues. This affects the exact hang case described in the comment above and can accumulate orphaned adapter processes across restarts; set kill-on-drop or manage the child handle so timeout cleanup terminates it.
Useful? React with 👍 / 👎.
Add zh/en translations for keys introduced by feat/sidecar-channel-configure (PR #5252). Without these the zh locale fell back to the English defaultValue, so zh users saw English where they should have seen Chinese. New keys (both locales): - channels.saved - channels.saved_restart_required - channels.shadowed_secrets_warning (refactored to use {{keys}} interpolation so the whole user-visible string lives in the translation file, including the "Warning:" prefix — addressed the "leave-it-as-English-prefix" question in the brief by translating the prefix too: zh = "警告:…") - common.show_advanced / common.hide_advanced Existing keys reused (no new entry, defaultValue retained as a build-time sanity fallback to match the surrounding ChannelsPage convention): common.close, common.cancel, common.save, common.saving, common.error. config.saved_restart was NOT reused — it lives in a different namespace (config.*), and the channels save toast intentionally owns its own channels.saved_restart_required string. Widened the t-prop signature on SidecarForm to also accept `keys?: string` so the i18next interpolation typechecks. Verified: - pnpm typecheck (clean) - pnpm test --run (68 files, 651 tests pass) - pnpm build (clean)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 5d9202eadd
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| super::sidecar_toml::upsert_sidecar_block( | ||
| &config_path, |
There was a problem hiding this comment.
Preserve included sidecar entries on save
When a deployment keeps [[sidecar_channels]] in a file referenced by include = [...], this call writes a new root-level sidecar_channels array into config.toml. The config include resolver deep-merges tables but replaces arrays (deep_merge_toml in crates/librefang-kernel/src/config.rs), so on the subsequent reload the newly written root array shadows the included array and any pre-existing sidecars from the include disappear. Preserve the effective sidecar list or update the file that owns the existing array before hot-reloading.
Useful? React with 👍 / 👎.
| .and_then(|i| i.as_str()) | ||
| .unwrap_or(""); | ||
| if existing_name == name { | ||
| *aot.get_mut(i).expect("indexed") = block.clone(); |
There was a problem hiding this comment.
Preserve existing sidecar supervisor settings
When this helper updates an already-declared sidecar with the same name, it replaces the entire table with a minimal block containing only name, channel_type, command, args, and env. That drops valid SidecarChannelConfig tuning such as restart = false, retry/backoff limits, ready_timeout_secs, message_buffer, or overflow; the immediate hot-reload then restarts the adapter with defaults instead of the operator's existing settings. Carry forward non-form fields while replacing only the schema-managed env values.
Useful? React with 👍 / 👎.
…take effect (codex review)
…cars (codex review)
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4eafd78d11
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if plan | ||
| .hot_actions | ||
| .contains(&librefang_kernel::config_reload::HotAction::ReloadChannels) | ||
| { | ||
| if let Err(e) = crate::channel_bridge::reload_channels_from_disk(&state).await { |
There was a problem hiding this comment.
Reload sidecars after secret-only saves
When an existing sidecar entry is already present and the request only rotates a secret field, upsert_sidecar_block rewrites the same sidecar_channels config, so reload_config() can return no ReloadChannels action. Because the bridge reload is gated on that action here, the newly written secrets.env value is not loaded/restarted into the child process even though the endpoint returns status: saved; trigger a channel reload whenever a secret was written, or include secret-file changes in the reload decision.
Useful? React with 👍 / 👎.
Move the `secrets.env` membership read and the `shadowed_secrets` computation inside the `config_write_lock` block in `configure_sidecar_channel`. Previously the read happened before the lock was acquired, so two concurrent saves on different keys could each see the pre-write file state and falsely report a shadow on a key the other handler was about to write — cosmetic-only since the writes are still serialised by the lock, but trivially closed by reading under the same lock that gates the writes. No behaviour change on the single-writer path.
Operators sometimes hand-edit `[[sidecar_channels]]` to point `command`
at a venv-pinned interpreter (`/opt/venv/bin/python`) or add extra
`args` (`--debug`). Saving from the dashboard ships the static
SIDECAR_CATALOG defaults (`python3` + module-load args); the previous
schema-managed write reverted those edits on every save.
Split the upsert helper into:
- `write_command_and_args_defaults` — catalog defaults, written on
**insert** and as a backfill when the existing block has neither
`command` nor `args` (hand-written stub case).
- `write_form_managed` — the keys the dashboard form actually owns
(`name`, `channel_type`, `env`), always written. `env` is still
replaced wholesale (form is source of truth at save time).
`restart`, `restart_*`, `ready_timeout_secs`, `shutdown_grace_secs`,
`message_buffer`, `overflow` (the codex review preserve set) keep
their existing semantics — untouched.
Tests:
- `preserves_operator_custom_command_and_args_on_replace` — venv
command + `--debug` arg survive a save with catalog defaults.
- `backfills_command_and_args_when_existing_block_is_a_stub` — a
block hand-written without `command`/`args` still gets the
catalog defaults so the kernel can spawn it.
Existing tests (`replaces_existing_block_with_same_name`,
`preserves_operator_tuned_fields_on_replace`,
`does_not_touch_other_sidecar_blocks`) cover the cases where the
existing `command`/`args` already match the catalog defaults — they
keep passing because the preserve path is a no-op there.
`SidecarSchemaField.options` is rendered into the sidecar discovery
row's `fields[]` payload but the SidecarForm only knew about `secret`
and free-text inputs — a sidecar that declares
`{type: "select", options: [...]}` would render as a plain text
input, defeating the discrete-choice intent.
Switch to the existing `ui/Select` component (already used by
`SchemaForm` for the same `string + options` pattern) when
`f.type === "select"` and `f.options` is non-empty.
No first-party adapter currently emits `select`, so this is
forward-compatible plumbing rather than a user-visible regression
fix.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 15a8f84b9e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| if std::env::var(&k).is_err() { | ||
| merged.insert(k, v); |
There was a problem hiding this comment.
Do not let boot-loaded secrets mask rotations
When a secret key was already present in secrets.env at daemon start, load_dotenv() has copied that old value into the process environment. After the dashboard writes a new token and a channel reload respawns the sidecar, this check skips the updated file entry because std::env::var(&k) is still set, so the child inherits the stale process value instead of the just-saved secret. This affects normal token rotation for existing Telegram/ntfy sidecars; distinguish true shell/vault shadows from values originally loaded from secrets.env, or otherwise ensure the updated file value is applied to the child.
Useful? React with 👍 / 👎.
| let include_arr = match doc.get("include").and_then(|i| i.as_array()) { | ||
| Some(a) => a, | ||
| None => return Vec::new(), | ||
| }; |
There was a problem hiding this comment.
Recurse through included configs before saving sidecars
Fresh evidence: this helper only inspects the root include array, but the kernel config loader resolves includes recursively. If config.toml includes base.toml and base.toml includes sidecars.toml with [[sidecar_channels]], this check returns empty and the endpoint writes a root-level sidecar array; on reload, the root array overrides the nested included array, so the existing sidecars disappear. Walk the include graph the same way as the loader before deciding it is safe to write to the root file.
Useful? React with 👍 / 👎.
The Quality CI job on PR #5253 failed at `cargo xtask fmt` against 8 files under `crates/librefang-api/src/routes/`, `crates/librefang-api/tests/`, and `crates/librefang-channels/src/`, all merged in from the sidecar work on main (#5249/#5250/#5252/#5263). None of these files are touched by this PR's logic — the drift sits on main and any later dashboard-only PR rebased onto current main would hit the same gate. Mechanical rustfmt application: - `routes/channels.rs`: alphabetize `use super::sidecar_describe` before `use super::skills`; collapse the `secrets_env_keys` and `upsert_secret(...).map_err(...)` chains to rustfmt's preferred break style. - `routes/secrets_env.rs`: collapse the `parent.join(format!(...))` to a single line that fits in 100 cols. - `routes/sidecar_describe.rs` & `channels/src/sidecar.rs`: collapse two-arg fn signatures (`describe_sidecar`, `build_spawn_env`, `SidecarAdapter::new`) onto one line. - `tests/channels_routes_test.rs`: wrap the inline `vec![X { ... }]` literal across multiple lines so each field reads at indentation. - `tests/sidecar_describe_test.rs`: collapse the `describe_sidecar(...)` call to one line. - `channels/src/sidecar.rs`: split the long `got.get(...).map(|s| s.as_str())` chain across two lines per rustfmt's chain_width heuristic. Verification: - Each diff applied byte-for-byte from the CI log `gh api repos/.../actions/jobs/76681052747/logs`. cargo not available locally; relying on CI to confirm. - No behavioural change. Refs #5199, #5253
The Quality CI job (cargo xtask fmt -> cargo fmt --all --check) was red on this PR because the sidecar files merged in from main via #5252 (3d1e837) carried fmt drift — wrapped multi-line function signatures rustfmt wants flattened to a single line, and a use-import ordering tweak in channels.rs. The drift came in with the merge, not with our scheduler changes, but CI runs against the branch tip so the red shows up against our PR. Fix by running `cargo fmt -p librefang-channels -p librefang-api`. Pure mechanical rewrap — no behaviour change.
… to live connection (#5199) (#5253) * fix(dashboard/chat): smooth auto-pin of agentId-only sessions; explicit "Unpinned" label (#5199) PR #5211 added URL auto-pinning after the first message of an unpinned `?agentId=` chat, but reused `handleBackendNewSession` which is wired for `/new` semantics — it bumps `sessionVersion` to force a full reload. The cascade was visible: data.session_id arrives → handleBackendNewSession → navigate(?sessionId=...) → useChatMessages.sessionId changes → load effect re-runs with sessionVersion bumped → deleteCachedChatMessages + setMessages([]) → fetchQuery(agentQueries.session) refetch + flicker. B fix: split the callback. New `handleAutoPinSession`: - navigates with `replace: true` (no Back-button trail through bare `?agentId=` URLs) - does NOT bump sessionVersion (the messages we just rendered belong to this session; bumping would wipe them) - invalidates only the per-agent sessions list so a freshly-created session surfaces in the dropdown Before navigating, the WS response handler seeds the chat-message cache under the new `(agentId, sessionId)` key from the just-applied response patch. The post-navigation load effect then takes a cache hit on sessionVersion=0 and renders instantly — no flicker, no refetch. The response patch is reified as a pure mapper so the cache snapshot matches what `setMessages` enqueues (messagesRef hasn't picked up the update yet at handler time). Auto-pin is also gated on `sendAgentId === currentAgentRef.current` so a late WS response for an agent the user has already navigated away from doesn't rewrite the URL off the agent they're now viewing. C fix: replace the generic "Session" placeholder in the dropdown label with an explicit "Unpinned" hint (new i18n keys en/zh) when `activeSessionId` is undefined. PR #5211 already prevents the misleading highlight from appearing on a row, but the label was ambiguous — "Unpinned" surfaces the ambiguity directly so users know messages are riding the canonical pointer rather than a chosen session. Backend issue #5198 (canonical pointer context loss) is intentionally out of scope here. Closes #5199 * fix(dashboard/chat): guard auto-pin against user mid-flight session swap; test dropdown label contract (#5199) Review follow-ups to PR #5253: - Tighten the WS-response auto-pin gate to also require `currentSessionRef.current === null`. The existing gate only checked `sendAgentId === currentAgentRef.current`, so if the user manually pinned a session by clicking another row in the dropdown between send and response, an already-queued WS frame on the about-to-detach listener could overwrite the user's deliberate pin. `ws.close()` is async — a frame queued in the browser can still reach the listener before close takes effect — so the WS swap-out alone is not enough to drop in-flight responses. Skipping the auto-pin in that case preserves the worse-case behaviour from before #5253 (URL stays unpinned for that one turn) instead of regressing into "URL silently rewrote off the session I just selected". - Extract the dropdown label-picking logic into a pure `pickSessionDropdownLabel` helper alongside `deriveDropdownActiveSessionId`. The label branch was inline JSX in a 2.5k-line file; with the helper extracted the contract (active row label > short id prefix > null) is unit-testable and a future refactor that drops the short-id fallback or the unpinned guard will be caught by the tests rather than landing silently. - 6 new vitest cases for `pickSessionDropdownLabel` covering the unpinned case (returns null so the caller renders the localized "Unpinned" hint), label hit, short-id fallback when the session hasn't surfaced in the list yet, defensive empty-id handling, and non-truncation of human labels. Verification: - pnpm typecheck: clean - pnpm test --run src/lib/sessionSelector.test.ts: 15/15 pass - pnpm test --run: 649/662 pass; the 13 failures are pre-existing in `ModelsPage.test.tsx` on main and untouched by this change (same baseline noted in PR #5253's body). - pnpm build: clean (ChatPage chunk 83.40 kB / 22.31 kB gz, unchanged from prior commit). - node scripts/i18n-parity.mjs: clean. Refs #5199 * style(channels): apply cargo fmt to sidecar + channel routes The Quality CI job on PR #5253 failed at `cargo xtask fmt` against 8 files under `crates/librefang-api/src/routes/`, `crates/librefang-api/tests/`, and `crates/librefang-channels/src/`, all merged in from the sidecar work on main (#5249/#5250/#5252/#5263). None of these files are touched by this PR's logic — the drift sits on main and any later dashboard-only PR rebased onto current main would hit the same gate. Mechanical rustfmt application: - `routes/channels.rs`: alphabetize `use super::sidecar_describe` before `use super::skills`; collapse the `secrets_env_keys` and `upsert_secret(...).map_err(...)` chains to rustfmt's preferred break style. - `routes/secrets_env.rs`: collapse the `parent.join(format!(...))` to a single line that fits in 100 cols. - `routes/sidecar_describe.rs` & `channels/src/sidecar.rs`: collapse two-arg fn signatures (`describe_sidecar`, `build_spawn_env`, `SidecarAdapter::new`) onto one line. - `tests/channels_routes_test.rs`: wrap the inline `vec![X { ... }]` literal across multiple lines so each field reads at indentation. - `tests/sidecar_describe_test.rs`: collapse the `describe_sidecar(...)` call to one line. - `channels/src/sidecar.rs`: split the long `got.get(...).map(|s| s.as_str())` chain across two lines per rustfmt's chain_width heuristic. Verification: - Each diff applied byte-for-byte from the CI log `gh api repos/.../actions/jobs/76681052747/logs`. cargo not available locally; relying on CI to confirm. - No behavioural change. Refs #5199, #5253 * fix(api/chat): pin HTTP fallback turns too — close Codex P2 on #5253 PR #5253 added URL auto-pinning for unpinned `?agentId=` chats, but only on the WS `response` event path. When `wsConnected` is false on first send, or the WS drops and `sendViaHttp` handles the turn, the HTTP fallback updates the assistant message but never calls `onAutoPinSession` — and the wire shape had no `session_id` body field anyway. So a bare `?agentId=` chat that took the HTTP path stayed unpinned even though the kernel persisted to a concrete session, leaving the user bookmarkable into a different canonical session after a daemon restart. That is the exact regression #5253 was meant to close, just for the second transport. Backend (preferred over a follow-up GET because the WS path already includes the field — adding it here is the natural symmetry, not a new surface): - `MessageResponse` gains `pub session_id: Option<String>` with `#[serde(default, skip_serializing_if = "Option::is_none")]`. Field is omitted entirely on the wire when None — matches the WS handler which only emits `session_id` when `explicit_session.is_none()`. - `routes/agents.rs::send_message` populates `session_id` from the post-turn registry entry only when `session_id_override.is_none()` — exact mirror of the WS branch in `ws.rs:1363`. When the caller explicitly pinned a session, the field is omitted so we never imply a server-side auto-resolution that did not happen. - `routes/skills.rs::hand_send_message` passes `session_id: None` (hands do not surface an auto-pinnable session id via this body; #5199 is dashboard-chat-only). - Two new serde-level tests in `types.rs::tests` lock the wire contract: `session_id` is omitted (not `null`) when None, and serializes as a bare string when present. A regression that flips the field to `null` would silently break the client's `typeof === "string"` check. Frontend: - New shared gate `shouldAutoPinResolvedSession` in `src/lib/sessionSelector.ts` — pure type-predicate function that both transports call. Five guards: same agent, no manual pin in flight, URL was unpinned, `resolvedSessionId` is a non-empty string. Extracted so the WS and HTTP paths cannot drift apart in a future refactor. - `ChatPage.tsx::sendViaHttp` now reifies its response-patch mapper (same pattern as the WS handler) and calls `shouldAutoPinResolvedSession` + `onAutoPinSession` exactly like the WS branch. Same cache-seed-before-navigate trick to avoid the post-nav flicker described in #5199-B. - WS handler refactored to call the same gate — round-1 had this as an inline 4-guard block; folding both paths through the helper preserves the round-1 invariant verbatim (verified against the invariants partner listed: deep-link, second-message-on-unpinned, back-button, reconnect — see the helper's docstring). - `AgentMessageResponse` TS type gains optional `session_id?: string`. - 8 new vitest cases for `shouldAutoPinResolvedSession` covering: happy path, agent-swap mid-flight (Codex P1), manual pin mid-flight (round-1), already-pinned request, omitted/`null`/empty `session_id`, non-string `session_id` (defensive), and the type predicate narrowing contract. Generated artifacts: - `openapi.json` updated by hand to add the `session_id` property in alphabetical position (utoipa's stable ordering). The OpenAPI Drift CI job will overwrite from the struct via `cargo xtask codegen --openapi`; if my edit differs from the regen the auto-commit branch handles it transparently for internal PRs. - `xtask/baselines/openapi.sha256` updated to match the hand-edited spec (`shasum -a 256 openapi.json`). - SDKs not regenerated locally — rustfmt unavailable, and the JS/Go/ Python generators don't model the response shape (no diff). CI's `python3 scripts/codegen-sdks.py` step regenerates and auto-commits if drift remains. Verification: - `pnpm typecheck`: clean. - `pnpm test --run src/lib/sessionSelector.test.ts`: 24/24 pass (was 15/15 after round-1, +9 for the new gate). - `pnpm test --run`: 658/671 pass; the 13 failures are the pre-existing `ModelsPage.test.tsx` baseline noted in #5253's body and unrelated to this change. - `pnpm build`: clean. ChatPage chunk 83.65 kB / 22.36 kB gz (was 83.40 kB / 22.31 kB — +0.25 kB for the new gate + shared HTTP fallback code path). - cargo not available locally; CI is the gate for `cargo check --workspace --lib`, `cargo clippy --workspace --all-targets -- -D warnings`, and the two new types.rs serde tests. Refs #5199, #5253 --------- Co-authored-by: vip <[email protected]>
…) (#5254) * fix(dashboard): cron picker click no longer closes schedule form (#5247) ScheduleModal previously used DrawerPanel, which pushes its body into a single global push-slot consumed by PushDrawer. When the picker was opened from inside another DrawerPanel (SchedulerPage create-schedule form, HandsPage hand-detail panel), both DrawerPanels competed for the slot — but as soon as the inner DrawerPanel claimed it, PushDrawer stopped rendering the outer body, which unmounted the inner DrawerPanel and tore the slot back down. Visible symptom: the user clicked the cron chip, the create-schedule form vanished, no picker appeared. Switch ScheduleModal to a Modal (fixed overlay, panel-right variant) with the same size as before (3xl ≈ 768px, matched the previous DrawerPanel xl=720px). The picker now renders independently of the push-slot and can safely appear over any DrawerPanel without collateral-closing it. Also tighten the CanvasPage Schedule button's disabled tooltip to explain why it doesn't open while the workflow is unsaved — addresses the third symptom in the issue ("Workflow button doesn't open the window at all"): the button was correctly disabled but the tooltip just said "Scheduler" so the disabled state looked like a broken button. - crates/librefang-api/dashboard/src/components/ui/ScheduleModal.tsx - crates/librefang-api/dashboard/src/pages/CanvasPage.tsx - crates/librefang-api/dashboard/src/pages/SchedulerPage.test.tsx (regression test: opens cron picker, asserts the create-form body stays mounted; fails on baseline, passes with the fix) Verification: - pnpm typecheck: green - pnpm build: green - pnpm test --run src/components/ui/DrawerPanel.test.tsx src/components/ui/PushDrawer.test.tsx src/pages/SchedulerPage.test.tsx src/pages/HandsPage.test.tsx src/pages/WorkflowsPage.test.tsx — 42/42 pass - Pre-existing test infra issue in ModelsPage.test.tsx (Zustand persist storage.setItem in jsdom) is unrelated — fails on baseline too. Closes #5247 * fix(dashboard): keep cron picker above PushDrawer mobile overlay + i18n key (#5247 follow-ups) Review follow-ups for #5254: - Modal default zIndex is 50; PushDrawer mobile overlay is z-[55]. After switching ScheduleModal from DrawerPanel to Modal in #5254, opening the cron picker from inside a drawer on <lg viewports rendered the picker BEHIND the drawer overlay, invisible to phone users. Pass zIndex={70} on the panel-right Modal — above the mobile drawer (55) and OfflineBanner (60), still below notification dropdowns (90/100) and ConfirmDialog (150). On lg+ the host drawer is a flex <aside>, so any z >= 50 works; no desktop change. - Add the new "canvas.save_before_schedule" key to en.json and zh.json per project convention. The PR was relying on the defaultValue fallback, which left the disabled-Schedule-button tooltip un-localized for Chinese users. - Extend the #5247 regression test to also assert the picker's outer z-index is >= 56 so the mobile rendering bug above can't silently come back. Verified the assertion fails when zIndex is omitted. * fix(dashboard): keep parent drawer open when Esc fires inside nested picker (#5247 Codex P2) PushDrawer's window-level Escape handler was treating the drawer-root as the *outermost* role=dialog ancestor and ignoring the fact that nested dialogs (the cron picker's Modal, ConfirmDialog, etc.) might sit between the focus target and the drawer-root. On <lg viewports the drawer-root *is* a role=dialog, so the previous check — target.closest("[role='dialog']") && !target.closest("[role='dialog'][data-drawer-root]") — matched the drawer-root for the second clause whenever the picker was open inside the drawer, the guard returned false, and the handler called triggerClose(). Esc from inside the cron picker tore the surrounding create-schedule drawer down before the Modal could dismiss itself. Narrow the check to the *nearest* dialog ancestor: only close the drawer when target's nearest role=dialog is the drawer-root itself. Nested dialogs get first crack at Esc, as they should. Regression test in PushDrawer.test.tsx forces mobile via matchMedia spy, embeds a role=dialog inside the drawer body to simulate a nested Modal, scopes the picker-input lookup to the drawer-root subtree (PushDrawer renders content.body twice — once in the desktop aside, once in the mobile overlay — and the bug only exercises in the mobile copy), dispatches Esc at the nested input, and asserts the drawer store is still open. A positive control asserts plain-body Esc still dismisses the drawer. Verified the test fails on the old condition and passes on the new one (TDD discipline). * style: apply cargo fmt rescue for sidecar adapter files merged from main The Quality CI job (cargo xtask fmt -> cargo fmt --all --check) was red on this PR because the sidecar files merged in from main via #5252 (3d1e837) carried fmt drift — wrapped multi-line function signatures rustfmt wants flattened to a single line, and a use-import ordering tweak in channels.rs. The drift came in with the merge, not with our scheduler changes, but CI runs against the branch tip so the red shows up against our PR. Fix by running `cargo fmt -p librefang-channels -p librefang-api`. Pure mechanical rewrap — no behaviour change. * fix(deps): clear brace-expansion 5.0.5 audit advisory (GHSA-jxxr-4gwj-5jf2) The Security CI job (cargo xtask deps --audit --web) was failing on the dashboard pnpm-audit step: openapi-typescript > @redocly/openapi-core > minimatch resolved [email protected], which is in the GHSA-jxxr-4gwj-5jf2 vulnerable range (>= 5.0.0 < 5.0.6 — "Large numeric range defeats documented `max` DoS protection", moderate). Add a pnpm override for the v5 range, matching the pattern of the existing v2 override added in #4227 (`brace-expansion@>=2.0.0 <2.0.3: '>=2.0.3'`). [email protected] is a patch bump and ships in the same 5.x line as the resolved version, so no semver-major surface change. Lockfile delta is intentionally minimal — `pnpm update brace-expansion --recursive --lockfile-only --depth 999` only touches the brace-expansion resolution and the one `[email protected]` consumer that depends on it, leaving the rest of the lockfile untouched. Audit clean after the change (`pnpm audit` returns "No known vulnerabilities found"). --------- Co-authored-by: vip <[email protected]>
Summary
After #5249 / #5250 the dashboard could discover telegram and ntfy sidecar adapters but had no save button — operators still had to hand-edit
~/.librefang/config.toml. This PR closes that gap end-to-end:python -m <module> --describereturning a JSON schema (name,display_name,description,fields[]). A newrun_stdio_main(adapter_class)entry point describes the class without instantiating it, so adapters whose__init__requires env vars (telegram →TELEGRAM_BOT_TOKEN, ntfy →NTFY_TOPIC) still describe correctly when daemon spawns them with empty env.--describefor every entry inSIDECAR_CATALOG, caches the result with a 5s per-adapter timeout, and surfacesfields[]on/api/channelsdiscovery rows. SDK-absent dev environments fall back to emptyfields[](logged at WARN) — dashboard renders the card without form fields rather than failing boot.POST /api/channels/sidecar/{name}/configureaccepts{ values: {key: value} }. It validates required fields against the cached schema, then splits the payload: everytype=secretfield goes to~/.librefang/secrets.env(mode 0600, atomic write+rename); every other field plus the[[sidecar_channels]]boilerplate (name / channel_type / command / args / env) goes to~/.librefang/config.tomlviatoml_edit(preserves comments + sibling blocks). Response never echoes the request payload.config_reload.rsnow diffssidecar_channelsand reuses the existingHotAction::ReloadChannelspath. The save handler explicitly follows upkernel.reload_config()withchannel_bridge::reload_channels_from_disk(&state)— without that follow-up the kernel clearsmesh.channel_adaptersbut the bridge cycle never re-runs, so the response claimsReloadChannelsapplied while no sidecar is actually spawned.category === "sidecar"row now opens a realSidecarForm(schema-driven inputs, password type for secrets, Show Advanced toggle) instead of the read-only details modal. Save → toast →channelKeys.allinvalidated → configured row appears.Reused infrastructure (kept this PR small)
crates/librefang-extensions/src/dotenv.rsalready loads~/.librefang/secrets.envintostd::envat daemon startup; sidecar children inherit it through normal env inheritance. No new env-passing code needed.HotAction::ReloadChannelsalready callsmesh.channel_adapters.clear()inconfig_reload_ops.rs:246-256; we only had to add the diff trigger and the handler-sidereload_channels_from_diskfollow-up.toml_edit = "0.25"already incrates/librefang-api/Cargo.toml.Tests
tests/test_describe_schema.pytests/test_describe_main.pydescribe_main+run_stdio_mainclass-not-instance dispatch)tests/test_first_party_describe.pytests/sidecar_describe_test.rstests/secrets_env_test.rstests/sidecar_toml_test.rstests/channels_routes_test.rsconfigure_sidecar_channel(success splits values correctly, missing-required → 400, unknown name → 404). The success test assertschannel_adapters_ref().contains_key("telegram")post-save to catch any future regression of the broken bridge-re-init chain that the audit caught here; also asserts the response never echoes the secret value or includes avaluesfield (Plan Risk #3 regression).config_reload.rsReloadChannels)mutations/channels.test.tsxchannelKeys.all)Verification
All gates green on
HEAD:cargo check --workspace --lib --tests— clean (1m57s)cargo clippy --workspace --all-targets -- -D warnings— clean (1m49s)cargo test -p librefang-api --test channels_routes_test— 35/35cargo test -p librefang-api --test sidecar_describe_test— 2/2cargo test -p librefang-api --test sidecar_toml_test— 3/3cargo test -p librefang-api --test secrets_env_test— 4/4cargo test -p librefang-kernel --lib config_reload— 35/35pytest sdk/python/tests/— 75/75pnpm typecheck— cleanpnpm test --run— 651/651 (68 files)pnpm build— cleanOut-of-scope follow-ups (intentionally deferred)
describe_sidecarat boot — currently serial, 5s × 2 = 10s worst case. Plan-Risk Bump docker/build-push-action from 6 to 7 #1: trip-wire atSIDECAR_CATALOG.len() > 5.secrets.envduplicate-key scrub —upsert_secretfirst-match-wins preserves any second occurrence of the same key. Loaders pick deterministically so behaviour is sound; cleanup is cosmetic.__test_seed_sidecar_schema_cacheis#[doc(hidden)] pubrather than#[cfg(any(test, feature = "test-utils"))]. Idiomatic Rust convention; tighten later if needed.Plan
Detailed implementation plan committed in
docs/plans/2026-05-19-sidecar-channel-configure.md(1881 lines). 15 commits across 6 phases; final code-review pass surfaced two flake-risk + leak-regression issues that were fixed in the last commit before this PR.Test plan
config.tomlwith no[[sidecar_channels]]: dashboard Add picker shows Telegram + ntfy with form fields driven by SDK--describeTELEGRAM_BOT_TOKEN(password input, required),ALLOWED_USERS+TELEGRAM_CLEAR_DONE_REACTIONunder Show Advanced~/.librefang/secrets.envcontainsTELEGRAM_BOT_TOKEN=...with mode 600;config.toml[[sidecar_channels]]exists withname = "telegram", NO token in the toml file