Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
5af3b41
feat(auth): store device pairing tokens in ~/.scifi-env
calvinleng-science Sep 14, 2026
6fba2b9
feat(auth): attach pairing tokens via a client interceptor
calvinleng-science Sep 14, 2026
76b22a4
fix(auth): write ~/.scifi-env atomically via temp file + os.replace
calvinleng-science Sep 14, 2026
ade2988
feat(auth): add synapsectl pair and unpair
calvinleng-science Sep 14, 2026
629863a
fix(auth): give unpair a local-only positional identifier
calvinleng-science Sep 14, 2026
e9f122e
fix(auth): handle Ctrl-C during the initial Info call in pair
calvinleng-science Sep 18, 2026
4bc2af5
refactor(auth): send the token as authorization: Bearer
calvinleng-science Sep 18, 2026
bd8bb3d
feat(files): gRPC file client to replace SFTP
calvinleng-science Sep 18, 2026
6fb293e
feat(files): move synapsectl file from SFTP to gRPC
calvinleng-science Sep 21, 2026
3549270
feat(files): move deploy-model to gRPC, add recursive upload
calvinleng-science Sep 21, 2026
98d05e0
fix(files): put into a directory means into it, not onto it
calvinleng-science Sep 22, 2026
b6808b3
feat(auth): unpair revokes access on the device too
calvinleng-science Oct 8, 2026
ffd65e2
chore(api): track synapse-api with pairing, file transfer and paired-…
calvinleng-science Oct 8, 2026
a4bb303
chore(api): track synapse-api with unsigned timestamps
calvinleng-science Oct 8, 2026
edb017d
feat(auth)!: unpair revokes with RevokeAuth
calvinleng-science Oct 9, 2026
714e0b2
bump version to 2.8.0 for pairing auth and gRPC file transfer
calvinleng-science Oct 9, 2026
39670c4
fix(files): ask before overwriting, and never resume into a complete …
calvinleng-science Oct 9, 2026
36e76f8
feat(files): say where an interrupted download was kept
calvinleng-science Oct 9, 2026
938fa20
feat(files): hide dotfiles in file ls unless --all
calvinleng-science Oct 9, 2026
d36d02b
fix(files): get of a missing file says so, and leaves nothing behind
calvinleng-science Oct 10, 2026
861fb9f
fix(files): only resume a download if the device file is unchanged
calvinleng-science Oct 10, 2026
f34d1f2
chore(api): use the *_bytes file fields
calvinleng-science Oct 12, 2026
8c7b15f
chore(api): track synapse-api main (f9fc77f, #85)
calvinleng-science Oct 12, 2026
dc3760b
feat(files): fall back to SFTP on device firmware without file RPCs
calvinleng-science Oct 12, 2026
3c91cac
docs: SFTP is the fallback for older device firmware, not gone
calvinleng-science Oct 14, 2026
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,8 +202,9 @@ Deploy machine learning models to Synapse devices.

### Prerequisites

1. **Docker** — required for model conversion
2. **QAIRT SDK v2.34** (Qualcomm AI Runtime) — required for model conversion for DSP runtime, not required for CPU runtime
1. **For `.pt` models:** the model-conversion extra, `pip install 'science-synapse[model-convert]'` (PyTorch and ONNX). Not needed for `.onnx` or `.dlc` models.
2. **Docker** — required for quantized (DSP) model conversion
3. **QAIRT SDK v2.34** (Qualcomm AI Runtime) — required for model conversion for DSP runtime, not required for CPU runtime

### Quick Start — Deploy a Float Model (CPU)

Expand Down
4 changes: 2 additions & 2 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ def stamp_api_version():

setup(
name="science-synapse",
version="2.7.7",
version="2.8.0",
description="Client library and CLI for the Synapse API",
author="Science Team",
author_email="team@science.xyz",
Expand Down Expand Up @@ -63,7 +63,7 @@ def stamp_api_version():
],
extras_require={
"model-convert": [
"onnx>=1.12.0,<1.16.0",
"onnx==1.16.2", # same as the converter container; first release with Python 3.12 wheels is 1.16.0
"torch",
"packaging",
"protobuf",
Expand Down
2 changes: 1 addition & 1 deletion synapse-api
2 changes: 1 addition & 1 deletion synapse/_api_version.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# Generated from synapse-api/VERSION by setup.py. Do not edit.
SYNAPSE_API_VERSION = "2.4.1"
SYNAPSE_API_VERSION = "2.6.0"
2 changes: 2 additions & 0 deletions synapse/cli/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from synapse import SYNAPSE_API_VERSION
from synapse.cli import (
auth,
apps,
deploy_model,
discover,
Expand Down Expand Up @@ -74,6 +75,7 @@ def main():
)
subparsers = parser.add_subparsers(title="Commands")
discover.add_commands(subparsers)
auth.add_commands(subparsers)
rpc.add_commands(subparsers)
streaming.add_commands(subparsers)
offline_plot.add_commands(subparsers)
Expand Down
228 changes: 228 additions & 0 deletions synapse/cli/auth.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
import getpass
import logging
import socket

import grpc
from google.protobuf.empty_pb2 import Empty
from rich.console import Console

import synapse as syn
from synapse.api.auth_pb2 import AuthRequest, RevokeAuthRequest
from synapse.client import auth

logger = logging.getLogger(__name__)


def add_commands(subparsers):
pair_parser = subparsers.add_parser(
"pair", help="Request access to a device, approved on the device screen"
)
pair_parser.add_argument(
"--label",
help="How this machine identifies itself on the device screen "
"(default: user@hostname)",
default=None,
)
pair_parser.set_defaults(func=pair)

unpair_parser = subparsers.add_parser(
"unpair", help="Revoke this computer's access to a device and forget its token"
)
unpair_parser.add_argument(
"identifier",
nargs="?",
default=None,
help="Serial or stored name of a device to forget, matched against "
"~/.scifi-env. Only forgets the local token, without contacting the "
"device -- use this when the device is gone. If omitted, --uri is used "
"to contact the device, revoke access there, then forget the token.",
)
unpair_parser.set_defaults(func=unpair)


def _default_label() -> str:
try:
return f"{getpass.getuser()}@{socket.gethostname()}"
except Exception:
return "unidentified client"


def pair(args):
console = Console()
device = syn.Device(args.uri, args.verbose)

# Info is open, so this works before pairing. It gives us the serial to key
# the token on and the name to show the user.
try:
info = device.rpc.Info(Empty(), timeout=10.0)
except grpc.RpcError as e:
console.print(f"[bold red]Could not reach the device: {e.details()}")
return
except KeyboardInterrupt:
# Ctrl-C during the 10s Info call, before any code is on screen. Without
# this the user gets a raw traceback; nothing has been requested of the
# device yet, so there is nothing to withdraw.
console.print("\n[yellow]Pairing cancelled. Nothing was saved.")
return

if not info.serial:
console.print("[bold red]This device did not report a serial number; cannot pair.")
return

label = args.label or _default_label()

try:
stream = device.rpc.RequestAuth(AuthRequest(client_label=label))

challenge_event = next(stream)
if not challenge_event.HasField("challenge"):
console.print("[bold red]Unexpected response from the device.")
return
code = challenge_event.challenge.code
expires_in = challenge_event.challenge.expires_in_sec

console.print()
console.print(f" Pairing code: [bold cyan]{code}[/bold cyan]")
console.print()
console.print(
f"Check the screen on [bold]{info.name}[/bold]. If it shows the same code, "
f"tap Allow."
)
console.print(f"[dim]This request expires in {expires_in} seconds.[/dim]")
console.print()

with console.status("Waiting for approval on the device", spinner="bouncingBall"):
result_event = next(stream)

if not result_event.HasField("result"):
console.print("[bold red]Unexpected response from the device.")
return

result = result_event.result
if not result.granted:
console.print(f"[bold red]Not paired: {result.reason}")
return

auth.save_token(info.serial, info.name, result.token)
console.print(
f"[bold green]Paired with {info.name} ({info.serial}).[/bold green] "
f"Token saved to {auth.env_file_path()}"
)

except grpc.RpcError as e:
if e.code() == grpc.StatusCode.UNIMPLEMENTED:
console.print(
"[bold red]This device's firmware does not support pairing. "
"Update the device firmware and try again."
)
elif e.code() == grpc.StatusCode.FAILED_PRECONDITION:
console.print(f"[bold red]{e.details()}. Try again in a moment.")
else:
console.print(f"[bold red]Pairing failed: {e.details()}")
except KeyboardInterrupt:
console.print("\n[yellow]Pairing cancelled. Nothing was saved.")


def unpair(args):
console = Console()

# A positional identifier is a request to clean up local state without
# touching the network at all -- this is the "device is gone" case, so it
# takes priority even if --uri was also given.
identifier = getattr(args, "identifier", None)
if identifier:
_unpair_by_identifier(console, identifier)
return

if not args.uri:
console.print(
"[yellow]Specify a device: `unpair <serial-or-name>` to remove a "
"local entry, or `--uri <device>` to look one up by contacting it."
)
_print_stored_entries(console)
return

device = syn.Device(args.uri, args.verbose)
try:
info = device.rpc.Info(Empty(), timeout=10.0)
except grpc.RpcError as e:
console.print(f"[bold red]Could not reach the device: {e.details()}")
console.print(
"[dim]Run `unpair <serial-or-name>` to remove a local entry "
"without contacting the device.[/dim]"
)
return

token = auth.token_for_serial(info.serial)
if token is None:
console.print(f"[yellow]No stored token for {info.name} ({info.serial}).")
return

try:
# Revokes whichever token the call carries, i.e. this computer's.
device.rpc.RevokeAuth(RevokeAuthRequest(), timeout=10.0)
console.print(f"[bold green]Revoked this computer's access to {info.name}.")
except grpc.RpcError as e:
if e.code() == grpc.StatusCode.UNAUTHENTICATED:
# Already revoked or expired on the device; only the local copy is left.
pass
elif e.code() == grpc.StatusCode.UNIMPLEMENTED:
console.print(
"[yellow]This device's firmware cannot revoke access remotely. "
"Revoke it on the device under Settings > Authorized Devices."
)
else:
# Keep the token: without it, this computer can no longer revoke itself.
console.print(f"[bold red]Could not revoke access on the device: {e.details()}")
return

_remove_and_report(console, info.serial, info.name)


def _unpair_by_identifier(console, identifier):
"""Forget a locally stored token, matched by serial or name -- no network."""
tokens = auth.load_tokens()

if identifier in tokens:
_remove_and_report(console, identifier, tokens[identifier][0], contacted=False)
return

matches = [serial for serial, (name, _) in tokens.items() if name == identifier]
if len(matches) == 1:
serial = matches[0]
_remove_and_report(console, serial, tokens[serial][0], contacted=False)
return

if not matches:
console.print(f"[bold red]No stored entry matches '{identifier}'.")
_print_stored_entries(console)
return

console.print(
f"[bold red]'{identifier}' matches more than one stored device; "
"re-run with the serial, which is unique:"
)
for serial in matches:
console.print(f" [dim]{tokens[serial][0]} (serial {serial})[/dim]")


def _print_stored_entries(console):
tokens = auth.load_tokens()
if not tokens:
console.print("[dim]No devices are currently paired.[/dim]")
return
console.print("[dim]Stored entries:[/dim]")
for serial, (name, _) in sorted(tokens.items()):
console.print(f" [dim]{name} (serial {serial})[/dim]")


def _remove_and_report(console, serial, name, contacted=True):
if auth.remove_token(serial):
console.print(f"[bold green]Forgot the token for {name} ({serial}).")
if not contacted:
console.print(
"[dim]The device was not contacted. Use `unpair --uri <device>` to "
"also revoke access there.[/dim]"
)
else:
console.print(f"[yellow]No stored token for {name} ({serial}).")
Loading
Loading