diff --git a/.changeset/calm-driver-source.md b/.changeset/calm-driver-source.md
new file mode 100644
index 00000000..af622f9d
--- /dev/null
+++ b/.changeset/calm-driver-source.md
@@ -0,0 +1,7 @@
+---
+"ftw": minor
+---
+
+Enforce signed read-only driver permissions in the host and add a safe public driver feedback flow.
+
+Local unsigned drivers remain offline-first and take priority over managed and bundled files. FTW now marks that source in inventory, keeps managed cache state unchanged, and documents the exact test, reload and rollback steps.
diff --git a/README.md b/README.md
index d3e2c356..962256cd 100644
--- a/README.md
+++ b/README.md
@@ -41,9 +41,11 @@ rule. See [docs/architecture.md](docs/architecture.md).
- CalDAV planning intents and published schedules;
- hot-reloadable, independently released Lua drivers.
-The local catalog is generated from `DRIVER` metadata. Device Support owns
-canonical Sourceful driver versions and signed target artifacts; the
-[`drivers/*.lua`](drivers/) tree remains FTW's bundled, offline recovery set.
+The local catalog is generated from `DRIVER` metadata. The public
+[`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo is the
+editable source for shared drivers. Device Support builds and signs an exact
+public commit. The [`drivers/*.lua`](drivers/) tree remains FTW's bundled,
+offline recovery set.
## Install on Linux
@@ -110,9 +112,10 @@ Drivers are plain Lua files and need no compilation. A driver declares its
catalog metadata, lifecycle and required capabilities in one file. The Go host
provides capability-scoped Modbus, MQTT, HTTP, WebSocket and TCP access.
-Start with [docs/writing-a-driver.md](docs/writing-a-driver.md). Signed driver
-artifacts are published once by Device Support, selected for FTW's explicit
-GopherLua target, and can be installed or rolled back independently.
+Start with [docs/writing-a-driver.md](docs/writing-a-driver.md). Send shared
+driver changes to `srcfl/device-drivers`. Device Support signs approved target
+artifacts, and FTW installs them from `drivers.sourceful.energy`. A driver can
+update or roll back without a new FTW core release.
## Releases
diff --git a/docs/architecture.md b/docs/architecture.md
index b39baca2..e213f191 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -11,7 +11,7 @@ make dispatch unsafe.
| Module | Source | Runtime | Responsibility |
|---|---|---|---|
| Core | `go/cmd/ftw`, `go/internal`, `web` | One Go binary | Configuration, telemetry, state, API/UI, safety, control and fallback planning |
-| Drivers | Canonical packages in Device Support; bundled recovery in `drivers/*.lua`; host in `go/internal/drivers` | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands |
+| Drivers | Editable source in `srcfl/device-drivers`; bundled recovery in `drivers/*.lua`; host in `go/internal/drivers` | One sandboxed Lua VM per configured device | Vendor protocol, sign conversion and device commands |
| Optimizer | `optimizer`, contract in `go/internal/mpc` | Optional Python service/process | Solve the long-horizon mathematical plan |
Core can run without the optimizer. Hardware cannot be accessed without a
@@ -65,8 +65,12 @@ Planner output is an input to that loop, never a direct device command.
## Drivers
-Device Support owns canonical Sourceful driver source, SemVer, permissions,
-provenance and signed target artifacts. FTW selects only the `ftw-core` target.
+The public `srcfl/device-drivers` repo owns editable driver source, versions,
+package recipes, target adapters, contracts and tests. Private Device Support
+locks an exact public commit, builds it, signs immutable packages and publishes
+the indexes served by `drivers.sourceful.energy`. FTW never fetches executable
+code from GitHub. It selects only a signed `ftw-core` target.
+
Each Lua artifact still contains its own `DRIVER` metadata and implements the
FTW lifecycle. `go/internal/drivers/lua.go` is the source of truth for FTW's
host API and capability sandbox. Network and protocol capabilities must be
@@ -80,8 +84,8 @@ Drivers are the only hardware-specific layer. They must:
- avoid policy decisions that belong in core;
- remain independently testable and hot-editable.
-Bundled drivers provide the offline recovery set. A signed Device Support
-index is discovery only; FTW independently verifies the selected package and
+Bundled drivers provide the offline recovery set. A signed distribution index
+is discovery only; FTW independently verifies the selected package and
artifact, while activation remains explicit and atomic. See
[writing-a-driver.md](writing-a-driver.md) and
[device-repository.md](device-repository.md).
diff --git a/docs/device-repository.md b/docs/device-repository.md
index b4d84e4b..f23a8f13 100644
--- a/docs/device-repository.md
+++ b/docs/device-repository.md
@@ -1,10 +1,13 @@
-# Device Support driver packages
+# Driver source and signed packages
-`srcful-device-support` is the canonical owner of Sourceful driver source,
-SemVer, package metadata and signed target artifacts. FTW is a target consumer:
-it accepts only the exact `ftw-core` runtime profile and runs downloaded Lua in
-the same capability-scoped sandbox as bundled drivers. Core remains the
-activation and safety authority.
+`srcfl/device-drivers` is the only editable source for shared driver code,
+versions, package recipes, target adapters, contracts and tests. Private Device
+Support consumes an exact public commit, builds it and signs release data. It
+does not own a second editable driver tree. `drivers.sourceful.energy` is the
+runtime distribution point.
+
+FTW accepts only the exact `ftw-core` runtime profile. It never loads code from
+GitHub. Core remains the activation and safety authority.
## Resolution and recovery
@@ -44,7 +47,7 @@ driver is unavailable.
| Format | Role |
|---|---|
-| `sourceful.driver-index/v1` | Canonical signed Device Support discovery index and package envelopes |
+| `sourceful.driver-index/v1` | Signed discovery index and package envelopes from `drivers.sourceful.energy` |
| `ftw.manifest/v1` | Transitional FTW-owned manifest retained until production cutover |
Use the beta channel only on a chosen pilot site:
@@ -69,7 +72,10 @@ the same pinned key. Change that default only after stable promotion. A catalog
refresh never installs or activates a driver.
Device Support publishes `beta` packages and promotes the exact reviewed
-version and target hashes to `stable`. There is no edge driver channel.
+version to `stable` without rebuilding the artifact. Package v1 signs the
+channel inside its payload, so stable has a new envelope and signature. The
+artifact bytes, hashes, URLs, public source commit, materials and provenance
+must stay the same. There is no edge driver channel.
The Device Support index may reference several immutable versions. FTW adapts
the newest compatible version into the Update Center and keeps older compatible
@@ -90,22 +96,29 @@ host/runtime compatibility and immutable target artifacts. Core verifies:
- HTTPS URLs, safe paths, exact artifact size and SHA-256;
- Lua lifecycle and matching id/version/host API/`read_only` metadata.
-Remote Lua is never executed directly from a URL. Unsigned and insecure sources
-remain limited to the legacy local-development format. Sourceful index/package
-envelopes are always signed. Cached indexes and packages are reverified before
-offline discovery.
+Remote Lua is never executed directly from a URL. Local unsigned drivers need
+an explicit operator overlay and never claim managed or signed status.
+Sourceful index/package envelopes are always signed. Cached indexes and
+packages are reverified before offline discovery. A signed read-only package
+also gets a host policy that denies write calls during init, poll, command,
+default mode and cleanup.
+
+The live beta path accepts read-only packages only. Packages with commands,
+control capabilities, write permissions or control-enabled FTW targets stay
+off. Control also needs per-driver process or heap isolation, default-mode and
+lease proof, structured command results, readback, and physical HIL.
-Phase 1 accepts read-only packages only. Packages with commands, control
-capabilities, write permissions or control-enabled FTW targets fail closed.
-That gate is removed only after default-mode invocation, bounded lease expiry,
-structured command results and physical control HIL have explicit acceptance.
+Before stable control, the trust policy must add key overlap and rotation,
+emergency deny lists for keys, packages, versions and artifact hashes, and a
+monotonic index policy. An offline host may keep its last verified artifact,
+but an old or expired index must not offer a new install or activation.
## Driver versions
-Each driver declares public runtime metadata in its `DRIVER` table, while
-Device Support owns the canonical package version. Executable or public
-metadata changes require a higher package SemVer. FTW refuses artifacts whose
-Lua id/version/read-only contract differs from the signed package.
+Each driver declares public runtime metadata in its `DRIVER` table. The public
+repo owns the package version. Executable or public metadata changes require a
+higher package SemVer. FTW refuses artifacts whose Lua id, version or
+read-only value differs from the signed package.
```bash
cd go
@@ -114,7 +127,7 @@ go run ./cmd/ftw-driver-repository check-versions \
```
`go/cmd/ftw-driver-repository` and `.github/workflows/drivers-release.yml` are
-legacy migration tooling, not the future canonical publisher. They remain until
-the Device Support stable endpoint, trust-root provisioning and recovery
-cutover have been exercised. New shared driver releases happen in Device
-Support once, not in FTW.
+old migration tools, not the shared source or future publisher. New source
+changes start in `srcfl/device-drivers`. Device Support releases only an exact,
+reviewed public commit. A public commit pin in FTW may serve as a contract test;
+it must not make a core release necessary for a driver update.
diff --git a/docs/sungrow-observe-pilot.md b/docs/sungrow-observe-pilot.md
new file mode 100644
index 00000000..fcf691a9
--- /dev/null
+++ b/docs/sungrow-observe-pilot.md
@@ -0,0 +1,40 @@
+# Sungrow observe-only pilot
+
+Do not activate Sungrow 1.3.1 on a live site. It can write even when its target
+has `control_enabled=false`. The first site package is 1.3.2 or later and must
+stay read-only: `read_only=true`, only `modbus.read`, no commands and no write
+calls.
+
+The public target currently blocks every model and firmware pair. Before a
+site test, record and review:
+
+- exact inverter model and family: SG-CX, SG-RT, SH-RS, SH-RT or SH-T;
+- firmware revision and the matching public register-map revision;
+- LAN connection type, port, and Modbus device ID;
+- whether a battery and external site meter are present.
+
+Do not put an IP address, serial number, credential or site ID in GitHub issues
+or fleet inventory.
+
+Test the local unsigned target first with **Test connection** or
+`POST /api/drivers/test`. A Lua file change needs a new test. An active driver
+needs a restart or a real config change that restarts the registry entry.
+
+Compare these read-only values with the inverter UI or app and the site meter:
+
+- PV power and MPPT power;
+- battery power sign and size, battery voltage, current and SoC;
+- grid import/export sign and total power;
+- L1, L2 and L3 power, voltage and current;
+- inverter temperature, grid frequency and running state.
+
+Stop if the model or firmware does not match an approved public profile, any
+sign differs, values are stale, or FTW attempts a write. After a local pass,
+the release proof must bind the exact public commit and tree to the private
+lock, artifact hash, signed beta index, FTW install and activation, rollback,
+and Nova inventory. Keep the bundled Sungrow driver as recovery until the
+signed target has parity and physical HIL evidence.
+
+Control stays off until FTW has per-driver process or heap isolation, host
+write denial, leases, safe default mode, command results and readback, and the
+exact Sungrow model has passed physical HIL.
diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md
index 10681549..d21b0fad 100644
--- a/docs/writing-a-driver.md
+++ b/docs/writing-a-driver.md
@@ -4,11 +4,12 @@ Drivers are the hardware boundary. A driver translates one vendor protocol to
FTW's site convention and runs in its own capability-scoped Lua 5.1 VM. No Go
build is needed.
-For a Sourceful-supported device, create or change the canonical source and
-package metadata in `srcful-device-support`. Start from David's hardware-near
-Blixt implementation when it is the best protocol reference, then prove an
-explicit `ftw-core` target against this host. The `drivers/` tree here is the
-bundled FTW recovery snapshot; operator-only drivers may still live locally.
+For a shared device, create or change the source and package recipe in the
+public [`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo.
+Prove an explicit `ftw-core` target against this host. Private Device Support
+builds and signs an exact reviewed public commit. The `drivers/` tree here is
+the bundled FTW recovery snapshot; operator-only drivers may still live
+locally.
## Metadata
@@ -32,9 +33,9 @@ DRIVER = {
```
The `DRIVER` table feeds target validation and FTW's in-app catalog. Its id,
-version, host API and `read_only` value must agree with the signed Device
-Support package. Do not duplicate the catalog in Markdown. Executable or
-public metadata changes require one canonical package version bump.
+version, host API and `read_only` value must agree with the signed package. Do
+not duplicate the catalog in Markdown. Executable or public metadata changes
+require one public package version bump.
## Lifecycle
@@ -114,7 +115,7 @@ not belong in structured meter/PV/battery/EV telemetry.
## Implementation sequence
-1. Add or update the canonical driver and package source in Device Support.
+1. Add or update the driver and package recipe in `srcfl/device-drivers`.
2. Implement read-only polling and verify signs against real vendor values.
3. Build the explicit FTW GopherLua/Lua 5.1 target and run FTW host tests.
4. Add stable identity and stale-cache handling.
@@ -141,9 +142,62 @@ device-specific safety knowledge in the driver next to the code it constrains.
## Local overrides and release
Custom drivers belong in the persistent user-driver directory, not inside a
-container layer. Managed drivers are signed, installed atomically and
-rollbackable; see [device-repository.md](device-repository.md).
+container layer:
-Device Support publishes new driver packages to `beta` first. Promote the same
-reviewed source, target hashes and version to `stable` only after hardware
-validation. FTW does not fork or independently renumber that release.
+- Docker: `/app/data/drivers`, which is the host's `./data/drivers` bind;
+- systemd: `/var/lib/ftw/drivers`;
+- another native run: pass `-user-drivers
`.
+
+Keep the config path portable:
+
+```yaml
+drivers:
+ - name: example
+ lua: drivers/example.lua
+ capabilities:
+ modbus:
+ host: 192.168.1.20
+ port: 502
+ unit_id: 1
+```
+
+FTW resolves that file as local, then managed signed, then bundled. Settings
+and fleet inventory mark the first case `local / unsigned`. Local code works
+offline and never needs GitHub, Device Support or `drivers.sourceful.energy`.
+It gets no auto-update or promotion and cannot claim signed package control.
+The normal host capabilities and lifecycle still apply.
+
+Use **Test connection** in Settings, or call `POST /api/drivers/test`. The test
+starts a short-lived driver, runs init and poll with the declared hardware
+capabilities, and does not save config. Start read-only on a test device. The
+endpoint reads the Lua file again for each test. FTW does not watch Lua files:
+an active driver needs an FTW restart or a real config change that restarts its
+registry entry after the file changes.
+
+```bash
+curl -sS -X POST http://127.0.0.1:8080/api/drivers/test \
+ -H 'Content-Type: application/json' \
+ -d '{"name":"example-test","lua":"drivers/example.lua","capabilities":{"modbus":{"host":"192.168.1.20","port":502,"unit_id":1}}}'
+```
+
+Restart Docker with `docker compose restart ftw`, or systemd with
+`sudo systemctl restart ftw`. To roll back a local overlay, move the local file
+out of the user-driver directory and restart. FTW then selects the managed or
+bundled file without changing its managed cache.
+
+Before a public pull request, clone `srcfl/device-drivers`, place the driver in
+its public layout, and run:
+
+```bash
+make test-driver ID=example
+make package-driver ID=example TARGET=ftw-core
+make check
+```
+
+Managed drivers are signed, installed atomically and rollbackable; see
+[device-repository.md](device-repository.md).
+
+Device Support publishes new driver packages to `beta` first. Promote without
+rebuilding after hardware validation: package v1 changes the signed channel,
+but keeps the exact artifact bytes, hashes, public source commit and materials.
+FTW does not fork or independently renumber that release.
diff --git a/go/internal/config/config.go b/go/internal/config/config.go
index 304b6d91..9674cdf8 100644
--- a/go/internal/config/config.go
+++ b/go/internal/config/config.go
@@ -1134,13 +1134,13 @@ func Parse(data []byte, baseDir string) (*Config, error) {
// Empty string preserves the historical "sibling-of-config" behaviour.
var DriversDirOverride string
-// UserDriversDirOverride is the second lookup path tried before
-// DriversDirOverride. Designed for persistent user-supplied drivers in
+// UserDriversDirOverride is the first lookup path. It is tried before managed
+// signed drivers and DriversDirOverride. It holds persistent user drivers in
// the docker deploy where DriversDirOverride lives in the immutable
// image layer. When set, ResolveDriverPaths checks whether a file
// exists in this directory first and uses it when found; otherwise
-// falls back to DriversDirOverride. Empty = single-dir behaviour
-// (back-compat).
+// falls back to the managed and bundled directories. Empty = single-dir
+// behaviour (back-compat).
var UserDriversDirOverride string
// ManagedDriversDirOverride contains stable active symlinks maintained by the
@@ -1151,8 +1151,8 @@ var ManagedDriversDirOverride string
// ResolveDriverPaths joins relative Lua driver paths with baseDir, or
// with DriversDirOverride when the relative path starts with "drivers/".
// When UserDriversDirOverride is also set, paths starting with "drivers/"
-// are first probed in UserDriversDirOverride; only if the file is absent
-// there do they fall through to DriversDirOverride.
+// are first probed there. They then fall through to the managed directory
+// and DriversDirOverride.
func (c *Config) ResolveDriverPaths(baseDir string) {
for i := range c.Drivers {
c.Drivers[i].Lua = stripLeadingDotDot(c.Drivers[i].Lua)
diff --git a/go/internal/driverinventory/inventory.go b/go/internal/driverinventory/inventory.go
index e2211086..d929abc9 100644
--- a/go/internal/driverinventory/inventory.go
+++ b/go/internal/driverinventory/inventory.go
@@ -67,7 +67,7 @@ type Snapshot struct {
}
// RepositoryArtifact describes one activated repository file. PackageID and
-// Channel are set only when a signed canonical package supplied them.
+// Channel are set only when a signed package supplied them.
type RepositoryArtifact struct {
LogicalPath string
InstalledPath string
@@ -199,16 +199,16 @@ func inspectDriver(path string, in Input, artifacts map[string]RepositoryArtifac
return row, nil
}
// A missing or changed activation must not claim signed provenance.
- row.Source = "local_override"
+ row.Source = "local"
row.SourceSHA256 = loadedSHA
return row, nil
}
if within(path, in.UserDriverDir) {
- row.Source = "local_override"
+ row.Source = "local"
} else if within(path, in.BundledDriverDir) {
row.Source = "bundled"
} else {
- row.Source = "local_override"
+ row.Source = "local"
}
row.SourceSHA256 = loadedSHA
return row, nil
diff --git a/go/internal/driverinventory/inventory_test.go b/go/internal/driverinventory/inventory_test.go
index 410461c0..5a93bc96 100644
--- a/go/internal/driverinventory/inventory_test.go
+++ b/go/internal/driverinventory/inventory_test.go
@@ -116,11 +116,57 @@ func TestBuildDowngradesChangedManagedFileToLocalOverride(t *testing.T) {
t.Fatal(err)
}
row := snapshot.Drivers[0]
- if row.Source != "local_override" || row.SourceSHA256 == "" || row.PackageID != "" || row.ArtifactSHA256 != "" {
+ if row.Source != "local" || row.SourceSHA256 == "" || row.PackageID != "" || row.ArtifactSHA256 != "" {
t.Fatalf("row = %+v", row)
}
}
+func TestBuildLocalOverrideDoesNotMutateManagedArtifact(t *testing.T) {
+ localDir, managedDir, bundledDir := t.TempDir(), t.TempDir(), t.TempDir()
+ localPath, localSHA := writeDriver(t, localDir, "meter.lua", "meter", "2.0.0-local", true)
+ managedPath, managedSHA := writeDriver(t, managedDir, "meter.lua", "meter", "1.1.2", true)
+ before, err := os.ReadFile(managedPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ origBundled, origLocal, origManaged := config.DriversDirOverride, config.UserDriversDirOverride, config.ManagedDriversDirOverride
+ config.DriversDirOverride, config.UserDriversDirOverride, config.ManagedDriversDirOverride = bundledDir, localDir, managedDir
+ t.Cleanup(func() {
+ config.DriversDirOverride, config.UserDriversDirOverride, config.ManagedDriversDirOverride = origBundled, origLocal, origManaged
+ })
+ cfg := config.Config{Drivers: []config.Driver{{Name: "meter", Lua: "drivers/meter.lua"}}}
+ cfg.ResolveDriverPaths(t.TempDir())
+ if cfg.Drivers[0].Lua != localPath {
+ t.Fatalf("resolved path = %q, want local %q", cfg.Drivers[0].Lua, localPath)
+ }
+
+ snapshot, err := Build(time.Now(), Input{
+ HostVersion: "1.8.1-beta.1", Drivers: cfg.Drivers,
+ UserDriverDir: localDir, ManagedDriverDir: managedDir, BundledDriverDir: bundledDir,
+ RepositoryDrivers: []RepositoryArtifact{{
+ LogicalPath: "drivers/meter.lua", InstalledPath: managedPath,
+ DriverID: "meter", Version: "1.1.2", SHA256: managedSHA,
+ RepositoryID: "sourceful", PackageID: "com.sourceful.driver.meter",
+ PackageChannel: "beta", ControlClass: "read_only",
+ }},
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ row := snapshot.Drivers[0]
+ if row.Source != "local" || row.SourceSHA256 != localSHA || row.PackageID != "" || row.ArtifactSHA256 != "" {
+ t.Fatalf("local inventory row = %+v", row)
+ }
+ after, err := os.ReadFile(managedPath)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if string(after) != string(before) {
+ t.Fatal("local override changed the managed artifact")
+ }
+}
+
func TestBuildMarksOldRepositoryArtifactAsLegacy(t *testing.T) {
dir := t.TempDir()
path, artifactSHA := writeDriver(t, dir, "old.lua", "old", "0.9.0", false)
diff --git a/go/internal/driverrepo/manager.go b/go/internal/driverrepo/manager.go
index b8c91aa2..05aa82c2 100644
--- a/go/internal/driverrepo/manager.go
+++ b/go/internal/driverrepo/manager.go
@@ -348,9 +348,25 @@ func (m *Manager) EnrichCatalog(entries []drivers.CatalogEntry) []drivers.Catalo
activeByLogical[in.LogicalPath] = in
}
for i := range entries {
- if in, ok := activeByLogical[entries[i].Path]; ok {
+ if entries[i].Source == "local" {
+ continue
+ }
+ if in, ok := activeByLogical[entries[i].Path]; ok && entries[i].Source == "managed" {
entries[i].InstalledVersion = in.Version
entries[i].RepositoryID = in.RepoID
+ for _, candidate := range candidates {
+ driver := candidate.Driver
+ if candidate.RepositoryID != in.RepoID || driver.ID != in.DriverID || driver.Version != in.Version ||
+ !strings.EqualFold(driver.SHA256, in.SHA256) {
+ continue
+ }
+ entries[i].PackageID = driver.PackageID
+ entries[i].PackageChannel = driver.Channel
+ entries[i].ArtifactSHA256 = strings.ToLower(driver.SHA256)
+ entries[i].RuntimeABI = driver.RuntimeABI
+ entries[i].HostAPIProfile = driver.HostAPIProfile
+ break
+ }
}
for _, candidate := range candidates {
if candidate.Driver.ID != entries[i].ID {
diff --git a/go/internal/driverrepo/sourceful.go b/go/internal/driverrepo/sourceful.go
index ccb2dba3..a2eb4dcc 100644
--- a/go/internal/driverrepo/sourceful.go
+++ b/go/internal/driverrepo/sourceful.go
@@ -528,7 +528,7 @@ func (m *Manager) sourcefulPackageDriver(
Version: pkg.Version, HostAPIMin: runtime.HostAPI.Min, HostAPIMax: runtime.HostAPI.Max,
Source: "upstream", Protocols: sourcefulProtocols(pkg.Permissions),
Capabilities: sourcefulFTWCapabilities(pkg.Capabilities.Telemetry),
- Description: "Canonical Sourceful driver package from Device Support.",
+ Description: "Signed Sourceful driver package from drivers.sourceful.energy.",
ReadOnly: pkg.ReadOnly, VerificationStatus: "experimental",
TestedModels: sourcefulTestedModels(pkg.DeviceMatches),
}
@@ -548,8 +548,8 @@ func (m *Manager) sourcefulPackageDriver(
}
// RuntimePolicy verifies the package envelope stored with an active managed
-// artifact and derives the v2 host policy without a network call. A local,
-// bundled, legacy repository or read-only Sourceful artifact returns nil.
+// artifact and derives its host permissions without a network call. Local,
+// bundled and legacy repository artifacts return nil.
func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, error) {
if m.store == nil {
if cfg.Control != nil && cfg.Control.Enabled {
@@ -620,7 +620,7 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro
if err != nil {
return nil, fmt.Errorf("validate installed signed package: %w", err)
}
- if !compatible || entry.ReadOnly || !entry.ControlEnabled {
+ if !compatible {
if cfg.Control != nil && cfg.Control.Enabled {
return nil, errors.New("control opt-in targets an artifact without an enabled FTW v2 control target")
}
@@ -629,6 +629,26 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro
if !strings.EqualFold(entry.SHA256, installed.SHA256) || entry.Version != installed.Version || entry.ID != installed.DriverID {
return nil, errors.New("installed artifact does not match its signed package envelope")
}
+ permissions := make(map[string]bool, len(entry.Permissions))
+ for _, permission := range entry.Permissions {
+ permissions[permission] = true
+ }
+ if entry.ReadOnly {
+ if cfg.Control != nil && cfg.Control.Enabled {
+ return nil, errors.New("control opt-in targets a read-only signed artifact")
+ }
+ return &drivers.RuntimePolicy{
+ PackageID: entry.PackageID, Version: entry.Version, ArtifactSHA256: strings.ToLower(entry.SHA256),
+ RuntimeABI: entry.RuntimeABI, HostAPIProfile: entry.HostAPIProfile,
+ ReadOnly: true, Permissions: permissions,
+ }, nil
+ }
+ if !entry.ControlEnabled {
+ if cfg.Control != nil && cfg.Control.Enabled {
+ return nil, errors.New("control opt-in targets an artifact without an enabled FTW v2 control target")
+ }
+ return nil, nil
+ }
siteEnabled := false
if cfg.Control != nil && cfg.Control.Enabled {
@@ -638,10 +658,6 @@ func (m *Manager) RuntimePolicy(cfg config.Driver) (*drivers.RuntimePolicy, erro
}
siteEnabled = true
}
- permissions := make(map[string]bool, len(entry.Permissions))
- for _, permission := range entry.Permissions {
- permissions[permission] = true
- }
commands := make(map[string]drivers.RuntimeCommand, len(entry.Commands))
for _, command := range entry.Commands {
inputs := make(map[string]drivers.RuntimeCommandInput, len(command.Inputs))
diff --git a/go/internal/driverrepo/sourceful_test.go b/go/internal/driverrepo/sourceful_test.go
index b77f70d4..e90a197c 100644
--- a/go/internal/driverrepo/sourceful_test.go
+++ b/go/internal/driverrepo/sourceful_test.go
@@ -19,6 +19,7 @@ import (
"testing"
"github.com/srcfl/ftw/go/internal/config"
+ "github.com/srcfl/ftw/go/internal/drivers"
"github.com/srcfl/ftw/go/internal/state"
)
@@ -270,6 +271,26 @@ func TestSourcefulIndexPackageInstallAndOfflineCache(t *testing.T) {
if err != nil || string(active) != string(fixture.artifact) {
t.Fatalf("active artifact mismatch: %v", err)
}
+ managed := manager.EnrichCatalog([]drivers.CatalogEntry{{
+ Path: "drivers/sdm630.lua", ID: "sdm630", Version: "1.1.1", Source: "managed",
+ }})[0]
+ if managed.PackageID != "com.sourceful.driver.sdm630" || managed.PackageChannel != "beta" ||
+ managed.ArtifactSHA256 != installed.SHA256 || managed.RuntimeABI != sourcefulFTWABIV1 ||
+ managed.HostAPIProfile != sourcefulFTWHostAPIProfileV1 {
+ t.Fatalf("managed catalog provenance = %+v", managed)
+ }
+ local := manager.EnrichCatalog([]drivers.CatalogEntry{{
+ Path: "drivers/sdm630.lua", ID: "sdm630", Version: "local", Source: "local",
+ }})[0]
+ if local.RepositoryID != "" || local.PackageID != "" || local.ArtifactSHA256 != "" || local.UpdateAvailable {
+ t.Fatalf("local catalog claimed managed provenance = %+v", local)
+ }
+ policy, err := manager.RuntimePolicy(config.Driver{
+ Name: "sdm630", Lua: filepath.Join(manager.ActiveDir(), "sdm630.lua"),
+ })
+ if err != nil || policy == nil || !policy.IsReadOnly() || !policy.Permissions["modbus.read"] || policy.Permissions["modbus.write"] {
+ t.Fatalf("signed read-only runtime policy = %+v, %v", policy, err)
+ }
// A failed refresh cannot replace the last-good in-memory or on-disk view.
fixture.mu.Lock()
diff --git a/go/internal/drivers/catalog.go b/go/internal/drivers/catalog.go
index 10727b5e..8267fa96 100644
--- a/go/internal/drivers/catalog.go
+++ b/go/internal/drivers/catalog.go
@@ -24,6 +24,11 @@ type CatalogEntry struct {
HostAPIMax int `json:"host_api_max,omitempty"`
Source string `json:"source,omitempty"` // local | managed | bundled | upstream
RepositoryID string `json:"repository_id,omitempty"`
+ PackageID string `json:"package_id,omitempty"`
+ PackageChannel string `json:"package_channel,omitempty"`
+ ArtifactSHA256 string `json:"artifact_sha256,omitempty"`
+ RuntimeABI string `json:"runtime_abi,omitempty"`
+ HostAPIProfile string `json:"host_api_profile,omitempty"`
InstalledVersion string `json:"installed_version,omitempty"`
UpstreamVersion string `json:"upstream_version,omitempty"`
UpdateAvailable bool `json:"update_available,omitempty"`
diff --git a/go/internal/drivers/control_v2.go b/go/internal/drivers/control_v2.go
index e477fc5b..c31d7fbb 100644
--- a/go/internal/drivers/control_v2.go
+++ b/go/internal/drivers/control_v2.go
@@ -29,6 +29,7 @@ type RuntimePolicy struct {
ArtifactSHA256 string
RuntimeABI string
HostAPIProfile string
+ ReadOnly bool
Permissions map[string]bool
Commands map[string]RuntimeCommand
DefaultMode string
@@ -58,7 +59,29 @@ func (p *RuntimePolicy) IsControlV2() bool {
return p != nil && p.RuntimeABI == ControlRuntimeABIV2 && p.HostAPIProfile == ControlHostAPIProfileV2
}
+func (p *RuntimePolicy) IsReadOnly() bool {
+ return p != nil && p.ReadOnly
+}
+
func (p *RuntimePolicy) validate() error {
+ if p.IsReadOnly() {
+ if !strings.HasPrefix(p.PackageID, "com.sourceful.driver.") || p.Version == "" ||
+ !controlHashRE.MatchString(p.ArtifactSHA256) || p.RuntimeABI == "" || p.HostAPIProfile == "" ||
+ p.SiteEnabled || len(p.Commands) != 0 || p.DefaultMode != "" {
+ return errors.New("invalid read-only runtime identity")
+ }
+ for permission, allowed := range p.Permissions {
+ if !allowed {
+ continue
+ }
+ switch permission {
+ case "http.get", "modbus.read", "mqtt.subscribe", "serial.read":
+ default:
+ return fmt.Errorf("read-only runtime has write-capable permission %q", permission)
+ }
+ }
+ return nil
+ }
if !p.IsControlV2() || !strings.HasPrefix(p.PackageID, "com.sourceful.driver.") || p.Version == "" ||
!controlHashRE.MatchString(p.ArtifactSHA256) || p.DefaultMode != "driver_default_mode_v2" {
return errors.New("invalid control v2 runtime identity")
diff --git a/go/internal/drivers/control_v2_test.go b/go/internal/drivers/control_v2_test.go
index 2b3cb2f4..148cee26 100644
--- a/go/internal/drivers/control_v2_test.go
+++ b/go/internal/drivers/control_v2_test.go
@@ -192,6 +192,48 @@ func TestControlV2RestrictsWritesAndReturnsHostEvidence(t *testing.T) {
}
}
+func TestSignedReadOnlyPolicyDeniesWritesInEveryLifecyclePhase(t *testing.T) {
+ source := `
+function driver_init(config) host.modbus_write(10, 1) end
+function driver_poll() host.modbus_write(10, 2) return 1000 end
+function driver_command(action, value, command) host.modbus_write(10, 3) end
+function driver_default_mode() host.modbus_write(10, 4) end
+function driver_cleanup() host.modbus_write(10, 5) end
+`
+ path := filepath.Join(t.TempDir(), "read-only.lua")
+ if err := os.WriteFile(path, []byte(source), 0o600); err != nil {
+ t.Fatal(err)
+ }
+ modbus := &controlV2Modbus{registers: make(map[uint16]uint16)}
+ policy := &RuntimePolicy{
+ PackageID: "com.sourceful.driver.read-only", Version: "1.0.0",
+ ArtifactSHA256: strings.Repeat("b", 64), RuntimeABI: "gopher-lua-source-v1",
+ HostAPIProfile: "sourceful.host/ftw-core/v1", ReadOnly: true,
+ Permissions: map[string]bool{"modbus.read": true},
+ }
+ env := NewHostEnv("read-only", telemetry.NewStore()).WithModbus(modbus)
+ driver, err := NewLuaDriverWithPolicy(path, env, policy)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := driver.Init(context.Background(), map[string]interface{}{}); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := driver.Poll(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if err := driver.Command(context.Background(), []byte(`{"action":"test","power_w":1}`)); err != nil {
+ t.Fatal(err)
+ }
+ if err := driver.DefaultMode(); err != nil {
+ t.Fatal(err)
+ }
+ driver.Cleanup()
+ if modbus.writes != 0 {
+ t.Fatalf("signed read-only driver reached hardware: %d writes", modbus.writes)
+ }
+}
+
func TestControlV2RequiresExactSiteOptIn(t *testing.T) {
driver, modbus := loadControlV2Driver(t, testControlV2Policy(false))
now := time.Now()
diff --git a/go/internal/drivers/host.go b/go/internal/drivers/host.go
index 670d9c47..388c92a1 100644
--- a/go/internal/drivers/host.go
+++ b/go/internal/drivers/host.go
@@ -99,9 +99,9 @@ type HostEnv struct {
// callers / tests can inspect what was granted.
TCPAllowedHosts []string
Start time.Time // monotonic start; host.millis() computed from here
- // RuntimePolicy is nil for bundled, local and managed v1 drivers. A signed
- // v2 policy narrows every host call and permits writes only inside a bounded
- // command/default-mode call.
+ // RuntimePolicy is nil for bundled, local and legacy repository drivers. A
+ // signed read-only policy denies writes in every phase. A signed v2 control
+ // policy also limits writes to a bounded command/default-mode call.
RuntimePolicy *RuntimePolicy
// BatteryCapacityWh mirrors the operator's `battery_capacity_wh`
diff --git a/go/internal/drivers/lua.go b/go/internal/drivers/lua.go
index fcb97e80..62143e5a 100644
--- a/go/internal/drivers/lua.go
+++ b/go/internal/drivers/lua.go
@@ -78,14 +78,15 @@ func NewLuaDriver(path string, env *HostEnv) (*LuaDriver, error) {
return NewLuaDriverWithPolicy(path, env, nil)
}
-// NewLuaDriverWithPolicy selects the restricted v2 surface only from verified
-// managed package metadata. Local, bundled and v1 managed drivers keep the
-// existing Lua 5.1 environment.
+// NewLuaDriverWithPolicy binds verified managed package permissions to the
+// host. Only control v2 also gets the restricted Lua library surface. Local,
+// bundled and legacy repository drivers keep the existing Lua 5.1 environment.
func NewLuaDriverWithPolicy(path string, env *HostEnv, policy *RuntimePolicy) (*LuaDriver, error) {
if policy != nil {
if err := policy.validate(); err != nil {
- return nil, fmt.Errorf("control runtime policy: %w", err)
+ return nil, fmt.Errorf("driver runtime policy: %w", err)
}
+ env.WithRuntimePolicy(policy)
}
src, err := os.ReadFile(path)
if err != nil {
@@ -95,7 +96,6 @@ func NewLuaDriverWithPolicy(path string, env *HostEnv, policy *RuntimePolicy) (*
L := lua.NewState(lua.Options{SkipOpenLibs: restricted})
if restricted {
openRestrictedLibraries(L)
- env.WithRuntimePolicy(policy)
}
d := &LuaDriver{Env: env, Path: path, L: L}
registerHost(L, env)
diff --git a/web/diagnostics-modal.js b/web/diagnostics-modal.js
index 9c9b4b80..e14b16f1 100644
--- a/web/diagnostics-modal.js
+++ b/web/diagnostics-modal.js
@@ -15,6 +15,9 @@
// content silently and preserve modal/log scroll positions so the
// operator's reading place isn't yanked away.
(function () {
+ var DRIVER_ISSUE_URL = "https://github.com/srcfl/device-drivers/issues/new?template=driver_issue.yml";
+ var DRIVER_REQUEST_URL = "https://github.com/srcfl/device-drivers/issues/new?template=driver_request.yml";
+
function apiFetch(path, opts) {
return fetch(path, opts);
}
@@ -65,7 +68,7 @@
".ftw-diag-shell{width:min(740px,94vw);max-height:90vh;display:flex;flex-direction:column;background:var(--ink-raised);border:1px solid var(--line);border-radius:10px;overflow:hidden;}",
// Header: eyebrow + driver name + status pill on the left,
// ghost actions on the right.
- ".ftw-diag-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line);background:var(--ink);}",
+ ".ftw-diag-head{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:14px 18px;border-bottom:1px solid var(--line);background:var(--ink);flex-wrap:wrap;}",
".ftw-diag-title{display:flex;align-items:center;gap:12px;flex-wrap:wrap;}",
// Eyebrow label per the shared design system: mono, 0.18em tracking, accent amber.
".ftw-diag-title-eyebrow{font-family:var(--mono);font-size:0.7rem;letter-spacing:0.18em;text-transform:uppercase;color:var(--accent-e);font-weight:500;}",
@@ -80,7 +83,7 @@
".ftw-diag-status-pill.ftw-diag-status-degraded::before{background:var(--accent-e);box-shadow:0 0 10px var(--accent-e);}",
".ftw-diag-status-pill.ftw-diag-status-offline{color:var(--red-e);border-color:var(--red-e);}",
".ftw-diag-status-pill.ftw-diag-status-offline::before{background:var(--red-e);box-shadow:0 0 10px var(--red-e);}",
- ".ftw-diag-actions{display:flex;gap:8px;align-items:center;}",
+ ".ftw-diag-actions{display:flex;gap:8px;align-items:center;flex-wrap:wrap;}",
// Ghost button per the shared design system: transparent bg, hover changes ONLY
// the border colour (no bg or text-colour shift).
".ftw-diag-btn{font-family:var(--sans);font-size:0.78rem;font-weight:500;letter-spacing:0.02em;padding:8px 14px;border:1px solid var(--line);border-radius:8px;background:transparent;color:var(--fg);cursor:pointer;transition:border-color 120ms ease;}",
@@ -131,6 +134,14 @@
".ftw-diag-job-meta{font-family:var(--mono);font-size:0.78rem;color:var(--fg-dim);}",
".ftw-diag-job-error{color:var(--red-e);font-family:var(--mono);font-size:0.82rem;margin-top:10px;}",
".ftw-diag-job-actions{display:flex;justify-content:center;gap:8px;margin-top:14px;}",
+ ".ftw-diag-review{position:fixed;inset:0;background:rgba(0,0,0,0.68);z-index:9200;display:flex;align-items:center;justify-content:center;}",
+ ".ftw-diag-review-card{width:min(680px,94vw);max-height:90vh;overflow:auto;background:var(--ink-raised);border:1px solid var(--line);border-radius:10px;padding:18px;color:var(--fg);}",
+ ".ftw-diag-review-head{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;}",
+ ".ftw-diag-review-head h3{margin:0;font:500 0.9rem var(--mono);letter-spacing:0.12em;text-transform:uppercase;color:var(--accent-e);}",
+ ".ftw-diag-review-note{color:var(--fg-dim);font-size:0.84rem;line-height:1.45;margin:0 0 10px;}",
+ ".ftw-diag-review textarea{box-sizing:border-box;width:100%;min-height:300px;resize:vertical;background:var(--ink-sunken);color:var(--fg);border:1px solid var(--line);border-radius:8px;padding:10px 12px;font:0.78rem/1.45 var(--mono);}",
+ ".ftw-diag-review-actions{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:10px;flex-wrap:wrap;}",
+ ".ftw-diag-review-status{margin-right:auto;color:var(--fg-dim);font-size:0.78rem;}",
].join("");
document.head.appendChild(style);
}
@@ -144,9 +155,12 @@
keyHandler: null,
jobOverlay: null,
jobTimer: null,
+ issueOverlay: null,
+ detail: null,
};
function close() {
+ closeIssueReview();
if (state.timer) { clearInterval(state.timer); state.timer = null; }
if (state.keyHandler) {
document.removeEventListener("keydown", state.keyHandler);
@@ -159,6 +173,145 @@
state.bodyEl = null;
state.statusPillEl = null;
state.name = null;
+ state.detail = null;
+ }
+
+ function closeIssueReview() {
+ if (state.issueOverlay && state.issueOverlay.parentNode) {
+ state.issueOverlay.parentNode.removeChild(state.issueOverlay);
+ }
+ state.issueOverlay = null;
+ }
+
+ function safeDiagnosticValue(value) {
+ var text = String(value == null ? "" : value).trim();
+ if (!text || text.length > 80) return "";
+ if (!/^[A-Za-z0-9][A-Za-z0-9 ._+\/-]*$/.test(text)) return "";
+ if (/\b\d{1,3}(?:\.\d{1,3}){3}\b/.test(text) || text.indexOf("://") >= 0) return "";
+ return text;
+ }
+
+ function diagnosticSource(entry) {
+ if (entry && entry.source === "managed" && entry.package_id && entry.artifact_sha256) {
+ return "managed / signed";
+ }
+ if (entry && entry.source === "local") return "local / unsigned";
+ if (entry && entry.source === "bundled") return "bundled";
+ return "unknown";
+ }
+
+ function buildDriverDiagnostics(cfg, entry, components) {
+ var detail = state.detail || {};
+ var health = detail.Health || detail.health || {};
+ var rawID = entry && entry.id;
+ var driverID = safeDiagnosticValue(rawID) || "local-custom";
+ var version = safeDiagnosticValue((entry && (entry.installed_version || entry.version)) || "unknown") || "unknown";
+ var source = diagnosticSource(entry);
+ var core = (components && components.core) || {};
+ var versionEl = document.getElementById("version");
+ var ftwVersion = safeDiagnosticValue(core.version || (versionEl && versionEl.textContent) || "dev") || "dev";
+ var status = statusName(health.Status);
+ var configData = (cfg && cfg.config) || {};
+ var make = safeDiagnosticValue((entry && entry.manufacturer) || "");
+ var model = safeDiagnosticValue(configData.model || "");
+ var firmware = safeDiagnosticValue(configData.firmware || "");
+ var hostAPI = "unknown";
+ if (entry && entry.host_api_min) {
+ hostAPI = String(entry.host_api_min);
+ if (entry.host_api_max && entry.host_api_max !== entry.host_api_min) {
+ hostAPI += "-" + String(entry.host_api_max);
+ }
+ }
+ var lines = [
+ "## FTW driver diagnostics",
+ "",
+ "- Driver: `" + driverID + "`",
+ "- Driver version: `" + version + "`",
+ "- Source: `" + source + "`",
+ ];
+ if (source === "managed / signed") {
+ lines.push("- Package: `" + safeDiagnosticValue(entry.package_id) + "`");
+ lines.push("- Channel: `" + (safeDiagnosticValue(entry.package_channel) || "unknown") + "`");
+ if (/^[0-9a-f]{64}$/.test(entry.artifact_sha256 || "")) {
+ lines.push("- Artifact SHA-256: `" + entry.artifact_sha256 + "`");
+ }
+ }
+ lines.push("- FTW version: `" + ftwVersion + "`");
+ lines.push("- Host API: `" + hostAPI + "`");
+ lines.push("- Runtime ABI: `" + (safeDiagnosticValue(entry && entry.runtime_abi) || "gopher-lua-source-v1") + "`");
+ if (make) lines.push("- Device make: `" + make + "`");
+ if (model) lines.push("- Device model: `" + model + "`");
+ if (firmware) lines.push("- Firmware: `" + firmware + "`");
+ lines.push("- Health: `" + status + "`");
+ lines.push("- Last error: " + (health.LastError ? "present; omitted here for privacy" : "none reported"));
+ lines.push("");
+ lines.push("## Test steps");
+ lines.push("");
+ lines.push("1. Opened the driver Diagnose view.");
+ lines.push("2. Checked current health and telemetry.");
+ lines.push("3. Add the steps that reproduce the issue, without IP addresses, serial numbers, credentials, site IDs, raw config, or logs.");
+ return {
+ body: lines.join("\n"),
+ issueURL: DRIVER_ISSUE_URL + "&title=" + encodeURIComponent("[" + driverID + "] Driver issue on " + version),
+ };
+ }
+
+ function copyText(textarea, statusEl) {
+ function done() { if (statusEl) statusEl.textContent = "Diagnostics copied."; }
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(textarea.value).then(done).catch(function () {
+ textarea.select();
+ document.execCommand("copy");
+ done();
+ });
+ return;
+ }
+ textarea.select();
+ document.execCommand("copy");
+ done();
+ }
+
+ function openIssueReview() {
+ var name = state.name;
+ if (!name) return;
+ Promise.all([
+ apiFetch("/api/config").then(function (r) { return r.json(); }),
+ apiFetch("/api/drivers/catalog").then(function (r) { return r.json(); }),
+ apiFetch("/api/components").then(function (r) { return r.json(); }).catch(function () { return {}; }),
+ ]).then(function (results) {
+ if (state.name !== name) return;
+ var cfg = ((results[0] && results[0].drivers) || []).find(function (item) { return item.name === name; }) || {};
+ var entries = (results[1] && results[1].entries) || [];
+ var entry = entries.find(function (item) { return item.path === cfg.lua; }) || null;
+ var report = buildDriverDiagnostics(cfg, entry, results[2]);
+ closeIssueReview();
+ var overlay = document.createElement("div");
+ overlay.className = "ftw-diag-review";
+ overlay.innerHTML =
+ '
' +
+ '
Review driver report
' +
+ '
Check the text before GitHub opens. FTW leaves raw errors, logs, IP addresses, serial numbers, credentials, site IDs and config out.
' +
+ ' ' +
+ '
' +
+ ' ' +
+ ' ' +
+ '
' +
+ '
';
+ document.body.appendChild(overlay);
+ state.issueOverlay = overlay;
+ overlay.addEventListener("click", function (event) { if (event.target === overlay) closeIssueReview(); });
+ overlay.querySelector('[data-role="close-review"]').addEventListener("click", closeIssueReview);
+ var textarea = overlay.querySelector('[data-role="diagnostics"]');
+ var copyStatus = overlay.querySelector('[data-role="copy-status"]');
+ overlay.querySelector('[data-role="copy-diagnostics"]').addEventListener("click", function () {
+ copyText(textarea, copyStatus);
+ });
+ overlay.querySelector('[data-role="open-issue"]').addEventListener("click", function () {
+ window.open(report.issueURL, "_blank", "noopener");
+ });
+ }).catch(function (error) {
+ alert("Could not prepare driver report: " + error.message);
+ });
}
function startJobOverlay(title, message) {
@@ -314,6 +467,11 @@
}
html += '';
+ html += '
Local exports
' +
+ '' +
+ '' +
+ '
';
+
// Preserve scroll positions across the silent re-render so the
// 5 s auto-refresh doesn't yank the operator off what they're
// reading. On first paint there's nothing to preserve.
@@ -326,6 +484,23 @@
state.bodyEl.innerHTML = html;
+ state.bodyEl.querySelector('[data-role="dump"]').addEventListener("click", function () {
+ downloadWithFeedback(
+ "/api/support/dump",
+ "Preparing support bundle",
+ "Collecting logs, redacted config, driver health, and recent telemetry.",
+ "ftw-support.tar.gz"
+ );
+ });
+ state.bodyEl.querySelector('[data-role="research"]').addEventListener("click", function () {
+ downloadWithFeedback(
+ "/api/research/load/dump?days=120",
+ "Preparing load research",
+ "Building the anonymized 120-day research bundle. This can take a while on large databases.",
+ "ftw-load-research.tar.gz"
+ );
+ });
+
if (!isFirstPaint) state.bodyEl.scrollTop = prevBodyTop;
var newLogs = state.bodyEl.querySelector(".ftw-diag-logs");
if (newLogs) {
@@ -343,7 +518,8 @@
apiFetch("/api/drivers/" + encodeURIComponent(n) + "/logs?limit=200").then(function (r) { return r.json(); }).catch(function () { return { entries: [] }; }),
]).then(function (results) {
if (state.name !== n) return; // user closed or switched
- renderBody(results[0] || {}, results[1] || {}, isFirstPaint);
+ state.detail = results[0] || {};
+ renderBody(state.detail, results[1] || {}, isFirstPaint);
}).catch(function (e) {
if (state.name !== n) return;
state.bodyEl.innerHTML = '
Failed to load: ' + escHtml(e.message) + '
';
@@ -366,8 +542,8 @@
' …' +
' ' +
'
' +
- ' ' +
- ' ' +
+ ' ' +
+ ' ' +
' ' +
'
' +
' ' +
@@ -384,21 +560,9 @@
if (ev.target === backdrop) close();
});
backdrop.querySelector('[data-role="close"]').addEventListener("click", close);
- backdrop.querySelector('[data-role="dump"]').addEventListener("click", function () {
- downloadWithFeedback(
- "/api/support/dump",
- "Preparing support bundle",
- "Collecting logs, redacted config, driver health, and recent telemetry.",
- "ftw-support.tar.gz"
- );
- });
- backdrop.querySelector('[data-role="research"]').addEventListener("click", function () {
- downloadWithFeedback(
- "/api/research/load/dump?days=120",
- "Preparing load research",
- "Building the anonymized 120-day research bundle. This can take a while on large databases.",
- "ftw-load-research.tar.gz"
- );
+ backdrop.querySelector('[data-role="report-driver"]').addEventListener("click", openIssueReview);
+ backdrop.querySelector('[data-role="request-driver"]').addEventListener("click", function () {
+ window.open(DRIVER_REQUEST_URL, "_blank", "noopener");
});
state.keyHandler = function (ev) { if (ev.key === "Escape") close(); };
diff --git a/web/driver-feedback.test.mjs b/web/driver-feedback.test.mjs
new file mode 100644
index 00000000..273c6f81
--- /dev/null
+++ b/web/driver-feedback.test.mjs
@@ -0,0 +1,40 @@
+import assert from "node:assert/strict";
+import { readFileSync } from "node:fs";
+import { dirname, join } from "node:path";
+import { describe, it } from "node:test";
+import { fileURLToPath } from "node:url";
+
+const webRoot = dirname(fileURLToPath(import.meta.url));
+const diagnostics = readFileSync(join(webRoot, "diagnostics-modal.js"), "utf8");
+const system = readFileSync(join(webRoot, "settings", "tabs", "system.js"), "utf8");
+
+describe("driver feedback", () => {
+ it("uses the public driver templates without app auth or tokens", () => {
+ assert.match(diagnostics, /srcfl\/device-drivers\/issues\/new\?template=driver_issue\.yml/);
+ assert.match(diagnostics, /srcfl\/device-drivers\/issues\/new\?template=driver_request\.yml/);
+ assert.doesNotMatch(diagnostics, /github.*(?:oauth|token)|Authorization.*github/i);
+ });
+
+ it("requires review and offers copy before opening GitHub", () => {
+ assert.match(diagnostics, /Review driver report/);
+ assert.match(diagnostics, /Sanitized driver diagnostics/);
+ assert.match(diagnostics, /Copy diagnostics/);
+ assert.match(diagnostics, /Open GitHub issue/);
+ assert.doesNotMatch(diagnostics, /DRIVER_ISSUE_URL\s*\+[^\n]*body=/);
+ });
+
+ it("marks local code unsigned and omits sensitive detail", () => {
+ assert.match(diagnostics, /local \/ unsigned/);
+ assert.match(diagnostics, /Last error:.*omitted here for privacy/);
+ assert.match(diagnostics, /raw errors, logs, IP addresses, serial numbers, credentials, site IDs and config out/);
+ assert.match(diagnostics, /entry\.source === "managed" && entry\.package_id && entry\.artifact_sha256/);
+ });
+});
+
+describe("FTW feedback", () => {
+ it("keeps FTW bugs and features in FTW with Discord as secondary help", () => {
+ assert.match(system, /srcfl\/ftw\/issues\/new\?template=bug_report\.yml/);
+ assert.match(system, /srcfl\/ftw\/issues\/new\?template=feature_request\.yml/);
+ assert.match(system, /ask on Discord/);
+ });
+});
diff --git a/web/settings/tabs/system.js b/web/settings/tabs/system.js
index 016d18c9..5845b2c4 100644
--- a/web/settings/tabs/system.js
+++ b/web/settings/tabs/system.js
@@ -46,6 +46,8 @@
' .sys-net { font-family: var(--mono, monospace); font-size: 0.85rem; }' +
' .sys-net-iface { color: var(--text-dim); margin-right: 8px; }' +
' .sys-fleet-preview { grid-column: 1 / -1; max-height: 260px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: 0.75rem/1.45 var(--mono, monospace); background: var(--line, rgba(255,255,255,0.06)); border-radius: 6px; padding: 10px; }' +
+ ' .sys-help-actions { display:flex; gap:8px; flex-wrap:wrap; align-items:center; }' +
+ ' .sys-help-secondary { margin:10px 0 0; color:var(--text-dim); font-size:0.8rem; }' +
'' +
'