cc-libs/.plans/archived/cloud-login-verify-password-plan-grilled-by-gpt.md

461 lines
18 KiB
Markdown

# 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.