Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .omo/evidence/task-30c-b2b6-pr.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Task 30c — B2/B5/B6 fix PR

- Branch: `fix/usb-mode-certify-b2-b5-b6`
- Base: current `origin/main` (`4b4b206`, PR #12 squash)
- PR: https://github.com/CERALIVE/modem-stack/pull/14
- Status: open; not merged
- CI: green — `Lint, typecheck, test (control + cli)` (run `32389680098`)

The branch contains the five post-consolidation commits fixing the hardware-confirmed B2/B5/B6 issues. The real-hardware findings and fix rationale are documented in:

- `.omo/evidence/task-30-real-hardware.md`
- `.omo/evidence/task-30b-b2-b5-b6-fix.md`

Local verification after cherry-picking onto `origin/main`:

- `bun test`: 1321 passed, 0 failed
- `bun run typecheck`: passed

The rebased tree matches the combined net changes from the five commits originally based on `90cb14a`; no AI trailers were present in the five resulting commits.
34 changes: 16 additions & 18 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -457,20 +457,18 @@ transform in `control/src/usb-mode/{ingestion,promotion-review,usb-devices-parse
- Per-SKU capture runbooks are `docs/BENCH.md` **RB-11 … RB-15** (RB-16 is the FM350
USB-vs-PCIe probe — its 2026-08-16 bench run found the unit not connected, so
`docs/FM350-DECISION.md`'s three-gate ledger stays OPEN with the probe evidence recorded;
RB-17 is modem-flap resilience). All are `[PARTIAL]` — **six** named blockers are recorded
in `docs/BENCH.md` § "Per-SKU certification", re-verified live on 2026-08-18:
**B1 CLEARED** (`usbutils` is now on the board), **B3 downgraded** (`socat` is present, so
a manual query-only AT session works; the CLI's `benchAtSender` still rejects every send),
and **B2 promoted to hardware-proven** — `certify` matches its device by `ifname`, which
the enumerator never populates, so **every real bundle comes out with no `sku` and empty
`udevProperties`** and the ingestion seam refuses it `sku-missing`. Two blockers are new:
**B5**, the shared redactor does not mask `imei` / `equipment-identifier`, so a real
bundle carries every bench modem's IMEI and must not be committed or pasted into a review
comment; and **B6**, `skuOf` reads `firmwarePrefix` from udev `ID_REVISION`, which is the
USB `bcdDevice` rather than the modem firmware revision, so a catalog entry built from a
capture would not actually be firmware-keyed. B2 and B6 are pinned by
`control/src/usb-mode/ingestion.hardware.test.ts`. No SKU is certified and no matrix row
is promoted.
RB-17 is modem-flap resilience). All are `[PARTIAL]` — the **six-entry** status ledger is
in `docs/BENCH.md` § "Per-SKU certification". B1 is cleared; B3 is partially cleared
(`socat` permits a manual query-only AT session, but `benchAtSender` still rejects sends);
B4 remains open. The hardware-proven capture defects B2/B5/B6 were fixed in software on
2026-08-20: USB snapshots retain their `/sys` path, `certify` correlates MM
`Device`/`Physdev` to the most-specific USB parent, `skuOf` receives MM `Modem.Revision`
rather than USB `ID_REVISION`, and the shared redactor masks MM/mmcli IMEI and equipment-
identifier spellings while preserving model/vendor/SKU facts. Realistic RM530N tests pin
`0504` versus `RM530NGLAAR05A01M4G`. The snapshot contract lives in
`control/src/backend/usb-device-snapshot.ts` and remains re-exported by the classifier. The
fixed build has **not** been rerun on the board, so no post-fix hardware bundle, certified
SKU, or promoted matrix row is claimed.
- **Bench composition evidence** for the SIMCom SIM7600G-H and the carrier-mounted Fibocom
FM350-GL is recorded in [`docs/COMPOSITION-EVIDENCE.md`](docs/COMPOSITION-EVIDENCE.md) —
descriptors, driver bindings, firmware revisions, and the read-back state of each vendor's
Expand Down Expand Up @@ -688,10 +686,10 @@ returns an IMSI and an ICCID, and they ARE retained — normalization does not g
what a diagnostician may need. Anything that logs, serializes or files a diagnostics block
must route it through `redactObservationDiagnostics`, which runs the package's own key-based
`redact`, so the classes masked here are the classes masked everywhere else. Retention and
disclosure are separate decisions; this layer only guarantees the first. Note the recorded
`B5` finding still applies: the shared redactor does not mask `imei` /
`equipment-identifier`, so a caller that puts `Modem.EquipmentIdentifier` in a raw record
owns that exposure.
disclosure are separate decisions; this layer only guarantees the first. The former B5
gap is closed: the shared redactor masks `imei`, `EquipmentIdentifier`, separator variants,
and dotted mmcli spellings. The raw diagnostics block still contains source values in
memory, so every serialization boundary must continue to call the redactor.

**PER-METRIC PROVENANCE, INCLUDING PER-METRIC AUTHORITY.** One normalized observation folds
several provider reads together — HiLink answers `monitoring_status` and `device_signal`
Expand Down
2 changes: 1 addition & 1 deletion cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ to mature the package and (with `certify`) capture per-SKU certification bundles
| `watch` | Live event stream. Prints `+ ADDED` / `~ CHANGED` / `- REMOVED` per change and `! SOURCE-UNAVAILABLE` on a bus drop. A bus drop / MM restart marks a modem source-unavailable with its row **retained** — never a removal (A3.1 epoch authority). `--duration <ms>` / `--events <n>` bound the run; otherwise it runs until Ctrl-C. |
| `apply --policy <file>` | Reads a JSON/YAML desired-state policy, derives the modem's durable binding key (refusing an ambiguous identity), runs the reconcile planner, applies the ops, and prints one receipt per policy dimension. |
| `set-usb-mode <slot> <target> --confirm` | Runs a certified USB-mode transition. **Omitting `--confirm` refuses the transition with zero side effects.** `<target>` is one of `qmi` / `mbim` / `ecm-ncm`. |
| `certify <slot>` | Captures a redacted, schema-validated certification bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a redacted `GetManagedObjects`, and a bounded signal window. `--transition <mode>` adds transition evidence (before/after descriptors, the executed AT command, and the port-drop / re-enumeration timeline) shaped to drop straight into an A4.2 catalog entry. Prints `CERTIFY OK: sha256=<hash> …` — the sha256 is the value a reviewer records in a catalog entry's `evidenceBundleSha256`. Real captures are marked `synthetic: false`; ICCID / IMSI / EID are masked; a malformed capture exits non-zero with a clear error rather than writing a broken bundle. `--output <file>` writes the bundle JSON (default stdout). |
| `certify <slot>` | Captures a redacted, schema-validated certification bundle: `lsusb -v`, `usb-devices`, the slot's udev properties, an `mmcli -K` dump, a redacted `GetManagedObjects`, and a bounded signal window. The selected MM object's `Device`/`Physdev` sysfs path is matched to the most-specific enumerated USB parent (never by `ifname`), and `sku.firmwarePrefix` comes from MM `Modem.Revision`, not USB `ID_REVISION`. `--transition <mode>` adds transition evidence shaped to drop into an A4.2 catalog entry. Prints `CERTIFY OK: sha256=<hash> …`; real captures are `synthetic: false`, and ICCID / IMSI / EID / IMEI / equipment identifiers are masked while model/vendor/SKU facts remain visible. A malformed capture exits non-zero rather than writing a broken bundle. `--output <file>` writes JSON (default stdout). |
| `usage` | Prints the data-usage sampler snapshot (per-slot cumulative-cycle bytes; advisory threshold). |
| `unlock-pin [slot]` / `unlock-puk [slot]` | Prompts for the PIN / PUK with **terminal echo disabled** (the secret is never printed back) and submits it. |
| `hil-cycle <slot> --hub-map <file>` | The hardware-in-the-loop port-cycle harness (bench runbook **RB-10**). The executable adapter lives in the CLI, not the published control provider set, and first acquires non-blocking exclusive ownership at `/run/ceralive/modem-control.lock`; contention refuses immediately. It captures pre-state (the `/sys/bus/usb/devices/*` sweep — there is no `lsusb` on the bench image — plus `mmcli -L`), drives the mapped port, then asserts the modem **left** the USB bus, **came back** at the same udev `ID_PATH` within the deadline, and that ModemManager re-detects the **same** `modem.generic.device` slot UID. Prints `HIL-CYCLE PASS slot=<s> disappeared=<ms> reenumerated=<ms>`, or `HIL-CYCLE FAIL slot=<s> reason=<r>` with a named reason and a non-zero exit. **A zero exit from `uhubctl` is never the gate** — the modem's disappearance from the bus is. Needs `sudo` for `uhubctl`; refuses with zero side effects when the slot is not in the map. |
Expand Down
75 changes: 52 additions & 23 deletions cli/src/certify/bundle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,22 @@ const ICCID = '8900000000000000123';
const IMSI = '001010000000123';
const EID = '89033024000000000000000000012345';
const IMEI = '350000000000001';
const MODEM_REVISION = 'RM530NGLAAR05A01M4G';
const USB_REVISION = '0504';
const RM530N_SYSFS_PATH =
'/sys/devices/platform/fc400000.usb/xhci-hcd.0.auto/usb4/4-1/4-1.4/4-1.4.4';

const LSUSB = `Bus 001 Device 004: ID 2c7c:0125 Quectel EG25-G
const LSUSB = `Bus 004 Device 009: ID 2c7c:0801 Quectel RM530N-GL
Device Descriptor:
bLength 18
idVendor 0x2c7c Quectel
idProduct 0x0125
idProduct 0x0801
iSerial 3 abcdef123456
`;

const USB_DEVICES = `T: Bus=01 Dev#= 4 Spd=480
P: Vendor=2c7c ProdID=0125 Rev=03.18
S: Product=EG25-G
const USB_DEVICES = `T: Bus=04 Dev#= 9 Spd=5000
P: Vendor=2c7c ProdID=0801 Rev=05.04
S: Product=RM530N-GL
`;

const MMCLI_K = `modem.generic.device-identifier : 0123456789abcdef
Expand All @@ -41,14 +45,17 @@ sim.properties.eid : ${EID}
sim.properties.operator-name : CeraTel
`;

const TREE = [
const TREE: DecodedManagedObjects = [
[
'/org/freedesktop/ModemManager1/Modem/0',
[
[
'org.freedesktop.ModemManager1.Modem',
[
['EquipmentIdentifier', { signature: 's', value: IMEI }],
['Device', { signature: 's', value: RM530N_SYSFS_PATH }],
['Physdev', { signature: 's', value: RM530N_SYSFS_PATH }],
['Revision', { signature: 's', value: MODEM_REVISION }],
['Sim', { signature: 'o', value: '/org/freedesktop/ModemManager1/SIM/0' }],
['State', { signature: 'i', value: 8 }],
],
Expand All @@ -69,24 +76,24 @@ const TREE = [
],
],
],
] as unknown as DecodedManagedObjects;
];

const DEVICE: UsbDeviceSnapshot = {
vendorId: '2c7c',
productId: '0125',
model: 'EG25-G',
firmwareRevision: 'SYNTHETICFW01',
productId: '0801',
model: 'RM530N-GL',
firmwareRevision: USB_REVISION,
bDeviceClass: 0,
interfaces: [
{ interfaceClass: 255, interfaceSubClass: 255, interfaceProtocol: 255, driver: 'qmi_wwan' },
],
physicalUid: 'usb-1-2',
ifname: 'wwan0',
physicalUid: 'platform-xhci-hcd.0.auto-usb-0:1.4.4',
ifname: 'wwan3',
udevProperties: {
ID_VENDOR_ID: '2c7c',
ID_MODEL_ID: '0125',
ID_MODEL: 'EG25-G',
ID_REVISION: 'SYNTHETICFW01',
ID_MODEL_ID: '0801',
ID_MODEL: 'RM530N-GL',
ID_REVISION: USB_REVISION,
ID_SERIAL_SHORT: 'abcdef123456',
// A udev rule that surfaced the SIM ICCID — proves redaction reaches udev props.
iccid: ICCID,
Expand Down Expand Up @@ -120,12 +127,16 @@ function fakeDeps(script: RunnerScript = {}): BaseCaptureDeps {
if (command === 'mmcli') return Promise.resolve(script.mmcli ?? ok(MMCLI_K));
return Promise.resolve({ stdout: '', stderr: `unknown ${command}`, exitCode: 127 });
},
fetchManagedObjects: () => Promise.resolve(TREE),
captureSignalWindow: () => Promise.resolve(SIGNALS),
};
}

const INPUT = { mmcliTarget: '/org/freedesktop/ModemManager1/Modem/0', device: DEVICE };
const INPUT = {
managedObjects: TREE,
modemPath: '/org/freedesktop/ModemManager1/Modem/0',
mmcliTarget: '/org/freedesktop/ModemManager1/Modem/0',
device: DEVICE,
};

const buildFrom = (base: BaseCaptureParts, synthetic: boolean) =>
buildCertificationBundle({ slot: 'Modem/0', synthetic, capturedAtMs: 1000, base });
Expand All @@ -135,33 +146,46 @@ test('builds a schema-valid synthetic bundle with a stable, reproducible sha256'
expect(first.bundle.schemaVersion).toBe(1);
expect(first.bundle.synthetic).toBe(true);
expect(first.bundle.sku).toEqual({
vidPid: '2c7c:0125',
model: 'EG25-G',
firmwarePrefix: 'SYNTHETICFW01',
vidPid: '2c7c:0801',
model: 'RM530N-GL',
firmwarePrefix: MODEM_REVISION,
});
expect(first.sha256).toMatch(/^[0-9a-f]{64}$/);

const second = buildFrom(await captureBase(fakeDeps(), INPUT), true);
expect(second.sha256).toBe(first.sha256);
});

test('uses Modem.Revision rather than USB ID_REVISION for the firmware-keyed SKU', async () => {
// Given
const deps = fakeDeps();

// When
const base = await captureBase(deps, INPUT);

// Then
expect(base.sku?.firmwarePrefix).toBe(MODEM_REVISION);
expect(base.sku?.firmwarePrefix).not.toBe(USB_REVISION);
});

test('a real capture is unambiguously marked synthetic:false', async () => {
const { bundle } = buildFrom(await captureBase(fakeDeps(), INPUT), false);
expect(bundle.synthetic).toBe(false);
});

test('redacts ICCID / IMSI / EID across mmcli-K, managed objects, and udev', async () => {
test('redacts subscriber and modem identifiers across mmcli-K, managed objects, and udev', async () => {
const { bundle } = buildFrom(await captureBase(fakeDeps(), INPUT), true);
const serialized = JSON.stringify(bundle);
expect(serialized).not.toContain(ICCID);
expect(serialized).not.toContain(IMSI);
expect(serialized).not.toContain(EID);
expect(serialized).not.toContain(IMEI);

// mmcli -K keyfile: subscriber secrets masked by dotted-segment; IMEI retained.
expect(bundle.modemManager.mmcliKeyfile['sim.properties.iccid']).toBe('[redacted]');
expect(bundle.modemManager.mmcliKeyfile['sim.properties.imsi']).toBe('[redacted]');
expect(bundle.modemManager.mmcliKeyfile['sim.properties.eid']).toBe('[redacted]');
expect(bundle.modemManager.mmcliKeyfile['modem.3gpp.imei']).toBe(IMEI);
expect(bundle.modemManager.mmcliKeyfile['modem.generic.equipment-identifier']).toBe('[redacted]');
expect(bundle.modemManager.mmcliKeyfile['modem.3gpp.imei']).toBe('[redacted]');

// GetManagedObjects: MM's SimIdentifier surfaced as `iccid` and masked; Imsi/Eid masked.
const sim = bundle.modemManager.managedObjects['/org/freedesktop/ModemManager1/SIM/0']?.[
Expand All @@ -171,6 +195,11 @@ test('redacts ICCID / IMSI / EID across mmcli-K, managed objects, and udev', asy
expect(sim.Imsi).toBe('[redacted]');
expect(sim.Eid).toBe('[redacted]');
expect(sim.OperatorName).toBe('CeraTel');
const modem =
bundle.modemManager.managedObjects['/org/freedesktop/ModemManager1/Modem/0']?.[
'org.freedesktop.ModemManager1.Modem'
];
expect(modem).toEqual(expect.objectContaining({ EquipmentIdentifier: '[redacted]' }));

// udev: redaction reaches the props (the ICCID-bearing rule is masked); the device
// serial is equipment identity (non-subscriber), retained per the A2.1 policy.
Expand Down
17 changes: 10 additions & 7 deletions cli/src/certify/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,11 @@
// broken bundle. The subscriber secrets live in the `mmcli -K` keyfile and the managed
// objects, both captured as OBJECTS so the shared key-based redactor can mask them.

import type {
DecodedManagedObjects,
SkuDiscriminator,
UsbDeviceSnapshot,
import {
type DecodedManagedObjects,
readRevision,
type SkuDiscriminator,
type UsbDeviceSnapshot,
} from '@ceralive/modem-control';
import type { SignalRecord } from './bundle-schema';
import type { CommandResult } from './command-runner';
Expand All @@ -35,14 +36,15 @@ export interface BaseCaptureParts {
/** The injectable seams the base capture reads from (fakes drive the synthetic tests). */
export interface BaseCaptureDeps {
run(command: string, args: readonly string[]): Promise<CommandResult>;
fetchManagedObjects(): Promise<DecodedManagedObjects>;
captureSignalWindow(): Promise<readonly SignalRecord[]>;
}

/** The target selection the base capture needs (matched device + the mmcli selector). */
export interface BaseCaptureInput {
/** The matched target USB device — source of the slot's udev properties and SKU. */
readonly device?: UsbDeviceSnapshot;
readonly managedObjects: DecodedManagedObjects;
readonly modemPath: string;
/** The `mmcli -m <target>` modem selector (a modem index or D-Bus path). */
readonly mmcliTarget: string;
}
Expand Down Expand Up @@ -110,10 +112,11 @@ export async function captureBase(
const lsusb = await captureLsusb(deps);
const usbDevices = await captureUsbDevices(deps);
const mmcliKeyfile = await captureMmcli(deps, input.mmcliTarget);
const managedObjects = objectifyManagedObjects(await deps.fetchManagedObjects());
const managedObjects = objectifyManagedObjects(input.managedObjects);
const signalWindow = await deps.captureSignalWindow();
const udevProperties = { ...(input.device?.udevProperties ?? {}) };
const sku = input.device !== undefined ? skuOf(input.device) : undefined;
const firmwareRevision = readRevision(input.managedObjects, input.modemPath);
const sku = input.device !== undefined ? skuOf(input.device, firmwareRevision) : undefined;

return {
...(sku !== undefined ? { sku } : {}),
Expand Down
14 changes: 8 additions & 6 deletions cli/src/certify/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,17 +120,19 @@ export function descriptorsOf(device: UsbDeviceSnapshot): ExpectedDescriptors {
}

/**
* Build the SKU discriminator (VID:PID + model + firmware prefix) from a device, or
* `undefined` when the device lacks a model or firmware string — the three parts the
* certified catalog matches on. A partial SKU is not a certified device.
* Build the SKU discriminator from USB identity plus ModemManager's firmware revision.
* A partial SKU is not a certified device.
*/
export function skuOf(device: UsbDeviceSnapshot): SkuDiscriminator | undefined {
if (device.model === undefined || device.firmwareRevision === undefined) {
export function skuOf(
device: UsbDeviceSnapshot,
firmwareRevision: string | undefined,
): SkuDiscriminator | undefined {
if (device.model === undefined || firmwareRevision === undefined) {
return undefined;
}
return {
vidPid: `${device.vendorId}:${device.productId}`,
model: device.model,
firmwarePrefix: device.firmwareRevision,
firmwarePrefix: firmwareRevision,
};
}
Loading