Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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 `<prefix>_run`.

While the service runs, it posts an ongoing notification:

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, LocalMcpModule>,
): 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>): 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"
}
}
17 changes: 9 additions & 8 deletions app/src/main/java/ai/sealgate/stdiod/tunnel/TunnelClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<DesiredServer>) {
if (stopped.get()) return
Expand All @@ -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))
Expand All @@ -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),
),
)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> = 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)
}
}
Loading