Files
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00
..

DeviceLink

packages/device-link is the release-enabled, transport-only DeviceLink core for Tutti Desktop, Android, iOS, and TSH. It owns ICE candidate negotiation, incremental candidate-exchange coordination, QUIC over the selected packet path, mutual ephemeral certificate pinning, and product-neutral Relay byte-stream mechanics. It does not own Agent, Session, Turn, Workspace, pairing, account, rendezvous, Relay authorization, or Relay product policy.

The initial implementation was upstreamed from TSH's production core/devicelink package. It is eligible for Tutti's stable package cohort after the authenticated Android/Desktop lifecycle and reproducible Mobile AAR consumer build pass. Consumer adapters keep their product policy outside this module.

The low-level candidate, ICE, TLS, and QUIC types remain provisional primitives. Product consumers use authenticated.Participant, which composes ICE, peer-fingerprint-pinned TLS/QUIC, stream creation, cancellation, and close ordering. The authenticated subpackage is the one narrow aggregation layer required by Go's dependency direction; it does not own account, pairing, rendezvous, or Agent behavior. Product bridges must not expose raw QUICEndpoint.Listen or Dial.

Every direct QUIC session uses a 5-second keepalive period and a 15-second maximum idle timeout. These defaults apply uniformly to Desktop, mobile, and TSH consumers so a peer whose network path disappears is retired promptly without product-specific liveness timers.

Managed connection lifecycle

The linkmanager package owns the reusable lifecycle mechanics that sit above an authenticated link:

  • generation-fenced admission, so a connection completed after invalidation cannot re-enter the pool;
  • one in-flight establishment per opaque peer key;
  • authenticated link reuse, stream reference counting, idle retirement, and deterministic collision resolution;
  • a delayed two-path race that verifies each candidate with a transport-owned stream probe before selecting it and closes every losing late connection;
  • an annealed direct-probe cache with bounded peer state and explicit environment invalidation.

The manager does not name or authenticate direct, Relay, room, account, or rendezvous paths. Product adapters inject opaque keys and metadata, dial functions, path timing, and policy. In particular, a consumer may let a preferred-path probe continue after a fallback path wins so it can learn direct reachability; that detached probe must have its own deadline and close every connection it does not register. Cache only path reachability failures, never authorization, control-plane, or product-policy failures. ClaimProbe returns a generation-bound lease; complete it with RecordFailure, RecordSuccess, or Close so invalidation can fence every late probe result.

The intended direct-path integration is:

server-authoritative STUN configuration
  -> candidateexchange.Start (credentials immediately; candidates may be empty)
  -> product publishes or joins its authenticated rendezvous attempt
  -> linkmanager admission snapshot
  -> candidateexchange.PublishLocal + FeedRemote run beside Participant.Connect
  -> linkmanager.Register
  -> shared OpenStream / incoming stream handler

One admission snapshot may register at most one link. Network, credential, or ownership changes invalidate the relevant peer generation before the old attempt is cancelled. A product-wide availability gate uses SetEnabled(false) to fence current and future admissions until explicitly re-enabled. Shutdown first begins manager quiescence, which rejects new admissions and streams, then waits for accepted stream handlers to finish. Link observations carry a per-connection sequence; projections must ignore older deliveries and treat disconnected as terminal for that globally comparable connection ID.

Lifecycle controllers that cannot wait for transport I/O use Retire or RetireAll. These methods synchronously advance admission generations, revoke incoming handlers, and remove the exact old link, then return immutable Retirement handles for physical close outside the controller lock. A delayed retirement close never looks up a key again and therefore cannot close a newer link registered for the same opaque key. Invalidate and InvalidateAll remain synchronous compatibility wrappers.

Tutti's mobileremote Desktop owner is the first production adapter: it keeps pairing, identity proof, rendezvous, and Agent framing in tuttid, while the shared manager owns the authenticated link and incoming stream lifecycle.

Relay byte-stream transport

The relaytransport package owns the reusable mechanics for Relay-backed byte streams. Dial turns binary WebSocket messages into a net.Conn for one caller stream and uses Ping/Pong liveness so an unresponsive peer closes the stream for the product's existing reconnect policy. DialLivenessConfig has 20-second ping and 60-second pong defaults. OwnerHost maintains one WebSocket/yamux owner tunnel while at least one product driver holds a reference, accepts remote streams only after the product readiness barrier succeeds, and reconnects with bounded full-jitter backoff plus Retry-After.

OwnerLifecycle.Activate returns an OwnerActivation: its Readiness context is a continuous product health condition for the complete connection generation. Cancelling it closes the generation, joins its stream handlers, reports its cancellation cause through SessionEnded, and starts a new generation while demand remains.

The owner path has an explicit ownership split:

product demand (zero -> one)
  -> product OwnerLifecycle.Prepare
  -> relaytransport WebSocket dial + liveness
  -> product OwnerLifecycle.Activate continuous readiness
  -> relaytransport yamux stream acceptance
  -> product StreamHandler
  -> final product demand release
  -> stop tunnel and handlers
  -> exact OwnerLifecycle.Release

The lifecycle factory creates isolated product state for every zero-to-one demand run. A final release may overlap a new acquire, but the old release can only clean up its own lifecycle. WebSocket ping/pong owns tunnel liveness, so yamux keepalive is disabled. Internal yamux logging is discarded; adapters map sanitized, typed OwnerEvent values into product logs or metrics. Retry events separate the backoff cap, chosen jitter, server Retry-After, and total delay; liveness events expose only ping/pong counts and timestamps, never payloads.

networkchange.Monitor is the process-level transport signal for reconnect self-healing. It starts at generation 1, publishes only later generations, and hashes interface status, addresses, and—where the platform exposes a reliable native table—default-route identity. Darwin uses a no-cgo route socket as a trigger with 500ms debounce and a 30s safety sample; its default-route summary comes from the native routing information base. Linux samples /proc/net/route and /proc/net/ipv6_route; Windows samples IP Helper's GetIpForwardTable2. Android 10 and newer deny ordinary applications access to /proc/net, so Android retains only the interface/address summary until a native ConnectivityManager source is provided. Android, iOS, and other unsupported platforms explicitly do not claim default-route coverage. Windows and mobile fallback to 2s polling, while Darwin watcher failure also falls back to 2s polling.

Monitor.Status() is a read-only diagnostic snapshot with stopped, starting, watching, or polling mode plus a sanitized polling reason and bounded sample-health counters. Consumers should derive watcher health from ModeWatching; they must not invent a separate watcherHealthy field. An OwnerHost.AdvanceNetworkGeneration call cancels only the current attempt or retry wait, closes the old Relay transport, and reuses the same owner lifecycle for an immediate retry. It does not clear credentials; SessionEnded remains the product-owned credential policy boundary.

Default-route changes are included only after successful parsing of the platform snapshot. A route-table read or parse failure fails that sample and does not advance generation, preserving the monitor's fail-closed rule rather than silently pretending that default-route coverage is complete.

Relay endpoints, headers, query values, credentials, leases, registrations, room or pairing state, application protocols, and token invalidation remain in consumer adapters. In particular, a shared transport error is evidence for the adapter to update its product state, not permission for this package to interpret HTTP status codes as product policy. DialError makes a bounded handshake response body available for adapter-owned wire-reason parsing, but does not include that body in its error string; adapters must not persist the raw value in ordinary logs or metrics.

OwnerHost.Wake is a product-neutral demand-preserving interrupt for the current generation or retry wait. It coalesces repeated requests, does not release or acquire references, and does not reset reconnect backoff.

Stream readiness probe

ProbeStream and ServeStreamProbe form the product-neutral readiness barrier for a newly opened authenticated stream. The caller writes a fresh nonce and selects the path only after the peer echoes that nonce. This matters for stale QUIC sessions: OpenStreamSync can succeed locally after a network transition even though the peer is no longer reachable. The probe is deliberately separate from Agent framing and leaves the verified stream open for the consumer's application protocol. Owners must run ServeStreamProbe before dispatching the stream to their handler.

Trickle ICE and protocol migration

The candidateexchange package is the consumer-facing Trickle ICE coordinator. Start returns publishable ICE credentials immediately, even when the initial candidate list is empty. NextLocalPublication and AcknowledgeLocalPublication expose the same acknowledgement-bound state machine used by PublishLocal: candidate notifications are coalesced, a failed publication is reissued with the same ID and exact snapshot after the shared retry interval, and the stream stops only after the final local snapshot has been observed. NotifyRemoteChange and WaitRemoteRefresh expose the same push-plus-poll scheduler used by FeedRemote; fetched candidates are then deduplicated into an in-progress Connect. Fetch failures terminate unless the consumer explicitly classifies them as retryable. The default authoritative poll fallback is 500ms.

Callback-free consumers use ActionPump. It owns the local-publication and remote-refresh workers, retry scheduling, stop ordering, and a one-outstanding-action-per-worker protocol. A consumer may drain the local and remote actions concurrently, so a slow authoritative read cannot hold up local candidate publication. It performs only product-authenticated rendezvous I/O for publish_local and refresh_remote, then resolves each action with success plus retryability. A local publication is acknowledged only after that resolution, so a caller can first verify that the server's returned authoritative snapshot contains the exact published candidates.

Consumers inject only their rendezvous reads/writes and retry classification. The shared package does not create attempts, sign requests, interpret room or pairing state, or authorize peers. Lower-level callers may still use StartLocalDescription, LocalCandidateChanges, LocalDescriptionSnapshot, LocalGatheringComplete, and AddRemoteCandidates directly, but product adapters should prefer candidateexchange so debounce, exact-snapshot retry, completion, and push-plus-poll behavior do not drift. A Participant represents one connection attempt and must not be reused after completion or cancellation.

Current integration status:

Consumer Candidate-exchange path Status
Tutti Desktop owner Go candidateexchange.Start, PublishLocal, and FeedRemote around the signed paired-device attempt adapter Integrated
Tutti Android/iOS caller Go ActionPump workers exposed as next/resolve actions; TypeScript executes and validates signed authoritative attempt I/O only Integrated
TSH Desktop Uses the same low-level authenticated.Participant Trickle primitives, but its adapter-local publish/feed loops have not yet cut over Migrate after the next stable DeviceLink release; no workspace replace
tsh-server paired-device rendezvous Protocol v2 accepts valid credentials with zero candidates and repeated authoritative participant snapshots Compatible; no server schema change required

For the TSH cutover, adapter-local publishTrickledCandidates maps to Exchange.PublishLocal, feedRemoteCandidates maps to Exchange.FeedRemote, and the local 200ms debounce maps to the shared default. TSH retains its room lease checks, attempt TTL context, candidate-summary diagnostics, configurable poll cadence, and product fallback policy. This keeps the migration behavioral: it deletes duplicated connection mechanics without moving room authority or Relay policy into this package.

Network path policy

The default empty NetworkPolicy is system. It keeps the operating system's network view, but applies a shared LAN-first candidate preference: private IPv4/ULA host candidates receive the normal host priority, global-unicast host candidates receive a lower host priority, and server-reflexive candidates remain available as the public-network fallback.

system includes active TUN interfaces for fallback, while direct additionally binds sockets to physical interfaces where the platform supports it. Product consumers should use system unless they expose an explicit advanced routing setting.

ALPN remains the canonical protocol name. During a rolling migration, a consumer may add a bounded list of CompatibleProtocols; the canonical value is always offered first. After connection, Link.NegotiatedProtocol lets the product adapter select only the matching application framing. Compatibility entries are a temporary cutover tool, not a promise that old wire protocols share semantics.

authenticated.ErrorPhase distinguishes connectivity failures from authenticated transport failures so consumers can keep network probe caches free of certificate, ALPN, QUIC-handshake, and caller-cancellation failures. authenticated.FailurePhase additionally reports the layer interrupted by a caller cancellation. It is reserved for callers that own a dedicated probe deadline and verify that exact context cause before recording a verdict.

Mobile vertical slice

The mobile package deliberately exposes only a gomobile-safe authenticated link facade:

  • the current application-stream protocol epoch;
  • creation from server-authoritative STUN endpoints;
  • non-blocking local-description start plus a callback-free Go action pump for acknowledgement-bound publication and authoritative remote refresh;
  • JSON next/resolve actions, push notification, incremental remote-candidate insertion, and Go-owned candidate-worker cancellation while Connect runs;
  • caller/owner connection using the peer description;
  • authenticated bidirectional stream open/accept/read/write/deadline/close;
  • OpenStreamWithRelay, which races a direct stream dial and an authorized Relay stream dial concurrently, requires the shared stream probe on both candidates, and closes the losing stream. The returned byte stream has crossed the transport readiness barrier; callers still own their application protocol handshake and request semantics;
  • the loopback integration probe used by the Android build gate.

The mobile read boundary intentionally fills a caller-owned byte buffer and returns only a scalar count. Do not replace it with a Go []byte plus error return while the pinned Go/cgo toolchain is affected by packed-result alignment failures: gomobile cannot preserve final bytes returned together with io.EOF, and the generated pointer-bearing result may abort the Android process before Java receives it.

mobile.DialRelay is the corresponding gomobile-safe byte-stream entry point for an already-authorized Relay caller. It accepts the endpoint, query values, headers, and WebSocket subprotocol as JSON map[string][]string values, then runs the shared stream probe before returning the same Stream abstraction used by the Agent framing adapter. A successful WebSocket upgrade alone is not proof that the owner tunnel or Agent handler accepted the stream; the probe confirms only that the transport owner is responsive. The mobile package does not issue credentials, select a target, or decide when to fall back; those policies remain in the Mobile pairing service and native bridge. Link.OpenStreamWithRelay uses the same stream abstraction to start direct and Relay together; a direct stream may wait for an in-progress Link.Connect, while Relay starts immediately. Relay streams must be closed by the caller after each HTTP request or when the live subscription ends.

Account identity signatures, DeviceLink attempts, pairing scope, Agent HTTP framing, and foreground/background policy remain in the Android and tuttid product adapters. The mobile facade never accepts account cookies or Agent DTOs.

Run the portable checks:

make test
make android-crosscompile
make android-bindings-check

Android

Build the AAR with JDK 17, minSdk 26, compileSdk 36, targetSdk 35, Android Build Tools 36.0.0, and the pinned NDK r27d (27.3.13750724). Set JAVA_HOME and ANDROID_HOME:

make android-aar

The AAR is written to dist/tutti-device-link.aar by default. Build artifacts under dist/ are not source and must not be committed. The build verifies the Java binding plus armeabi-v7a, arm64-v8a, x86, and x86_64 native libraries before succeeding.

Build the minimal arm64 Android probe APK without adding a Gradle project:

make android-probe-apk

The probe Activity invokes the same exported gomobile API and writes either PASS or FAIL under the TuttiDeviceLinkProbe logcat tag. It is a transport integration fixture, not a product App shell. The generated APK and its stable local debug keystore remain under ignored dist/ so repeated local installs keep the same signature.

The Android link step passes -checklinkname=0 because Pion's Android network enumeration dependency github.com/wlynxg/anet uses the Go standard library's zone cache through go:linkname. Go 1.23 and newer reject that reference unless the documented linker compatibility flag is explicit. Keep the flag scoped to the gomobile build; ordinary host tests and builds do not use it.

iOS

The Mobile app host binds the same DeviceLink facade and Agent-owned live Subscriber into one XCFramework containing iOS device and iOS Simulator slices. Full Xcode and the iOS SDK are required:

pnpm --filter @tutti-os/mobile check:ios-bindings
pnpm --filter @tutti-os/mobile ios:framework

The generated apps/mobile/ios/Frameworks/TuttiMobileGo.xcframework is ignored build output. The iOS product adapter owns HTTP/application framing, lifecycle grace, account identity, pairing, and Agent event emission exactly as the Android adapter does; the Go mobile package remains transport-only.

Privacy invariants

  • Raw candidates, IP addresses, credentials, certificates, and application payloads must never enter ordinary logs or metrics.
  • Pion logging remains fully discarded because upstream messages may include candidate addresses.
  • Callers expose only categorical path scope and sanitized failure reasons.