From bd2bd220691c3bf9bbfa17a60bc4d966ae2dc3c6 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Wed, 17 Jun 2026 03:12:51 +0200 Subject: [PATCH] feat(cloud): reconnect after login --- manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-cloud/apis/libcloud.lua | 32 +++++++++- packages/trapos-cloud/ccpm.json | 2 +- packages/trapos-cloud/programs/cloud.lua | 19 +++--- packages/trapos-cloud/servers/cloud.lua | 4 +- packages/trapos-cloud/tests/cloud-program.lua | 62 ++++++++++++++----- packages/trapos-cloud/tests/cloud.lua | 58 +++++++++++++++++ packages/trapos/ccpm.json | 2 +- 9 files changed, 154 insertions(+), 31 deletions(-) diff --git a/manifest.json b/manifest.json index 5d0fd28..80f904b 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.12.1", + "version": "0.12.2", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index b35e0e4..c60c959 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,8 +6,8 @@ "trapos-net": "0.3.2", "trapos-ui": "0.2.4", "trapos-ai": "0.8.0", - "trapos-cloud": "0.4.1", + "trapos-cloud": "0.4.2", "trapos-sandbox-legacy": "0.3.2", - "trapos": "0.12.1" + "trapos": "0.12.2" } } diff --git a/packages/trapos-cloud/apis/libcloud.lua b/packages/trapos-cloud/apis/libcloud.lua index f9ecce7..66e295d 100644 --- a/packages/trapos-cloud/apis/libcloud.lua +++ b/packages/trapos-cloud/apis/libcloud.lua @@ -197,7 +197,8 @@ local function createCloud() local queueEvent = opts.queueEvent or function(...) return osLike.queueEvent(...); end; local reconnectDelay = opts.reconnectDelay or 5; 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 -- stays silent unless the caller wires a sink (e.g. a /logs file appender). local log = opts.log or function() end; @@ -210,6 +211,8 @@ local function createCloud() local state = 'connecting'; local lastError = nil; local activeWs = nil; + local suppressNextClose = false; + local clearReconnectTimeout = nil; -- 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 -- and once on recovery instead of flooding the file every reconnectDelay seconds. @@ -234,6 +237,11 @@ local function createCloud() end local function connect() + if clearReconnectTimeout then + clearReconnectTimeout(); + clearReconnectTimeout = nil; + end + secret = getSecret(); state = 'connecting'; if not reconnecting then log('info', 'connecting', { url = url }); @@ -253,7 +261,18 @@ local function createCloud() reconnecting = true; log('warn', 'reconnect loop started', { delaySeconds = reconnectDelay }); 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 local function onHello(ok, _payload, err) @@ -326,6 +345,11 @@ local function createCloud() -- of bricking the daemon into a permanent 'displaced' state. el.register('websocket_closed', function(eventUrl, reason, code) 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 state == 'ready' then activeWs = nil; @@ -375,6 +399,10 @@ local function createCloud() sendFrame(api.buildComputerResponse(traposId, messageId, msgType, ok == true, payload, err)); end); + el.register('trapos_cloud_reconnect', function(reason) + reconnectNow(reason); + end); + connect(); return { diff --git a/packages/trapos-cloud/ccpm.json b/packages/trapos-cloud/ccpm.json index cb5247c..677d479 100644 --- a/packages/trapos-cloud/ccpm.json +++ b/packages/trapos-cloud/ccpm.json @@ -1,6 +1,6 @@ { "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", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos-cloud/programs/cloud.lua b/packages/trapos-cloud/programs/cloud.lua index d5256ce..e9e742f 100644 --- a/packages/trapos-cloud/programs/cloud.lua +++ b/packages/trapos-cloud/programs/cloud.lua @@ -3,6 +3,7 @@ local createVersion = require('/apis/libversion'); local URL_SETTING = 'cloud.url'; local PASSWORD_SETTING = 'cloud.password'; +local RECONNECT_EVENT = 'trapos_cloud_reconnect'; local rawArgs = table.pack(...); local command = rawArgs[1]; @@ -15,7 +16,7 @@ local function printUsage() print('cloud usage:'); print(); print(' cloud status'); - print(' cloud set-password [--force|-f]'); + print(' cloud login [--force|-f] [password]'); print(' cloud --version'); print(' cloud --help'); print(); @@ -32,8 +33,9 @@ local function readPassword() return ''; end -local function parseSetPasswordArgs(args) +local function parseLoginArgs(args) local force = false; + local password = nil; for i = 2, args.n do local arg = args[i]; @@ -41,12 +43,14 @@ local function parseSetPasswordArgs(args) force = true; elseif arg and string.sub(arg, 1, 1) == '-' then return nil, 'unknown option: ' .. arg; + elseif not password then + password = arg; else return nil, 'unexpected argument: ' .. tostring(arg); end end - return { force = force }, nil; + return { force = force, password = password }, nil; end if command == '--version' or command == '-version' or command == 'version' or command == '-v' then @@ -82,8 +86,8 @@ if command == 'status' then return; end -if command == 'set-password' then - local opts, parseError = parseSetPasswordArgs(rawArgs); +if command == 'login' then + local opts, parseError = parseLoginArgs(rawArgs); if parseError then print(parseError); printUsage(); @@ -91,11 +95,11 @@ if command == 'set-password' then end 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; end - local password = readPassword(); + local password = opts.password or readPassword(); if isBlank(password) then print('cloud.password not changed: password cannot be empty'); return; @@ -103,6 +107,7 @@ if command == 'set-password' then settings.set(PASSWORD_SETTING, password); settings.save(); + os.queueEvent(RECONNECT_EVENT); print('cloud.password set'); return; end diff --git a/packages/trapos-cloud/servers/cloud.lua b/packages/trapos-cloud/servers/cloud.lua index f3e885a..8d59985 100644 --- a/packages/trapos-cloud/servers/cloud.lua +++ b/packages/trapos-cloud/servers/cloud.lua @@ -39,14 +39,12 @@ local function fileLog(level, msg, fields) f.close(); end -local secret = settings.get(PASSWORD_SETTING); - _G.__trapos_cloud_daemon = true; createCloud().startSession({ eventloop = _G.bootEventLoop, url = url, - secret = secret, + getSecret = function() return settings.get(PASSWORD_SETTING); end, os = os, log = fileLog, }); diff --git a/packages/trapos-cloud/tests/cloud-program.lua b/packages/trapos-cloud/tests/cloud-program.lua index d00cb9e..1cb58ba 100644 --- a/packages/trapos-cloud/tests/cloud-program.lua +++ b/packages/trapos-cloud/tests/cloud-program.lua @@ -39,6 +39,7 @@ local function runCloud(args, opts) local writes = {}; local readMasks = {}; local cloudRequests = {}; + local queuedEvents = {}; local settingsLib = opts.settings or fakeSettings(); local readValues = opts.readValues or {}; @@ -60,6 +61,11 @@ local function runCloud(args, opts) set = function(key, value) return settingsLib:set(key, value); end, save = function() return settingsLib:save(); end, }, + os = setmetatable({ + queueEvent = function(...) + queuedEvents[#queuedEvents + 1] = table.pack(...); + end, + }, { __index = os }), require = function(path) if path == '/apis/libcloud' then return function() @@ -94,16 +100,18 @@ local function runCloud(args, opts) writes = writes, readMasks = readMasks, cloudRequests = cloudRequests, + queuedEvents = queuedEvents, settings = settingsLib, }; 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' }); 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 containsText(ctx.lines, 'set-password')); end); 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')); 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 ctx = runCloud({ 'set-password' }, { + local ctx = runCloud({ 'login' }, { settings = settingsLib, 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(settingsLib.values['cloud.password'], 'hunter2'); testlib.assertEquals(settingsLib.saveCount, 1); + testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); testlib.assertTrue(containsLine(ctx.lines, 'cloud.password set')); 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 ctx = runCloud({ 'set-password' }, { + local ctx = runCloud({ 'login' }, { settings = settingsLib, readValues = { 'new' }, }); @@ -152,40 +182,44 @@ testlib.test('cloud set-password rejects replacing without force', function() testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(settingsLib.values['cloud.password'], 'old'); 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); -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' }); - runCloud({ 'set-password', '--force' }, { + local ctx = runCloud({ 'login', '--force' }, { settings = settingsLib, readValues = { 'new' }, }); testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); testlib.assertEquals(settingsLib.saveCount, 1); + testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); 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' }); - runCloud({ 'set-password', '-f' }, { + local ctx = runCloud({ 'login', '-f', 'new' }, { settings = settingsLib, - readValues = { 'new' }, }); + testlib.assertEquals(#ctx.readMasks, 0); testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); testlib.assertEquals(settingsLib.saveCount, 1); + testlib.assertEquals(ctx.queuedEvents[1][1], 'trapos_cloud_reconnect'); end); -testlib.test('cloud set-password rejects an empty password', function() +testlib.test('cloud login rejects an empty password', function() local settingsLib = fakeSettings(); - local ctx = runCloud({ 'set-password' }, { + local ctx = runCloud({ 'login' }, { settings = settingsLib, readValues = { '' }, }); testlib.assertEquals(settingsLib.values['cloud.password'], nil); testlib.assertEquals(settingsLib.saveCount, 0); + testlib.assertEquals(#ctx.queuedEvents, 0); testlib.assertTrue(containsLine(ctx.lines, 'cloud.password not changed: password cannot be empty')); end); diff --git a/packages/trapos-cloud/tests/cloud.lua b/packages/trapos-cloud/tests/cloud.lua index df16135..ad036f7 100644 --- a/packages/trapos-cloud/tests/cloud.lua +++ b/packages/trapos-cloud/tests/cloud.lua @@ -269,6 +269,64 @@ testlib.test('startSession reconnects on an abnormal close with a non-displaced testlib.assertEquals(#ctx.http.connects, 2); 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() local logs = {}; local function countMsg(msg) diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 7d7cd3b..449f875 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.12.1", + "version": "0.12.2", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot",