diff --git a/README.md b/README.md
index e01f818..8d0c604 100644
--- a/README.md
+++ b/README.md
@@ -152,6 +152,12 @@ The `/fingerprint` wizard walks the operator through:
travel with every `fprint-enroll` to fpagent.
3. **Save** — both templates are posted to a2loft with a fresh OTP.
+The wizard tracks fpagent's scanner availability: the header tag and the capture
+panel show when the agent is offline or no reader is attached, and capture is
+disabled until a supported reader is detected. Availability means the reader is
+attached, not that a capture will succeed; enrolling still fails if the scanner
+is claimed or a scan does not complete.
+
### Installing the agent
`/fpagent/install.sh` is a public installer (also served for `curl | bash`). It
diff --git a/openspec/changes/archive/2026-10-02-fpagent-device-availability/.openspec.yaml b/openspec/changes/archive/2026-10-02-fpagent-device-availability/.openspec.yaml
new file mode 100644
index 0000000..ad94bad
--- /dev/null
+++ b/openspec/changes/archive/2026-10-02-fpagent-device-availability/.openspec.yaml
@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-10-02
diff --git a/openspec/changes/archive/2026-10-02-fpagent-device-availability/design.md b/openspec/changes/archive/2026-10-02-fpagent-device-availability/design.md
new file mode 100644
index 0000000..ad972a2
--- /dev/null
+++ b/openspec/changes/archive/2026-10-02-fpagent-device-availability/design.md
@@ -0,0 +1,72 @@
+# Design
+
+## Context
+
+See `proposal.md` — Why. fpagent pushes `fprint-device-status`
+`{available, device, captureAgentId}` to each connection on connect and to all
+connections when availability changes (2 s watcher; probes skipped while an
+enrollment runs). `available` means "a supported reader is attached", not that a
+capture will succeed. The wizard currently learns only `agent-hello` and the
+enrollment events, and its header tag reflects only the socket connection.
+
+## Goals / Non-Goals
+
+**Goals:** surface scanner availability in the wizard; stop a capture from
+starting when no reader is attached; keep working against agents that do not
+emit the event.
+
+**Non-Goals:** changing the agent or the event contract; blocking capture when
+the agent is merely unreachable (pre-existing behavior); a proactive device
+picker or wiring guidance.
+
+## Decisions
+
+1. **The client is a pure listener.** The agent already emits on connect and on
+ change, so the wizard adds one handler and no polling or request event.
+
+2. **Availability is tri-state, not boolean.** `agent.available` starts `null`
+ (unknown) and becomes `true`/`false` on the first status. An agent that never
+ emits the event leaves it `null`, so captures are not blocked — the feature
+ degrades to today's behavior instead of deadlocking older field laptops.
+
+3. **The status tag has four labels.** Offline (socket down), "Scanner not
+ connected" (`available: false`), "Agent ready" (`available: true`), and
+ "Agent connected" (unknown). The `title` carries the version and the device
+ name when known.
+
+4. **Capture is gated at both layers.** The buttons are disabled when
+ `available === false`, and `capture()` returns early on the same condition so
+ a race (availability changing between click and handler) cannot start an
+ enrollment. The agent remains authoritative and still rejects a capture with
+ no reader.
+
+5. **The gating logic gets a small vm-sandbox test.** `app.js` is a browser
+ IIFE, so `test/fp-wizard.test.js` loads it in a `node:vm` sandbox with
+ stubbed `Vue`/`io`/`document` and exercises the availability state, the tag
+ labels, and the capture refusal directly. No new dependency and no browser
+ runtime; the remaining end-to-end pass against a real agent is manual, since
+ `/fingerprint` requires a session.
+
+## Risks / Trade-offs
+
+- [Old agent never emits the event] → availability stays `null`, captures are
+ allowed, behavior matches today. The status tag shows "Agent connected".
+- [Brief wrong tag between socket connect and first status] → the agent emits
+ the status in the same connection handler as `agent-hello`, so the window is
+ negligible; the `null` state shows "Agent connected" rather than a false
+ "Scanner not connected".
+- [`available` read as "ready to capture"] → the agent's contract says it means
+ "attached"; enrollment remains authoritative and a capture can still fail.
+- [Watcher is skipped during a capture] → availability is not updated mid-scan,
+ which is correct: the reader is in use by that capture.
+
+## Migration Plan
+
+Ship the wizard; it is backward compatible with agents that lack the event.
+No agent change is required to deploy the wizard, and the full benefit appears
+once the field laptops run the agent that emits `fprint-device-status`. Rollback
+= revert the wizard.
+
+## Open Questions
+
+None.
diff --git a/openspec/changes/archive/2026-10-02-fpagent-device-availability/proposal.md b/openspec/changes/archive/2026-10-02-fpagent-device-availability/proposal.md
new file mode 100644
index 0000000..85c342a
--- /dev/null
+++ b/openspec/changes/archive/2026-10-02-fpagent-device-availability/proposal.md
@@ -0,0 +1,43 @@
+# Proposal: fpagent-device-availability
+
+## Why
+
+fpagent now reports whether a supported fingerprint reader is attached, as
+`fprint-device-status {available, device, captureAgentId}`, sent to each portal
+client on connect and whenever availability changes. The wizard does not consume
+it: the header tag reflects only the socket connection, so a connected agent
+with no scanner still reads "Agent connected" and capture is allowed, failing
+only when the enrollment reaches the reader. Operators need to see that the
+scanner is missing before they try.
+
+## What Changes
+
+- The wizard consumes `fprint-device-status` and tracks availability and the
+ device name alongside the connected state.
+- The header status tag distinguishes agent offline, connected with no scanner,
+ and ready.
+- While no supported reader is attached, the capture panel shows a warning and
+ the capture/recapture controls are disabled; the capture handler refuses a
+ racing attempt.
+
+## Capabilities
+
+### New Capabilities
+
+(none)
+
+### Modified Capabilities
+
+- `fingerprint-wizard`: shows scanner availability from `fprint-device-status`
+ and gates capture on a supported reader being attached.
+
+## Impact
+
+- `tools/public/assets/fp/app.js`: handle the event, a multi-state status tag,
+ and a gated `capture()`.
+- `tools/public/assets/fp/index.html`: disabled controls and an inline warning
+ when no reader is attached.
+- `README.md`, `tools/public/assets/help.html`: document the behavior.
+- Depends on `fingerprint-agent`'s `fprint-device-status` event (change
+ `scanner-hotplug-recovery`). Agents that do not emit it are tolerated:
+ availability stays unknown and captures are not blocked.
diff --git a/openspec/changes/archive/2026-10-02-fpagent-device-availability/specs/fingerprint-wizard/spec.md b/openspec/changes/archive/2026-10-02-fpagent-device-availability/specs/fingerprint-wizard/spec.md
new file mode 100644
index 0000000..dbcf915
--- /dev/null
+++ b/openspec/changes/archive/2026-10-02-fpagent-device-availability/specs/fingerprint-wizard/spec.md
@@ -0,0 +1,44 @@
+# Spec Delta: fingerprint-wizard
+
+## ADDED Requirements
+
+### Requirement: Scanner availability is shown and gates capture
+The wizard SHALL consume fpagent's `fprint-device-status`
+`{available, device, captureAgentId}` event and SHALL distinguish the agent
+being offline (socket not connected), connected with no supported reader
+attached (`available` false), and ready (`available` true). Until a status is
+received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
+capture while no supported reader is attached, SHALL re-enable capture when a
+reader becomes available, and SHALL clear availability when the agent
+disconnects. Availability means "a supported reader is attached"; it does not
+guarantee a capture will succeed.
+
+#### Scenario: Reader attached on connect
+- **WHEN** the page connects and fpagent reports `fprint-device-status` with
+ `available: true`
+- **THEN** the wizard shows the agent ready and leaves capture enabled
+
+#### Scenario: No reader attached on connect
+- **WHEN** the page connects and fpagent reports `fprint-device-status` with
+ `available: false`
+- **THEN** the wizard shows that the scanner is not connected, explains captures
+ are disabled, and disables the capture controls
+
+#### Scenario: Unplug blocks capture
+- **WHEN** fpagent reports `available: false` while the operator is capturing or
+ between captures
+- **THEN** the wizard disables the capture controls and a capture attempt is
+ refused
+
+#### Scenario: Replug re-enables capture
+- **WHEN** fpagent reports `available: true` after the reader is reconnected
+- **THEN** the wizard re-enables the capture controls
+
+#### Scenario: Status absent or old agent
+- **WHEN** the connected agent never reports `fprint-device-status`
+- **THEN** availability stays unknown and the wizard does not block capture
+
+#### Scenario: Disconnect clears availability
+- **WHEN** the agent socket disconnects
+- **THEN** the wizard shows the agent offline and does not present the reader as
+ available
diff --git a/openspec/changes/archive/2026-10-02-fpagent-device-availability/tasks.md b/openspec/changes/archive/2026-10-02-fpagent-device-availability/tasks.md
new file mode 100644
index 0000000..3dec65c
--- /dev/null
+++ b/openspec/changes/archive/2026-10-02-fpagent-device-availability/tasks.md
@@ -0,0 +1,40 @@
+# Tasks
+
+## 1. Consume the availability event
+
+- [x] 1.1 In `tools/public/assets/fp/app.js`, add `agent.available` (default
+ `null`) and `agent.device` (default `''`); handle `fprint-device-status` to set
+ both and re-render the tag. Verify the handler is present.
+- [x] 1.2 Clear `available` back to `null` and `device` to `''` on `disconnect`
+ and `connect_error`.
+
+## 2. Show availability
+
+- [x] 2.1 Extend `renderAgentTag` to the four states (offline, scanner not
+ connected, agent ready, agent connected/unknown) with the device name in the
+ tag `title`.
+- [x] 2.2 Add a computed notice (title/subtitle) for agent-offline and
+ no-scanner, and render it as a warning `cds-inline-notification` in the
+ capture panel.
+- [x] 2.3 Disable the capture and recapture buttons while
+ `agent.available === false`, and make `capture()` refuse an attempt in the
+ same state.
+
+## 3. Docs
+
+- [x] 3.1 Add a paragraph to `README.md` under "The Fingerprint Agent".
+- [x] 3.2 Add a line to `tools/public/assets/help.html`.
+
+## 4. Verification
+
+- [x] 4.1 `cd tools && npm test` passes (65 tests, 6 new in
+ `test/fp-wizard.test.js`).
+- [x] 4.2 `openspec validate 2026-10-02-fpagent-device-availability --strict`
+ passes.
+- [x] 4.3 `test/fp-wizard.test.js` loads `app.js` in a vm sandbox and asserts the
+ availability states, tag labels, capture refusal, and disconnect reset.
+- [x] 4.4 End-to-end browser pass against a running agent (tag transitions and
+ disabled controls) — done with Playwright driving the real page against a
+ stubbed agent socket; screenshots in the harness run.
+- [x] 4.5 Archive the change (`openspec archive`), which syncs the
+ `fingerprint-wizard` spec.
diff --git a/openspec/specs/fingerprint-wizard/spec.md b/openspec/specs/fingerprint-wizard/spec.md
index b165134..b4538d4 100644
--- a/openspec/specs/fingerprint-wizard/spec.md
+++ b/openspec/specs/fingerprint-wizard/spec.md
@@ -154,3 +154,44 @@ Submission SHALL be blocked while the OTP is missing.
#### Scenario: Missing OTP blocks submission
- **WHEN** the OTP field is empty for a lookup or save
- **THEN** the corresponding submit control is disabled
+
+### Requirement: Scanner availability is shown and gates capture
+The wizard SHALL consume fpagent's `fprint-device-status`
+`{available, device, captureAgentId}` event and SHALL distinguish the agent
+being offline (socket not connected), connected with no supported reader
+attached (`available` false), and ready (`available` true). Until a status is
+received, availability SHALL be treated as unknown. The wizard SHALL NOT start a
+capture while no supported reader is attached, SHALL re-enable capture when a
+reader becomes available, and SHALL clear availability when the agent
+disconnects. Availability means "a supported reader is attached"; it does not
+guarantee a capture will succeed.
+
+#### Scenario: Reader attached on connect
+- **WHEN** the page connects and fpagent reports `fprint-device-status` with
+ `available: true`
+- **THEN** the wizard shows the agent ready and leaves capture enabled
+
+#### Scenario: No reader attached on connect
+- **WHEN** the page connects and fpagent reports `fprint-device-status` with
+ `available: false`
+- **THEN** the wizard shows that the scanner is not connected, explains captures
+ are disabled, and disables the capture controls
+
+#### Scenario: Unplug blocks capture
+- **WHEN** fpagent reports `available: false` while the operator is capturing or
+ between captures
+- **THEN** the wizard disables the capture controls and a capture attempt is
+ refused
+
+#### Scenario: Replug re-enables capture
+- **WHEN** fpagent reports `available: true` after the reader is reconnected
+- **THEN** the wizard re-enables the capture controls
+
+#### Scenario: Status absent or old agent
+- **WHEN** the connected agent never reports `fprint-device-status`
+- **THEN** availability stays unknown and the wizard does not block capture
+
+#### Scenario: Disconnect clears availability
+- **WHEN** the agent socket disconnects
+- **THEN** the wizard shows the agent offline and does not present the reader as
+ available
diff --git a/tools/public/assets/fp/app.js b/tools/public/assets/fp/app.js
index b8e5362..d880139 100644
--- a/tools/public/assets/fp/app.js
+++ b/tools/public/assets/fp/app.js
@@ -20,7 +20,9 @@ Vue.config.ignoredElements = [/^cds-/];
session: null,
tick: Date.now(),
socket: null,
- agent: { connected: false, version: '', captureAgentId: '' },
+ // available is tri-state: null = not yet reported (old agent), true/false
+ // = the last fprint-device-status. Unknown is not blocked.
+ agent: { connected: false, version: '', captureAgentId: '', available: null, device: '' },
domain: '',
reference: '',
otpLookup: '',
@@ -68,6 +70,27 @@ Vue.config.ignoredElements = [/^cds-/];
if (!this.session || !this.session.domainsCheckedAt) return '';
return new Date(this.session.domainsCheckedAt).toLocaleTimeString();
},
+ // Warning shown in the capture panel when the agent cannot capture.
+ scannerNotice: function () {
+ if (!this.agent.connected) {
+ return {
+ title: 'Capture agent offline',
+ subtitle: 'The fpagent service is not reachable on this machine. Captures are disabled until it reconnects.',
+ };
+ }
+ if (this.agent.available === false) {
+ return {
+ title: 'Scanner not connected',
+ subtitle: 'Attach the fingerprint reader. Captures are disabled until the agent detects it.',
+ };
+ }
+ return null;
+ },
+ // Capture is allowed when the agent is connected and no status has said
+ // the scanner is missing. Unknown availability (old agent) stays allowed.
+ captureReady: function () {
+ return this.agent.connected && this.agent.available !== false;
+ },
},
created: function () {
@@ -139,18 +162,38 @@ Vue.config.ignoredElements = [/^cds-/];
renderAgentTag: function () {
var el = document.getElementById('agent-status');
if (!el) return;
- el.textContent = this.agent.connected ? 'Agent connected' : 'Agent offline';
- el.setAttribute('type', this.agent.connected ? 'green' : 'red');
- el.setAttribute('title', this.agent.version ? 'fpagent ' + this.agent.version : '');
+ var label, type, title = this.agent.version ? 'fpagent ' + this.agent.version : '';
+ if (!this.agent.connected) {
+ label = 'Agent offline'; type = 'red';
+ } else if (this.agent.available === false) {
+ label = 'Scanner not connected'; type = 'red';
+ } else if (this.agent.available === true) {
+ label = 'Agent ready'; type = 'green';
+ } else {
+ // Connected but no status yet (or an agent that does not report it).
+ label = 'Agent connected'; type = 'green';
+ }
+ if (this.agent.available === true && this.agent.device) {
+ title += ' · ' + this.agent.device;
+ }
+ el.textContent = label;
+ el.setAttribute('type', type);
+ el.setAttribute('title', title);
},
connectAgent: function () {
var self = this;
this.socket = io(this.session.agentUrl, { reconnectionDelayMax: 5000 });
+ var offline = function () {
+ self.agent.connected = false;
+ self.agent.available = null;
+ self.agent.device = '';
+ self.renderAgentTag();
+ };
this.socket.on('connect', function () { self.agent.connected = true; self.renderAgentTag(); });
- this.socket.on('disconnect', function () { self.agent.connected = false; self.renderAgentTag(); });
- this.socket.on('connect_error', function () { self.agent.connected = false; self.renderAgentTag(); });
+ this.socket.on('disconnect', offline);
+ this.socket.on('connect_error', offline);
this.socket.on('agent-hello', function (data) {
self.agent.version = (data && data.version) || '';
@@ -158,6 +201,12 @@ Vue.config.ignoredElements = [/^cds-/];
self.renderAgentTag();
});
+ this.socket.on('fprint-device-status', function (data) {
+ self.agent.available = !!(data && data.available);
+ self.agent.device = (data && data.device) || '';
+ self.renderAgentTag();
+ });
+
this.socket.on('fprint-enroll-status', function (data) {
if (!self.capturing) return;
self.fingers[self.capturing].stage = (data && data.stage) || 0;
@@ -213,6 +262,11 @@ Vue.config.ignoredElements = [/^cds-/];
capture: function (finger) {
if (this.capturing || !this.socket || !this.domain || !this.reference) return;
+ if (!this.captureReady) {
+ var notice = this.scannerNotice;
+ if (notice) this.showNotif('warning', notice.title, notice.subtitle);
+ return;
+ }
// Preserve a previous good capture so a cancelled/failed recapture
// can restore it instead of destroying it.
var old = this.fingers[finger];
diff --git a/tools/public/assets/fp/index.html b/tools/public/assets/fp/index.html
index d368113..703de0f 100644
--- a/tools/public/assets/fp/index.html
+++ b/tools/public/assets/fp/index.html
@@ -84,6 +84,10 @@
Two fingers are required · place the finger on the scanner each scan
+
The scanner runs locally through the fpagent service; scans never leave this machine. Domain permissions are re-checked periodically and cached for a few minutes.
+The scanner runs locally through the fpagent service; scans never leave this machine. Domain permissions are re-checked periodically and cached for a few minutes. The wizard shows whether a supported scanner is connected and disables capture until one is.
diff --git a/tools/test/fp-wizard.test.js b/tools/test/fp-wizard.test.js new file mode 100644 index 0000000..69f0b39 --- /dev/null +++ b/tools/test/fp-wizard.test.js @@ -0,0 +1,143 @@ +// The /fingerprint wizard's scanner-availability logic. app.js is a browser +// IIFE, so it is loaded in a vm sandbox with stubbed browser globals and its +// Vue options are exercised directly (no Vue runtime needed). +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; + +const SRC = fs.readFileSync(new URL('../public/assets/fp/app.js', import.meta.url), 'utf8'); + +function loadApp() { + let options = null; + let socket = null; + const els = {}; + const document = { + getElementById(id) { + return (els[id] ||= { + textContent: '', + attrs: {}, + setAttribute(k, v) { this.attrs[k] = v; }, + getAttribute(k) { return this.attrs[k]; }, + }); + }, + }; + const sandbox = { + window: { + __SESSION__: { agentUrl: 'http://127.0.0.1:9000', domains: [] }, + location: { href: '' }, + }, + document, + Vue: class Vue { static config = {}; constructor(o) { options = o; } }, + io() { + socket = { handlers: {}, on(e, cb) { this.handlers[e] = cb; return this; } }; + return socket; + }, + setInterval: () => 0, + setTimeout: () => 0, + clearTimeout: () => {}, + console, + }; + vm.runInNewContext(SRC, sandbox); + return { + get options() { return options; }, + get socket() { return socket; }, + getElement: (id) => document.getElementById(id), + }; +} + +function makeVm(app) { + const o = app.options; + const inst = Object.assign({}, o.data); + for (const [k, fn] of Object.entries(o.methods)) inst[k] = fn.bind(inst); + for (const [k, fn] of Object.entries(o.computed)) { + Object.defineProperty(inst, k, { get: fn.bind(inst), configurable: true }); + } + inst.$forceUpdate = () => {}; + inst.loadSession(); + inst.connectAgent(); + return inst; +} + +test('disconnected agent blocks capture and warns', () => { + const vmInst = makeVm(loadApp()); + assert.equal(vmInst.captureReady, false); + assert.equal(vmInst.scannerNotice.title, 'Capture agent offline'); +}); + +test('available:false blocks capture and warns; true re-enables', () => { + const app = loadApp(); + const vmInst = makeVm(app); + const h = app.socket.handlers; + h.connect(); + h['fprint-device-status']({ available: false, device: '' }); + assert.equal(vmInst.captureReady, false); + assert.equal(vmInst.scannerNotice.title, 'Scanner not connected'); + + h['fprint-device-status']({ available: true, device: 'Digital Persona U.are.U' }); + assert.equal(vmInst.captureReady, true); + assert.equal(vmInst.scannerNotice, null); + assert.equal(vmInst.agent.device, 'Digital Persona U.are.U'); +}); + +test('unknown availability (old agent) does not block capture', () => { + const app = loadApp(); + const vmInst = makeVm(app); + app.socket.handlers.connect(); + assert.equal(vmInst.agent.available, null); + assert.equal(vmInst.captureReady, true); + assert.equal(vmInst.scannerNotice, null); +}); + +test('status tag reflects offline, connected, no-scanner and ready', () => { + const app = loadApp(); + const vmInst = makeVm(app); + const h = app.socket.handlers; + const el = app.getElement('agent-status'); + + vmInst.renderAgentTag(); + assert.equal(el.textContent, 'Agent offline'); + assert.equal(el.getAttribute('type'), 'red'); + + h.connect(); + assert.equal(el.textContent, 'Agent connected'); + + h['agent-hello']({ version: 'abc123', captureAgentId: 'aid' }); + assert.equal(el.getAttribute('title'), 'fpagent abc123'); + + h['fprint-device-status']({ available: false }); + assert.equal(el.textContent, 'Scanner not connected'); + assert.equal(el.getAttribute('type'), 'red'); + + h['fprint-device-status']({ available: true, device: 'U.are.U 4500' }); + assert.equal(el.textContent, 'Agent ready'); + assert.equal(el.getAttribute('type'), 'green'); + assert.match(el.getAttribute('title'), /abc123/); + assert.match(el.getAttribute('title'), /U\.are\.U 4500/); +}); + +test('capture is refused when no reader is attached', () => { + const app = loadApp(); + const vmInst = makeVm(app); + const h = app.socket.handlers; + h.connect(); + h['fprint-device-status']({ available: false }); + vmInst.domain = 'uc'; + vmInst.reference = 'UC/1'; + vmInst.capture('right-thumb'); + assert.equal(vmInst.capturing, null); + assert.equal(vmInst.notif.show, true); +}); + +test('disconnect clears availability', () => { + const app = loadApp(); + const vmInst = makeVm(app); + const h = app.socket.handlers; + h.connect(); + h['fprint-device-status']({ available: true, device: 'U.are.U' }); + h.disconnect(); + assert.equal(vmInst.agent.connected, false); + assert.equal(vmInst.agent.available, null); + assert.equal(vmInst.agent.device, ''); + assert.equal(vmInst.captureReady, false); +});