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..a4454105 100644
--- a/cmd/sam-node/main.go
+++ b/cmd/sam-node/main.go
@@ -259,31 +259,6 @@ 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.
-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
-}
-
func main() {
rootCmd := &cobra.Command{
Use: "sam-node",
@@ -327,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)
}
@@ -746,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",
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/development/kind/run.sh b/development/kind/run.sh
index f6d7cdc8..5523a36d 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 \
@@ -131,10 +132,20 @@ tmuxs() { tmux -L samsocket -f /dev/null "$@"; }
show_cluster_logs() {
tmuxs kill-session -t "${SESSION}" 2>/dev/null || true
+ # 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="$(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')"
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)
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/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/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 @@
+
+
+
+
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..f5030e45 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) {
+Future _isolatedEnroll(String dataDir, String controlPlaneText, String jwtText, bool allowLoopback, String labelsText) => Isolate.run(() {
try {
- return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback);
+ return SamNodeLib().enroll(dataDir, controlPlaneText, jwtText, allowLoopback, labelsText);
} catch (e) {
return e.toString();
}
-}
+});
void main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -71,7 +74,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;
@@ -92,11 +97,12 @@ 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;
+ bool _starting = false;
int _selectedTab = 0; // 0 = Dashboard, 1 = Services
@override
@@ -116,6 +122,7 @@ class _NodeControlPageState extends State {
_controlPlaneController.dispose();
_jwtController.dispose();
_tokenController.dispose();
+ _labelsController.dispose();
_externalMcpUrlController.dispose();
_externalMcpNameController.dispose();
_externalMcpDescController.dispose();
@@ -160,7 +167,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');
@@ -363,7 +370,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);
@@ -574,9 +581,8 @@ class _NodeControlPageState extends State {
final dataDir = '${appDir.path}/sam_data';
final controlPlaneText = _controlPlaneController.text;
final jwtText = _jwtController.text;
- final err = await Isolate.run(() {
- return _isolatedEnroll(dataDir, controlPlaneText, jwtText, true);
- });
+ final labelsText = _labelsController.text.trim();
+ final err = await _isolatedEnroll(dataDir, controlPlaneText, jwtText, true, labelsText);
setState(() {
if (err != null) {
@@ -622,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 =