18 KiB
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 witherror(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.passwordonly 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_connectedandtrapos_cloud_unauthorizedevents. - Use a local
/apis/eventloopinstance insidecloud loginfor the wait, not a hand-writtenos.pullEventRaw()loop. - Keep
trapos_cloud_connectedas the success event, with token as an optional trailing argument. - Emit
trapos_cloud_unauthorizedfor 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-savedso 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-verifyfor 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.urlguard tocloud loginand fail fast withset cloud.url firstwhen verification cannot be started. - Keep the existing already-set guard unless
--forceis 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-cloudfrom0.4.2to0.4.3in 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:
os.queueEvent('trapos_cloud_reconnect', 'login-verify', candidateSecret, token);
Successful verified hello:
queueEvent('trapos_cloud_connected', traposId, token);
Unauthorized hello:
queueEvent('trapos_cloud_unauthorized', traposId, err, token);
Restore after failed/aborted verification:
os.queueEvent('trapos_cloud_reconnect', 'login-restore');
Cleanup after successful save:
os.queueEvent('trapos_cloud_reconnect', 'login-saved');
Rules:
candidateSecret == nilorcandidateSecret == ''means plain reconnect: clear candidate and token.- Candidate absence wins; a token without a candidate is ignored by clearing both candidate and token.
cloud loginrejects empty candidates before queueing verification.- Ordinary daemon reconnects may emit tokenless
trapos_cloud_connectedortrapos_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:
local secretOverride = nil;
local verifyToken = nil;
local activeSecret = nil;
local activeVerifyToken = nil;
In connect(), capture the candidate state for that physical connection attempt:
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:
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:
queueEvent('trapos_cloud_connected', traposId, activeVerifyToken);
On rejection, normalize the gateway error table for the event:
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:
local createEventLoop = require('/apis/eventloop');
local VERIFY_TIMEOUT = 12; -- daemon helloTimeout is 10s
Instantiate the cloud API once:
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:
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:
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(), notrunLoop(true), soterminatestops the loop. - The terminate handler only records
result = 'terminate'; command-level flow restores and throws. finish()is idempotent becausestopLoop()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:
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, queuelogin-saved, printlogged in; cloud.password set, and return. - On
unauthorized, queuelogin-restore, printinvalid password, try again (ctrl+t to cancel), and immediately re-prompt without waiting for restore completion. - On
timeout, queuelogin-restore, printcloud login failed: could not reach gateway, and return. - On
terminate, queuelogin-restore, thenerror('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, queuelogin-saved, printlogged in; cloud.password set, and return. - On
unauthorized, queuelogin-restore, thenerror('cloud login failed: invalid password', 0). - On
timeout, queuelogin-restore, thenerror('cloud login failed: could not reach gateway', 0). - On
terminate, queuelogin-restore, thenerror('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_unauthorizedwith(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_connectedwith(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 withregister,setTimeout,runLoop, andstopLoop. - Script verification events through
opts.eventsor equivalent. - Let fake
runLoop()consume scripted events and dispatch registered handlers until stopped. - Capture the token from queued
trapos_cloud_reconnectverification events and use it in scripted matchingtrapos_cloud_connected/trapos_cloud_unauthorizedevents. - 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 returnokanderrinstead 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 loginwith existing password and no--forcerefuses replacement before checkingcloud.url.cloud loginwith missingcloud.urlprintsset cloud.url first, does not prompt, does not save, and does not reconnect.- Interactive accepted password saves only after matching
trapos_cloud_connectedtoken, queueslogin-verifythenlogin-saved, and printslogged in; cloud.password set. - Non-interactive accepted password does not prompt, saves after matching connected token, queues
login-verifythenlogin-saved, and succeeds. - Interactive wrong-then-right flow queues
login-restoreafter 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, printscloud login failed: could not reach gateway, and does not save. - Non-interactive unauthorized queues
login-restore, errors withcloud login failed: invalid password, and does not save. - Non-interactive timeout queues
login-restore, errors withcloud login failed: could not reach gateway, and does not save. - Interactive terminate during verification queues
login-restore, errors withTerminated, 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 withcloud login failed: could not save settings, and does not queuelogin-saved. - Save failure after verified success with no old password calls
settings.unset(PASSWORD_SETTING), queueslogin-restore, errors withcloud login failed: could not save settings, and does not queuelogin-saved.
Package Version
Update:
packages/trapos-cloud/ccpm.json:0.4.2->0.4.3packages/index.json:trapos-cloud0.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
- Update
packages/trapos-cloud/apis/libcloud.luawith candidate/token reconnect contract and events. - Update
packages/trapos-cloud/tests/cloud.luafor daemon/API behavior. - Update
packages/trapos-cloud/programs/cloud.luawith local eventloop verification and save rollback. - Update
packages/trapos-cloud/tests/cloud-program.luaharness and CLI tests. - Update package versions in
packages/trapos-cloud/ccpm.jsonandpackages/index.json. - Run verification and fix issues.
Verification
Because this change edits Lua and this Markdown plan, run:
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:
just e2e
Manual behavior to confirm with the daemon running and server password configured:
cloud login --forcewith 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 withcloud login failed: invalid passwordand does not save.- A correct password prints
logged in; cloud.password setand/logs/cloud.logshows reconnect reasons aroundlogin-verifyandlogin-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.