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
9 changes: 7 additions & 2 deletions platform/cluster/flux/apps/agents/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,17 @@ via `nodeSelector: personal-stack/node: enschede-gtx-960m-1`. RWO
permits multiple Pods on that single node to mount the volume
simultaneously, so concurrent per-workspace runners + the long-lived
bootstrap Pod + the refresh-ping CronJob still work, as long as they
all land on the same node. The orchestrator default and every
all land on the same node. The runner Pods also require
`personal-stack/capability-docker-socket=true` because they mount
that node's `/var/run/docker.sock` for Docker CLI and Testcontainers
workflows. That mount is host-equivalent access; the namespace
enforces Pod Security `privileged` explicitly because `baseline`
forbids hostPath sockets. The orchestrator default and every
manifest in this directory hard-codes that node; moving the agent
host means flipping the selector in five places (the credential
PVCs, the auth-bootstrap Pod, the refresh-ping and kb-install
CronJobs, and the `AGENT_RUNTIME_NODE` env on the assistant-api
Deployment).
Deployment), plus the Docker socket gid and runner egress node IP.

## First-time auth bootstrap

Expand Down
11 changes: 10 additions & 1 deletion platform/cluster/flux/apps/agents/namespace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,19 @@
# NetworkPolicy uses the `app.kubernetes.io/part-of: agent-runner`
# label to segregate runner egress from the bootstrap Pod's broader
# allowance.
#
# Runner Pods intentionally mount /var/run/docker.sock so future
# agent sessions can run Docker CLI commands and JVM Testcontainers
# suites. That hostPath is host-equivalent access and violates the
# baseline Pod Security profile, so this namespace enforces
# `privileged` explicitly instead of failing admission by accident.
# The compensating controls are node capability pinning, no projected
# runner ServiceAccount token, and the runner NetworkPolicy below.
apiVersion: v1
kind: Namespace
metadata:
name: agents-system
labels:
pod-security.kubernetes.io/enforce: baseline
pod-security.kubernetes.io/enforce: privileged
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,19 @@ spec:
ports:
- protocol: TCP
port: 8082
# Testcontainers callback ports. The runner talks to the host Docker
# daemon through /var/run/docker.sock; containers started by that daemon
# expose random high ports on the node IP advertised through
# status.hostIP/TESTCONTAINERS_HOST_OVERRIDE. Keep this to the one
# Docker-socket-capable node from platform/inventory/fleet.yaml rather
# than reopening private RFC1918 egress generally.
- to:
- ipBlock:
cidr: 100.89.41.92/32
ports:
- protocol: TCP
port: 32768
endPort: 65535
# Outbound HTTPS — needed for Anthropic, OpenAI, GitHub. Re-tighten
# once an L7 gateway lands.
- to:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,9 @@ spec:
# Runner Pods must co-locate with the credential PVCs.
- name: AGENT_RUNTIME_NODE
value: enschede-gtx-960m-1
# Must match users.groups.docker.gid on the selected Nix host.
- name: AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS
value: '131'
# Read-only GitHub token for the deploy-key branch-protection
# check. Sourced from the github-api-token Secret (key
# `token`), which a VaultStaticSecret projects from Vault —
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@ spec:
# Runner Pods must co-locate with the credential PVCs.
- name: AGENT_RUNTIME_NODE
value: enschede-gtx-960m-1
# Must match users.groups.docker.gid on the selected Nix host.
- name: AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS
value: '131'
# Read-only GitHub token for the deploy-key branch-protection
# check. Sourced from the github-api-token Secret (key
# `token`), which a VaultStaticSecret projects from Vault —
Expand Down
4 changes: 4 additions & 0 deletions platform/inventory/fleet.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,10 @@ nodes:
- tailscale
- lan-ingress
- game-streaming
# agent-runner Pods mount this node's /var/run/docker.sock for
# Docker/Testcontainers. The matching k3s label is required by
# assistant-api before scheduling host-equivalent runner Pods here.
- docker-socket
- nvidia

enschede-t1000-1:
Expand Down
17 changes: 17 additions & 0 deletions platform/nix/hosts/enschede-gtx-960m-1/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"personal-stack/capability-tailscale" = "true";
"personal-stack/capability-lan-ingress" = "true";
"personal-stack/capability-game-streaming" = "true";
"personal-stack/capability-docker-socket" = "true";
# capability-samba deliberately absent: the media drive (/srv/media)
# is mounted on enschede-t1000-1, not here.
# capability-adguard deliberately absent: AdGuard runs only on
Expand All @@ -35,5 +36,21 @@
"personal-stack/gpu-model-gtx960m" = "true";
"personal-stack/gpu-class-transcode" = "true";
};
# Keep Testcontainers' status.hostIP callback stable and aligned with the
# runner NetworkPolicy's single allowed node IP.
services.k3s.extraFlags = [ "--node-ip=100.89.41.92" ];
# Testcontainers maps container ports onto random high ports on the host
# Docker daemon. Runner Pods reach those through status.hostIP, so allow
# callbacks from the pod bridge only; do not expose the range on LAN/tailnet.
networking.firewall.interfaces."cni0".allowedTCPPortRanges = [
{
from = 32768;
to = 65535;
}
];
# Docker's socket group must be a repo-declared value, not a guessed
# distro default. assistant-api injects the same gid into runner Pods via
# AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS.
users.groups.docker.gid = 131;
system.stateVersion = "25.05";
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,82 @@ class PlatformAgentMcpFluxTest {
.contains("port: 8082")
}

@Test
fun `agents namespace explicitly admits docker socket runner pods`() {
val namespace =
repositoryRoot
.resolve("platform/cluster/flux/apps/agents/namespace.yaml")
.toFile()
.readText()

assertThat(namespace)
.contains("mount /var/run/docker.sock")
.contains("host-equivalent access")
.contains("pod-security.kubernetes.io/enforce: privileged")
.contains("pod-security.kubernetes.io/audit: restricted")
.contains("pod-security.kubernetes.io/warn: restricted")
}

@Test
fun `runner egress allows only docker node high ports for testcontainers callbacks`() {
val manifest =
repositoryRoot
.resolve("platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml")
.toFile()
.readText()

assertThat(manifest)
.contains("TESTCONTAINERS_HOST_OVERRIDE")
.contains("cidr: 100.89.41.92/32")
.contains("port: 32768")
.contains("endPort: 65535")
}

@Test
fun `agent runner docker socket node capability and gid are pinned in platform config`() {
val inventory = repositoryRoot.resolve("platform/inventory/fleet.yaml").toFile().readText()
val host =
repositoryRoot
.resolve("platform/nix/hosts/enschede-gtx-960m-1/default.nix")
.toFile()
.readText()
val appConfig =
repositoryRoot
.resolve("services/assistant-api/src/main/resources/application.yml")
.toFile()
.readText()
val assistantApiDeployment =
repositoryRoot
.resolve("platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml")
.toFile()
.readText()
val assistantApiWsDeployment =
repositoryRoot
.resolve("platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml")
.toFile()
.readText()

assertThat(inventory).contains("- docker-socket")
assertThat(host)
.contains("\"personal-stack/capability-docker-socket\" = \"true\"")
.contains("--node-ip=100.89.41.92")
.contains("networking.firewall.interfaces.\"cni0\".allowedTCPPortRanges")
.contains("to = 65535;")
.contains("users.groups.docker.gid = 131;")
assertThat(appConfig)
.contains("docker-socket-supplemental-groups: \${AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS:131}")
.contains(
"'[personal-stack/capability-docker-socket]': " +
"\${AGENT_RUNTIME_DOCKER_SOCKET_CAPABILITY:true}",
)
assertThat(assistantApiDeployment)
.contains("name: AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS")
.contains("value: '131'")
assertThat(assistantApiWsDeployment)
.contains("name: AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS")
.contains("value: '131'")
}

@Test
fun `agent runner wires github app token into gh github mcp and git push`() {
val dockerfile =
Expand Down Expand Up @@ -73,6 +149,11 @@ class PlatformAgentMcpFluxTest {
.readText()

assertThat(dockerfile)
.contains("docker-ce-cli")
.contains("docker-buildx-plugin")
.contains("docker-compose-plugin")
.contains("docker --version >/dev/null")
.contains("docker compose version >/dev/null")
.contains("agent-github-token.sh /usr/local/bin/agent-github-token")
.contains("gh-app-token-wrapper.sh /usr/local/bin/gh")
.contains("git-credential-agent-gh-app.sh /usr/local/bin/git-credential-agent-gh-app")
Expand All @@ -91,6 +172,8 @@ class PlatformAgentMcpFluxTest {
// Multi-repo: clone every REPO_URLS entry and bound the credential
// helper to the workspace's repos via REPO_ALLOW.
.contains("REPO_URLS")
.contains("clone_repo_into_workspace \"${'$'}REPO_URL\"")
.contains("_repo_target=\"${'$'}{WORKSPACE_ROOT}/${'$'}{_repo_name}\"")
.contains("export REPO_ALLOW")
.contains("register_repo_trust")

Expand Down Expand Up @@ -152,6 +235,12 @@ class PlatformAgentMcpFluxTest {
assertThat(orchestrator)
.contains("withName(\"AGENT_MCP_PROFILE\")")
.contains("props.defaultMcpProfile")
.contains("DOCKER_HOST")
.contains("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE")
.contains("TESTCONTAINERS_HOST_OVERRIDE")
.contains("withNewHostPath()")
.contains("withType(\"Socket\")")
.contains("withSupplementalGroups(podSupplementalGroups())")
assertThat(entrypoint)
.contains("AGENT_MCP_PROFILE=\"\${AGENT_MCP_PROFILE:-minimal}\"")
.contains("claude-mcp-servers.\${AGENT_MCP_PROFILE}.json")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.jorisjonkers.personalstack.agentgateway.config

import org.springframework.boot.context.properties.ConfigurationProperties

private const val DEFAULT_LOG_CAP_BYTES = 8L * 1024 * 1024
private const val DEFAULT_STAGED_INPUT_MAX_BYTES = 5L * 1024 * 1024

@ConfigurationProperties(prefix = "agent-gateway")
data class GatewayProperties(
val workspaceRoot: String,
Expand All @@ -19,17 +22,18 @@ data class GatewayProperties(
// The pipe-pane log is only a streaming conduit (the live screen
// lives in tmux, scrollback in the browser), so it is truncated
// once it outgrows this cap to keep the runner disk bounded.
val logCapBytes: Long = 8L * 1024 * 1024,
val logCapBytes: Long = DEFAULT_LOG_CAP_BYTES,
val logTrimIntervalSeconds: Long = 30,
)

data class Cli(
val claude: String,
val codex: String,
// The runner container is the sandbox (unprivileged user, only a
// git deploy key, no host access), so the CLIs launch with every
// approval/permission/sandbox prompt bypassed. Kept as config so a
// flag rename upstream is a redeploy-free value flip.
// The runner Pod is the outer sandbox. Docker-socket-enabled runners
// are host-equivalent by design for Docker/Testcontainers, while the
// CLIs still launch with every approval/permission/sandbox prompt
// bypassed. Kept as config so a flag rename upstream is a
// redeploy-free value flip.
val claudeArgs: List<String> = emptyList(),
val codexArgs: List<String> = emptyList(),
)
Expand All @@ -40,6 +44,6 @@ data class GatewayProperties(

data class StagedInputs(
val dirName: String = ".agent-inputs",
val maxBytes: Long = 5L * 1024 * 1024,
val maxBytes: Long = DEFAULT_STAGED_INPUT_MAX_BYTES,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ import kotlin.io.path.Path
* Wraps git + gh for the runner. The deploy key lives at
* `agent-gateway.git.deploy-key-dir/private_key`; this client
* materialises it into a private file with 0600 and exports
* GIT_SSH_COMMAND so every clone/push uses it without polluting
* ~/.ssh.
* GIT_SSH_COMMAND so SSH operations can use it without polluting
* ~/.ssh. Runner boot also rewrites GitHub SSH remotes to HTTPS,
* so live clones carry a one-repo App-token allow-list for the
* credential helper.
*
* `gh` is used for the PR open step because issuing a PAT for the
* agent would be a wider blast radius than the per-repo deploy key
Expand All @@ -39,6 +41,10 @@ class GitClient(
intoDir: String,
branch: String? = null,
): String {
val target = Path(intoDir)
if (Files.isDirectory(target.resolve(".git"))) {
return intoDir
}
val argv =
mutableListOf("git", "clone", "--depth", "50").apply {
if (branch != null) {
Expand All @@ -48,7 +54,7 @@ class GitClient(
add(repoUrl)
add(intoDir)
}
runner.run(argv, env = sshEnv(), timeoutSeconds = 300)
runner.run(argv, env = cloneEnv(repoUrl), timeoutSeconds = 300)
return intoDir
}

Expand Down Expand Up @@ -213,6 +219,24 @@ class GitClient(
return mapOf("GIT_SSH_COMMAND" to sshOpts)
}

private fun cloneEnv(repoUrl: String): Map<String, String> =
sshEnv().toMutableMap().also { env ->
env["AGENT_GITHUB_REPO_URL"] = repoUrl
githubSlug(repoUrl)?.let { env["REPO_ALLOW"] = it }
}

private fun githubSlug(repoUrl: String): String? {
val normalized =
when {
repoUrl.startsWith("git@github.com:") -> repoUrl.removePrefix("git@github.com:")
repoUrl.startsWith("ssh://git@github.com/") -> repoUrl.removePrefix("ssh://git@github.com/")
repoUrl.startsWith("https://github.com/") -> repoUrl.removePrefix("https://github.com/")
repoUrl.startsWith("http://github.com/") -> repoUrl.removePrefix("http://github.com/")
else -> return null
}.removeSuffix(".git")
return normalized.takeIf { it.contains('/') }
}

private fun ghEnv(): Map<String, String> {
val token = System.getenv("GH_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: ""
return mapOf("GH_TOKEN" to token)
Expand Down
Loading
Loading