From 06ede45212bd3a93b863bff491317f7338aa0b82 Mon Sep 17 00:00:00 2001 From: Guillaume ARM Date: Wed, 17 Jun 2026 02:41:48 +0200 Subject: [PATCH] feat(cloud): add password setup command --- manifest.json | 2 +- packages/index.json | 4 +- packages/trapos-cloud/ccpm.json | 2 +- packages/trapos-cloud/programs/cloud.lua | 63 +++++- packages/trapos-cloud/tests/cloud-program.lua | 192 ++++++++++++++++++ packages/trapos/ccpm.json | 2 +- .../test-integration/lua/full-boot.lua | 4 +- 7 files changed, 258 insertions(+), 11 deletions(-) create mode 100644 packages/trapos-cloud/tests/cloud-program.lua diff --git a/manifest.json b/manifest.json index 232ed2c..5d0fd28 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.12.0", + "version": "0.12.1", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 1cfb132..b35e0e4 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.0", + "trapos-cloud": "0.4.1", "trapos-sandbox-legacy": "0.3.2", - "trapos": "0.12.0" + "trapos": "0.12.1" } } diff --git a/packages/trapos-cloud/ccpm.json b/packages/trapos-cloud/ccpm.json index 18d548e..cb5247c 100644 --- a/packages/trapos-cloud/ccpm.json +++ b/packages/trapos-cloud/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-cloud", - "version": "0.4.0", + "version": "0.4.1", "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 3d7a0f4..d5256ce 100644 --- a/packages/trapos-cloud/programs/cloud.lua +++ b/packages/trapos-cloud/programs/cloud.lua @@ -2,14 +2,20 @@ local createCloud = require('/apis/libcloud'); local createVersion = require('/apis/libversion'); local URL_SETTING = 'cloud.url'; +local PASSWORD_SETTING = 'cloud.password'; local rawArgs = table.pack(...); local command = rawArgs[1]; +local function isBlank(value) + return type(value) ~= 'string' or value == ''; +end + local function printUsage() print('cloud usage:'); print(); - print(' cloud health'); + print(' cloud status'); + print(' cloud set-password [--force|-f]'); print(' cloud --version'); print(' cloud --help'); print(); @@ -19,6 +25,30 @@ local function printUsage() print(' cloud.password (shared secret for the hello handshake)'); end +local function readPassword() + write('Password: '); + if type(read) == 'function' then return read('*'); end + if io and type(io.read) == 'function' then return io.read(); end + return ''; +end + +local function parseSetPasswordArgs(args) + local force = false; + + for i = 2, args.n do + local arg = args[i]; + if arg == '--force' or arg == '-f' then + force = true; + elseif arg and string.sub(arg, 1, 1) == '-' then + return nil, 'unknown option: ' .. arg; + else + return nil, 'unexpected argument: ' .. tostring(arg); + end + end + + return { force = force }, nil; +end + if command == '--version' or command == '-version' or command == 'version' or command == '-v' then print('v' .. createVersion().forSelf()); return; @@ -29,9 +59,9 @@ if command == '--help' or command == '-help' or command == 'help' or command == return; end -if command == 'health' then +if command == 'status' then local url = settings.get(URL_SETTING); - if type(url) ~= 'string' or url == '' then + if isBlank(url) then print('set cloud.url first'); return; end @@ -40,7 +70,7 @@ if command == 'health' then if not ok then local code = (type(result) == 'table' and result.code) or 'internal'; local message = (type(result) == 'table' and result.message) or ''; - print('cloud health failed: ' .. tostring(code) .. ' ' .. tostring(message)); + print('cloud status failed: ' .. tostring(code) .. ' ' .. tostring(message)); return; end @@ -52,4 +82,29 @@ if command == 'health' then return; end +if command == 'set-password' then + local opts, parseError = parseSetPasswordArgs(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 set-password --force to replace it'); + return; + end + + local password = readPassword(); + if isBlank(password) then + print('cloud.password not changed: password cannot be empty'); + return; + end + + settings.set(PASSWORD_SETTING, password); + settings.save(); + print('cloud.password set'); + return; +end + printUsage(); diff --git a/packages/trapos-cloud/tests/cloud-program.lua b/packages/trapos-cloud/tests/cloud-program.lua new file mode 100644 index 0000000..d00cb9e --- /dev/null +++ b/packages/trapos-cloud/tests/cloud-program.lua @@ -0,0 +1,192 @@ +local createLibTest = require('/apis/libtest'); + +local testlib = createLibTest({ ... }); + +local function containsLine(lines, expected) + for _, line in ipairs(lines) do + if line == expected then return true; end + end + return false; +end + +local function containsText(lines, expected) + for _, line in ipairs(lines) do + if string.find(line, expected, 1, true) then return true; end + end + return false; +end + +local function fakeSettings(values) + return { + values = values or {}, + saveCount = 0, + get = function(self, key) + return self.values[key]; + end, + set = function(self, key, value) + self.values[key] = value; + end, + save = function(self) + self.saveCount = self.saveCount + 1; + return true; + end, + }; +end + +local function runCloud(args, opts) + opts = opts or {}; + local lines = {}; + local writes = {}; + local readMasks = {}; + local cloudRequests = {}; + local settingsLib = opts.settings or fakeSettings(); + local readValues = opts.readValues or {}; + + local env = setmetatable({ + print = function(line) + lines[#lines + 1] = line or ''; + end, + write = function(text) + writes[#writes + 1] = text or ''; + end, + read = function(mask) + readMasks[#readMasks + 1] = mask; + local value = readValues[#readMasks]; + if value == nil then return ''; end + return value; + end, + settings = { + get = function(key) return settingsLib:get(key); end, + set = function(key, value) return settingsLib:set(key, value); end, + save = function() return settingsLib:save(); end, + }, + require = function(path) + if path == '/apis/libcloud' then + return function() + return { + request = function(msgType, payload, requestOpts) + cloudRequests[#cloudRequests + 1] = { + msgType = msgType, + payload = payload, + opts = requestOpts, + }; + return opts.requestOk ~= false, opts.requestResult or { serverOk = true, opencodeOk = true }; + end, + }; + end; + end + if path == '/apis/libversion' then + return function() + return { forSelf = function() return '0.0.0-test'; end }; + end; + end + return require(path); + end, + }, { __index = _G }); + + local chunk, loadErr = loadfile('/programs/cloud.lua', 't', env); + if not chunk then error(loadErr, 0); end + local ok, err = pcall(chunk, table.unpack(args or {})); + if not ok then error(err, 0); end + + return { + lines = lines, + writes = writes, + readMasks = readMasks, + cloudRequests = cloudRequests, + settings = settingsLib, + }; +end + +testlib.test('cloud help lists status and set-password 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(not containsLine(ctx.lines, ' cloud health')); +end); + +testlib.test('cloud health is not kept as an alias', function() + local ctx = runCloud({ 'health' }, { + settings = fakeSettings({ ['cloud.url'] = 'wss://example.test' }), + }); + + testlib.assertEquals(#ctx.cloudRequests, 0); + testlib.assertTrue(containsLine(ctx.lines, 'cloud usage:')); +end); + +testlib.test('cloud status probes the server', function() + local ctx = runCloud({ 'status' }, { + settings = fakeSettings({ ['cloud.url'] = 'wss://example.test' }), + requestResult = { serverOk = true, opencodeOk = false, opencodeDetail = 'missing key' }, + }); + + testlib.assertEquals(#ctx.cloudRequests, 1); + testlib.assertEquals(ctx.cloudRequests[1].msgType, 'probe_server'); + testlib.assertTrue(containsLine(ctx.lines, 'serverOk: true')); + testlib.assertTrue(containsLine(ctx.lines, 'opencodeOk: false')); + testlib.assertTrue(containsLine(ctx.lines, 'opencode: missing key')); +end); + +testlib.test('cloud set-password stores a missing password with masked input', function() + local settingsLib = fakeSettings(); + local ctx = runCloud({ 'set-password' }, { + settings = settingsLib, + readValues = { 'hunter2' }, + }); + + testlib.assertEquals(ctx.writes[1], 'Password: '); + testlib.assertEquals(ctx.readMasks[1], '*'); + testlib.assertEquals(settingsLib.values['cloud.password'], 'hunter2'); + testlib.assertEquals(settingsLib.saveCount, 1); + testlib.assertTrue(containsLine(ctx.lines, 'cloud.password set')); +end); + +testlib.test('cloud set-password rejects replacing without force', function() + local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); + local ctx = runCloud({ 'set-password' }, { + settings = settingsLib, + readValues = { 'new' }, + }); + + 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')); +end); + +testlib.test('cloud set-password --force replaces an existing password', function() + local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); + runCloud({ 'set-password', '--force' }, { + settings = settingsLib, + readValues = { 'new' }, + }); + + testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); + testlib.assertEquals(settingsLib.saveCount, 1); +end); + +testlib.test('cloud set-password -f replaces an existing password', function() + local settingsLib = fakeSettings({ ['cloud.password'] = 'old' }); + runCloud({ 'set-password', '-f' }, { + settings = settingsLib, + readValues = { 'new' }, + }); + + testlib.assertEquals(settingsLib.values['cloud.password'], 'new'); + testlib.assertEquals(settingsLib.saveCount, 1); +end); + +testlib.test('cloud set-password rejects an empty password', function() + local settingsLib = fakeSettings(); + local ctx = runCloud({ 'set-password' }, { + settings = settingsLib, + readValues = { '' }, + }); + + testlib.assertEquals(settingsLib.values['cloud.password'], nil); + testlib.assertEquals(settingsLib.saveCount, 0); + testlib.assertTrue(containsLine(ctx.lines, 'cloud.password not changed: password cannot be empty')); +end); + +testlib.run(); diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index 3365457..7d7cd3b 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.12.0", + "version": "0.12.1", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/tools/trapos-server/test-integration/lua/full-boot.lua b/tools/trapos-server/test-integration/lua/full-boot.lua index 0bab8eb..a75d884 100644 --- a/tools/trapos-server/test-integration/lua/full-boot.lua +++ b/tools/trapos-server/test-integration/lua/full-boot.lua @@ -1,7 +1,7 @@ -- Full-boot e2e: replicate the real boot path (startup/boot.lua) closely enough -- to prove the whole stack works end to end. Sets the cloud.* settings, creates -- the global event loop, runs the REAL servers/cloud.lua daemon, then under --- parallel waits for the connection and runs the REAL `cloud health` program. +-- parallel waits for the connection and runs the REAL `cloud status` program. -- -- Args (via shell.run): url, secret ('' => none). -- NOTE: shell.run drops empty-string args; the harness passes '-' for "no secret". @@ -23,7 +23,7 @@ parallel.waitForAny( function() _G.bootEventLoop.runLoop(true); end, function() os.pullEvent('trapos_cloud_connected'); - shell.run('/programs/cloud.lua', 'health'); + shell.run('/programs/cloud.lua', 'status'); end );