Skip to content
Open
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
4 changes: 3 additions & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,9 @@
"source=${localEnv:HOME}/.config/gh,target=/home/vscode/.config/gh,type=bind,consistency=cached",
"source=${localEnv:HOME}/.claude,target=/home/vscode/.claude,type=bind,consistency=cached",
"source=${localEnv:HOME}/.aws,target=/home/vscode/.aws,type=bind,consistency=cached",
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached"
"source=${localEnv:HOME}/.ssh,target=/home/vscode/.ssh,type=bind,consistency=cached",
"source=${localWorkspaceFolder}/../site,target=/workspaces/site,type=bind,consistency=cached",
"source=${localWorkspaceFolder}/../remo-broker,target=/workspaces/remo-broker,type=bind,consistency=cached"
],
"postCreateCommand": "python3 --version && node --version"
}
18 changes: 18 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ updates:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
# Supply-chain cooldown: hold newly published versions for 7 days so a
# compromised release can be detected/yanked before it's proposed.
cooldown:
default-days: 7
groups:
# One PR per week with all minor/patch production deps bumped together.
production-minor-patch:
Expand All @@ -29,8 +33,22 @@ updates:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
cooldown:
default-days: 7
groups:
actions:
update-types:
- "minor"
- "patch"

# Docker base images (Dockerfile + notifier/Dockerfile)
- package-ecosystem: "docker"
directories:
- "/"
- "/notifier"
schedule:
interval: "weekly"
day: "monday"
open-pull-requests-limit: 5
cooldown:
default-days: 7
45 changes: 45 additions & 0 deletions .github/workflows/smoke-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -803,6 +803,51 @@ jobs:
$SSH_CMD "pkill -f 'python3 -m http.server 9090'" 2>/dev/null || true
$SSH_CMD "pkill -f 'python3 -m http.server 3000'" 2>/dev/null || true

- name: Test remo shell -p / --exec / --detach (project-launch passthrough)
run: |
SSH_CMD="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=30 -o BatchMode=yes -i ~/.ssh/id_rsa remo@$HOST"

# Plant a project dir without a .devcontainer so we exercise the
# host-side branches of project-launch (no docker dance needed in CI).
$SSH_CMD "mkdir -p ~/projects/smoke-proj && \
echo 'placeholder' > ~/projects/smoke-proj/README"

# --- Foreground --exec on a no-devcontainer project (Case 4) ---
OUT=$(uv run remo shell -p smoke-proj --exec 'echo hello-$REMO_PROJECT-$REMO_INSTANCE && pwd' 2>&1)
echo "$OUT"
echo "$OUT" | grep -q "hello-smoke-proj-" || {
echo "FAIL: --exec did not run / env vars missing"; exit 1; }
echo "$OUT" | grep -q "/projects/smoke-proj" || {
echo "FAIL: --exec did not cd into project dir"; exit 1; }
echo "remo shell -p --exec (foreground): PASS"

# --- Detached --exec writes log file and returns immediately ---
DETACH_OUT=$(uv run remo shell -p smoke-proj --detach \
--exec 'sleep 1 && echo detached-marker-$REMO_PROJECT' 2>&1)
echo "$DETACH_OUT"
echo "$DETACH_OUT" | grep -q "Launched detached" || {
echo "FAIL: detach didn't print launched message"; exit 1; }
# Give the background command a moment to finish
sleep 3
LOG=$($SSH_CMD "cat ~/.local/state/remo/smoke-proj.log")
echo "log contents:"; echo "$LOG"
echo "$LOG" | grep -q "detached-marker-smoke-proj" || {
echo "FAIL: detached command did not run / log missing marker"; exit 1; }
echo "remo shell -p --detach --exec: PASS"

# --- Validation: --exec without -p exits 2 ---
if uv run remo shell --exec 'true' 2>/dev/null; then
echo "FAIL: --exec without -p should have errored"; exit 1
fi
echo "validation: --exec without -p errors as expected"

# --- Validation: --detach without --exec exits 2 ---
if uv run remo shell -p smoke-proj --detach 2>/dev/null; then
echo "FAIL: --detach without --exec should have errored"; exit 1
fi
echo "validation: --detach without --exec errors as expected"

- name: Teardown
if: always()
run: |
Expand Down
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ build/
*.egg-info/
*.egg

# Test / coverage artifacts
.coverage
.coverage.*
.pytest_cache/
htmlcov/

# Python virtual environment
venv/
.venv/
Expand All @@ -45,3 +51,4 @@ env/
.maverick/*
!.maverick/checkpoints/
.claude/settings.local.json
.DS_Store
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ See `.specify/memory/constitution.md` for project principles and non-negotiable
- Python 3.11+ + Click (CLI framework), InquirerPy (interactive picker), boto3 (AWS, optional), hcloud (Hetzner, optional) (003-python-cli-rewrite)
- Flat file (`~/.config/remo/known_hosts`, colon-delimited) (003-python-cli-rewrite)
- Cross-provider snapshot model (`models/snapshot.py`) + shared helpers in `core/snapshot.py` (name generator, validator, table formatter, destroy-time cleanup hook). No new runtime deps. (005-provider-snapshots)
- Python 3.11+ (package `requires-python = ">=3.11"`); service container runs Python 3.13-slim + Service (new `[notifier]` extra): FastAPI ≥0.115, uvicorn[standard] ≥0.32, pydantic ≥2.9, python-telegram-bot ≥21.6, structlog ≥24.4, tomli (py<3.11 only). CLI side: Click ≥8.1 (existing), no new laptop runtime deps. Build: hatchling (existing), uv (in-container). Ansible: new `community.docker` collection. (007-notifier-sidecar)
- None. All approval state is in-memory in a `PendingApprovals` registry; never persisted (FR-009). (007-notifier-sidecar)

- Ansible 2.14+ / YAML + `ansible.builtin`, `community.general` (for zypper module) (001-bootstrap-incus-host)

Expand Down Expand Up @@ -132,9 +134,9 @@ Provider SDKs (boto3, hcloud) are lazy-imported with clear error messages if mis
- Ansible 2.14+ / YAML: Follow standard conventions plus Constitution principles

## Recent Changes
- 007-notifier-sidecar: Added Python 3.11+ (package `requires-python = ">=3.11"`); service container runs Python 3.13-slim + Service (new `[notifier]` extra): FastAPI ≥0.115, uvicorn[standard] ≥0.32, pydantic ≥2.9, python-telegram-bot ≥21.6, structlog ≥24.4, tomli (py<3.11 only). CLI side: Click ≥8.1 (existing), no new laptop runtime deps. Build: hatchling (existing), uv (in-container). Ansible: new `community.docker` collection.
- 005-provider-snapshots: Added cross-provider snapshot CLI (`remo <P> snapshot {create,list,restore,delete}`) + destroy-time cleanup hook across Incus / Proxmox / AWS / Hetzner.
- 003-python-cli-rewrite: Added Python 3.11+ + Click (CLI framework), InquirerPy (interactive picker), boto3 (AWS, optional), hcloud (Hetzner, optional)
- 002-incus-container-support: Added Ansible 2.14+ / YAML + `ansible.builtin`, `community.general` (existing), Incus CLI (local)


<!-- MANUAL ADDITIONS START -->
Expand Down
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,42 @@ The `fzf`-powered menu shows your projects from `~/projects`:
- **c**: Clone a new repository
- **x**: Exit to shell

### Jump Straight to a Project

Skip the menu and land directly in a project (devcontainer auto-launches):

```bash
remo shell -p my-app
```

Run a one-shot command inside the project's devcontainer instead of opening
a shell — quote the command as a single string:

```bash
remo shell -p my-app --exec 'pytest -x'
remo shell -p my-app --exec 'claude --remote-control'
```

Fire-and-forget — kick off a command on the remote and exit SSH immediately:

```bash
remo shell -p my-app --detach --exec 'claude remote-control --name remo-rc'
remo shell -p my-app --detach --exec './long-build.sh'
```

Detached output is captured to `~/.local/state/remo/<project>.log` on the
remote, so you can tail it later (`remo shell -p my-app --exec 'tail -f
~/.local/state/remo/my-app.log'`). The command's environment gets
`REMO_INSTANCE` and `REMO_PROJECT` exported automatically — handy for
deterministic naming, e.g.:

```bash
remo shell -p my-app --detach --exec \
'claude remote-control --name "remo-$REMO_INSTANCE-$REMO_PROJECT"'
```

Then on your phone, open claude.ai/code and pick the session by name.

### Port Forwarding

Forward remote ports to your local machine during SSH sessions:
Expand Down Expand Up @@ -169,12 +205,66 @@ The `destroy` command on each provider checks for existing snapshots first and
offers to clean them up. Decline and the snapshots remain (you'll be warned
they continue to incur storage costs on AWS/Hetzner).

## Notifier

The **notifier** is a small approval bridge that runs as a hardened container on
a remo host. When an agentsh-secured devcontainer needs human sign-off for an
operation, it POSTs an approval request to the notifier, which messages you on
Telegram with **Approve** / **Deny** buttons and returns your decision. It fails
secure: a timeout, a shutdown, or a lost connection all mean *deny*. The wire
protocol is documented in
[`src/remo_cli/notifier/docs/wire-protocol.md`](src/remo_cli/notifier/docs/wire-protocol.md).

The notifier's runtime dependencies (FastAPI, python-telegram-bot, …) live in an
optional `notifier` extra and are installed only inside the container — a normal
`remo` install does not pull them in.

### Notifier setup

1. **Create a Telegram bot**: message [`@BotFather`](https://t.me/BotFather), run
`/newbot`, follow the prompts, and save the bot token.
2. **Find your chat id**: message `@userinfobot` from your own account; it
replies with your numeric user id — that's your `authorized_chat_id`.
3. **Message your new bot once** (any message) so it can DM you.
4. **Export credentials** on the machine where `remo` runs:
```bash
export REMO_NOTIFIER_TELEGRAM_BOT_TOKEN="12345:ABC...your-token"
export REMO_NOTIFIER_TELEGRAM_CHAT_ID="987654321"
```
5. **Deploy**: `remo notifier deploy <host>` (omit `<host>` to fuzzy-pick).
6. **Verify**: `remo notifier test <host>` — you should get a Telegram message
within ~2 s; tapping Approve or Deny returns the decision to the CLI.

### Notifier commands

```bash
remo notifier deploy <host> [--rebuild] # apply the role; --rebuild forces a fresh image
remo notifier status <host> # GET /v1/health
remo notifier logs <host> [-f] [-n N] # journalctl -u remo-notifier.service
remo notifier test <host> # push a test approval, print the decision
remo notifier restart <host> # systemctl restart remo-notifier.service
```

### "Always" — standing grants

Approval messages offer **✅ Approve · ⏩ Always… · ❌ Deny**. Tapping **Always…**
lets you auto-approve a *class* of operation (e.g. `git push *` in this project)
so matching requests are approved instantly without pinging you again. Grants are
held in memory only (cleared on restart → you're asked again — fail-closed),
expire after a default 8h, and default to the narrowest scope. Manage them from
Telegram: `/rules` (list + revoke), `/revoke <id>`, `/pause` and `/resume`.
Auto-approvals are logged and summarized in a periodic digest. Tune via the
`[grants]` config block (see `src/remo_cli/notifier/docs/config-schema.md`).

## CLI Reference

```bash
# Connect to environment
remo shell # Auto-connect (or picker if multiple)
remo shell my-env # Connect to a specific environment
remo shell -p my-app # Skip the menu, jump to ~/projects/my-app
remo shell -p my-app --exec 'pytest -x' # Run command in devcontainer
remo shell -p my-app --detach --exec 'claude remote-control --name rc' # Fire and exit
remo shell -L 8080 # Shell + forward remote :8080 to local :8080
remo shell -L 9000:8080 # Shell + forward remote :8080 to local :9000
remo shell -L 8080 -L 3000 # Shell + forward multiple ports
Expand Down
9 changes: 9 additions & 0 deletions ansible/group_vars/all.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,12 @@ zellij_session_name: "main" # Session name for SSH auto-attach

# Node.js Configuration
nodejs_version: "24" # LTS version

# ============================================================================
# Remo Notifier (spec 007)
# ============================================================================
# Notifier sidecar that delivers agentsh approval requests to Telegram and
# returns the human's decision. Deployed by the remo_notifier role.

remo_notifier_telegram_bot_token: "{{ lookup('env', 'REMO_NOTIFIER_TELEGRAM_BOT_TOKEN') }}"
remo_notifier_telegram_chat_id: "{{ lookup('env', 'REMO_NOTIFIER_TELEGRAM_CHAT_ID') }}"
17 changes: 17 additions & 0 deletions ansible/notifier_deploy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
# Deploy the remo notifier to an existing, provisioned host.
#
# Usage (via the CLI):
# remo notifier deploy <host>
#
# Direct usage:
# ansible-playbook notifier_deploy.yml -i "<host>," -e "ansible_user=remo" \
# -e "remo_notifier_build_context_local=/path/to/build/context"

- name: Deploy remo notifier
hosts: all
become: true
gather_facts: true

roles:
- role: remo_notifier
2 changes: 2 additions & 0 deletions ansible/requirements.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ collections:
version: ">=11.0.0"
- name: community.crypto
version: ">=3.1.0"
- name: community.docker
version: ">=4.0.0"
38 changes: 38 additions & 0 deletions ansible/roles/remo_notifier/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
---
# Defaults for the remo_notifier role. Override in group_vars/all.yml.

remo_notifier_version: "0.1.0"
remo_notifier_image: "remo-notifier:{{ remo_notifier_version }}"
remo_notifier_listen_port: 18181
remo_notifier_bind_address: "172.17.0.1" # Docker bridge address
remo_notifier_config_dir: "/etc/notifier"
remo_notifier_secrets_dir: "/etc/notifier/secrets"
remo_notifier_instance_id: "{{ inventory_hostname }}"
remo_notifier_default_timeout_seconds: 300
remo_notifier_max_timeout_seconds: 1800
remo_notifier_max_pending_approvals: 50
remo_notifier_log_level: "info"
remo_notifier_message_parse_mode: "MarkdownV2"

# Standing grants ("Always" auto-approval) — Addendum 001
remo_notifier_grants_enabled: true
remo_notifier_grants_default_ttl_seconds: 28800 # 8h; every grant expires
remo_notifier_grants_max: 100
remo_notifier_grants_allow_global_scope: true
remo_notifier_grants_digest_interval_seconds: 3600 # 0 disables the digest

remo_notifier_telegram_bot_token: "{{ lookup('env', 'REMO_NOTIFIER_TELEGRAM_BOT_TOKEN') }}"
remo_notifier_telegram_chat_id: "{{ lookup('env', 'REMO_NOTIFIER_TELEGRAM_CHAT_ID') }}"

# v1 builds the image on the host from a bundled context; future: pull from a
# registry by setting this to false.
remo_notifier_build_from_source: true
remo_notifier_source_dir: "/opt/remo-notifier-build"

# Absolute path to the build context bundled in the wheel
# (remo_cli/notifier_build/). The CLI resolves and passes this; when running the
# role directly, point it at a repo checkout root.
remo_notifier_build_context_local: ""

# Force a rebuild even if the image already exists.
remo_notifier_force_rebuild: false
11 changes: 11 additions & 0 deletions ansible/roles/remo_notifier/handlers/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
- name: Reload systemd
ansible.builtin.systemd:
daemon_reload: true
listen: "Restart remo-notifier"

- name: Restart remo-notifier
ansible.builtin.systemd:
name: remo-notifier.service
state: restarted
listen: "Restart remo-notifier"
5 changes: 5 additions & 0 deletions ansible/roles/remo_notifier/meta/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
# The notifier runs as a Docker container, so it depends on the docker role
# being applied first (installs the Docker runtime + community.docker prereqs).
dependencies:
- role: docker
Loading
Loading