From 674a0bb15a37fdba2db3332a4964b44e582ac401 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Wed, 17 Jun 2026 22:58:13 +0200 Subject: [PATCH] chore: save plans --- ...-verify-password-plan-grilled-by-claude.md | 163 +++++++ ...gin-verify-password-plan-grilled-by-gpt.md | 460 ++++++++++++++++++ ...loud-login-verify-password-plan-initial.md | 124 +++++ 3 files changed, 747 insertions(+) create mode 100644 .plans/cloud-login-verify-password-plan-grilled-by-claude.md create mode 100644 .plans/cloud-login-verify-password-plan-grilled-by-gpt.md create mode 100644 .plans/cloud-login-verify-password-plan-initial.md diff --git a/.plans/cloud-login-verify-password-plan-grilled-by-claude.md b/.plans/cloud-login-verify-password-plan-grilled-by-claude.md new file mode 100644 index 0000000..532bad7 --- /dev/null +++ b/.plans/cloud-login-verify-password-plan-grilled-by-claude.md @@ -0,0 +1,163 @@ +# Plan: verify the password in `cloud login` + +## Context + +Today `cloud login` (`packages/trapos-cloud/programs/cloud.lua:89-113`) **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` (`libcloud.lua:278-297`) silently sets the terminal `unauthorized` state — no +event — so the user types a bad password and still sees "cloud.password set". + +We want `cloud login` to confirm the gateway **accepts** the password before reporting success, and +to never overwrite a known-good saved password with an unverified one: + +- **Interactive** (no password arg → prompted): on a wrong password, re-prompt and retry; cancel + with ctrl+t. +- **Non-interactive** (password passed as an arg): on a wrong password, fail with `error(msg, 0)` + (non-zero exit). + +The daemon owns the only websocket and shares the OS event queue with programs (`libcloud.lua:6-8`). +`cloud login` drives verification by asking the daemon to reconnect with a *candidate* secret and +waiting for the handshake verdict; nothing is persisted until the gateway accepts it. The same +cooperative-coroutine pattern `api.request` (`libcloud.lua:416`) already uses applies here: login +yields in `pullEventRaw`, which lets the daemon coroutine advance. + +## Confirmed design decisions + +1. **Correlation token.** Login generates a token, passes it as a 3rd arg on `trapos_cloud_reconnect`; + the daemon echoes it on the verdict events; login **ignores any verdict whose token doesn't + match**. This prevents a coincidental boot/network-flap `connected` from being mis-read as + acceptance (a false-positive save) or an unrelated `unauthorized` from being mis-attributed. +2. **`secretOverride` cleared on successful hello** (not left sticky), so `getSecret()` stays the + single source of truth after verification; it still survives mid-verify network retries. +3. **Daemon restored on every interactive exit path.** A guard around the whole `login` body queues a + plain `trapos_cloud_reconnect` on any exit (terminate, error, failure), **plus** a restore between + rounds so the link stays up while the user types the next attempt. The token from (1) makes the + restore's own `connected` event safely ignorable. +4. **Add a `cloud.url` guard** as the first thing in `login` (login has none today — only `status` + does, `cloud.lua:67-71`), so an unset URL fails fast with `set cloud.url first` instead of a 12s + timeout. +5. **Success message is `logged in` only** (drop the `cloud.password set` line); the existing test is + updated. +6. **Verify timeout is a snappy single-attempt ~12s**, hardcoded with a comment noting the coupling + to the daemon `helloTimeout` (10s). Slower/retry paths surface as "could not reach gateway" with + nothing saved. +7. **Scope kept tight:** daemon-not-running and `displaced` both surface as the ~12s "could not reach + gateway" timeout (nothing saved). No liveness flag, no pre-verify probe, no displaced-specific + handling — noted as known limitations. + +## Changes + +### 1. `packages/trapos-cloud/apis/libcloud.lua` — observable, candidate- and token-based verification + +- **Candidate secret + login token state.** In `startSession`, add locals `secretOverride = nil` and + `loginToken = nil`. +- **Use the override in `connect()`** (`libcloud.lua:244`): change `secret = getSecret();` to + `secret = secretOverride or getSecret();`. Login rejects empty candidates, so `or` is safe. Do + **not** clear the override here — it must survive `websocket_failure → scheduleReconnect → connect` + retries during the verify window. +- **Emit the verdict events with the token.** In `onHello`: + - success branch (after `state = 'ready'`, around `libcloud.lua:290`): + `queueEvent('trapos_cloud_connected', traposId, loginToken);` then **clear both** + `secretOverride = nil; loginToken = nil;`. + - rejection branch (around `libcloud.lua:291-296`, after setting `state = 'unauthorized'`): + `queueEvent('trapos_cloud_unauthorized', traposId, lastError, loginToken);`. +- **Carry candidate + token on reconnect** (`libcloud.lua:402`): + `el.register('trapos_cloud_reconnect', function(reason, candidateSecret, token) + secretOverride = candidateSecret; loginToken = token; reconnectNow(reason); end)`. The handler + **always assigns both from its args**, so a plain reconnect (no args) clears both to `nil` — + exactly how login restores the daemon to the saved password. + +Note the verdict-event shapes (login matches the token per-branch): `trapos_cloud_connected` = +`(name, traposId, token)`; `trapos_cloud_unauthorized` = `(name, traposId, lastError, token)`. + +### 2. `packages/trapos-cloud/programs/cloud.lua` — verify in the `login` command + +Replace the body of `if command == 'login'` (`cloud.lua:89-113`). Keep `parseLoginArgs`, +`readPassword`, and the `--force`/already-set guard. Additions: + +- **`cloud.url` guard first** (mirror `status`): `if isBlank(settings.get(URL_SETTING)) then + print('set cloud.url first'); return; end` — before the already-set guard and any prompt. +- **Constant:** `local VERIFY_TIMEOUT = 12; -- one hello attempt; daemon helloTimeout is 10s`. +- **`verifyPassword(candidate)` helper** — drives one verification round using `os.pullEventRaw` so + ctrl+t is caught, generates a fresh token, and **only acts on a verdict whose token matches**: + +```lua +local function verifyPassword(candidate) + local token = createCloud().uuid(); -- correlation token for this round + os.queueEvent(RECONNECT_EVENT, 'login', candidate, token); + local timer = os.startTimer(VERIFY_TIMEOUT); + while true do + local e = table.pack(os.pullEventRaw()); + local name = e[1]; + if name == 'terminate' then + error('Terminated', 0); -- guard (below) restores the daemon + elseif name == 'trapos_cloud_connected' and e[3] == token then + os.cancelTimer(timer); return 'ok'; + elseif name == 'trapos_cloud_unauthorized' and e[4] == token then + os.cancelTimer(timer); return 'unauthorized'; + elseif name == 'timer' and e[2] == timer then + return 'timeout'; + end + -- any other event (incl. mismatched-token verdicts from a restore) is ignored + end +end +``` + +- **Restore guard.** Wrap the verification/command flow so that **any** exit queues a plain + `os.queueEvent(RECONNECT_EVENT)` to restore the daemon to its saved secret before propagating — + e.g. run the flow under `pcall`; on failure, queue the plain reconnect, then re-`error(err, 0)`. + This covers ctrl+t at the re-prompt (outside `verifyPassword`) and any unexpected error. + +- **Interactive flow** (`opts.password == nil`): loop — `readPassword()`; blank ⇒ print + `password cannot be empty` and re-prompt; else `verifyPassword(candidate)`: + - `ok` ⇒ `settings.set/save`, print `logged in`, return. + - `unauthorized` ⇒ queue a plain reconnect (restore the link while the user types), print + `invalid password, try again (ctrl+t to cancel)`, loop. + - `timeout` ⇒ queue a plain reconnect, print `cloud login failed: could not reach gateway`, return. + +- **Non-interactive flow** (`opts.password` set): blank ⇒ + `error('cloud login failed: password cannot be empty', 0)`. Else `verifyPassword`: + - `ok` ⇒ save + print `logged in`, return. + - else queue a plain reconnect, then `error('cloud login failed: invalid password', 0)` + (unauthorized) or `error('cloud login failed: could not reach gateway', 0)` (timeout). + +Password is saved **only** on `ok`, so a wrong/aborted attempt never clobbers an existing valid +`cloud.password`. + +### 3. Tests + +- **`packages/trapos-cloud/tests/cloud.lua`** (daemon/api): assert `onHello(false, …)` queues + `trapos_cloud_unauthorized` carrying `(traposId, lastError, token)`; that a + `trapos_cloud_reconnect` carrying a candidate makes the next `connect()` send that secret in the + hello (vs. `getSecret()` when absent); that the success `trapos_cloud_connected` carries the token + and that a successful hello clears the override (a subsequent network reconnect falls back to + `getSecret()`). Existing assertions (`connected[2] == '7:base'`, etc.) are unaffected by the added + token arg. +- **`packages/trapos-cloud/tests/cloud-program.lua`**: the fake `os` (lines ~64-68) stubs only + `queueEvent`; extend it to drive events — stub `os.pullEventRaw`/`startTimer`/`cancelTimer`. The + fake `queueEvent` already records queued events, so it **captures the token** from the + `trapos_cloud_reconnect` event; the scripted `pullEventRaw` **echoes that captured token** into the + fed `connected`/`unauthorized` event (no test-only seam in `login`). Update existing login tests + (they assert the immediate `cloud.password set` + reconnect, and the `cloud.password set` line) and + add: interactive wrong-then-right retry (saved only after the accepted attempt; plain reconnect + queued between rounds), interactive timeout (not saved, plain reconnect queued), non-interactive + wrong password (raises, not saved), non-interactive accepted (saved, prints `logged in`), + `cloud.url` unset (prints `set cloud.url first`, no prompt/save). + +## Verification + +- Lua suite: `just test` (or `just _craftos-test --pretty`) — exercises `tests/cloud.lua` and + `tests/cloud-program.lua` under headless CraftOS-PC. +- End-to-end: `just e2e` (`tools/trapos-server` gateway + headless CraftOS), then with the daemon + running and a server password configured: + - `cloud login --force` with a wrong password should re-prompt (interactive), and ctrl+t at the + prompt should leave the daemon reconnected to the saved secret (check `/logs/cloud.log` shows a + reconnect, not a lingering `unauthorized`). + - `cloud login ` should `error` with a non-zero exit and not change `cloud.password`. + - A correct password should print `logged in` and the daemon log should show `connected`. +- **Known limitations** (by design): with the daemon not running, or when the computer is + `displaced`, `cloud login` reports `could not reach gateway` after ~12s (nothing saved) rather than + a more specific message. diff --git a/.plans/cloud-login-verify-password-plan-grilled-by-gpt.md b/.plans/cloud-login-verify-password-plan-grilled-by-gpt.md new file mode 100644 index 0000000..7d2620b --- /dev/null +++ b/.plans/cloud-login-verify-password-plan-grilled-by-gpt.md @@ -0,0 +1,460 @@ +# 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. diff --git a/.plans/cloud-login-verify-password-plan-initial.md b/.plans/cloud-login-verify-password-plan-initial.md new file mode 100644 index 0000000..5b673bc --- /dev/null +++ b/.plans/cloud-login-verify-password-plan-initial.md @@ -0,0 +1,124 @@ +# 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`.