feat(cloud): reconnect after login

This commit is contained in:
Guillaume ARM 2026-06-17 03:12:51 +02:00
parent 06ede45212
commit bd2bd22069
9 changed files with 154 additions and 31 deletions

View File

@ -1,6 +1,6 @@
{ {
"name": "TrapOS", "name": "TrapOS",
"version": "0.12.1", "version": "0.12.2",
"branch": "master", "branch": "master",
"packages": [ "packages": [
"trapos" "trapos"

View File

@ -6,8 +6,8 @@
"trapos-net": "0.3.2", "trapos-net": "0.3.2",
"trapos-ui": "0.2.4", "trapos-ui": "0.2.4",
"trapos-ai": "0.8.0", "trapos-ai": "0.8.0",
"trapos-cloud": "0.4.1", "trapos-cloud": "0.4.2",
"trapos-sandbox-legacy": "0.3.2", "trapos-sandbox-legacy": "0.3.2",
"trapos": "0.12.1" "trapos": "0.12.2"
} }
} }

View File

@ -197,7 +197,8 @@ local function createCloud()
local queueEvent = opts.queueEvent or function(...) return osLike.queueEvent(...); end; local queueEvent = opts.queueEvent or function(...) return osLike.queueEvent(...); end;
local reconnectDelay = opts.reconnectDelay or 5; local reconnectDelay = opts.reconnectDelay or 5;
local helloTimeout = opts.helloTimeout or 10; local helloTimeout = opts.helloTimeout or 10;
local secret = opts.secret; local getSecret = opts.getSecret or function() return opts.secret; end;
local secret = getSecret();
-- Lifecycle logger: log(level, msg, fields?). Defaults to a no-op so the daemon -- Lifecycle logger: log(level, msg, fields?). Defaults to a no-op so the daemon
-- stays silent unless the caller wires a sink (e.g. a /logs file appender). -- stays silent unless the caller wires a sink (e.g. a /logs file appender).
local log = opts.log or function() end; local log = opts.log or function() end;
@ -210,6 +211,8 @@ local function createCloud()
local state = 'connecting'; local state = 'connecting';
local lastError = nil; local lastError = nil;
local activeWs = nil; local activeWs = nil;
local suppressNextClose = false;
local clearReconnectTimeout = nil;
-- True while looping on retries. Gates the per-attempt chatter (connecting / socket -- True while looping on retries. Gates the per-attempt chatter (connecting / socket
-- open / connection failed / connection closed) so a long outage logs once on entry -- open / connection failed / connection closed) so a long outage logs once on entry
-- and once on recovery instead of flooding the file every reconnectDelay seconds. -- and once on recovery instead of flooding the file every reconnectDelay seconds.
@ -234,6 +237,11 @@ local function createCloud()
end end
local function connect() local function connect()
if clearReconnectTimeout then
clearReconnectTimeout();
clearReconnectTimeout = nil;
end
secret = getSecret();
state = 'connecting'; state = 'connecting';
if not reconnecting then if not reconnecting then
log('info', 'connecting', { url = url }); log('info', 'connecting', { url = url });
@ -253,7 +261,18 @@ local function createCloud()
reconnecting = true; reconnecting = true;
log('warn', 'reconnect loop started', { delaySeconds = reconnectDelay }); log('warn', 'reconnect loop started', { delaySeconds = reconnectDelay });
end end
el.setTimeout(connect, reconnectDelay); clearReconnectTimeout = el.setTimeout(connect, reconnectDelay);
end
local function reconnectNow(reason)
log('info', 'reconnect requested', { reason = reason or 'manual' });
lastError = nil;
reconnecting = false;
if activeWs then
suppressNextClose = true;
closeActive();
end
connect();
end end
local function onHello(ok, _payload, err) local function onHello(ok, _payload, err)
@ -326,6 +345,11 @@ local function createCloud()
-- of bricking the daemon into a permanent 'displaced' state. -- of bricking the daemon into a permanent 'displaced' state.
el.register('websocket_closed', function(eventUrl, reason, code) el.register('websocket_closed', function(eventUrl, reason, code)
if eventUrl ~= url then return; end if eventUrl ~= url then return; end
if suppressNextClose then
suppressNextClose = false;
log('info', 'ignored intentional close', { reason = reason, code = code });
return;
end
if code == DISPLACED_CLOSE_CODE or reason == 'displaced' then if code == DISPLACED_CLOSE_CODE or reason == 'displaced' then
if state == 'ready' then if state == 'ready' then
activeWs = nil; activeWs = nil;
@ -375,6 +399,10 @@ local function createCloud()
sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err)); sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err));
end); end);
el.register('trapos_cloud_reconnect', function(reason)
reconnectNow(reason);
end);
connect(); connect();
return { return {

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos-cloud", "name": "trapos-cloud",
"version": "0.4.1", "version": "0.4.2",
"description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server", "description": "TrapOS cloud gateway client: WS daemon (servers/cloud) + cloud command + MCP exec/write server",
"dependencies": ["trapos-core"], "dependencies": ["trapos-core"],
"files": [ "files": [

View File

@ -3,6 +3,7 @@ local createVersion = require('/apis/libversion');
local URL_SETTING = 'cloud.url'; local URL_SETTING = 'cloud.url';
local PASSWORD_SETTING = 'cloud.password'; local PASSWORD_SETTING = 'cloud.password';
local RECONNECT_EVENT = 'trapos_cloud_reconnect';
local rawArgs = table.pack(...); local rawArgs = table.pack(...);
local command = rawArgs[1]; local command = rawArgs[1];
@ -15,7 +16,7 @@ local function printUsage()
print('cloud usage:'); print('cloud usage:');
print(); print();
print(' cloud status'); print(' cloud status');
print(' cloud set-password [--force|-f]'); print(' cloud login [--force|-f] [password]');
print(' cloud --version'); print(' cloud --version');
print(' cloud --help'); print(' cloud --help');
print(); print();
@ -32,8 +33,9 @@ local function readPassword()
return ''; return '';
end end
local function parseSetPasswordArgs(args) local function parseLoginArgs(args)
local force = false; local force = false;
local password = nil;
for i = 2, args.n do for i = 2, args.n do
local arg = args[i]; local arg = args[i];
@ -41,12 +43,14 @@ local function parseSetPasswordArgs(args)
force = true; force = true;
elseif arg and string.sub(arg, 1, 1) == '-' then elseif arg and string.sub(arg, 1, 1) == '-' then
return nil, 'unknown option: ' .. arg; return nil, 'unknown option: ' .. arg;
elseif not password then
password = arg;
else else
return nil, 'unexpected argument: ' .. tostring(arg); return nil, 'unexpected argument: ' .. tostring(arg);
end end
end end
return { force = force }, nil; return { force = force, password = password }, nil;
end end
if command == '--version' or command == '-version' or command == 'version' or command == '-v' then if command == '--version' or command == '-version' or command == 'version' or command == '-v' then
@ -82,8 +86,8 @@ if command == 'status' then
return; return;
end end
if command == 'set-password' then if command == 'login' then
local opts, parseError = parseSetPasswordArgs(rawArgs); local opts, parseError = parseLoginArgs(rawArgs);
if parseError then if parseError then
print(parseError); print(parseError);
printUsage(); printUsage();
@ -91,11 +95,11 @@ if command == 'set-password' then
end end
if not opts.force and not isBlank(settings.get(PASSWORD_SETTING)) then if not opts.force and not isBlank(settings.get(PASSWORD_SETTING)) then
print('cloud.password is already set; use cloud set-password --force to replace it'); print('cloud.password is already set; use cloud login --force to replace it');
return; return;
end end
local password = readPassword(); local password = opts.password or readPassword();
if isBlank(password) then if isBlank(password) then
print('cloud.password not changed: password cannot be empty'); print('cloud.password not changed: password cannot be empty');
return; return;
@ -103,6 +107,7 @@ if command == 'set-password' then
settings.set(PASSWORD_SETTING, password); settings.set(PASSWORD_SETTING, password);
settings.save(); settings.save();
os.queueEvent(RECONNECT_EVENT);
print('cloud.password set'); print('cloud.password set');
return; return;
end end

View File

@ -39,14 +39,12 @@ local function fileLog(level, msg, fields)
f.close(); f.close();
end end
local secret = settings.get(PASSWORD_SETTING);
_G.__trapos_cloud_daemon = true; _G.__trapos_cloud_daemon = true;
createCloud().startSession({ createCloud().startSession({
eventloop = _G.bootEventLoop, eventloop = _G.bootEventLoop,
url = url, url = url,
secret = secret, getSecret = function() return settings.get(PASSWORD_SETTING); end,
os = os, os = os,
log = fileLog, log = fileLog,
}); });

View File

@ -39,6 +39,7 @@ local function runCloud(args, opts)
local writes = {}; local writes = {};
local readMasks = {}; local readMasks = {};
local cloudRequests = {}; local cloudRequests = {};
local queuedEvents = {};
local settingsLib = opts.settings or fakeSettings(); local settingsLib = opts.settings or fakeSettings();
local readValues = opts.readValues or {}; local readValues = opts.readValues or {};
@ -60,6 +61,11 @@ local function runCloud(args, opts)
set = function(key, value) return settingsLib:set(key, value); end, set = function(key, value) return settingsLib:set(key, value); end,
save = function() return settingsLib:save(); end, save = function() return settingsLib:save(); end,
}, },
os = setmetatable({
queueEvent = function(...)
queuedEvents[#queuedEvents + 1] = table.pack(...);
end,
}, { __index = os }),
require = function(path) require = function(path)
if path == '/apis/libcloud' then if path == '/apis/libcloud' then
return function() return function()
@ -94,16 +100,18 @@ local function runCloud(args, opts)
writes = writes, writes = writes,
readMasks = readMasks, readMasks = readMasks,
cloudRequests = cloudRequests, cloudRequests = cloudRequests,
queuedEvents = queuedEvents,
settings = settingsLib, settings = settingsLib,
}; };
end end
testlib.test('cloud help lists status and set-password without health', function() testlib.test('cloud help lists status and login without health', function()
local ctx = runCloud({ '--help' }); local ctx = runCloud({ '--help' });
testlib.assertTrue(containsLine(ctx.lines, ' cloud status')); testlib.assertTrue(containsLine(ctx.lines, ' cloud status'));
testlib.assertTrue(containsLine(ctx.lines, ' cloud set-password [--force|-f]')); testlib.assertTrue(containsLine(ctx.lines, ' cloud login [--force|-f] [password]'));
testlib.assertTrue(not containsLine(ctx.lines, ' cloud health')); testlib.assertTrue(not containsLine(ctx.lines, ' cloud health'));
testlib.assertTrue(not containsText(ctx.lines, 'set-password'));
end); end);
testlib.test('cloud health is not kept as an alias', function() testlib.test('cloud health is not kept as an alias', function()
@ -128,9 +136,17 @@ testlib.test('cloud status probes the server', function()
testlib.assertTrue(containsLine(ctx.lines, 'opencode: missing key')); testlib.assertTrue(containsLine(ctx.lines, 'opencode: missing key'));
end); end);
testlib.test('cloud set-password stores a missing password with masked input', function() testlib.test('cloud set-password is not kept as an alias', function()
local ctx = runCloud({ 'set-password', 'hunter2' });
testlib.assertEquals(ctx.settings.values['cloud.password'], nil);
testlib.assertEquals(ctx.settings.saveCount, 0);
testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:'));
end);
testlib.test('cloud login stores a missing password with masked input', function()
local settingsLib = fakeSettings(); local settingsLib = fakeSettings();
local ctx = runCloud({ 'set-password' }, { local ctx = runCloud({ 'login' }, {
settings = settingsLib, settings = settingsLib,
readValues = { 'hunter2' }, readValues = { 'hunter2' },
}); });
@ -139,12 +155,26 @@ testlib.test('cloud set-password stores a missing password with masked input', f
testlib.assertEquals(ctx.readMasks[1], '*'); testlib.assertEquals(ctx.readMasks[1], '*');
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2'); testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
testlib.assertEquals(settingsLib.saveCount, 1); testlib.assertEquals(settingsLib.saveCount, 1);
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
testlib.assertTrue(containsLine(ctx.lines, 'cloud.password set')); testlib.assertTrue(containsLine(ctx.lines, 'cloud.password set'));
end); end);
testlib.test('cloud set-password rejects replacing without force', function() testlib.test('cloud login stores a password argument without prompting', function()
local settingsLib = fakeSettings();
local ctx = runCloud({ 'login', 'hunter2' }, {
settings = settingsLib,
readValues = { 'unused' },
});
testlib.assertEquals(#ctx.readMasks, 0);
testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2');
testlib.assertEquals(settingsLib.saveCount, 1);
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
end);
testlib.test('cloud login rejects replacing without force', function()
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
local ctx = runCloud({ 'set-password' }, { local ctx = runCloud({ 'login' }, {
settings = settingsLib, settings = settingsLib,
readValues = { 'new' }, readValues = { 'new' },
}); });
@ -152,40 +182,44 @@ testlib.test('cloud set-password rejects replacing without force', function()
testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(#ctx.readMasks, 0);
testlib.assertEquals(settingsLib.values['cloud.password'], 'old'); testlib.assertEquals(settingsLib.values['cloud.password'], 'old');
testlib.assertEquals(settingsLib.saveCount, 0); testlib.assertEquals(settingsLib.saveCount, 0);
testlib.assertTrue(containsText(ctx.lines, 'use cloud set-password --force')); testlib.assertEquals(#ctx.queuedEvents, 0);
testlib.assertTrue(containsText(ctx.lines, 'use cloud login --force'));
end); end);
testlib.test('cloud set-password --force replaces an existing password', function() testlib.test('cloud login --force replaces an existing password', function()
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
runCloud({ 'set-password', '--force' }, { local ctx = runCloud({ 'login', '--force' }, {
settings = settingsLib, settings = settingsLib,
readValues = { 'new' }, readValues = { 'new' },
}); });
testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
testlib.assertEquals(settingsLib.saveCount, 1); testlib.assertEquals(settingsLib.saveCount, 1);
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
end); end);
testlib.test('cloud set-password -f replaces an existing password', function() testlib.test('cloud login -f replaces an existing password from argument', function()
local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); local settingsLib = fakeSettings({ ['cloud.password'] = 'old' });
runCloud({ 'set-password', '-f' }, { local ctx = runCloud({ 'login', '-f', 'new' }, {
settings = settingsLib, settings = settingsLib,
readValues = { 'new' },
}); });
testlib.assertEquals(#ctx.readMasks, 0);
testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); testlib.assertEquals(settingsLib.values['cloud.password'], 'new');
testlib.assertEquals(settingsLib.saveCount, 1); testlib.assertEquals(settingsLib.saveCount, 1);
testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect');
end); end);
testlib.test('cloud set-password rejects an empty password', function() testlib.test('cloud login rejects an empty password', function()
local settingsLib = fakeSettings(); local settingsLib = fakeSettings();
local ctx = runCloud({ 'set-password' }, { local ctx = runCloud({ 'login' }, {
settings = settingsLib, settings = settingsLib,
readValues = { '' }, readValues = { '' },
}); });
testlib.assertEquals(settingsLib.values['cloud.password'], nil); testlib.assertEquals(settingsLib.values['cloud.password'], nil);
testlib.assertEquals(settingsLib.saveCount, 0); testlib.assertEquals(settingsLib.saveCount, 0);
testlib.assertEquals(#ctx.queuedEvents, 0);
testlib.assertTrue(containsLine(ctx.lines, 'cloud.password not changed: password cannot be empty')); testlib.assertTrue(containsLine(ctx.lines, 'cloud.password not changed: password cannot be empty'));
end); end);

View File

@ -269,6 +269,64 @@ testlib.test('startSession reconnects on an abnormal close with a non-displaced
testlib.assertEquals(#ctx.http.connects, 2); testlib.assertEquals(#ctx.http.connects, 2);
end); end);
testlib.test('startSession reconnect event reloads the secret immediately', function()
local secret = 'old';
local ctx = startSession({ getSecret = function() return secret; end });
local ws1 = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws1);
local hello1 = textutils.unserializeJSON(ws1.sent[1]);
testlib.assertEquals(hello1.payload.secret, 'old');
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
secret = 'new';
ctx.el.fire('trapos_cloud_reconnect', 'login');
testlib.assertEquals(ws1.closed, true);
testlib.assertEquals(#ctx.http.connects, 2);
local ws2 = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws2);
local hello2 = textutils.unserializeJSON(ws2.sent[1]);
testlib.assertEquals(hello2.payload.secret, 'new');
end);
testlib.test('startSession reconnect event suppresses the intentional close', function()
local ctx = startSession();
local ws1 = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws1);
ctx.el.fire('websocket_message', ctx.url, encode({ event = 'server_response', type = 'hello', ok = true, payload = {} }));
ctx.el.fire('trapos_cloud_reconnect', 'login');
local ws2 = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws2);
local timersAfterOpen = #ctx.el.timers;
ctx.el.fire('websocket_closed', ctx.url, 'manual close');
testlib.assertEquals(#ctx.el.timers, timersAfterOpen);
testlib.assertEquals(#ctx.http.connects, 2);
end);
testlib.test('startSession reconnect event recovers after unauthorized', function()
local secret = 'old';
local ctx = startSession({ getSecret = function() return secret; end });
local ws1 = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws1);
ctx.el.fire('websocket_message', ctx.url, encode({
event = 'server_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' },
}));
testlib.assertEquals(ctx.session.lastError(), 'unauthorized');
secret = 'new';
ctx.el.fire('trapos_cloud_reconnect', 'login');
testlib.assertEquals(ctx.session.lastError(), nil);
testlib.assertEquals(#ctx.http.connects, 2);
local ws2 = fakes.fakeWs();
ctx.el.fire('websocket_success', ctx.url, ws2);
local hello2 = textutils.unserializeJSON(ws2.sent[1]);
testlib.assertEquals(hello2.payload.secret, 'new');
end);
testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function() testlib.test('startSession logs the reconnect loop once on entry and once on recovery', function()
local logs = {}; local logs = {};
local function countMsg(msg) local function countMsg(msg)

View File

@ -1,6 +1,6 @@
{ {
"name": "trapos", "name": "trapos",
"version": "0.12.1", "version": "0.12.2",
"description": "TrapOS full install meta-package", "description": "TrapOS full install meta-package",
"dependencies": [ "dependencies": [
"trapos-boot", "trapos-boot",