Skip to content

feat(scripts): remote devcontainer orchestration via just recipe (#70)#166

Open
gerchowl wants to merge 106 commits intodevfrom
feature/70-remote-devc-orchestration
Open

feat(scripts): remote devcontainer orchestration via just recipe (#70)#166
gerchowl wants to merge 106 commits intodevfrom
feature/70-remote-devc-orchestration

Conversation

@gerchowl
Copy link
Contributor

@gerchowl gerchowl commented Feb 23, 2026

Description

Implements remote devcontainer orchestration: a single command (just remote-devc <host> or devc-remote.sh) that provisions a devcontainer on a remote host and connects your IDE to it. This enables developers to spin up devcontainers on powerful remote machines (GPU servers, cloud VMs) from their local terminal without manual SSH and compose steps.

Key capabilities

  • Core orchestration — SSH preflight, container state detection (fresh/running/stopped), compose lifecycle, IDE launch
  • gh:org/repo[:branch] targets — Clone a GitHub repo on the remote host and start its devcontainer in one command
  • --bootstrap flag — One-time remote host setup (config file, GHCR auth, image build)
  • --force flag — Auto-push unpushed commits before deploying; guards against deploying stale code
  • --open ssh|cursor|code|none — IDE-agnostic connection modes with auto-detection
  • Tailscale SSH integration — Ephemeral auth key generation via OAuth API, TUN device injection, peer wait polling
  • Claude Code CLI injection — Subscription OAuth token forwarding for AI-assisted development in containers
  • Container lifecycle execution — Runs post-create/post-start scripts inside the container after compose up
  • Compose file parsing — Reads dockerComposeFile from devcontainer.json, builds correct -f flags

Implementation

Component Purpose
scripts/devc-remote.sh Bash orchestrator: parse_args, check_ssh, remote_preflight, inject_tailscale_key, inject_claude_auth, remote_compose_up, run_container_lifecycle, open_editor
scripts/devc_remote_uri.py Python helper for Cursor/VS Code nested authority URIs (hex-encoded devcontainer specs)
justfile.base remote-devc recipe wrapping devc-remote.sh with local git state auto-detection
setup-tailscale.sh Opt-in Tailscale SSH daemon (install/start subcommands, lifecycle hooks)
setup-claude.sh Opt-in Claude Code CLI (install/start subcommands, lifecycle hooks)

Type of Change

  • feat -- New feature

Issues

Closes #152, #153, #221, #230, #231, #232, #235, #236, #243
Refs: #70, #208, #246

Testing

Manual Integration Test Results (#243)

36/39 items verified on a real remote host. Remaining 3 edge cases (low disk, missing compose, missing runtime) covered by unit tests.

Full test matrix (click to expand)

1. Core orchestration

  • devc-remote.sh myserver:~/Projects/fd5 — SSH, preflight, compose up
  • Re-run with container already running — skips compose up, opens editor
  • Re-run with container stopped — restarts and opens
  • --open none — infra only, no IDE launch
  • --open ssh — waits for Tailscale, prints hostname
  • --open code — opens VS Code instead of Cursor
  • --yes flag — auto-accepts prompts (reuse running container)

2. Tailscale SSH integration (#208, #230)

  • With TS_CLIENT_ID + TS_CLIENT_SECRET set — generates ephemeral key, injects into remote compose
  • Container joins tailnet after compose up
  • --open ssh mode — polls tailscale status, prints hostname when ready
  • Without TS env vars — silently skips (no error)

3. Claude Code CLI (#70)

  • With CLAUDE_CODE_OAUTH_TOKEN set — injects token into remote compose
  • setup-claude.sh install inside container — installs CLI, creates claude user
  • claude wrapper auto-switches to non-root user when run as root
  • setup-claude.sh start — refreshes workspace permissions
  • Without token — silently skips (no error)

4. Container lifecycle

  • Fresh container — runs post-create.sh then post-start.sh inside container
  • Existing running container — runs only post-start.sh (skips post-create)
  • Lifecycle scripts not present — skips gracefully with log message

5. --bootstrap (#235)

  • First run on clean host — prompts for projects_dir, creates config, forwards GHCR auth, clones devcontainer repo, builds image
  • --bootstrap --yes — uses defaults without prompting
  • Re-run — reads existing config, skips prompts, pulls latest, rebuilds
  • GHCR auth forwarding — podman credentials or GHCR_TOKEN copied to remote

6. gh:org/repo[:branch] (#236)

  • devc-remote.sh myserver gh:vig-os/fd5 — clones to ~/Projects/fd5, starts devcontainer
  • devc-remote.sh myserver gh:vig-os/fd5:feature/my-branch — clones and checks out branch
  • Re-run with repo already cloned — fetches, doesn't re-clone
  • devc-remote.sh myserver:~/custom/path gh:vig-os/fd5 — overrides clone location
  • Branch switch on existing clone — checks out new branch

7. Compose file parsing

  • read_compose_files() correctly reads dockerComposeFile array from devcontainer.json
  • compose_cmd_with_files() builds correct -f flags
  • Works with single file string and multi-file array

8. Edge cases

  • Low disk space warning (<2GB) — requires special host (covered by unit test)
  • Remote host without compose — requires special host (covered by unit test)
  • Remote host without container runtime — requires special host (covered by unit test)
  • SSH connection failure — clear error message
  • macOS remote host — not tested (covered by unit test)

Bugs found and fixed during testing

  • SSH drops empty args / expands ~ in remote_clone_project — fixed with sentinel values (17ca79f)
  • GHCR auth forwarding moved from bootstrap-only to every deploy (9280224)
  • Tailscale SSH required real TUN device, not userspace networking (c209f1d)
  • Tailscale key regenerated on every deploy to avoid expired ephemeral keys (0b0bcef)
  • ~/.local/bin added to PATH for SSH compose commands (15120fb)
  • Reverted unnecessary podman-compose preference logic (ea3af49)
  • Pre-flight check for stale local Tailscale daemon (49c7a4e)

Changelog Entry

See CHANGELOG.md ## Unreleased section — fully up to date.

Checklist

  • My code follows the project's style guidelines
  • I have performed a self-review of my code
  • I have updated the documentation accordingly
  • I have updated CHANGELOG.md in the [Unreleased] section
  • My changes generate no new warnings or errors
  • I have added tests that prove my feature works
  • New and existing unit tests pass locally with my changes
  • Manual integration tests pass (chore: manual integration tests for remote devcontainer features #243)

gerchowl and others added 18 commits February 22, 2026 10:42
- CHANGELOG: keep both #152 and #153 entries
- devc_remote_uri.py: keep full implementation from #153

Refs: #153
Co-authored-by: Cursor <[email protected]>
…e devcontainers (#153) (#155)

## Description

Adds `scripts/devc_remote_uri.py` — a standalone Python module/CLI that
builds the Cursor/VS Code nested authority URI for remote devcontainers.
Part of #70. Opening Cursor/VS Code into a remote devcontainer requires
constructing a `vscode-remote://` URI with hex-encoded JSON specs;
Python handles this cleanly with stdlib only.

## Type of Change

<!-- Mark the relevant option(s) with an 'x' -->

- [x] `feat` -- New feature
- [ ] `fix` -- Bug fix
- [ ] `docs` -- Documentation only
- [ ] `chore` -- Maintenance task (deps, config, etc.)
- [ ] `refactor` -- Code restructuring (no behavior change)
- [ ] `test` -- Adding or updating tests
- [ ] `ci` -- CI/CD pipeline changes
- [ ] `build` -- Build system or dependency changes
- [ ] `revert` -- Reverts a previous commit
- [ ] `style` -- Code style (formatting, whitespace)

### Modifiers

- [ ] Breaking change (`!`) -- This change breaks backward compatibility

## Changes Made

- **scripts/devc_remote_uri.py** (new): `hex_encode()`, `build_uri()`,
CLI with argparse
- **tests/test_devc_remote_uri.py** (new): 11 pytest unit tests
(hex_encode, build_uri, CLI, edge cases)
- **CHANGELOG.md**: Added entry under ## Unreleased

## Changelog Entry

<!-- Paste the exact entry you added to CHANGELOG.md under ##
Unreleased.
If no changelog update is needed, write "No changelog needed" and
explain why.
     Example:
     ### Added
- **SSH agent forwarding**
([#42](#42))
- Forward host SSH agent into devcontainer for seamless git
authentication
-->

### Added

- **devc_remote_uri.py — Cursor URI construction for remote
devcontainers**
([#153](#153))
- Standalone Python module with `hex_encode()` and `build_uri()` for
vscode-remote URIs
- CLI: `devc_remote_uri.py <workspace_path> <ssh_host>
<container_workspace>` prints URI to stdout
- Stdlib only (json, argparse); called by devc-remote.sh (sibling
sub-issue)

## Testing

<!-- Describe the tests you ran and how to verify your changes -->
- [x] Tests pass locally (`just test`)
- [ ] Manual testing performed (describe below)

### Manual Testing Details

<!-- If applicable, describe manual testing steps -->

N/A — `uv run pytest tests/test_devc_remote_uri.py -v` passes (11
tests). `just lint` passes.

## Checklist

<!-- Mark completed items with an 'x' -->
- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [ ] I have commented my code, particularly in hard-to-understand areas
- [ ] I have updated the documentation accordingly (edit
`docs/templates/`, then run `just docs`)
- [x] I have updated `CHANGELOG.md` in the `[Unreleased]` section (and
pasted the entry above)
- [x] My changes generate no new warnings or errors
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published

## Additional Notes

<!-- Any additional information, screenshots, or context that reviewers
should know -->

Design and implementation plan posted on issue #153. Sub-issue of #70
(remote devcontainer orchestration).

Refs: #153
…e recipe

- Fix open_editor() to read workspaceFolder from remote devcontainer.json
- Call devc_remote_uri.py with correct positional arguments
- Add devc-remote recipe to justfile.base for convenient access

Refs: #70
Co-authored-by: Cursor <[email protected]>
@gerchowl gerchowl self-assigned this Feb 23, 2026
gerchowl and others added 4 commits February 24, 2026 01:03
Change argument format from '--path' flag to SSH-style 'host:path' syntax:
- Accept user@host:path or host:path format
- Default to $HOME if no path specified
- Update justfile recipe to match new format

This follows familiar SSH conventions and simplifies usage.

Refs: #70
Co-authored-by: Cursor <[email protected]>
When no path is specified, use ~ which will be expanded by the remote
shell to the remote user's home directory, matching SSH conventions.

Refs: #70
Co-authored-by: Cursor <[email protected]>
@gerchowl gerchowl requested a review from c-vigo as a code owner February 24, 2026 15:15
gerchowl added 16 commits March 7, 2026 16:56
- Add CI=true env var path test for check_agent_identity
- Add setup-labels entry point availability test
- Add invalid TOML error propagation test for load_blocklist
- Strengthen assertion on contains_agent_fingerprint call args

Refs: #217
…ig-utils' into feature/70-remote-devc-orchestration

# Conflicts:
#	.github/actions/test-project/action.yml
#	packages/vig-utils/src/vig_utils/agent_blocklist.py
#	scripts/manifest.toml
- prepare_remote() writes socket path and stubs local compose override
- read_compose_files() / compose_cmd_with_files() parse devcontainer.json
- run_container_lifecycle() runs post-create/post-start inside container
- Socket detection in preflight, CONTAINER_FRESH tracking
- Fix Tailscale tag from tag:devcontainer to tag:devc

Refs: #70
- setup-claude.sh with install/start subcommands, gated by CLAUDE_CODE_OAUTH_TOKEN
- inject_claude_auth() in devc-remote.sh forwards local OAuth token to remote compose
- Uses `claude setup-token` flow (sk-ant-oat01-..., valid 1 year) — no API key needed
- Hooks into post-create.sh (install) and post-start.sh (start)
- Commented example in docker-compose.local.yaml

Refs: #70
…fig file

Adds devc-remote.sh --bootstrap <ssh-host> that performs one-time remote
setup: interactive config creation, GHCR auth forwarding, and devcontainer
image build. Re-runs read existing config without re-prompting.

Refs: #235
…vc-orchestration

# Conflicts:
#	CHANGELOG.md
#	tests/bats/devc-remote.bats
…ote-devc-orchestration

# Conflicts:
#	CHANGELOG.md
#	assets/workspace/scripts/devc-remote.sh
#	scripts/devc-remote.sh
gerchowl added 11 commits March 9, 2026 12:17
SSH drops empty string arguments and the remote shell expands ~ before
the script sees it. Use _NONE_ and _DEFAULT_ sentinels to preserve
empty branch and default path values through the SSH boundary.

Refs: #243
Move forward_ghcr_auth (renamed from bootstrap_forward_ghcr_auth) into
the normal main() flow after check_ssh. This ensures the remote always
has valid GHCR credentials without requiring a separate --bootstrap
step. The call is idempotent — overwrites with current local creds.

Refs: #243
…te-devc recipe

- check_unpushed_commits() blocks deploy when local commits aren't pushed
- --force/-f auto-pushes before deploying (handles no-upstream branches too)
- just remote-devc <host> auto-detects org/repo:branch from local git state
- BATS tests for all check_unpushed_commits scenarios
- Fix existing gh: target tests to mock git (avoid real repo interference)

Refs: #246
…orking

userspace-networking mode cannot intercept incoming connections, so
--ssh was advertised but non-functional. Now setup-tailscale.sh uses
real TUN when /dev/net/tun is available and warns otherwise.

inject_tailscale_key adds devices + cap_add to remote compose.
Template example updated with required entries.

Refs: #70
…pose files

When TAILSCALE_AUTHKEY already existed in docker-compose.local.yaml,
inject_tailscale_key returned early without adding the required
devices + cap_add entries for real TUN networking.

Refs: #70
Expired ephemeral keys in docker-compose.local.yaml blocked redeploys
because inject_tailscale_key skipped when any TAILSCALE_AUTHKEY existed.
Now always regenerates via OAuth. Also prefers podman-compose (Python)
over docker-compose bridge which drops devices/cap_add on podman <5.

Refs: #70
Non-login SSH shells miss ~/.local/bin where podman-compose is typically
installed via pip. Add PATH prefix to preflight and all remote compose
invocations.

Refs: #70
podman compose (docker-compose bridge) correctly passes devices/cap_add
when compose files are specified via -f flags, which compose_cmd_with_files
already does. The earlier failure was caused by a stale local Tailscale
client, not by the compose tooling. Remove COMPOSE_TOOL detection,
REMOTE_ENV_PREFIX, and ~/.local/bin PATH injection.

Refs: #70
check_local_tailscale() verifies BackendState=Running and Self.Online
before spending time on remote setup. Fails fast with actionable error
messages. Also adds health check to WezTerm LEADER+s and LEADER+d via
shared check_tailscale_health() helper with toast notifications.

Refs: #70
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants