# Plan: verify the password in `cloud login` (grilled by GPT) ## Context Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89-113`) does not verify 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 gateway rejects a wrong secret. On rejection, the daemon's `onHello` sets terminal `unauthorized` state without a user-visible event, so the user can type a bad password and still see success-looking output. The desired behavior is: - Interactive login (`cloud login`, no password arg) verifies the candidate password before saving it. - Interactive wrong passwords re-prompt; ctrl+t cancels and restores the daemon to persisted settings. - Non-interactive login (`cloud login `) verifies before saving, and wrong passwords fail with `error(msg, 0)` for a non-zero exit. - A wrong, aborted, timed-out, or unsaved candidate never overwrites a previously valid saved password. The daemon owns the only websocket and shares the OS event queue with programs. `cloud login` should not open its own websocket. Instead, it asks the daemon to reconnect with a candidate secret and waits for the candidate hello verdict. ## Confirmed Decisions - Verify before commit: save `cloud.password` only after the gateway accepts the candidate. - Use `error(msg, 0)` for non-interactive failures and storage failures. - Use a per-attempt correlation token so login ignores unrelated `trapos_cloud_connected` and `trapos_cloud_unauthorized` events. - Use a local `/apis/eventloop` instance inside `cloud login` for the wait, not a hand-written `os.pullEventRaw()` loop. - Keep `trapos_cloud_connected` as the success event, with token as an optional trailing argument. - Emit `trapos_cloud_unauthorized` for every rejected hello, not only login verification attempts. - Keep candidate override active until an explicit plain reconnect clears it, so transient websocket retries during the verify window still use the candidate. - After successful verification and save, queue a plain reconnect with reason `login-saved` so the daemon clears candidate state and resumes reading persisted settings. - After failed, aborted, timed-out, unauthorized, or save-failed verification, queue a plain reconnect with reason `login-restore`. - Use reconnect reason `login-verify` for candidate verification. - Do not log verification tokens; log only the reconnect reason. - Do not trim, lowercase, normalize, or otherwise transform passwords. Reject only the exact empty string `''`. - Add a `cloud.url` guard to `cloud login` and fail fast with `set cloud.url first` when verification cannot be started. - Keep the existing already-set guard unless `--force` is passed. - Keep `lastError()` returning a string code; structured unauthorized details are only for the event. - Do not add a concurrent-login lock. - Treat daemon absence and unresolved displaced state as timeout/`could not reach gateway`. - Bump `trapos-cloud` from `0.4.2` to `0.4.3` in both package metadata locations. ## Non-Goals - Do not add a standalone Lua test harness. - Do not add daemon liveness/status APIs for login. - Do not open a program-owned websocket for verification. - Do not trim or normalize passwords. - Do not add a concurrent-login lock. - Do not change `lastError()` to return structured errors. - Do not broaden this into general cloud daemon state-management UX. ## Event Contract Verification reconnect: ```lua os.queueEvent('trapos_cloud_reconnect', 'login-verify', candidateSecret, token); ``` Successful verified hello: ```lua queueEvent('trapos_cloud_connected', traposId, token); ``` Unauthorized hello: ```lua queueEvent('trapos_cloud_unauthorized', traposId, err, token); ``` Restore after failed/aborted verification: ```lua os.queueEvent('trapos_cloud_reconnect', 'login-restore'); ``` Cleanup after successful save: ```lua os.queueEvent('trapos_cloud_reconnect', 'login-saved'); ``` Rules: - `candidateSecret == nil` or `candidateSecret == ''` means plain reconnect: clear candidate and token. - Candidate absence wins; a token without a candidate is ignored by clearing both candidate and token. - `cloud login` rejects empty candidates before queueing verification. - Ordinary daemon reconnects may emit tokenless `trapos_cloud_connected` or `trapos_cloud_unauthorized`. - Login only treats events with the exact current token as authoritative. - Login drops unrelated or tokenless verdict events it consumes; it does not re-queue them. ## Daemon Changes File: `packages/trapos-cloud/apis/libcloud.lua` ### Candidate And Token State Inside `api.startSession(opts)`, add candidate state: ```lua local secretOverride = nil; local verifyToken = nil; local activeSecret = nil; local activeVerifyToken = nil; ``` In `connect()`, capture the candidate state for that physical connection attempt: ```lua activeSecret = secretOverride or getSecret(); activeVerifyToken = verifyToken; ``` Then send hello with `activeSecret`, not the mutable outer `secret` value. Reason: reconnect events can supersede earlier attempts. The secret/token used for a hello result must be the secret/token captured for the active attempt, not whatever mutable candidate state happens to contain when `onHello` runs. ### Reconnect Handler Change the `trapos_cloud_reconnect` handler to accept candidate and token: ```lua el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token) if type(candidateSecret) == 'string' and candidateSecret ~= '' then secretOverride = candidateSecret; verifyToken = token; else secretOverride = nil; verifyToken = nil; end reconnectNow(reason); end); ``` This makes `login-restore`, `login-saved`, and any manual/plain reconnect clear login verification state. ### Hello Verdict Events In `onHello`, keep `lastError()` as a string code, but emit observable events. On success: ```lua queueEvent('trapos_cloud_connected', traposId, activeVerifyToken); ``` On rejection, normalize the gateway error table for the event: ```lua local errBody = err; if type(errBody) ~= 'table' then errBody = { code = 'unauthorized', message = 'unauthorized' }; end lastError = errBody.code or 'unauthorized'; queueEvent('trapos_cloud_unauthorized', traposId, errBody, activeVerifyToken); ``` Do not log the token. Existing reconnect logs should become more useful through reasons such as `login-verify`, `login-restore`, and `login-saved`. ### Stale Socket Investigation Before finalizing stale-message behavior, probe or test whether the runtime's `websocket_message` event includes a websocket handle in addition to `(url, content)`. If a handle is available, only accept messages for the current `activeWs` attempt. If the runtime only provides `(url, content)`, document the limitation and rely on the existing URL/state guards plus per-attempt token capture. ## Program Changes File: `packages/trapos-cloud/programs/cloud.lua` ### Setup Add: ```lua local createEventLoop = require('/apis/eventloop'); local VERIFY_TIMEOUT = 12; -- daemon helloTimeout is 10s ``` Instantiate the cloud API once: ```lua local cloudApi = createCloud(); ``` Use `cloudApi.request(...)` for `status` and `cloudApi.uuid()` for login verification tokens. ### Guard Order In `login`, keep parse behavior and the existing force semantics. Use this order: ```lua local opts, parseError = parseLoginArgs(rawArgs); if parseError then print(parseError); printUsage(); return; end if not opts.force and not isBlank(settings.get(PASSWORD_SETTING)) then print('cloud.password is already set; use cloud login --force to replace it'); return; end if isBlank(settings.get(URL_SETTING)) then print('set cloud.url first'); return; end ``` Reason: replacement intent is validated before requiring gateway configuration. If replacement is allowed or no password exists, verification requires `cloud.url` and fails fast when absent. ### Verification Helper Use a local eventloop per candidate attempt: ```lua local function verifyPassword(candidate) local token = cloudApi.uuid(); local result = nil; local el = createEventLoop(); local function finish(value) if result ~= nil then return; end result = value; el.stopLoop(); end el.register('trapos_cloud_connected', function(_traposId, eventToken) if eventToken == token then finish('ok'); end end); el.register('trapos_cloud_unauthorized', function(_traposId, _err, eventToken) if eventToken == token then finish('unauthorized'); end end); el.register('terminate', function() result = 'terminate'; end); el.setTimeout(function() finish('timeout'); end, VERIFY_TIMEOUT); os.queueEvent(RECONNECT_EVENT, 'login-verify', candidate, token); el.runLoop(); return result; end ``` Important details: - Create a new eventloop for each retry attempt. - Use default `el.runLoop()`, not `runLoop(true)`, so `terminate` stops the loop. - The terminate handler only records `result = 'terminate'`; command-level flow restores and throws. - `finish()` is idempotent because `stopLoop()` queues an internal stop event rather than interrupting the current dispatch immediately. - Rely on `eventloop.runLoop()` cleanup for local timers and handlers. ### Save Helper Add a helper or inline flow that saves only after verified `ok`, and rolls back live settings if saving fails: ```lua local oldPassword = settings.get(PASSWORD_SETTING); local hadPassword = oldPassword ~= nil; settings.set(PASSWORD_SETTING, password); if not settings.save() then if hadPassword then settings.set(PASSWORD_SETTING, oldPassword); else settings.unset(PASSWORD_SETTING); end os.queueEvent(RECONNECT_EVENT, 'login-restore'); error('cloud login failed: could not save settings', 0); end os.queueEvent(RECONNECT_EVENT, 'login-saved'); print('logged in; cloud.password set'); ``` Reason: the daemon reads live `settings.get(PASSWORD_SETTING)`, not just persisted files. If save fails, the in-memory setting must be restored before `login-restore` reconnects. ### Interactive Flow Interactive is exactly `opts.password == nil`; do not add TTY probing. Loop behavior: - Prompt with existing masked `readPassword()`. - If input is exactly `''`, print a password-empty message and re-prompt. - Do not trim or normalize the input. - Run `verifyPassword(candidate)`. - On `ok`, save, queue `login-saved`, print `logged in; cloud.password set`, and return. - On `unauthorized`, queue `login-restore`, print `invalid password, try again (ctrl+t to cancel)`, and immediately re-prompt without waiting for restore completion. - On `timeout`, queue `login-restore`, print `cloud login failed: could not reach gateway`, and return. - On `terminate`, queue `login-restore`, then `error('Terminated', 0)`. ### Non-Interactive Flow Non-interactive is exactly `opts.password ~= nil`. Behavior: - If password is exactly `''`, `error('cloud login failed: password cannot be empty', 0)`. - Do not trim or normalize the argument. - Run `verifyPassword(password)`. - On `ok`, save, queue `login-saved`, print `logged in; cloud.password set`, and return. - On `unauthorized`, queue `login-restore`, then `error('cloud login failed: invalid password', 0)`. - On `timeout`, queue `login-restore`, then `error('cloud login failed: could not reach gateway', 0)`. - On `terminate`, queue `login-restore`, then `error('Terminated', 0)`. Do not print before non-interactive `error(...)`; the error text is the output. ## Tests ### Daemon/API Tests File: `packages/trapos-cloud/tests/cloud.lua` Add or update tests to cover: - Unauthorized hello queues `trapos_cloud_unauthorized` with `(traposId, normalizedErrTable, token)`. - `lastError()` remains the string code, for example `'unauthorized'`. - Candidate reconnect sends the candidate secret in the next hello. - Plain reconnect clears candidate and token and falls back to `getSecret()`. - Successful hello queues `trapos_cloud_connected` with `(traposId, token)`. - Token without candidate is ignored/cleared by reconnect handler. - Candidate override survives websocket failure/retry until a plain reconnect clears it. - Existing network drop, displacement, send, request, serve, and respond tests remain valid. If websocket-message identity is available in the runtime, add a stale-socket test proving old socket messages cannot drive the current attempt. If identity is not available, document the limitation in test comments or plan follow-up notes. ### Program Tests File: `packages/trapos-cloud/tests/cloud-program.lua` Update the harness: - Intercept `require('/apis/eventloop')` and provide a fake eventloop with `register`, `setTimeout`, `runLoop`, and `stopLoop`. - Script verification events through `opts.events` or equivalent. - Let fake `runLoop()` consume scripted events and dispatch registered handlers until stopped. - Capture the token from queued `trapos_cloud_reconnect` verification events and use it in scripted matching `trapos_cloud_connected` / `trapos_cloud_unauthorized` events. - Support non-matching/tokenless verdict events so tests can prove login ignores them. - Extend fake settings with `unset()`. - Add save-failure injection, for example `saveOk = false`. - Change `runCloud()` to return `ok` and `err` instead of rethrowing expected program errors. - Update successful tests to assert `ctx.ok == true`. Update existing login tests because old behavior saved immediately and printed `cloud.password set`. Add/cover these scenarios: - `cloud login` with existing password and no `--force` refuses replacement before checking `cloud.url`. - `cloud login` with missing `cloud.url` prints `set cloud.url first`, does not prompt, does not save, and does not reconnect. - Interactive accepted password saves only after matching `trapos_cloud_connected` token, queues `login-verify` then `login-saved`, and prints `logged in; cloud.password set`. - Non-interactive accepted password does not prompt, saves after matching connected token, queues `login-verify` then `login-saved`, and succeeds. - Interactive wrong-then-right flow queues `login-restore` after unauthorized, re-prompts immediately, saves only the accepted second password, and never saves the rejected one. - Interactive blank password re-prompts and does not queue verification for the blank value. - Non-interactive blank password errors with `cloud login failed: password cannot be empty`, does not queue restore, and does not save. - Interactive timeout queues `login-restore`, prints `cloud login failed: could not reach gateway`, and does not save. - Non-interactive unauthorized queues `login-restore`, errors with `cloud login failed: invalid password`, and does not save. - Non-interactive timeout queues `login-restore`, errors with `cloud login failed: could not reach gateway`, and does not save. - Interactive terminate during verification queues `login-restore`, errors with `Terminated`, and leaves settings unchanged. - Matching-token events complete verification; non-matching and tokenless verdict events are ignored until matching result or timeout. - Save failure after verified success with an old password restores the old in-memory password, queues `login-restore`, errors with `cloud login failed: could not save settings`, and does not queue `login-saved`. - Save failure after verified success with no old password calls `settings.unset(PASSWORD_SETTING)`, queues `login-restore`, errors with `cloud login failed: could not save settings`, and does not queue `login-saved`. ## Package Version Update: - `packages/trapos-cloud/ccpm.json`: `0.4.2` -> `0.4.3` - `packages/index.json`: `trapos-cloud` `0.4.2` -> `0.4.3` Reason: this changes runtime behavior of `trapos-cloud` APIs and programs, and `cloud --version` reports the package version through `libversion`. ## Implementation Order 1. Update `packages/trapos-cloud/apis/libcloud.lua` with candidate/token reconnect contract and events. 2. Update `packages/trapos-cloud/tests/cloud.lua` for daemon/API behavior. 3. Update `packages/trapos-cloud/programs/cloud.lua` with local eventloop verification and save rollback. 4. Update `packages/trapos-cloud/tests/cloud-program.lua` harness and CLI tests. 5. Update package versions in `packages/trapos-cloud/ccpm.json` and `packages/index.json`. 6. Run verification and fix issues. ## Verification Because this change edits Lua and this Markdown plan, run: ```sh just check just test ``` `just check` covers Lua lint and offline Markdown link validation. `just test` runs the CraftOS-PC test suite, including `tests/cloud.lua` and `tests/cloud-program.lua`. Optional end-to-end verification against the real gateway: ```sh just e2e ``` Manual behavior to confirm with the daemon running and server password configured: - `cloud login --force` with a wrong password re-prompts and does not save. - Ctrl+t during interactive verification restores daemon state and exits with `Terminated`. - `cloud login ` errors non-zero with `cloud login failed: invalid password` and does not save. - A correct password prints `logged in; cloud.password set` and `/logs/cloud.log` shows reconnect reasons around `login-verify` and `login-saved`, without logging verification tokens. ## Known Limitations - If the daemon is not running, login times out with `cloud login failed: could not reach gateway`. - If the computer is displaced by another registration and cannot recover during verification, login times out with `cloud login failed: could not reach gateway`. - Unless the runtime exposes websocket identity in message events, stale websocket messages can only be mitigated by URL/state guards and per-attempt token capture, not by direct socket-handle comparison.