Skip to content

mobile: support attested labels on the Android node - #381

Closed
kaisoz wants to merge 12 commits into
google:mainfrom
kaisoz:kaisoz/fix-android-labels
Closed

mobile: support attested labels on the Android node#381
kaisoz wants to merge 12 commits into
google:mainfrom
kaisoz:kaisoz/fix-android-labels

Conversation

@kaisoz

@kaisoz kaisoz commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator

The mobile FFI had no way to declare labels, so a phone always enrolled unlabeled and any call against it with required_labels was refused by the caller's labels gate. Getting the label flow to work end to end on the emulator also surfaced several bugs in the app and gaps in the kind dev setup, fixed here.

Labels

  • api.ParseLabels: the key=value parser moves from cmd/sam-node into api/labels.go so the CLI and the FFI share it; the CLI calls it directly.
  • EnrollNode and MobileConfig gain labels. Enrollment mints them into the Biscuit; the FFI keeps a copy in the app's data directory and reuses it on start and renewal, since the app only shows the labels field on its enrollment screen.
  • The enrollment screen gains an optional labels field.

App fixes

  • Every enroll path failed with "Illegal argument in isolate message": Isolate.run closures inside State methods captured this and its DynamicLibrary. They now run from top-level functions.
  • A second Start tap tore down the embedded MCP server; start() is guarded and errors propagate.
  • External MCP fields use hints instead of prefilled values.

Kind dev setup

  • Dex lists the app's device-flow and loopback callbacks; bootstrap.nodeLabels={*} so dev nodes can attest labels; debug builds allow cleartext http to the kind cluster.
  • The tmux log window shows the console, control plane and Dex URLs.

Docs: the mobile README and site page no longer claim the mobile-ffi-* targets copy into jniLibs, give the right x86_64 paths, and describe how the app carries labels across starts.

Tests: api.ParseLabels unit test; the FFI lifecycle test asserts the enroll request carries the labels, that they are persisted, and that a label-less StartNode falls back to them; a negative test checks StartNode rejects malformed labels.

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.
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.
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.
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.
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.
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.
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.
@kaisoz
kaisoz requested a review from aojea September 9, 2026 15:21

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors label parsing by moving the parsing logic to a shared api.ParseLabels function and integrates label support into the mobile application's enrollment and startup flows. It also improves the mobile app's start button state handling and error reporting. The review feedback suggests enhancing the label parsing robustness by trimming whitespace from the input string, adding corresponding test coverage, and ensuring that parsed labels are semantically validated using api.ValidateLabels during both node enrollment and startup in the mobile FFI layer.

Comment on lines +91 to +101
labels, err := api.ParseLabels(config.Labels)
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
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The parsed labels should be validated using api.ValidateLabels to ensure that they conform to the required key/value specifications (e.g., length limits, allowed characters) before starting the node. Currently, only syntactic parsing is performed, which could allow malformed labels to be used at runtime.

	labels, err := api.ParseLabels(config.Labels)
	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
		}
	}
	if err := api.ValidateLabels(labels); err != nil {
		return fmt.Errorf("invalid labels: %w", err)
	}

Comment on lines +348 to +351
parsedLabels, err := api.ParseLabels(labels)
if err != nil {
return fmt.Errorf("invalid labels: %w", err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Validate the parsed labels using api.ValidateLabels before saving them to the local labels file and proceeding with enrollment. This prevents enrolling with or persisting malformed labels that would fail validation later.

	parsedLabels, err := api.ParseLabels(labels)
	if err != nil {
		return fmt.Errorf("invalid labels: %w", err)
	}
	if err := api.ValidateLabels(parsedLabels); err != nil {
		return fmt.Errorf("invalid labels: %w", err)
	}

Comment thread api/labels.go
Comment on lines +93 to +96
func ParseLabels(s string) (map[string]string, error) {
if s == "" {
return nil, nil
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Trimming leading and trailing whitespace from the input string s at the beginning of ParseLabels ensures that whitespace-only strings (e.g., " ") are consistently treated as empty and return nil, nil, rather than returning an empty map.

Suggested change
func ParseLabels(s string) (map[string]string, error) {
if s == "" {
return nil, nil
}
func ParseLabels(s string) (map[string]string, error) {
s = strings.TrimSpace(s)
if s == "" {
return nil, nil
}

Comment thread api/labels_test.go
Comment on lines +119 to +121
if labels, err := ParseLabels(""); err != nil || labels != nil {
t.Fatalf("empty string should yield no labels, got %v, %v", labels, err)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a test case to verify that whitespace-only strings are correctly parsed as empty/nil labels, matching the updated ParseLabels behavior.

Suggested change
if labels, err := ParseLabels(""); err != nil || labels != nil {
t.Fatalf("empty string should yield no labels, got %v, %v", labels, err)
}
if labels, err := ParseLabels(""); err != nil || labels != nil {
t.Fatalf("empty string should yield no labels, got %v, %v", labels, err)
}
if labels, err := ParseLabels(" "); err != nil || labels != nil {
t.Fatalf("whitespace-only string should yield no labels, got %v, %v", labels, err)
}

@kaisoz

kaisoz commented Sep 9, 2026

Copy link
Copy Markdown
Collaborator Author

This needs to be reworked as we decided to move the labels to the config from the CLI flags

@kaisoz kaisoz closed this Sep 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant