pocketshell 0.0.0 → 1.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,296 @@
1
+ # PocketShell
2
+
3
+ > A real terminal in your pocket. End-to-end encrypted. WebRTC peer-to-peer.
4
+
5
+ PocketShell turns your phone into a real PTY for the machines you care about — your dev box, a homelab Pi, a fleet of edge servers. Type into your phone, the bytes flow over an encrypted WebRTC data channel directly to your machine. The cloud signals; it does not see your shell.
6
+
7
+ This repository is the **open-source host agent** that runs on the machine you want to reach. The mobile apps (iOS / Android) and the backend control plane that brokers pairing and signaling are closed-source.
8
+
9
+ ```
10
+ ┌──────────┐ ┌──────────────┐ ┌──────────┐
11
+ │ mobile │ ──── auth · signal ──── │ backend │ ──── auth · signal ──── │ host │
12
+ │ (closed) │ │ (closed) │ │ (this 👋)│
13
+ └────┬─────┘ └──────────────┘ └────┬─────┘
14
+ │ │
15
+ └──── WebRTC datachannel (DTLS) · ED25519-pinned SDP transcript ──── PTY ───┘
16
+ (your terminal traffic; never touches our servers)
17
+ ```
18
+
19
+ The control plane refuses, throttles, or routes connections. Once a mobile↔host session is up, the backend cannot read or forge the terminal/files/agent bytes — they travel over a WebRTC data channel protected by DTLS (the WebRTC stack's standard transport encryption), and the DTLS peer-cert fingerprint is bound into a signed SDP transcript verified against the device's long-term ED25519 pairing key, so a backend that swaps SDPs to MitM is detected before any byte traverses the channel. A second E2E layer (ChaCha20-Poly1305 with X25519-derived session keys) wraps sensitive *signaling* messages that pass through the backend WS. Compromise of our backend does not compromise your shell.
20
+
21
+ > Host↔host direct file transfer (a Pro feature) is anchored to the mobile device as introducer: the phone signs an attestation binding both hosts' long-term public keys to the transfer, and each host verifies it against the mobile pubkey pinned at pairing — so the backend can relay SDP but cannot substitute a peer's identity. See [Security model](#security-model).
22
+
23
+ ---
24
+
25
+ ## What's in this repo
26
+
27
+ ```
28
+ crates/
29
+ host-core/ # library: api client, websocket, pty, crypto, stats, audit, files
30
+ host-agent/ # the `pocketshell` CLI — login, pair, daemon, devices, stats
31
+ packaging/
32
+ debian/ # debian package + systemd --user unit
33
+ homebrew/ # homebrew formula
34
+ macos/ # launchd plist
35
+ mobile/src/locales/ # 12-language UI translation files (community-maintained)
36
+ mobile/src/i18n/ # i18n loader
37
+ LICENSE # Apache-2.0
38
+ NOTICE # required attribution per Apache-2.0 §4(d)
39
+ ```
40
+
41
+ The `mobile/src/locales/` tree is here because translations benefit from being public — the rest of the mobile app source isn't in this repo.
42
+
43
+ ---
44
+
45
+ ## Install
46
+
47
+ Pre-built binaries — pick either:
48
+
49
+ ```bash
50
+ # install script
51
+ brew install cosign # one-time, for Sigstore signature verification
52
+ curl -fsSL https://pocketshell.app/install.sh | bash
53
+
54
+ # npm
55
+ npm i -g pocketshell
56
+ ```
57
+
58
+ **Install script** detects your OS and arch, fetches the matching
59
+ tarball from the latest [GitHub
60
+ Release](https://github.com/yashagldit/PocketShell/releases), verifies
61
+ its SHA-256 checksum **and the Sigstore cosign keyless signature**
62
+ (pinned to this repo's release workflow identity), then installs to
63
+ `~/.local/bin/pocketshell` (or `/usr/local/bin/pocketshell` if run as
64
+ root). Without cosign installed the script refuses to proceed — the
65
+ SHA-256 alone is same-origin integrity, not authenticity, so a
66
+ compromised release publisher could serve a matching checksum for a
67
+ malicious binary. Set `POCKETSHELL_SKIP_COSIGN=1` only if you've
68
+ verified the artifact out of band.
69
+
70
+ Read the script first if you'd rather not pipe to bash blindly — it's
71
+ served from the static site and intentionally short.
72
+
73
+ **npm** ships the binary inside platform-specific packages
74
+ (`@pocketshell/darwin-arm64`, `@pocketshell/linux-x64-gnu`, etc.)
75
+ selected by npm's `os` / `cpu` / `libc` filters. The packages are
76
+ published from this repo's release workflow via [npm Trusted
77
+ Publishing](https://docs.npmjs.com/trusted-publishers/) (OIDC, no
78
+ long-lived tokens), with build provenance attached automatically.
79
+
80
+ From source (Rust 1.78+):
81
+
82
+ ```bash
83
+ git clone https://github.com/yashagldit/PocketShell.git
84
+ cd PocketShell
85
+ cargo install --path crates/host-agent --root ~/.local
86
+ export PATH="$HOME/.local/bin:$PATH"
87
+ ```
88
+
89
+ Verify:
90
+
91
+ ```bash
92
+ pocketshell --version
93
+ ```
94
+
95
+ ## Pair and run
96
+
97
+ ```bash
98
+ # 1. open the PocketShell app, tap "Pair host" — you get a 9-character code
99
+ pocketshell pair 7H2-9K4-PXM
100
+
101
+ # 2. start the daemon (runs as your user, not root)
102
+ pocketshell daemon start
103
+
104
+ # 3. status & logs
105
+ pocketshell daemon status
106
+ journalctl --user -fu pocketshell-host-agent # linux
107
+ log stream --predicate 'process == "pocketshell"' # macOS
108
+ ```
109
+
110
+ The daemon connects out to the signaling backend over WSS and waits for a peer offer. When your phone wants a session, the backend signals; the data channel is end-to-end encrypted between phone and host.
111
+
112
+ ---
113
+
114
+ ## How it works
115
+
116
+ Two planes, deliberately separated. The **control plane** (HTTPS + WebSocket to the backend) carries auth, pairing, presence, SDP offers / answers, and ICE candidates; sensitive signaling messages (file metadata) get an additional ChaCha20-Poly1305 envelope with X25519-derived keys so the backend WS sees only ciphertext. The **data plane** (WebRTC peer connection) carries every byte of your shell — PTY I/O, file chunks, stats samples, agent stdio — directly between phone and host, encrypted by DTLS at the WebRTC layer with the peer cert fingerprint bound into the ED25519-signed SDP.
117
+
118
+ ```mermaid
119
+ flowchart TB
120
+ subgraph Mobile["📱 Mobile app (closed source)"]
121
+ direction TB
122
+ M_auth["Email OTP → JWT<br/>(15 min access · 30 day refresh)"]
123
+ M_key["ED25519 device key<br/>Expo SecureStore"]
124
+ M_ui["Terminal · Files · Stats<br/>Agent chat · Alerts · Workspaces"]
125
+ M_ws["Signaling WS client<br/>/ws/mobile"]
126
+ M_rtc["WebRTC peer<br/>(react-native-webrtc)"]
127
+ end
128
+
129
+ subgraph Backend["☁️ Backend control plane (closed source)"]
130
+ direction TB
131
+ B_api["FastAPI · /api/v1/*<br/>auth · devices · pairing · sessions<br/>presence · turn · alerts · workspaces"]
132
+ B_ws["ConnectionManager<br/>/ws/mobile · /ws/host<br/>signaling relay only"]
133
+ B_pg[("PostgreSQL<br/>users · hosts · sessions<br/>trusted_devices · audit_log")]
134
+ B_redis[("Redis<br/>presence · live stats<br/>rate-limit · pub/sub relay")]
135
+ B_turn["TURN server<br/>(rotating HMAC creds)"]
136
+ end
137
+
138
+ subgraph Host["🖥️ Host agent (this repo)"]
139
+ direction TB
140
+ H_cli["pocketshell CLI<br/>pair · daemon · devices · stats"]
141
+ H_key["ED25519 host key<br/>OS keychain"]
142
+ H_ws["transport.rs<br/>WS client → /ws/host"]
143
+ H_rtc["webrtc_manager.rs<br/>peer-per-mobile-device"]
144
+ H_pty["pty.rs · discovery.rs<br/>tmux / screen / shell"]
145
+ H_stats["stats.rs · files.rs<br/>agent_session.rs · alerts.rs"]
146
+ end
147
+
148
+ M_auth -. "HTTPS · JWT" .-> B_api
149
+ M_ws -. "WSS · signaling<br/>session_offer / answer<br/>ice_candidate · stats_offer<br/>files_offer · agent_offer" .-> B_ws
150
+ H_cli -. "HTTPS · pair / refresh" .-> B_api
151
+ H_ws -. "WSS · signaling<br/>session_ack · session_event<br/>alert · host_summary<br/>stats_snapshot" .-> B_ws
152
+
153
+ B_api --- B_pg
154
+ B_ws --- B_redis
155
+ B_api --- B_turn
156
+
157
+ M_rtc <==>|"WebRTC data channels — E2E encrypted, never touches backend<br/><br/><b>terminal</b> (PTY bytes) · <b>stats</b> (JSON ~1 Hz)<br/><b>files</b> (framed JSON + chunks) · <b>agent-{id}</b> (Claude / Codex stdio)<br/><br/>P2P direct · TURN relay only when NAT blocks P2P"| H_rtc
158
+
159
+ B_turn -. "TURN relay path<br/>(opaque to backend)" .-> M_rtc
160
+ B_turn -. "TURN relay path" .-> H_rtc
161
+
162
+ classDef mobile fill:#1e3a5f,stroke:#4a90e2,color:#fff
163
+ classDef backend fill:#3a2f5c,stroke:#9b6dd1,color:#fff
164
+ classDef host fill:#2d4a3e,stroke:#5cb88c,color:#fff
165
+ class M_auth,M_key,M_ui,M_ws,M_rtc mobile
166
+ class B_api,B_ws,B_pg,B_redis,B_turn backend
167
+ class H_cli,H_key,H_ws,H_rtc,H_pty,H_stats host
168
+ ```
169
+
170
+ ### Session establishment (the happy path)
171
+
172
+ ```mermaid
173
+ sequenceDiagram
174
+ autonumber
175
+ participant M as 📱 Mobile
176
+ participant B as ☁️ Backend
177
+ participant H as 🖥️ Host daemon
178
+
179
+ Note over M,H: Both sides are already authed and connected to /ws/mobile and /ws/host.
180
+
181
+ M->>B: POST /api/v1/sessions (host_id, purpose=terminal)
182
+ B-->>M: 201 · session_id (state = REQUESTED)
183
+ M->>B: WS session_offer (SDP, signed)
184
+ B->>H: relay session_offer
185
+ H->>H: verify ED25519 sig over SDP · spawn PTY
186
+ H-->>B: WS session_ack { accepted: true }
187
+ B-->>M: relay session_ack → state = APPROVED
188
+ H->>B: WS session_answer (SDP)
189
+ B->>M: relay session_answer
190
+ M-->>H: WS ice_candidate (× N, both directions, via backend)
191
+ H-->>M: WS ice_candidate (× N)
192
+
193
+ rect rgba(92, 184, 140, 0.15)
194
+ Note over M,H: ── WebRTC data channel established ──
195
+ M-->>H: terminal · keystrokes (DTLS over WebRTC datachannel)
196
+ H-->>M: terminal · PTY output
197
+ H-->>M: stats · JSON snapshots ~1 Hz
198
+ M-->>H: files · list / read / write
199
+ Note right of B: Backend sees zero bytes of this traffic.
200
+ end
201
+
202
+ Note over M,H: On host disconnect, state becomes DETACHED. PTY survives — mobile can rejoin.
203
+ ```
204
+
205
+ ### What flows where
206
+
207
+ | Plane | Carrier | Payload |
208
+ |---|---|---|
209
+ | Auth | HTTPS `/api/v1/auth/*` | OTP, JWT issue / refresh, host pair / re-auth |
210
+ | Signaling | WSS `/ws/mobile`, `/ws/host` | `session_offer` · `session_answer` · `ice_candidate` · `session_event` · `stats_offer` · `files_offer` · `agent_offer` · `alert` · `host_summary` · `available_sessions` |
211
+ | Presence + lightweight metrics | WSS `/ws/host` | `heartbeat` (host_id, active_sessions, app_version) · `host_summary` (cpu%, ram%) · `stats_snapshot` and `stats_minute_batch` (cpu/mem/disk/load/battery/net IO/temps — **no processes, no command lines, no logged-in users**) |
212
+ | Metrics history (Pro) | HTTPS `/api/v1/presence/hosts/{id}/stats/history*` | minute / 30-min / hourly / daily aggregates served from PostgreSQL — used by the history charts even when no live session is active |
213
+ | **Terminal I/O** | **WebRTC `terminal` channel** | **PTY bytes — never touches backend** |
214
+ | **Live stats (rich)** | **WebRTC `stats` channel** | **Top-50 processes · per-core CPU · logged-in users · network connections · OS info · hostname — never touches backend** |
215
+ | **File ops** | **WebRTC `files` channel** | **Framed JSON + base64 chunks (sentinel `\x00PSFC`)** |
216
+ | **Agent chat** | **WebRTC `agent-{id}` channel** | **Claude / Codex stdio, JSON framed** |
217
+
218
+ The backend is a switchboard — it can refuse a connection, throttle it, or route it through TURN, but once the data channel is up it sees ciphertext at best and nothing at all on direct P2P.
219
+
220
+ **What the backend persists.** To deliver the Pro history-chart feature, the backend stores minute-bucket aggregates of numerical metrics (cpu/mem/disk/uptime/load/battery/net IO/disk IO/max temp + collected-at) per host, rolled up into 30-min/hourly/daily over time. It does **not** persist processes, command lines, hostnames, logged-in users, or network connection lists — those flow only over WebRTC and are dropped from Redis after ~35 minutes. Account deletion removes all of the above; see `app/services/account_deletion_service.py`.
221
+
222
+ ---
223
+
224
+ ## Security model
225
+
226
+ | Layer | Primitive |
227
+ |---|---|
228
+ | Identity | ED25519 long-term host & device keys |
229
+ | Handshake | X25519 ephemeral · ED25519-signed SDP transcript binds DTLS cert fingerprint to the device pairing key |
230
+ | Data plane | WebRTC DTLS (transport encryption — AES-GCM by default, ciphersuite negotiated) |
231
+ | Signaling envelope | ChaCha20-Poly1305 AEAD · per-direction keys (wraps sensitive file/control payloads that pass through the backend WS) |
232
+ | KDF | HKDF-SHA256, domain-separated |
233
+ | Transport | WebRTC P2P · TURN fallback (rotating credentials) |
234
+ | Storage | OS keychain — Apple Keychain · Linux secret-service · Windows DPAPI · 0o600 file fallback for headless Linux |
235
+
236
+ Long-lived secrets (host private key, refresh token) live in the OS keychain via `crates/host-core/src/secret_store.rs`. Short-lived access tokens sit in `state.json` (mode `0o600`).
237
+
238
+ ### Host↔host transfer trust model
239
+
240
+ The destination host verifies the source's SDP signature against a public key carried inside a *mobile-signed introducer attestation*, not a backend-relayed field. At transfer-initiation time the mobile app signs `(transfer_id, src_host_id, src_host_pubkey, dst_host_id, dst_host_pubkey, expires_at, nonce)` with its long-term device key; both hosts verify that signature against the mobile pubkey they pinned at pairing. The attestation is short-lived (≤ 5 min), bound to a single `transfer_id`, and both hosts additionally check that their own `host_id`+`public_key` match the attested values. Backend can relay SDP but cannot substitute a peer's identity. See `crates/host-core/src/signaling_crypto.rs` (`verify_host_transfer_attestation`) and `crates/host-core/src/daemon.rs` (`extract_and_verify_mobile_attestation`).
241
+
242
+ ### Known gaps
243
+
244
+ - **No per-session approval prompt (by design)** — once a mobile device is trusted, every session offer is accepted immediately. The stolen-and-unlocked-phone case is handled by layered controls instead: app open is gated by device biometric, every paired device shows up in the in-app device list and can be revoked remotely from any other paired device, and sensitive ops are written to the local audit log on the host. Adding a per-session prompt would train users to tap "approve" reflexively and degrade the UX for the common case, so it isn't on the roadmap.
245
+
246
+ Found something? Email `support@pocketshell.app`. Public issues are fine for non-sensitive bugs.
247
+
248
+ ---
249
+
250
+ ## Building & testing
251
+
252
+ ```bash
253
+ cargo build -p host-agent # debug build
254
+ cargo build -p host-agent --release # release
255
+ cargo test -p host-core # core library tests
256
+ cargo test -p host-agent # CLI tests
257
+ RUST_LOG=debug cargo run -p host-agent -- daemon run # run with verbose logs
258
+ ```
259
+
260
+ Targets supported today: `x86_64-unknown-linux-gnu`, `aarch64-unknown-linux-gnu`, `aarch64-apple-darwin`, `x86_64-apple-darwin`. Windows is not yet supported (PRs welcome).
261
+
262
+ ---
263
+
264
+ ## Translations
265
+
266
+ The mobile app currently ships in **12 languages**: English, German, Spanish, French, Hindi, Italian, Japanese, Korean, Portuguese (BR), Russian, Simplified Chinese, Traditional Chinese.
267
+
268
+ Files live under `mobile/src/locales/<lang>/*.json`. To add or improve a language, edit the JSON files and open a PR — no app build required. The strings are loaded by `mobile/src/i18n/index.ts`.
269
+
270
+ If you want a new language added, open an issue and we'll seed the directory.
271
+
272
+ ---
273
+
274
+ ## Contributing
275
+
276
+ PRs are welcome. A few notes:
277
+
278
+ - **Source of truth.** This repo is mirrored from a private monorepo. We accept patches here and apply them upstream. Force-pushes happen on every release — keep your fork rebased rather than merged.
279
+ - **Scope.** Issues for the mobile app or the backend get redirected; this repo is the host agent and the locales.
280
+ - **Style.** Match the surrounding code. `cargo fmt` and `cargo clippy --all-targets -- -D warnings` should pass.
281
+ - **Commits.** Conventional commits preferred (`feat:`, `fix:`, `refactor:`).
282
+
283
+ ---
284
+
285
+ ## License
286
+
287
+ Licensed under the [Apache License, Version 2.0](./LICENSE).
288
+
289
+ Unless you explicitly state otherwise, any contribution intentionally
290
+ submitted for inclusion in this project shall be licensed as Apache-2.0,
291
+ without any additional terms or conditions, per §5 of the License. See
292
+ also [NOTICE](./NOTICE).
293
+
294
+ ---
295
+
296
+ **Links** · [pocketshell.app](https://pocketshell.app) · [Privacy](https://pocketshell.app/privacy) · [Terms](https://pocketshell.app/terms) · [Support](https://pocketshell.app/support)
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env node
2
+ // Thin Node shim. The real `pocketshell` binary ships inside one of the
3
+ // platform-specific @pocketshell/<triple> packages, declared as
4
+ // optionalDependencies. npm installs only the package whose os/cpu/libc
5
+ // matches the user's machine, so by the time this shim runs, exactly one
6
+ // of them is present on disk. We resolve its location and exec the binary
7
+ // with the user's argv.
8
+ //
9
+ // Runtime libc detection (musl vs glibc on Linux) mirrors npm's own
10
+ // optional-dep filtering, so the package chosen here always matches what
11
+ // npm actually installed.
12
+
13
+ 'use strict';
14
+
15
+ const { spawnSync } = require('child_process');
16
+ const path = require('path');
17
+ const fs = require('fs');
18
+
19
+ function detectLinuxLibc() {
20
+ try {
21
+ if (fs.existsSync('/etc/alpine-release')) return 'musl';
22
+ if (
23
+ fs.existsSync('/lib/ld-musl-x86_64.so.1') ||
24
+ fs.existsSync('/lib/ld-musl-aarch64.so.1')
25
+ ) {
26
+ return 'musl';
27
+ }
28
+ } catch (_) {}
29
+ return 'gnu';
30
+ }
31
+
32
+ function resolvePackageName() {
33
+ const { platform, arch } = process;
34
+ if (platform === 'darwin' && arch === 'arm64') return '@pocketshell/darwin-arm64';
35
+ if (platform === 'linux' && arch === 'arm64') return '@pocketshell/linux-arm64-gnu';
36
+ if (platform === 'linux' && arch === 'x64') {
37
+ return detectLinuxLibc() === 'musl'
38
+ ? '@pocketshell/linux-x64-musl'
39
+ : '@pocketshell/linux-x64-gnu';
40
+ }
41
+ return null;
42
+ }
43
+
44
+ const pkgName = resolvePackageName();
45
+ if (!pkgName) {
46
+ console.error(
47
+ `pocketshell: no prebuilt binary for ${process.platform}/${process.arch}.\n` +
48
+ `Supported: darwin/arm64, linux/x64 (gnu+musl), linux/arm64.\n` +
49
+ `See https://pocketshell.app for manual install options.`
50
+ );
51
+ process.exit(1);
52
+ }
53
+
54
+ let pkgRoot;
55
+ try {
56
+ pkgRoot = path.dirname(require.resolve(`${pkgName}/package.json`));
57
+ } catch (_) {
58
+ console.error(
59
+ `pocketshell: platform package ${pkgName} is not installed.\n` +
60
+ `This usually means npm skipped optional dependencies ` +
61
+ `(e.g. --no-optional / --omit=optional).\n` +
62
+ `Reinstall with: npm i -g pocketshell`
63
+ );
64
+ process.exit(1);
65
+ }
66
+
67
+ const binPath = path.join(pkgRoot, 'bin', 'pocketshell');
68
+ const result = spawnSync(binPath, process.argv.slice(2), { stdio: 'inherit' });
69
+
70
+ if (result.error) {
71
+ console.error(`pocketshell: failed to launch binary: ${result.error.message}`);
72
+ process.exit(1);
73
+ }
74
+ process.exit(result.status == null ? 1 : result.status);
package/package.json CHANGED
@@ -1,6 +1,26 @@
1
1
  {
2
2
  "name": "pocketshell",
3
- "version": "0.0.0",
4
- "description": "Placeholder for trusted-publishing bootstrap. See https://pocketshell.app",
5
- "license": "Apache-2.0"
3
+ "version": "1.0.4",
4
+ "description": "Secure mobile-to-host terminal access — installs the pocketshell host-agent CLI.",
5
+ "homepage": "https://pocketshell.app",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/yashagldit/PocketShell"
9
+ },
10
+ "license": "Apache-2.0",
11
+ "bin": {
12
+ "pocketshell": "bin/pocketshell.js"
13
+ },
14
+ "files": [
15
+ "bin/"
16
+ ],
17
+ "engines": {
18
+ "node": ">=14"
19
+ },
20
+ "optionalDependencies": {
21
+ "@pocketshell/darwin-arm64": "1.0.4",
22
+ "@pocketshell/linux-x64-gnu": "1.0.4",
23
+ "@pocketshell/linux-arm64-gnu": "1.0.4",
24
+ "@pocketshell/linux-x64-musl": "1.0.4"
25
+ }
6
26
  }