From 7f6a993e4558b05edaf050546ff5d9bd1d801fd3 Mon Sep 17 00:00:00 2001 From: Jer Miller Date: Sat, 13 Jun 2026 17:26:03 -0600 Subject: [PATCH] feat(link): store assigned + client labels, compose display_label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both pairing labels are now optional and stored as distinct fields: device_label stays the home-assigned/renameable label, while the new journal-local client_label (omit-when-empty, legacy-tolerant on read) holds the client's hostname/self-name. The old client-wins collapse is gone. sol link join --label is optional, defaulting to the sanitized client hostname with a path-safe linked-system fallback; sol call link pair --device-label is optional. The server composes display_label (assigned (client) when they differ) in _entry_to_json and the pair-complete event; the workspace and CLI render it instead of hand-composing. Cert CN and peer journal-source labels keep their prior non-empty semantics via a derived cert_label. No wire/protocol record shape changed — client_label is local-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- solstone/apps/link/call.py | 15 ++- solstone/apps/link/routes.py | 52 +++++--- .../apps/link/tests/test_api_devices_role.py | 36 +++++- solstone/apps/link/tests/test_by_code.py | 112 +++++++++++++++++- solstone/apps/link/tests/test_pair_copy.py | 1 - solstone/apps/link/tests/test_pair_start.py | 47 ++++++++ .../tests/test_peer_journal_source_pairing.py | 32 +++++ solstone/apps/link/tests/test_rename_route.py | 2 + .../apps/link/tests/test_workspace_copy.py | 1 - .../apps/link/tests/test_workspace_states.py | 24 ++++ solstone/apps/link/workspace.html | 10 +- .../apps/observer/tests/test_pl_pairing.py | 2 + solstone/think/link/auth.py | 21 +++- solstone/think/link/join_cli.py | 39 +++++- tests/link/test_auth.py | 56 +++++++++ tests/link/test_link_join_label_validation.py | 23 +++- tests/link/test_link_join_observer.py | 52 +++++++- tests/test_link_call_parity.py | 69 ++++++++++- 18 files changed, 551 insertions(+), 43 deletions(-) diff --git a/solstone/apps/link/call.py b/solstone/apps/link/call.py index 128888ee6..dee8e0b32 100644 --- a/solstone/apps/link/call.py +++ b/solstone/apps/link/call.py @@ -272,8 +272,8 @@ def private_link_disable() -> None: @app.command() @convey_cli def pair( - device_label: str = typer.Option( - ..., "--device-label", help="Label for the linked system being paired" + device_label: str | None = typer.Option( + None, "--device-label", help="Label for the linked system being paired" ), as_role: str | None = typer.Option( None, @@ -305,7 +305,7 @@ def pair( raise typer.Exit(2) client = get_client() - payload = {"device_label": device_label} + payload = {"device_label": device_label or ""} if as_role is not None: payload["role"] = as_role mint = client.request( @@ -325,7 +325,8 @@ def pair( typer.echo(f"manual code: {manual_code}") typer.echo(f"Pair URL: {url}") typer.echo(f"CA fingerprint: sha256:{ca_fp}") - typer.echo(f"Device: {device_label}{' (peer)' if as_role == 'peer' else ''}") + if device_label: + typer.echo(f"Device: {device_label}{' (peer)' if as_role == 'peer' else ''}") typer.echo("") typer.echo("Waiting for linked system…") @@ -341,7 +342,8 @@ def pair( if new_entries: entry = new_entries[-1] suffix = " (peer)" if entry["role"] == "peer" else "" - typer.echo(f"Paired: {entry['device_label']}{suffix}") + label = entry.get("display_label") or entry["device_label"] + typer.echo(f"Paired: {label}{suffix}") typer.echo(f" fingerprint: {entry['fingerprint']}") typer.echo(f" paired_at: {entry['paired_at']}") raise typer.Exit(0) @@ -387,8 +389,9 @@ def list_devices() -> None: typer.echo("") typer.echo(heading) for device in entries: + label = device.get("display_label") or device["device_label"] typer.echo( - f"- {device['device_label']}" + f"- {label}" f" — added {_relative_time(device['paired_at'])}" f" — last seen {_relative_time(device['last_seen_at'])}" f" [{device['fingerprint_short']}]" diff --git a/solstone/apps/link/routes.py b/solstone/apps/link/routes.py index fdde73ad0..54e09563a 100644 --- a/solstone/apps/link/routes.py +++ b/solstone/apps/link/routes.py @@ -160,6 +160,14 @@ def _default_device_label() -> str: ) +def _display_label(assigned: str, client: str) -> str: + assigned = (assigned or "").strip() + client = (client or "").strip() + if assigned and client and assigned != client: + return f"{assigned} ({client})" + return assigned or client + + def _rough_network(mode: str) -> str: return "anywhere" if mode == "pl-via-spl" else "network" @@ -537,9 +545,7 @@ def api_pair_nonce_status() -> Any: def pair_start() -> Any: """Generate a single-use 5-minute nonce and return link-ready payload.""" payload = request.get_json(silent=True) or {} - device_label = ( - str(payload.get("device_label") or "").strip() or _default_device_label() - ) + device_label = str(payload.get("device_label") or "").strip() raw_role = payload.get("role", "") role = "" if raw_role is None else raw_role if not isinstance(role, str) or role not in VALID_ROLES: @@ -614,13 +620,15 @@ def pair_start() -> Any: def _complete_pairing( consumed: Nonce, csr_pem: str, - device_label: str, + assigned_label: str, + client_label: str, *, network: str, sender_instance_id: str | None = None, ) -> tuple[dict[str, Any], str, str]: ca = load_or_generate_ca(ca_dir()) - client_cert_pem, fingerprint = sign_csr(ca, csr_pem, device_label) + cert_label = client_label or assigned_label or _default_device_label() + client_cert_pem, fingerprint = sign_csr(ca, csr_pem, cert_label) state = LinkState.load_or_create() paired_at = _utc_now_iso() @@ -643,18 +651,19 @@ def _complete_pairing( if is_peer(consumed.role): journal_source_record_path = mint_pl_journal_source_record( fingerprint=fingerprint, - device_label=device_label, + device_label=cert_label, paired_at=paired_at, peer_instance_id=sender_instance_id, ) create_state_directory(Path(get_journal()), journal_source_record_path.stem) _authorized().add( fingerprint=fingerprint, - device_label=device_label, + device_label=assigned_label, instance_id=state.instance_id, role="peer" if is_peer(consumed.role) else "", paired_at=paired_at, network=network, + client_label=client_label, ) except Exception: if journal_source_record_path is not None: @@ -693,7 +702,7 @@ def pair() -> Any: Body (JSON): { "csr": "", // required - "device_label": "", // optional (falls back to nonce label) + "device_label": "", // optional client self-name "nonce": "" // optional: may be in body instead of query } @@ -736,21 +745,28 @@ def pair() -> Any: detail="nonce expired or used", ) - effective_label = device_label or (consumed.device_label or _default_device_label()) + assigned_label = consumed.device_label + client_label = device_label network = _rough_network(g.identity.mode) try: response, fingerprint, paired_at = _complete_pairing( consumed, csr_pem, - effective_label, + assigned_label, + client_label, network=network, sender_instance_id=sender_instance_id, ) except ValueError as exc: logger.info("pair: bad csr: %s", exc) return error_response(PAIRING_KEY_INVALID, detail=f"bad csr: {exc}") - _emit_pair_complete(effective_label, fingerprint, paired_at, network=network) + _emit_pair_complete( + _display_label(assigned_label, client_label), + fingerprint, + paired_at, + network=network, + ) return jsonify(response) @@ -790,20 +806,27 @@ def by_code() -> Any: detail="nonce expired or used", ) - effective_label = device_label or consumed.device_label or _default_device_label() + assigned_label = consumed.device_label + client_label = device_label network = _rough_network(g.identity.mode) try: response, fingerprint, paired_at = _complete_pairing( consumed, csr_pem, - effective_label, + assigned_label, + client_label, network=network, sender_instance_id=sender_instance_id, ) except ValueError as exc: logger.info("by-code: bad csr: %s", exc) return error_response(PAIRING_KEY_INVALID, detail=f"bad csr: {exc}") - _emit_pair_complete(effective_label, fingerprint, paired_at, network=network) + _emit_pair_complete( + _display_label(assigned_label, client_label), + fingerprint, + paired_at, + network=network, + ) return jsonify(response) @@ -919,6 +942,7 @@ def _entry_to_json(entry: ClientEntry) -> dict[str, Any]: "fingerprint": entry.fingerprint, "fingerprint_short": short_fp, "device_label": entry.device_label, + "display_label": _display_label(entry.device_label, entry.client_label), "paired_at": entry.paired_at, "last_seen_at": entry.last_seen_at, "role": entry.role, diff --git a/solstone/apps/link/tests/test_api_devices_role.py b/solstone/apps/link/tests/test_api_devices_role.py index 5ecfa259d..cd1bf7f82 100644 --- a/solstone/apps/link/tests/test_api_devices_role.py +++ b/solstone/apps/link/tests/test_api_devices_role.py @@ -5,7 +5,10 @@ from __future__ import annotations import json -from solstone.think.link.auth import AuthorizedClients +import pytest + +from solstone.apps.link import routes as link_routes +from solstone.think.link.auth import AuthorizedClients, ClientEntry from solstone.think.link.paths import authorized_clients_path @@ -26,6 +29,35 @@ def test_devices_api_includes_role_field(link_env) -> None: } +@pytest.mark.parametrize( + ("assigned", "client", "expected"), + [ + ("assigned", "client", "assigned (client)"), + ("same", "same", "same"), + ("assigned", "", "assigned"), + ("", "client", "client"), + ("", "", ""), + ], +) +def test_entry_to_json_composes_display_label( + assigned: str, + client: str, + expected: str, +) -> None: + entry = ClientEntry( + fingerprint="sha256:abcdef", + device_label=assigned, + paired_at="2026-04-19T00:00:00Z", + instance_id="inst-1", + client_label=client, + ) + + payload = link_routes._entry_to_json(entry) + + assert payload["display_label"] == expected + assert "client_label" not in payload + + def test_devices_api_legacy_entry_defaults_to_role_less(link_env) -> None: env = link_env() path = authorized_clients_path() @@ -51,3 +83,5 @@ def test_devices_api_legacy_entry_defaults_to_role_less(link_env) -> None: devices = response.get_json()["devices"] assert len(devices) == 1 assert devices[0]["role"] == "" + assert devices[0]["display_label"] == "legacy" + assert "client_label" not in devices[0] diff --git a/solstone/apps/link/tests/test_by_code.py b/solstone/apps/link/tests/test_by_code.py index fdbb71535..e1afa25cc 100644 --- a/solstone/apps/link/tests/test_by_code.py +++ b/solstone/apps/link/tests/test_by_code.py @@ -35,6 +35,11 @@ def _make_csr(label: str = "test") -> str: return csr.public_bytes(serialization.Encoding.PEM).decode("ascii") +def _cert_common_name(cert_pem: str) -> str: + cert = x509.load_pem_x509_certificate(cert_pem.encode("utf-8")) + return cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME)[0].value + + def _start_pair(env, device_label: str = "Test Phone") -> dict: response = env.client.post( "/app/link/pair-start", @@ -63,7 +68,82 @@ def test_by_code_happy_path(link_env) -> None: ) assert response.status_code == 200 - _assert_pair_response(response.get_json()) + payload = response.get_json() + _assert_pair_response(payload) + assert _cert_common_name(payload["client_cert"]) == "Test Phone" + entries = link_routes._authorized().snapshot() + assert len(entries) == 1 + assert entries[0].device_label == "Test Phone" + assert entries[0].client_label == "" + + +def test_by_code_stores_distinct_assigned_and_client_labels(link_env) -> None: + env = link_env() + started = _start_pair(env, "Assigned Phone") + + response = env.client.post( + "/app/link/by-code", + json={ + "code": started["manual_code"], + "csr": _make_csr("client-csr"), + "device_label": "Client Phone", + }, + ) + + assert response.status_code == 200 + payload = response.get_json() + _assert_pair_response(payload) + assert _cert_common_name(payload["client_cert"]) == "Client Phone" + entries = link_routes._authorized().snapshot() + assert len(entries) == 1 + assert entries[0].device_label == "Assigned Phone" + assert entries[0].client_label == "Client Phone" + + +def test_by_code_client_only_label_stores_assigned_empty(link_env) -> None: + env = link_env() + started = _start_pair(env, "") + + response = env.client.post( + "/app/link/by-code", + json={ + "code": started["manual_code"], + "csr": _make_csr("client-only"), + "device_label": "Client Only", + }, + ) + + assert response.status_code == 200 + payload = response.get_json() + _assert_pair_response(payload) + assert _cert_common_name(payload["client_cert"]) == "Client Only" + entries = link_routes._authorized().snapshot() + assert len(entries) == 1 + assert entries[0].device_label == "" + assert entries[0].client_label == "Client Only" + + +def test_by_code_empty_labels_use_default_for_certificate_only( + link_env, + monkeypatch: pytest.MonkeyPatch, +) -> None: + env = link_env() + monkeypatch.setattr(link_routes, "_default_device_label", lambda: "Default Device") + started = _start_pair(env, "") + + response = env.client.post( + "/app/link/by-code", + json={"code": started["manual_code"], "csr": _make_csr("empty-labels")}, + ) + + assert response.status_code == 200 + payload = response.get_json() + _assert_pair_response(payload) + assert _cert_common_name(payload["client_cert"]) == "Default Device" + entries = link_routes._authorized().snapshot() + assert len(entries) == 1 + assert entries[0].device_label == "" + assert entries[0].client_label == "" @pytest.mark.parametrize("variant", ["lowercase", "no_hyphen", "whitespace"]) @@ -220,3 +300,33 @@ def test_successful_pairing_emits_pair_complete_once( r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z", kwargs["paired_at"], ) + + +def test_pair_complete_event_uses_display_label( + link_env, + monkeypatch: pytest.MonkeyPatch, +) -> None: + env = link_env() + calls = [] + + def mock_emit(*args, **kwargs): + calls.append((args, kwargs)) + return True + + monkeypatch.setattr("solstone.apps.link.routes.emit", mock_emit) + started = _start_pair(env, "Assigned Phone") + + response = env.client.post( + "/app/link/by-code", + json={ + "code": started["manual_code"], + "csr": _make_csr("display-event"), + "device_label": "Client Phone", + }, + ) + + assert response.status_code == 200 + assert len(calls) == 1 + args, kwargs = calls[0] + assert args == ("link", "pair_complete") + assert kwargs["device_label"] == "Assigned Phone (Client Phone)" diff --git a/solstone/apps/link/tests/test_pair_copy.py b/solstone/apps/link/tests/test_pair_copy.py index 0a98d467c..b175330af 100644 --- a/solstone/apps/link/tests/test_pair_copy.py +++ b/solstone/apps/link/tests/test_pair_copy.py @@ -21,7 +21,6 @@ U4_COPY_VALUES = [ copy.CA_FP_LABEL, copy.CA_FP_NOTE, copy.DEVICE_LABEL_FIELD_LABEL, - copy.DEVICE_LABEL_DEFAULT_FORMAT, copy.EXPIRED_BUTTON, copy.PAIR_ERROR_BODY, copy.SUCCESS_HEADING, diff --git a/solstone/apps/link/tests/test_pair_start.py b/solstone/apps/link/tests/test_pair_start.py index 51c5cd462..27d60293d 100644 --- a/solstone/apps/link/tests/test_pair_start.py +++ b/solstone/apps/link/tests/test_pair_start.py @@ -57,6 +57,53 @@ def test_pair_start_shape_and_locked_order(link_env) -> None: assert "qr_payload" not in payload +def test_pair_start_omitted_assigned_label_stores_empty(link_env) -> None: + env = link_env() + + response = env.client.post("/app/link/pair-start", json={}) + + assert response.status_code == 200 + payload = response.get_json() + assert list(payload.keys()) == PAIR_START_KEYS + assert payload["device_label"] == "" + snap = link_routes._nonces().snapshot() + assert len(snap) == 1 + assert snap[0].device_label == "" + + +def test_pair_start_blank_assigned_label_stores_empty(link_env) -> None: + env = link_env() + + response = env.client.post( + "/app/link/pair-start", + json={"device_label": " "}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["device_label"] == "" + snap = link_routes._nonces().snapshot() + assert len(snap) == 1 + assert snap[0].device_label == "" + + +def test_pair_start_allows_lenient_assigned_label(link_env) -> None: + env = link_env() + label = "device — added Jun 13!" + + response = env.client.post( + "/app/link/pair-start", + json={"device_label": label}, + ) + + assert response.status_code == 200 + payload = response.get_json() + assert payload["device_label"] == label + snap = link_routes._nonces().snapshot() + assert len(snap) == 1 + assert snap[0].device_label == label + + def test_pair_start_mints_distinct_nonce_and_manual_code(link_env) -> None: env = link_env() diff --git a/solstone/apps/link/tests/test_peer_journal_source_pairing.py b/solstone/apps/link/tests/test_peer_journal_source_pairing.py index 8caee6f11..2c29e1274 100644 --- a/solstone/apps/link/tests/test_peer_journal_source_pairing.py +++ b/solstone/apps/link/tests/test_peer_journal_source_pairing.py @@ -44,10 +44,13 @@ def _pair( *, role: str, label: str = "Pair Device", + client_label: str | None = None, sender_instance_id: object = None, ) -> dict: started = _start_pair(env, role=role, label=label) body = {"nonce": started["nonce"], "csr": _make_csr(label)} + if client_label is not None: + body["device_label"] = client_label if sender_instance_id is not None: body["sender_instance_id"] = sender_instance_id response = env.client.post( @@ -114,6 +117,32 @@ def test_peer_role_pairing_mints_journal_source_state_dir_and_authorized( assert len(entries) == 1 assert entries[0].fingerprint == response["fingerprint"] assert entries[0].role == "peer" + assert entries[0].device_label == "Peer Laptop" + assert entries[0].client_label == "" + + +def test_peer_pairing_uses_client_label_for_source_and_preserves_assigned( + link_env, +) -> None: + env = link_env() + + response = _pair( + env, + role="peer", + label="Assigned Peer", + client_label="Client Peer", + ) + + source = journal_sources.load_journal_source_by_fingerprint(response["fingerprint"]) + assert source is not None + assert source["device_label"] == "Client Peer" + + entries = link_routes._authorized().snapshot() + assert len(entries) == 1 + assert entries[0].fingerprint == response["fingerprint"] + assert entries[0].role == "peer" + assert entries[0].device_label == "Assigned Peer" + assert entries[0].client_label == "Client Peer" def test_peer_role_pairing_records_sender_instance_id(link_env) -> None: @@ -270,6 +299,7 @@ def test_peer_journal_source_mint_failure_does_not_add_authorized( _consumed_nonce("peer"), _make_csr("mint"), "Peer Laptop", + "", network="network", ) @@ -325,6 +355,7 @@ def test_peer_state_dir_failure_unlinks_journal_source_and_skips_authorized( _consumed_nonce("peer"), _make_csr("state-dir"), "Peer Laptop", + "", network="network", ) @@ -376,6 +407,7 @@ def test_peer_journal_source_rolls_back_when_authorized_add_fails( _consumed_nonce("peer"), _make_csr("rollback"), "Peer Laptop", + "", network="network", ) diff --git a/solstone/apps/link/tests/test_rename_route.py b/solstone/apps/link/tests/test_rename_route.py index 09af6ec19..156414707 100644 --- a/solstone/apps/link/tests/test_rename_route.py +++ b/solstone/apps/link/tests/test_rename_route.py @@ -21,6 +21,7 @@ def _add_device() -> None: "old name", "inst-1", paired_at=PAIRED_AT, + client_label="client-host", ) @@ -38,6 +39,7 @@ def test_rename_updates_paired_device_label(link_env) -> None: entry = _authorized().get(FINGERPRINT) assert entry is not None assert entry.device_label == "new name" + assert entry.client_label == "client-host" def test_rename_empty_label_returns_invalid_request(link_env) -> None: diff --git a/solstone/apps/link/tests/test_workspace_copy.py b/solstone/apps/link/tests/test_workspace_copy.py index 48a39d063..34cf255d9 100644 --- a/solstone/apps/link/tests/test_workspace_copy.py +++ b/solstone/apps/link/tests/test_workspace_copy.py @@ -22,7 +22,6 @@ MODAL_COPY_VALUES = [ copy.CA_FP_LABEL, copy.CA_FP_NOTE, copy.DEVICE_LABEL_PLACEHOLDER, - copy.DEVICE_LABEL_DEFAULT_FORMAT, copy.EXPIRED_BUTTON, copy.PAIR_ERROR_BODY, copy.SUCCESS_HEADING, diff --git a/solstone/apps/link/tests/test_workspace_states.py b/solstone/apps/link/tests/test_workspace_states.py index 193904bcf..dd441d38f 100644 --- a/solstone/apps/link/tests/test_workspace_states.py +++ b/solstone/apps/link/tests/test_workspace_states.py @@ -175,6 +175,30 @@ def test_pair_complete_single_refresh(link_env) -> None: assert "refreshDevices" in init_body +def test_workspace_uses_server_display_label_for_device_titles(link_env) -> None: + env = link_env() + response = env.client.get("/app/link/") + + assert response.status_code == 200 + body = response.get_data(as_text=True) + + assert "label.textContent = device.display_label || '';" in body + assert "label.textContent = `${device.display_label || ''}${suffix}`;" in body + assert ( + ".replace('{label}', device.display_label || device.device_label || '')" in body + ) + assert "const previous = device.device_label || '';" in body + assert "String(device.device_label || '').toLowerCase().includes(needle)" in body + + commit_start = body.index("async function commit()") + commit_end = body.index("function cancel()", commit_start) + commit_body = body[commit_start:commit_end] + assert "device.device_label = persisted;" in commit_body + assert "replaceRenameInput(input, persisted);" in commit_body + assert "refreshDevices();" in commit_body + assert "renderRecentlyPaired(latestDevices);" not in commit_body + + def test_pair_modal_error_state_present(link_env) -> None: env = link_env() response = env.client.get("/app/link/") diff --git a/solstone/apps/link/workspace.html b/solstone/apps/link/workspace.html index 7e11255c9..64b9d04d7 100644 --- a/solstone/apps/link/workspace.html +++ b/solstone/apps/link/workspace.html @@ -188,7 +188,7 @@