chore: save plans

This commit is contained in:
Guillaume ARM 2026-06-17 22:58:13 +02:00
parent fdfc978c2f
commit 674a0bb15a
3 changed files with 747 additions and 0 deletions

View File

@ -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 <wrong>` 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.

View File

@ -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 <password>`) 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 <wrong>` 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.

View File

@ -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 <wrong>` 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`.