Skip to content

v1.22.1 - #51

Merged
bethropolis merged 7 commits into
mainfrom
dev/next
Oct 5, 2026
Merged

bethropolis merged 7 commits into
mainfrom
dev/next

Conversation

@bethropolis

@bethropolis bethropolis commented Oct 5, 2026 •

Copy link
Copy Markdown
Owner

SMS is now opt-in

A bare kcd watch printed incoming message bodies (sms.incoming with sender and text). Message delivery is now consent-gated at Subscriber.matches, the bus chokepoint no caller can bypass: unfiltered subscribers never match the SMS types, naming them in --events is the opt-in. . sms.attachment stays published but excluded from bare watch: no message body, and it is the only way kcd sms attachment reports the saved path.

Android's latch (haveMessagesBeenRequested) is one-way and not fixable from kcd; recorded under Known Gaps in AGENTS.md. Mitigated, not solved.

Pairing for clients that cannot prompt

kcd pair --advertise-only [--json]: opens the advertisement window and accepts nothing. Panels and widgets keep their existing watch stream, read candidates from pair.requested (already carries verificationKey), and accept per device with kcd pair <device-id>. Combining with --yes or a device ID is an error. CLI plumbing only — no daemon, IPC, or protocol change. Documented in CLI.md, CLIENT_GUIDE.md (do not shell out to kcd pair -y), and IPC_PROTOCOL.md.

Fixes

  • battery --json with no positional reported "deviceId": "" while querying the right device. Now reports the resolved ID; runBattery extracted for testability.
  • mpris seek rejected negative values (flag parsing) and silently did nothing when no position was tracked. SkipFlagParsing + splitSeekArgs; relative seeks without a tracked position now error.

Docs and hygiene

  • Comment policy encoded in AGENTS.md and enforced with godot/godox; narration stripped, conclusions kept.
  • Fixed a broken setVolume example (no value key), a duplicated sftp mount synopsis, and three smaller drifts.
  • CLI.md now states flags must precede positionals (POSIX, urfave/cli v2) — trailing flags are silently ignored, not rejected.

All gates green locally: go vet, golangci-lint 0 issues, go test -race ./..., integration, static build, hk check --all, goreleaser check. Live-verified against a connected phone: negative seek, battery JSON both paths, advertise-only start/stop in both modes.

Comments here are load-bearing — security rationale, protocol quirks,
error strings worth matching on — so a naive volume cut would delete
the value. The problem was never density (10.2% overall, normal for
Go) but altitude: restatements of the next line, narration of past
changes, and references to a planning process that does not exist in
the repo.

The policy in AGENTS.md is deliberately worded as "required, not
optional" for the categories that matter. A plain "explain why, never
what" reads against the standing instruction to add no comments, and
the next person to touch this tree would take that as licence to strip
the security rationales out.

Applied to production code only; test comments document real protocol
behaviour and stay. Every comment touching validation, TLS, key
handling, timeouts or the wire format keeps its reason.

Changes, by kind:

- Dropped the two "Phase 2" planning references (sms/send.go,
  ipc/server.go). The ipc one gained the actual reason for the
  chmod, which is worth more than the label was.
- Converted change narration to its conclusion in 8 places: "used to
  fire on any message containing fusermount" now states the narrow rule
  directly, "a graceful stop used to leave every mount live" now says
  it must unmount explicitly.
- Removed 20 inline restatements ("Send our local battery to the
  phone", "Read capacity (0-100)", "Send OK response to indicate
  stream is starting").
- Merged internal/log/log.go's four identical level comments into one
  statement of why the forwards exist.
- Split two comments that had fused a contract to a history:
  mounttable.go's liveMountPoint had run straight into mountExists'
  doc without a blank line, and sftp.Info repeated "returns nil" twice.
- Compressed sftp.UnmountAll from 23 comment lines to 11 by moving the
  ctx contract into the godoc where it belongs and the shutdown
  rationale into the body. The ctx semantics are load-bearing — they
  are the fix for a race where the caller returned while goroutines
  were still logging — so they stay verbatim in substance.
- Added the reason for AllowedTimestampDiff (clock skew bound) and
  explained what ShareBody carries that Android's packet type omits.

Enforcement: enabled godot and godox. Both are already available —
golangci-lint is pinned and already runs in hk and CI — so this adds
no dependency and no workflow change. godox reports zero today and is
pure insurance; godot caught the two comments above, which is how they
were found rather than by eye.

Comment lines, production Go: 2449 -> 2422. The small number is the
point: this is a precision pass, not a diet. The density was never the
problem.
Issue #50: `kcd mpris seek -10s` was rejected as an unknown flag. The
offset parser was always right about "-10s"; urfave/cli never handed it
the string, because it reads any argument starting with "-" as a flag.
`-- -10s` worked around it, so the help text was advertising a form
that did not parse.

`kcd mpris seek` now sets SkipFlagParsing and recovers --device/--player
itself. A "-" followed by a digit is the offset, which is unambiguous
here because neither flag takes a numeric value. Anything else starting
with "-" stays an error, so a typo like "--vol 5" cannot seek the wrong
device by being silently ignored. SkipFlagParsing also stops urfave/cli
handling -h, so that is recognised and routed to the subcommand help.

Second fix, same plugin. A relative seek is sent to the phone as an
absolute SetPosition, since phones implement SetPosition but ignore
Seek. That conversion needs the tracked playback position, and when
there was none the old code fell through and sent nothing at all -- a
no-op that still reported success, which is the failure mode #49 set
out to fix. It now returns an error naming the cause. An absolute
setPosition or bare number needs no cached state and is unaffected.

While here: SendAction's doc comment had drifted onto the
canonicalActions var two declarations above it, so the function that
sends on two packet types documented none of it.

Verified against a paired phone with a track playing: -10s, -20s, +30s,
+20s and the absolute 1m30s all move the position, `-- -15s` still
works, and clamping holds at both ends of the track.
The `setVolume` snippet in CLIENT_GUIDE was wrong in a way that would
have wasted someone's afternoon: MprisActionPayload has no `value` key,
so {"action": "setVolume", "value": 50} unmarshals to a nil Volume,
sends a bare action, and returns ok. Volume has always been its own
field. Both copies corrected, and seek added alongside since the
relative/absolute distinction is the thing a client author has to get
right.

Also:

- CLI.md listed `kcd sftp mount` twice, once without the [--ro] flags
  and once with, and never said where mounts land by default. Merged
  into one synopsis.
- IPC_PROTOCOL.md did not say that a relative seek without a tracked
  position is an error. A client author reading it would assume a
  no-op.
- ARCHITECTURE.md's mpris row predated both the action-name canonical
  mapping and the SetPosition conversion.
- device_id was the only scalar config key absent from the example
  TOML. It is generated and written back automatically, and editing it
  breaks pairing for every device, so it is worth a warning next to the
  other identity keys.

Checked while here, all correct: all 32 event types appear in
IPC_PROTOCOL.md; every CLI subcommand and flag is documented; the other
84 scalar config keys are present in the example TOML.
SMS content reached a client three ways, only one intentional. A bare
`kcd watch` subscribed to every event, so it both received message
bodies and armed the phone, since arming was gated on having a
subscriber for sms.incoming. `kcd sms conversation` and `conversations`
are on-demand history reads, but they latch the phone armed.

That latch is the root cause and it is not ours to clear: Android sets
haveMessagesBeenRequested from any sms.request* packet
(SMSPlugin.kt:313,327) and nothing resets it, not even onDestroy. Once
armed, the phone pushes every new message for the life of the
connection. So `always_arm = false` only ever stopped kcd from asking;
it could not stop kcd from being asked by something else, and could
never un-ask.

Two changes, because one gate is not enough against a one-way latch:

- An empty watch filter no longer means "everything". It means every
  type except the opt-in ones, so SMS has to be named with -e. This
  closes both halves of the bare-watch problem: no body delivered, and
  no arming triggered.
- The bus publish is gated on [sms] publish_incoming, default false.
  This is the half that matters, because it holds even when the phone
  was armed by something else.

notify_incoming is untouched and independent, so a desktop popup does
not imply the body is exposed over IPC.

sms.attachment stays published but is excluded from a bare watch. Its
payload is a saved path with no message body, and it is the only way the
`kcd sms attachment` command can report where the file landed.

All() is new, built from the event constants and checked against bus.go
by parsing it — a new event added without being listed would otherwise
be silently dropped from every default watch stream.

The packets still cross the wire once the phone is armed; this stops
content leaving the daemon, not the phone sending. Recorded under Known
Gaps in AGENTS.md, along with the fact that ending it means disabling
the plugin on the device.
The config flag was redundant and inert where it mattered. armed()
never consulted it, so setting it did not stop the phone being armed —
it only suppressed the publish. The result was that naming
`-e sms.incoming` armed the phone and then delivered nothing, silently,
with no error and no hint of what to set.

The filter is sufficient on its own, and is the only real gate. Once an
unfiltered subscriber stops matching SMS, "has a subscriber for
sms.incoming" means "someone typed -e sms.incoming". Every subscriber in
the daemon names its types explicitly, so there is no unfiltered one
left to defend against.

Enforcement also moves into Subscriber.matches rather than staying in
handleWatch. It was in the watch handler last time, which meant any
future internal caller writing bus.Subscribe(0) would silently re-arm
the phone. One unfiltered path existed this session — my own test — which
is how this showed up. matches() is the chokepoint both delivery and
HasSubscribers go through, so it is the only place the rule can be
bypassed deliberately rather than by oversight. handleWatch now passes
its filters through unchanged.

So the opt-in is one word:

  kcd watch                    # no SMS, no arming
  kcd watch -e sms.incoming    # arms and delivers

Checked a backlog question while here: an armed phone with no listener
does not queue. sendLatestMessage advances mostRecentTimestamp on every
push, subscribed or not, so someone who subscribes later sees only
genuinely new messages.

Tests: TestNamingSMSDeliversAndArms covers the consent path in one go —
an unfiltered subscriber receives nothing and leaves HasSubscribers
false, then naming the type arms the phone and delivers the body. The
four flag tests are gone, since the flag is gone.
The gap: the only non-interactive pairing mode was -y, which trusts the
first device that asks on the local network. --expected-fingerprint and
--known-only both require knowing the peer in advance, which is exactly
what a first-time pairing does not give you. A client without a prompt
was therefore pushed toward trusting the network by the headless framing
-- right for a server, wrong for a panel.

Every primitive needed already existed, so this is CLI plumbing only. No
daemon, IPC or protocol change: broadcast_start maps to StartOwned(ctx,
OwnerPairing) and broadcast_stop releases it.

  kcd pair --advertise-only            # advertise, accept nothing, Ctrl+C
  kcd pair --advertise-only --json     # {"advertising":true|false} on stdout

The client then keeps its existing watch stream, reads candidates from
pair.requested (which already carries verificationKey), shows the code to
the owner, and accepts per device with `kcd pair <device-id>`. Acceptance
stays a separate, explicit, per-device step -- --advertise-only never
calls CmdPair, asserted directly by the test rather than inferred.

--json prints both transitions so a supervising client knows when the
daemon is genuinely discoverable, not merely that a process started.
Broadcast stops on every exit path including SIGTERM, and stopping also
drops the discovery connections that never led to pairing, so strangers
do not linger.

Contradictory flags are refused rather than silently dropped: combining
with --yes or a device ID is an error, since the caller is a program
trusting "never accepts".

advertiseUntil takes the stop condition as a parameter rather than
handling signals itself, so the tests drive it with a channel instead of
signalling the test binary -- my first attempt did the latter, which is
both fragile and rude to the rest of the suite.

Verified live: both modes emit and terminate correctly, and listen mode,
-y, --known-only and --expected-fingerprint are unchanged.
`kcd battery --json` with no positional printed `"deviceId": ""` while
correctly querying the auto-selected device. The action resolved the ID
and then reported the raw positional instead of the resolution.

One-token root cause, but the regression was invisible: charge and
charging were right, only the identifying field was wrong, and only on
the convenience path. runBattery is now a separate function taking the
client, so the test can wire a stub IPC server and assert the reported
ID equals the auto-resolved one.

Also documented in CLI.md that flags must precede positionals. Trailing
flags are silently ignored under urfave/cli v2's POSIX parsing, which is
what made `kcd battery <id> --json` print text instead of JSON during
verification. No code change there: upstream v2 offers no trailing-flag
mode, and per-command hacks would be worse than the convention.
@bethropolis
bethropolis merged commit dbd50e0 into main Oct 5, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant