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
115 changes: 108 additions & 7 deletions products/tasks/backend/logic/services/agentsh.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,16 @@

import yaml

from products.tasks.backend.constants import SANDBOX_AGENT_LAUNCH_UNSET_ENV_VARS

AGENTSH_DAEMON_PORT = 18080
SESSION_ID_FILE = "/tmp/agentsh-session-id"
ENV_FILE = "/tmp/agent-env"
GITHUB_ENV_FILE = "/tmp/agent-github-env"
OAUTH_ENV_FILE = "/tmp/agent-oauth-env"
ENV_WRAPPER_SCRIPT = "/tmp/agentsh-env-wrapper.sh"
# Sourced via BASH_ENV on every `bash -c` the agent runs, so git/gh pick up a
# mid-session GitHub credential refresh (the backend rewrites ENV_FILE in place).
# mid-session GitHub credential refresh from its dedicated credential file.
BASH_ENV_SCRIPT = "/tmp/agentsh-bash-env.sh"
AGENTSH_AUDIT_DB = "/var/lib/agentsh/events.db"
INFRASTRUCTURE_DOMAINS = [
Expand Down Expand Up @@ -93,7 +97,22 @@ def _get_debug_only_ports() -> list[int]:
return ports


def generate_env_wrapper() -> str:
_MANAGED_CREDENTIAL_ENV_KEYS = ("GH_TOKEN", "GITHUB_TOKEN", "POSTHOG_PERSONAL_API_KEY")
_EXCLUDED_AGENT_ENV_KEYS = (
*SANDBOX_AGENT_LAUNCH_UNSET_ENV_VARS,
"BASH_ENV",
"PROMPT_COMMAND",
"PYTHONSTARTUP",
"PERL5OPT",
"RUBYOPT",
)


def generate_env_wrapper(
env_file: str = ENV_FILE,
github_env_file: str = GITHUB_ENV_FILE,
oauth_env_file: str = OAUTH_ENV_FILE,
) -> str:
"""Generate a wrapper that restores the full sandbox environment.

``agentsh exec`` starts child processes with a heavily stripped
Expand All @@ -104,25 +123,107 @@ def generate_env_wrapper() -> str:
Network policy enforcement happens at the syscall level (ptrace) —
it does not depend on proxy environment variables.
"""
quoted_env_file = shlex.quote(env_file)
quoted_github_env_file = shlex.quote(github_env_file)
quoted_oauth_env_file = shlex.quote(oauth_env_file)
excluded_names = " ".join((*_MANAGED_CREDENTIAL_ENV_KEYS, *_EXCLUDED_AGENT_ENV_KEYS))
excluded_entries = "|".join(f"{name}=*" for name in (*_MANAGED_CREDENTIAL_ENV_KEYS, *_EXCLUDED_AGENT_ENV_KEYS))
return f"""\
#!/bin/bash
unset {excluded_names}
while IFS= read -r -d $'\\0' line; do
case "$line" in
{excluded_entries}) ;;
*) export "$line" ;;
esac
done < {quoted_env_file} 2>/dev/null

while IFS= read -r -d $'\\0' line; do
case "$line" in
GH_TOKEN=*|GITHUB_TOKEN=*) export "$line" ;;
esac
done < {quoted_github_env_file} 2>/dev/null

while IFS= read -r -d $'\\0' line; do
export "$line"
done < {ENV_FILE}
case "$line" in
POSTHOG_PERSONAL_API_KEY=*) export "$line" ;;
esac
done < {quoted_oauth_env_file} 2>/dev/null
exec "$@"
"""


def generate_bash_env_script() -> str:
def generate_bash_env_script(
env_file: str = ENV_FILE,
github_env_file: str = GITHUB_ENV_FILE,
oauth_env_file: str = OAUTH_ENV_FILE,
) -> str:
"""
Generate the script sourced via ``BASH_ENV``.
Generate the script sourced via ``BASH_ENV`` and used to initialize its env file.

The explicit invocation runs before the background agent-server launch. It
atomically replaces the full environment with the current sandbox process
environment, excluding launch hooks and credentials. Credential files are
initialized only when absent, so a backend refresh that happened before startup
wins. Sourced invocations stay cheap and only export GitHub credentials.
"""
quoted_env_file = shlex.quote(env_file)
quoted_github_env_file = shlex.quote(github_env_file)
quoted_oauth_env_file = shlex.quote(oauth_env_file)
excluded_entries = "|".join(f"{name}=*" for name in (*_MANAGED_CREDENTIAL_ENV_KEYS, *_EXCLUDED_AGENT_ENV_KEYS))
return f"""\
if [[ "${{BASH_SOURCE[0]}}" == "$0" ]]; then
set -euo pipefail
umask 077
env_tmp="$(mktemp {quoted_env_file}.tmp.XXXXXX)"
github_tmp="$(mktemp {quoted_github_env_file}.tmp.XXXXXX)"
oauth_tmp="$(mktemp {quoted_oauth_env_file}.tmp.XXXXXX)"
trap 'rm -f "$env_tmp" "$github_tmp" "$oauth_tmp"' EXIT

while IFS= read -r -d $'\\0' kv 2>/dev/null; do
case "$kv" in
{excluded_entries}) ;;
*) printf '%s\\0' "$kv" >> "$env_tmp" ;;
esac
done < <(env -0)
chmod 600 "$env_tmp"
mv "$env_tmp" {quoted_env_file}

github_token="${{GITHUB_TOKEN:-${{GH_TOKEN:-}}}}"
if [[ -n "$github_token" ]]; then
printf 'GITHUB_TOKEN=%s\\0GH_TOKEN=%s\\0' "$github_token" "$github_token" > "$github_tmp"
fi
chmod 600 "$github_tmp"
if [[ -e {quoted_github_env_file} || -L {quoted_github_env_file} ]]; then
[[ -f {quoted_github_env_file} && ! -L {quoted_github_env_file} ]]
chmod 600 {quoted_github_env_file}
else
if ! ln "$github_tmp" {quoted_github_env_file} 2>/dev/null; then
[[ -f {quoted_github_env_file} && ! -L {quoted_github_env_file} ]]
fi
fi

if [[ -n "${{POSTHOG_PERSONAL_API_KEY:-}}" ]]; then
printf 'POSTHOG_PERSONAL_API_KEY=%s\\0' "$POSTHOG_PERSONAL_API_KEY" > "$oauth_tmp"
fi
chmod 600 "$oauth_tmp"
if [[ -e {quoted_oauth_env_file} || -L {quoted_oauth_env_file} ]]; then
[[ -f {quoted_oauth_env_file} && ! -L {quoted_oauth_env_file} ]]
chmod 600 {quoted_oauth_env_file}
else
if ! ln "$oauth_tmp" {quoted_oauth_env_file} 2>/dev/null; then
[[ -f {quoted_oauth_env_file} && ! -L {quoted_oauth_env_file} ]]
fi
fi
exit 0
fi

unset GH_TOKEN GITHUB_TOKEN
while IFS= read -r -d $'\\0' kv 2>/dev/null; do
case "$kv" in
GH_TOKEN=*|GITHUB_TOKEN=*) export "$kv" ;;
esac
done < {ENV_FILE} 2>/dev/null
done < {quoted_github_env_file} 2>/dev/null
"""


Expand Down
10 changes: 4 additions & 6 deletions products/tasks/backend/logic/services/docker_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@

from .agentsh import (
BASH_ENV_SCRIPT,
ENV_FILE,
ENV_WRAPPER_SCRIPT,
SESSION_ID_FILE,
build_exec_prefix,
Expand Down Expand Up @@ -852,16 +851,15 @@ def _build_agent_server_command(
'export NO_PROXY="host.docker.internal,${NO_PROXY:-localhost,127.0.0.1}"; export no_proxy="$NO_PROXY"; '
)
inner = f"cd /scripts && {no_proxy_export}{server_cmd} > /tmp/agent-server.log 2>&1"
initialize_env_file = f"bash {shlex.quote(BASH_ENV_SCRIPT)}"

if allowed_domains is not None:
return (
f"cd /scripts && env -0 > {ENV_FILE} && "
f"{build_exec_prefix()} {ENV_WRAPPER_SCRIPT} bash -c {shlex.quote(inner)} &"
f"cd /scripts && {initialize_env_file} && "
f"({build_exec_prefix()} {ENV_WRAPPER_SCRIPT} bash -c {shlex.quote(inner)} &)"
)
else:
# Write the env file even without agentsh so BASH_ENV (and the
# in-process token resolver) can re-read a backend-refreshed token.
return f"cd /scripts && env -0 > {ENV_FILE} && nohup {server_cmd} > /tmp/agent-server.log 2>&1 &"
return f"cd /scripts && {initialize_env_file} && (nohup {server_cmd} > /tmp/agent-server.log 2>&1 &)"

def _launch_and_check(self, command: str) -> bool:
"""Execute the agent-server command and wait for the health check.
Expand Down
54 changes: 54 additions & 0 deletions products/tasks/backend/logic/services/local_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@

from django.conf import settings

import yaml

logger = logging.getLogger(__name__)

BUILD_OUTPUT_SUBDIR = "dist"
Expand Down Expand Up @@ -81,6 +83,7 @@ def get_local_posthog_code_packages() -> tuple[LocalPackage, ...] | None:
def get_local_package_runtime_dependencies(packages: tuple[LocalPackage, ...]) -> dict[str, dict[str, str]]:
"""Collect registry dependencies needed by the overlaid local package builds."""
dependencies: dict[str, dict[str, str]] = {}
dependency_catalogs: dict[Path, tuple[dict[str, str], dict[str, dict[str, str]]]] = {}

for package in packages:
manifest_path = package.source_path / "package.json"
Expand All @@ -95,6 +98,57 @@ def get_local_package_runtime_dependencies(packages: tuple[LocalPackage, ...]) -
raise ValueError(f"Expected dependency names and versions to be strings in {manifest_path}")
if version.startswith("workspace:"):
continue
if version.startswith("catalog:"):
workspace_manifest_path = next(
(
parent / "pnpm-workspace.yaml"
for parent in package.source_path.parents
if (parent / "pnpm-workspace.yaml").is_file()
),
None,
)
if workspace_manifest_path is None:
raise ValueError(f"Could not resolve catalog dependency {name} from {manifest_path}")

if workspace_manifest_path not in dependency_catalogs:
workspace_manifest = yaml.safe_load(workspace_manifest_path.read_text())
catalog = workspace_manifest.get("catalog", {}) if isinstance(workspace_manifest, dict) else None
catalogs = workspace_manifest.get("catalogs", {}) if isinstance(workspace_manifest, dict) else None
if not isinstance(catalog, dict) or not all(
isinstance(catalog_name, str) and isinstance(catalog_version, str)
for catalog_name, catalog_version in catalog.items()
):
raise ValueError(
f"Expected catalog to contain string dependencies in {workspace_manifest_path}"
)
if not isinstance(catalogs, dict) or not all(
isinstance(catalog_name, str)
and isinstance(named_catalog, dict)
and all(
isinstance(dependency_name, str) and isinstance(dependency_version, str)
for dependency_name, dependency_version in named_catalog.items()
)
for catalog_name, named_catalog in catalogs.items()
):
raise ValueError(
f"Expected catalogs to contain named string dependencies in {workspace_manifest_path}"
)
dependency_catalogs[workspace_manifest_path] = (catalog, catalogs)

catalog, catalogs = dependency_catalogs[workspace_manifest_path]
catalog_reference = version.removeprefix("catalog:")
if catalog_reference in {"", "*"}:
selected_catalog = catalog
else:
named_catalog = catalogs.get(catalog_reference)
if named_catalog is None:
raise ValueError(f"Catalog {catalog_reference} is missing from {workspace_manifest_path}")
selected_catalog = named_catalog

resolved_version = selected_catalog.get(name)
if resolved_version is None:
raise ValueError(f"Catalog dependency {name} is missing from {workspace_manifest_path}")
version = resolved_version
runtime_dependencies[name] = version

if runtime_dependencies:
Expand Down
8 changes: 4 additions & 4 deletions products/tasks/backend/logic/services/modal_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,6 @@
from products.tasks.backend.logic.services.agentsh import (
AGENTSH_DAEMON_PORT,
BASH_ENV_SCRIPT,
ENV_FILE,
ENV_WRAPPER_SCRIPT,
SESSION_ID_FILE,
_hostname_from_url,
Expand Down Expand Up @@ -1000,14 +999,15 @@ def _build_agent_server_command(
server_cmd = f"bash -c {shlex.quote(wait_for_repo)}"

inner = f"cd /scripts && {server_cmd} > /tmp/agent-server.log 2>&1"
initialize_env_file = f"bash {shlex.quote(BASH_ENV_SCRIPT)}"

if allowed_domains is not None:
return (
f"cd /scripts && env -0 > {ENV_FILE} && "
f"{build_exec_prefix()} {ENV_WRAPPER_SCRIPT} bash -c {shlex.quote(inner)} &"
f"cd /scripts && {initialize_env_file} && "
f"({build_exec_prefix()} {ENV_WRAPPER_SCRIPT} bash -c {shlex.quote(inner)} &)"
)
else:
return f"cd /scripts && env -0 > {ENV_FILE} && nohup {server_cmd} > /tmp/agent-server.log 2>&1 &"
return f"cd /scripts && {initialize_env_file} && (nohup {server_cmd} > /tmp/agent-server.log 2>&1 &)"

def _diagnose_startup_failure(self, allowed_domains: list[str] | None) -> dict[str, str]:
diagnostics: dict[str, str] = {}
Expand Down
36 changes: 36 additions & 0 deletions products/tasks/backend/logic/services/tests/test_local_packages.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
from products.tasks.backend.logic.services.local_packages import (
BUILD_OUTPUT_SUBDIR,
PACKAGE_NAMES,
LocalPackage,
get_local_package_runtime_dependencies,
get_local_posthog_code_packages,
)

Expand Down Expand Up @@ -67,3 +69,37 @@ def test_returns_packages_when_everything_present(self, fake_monorepo: Path) ->
assert packages[0].sandbox_install_path == "/scripts/node_modules/@posthog/agent"
assert packages[0].sandbox_build_output_path == "/scripts/node_modules/@posthog/agent/dist"
assert packages[0].build_output_path == fake_monorepo / "packages" / "agent" / "dist"


def test_resolves_default_catalog_runtime_dependencies(fake_monorepo: Path) -> None:
(fake_monorepo / "pnpm-workspace.yaml").write_text(
"catalog:\n"
" catalog-runtime: 1.2.3\n"
" star-catalog-runtime: 2.3.4\n"
"catalogs:\n"
" build:\n"
" named-catalog-runtime: 3.4.5\n"
)
agent_source_path = fake_monorepo / "packages" / "agent"
(agent_source_path / "package.json").write_text(
'{"dependencies":{'
'"catalog-runtime":"catalog:",'
'"star-catalog-runtime":"catalog:*",'
'"named-catalog-runtime":"catalog:build",'
'"registry-runtime":"^4.5.6"'
"}}"
)
package = LocalPackage(
name="agent",
source_path=agent_source_path,
sandbox_install_path="/scripts/node_modules/@posthog/agent",
)

assert get_local_package_runtime_dependencies((package,)) == {
"agent": {
"catalog-runtime": "1.2.3",
"named-catalog-runtime": "3.4.5",
"registry-runtime": "^4.5.6",
"star-catalog-runtime": "2.3.4",
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -487,7 +487,7 @@ def test_start_agent_server_wraps_with_agentsh_when_domains_provided(self, mock_
command = _agent_server_launch_command(mock_sandbox.execute)
assert "--createPr true" in command
assert "agentsh exec --client-timeout 2h --timeout 2h" in command
assert "env -0 > /tmp/agent-env" in command
assert "bash /tmp/agentsh-bash-env.sh" in command
assert "/tmp/agentsh-env-wrapper.sh" in command
assert "./node_modules/.bin/agent-server" in command

Expand All @@ -509,7 +509,7 @@ def test_start_agent_server_wraps_with_agentsh_when_domains_empty(self, mock_san
command = _agent_server_launch_command(mock_sandbox.execute)
assert "--allowedDomains" not in command
assert "agentsh exec --client-timeout 2h --timeout 2h" in command
assert "env -0 > /tmp/agent-env" in command
assert "bash /tmp/agentsh-bash-env.sh" in command

@pytest.mark.parametrize(
("create_pr", "expected_flag"),
Expand Down
Loading
Loading