# Plan: verify the password in `cloud login` ## Context Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89`) **never verifies** the password. It writes `cloud.password` to settings, queues `trapos_cloud_reconnect`, prints `cloud.password set`, and returns. Verification only happens later and asynchronously inside the daemon: `startSession` sends the secret in the `hello` handshake, and the server (`tools/trapos-server/src/auth.ts` `resolveAccount`) rejects a wrong secret. On rejection the daemon's `onHello` (`apis/libcloud.lua:278`) sets the terminal `unauthorized` state **silently** — no event, so nothing tells the user their password was wrong. The user types a bad password and still sees "cloud.password set". We want `cloud login` to confirm the password is accepted by the gateway before reporting success: - **Interactive** (no password arg → prompted): on a wrong password, re-prompt and let the user retry; cancel with ctrl+t. - **Non-interactive** (password passed as an arg): on a wrong password, fail with an error and a non-zero exit (`error(msg, 0)`). Confirmed decisions: **verify before commit** (never overwrite a known-good saved password with an unverified one) and **`error()` non-zero exit** for non-interactive failure. ## Design The daemon owns the only websocket and shares the os event queue with programs. `cloud login` drives verification by asking the daemon to reconnect with a *candidate* secret and waiting for the handshake result. Nothing is persisted until the gateway accepts it. ``` login: candidate = arg or prompt -> os.queueEvent('trapos_cloud_reconnect', 'login', candidate) # candidate, NOT saved -> wait: trapos_cloud_connected | trapos_cloud_unauthorized | timeout connected -> settings.set/save(candidate); print "logged in"; done unauthorized -> interactive: re-prompt and loop non-interactive: queue plain reconnect (restore daemon); error() timeout -> queue plain reconnect (restore daemon); report "could not reach gateway" ``` Interactive/non-interactive is decided exactly as today: `opts.password == nil` ⇒ interactive (prompted), else non-interactive. No TTY probing. ## Changes ### 1. `packages/trapos-cloud/apis/libcloud.lua` — make verification observable + candidate-based - **Emit a hello-failure event.** In `onHello`'s rejection branch (around line 291-296), after setting `state = 'unauthorized'`, add `queueEvent('trapos_cloud_unauthorized', traposId, lastError);`. Success already emits `trapos_cloud_connected` (line 290) — leave it. - **One-shot candidate secret.** Add a local `secretOverride = nil` in `startSession`. In `connect()` (line 239) change `secret = getSecret();` to `secret = secretOverride or getSecret();`. The override is non-empty when present (login rejects empty), so `or` is safe. - **Carry the candidate on reconnect.** Change the `trapos_cloud_reconnect` handler (line 402) to accept a second arg: `function(reason, candidateSecret) secretOverride = candidateSecret; reconnectNow(reason); end`. A reconnect with no candidate clears the override (back to `getSecret()`), which is exactly how login restores the daemon to the previously-saved password after a failed/aborted attempt. ### 2. `packages/trapos-cloud/programs/cloud.lua` — verify in the `login` command Replace the body of `if command == 'login'` (lines 89-113). Keep the existing `parseLoginArgs`, `readPassword`, the `cloud.url` guard, and the `--force`/already-set guard. Add a helper that drives one verification round, using **`os.pullEventRaw`** so ctrl+t is caught and the daemon is restored before exiting: ```lua local VERIFY_TIMEOUT = 12; -- comfortably over the daemon helloTimeout (10s) local function verifyPassword(candidate) os.queueEvent(RECONNECT_EVENT, 'login', candidate); local timer = os.startTimer(VERIFY_TIMEOUT); while true do local e = table.pack(os.pullEventRaw()); local name = e[1]; if name == 'terminate' then os.queueEvent(RECONNECT_EVENT); -- restore daemon to saved secret error('Terminated', 0); elseif name == 'trapos_cloud_connected' then os.cancelTimer(timer); return 'ok'; elseif name == 'trapos_cloud_unauthorized' then os.cancelTimer(timer); return 'unauthorized'; elseif name == 'timer' and e[2] == timer then return 'timeout'; end end end ``` Command flow: - Interactive (`opts.password == nil`): loop — `readPassword()`; reject blank and re-prompt; else `verifyPassword`. `ok` ⇒ `settings.set/save`, print `logged in; cloud.password set`, return. `unauthorized` ⇒ print `invalid password, try again (ctrl+t to cancel)` and loop. `timeout` ⇒ queue plain `trapos_cloud_reconnect`, print `cloud login failed: could not reach gateway`, return. - Non-interactive (`opts.password` set): blank ⇒ `error('cloud login failed: password cannot be empty', 0)`. Else `verifyPassword`. `ok` ⇒ save + print, return. Otherwise queue plain reconnect to restore the daemon, then `error('cloud login failed: invalid password', 0)` (unauthorized) or `error('cloud login failed: could not reach gateway', 0)` (timeout). Note: password is saved **only** on `ok`, so a wrong attempt never clobbers an existing valid `cloud.password`. If `cloud.url` is unset the existing `set cloud.url first` guard applies; if the daemon isn't running, verification simply times out into the "could not reach gateway" path (no new global coupling needed). ### 3. Tests - `packages/trapos-cloud/tests/cloud.lua` (daemon/api): assert `onHello(false, ...)` queues `trapos_cloud_unauthorized`, and that a `trapos_cloud_reconnect` carrying a candidate makes the next `connect()` send that secret in the hello (vs. falling back to `getSecret()` when the candidate is absent). - `packages/trapos-cloud/tests/cloud-program.lua`: the harness's fake `os` (lines ~64) stubs only `queueEvent`; extend it to feed events — add `opts.events` consumed by stubbed `os.pullEventRaw`/`startTimer`/`cancelTimer`. Update existing login tests (they currently assert the immediate `cloud.password set` + reconnect) and add: interactive wrong-then-right retry (password saved only after the accepted attempt), interactive timeout (not saved, plain reconnect queued), non-interactive wrong password (raises, not saved), non-interactive accepted (saved). ## Verification - Run the lua suite: `just test` (or `just _craftos-test --pretty` for grouped output) — exercises `tests/cloud.lua` and `tests/cloud-program.lua` under headless CraftOS-PC. - End-to-end against the real gateway: `just e2e` (`tools/trapos-server` gateway + headless CraftOS), then manually: with the daemon running and a server password configured, `cloud login --force` with a wrong password should re-prompt (interactive) and `cloud login ` should error with a non-zero exit; a correct password should print `logged in; cloud.password set` and the daemon log (`/logs/cloud.log`) should show `connected`.