diff --git a/CLAUDE.md b/CLAUDE.md index 8671838..3f6ff76 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ Caveman-terse. Android client for SealGate stdio tunnel. Details: README.md. ## What - Device daemon. Outbound WebSocket to backend. Cloud agents reach local MCP servers. - No subprocesses. `mcp_frame` -> in-process `LocalMcpModule` (`app/.../mcp/`). Unknown name -> spawn error. -- Mobile Bash is the only MCP surface: prefix `mobilebash`, one `run` tool, dashboard display name `Mobile Bash`, command `mobile-builtin`. Pinned just-bash browser bundle in QuickJS, in-memory FS only. Source/build recipe: `tools/bash-runtime/`; generated asset is checked in. +- Mobile Bash is the only MCP surface: one `run` tool, dashboard display name `Mobile Bash`, command `mobile-builtin`. Default prefix `mobilebash`, but binding is by command (`tunnel/BuiltinServerBinding.kt`): prefixes are org-unique, so any prefix must work. Pinned just-bash browser bundle in QuickJS, in-memory FS only. Source/build recipe: `tools/bash-runtime/`; generated asset is checked in. - Kotlin, JDK 17. Android Views, no Compose. Single module `:app`, ns `ai.sealgate.stdiod`. ## ❗ Environment (NOT discoverable from code — read this) diff --git a/README.md b/README.md index 8f8a651..42a12a0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,11 @@ daemon spawns `npx`/`uvx` subprocesses, this app answers MCP requests from ### Mobile Bash -The app exposes one built-in MCP server whose SealGate prefix is `mobilebash`. +The app exposes one built-in MCP server, conventionally registered under the +SealGate prefix `mobilebash`. The prefix is only a label: the dashboard +command `mobile-builtin` is what binds a server to this module, so any prefix +works (prefixes are unique per organisation, so a second phone in the same org +needs a different one). Its single `run` tool executes the required `script` argument inside a restricted [just-bash](https://github.com/vercel-labs/just-bash) environment: @@ -99,6 +103,9 @@ per-file limit, and 32 MiB total virtual filesystem limit. In the SealGate dashboard, add one local server for the device with display name **Mobile Bash**, MCP prefix `mobilebash`, and command `mobile-builtin`. No arguments are required. The resulting agent tool is `mobilebash_run`. + If `mobilebash` is already taken in your organisation, pick any other + prefix (say `mobilebash-alice`); the command is what matters, and the tool + becomes `_run`. While the service runs, it posts an ongoing notification: diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/BuiltinServerBinding.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/BuiltinServerBinding.kt new file mode 100644 index 0000000..0c48625 --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/BuiltinServerBinding.kt @@ -0,0 +1,46 @@ +package ai.sealgate.stdiod.tunnel + +import ai.sealgate.stdiod.mcp.LocalMcpModule + +/** + * The dashboard "command" that marks a local server as one of this app's + * built-in modules. Anything else would need a subprocess, which a phone + * cannot spawn. + */ +const val BUILTIN_COMMAND = "mobile-builtin" + +/** + * Pick the built-in module a desired server should be served by. + * + * The SealGate prefix (`server.name`) is unique per organisation, so two + * people in the same org cannot both call their phone `mobilebash`. The + * prefix therefore cannot be the only way to find a module. Resolution order: + * + * 1. A module whose name equals the prefix (the original convention). + * 2. Otherwise, when the command is [BUILTIN_COMMAND]: the module named by + * the first argument, or - with no arguments - the only exposed module. + * + * Returns null when nothing matches; the caller reports a spawn error. + */ +fun resolveBuiltinModule( + server: DesiredServer, + modulesByName: Map, +): LocalMcpModule? { + modulesByName[server.name]?.let { return it } + if (server.command != BUILTIN_COMMAND) return null + val requested = server.args.firstOrNull() + if (requested != null) return modulesByName[requested] + return modulesByName.values.singleOrNull() +} + +/** Human-readable reason for refusing to bind [server]. */ +fun describeUnboundServer(server: DesiredServer, moduleNames: Collection): String { + val available = moduleNames.sorted() + return if (server.command == BUILTIN_COMMAND) { + "no built-in module matches server `${server.name}` on this Android device; " + + "pass one of $available as the first argument, or use it as the prefix" + } else { + "command `${server.command}` is not `$BUILTIN_COMMAND`; this Android device " + + "cannot spawn subprocesses and only serves built-in modules $available" + } +} diff --git a/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt b/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt index 2b84825..df28224 100644 --- a/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt +++ b/app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt @@ -45,9 +45,9 @@ sealed interface TunnelState { * lifetime. * * Unlike the desktop daemon there is no process supervision: `mcp_frame`s - * route to in-process [LocalMcpModule]s by server name, and desired-state - * entries that don't match a built-in module are refused with a spawn error - * (a phone cannot run `npx`). + * route to in-process [LocalMcpModule]s bound from the desired state (by + * prefix, or by the `mobile-builtin` command), and entries that match no + * built-in module are refused with a spawn error (a phone cannot run `npx`). */ class TunnelClient( private val gatewayUrl: String, @@ -218,8 +218,10 @@ class TunnelClient( /** * Bind desired servers to built-in modules and ack each spawn the way * the desktop daemon's supervisor does — except "spawning" here is a - * name lookup. Unknown names are refused loudly so the dashboard's - * create-server flow gets a real error instead of a timeout. + * lookup (see [resolveBuiltinModule]: by prefix, else by the + * `mobile-builtin` command). Unmatched servers are refused loudly so + * the dashboard's create-server flow gets a real error instead of a + * timeout. */ private fun bindServers(webSocket: WebSocket, servers: List) { if (stopped.get()) return @@ -228,7 +230,7 @@ class TunnelClient( modulesByServerId.remove(server.serverId) continue } - val module = modulesByName[server.name] + val module = resolveBuiltinModule(server, modulesByName) if (module != null) { modulesByServerId[server.serverId] = module send(webSocket, ServerSpawnResult(serverId = server.serverId, ok = true)) @@ -239,8 +241,7 @@ class TunnelClient( ServerSpawnResult( serverId = server.serverId, ok = false, - error = "no built-in module named `${server.name}` on this Android device; " + - "available: ${modulesByName.keys.sorted()}", + error = describeUnboundServer(server, modulesByName.keys), ), ) } diff --git a/app/src/test/java/ai/sealgate/stdiod/tunnel/BuiltinServerBindingTest.kt b/app/src/test/java/ai/sealgate/stdiod/tunnel/BuiltinServerBindingTest.kt new file mode 100644 index 0000000..1cdc73c --- /dev/null +++ b/app/src/test/java/ai/sealgate/stdiod/tunnel/BuiltinServerBindingTest.kt @@ -0,0 +1,65 @@ +package ai.sealgate.stdiod.tunnel + +import ai.sealgate.stdiod.mcp.LocalMcpModule +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class BuiltinServerBindingTest { + private class FakeModule(override val name: String) : LocalMcpModule { + override fun handle(message: JsonObject): JsonObject? = null + } + + private val bash = FakeModule("mobilebash") + private val computer = FakeModule("computer") + + private fun server( + name: String, + command: String = BUILTIN_COMMAND, + args: List = emptyList(), + ) = DesiredServer(serverId = name, name = name, command = command, args = args, enabled = true) + + @Test + fun prefixEqualToModuleNameBinds() { + val modules = mapOf(bash.name to bash, computer.name to computer) + assertSame(bash, resolveBuiltinModule(server("mobilebash", command = "anything"), modules)) + } + + @Test + fun customPrefixWithBuiltinCommandBindsTheOnlyModule() { + val modules = mapOf(bash.name to bash) + assertSame(bash, resolveBuiltinModule(server("mobilebash3"), modules)) + } + + @Test + fun firstArgumentSelectsModuleWhenSeveralAreExposed() { + val modules = mapOf(bash.name to bash, computer.name to computer) + assertSame(computer, resolveBuiltinModule(server("phone", args = listOf("computer")), modules)) + assertNull(resolveBuiltinModule(server("phone", args = listOf("nope")), modules)) + } + + @Test + fun ambiguousBuiltinWithoutArgumentIsRefused() { + val modules = mapOf(bash.name to bash, computer.name to computer) + assertNull(resolveBuiltinModule(server("phone"), modules)) + } + + @Test + fun nonBuiltinCommandIsRefused() { + val modules = mapOf(bash.name to bash) + assertNull(resolveBuiltinModule(server("mobilebash3", command = "npx"), modules)) + } + + @Test + fun refusalMessagesNameTheAvailableModules() { + val names = listOf("mobilebash") + val builtin = describeUnboundServer(server("phone"), names) + assertTrue(builtin, builtin.contains("[mobilebash]") && builtin.contains("`phone`")) + val npx = describeUnboundServer(server("phone", command = "npx"), names) + assertTrue(npx, npx.contains("`npx`") && npx.contains(BUILTIN_COMMAND)) + assertEquals("mobile-builtin", BUILTIN_COMMAND) + } +}