Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
835af63
docs: the terminal on the bridge, designed for security and auditing
deymosh Oct 16, 2026
f9b92c1
feat(protocol): the terminal wire — messages, capability, device id
deymosh Oct 16, 2026
3172109
docs: describe the terminal protocol
deymosh Oct 16, 2026
b6d2cf3
fix(protocol): terminal-input-from names the first seq to resend, and…
deymosh Oct 16, 2026
d68de34
docs: the terminal design's message table matches the wire
deymosh Oct 16, 2026
f43abf0
feat(bridge-core): a device id and a name per client, and where its c…
deymosh Oct 16, 2026
88e9596
feat(bridge-core): route terminals — the gates, the writer, seq, and …
deymosh Oct 16, 2026
0f85fd8
feat(bridge-core): read a phone's keystrokes without spending a comma…
deymosh Oct 16, 2026
9b708a5
fix(bridge-core): a device's terminal seq never starts over, and held…
deymosh Oct 16, 2026
b4a7acb
fix(bridge-core): a heartbeat names only the receiving identity's own…
deymosh Oct 16, 2026
9795edc
build: the lockfile records base64 as a bridge-core dependency
deymosh Oct 16, 2026
7de3563
build(bridge-runtime): portable-pty for real terminals, and the nix i…
deymosh Oct 16, 2026
41c161c
feat(bridge): run terminals in real ptys, with a ring of their output…
deymosh Oct 16, 2026
cc54de5
perf(bridge): keep a terminal's writes, recording and output off the …
deymosh Oct 16, 2026
5868d10
feat(bridge-core): make the terminal audit record serializable
deymosh Oct 16, 2026
055dde8
feat(bridge-runtime): terminal config, device allowlist, audit log an…
deymosh Oct 16, 2026
b19bd40
feat(bridge-runtime): wire the allowlist reload and the audit into th…
deymosh Oct 16, 2026
038ca5a
docs(bridge): the terminal section
deymosh Oct 16, 2026
ed431c5
perf(bridge): no allowlist check on a bridge with terminals off, and …
deymosh Oct 16, 2026
1999f14
feat(bridge-runtime): the terminal's transport — live kind, a low-pri…
deymosh Oct 16, 2026
64dc2bb
docs: the terminal's two lanes, the relay pacing, and live input on t…
deymosh Oct 16, 2026
c9a6962
test(bridge-runtime): a phone opens a terminal and types through the …
deymosh Oct 16, 2026
82e2061
perf(bridge): a direct link sends one terminal frame per turn, after …
deymosh Oct 16, 2026
9a07df2
test(bridge-runtime): the echo-off test waits for the shell's output,…
deymosh Oct 16, 2026
14ec922
feat(client-core): terminal state, input batching, seq and output off…
deymosh Oct 16, 2026
21bd794
feat(client-runtime): route terminal messages, pace input, stream output
deymosh Oct 16, 2026
c509965
feat(client-ffi): the terminal intents, view and output event
deymosh Oct 16, 2026
f350b88
docs(client): the terminal section
deymosh Oct 16, 2026
6848034
feat(android): the terminal's screens around the emulator
deymosh Oct 16, 2026
6856f75
fix(bridge-runtime): the terminal allowlist's file work stays off the…
deymosh Oct 16, 2026
3e8321a
fix(bridge-runtime): open a terminal's recording off the runtime thread
deymosh Oct 16, 2026
466370e
fix(bridge-runtime): audit verify prints the head hash, the chain's b…
deymosh Oct 16, 2026
7ce5670
fix(bridge-runtime): the terminal config comment matches what the cod…
deymosh Oct 16, 2026
a2673ed
feat(client-runtime): send the phone's device name on every session-k…
deymosh Oct 16, 2026
4637f26
feat(client-ffi): take the device label on the core constructor
deymosh Oct 16, 2026
c5af51c
feat(android): name this phone on the session-key grant
deymosh Oct 16, 2026
acd1f2a
build(android): vendor Termux's terminal emulator and view at v0.118.3
deymosh Oct 16, 2026
89b1df3
feat(android): a terminal session the remote shell drives
deymosh Oct 16, 2026
ce9ce9e
fix(android): hold terminal output that arrives before the view is sized
deymosh Oct 16, 2026
dde1e20
feat(protocol): mark a terminal replay answer so a clamped one is taken
deymosh Oct 17, 2026
d5c5269
fix(client-core): an attach or a gap older than the ring no longer re…
deymosh Oct 17, 2026
8f0b6b4
build: the compose file passes CODEDECK_TERMINAL through to the bridge
deymosh Oct 17, 2026
aca3424
feat(android): open, watch and type into a machine's terminal
deymosh Oct 17, 2026
897e11c
docs: the phone's terminal screen and the clamped replay answer
deymosh Oct 17, 2026
b22fa7a
fix(client-core): the predicted echo is empty on the alternate screen
deymosh Oct 17, 2026
c672c03
fix(client-runtime): a key held back for the next batch redraws the app
deymosh Oct 17, 2026
5829190
fix(bridge): with no $SHELL a terminal runs the account's login shell
deymosh Oct 17, 2026
4de490b
fix(android): the predicted echo stays until the shell echoes the keys
deymosh Oct 17, 2026
4694c31
fix(bridge): a service account's nologin shell does not replace /bin/sh
deymosh Oct 17, 2026
53457ac
fix(android): the predicted echo never draws a password, nor loses keys
deymosh Oct 17, 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
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -72,3 +72,7 @@ GIT_REPO=https://github.com/your-username/your-repo.git
# set them here (or add them on the phone).
# CODEDECK_DIRECT_LISTEN=0.0.0.0:7447
# CODEDECK_DIRECT_ENDPOINTS=wss://192.168.1.20:7447
# Terminals: 1 lets an approved phone open a shell in the container (off by
# default). Approve each phone with `docker compose exec codedeck-bridge
# codedeck-bridge terminal allow <device>`; docs/TERMINAL.md has the rest.
# CODEDECK_TERMINAL=1
127 changes: 125 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions apps/android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,11 @@ dependencies {
implementation(libs.markdown.renderer.m3)
implementation(libs.kotlinx.serialization.json)

// The vendored Termux terminal view and its emulator (Apache-2.0) — a
// Compose screen hosts the view; see terminal-view/README.md.
implementation(project(":terminal-view"))
implementation(project(":terminal-emulator"))

testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
Expand Down
2 changes: 2 additions & 0 deletions apps/android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
<!-- Camera is only for the pairing-QR scan (ui/screens/PairingScanView.kt);
requested on the first scan tap, never at launch. -->
<uses-permission android:name="android.permission.CAMERA" />
<!-- The fingerprint or screen-lock prompt before a terminal opens, when the user asks for it. -->
<uses-permission android:name="android.permission.USE_BIOMETRIC" />

<!-- Camera-less devices can still install and run: a scan tap on one
falls into the denied/unavailable no-scan path instead of the app
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ class CoreHost(
dbPath: String,
proxy: String?,
tor: Boolean,
/** This phone's name for the bridge, built by the top-level `deviceLabel`
* function. Sent on every session-key grant, shown in the bridge's
* terminal allowlist and in a refusal to open a terminal. */
deviceLabel: String,
) : CoreListener {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)

Expand All @@ -91,6 +95,18 @@ class CoreHost(
)
val events: SharedFlow<CoreEvent> = _events.asSharedFlow()

/**
* Where the terminal on screen takes its output (`CoreEvent.TerminalOutput`),
* called on the core's thread. Kept off [events] on purpose: a shell can
* send a frame every 16 ms, which would push every other event out of
* that flow's small buffer, and dropping a terminal frame there would
* leave a hole in the screen that no offset check could see. Set by the
* terminal screen before it opens or attaches, so a replay that answers
* at once is not missed; null while no terminal is on screen.
*/
@Volatile
var terminalOutput: ((CoreEvent.TerminalOutput) -> Unit)? = null

private val _machines = MutableStateFlow<UniffiMachinesView?>(null)
val machines: StateFlow<UniffiMachinesView?> = _machines.asStateFlow()

Expand Down Expand Up @@ -161,7 +177,7 @@ class CoreHost(
// Constructed last: `Core` holds `this` as its listener and may call back
// from its own thread straight away, so every field a callback touches
// (the refreshers above) must already be initialized.
private val core: Core = Core(identity, sessionKeys, this, notifier, CoreHttpFetch(), dbPath, proxy, tor)
private val core: Core = Core(identity, sessionKeys, this, notifier, CoreHttpFetch(), dbPath, proxy, tor, deviceLabel)

fun start() {
core.start()
Expand Down Expand Up @@ -218,6 +234,15 @@ class CoreHost(
}
}

/**
* [dispatch] on the host's own scope, for a caller whose scope is going
* away — a screen telling the core it closed, from its `onDispose`, where
* a launch on the composition's scope would be cancelled before it ran.
*/
fun dispatchDetached(intent: UniffiIntent) {
scope.launch { dispatch(intent) }
}

/**
* The phone's own Nostr id in bech32 `npub1…` form — derived by the core
* at construction from the identity secret it holds, so callers get the
Expand Down Expand Up @@ -289,6 +314,10 @@ class CoreHost(
}

override fun onEvent(event: CoreEvent) {
if (event is CoreEvent.TerminalOutput) {
terminalOutput?.invoke(event)
return
}
_events.tryEmit(event)
when (event) {
is CoreEvent.StateChanged -> when (event.slice) {
Expand Down Expand Up @@ -326,3 +355,31 @@ class CoreHost(

private fun refreshPairing() = pairingRefresher.request()
}

/**
* This phone's name for the bridge's terminal allowlist and its refusal
* message, built from the platform's own model string ([model]) and the
* manufacturer. The make is added only when the model does not already name
* it: a "Google Pixel 8a" says nothing "Pixel 8a" does not, while a bare
* model code like "SM-S918B" names no make on its own. The manufacturer is
* capitalized because `Build.MANUFACTURER` is often lowercase ("samsung").
*
* The bridge sanitizes and caps the name before showing it (see
* `CoreConfig::device_label`), so a name that is empty here is simply no
* name.
*/
internal fun deviceLabel(manufacturer: String, model: String): String {
val make = capitalizeAscii(manufacturer.trim())
val name = model.trim()
return when {
name.isEmpty() -> make
make.isEmpty() -> name
// Already names its make: either it starts with the make, or it reads
// as a product name (it carries lowercase) rather than a model code.
name.startsWith(make, ignoreCase = true) || name.any { it.isLowerCase() } -> name
else -> "$make $name"
}.ifBlank { "Android device" }
}

private fun capitalizeAscii(value: String): String =
if (value.isEmpty()) "" else value.take(1).uppercase() + value.drop(1)
Loading
Loading