diff --git a/platform/cluster/flux/apps/agents/README.md b/platform/cluster/flux/apps/agents/README.md index 129c7ed7..ab927db8 100644 --- a/platform/cluster/flux/apps/agents/README.md +++ b/platform/cluster/flux/apps/agents/README.md @@ -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 diff --git a/platform/cluster/flux/apps/agents/namespace.yaml b/platform/cluster/flux/apps/agents/namespace.yaml index 6a9028c9..6cfed8d6 100644 --- a/platform/cluster/flux/apps/agents/namespace.yaml +++ b/platform/cluster/flux/apps/agents/namespace.yaml @@ -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 diff --git a/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml b/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml index a28e6a23..98e67cf9 100644 --- a/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml +++ b/platform/cluster/flux/apps/agents/network-policy/runner-egress.yaml @@ -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: diff --git a/platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml b/platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml index e41ed3f2..c89c2d5e 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml +++ b/platform/cluster/flux/apps/stateless/assistant-api/deployment-enschede-ws.yaml @@ -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 — diff --git a/platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml b/platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml index 1d428d84..c3685f05 100644 --- a/platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml +++ b/platform/cluster/flux/apps/stateless/assistant-api/deployment.yaml @@ -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 — diff --git a/platform/inventory/fleet.yaml b/platform/inventory/fleet.yaml index 409e85f5..5a7def14 100644 --- a/platform/inventory/fleet.yaml +++ b/platform/inventory/fleet.yaml @@ -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: diff --git a/platform/nix/hosts/enschede-gtx-960m-1/default.nix b/platform/nix/hosts/enschede-gtx-960m-1/default.nix index bb988358..f4c49c22 100644 --- a/platform/nix/hosts/enschede-gtx-960m-1/default.nix +++ b/platform/nix/hosts/enschede-gtx-960m-1/default.nix @@ -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 @@ -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"; } diff --git a/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt b/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt index 1169e9ea..57c82e66 100644 --- a/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt +++ b/platform/tooling/src/test/kotlin/com/jorisjonkers/personalstack/platform/PlatformAgentMcpFluxTest.kt @@ -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 = @@ -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") @@ -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") @@ -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") diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt index cf6f0be1..a158cdae 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt @@ -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, @@ -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 = emptyList(), val codexArgs: List = emptyList(), ) @@ -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, ) } diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt index 545d4391..eebc5381 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClient.kt @@ -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 @@ -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) { @@ -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 } @@ -213,6 +219,24 @@ class GitClient( return mapOf("GIT_SSH_COMMAND" to sshOpts) } + private fun cloneEnv(repoUrl: String): Map = + 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 { val token = System.getenv("GH_TOKEN") ?: System.getenv("GITHUB_TOKEN") ?: "" return mapOf("GH_TOKEN" to token) diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt index 88d35a3f..da89a5d7 100644 --- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt +++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/LogTailer.kt @@ -110,6 +110,21 @@ class LogTailer( companion object { const val MAX_CHUNK_CHARS = 1024 private const val MAX_READ_BYTES = 64 * 1024 + private const val BYTE_TO_UNSIGNED_MASK = 0xFF + private const val NO_UTF8_LEAD_BYTE = -1 + private const val UTF8_CONTINUATION_MASK = 0xC0 + private const val UTF8_CONTINUATION_PREFIX = 0x80 + private const val UTF8_SINGLE_BYTE_LIMIT = 0x80 + private const val UTF8_TWO_BYTE_LEAD_START = 0xC0 + private const val UTF8_TWO_BYTE_LEAD_END = 0xDF + private const val UTF8_THREE_BYTE_LEAD_START = 0xE0 + private const val UTF8_THREE_BYTE_LEAD_END = 0xEF + private const val UTF8_FOUR_BYTE_LEAD_START = 0xF0 + private const val UTF8_FOUR_BYTE_LEAD_END = 0xF7 + private const val UTF8_SINGLE_BYTE_SEQUENCE_LENGTH = 1 + private const val UTF8_TWO_BYTE_SEQUENCE_LENGTH = 2 + private const val UTF8_THREE_BYTE_SEQUENCE_LENGTH = 3 + private const val UTF8_FOUR_BYTE_SEQUENCE_LENGTH = 4 private val EMPTY = ByteArray(0) /** @@ -120,25 +135,35 @@ class LogTailer( */ internal fun completeUtf8Length(buf: ByteArray): Int { if (buf.isEmpty()) return 0 + val leadIndex = trailingLeadByteIndex(buf) + if (leadIndex == NO_UTF8_LEAD_BYTE) return buf.size + val availableSequenceBytes = buf.size - leadIndex + val expectedSequenceBytes = utf8SequenceLength(buf[leadIndex].toUnsignedInt()) + return if (availableSequenceBytes >= expectedSequenceBytes) buf.size else leadIndex + } + + private fun trailingLeadByteIndex(buf: ByteArray): Int { var i = buf.size - 1 - var continuations = 0 - while (i >= 0 && (buf[i].toInt() and 0xC0) == 0x80) { + while (i >= 0 && buf[i].isUtf8Continuation()) { i-- - continuations++ } - if (i < 0) return buf.size - val lead = buf[i].toInt() and 0xFF - val expected = - when { - lead < 0x80 -> 1 - lead in 0xC0..0xDF -> 2 - lead in 0xE0..0xEF -> 3 - lead in 0xF0..0xF7 -> 4 - else -> 1 - } - return if (continuations + 1 >= expected) buf.size else i + return i } + private fun Byte.isUtf8Continuation(): Boolean = + (toInt() and UTF8_CONTINUATION_MASK) == UTF8_CONTINUATION_PREFIX + + private fun Byte.toUnsignedInt(): Int = toInt() and BYTE_TO_UNSIGNED_MASK + + private fun utf8SequenceLength(lead: Int): Int = + when { + lead < UTF8_SINGLE_BYTE_LIMIT -> UTF8_SINGLE_BYTE_SEQUENCE_LENGTH + lead in UTF8_TWO_BYTE_LEAD_START..UTF8_TWO_BYTE_LEAD_END -> UTF8_TWO_BYTE_SEQUENCE_LENGTH + lead in UTF8_THREE_BYTE_LEAD_START..UTF8_THREE_BYTE_LEAD_END -> UTF8_THREE_BYTE_SEQUENCE_LENGTH + lead in UTF8_FOUR_BYTE_LEAD_START..UTF8_FOUR_BYTE_LEAD_END -> UTF8_FOUR_BYTE_SEQUENCE_LENGTH + else -> UTF8_SINGLE_BYTE_SEQUENCE_LENGTH + } + /** * Feeds [text] to [action] in pieces of at most [maxChars], * never splitting a surrogate pair across two pieces. diff --git a/services/agent-gateway/src/main/resources/application.yml b/services/agent-gateway/src/main/resources/application.yml index 33a46955..82536d2f 100644 --- a/services/agent-gateway/src/main/resources/application.yml +++ b/services/agent-gateway/src/main/resources/application.yml @@ -40,8 +40,9 @@ agent-gateway: cli: claude: ${CLAUDE_BIN:claude} codex: ${CODEX_BIN:codex} - # The runner container is the sandbox, so agents launch with all - # approval/permission/sandbox prompts bypassed. Listed here so an + # The runner Pod is the outer sandbox. Docker-socket-enabled runners are + # host-equivalent for Docker/Testcontainers, while the CLIs still launch + # with approval/permission/sandbox prompts bypassed. Listed here so an # upstream flag rename is a config flip, not an image rebuild. claude-args: - --dangerously-skip-permissions diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt index ca524ab6..1d66bb00 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/git/GitClientVerifyTest.kt @@ -40,6 +40,7 @@ class GitClientVerifyTest { private val results: List, ) : ProcessRunner() { val calls = mutableListOf>() + val envs = mutableListOf>() private var idx = 0 override fun run( @@ -50,6 +51,7 @@ class GitClientVerifyTest { checked: Boolean, ): Result { calls.add(argv) + envs.add(env) val result = results[idx.coerceAtMost(results.size - 1)] idx++ if (checked && result.exitCode != 0) throw ProcessFailedException(argv, result) @@ -65,6 +67,46 @@ class GitClientVerifyTest { "deadbeef00000000000000000000000000000000\tHEAD\n" + "deadbeef00000000000000000000000000000000\trefs/heads/main\n" + @Test + fun `clone skips an existing checkout`() { + val target = keyDir.resolve("workspace/repo") + Files.createDirectories(target.resolve(".git")) + val runner = FakeRunner(listOf(ok())) + + val result = GitClient(runner, props()).clone(repoUrl, target.toString(), branch = "main") + + assertThat(result).isEqualTo(target.toString()) + assertThat(runner.calls).isEmpty() + } + + @Test + fun `clone scopes the app-token helper to the requested repository`() { + val target = keyDir.resolve("workspace/extra") + val runner = FakeRunner(listOf(ok())) + + val result = + GitClient(runner, props()).clone( + "git@github.com:owner/extra.git", + target.toString(), + branch = "main", + ) + + assertThat(result).isEqualTo(target.toString()) + assertThat(runner.calls.single()) + .containsExactly( + "git", + "clone", + "--depth", + "50", + "--branch", + "main", + "git@github.com:owner/extra.git", + target.toString(), + ) + assertThat(runner.envs.single()["AGENT_GITHUB_REPO_URL"]).isEqualTo("git@github.com:owner/extra.git") + assertThat(runner.envs.single()["REPO_ALLOW"]).isEqualTo("owner/extra") + } + @Test fun `read ok and write ok with HEAD when no branch given`() { val runner = FakeRunner(listOf(ok(lsRemoteHead), ok(), ok())) diff --git a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt index 904aea00..af587a28 100644 --- a/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt +++ b/services/agent-gateway/src/test/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManagerTest.kt @@ -128,7 +128,9 @@ class AgentSessionManagerTest { "--dangerously-bypass-approvals-and-sandbox", "--dangerously-bypass-hook-trust", ), - ) && !cmd.contains("resume") && !cmd.contains("--last") + ) && + !cmd.contains("resume") && + !cmd.contains("--last") }, "/workspace", ) diff --git a/services/agent-runner/Dockerfile b/services/agent-runner/Dockerfile index 2aff7f6e..334c1188 100644 --- a/services/agent-runner/Dockerfile +++ b/services/agent-runner/Dockerfile @@ -33,9 +33,9 @@ ARG GIT_SHA=unknown ENV SERVICE_VERSION=${GIT_SHA} ENV DEBIAN_FRONTEND=noninteractive -# Base tools. `gh` from the official apt source; node 22 LTS via the -# nodesource one-liner; Temurin JDK 21 from the Adoptium apt source; -# python from Debian. +# Base tools. `gh` from the official apt source; Docker CLI / Compose +# from Docker's apt source; node 22 LTS via the nodesource one-liner; +# Temurin JDK 21 from the Adoptium apt source; python from Debian. # # Java must be 21, not Debian bookworm's `default-jdk` — that resolves # to openjdk-17, and the gateway jar is compiled to Java 21 bytecode @@ -58,8 +58,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ | gpg --dearmor -o /usr/share/keyrings/adoptium.gpg \ && echo "deb [signed-by=/usr/share/keyrings/adoptium.gpg] https://packages.adoptium.net/artifactory/deb bookworm main" \ > /etc/apt/sources.list.d/adoptium.list \ + && curl -fsSL https://download.docker.com/linux/debian/gpg \ + | gpg --dearmor -o /usr/share/keyrings/docker.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" \ + > /etc/apt/sources.list.d/docker.list \ && curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ - && apt-get install -y --no-install-recommends gh nodejs temurin-21-jdk \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + gh nodejs temurin-21-jdk \ + docker-ce-cli docker-buildx-plugin docker-compose-plugin \ + && docker --version >/dev/null \ + && docker compose version >/dev/null \ && rm -rf /var/lib/apt/lists/* # Claude Code + Codex CLIs. Both ship via npm; pin to the versions @@ -188,6 +197,8 @@ RUN set -eu; \ allow="$(REPO_URL='git@github.com:o/primary.git' REPO_URLS='https://github.com/o/extra.git#dev git@github.com:o/second.git' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-allow /opt/entrypoint.sh)"; \ test "${allow}" = 'o/primary o/extra o/second'; \ test -z "$(REPO_URL='' REPO_URLS='' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-allow /opt/entrypoint.sh)"; \ + test "$(REPO_URL='git@github.com:o/personal-stack.git' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-dir /opt/entrypoint.sh)" = 'personal-stack'; \ + test "$(REPO_URL='https://github.com/o/website.git' AGENT_RUNNER_ENTRYPOINT_SELF_TEST=repo-dir /opt/entrypoint.sh)" = 'website'; \ rm -rf "${tmp}" # Shared token helper used by gh, GitHub MCP, and Git's credential @@ -217,8 +228,14 @@ RUN chmod +x /usr/local/bin/gh-mcp-wrapper # Non-root agent user, with /workspace writable. /home/agent is the # anchor for ~/.claude, ~/.codex, ~/.gitconfig, ~/.ssh — all mounted -# from PVCs/Secrets at runtime. -RUN useradd -m -u 1000 -s /bin/bash agent \ +# from PVCs/Secrets at runtime. The runtime Pod also injects the host +# Docker socket's supplemental group; the image-level docker group keeps +# local/manual runs usable when the host happens to use the common GID. +ARG DOCKER_GROUP_ID=999 +RUN if getent group docker >/dev/null; then :; \ + elif getent group "${DOCKER_GROUP_ID}" >/dev/null; then groupadd docker; \ + else groupadd --gid "${DOCKER_GROUP_ID}" docker; fi \ + && useradd -m -u 1000 -G docker -s /bin/bash agent \ && mkdir -p /workspace /home/agent/.claude /home/agent/.codex /home/agent/.ssh \ && chown -R agent:agent /workspace /home/agent USER agent diff --git a/services/agent-runner/agent-github-token.sh b/services/agent-runner/agent-github-token.sh index 7638b240..215a90de 100755 --- a/services/agent-runner/agent-github-token.sh +++ b/services/agent-runner/agent-github-token.sh @@ -3,9 +3,10 @@ # GH_TOKEN/GITHUB_PERSONAL_ACCESS_TOKEN wins; otherwise the helper mints # a short-lived GitHub App installation token through assistant-api and # caches it per repo until it is close to expiry. The target repo is the -# first argument, else AGENT_GITHUB_REPO_URL, else REPO_URL, else the -# workspace's origin — so the same helper serves every repo a multi-repo -# workspace clones (the credential helper passes AGENT_GITHUB_REPO_URL). +# first argument, else AGENT_GITHUB_REPO_URL, else the current git +# remote, else REPO_URL. That keeps commands run inside +# /workspace/ scoped to that repo, while root-level commands +# still fall back to the primary repository. set -u CACHE_DIR="${GH_APP_TOKEN_CACHE_DIR:-/tmp}" @@ -44,13 +45,17 @@ target_repo_url() { printf '%s' "$AGENT_GITHUB_REPO_URL" return 0 fi + if git -C "$WORKSPACE_ROOT" remote get-url origin 2>/dev/null; then + return 0 + fi + if git remote get-url origin 2>/dev/null; then + return 0 + fi if [ -n "${REPO_URL:-}" ]; then printf '%s' "$REPO_URL" return 0 fi - git -C "$WORKSPACE_ROOT" remote get-url origin 2>/dev/null || - git remote get-url origin 2>/dev/null || - return 1 + return 1 } REPO_URL_RESOLVED="$(target_repo_url "${1:-}")" || exit 1 diff --git a/services/agent-runner/entrypoint.sh b/services/agent-runner/entrypoint.sh index 4d6774b2..5ea6c346 100755 --- a/services/agent-runner/entrypoint.sh +++ b/services/agent-runner/entrypoint.sh @@ -48,6 +48,10 @@ repo_slug() { -e 's#\.git$##' } +repo_dir_name() { + basename "$1" .git +} + # The credential-helper allow-list: owner/repo slugs for the primary REPO_URL # plus every REPO_URLS entry ("url[#branch]"). Bounding the helper to exactly # the workspace's repos keeps the App token's blast radius the same as the old @@ -98,6 +102,11 @@ if [ "${AGENT_RUNNER_ENTRYPOINT_SELF_TEST:-}" = "repo-allow" ]; then exit 0 fi +if [ "${AGENT_RUNNER_ENTRYPOINT_SELF_TEST:-}" = "repo-dir" ]; then + repo_dir_name "${REPO_URL:-}" + exit 0 +fi + check_agent_kit_manifests # Bootstrap git identity — assistant-api injects GIT_AUTHOR_NAME / @@ -311,21 +320,56 @@ if [ -f /var/run/secrets/agents/github-deploy-key/private_key ]; then fi fi -# Clone the repo into the workspace at boot. The orchestrator passes -# REPO_URL (+ optional REPO_BRANCH) for repo-backed workspaces. Cloning -# here — with the deploy key staged above — avoids the race that left -# repo-backed workspaces empty: the old create-time gateway.clone fired -# before this gateway was up and was lost. Idempotent — only clones into -# an empty workspace, so a Pod restart on a populated PVC never re-clones. -# Failure is non-fatal so the gateway still starts and the repo can be -# cloned by hand. -if [ -n "${REPO_URL:-}" ] && [ -z "$(ls -A "$WORKSPACE_ROOT" 2>/dev/null || true)" ]; then - export GIT_SSH_COMMAND="ssh -i /tmp/agent-deploy-key -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${HOME}/.ssh/known_hosts" - if [ -n "${REPO_BRANCH:-}" ]; then - git clone --branch "$REPO_BRANCH" "$REPO_URL" "$WORKSPACE_ROOT" || echo "[entrypoint] WARN: clone of $REPO_URL failed; starting gateway anyway" +clone_repo_into_workspace() { + _repo_url="$1" + _repo_branch="${2:-}" + _repo_name="$(repo_dir_name "$_repo_url")" + _repo_target="${WORKSPACE_ROOT}/${_repo_name}" + + if [ -d "${_repo_target}/.git" ]; then + echo "[entrypoint] ${_repo_name} already present; skipping clone" + register_repo_trust "$_repo_target" + return + fi + + # Migrate legacy single-repo PVCs that cloned the primary directly + # into /workspace. Keep staged inputs at the multi-repo root; move + # the git checkout into /workspace/. + if [ -d "${WORKSPACE_ROOT}/.git" ] && [ ! -e "$_repo_target" ]; then + echo "[entrypoint] migrating legacy workspace root clone into ${_repo_target}" + mkdir -p "$_repo_target" + find "$WORKSPACE_ROOT" -mindepth 1 -maxdepth 1 \ + ! -name "$_repo_name" \ + ! -name ".agent-inputs" \ + -exec mv {} "$_repo_target/" \; + register_repo_trust "$_repo_target" + return + fi + + if [ -e "$_repo_target" ]; then + echo "[entrypoint] WARN: ${_repo_target} exists but is not a git checkout; skipping clone of ${_repo_url}" + return + fi + + if [ -n "$_repo_branch" ]; then + git clone --branch "$_repo_branch" "$_repo_url" "$_repo_target" \ + || echo "[entrypoint] WARN: clone of ${_repo_url} failed; continuing" else - git clone "$REPO_URL" "$WORKSPACE_ROOT" || echo "[entrypoint] WARN: clone of $REPO_URL failed; starting gateway anyway" + git clone "$_repo_url" "$_repo_target" \ + || echo "[entrypoint] WARN: clone of ${_repo_url} failed; continuing" + fi + if [ -d "${_repo_target}/.git" ]; then + register_repo_trust "$_repo_target" fi +} + +# Clone every workspace repository into its own named folder under +# /workspace. The orchestrator passes REPO_URL (+ optional REPO_BRANCH) +# for the primary repository and REPO_URLS for additional repositories. +# /workspace itself remains the multi-repo root where agents start. +if [ -n "${REPO_URL:-}" ]; then + export GIT_SSH_COMMAND="ssh -i /tmp/agent-deploy-key -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=${HOME}/.ssh/known_hosts" + clone_repo_into_workspace "$REPO_URL" "${REPO_BRANCH:-}" fi # Keep boot-time clone on the deploy-key path above, then make every @@ -348,25 +392,13 @@ fi # Multi-repo workspaces: clone every repo in REPO_URLS (space/newline/semicolon # separated, each "url[#branch]") into its own subdir of the workspace over # HTTPS, authenticated by the App-token credential helper — no per-repo deploy -# key needed. The primary REPO_URL clone above stays on the deploy-key SSH path -# for back-compat. Idempotent: a repo already cloned is left alone. +# key needed. Idempotent: a repo already cloned is left alone. if [ -n "${REPO_URLS:-}" ]; then for entry in $(printf '%s' "$REPO_URLS" | tr ';\n' ' '); do repo_clone_url="${entry%%#*}" repo_branch="" case "$entry" in *"#"*) repo_branch="${entry#*#}" ;; esac - repo_name="$(basename "$repo_clone_url" .git)" - repo_target="${WORKSPACE_ROOT}/${repo_name}" - if [ -d "${repo_target}/.git" ]; then - echo "[entrypoint] ${repo_name} already present; skipping clone" - elif [ -n "$repo_branch" ]; then - git clone --branch "$repo_branch" "$repo_clone_url" "$repo_target" \ - || echo "[entrypoint] WARN: clone of ${repo_clone_url} failed; continuing" - else - git clone "$repo_clone_url" "$repo_target" \ - || echo "[entrypoint] WARN: clone of ${repo_clone_url} failed; continuing" - fi - register_repo_trust "$repo_target" + clone_repo_into_workspace "$repo_clone_url" "$repo_branch" done fi diff --git a/services/agent-runner/gh-mcp-wrapper.sh b/services/agent-runner/gh-mcp-wrapper.sh index e9ed194b..02d6aa8e 100755 --- a/services/agent-runner/gh-mcp-wrapper.sh +++ b/services/agent-runner/gh-mcp-wrapper.sh @@ -42,8 +42,8 @@ if [ -z "$TOKEN" ]; then [gh-mcp-wrapper] ERROR: no GitHub token available for github-mcp-server. [gh-mcp-wrapper] Set GITHUB_PERSONAL_ACCESS_TOKEN/GH_TOKEN, or provide [gh-mcp-wrapper] GITHUB_APP_TOKEN_URL and GITHUB_APP_TOKEN_BEARER. The -[gh-mcp-wrapper] runner also needs REPO_URL or a git remote in /workspace -[gh-mcp-wrapper] so it can mint a repo-scoped installation token. +[gh-mcp-wrapper] runner also needs REPO_URL or a current git remote inside +[gh-mcp-wrapper] /workspace/ so it can mint a repo-scoped token. EOF exit 78 fi diff --git a/services/assistant-api/openapi.json b/services/assistant-api/openapi.json index 716d9dff..781e2b2c 100644 --- a/services/assistant-api/openapi.json +++ b/services/assistant-api/openapi.json @@ -241,8 +241,8 @@ "required" : true }, "responses" : { - "200" : { - "description" : "OK" + "204" : { + "description" : "No Content" } } } @@ -1213,6 +1213,17 @@ "type" : [ "string", "null" ], "format" : "uuid" }, + "primaryRepositoryId" : { + "type" : [ "string", "null" ], + "format" : "uuid" + }, + "repositoryIds" : { + "type" : [ "array", "null" ], + "items" : { + "type" : "string", + "format" : "uuid" + } + }, "githubLinkId" : { "type" : [ "string", "null" ], "format" : "uuid", @@ -1825,6 +1836,13 @@ "name" : { "type" : "string" }, + "isPrimary" : { + "type" : "boolean" + }, + "attachedAt" : { + "type" : "string", + "format" : "date-time" + }, "repoUrl" : { "type" : "string" }, @@ -1857,7 +1875,7 @@ } ] } }, - "required" : [ "createdAt", "defaultBranch", "id", "name", "repoUrl", "updatedAt", "vaultKeyPath" ] + "required" : [ "attachedAt", "createdAt", "defaultBranch", "id", "isPrimary", "name", "repoUrl", "updatedAt", "vaultKeyPath" ] }, "WorkspaceWithRepositoriesResponse" : { "type" : "object", diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt index e5569006..596f5214 100644 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/flow/WorkspaceRepositoryFlowIntegrationTest.kt @@ -57,6 +57,40 @@ class WorkspaceRepositoryFlowIntegrationTest : IntegrationTestBase() { .containsExactlyInAnyOrder("primary-$unique", "extra-one-$unique", "extra-two-$unique") } + @Test + fun `workspace creation accepts multi-repository selection with explicit primary`() { + val unique = unique() + val primaryId = createRepository("primary-$unique") + val extraOneId = createRepository("extra-one-$unique") + val extraTwoId = createRepository("extra-two-$unique") + + val result = + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "workspace-$unique", + "kind" to "REPO_BACKED", + "primaryRepositoryId" to primaryId, + "repositoryIds" to listOf(extraOneId, primaryId, extraTwoId), + ), + ), + ), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.repositoryId").value(primaryId)) + .andReturn() + val workspaceId = objectMapper.readTree(result.response.contentAsString)["id"].asText() + + val repositories = getWorkspaceDetail(workspaceId)["workspace"]["repositories"] + assertThat(repositories.map { it["id"].asText() }) + .containsExactlyInAnyOrder(primaryId, extraOneId, extraTwoId) + assertThat(repositories.single { it["isPrimary"].asBoolean() }["id"].asText()) + .isEqualTo(primaryId) + } + @Test fun `attach and detach endpoints mutate workspace detail repositories`() { val unique = unique() diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt index bd05963c..0b543c11 100644 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/Fabric8AgentRunnerOrchestratorIntegrationTest.kt @@ -144,6 +144,14 @@ class Fabric8AgentRunnerOrchestratorIntegrationTest { // IS_SANDBOX tells Claude Code it is sandboxed so it skips the // bypass-permissions warning/acceptance. assertThat(env.single { it.name == "IS_SANDBOX" }.value).isEqualTo("1") + assertThat(env.single { it.name == "DOCKER_HOST" }.value).isEqualTo("unix:///var/run/docker.sock") + assertThat(env.single { it.name == "TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE" }.value) + .isEqualTo("/var/run/docker.sock") + val nodeHostIp = env.single { it.name == "AGENT_RUNNER_NODE_HOST_IP" } + assertThat(nodeHostIp.value).isNull() + assertThat(nodeHostIp.valueFrom.fieldRef.fieldPath).isEqualTo("status.hostIP") + assertThat(env.single { it.name == "TESTCONTAINERS_HOST_OVERRIDE" }.value) + .isEqualTo("$(AGENT_RUNNER_NODE_HOST_IP)") // REPO_URL + REPO_BRANCH drive the entrypoint's boot-time clone // (adHocWorkspace is repo-backed with a branch). assertThat(env.single { it.name == "REPO_URL" }.value).isEqualTo("git@github.com:example/repo.git") @@ -170,6 +178,9 @@ class Fabric8AgentRunnerOrchestratorIntegrationTest { .inNamespace(K3sTestSupport.AGENTS_NAMESPACE) .withName("agent-runner-${workspace.id.short()}") .get() + assertThat(pod.spec.nodeSelector) + .containsEntry("personal-stack/node", "enschede-gtx-960m-1") + .containsEntry("personal-stack/capability-docker-socket", "true") val mcpMount = pod.spec.containers .single() @@ -179,12 +190,24 @@ class Fabric8AgentRunnerOrchestratorIntegrationTest { val mcpVol = pod.spec.volumes.single { it.name == "mcp-config" } assertThat(mcpVol.configMap.name).isEqualTo("agents-mcp-servers") assertThat(mcpVol.configMap.optional).isTrue() + val dockerMount = + pod.spec.containers + .single() + .volumeMounts + .single { it.name == "docker-socket" } + assertThat(dockerMount.mountPath).isEqualTo("/var/run/docker.sock") + val dockerVol = pod.spec.volumes.single { it.name == "docker-socket" } + assertThat(dockerVol.hostPath.path).isEqualTo("/var/run/docker.sock") + assertThat(dockerVol.hostPath.type).isEqualTo("Socket") // The runner must hold no Kubernetes API credential: cluster reads // go through the read-only MCP server, never the SA token. An // unsandboxed agent therefore cannot reach the API server to // modify or delete any Pod. assertThat(pod.spec.automountServiceAccountToken).isFalse() + assertThat(pod.spec.securityContext.supplementalGroups) + .contains(131L) + .doesNotContain(998L, 999L) // A startup probe must gate liveness so the gateway's JVM cold // start is not killed mid-boot (which re-provisioned the runner diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt index 59cd1b61..6769952a 100644 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/k8s/K3sTestSupport.kt @@ -94,17 +94,21 @@ object K3sTestSupport { * apply); safe to call once per test. */ fun bootstrapNamespacesAndServiceAccount(admin: KubernetesClient) { - listOf(AGENTS_NAMESPACE, ASSISTANT_NAMESPACE).forEach { ns -> + val agentsNamespace = repoRoot().resolve("platform/cluster/flux/apps/agents/namespace.yaml") + Files.newInputStream(agentsNamespace).use { stream -> admin - .namespaces() - .resource( - NamespaceBuilder() - .withNewMetadata() - .withName(ns) - .endMetadata() - .build(), - ).serverSideApply() + .load(stream) + .serverSideApply() } + admin + .namespaces() + .resource( + NamespaceBuilder() + .withNewMetadata() + .withName(ASSISTANT_NAMESPACE) + .endMetadata() + .build(), + ).serverSideApply() admin .serviceAccounts() .inNamespace(ASSISTANT_NAMESPACE) diff --git a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt index cba63690..f0aa90c9 100644 --- a/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt +++ b/services/assistant-api/src/integrationTest/kotlin/com/jorisjonkers/personalstack/assistant/persistence/JooqWorkspaceRepositoryRepositoryIntegrationTest.kt @@ -73,6 +73,22 @@ class JooqWorkspaceRepositoryRepositoryIntegrationTest : IntegrationTestBase() { assertThat(junction.findAllByWorkspaceId(w.id)).hasSize(1) } + @Test + fun `attaching a repository as primary promotes it and clears previous primary`() { + val w = workspace() + val r1 = repository() + val r2 = repository() + junction.attach(w.id, r1.id, isPrimary = true) + junction.attach(w.id, r2.id) + + val promoted = junction.attach(w.id, r2.id, isPrimary = true) + + assertThat(promoted.isPrimary).isTrue + val links = junction.findAllByWorkspaceId(w.id) + assertThat(links.filter { it.isPrimary }.map { it.repositoryId }).containsExactly(r2.id) + assertThat(links.first().repositoryId).isEqualTo(r2.id) + } + @Test fun `detach removes the row`() { val w = workspace() diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt index 7e9c9480..0702e1a0 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommand.kt @@ -5,9 +5,9 @@ import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId import com.jorisjonkers.personalstack.common.command.Command /** - * Attach an additional repository to a workspace. The repo is cloned - * alongside the primary (it becomes part of REPO_URLS) the next time the - * runner Pod boots; running pods are not re-provisioned. + * Attach an additional repository to a workspace. The link becomes part + * of REPO_URLS for future runner boots, and a currently running gateway + * is asked to clone it immediately into /workspace/. */ data class AttachWorkspaceRepositoryCommand( val workspaceId: WorkspaceId, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt index 834ebd4e..661e5d4a 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandler.kt @@ -1,5 +1,6 @@ package com.jorisjonkers.personalstack.assistant.application.command +import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository @@ -12,13 +13,19 @@ class AttachWorkspaceRepositoryCommandHandler( private val workspaces: WorkspaceRepository, private val repositories: RepositoryRepository, private val links: WorkspaceRepositoryRepository, + private val gateway: AgentGatewayClient, ) : CommandHandler { @Transactional override fun handle(command: AttachWorkspaceRepositoryCommand) { - workspaces.findById(command.workspaceId) - ?: throw NoSuchElementException("workspace not found: ${command.workspaceId}") - repositories.findById(command.repositoryId) - ?: throw NoSuchElementException("repository not found: ${command.repositoryId}") + val workspace = + workspaces.findById(command.workspaceId) + ?: throw NoSuchElementException("workspace not found: ${command.workspaceId}") + val repository = + repositories.findById(command.repositoryId) + ?: throw NoSuchElementException("repository not found: ${command.repositoryId}") links.attach(command.workspaceId, command.repositoryId, isPrimary = false) + if (workspace.gatewayEndpoint != null && gateway.isReady(workspace)) { + gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) + } } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt index f1eb9eba..d6231760 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommand.kt @@ -36,6 +36,13 @@ data class CreateWorkspaceCommand( val kind: WorkspaceKind = WorkspaceKind.REPO_BACKED, val projectId: ProjectId? = null, val repositoryId: RepositoryId? = null, + /** + * Ordered repository selection for multi-repo workspaces. The + * first entry becomes the primary repository when [repositoryId] + * is omitted; all other distinct entries are attached as + * additional repositories before the runner is provisioned. + */ + val repositoryIds: List = emptyList(), @Deprecated( "Use repositoryId — kept for the V9 migration window so existing UI " + "callers continue to work until PR F migrates them.", diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt index ddc9919e..5b23f238 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandler.kt @@ -24,7 +24,7 @@ import java.time.Instant * Creates the workspace record and provisions the runner Pod. For a * repo-backed workspace the clone happens inside the runner at boot * (the orchestrator passes REPO_URL/REPO_BRANCH and the entrypoint - * clones into /workspace with the mounted deploy key) — not from here. + * clones into /workspace/ with the mounted deploy key) — not from here. * An earlier create-time `gateway.clone` raced the runner's startup: * it fired before the gateway was up, failed, and was swallowed, * leaving the workspace empty. @@ -81,7 +81,7 @@ class CreateWorkspaceCommandHandler( verifyOrFail(resolved.repoUrl, resolved.branch, resolved.repositoryId) } val workspace = persistInitial(command, resolved) - seedRepositoryMembership(workspace) + seedRepositoryMembership(workspace, command) val withPod = provisionAndUpdate(workspace) log.info("workspace {} provisioned as pod {}", workspace.id, withPod.podName) } @@ -137,25 +137,53 @@ class CreateWorkspaceCommandHandler( return workspace } - private fun seedRepositoryMembership(workspace: Workspace) { + private fun seedRepositoryMembership( + workspace: Workspace, + command: CreateWorkspaceCommand, + ) { val repoId = workspace.repositoryId ?: return + val repoPort = repositories.ifAvailable ?: return val realPrimaryRepoId = - repositories - .ifAvailable - ?.findById(repoId) + repoPort + .findById(repoId) ?.id ?: return workspaceRepositories.attach(workspace.id, realPrimaryRepoId, isPrimary = true) - workspace.projectId - ?.let { projectRepositories.findAllByProjectId(it) } - .orEmpty() - .map { it.repositoryId } - .filterNot { it == realPrimaryRepoId } - .distinct() - .forEach { workspaceRepositories.attach(workspace.id, it, isPrimary = false) } + selectedExtraRepositoryIds(command, realPrimaryRepoId) + .forEach { repositoryId -> + val repository = requireRepository(repoPort, repositoryId) + workspaceRepositories.attach( + workspace.id, + repository.id, + isPrimary = false, + ) + } + } + + private fun selectedExtraRepositoryIds( + command: CreateWorkspaceCommand, + primaryRepoId: RepositoryId, + ): List { + val extras = + if (command.repositoryIds.isNotEmpty()) { + command.repositoryIds.filterNot { it == primaryRepoId } + } else { + command.projectId + ?.let { projectRepositories.findAllByProjectId(it) } + .orEmpty() + .map { it.repositoryId } + .filterNot { it == primaryRepoId } + } + return extras.distinct() } + private fun requireRepository( + repos: RepositoryRepository, + repositoryId: RepositoryId, + ) = repos.findById(repositoryId) + ?: throw NoSuchElementException("repository not found: repositoryId=${repositoryId.value}") + private fun provisionAndUpdate(workspace: Workspace): Workspace { val handle = orchestrator.provision(workspace) val withPod = @@ -175,13 +203,18 @@ class CreateWorkspaceCommandHandler( val legacyLinkId: GithubLinkId?, ) - private fun resolveRepo(command: CreateWorkspaceCommand): ResolvedRepo = - when { + private fun resolveRepo(command: CreateWorkspaceCommand): ResolvedRepo { + val primaryRepositoryId = primaryRepositoryId(command) + return when { command.kind == WorkspaceKind.SCRATCH -> ResolvedRepo(null, null, null, null) - command.repositoryId != null -> resolveFromRepository(command, command.repositoryId) + primaryRepositoryId != null -> resolveFromRepository(command, primaryRepositoryId) command.githubLinkId != null -> resolveFromLegacyLink(command, command.githubLinkId) else -> ResolvedRepo(command.repoUrl, command.branch, null, null) } + } + + private fun primaryRepositoryId(command: CreateWorkspaceCommand): RepositoryId? = + command.repositoryId ?: command.repositoryIds.firstOrNull() private fun resolveFromRepository( command: CreateWorkspaceCommand, diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt index adb92b39..ac01f64a 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryService.kt @@ -9,6 +9,7 @@ import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceAgentSessio import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository import org.springframework.stereotype.Service +import java.time.Instant @Service class GetWorkspaceQueryService( @@ -20,22 +21,43 @@ class GetWorkspaceQueryService( data class WorkspaceView( val workspace: Workspace, val sessions: List, - val repositories: List, + val repositories: List, + ) + + data class WorkspaceRepositoryView( + val repository: Repository, + val isPrimary: Boolean, + val attachedAt: Instant, ) fun getSummary(id: WorkspaceId): Workspace? = workspaces.findById(id) fun get(id: WorkspaceId): WorkspaceView? { val workspace = workspaces.findById(id) ?: return null + val links = repositoryLinks(workspace) val resolvedRepositories = - workspaceRepositories - .findAllByWorkspaceId(id) - .map { link -> + links.map { link -> + val repository = repositories.findById(link.repositoryId) ?: throw IllegalStateException( "Workspace ${id.value} references missing repository ${link.repositoryId.value}", ) - } + WorkspaceRepositoryView(repository, link.isPrimary, link.attachedAt) + } return WorkspaceView(workspace, sessions.findAllByWorkspaceId(id), resolvedRepositories) } + + private fun repositoryLinks(workspace: Workspace): List { + val links = workspaceRepositories.findAllByWorkspaceId(workspace.id) + val primaryRepositoryId = workspace.repositoryId ?: return links + if (links.any { it.repositoryId == primaryRepositoryId }) return links + val primaryLink = + WorkspaceRepositoryRepository.Link( + workspaceId = workspace.id, + repositoryId = primaryRepositoryId, + isPrimary = true, + attachedAt = workspace.createdAt, + ) + return listOf(primaryLink) + links + } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt index 782d9a3b..43814c00 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimeProperties.kt @@ -2,6 +2,17 @@ package com.jorisjonkers.personalstack.assistant.config import org.springframework.boot.context.properties.ConfigurationProperties +private const val NIXOS_DOCKER_GROUP_GID = 131L + +private val DEFAULT_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS = + listOf(NIXOS_DOCKER_GROUP_GID) + +private val DEFAULT_NODE_SELECTOR = + mapOf( + "personal-stack/node" to "enschede-gtx-960m-1", + "personal-stack/capability-docker-socket" to "true", + ) + /** * Properties that drive Pod creation for workspaces. All sourced from * application.yml so the manifests need no rebuild when the image @@ -35,7 +46,15 @@ data class AgentRuntimeProperties( // MCP server profile selected by the runner entrypoint. Keep the // default narrow; wider diagnostic profiles are explicit opt-ins. val defaultMcpProfile: String = "minimal", - val nodeSelector: Map = mapOf("personal-stack/node" to "enschede-gtx-960m-1"), + // Mount the host Docker socket into runner Pods so agent sessions can run + // Docker CLI commands and JVM Testcontainers suites from inside /workspace. + // This is host-equivalent access. The supplemental group default is pinned + // in platform/nix/hosts/enschede-gtx-960m-1/default.nix; override both the + // Nix host and this property together when moving runners to another node. + val dockerSocketEnabled: Boolean = true, + val dockerSocketPath: String = "/var/run/docker.sock", + val dockerSocketSupplementalGroups: List = DEFAULT_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS, + val nodeSelector: Map = DEFAULT_NODE_SELECTOR, val gatewayConnectTimeoutMs: Long = 5_000, val gatewayReadTimeoutMs: Long = 60_000, // Base URL of the standing agent-gateway used for the @@ -80,6 +99,12 @@ data class AgentRuntimeProperties( VALID_MCP_PROFILES.joinToString(", ") + "; got '$defaultMcpProfile'" } + require(dockerSocketPath.startsWith("/")) { + "agent-runtime.docker-socket-path must be an absolute path; got '$dockerSocketPath'" + } + require(dockerSocketSupplementalGroups.all { it > 0L }) { + "agent-runtime.docker-socket-supplemental-groups must contain positive numeric gids" + } } companion object { diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt index 255f8974..cb966bcf 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/k8s/Fabric8AgentRunnerOrchestrator.kt @@ -18,6 +18,8 @@ import io.fabric8.kubernetes.api.model.Quantity import io.fabric8.kubernetes.api.model.Secret import io.fabric8.kubernetes.api.model.SecretBuilder import io.fabric8.kubernetes.api.model.ServiceBuilder +import io.fabric8.kubernetes.api.model.Volume +import io.fabric8.kubernetes.api.model.VolumeBuilder import io.fabric8.kubernetes.api.model.VolumeMountBuilder import io.fabric8.kubernetes.client.KubernetesClient import org.slf4j.LoggerFactory @@ -32,7 +34,9 @@ import java.util.Base64 * (`workspace-`), one ClusterIP Service so the gateway has * a stable in-cluster name. Adversarial use is anticipated, so the * Pod runs as UID 1000, with read-only mounts for the credential - * PVCs that the agent itself doesn't need to write. + * PVCs that the agent itself doesn't need to write. Docker socket + * access is intentionally host-equivalent and exists so repo tests + * using Testcontainers can run inside future agent sessions. * * Per-workspace deploy-key isolation: when the workspace's * `githubLinkId` is set, the orchestrator looks up the link, reads @@ -298,6 +302,7 @@ class Fabric8AgentRunnerOrchestrator( .withRunAsUser(RUN_AS_UID) .withRunAsGroup(RUN_AS_GID) .withFsGroup(FS_GROUP) + .withSupplementalGroups(podSupplementalGroups()) .endSecurityContext() .addNewContainer() .withName("agent-runner") @@ -364,16 +369,18 @@ class Fabric8AgentRunnerOrchestrator( .build(), ) add(EnvVarBuilder().withName("OTEL_EXPORTER_OTLP_PROTOCOL").withValue("http/protobuf").build()) - // The runner Pod is the sandbox (unprivileged, no host access, - // only its deploy key). IS_SANDBOX tells Claude Code so that - // --dangerously-skip-permissions runs without the bypass-mode - // warning + acceptance prompt. + // The runner Pod is the outer sandbox for the agent process. + // Docker socket access is the explicit host-equivalent exception + // for Testcontainers and Docker CLI workflows. IS_SANDBOX tells + // Claude Code so that --dangerously-skip-permissions runs without + // the bypass-mode warning + acceptance prompt. add(EnvVarBuilder().withName("IS_SANDBOX").withValue("1").build()) add(EnvVarBuilder().withName("AGENT_MCP_PROFILE").withValue(props.defaultMcpProfile).build()) + addAll(dockerEnv()) addAll(knowledgeEnv()) addAll(githubAppTokenEnv()) // REPO_URL/REPO_BRANCH drive the entrypoint's boot-time clone - // into /workspace. Cloning in the runner removes the race that + // into /workspace/. Cloning in the runner removes the race that // left repo-backed workspaces empty: the old create-time // gateway.clone fired before the runner gateway was up and was // swallowed. Only repo-backed workspaces carry a repoUrl. @@ -382,10 +389,9 @@ class Fabric8AgentRunnerOrchestrator( workspace.branch?.let { add(EnvVarBuilder().withName("REPO_BRANCH").withValue(it).build()) } } // REPO_URLS carries the workspace's additional repos (everything - // attached that is not the primary). The entrypoint clones each - // into its own subdir over the App-token credential helper. Only - // emitted when there are extras, so single-repo workspaces look - // identical to before to an older runner image. + // attached that is not the primary), as url#branch entries. The + // entrypoint clones each into /workspace/ over the + // App-token credential helper. additionalRepoUrls(workspace).takeIf { it.isNotEmpty() }?.let { urls -> add(EnvVarBuilder().withName("REPO_URLS").withValue(urls.joinToString(" ")).build()) } @@ -397,9 +403,36 @@ class Fabric8AgentRunnerOrchestrator( return links .findAllByWorkspaceId(workspace.id) .filterNot { it.isPrimary } - .mapNotNull { repos.findById(it.repositoryId)?.repoUrl } + .mapNotNull { link -> + repos.findById(link.repositoryId)?.let { repo -> "${repo.repoUrl}#${repo.defaultBranch}" } + } } + private fun dockerEnv() = + if (!props.dockerSocketEnabled) { + emptyList() + } else { + listOf( + EnvVarBuilder().withName("DOCKER_HOST").withValue("unix://${props.dockerSocketPath}").build(), + EnvVarBuilder() + .withName("TESTCONTAINERS_DOCKER_SOCKET_OVERRIDE") + .withValue(props.dockerSocketPath) + .build(), + EnvVarBuilder() + .withName("AGENT_RUNNER_NODE_HOST_IP") + .withNewValueFrom() + .withNewFieldRef() + .withFieldPath("status.hostIP") + .endFieldRef() + .endValueFrom() + .build(), + EnvVarBuilder() + .withName("TESTCONTAINERS_HOST_OVERRIDE") + .withValue("$(AGENT_RUNNER_NODE_HOST_IP)") + .build(), + ) + } + // KB_URL + KB_BEARER_TOKEN are the exact names the knowledge-system // install.sh hooks read; without the bearer every hook short-circuits // to a no-op and the knowledge.* MCP tools are unreachable. @@ -435,59 +468,95 @@ class Fabric8AgentRunnerOrchestrator( ) private fun podVolumeMounts() = - listOf( - VolumeMountBuilder().withName("workspace").withMountPath("/workspace").build(), - VolumeMountBuilder().withName("claude-credentials").withMountPath("/home/agent/.claude").build(), - VolumeMountBuilder().withName("codex-credentials").withMountPath("/home/agent/.codex").build(), - VolumeMountBuilder() - .withName("github-deploy-key") - .withMountPath("/var/run/secrets/agents/github-deploy-key") - .withReadOnly(true) - .build(), + buildList { + add(VolumeMountBuilder().withName("workspace").withMountPath("/workspace").build()) + add(VolumeMountBuilder().withName("claude-credentials").withMountPath("/home/agent/.claude").build()) + add(VolumeMountBuilder().withName("codex-credentials").withMountPath("/home/agent/.codex").build()) + if (props.dockerSocketEnabled) { + add( + VolumeMountBuilder() + .withName(DOCKER_SOCKET_VOLUME) + .withMountPath(props.dockerSocketPath) + .build(), + ) + } + add( + VolumeMountBuilder() + .withName("github-deploy-key") + .withMountPath("/var/run/secrets/agents/github-deploy-key") + .withReadOnly(true) + .build(), + ) // Declarative MCP server set; the entrypoint seeds it into // ~/.claude.json. Optional volume, so an absent ConfigMap // leaves the runner with no managed MCP servers. - VolumeMountBuilder() - .withName("mcp-config") - .withMountPath("/etc/agent-mcp") - .withReadOnly(true) - .build(), - ) + add( + VolumeMountBuilder() + .withName("mcp-config") + .withMountPath("/etc/agent-mcp") + .withReadOnly(true) + .build(), + ) + } private fun podVolumes( workspacePvc: String, deployKeySecret: String, - ) = listOf( - pvcVolume("workspace", workspacePvc), - pvcVolume("claude-credentials", props.claudeCredentialsPvc), - pvcVolume("codex-credentials", props.codexCredentialsPvc), - io.fabric8.kubernetes.api.model - .VolumeBuilder() + ) = buildList { + add(pvcVolume("workspace", workspacePvc)) + add(pvcVolume("claude-credentials", props.claudeCredentialsPvc)) + add(pvcVolume("codex-credentials", props.codexCredentialsPvc)) + dockerSocketVolume()?.let(::add) + add(githubDeployKeyVolume(deployKeySecret)) + add(mcpConfigVolume()) + } + + private fun pvcVolume( + name: String, + claim: String, + ) = VolumeBuilder() + .withName(name) + .withNewPersistentVolumeClaim() + .withClaimName(claim) + .endPersistentVolumeClaim() + .build() + + private fun dockerSocketVolume(): Volume? = + if (!props.dockerSocketEnabled) { + null + } else { + VolumeBuilder() + .withName(DOCKER_SOCKET_VOLUME) + .withNewHostPath() + .withPath(props.dockerSocketPath) + .withType("Socket") + .endHostPath() + .build() + } + + private fun githubDeployKeyVolume(deployKeySecret: String): Volume = + VolumeBuilder() .withName("github-deploy-key") .withNewSecret() .withSecretName(deployKeySecret) .endSecret() - .build(), - io.fabric8.kubernetes.api.model - .VolumeBuilder() + .build() + + private fun mcpConfigVolume(): Volume = + VolumeBuilder() .withName("mcp-config") .withNewConfigMap() .withName(props.mcpServersConfigMap) .withOptional(true) .endConfigMap() - .build(), - ) + .build() - private fun pvcVolume( - name: String, - claim: String, - ) = io.fabric8.kubernetes.api.model - .VolumeBuilder() - .withName(name) - .withNewPersistentVolumeClaim() - .withClaimName(claim) - .endPersistentVolumeClaim() - .build() + private fun podSupplementalGroups(): List = + if (props.dockerSocketEnabled) { + (listOf(RUN_AS_GID) + props.dockerSocketSupplementalGroups).distinct() + } else { + listOf(RUN_AS_GID) + } private fun service( name: String, @@ -513,6 +582,7 @@ class Fabric8AgentRunnerOrchestrator( private const val RUN_AS_UID = 1000L private const val RUN_AS_GID = 1000L private const val FS_GROUP = 1000L + private const val DOCKER_SOCKET_VOLUME = "docker-socket" // Resource sizing. One Pod hosts the gateway JVM, the agent CLIs // (Claude Code, Codex) and the workspace's own processes in a diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt index c026cea1..840d40a3 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/persistence/JooqWorkspaceRepositoryRepository.kt @@ -22,16 +22,51 @@ class JooqWorkspaceRepositoryRepository( isPrimary: Boolean, ): WorkspaceRepositoryRepository.Link { val now = Instant.now() + if (isPrimary) { + attachPrimary(workspaceId, repositoryId, now) + } else { + attachSecondary(workspaceId, repositoryId, now) + } + return findLink(workspaceId, repositoryId) + ?: WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now) + } + + private fun attachPrimary( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + now: Instant, + ) { + dsl + .update(JUNCTION) + .set(IS_PRIMARY, false) + .where(WORKSPACE_ID.eq(workspaceId.value)) + .execute() dsl .insertInto(JUNCTION) .set(WORKSPACE_ID, workspaceId.value) .set(REPOSITORY_ID, repositoryId.value) - .set(IS_PRIMARY, isPrimary) + .set(IS_PRIMARY, true) + .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) + .onConflict(WORKSPACE_ID, REPOSITORY_ID) + .doUpdate() + .set(IS_PRIMARY, true) + .execute() + } + + private fun attachSecondary( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + now: Instant, + ) { + dsl + .insertInto(JUNCTION) + .set(WORKSPACE_ID, workspaceId.value) + .set(REPOSITORY_ID, repositoryId.value) + .set(IS_PRIMARY, false) .set(ATTACHED_AT, now.atOffset(ZoneOffset.UTC)) .onConflict(WORKSPACE_ID, REPOSITORY_ID) .doNothing() .execute() - return WorkspaceRepositoryRepository.Link(workspaceId, repositoryId, isPrimary, now) } override fun detach( @@ -49,10 +84,21 @@ class JooqWorkspaceRepositoryRepository( dsl .selectFrom(JUNCTION) .where(WORKSPACE_ID.eq(workspaceId.value)) - .orderBy(ATTACHED_AT.asc()) + .orderBy(IS_PRIMARY.desc(), ATTACHED_AT.asc()) .fetch() .map { it.toLink() } + private fun findLink( + workspaceId: WorkspaceId, + repositoryId: RepositoryId, + ): WorkspaceRepositoryRepository.Link? = + dsl + .selectFrom(JUNCTION) + .where(WORKSPACE_ID.eq(workspaceId.value)) + .and(REPOSITORY_ID.eq(repositoryId.value)) + .fetchOne() + ?.toLink() + private fun Record.toLink(): WorkspaceRepositoryRepository.Link = WorkspaceRepositoryRepository.Link( workspaceId = WorkspaceId(this[WORKSPACE_ID]), diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt index 0f542acb..1ef2e368 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceController.kt @@ -15,6 +15,7 @@ import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.CreateWor import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceDetailResponse import com.jorisjonkers.personalstack.assistant.infrastructure.web.dto.WorkspaceResponse import com.jorisjonkers.personalstack.common.command.CommandBus +import io.swagger.v3.oas.annotations.responses.ApiResponse import jakarta.validation.Valid import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity @@ -40,6 +41,7 @@ class WorkspaceController( @Valid @RequestBody req: CreateWorkspaceRequest, ): ResponseEntity { val id = WorkspaceId.random() + val primaryRepositoryId = primaryRepositoryId(req) commandBus.dispatch( CreateWorkspaceCommand( workspaceId = id, @@ -48,7 +50,8 @@ class WorkspaceController( branch = req.branch, kind = req.kind, projectId = req.projectId?.let { ProjectId(it) }, - repositoryId = req.repositoryId?.let { RepositoryId(it) }, + repositoryId = primaryRepositoryId?.let { RepositoryId(it) }, + repositoryIds = selectedRepositoryIds(req, primaryRepositoryId).map { RepositoryId(it) }, githubLinkId = req.githubLinkId?.let { GithubLinkId(it) }, ), ) @@ -72,6 +75,7 @@ class WorkspaceController( } @PostMapping("/{id}/repositories") + @ApiResponse(responseCode = "204", description = "No Content") fun attachRepository( @PathVariable id: UUID, @Valid @RequestBody req: AttachWorkspaceRepositoryRequest, @@ -106,4 +110,24 @@ class WorkspaceController( commandBus.dispatch(DestroyWorkspaceCommand(WorkspaceId(id))) return ResponseEntity.noContent().build() } + + private fun primaryRepositoryId(req: CreateWorkspaceRequest): UUID? { + if (req.repositoryId != null && + req.primaryRepositoryId != null && + req.repositoryId != req.primaryRepositoryId + ) { + throw IllegalArgumentException("repositoryId and primaryRepositoryId must match when both are supplied") + } + return req.repositoryId ?: req.primaryRepositoryId ?: req.repositoryIds.orEmpty().firstOrNull() + } + + private fun selectedRepositoryIds( + req: CreateWorkspaceRequest, + primaryRepositoryId: UUID?, + ): List { + val repositoryIds = req.repositoryIds.orEmpty() + if (repositoryIds.isEmpty()) return emptyList() + return (listOfNotNull(primaryRepositoryId) + repositoryIds) + .distinct() + } } diff --git a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt index 67daca1f..650790fa 100644 --- a/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt +++ b/services/assistant-api/src/main/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/dto/WorkspaceDtos.kt @@ -1,5 +1,7 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web.dto +import com.fasterxml.jackson.annotation.JsonProperty +import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService.WorkspaceRepositoryView import com.jorisjonkers.personalstack.assistant.domain.model.Repository import com.jorisjonkers.personalstack.assistant.domain.model.Turn import com.jorisjonkers.personalstack.assistant.domain.model.Workspace @@ -34,6 +36,20 @@ data class CreateWorkspaceRequest( * Repository row. */ val repositoryId: UUID? = null, + /** + * Primary repo for the multi-repository request shape. Kept + * separate from [repositoryIds] so clients can submit an ordered + * or unordered selected set without relying on list position. + * [repositoryId] remains the backwards-compatible alias and wins + * when both fields match. + */ + val primaryRepositoryId: UUID? = null, + /** + * Selected repositories for a multi-repo workspace. When no + * primary field is set, the first entry becomes the primary and + * the remaining distinct ids are attached as extras. + */ + val repositoryIds: List? = null, /** * Deprecated alias for [repositoryId]. Kept until PR F migrates * the assistant-ui to the new field. The server prefers @@ -87,6 +103,9 @@ data class WorkspaceResponse( data class WorkspaceRepositoryResponse( val id: UUID, val name: String, + @get:JsonProperty("isPrimary") + val isPrimary: Boolean, + val attachedAt: Instant, val repoUrl: String, val defaultBranch: String, val vaultKeyPath: String, @@ -97,10 +116,30 @@ data class WorkspaceRepositoryResponse( val verification: AccessVerificationResponse?, ) { companion object { + fun of(view: WorkspaceRepositoryView): WorkspaceRepositoryResponse { + val r = view.repository + return WorkspaceRepositoryResponse( + id = r.id.value, + name = r.name, + isPrimary = view.isPrimary, + attachedAt = view.attachedAt, + repoUrl = r.repoUrl, + defaultBranch = r.defaultBranch, + vaultKeyPath = r.vaultKeyPath, + deployKeyFingerprint = r.deployKeyFingerprint, + deployKeyAddedAt = r.deployKeyAddedAt, + createdAt = r.createdAt, + updatedAt = r.updatedAt, + verification = r.verification?.let(AccessVerificationResponse::of), + ) + } + fun of(r: Repository) = WorkspaceRepositoryResponse( id = r.id.value, name = r.name, + isPrimary = false, + attachedAt = r.createdAt, repoUrl = r.repoUrl, defaultBranch = r.defaultBranch, vaultKeyPath = r.vaultKeyPath, @@ -133,7 +172,7 @@ data class WorkspaceWithRepositoriesResponse( companion object { fun of( w: Workspace, - repositories: List, + repositories: List, ) = WorkspaceWithRepositoriesResponse( id = w.id.value, name = w.name, @@ -160,7 +199,7 @@ data class WorkspaceDetailResponse( companion object { fun of( workspace: Workspace, - repositories: List, + repositories: List, sessions: List, ) = WorkspaceDetailResponse( workspace = WorkspaceWithRepositoriesResponse.of(workspace, repositories), diff --git a/services/assistant-api/src/main/resources/application.yml b/services/assistant-api/src/main/resources/application.yml index 41398009..aec70736 100644 --- a/services/assistant-api/src/main/resources/application.yml +++ b/services/assistant-api/src/main/resources/application.yml @@ -116,6 +116,14 @@ agent-runtime: # Runner MCP profile. Keep default tool count low; wider profiles are # opt-ins via AGENT_RUNTIME_DEFAULT_MCP_PROFILE. default-mcp-profile: ${AGENT_RUNTIME_DEFAULT_MCP_PROFILE:minimal} + # Runner Pods mount the host Docker socket so future agent sessions can run + # Docker CLI commands and JVM Testcontainers suites. This is host-equivalent + # access. The default gid is pinned in the Nix host definition for the + # Docker-socket-capable runner node; override both values together when + # moving runners. + docker-socket-enabled: ${AGENT_RUNTIME_DOCKER_SOCKET_ENABLED:true} + docker-socket-path: ${AGENT_RUNTIME_DOCKER_SOCKET_PATH:/var/run/docker.sock} + docker-socket-supplemental-groups: ${AGENT_RUNTIME_DOCKER_SOCKET_SUPPLEMENTAL_GROUPS:131} # Pinning to a single node is mandatory because the shared # credential PVCs (claude-credentials / codex-credentials) are # backed by local-path RWO — every runner Pod must land on the @@ -131,6 +139,7 @@ agent-runtime: # https://docs.spring.io/spring-boot/reference/features/external-config.html#features.external-config.typesafe-configuration-properties.relaxed-binding.maps node-selector: '[personal-stack/node]': ${AGENT_RUNTIME_NODE:enschede-gtx-960m-1} + '[personal-stack/capability-docker-socket]': ${AGENT_RUNTIME_DOCKER_SOCKET_CAPABILITY:true} # Idle scale-down. A workspace whose last observed activity is # older than `idle-after-seconds` gets its Pod + Service + per- # workspace Secret deleted; the workspace PVC survives so a diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt index e581563d..4e9fdfa0 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/AttachWorkspaceRepositoryCommandHandlerTest.kt @@ -4,6 +4,8 @@ import com.jorisjonkers.personalstack.assistant.domain.model.Repository import com.jorisjonkers.personalstack.assistant.domain.model.RepositoryId import com.jorisjonkers.personalstack.assistant.domain.model.Workspace import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceId +import com.jorisjonkers.personalstack.assistant.domain.model.WorkspaceStatus +import com.jorisjonkers.personalstack.assistant.domain.port.AgentGatewayClient import com.jorisjonkers.personalstack.assistant.domain.port.RepositoryRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepository import com.jorisjonkers.personalstack.assistant.domain.port.WorkspaceRepositoryRepository @@ -12,25 +14,56 @@ import io.mockk.mockk import io.mockk.verify import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows +import java.time.Instant class AttachWorkspaceRepositoryCommandHandlerTest { private val workspaces = mockk() private val repositories = mockk() private val links = mockk(relaxed = true) - private val handler = AttachWorkspaceRepositoryCommandHandler(workspaces, repositories, links) + private val gateway = mockk(relaxed = true) + private val handler = AttachWorkspaceRepositoryCommandHandler(workspaces, repositories, links, gateway) private val workspaceId = WorkspaceId.random() private val repositoryId = RepositoryId.random() private val command = AttachWorkspaceRepositoryCommand(workspaceId, repositoryId) @Test - fun `attaches a non-primary link when both exist`() { - every { workspaces.findById(workspaceId) } returns mockk() - every { repositories.findById(repositoryId) } returns mockk() + fun `attaches a non-primary link and clones into a running workspace`() { + val workspace = workspace() + val repository = repository() + every { workspaces.findById(workspaceId) } returns workspace + every { repositories.findById(repositoryId) } returns repository + every { gateway.isReady(workspace) } returns true handler.handle(command) verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + verify { gateway.clone(workspace, repository.repoUrl, repository.defaultBranch) } + } + + @Test + fun `attaches without live clone when workspace has no gateway yet`() { + val workspace = workspace(gatewayEndpoint = null) + every { workspaces.findById(workspaceId) } returns workspace + every { repositories.findById(repositoryId) } returns repository() + + handler.handle(command) + + verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + verify(exactly = 0) { gateway.clone(any(), any(), any()) } + } + + @Test + fun `attaches without live clone when gateway is not ready`() { + val workspace = workspace() + every { workspaces.findById(workspaceId) } returns workspace + every { repositories.findById(repositoryId) } returns repository() + every { gateway.isReady(workspace) } returns false + + handler.handle(command) + + verify { links.attach(workspaceId, repositoryId, isPrimary = false) } + verify(exactly = 0) { gateway.clone(any(), any(), any()) } } @Test @@ -43,10 +76,37 @@ class AttachWorkspaceRepositoryCommandHandlerTest { @Test fun `rejects an unknown repository`() { - every { workspaces.findById(workspaceId) } returns mockk() + every { workspaces.findById(workspaceId) } returns workspace() every { repositories.findById(repositoryId) } returns null assertThrows { handler.handle(command) } verify(exactly = 0) { links.attach(any(), any(), any()) } } + + private fun workspace(gatewayEndpoint: String? = "http://runner:8090") = + Workspace( + id = workspaceId, + name = "workspace", + repoUrl = "git@github.com:o/primary.git", + branch = "main", + podName = "pod", + pvcName = "pvc", + gatewayEndpoint = gatewayEndpoint, + status = WorkspaceStatus.READY, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) + + private fun repository() = + Repository( + id = repositoryId, + name = "website", + repoUrl = "git@github.com:o/website.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/${repositoryId.value}", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) } diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt index c601db88..21c82cc9 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/command/CreateWorkspaceCommandHandlerTest.kt @@ -264,6 +264,8 @@ class CreateWorkspaceCommandHandlerTest { updatedAt = Instant.now(), ) every { repositories.findById(primaryRepoId) } returns repository + every { repositories.findById(extraRepoId) } returns repository(extraRepoId, "extra-one") + every { repositories.findById(secondExtraRepoId) } returns repository(secondExtraRepoId, "extra-two") every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null every { projectRepositoryLinks.findAllByProjectId(projectId) } returns listOf( @@ -296,6 +298,41 @@ class CreateWorkspaceCommandHandlerTest { } } + @Test + fun `repositoryIds-only create treats first selected repository as primary and attaches distinct extras`() { + val primaryRepoId = RepositoryId.random() + val extraRepoId = RepositoryId.random() + val secondExtraRepoId = RepositoryId.random() + every { repositories.findById(primaryRepoId) } returns repository(primaryRepoId, "primary") + every { repositories.findById(extraRepoId) } returns repository(extraRepoId, "extra") + every { repositories.findById(secondExtraRepoId) } returns repository(secondExtraRepoId, "docs") + every { githubLinks.findById(GithubLinkId(primaryRepoId.value)) } returns null + val saved = mutableListOf() + every { workspaces.save(capture(saved)) } answers { firstArg() } + every { orchestrator.provision(any()) } returns + AgentRunnerOrchestrator.RunnerHandle("p", "v", "http://p.svc:8090") + val workspaceId = WorkspaceId.random() + + handler.handle( + CreateWorkspaceCommand( + workspaceId = workspaceId, + name = "demo", + repoUrl = null, + branch = null, + repositoryIds = listOf(primaryRepoId, extraRepoId, primaryRepoId, secondExtraRepoId), + ), + ) + + assertThat(saved.first().repositoryId).isEqualTo(primaryRepoId) + verifyOrder { + workspaces.save(match { it.id == workspaceId && it.repositoryId == primaryRepoId }) + workspaceRepositoryLinks.attach(workspaceId, primaryRepoId, isPrimary = true) + workspaceRepositoryLinks.attach(workspaceId, extraRepoId, isPrimary = false) + workspaceRepositoryLinks.attach(workspaceId, secondExtraRepoId, isPrimary = false) + orchestrator.provision(match { it.id == workspaceId }) + } + } + @Test fun `handle leaves legacy linkId null when no github_links mirror exists`() { // Regression for the production workspace 500: post-V9 @@ -667,4 +704,19 @@ class CreateWorkspaceCommandHandlerTest { verify(exactly = 0) { verifyAccess.verify(any(), any()) } } + + private fun repository( + id: RepositoryId, + name: String, + ) = Repository( + id = id, + name = name, + repoUrl = "git@github.com:ExtraToast/$name.git", + defaultBranch = "main", + vaultKeyPath = "secret/data/agents/repositories/$id", + deployKeyFingerprint = null, + deployKeyAddedAt = null, + createdAt = Instant.now(), + updatedAt = Instant.now(), + ) } diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt index 74b56b2f..2408b343 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/application/query/GetWorkspaceQueryServiceTest.kt @@ -57,7 +57,25 @@ class GetWorkspaceQueryServiceTest { assertThat(result.workspace).isEqualTo(w) assertThat(result.sessions).isEmpty() - assertThat(result.repositories).containsExactly(r1, r2) + assertThat(result.repositories.map { it.repository }).containsExactly(r1, r2) + assertThat(result.repositories.map { it.isPrimary }).containsExactly(true, false) + } + + @Test + fun `get includes denormalized primary repository when junction row is missing`() { + val repoId = RepositoryId.random() + val w = workspace(repositoryId = repoId) + val r = repository(id = repoId) + every { workspaces.findById(w.id) } returns w + every { sessions.findAllByWorkspaceId(w.id) } returns emptyList() + every { workspaceRepositories.findAllByWorkspaceId(w.id) } returns emptyList() + every { repositories.findById(repoId) } returns r + + val result = service.get(w.id) ?: error("expected workspace detail") + + assertThat(result.repositories.map { it.repository }).containsExactly(r) + assertThat(result.repositories.single().isPrimary).isTrue + assertThat(result.repositories.single().attachedAt).isEqualTo(w.createdAt) } @Test @@ -87,19 +105,22 @@ class GetWorkspaceQueryServiceTest { .hasMessageContaining("references missing repository ${missingRepositoryId.value}") } - private fun workspace(id: WorkspaceId = WorkspaceId.random()) = - Workspace( - id = id, - name = "demo", - repoUrl = "git@github.com:owner/repo.git", - branch = "main", - podName = "pod", - pvcName = "pvc", - gatewayEndpoint = "http://endpoint:8090", - status = WorkspaceStatus.READY, - createdAt = Instant.now(), - updatedAt = Instant.now(), - ) + private fun workspace( + id: WorkspaceId = WorkspaceId.random(), + repositoryId: RepositoryId? = null, + ) = Workspace( + id = id, + name = "demo", + repoUrl = "git@github.com:owner/repo.git", + branch = "main", + podName = "pod", + pvcName = "pvc", + gatewayEndpoint = "http://endpoint:8090", + status = WorkspaceStatus.READY, + createdAt = Instant.now(), + updatedAt = Instant.now(), + repositoryId = repositoryId, + ) private fun repository( id: RepositoryId = RepositoryId.random(), diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt index e1014a24..7eb8a531 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/config/AgentRuntimePropertiesBindingTest.kt @@ -100,6 +100,29 @@ class AgentRuntimePropertiesBindingTest { .hasMessageContaining("frontned") } + @Test + fun `docker socket defaults keep testcontainers available in runner pods`() { + val props = bind(base) + + assertThat(props.dockerSocketEnabled).isTrue() + assertThat(props.dockerSocketPath).isEqualTo("/var/run/docker.sock") + assertThat(props.dockerSocketSupplementalGroups).containsExactly(131L) + assertThat(props.nodeSelector).containsEntry("personal-stack/capability-docker-socket", "true") + } + + @Test + fun `docker socket supplemental groups bind from comma separated environment value`() { + val props = + bind( + base + + mapOf( + "agent-runtime.docker-socket-supplemental-groups" to "44,45", + ), + ) + + assertThat(props.dockerSocketSupplementalGroups).containsExactly(44L, 45L) + } + private fun bind(properties: Map): AgentRuntimeProperties { val source = MapConfigurationPropertySource(properties) return Binder(source) diff --git a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt index 3eeddb13..6d474bfa 100644 --- a/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt +++ b/services/assistant-api/src/test/kotlin/com/jorisjonkers/personalstack/assistant/infrastructure/web/WorkspaceControllerTest.kt @@ -2,6 +2,7 @@ package com.jorisjonkers.personalstack.assistant.infrastructure.web import com.fasterxml.jackson.databind.ObjectMapper import com.jorisjonkers.personalstack.assistant.application.command.AttachWorkspaceRepositoryCommand +import com.jorisjonkers.personalstack.assistant.application.command.CreateWorkspaceCommand import com.jorisjonkers.personalstack.assistant.application.command.DetachWorkspaceRepositoryCommand import com.jorisjonkers.personalstack.assistant.application.query.GetWorkspaceQueryService import com.jorisjonkers.personalstack.assistant.application.query.ListWorkspacesQueryService @@ -110,6 +111,65 @@ class WorkspaceControllerTest { verify { commandBus.dispatch(any()) } } + @Test + fun `POST creates a workspace with primary and extra repository ids`() { + val primaryRepositoryId = RepositoryId.random() + val extraRepositoryId = RepositoryId.random() + val w = workspace(kind = WorkspaceKind.REPO_BACKED, repositoryId = primaryRepositoryId) + every { getQuery.getSummary(any()) } returns w + + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "demo", + "kind" to "REPO_BACKED", + "primaryRepositoryId" to primaryRepositoryId.value.toString(), + "repositoryIds" to + listOf( + extraRepositoryId.value.toString(), + primaryRepositoryId.value.toString(), + extraRepositoryId.value.toString(), + ), + ), + ), + ), + ).andExpect(status().isCreated) + .andExpect(jsonPath("$.repositoryId").value(primaryRepositoryId.value.toString())) + + verify { + commandBus.dispatch( + match { + it.repositoryId == primaryRepositoryId && + it.repositoryIds == listOf(primaryRepositoryId, extraRepositoryId) + }, + ) + } + } + + @Test + fun `POST rejects conflicting primary repository fields`() { + mockMvc + .perform( + post("/api/v1/workspaces") + .contentType(MediaType.APPLICATION_JSON) + .content( + objectMapper.writeValueAsString( + mapOf( + "name" to "demo", + "repositoryId" to UUID.randomUUID().toString(), + "primaryRepositoryId" to UUID.randomUUID().toString(), + ), + ), + ), + ).andExpect(status().isBadRequest) + + verify(exactly = 0) { commandBus.dispatch(any()) } + } + @Test fun `POST returns error on blank name`() { mockMvc @@ -140,13 +200,25 @@ class WorkspaceControllerTest { fun `GET by id returns workspace detail with repositories under workspace`() { val w = workspace() val r = repository() - every { getQuery.get(w.id) } returns GetWorkspaceQueryService.WorkspaceView(w, emptyList(), listOf(r)) + every { getQuery.get(w.id) } returns + GetWorkspaceQueryService.WorkspaceView( + w, + emptyList(), + listOf( + GetWorkspaceQueryService.WorkspaceRepositoryView( + repository = r, + isPrimary = true, + attachedAt = Instant.now(), + ), + ), + ) mockMvc .perform(get("/api/v1/workspaces/${w.id.value}")) .andExpect(status().isOk) .andExpect(jsonPath("$.workspace.id").value(w.id.value.toString())) .andExpect(jsonPath("$.workspace.repositories[0].id").value(r.id.value.toString())) .andExpect(jsonPath("$.workspace.repositories[0].name").value("personal-stack")) + .andExpect(jsonPath("$.workspace.repositories[0].isPrimary").value(true)) .andExpect(jsonPath("$.sessions").isArray) } diff --git a/services/assistant-ui/src/api/generated.ts b/services/assistant-ui/src/api/generated.ts index 5dba7530..b77d15b1 100644 --- a/services/assistant-ui/src/api/generated.ts +++ b/services/assistant-ui/src/api/generated.ts @@ -491,6 +491,9 @@ export interface components { projectId?: string | null; /** Format: uuid */ repositoryId?: string | null; + /** Format: uuid */ + primaryRepositoryId?: string | null; + repositoryIds?: string[] | null; /** * Format: uuid * @deprecated @@ -721,6 +724,9 @@ export interface components { /** Format: uuid */ id: string; name: string; + isPrimary: boolean; + /** Format: date-time */ + attachedAt: string; repoUrl: string; defaultBranch: string; vaultKeyPath: string; @@ -927,8 +933,8 @@ export interface operations { }; }; responses: { - /** @description OK */ - 200: { + /** @description No Content */ + 204: { headers: { [name: string]: unknown; }; diff --git a/services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts b/services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts new file mode 100644 index 00000000..33ce0bee --- /dev/null +++ b/services/assistant-ui/src/features/sessions/__tests__/CreateWorkspaceWizard.test.ts @@ -0,0 +1,215 @@ +import type { Project, ProjectDetail } from '@/features/projects' +import type { Repository, RepositoryDetail } from '@/features/repositories' +import type { Workspace } from '@/features/workspaces' +import { mount } from '@vue/test-utils' +import { createPinia, setActivePinia } from 'pinia' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createMemoryHistory, createRouter } from 'vue-router' +import CreateWorkspaceWizard from '../components/CreateWorkspaceWizard.vue' + +vi.mock('@personal-stack/vue-common', () => ({ + FormErrors: { + props: ['error'], + template: '
{{ error }}
', + }, + FormField: { + props: ['label'], + template: '', + }, + SubmitButton: { + props: ['disabled', 'label', 'type'], + emits: ['click'], + template: + '', + }, + useFormErrors: () => ({ + general: { value: null }, + clear: vi.fn(), + captureFromCatch: vi.fn(), + fieldErrorFor: vi.fn(() => null), + }), + useMutationState: () => ({ + status: { value: 'idle' }, + run: async (fn: () => Promise) => fn(), + }), + useToast: () => ({ + success: vi.fn(), + errorFromCatch: vi.fn(), + }), +})) + +const listProjects = vi.fn<() => Promise>() +const getProject = vi.fn<(id: string) => Promise>() +vi.mock('@/features/projects/services/projectsService', () => ({ + listProjects: () => listProjects(), + getProject: (id: string) => getProject(id), + createProject: vi.fn(), + linkRepository: vi.fn(), + unlinkRepository: vi.fn(), + addLink: vi.fn(), + removeLink: vi.fn(), + attachKey: vi.fn(), +})) + +const getRepository = vi.fn<(id: string) => Promise>() +vi.mock('@/features/repositories/services/repositoriesService', () => ({ + listRepositories: vi.fn(), + getRepository: (id: string) => getRepository(id), + createRepository: vi.fn(), + attachDeployKey: vi.fn(), + deleteRepository: vi.fn(), + verifyRepositoryAccess: vi.fn(), +})) + +const createWorkspace = vi.fn() +const attachRepository = vi.fn() +vi.mock('@/features/workspaces/services/workspaceService', () => ({ + listWorkspaces: vi.fn(), + getWorkspace: vi.fn(), + createWorkspace: (...args: unknown[]) => createWorkspace(...args), + destroyWorkspace: vi.fn(), + attachRepository: (...args: unknown[]) => attachRepository(...args), + detachRepository: vi.fn(), + startSession: vi.fn(), + stopSession: vi.fn(), + getTurns: vi.fn(), + sendInput: vi.fn(), +})) + +function fakeProject(over: Partial = {}): Project { + return { + id: 'project-1', + name: 'Project One', + slug: 'project-one', + description: '', + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +function fakeRepository(over: Partial = {}): Repository { + return { + id: 'repo-primary', + name: 'primary', + repoUrl: 'git@github.com:owner/primary.git', + defaultBranch: 'main', + vaultKeyPath: 'secret/data/agents/repositories/repo-primary', + deployKeyFingerprint: 'SHA256:primary', + deployKeyAddedAt: '2026-05-20T10:00:00Z', + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +function fakeWorkspace(over: Partial = {}): Workspace { + return { + id: 'ws-new', + name: 'workspace', + repoUrl: 'git@github.com:owner/primary.git', + branch: 'trunk', + podName: null, + gatewayEndpoint: null, + status: 'PENDING', + kind: 'REPO_BACKED', + projectId: 'project-1', + repositoryId: 'repo-primary', + githubLinkId: null, + createdAt: '2026-05-20T10:00:00Z', + updatedAt: '2026-05-20T10:00:00Z', + ...over, + } +} + +async function mountWizard() { + const router = createRouter({ + history: createMemoryHistory(), + routes: [ + { path: '/sessions', component: { template: '
' } }, + { path: '/sessions/workspace/:id', component: { template: '
' } }, + { path: '/projects', component: { template: '
' } }, + { path: '/repositories', component: { template: '
' } }, + { path: '/repositories/:id', component: { template: '
' } }, + ], + }) + await router.push('/sessions') + await router.isReady() + + const wrapper = mount(CreateWorkspaceWizard, { + props: { open: true }, + global: { plugins: [router] }, + }) + await flush() + return { router, wrapper } +} + +describe('createWorkspaceWizard', () => { + beforeEach(() => { + setActivePinia(createPinia()) + listProjects.mockReset() + getProject.mockReset() + getRepository.mockReset() + createWorkspace.mockReset() + attachRepository.mockReset() + }) + + it('creates with a primary repository and selected repository ids', async () => { + const project = fakeProject() + const primary = fakeRepository({ defaultBranch: 'trunk' }) + const extra = fakeRepository({ + id: 'repo-extra', + name: 'extra', + repoUrl: 'git@github.com:owner/extra.git', + vaultKeyPath: 'secret/data/agents/repositories/repo-extra', + deployKeyFingerprint: 'SHA256:extra', + }) + const skipped = fakeRepository({ + id: 'repo-skipped', + name: 'skipped', + repoUrl: 'git@github.com:owner/skipped.git', + vaultKeyPath: 'secret/data/agents/repositories/repo-skipped', + deployKeyFingerprint: 'SHA256:skipped', + }) + listProjects.mockResolvedValue([project]) + getProject.mockResolvedValue({ project, links: [], repositories: [primary, extra, skipped] }) + getRepository.mockResolvedValue({ repository: primary, attachedProjects: [] }) + createWorkspace.mockResolvedValue(fakeWorkspace()) + + const { router, wrapper } = await mountWizard() + + await wrapper.get('[data-testid="wizard-project-project-1"]').setValue() + await flush() + await wrapper.get('[data-testid="wizard-step1-next"]').trigger('click') + await flush() + await wrapper.get('[data-testid="wizard-repo-primary-repo-primary"]').setValue() + await flush() + await wrapper.get('[data-testid="wizard-repo-checkbox-repo-extra"]').setValue(true) + await flush() + await wrapper.get('[data-testid="wizard-step2-next"]').trigger('click') + await flush() + await wrapper.get('[data-testid="wizard-name"]').setValue('workspace') + await wrapper.get('[data-testid="wizard-submit"]').trigger('click') + await flush() + + expect(createWorkspace).toHaveBeenCalledWith({ + name: 'workspace', + kind: 'REPO_BACKED', + projectId: 'project-1', + repositoryId: 'repo-primary', + primaryRepositoryId: 'repo-primary', + repositoryIds: ['repo-primary', 'repo-extra'], + branch: 'trunk', + }) + expect(attachRepository).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(router.currentRoute.value.fullPath).toBe('/sessions/workspace/ws-new') + }) + }) +}) + +async function flush(): Promise { + await Promise.resolve() + await Promise.resolve() + await Promise.resolve() +} diff --git a/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue b/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue index 6c8515d9..503d0f0d 100644 --- a/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue +++ b/services/assistant-ui/src/features/sessions/components/CreateWorkspaceWizard.vue @@ -32,7 +32,8 @@ const toast = useToast() const step = ref<'pick-project' | 'pick-repo' | 'pick-branch'>('pick-project') const selectedProjectId = ref(null) -const selectedRepositoryId = ref(null) +const selectedPrimaryRepositoryId = ref(null) +const selectedRepositoryIds = ref([]) const branch = ref('main') const name = ref('') @@ -53,7 +54,8 @@ onMounted(async () => { watch(selectedProjectId, async (id) => { if (!id) return - selectedRepositoryId.value = null + selectedPrimaryRepositoryId.value = null + selectedRepositoryIds.value = [] try { await projects.open(id) } catch (e) { @@ -61,7 +63,7 @@ watch(selectedProjectId, async (id) => { } }) -watch(selectedRepositoryId, async (id) => { +watch(selectedPrimaryRepositoryId, async (id) => { if (!id) return // Load detail so we can read the default branch + key state. try { @@ -77,13 +79,54 @@ watch(selectedRepositoryId, async (id) => { }) const projectRepos = computed(() => projects.repositories) -const selectedRepo = computed(() => - selectedRepositoryId.value ? (repos.detailById[selectedRepositoryId.value]?.repository ?? null) : null, +const selectedRepo = computed(() => { + const primaryId = selectedPrimaryRepositoryId.value + if (!primaryId) return null + + const loadedRepo = repos.detailById[primaryId]?.repository + if (loadedRepo) return loadedRepo + + return projectRepos.value.find((r) => r.id === primaryId) ?? null +}) +const selectedRepositories = computed(() => { + const ids = new Set(selectedRepositoryIds.value) + return projectRepos.value.filter((r) => ids.has(r.id)) +}) +const additionalRepositoriesMissingKeys = computed(() => + selectedRepositories.value.filter((r) => r.id !== selectedPrimaryRepositoryId.value && !r.deployKeyFingerprint), ) const keyAttached = computed(() => Boolean(selectedRepo.value?.deployKeyFingerprint)) +const selectedRepositoryCount = computed(() => selectedRepositoryIds.value.length) + +function ensureRepositorySelected(repositoryId: string): void { + if (selectedRepositoryIds.value.includes(repositoryId)) return + selectedRepositoryIds.value = [...selectedRepositoryIds.value, repositoryId] +} + +function checked(event: Event): boolean { + return event.target instanceof HTMLInputElement && event.target.checked +} + +function onRepositorySelectionChange(repositoryId: string, isSelected: boolean): void { + if (isSelected) { + ensureRepositorySelected(repositoryId) + if (!selectedPrimaryRepositoryId.value) selectedPrimaryRepositoryId.value = repositoryId + return + } + + selectedRepositoryIds.value = selectedRepositoryIds.value.filter((id) => id !== repositoryId) + if (selectedPrimaryRepositoryId.value === repositoryId) { + selectedPrimaryRepositoryId.value = selectedRepositoryIds.value[0] ?? null + } +} + +function onPrimaryRepositoryChange(repositoryId: string): void { + selectedPrimaryRepositoryId.value = repositoryId + ensureRepositorySelected(repositoryId) +} async function onSubmit(): Promise { - if (!selectedProjectId.value || !selectedRepositoryId.value || !name.value.trim()) return + if (!selectedProjectId.value || !selectedPrimaryRepositoryId.value || !name.value.trim()) return formErrors.clear() try { await create.run(async () => { @@ -91,7 +134,9 @@ async function onSubmit(): Promise { name: name.value.trim(), kind: 'REPO_BACKED', projectId: selectedProjectId.value, - repositoryId: selectedRepositoryId.value, + repositoryId: selectedPrimaryRepositoryId.value, + primaryRepositoryId: selectedPrimaryRepositoryId.value, + repositoryIds: selectedRepositoryIds.value, branch: branch.value.trim() || 'main', }) emit('created', ws.id) @@ -101,7 +146,8 @@ async function onSubmit(): Promise { // Reset for next time. step.value = 'pick-project' selectedProjectId.value = null - selectedRepositoryId.value = null + selectedPrimaryRepositoryId.value = null + selectedRepositoryIds.value = [] branch.value = 'main' name.value = '' } catch (e) { @@ -119,7 +165,7 @@ async function onSubmit(): Promise {
  1. 1. Project
  2. -
  3. 2. Repository
  4. +
  5. 2. Repositories
  6. 3. Branch + name
@@ -166,7 +212,8 @@ async function onSubmit(): Promise {

- Pick a repository from the project's pool. Need a different one? + Select the repositories to clone into this workspace, then choose one primary repository for the branch and + split workflow defaults. Need a different one? Add a repository @@ -174,25 +221,37 @@ async function onSubmit(): Promise {

  • -

No repositories linked to this project yet.

@@ -202,17 +261,27 @@ async function onSubmit(): Promise { class="rounded-md border border-amber-500/40 bg-amber-500/5 p-3 text-sm text-amber-300" data-testid="wizard-missing-key-warning" > - The selected repository has no deploy key yet — the runner Pod won't be able to clone it. - Attach a key + The primary repository has no deploy key yet — the runner Pod won't be able to clone it. + Attach a key first.
+
+ {{ additionalRepositoriesMissingKeys.length }} selected + {{ additionalRepositoriesMissingKeys.length === 1 ? 'repository has' : 'repositories have' }} no deploy key yet; + additional repositories clone with the GitHub App token when the runner starts. +
+
diff --git a/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts index 016baab2..5bc639ae 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/SessionTabs.test.ts @@ -75,4 +75,18 @@ describe('sessionTabs', () => { await wrapper.find('[data-testid="session-tab-rename"]').trigger('click') expect(wrapper.emitted('select')).toBeUndefined() }) + + it('renders vertical session controls without nesting the stop action in the selector', async () => { + const session = fakeSession() + const wrapper = mount(SessionTabs, { + props: { sessions: [session], activeId: session.id, orientation: 'vertical' }, + }) + + await wrapper.get(`[data-testid="session-tab-${session.id}"]`).trigger('click') + await wrapper.get(`[data-testid="session-tab-stop-${session.id}"]`).trigger('click') + + expect(wrapper.get('[data-testid="session-tabs"]').attributes('aria-label')).toBe('Agent sessions') + expect(wrapper.emitted('select')).toEqual([[session.id]]) + expect(wrapper.emitted('stop')).toEqual([[session.id]]) + }) }) diff --git a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts index 32d6a251..de23195d 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/WorkspaceView.test.ts @@ -159,7 +159,6 @@ async function mountView() { const wrapper = mount(WorkspaceView, { global: { plugins: [router], - stubs: { AgentKindPicker: true, SessionTabs: true }, }, }) await flush() @@ -263,6 +262,24 @@ describe('workspaceView terminal persistence', () => { expect(sendInput.mock.calls[0]?.[2]).not.toContain('large document') }) + it('keeps workspace controls in the sidebar', async () => { + getWorkspace.mockResolvedValue(detail([fakeSession({ id: 'sess-a', gatewayAgentId: 'abc12345' })])) + + const wrapper = await mountView() + const sidebar = wrapper.get('[data-testid="workspace-sidebar"]') + + expect(wrapper.get('[data-testid="workspace-view-header"]').find('[data-testid="stage-input-open"]').exists()).toBe( + false, + ) + expect(sidebar.find('[data-testid="workspace-agent-panel"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-new-agent"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-tools-panel"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="stage-input-open"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-sessions-panel"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="session-tabs"]').exists()).toBe(true) + expect(sidebar.find('[data-testid="workspace-repositories-panel"]').exists()).toBe(true) + }) + it('renders attached repositories, marks the primary, and shows split guidance', async () => { const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) const destination = fakeWorkspaceRepository({ @@ -281,7 +298,7 @@ describe('workspaceView terminal persistence', () => { expect(wrapper.find('[data-testid="workspace-detach-repository-repo-primary"]').exists()).toBe(false) expect(wrapper.find('[data-testid="workspace-detach-repository-repo-dest"]').exists()).toBe(true) expect(wrapper.find('[data-testid="workspace-split-command"]').text()).toBe( - 'council split --path path/to/subtree --dest owner/split-dest', + 'cd /workspace/primary && council split --path path/to/subtree --dest owner/split-dest', ) expect(wrapper.find('[data-testid="split-follow-up"]').text()).toContain( 'Keep owner/split-dest linked in the project repository pool', @@ -307,6 +324,29 @@ describe('workspaceView terminal persistence', () => { ) }) + it('sends the split command to the active running session', async () => { + const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) + const destination = fakeWorkspaceRepository({ + id: 'repo-dest', + name: 'split-dest', + repoUrl: 'git@github.com:owner/split-dest.git', + }) + getWorkspace.mockResolvedValue( + detail([fakeSession({ id: 'sess-a', gatewayAgentId: 'abc12345' })], { repositories: [primary, destination] }), + ) + + const wrapper = await mountView() + await wrapper.find('[data-testid="split-send-command"]').trigger('click') + await flush() + + expect(sendInput).toHaveBeenCalledWith( + 'ws-1', + 'sess-a', + 'cd /workspace/primary && council split --path path/to/subtree --dest owner/split-dest', + true, + ) + }) + it('loads candidate repositories, filters attached repositories, and attaches the selected one', async () => { const primary = fakeWorkspaceRepository({ id: 'repo-primary', name: 'primary', isPrimary: true }) const extra = fakeWorkspaceRepository({ @@ -321,7 +361,7 @@ describe('workspaceView terminal persistence', () => { fakeRepository({ id: 'repo-primary', name: 'primary' }), fakeRepository({ id: 'repo-extra', name: 'extra', repoUrl: 'git@github.com:owner/extra.git' }), ]) - attachRepository.mockResolvedValue([extra]) + attachRepository.mockResolvedValue(undefined) const wrapper = await mountView() await wrapper.find('[data-testid="workspace-add-repository"]').trigger('click') diff --git a/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts b/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts index 9b347f37..778607c0 100644 --- a/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts +++ b/services/assistant-ui/src/features/workspaces/__tests__/workspaces.store.test.ts @@ -178,9 +178,9 @@ describe('useWorkspacesStore', () => { expect(store.activeWorkspace).toBeNull() }) - it('attachRepository attaches to the active workspace and refreshes detail', async () => { + it('attachRepository refreshes detail without reading an attach response body', async () => { const repo = fakeRepository({ id: 'repo-a' }) - mocked.attachRepository.mockResolvedValue([repo]) + mocked.attachRepository.mockResolvedValue() mocked.getWorkspace.mockResolvedValue({ workspace: { ...fakeWorkspace(), repositories: [repo] }, sessions: [], diff --git a/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue b/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue index b04291e7..cae90e57 100644 --- a/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue +++ b/services/assistant-ui/src/features/workspaces/components/AgentKindPicker.vue @@ -1,7 +1,9 @@