Let pushes go by an HTTPS token or an SSH deploy key
CI / checks (push) Skipped
CI / checks (pull_request) Failing after 56s

The push credential is either a push-only account's token, handed to git
through a credential helper that names environment variables, never
values, or a deploy key in a short-lived ssh-agent. The remote URL
follows the credential. Configured as gitea.push_user and push_token, or
gitea.push_key.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
This commit is contained in:
dongarandClaude Opus 5.5 committed 2026-10-02 21:59:56 +00:00
1 parent 761ebdb857
commit c931ac939b
15 files changed
+241 -81

No files matched your search

+25 -10
View File
@@ -21,7 +21,7 @@ from nisse_dev.delegation import Caps, Delegation
from nisse_dev.execution import SSHHost
from nisse_dev.executor import LLMExecutor
from nisse_dev.expert import ExpertDesk
from nisse_dev.git import GitRepo
from nisse_dev.git import GitRepo, HTTPSToken, PushCredential, SSHKey
from nisse_dev.gitea import Comment, GiteaClient
from nisse_dev.llm import AnthropicPassthroughClient, LiteLLMClient
from nisse_dev.matrix import MatrixTransport
@@ -177,30 +177,45 @@ def build_host(config: AgentConfig) -> SSHHost:
)
def read_push_key(config: AgentConfig) -> str | None:
def push_credential(config: AgentConfig) -> PushCredential | None:
"""What pushes go by: a push-only account's token over HTTPS, or a
deploy key over SSH (D4)."""
if config.push_user and config.push_token:
return HTTPSToken(config.push_user, config.push_token)
if config.push_key is None:
return None
try:
return config.push_key.read_text()
return SSHKey(config.push_key.read_text())
except OSError as exc:
raise RuntimeError(
f"cannot read the push key {config.push_key}: {exc}"
) from exc
def remote_url(gitea: GiteaClient, repo: str, credential: PushCredential | None) -> str:
"""The credential-free URL the workspace clones from and pushes to."""
if isinstance(credential, SSHKey):
return gitea.ssh_url(repo)
return gitea.clone_url(repo)
def prepare_workspace(
host: SSHHost, config: AgentConfig, account: str, remote: str, key: str | None
host: SSHHost,
config: AgentConfig,
account: str,
remote: str,
credential: PushCredential | None,
) -> GitRepo:
"""Ensure the workspace on the build VM is a clone of the project repo.
The clone uses the repository's SSH URL, so no credential is ever stored
in it (#89); the push key is handed to the clone and nothing else.
in it (#89); the push credential is handed to the clone and nothing else.
Cloning here rather than when a work item starts is the single-project
shortcut that M1.4 and D3's fresh workspace replace.
"""
repo = GitRepo(host, config.workspace)
if not repo.exists():
repo.clone(remote, key=key)
repo.clone(remote, credential=credential)
repo.configure_identity(account, f"{account}@users.noreply.local")
return repo
@@ -229,9 +244,9 @@ def build_agent(
account = gitea.whoami()
host = build_host(config)
push_key = read_push_key(config)
remote = gitea.ssh_url(config.gitea_repo)
git_repo = prepare_workspace(host, config, account, remote, push_key)
credential = push_credential(config)
remote = remote_url(gitea, config.gitea_repo, credential)
git_repo = prepare_workspace(host, config, account, remote, credential)
tuning = config.tuning
workspace = Workspace(
host,
@@ -278,7 +293,7 @@ def build_agent(
config.gitea_repo,
git_repo,
remote_url=remote,
push_key=push_key,
push_credential=credential,
base_branch=config.base_branch,
)
+15 -2
View File
@@ -55,6 +55,8 @@ OPTIONAL_KEYS = (
"llm.request_timeout_s",
"gitea.base_branch",
"gitea.push_key",
"gitea.push_user",
"gitea.push_token",
"build_host.user",
"build_host.port",
)
@@ -241,9 +243,12 @@ class AgentConfig:
gitea_operators: frozenset[str]
build_host: BuildHost
base_branch: str = "main"
# The key that may push to the project repository, handed to each push on
# the build VM and to nothing else (D4).
# What may push to the project repository, handed to each push on the
# build VM and to nothing else (D4): a push-only account's HTTPS token
# (push_user, push_token), or the path to a deploy key (push_key).
push_key: Path | None = None
push_user: str | None = None
push_token: str | None = None
max_output_tokens: int = DEFAULT_MAX_OUTPUT_TOKENS
request_timeout_s: float = DEFAULT_REQUEST_TIMEOUT_S
tuning: Tuning = field(default_factory=Tuning)
@@ -331,6 +336,12 @@ class AgentConfig:
raise ValueError
except (TypeError, ValueError):
errors.append("build_host.port: must be a TCP port number")
push_user = values.get("gitea.push_user")
push_token = values.get("gitea.push_token")
if bool(push_user) != bool(push_token):
errors.append("gitea.push_user and gitea.push_token: set both or neither")
if push_token and values.get("gitea.push_key"):
errors.append("gitea: set a push token or a push key, not both")
if errors:
raise ConfigError("cannot start: " + "; ".join(errors))
@@ -365,6 +376,8 @@ class AgentConfig:
port=port,
),
push_key=Path(push_key).expanduser() if push_key else None,
push_user=str(push_user) if push_user else None,
push_token=str(push_token) if push_token else None,
base_branch=str(values.get("gitea.base_branch") or "main"),
max_output_tokens=max_output,
request_timeout_s=request_timeout,
+78 -15
View File
@@ -3,9 +3,15 @@
Every command runs on the build VM over SSH. Remotes are plain URLs with no
credential in them, so nothing secret is ever written to the clone's
configuration (#89). Operations that reach the remote (clone, fetch, push,
ls-remote) are handed the push key for that one operation: it is fed over
stdin to a short-lived ``ssh-agent`` on the VM and gone when the command
ends. The model's commands never receive it.
ls-remote) are handed the push credential for that one operation, and the
model's commands never receive it:
- an HTTPS token (the default: HTTPS reaches the forge from everywhere) goes
to git through a credential helper that names environment variables, never
values; the values are that one command's secrets, so they are never on a
command line and are redacted from its output;
- an SSH key is fed over stdin to a short-lived ``ssh-agent`` on the VM, gone
when the command ends.
Pull requests are opened from the agent's own host with its Gitea API token
(publish.py), which never reaches the VM.
@@ -27,6 +33,31 @@ class GitError(RuntimeError):
pass
@dataclass(frozen=True)
class HTTPSToken:
"""A push-only account's token, for HTTPS remotes (D4)."""
username: str
token: str
@dataclass(frozen=True)
class SSHKey:
"""A deploy key's private half, for SSH remotes (D4)."""
private_key: str
PushCredential = HTTPSToken | SSHKey
# git asks the helper for credentials; it answers from the environment, so
# the command line names variables, never values.
_HELPER = (
'!f() { test "$1" = get || return 0; '
'echo "username=$NISSE_PUSH_USER"; echo "password=$NISSE_PUSH_TOKEN"; }; f'
)
def _with_key(command: str, known_hosts: str | None) -> str:
"""Run ``command`` with the key on stdin loaded into a throwaway agent."""
git_ssh = _GIT_SSH
@@ -48,13 +79,33 @@ class GitRepo:
# the VM user's own known_hosts.
known_hosts: str | None = None
def _git(self, *args: str, key: str | None = None, cwd: str | None = None) -> str:
command = "git " + " ".join(shlex.quote(a) for a in args)
def _git(
self,
*args: str,
credential: PushCredential | None = None,
cwd: str | None = None,
) -> str:
quoted = " ".join(shlex.quote(a) for a in args)
command, stdin, secrets = f"git {quoted}", None, {}
if isinstance(credential, SSHKey):
command = _with_key(command, self.known_hosts)
stdin = credential.private_key
elif isinstance(credential, HTTPSToken):
# The empty helper first clears any configured on the VM.
command = (
"GIT_TERMINAL_PROMPT=0 git -c credential.helper= "
f"-c credential.helper={shlex.quote(_HELPER)} {quoted}"
)
secrets = {
"NISSE_PUSH_USER": credential.username,
"NISSE_PUSH_TOKEN": credential.token,
}
try:
result = self.host.execute(
_with_key(command, self.known_hosts) if key else command,
command,
cwd=cwd or str(self.root),
stdin=key,
stdin=stdin,
secrets=secrets,
timeout_s=TIMEOUT_S,
)
except HostUnavailable as exc:
@@ -87,11 +138,13 @@ class GitRepo:
return int(self._git("rev-list", "--count", f"{ref}..HEAD"))
def remote_sha(
self, remote_url: str, branch: str, *, key: str | None = None
self, remote_url: str, branch: str, *, credential: PushCredential | None = None
) -> str | None:
"""What the remote's branch points at, or None if it has no such
branch. Pushes go by URL, so there is no tracking ref to read."""
out = self._git("ls-remote", remote_url, f"refs/heads/{branch}", key=key)
out = self._git(
"ls-remote", remote_url, f"refs/heads/{branch}", credential=credential
)
return out.split()[0] if out else None
def exists(self) -> bool:
@@ -110,12 +163,16 @@ class GitRepo:
self.host.execute(f"mkdir -p -- {shlex.quote(str(self.root))}")
self._git("init", "-q", "-b", default_branch)
def clone(self, remote_url: str, *, key: str | None = None) -> None:
def clone(
self, remote_url: str, *, credential: PushCredential | None = None
) -> None:
"""Clone into the workspace root. The remote URL carries no
credential, so none is stored in the clone's configuration."""
parent = str(self.root.parent)
self.host.execute(f"mkdir -p -- {shlex.quote(parent)}")
self._git("clone", "-q", remote_url, str(self.root), key=key, cwd=parent)
self._git(
"clone", "-q", remote_url, str(self.root), credential=credential, cwd=parent
)
def configure_identity(self, name: str, email: str) -> None:
self._git("config", "user.name", name)
@@ -143,10 +200,14 @@ class GitRepo:
self._git("commit", "-q", "-m", message)
return self.head_sha()
def push(self, remote_url: str, branch: str, *, key: str | None = None) -> None:
def push(
self, remote_url: str, branch: str, *, credential: PushCredential | None = None
) -> None:
# By URL, per invocation, rather than a configured remote with a
# credential in it.
self._git("push", "-q", remote_url, f"HEAD:refs/heads/{branch}", key=key)
self._git(
"push", "-q", remote_url, f"HEAD:refs/heads/{branch}", credential=credential
)
def discard_branch(self, branch: str, *, back_to: str) -> None:
"""Throw away a branch and everything on it.
@@ -159,6 +220,8 @@ class GitRepo:
self._git("branch", "-q", "-D", branch)
def delete_remote_branch(
self, remote_url: str, branch: str, *, key: str | None = None
self, remote_url: str, branch: str, *, credential: PushCredential | None = None
) -> None:
self._git("push", "-q", remote_url, f":refs/heads/{branch}", key=key)
self._git(
"push", "-q", remote_url, f":refs/heads/{branch}", credential=credential
)
+4
View File
@@ -74,6 +74,10 @@ class GiteaClient:
"""The repository's SSH clone URL, which carries no credential."""
return str(self._request("GET", f"/repos/{repo}")["ssh_url"])
def clone_url(self, repo: str) -> str:
"""The repository's HTTPS clone URL, which carries no credential."""
return str(self._request("GET", f"/repos/{repo}")["clone_url"])
def whoami(self) -> str:
return str(self._request("GET", "/user")["login"])
+10 -8
View File
@@ -9,7 +9,7 @@ an idempotent transition reconciled against external truth.
from dataclasses import dataclass
from nisse_dev.git import GitRepo
from nisse_dev.git import GitRepo, PushCredential
from nisse_dev.gitea import (
GiteaClient,
GiteaError,
@@ -37,7 +37,7 @@ class MilestonePublisher:
git_repo: GitRepo,
*,
remote_url: str,
push_key: str | None = None,
push_credential: PushCredential | None = None,
base_branch: str = "main",
) -> None:
self._store = store
@@ -45,10 +45,10 @@ class MilestonePublisher:
self._repo = repo
self._git = git_repo
self._base_branch = base_branch
# A URL with no credential in it; the push key is handed to each push
# on the build VM and nothing else (D4, L12).
# A URL with no credential in it; the push credential is handed to
# each push on the build VM and nothing else (D4, L12).
self._remote = remote_url
self._key = push_key
self._credential = push_credential
def reconcilers(self) -> dict[str, Reconciler]:
return {OPEN_PR_INTENT: pull_request_reconciler(self._client, self._repo)}
@@ -85,7 +85,7 @@ class MilestonePublisher:
cause=f"publishing milestone {milestone}",
work_item=task_id, # owned by the work item it publishes (L1)
)
self._git.push(self._remote, branch, key=self._key)
self._git.push(self._remote, branch, credential=self._credential)
# Idempotent: if a PR for this branch is already open (a crash after
# opening it, or a response lost on the way back), the push above
# has updated it; adopt it rather than fail on a second.
@@ -150,10 +150,12 @@ class MilestonePublisher:
if sha is None:
# The executor may have committed the fix itself.
sha = self._git.head_sha()
pushed = self._git.remote_sha(self._remote, branch, key=self._key)
pushed = self._git.remote_sha(
self._remote, branch, credential=self._credential
)
if sha == pushed:
return None
self._git.push(self._remote, branch, key=self._key)
self._git.push(self._remote, branch, credential=self._credential)
return sha