Skip to content

Commit a25b7f8

Browse files
NiveditJainclaude
andauthored
[luv-legion-44] Give sessions the environment your shell would have (#44)
tmux and ssh exec their command directly, with no shell in between, so a session luv started — a remote dispatch, a handover, a detached start — never sources ~/.bashrc or ~/.zshrc. It runs with whatever environment the tmux server was started with, frozen at whenever that server first came up. The API key an agent authenticates with and the PATH entry that makes 'codex' resolvable both live in the rc, and both go missing — and only for the sessions luv started for you, which is the confusing part. So ask for them. shell_env() runs $SHELL -lic and has it print its own environment: rc files are code, not data, and there is no reading them from outside. The environment comes back behind a marker so an rc that greets you doesn't corrupt the parse, and a shell that hangs, warns, or fails outright is ignored rather than fatal. The merge happens in main(), before anything shells out — the clone needs gh and git on PATH as much as the agent needs its credentials — and ahead of collect_luv_env(), so a LUV_* exported from the rc is one -e can forward. What is already set wins: 'FOO=bar luv ...' and -e are statements about this session, and one that quietly overrode the value you handed it would be worse than one missing it. PATH is the exception, being a list rather than a value, and gets the rc's entries appended after the ones that got us here. Only for the luv that tmux or ssh started (_LUV_INNER). A luv you ran yourself already inherited the shell it ran in, and re-deriving it there would charge every session start for an rc that has already run. 'shell_env': false opts a machine out entirely, read where the session runs, so a remote can decide independently of the laptop. Docker sessions are untouched: the container has its own environment, and -e remains the only thing that crosses into it. Claude-Session: https://claude.ai/code/session_012SkaBJLpVm4LP5RQL9NXf5 Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 919a5e3 commit a25b7f8

6 files changed

Lines changed: 251 additions & 4 deletions

File tree

‎README.md‎

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,32 @@ Handover flags:
146146
| `--purge` | Delete the source folder once the copy is verified |
147147
| `-y` | Skip the "an agent may still be running" confirmation |
148148

149+
## The session's environment
150+
151+
A session luv starts for you — a remote dispatch, a handover, a detached start
152+
— is exec'd by tmux or ssh with no shell in between, so it never sources
153+
`~/.bashrc` or `~/.zshrc` and would otherwise run with whatever environment the
154+
tmux server happened to start with. luv asks your login shell (`$SHELL -lic`)
155+
for its environment and fills in the gaps before cloning or launching anything,
156+
so the agent gets the API keys and the `PATH` you'd have in a terminal there.
157+
158+
Precedence, highest first:
159+
160+
1. Anything already set — `FOO=bar luv …`, or `-e` — is a decision about *this*
161+
session and is never overwritten.
162+
2. Your rc's exports fill in the rest. `PATH` is merged instead of replaced:
163+
entries the rc adds (nvm, pyenv, `~/.local/bin`) are appended after the ones
164+
already there.
165+
166+
The probe runs on the machine the session runs on, so a remote session picks up
167+
the *remote* user's rc. It is skipped when you run `luv` yourself — that
168+
process already inherited your shell — and can be turned off entirely with
169+
`luv config set shell_env false`. An rc that hangs is given 15 seconds and then
170+
ignored.
171+
172+
Docker sessions are the exception: the container has its own environment, and
173+
only `-e` crosses into it (see below).
174+
149175
## Docker dev environments
150176

151177
If a repo contains `.luv/settings.json` with a `compose_file` key, `luv` automatically starts a Docker Compose environment and runs Claude inside the `dev-environment` container.

‎docs/configuration.md‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,35 @@ pushed. Workspaces created before slugs existed keep working.
7070
*string, default `~/prs`.* Where workspace folders are created on **this**
7171
machine. `~` is expanded.
7272

73+
### `shell_env`
74+
75+
*boolean, default `true`.* Whether a session luv starts should inherit your
76+
shell's environment.
77+
78+
tmux and ssh exec their command directly, so a remote dispatch, a handover, or
79+
a detached start never sources `~/.bashrc` or `~/.zshrc` — it runs with the
80+
environment the tmux server was started with, frozen at whenever that server
81+
first came up. Anything you export from your rc (the API key an agent
82+
authenticates with, the `PATH` entry that makes `codex` resolvable) is simply
83+
absent, and only for the sessions luv started for you.
84+
85+
With this on, luv runs `$SHELL -lic` on the machine the session runs on and
86+
fills in what's missing before it clones or launches anything. Values already
87+
set win, so `FOO=bar luv …` and `-e` still decide; `PATH` is merged rather than
88+
replaced, with the rc's entries appended. An rc that never returns is abandoned
89+
after 15 seconds.
90+
91+
Set it to `false` if your rc is slow, or if you want sessions to see only what
92+
you pass them explicitly:
93+
94+
```bash
95+
luv config set shell_env false
96+
```
97+
98+
This is read on the machine the session runs on, so a remote host can opt out
99+
independently of your laptop. It does not apply to Docker sessions — the
100+
container has its own environment, and only `-e` crosses into it.
101+
73102
### `remote.host`
74103

75104
*string.* SSH destination — an `~/.ssh/config` alias (`box`), a `user@host`, or

‎docs/remote-sessions.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -521,6 +521,19 @@ The key isn't installed or isn't being offered. Test with `ssh -v box`. Check
521521
`gh` is authenticated on your laptop but not on the remote. luv numbers
522522
workspaces with `gh api` *on the remote*, so it needs its own login there.
523523

524+
**The agent can't find an API key / a tool it needs is missing**
525+
Sessions inherit your rc: before cloning or launching, luv asks the remote's
526+
login shell (`$SHELL -lic`) for its environment and fills in whatever tmux
527+
didn't pass on, `PATH` included. So this means the var isn't exported by the
528+
*remote* user's `~/.bashrc`/`~/.zshrc` — check with
529+
`ssh box 'zsh -lic "printenv | cut -d= -f1"'` — or that the rc takes longer than
530+
15 seconds, or that `shell_env` is `false` there. `-e` with a `LUV_`-prefixed
531+
variable forwards one from your laptop regardless. See
532+
[configuration.md](configuration.md#shell_env).
533+
534+
Note that this happens *inside* the remote luv, so it can't help ssh find `luv`
535+
itself — that's the `PATH` problem above.
536+
524537
**My prompt didn't get sent**
525538
Reopening a workspace that already has a live session attaches to it, and tmux
526539
discards the command — so the prompt is dropped. luv prints a note when this can

‎luv/__init__.py‎

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,13 @@ def die(msg: str) -> None:
5757
sys.exit(1)
5858

5959

60-
def run(cmd: list[str], *, cwd: str | None = None,
61-
timeout: float | None = None) -> subprocess.CompletedProcess:
60+
def run(cmd: list[str], *, cwd: str | None = None, timeout: float | None = None,
61+
stdin: int | None = None) -> subprocess.CompletedProcess:
6262
"""Capture a subprocess. A timeout surfaces as a failure, not an exception,
6363
so callers keep their plain returncode checks."""
6464
try:
6565
return subprocess.run(cmd, capture_output=True, text=True, cwd=cwd,
66-
timeout=timeout)
66+
timeout=timeout, stdin=stdin)
6767
except subprocess.TimeoutExpired:
6868
return subprocess.CompletedProcess(cmd, 124, "", f"timed out after {timeout}s")
6969

@@ -743,6 +743,84 @@ def collect_luv_env() -> dict[str, str]:
743743
return result
744744

745745

746+
# An rc file is a program, and some of them never finish: one that starts tmux,
747+
# or waits on a prompt, would otherwise hang every session start.
748+
SHELL_ENV_TIMEOUT = 15
749+
750+
# Vars that describe the shell we just ran rather than the setup we asked it
751+
# about. Importing them would tell the agent it is somewhere it is not.
752+
SHELL_ENV_SKIP = {"_", "OLDPWD", "PWD", "SHLVL"}
753+
754+
755+
def shell_env() -> dict[str, str]:
756+
"""Everything the user's login+interactive shell exports.
757+
758+
tmux and ssh exec their command directly, with no shell in between, so a
759+
session luv started — a remote dispatch, a handover, a detached start —
760+
never sources ~/.bashrc or ~/.zshrc. It runs with whatever environment the
761+
tmux server was started with, frozen at whenever that server first came up.
762+
The API key an agent authenticates with and the PATH entry that makes
763+
'codex' resolvable both live in the rc, and both go missing.
764+
765+
Asking $SHELL for its own environment is the only way to get them back: rc
766+
files are code, not data, and there is no reading them from outside. -l
767+
covers the profile files, -i the rc files; between them they reach where
768+
bash and zsh users actually put their exports.
769+
"""
770+
shell = os.environ.get("SHELL") or "/bin/bash"
771+
marker = "__luv_env__"
772+
code = ("import json,os,sys;"
773+
f"sys.stdout.write({marker!r} + json.dumps(dict(os.environ)))")
774+
probe = f"{shlex.quote(sys.executable)} -c {shlex.quote(code)}"
775+
# DEVNULL because an interactive shell sharing our tty would fight the
776+
# agent for it, and lose to SIGTTOU rather than to the timeout.
777+
r = run([shell, "-lic", probe], timeout=SHELL_ENV_TIMEOUT,
778+
stdin=subprocess.DEVNULL)
779+
# Match on the marker, not the exit status: rc files greet, warn, and fail
780+
# in ways that say nothing about whether the environment came back. What
781+
# precedes the marker is the greeting; raw_decode drops anything an exit
782+
# hook prints after it.
783+
if marker not in r.stdout:
784+
return {}
785+
try:
786+
env, _ = json.JSONDecoder().raw_decode(r.stdout.split(marker, 1)[1].lstrip())
787+
except ValueError:
788+
return {}
789+
return {k: v for k, v in env.items() if isinstance(v, str)}
790+
791+
792+
def merged_path(current: str, from_rc: str) -> str:
793+
"""`current` first, then whatever the rc adds. PATH order is a precedence
794+
list, so the binaries that got us this far keep winning."""
795+
entries = [p for p in current.split(os.pathsep) if p]
796+
entries += [p for p in from_rc.split(os.pathsep) if p and p not in entries]
797+
return os.pathsep.join(entries)
798+
799+
800+
def apply_shell_env() -> None:
801+
"""Fill this process's environment in from the user's shell.
802+
803+
Only for the luv that tmux or ssh started (_LUV_INNER). A luv you ran
804+
yourself already inherited the shell it ran in; re-deriving it there would
805+
charge every session start for an rc that has already run.
806+
807+
What is already set wins. 'FOO=bar luv …' and -e are statements about this
808+
session, while the rc is the background default, and a session that quietly
809+
overrode the value you handed it would be worse than one missing it. PATH
810+
is the exception, being a list rather than a value: the rc's entries are
811+
appended, which is how nvm's node and ~/.local/bin become findable.
812+
"""
813+
if not os.environ.get("_LUV_INNER") or load_config().get("shell_env") is False:
814+
return
815+
for key, value in shell_env().items():
816+
if key in SHELL_ENV_SKIP:
817+
continue
818+
if key == "PATH":
819+
os.environ["PATH"] = merged_path(os.environ.get("PATH", ""), value)
820+
elif key not in os.environ:
821+
os.environ[key] = value
822+
823+
746824
def docker_env_flags(env_vars: dict[str, str]) -> list[str]:
747825
"""Convert env dict to docker compose exec -e flags."""
748826
flags: list[str] = []
@@ -3457,6 +3535,11 @@ def take_value(flag: str, what: str) -> str | None:
34573535
die("--local cannot be combined with -s or -i")
34583536

34593537
args = [a for a in args if a not in ("-n", "-r", "-e", "-f", "--force", "-p", "-nit", "--safe", "--claude", "--codex", "--local", "--no-agent-state", "--no-attach", "--purge", "-y")]
3538+
3539+
# Before anything shells out: the clone needs gh and git on PATH as much as
3540+
# the agent needs its credentials, and both come from the same rc. Ahead of
3541+
# collect_luv_env() too, so a LUV_* exported there is one -e can forward.
3542+
apply_shell_env()
34603543
extra_env = collect_luv_env() if env_mode else {}
34613544

34623545
# _LUV_INNER marks the remote-side luv: it must never dispatch onward.

‎pyproject.toml‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "luv-cli"
7-
version = "0.5.1"
7+
version = "0.6.0"
88
description = "Launch Claude Code or Codex agents on GitHub repos with isolated workspaces and optional Docker dev environments"
99
requires-python = ">=3.10"
1010
license = "MIT"

‎tests/test_agents.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -812,6 +812,102 @@ def test_batch_mode_adds_timeouts(self):
812812
self.assertIn("ConnectTimeout=5", base)
813813

814814

815+
def _shell_env_output(env, before="", after=""):
816+
"""What the probe sees: an rc's greeting, the marker, the environment."""
817+
return f"{before}__luv_env__{json.dumps(env)}{after}"
818+
819+
820+
class ShellEnvTests(unittest.TestCase):
821+
"""tmux and ssh exec without a shell, so the rc has to be asked for."""
822+
823+
def _probe(self, stdout, returncode=0):
824+
with (patch.dict(luv.os.environ, {"SHELL": "/bin/zsh"}, clear=True),
825+
patch.object(luv, "run",
826+
return_value=_completed(stdout, returncode)) as run):
827+
return luv.shell_env(), run.call_args
828+
829+
def test_asks_the_users_own_shell_as_login_and_interactive(self):
830+
env, call = self._probe(_shell_env_output({"API_BASE": "https://x"}))
831+
832+
self.assertEqual(env, {"API_BASE": "https://x"})
833+
self.assertEqual(call.args[0][:2], ["/bin/zsh", "-lic"])
834+
self.assertEqual(call.kwargs["stdin"], subprocess.DEVNULL)
835+
self.assertEqual(call.kwargs["timeout"], luv.SHELL_ENV_TIMEOUT)
836+
837+
def test_greeting_before_the_marker_is_ignored(self):
838+
env, _ = self._probe(_shell_env_output({"A": "1"}, before="welcome back!\n"))
839+
840+
self.assertEqual(env, {"A": "1"})
841+
842+
def test_exit_hook_after_the_environment_is_ignored(self):
843+
env, _ = self._probe(_shell_env_output({"A": "1"}, after="\ngoodbye\n"))
844+
845+
self.assertEqual(env, {"A": "1"})
846+
847+
def test_failing_rc_still_yields_what_it_exported(self):
848+
env, _ = self._probe(_shell_env_output({"A": "1"}), returncode=1)
849+
850+
self.assertEqual(env, {"A": "1"})
851+
852+
def test_a_shell_that_says_nothing_useful_is_not_an_error(self):
853+
self.assertEqual(self._probe("")[0], {})
854+
self.assertEqual(self._probe("__luv_env__not json")[0], {})
855+
856+
def test_timeout_is_not_an_error(self):
857+
# run() turns a hung rc into returncode 124 rather than an exception.
858+
self.assertEqual(self._probe("", returncode=124)[0], {})
859+
860+
861+
class ApplyShellEnvTests(unittest.TestCase):
862+
def _apply(self, environ, rc, config=None):
863+
with (patch.dict(luv.os.environ, environ, clear=True),
864+
patch.object(luv, "load_config", return_value=config or {}),
865+
patch.object(luv, "shell_env", return_value=rc) as probe):
866+
luv.apply_shell_env()
867+
return dict(luv.os.environ), probe.called
868+
869+
def test_only_runs_for_the_luv_tmux_or_ssh_started(self):
870+
env, probed = self._apply({}, {"API_KEY": "from-rc"})
871+
872+
self.assertFalse(probed)
873+
self.assertNotIn("API_KEY", env)
874+
875+
def test_fills_in_what_the_session_never_sourced(self):
876+
env, _ = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"})
877+
878+
self.assertEqual(env["API_KEY"], "from-rc")
879+
880+
def test_what_the_caller_set_wins(self):
881+
env, _ = self._apply({"_LUV_INNER": "1", "API_KEY": "explicit"},
882+
{"API_KEY": "from-rc", "OTHER": "from-rc"})
883+
884+
self.assertEqual(env["API_KEY"], "explicit")
885+
self.assertEqual(env["OTHER"], "from-rc")
886+
887+
def test_path_is_merged_rather_than_replaced(self):
888+
env, _ = self._apply({"_LUV_INNER": "1", "PATH": "/usr/bin:/bin"},
889+
{"PATH": "/home/u/.nvm/bin:/usr/bin:/home/u/.local/bin"})
890+
891+
self.assertEqual(env["PATH"],
892+
"/usr/bin:/bin:/home/u/.nvm/bin:/home/u/.local/bin")
893+
894+
def test_the_probe_shells_own_bookkeeping_is_left_behind(self):
895+
env, _ = self._apply({"_LUV_INNER": "1"},
896+
{"PWD": "/home/u", "SHLVL": "3", "_": "/usr/bin/env",
897+
"OLDPWD": "/tmp", "KEEP": "yes"})
898+
899+
self.assertEqual(env["KEEP"], "yes")
900+
for key in ("PWD", "SHLVL", "_", "OLDPWD"):
901+
self.assertNotIn(key, env)
902+
903+
def test_config_can_turn_it_off(self):
904+
env, probed = self._apply({"_LUV_INNER": "1"}, {"API_KEY": "from-rc"},
905+
config={"shell_env": False})
906+
907+
self.assertFalse(probed)
908+
self.assertNotIn("API_KEY", env)
909+
910+
815911
class CleanGuardTests(unittest.TestCase):
816912
def test_live_session_folder_is_skipped(self):
817913
tempdir = tempfile.TemporaryDirectory()

0 commit comments

Comments
 (0)