Skip to content

fix: away-mode side resolution, IANA timezone lookup, keychain fallback#36

Merged
omarshahine merged 4 commits intosteipete:mainfrom
omarshahine:fix/side-resolution-timezone-keychain
Apr 18, 2026
Merged

fix: away-mode side resolution, IANA timezone lookup, keychain fallback#36
omarshahine merged 4 commits intosteipete:mainfrom
omarshahine:fix/side-resolution-timezone-keychain

Conversation

@omarshahine
Copy link
Copy Markdown
Collaborator

Summary

Three independent fixes that surfaced while testing #35 against a live Pod 2 Pro in Away mode.

1. Away-mode side resolution (internal/client/targets.go)

HouseholdUserTargets was reading each user's currentDevice.side, but the API overwrites that with the sentinel "away" for every user when the household is in Away mode. That broke --side left|right for anyone who'd ever used Away mode:

$ eightctl status --side left
Error: side "left" is not available for this household; available sides: away

The /devices payload has the authoritative assignment, just in a different shape in Away mode — the top-level leftUserId/rightUserId come back blank and the real IDs live inside awaySides as {"leftUserId":"…","rightUserId":"…"}. Read from there, fall through to the currentDevice.side value only when it's one of left/right/solo (never the Away sentinel), and filter the "available sides" error message to drop away.

Live verification (household currently in Away mode, two users):

$ eightctl status
side   name          user_id                           mode           level
left   Omar Shahine  b00bbaf376b846cfa0cf9e801fe510ce  smart:bedtime  -10
right  Lora Shahine  6354ffd8972c456c917126aac7eff310  smart:bedtime  -20

$ eightctl status --side left
side  name          user_id                           mode           level
left  Omar Shahine  b00bbaf376b846cfa0cf9e801fe510ce  smart:bedtime  -10

2. IANA timezone lookup (internal/cmd/timezone.go)

resolveAPITimezone previously fell back to UTC whenever time.Local.String() returned "Local" (which is the common case on macOS/Linux when $TZ isn't set — Eight Sleep rejects that value). Ports @dtrinh's /etc/localtime / /etc/timezone / $TZ resolution from #21 so presence, sleep day, sleep range, and metrics actually get the real local zone. Closes #21 with credit via Co-Authored-By.

Before:

$ eightctl presence --from 2026-04-10 --to 2026-04-17
WARN system local timezone is not an IANA zone; falling back to UTC for API queries
present
false

After:

$ eightctl presence --from 2026-04-10 --to 2026-04-17
present
false

3. Keychain backend fallback (internal/tokencache/tokencache.go)

defaultOpenKeyring listed KeychainBackend first in AllowedBackends, and the 99designs/keyring library picks the first backend whose Open() succeeds. On macOS sessions without a writable login keychain (which is more common than you'd expect — e.g., agent-spawned shells, kiosk accounts), Open() succeeds but every Set() fails with Keychain Error (-61), so the FileBackend fallback was never reached. The net effect: every CLI invocation re-ran the password grant, tripping Eight Sleep's auth rate limiter (401/429 on /tokens) after a handful of commands.

Save / Load / Clear now try the primary backend, and on write/read failure open a FileBackend-only ring pointed at ~/.config/eightctl/keyring/ and retry there. The OAuth token then survives between invocations as intended.

Live verification on a machine without a usable login keychain:

$ eightctl status --verbose  # run 1
DEBU primary keyring set failed; falling back to file backend error="Keychain Error. (-61)"
DEBU keyring saved token to file fallback
DEBU saved token to cache expires_at=…

$ eightctl status --verbose  # run 2
DEBU loaded token from cache expires_at=… user_id=…
DEBU using in-memory token expires_in=19h58m33s

No re-auth on subsequent invocations, no rate-limit bursts.

Test plan

  • go test ./... — all pass, new tests cover each fix
    • TestHouseholdUserTargetsUsesDeviceMappingInAwayMode — Away-mode side resolution
    • TestResolveAPITimezoneUsesLocalIANAWhenValueIsLocal and TestExtractZoneinfoSuffix — IANA lookup
    • TestSaveFallsBackToFileWhenPrimarySetFails — keychain fallback via an unwritableKeyring fake
  • go build ./..., go vet ./... clean
  • Live verified against a Pod 2 Pro in Away mode with two users (see output above)

Refs #35 (follow-up), closes #21.

omarshahine and others added 4 commits April 18, 2026 07:10
Three independent bugs surfaced while testing steipete#35:

1. HouseholdUserTargets relied on `user.currentDevice.side`, which the
   Eight Sleep API overwrites with the sentinel "away" for every user
   when the household is in Away mode. `--side left|right` therefore
   errored with `available sides: away` for any Away-mode household.
   Resolve side from the `/devices` payload instead: when Away mode
   blanks the top-level `leftUserId`/`rightUserId`, the real IDs live
   inside `awaySides` as `{"leftUserId":"…","rightUserId":"…"}`. Also
   filter the "available sides" error list to drop the Away sentinel.

2. `resolveAPITimezone` fell back to UTC whenever `time.Local.String()`
   returned "Local". Port the `/etc/localtime` / `/etc/timezone` / `$TZ`
   IANA resolution from PR steipete#21 (@dtrinh) so `presence`, `sleep day`,
   `sleep range`, and `metrics` get the real local zone the API expects.

3. Tokencache picked the first backend whose `Open()` succeeded even
   when every `Set()` then failed with `Keychain Error (-61)` — e.g.,
   macOS sessions without a writable login keychain. `Save`, `Load`,
   and `Clear` now fall through to a FileBackend-only keyring so the
   OAuth token survives across invocations instead of re-running the
   password grant on every command and tripping Eight Sleep's auth
   rate limiter.

Co-Authored-By: Danny Trinh <noreply@users.noreply.github.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
CI's gofumpt format check caught whitespace drift in the two new test
files. No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The fallback path added in the previous commit made `tokencache.Load`
reach into `~/.config/eightctl/keyring/` whenever the primary backend
returned ErrKeyNotFound. Existing test helpers (`useTempKeyring` in
cmd, `withTestKeyring` in tokencache) only overrode `openKeyring`,
so tests that exercised the miss path could see the real filesystem
and return stale state — this is what caused
`TestRequireAuthFieldsFailsWithoutCacheOrCreds` to fail in CI.

Both helpers now override `openKeyring` and `openFileKeyring` with the
same tmp-backed opener, keeping the fallback entirely in-process.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Move the IANA-discovery doc comment onto `defaultLocalIANA` where
  the behavior lives; leave a one-liner on the swappable var.
- Make `unwritableKeyring.Remove` return the same sentinel as `Set`
  so the fake behaves consistently if a future test exercises it.

No behavior change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@omarshahine omarshahine merged commit 88e0555 into steipete:main Apr 18, 2026
2 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