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
57 changes: 52 additions & 5 deletions components/runners/ambient-runner/ambient_runner/platform/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@
"/workspace/.google_workspace_mcp/credentials/credentials.json"
)

# Token files written on every credential refresh so the git credential helper
# can read the latest token even after the CLI subprocess has already been spawned.
# The helper runs inside the CLI subprocess's environment (which is fixed at spawn
# time), so updating os.environ mid-run would not reach it without these files.
_GITHUB_TOKEN_FILE = Path("/tmp/.ambient_github_token")
_GITLAB_TOKEN_FILE = Path("/tmp/.ambient_gitlab_token")


# ---------------------------------------------------------------------------
# Vertex AI credential validation (shared across all bridges)
Expand Down Expand Up @@ -364,6 +371,13 @@ async def populate_runtime_credentials(context: RunnerContext) -> None:
auth_failures.append(str(gitlab_creds))
elif gitlab_creds.get("token"):
os.environ["GITLAB_TOKEN"] = gitlab_creds["token"]
# Also write to file so the git credential helper picks up mid-run
# refreshes even after the CLI subprocess has been spawned.
try:
_GITLAB_TOKEN_FILE.write_text(gitlab_creds["token"])
_GITLAB_TOKEN_FILE.chmod(0o600)
except OSError as e:
logger.warning(f"Failed to write GitLab token file: {e}")
logger.info("Updated GitLab token in environment")
if gitlab_creds.get("userName"):
git_user_name = gitlab_creds["userName"]
Expand All @@ -377,6 +391,13 @@ async def populate_runtime_credentials(context: RunnerContext) -> None:
auth_failures.append(str(github_creds))
elif github_creds.get("token"):
os.environ["GITHUB_TOKEN"] = github_creds["token"]
# Also write to file so the git credential helper picks up mid-run
# refreshes even after the CLI subprocess has been spawned.
try:
_GITHUB_TOKEN_FILE.write_text(github_creds["token"])
_GITHUB_TOKEN_FILE.chmod(0o600)
except OSError as e:
logger.warning(f"Failed to write GitHub token file: {e}")
logger.info("Updated GitHub token in environment")
if github_creds.get("userName"):
git_user_name = github_creds["userName"]
Expand Down Expand Up @@ -425,6 +446,14 @@ def clear_runtime_credentials() -> None:
os.environ.pop(key, None)
cleared.append(key)

# Remove token files used by the git credential helper.
for token_file in (_GITHUB_TOKEN_FILE, _GITLAB_TOKEN_FILE):
try:
token_file.unlink(missing_ok=True)
cleared.append(token_file.name)
except OSError as e:
logger.warning(f"Failed to remove token file {token_file}: {e}")

# Remove Google Workspace credential file if present (uses same hardcoded path as populate_runtime_credentials)
google_cred_file = _GOOGLE_WORKSPACE_CREDS_FILE
if google_cred_file.exists():
Expand Down Expand Up @@ -511,6 +540,10 @@ async def populate_mcp_server_credentials(context: RunnerContext) -> None:
# time, so refreshes are picked up without mutating .git/config.
_GIT_CREDENTIAL_HELPER_SCRIPT = """\
#!/bin/sh
# Ambient git credential helper.
# Reads tokens from files first so mid-run MCP refreshes are picked up even
# after the CLI subprocess was already spawned (subprocess env is fixed at
# creation time; the files are updated by the runner on every refresh).
case "$1" in
get)
while IFS='=' read -r key value; do
Expand All @@ -521,21 +554,35 @@ async def populate_mcp_server_credentials(context: RunnerContext) -> None:

case "$HOST" in
*github*)
if [ -n "$GITHUB_TOKEN" ]; then
printf 'protocol=https\\nhost=%s\\nusername=x-access-token\\npassword=%s\\n' "$HOST" "$GITHUB_TOKEN"
token=""
if [ -f "/tmp/.ambient_github_token" ]; then
token=$(cat /tmp/.ambient_github_token 2>/dev/null)
fi
if [ -z "$token" ]; then
token="$GITHUB_TOKEN"
fi
if [ -n "$token" ]; then
printf 'protocol=https\\nhost=%s\\nusername=x-access-token\\npassword=%s\\n' "$HOST" "$token"
fi
;;
*gitlab*)
if [ -n "$GITLAB_TOKEN" ]; then
printf 'protocol=https\\nhost=%s\\nusername=oauth2\\npassword=%s\\n' "$HOST" "$GITLAB_TOKEN"
token=""
if [ -f "/tmp/.ambient_gitlab_token" ]; then
token=$(cat /tmp/.ambient_gitlab_token 2>/dev/null)
fi
if [ -z "$token" ]; then
token="$GITLAB_TOKEN"
fi
if [ -n "$token" ]; then
printf 'protocol=https\\nhost=%s\\nusername=oauth2\\npassword=%s\\n' "$HOST" "$token"
fi
;;
esac
;;
esac
"""

_credential_helper_installed = False
_credential_helper_installed = False # reset on every new process / deployment


def install_git_credential_helper() -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
import pytest

from ambient_runner.platform.auth import (
_GITHUB_TOKEN_FILE,
_GITLAB_TOKEN_FILE,
_fetch_credential,
clear_runtime_credentials,
populate_runtime_credentials,
Expand Down Expand Up @@ -151,6 +153,118 @@ def test_does_not_clear_unrelated_vars(self):
os.environ.pop("GITHUB_TOKEN", None)


# ---------------------------------------------------------------------------
# Token file lifecycle (mid-run refresh support)
# ---------------------------------------------------------------------------


class TestTokenFiles:
"""Token files let the git credential helper pick up mid-run refreshes.

The CLI subprocess is spawned once and its environment is fixed at that
point. Updating os.environ later does not propagate into the subprocess.
Writing tokens to files allows the credential helper (which runs fresh for
every git operation) to always use the latest token.
"""

def _cleanup(self):
"""Remove token files created during tests."""
_GITHUB_TOKEN_FILE.unlink(missing_ok=True)
_GITLAB_TOKEN_FILE.unlink(missing_ok=True)

@pytest.mark.asyncio
async def test_populate_writes_github_token_file(self):
"""populate_runtime_credentials writes GITHUB_TOKEN to the token file."""
self._cleanup()
try:
with patch("ambient_runner.platform.auth._fetch_credential") as mock_fetch:

async def _creds(ctx, ctype):
if ctype == "github":
return {"token": "gh-mid-run-token", "userName": "user", "email": "u@example.com"}
return {}

mock_fetch.side_effect = _creds
ctx = _make_context()
await populate_runtime_credentials(ctx)

assert _GITHUB_TOKEN_FILE.exists()
assert _GITHUB_TOKEN_FILE.read_text() == "gh-mid-run-token"
finally:
self._cleanup()
for key in ["GITHUB_TOKEN", "GIT_USER_NAME", "GIT_USER_EMAIL"]:
os.environ.pop(key, None)

@pytest.mark.asyncio
async def test_populate_writes_gitlab_token_file(self):
"""populate_runtime_credentials writes GITLAB_TOKEN to the token file."""
self._cleanup()
try:
with patch("ambient_runner.platform.auth._fetch_credential") as mock_fetch:

async def _creds(ctx, ctype):
if ctype == "gitlab":
return {"token": "gl-mid-run-token", "userName": "user", "email": "u@example.com"}
return {}

mock_fetch.side_effect = _creds
ctx = _make_context()
await populate_runtime_credentials(ctx)

assert _GITLAB_TOKEN_FILE.exists()
assert _GITLAB_TOKEN_FILE.read_text() == "gl-mid-run-token"
finally:
self._cleanup()
for key in ["GITLAB_TOKEN", "GIT_USER_NAME", "GIT_USER_EMAIL"]:
os.environ.pop(key, None)

def test_clear_removes_token_files(self):
"""clear_runtime_credentials removes the token files written at populate time."""
_GITHUB_TOKEN_FILE.write_text("old-token")
_GITLAB_TOKEN_FILE.write_text("old-gl-token")
try:
clear_runtime_credentials()
assert not _GITHUB_TOKEN_FILE.exists(), "GitHub token file should be removed"
assert not _GITLAB_TOKEN_FILE.exists(), "GitLab token file should be removed"
finally:
self._cleanup()

def test_clear_does_not_crash_when_token_files_absent(self):
"""clear_runtime_credentials succeeds even if the token files don't exist."""
self._cleanup()
# Should not raise
clear_runtime_credentials()

@pytest.mark.asyncio
async def test_second_populate_overwrites_token_file(self):
"""A second populate_runtime_credentials call overwrites the stale token file.

This is the mid-run refresh scenario: the MCP tool calls populate again
with a fresh token and the file must reflect the new value.
"""
self._cleanup()
try:
call_num = [0]

async def _creds(ctx, ctype):
if ctype == "github":
call_num[0] += 1
return {"token": f"gh-token-{call_num[0]}", "userName": "u", "email": "u@e.com"}
return {}

with patch("ambient_runner.platform.auth._fetch_credential", side_effect=_creds):
ctx = _make_context()
await populate_runtime_credentials(ctx)
assert _GITHUB_TOKEN_FILE.read_text() == "gh-token-1"

await populate_runtime_credentials(ctx)
assert _GITHUB_TOKEN_FILE.read_text() == "gh-token-2"
finally:
self._cleanup()
for key in ["GITHUB_TOKEN", "GIT_USER_NAME", "GIT_USER_EMAIL"]:
os.environ.pop(key, None)


# ---------------------------------------------------------------------------
# _fetch_credential — X-Runner-Current-User header
# ---------------------------------------------------------------------------
Expand Down
Loading