From cde94c80964e6297685df826296ee8f8481e8706 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Mon, 7 Sep 2026 13:39:44 +0000 Subject: [PATCH 01/12] mobile: support attested labels on the Android node The mobile FFI had no way to declare labels, so a phone always enrolled unlabeled and any mesh call with required_labels against it was refused fail-closed. Add a labels field to the FFI start config and an EnrollNode parameter (labels are attested only at enrollment), plumbed from a new field on the app's enrollment screen using the CLI --labels syntax. The comma-separated parser moves from cmd/sam-node to api.ParseLabels so the CLI and the FFI share one implementation. --- api/labels.go | 26 +++++++++++++++++++ api/labels_test.go | 19 ++++++++++++++ cmd/sam-node/main.go | 24 ++--------------- .../integration_test/e2e_test.dart | 2 +- mobile/sam-node-app/lib/main.dart | 24 +++++++++++++---- mobile/sam-node-app/lib/sam_ffi.dart | 12 ++++++--- mobile/sam-node-ffi/ffi/ffi.go | 24 ++++++++++++++--- mobile/sam-node-ffi/ffi/ffi_test.go | 15 ++++++++++- mobile/sam-node-ffi/main.go | 5 ++-- site/content/docs/development/mobile.md | 2 +- 10 files changed, 113 insertions(+), 40 deletions(-) diff --git a/api/labels.go b/api/labels.go index 73349c71..c216a5f6 100644 --- a/api/labels.go +++ b/api/labels.go @@ -87,6 +87,32 @@ func ValidateLabels(labels map[string]string) error { return nil } +// ParseLabels parses a comma-separated "key=value" list (the wire/CLI form of +// a label set) into a label map; an empty string means no claims. Parsing is +// syntax only — run the result through ValidateLabels. +func ParseLabels(s string) (map[string]string, error) { + if s == "" { + return nil, nil + } + labels := make(map[string]string) + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + k, v, ok := strings.Cut(part, "=") + if !ok { + return nil, fmt.Errorf("invalid label %q: expected key=value", part) + } + key := strings.TrimSpace(k) + if _, exists := labels[key]; exists { + return nil, fmt.Errorf("duplicate label key %q", key) + } + labels[key] = strings.TrimSpace(v) + } + return labels, nil +} + // A node declares its own labels when it enrols, so on its own a label is a // claim rather than an attestation. A role's allowed_labels is what makes it // one: the control plane only signs a label the operator said that role may diff --git a/api/labels_test.go b/api/labels_test.go index 4ad2d80a..9cf0c985 100644 --- a/api/labels_test.go +++ b/api/labels_test.go @@ -107,3 +107,22 @@ func TestValidateLabels_AllKeysValidStaysSorted(t *testing.T) { t.Errorf("ValidateLabels(sorted happy path): unexpected error: %v", err) } } + +func TestParseLabels(t *testing.T) { + labels, err := ParseLabels("region=eu-west-1, team = platform") + if err != nil { + t.Fatalf("ParseLabels failed: %v", err) + } + if labels["region"] != "eu-west-1" || labels["team"] != "platform" { + t.Fatalf("unexpected labels: %v", labels) + } + if labels, err := ParseLabels(""); err != nil || labels != nil { + t.Fatalf("empty string should yield no labels, got %v, %v", labels, err) + } + if _, err := ParseLabels("no-equals"); err == nil { + t.Fatal("expected error for label without =") + } + if _, err := ParseLabels("k=a,k=b"); err == nil { + t.Fatal("expected error for duplicate key") + } +} diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index 111eae8d..1c1bf5b8 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -259,29 +259,9 @@ func interactiveJoin(ctx context.Context, store *node.Store, targetControlPlane return jwtStr, info, nil } -// parseLabelsFlag parses a comma-separated "key=value" list (see -// api/labels.go) into a label map; an empty string means no claims. +// parseLabelsFlag parses the --labels flag value; see api.ParseLabels. func parseLabelsFlag(s string) (map[string]string, error) { - if s == "" { - return nil, nil - } - labels := make(map[string]string) - for _, part := range strings.Split(s, ",") { - part = strings.TrimSpace(part) - if part == "" { - continue - } - k, v, ok := strings.Cut(part, "=") - if !ok { - return nil, fmt.Errorf("invalid label %q: expected key=value", part) - } - key := strings.TrimSpace(k) - if _, exists := labels[key]; exists { - return nil, fmt.Errorf("duplicate label key %q", key) - } - labels[key] = strings.TrimSpace(v) - } - return labels, nil + return api.ParseLabels(s) } func main() { diff --git a/mobile/sam-node-app/integration_test/e2e_test.dart b/mobile/sam-node-app/integration_test/e2e_test.dart index ba7adeaa..a041fe1e 100644 --- a/mobile/sam-node-app/integration_test/e2e_test.dart +++ b/mobile/sam-node-app/integration_test/e2e_test.dart @@ -44,7 +44,7 @@ void main() { // 3. Enroll Node against the host control plane const controlPlaneURL = 'http://127.0.0.1:37001'; - final enrollErr = samLib.enroll(dataDir, controlPlaneURL, jwt, true); + final enrollErr = samLib.enroll(dataDir, controlPlaneURL, jwt, true, ''); expect(enrollErr, isNull); // Start local Mock MCP Server inside the Android emulator. It must be diff --git a/mobile/sam-node-app/lib/main.dart b/mobile/sam-node-app/lib/main.dart index cf6f005e..8646c1dc 100644 --- a/mobile/sam-node-app/lib/main.dart +++ b/mobile/sam-node-app/lib/main.dart @@ -25,9 +25,9 @@ String? _isolatedFetchControlPlaneInfo(String url) { } } -String? _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback) { +String? _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback, String labelsText) { try { - return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback); + return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback, labelsText); } catch (e) { return e.toString(); } @@ -71,7 +71,9 @@ class _NodeControlPageState extends State { TextEditingController(text: 'https://bananas.sam-mesh.dev'); final _jwtController = TextEditingController(); final _tokenController = TextEditingController(text: 'secret-token'); - + // Labels are attested at enrollment; changing them requires re-enrolling. + final _labelsController = TextEditingController(); + static const _exposeChannel = MethodChannel('com.example.sam_agent/mesh_expose'); late SamNodeLib _samLib; @@ -116,6 +118,7 @@ class _NodeControlPageState extends State { _controlPlaneController.dispose(); _jwtController.dispose(); _tokenController.dispose(); + _labelsController.dispose(); _externalMcpUrlController.dispose(); _externalMcpNameController.dispose(); _externalMcpDescController.dispose(); @@ -574,8 +577,9 @@ class _NodeControlPageState extends State { final dataDir = '${appDir.path}/sam_data'; final controlPlaneText = _controlPlaneController.text; final jwtText = _jwtController.text; + final labelsText = _labelsController.text.trim(); final err = await Isolate.run(() { - return _isolatedEnroll(dataDir, controlPlaneText, jwtText, true); + return _isolatedEnroll(dataDir, controlPlaneText, jwtText, true, labelsText); }); setState(() { @@ -656,6 +660,7 @@ class _NodeControlPageState extends State { 'apiToken': _tokenController.text, 'allowLoopback': true, 'enableRelay': false, + 'labels': _labelsController.text.trim(), 'services': services, }); @@ -688,7 +693,7 @@ class _NodeControlPageState extends State { _pollingTimer?.cancel(); _embeddedMcpServer.stop(); final err = _samLib.stop(); - + // Stop Android Foreground Service try { _exposeChannel.invokeMethod('stopBackgroundService'); @@ -929,6 +934,15 @@ class _NodeControlPageState extends State { ), ), const SizedBox(height: 20), + TextField( + controller: _labelsController, + decoration: const InputDecoration( + labelText: 'Labels (key=value, comma-separated)', + border: OutlineInputBorder(), + hintText: 'region=eu-west-1', + ), + ), + const SizedBox(height: 20), ElevatedButton.icon( onPressed: _loggingIn ? null : _loginAndEnroll, icon: _loggingIn diff --git a/mobile/sam-node-app/lib/sam_ffi.dart b/mobile/sam-node-app/lib/sam_ffi.dart index 7b7aca10..e6cf9118 100644 --- a/mobile/sam-node-app/lib/sam_ffi.dart +++ b/mobile/sam-node-app/lib/sam_ffi.dart @@ -17,12 +17,14 @@ typedef EnrollNodeC = ffi.Pointer Function( ffi.Pointer dataDir, ffi.Pointer controlPlaneURL, ffi.Pointer jwt, - ffi.Int8 allowLoopback); + ffi.Int8 allowLoopback, + ffi.Pointer labels); typedef EnrollNodeDart = ffi.Pointer Function( ffi.Pointer dataDir, ffi.Pointer controlPlaneURL, ffi.Pointer jwt, - int allowLoopback); + int allowLoopback, + ffi.Pointer labels); typedef FetchControlPlaneInfoJSONC = ffi.Pointer Function(ffi.Pointer controlPlaneURL); typedef FetchControlPlaneInfoJSONDart = ffi.Pointer Function(ffi.Pointer controlPlaneURL); @@ -94,17 +96,19 @@ class SamNodeLib { return goID; } - String? enroll(String dataDir, String controlPlaneURL, String jwt, bool allowLoopback) { + String? enroll(String dataDir, String controlPlaneURL, String jwt, bool allowLoopback, String labels) { final cDataDir = dataDir.toNativeUtf8(); final cControlPlaneURL = controlPlaneURL.toNativeUtf8(); final cJWT = jwt.toNativeUtf8(); final cAllowLoopback = allowLoopback ? 1 : 0; + final cLabels = labels.toNativeUtf8(); - final cErr = _enrollNode(cDataDir, cControlPlaneURL, cJWT, cAllowLoopback); + final cErr = _enrollNode(cDataDir, cControlPlaneURL, cJWT, cAllowLoopback, cLabels); calloc.free(cDataDir); calloc.free(cControlPlaneURL); calloc.free(cJWT); + calloc.free(cLabels); if (cErr.address == 0) return null; final goErr = cErr.toDartString(); diff --git a/mobile/sam-node-ffi/ffi/ffi.go b/mobile/sam-node-ffi/ffi/ffi.go index ea26a847..c0740d8c 100644 --- a/mobile/sam-node-ffi/ffi/ffi.go +++ b/mobile/sam-node-ffi/ffi/ffi.go @@ -56,8 +56,11 @@ type MobileConfig struct { LogLevel string `json:"logLevel"` DiscoveryInterval string `json:"discoveryInterval"` ListenAddrs string `json:"listenAddrs"` // comma-separated - AllowLoopback bool `json:"allowLoopback"` - EnableRelay bool `json:"enableRelay"` + // Labels are comma-separated key=value claims (same syntax as the CLI + // --labels flag); they are attested only at enrollment. + Labels string `json:"labels"` + AllowLoopback bool `json:"allowLoopback"` + EnableRelay bool `json:"enableRelay"` // Services this node exposes, declared at start like the node config // file's services block; there is no runtime registration. Services []MobileService `json:"services,omitempty"` @@ -85,6 +88,11 @@ func StartNode(configJSON string) error { return fmt.Errorf("failed to parse config JSON: %w", err) } + labels, err := api.ParseLabels(config.Labels) + if err != nil { + return fmt.Errorf("invalid labels: %w", err) + } + lvl := golog.LevelInfo if config.LogLevel != "" { if l, err := golog.LevelFromString(config.LogLevel); err == nil { @@ -193,6 +201,7 @@ func StartNode(configJSON string) error { Store: store, BannedPeerIDs: bannedPeerIDs, MeshID: meshID, + Labels: labels, DiscoveryInterval: discoveryInterval, ListenAddrs: listenAddrs, EnableRelay: config.EnableRelay, @@ -311,8 +320,14 @@ func GetNodeID() string { return "" } -// EnrollNode enrolls a node. -func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopback bool) error { +// EnrollNode enrolls a node. Labels use the CLI --labels syntax and are +// minted into the node's Biscuit here — changing them requires re-enrolling. +func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopback bool, labels string) error { + parsedLabels, err := api.ParseLabels(labels) + if err != nil { + return fmt.Errorf("invalid labels: %w", err) + } + _ = os.MkdirAll(dataDir, 0700) logFilePath := filepath.Join(dataDir, "node.log") golog.SetupLogging(golog.Config{ @@ -354,6 +369,7 @@ func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopbac Store: store, AllowLoopback: allowLoopback, ListenAddrs: listenAddrs, + Labels: parsedLabels, }) if err != nil { return fmt.Errorf("failed to create node for enrollment: %w", err) diff --git a/mobile/sam-node-ffi/ffi/ffi_test.go b/mobile/sam-node-ffi/ffi/ffi_test.go index 559db114..a4da5b7a 100644 --- a/mobile/sam-node-ffi/ffi/ffi_test.go +++ b/mobile/sam-node-ffi/ffi/ffi_test.go @@ -72,11 +72,13 @@ func TestMobileFFILifecycle(t *testing.T) { println("--- MOCK ROUTER: wrote AuthResponse success with valid biscuit") }) + var enrolledLabels map[string]string mux := http.NewServeMux() mux.HandleFunc("/register", func(w http.ResponseWriter, r *http.Request) { body, _ := io.ReadAll(r.Body) var req api.EnrollRequest _ = proto.Unmarshal(body, &req) + enrolledLabels = req.Labels biscuitBytes := mintMockBiscuit(t, req.PeerId, cpPrivKey, api.RoleNode) resp := &api.EnrollResponse{ @@ -104,10 +106,13 @@ func TestMobileFFILifecycle(t *testing.T) { // 2. Mobile Enrollment tmpDir := t.TempDir() - err = EnrollNode(tmpDir, httpServer.URL, "dummy-jwt", true) + err = EnrollNode(tmpDir, httpServer.URL, "dummy-jwt", true, "region=eu-west-1") if err != nil { t.Fatalf("EnrollNode failed: %v", err) } + if enrolledLabels["region"] != "eu-west-1" { + t.Fatalf("Expected label region=eu-west-1 in enroll request, got %v", enrolledLabels) + } // 3. Mobile Node Start cfg := MobileConfig{ @@ -117,6 +122,7 @@ func TestMobileFFILifecycle(t *testing.T) { BindAddr: "127.0.0.1:0", // random free port ApiToken: "test-token", AllowLoopback: true, + Labels: "region=eu-west-1", } cfgBytes, _ := json.Marshal(cfg) @@ -136,6 +142,13 @@ func TestMobileFFILifecycle(t *testing.T) { } } +func TestStartNodeRejectsInvalidLabels(t *testing.T) { + if err := StartNode(`{"labels": "no-equals"}`); err == nil { + _ = StopNode() + t.Fatal("expected StartNode to reject invalid labels") + } +} + func mintMockBiscuit(t *testing.T, peerID string, priv ed25519.PrivateKey, role string) []byte { builder := biscuit.NewBuilder(priv) if err := builder.AddAuthorityFact(biscuit.Fact{ diff --git a/mobile/sam-node-ffi/main.go b/mobile/sam-node-ffi/main.go index a99d4718..622526f5 100644 --- a/mobile/sam-node-ffi/main.go +++ b/mobile/sam-node-ffi/main.go @@ -55,13 +55,14 @@ func GetNodeID() *C.char { } //export EnrollNode -func EnrollNode(dataDir *C.char, controlPlaneURL *C.char, jwt *C.char, allowLoopback C.char) *C.char { +func EnrollNode(dataDir *C.char, controlPlaneURL *C.char, jwt *C.char, allowLoopback C.char, labels *C.char) *C.char { goDataDir := C.GoString(dataDir) goControlPlaneURL := C.GoString(controlPlaneURL) goJWT := C.GoString(jwt) goAllowLoopback := allowLoopback != 0 + goLabels := C.GoString(labels) - err := ffi.EnrollNode(goDataDir, goControlPlaneURL, goJWT, goAllowLoopback) + err := ffi.EnrollNode(goDataDir, goControlPlaneURL, goJWT, goAllowLoopback, goLabels) if err != nil { return C.CString(err.Error()) } diff --git a/site/content/docs/development/mobile.md b/site/content/docs/development/mobile.md index 67ae9d64..0c9759be 100644 --- a/site/content/docs/development/mobile.md +++ b/site/content/docs/development/mobile.md @@ -41,7 +41,7 @@ To run `sam-node` on mobile with near-zero codebase maintenance, we avoid rewrit 1. **Go FFI Binding Package (`mobile/sam-node-ffi/`)**: Contains CGO-exported functions (`StartNode`, `StopNode`, `EnrollNode`, `GetNodeID`, `FreeString`) which compile into a C-shared library (`.so`) or static archive (`.a`). 2. **Flutter App (`mobile/sam-node-app/`)**: A cross-platform app containing: - `lib/sam_ffi.dart`: The Dart FFI wrapper loading the Go library and exposing Dart methods. - - `lib/main.dart`: Simple control UI to enroll and start/stop the background node. + - `lib/main.dart`: Simple control UI to enroll and start/stop the background node. The enrollment screen accepts optional labels (comma-separated `key=value`, same syntax as the CLI `--labels` flag); like on desktop, labels are attested only at enrollment, so changing them requires re-enrolling. 3. **Local Loopback Communication**: - The Flutter Dart environment controls the node lifecycle (construction, starting, stopping) via FFI. - Any actual tool registration, discovery, or mesh API queries are performed using standard HTTP JSON-RPC calls over local loopback (`127.0.0.1`) to the `sam-node` sidecar API. From 343f2f71d31e91fd542b75c8b9221859308df292 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Mon, 7 Sep 2026 22:16:47 +0000 Subject: [PATCH 02/12] mobile: run FFI isolates from top-level functions A closure created inside a State method shares its context with the sibling setState closures, so Isolate.run received the widget state and its DynamicLibrary and failed with "Illegal argument in isolate message". Every enroll path in the app was affected. --- mobile/sam-node-app/lib/main.dart | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/mobile/sam-node-app/lib/main.dart b/mobile/sam-node-app/lib/main.dart index 8646c1dc..5401b8d7 100644 --- a/mobile/sam-node-app/lib/main.dart +++ b/mobile/sam-node-app/lib/main.dart @@ -17,21 +17,24 @@ import 'package:url_launcher/url_launcher.dart'; import 'sam_ffi.dart'; import 'mcp_server.dart'; -String? _isolatedFetchControlPlaneInfo(String url) { +// Isolate.run lives in these top-level functions, not in State methods: a closure +// there shares its context with sibling setState closures, so `this` and its +// DynamicLibrary would be sent to the isolate and rejected as unsendable. +Future _isolatedFetchControlPlaneInfo(String url) => Isolate.run(() { try { return SamNodeLib().fetchControlPlaneInfoJSON(url); } catch (e) { return jsonEncode({'error': 'FFI_ERROR: ${e.toString()}'}); } -} +}); -String? _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback, String labelsText) { +Future _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback, String labelsText) => Isolate.run(() { try { return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback, labelsText); } catch (e) { return e.toString(); } -} +}); void main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -163,7 +166,7 @@ class _NodeControlPageState extends State { try { final controlPlaneUrl = _controlPlaneController.text.trim(); debugPrint('DEBUG: Fetching control plane info from $controlPlaneUrl'); - final infoJson = await Isolate.run(() => _isolatedFetchControlPlaneInfo(controlPlaneUrl)); + final infoJson = await _isolatedFetchControlPlaneInfo(controlPlaneUrl); debugPrint('DEBUG: Control plane info JSON: $infoJson'); if (infoJson == null) { throw Exception('Failed to fetch control plane info'); @@ -366,7 +369,7 @@ class _NodeControlPageState extends State { try { final controlPlaneUrl = _controlPlaneController.text.trim(); debugPrint('DEBUG: Device Login: Fetching control plane info from $controlPlaneUrl'); - final infoJson = await Isolate.run(() => _isolatedFetchControlPlaneInfo(controlPlaneUrl)); + final infoJson = await _isolatedFetchControlPlaneInfo(controlPlaneUrl); if (infoJson == null) throw Exception('Failed to fetch control plane info'); final info = jsonDecode(infoJson); @@ -578,9 +581,7 @@ class _NodeControlPageState extends State { final controlPlaneText = _controlPlaneController.text; final jwtText = _jwtController.text; final labelsText = _labelsController.text.trim(); - final err = await Isolate.run(() { - return _isolatedEnroll(dataDir, controlPlaneText, jwtText, true, labelsText); - }); + final err = await _isolatedEnroll(dataDir, controlPlaneText, jwtText, true, labelsText); setState(() { if (err != null) { From 1c24a8af4b2f2cc431c8a7cf053595f93303dede Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Mon, 7 Sep 2026 22:16:47 +0000 Subject: [PATCH 03/12] docs: mobile-ffi targets do not copy into jniLibs; fix x86_64 paths --- mobile/sam-node-app/README.md | 12 +++++++----- site/content/docs/development/mobile.md | 4 +++- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/mobile/sam-node-app/README.md b/mobile/sam-node-app/README.md index 3af03812..94175c2f 100644 --- a/mobile/sam-node-app/README.md +++ b/mobile/sam-node-app/README.md @@ -26,19 +26,21 @@ To build the app, you must first compile the Go FFI library and bundle it inside ### 1. Compile FFI Library -Run one of the following commands from the **repository root directory**: +Run one of the following from the **repository root directory**. The `mobile-ffi-*` targets only build into `bin/`; the `cp` step puts the library where the Flutter project loads it from. (`make mobile-app-apk` and `make mobile-app-apk-emulator` do build, copy and release APK in one go.) -* **For Android ARM64 Devices (Physical Phones)**: +* **For Android ARM64 (physical phones, and emulators on Apple Silicon hosts)**: ```bash make mobile-ffi-android + mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a + cp bin/android/libsam.so mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/ ``` - *Copies the binary to `mobile/sam-node-app/android/app/src/main/jniLibs/arm64-v8a/libsam.so`* -* **For Android x86_64 Emulators (AVD)**: +* **For Android x86_64 emulators (Intel and Linux hosts)**: ```bash make mobile-ffi-android-x86_64 + mkdir -p mobile/sam-node-app/android/app/src/main/jniLibs/x86_64 + cp bin/android-x86_64/libsam.so mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/ ``` - *Copies the binary to `mobile/sam-node-app/android/app/src/main/jniLibs/x86_64/libsam.so`* * **For iOS Devices**: ```bash diff --git a/site/content/docs/development/mobile.md b/site/content/docs/development/mobile.md index 0c9759be..8b451fae 100644 --- a/site/content/docs/development/mobile.md +++ b/site/content/docs/development/mobile.md @@ -65,7 +65,7 @@ make mobile-ffi-android ``` ### 3. Build Android Emulator FFI Library -Compiles `bin/android/libsam.so` targeting Android x86_64 emulator environments: +Compiles `bin/android-x86_64/libsam.so` for x86_64 emulator images (Intel and Linux hosts). Emulators on Apple Silicon run arm64-v8a images; use target 2 for those: ```bash make mobile-ffi-android-x86_64 ``` @@ -89,6 +89,8 @@ make mobile-app-apk To make changes to `sam-node` and run them on a mobile device: ### Android Setup +The `mobile-ffi-*` targets only build into `bin/`; copy the library into `jniLibs/` yourself (or use `make mobile-app-apk`, which does both and builds the APK). For an x86_64 emulator, swap in `mobile-ffi-android-x86_64`, `bin/android-x86_64/libsam.so` and `jniLibs/x86_64`. + 1. Compile the Android ARM64 FFI shared library: ```bash make mobile-ffi-android From 1a76c7eb1ed9cc7c66fedbb70f216407eb31c3ec Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Tue, 8 Sep 2026 08:42:44 +0000 Subject: [PATCH 04/12] kind: let the Android app enroll through Dex Dex rejects the app's device flow and loopback redirects unless the client lists them. Debug builds also need a network security config: Flutter blocks plain http to anything but loopback, and the kind Dex is plain http. --- development/kind/dex.yaml | 7 +++++++ .../sam-node-app/android/app/src/debug/AndroidManifest.xml | 3 +++ .../app/src/debug/res/xml/network_security_config.xml | 4 ++++ 3 files changed, 14 insertions(+) create mode 100644 mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml diff --git a/development/kind/dex.yaml b/development/kind/dex.yaml index b068d31b..a5529b6a 100644 --- a/development/kind/dex.yaml +++ b/development/kind/dex.yaml @@ -18,6 +18,13 @@ data: - id: sam-console redirectURIs: - ${CONSOLE_REDIRECT_URI} + # The sam-node CLI and the mobile app log in through Dex's device flow or a + # loopback browser callback; Dex rejects both unless listed here. + - /device/callback + # Fixed ports from internal/node/oidc.go and mobile/sam-node-app/lib/main.dart. + - http://127.0.0.1:13000/callback + - http://127.0.0.1:13001/callback + - http://127.0.0.1:13002/callback name: 'SAM Console' public: true enablePasswordDB: true diff --git a/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml b/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml index 399f6981..9ff81738 100644 --- a/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml +++ b/mobile/sam-node-app/android/app/src/debug/AndroidManifest.xml @@ -4,4 +4,7 @@ to allow setting breakpoints, to provide hot reload, etc. --> + + diff --git a/mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml b/mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml new file mode 100644 index 00000000..2439f15c --- /dev/null +++ b/mobile/sam-node-app/android/app/src/debug/res/xml/network_security_config.xml @@ -0,0 +1,4 @@ + + + + From fb73dcb4be49b7ca6ab0dc8489d0a5079f35808d Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Tue, 8 Sep 2026 09:16:01 +0000 Subject: [PATCH 05/12] kind: show the mesh URLs in the tmux log window header --- development/kind/run.sh | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/development/kind/run.sh b/development/kind/run.sh index f6d7cdc8..cf3d5f8b 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -131,10 +131,19 @@ tmuxs() { tmux -L samsocket -f /dev/null "$@"; } show_cluster_logs() { tmuxs kill-session -t "${SESSION}" 2>/dev/null || true + # Resolved here rather than inherited so `-l` on a running cluster gets the header too. + local main_ip dex_ip + main_ip="$(gateway_ip sam-mesh-gateway)" + dex_ip="$(gateway_ip sam-mesh-dex-gateway)" + tmuxs new-session -d -s "${SESSION}" -n mesh "$(logs control-plane 'deploy/sam-mesh-control-plane')" \; set -t "${SESSION}" destroy-unattached off tmuxs split-window -t "${SESSION}:0" "$(logs router 'statefulset/sam-mesh-router')" tmuxs set-option -t "${SESSION}" -g pane-border-status top tmuxs set-option -t "${SESSION}" -g pane-border-format ' #{pane_title} ' + tmuxs set-option -t "${SESSION}" status-position top + tmuxs set-option -t "${SESSION}" status-left-length 250 + tmuxs set-option -t "${SESSION}" status-right '' + tmuxs set-option -t "${SESSION}" status-left " console http://${main_ip}${CONSOLE_BASE_PATH}/ control plane http://${main_ip} dex http://${dex_ip}/dex " # Title the tmux panes in creation order: control-plane, router. titles=(control-plane router) From b988db0f07f048c319dd84b4f63a165d7c043208 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Tue, 8 Sep 2026 13:10:59 +0200 Subject: [PATCH 06/12] mobile: use hints instead of prefilled external MCP values The external MCP url, name and description fields were prefilled with the local android-remote server's values. Turn them into hints so the fields start empty and the values read as examples. Also exclude build/ and android/ from the analyzer so `flutter analyze` only reports on our own sources. --- mobile/sam-node-app/analysis_options.yaml | 4 ++++ mobile/sam-node-app/lib/main.dart | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/mobile/sam-node-app/analysis_options.yaml b/mobile/sam-node-app/analysis_options.yaml index 0d290213..5671d4c5 100644 --- a/mobile/sam-node-app/analysis_options.yaml +++ b/mobile/sam-node-app/analysis_options.yaml @@ -7,6 +7,10 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** include: package:flutter_lints/flutter.yaml linter: diff --git a/mobile/sam-node-app/lib/main.dart b/mobile/sam-node-app/lib/main.dart index 5401b8d7..a006218b 100644 --- a/mobile/sam-node-app/lib/main.dart +++ b/mobile/sam-node-app/lib/main.dart @@ -97,9 +97,9 @@ class _NodeControlPageState extends State { // External MCP Bridging State: read when the node starts, since services // are declared in the start configuration. - final _externalMcpUrlController = TextEditingController(text: 'http://127.0.0.1:8080'); - final _externalMcpNameController = TextEditingController(text: 'android-remote'); - final _externalMcpDescController = TextEditingController(text: 'External Android Remote Control MCP'); + final _externalMcpUrlController = TextEditingController(); + final _externalMcpNameController = TextEditingController(); + final _externalMcpDescController = TextEditingController(); late SamDartMcpServer _embeddedMcpServer; int _selectedTab = 0; // 0 = Dashboard, 1 = Services @@ -901,6 +901,7 @@ class _NodeControlPageState extends State { enabled: !isRunning, decoration: const InputDecoration( labelText: 'Description', + hintText: 'External Android Remote Control MCP', border: OutlineInputBorder(), ), ), From a09200cb2bb313f5bbc310aec51a84c92960f9bc Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Tue, 8 Sep 2026 11:13:51 +0000 Subject: [PATCH 07/12] kind: let dev nodes attest any label --- development/kind/run.sh | 1 + 1 file changed, 1 insertion(+) diff --git a/development/kind/run.sh b/development/kind/run.sh index cf3d5f8b..db3a2aeb 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -104,6 +104,7 @@ deploy_chart() { --set controlPlane.allowedAudiences="${ALLOWED_AUDIENCES//,/\\,}" \ --set controlPlane.insecureSkipTlsVerify=true \ --set 'bootstrap.nodeServices={*}' \ + --set 'bootstrap.nodeLabels={*}' \ --set 'bootstrap.nodeMembers={sam:system:authenticated}' \ --set gateway.enabled=true \ --set gateway.className=cloud-provider-kind \ From d4b48578968260ecd2ea3bd63759532bde83f12b Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Tue, 8 Sep 2026 16:24:06 +0200 Subject: [PATCH 08/12] mobile: stop a second Start tap from killing the embedded MCP server MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Start button stayed enabled until _running was set, so a second tap re-entered _start() about two seconds later. Its HttpServer.bind hit the port the first call had already bound, the SocketException was swallowed by a debugPrint, and the failing call's error path then stopped the server the first call had successfully started. The node stayed up with a dead backend on 9090, so it withheld phone-sensors from the DHT and mesh discovery returned "initialize: EOF" or nothing at all, flapping with the health probe. Guard _start() against re-entry, let the bind error propagate so a failure aborts the start instead of declaring a service that points at a dead port, and only stop the embedded server when this call is the one that started it. The button now reports "Starting…" while it runs. --- mobile/sam-node-app/lib/main.dart | 31 ++++++++++++++++++--- mobile/sam-node-app/lib/mcp_server.dart | 36 ++++++++++++------------- 2 files changed, 46 insertions(+), 21 deletions(-) diff --git a/mobile/sam-node-app/lib/main.dart b/mobile/sam-node-app/lib/main.dart index a006218b..f5030e45 100644 --- a/mobile/sam-node-app/lib/main.dart +++ b/mobile/sam-node-app/lib/main.dart @@ -102,6 +102,7 @@ class _NodeControlPageState extends State { final _externalMcpDescController = TextEditingController(); late SamDartMcpServer _embeddedMcpServer; + bool _starting = false; int _selectedTab = 0; // 0 = Dashboard, 1 = Services @override @@ -627,13 +628,37 @@ class _NodeControlPageState extends State { } Future _start() async { + // Backstop for the disabled button while a start is in flight. + if (_starting) return; + setState(() { + _starting = true; + }); + try { + await _startNode(); + } finally { + if (mounted) { + setState(() { + _starting = false; + }); + } + } + } + + Future _startNode() async { final appDir = await getApplicationDocumentsDirectory(); final dataDir = '${appDir.path}/sam_data'; // The embedded MCP backend must be listening before the node starts: // services are declared in the start configuration and probed at startup, // there is no runtime registration. - await _embeddedMcpServer.start(port: 9090); + try { + await _embeddedMcpServer.start(port: 9090); + } catch (e) { + setState(() { + _status = 'Start failed: embedded MCP server: $e'; + }); + return; + } final services = >[ { @@ -1114,9 +1139,9 @@ class _NodeControlPageState extends State { children: [ Expanded( child: ElevatedButton.icon( - onPressed: isRunning ? null : _start, + onPressed: (isRunning || _starting) ? null : _start, icon: const Icon(Icons.play_arrow), - label: const Text('Start'), + label: Text(_starting ? 'Starting…' : 'Start'), style: ElevatedButton.styleFrom( backgroundColor: Colors.green, foregroundColor: Colors.white, diff --git a/mobile/sam-node-app/lib/mcp_server.dart b/mobile/sam-node-app/lib/mcp_server.dart index fd8b32f8..a111e8d0 100644 --- a/mobile/sam-node-app/lib/mcp_server.dart +++ b/mobile/sam-node-app/lib/mcp_server.dart @@ -21,30 +21,30 @@ class SamDartMcpServer { /// backs is declared in the node's start configuration; there is no /// runtime registration, so this server must be listening before the node /// starts and probes it. + /// + /// Throws if the port cannot be bound. Future start({int port = 9090}) async { - try { - _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); - debugPrint('SAM Dart MCP Server listening on port $port'); - - _server!.listen((HttpRequest request) async { - // Handle CORS if needed, but since it's loopback and called by Go, maybe not strict - if (request.method == 'GET') { - _handleSse(request); - } else if (request.method == 'POST') { - _handlePost(request); - } else { - request.response.statusCode = HttpStatus.methodNotAllowed; - await request.response.close(); - } - }); - } catch (e) { - debugPrint('Failed to start Dart MCP Server: $e'); - } + if (_server != null) throw StateError('already started'); + _server = await HttpServer.bind(InternetAddress.loopbackIPv4, port); + debugPrint('SAM Dart MCP Server listening on port $port'); + + _server!.listen((HttpRequest request) async { + // Handle CORS if needed, but since it's loopback and called by Go, maybe not strict + if (request.method == 'GET') { + _handleSse(request); + } else if (request.method == 'POST') { + _handlePost(request); + } else { + request.response.statusCode = HttpStatus.methodNotAllowed; + await request.response.close(); + } + }); } /// Stops the server Future stop() async { await _server?.close(force: true); + _server = null; _sseClients.clear(); debugPrint('SAM Dart MCP Server stopped'); } From 4045d99761b2c7b3c44795bb9eb64ec307566a51 Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Wed, 9 Sep 2026 09:49:22 +0000 Subject: [PATCH 09/12] mobile: remember the labels the node enrolled with The app only shows the labels field on its enrollment screen, so after a relaunch StartNode ran with no labels: discovery gossiped none and the next Biscuit renewal re-enrolled without them. EnrollNode now writes the labels to the app's data directory and StartNode reads them back when the start config carries none. --- mobile/sam-node-ffi/ffi/ffi.go | 25 +++++++++++++++++++++++++ mobile/sam-node-ffi/ffi/ffi_test.go | 5 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/mobile/sam-node-ffi/ffi/ffi.go b/mobile/sam-node-ffi/ffi/ffi.go index c0740d8c..af245243 100644 --- a/mobile/sam-node-ffi/ffi/ffi.go +++ b/mobile/sam-node-ffi/ffi/ffi.go @@ -92,6 +92,13 @@ func StartNode(configJSON string) error { if err != nil { return fmt.Errorf("invalid labels: %w", err) } + // The app only shows the labels field on its enrollment screen, so an + // empty start config reuses the labels the node enrolled with. + if len(labels) == 0 { + if labels, err = loadEnrolledLabels(config.DataDir); err != nil { + return err + } + } lvl := golog.LevelInfo if config.LogLevel != "" { @@ -320,6 +327,21 @@ func GetNodeID() string { return "" } +// labelsFile keeps the enrolled labels in the app's data directory. The CLI +// has no equivalent: --labels is passed on every run. +const labelsFile = "labels" + +func loadEnrolledLabels(dataDir string) (map[string]string, error) { + raw, err := os.ReadFile(filepath.Join(dataDir, labelsFile)) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to read labels: %w", err) + } + return api.ParseLabels(string(raw)) +} + // EnrollNode enrolls a node. Labels use the CLI --labels syntax and are // minted into the node's Biscuit here — changing them requires re-enrolling. func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopback bool, labels string) error { @@ -329,6 +351,9 @@ func EnrollNode(dataDir string, controlPlaneURL string, jwt string, allowLoopbac } _ = os.MkdirAll(dataDir, 0700) + if err := os.WriteFile(filepath.Join(dataDir, labelsFile), []byte(labels), 0600); err != nil { + return fmt.Errorf("failed to save labels: %w", err) + } logFilePath := filepath.Join(dataDir, "node.log") golog.SetupLogging(golog.Config{ File: logFilePath, diff --git a/mobile/sam-node-ffi/ffi/ffi_test.go b/mobile/sam-node-ffi/ffi/ffi_test.go index a4da5b7a..37186b72 100644 --- a/mobile/sam-node-ffi/ffi/ffi_test.go +++ b/mobile/sam-node-ffi/ffi/ffi_test.go @@ -113,6 +113,9 @@ func TestMobileFFILifecycle(t *testing.T) { if enrolledLabels["region"] != "eu-west-1" { t.Fatalf("Expected label region=eu-west-1 in enroll request, got %v", enrolledLabels) } + if got, err := loadEnrolledLabels(tmpDir); err != nil || got["region"] != "eu-west-1" { + t.Fatalf("Expected enrolled labels persisted for StartNode, got %v, %v", got, err) + } // 3. Mobile Node Start cfg := MobileConfig{ @@ -122,7 +125,7 @@ func TestMobileFFILifecycle(t *testing.T) { BindAddr: "127.0.0.1:0", // random free port ApiToken: "test-token", AllowLoopback: true, - Labels: "region=eu-west-1", + // No labels: StartNode must fall back to the enrolled ones. } cfgBytes, _ := json.Marshal(cfg) From b79af332caf17825ea70a6fe379d375d5d98f0fa Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Wed, 9 Sep 2026 09:49:59 +0000 Subject: [PATCH 10/12] sam-node: call api.ParseLabels directly --- cmd/sam-node/main.go | 9 ++------- cmd/sam-node/main_test.go | 19 ------------------- 2 files changed, 2 insertions(+), 26 deletions(-) diff --git a/cmd/sam-node/main.go b/cmd/sam-node/main.go index 1c1bf5b8..a4454105 100644 --- a/cmd/sam-node/main.go +++ b/cmd/sam-node/main.go @@ -259,11 +259,6 @@ func interactiveJoin(ctx context.Context, store *node.Store, targetControlPlane return jwtStr, info, nil } -// parseLabelsFlag parses the --labels flag value; see api.ParseLabels. -func parseLabelsFlag(s string) (map[string]string, error) { - return api.ParseLabels(s) -} - func main() { rootCmd := &cobra.Command{ Use: "sam-node", @@ -307,7 +302,7 @@ func main() { if jwtFlag != "" { logger.Warn("--jwt passes a secret on the command line; prefer --jwt-path") } - labels, err := parseLabelsFlag(labelsFlag) + labels, err := api.ParseLabels(labelsFlag) if err != nil { logger.Fatalf("Invalid --labels: %v", err) } @@ -726,7 +721,7 @@ func main() { } } - labels, err := parseLabelsFlag(labelsFlag) + labels, err := api.ParseLabels(labelsFlag) if err != nil { logger.Fatalf("Invalid --labels: %v", err) } diff --git a/cmd/sam-node/main_test.go b/cmd/sam-node/main_test.go index 76d97034..ee9cb929 100644 --- a/cmd/sam-node/main_test.go +++ b/cmd/sam-node/main_test.go @@ -62,25 +62,6 @@ func TestResolveSocketPath(t *testing.T) { }) } -func TestParseLabelsFlag(t *testing.T) { - if got, err := parseLabelsFlag(""); got != nil || err != nil { - t.Errorf("empty flag: got %v, %v; want nil, nil", got, err) - } - - got, err := parseLabelsFlag(" region=eu , team=platform ,,") - if err != nil || len(got) != 2 || got["region"] != "eu" || got["team"] != "platform" { - t.Errorf("parse should split key=value pairs: got %v, %v", got, err) - } - - if _, err := parseLabelsFlag("noequals"); err == nil { - t.Error("entry without '=' must be rejected") - } - - if _, err := parseLabelsFlag("region=us-east-1,region=us-west-1"); err == nil { - t.Error("duplicate label key must be rejected") - } -} - func TestNormalizeControlPlaneURL(t *testing.T) { cases := map[string]string{ "bananas.sam-mesh.dev": "https://bananas.sam-mesh.dev", From cfe967fed96f4e59562ed2e95d483a7d769fe41b Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Wed, 9 Sep 2026 09:49:59 +0000 Subject: [PATCH 11/12] kind: look the header addresses up without polling show_cluster_logs used gateway_ip, which polls for two minutes and exits non-zero, so under set -e `run.sh -l` opened nothing when a gateway had no address yet. --- development/kind/run.sh | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/development/kind/run.sh b/development/kind/run.sh index db3a2aeb..5523a36d 100755 --- a/development/kind/run.sh +++ b/development/kind/run.sh @@ -132,10 +132,11 @@ tmuxs() { tmux -L samsocket -f /dev/null "$@"; } show_cluster_logs() { tmuxs kill-session -t "${SESSION}" 2>/dev/null || true - # Resolved here rather than inherited so `-l` on a running cluster gets the header too. + # Looked up here, not inherited, so `-l` gets the header too; one direct query rather + # than gateway_ip's polling, so the logs still open when a gateway has no address. local main_ip dex_ip - main_ip="$(gateway_ip sam-mesh-gateway)" - dex_ip="$(gateway_ip sam-mesh-dex-gateway)" + main_ip="$(kubectl --context "${KCTX}" -n "${NAMESPACE}" get gateway sam-mesh-gateway -o jsonpath='{.status.addresses[0].value}' 2>/dev/null || true)" + dex_ip="$(kubectl --context "${KCTX}" -n "${NAMESPACE}" get gateway sam-mesh-dex-gateway -o jsonpath='{.status.addresses[0].value}' 2>/dev/null || true)" tmuxs new-session -d -s "${SESSION}" -n mesh "$(logs control-plane 'deploy/sam-mesh-control-plane')" \; set -t "${SESSION}" destroy-unattached off tmuxs split-window -t "${SESSION}:0" "$(logs router 'statefulset/sam-mesh-router')" @@ -144,7 +145,7 @@ show_cluster_logs() { tmuxs set-option -t "${SESSION}" status-position top tmuxs set-option -t "${SESSION}" status-left-length 250 tmuxs set-option -t "${SESSION}" status-right '' - tmuxs set-option -t "${SESSION}" status-left " console http://${main_ip}${CONSOLE_BASE_PATH}/ control plane http://${main_ip} dex http://${dex_ip}/dex " + tmuxs set-option -t "${SESSION}" status-left " console http://${main_ip:-?}${CONSOLE_BASE_PATH}/ control plane http://${main_ip:-?} dex http://${dex_ip:-?}/dex " # Title the tmux panes in creation order: control-plane, router. titles=(control-plane router) From 0a0f73b4a0df6837b0ac542d330cec485420ec3e Mon Sep 17 00:00:00 2001 From: Tomas Tormo Date: Wed, 9 Sep 2026 09:55:55 +0000 Subject: [PATCH 12/12] docs: describe how the app carries labels across starts --- site/content/docs/development/mobile.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/content/docs/development/mobile.md b/site/content/docs/development/mobile.md index 8b451fae..91c53dbb 100644 --- a/site/content/docs/development/mobile.md +++ b/site/content/docs/development/mobile.md @@ -41,7 +41,7 @@ To run `sam-node` on mobile with near-zero codebase maintenance, we avoid rewrit 1. **Go FFI Binding Package (`mobile/sam-node-ffi/`)**: Contains CGO-exported functions (`StartNode`, `StopNode`, `EnrollNode`, `GetNodeID`, `FreeString`) which compile into a C-shared library (`.so`) or static archive (`.a`). 2. **Flutter App (`mobile/sam-node-app/`)**: A cross-platform app containing: - `lib/sam_ffi.dart`: The Dart FFI wrapper loading the Go library and exposing Dart methods. - - `lib/main.dart`: Simple control UI to enroll and start/stop the background node. The enrollment screen accepts optional labels (comma-separated `key=value`, same syntax as the CLI `--labels` flag); like on desktop, labels are attested only at enrollment, so changing them requires re-enrolling. + - `lib/main.dart`: Simple control UI to enroll and start/stop the background node. The enrollment screen accepts optional labels (comma-separated `key=value`, same syntax as the CLI `--labels` flag). The control plane mints them into the node's Biscuit at enrollment; the app keeps a copy in its data directory and sends it again on every start and Biscuit renewal, so changing labels means re-enrolling. Unlike the CLI, where `sam-node run --labels` has to be passed on every run, the app remembers them. 3. **Local Loopback Communication**: - The Flutter Dart environment controls the node lifecycle (construction, starting, stopping) via FFI. - Any actual tool registration, discovery, or mesh API queries are performed using standard HTTP JSON-RPC calls over local loopback (`127.0.0.1`) to the `sam-node` sidecar API.