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

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 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:

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 == 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:

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(), 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:

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:

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