diff --git a/apis/libmcpcomputer.lua b/apis/libmcpcomputer.lua index bee37d6..18c66ea 100644 --- a/apis/libmcpcomputer.lua +++ b/apis/libmcpcomputer.lua @@ -37,7 +37,33 @@ local function serializeReturn(value) return { type = valueType, repr = tostring(value) }; end -local function createExecEnv(output) +-- Builds a CraftOS-style require/package pair the same way /rom/programs/shell.lua does +-- (dofile of cc.require, then .make(env, dir)). Returned factory caches the make function so +-- the rom module is only loaded once. Stays synchronous (require never yields), so it is safe +-- inside the synchronous sandbox handler -- unlike spawning a shell, which yields and deadlocks. +local function defaultRequireFactory() + local make; + local loaded = false; + return function(env, dir) + if not loaded then + loaded = true; + local ok, mod = pcall(dofile, '/rom/modules/main/cc/require.lua'); + if ok and type(mod) == 'table' then + make = mod.make; + end + end + if type(make) ~= 'function' then + return nil, nil; + end + local madeOk, req, pkg = pcall(make, env, dir or ''); + if not madeOk then + return nil, nil; + end + return req, pkg; + end +end + +local function createExecEnv(output, requireFactory, requireDir) local env = setmetatable({}, { __index = _G }); env.write = function(value) @@ -55,13 +81,21 @@ local function createExecEnv(output) appendOutput(output, '\n'); end; + if requireFactory then + local req, pkg = requireFactory(env, requireDir); + if req then + env.require = req; + env.package = pkg; + end + end + return env; end -local function executeSource(code, chunkName) +local function executeSource(code, chunkName, deps) local output = {}; - local fn, loadErr = load(code, chunkName, 't', createExecEnv(output)); + local fn, loadErr = load(code, chunkName, 't', createExecEnv(output, deps.requireFactory, deps.requireDir)); if not fn then return { ok = false, error = tostring(loadErr), output = table.concat(output) }; end @@ -78,7 +112,20 @@ local function executeSource(code, chunkName) return { ok = true, returns = returns, output = table.concat(output) }; end -local function createMcpComputer() +local function createMcpComputer(deps) + deps = deps or {}; + + -- requireFactory: function(env, dir) -> require, package. Defaults to the rom-backed factory; + -- pass `false` to disable require injection, or a function to stub it in tests. + local requireFactory = deps.requireFactory; + if requireFactory == nil then + requireFactory = defaultRequireFactory(); + end + local resolved = { + requireFactory = requireFactory or nil, + requireDir = deps.requireDir or '', + }; + local api = {}; api.formatLabel = formatLabel; @@ -94,7 +141,7 @@ local function createMcpComputer() return { ok = false, error = 'code must be a non-empty string', output = '' }; end - return executeSource(code, 'mcp-exec'); + return executeSource(code, 'mcp-exec', resolved); end function api.executeFile(path, fsLike) @@ -122,7 +169,7 @@ local function createMcpComputer() return { ok = false, error = tostring(closeErr), output = '' }; end - return executeSource(contentOrErr, '@' .. path); + return executeSource(contentOrErr, '@' .. path, resolved); end function api.writeFile(path, content, fsLike) diff --git a/manifest.json b/manifest.json index 3ad08d4..5c98718 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "name": "TrapOS", - "version": "0.11.1", + "version": "0.11.2", "branch": "master", "packages": [ "trapos" diff --git a/packages/index.json b/packages/index.json index 8930721..146586e 100644 --- a/packages/index.json +++ b/packages/index.json @@ -6,8 +6,8 @@ "trapos-net": "0.3.0", "trapos-ui": "0.2.2", "trapos-ai": "0.7.0", - "trapos-sandbox": "0.3.1", + "trapos-sandbox": "0.3.2", "trapos-sandbox-legacy": "0.3.0", - "trapos": "0.11.1" + "trapos": "0.11.2" } } diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 525855d..52ddf90 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos-sandbox", - "version": "0.3.1", + "version": "0.3.2", "description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command + MCP exec/write server", "dependencies": ["trapos-core"], "files": [ diff --git a/packages/trapos/ccpm.json b/packages/trapos/ccpm.json index c875c3e..5517e59 100644 --- a/packages/trapos/ccpm.json +++ b/packages/trapos/ccpm.json @@ -1,6 +1,6 @@ { "name": "trapos", - "version": "0.11.1", + "version": "0.11.2", "description": "TrapOS full install meta-package", "dependencies": [ "trapos-boot", diff --git a/tests/mcpcomputer.lua b/tests/mcpcomputer.lua index 616557b..ab7e30d 100644 --- a/tests/mcpcomputer.lua +++ b/tests/mcpcomputer.lua @@ -122,6 +122,68 @@ testlib.test('executeLua leaves direct terminal writes out of captured output', testlib.assertEquals(result.returns[1].value, 'done'); end); +testlib.test('executeLua injects require and package into the environment', function() + local seenEnv, seenDir; + local mcpComputer = createMcpComputer({ + requireFactory = function(env, dir) + seenEnv, seenDir = env, dir; + return function() return 'REQUIRED'; end, { loaded = {} }; + end, + }); + + local result = mcpComputer.executeLua("return require('whatever'), type(package)"); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.returns[1].type, 'string'); + testlib.assertEquals(result.returns[1].value, 'REQUIRED'); + testlib.assertEquals(result.returns[2].type, 'string'); + testlib.assertEquals(result.returns[2].value, 'table'); + testlib.assertEquals(type(seenEnv), 'table'); + testlib.assertEquals(seenDir, ''); +end); + +testlib.test('executeLua passes the configured requireDir to the factory', function() + local seenDir; + local mcpComputer = createMcpComputer({ + requireDir = '/apis', + requireFactory = function(_, dir) + seenDir = dir; + return function() end, {}; + end, + }); + + mcpComputer.executeLua('return 1'); + + testlib.assertEquals(seenDir, '/apis'); +end); + +testlib.test('executeLua leaves require unset when injection is disabled', function() + local mcpComputer = createMcpComputer({ requireFactory = false }); + + local result = mcpComputer.executeLua('return require == nil, package == nil'); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.returns[1].type, 'boolean'); + testlib.assertEquals(result.returns[1].value, true); + testlib.assertEquals(result.returns[2].type, 'boolean'); + testlib.assertEquals(result.returns[2].value, true); +end); + +testlib.test('executeFile injects require into the file environment', function() + local mcpComputer = createMcpComputer({ + requireFactory = function() return function() return 'FROM_FILE'; end, {}; end, + }); + local fsLike = fsWithFiles({ + ['/needs-require.lua'] = "return require('x')", + }); + + local result = mcpComputer.executeFile('/needs-require.lua', fsLike); + + testlib.assertEquals(result.ok, true); + testlib.assertEquals(result.returns[1].type, 'string'); + testlib.assertEquals(result.returns[1].value, 'FROM_FILE'); +end); + testlib.test('executeFile captures output and return values from file', function() local mcpComputer = createMcpComputer(); local fsLike = fsWithFiles({