Adds pause/resume support for scheduled prompts.
Backend:
- New paused_at_unix column on scheduled_prompts table (added via
ALTER TABLE migration so existing databases are upgraded on first
start). The due-rows index is dropped and recreated to also exclude
paused rows so the worker never fires them while paused.
- Worker's due() query gains AND paused_at_unix IS NULL filter.
- New pause(id) and resume(id) methods on ScheduledPrompts; both are
idempotent and refuse cancelled rows.
- New POST /api/schedules/{id}/pause and /api/schedules/{id}/resume
dashboard endpoints (operator-direct, no approval gate). Both emit
a schedules snapshot on success so the tab updates live.
- WireSchedule gains paused_at_unix: Option<i64> so the frontend can
render the state without an extra fetch.
Frontend:
- Paused rows render with a distinct row class + muted opacity.
- The next-fire cell shows a yellow pause glyph + tooltip with the
paused-since timestamp and the would-have-fired time.
- Actions column: pause/resume toggle button (⏸/▶) beside fire/edit/cancel.
Fire-now is disabled while paused (resume first).
- Sort order: active → paused → cancelled (paused slot keeps schedules
visible without mixing them into the active top section).
- pauseSchedule() / resumeSchedule() async functions POST to the new
endpoints and refresh the table on success.
Per review: rather than adding forge_http_full (a near-duplicate of
forge_http), change forge_http itself to return (StatusCode, String).
Status-only callers bind (status, _); the branch-protection verify path
uses the body to log the real Forgejo rejection reason. Updates all call
sites accordingly.
apply_config_repo_branch_protection treated 200/409/422 from the
create-branch-protection POST all as success. But a 422 means Forgejo
*rejected* the request and created no rule — so a rejected POST silently
left the agent's config repo unprotected, with nothing logged (a new
agent's config repo was found with no main-branch protection and no
trace of why).
Don't trust the status code:
- On any non-201, GET the single .../branch_protections/main rule and
only treat it as success if the rule is actually present.
- Otherwise return Err carrying the POST's response body, so the real
Forgejo rejection reason lands in the host journal. (forge_http
discarded the body; added forge_http_full that returns it.)
ensure_config_repo runs on every sync_agent sweep (startup + each
rebuild), so a now-Err result is logged and retried next sweep —
self-healing once a real cause is fixed. Net: the failure is loud +
retried instead of silently swallowed.
nix fmt clean.
Both remove_agent() calls now run unconditionally for maximum partial
cleanup, but any I/O error is returned as HTTP 500 instead of silently
200-ing — so the frontend's !resp.ok path fires and the operator sees a
meaningful error rather than the stale row reappearing unchanged.
Also add a clarifying comment on isStale in permissions.js explaining
that containersState is keyed from nixos-container list (which includes
stopped-but-configured containers), so a temporarily-stopped agent is
not treated as stale — only destroyed/renamed agents are absent.
The P3RM1SS10NS tab showed agents that no longer exist in the live
container roster — e.g. an agent named 'root' that was renamed or
destroyed but still had explicit entries in tool-groups.json and/or
capabilities.json. The roster-union behaviour is intentional for
temporarily-stopped agents, but stale entries from renamed/destroyed
agents are confusing.
Backend (dashboard/permissions.rs):
- New DELETE /api/permissions/{agent} handler that bypasses the live-
roster guard (intentionally — that's the point). Calls
tool_groups::remove_agent + capabilities::remove_agent to clear both
JSON files, then emits live SSE snapshots so the tab updates without
a page reload. Format-checks the agent name but does not require it to
be in the containers snapshot.
Frontend (permissions.js):
- renderCapabilities / renderToolGroups now cross-reference agentNames
against containersState (the live roster, already imported). Agents
not in the live roster get an isStale flag.
- Stale rows get a '(not running)' label and a '✕ remove' button that
calls clearStaleAgent() — a new async helper that DELETEs the stale
entry and re-fetches both perm tables.
- Non-stale agents without explicit assignments still get '(default)'.
CSS (dashboard.css):
- .perm-row-stale (reduced opacity), .perm-stale-label (muted small
text), .perm-remove-btn (small red-bordered button) + disabled state.
The CSS for the rebuild-queue live-log panel targets .rebuild-live-log
(border, border-radius, margin-top, background) but the HTML element only
had id="rebuild-live-log" — no class. As a result the panel box styles
never applied and the live log rendered unstyled (no border, no background,
no visual separation from the queue rows).
Fix: add class="rebuild-live-log" to the element so the CSS selector
matches.
Both kinds fell through to the spawn branch in renderApprovals, showing
a misleading 'spawn' chip and agent-spawn body text. Mara saw a meta-input
bump render as a spawn card for agent damocles and denied it.
Backend (dashboard.rs):
- Add commit_ref: None to the MergeConfigPr arm (struct was incomplete).
All arms of ApprovalView now initialise every field.
Frontend (call.js):
- Add isUpdateMeta / isSchedule booleans alongside the existing kind flags.
- Glyph: update_meta_inputs gets ↻, schedule_prompt gets ⏱.
- Kind chip: 'meta-update' / 'schedule' (no kind-spawn class for either).
- Body: update_meta_inputs parses commit_ref as JSON Vec<String> and shows
'bump flake inputs: foo, bar' or 'bump all flake inputs'; schedule_prompt
parses SchedulePromptPayload and shows targets + first-fire time + cadence
+ a truncated body excerpt.
- History row: add 'meta-update' and 'schedule' cases (were both 'spawn').
- Import fmtDuration from util.js (needed for schedule cadence display).
Per mara (verified in forge.rs): agents have max_repo_creation=0, so every
internal-forge repo is core-created with branch protection on by default
(merge restricted to operators team + required operators-team approval via
apply_operator_branch_protection / the config-repo equivalent), and agents
(write collaborators, not admins) can't change it or self-merge. So it's
technically enforced there, not just convention — only external VCS (GitHub)
is unprotected. Corrects my prior over-correction.
Per mara: branch protection isn't a blanket enforced check, and it's not
set up for external VCS (GitHub). Reframe the bullet as the operator-merge
*convention* — technically enforced only on the core-managed config repos,
and explicitly NOT wired up for GitHub/external VCS (process + accepted
risk there, not a control).
The trust-model insert accidentally consumed the
'## State-file endpoint security model' heading, leaving that section
floating under the new one. Re-add the heading and collapse the extra
blank lines (argus review).
Add an 'Agent trust model' framing section at the top of docs/security.md
covering the conceptual model the existing mechanism-level sections serve:
- trust boundary = the container, not credential storage (agents have
passwordless sudo by default; isolating creds from the agent itself is
not a goal — cross-tenant leakage is)
- scoped per-agent forge/matrix tokens bound the blast radius
- threat model: prompt injection -> confused deputy (untrusted input is
the adversary; the agent + its tools are trusted)
- branch protection (agents push, operator merges) as the human-in-loop
checkpoint, incl. external VCS
- capability = accepted risk ('don't grant what you can't afford to lose')
- no auto-sandboxing of external tokens (operator-accepted scope)
Emerged from the 2026-06-24 security discussion. Updates the CLAUDE.md
pointer for findability.