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

369 lines
20 KiB
Markdown

# 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:
```text
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:
```text
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:
```sh
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`:
```sh
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:
```sh
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:
```sh
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.