Every registered Pulse tool now carries a canonical invocation
descriptor (internal/agentcapabilities/invocation.go): static or
discriminator-based, classifying each invocation with a workflow kind
plus a mutation target (none / pulse_state / infrastructure). Mixed
descriptors must exactly cover their schema enum and registration
panics otherwise, so an unclassifiable tool cannot exist. Missing,
malformed, unknown, or fabricated discriminator values classify
fail-closed as infrastructure writes.
Provider projection and runtime enforcement consume the same
descriptor under one InvocationPolicy (control level plus the
request-local, non-serializable deny_infrastructure_mutations
restriction, isolated across executor clones): ListTools and
ListToolGovernance remove forbidden enum values, drop empty tools, and
recompute the offered action mode, while ToolRegistry.Execute blocks
forbidden invocations before the handler runs. This closes the mixed
tool control-level bypass, most seriously Docker action:update, which
previously fell through to direct execution at read-only, and fixes
the Kubernetes misclassification: the retired switch read the action
argument while the schema discriminator is type, so type:scale
classified as read.
pulse_file_edit is now write-only (append/write); file inspection
routes through pulse_read action=file, whose exec path keeps its
structural read-only execution-intent enforcement. ClassifyToolCall
consults the descriptor table first and retains only genuinely
non-registry compatibility cases. The deny restriction is deliberately
separate from autonomous mode, which only suppresses interactive
questions and grants no mutation authority.
Proofs: descriptor validation and fail-closed classification unit
tests, plus the invocation-policy regression suite (scale classifies
write and never invokes at read-only or under deny; Docker update
queues nothing at read-only; autonomous plus deny cannot mutate;
fabricated enum values fail at runtime; filtered projection and
runtime enforcement agree; executor clones keep request policies
isolated). Contracts and registry ownership updated for the new
shared invocation descriptor boundary.
Slice 3a of the typed-lifecycle ratchet; the patrol_investigation
execution profile and patrol_propose_action tool build on this
substrate next.
Ollama is the zero-cost AI path but the setup row offered only a Server
URL, and Patrol then failed on models that cannot emit tool_calls
(#1463, #847, #1425, #1152, #880). Bless qwen3:8b, the model family
Ollama's own tool-calling docs are written against, verified locally
against Patrol's real preflight: qwen3:8b emitted the tool call on
every run; qwen3:4b never did (0/4), so no low-RAM tag is suggested.
- Registry: SuggestedModel/Note/Equivalents on AIProviderDefinition,
projected on /api/settings/ai providers; Ollama default model goes
llama3.2 -> qwen3:8b.
- Provider row: copyable 'ollama pull qwen3:8b' block with hardware
note, and a next-step hint when a successful test resolves a model
outside the blessing set.
- Model resolution: exact-ID blessed preference, so pulling qwen3:8b
makes it the auto-resolved Patrol model with no manual selection.
- Readiness copy names the blessed model (its contract pins landed
with 94ccc7a0c's staging; this commit restores green).
- manual_ollama_preflight_test.go is the env-gated re-blessing
harness; contracts updated for ai-runtime, api-contracts,
frontend-primitives, and the agent-lifecycle/storage-recovery
dependent boundaries; subsystem_lookup_test line pins follow the
api-contracts.md insertion.
Exercised the full provider portal as a pilot MSP would and fixed what
made it feel broken:
- The signed-out portal promised "a sign-in link is on the way" even when
the control plane has no email provider (the bundle default), and team
invitations silently sent nothing. The portal bootstrap now carries
email_sign_in_available and provider_hosted_mode; the sign-in page shows
the host command that actually prints a link, and the invite panel says
invitation emails are not sent and how to hand over a link instead.
- New "provider-msp portal-link --email" CLI mints a one-time portal link
for an account member or pending invitee, so teammates can sign in at
all on email-less installs (bootstrap only covers the owner).
- Portal sessions were fixed at 12h; CP_SESSION_TTL now configures them
and provider-hosted MSP mode defaults to 7 days.
- Creating a client past the license cap showed a generic "Failed to
create workspace." toast: the limit error is now a JSON payload with
current/limit, the API client no longer drops non-JSON error bodies
(double body read), and the toast explains the license limit.
- Copy polish: provider-mode sign-in intro (no refunds/privacy register),
least-privilege default invite role, queue tile label matches "Client
onboarding", softer Support tab with a docs/MSP.md pointer, setup.sh
summary now prints the bootstrap next step and day-2 sign-in commands,
.env.example and docs/MSP.md document portal sign-in and sessions.
Contracts updated (cloud-paid, api-contracts, deployment-installability,
security-privacy) with verification pins in tenant_handlers_test,
config_test, magiclink_test, and provider_msp_deploy_test.
Verified live against a dockerless control plane: portal-link for an
invitee redeems, promotes the invitation, and sets a 7-day session;
the at-cap toast shows the license copy; portal vitest suite and
cloudcp/auth/account/installtests Go suites pass.
Planning, approval decisions, and execution for typed resource actions
move out of the HTTP handlers in internal/api/actions.go into a new
internal/actionlifecycle.Service owned by api-contracts. The REST
handlers become thin decode/actor/error-mapping adapters over the one
shared service, and ResourceHandlers.ActionLifecycle() exposes the same
service for in-process consumers, so a future Patrol action broker
inherits identical resource lookup, availability checks, plan hashing,
audit persistence, remediation locks, plan-drift revalidation,
execution, and terminal publication instead of loopback HTTP or a
parallel lifecycle.
Behavior is preserved: same status codes, error codes, and audit/
lifecycle persistence ordering, backed by the existing api contract
tests plus new fail-closed proofs for the service itself (unknown
resource/capability, availability refusal, unapproved execution,
remediation lock, plan drift, missing executor, missing store).
Contract text in api-contracts, agent-lifecycle, and storage-recovery
now names the service alongside actions.go and planner.go; the
subsystem registry owns internal/actionlifecycle/ under api-contracts
with a dedicated path policy; the code-standards and contract source
pins follow the moved invariants; and the subsystem_lookup line-number
pin shifts with the api-contracts canonical-files list insertion.
This is the first slice of making the typed action lifecycle the only
autonomous execution route for Patrol, Assistant, and MCP.
The only production consumer of the UpdaterRegistry is the plan endpoint
(GET /api/updates/plan); every real apply runs through the in-Go pipeline
in manager.go ApplyUpdate, with concurrency held by updateMu/updateInFlight.
The InstallShAdapter Execute/Rollback path (install.sh piping, rollback
binary download, health wait) and the UpdateQueue were never called outside
tests, so fixes made there silently did nothing. Adapters are now plan
providers only: the Updater interface keeps SupportsApply, PrepareUpdate,
and GetDeploymentType, pinned by the new registry proof in
internal/api/updates_test.go.
The orphaned fetchAndVerifyReleaseSignature helper goes with it; its
fail-closed sidecar tests now exercise the manager's
downloadAndVerifyReleaseSignature directly. The deployment-installability
sshsig invariant now names the manager pipeline as the only surface that
fetches release artifacts, and api-contracts, storage-recovery, and
agent-lifecycle pin the same plan-provider-only shape at their update
transport boundaries. Stale adapter references in build-release.sh,
validate-release.sh, and the installtests comments are updated to match.
Cover the two installer bugs fixed in c98acb9e8 so they cannot regress:
- TestRootInstallScriptSourceGuardSurvivesPipedExecution runs the source guard
through stdin the way curl ... | bash does and asserts it reaches the
installer body instead of aborting on BASH_SOURCE[0] unbound. (#1526)
- TestInstallSHAgentVersionMismatchIgnoresVPrefix drives the version check and
asserts v6.0.4 vs 6.0.4 does not warn while a real 6.0.3 vs 6.0.4 does, plus
content assertions pinning the normalization in scripts/install.sh. (#1527)
Refs #1526#1527
Two installer bugs reported after the v5 to v6 upgrades:
- The server installer (install.sh) guarded its "am I being sourced?" check
with a bare ${BASH_SOURCE[0]}. When piped to bash (curl ... | bash) there is
no source file, so under `set -u` the run aborted with "BASH_SOURCE[0]:
unbound variable" before the installer body. Default the lookup and only
early-return on a genuine source. Regression of the v5 fix in #1396. (#1526)
- The agent installer (scripts/install.sh) compared the downloaded binary's
version ("v6.0.4") against the server /api/version value ("6.0.4") verbatim,
raising a spurious mismatch warning on matching versions. Strip a leading "v"
from both sides before comparing so only a genuine difference warns. (#1527)
Refs #1526#1527
The in-app updater and the unattended timer both target the public
rcourtman/Pulse community assets, so a Pro install that used them was
silently downgraded to community. Guard 2 (983a89326) blocked in-app
apply on the Pro edition, which stopped the downgrade but left Pro
installs with no update path except manual portal downloads. That
friction is a plausible driver of the runtime split: as of 2026-07-08
only 10 of 66 active paid licenses have any Pro-runtime install.
Root fix: the compiled Pro binary now checks and applies updates
through the license server download broker (GET /v1/downloads/pulse-pro
with the installation token and instance fingerprint). The check
compares against the broker's pinned private release instead of GitHub,
respecting the stable/rc channel guard. Apply re-resolves fresh signed
R2 URLs at apply time, verifies the archive against the same pinned
pulse-installer SSHSIG key plus the broker manifest sha256, and refuses
GitHub-shaped download URLs outright. An unactivated Pro binary still
refuses with the portal fallback. The community edition path is
unchanged.
The update banner restores in-app apply for auto-updatable Pro
deployments and keeps the portal instructions for deployments the
updater cannot drive (Docker). scripts/pulse-auto-update.sh now skips
when the installed binary reports Pulse Pro so the unattended timer can
never reinstall community over Pro.
Note: internal/updates/pro_update.go and manager_pro_update_test.go for
this change landed one commit early inside 313552deb via a parallel
session committing a shared staged index; this commit completes the
wiring they belong to.
TestUpdateDemoWorkflowUsesGovernedNetworkPath still asserted the retired
authkey: TS_AUTHKEY line; 0a9a29d63 moved update-demo-server.yml to the
OAuth client (TS_OAUTH_CLIENT_ID / TS_OAUTH_SECRET, tag:infra), so the
test now pins those lines instead.
Commit 0a9a29d63 moved deploy-demo-server.yml and update-demo-server.yml
from the static TS_AUTHKEY to the Tailscale OAuth client but left the
release promotion policy pin asserting TS_AUTHKEY, so the Canonical
Governance run failed at the promotion policy unit tests once the
completion guard false positive (fixed in 54a6118d1) stopped masking
the step. The pin now asserts the OAuth client id and secret and pins
the static key retirement.
The guard judged substantive contract updates by diffing HEAD against
the index. In CI nothing is staged, the index equals HEAD, so every
contract file piped in via --files-from-stdin looked unchanged and the
guard blocked compliant pushes. Concretely, run 28944317805 blocked
7645965af even though its deployment-installability.md addition sits
inside the Current State section.
The guard now accepts --diff-base <ref> (requires --files-from-stdin),
resolves it to its merge base with HEAD so the comparison anchor
matches the three-dot changed-file list, and compares base vs HEAD
contract texts in that mode. Pre-commit keeps the index comparison.
The canonical-governance workflow passes the push or PR range base.
The weekly release-dry-run schedule failed at 'Resolve rehearsal
metadata' because GitHub does not apply workflow_dispatch input
defaults to schedule events, so rollback_version arrived empty and
resolve_release_promotion.py rejected the run.
Scheduled runs now pass --derive-rollback-latest-stable, which fills
an empty rollback_version with the latest stable tag preceding the
rehearsal version (currently v6.0.4 for 6.0.5-rc.3). Manual dispatches
keep the explicit rollback_version requirement; the stale prefilled
5.1.29 default is removed so operators state the target themselves.
The deployment-installability contract records the scoped scheduled
exception.
Refs #1515
The install command is generated under the Settings infrastructure
installer, not a "Settings > Agents" tab. Correct the agent log, installer
warning, and Machines tooltip to say "the Pulse UI" so the recovery step is
accurate.
Refs #1515
When an upgraded or restored Pulse server no longer recognises an agent's
API token, the report endpoint returns 401. The agent buffered and retried
that report forever with only a generic warning, and the server kept the
node green at its last known agent version because a Proxmox node stays
online via the PVE API poll even after its agent dies.
- Agent: special-case 401 on /api/agents/agent/report. Drop the report
instead of buffering it and log a throttled, actionable error pointing the
operator at the install command to mint a fresh token.
- Installer: verify_agent_server_registration now tells a rejected token
(401/403) apart from "agent has not reported yet" and prints the recovery
steps at install time instead of a vague soft warning.
- Status layer: resourceFromHost flags a stale agent (its host marked
offline by the staleness evaluator) and carries the agent's own last
report time. Coalescing keeps a node online via the PVE source but the
dead agent stays flagged, so its version is no longer presented as current.
The Machines table renders such versions as "(stale)".
contract_patch_has_substantive_change walked git diff --unified=1000
output and tracked the last ## header seen in the patch, so an edit
more than 1000 lines below its section header (e.g. deep inside
unified-resources.md's Current State) lost its section and was wrongly
reported as not substantive, forcing a contract-neutral bypass on
74131e56e.
Replace the patch walk with full-file attribution: diff the HEAD blob
against the staged blob and map every changed line to its section via
a per-line section map built from the complete file. Covers new
contract files (empty HEAD blob) and keeps metadata-only edits
non-substantive. Adds regression tests for edits >1000 lines below
their section header in both directions.