diff --git a/README.md b/README.md index 48a8334..333f043 100644 --- a/README.md +++ b/README.md @@ -352,10 +352,15 @@ auth through egress and gitea.dideric.is over SSH. For a Codex-backed base bottle, set `agent_provider.template: codex`. The Codex template expects ChatGPT/device login state instead of an `OPENAI_API_KEY` env var; no API-key placeholder is forwarded into the -agent. To let headless device-code login request a user code, add an -unauthenticated egress route for the device-auth endpoint: +agent. To let bot-bottle read the host's current Codex ChatGPT access +token and inject it from egress only for Codex's API calls, opt in +explicitly: ```yaml +agent_provider: + template: codex + forward_host_credentials: true + egress: routes: - host: auth.openai.com @@ -363,6 +368,18 @@ egress: - /api/accounts/deviceauth/ ``` +Run `codex login --device-auth` on the host before launch. The +launcher reads `tokens.access_token` from the host's +`~/.codex/auth.json`, verifies it is fresh user/device auth, and passes +it to the sidecar's `EGRESS_TOKEN_N` env slot. The agent container gets +a dummy `~/.codex/auth.json` that preserves the host auth-mode shape +but replaces credential values with placeholders. It keeps the selected +ChatGPT account id so Codex sends requests for the same account while +egress owns the real bearer token. The agent never receives real access +tokens, refresh tokens, or `OPENAI_API_KEY`. The effective egress table +automatically adds or upgrades `api.openai.com` and `chatgpt.com` to +authenticated routes when `forward_host_credentials` is true. + The built-in Codex template uses `Dockerfile.codex`; set `agent_provider.dockerfile` to build the agent from a custom Dockerfile while keeping the bot-bottle sidecars in place. diff --git a/bot_bottle/agent_provider.py b/bot_bottle/agent_provider.py index b8b9ec8..cd271dc 100644 --- a/bot_bottle/agent_provider.py +++ b/bot_bottle/agent_provider.py @@ -7,14 +7,23 @@ command, default image, and prompt/auth behavior. from __future__ import annotations -from dataclasses import dataclass +import os +from dataclasses import dataclass, field from pathlib import Path from typing import Literal +from .codex_auth import write_codex_dummy_auth_file +from .egress import CODEX_HOST_CREDENTIAL_TOKEN_REF, EgressRoute + PROVIDER_CLAUDE = "claude" PROVIDER_CODEX = "codex" PROVIDER_TEMPLATES = frozenset({PROVIDER_CLAUDE, PROVIDER_CODEX}) + +# Hosts that egress injects the host ChatGPT bearer on when Codex +# forward_host_credentials is enabled. Pipelock must pass these through +# (no TLS MITM) or its header DLP blocks the injected JWT. +CODEX_HOST_CREDENTIAL_HOSTS = ("api.openai.com", "chatgpt.com") PromptMode = Literal["append_file", "read_prompt_file"] @@ -24,14 +33,67 @@ class AgentProviderRuntime: command: str image: str dockerfile: str - auth_role: str - placeholder_env: str prompt_mode: PromptMode bypass_args: tuple[str, ...] resume_args: tuple[str, ...] remote_control_args: tuple[str, ...] +@dataclass(frozen=True) +class AgentProvisionDir: + guest_path: str + mode: str = "700" + owner: str = "node:node" + + +@dataclass(frozen=True) +class AgentProvisionFile: + host_path: Path + guest_path: str + mode: str = "600" + owner: str = "node:node" + + +@dataclass(frozen=True) +class AgentProvisionCommand: + argv: tuple[str, ...] + error: str = "" + + +@dataclass(frozen=True) +class AgentProvisionPlan: + """Provider-owned guest setup. + + Backends interpret this plan with their own copy/exec primitives. + Provider-specific content stays here so future provider plugins can + return the same shape without adding backend-plan fields. + + `egress_routes` are provider-declared EgressRoutes that backends + pass to `Egress.prepare` and `PipelockProxy.prepare`. This keeps + provider logic out of the egress and pipelock modules — they merge + provider routes generically without knowing the provider type. + + `hidden_env_names` is the set of env var names the provider injected + as non-secret placeholders. `print_util.visible_agent_env_names` uses + this to suppress them from the preflight summary so operators don't + mistake them for real credentials. + """ + + template: str + command: str + prompt_mode: PromptMode + image: str + dockerfile: str + guest_env: dict[str, str] + env_vars: dict[str, str] = field(default_factory=dict) + dirs: tuple[AgentProvisionDir, ...] = () + files: tuple[AgentProvisionFile, ...] = () + pre_copy: tuple[AgentProvisionCommand, ...] = () + verify: tuple[AgentProvisionCommand, ...] = () + egress_routes: tuple[EgressRoute, ...] = () + hidden_env_names: frozenset[str] = field(default_factory=frozenset) + + _REPO_ROOT = Path(__file__).resolve().parent.parent @@ -41,8 +103,6 @@ _RUNTIMES = { command="claude", image="bot-bottle-claude:latest", dockerfile=str(_REPO_ROOT / "Dockerfile.claude"), - auth_role="claude_code_oauth", - placeholder_env="CLAUDE_CODE_OAUTH_TOKEN", prompt_mode="append_file", bypass_args=("--dangerously-skip-permissions",), resume_args=("--continue",), @@ -53,8 +113,6 @@ _RUNTIMES = { command="codex", image="bot-bottle-codex:latest", dockerfile=str(_REPO_ROOT / "Dockerfile.codex"), - auth_role="", - placeholder_env="", prompt_mode="read_prompt_file", bypass_args=("--dangerously-bypass-approvals-and-sandbox",), resume_args=("resume", "--last"), @@ -67,6 +125,104 @@ def runtime_for(template: str) -> AgentProviderRuntime: return _RUNTIMES[template] +def agent_provision_plan( + *, + template: str, + dockerfile: str, + state_dir: Path, + guest_home: str = "/home/node", + guest_env: dict[str, str] | None = None, + auth_token: str = "", + forward_host_credentials: bool = False, + host_env: dict[str, str] | None = None, +) -> AgentProvisionPlan: + runtime = runtime_for(template) + resolved_guest_env = dict(guest_env or {}) + env_vars: dict[str, str] = {} + dirs: list[AgentProvisionDir] = [] + files: list[AgentProvisionFile] = [] + pre_copy: list[AgentProvisionCommand] = [] + verify: list[AgentProvisionCommand] = [] + egress_routes: list[EgressRoute] = [] + hidden_env_names: frozenset[str] = frozenset() + + if template == PROVIDER_CODEX: + env_vars["CODEX_CA_CERTIFICATE"] = "/etc/ssl/certs/ca-certificates.crt" + auth_dir = resolved_guest_env.get("CODEX_HOME", f"{guest_home}/.codex") + if forward_host_credentials: + env_vars["CODEX_HOME"] = auth_dir + dirs.append(AgentProvisionDir(auth_dir)) + config_path = f"{auth_dir}/config.toml" + config_file = state_dir / "codex-config.toml" + config_file.write_text( + f'[projects."{guest_home}"]\n' + 'trust_level = "trusted"\n' + ) + config_file.chmod(0o600) + files.append(AgentProvisionFile(config_file, config_path)) + + for host in CODEX_HOST_CREDENTIAL_HOSTS: + egress_routes.append(EgressRoute( + host=host, + auth_scheme="Bearer" if forward_host_credentials else "", + token_ref=CODEX_HOST_CREDENTIAL_TOKEN_REF if forward_host_credentials else "", + tls_passthrough=True, + )) + if forward_host_credentials: + auth_file = state_dir / "codex-auth.json" + write_codex_dummy_auth_file(auth_file, host_env or dict(os.environ)) + files.append(AgentProvisionFile(auth_file, f"{auth_dir}/auth.json")) + pre_copy.append(AgentProvisionCommand(( + "find", auth_dir, + "-maxdepth", "1", + "-type", "f", + "(", + "-name", "*.sqlite", + "-o", "-name", "*.sqlite-*", + "-o", "-name", "*.codex-repair-*.bak", + ")", + "-delete", + ), "codex host credentials: could not reset runtime db files")) + verify.append(AgentProvisionCommand(( + "runuser", "-u", "node", "--", + "env", + f"HOME={guest_home}", + f"CODEX_HOME={auth_dir}", + "codex", "login", "status", + ), ( + "codex host credentials: dummy auth was copied into the " + "guest, but Codex did not accept it" + ))) + if template == PROVIDER_CLAUDE: + env_vars["CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"] = "1" + env_vars["DISABLE_ERROR_REPORTING"] = "1" + egress_routes.append(EgressRoute( + host="api.anthropic.com", + auth_scheme="Bearer" if auth_token else "", + token_ref=auth_token, + tls_passthrough=True, + )) + if auth_token: + env_vars["CLAUDE_CODE_OAUTH_TOKEN"] = "egress-placeholder" + hidden_env_names = frozenset({"CLAUDE_CODE_OAUTH_TOKEN"}) + + return AgentProvisionPlan( + template=template, + command=runtime.command, + prompt_mode=runtime.prompt_mode, + image=runtime.image, + dockerfile=dockerfile, + env_vars=env_vars, + guest_env=resolved_guest_env, + dirs=tuple(dirs), + files=tuple(files), + pre_copy=tuple(pre_copy), + verify=tuple(verify), + egress_routes=tuple(egress_routes), + hidden_env_names=hidden_env_names, + ) + + def prompt_args( prompt_mode: PromptMode, prompt_path: str | None, diff --git a/bot_bottle/backend/__init__.py b/bot_bottle/backend/__init__.py index bd680de..c55faac 100644 --- a/bot_bottle/backend/__init__.py +++ b/bot_bottle/backend/__init__.py @@ -286,6 +286,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): intercepted without per-tool reconfiguration.""" self.provision_ca(plan, target) prompt_path = self.provision_prompt(plan, target) + self.provision_provider_auth(plan, target) self.provision_skills(plan, target) self.provision_git(plan, target) self.provision_supervise(plan, target) @@ -300,6 +301,11 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]): backend overrides to docker-cp the cert in and run `update-ca-certificates`.""" + def provision_provider_auth(self, plan: PlanT, target: str) -> None: + """Install non-secret provider auth marker files into the agent + home when a provider needs them to select the right auth mode. + The default is no-op.""" + @abstractmethod def provision_prompt(self, plan: PlanT, target: str) -> str | None: """Copy the prompt file into the running bottle. Returns the diff --git a/bot_bottle/backend/docker/backend.py b/bot_bottle/backend/docker/backend.py index 195f924..23f7b97 100644 --- a/bot_bottle/backend/docker/backend.py +++ b/bot_bottle/backend/docker/backend.py @@ -29,6 +29,7 @@ from .bottle_plan import DockerBottlePlan from .provision import ca as _ca from .provision import git as _git from .provision import prompt as _prompt +from .provision import provider_auth as _provider_auth from .provision import skills as _skills from .provision import supervise as _supervise_prov @@ -62,6 +63,9 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup def provision_prompt(self, plan: DockerBottlePlan, target: str) -> str | None: return _prompt.provision_prompt(plan, target) + def provision_provider_auth(self, plan: DockerBottlePlan, target: str) -> None: + _provider_auth.provision_provider_auth(plan, target) + def provision_skills(self, plan: DockerBottlePlan, target: str) -> None: _skills.provision_skills(plan, target) diff --git a/bot_bottle/backend/docker/bottle_plan.py b/bot_bottle/backend/docker/bottle_plan.py index 8e49c07..e6240f1 100644 --- a/bot_bottle/backend/docker/bottle_plan.py +++ b/bot_bottle/backend/docker/bottle_plan.py @@ -11,7 +11,7 @@ import sys from dataclasses import dataclass, field from pathlib import Path -from ...agent_provider import PromptMode +from ...agent_provider import AgentProvisionPlan, PromptMode from ...egress import EgressPlan from ...git_gate import GitGatePlan from ...log import info @@ -52,9 +52,19 @@ class DockerBottlePlan(BottlePlan): # is opt-in via the manifest's bottle.supervise field. supervise_plan: SupervisePlan | None use_runsc: bool - agent_command: str = "claude" - agent_prompt_mode: PromptMode = "append_file" - agent_provider_template: str = "claude" + agent_provision: AgentProvisionPlan + + @property + def agent_command(self) -> str: + return self.agent_provision.command + + @property + def agent_prompt_mode(self) -> PromptMode: + return self.agent_provision.prompt_mode + + @property + def agent_provider_template(self) -> str: + return self.agent_provision.template def print(self, *, remote_control: bool) -> None: """Render the y/N preflight summary to stderr — compact form @@ -74,8 +84,12 @@ class DockerBottlePlan(BottlePlan): # upstream tokens in its own environ, so no token forwarding # from the agent to the proxy is needed. env_names = visible_agent_env_names( - sorted(set(bottle.env.keys()) | set(self.forwarded_env.keys())), - agent_provider_template=self.agent_provider_template, + sorted( + set(bottle.env.keys()) + | set(self.forwarded_env.keys()) + | set(self.agent_provision.guest_env.keys()) + ), + hidden_env_names=self.agent_provision.hidden_env_names, ) print(file=sys.stderr) diff --git a/bot_bottle/backend/docker/compose.py b/bot_bottle/backend/docker/compose.py index ce32476..863adaf 100644 --- a/bot_bottle/backend/docker/compose.py +++ b/bot_bottle/backend/docker/compose.py @@ -286,6 +286,8 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]: f"SSL_CERT_FILE={AGENT_CA_BUNDLE}", f"REQUESTS_CA_BUNDLE={AGENT_CA_BUNDLE}", ] + for name, value in sorted(plan.agent_provision.guest_env.items()): + env.append(f"{name}={value}") # Forwarded vars (OAuth token, manifest host-interpolations): # bare name → inherits from compose-up process env, value # never lands on argv or in the compose file. diff --git a/bot_bottle/backend/docker/launch.py b/bot_bottle/backend/docker/launch.py index 37dfa65..e811cbe 100644 --- a/bot_bottle/backend/docker/launch.py +++ b/bot_bottle/backend/docker/launch.py @@ -42,7 +42,11 @@ from contextlib import ExitStack, contextmanager from pathlib import Path from typing import Callable, Generator -from ...egress import egress_resolve_token_values +from ...codex_auth import codex_host_access_token +from ...egress import ( + CODEX_HOST_CREDENTIAL_TOKEN_REF, + egress_resolve_token_values, +) from ...log import info from . import network as network_mod from . import util as docker_mod @@ -181,6 +185,13 @@ def launch( token_values = egress_resolve_token_values( plan.egress_plan.token_env_map, dict(os.environ), ) + if plan.spec.manifest.bottle_for( + plan.spec.agent_name, + ).agent_provider.forward_host_credentials: + access_token = codex_host_access_token(dict(os.environ)) + for token_env, token_ref in plan.egress_plan.token_env_map.items(): + if token_ref == CODEX_HOST_CREDENTIAL_TOKEN_REF: + token_values[token_env] = access_token compose_env: dict[str, str] = { **os.environ, **plan.forwarded_env, diff --git a/bot_bottle/backend/docker/prepare.py b/bot_bottle/backend/docker/prepare.py index c2c5943..3ce9c83 100644 --- a/bot_bottle/backend/docker/prepare.py +++ b/bot_bottle/backend/docker/prepare.py @@ -12,9 +12,10 @@ from __future__ import annotations import os from datetime import datetime, timezone +from dataclasses import replace from pathlib import Path -from ...agent_provider import runtime_for +from ...agent_provider import agent_provision_plan, runtime_for from ...egress import Egress from ...env import ResolvedEnv, resolve_env from ...git_gate import GitGate @@ -158,17 +159,44 @@ def resolve_plan( prompt_file.write_text("") prompt_file.chmod(0o600) - pipelock_dir = pipelock_state_dir(slug) - pipelock_dir.mkdir(parents=True, exist_ok=True) - proxy_plan = proxy.prepare(bottle, slug, pipelock_dir) - git_gate_dir = git_gate_state_dir(slug) git_gate_dir.mkdir(parents=True, exist_ok=True) git_gate_plan = git_gate.prepare(bottle, slug, git_gate_dir) + resolved = resolve_env(manifest, spec.agent_name) + # Everything that should reach the bottle by-name (so its value + # never lands on argv or in env_file) goes into one dict. Nothing + # mutates the host os.environ. + forwarded_env: dict[str, str] = dict(resolved.forwarded) + _write_env_file(resolved, env_file) + prompt_file.write_text(agent.prompt) + + use_runsc = docker_mod.runsc_available() + agent_provision = agent_provision_plan( + template=provider.template, + dockerfile=dockerfile_path, + state_dir=agent_dir, + guest_home=os.environ.get("BOT_BOTTLE_CONTAINER_HOME", "/home/node"), + forward_host_credentials=provider.forward_host_credentials, + auth_token=provider.auth_token, + host_env=dict(os.environ), + ) + guest_env = dict(agent_provision.guest_env) + for key, val in agent_provision.env_vars.items(): + guest_env.setdefault(key, val) + agent_provision = replace(agent_provision, guest_env=guest_env) + + pipelock_dir = pipelock_state_dir(slug) + pipelock_dir.mkdir(parents=True, exist_ok=True) + proxy_plan = proxy.prepare( + bottle, slug, pipelock_dir, agent_provision.egress_routes, + ) + egress_dir = egress_state_dir(slug) egress_dir.mkdir(parents=True, exist_ok=True) - egress_plan = egress.prepare(bottle, slug, egress_dir) + egress_plan = egress.prepare( + bottle, slug, egress_dir, agent_provision.egress_routes, + ) supervise_plan = None if bottle.supervise: @@ -196,33 +224,6 @@ def resolve_plan( slug, supervise_dir, dockerfile_content=dockerfile_content, ) - resolved = resolve_env(manifest, spec.agent_name) - # Everything that should reach the bottle by-name (so its value - # never lands on argv or in env_file) goes into one dict. Nothing - # mutates the host os.environ. - forwarded_env: dict[str, str] = dict(resolved.forwarded) - # Some provider CLIs refuse to start without *some* credential - # env var even when egress will strip + re-inject the real - # Authorization header. For those providers, auth_role names the - # route marker that enables a non-secret placeholder env. Codex is - # intentionally absent here: it should use its device/ChatGPT login - # state, and an OPENAI_API_KEY placeholder would force API-key auth. - has_provider_auth = any( - provider_runtime.auth_role - and provider_runtime.auth_role in r.roles - for r in egress_plan.routes - ) - if has_provider_auth and provider_runtime.placeholder_env: - forwarded_env[provider_runtime.placeholder_env] = "egress-placeholder" - if provider.template == "claude" and has_provider_auth: - # Belt-and-braces: turn off telemetry endpoints (statsig, - # error reporting) that egress can't gate by auth. - forwarded_env.setdefault("CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC", "1") - forwarded_env.setdefault("DISABLE_ERROR_REPORTING", "1") - _write_env_file(resolved, env_file) - prompt_file.write_text(agent.prompt) - - use_runsc = docker_mod.runsc_available() return DockerBottlePlan( spec=spec, @@ -242,9 +243,7 @@ def resolve_plan( egress_plan=egress_plan, supervise_plan=supervise_plan, use_runsc=use_runsc, - agent_command=provider_runtime.command, - agent_prompt_mode=provider_runtime.prompt_mode, - agent_provider_template=provider.template, + agent_provision=agent_provision, ) diff --git a/bot_bottle/backend/docker/provision/provider_auth.py b/bot_bottle/backend/docker/provision/provider_auth.py new file mode 100644 index 0000000..e02f469 --- /dev/null +++ b/bot_bottle/backend/docker/provision/provider_auth.py @@ -0,0 +1,36 @@ +"""Provision non-secret provider auth markers into a Docker bottle.""" + +from __future__ import annotations + +import subprocess + +from ..bottle_plan import DockerBottlePlan + + +def provision_provider_auth(plan: DockerBottlePlan, target: str) -> None: + """Apply provider-owned guest setup through Docker primitives.""" + provision = plan.agent_provision + for d in provision.dirs: + _exec(target, ["mkdir", "-p", d.guest_path]) + _exec(target, ["chown", d.owner, d.guest_path]) + _exec(target, ["chmod", d.mode, d.guest_path]) + for command in provision.pre_copy: + _exec(target, list(command.argv)) + for f in provision.files: + subprocess.run( + ["docker", "cp", str(f.host_path), f"{target}:{f.guest_path}"], + stdout=subprocess.DEVNULL, + check=True, + ) + _exec(target, ["chown", f.owner, f.guest_path]) + _exec(target, ["chmod", f.mode, f.guest_path]) + for command in provision.verify: + _exec(target, list(command.argv)) + + +def _exec(target: str, argv: list[str]) -> None: + subprocess.run( + ["docker", "exec", "-u", "0", target, *argv], + stdout=subprocess.DEVNULL, + check=True, + ) diff --git a/bot_bottle/backend/print_util.py b/bot_bottle/backend/print_util.py index 5e277c8..4b4ec3d 100644 --- a/bot_bottle/backend/print_util.py +++ b/bot_bottle/backend/print_util.py @@ -9,7 +9,6 @@ from __future__ import annotations from typing import Sequence -from ..agent_provider import runtime_for from ..log import info @@ -30,16 +29,13 @@ def print_multi(label: str, values: Sequence[str]) -> None: def visible_agent_env_names( - env_names: Sequence[str], *, agent_provider_template: str, + env_names: Sequence[str], *, hidden_env_names: frozenset[str], ) -> list[str]: """Env names worth showing in launch summaries. - Provider auth placeholders (currently `CLAUDE_CODE_OAUTH_TOKEN`) - are implementation details: they are non-secret dummy values that - satisfy the provider CLI while egress injects the real upstream - Authorization header. Showing them in preflight makes the operator - think a real key is entering the agent, so hide only the active - provider-owned placeholder. + Provider-injected placeholder env vars are implementation details: + they are non-secret dummy values that satisfy provider CLIs while + egress injects the real Authorization header. The plan's + `hidden_env_names` carries exactly which names to suppress. """ - hidden = {runtime_for(agent_provider_template).placeholder_env} - return sorted({name for name in env_names if name and name not in hidden}) + return sorted({name for name in env_names if name and name not in hidden_env_names}) diff --git a/bot_bottle/backend/smolmachines/backend.py b/bot_bottle/backend/smolmachines/backend.py index b1d054a..bc3ab65 100644 --- a/bot_bottle/backend/smolmachines/backend.py +++ b/bot_bottle/backend/smolmachines/backend.py @@ -19,6 +19,7 @@ from .bottle_plan import SmolmachinesBottlePlan from .provision import ca as _ca from .provision import git as _git from .provision import prompt as _prompt +from .provision import provider_auth as _provider_auth from .provision import skills as _skills from .provision import supervise as _supervise @@ -61,6 +62,11 @@ class SmolmachinesBottleBackend( ) -> str | None: return _prompt.provision_prompt(plan, target) + def provision_provider_auth( + self, plan: SmolmachinesBottlePlan, target: str + ) -> None: + _provider_auth.provision_provider_auth(plan, target) + def provision_skills( self, plan: SmolmachinesBottlePlan, target: str ) -> None: diff --git a/bot_bottle/backend/smolmachines/bottle.py b/bot_bottle/backend/smolmachines/bottle.py index 5cebc4c..2553cb2 100644 --- a/bot_bottle/backend/smolmachines/bottle.py +++ b/bot_bottle/backend/smolmachines/bottle.py @@ -45,19 +45,11 @@ _HOME_FOR = { } -def _env_flags_for(user: str) -> list[str]: +def _env_assignments_for(user: str, env: Mapping[str, str]) -> list[str]: home = _HOME_FOR.get(user, f"/home/{user}") - return ["-e", f"HOME={home}", "-e", f"USER={user}"] - - -def _guest_env_flags(env: Mapping[str, str]) -> list[str]: - """Render `{K: V}` into a flat `-e K=V` argv slice for - `smolvm machine exec`. `smolvm machine create -e` set env - on PID 1 but it doesn't propagate to fresh exec process - trees, so we have to re-pass them every call.""" - out: list[str] = [] + out = [f"HOME={home}", f"USER={user}"] for k, v in env.items(): - out += ["-e", f"{k}={v}"] + out.append(f"{k}={v}") return out @@ -98,9 +90,8 @@ class SmolmachinesBottle(Bottle): flags = ["smolvm", "machine", "exec", "--name", self.name] if tty: flags += ["-i", "-t"] - flags += _env_flags_for("node") - flags += _guest_env_flags(self._guest_env) - agent_tail = [self.agent_command] + agent_tail = ["env", *_env_assignments_for("node", self._guest_env), + self.agent_command] provider_prompt_args = prompt_args( self._agent_prompt_mode, self._prompt_path, argv=argv, ) @@ -148,16 +139,16 @@ class SmolmachinesBottle(Bottle): on both backends. Pass `user="root"` for tests that need root. - `runuser -u -- /bin/sh -c