Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to shelldeck are listed here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and versions follow [Semantic Versioning](https://semver.org/).

## [0.0.6] - 2026-10-01

### Added

- Share from the browser: a **Share** button (and *Share…* in the palette) starts the tunnel, shows the one-use link as a QR code, and makes a new link. A **Sharing** pill in the top bar shows while a share runs.
- Allow or deny a device that opened the link from any host browser: a toast, the Share dialog, or *Waiting for approval* on the Devices page. The `sd share` window still asks too; whichever answers first wins.
- `sd share stop` ends the running share.
- Terms of sharing: before the first share, the Share dialog (a checkbox) and `sd share` (`[y/N]`) show the risks (your own risk, keep links private, Cloudflare sees the traffic, no sharing from corporate or other restricted networks) and need your consent. It is kept in `share-consent` next to the password and asked again when the terms change.
- The server tracks each agent's state (working, needs you, idle) itself and sends it to every browser.
- Groundwork for a companion phone app (work in progress): remote logins that don't take over the one-device slot, push tokens (`/api/push`) for needs-you alerts, and a single-terminal embed view (`/?sid=<id>&embed=1`).

### Changed

- The server runs cloudflared itself, so a share keeps running after `sd share` exits or its window closes, until you stop it (`sd share stop`, the Share dialog, or Ctrl+C in the `sd share` that started it). The 60s lease is gone.
- `sd share` attaches to a share that is already running instead of starting a second one.

## [0.0.5] - 2026-10-01

### Added
Expand Down Expand Up @@ -89,6 +105,7 @@ First public release.
- Mandatory password with per-browser logins, idle lock, and HTTPS or SSH-tunnel remote access.
- `sd` CLI with a startup banner, `sd search` (LLM-free code search), and one-line installers for Windows and Linux.

[0.0.6]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.6
[0.0.5]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.5
[0.0.4]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.4
[0.0.3]: https://github.com/codejunction/shelldeck/releases/tag/v0.0.3
Expand Down
9 changes: 5 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,11 @@ uv tool install --force -e . # global sd/shelldeck from this checkout (th
- The password changes only in Settings, with current + new + confirm, and the change signs out every other browser. There is no way to remove it.
- The host CLI uses `cli-token` (the `X-Shelldeck-Token` header).
- Emergency, host only: `sd login-link` and `sd reset-password`.
- `sd share`: foreground cloudflared quick tunnel. The CLI waits for "Registered tunnel connection", then `POST /api/share` (CLI token; `409 weak_password` unless `auth.strong_password()`, i.e. 12+ chars, flagged on set/login) sets `server._share` and returns a link `/share/<token>`, printed with a segno QR.
- **Link:** one unused link at a time (sha256 in `_share["link"]`), one use, `LINK_TTL` 10 min; `sd share --new-link` → `POST /api/share/link`.
- **Approval:** opening the link makes a pending grant (sha256 of a random `sd_share` cookie) and serves `_WAIT_PAGE`, which polls `/share/status`. `_watch_share()` in the CLI polls `GET /api/share` (the lease heartbeat, which lists pending grants) and asks `Allow it? [y/N]` on a second thread → `POST /api/share/decide`. `_trusted()` refuses the tunnel host except `/share/*` unless the grant is `ok`; the password is still required.
- **Lease:** `_share_on()` is false after `SHARE_LEASE` (60s) without a heartbeat; `_share_reaper` then runs `_share_stop`. `DELETE /api/share` (on Ctrl+C) clears it and signs tunnel logins out. Revoking a share login deletes its grant (`grant["session"]`).
- Share: the server runs cloudflared itself (`share.py`: `start`/`stop`, `CREATE_NO_WINDOW`). `POST /api/share` (host only, `_host()`: CLI token or a local browser not on the tunnel; `409 weak_password` unless `auth.strong_password()`, i.e. 12+ chars) starts it under `_share_lock` and returns ShareState (`_share_state()`: sharing, starting, url, link with `qr_svg`, pending, strong_password, cloudflared, terms). Host browsers get `share_state` over the alarm socket (`_broadcast(host_only=True)`); `static/share.js` is the Share dialog, the Sharing pill and Allow/Deny toasts.
- **Link:** one unused link at a time (`_share["token"]`, kept so the host UI can show its QR), one use, `LINK_TTL` 10 min; `sd share --new-link` / *New link* → `POST /api/share/link`.
- **Approval:** opening the link makes a pending grant (sha256 of a random `sd_share` cookie) and serves `_WAIT_PAGE`, which polls `/share/status`. Any non-tunnel browser (toast, Share dialog, Devices) or the `sd share` window (`Allow it? [y/N]`) answers via `POST /api/share/decide`; first answer wins. `_trusted()` refuses the tunnel host except `/share/*` unless the grant is `ok`; the password is still required.
- **Lifetime:** no lease; a share runs until `DELETE /api/share` (`sd share stop`, *Stop sharing*, Ctrl+C in the `sd share` that started it), cloudflared exiting (`_tunnel_exited`) or server shutdown. Stopping signs tunnel logins out. Revoking a share login deletes its grant (`grant["session"]`).
- **Terms:** `share.TERMS` / `TERMS_VERSION`; `POST /api/share` is `409 terms_required` until `POST /api/share/terms {accept:true}` (host only) writes `<config>/share-consent` (`auth.accept_terms`, owner-only like the password). Bump the version when the text changes in substance.
- Tunnel requests are remote (`X-Forwarded-For`/`CF-Connecting-IP`). Cloudflare terminates TLS and sees plaintext; the README says so.
- `guard` adds `SECURITY_HEADERS` (no framing, nosniff, no referrer) to every response and `Cache-Control: no-store` to `/api/*`.
- `serve` refuses plain HTTP on non-loopback addresses without `--cert/--key` or `--insecure-http`.
Expand Down
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,7 @@ sd schedule list|add|run|toggle|delete|logs
sd task list|add|move|delete|alarms
sd serve [--host H] run the server in the foreground
sd stop stop the background server (closes all terminals)
sd share [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
sd share [stop] [--new-link] share over an HTTPS Cloudflare tunnel: one-use QR link, host approval (needs cloudflared)
sd login-link emergency: one-time login URL (host only)
sd reset-password emergency: forget the password (host only)
```
Expand Down Expand Up @@ -220,14 +220,16 @@ ssh -N -L 5455:127.0.0.1:5455 you@vm # on your machine, then open http://12
```sh
sd share # starts shelldeck if needed, prints a QR code and a link; Ctrl+C stops sharing
sd share --new-link # another one-use link for the running share (a second device)
sd share stop # end the running share
```

- **Encrypted in transit, not end to end.** The other device talks HTTPS to Cloudflare, which relays it through cloudflared's outbound encrypted tunnel to shelldeck on `127.0.0.1`. No ports are opened. Cloudflare ends the TLS connection, so it can see the traffic (terminal input and output, your password as you log in). For sensitive work use the SSH tunnel or Tailscale instead.
- **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
- **Three locks.** The random `trycloudflare.com` address alone is refused. The printed link works **once** and only for **10 minutes**; opening it makes the device wait until you allow it in a shelldeck browser on this machine (toast, Share dialog or Devices) or in the `sd share` window (`Allow it? [y/N]`). Then it still needs your password.
- **Your consent.** The first share shows the terms of sharing and needs you to accept them: you share at your own risk, keep links and QR codes private, and don't share from corporate or other restricted networks. The acceptance is kept in `share-consent` in the config folder (like the password) and asked again only when the terms change.
- **Long password.** Sharing needs a password of 12+ characters. An older password that long counts after your next login.
- **Self-closing.** `sd share` renews the share every few seconds. If it stops without Ctrl+C (window closed, process killed), the server closes the share within a minute and signs those browsers out.
- **From the browser.** The **Share** button in the top bar does the same as `sd share` (only on this machine): it starts the tunnel, shows the QR code, and makes new links. A **Sharing** pill stays in the top bar while a share runs.
- **Phones.** On touch screens a key bar adds Esc, Tab, Ctrl (applies to the next letter), arrows and `| ~ / -`; dialogs open as bottom sheets.
- **Stopping.** Ctrl+C closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.
- **Stopping.** The server runs the tunnel, so a share keeps going until you stop it: `sd share stop`, *Stop sharing* in the Share dialog, or Ctrl+C in the `sd share` that started it. Stopping closes the tunnel, voids the link and signs out every browser that logged in through it (they also vanish from **Devices**; a server restart clears them too). Revoking a shared device in **Devices** also voids its approval, so it needs a new link. Each `sd share` gets a new address and link.

**HTTPS on the VM's address,** with a real certificate (Tailscale `tailscale cert`, Let's Encrypt, your reverse proxy) or a self-signed one:

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "shelldeck"
version = "0.0.5"
version = "0.0.6"
description = "Project-first multi-terminal for Windows and Linux: real shells, grouped by project, in your browser"
readme = "README.md"
license = "MIT"
Expand Down
31 changes: 27 additions & 4 deletions shelldeck/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

import hashlib
import hmac
import json
import os
import secrets
import time
Expand Down Expand Up @@ -94,6 +95,18 @@ def validate_new(password: str, confirm: str) -> str | None:
return None


def terms_accepted(version: int) -> bool:
"""The host accepted the share terms of this version (<config>/share-consent, kept like the password)."""
try:
return json.loads(_file("share-consent").read_text(encoding="utf-8")).get("version") == version
except (OSError, ValueError, AttributeError):
return False


def accept_terms(version: int) -> None:
_write_private("share-consent", json.dumps({"version": version, "accepted_at": int(time.time())}))


def set_password(password: str) -> None:
hashed = _hash_password(password)
_write_private("password", hashed)
Expand Down Expand Up @@ -174,18 +187,28 @@ def _h(token: str) -> str:
return hashlib.sha256(token.encode()).hexdigest()


def new_session(client: str, via: str = "local") -> str:
def new_session(client: str, via: str = "local", kind: str = "browser") -> str:
token = secrets.token_urlsafe(32)
db.add_auth_session(_h(token), time.time(), client, via)
db.add_auth_session(_h(token), time.time(), client, via, kind)
return token


def _row(h: str) -> dict | None:
hit = _cache.get(h)
return hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)


def remote(h: str | None) -> bool:
"""A remote (iOS app) login: it never takes the one-device slot."""
row = _row(h) if h else None
return bool(row) and row.get("kind") == "remote"


def alive(h: str | None) -> bool:
"""A session (by hash) that still exists and hasn't idled out."""
if not h:
return False
hit = _cache.get(h)
row = hit[1] if hit and time.time() - hit[0] < _CACHE_TTL else db.get_auth_session(h)
row = _row(h)
return bool(row) and time.time() - row["last_seen"] <= LOCK_TIMEOUT_SECONDS


Expand Down
Loading
Loading