chore: save plans
This commit is contained in:
parent
fdfc978c2f
commit
674a0bb15a
163
.plans/cloud-login-verify-password-plan-grilled-by-claude.md
Normal file
163
.plans/cloud-login-verify-password-plan-grilled-by-claude.md
Normal 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.
|
||||||
460
.plans/cloud-login-verify-password-plan-grilled-by-gpt.md
Normal file
460
.plans/cloud-login-verify-password-plan-grilled-by-gpt.md
Normal 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.
|
||||||
124
.plans/cloud-login-verify-password-plan-initial.md
Normal file
124
.plans/cloud-login-verify-password-plan-initial.md
Normal 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`.
|
||||||
Loading…
Reference in New Issue
Block a user