From 3d915db9915337c5d5ea7e740a1234709c149919 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 18 Aug 2026 19:25:01 +0300 Subject: [PATCH 01/22] feat(sandbox): Add managed git-agent deployment and HTTPS sandbox support Introduce durable token enrollment, HTTPS mailbox and sidecar transport, and hardened Docker/Kubernetes deployment with address preflight and lifecycle controls. Persist remote task history and expose sandbox catalogs, credential publishing, deployment management, and runtime inspection through the API and web UI. BREAKING CHANGE: replace single-use git-agent join-token APIs and replay semantics with durable captain tokens; callers must use AdmitToken and context-aware enrollment. --- README.md | 117 ++ Taskfile.yaml | 53 +- migrations/35_git_agent.pg.hcl | 269 ++++ .../git_agent_schema_integration_test.go | 127 ++ pkg/cli/db_context_http.go | 9 + pkg/cli/gitagent.go | 271 +++- pkg/cli/gitagent_agent_api_ginkgo_test.go | 208 ++++ pkg/cli/gitagent_credentials.go | 136 +++ pkg/cli/gitagent_credentials_test.go | 172 +++ pkg/cli/gitagent_deploy.go | 533 ++++++++ pkg/cli/gitagent_deploy_credentials_test.go | 85 ++ pkg/cli/gitagent_deploy_detect.go | 396 ++++++ pkg/cli/gitagent_deploy_detect_test.go | 495 ++++++++ pkg/cli/gitagent_deploy_ingress.go | 286 +++++ pkg/cli/gitagent_deploy_ingress_test.go | 209 ++++ pkg/cli/gitagent_deploy_reach.go | 276 +++++ pkg/cli/gitagent_deploy_reach_test.go | 275 +++++ pkg/cli/gitagent_deploy_run.go | 375 ++++++ pkg/cli/gitagent_deploy_test.go | 294 +++++ .../gitagent_deployment_edit_ginkgo_test.go | 302 +++++ pkg/cli/gitagent_deployments.go | 279 +++++ pkg/cli/gitagent_directory.go | 233 +++- pkg/cli/gitagent_directory_test.go | 229 ++++ pkg/cli/gitagent_e2e_test.go | 35 +- pkg/cli/gitagent_hook.go | 19 + pkg/cli/gitagent_mailbox_record.go | 150 +++ pkg/cli/gitagent_restart_ginkgo_test.go | 62 + pkg/cli/gitagent_serve.go | 341 +++++- pkg/cli/gitagent_serve_https.go | 236 ++++ pkg/cli/gitagent_serve_options.go | 87 ++ pkg/cli/gitagent_serve_test.go | 375 ++++++ pkg/cli/gitagent_test.go | 141 ++- pkg/cli/gitagent_undeploy.go | 157 +++ pkg/cli/prompt_schema_build.go | 4 +- pkg/cli/prompt_schema_sandboxes.go | 200 +++ pkg/cli/prompt_schema_sandboxes_test.go | 327 +++++ pkg/cli/prompt_schema_test.go | 21 +- pkg/cli/secret_catalog.go | 50 +- pkg/cli/serve_git.go | 227 ++++ pkg/cli/serve_sandbox.go | 224 ++++ pkg/cli/serve_sandbox_deploy.go | 368 ++++++ pkg/cli/serve_sandbox_deploy_test.go | 450 +++++++ pkg/cli/serve_sandbox_pickers.go | 160 +++ pkg/cli/serve_sandbox_test.go | 338 +++++ pkg/cli/serve_sandbox_update.go | 85 ++ pkg/cli/serve_sandbox_whoami.go | 140 +++ pkg/cli/webapp/dist/.gitkeep | 0 pkg/cli/webapp/src/App.tsx | 6 + pkg/cli/webapp/src/GitAgentDeployForm.tsx | 304 +++++ pkg/cli/webapp/src/GitAgentDeployModal.tsx | 397 ++++++ pkg/cli/webapp/src/GitAgentDeployResult.tsx | 115 ++ pkg/cli/webapp/src/GitAgentDeployRouting.tsx | 324 +++++ pkg/cli/webapp/src/GitAgentEnrollModal.tsx | 190 +++ pkg/cli/webapp/src/GitAgentTasks.tsx | 225 ++++ pkg/cli/webapp/src/GitAgentWhoami.tsx | 168 +++ pkg/cli/webapp/src/PromptWorkbench.tsx | 10 + pkg/cli/webapp/src/SandboxesPage.test.tsx | 1087 +++++++++++++++++ pkg/cli/webapp/src/SandboxesPage.tsx | 492 ++++++++ pkg/cli/webapp/src/SandboxesPageEdit.test.tsx | 183 +++ .../src/gitAgentDeployValidation.test.ts | 199 +++ .../webapp/src/gitAgentDeployValidation.ts | 135 ++ pkg/cli/webapp/src/gitAgentDeploymentData.ts | 236 ++++ pkg/cli/webapp/src/sandboxData.ts | 336 +++++ pkg/cli/webapp/src/shellHelpers.ts | 9 + pkg/cli/webapp/vite.config.ts | 4 + pkg/container/base/Dockerfile | 16 +- pkg/container/base/Dockerfile.flanksource | 4 + pkg/container/base/Dockerfile.lab | 16 + pkg/container/base/deps.yaml | 68 -- pkg/container/base_image.go | 6 - pkg/database/git_agent_store.go | 501 ++++++++ .../git_agent_store_integration_test.go | 260 ++++ .../deploy/credentials_ginkgo_test.go | 117 ++ pkg/gitagent/deploy/deploy_suite_test.go | 13 + pkg/gitagent/deploy/docker.go | 227 ++++ pkg/gitagent/deploy/docker_ginkgo_test.go | 169 +++ pkg/gitagent/deploy/kubernetes.go | 326 +++++ .../deploy/kubernetes_apply_ginkgo_test.go | 245 ++++ pkg/gitagent/deploy/kubernetes_ingress.go | 138 +++ .../deploy/kubernetes_ingress_ginkgo_test.go | 198 +++ pkg/gitagent/deploy/kubernetes_objects.go | 292 +++++ .../deploy/kubernetes_objects_ginkgo_test.go | 267 ++++ pkg/gitagent/deploy/kubernetes_traefik.go | 90 ++ pkg/gitagent/deploy/namespace_ginkgo_test.go | 64 + pkg/gitagent/deploy/plan.go | 317 +++++ pkg/gitagent/deploy/plan_ginkgo_test.go | 153 +++ pkg/gitagent/deploy/security.go | 145 +++ pkg/gitagent/deploy/security_ginkgo_test.go | 95 ++ pkg/gitagent/dispatch.go | 49 +- pkg/gitagent/dispatchtoken.go | 115 ++ pkg/gitagent/dispatchtoken_ginkgo_test.go | 173 +++ pkg/gitagent/enroll.go | 105 +- pkg/gitagent/enrollhttps.go | 99 ++ pkg/gitagent/httpclient.go | 175 +++ pkg/gitagent/httpserver.go | 292 +++++ pkg/gitagent/httpserver_ginkgo_test.go | 260 ++++ .../httpserver_sidecar_ginkgo_test.go | 240 ++++ pkg/gitagent/probe.go | 180 +++ pkg/gitagent/probe_ginkgo_test.go | 160 +++ pkg/gitagent/relay.go | 36 +- pkg/gitagent/scan.go | 276 +++++ pkg/gitagent/scan_ginkgo_test.go | 211 ++++ pkg/gitagent/server.go | 22 +- pkg/gitagent/server_ginkgo_test.go | 36 +- pkg/gitagent/tlscert.go | 272 +++++ pkg/gitagent/tlscert_ginkgo_test.go | 172 +++ pkg/gitagent/tokenfile.go | 62 + pkg/monitor/backfill.go | 4 + pkg/monitor/gitagent.go | 204 ++++ pkg/monitor/gitagent_integration_test.go | 138 +++ pkg/sandbox/adapter/gitagent.go | 64 +- .../gitagent_dispatch_credentials_test.go | 118 ++ pkg/sandbox/runtime_sockets.go | 28 + 113 files changed, 21049 insertions(+), 415 deletions(-) create mode 100644 migrations/35_git_agent.pg.hcl create mode 100644 migrations/git_agent_schema_integration_test.go create mode 100644 pkg/cli/gitagent_agent_api_ginkgo_test.go create mode 100644 pkg/cli/gitagent_credentials.go create mode 100644 pkg/cli/gitagent_credentials_test.go create mode 100644 pkg/cli/gitagent_deploy.go create mode 100644 pkg/cli/gitagent_deploy_credentials_test.go create mode 100644 pkg/cli/gitagent_deploy_detect.go create mode 100644 pkg/cli/gitagent_deploy_detect_test.go create mode 100644 pkg/cli/gitagent_deploy_ingress.go create mode 100644 pkg/cli/gitagent_deploy_ingress_test.go create mode 100644 pkg/cli/gitagent_deploy_reach.go create mode 100644 pkg/cli/gitagent_deploy_reach_test.go create mode 100644 pkg/cli/gitagent_deploy_run.go create mode 100644 pkg/cli/gitagent_deploy_test.go create mode 100644 pkg/cli/gitagent_deployment_edit_ginkgo_test.go create mode 100644 pkg/cli/gitagent_deployments.go create mode 100644 pkg/cli/gitagent_directory_test.go create mode 100644 pkg/cli/gitagent_mailbox_record.go create mode 100644 pkg/cli/gitagent_restart_ginkgo_test.go create mode 100644 pkg/cli/gitagent_serve_https.go create mode 100644 pkg/cli/gitagent_serve_options.go create mode 100644 pkg/cli/gitagent_serve_test.go create mode 100644 pkg/cli/gitagent_undeploy.go create mode 100644 pkg/cli/prompt_schema_sandboxes.go create mode 100644 pkg/cli/prompt_schema_sandboxes_test.go create mode 100644 pkg/cli/serve_git.go create mode 100644 pkg/cli/serve_sandbox.go create mode 100644 pkg/cli/serve_sandbox_deploy.go create mode 100644 pkg/cli/serve_sandbox_deploy_test.go create mode 100644 pkg/cli/serve_sandbox_pickers.go create mode 100644 pkg/cli/serve_sandbox_test.go create mode 100644 pkg/cli/serve_sandbox_update.go create mode 100644 pkg/cli/serve_sandbox_whoami.go create mode 100644 pkg/cli/webapp/dist/.gitkeep create mode 100644 pkg/cli/webapp/src/GitAgentDeployForm.tsx create mode 100644 pkg/cli/webapp/src/GitAgentDeployModal.tsx create mode 100644 pkg/cli/webapp/src/GitAgentDeployResult.tsx create mode 100644 pkg/cli/webapp/src/GitAgentDeployRouting.tsx create mode 100644 pkg/cli/webapp/src/GitAgentEnrollModal.tsx create mode 100644 pkg/cli/webapp/src/GitAgentTasks.tsx create mode 100644 pkg/cli/webapp/src/GitAgentWhoami.tsx create mode 100644 pkg/cli/webapp/src/SandboxesPage.test.tsx create mode 100644 pkg/cli/webapp/src/SandboxesPage.tsx create mode 100644 pkg/cli/webapp/src/SandboxesPageEdit.test.tsx create mode 100644 pkg/cli/webapp/src/gitAgentDeployValidation.test.ts create mode 100644 pkg/cli/webapp/src/gitAgentDeployValidation.ts create mode 100644 pkg/cli/webapp/src/gitAgentDeploymentData.ts create mode 100644 pkg/cli/webapp/src/sandboxData.ts create mode 100644 pkg/container/base/Dockerfile.flanksource create mode 100644 pkg/container/base/Dockerfile.lab delete mode 100644 pkg/container/base/deps.yaml create mode 100644 pkg/database/git_agent_store.go create mode 100644 pkg/database/git_agent_store_integration_test.go create mode 100644 pkg/gitagent/deploy/credentials_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/deploy_suite_test.go create mode 100644 pkg/gitagent/deploy/docker.go create mode 100644 pkg/gitagent/deploy/docker_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/kubernetes.go create mode 100644 pkg/gitagent/deploy/kubernetes_apply_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/kubernetes_ingress.go create mode 100644 pkg/gitagent/deploy/kubernetes_ingress_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/kubernetes_objects.go create mode 100644 pkg/gitagent/deploy/kubernetes_objects_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/kubernetes_traefik.go create mode 100644 pkg/gitagent/deploy/namespace_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/plan.go create mode 100644 pkg/gitagent/deploy/plan_ginkgo_test.go create mode 100644 pkg/gitagent/deploy/security.go create mode 100644 pkg/gitagent/deploy/security_ginkgo_test.go create mode 100644 pkg/gitagent/dispatchtoken.go create mode 100644 pkg/gitagent/dispatchtoken_ginkgo_test.go create mode 100644 pkg/gitagent/enrollhttps.go create mode 100644 pkg/gitagent/httpclient.go create mode 100644 pkg/gitagent/httpserver.go create mode 100644 pkg/gitagent/httpserver_ginkgo_test.go create mode 100644 pkg/gitagent/httpserver_sidecar_ginkgo_test.go create mode 100644 pkg/gitagent/probe.go create mode 100644 pkg/gitagent/probe_ginkgo_test.go create mode 100644 pkg/gitagent/scan.go create mode 100644 pkg/gitagent/scan_ginkgo_test.go create mode 100644 pkg/gitagent/tlscert.go create mode 100644 pkg/gitagent/tlscert_ginkgo_test.go create mode 100644 pkg/gitagent/tokenfile.go create mode 100644 pkg/monitor/gitagent.go create mode 100644 pkg/monitor/gitagent_integration_test.go create mode 100644 pkg/sandbox/adapter/gitagent_dispatch_credentials_test.go create mode 100644 pkg/sandbox/runtime_sockets.go diff --git a/README.md b/README.md index 5e9de221..df8866ff 100644 --- a/README.md +++ b/README.md @@ -650,6 +650,123 @@ Publishing to `flanksource/captain` on Docker Hub and GHCR (`linux/amd64` + the tag you want to ship, and only once that tag's release exists, since the image installs `captain` from the latest GitHub release. +### Running a git-agent sidecar from it + +`captain sandbox git-agent deploy` enrolls an agent and places its sidecar on Docker or +Kubernetes using this image: + +```bash +captain sandbox git-agent serve --role mailbox --listen :7422 & # once, on the supervisor +captain sandbox git-agent deploy worker-01 --target docker --dry-run +captain sandbox git-agent deploy worker-01 --target docker +``` + +It applies by default; `--dry-run` prints every intended mutation first. Before minting +anything it proves a live mailbox is listening and presenting this host's own key, then +resolves the two addresses the protocol needs in opposite directions — the one the agent +reaches the mailbox on, and the one the supervisor dispatches back to. Leaving either to +be derived produces an agent that enrols, looks healthy in `git-agent list`, and fails at +the first dispatch, so `deploy` refuses rather than guesses. For `--target kubernetes` +that means `--supervisor-address` is required unless captain is itself running in the +cluster: a laptop is not routable from a managed cluster. + +The sidecar runs unprivileged (uid 501, all capabilities dropped, no privilege +escalation, read-only root with scratch on `/tmp`) and never receives a container runtime +socket — that would be a full host escape, and there is no flag to grant one. Sizing is +`--cpu-limit` / `--memory-limit` / `--storage`; the image is `--image`. The captain token +reaches the workload as a mounted file, never in argv, and `undeploy` tears the workload +down and revokes both the key and the token together. + +### Over HTTPS, with no separate mailbox process + +The mailbox can be hosted on the ordinary `captain serve` instead of its own SSH +listener. That is the process holding the database the tokens live in, so one server +serves the API, the UI and the git endpoint: + +```bash +captain serve --host 0.0.0.0 --tls --tls-host supervisor.internal # supervisor +captain sandbox git-agent add worker-01 --endpoint https://supervisor.internal:9020 +# run the printed join command on the agent host +``` + +`--tls` generates a self-signed certificate beside the git-agent keys and reuses it; +`--tls-cert` / `--tls-key` supply a real one instead. The certificate is never silently +re-issued, because every enrolled agent pins it — a certificate that does not cover an +address you name with `--tls-host` is an error at startup rather than a push failure +weeks later. An enrolling agent receives that certificate over the exchange it already +pinned, and verifies later relays against it. + +Requests from `127.0.0.1` need no token, so the local UI, CLI and hooks are unaffected; +anything off-box needs one. See `captain token` to mint, list and revoke them. Two +scopes exist: `git` reaches the push endpoint only, and `api` reaches the command API — +an agent gets `git`, so a leaked agent token cannot run commands on the supervisor. + +Tokens are durable rather than single-use, which is what lets a restarting or rescheduled +sidecar re-present the one it already has. `captain sandbox git-agent add --pool +--max-agents 5` mints one token that names each member as it arrives, for a scaled +deployment; members of a pool share a secret, so a token bound to a single agent remains +the default and stronger choice. + +## Sandbox credentials + +A sandbox needs a way to reach a model provider. An API key can simply be passed through, +but a **subscription** login cannot: Claude Code keeps its OAuth credential in the macOS +Keychain (item `Claude Code-credentials`) or `~/.claude/.credentials.json`, and Codex keeps +a ChatGPT-plan credential in `~/.codex/auth.json`, which has no env-var form at all. + +`captain sandbox credentials` mirrors those logins **with the refresh token stripped**, so a +sandbox holds an access token it can use but cannot use to mint another. For Claude that +also drops the entire `mcpOAuth` map, which holds unrelated client secrets for every MCP +server the user has authorized. + +```bash +captain sandbox credentials status # expiry per provider, and where it publishes +captain sandbox credentials sync --directory ~/.captain/sandbox/credentials +captain sandbox credentials sync --namespace agents # into a Kubernetes Secret +``` + +Because the copy cannot refresh itself, the supervisor has to. `captain serve` runs a +republish loop whose schedule comes from the credential's own expiry rather than a fixed +interval, so what lands in the target is as fresh as the host can make it. An +already-expired source is refused rather than published — a dead token in a Secret would +surface as an unexplained `401` inside an agent instead of an error where it was caused. +The host CLIs refresh their own tokens when used, so the fix is to use them. + +```yaml +# ~/.captain.yaml — publishing is opt-in; with no entries nothing is mirrored +credentials: + refreshMargin: 5m + publish: + - providers: [claude, codex] + directory: ~/.captain/sandbox/credentials # docker bind-mounts this + - providers: [claude, codex] + kubernetes: { namespace: agents, secret: captain-agent-credentials } +``` + +Local `srt` and `container` sandboxes consume the same credentials through the backend's +`tokens:` block, which also finally makes the cloud providers beside them take effect: + +```yaml +sandbox: + backends: + local: + kind: srt + tokens: { claude: {}, codex: {}, github: {} } +``` + +The redacted copy lands in a private directory and the CLI is pointed at it with +`CLAUDE_CONFIG_DIR` / `CODEX_HOME`. Once that replacement exists the host's own credential +file is added to the sandbox's deny-read list, so the sandboxed CLI can no longer reach the +refresh token — only the credential file, not the whole state directory, since the CLI still +needs its own settings and history. + +A deployed sidecar receives the Secret as a read-only **directory** mount at +`/run/captain/credentials` (`--credentials-secret`, or `--credentials-dir` for docker) and +copies each credential to the path its CLI reads. The mount is a directory rather than a +`subPath` deliberately: kubelet never updates a `subPath` volume after the pod starts, which +would pin the sidecar to the first credential it ever saw. The Secret is shared across every +agent in the namespace, so `undeploy` leaves it alone. + ## Dependencies and stack Primary stack: diff --git a/Taskfile.yaml b/Taskfile.yaml index c6923e4b..e4ea20d1 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -3,6 +3,9 @@ version: "3" vars: BINARY: captain BIN_DIR: .bin + APP_NAME: captain + REGISTRY: '{{.REGISTRY | default "docker.lab"}}' + IMAGE: "{{.REGISTRY}}/{{.APP_NAME}}" tasks: default: @@ -27,9 +30,14 @@ tasks: echo false fi LDFLAGS: -s -w -X main.version={{.VERSION}} -X main.commit={{.COMMIT}} -X main.date={{.DATE}} -X main.dirty={{.DIRTY}} + OUTPUT: '{{.OUTPUT | default (printf "%s/%s" .BIN_DIR .BINARY)}}' cmds: - - mkdir -p {{.BIN_DIR}} - - go build -ldflags "{{.LDFLAGS}}" -o {{.BIN_DIR}}/{{.BINARY}} ./cmd/captain + - mkdir -p {{dir .OUTPUT}} + # GOOS set (image:lab) means cross-compiling: there is no linux cgo + # toolchain on a macOS host, so that binary drops the sqlite-backed AI + # response cache — opt-in via CacheTTL/CacheDBPath, and it fails loudly + # rather than silently when enabled. + - '{{if .GOOS}}GOOS={{.GOOS}} GOARCH={{.GOARCH}} CGO_ENABLED=0 {{end}}go build -ldflags "{{.LDFLAGS}}" -o {{.OUTPUT}} ./cmd/captain' www:build: desc: Build the embedded Captain web UI @@ -60,6 +68,47 @@ tasks: - CI=true pnpm install --frozen-lockfile - pnpm run dev + image:base: + desc: "Build the agent sandbox base image locally as captain:latest — the FROM of Dockerfile.flanksource and the base image:lab layers onto. Slow (apt, Go, Playwright); rerun only when the Dockerfile's pinned tool versions change." + vars: + GOARCH: '{{.GOARCH | default "amd64"}}' + cmds: + # deps pulls release assets from GitHub; GITHUB_TOKEN in the environment + # lifts the anonymous rate limit the Dockerfile's secret mount expects. + - >- + docker buildx build --platform linux/{{.GOARCH}} --load pkg/container/base + -f pkg/container/base/Dockerfile + {{if .GITHUB_TOKEN}}--secret id=GITHUB_TOKEN,env=GITHUB_TOKEN{{end}} + -t {{.APP_NAME}}:latest + + image:lab: + desc: "Build and push the sandbox image with the working tree's captain binary to the lab registry. Requires image:base once. Defaults REGISTRY=docker.lab; override with REGISTRY=docker.example.com." + # The binary embeds pkg/cli/webapp/dist (serve.go), so the UI is rebuilt + # first or the image ships a stale one. + deps: [www:build] + vars: + VERSION: + sh: git describe --tags --always --dirty 2>/dev/null || echo dev + GOARCH: '{{.GOARCH | default "amd64"}}' + # Supplies everything but captain itself. Built by image:base — nothing is + # published to a registry, because .github/workflows/publish-image.yml is + # manual-only and has never been run. + BASE_IMAGE: '{{.BASE_IMAGE | default "captain:latest"}}' + LAB_DIR: "{{.BIN_DIR}}/lab" + cmds: + - task: build + vars: + GOOS: linux + GOARCH: "{{.GOARCH}}" + OUTPUT: "{{.LAB_DIR}}/captain-linux-{{.GOARCH}}" + # Context is the lab bin dir, not the repo root: the overlay needs one + # binary, and a root context would upload every node_modules tree. + - >- + docker buildx build --platform linux/{{.GOARCH}} --push {{.LAB_DIR}} + -f pkg/container/base/Dockerfile.lab + --build-arg BASE_IMAGE={{.BASE_IMAGE}} + -t {{.IMAGE}}:{{.VERSION}} -t {{.IMAGE}}:latest + lint: desc: Run linters cmds: diff --git a/migrations/35_git_agent.pg.hcl b/migrations/35_git_agent.pg.hcl new file mode 100644 index 00000000..64b7d221 --- /dev/null +++ b/migrations/35_git_agent.pg.hcl @@ -0,0 +1,269 @@ +# Durable run history for tasks dispatched to remote git-agents. +# +# The enrolled-agent roster is deliberately NOT here: ~/.captain.yaml stays the +# single source of truth for it, because the receiver re-reads that file on every +# SSH handshake so a revocation takes effect immediately (SPEC-git-agent-protocol +# R8.5). A second copy in Postgres would be a second source of truth for an +# authorization decision. Only what a run *did* is persisted. +# +# Written by the ingest watcher in captain serve (pkg/monitor), which walks the +# supervisor's mailbox tree. Both tables are upserted on their natural key, so a +# re-scan of unchanged state is a no-op. + +table "captain_git_agent_tasks" { + schema = schema.public + + column "id" { + null = false + type = uuid + default = sql("gen_random_uuid()") + } + # The protocol task id, unique only within its mailbox. + column "task_id" { + null = false + type = text + } + # The mailbox this task was routed through ("mailboxes/.git"). One + # endpoint serves many repositories, so the mailbox is the id's scope. + column "mailbox" { + null = false + type = text + } + # Canonical repository path bound to the mailbox, for display. + column "repository" { + null = true + type = text + } + # The configured sandbox backend that dispatched this task. + column "backend" { + null = true + type = text + } + # The enrolled agent it was dispatched to, when one was pinned or chosen. + column "agent" { + null = true + type = text + } + # Filled opportunistically: persistPromptRun writes the prompt_runs row only + # after the run finishes, so the task row always exists first. The watcher + # resolves this from admission_key on a later pass. + column "prompt_run_id" { + null = true + type = uuid + } + # The originating run's admission key, the handle used to resolve + # prompt_run_id once that row lands. + column "admission_key" { + null = true + type = text + } + column "base" { + null = false + type = text + } + column "dispatch_commit" { + null = false + type = text + } + column "control_commit" { + null = true + type = text + } + column "relay" { + null = true + type = text + } + # The dispatch policy (paths, maxAttempts, maxBlobSize) verbatim. + column "policy" { + null = false + type = jsonb + default = sql("'{}'::jsonb") + } + column "hooks" { + null = true + type = jsonb + } + # Highest attempt seen. Monotonic: the watcher never lowers it. + column "attempts" { + null = false + type = integer + default = 0 + } + column "max_attempts" { + null = false + type = integer + default = 0 + } + column "status" { + null = false + type = enum.captain_git_agent_task_status + default = "dispatched" + } + # The concluding verdict, once one exists. + column "final_status" { + null = true + type = enum.captain_git_agent_verdict_status + } + # Branch the accepted work was integrated onto. + column "integrated_branch" { + null = true + type = text + } + column "error" { + null = true + type = text + } + column "dispatched_at" { + null = false + type = timestamptz + default = sql("now()") + } + column "concluded_at" { + null = true + type = timestamptz + } + column "created_at" { + null = false + type = timestamptz + default = sql("now()") + } + column "updated_at" { + null = false + type = timestamptz + default = sql("now()") + } + + primary_key { + columns = [column.id] + } + + # SET_NULL, not CASCADE: the remote task happened regardless of whether the + # prompt run row is later pruned, and losing that history would be wrong. + foreign_key "captain_git_agent_tasks_prompt_run_id_fkey" { + columns = [column.prompt_run_id] + ref_columns = [table.captain_prompt_runs.column.id] + on_update = NO_ACTION + on_delete = SET_NULL + } + + index "captain_git_agent_tasks_mailbox_task_key" { + unique = true + columns = [column.mailbox, column.task_id] + } + index "captain_git_agent_tasks_status_idx" { + columns = [column.status, column.updated_at] + } + index "captain_git_agent_tasks_dispatched_at_idx" { + columns = [column.dispatched_at] + } + index "captain_git_agent_tasks_agent_idx" { + columns = [column.agent, column.dispatched_at] + where = "agent IS NOT NULL" + } + index "captain_git_agent_tasks_prompt_run_id_idx" { + columns = [column.prompt_run_id] + where = "prompt_run_id IS NOT NULL" + } + index "captain_git_agent_tasks_admission_key_idx" { + columns = [column.admission_key] + where = "admission_key IS NOT NULL" + } + + check "captain_git_agent_tasks_attempts_nonnegative" { + expr = "attempts >= 0 AND max_attempts >= 0" + } + check "captain_git_agent_tasks_time_order" { + expr = "concluded_at IS NULL OR concluded_at >= dispatched_at" + } + check "captain_git_agent_tasks_task_id_nonempty" { + expr = "length(btrim(task_id)) > 0" + } +} + +# One tier's decision on one attempt. Findings ride along as jsonb rather than a +# third table: they are always read with their attempt and never queried alone, +# matching captain_prompt_run_iterations.verification_result. +table "captain_git_agent_task_attempts" { + schema = schema.public + + column "id" { + null = false + type = uuid + default = sql("gen_random_uuid()") + } + column "task_id" { + null = false + type = uuid + } + column "attempt" { + null = false + type = integer + } + # "sidecar" | "supervisor". Text with a check rather than an enum: + # TierVerdict.Tier is a free Go string, and a new tier should not need a + # migration to record. + column "tier" { + null = false + type = text + } + column "status" { + null = false + type = enum.captain_git_agent_verdict_status + } + column "protocol_version" { + null = false + type = integer + default = 1 + } + column "findings" { + null = false + type = jsonb + default = sql("'[]'::jsonb") + } + column "result_commit" { + null = true + type = text + } + column "feedback" { + null = true + type = text + } + column "recorded_at" { + null = false + type = timestamptz + default = sql("now()") + } + column "created_at" { + null = false + type = timestamptz + default = sql("now()") + } + + primary_key { + columns = [column.id] + } + + foreign_key "captain_git_agent_task_attempts_task_id_fkey" { + columns = [column.task_id] + ref_columns = [table.captain_git_agent_tasks.column.id] + on_update = NO_ACTION + on_delete = CASCADE + } + + # Keyed on (task, attempt, tier), not (task, attempt): the sidecar and the + # supervisor each reach their own verdict on the same attempt. + index "captain_git_agent_task_attempts_task_attempt_tier_key" { + unique = true + columns = [column.task_id, column.attempt, column.tier] + } + index "captain_git_agent_task_attempts_status_idx" { + columns = [column.status, column.recorded_at] + } + + check "captain_git_agent_task_attempts_attempt_positive" { + expr = "attempt >= 1" + } + check "captain_git_agent_task_attempts_tier" { + expr = "tier IN ('sidecar', 'supervisor')" + } +} diff --git a/migrations/git_agent_schema_integration_test.go b/migrations/git_agent_schema_integration_test.go new file mode 100644 index 00000000..273c3e45 --- /dev/null +++ b/migrations/git_agent_schema_integration_test.go @@ -0,0 +1,127 @@ +package migrations + +import ( + "github.com/flanksource/commons-db/dbtest" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The git-agent history tables carry the constraints the ingest watcher relies +// on to be idempotent: it re-scans the same mailbox state repeatedly and upserts +// on natural keys, so those keys have to be enforced by the database rather than +// by the watcher remembering what it already wrote. +var _ = Describe("Captain git-agent schema", func() { + It("enforces the keys and cascades the ingest watcher depends on", func(ctx SpecContext) { + handle := dbtest.ForGinkgo(dbtest.Options{Name: "captain_git_agent_schema"}) + dsn, db := handle.DSN(), handle.SQL() + + Expect(Apply(ctx, dsn)).To(Succeed()) + + insertTask := func(mailbox, taskID string) (string, error) { + var id string + err := db.QueryRowContext(ctx, ` + INSERT INTO public.captain_git_agent_tasks + (task_id, mailbox, base, dispatch_commit) + VALUES ($1, $2, 'main', 'deadbeef') + RETURNING id`, taskID, mailbox).Scan(&id) + return id, err + } + + taskID, err := insertTask("mailboxes/aaa.git", "task-1") + Expect(err).NotTo(HaveOccurred()) + + By("scoping the task id to its mailbox, because one endpoint routes many repositories") + _, err = insertTask("mailboxes/aaa.git", "task-1") + Expect(err).To(MatchError(ContainSubstring("captain_git_agent_tasks_mailbox_task_key"))) + _, err = insertTask("mailboxes/bbb.git", "task-1") + Expect(err).NotTo(HaveOccurred()) + + By("defaulting a fresh task to dispatched with no verdict") + var status string + var finalStatus *string + Expect(db.QueryRowContext(ctx, + `SELECT status, final_status FROM public.captain_git_agent_tasks WHERE id = $1`, taskID). + Scan(&status, &finalStatus)).To(Succeed()) + Expect(status).To(Equal("dispatched")) + Expect(finalStatus).To(BeNil()) + + insertAttempt := func(attempt int, tier, verdict string) error { + _, err := db.ExecContext(ctx, ` + INSERT INTO public.captain_git_agent_task_attempts + (task_id, attempt, tier, status) + VALUES ($1, $2, $3, $4)`, taskID, attempt, tier, verdict) + return err + } + + By("letting both tiers reach their own verdict on the same attempt") + Expect(insertAttempt(1, "sidecar", "accepted")).To(Succeed()) + Expect(insertAttempt(1, "supervisor", "rejected")).To(Succeed()) + + By("rejecting a duplicate verdict for one tier and attempt") + Expect(insertAttempt(1, "supervisor", "accepted")). + To(MatchError(ContainSubstring("captain_git_agent_task_attempts_task_attempt_tier_key"))) + + By("refusing a tier the protocol does not define") + Expect(insertAttempt(2, "supervisorr", "accepted")). + To(MatchError(ContainSubstring("captain_git_agent_task_attempts_tier"))) + + By("refusing a non-positive attempt") + Expect(insertAttempt(0, "sidecar", "accepted")). + To(MatchError(ContainSubstring("captain_git_agent_task_attempts_attempt_positive"))) + + By("refusing a conclusion that precedes its dispatch") + _, err = db.ExecContext(ctx, ` + UPDATE public.captain_git_agent_tasks + SET concluded_at = dispatched_at - interval '1 hour' WHERE id = $1`, taskID) + Expect(err).To(MatchError(ContainSubstring("captain_git_agent_tasks_time_order"))) + + By("cascading attempts when their task is deleted") + _, err = db.ExecContext(ctx, + `DELETE FROM public.captain_git_agent_tasks WHERE id = $1`, taskID) + Expect(err).NotTo(HaveOccurred()) + var remaining int + Expect(db.QueryRowContext(ctx, + `SELECT count(*) FROM public.captain_git_agent_task_attempts WHERE task_id = $1`, taskID). + Scan(&remaining)).To(Succeed()) + Expect(remaining).To(Equal(0)) + }) + + // The prompt-run link is filled after the fact — persistPromptRun writes its + // row only once the run finishes, by which time the remote task has already + // concluded — so the task must outlive the run row rather than cascade with it. + It("keeps task history when its prompt run is deleted", func(ctx SpecContext) { + handle := dbtest.ForGinkgo(dbtest.Options{Name: "captain_git_agent_prompt_run_link"}) + dsn, db := handle.DSN(), handle.SQL() + + Expect(Apply(ctx, dsn)).To(Succeed()) + + var sessionID string + Expect(db.QueryRowContext(ctx, ` + INSERT INTO public.captain_sessions (id, source) VALUES (gen_random_uuid(), 'claude') + RETURNING id`).Scan(&sessionID)).To(Succeed()) + + var runID string + Expect(db.QueryRowContext(ctx, ` + INSERT INTO public.captain_prompt_runs (session_id, root_session_id, admission_key) + VALUES ($1, $1, 'run-key-1') RETURNING id`, sessionID).Scan(&runID)).To(Succeed()) + + var taskID string + Expect(db.QueryRowContext(ctx, ` + INSERT INTO public.captain_git_agent_tasks + (task_id, mailbox, base, dispatch_commit, prompt_run_id, admission_key) + VALUES ('task-1', 'mailboxes/aaa.git', 'main', 'deadbeef', $1, 'run-key-1') + RETURNING id`, runID).Scan(&taskID)).To(Succeed()) + + _, err := db.ExecContext(ctx, + `DELETE FROM public.captain_prompt_runs WHERE id = $1`, runID) + Expect(err).NotTo(HaveOccurred()) + + var linked *string + var admissionKey string + Expect(db.QueryRowContext(ctx, + `SELECT prompt_run_id, admission_key FROM public.captain_git_agent_tasks WHERE id = $1`, taskID). + Scan(&linked, &admissionKey)).To(Succeed()) + Expect(linked).To(BeNil(), "the task row must survive its prompt run") + Expect(admissionKey).To(Equal("run-key-1"), "the correlation handle must survive too") + }) +}) diff --git a/pkg/cli/db_context_http.go b/pkg/cli/db_context_http.go index f48cfe34..21b532fc 100644 --- a/pkg/cli/db_context_http.go +++ b/pkg/cli/db_context_http.go @@ -34,6 +34,15 @@ type databaseContextError struct { // defaulted, and writes are rejected on a read-only context. func DatabaseContextMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The git transport has no database context to select: it writes to a + // repository on disk, not to captain's tables. Without this exemption a + // push (a POST) inherits whatever context a browser cookie last + // selected and is rejected with a 409 that a git client renders as an + // unexplained protocol failure. + if strings.HasPrefix(r.URL.Path, gitPathPrefix) { + next.ServeHTTP(w, r) + return + } name := requestDatabaseContextName(r) dbContext, err := lookupDatabaseContext(name) if errors.Is(err, errUnknownDatabaseContext) { diff --git a/pkg/cli/gitagent.go b/pkg/cli/gitagent.go index b8274f28..f0def74b 100644 --- a/pkg/cli/gitagent.go +++ b/pkg/cli/gitagent.go @@ -4,26 +4,24 @@ package cli import ( + "context" "fmt" "path/filepath" + "strings" "time" "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/clicky" "github.com/flanksource/clicky/api" + "github.com/flanksource/clicky/text" ) -// gitAgentKeysDir anchors key material beside the config file: with the -// default ~/.captain.yaml this is ~/.captain/sandbox, and tests that redirect -// the config path get an isolated keys dir for free. -func gitAgentKeysDir() (string, error) { - path, err := captainconfig.Path() - if err != nil { - return "", err - } - return filepath.Join(filepath.Dir(path), ".captain", "sandbox"), nil -} +// The layout lives in pkg/gitagent so the ingest watcher, which cannot import +// this package, resolves the same directories from the same constants. +func gitAgentKeysDir() (string, error) { return gitagent.DefaultKeysDir() } // The fixed layout every git-agent host uses, so enrollment and dispatch agree // on where key material and repositories live without configuration. @@ -31,18 +29,15 @@ const ( hostKeyName = "host_ed25519" // this endpoint's SSH host key dispatchKeyName = "supervisor_ed25519" // the supervisor's client key agentKeyName = "agent_ed25519" // the agent's client key - servedReposDir = "repos" // served root, under the keys dir SidecarRepoName = "repo.git" // the agent's sidecar repo, under the root supervisorAgentID = "supervisor" // the supervisor's identity on a sidecar + // supervisorCAName is the supervisor's TLS certificate as an enrolled agent + // stores it, so a relay over https verifies against the endpoint it joined + // rather than against whatever the system trust store happens to contain. + supervisorCAName = "supervisor_ca.pem" ) -func gitAgentServedRoot() (string, error) { - keysDir, err := gitAgentKeysDir() - if err != nil { - return "", err - } - return filepath.Join(keysDir, servedReposDir), nil -} +func gitAgentServedRoot() (string, error) { return gitagent.DefaultServedRoot() } // GitAgentHelp documents the group and the two-host setup, because the order // of the steps is the part that is not guessable from the flags. @@ -59,12 +54,32 @@ func GitAgentHelp() api.Textable { AddText(" captain sandbox git-agent list", "text-green-400"). AddText(" — enrolled agents and pending enrollments", "text-gray-500").NewLine(). AddText(" captain sandbox git-agent revoke", "text-green-400"). - AddText(" — refuse an agent's key from now on", "text-gray-500").NewLine().NewLine(). - AddText("Setting up (supervisor first, then the agent host):", "font-bold text-blue-400").NewLine(). - AddText(" 1. supervisor: captain sandbox git-agent serve --role mailbox", "text-green-400").NewLine(). + AddText(" — refuse an agent's key from now on", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent deploy", "text-green-400"). + AddText(" — enroll and run a sidecar on docker or kubernetes", "text-gray-500").NewLine(). + AddText(" captain sandbox git-agent undeploy", "text-green-400"). + AddText("— tear that sidecar down and revoke it", "text-gray-500").NewLine().NewLine(). + AddText("Setting up over SSH (supervisor first, then the agent host):", "font-bold text-blue-400").NewLine(). + AddText(" 1. supervisor: captain sandbox git-agent serve --role mailbox --listen :7422", "text-green-400").NewLine(). AddText(" 2. supervisor: captain sandbox git-agent add worker-01 --endpoint ssh://:7422", "text-green-400").NewLine(). AddText(" 3. agent host: run the printed join command (it enrolls, then serves)", "text-green-400").NewLine(). AddText(" 4. supervisor: captain ai prompt ./task.prompt --sandbox git-agent", "text-green-400").NewLine().NewLine(). + AddText("Or over HTTPS, with no separate mailbox process — `captain serve` hosts it:", "font-bold text-blue-400").NewLine(). + AddText(" 1. supervisor: captain serve --host 0.0.0.0 --tls --tls-host ", "text-green-400").NewLine(). + AddText(" 2. supervisor: captain sandbox git-agent add worker-01 --endpoint https://:9020", "text-green-400").NewLine(). + AddText(" 3. agent host: run the printed join command", "text-green-400").NewLine(). + AddText(" Both flags in step 1 matter: without --host 0.0.0.0 no container can reach it,", "text-gray-400").NewLine(). + AddText(" and without --tls a token would cross the network in clear text. deploy refuses", "text-gray-400").NewLine(). + AddText(" either way and names the flag.", "text-gray-400").NewLine().NewLine(). + AddText("Tokens are durable: a restarting sidecar re-presents the same one instead of", "text-gray-400").NewLine(). + AddText("needing a new join. `--pool` mints one token that names many members, for a", "text-gray-400").NewLine(). + AddText("scaled deployment. See `captain token` to list or revoke them.", "text-gray-400").NewLine().NewLine(). + AddText("Or let deploy do steps 2 and 3 (it detects both addresses and refuses", "text-gray-400").NewLine(). + AddText("rather than enrolling an agent it cannot prove is reachable):", "text-gray-400").NewLine(). + AddText(" captain sandbox git-agent deploy worker-01 --target docker", "text-green-400").NewLine(). + AddText(" captain sandbox git-agent deploy worker-01 --target docker --dry-run", "text-green-400").NewLine(). + AddText(" It enrolls against whichever mailbox this host serves; --transport picks when", "text-gray-400").NewLine(). + AddText(" it serves both.", "text-gray-400").NewLine().NewLine(). AddText("Step 3 establishes trust in both directions: the supervisor learns the agent's", "text-gray-400").NewLine(). AddText("endpoint and host key, and the agent authorizes the supervisor's dispatch key.", "text-gray-400").NewLine().NewLine(). AddText("The agent:", "font-bold text-blue-400").NewLine(). @@ -76,28 +91,50 @@ func GitAgentHelp() api.Textable { } type GitAgentAddOptions struct { - Name string `args:"true" help:"Name for the agent being enrolled"` - Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` - Endpoint string `flag:"endpoint" help:"ssh://host:port the new agent will join through (defaults to the backend's url)"` - DryRun bool `flag:"dry-run" help:"Print every intended mutation without touching anything" short:"n"` + Name string `args:"true" help:"Name for the agent being enrolled; a pool derives its member names from it"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Endpoint string `flag:"endpoint" help:"ssh:// or https:// endpoint the new agent will join through (defaults to the backend's url)"` + Pool bool `flag:"pool" help:"Mint one token that serves many agents, naming each member as it arrives"` + MaxAgents int `flag:"max-agents" help:"Cap a pool's members; 0 leaves it unbounded"` + Expires string `flag:"expires" help:"Token lifetime, e.g. 90d or 720h; empty never expires"` + DryRun bool `flag:"dry-run" help:"Print every intended mutation without touching anything" short:"n"` } -// GitAgentAddResult is the enrollment hand-off. The token is single-use and -// short-TTL; a private key is never printed (R8.2/A7.1). +// GitAgentAddResult is the enrollment hand-off. A private key is never printed +// (R8.2/A7.1); the token is, once, because nothing stored can reproduce it. type GitAgentAddResult struct { - Backend string `json:"backend" pretty:"label=Backend"` - Agent string `json:"agent" pretty:"label=Agent"` - Expires time.Time `json:"expires" pretty:"label=Token expires"` - HostFingerprint string `json:"hostFingerprint" pretty:"label=Host key"` - DispatchKey string `json:"dispatchKey" pretty:"label=Dispatch key"` - JoinCommand string `json:"joinCommand" pretty:"label=Join command"` - DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + TokenID string `json:"tokenId,omitempty" pretty:"label=Token ID"` + Pool bool `json:"pool,omitempty" pretty:"label=Pool"` + Expires *time.Time `json:"expires,omitempty" pretty:"label=Token expires"` + // HostFingerprint is what the joining agent pins: this host's SSH host key + // for an ssh:// endpoint, the served certificate's public-key pin for an + // https:// one. Both are passed as --host-fingerprint and both are compared + // against what the supervisor actually presents. + HostFingerprint string `json:"hostFingerprint" pretty:"label=Supervisor identity"` + DispatchKey string `json:"dispatchKey" pretty:"label=Dispatch key"` + JoinCommand string `json:"joinCommand" pretty:"label=Join command"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` + + // Token is the raw credential, for in-process callers such as + // `git-agent deploy` that must hand it to a workload rather than print it. + // JoinCommand already embeds it for the human who has to type it, so this + // adds no exposure — but it stays off both output surfaces (json:"-", + // pretty:"-") so that a caller reading the struct never widens them. + // Recovering the token by re-parsing JoinCommand would couple a caller to a + // fmt.Sprintf. + Token text.SensitiveString `json:"-" pretty:"-"` } -func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { +func RunGitAgentAdd(ctx context.Context, opts GitAgentAddOptions) (any, error) { if err := gitagent.ValidateTaskID(opts.Name); err != nil { return nil, fmt.Errorf("agent name: %w", err) } + expiresAt, err := parseTokenLifetime(opts.Expires) + if err != nil { + return nil, err + } keysDir, err := gitAgentKeysDir() if err != nil { return nil, err @@ -111,10 +148,10 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { if opts.DryRun { clicky.Printf("[dry-run] would ensure host key at %s\n", hostKeyPath) clicky.Printf("[dry-run] would ensure dispatch key at %s\n", dispatchKeyPath) - clicky.Printf("[dry-run] would mint a single-use join token (TTL %s) for agent %q\n", gitagent.JoinTokenTTL, opts.Name) - clicky.Printf("[dry-run] would record the pending enrollment under sandbox.backends.%s in %s\n", opts.Backend, configPathForDisplay()) + clicky.Printf("[dry-run] would mint a durable captain token for agent %q\n", opts.Name) + clicky.Printf("[dry-run] would record the dispatch key under sandbox.backends.%s in %s\n", opts.Backend, configPathForDisplay()) clicky.Printf("[dry-run] would print the join command for endpoint %s\n", endpoint) - return GitAgentAddResult{Backend: opts.Backend, Agent: opts.Name, DryRun: true}, nil + return GitAgentAddResult{Backend: opts.Backend, Agent: opts.Name, Pool: opts.Pool, DryRun: true}, nil } _, hostFP, err := gitagent.EnsureKeyPair(hostKeyPath) if err != nil { @@ -126,25 +163,32 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { if err != nil { return nil, err } - token, hash, err := gitagent.MintJoinToken() + // Resolved before the mint: an endpoint whose identity cannot be named would + // otherwise leave a live token behind for an agent that can never join. + identity, err := joinIdentity(opts.Backend, endpoint, hostFP) + if err != nil { + return nil, err + } + db, err := captainServeDB(ctx) + if err != nil { + return nil, err + } + input := database.CreateAPITokenInput{ + Name: opts.Name, Scope: captaintoken.ScopeGit, Pool: opts.Pool, + MaxAgents: opts.MaxAgents, ExpiresAt: expiresAt, + } + if !opts.Pool { + input.Agent = opts.Name + } + token, secret, err := db.CreateAPIToken(ctx, input) if err != nil { return nil, err } - expires := time.Now().UTC().Add(gitagent.JoinTokenTTL) err = captainconfig.Update(func(cfg *captainconfig.Config) error { backend, err := ensureGitAgentBackend(cfg, opts.Backend) if err != nil { return err } - pending, _ := backend.Options["pending"].(map[string]any) - if pending == nil { - pending = map[string]any{} - } - pending[hash] = map[string]any{ - "agent": opts.Name, - "expires": expires.Format(time.RFC3339), - } - backend.Options["pending"] = pending backend.Options["dispatchKey"] = dispatchFP cfg.Sandbox.Backends[opts.Backend] = backend return nil @@ -153,15 +197,18 @@ func RunGitAgentAdd(opts GitAgentAddOptions) (any, error) { return nil, err } join := fmt.Sprintf( - "captain sandbox git-agent serve --join %s --supervisor %s --host-fingerprint %s", - token, endpoint, hostFP) + "captain sandbox git-agent serve --token %s --supervisor %s --host-fingerprint %s", + secret.Value(), endpoint, identity) return GitAgentAddResult{ Backend: opts.Backend, Agent: opts.Name, - Expires: expires, - HostFingerprint: hostFP, + TokenID: token.TokenID, + Pool: token.Pool, + Expires: token.ExpiresAt, + HostFingerprint: identity, DispatchKey: dispatchFP, JoinCommand: join, + Token: secret, }, nil } @@ -170,44 +217,106 @@ type GitAgentListOptions struct { } type GitAgentListEntry struct { - Name string `json:"name" pretty:"label=Name"` + Name string `json:"name" pretty:"label=Name"` + // Fingerprint is the agent's own client key, matched at the SSH handshake. Fingerprint string `json:"fingerprint,omitempty" pretty:"label=Fingerprint"` URL string `json:"url,omitempty" pretty:"label=Endpoint"` - AddedAt string `json:"addedAt,omitempty" pretty:"label=Added"` - Status string `json:"status" pretty:"label=Status"` + // HostFingerprint is the agent's host key, pinned only for SSH dispatch. + // HTTPS dispatch authenticates with a token whose path is never exposed. + HostFingerprint string `json:"hostFingerprint,omitempty" pretty:"label=Host key"` + AddedAt string `json:"addedAt,omitempty" pretty:"label=Added"` + Status string `json:"status" pretty:"label=Status"` + // Dispatchable is the transport-neutral readiness contract for roster + // consumers. DispatchIssue is safe to expose: it names the missing class of + // credential without revealing the HTTPS token path. + Dispatchable bool `json:"dispatchable" pretty:"label=Dispatchable"` + DispatchIssue string `json:"dispatchIssue,omitempty" pretty:"label=Dispatch issue"` + // Deployment is set when captain placed this agent's sidecar itself. An + // agent joined by hand has none, and cannot be torn down from here. + Deployment *GitAgentDeployment `json:"deployment,omitempty" pretty:"label=Deployment"` } // RunGitAgentList always returns a slice — an empty roster renders as [] in // JSON rather than null, so a consumer can iterate it unconditionally. func RunGitAgentList(opts GitAgentListOptions) (any, error) { - entries := []GitAgentListEntry{} cfg, _, err := captainconfig.Load() if err != nil { return nil, err } backend, ok := cfg.Sandbox.Backends[opts.Backend] if !ok { - return entries, nil + return []GitAgentListEntry{}, nil } + return gitAgentRoster(backend), nil +} + +// gitAgentRoster decodes a git-agent backend's enrolled and pending agents out +// of its opaque options. The options map is untyped by design — each adapter +// decodes its own — so every read is a checked assert. Shared with the sandbox +// catalog so the CLI roster and the schema/HTTP roster cannot drift. +func gitAgentRoster(backend captainconfig.SandboxBackend) []GitAgentListEntry { + entries := []GitAgentListEntry{} agents, _ := backend.Options["agents"].(map[string]any) + deployments, _ := backend.Options["deployments"].(map[string]any) for _, name := range sortedKeys(agents) { entry := GitAgentListEntry{Name: name, Status: "enrolled"} - if m, ok := agents[name].(map[string]any); ok { + m, _ := agents[name].(map[string]any) + if m != nil { entry.Fingerprint, _ = m["fingerprint"].(string) entry.URL, _ = m["url"].(string) + entry.HostFingerprint, _ = m["hostFingerprint"].(string) entry.AddedAt, _ = m["addedAt"].(string) } + entry.Dispatchable, entry.DispatchIssue = gitAgentDispatchStatus(m) + if record, ok := deployments[name].(map[string]any); ok { + deployment := deploymentFromRecord(record) + entry.Deployment = &deployment + } entries = append(entries, entry) } - pending, _ := backend.Options["pending"].(map[string]any) - for _, hash := range sortedKeys(pending) { - if m, ok := pending[hash].(map[string]any); ok { - name, _ := m["agent"].(string) - expires, _ := m["expires"].(string) - entries = append(entries, GitAgentListEntry{Name: name, Status: "pending until " + expires}) + // A workload captain placed that has not joined yet: the sidecar is still + // starting, or it was deployed with --wait=false. Without this it is invisible + // until it enrolls, so an operator who just deployed sees an empty roster and + // no way to tear the workload down. + for _, name := range sortedKeys(deployments) { + if _, enrolled := agents[name]; enrolled { + continue + } + record, ok := deployments[name].(map[string]any) + if !ok { + continue + } + deployment := deploymentFromRecord(record) + entries = append(entries, GitAgentListEntry{ + Name: name, Status: "deployed — waiting to enroll", Deployment: &deployment, + }) + } + return entries +} + +func gitAgentDispatchStatus(entry map[string]any) (bool, string) { + endpoint, _ := entry["url"].(string) + if strings.TrimSpace(endpoint) == "" { + return false, "missing endpoint" + } + switch gitagent.EndpointScheme(endpoint) { + case "ssh": + hostFingerprint, _ := entry["hostFingerprint"].(string) + if strings.TrimSpace(hostFingerprint) == "" { + return false, "missing host key" + } + case "https": + tokenPath, _ := entry["tokenPath"].(string) + if strings.TrimSpace(tokenPath) == "" { + return false, "missing dispatch token" + } + if _, err := gitagent.ReadTokenFile(tokenPath); err != nil { + return false, "unreadable dispatch token" } + default: + return false, "unsupported endpoint transport" } - return entries, nil + return true, "" } type GitAgentRevokeOptions struct { @@ -258,6 +367,12 @@ func RunGitAgentRevoke(opts GitAgentRevokeOptions) (any, error) { if err != nil { return nil, err } + // The roster entry is gone, so nothing reads the token any more — but a + // credential left on disk is still a credential, and this is the only place + // that knows the agent is finished. + if err := removeDispatchTokenFile(opts.Name); err != nil { + return nil, fmt.Errorf("agent %q was revoked but its dispatch token could not be removed: %w", opts.Name, err) + } // Effective for connections established after now (R8.5): the server // consults the config per handshake. return GitAgentRevokeResult{ @@ -281,6 +396,30 @@ func enrolledAgent(cfg captainconfig.Config, backendName, agentName string) (map return entry, nil } +// joinIdentity is what the joining agent pins the supervisor by: this host's +// SSH host key over ssh, the served certificate's public-key pin over https. +// +// The pin comes from the mailbox record rather than from a certificate file on +// disk, because `captain serve` may be presenting one supplied with --tls-cert. +// Printing the wrong one produces a join that is refused at the last moment, +// with nothing in the message to say which half of the pair is wrong. +func joinIdentity(backendName, endpoint, hostFingerprint string) (string, error) { + if gitagent.EndpointScheme(endpoint) != "https" { + return hostFingerprint, nil + } + record, err := selectMailboxRecord(backendName, transportHTTPS) + if err != nil { + return "", err + } + if !record.Encrypted || record.Identity == "" { + return "", fmt.Errorf( + "endpoint %s is https, but `captain serve` on this host serves plain HTTP on %s, so there is no "+ + "certificate for the agent to pin; restart it with --tls --tls-host
", + endpoint, record.Listen) + } + return record.Identity, nil +} + func gitAgentBackendEndpoint(backend string) string { cfg, _, err := captainconfig.Load() if err == nil { diff --git a/pkg/cli/gitagent_agent_api_ginkgo_test.go b/pkg/cli/gitagent_agent_api_ginkgo_test.go new file mode 100644 index 00000000..dadcedd3 --- /dev/null +++ b/pkg/cli/gitagent_agent_api_ginkgo_test.go @@ -0,0 +1,208 @@ +package cli + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +var _ = Describe("git-agent API status", func() { + It("reports an HTTPS agent with a readable dispatch token as dispatchable", func() { + tokenPath := filepath.Join(GinkgoT().TempDir(), "w03.token") + Expect(gitagent.WriteTokenFile(tokenPath, text.NewSensitiveString("dispatch-token"))).To(Succeed()) + backend := captainconfig.SandboxBackend{Kind: "git-agent", Options: map[string]any{ + "agents": map[string]any{ + "w03": map[string]any{ + "url": "https://w03.agents.lab/git/repo.git", + "tokenPath": tokenPath, + }, + }, + }} + + entries := gitAgentRoster(backend) + + Expect(entries).To(HaveLen(1)) + Expect(entries[0].Dispatchable).To(BeTrue()) + Expect(entries[0].DispatchIssue).To(BeEmpty()) + Expect(entries[0].HostFingerprint).To(BeEmpty()) + encoded, err := json.Marshal(entries[0]) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).NotTo(ContainSubstring(tokenPath)) + }) + + DescribeTable("reports the transport-specific missing prerequisite", + func(agent map[string]any, issue string) { + entries := gitAgentRoster(captainconfig.SandboxBackend{Kind: "git-agent", Options: map[string]any{ + "agents": map[string]any{"worker-01": agent}, + }}) + + Expect(entries).To(HaveLen(1)) + Expect(entries[0].Dispatchable).To(BeFalse()) + Expect(entries[0].DispatchIssue).To(Equal(issue)) + }, + Entry("endpoint", map[string]any{}, "missing endpoint"), + Entry("SSH host key", map[string]any{"url": "ssh://worker-01:7422/repo.git"}, "missing host key"), + Entry("HTTPS token", map[string]any{"url": "https://worker-01.example.com/git/repo.git"}, "missing dispatch token"), + ) + + It("serves the authenticated whoami contract from the agent", func() { + var received WhoamiOptions + handler := agentWhoamiHandler( + func(r *http.Request) (string, error) { + if r.Header.Get("Authorization") != "Bearer allowed" { + return "", fmt.Errorf("invalid credential") + } + return supervisorAgentID, nil + }, + func(options WhoamiOptions) (any, error) { + received = options + return map[string]any{"adapters": []any{}}, nil + }, + ) + request := httptest.NewRequest(http.MethodPost, + gitagent.AgentWhoamiPath+"?backend=codex-cmux&models=false&limit=2&disabled=true&no-cache=true", + strings.NewReader("{}")) + request.Header.Set("Authorization", "Bearer allowed") + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + Expect(response.Code).To(Equal(http.StatusOK), response.Body.String()) + Expect(response.Header().Get("Content-Type")).To(Equal("application/json")) + Expect(received).To(Equal(WhoamiOptions{ + Backend: "codex-cmux", Models: false, Limit: 2, IncludeDisabled: true, NoCache: true, + })) + Expect(response.Body.String()).To(MatchJSON(`{"adapters":[]}`)) + }) + + It("does not probe whoami without the supervisor credential", func() { + called := false + handler := agentWhoamiHandler( + func(*http.Request) (string, error) { return "", fmt.Errorf("invalid credential") }, + func(WhoamiOptions) (any, error) { + called = true + return nil, nil + }, + ) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, httptest.NewRequest(http.MethodPost, gitagent.AgentWhoamiPath, nil)) + + Expect(response.Code).To(Equal(http.StatusForbidden)) + Expect(called).To(BeFalse()) + Expect(response.Body.String()).NotTo(ContainSubstring("invalid credential")) + }) + + It("proxies an on-demand whoami probe without exposing the dispatch token", func() { + const dispatchToken = "cptn_dispatch.secret" + var received *http.Request + agent := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + received = r.Clone(r.Context()) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "adapters":[{"backend":"codex-agent","type":"cli","provider":"openai","mode":"agent","authenticated":true,"modelCount":1,"models":["gpt-5.6-sol"]}], + "defaultProvider":"openai","providerDefaults":{},"disabled":{},"axes":{},"runtimes":[] + }`)) + })) + DeferCleanup(agent.Close) + + tokenPath := filepath.Join(GinkgoT().TempDir(), "w03.token") + Expect(gitagent.WriteTokenFile(tokenPath, text.NewSensitiveString(dispatchToken))).To(Succeed()) + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + Expect(captainconfig.Save(captainconfig.Config{Sandbox: captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{ + "git-agent": {Kind: "git-agent", Options: map[string]any{ + "agents": map[string]any{"w03": map[string]any{ + "url": agent.URL + "/git/repo.git", "tokenPath": tokenPath, + }}, + }}, + }, + }})).To(Succeed()) + + handler := handleGitAgentWhoamiWithClient(agent.Client()) + response := httptest.NewRecorder() + request := loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/agents/w03/whoami?backend=git-agent", "{}") + request.SetPathValue("name", "w03") + + handler.ServeHTTP(response, request) + + Expect(response.Code).To(Equal(http.StatusOK), response.Body.String()) + Expect(received).NotTo(BeNil()) + Expect(received.Method).To(Equal(http.MethodPost)) + Expect(received.URL.Path).To(Equal(gitagent.AgentWhoamiPath)) + Expect(received.URL.Query().Get("models")).To(Equal("true")) + Expect(received.URL.Query().Get("limit")).To(Equal("0")) + Expect(received.Header.Get("Authorization")).To(Equal("Bearer " + dispatchToken)) + Expect(response.Body.String()).To(ContainSubstring("codex-agent")) + Expect(response.Body.String()).To(ContainSubstring("gpt-5.6-sol")) + Expect(response.Body.String()).NotTo(ContainSubstring(dispatchToken)) + Expect(response.Body.String()).NotTo(ContainSubstring(tokenPath)) + }) + + It("refuses whoami for an agent without an HTTPS runtime endpoint", func() { + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + Expect(captainconfig.Save(captainconfig.Config{Sandbox: captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{ + "git-agent": {Kind: "git-agent", Options: map[string]any{ + "agents": map[string]any{"ssh-worker": map[string]any{ + "url": "ssh://ssh-worker:7422/repo.git", "hostFingerprint": "SHA256:host", + }}, + }}, + }, + }})).To(Succeed()) + + response := httptest.NewRecorder() + request := loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/agents/ssh-worker/whoami?backend=git-agent", "{}") + request.SetPathValue("name", "ssh-worker") + + handleGitAgentWhoamiWithClient(http.DefaultClient).ServeHTTP(response, request) + + Expect(response.Code).To(Equal(http.StatusBadRequest)) + Expect(response.Body.String()).To(ContainSubstring("does not expose the HTTPS whoami endpoint")) + }) + + It("reloads a mounted route certificate after cert-manager rotates it", func() { + const host = "w03.agents.example.com" + first, err := gitagent.EnsureTLSCredential(GinkgoT().TempDir(), []string{host}) + Expect(err).NotTo(HaveOccurred()) + second, err := gitagent.EnsureTLSCredential(GinkgoT().TempDir(), []string{host}) + Expect(err).NotTo(HaveOccurred()) + + mounted := GinkgoT().TempDir() + certPath, keyPath := filepath.Join(mounted, "tls.crt"), filepath.Join(mounted, "tls.key") + copyTLSFile := func(from, to string) { + contents, readErr := os.ReadFile(from) + Expect(readErr).NotTo(HaveOccurred()) + Expect(os.WriteFile(to, contents, 0o600)).To(Succeed()) + } + copyTLSFile(first.CertPath, certPath) + copyTLSFile(first.KeyPath, keyPath) + + _, config, err := sidecarTLSConfig(sidecarHTTPSPlan{ + certPath: certPath, keyPath: keyPath, + }, host) + Expect(err).NotTo(HaveOccurred()) + presented, err := config.GetCertificate(nil) + Expect(err).NotTo(HaveOccurred()) + Expect(presented.Leaf.SerialNumber).To(Equal(first.Leaf.SerialNumber)) + + copyTLSFile(second.CertPath, certPath) + copyTLSFile(second.KeyPath, keyPath) + presented, err = config.GetCertificate(nil) + Expect(err).NotTo(HaveOccurred()) + Expect(presented.Leaf.SerialNumber).To(Equal(second.Leaf.SerialNumber)) + }) +}) diff --git a/pkg/cli/gitagent_credentials.go b/pkg/cli/gitagent_credentials.go new file mode 100644 index 00000000..83903396 --- /dev/null +++ b/pkg/cli/gitagent_credentials.go @@ -0,0 +1,136 @@ +package cli + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// credentialMaterializeInterval is how often the sidecar re-reads the mounted +// credentials. Kubelet refreshes a projected Secret volume on its own sync +// period (tens of seconds), so polling faster buys nothing; polling much slower +// would let a republished credential sit unused while the old one expires. +const credentialMaterializeInterval = 30 * time.Second + +// credentialMaterializer copies the credentials the supervisor publishes into +// the paths the agent CLIs actually read. +// +// The mount cannot simply BE those paths. It is read-only and shared, while +// ~/.claude and ~/.codex are directories the CLIs write their own state into — +// settings, history, session transcripts. Copying the one file out of the mount +// leaves the rest of each state directory writable. +// +// Symlinking would follow the supervisor's updates for free, but into a +// read-only volume, so the first time a CLI tried to rewrite its own credential +// it would fail. Copying plus this poll keeps both properties. +type credentialMaterializer struct { + // source is the mounted directory, deploy.CredentialsMountPath in a workload. + source string + // home is where the CLI config directories live. + home string + interval time.Duration +} + +func newCredentialMaterializer(home string) *credentialMaterializer { + return &credentialMaterializer{ + source: deploy.CredentialsMountPath, + home: home, + interval: credentialMaterializeInterval, + } +} + +// mounted reports whether a credential volume is present. Absence is the normal +// case for a sidecar deployed without credentials, so it is not an error. +func (m *credentialMaterializer) mounted() bool { + info, err := os.Stat(m.source) + return err == nil && info.IsDir() +} + +// run materializes now and then keeps the copies in step with the mount. +func (m *credentialMaterializer) run(ctx context.Context) { + ticker := time.NewTicker(m.interval) + defer ticker.Stop() + for { + if err := m.materialize(); err != nil { + log.Warnf("git-agent credential materializer: %v", err) + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} + +// credentialTargets maps a published key onto the path its CLI reads. +func (m *credentialMaterializer) credentialTargets() map[string]string { + return map[string]string{ + agentcreds.ClaudeFilename: filepath.Join(m.home, ".claude", agentcreds.ClaudeRelPath), + agentcreds.CodexFilename: filepath.Join(m.home, ".codex", agentcreds.CodexRelPath), + } +} + +// materialize copies each published credential to its CLI path, skipping files +// whose contents already match so the CLIs are not handed a changed mtime on +// every tick. +// +// A mount that exists but cannot be read is reported rather than skipped: the +// workload was deployed with credentials and is not getting them, which is the +// one failure mode this whole path exists to avoid being silent. +func (m *credentialMaterializer) materialize() error { + for key, target := range m.credentialTargets() { + source := filepath.Join(m.source, key) + payload, err := os.ReadFile(source) + if os.IsNotExist(err) { + // The supervisor publishes only the providers it is configured for. + continue + } + if err != nil { + return fmt.Errorf("read published credential %s: %w", source, err) + } + if existing, err := os.ReadFile(target); err == nil && bytes.Equal(existing, payload) { + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { + return fmt.Errorf("create credential directory for %s: %w", target, err) + } + if err := writeCredentialFile(target, payload); err != nil { + return err + } + log.Infof("Materialized %s credential to %s", key, target) + } + return nil +} + +// writeCredentialFile replaces target atomically, so a CLI reading concurrently +// sees either the previous credential or the new one. +func writeCredentialFile(target string, payload []byte) error { + temp, err := os.CreateTemp(filepath.Dir(target), ".credential-*") + if err != nil { + return fmt.Errorf("create temp file beside %s: %w", target, err) + } + tempPath := temp.Name() + defer func() { _ = os.Remove(tempPath) }() + + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return fmt.Errorf("secure temp file for %s: %w", target, err) + } + if _, err := temp.Write(payload); err != nil { + _ = temp.Close() + return fmt.Errorf("write %s: %w", target, err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close temp file for %s: %w", target, err) + } + if err := os.Rename(tempPath, target); err != nil { + return fmt.Errorf("replace %s: %w", target, err) + } + return nil +} diff --git a/pkg/cli/gitagent_credentials_test.go b/pkg/cli/gitagent_credentials_test.go new file mode 100644 index 00000000..a8aa60f9 --- /dev/null +++ b/pkg/cli/gitagent_credentials_test.go @@ -0,0 +1,172 @@ +package cli + +import ( + "os" + "path/filepath" + "testing" + + "github.com/flanksource/captain/pkg/agentcreds" +) + +// materializerFixture builds a materializer over a fake mount and home. +func materializerFixture(t *testing.T) *credentialMaterializer { + t.Helper() + root := t.TempDir() + source := filepath.Join(root, "mount") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + return &credentialMaterializer{source: source, home: filepath.Join(root, "home")} +} + +// publish writes a credential into the mount the way kubelet does: mode 0400 +// and replaced wholesale rather than edited in place, so an update cannot be +// blocked by the previous file's read-only mode. +func publish(t *testing.T, m *credentialMaterializer, key, payload string) { + t.Helper() + path := filepath.Join(m.source, key) + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(payload), 0o400); err != nil { + t.Fatal(err) + } +} + +func TestMaterializeCopiesEachCredentialToItsCLIPath(t *testing.T) { + m := materializerFixture(t) + publish(t, m, agentcreds.ClaudeFilename, `{"claudeAiOauth":{"accessToken":"a"}}`) + publish(t, m, agentcreds.CodexFilename, `{"auth_mode":"chatgpt"}`) + + if err := m.materialize(); err != nil { + t.Fatal(err) + } + + for path, want := range map[string]string{ + filepath.Join(m.home, ".claude", ".credentials.json"): `{"claudeAiOauth":{"accessToken":"a"}}`, + filepath.Join(m.home, ".codex", "auth.json"): `{"auth_mode":"chatgpt"}`, + } { + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%s: %v", path, err) + } + if string(got) != want { + t.Errorf("%s = %q, want %q", path, got, want) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + // The mount is 0400; the copy must be writable by its owner so the CLI + // can rewrite its own credential, but readable by nobody else. + if info.Mode().Perm() != 0o600 { + t.Errorf("%s mode = %v, want 0600", path, info.Mode().Perm()) + } + } +} + +func TestMaterializeIsQuietWhenAProviderIsNotPublished(t *testing.T) { + // The supervisor publishes only its configured providers, so a missing key + // is normal rather than an error. + m := materializerFixture(t) + publish(t, m, agentcreds.ClaudeFilename, `{"claudeAiOauth":{"accessToken":"a"}}`) + + if err := m.materialize(); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(m.home, ".codex", "auth.json")); !os.IsNotExist(err) { + t.Errorf("codex credential was materialized from nothing: %v", err) + } +} + +func TestMaterializePicksUpARepublishedCredential(t *testing.T) { + m := materializerFixture(t) + publish(t, m, agentcreds.ClaudeFilename, `{"claudeAiOauth":{"accessToken":"first"}}`) + if err := m.materialize(); err != nil { + t.Fatal(err) + } + + publish(t, m, agentcreds.ClaudeFilename, `{"claudeAiOauth":{"accessToken":"second"}}`) + if err := m.materialize(); err != nil { + t.Fatal(err) + } + + target := filepath.Join(m.home, ".claude", ".credentials.json") + got, err := os.ReadFile(target) + if err != nil { + t.Fatal(err) + } + if string(got) != `{"claudeAiOauth":{"accessToken":"second"}}` { + t.Errorf("republished credential was not picked up: %q", got) + } +} + +func TestMaterializeLeavesAnUnchangedCredentialAlone(t *testing.T) { + // Rewriting every tick would hand the CLIs a new mtime every 30s. + m := materializerFixture(t) + publish(t, m, agentcreds.ClaudeFilename, `{"claudeAiOauth":{"accessToken":"a"}}`) + if err := m.materialize(); err != nil { + t.Fatal(err) + } + + target := filepath.Join(m.home, ".claude", ".credentials.json") + before, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if err := m.materialize(); err != nil { + t.Fatal(err) + } + after, err := os.Stat(target) + if err != nil { + t.Fatal(err) + } + if !before.ModTime().Equal(after.ModTime()) { + t.Error("an unchanged credential was rewritten") + } +} + +func TestMaterializeLeavesNoTempFilesBehind(t *testing.T) { + m := materializerFixture(t) + publish(t, m, agentcreds.ClaudeFilename, `{"claudeAiOauth":{"accessToken":"a"}}`) + if err := m.materialize(); err != nil { + t.Fatal(err) + } + + entries, err := os.ReadDir(filepath.Join(m.home, ".claude")) + if err != nil { + t.Fatal(err) + } + if len(entries) != 1 || entries[0].Name() != ".credentials.json" { + names := make([]string, 0, len(entries)) + for _, entry := range entries { + names = append(names, entry.Name()) + } + t.Errorf("unexpected contents: %v", names) + } +} + +func TestMountedReportsAbsenceWithoutFailing(t *testing.T) { + // A sidecar deployed without credentials is the normal case. + m := materializerFixture(t) + if !m.mounted() { + t.Error("an existing mount directory reported as absent") + } + + m.source = filepath.Join(t.TempDir(), "not-there") + if m.mounted() { + t.Error("a missing mount reported as present") + } +} + +func TestMountedRejectsAFileMasqueradingAsTheMount(t *testing.T) { + m := materializerFixture(t) + path := filepath.Join(t.TempDir(), "credentials") + if err := os.WriteFile(path, []byte("not a directory"), 0o600); err != nil { + t.Fatal(err) + } + m.source = path + if m.mounted() { + t.Error("a plain file was accepted as the credential mount") + } +} diff --git a/pkg/cli/gitagent_deploy.go b/pkg/cli/gitagent_deploy.go new file mode 100644 index 00000000..33b6cbb7 --- /dev/null +++ b/pkg/cli/gitagent_deploy.go @@ -0,0 +1,533 @@ +// `captain sandbox git-agent deploy` — enroll an agent and place its sidecar. +// +// The command exists because the gap between `add` and a working agent is where +// this topology goes wrong. `add` prints a join command; carrying it to a +// machine by hand means choosing an image, a resource envelope, a security +// posture, and — the part that fails silently — the two addresses the protocol +// needs pointing in opposite directions. Every step here either proves its +// input or refuses. +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/captain/pkg/gitagent/deploy" + "github.com/flanksource/clicky" +) + +type GitAgentDeployOptions struct { + Name string `args:"true" help:"Name for the agent being enrolled and deployed"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Target string `flag:"target" help:"Where the sidecar runs: docker or kubernetes"` + // Transport is only needed when this host serves both; otherwise detection + // takes the one that is there. + Transport string `flag:"transport" help:"Which mailbox to enroll against when this host serves both: https (captain serve) or ssh"` + + Namespace string `flag:"namespace" help:"kubernetes: namespace for the sidecar (default: the kubeconfig context's)"` + CreateNamespace bool `flag:"create-namespace" help:"kubernetes: create --namespace when it does not exist, instead of refusing"` + KubeContext string `flag:"kube-context" help:"kubernetes: kubeconfig context to apply into (default: current-context)"` + + Domain string `flag:"domain" help:"kubernetes: DNS domain a supervisor outside the cluster reaches agents on; this agent is published at . behind an Ingress"` + IngressClass string `flag:"ingress-class" help:"kubernetes: ingressClassName of the controller serving --domain" default:"nginx"` + IngressIssuer string `flag:"ingress-issuer" help:"kubernetes: cert-manager ClusterIssuer that mints the certificate for ."` + IngressTLSSecret string `flag:"ingress-tls-secret" help:"kubernetes: existing TLS Secret covering ., instead of --ingress-issuer"` + IngressAnnotation []string `flag:"ingress-annotation" help:"kubernetes: extra key=value Ingress annotations, merged over the ingress-nginx defaults; required for any other controller, and where a source-range allowlist goes"` + + Image string `flag:"image" help:"Sidecar image; must carry the captain binary on PATH" default:"ghcr.io/flanksource/captain:latest"` + ImagePullPolicy string `flag:"image-pull-policy" help:"kubernetes: Always, IfNotPresent or Never" default:"IfNotPresent"` + ImagePullSecret string `flag:"image-pull-secret" help:"kubernetes: name of an existing imagePullSecret"` + + SupervisorAddress string `flag:"supervisor-address" help:"ssh:// or https:// endpoint the deployed agent uses to reach this mailbox (detected for docker; required for kubernetes)"` + Advertise string `flag:"advertise" help:"ssh://host:port the supervisor dispatches back to (detected per target)"` + ListenPort int `flag:"listen-port" help:"Port the sidecar listens on inside the workload" default:"7422"` + HostPort int `flag:"host-port" help:"docker: loopback port published for the sidecar; 0 reserves a free one"` + + CPURequest string `flag:"cpu-request" help:"kubernetes requests.cpu" default:"500m"` + CPULimit string `flag:"cpu-limit" help:"kubernetes limits.cpu, docker --cpus" default:"2"` + MemoryRequest string `flag:"memory-request" help:"kubernetes requests.memory, docker --memory-reservation" default:"1Gi"` + MemoryLimit string `flag:"memory-limit" help:"kubernetes limits.memory, docker --memory" default:"4Gi"` + Storage string `flag:"storage" help:"Persistent volume holding HOME: agent key, config and served repos" default:"20Gi"` + StorageClass string `flag:"storage-class" help:"kubernetes: StorageClass for the state volume (default: the cluster's)"` + TmpSize string `flag:"tmp-size" help:"Writable /tmp; RAM-backed on docker, so it counts against --memory-limit" default:"1Gi"` + PidsLimit int `flag:"pids-limit" help:"Maximum process count in the workload; 0 disables" default:"1024"` + + RunAsUser int `flag:"run-as-user" help:"UID to run as; must own --home in the image" default:"501"` + RunAsGroup int `flag:"run-as-group" help:"GID to run as" default:"20"` + Home string `flag:"home" help:"HOME inside the workload; the state volume mounts here" default:"/home/claude"` + ReadOnlyRoot bool `flag:"read-only-root" help:"Mount the image root read-only; state on the volume, scratch on /tmp" default:"true"` + Network string `flag:"network" help:"docker: network to attach; host and none are refused" default:"bridge"` + CapAdd []string `flag:"cap-add" help:"Linux capabilities to restore on top of an otherwise empty set"` + + Env []string `flag:"env" help:"Environment variable NAMES to forward; values are read from this process, never argv"` + EnvFromSecret []string `flag:"env-from-secret" help:"kubernetes: existing Secret names exposed to the sidecar via envFrom"` + + CredentialsSecret string `flag:"credentials-secret" help:"kubernetes: Secret of redacted agent logins kept fresh by 'captain sandbox credentials'; mounted at /run/captain/credentials"` + CredentialsDir string `flag:"credentials-dir" help:"docker: host directory of redacted agent logins to bind-mount read-only at /run/captain/credentials"` + + Wait bool `flag:"wait" help:"Wait for readiness AND for the enrollment to be recorded here" default:"true"` + Timeout string `flag:"timeout" help:"How long --wait waits" default:"5m"` + Replace bool `flag:"replace" help:"Replace an existing deployment of this name"` + DryRun bool `flag:"dry-run" help:"Print every intended mutation without touching anything" short:"n"` + + reuseEnrollment bool +} + +// GitAgentDeployResult reports what was placed and, as importantly, what could +// not be proven: an operator who does not know the sidecar has unrestricted +// egress or no model credentials will find out at the first dispatch. +type GitAgentDeployResult struct { + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + Target string `json:"target" pretty:"label=Target"` + Image string `json:"image" pretty:"label=Image"` + + Workload string `json:"workload" pretty:"label=Workload"` + Namespace string `json:"namespace,omitempty" pretty:"label=Namespace"` + Objects []string `json:"objects,omitempty" pretty:"label=Objects"` + Volume string `json:"volume" pretty:"label=State volume"` + + Supervisor string `json:"supervisor" pretty:"label=Reaches mailbox at"` + SupervisorFrom string `json:"supervisorFrom" pretty:"label=Detected from"` + Advertise string `json:"advertise" pretty:"label=Dispatched to at"` + AdvertiseFrom string `json:"advertiseFrom" pretty:"label=Detected from"` + HostFingerprint string `json:"hostFingerprint" pretty:"label=Mailbox host key"` + // OffHostAddresses is every address of this host that answered as the + // mailbox. It is evidence the mailbox is reachable off loopback — NOT proof + // of the path the sidecar dials, which is the host.docker.internal alias. + // Empty when --supervisor-address skipped the proof. + OffHostAddresses []string `json:"offHostAddresses,omitempty" pretty:"label=Off-loopback proof"` + + // Route is the external hostname a supervisor outside the cluster dispatches + // to, empty for the in-cluster topology. Reported apart from Advertise + // because the DNS record pointing it at the controller is the one thing this + // deploy cannot create and cannot prove. + Route string `json:"route,omitempty" pretty:"label=Ingress host"` + RouteClass string `json:"routeClass,omitempty" pretty:"label=Ingress class"` + + Security string `json:"security" pretty:"label=Security"` + Credentials string `json:"credentials" pretty:"label=Agent credentials"` + // EgressRestricted is always false today: the egress credential proxy has no + // callers, so the sidecar reaches model APIs, git remotes and package + // registries directly. + EgressRestricted bool `json:"egressRestricted" pretty:"label=Egress restricted"` + + Enrolled bool `json:"enrolled" pretty:"label=Enrolled"` + Ready bool `json:"ready" pretty:"label=Ready"` + EnrollmentReused bool `json:"enrollmentReused,omitempty" pretty:"label=Enrollment reused"` + Replaced bool `json:"replaced,omitempty" pretty:"label=Replaced"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` + + // Mutations is every change the deploy intends, in order. It is populated on + // a dry run and is what the CLI prints and the web UI previews — one builder, + // so the two cannot describe different deployments. + Mutations []string `json:"mutations,omitempty" pretty:"label=Would do"` +} + +func RunGitAgentDeploy(ctx context.Context, opts GitAgentDeployOptions) (any, error) { + target, err := deploy.ParseTarget(opts.Target) + if err != nil { + return nil, err + } + if err := gitagent.ValidateTaskID(opts.Name); err != nil { + return nil, fmt.Errorf("agent name: %w", err) + } + timeout, err := time.ParseDuration(strings.TrimSpace(opts.Timeout)) + if err != nil { + return nil, fmt.Errorf("--timeout %q is not a duration: %w", opts.Timeout, err) + } + sizing, err := deploy.ParseSizing(deploy.SizingRequest{ + CPURequest: opts.CPURequest, + CPULimit: opts.CPULimit, + MemoryRequest: opts.MemoryRequest, + MemoryLimit: opts.MemoryLimit, + Storage: opts.Storage, + TmpSize: opts.TmpSize, + PidsLimit: opts.PidsLimit, + }) + if err != nil { + return nil, err + } + + security := deploy.HardenedSecurity() + security.RunAsUser, security.RunAsGroup = opts.RunAsUser, opts.RunAsGroup + security.ReadOnlyRoot, security.Network, security.CapAdd = opts.ReadOnlyRoot, opts.Network, opts.CapAdd + presets, err := backendPresets(opts.Backend) + if err != nil { + return nil, err + } + if err := deploy.RefuseUnsafe(security, opts.Home, nil, presets); err != nil { + return nil, err + } + + // RecordAgent overwrites an existing entry wholesale. A managed replacement + // keeps the state volume and therefore must restart from the identity already + // persisted there instead of minting another one. + reuseEnrollment, err := replacementReusesEnrollment(opts) + if err != nil { + return nil, err + } + opts.reuseEnrollment = opts.reuseEnrollment || reuseEnrollment + + transport, err := parseMailboxTransport(opts.Transport) + if err != nil { + return nil, err + } + mailbox, supervisor, supervisorFrom, err := resolveDeployMailbox(ctx, target, opts, transport) + if err != nil { + return nil, err + } + opts.Transport = string(mailbox.Transport) + joinPath := deploy.JoinMountPath + if opts.reuseEnrollment { + joinPath = "" + } + + plan := deploy.Plan{ + Name: opts.Name, + Backend: opts.Backend, + Target: target, + Image: opts.Image, + Home: opts.Home, + ListenPort: opts.ListenPort, + HostPort: opts.HostPort, + Supervisor: supervisor, + HostFingerprint: mailbox.HostFingerprint, + JoinPath: joinPath, + Sizing: sizing, + Security: security, + EnvNames: opts.Env, + EnvFromSecrets: opts.EnvFromSecret, + } + if err := applyCredentialMount(&plan, opts, target); err != nil { + return nil, err + } + // Before the advertise address, which the route decides. + if err := applyExternalRoute(&plan, opts, target); err != nil { + return nil, err + } + if target == deploy.TargetDocker && plan.HostPort == 0 { + if plan.HostPort, err = freeLoopbackPort(mailbox.Port); err != nil { + return nil, err + } + } + + namespace := strings.TrimSpace(opts.Namespace) + advertise, advertiseFrom, err := resolveAdvertiseAddress(target, plan, namespace, opts.Advertise, runningInCluster()) + if err != nil { + return nil, err + } + plan.Advertise = advertise + + result := GitAgentDeployResult{ + Backend: opts.Backend, Agent: opts.Name, Target: string(target), Image: opts.Image, + Workload: plan.WorkloadName(), Namespace: namespace, Volume: plan.VolumeName(), + Supervisor: supervisor, SupervisorFrom: supervisorFrom, + Advertise: advertise, AdvertiseFrom: advertiseFrom, + HostFingerprint: mailbox.HostFingerprint, + OffHostAddresses: mailbox.OffHostAddresses, + Route: plan.ExternalRoute.Host, + RouteClass: plan.ExternalRoute.ClassName, + Security: security.Describe(), + Credentials: describeCredentials(opts), + } + + // Apply-by-default is only safe if the operator can see where. Printed + // unconditionally, not just under --dry-run. + printResolvedTarget(plan, result, timeout) + if opts.DryRun { + result.Mutations = deployMutations(plan, opts) + printDeployDryRun(result.Mutations) + result.DryRun = true + return result, nil + } + return runDeploy(ctx, plan, opts, result, timeout) +} + +func resolveDeployMailbox(ctx context.Context, target deploy.Target, opts GitAgentDeployOptions, + transport mailboxTransport) (detectedMailbox, string, string, error) { + if opts.reuseEnrollment { + supervisor := strings.TrimSpace(opts.SupervisorAddress) + if supervisor == "" { + return detectedMailbox{}, "", "", fmt.Errorf( + "reuse enrollment for agent %q: saved deployment has no supervisor address", opts.Name) + } + return detectedMailbox{Transport: transport}, supervisor, "saved deployment", nil + } + + // Detection before minting. A mint followed by a failed provision leaves a + // live credential to revoke, and a wrongly resolved address does not surface + // until the first dispatch. + mailbox, err := detectMailbox(ctx, mailboxDetection{ + Backend: opts.Backend, NeedOffHost: opts.SupervisorAddress == "", Transport: transport, + }) + if err != nil { + return detectedMailbox{}, "", "", err + } + supervisor, from, err := resolveSupervisorAddress(target, mailbox, opts.SupervisorAddress) + if err != nil { + return detectedMailbox{}, "", "", err + } + if err := verifySupervisorNameIsCovered(ctx, mailbox, supervisor); err != nil { + return detectedMailbox{}, "", "", err + } + return mailbox, supervisor, from, nil +} + +// applyCredentialMount puts the agent-login source on the plan, refusing the +// flag that belongs to the other target rather than silently ignoring it — an +// ignored --credentials-dir on a Kubernetes deploy would look configured and +// leave the sidecar with no login. +func applyCredentialMount(plan *deploy.Plan, opts GitAgentDeployOptions, target deploy.Target) error { + secret := strings.TrimSpace(opts.CredentialsSecret) + directory := strings.TrimSpace(opts.CredentialsDir) + switch { + case secret != "" && directory != "": + return fmt.Errorf("--credentials-secret and --credentials-dir are mutually exclusive") + case secret != "" && target != deploy.TargetKubernetes: + return fmt.Errorf("--credentials-secret needs --target kubernetes; use --credentials-dir for docker") + case directory != "" && target != deploy.TargetDocker: + return fmt.Errorf("--credentials-dir needs --target docker; use --credentials-secret for kubernetes") + } + if directory != "" { + absolute, err := filepath.Abs(directory) + if err != nil { + return fmt.Errorf("resolve --credentials-dir %q: %w", directory, err) + } + // A path docker cannot bind-mount produces a container that fails to + // start, long after deploy has already minted a token. + if info, err := os.Stat(absolute); err != nil { + return fmt.Errorf("--credentials-dir %s: %w (run `captain sandbox credentials sync --directory %s` first)", + absolute, err, absolute) + } else if !info.IsDir() { + return fmt.Errorf("--credentials-dir %s is not a directory", absolute) + } + plan.CredentialsDir = absolute + } + plan.CredentialsSecret = secret + return nil +} + +// describeCredentials reports whether the sidecar was given any way to +// authenticate to a model provider. Without one it enrolls, goes ready, and +// fails the first dispatch — so it is stated rather than discovered. +func describeCredentials(opts GitAgentDeployOptions) string { + declared := len(opts.Env) + len(opts.EnvFromSecret) + if strings.TrimSpace(opts.CredentialsSecret) != "" || strings.TrimSpace(opts.CredentialsDir) != "" { + declared++ + } + if declared == 0 { + return "none declared — the agent cannot reach a model provider (--env / --env-from-secret / --credentials-secret)" + } + return fmt.Sprintf("%d source(s) declared", declared) +} + +// backendPresets reads the sandbox presets the backend selects, so a preset +// granting a runtime socket is refused before anything is created. +func backendPresets(backendName string) ([]string, error) { + cfg, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return nil, nil + } + var presets []string + switch declared := backend.Options["presets"].(type) { + case []string: + presets = declared + case []any: + for _, item := range declared { + if name, ok := item.(string); ok { + presets = append(presets, name) + } + } + } + return presets, nil +} + +// replacementReusesEnrollment prevents replacement from rotating the identity +// stored on a managed deployment's retained state volume. +func replacementReusesEnrollment(opts GitAgentDeployOptions) (bool, error) { + cfg, _, err := captainconfig.Load() + if err != nil { + return false, err + } + if _, err := enrolledAgent(cfg, opts.Backend, opts.Name); err != nil { + if opts.reuseEnrollment { + return false, fmt.Errorf("agent %q no longer has an enrollment to reuse", opts.Name) + } + return false, nil + } + if !opts.Replace { + return false, fmt.Errorf( + "agent %q is already enrolled in backend %q; re-enrolling would repoint the supervisor at a new key and "+ + "leave any existing sidecar running with one that is no longer authorized. "+ + "Pass --replace to restart its managed workload with the same enrollment, or pick another name", + opts.Name, opts.Backend) + } + if _, found := lookupDeployment(opts.Backend, opts.Name); !found { + return false, fmt.Errorf( + "agent %q is enrolled but has no Captain-managed deployment whose state can be retained; "+ + "replace cannot preserve its identity", opts.Name) + } + return true, nil +} + +func printResolvedTarget(plan deploy.Plan, result GitAgentDeployResult, timeout time.Duration) { + clicky.Printf("deploying git-agent %q\n", plan.Name) + if plan.Target == deploy.TargetKubernetes { + clicky.Printf(" cluster: %s\n", kubeTargetDescription(result.Namespace)) + } else { + clicky.Printf(" docker host: %s\n", dockerHostDescription()) + } + clicky.Printf(" image: %s\n", plan.Image) + clicky.Printf(" reaches mailbox at: %s (%s)\n", plan.Supervisor, result.SupervisorFrom) + // Printed directly under the line whose credibility it qualifies, and + // explicit that it is not the path the sidecar takes. + if len(result.OffHostAddresses) > 0 { + clicky.Printf(" off-loopback proof: %s answered (the sidecar dials the name above, not these)\n", + strings.Join(result.OffHostAddresses, ", ")) + } + clicky.Printf(" dispatched to at: %s (%s)\n", plan.Advertise, result.AdvertiseFrom) + // The DNS record is the one precondition this command cannot create and + // cannot prove from here, so it is stated every time rather than discovered + // when the first dispatch goes unanswered. + if plan.HasExternalRoute() { + clicky.Printf(" ingress: %s (class %s, %s)\n", + plan.ExternalRoute.Host, plan.ExternalRoute.ClassName, describeRouteCertificate(plan.ExternalRoute)) + clicky.Printf(" DNS required: %s must resolve to the %s ingress controller\n", + plan.ExternalRoute.Host, plan.ExternalRoute.ClassName) + } + clicky.Printf(" security: %s\n", result.Security) + clicky.Printf(" credentials: %s\n", result.Credentials) + clicky.Printf(" timeout: %s\n", timeout) +} + +func kubeTargetDescription(namespace string) string { + if namespace == "" { + namespace = "" + } + return fmt.Sprintf("namespace %s", namespace) +} + +func dockerHostDescription() string { + if host := strings.TrimSpace(os.Getenv("DOCKER_HOST")); host != "" { + return host + } + return "the local docker daemon" +} + +// deployMutations lists every change a deploy intends, in the order it makes +// them. It is the single source for both the CLI's --dry-run output and the web +// UI's preview, so the two cannot describe different deployments. +func deployMutations(plan deploy.Plan, opts GitAgentDeployOptions) []string { + var mutations []string + if opts.Replace { + mutations = append(mutations, fmt.Sprintf( + "remove the existing workload %s and retain its state volume %s", + plan.WorkloadName(), plan.VolumeName())) + } + if opts.reuseEnrollment { + mutations = append(mutations, fmt.Sprintf( + "reuse the existing durable enrollment for agent %q from its retained state volume", plan.Name)) + } else { + mutations = append(mutations, + fmt.Sprintf("mint a durable captain token for agent %q", plan.Name), + fmt.Sprintf("record the dispatch key under sandbox.backends.%s in %s", plan.Backend, configPathForDisplay()), + ) + } + switch plan.Target { + case deploy.TargetDocker: + joinPath := "" + if !opts.reuseEnrollment { + joinPath = joinTokenPath(plan) + mutations = append(mutations, fmt.Sprintf("write the token to %s (0600)", joinPath)) + } + mutations = append(mutations, + "pull "+plan.Image, + "run: docker "+strings.Join(deploy.DockerArgs(plan, joinPath), " ")) + case deploy.TargetKubernetes: + mutations = append(mutations, kubernetesMutations(plan, opts)...) + } + return append(mutations, + fmt.Sprintf("record the deployment under sandbox.backends.%s.deployments so undeploy knows where it went", plan.Backend)) +} + +// kubernetesMutations lists the cluster changes, and the one precondition this +// deploy pointedly does NOT create. +func kubernetesMutations(plan deploy.Plan, opts GitAgentDeployOptions) []string { + var mutations []string + if opts.CreateNamespace { + // Listed separately because it is the one cluster-scoped change here, + // and it outlives an undeploy. + mutations = append(mutations, + fmt.Sprintf("create %s if it does not exist", kubeTargetDescription(opts.Namespace))) + } + mutations = append(mutations, fmt.Sprintf("apply to %s as field manager %s", + kubeTargetDescription(opts.Namespace), deploy.FieldManager)) + objects := []string{"PersistentVolumeClaim/" + plan.VolumeName(), + "Service/" + plan.WorkloadName(), + "Deployment/" + plan.WorkloadName(), + } + if !opts.reuseEnrollment { + objects = append([]string{"Secret/" + plan.JoinSecretName()}, objects...) + } + if plan.HasExternalRoute() { + objects = append(objects, "Ingress/"+plan.IngressName()) + } + for _, object := range objects { + mutations = append(mutations, " "+object) + } + if plan.CredentialsSecret != "" { + // Read, never written, by this deploy: the Secret is owned by the + // credential publisher and shared with every other agent in the + // namespace, so undeploy must not remove it either. + mutations = append(mutations, fmt.Sprintf("mount existing Secret/%s read-only at %s (not created or deleted here)", + plan.CredentialsSecret, deploy.CredentialsMountPath)) + } + if !plan.HasExternalRoute() { + return mutations + } + mutations = append(mutations, fmt.Sprintf("route https://%s%s to it, with the certificate in Secret/%s (%s)", + plan.ExternalRoute.Host, gitagent.GitHTTPPrefix, plan.IngressTLSSecretName(), + describeRouteCertificate(plan.ExternalRoute))) + // deployMutations is documented as every change the deploy intends, and the + // most consequential thing about this feature is a change it does not make. + return append(mutations, fmt.Sprintf( + "NOT create the DNS record: %s must already resolve to the %s ingress controller, or the certificate "+ + "never issues and the supervisor cannot reach the agent", + plan.ExternalRoute.Host, plan.ExternalRoute.ClassName)) +} + +func printDeployDryRun(mutations []string) { + for _, mutation := range mutations { + clicky.Printf("[dry-run] would %s\n", mutation) + } + clicky.Printf("[dry-run] the token is never printed and never enters the workload's argv\n") +} + +// joinTokenPath is the host-side token file for a docker deployment, kept +// beside the other key material so it inherits that directory's permissions. +func joinTokenPath(plan deploy.Plan) string { + keysDir, err := gitAgentKeysDir() + if err != nil { + return filepath.Join(os.TempDir(), plan.WorkloadName(), "join") + } + return filepath.Join(keysDir, "deploy", plan.Name, "join") +} + +// describeRouteCertificate names where the certificate for the route comes from. +func describeRouteCertificate(route deploy.ExternalRoute) string { + if route.ClusterIssuer != "" { + return "issuer " + route.ClusterIssuer + } + return "secret " + route.TLSSecret +} diff --git a/pkg/cli/gitagent_deploy_credentials_test.go b/pkg/cli/gitagent_deploy_credentials_test.go new file mode 100644 index 00000000..d9d0c61b --- /dev/null +++ b/pkg/cli/gitagent_deploy_credentials_test.go @@ -0,0 +1,85 @@ +package cli + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// The two credential flags belong to different targets. Ignoring the wrong one +// would leave an operator with a deploy that looks configured and a sidecar with +// no login, so each is refused rather than dropped. + +func TestCredentialsSecretRequiresKubernetes(t *testing.T) { + var plan deploy.Plan + err := applyCredentialMount(&plan, + GitAgentDeployOptions{CredentialsSecret: "captain-agent-credentials"}, deploy.TargetDocker) + + if err == nil { + t.Fatal("--credentials-secret was accepted on a docker deploy") + } + if !strings.Contains(err.Error(), "--credentials-dir for docker") { + t.Errorf("error does not name the right flag: %v", err) + } +} + +func TestCredentialsDirRequiresDocker(t *testing.T) { + var plan deploy.Plan + err := applyCredentialMount(&plan, + GitAgentDeployOptions{CredentialsDir: t.TempDir()}, deploy.TargetKubernetes) + + if err == nil { + t.Fatal("--credentials-dir was accepted on a kubernetes deploy") + } + if !strings.Contains(err.Error(), "--credentials-secret for kubernetes") { + t.Errorf("error does not name the right flag: %v", err) + } +} + +func TestCredentialFlagsAreMutuallyExclusive(t *testing.T) { + var plan deploy.Plan + err := applyCredentialMount(&plan, GitAgentDeployOptions{ + CredentialsSecret: "s", CredentialsDir: t.TempDir(), + }, deploy.TargetKubernetes) + + if err == nil || !strings.Contains(err.Error(), "mutually exclusive") { + t.Fatalf("err = %v", err) + } +} + +func TestCredentialsDirIsResolvedAndMustExist(t *testing.T) { + // A path docker cannot bind-mount produces a container that fails to start, + // long after deploy has already minted a durable token. + var missing deploy.Plan + err := applyCredentialMount(&missing, + GitAgentDeployOptions{CredentialsDir: filepath.Join(t.TempDir(), "never-synced")}, + deploy.TargetDocker) + if err == nil { + t.Fatal("a missing credentials directory was accepted") + } + if !strings.Contains(err.Error(), "credentials sync") { + t.Errorf("error does not name the command that creates it: %v", err) + } + + dir := t.TempDir() + var present deploy.Plan + if err := applyCredentialMount(&present, + GitAgentDeployOptions{CredentialsDir: dir}, deploy.TargetDocker); err != nil { + t.Fatal(err) + } + if !filepath.IsAbs(present.CredentialsDir) { + t.Errorf("CredentialsDir = %q, want an absolute path", present.CredentialsDir) + } +} + +func TestNoCredentialFlagsLeavesThePlanUnmounted(t *testing.T) { + var plan deploy.Plan + if err := applyCredentialMount(&plan, GitAgentDeployOptions{}, deploy.TargetKubernetes); err != nil { + t.Fatal(err) + } + if plan.CredentialsSecret != "" || plan.CredentialsDir != "" { + t.Errorf("plan gained a credential mount from nothing: %+v", plan) + } +} diff --git a/pkg/cli/gitagent_deploy_detect.go b/pkg/cli/gitagent_deploy_detect.go new file mode 100644 index 00000000..bf414a6c --- /dev/null +++ b/pkg/cli/gitagent_deploy_detect.go @@ -0,0 +1,396 @@ +// Address detection for `captain sandbox git-agent deploy`. +// +// A git-agent topology needs two addresses pointing in opposite directions, and +// getting either wrong produces the same symptom: enrollment succeeds, the +// roster looks healthy, and the first dispatch — minutes or hours later — fails. +// That is why everything here proves rather than guesses, and refuses rather +// than defaults. +package cli + +import ( + "context" + "fmt" + "net" + "net/url" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// serviceAccountTokenPath is the projected token every in-cluster pod gets. Its +// presence alongside the service env vars is what client-go itself treats as +// proof of running in a cluster. +const serviceAccountTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token" + +// detectedMailbox is a mailbox this host serves, proven live and proven to be +// this host's own. +type detectedMailbox struct { + // Transport is the channel the sidecar relays over, and the scheme of the + // URL it is given. + Transport mailboxTransport + // Listen is the recorded bind address, Port the port parsed out of it. + Listen string + Port int + // HostFingerprint is the identity the mailbox presented: an SSH host key + // over ssh, a TLS public-key pin over https. The sidecar pins it, and the + // two are interchangeable everywhere downstream because both are compared + // against what the endpoint actually presents. + HostFingerprint string + // OffHostAddresses is every non-loopback address of this host that answered + // as this same mailbox, best-ranked first. Empty when the caller did not + // need the proof. + // + // It is evidence that the mailbox answers off loopback — not proof of the + // path the sidecar takes, which for docker is the host.docker.internal + // alias and not any of these. + OffHostAddresses []string +} + +// mailboxDetection asks for a mailbox with the properties a given deploy needs. +type mailboxDetection struct { + Backend string + // NeedOffHost requires proof that a workload in another network namespace + // can reach the mailbox, not merely that this host can. + NeedOffHost bool + // Transport forces one channel when this host serves both. Empty picks. + Transport mailboxTransport +} + +// detectMailbox proves this host serves a live git-agent mailbox before any +// token is minted. +// +// The record is the only authoritative source: a serving process writes it on +// startup, and a sidecar taking over the same address clears it. Falling back to +// a hardcoded :7422 would reintroduce the guess this exists to remove. +func detectMailbox(ctx context.Context, req mailboxDetection) (detectedMailbox, error) { + record, err := selectMailboxRecord(req.Backend, req.Transport) + if err != nil { + return detectedMailbox{}, err + } + if err := refuseUnusableMailbox(record, req.NeedOffHost); err != nil { + return detectedMailbox{}, err + } + port, err := record.Port() + if err != nil { + return detectedMailbox{}, err + } + identity, err := expectedMailboxIdentity(record) + if err != nil { + return detectedMailbox{}, err + } + local, err := record.LoopbackURL() + if err != nil { + return detectedMailbox{}, err + } + if err := gitagent.VerifyEndpointIdentity(ctx, local, identity); err != nil { + return detectedMailbox{}, fmt.Errorf("no live git-agent mailbox on %s: %w\n%s", + local, err, startMailboxHint(record)) + } + + detected := detectedMailbox{ + Transport: record.Transport, Listen: record.Listen, Port: port, HostFingerprint: identity, + } + if !req.NeedOffHost { + return detected, nil + } + // Binding off-loopback is not the same as being reachable off-loopback: a + // host firewall can accept on 127.0.0.1 and drop everything else. + reachable, err := proveOffHostReach(ctx, record, detected) + if err != nil { + return detectedMailbox{}, err + } + detected.OffHostAddresses = reachable + return detected, nil +} + +// selectMailboxRecord picks which recorded mailbox a deploy enrolls against. +// +// HTTPS is preferred when it is usable: it is what `captain serve` hosts, so it +// needs no second long-lived process. A usable ssh mailbox beats an https one +// that is recorded but serving plain HTTP, because it works — the caller only +// hears about the unusable https record when it is the only one there. +func selectMailboxRecord(backendName string, want mailboxTransport) (mailboxRecord, error) { + cfg, _, err := captainconfig.Load() + if err != nil { + return mailboxRecord{}, err + } + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return mailboxRecord{}, fmt.Errorf("backend %q is not configured; %s", backendName, noMailboxHint) + } + records := mailboxRecords(backend.Options) + if want != "" { + record, ok := records[want] + if !ok { + return mailboxRecord{}, fmt.Errorf( + "no %s mailbox has served from backend %q on this host (recorded: %s); %s", + want, backendName, recordedTransports(records), noMailboxHint) + } + return record, nil + } + if record, ok := records[transportHTTPS]; ok && record.Encrypted { + return record, nil + } + if record, ok := records[transportSSH]; ok { + return record, nil + } + if record, ok := records[transportHTTPS]; ok { + return record, nil // unusable, and refuseUnusableMailbox says exactly why + } + return mailboxRecord{}, fmt.Errorf( + "no mailbox has served from backend %q on this host, so there is no address to enroll against; %s", + backendName, noMailboxHint) +} + +// noMailboxHint names both ways to make this host a supervisor. It is one +// string because every refusal that ends here needs the same two commands. +const noMailboxHint = "either run `captain serve --host 0.0.0.0 --tls --tls-host
`, " + + "which hosts the mailbox over https, or run `captain sandbox git-agent serve --role mailbox` for the ssh transport" + +func recordedTransports(records map[mailboxTransport]mailboxRecord) string { + if len(records) == 0 { + return "none" + } + names := make([]string, 0, len(records)) + for transport := range records { + names = append(names, string(transport)) + } + sort.Strings(names) + return strings.Join(names, ", ") +} + +// refuseUnusableMailbox reports why a recorded mailbox cannot serve a deployed +// agent, naming the flags that fix it. +func refuseUnusableMailbox(record mailboxRecord, needOffHost bool) error { + if !record.Encrypted { + return fmt.Errorf( + "`captain serve` is hosting the mailbox on %s over plain HTTP, and an agent's captain token would "+ + "cross the network in clear text; restart it with: "+ + "captain serve --host 0.0.0.0 --tls --tls-host
", record.Listen) + } + host, _, err := net.SplitHostPort(record.Listen) + if err != nil { + return fmt.Errorf("recorded mailbox listen address %q is not [host]:port: %w", record.Listen, err) + } + // A loopback-bound mailbox can never be reached from another network + // namespace, so no workload could ever relay to it. Cheapest check, and the + // most common misconfiguration. + if !needOffHost || !isLoopbackHost(host) { + return nil + } + if record.Transport == transportHTTPS { + return fmt.Errorf( + "`captain serve` is hosting the mailbox on %s, which no container or pod can reach; "+ + "restart it with --host 0.0.0.0", record.Listen) + } + port, err := record.Port() + if err != nil { + return err + } + return fmt.Errorf( + "the mailbox is bound to %s, which no container or pod can reach; restart it with --listen :%d", + record.Listen, port) +} + +// expectedMailboxIdentity is what the endpoint must present to be this host's +// own mailbox. +// +// Over ssh it comes from the local host key rather than the record: the key file +// is always there and proves the listener holds *this host's* identity, which is +// stronger than agreeing with something written beside it. Over https the served +// certificate may be one supplied with --tls-cert and not in the keys directory, +// so the record — written by the process that is serving it — is the only source +// that is guaranteed to name the right one. +func expectedMailboxIdentity(record mailboxRecord) (string, error) { + if record.Transport == transportHTTPS { + if record.Identity == "" { + return "", fmt.Errorf( + "the recorded https mailbox on %s has no certificate pin; restart `captain serve` to re-record it", + record.Listen) + } + return record.Identity, nil + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return "", err + } + _, fingerprint, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + return fingerprint, err +} + +// verifySupervisorNameIsCovered proves the mailbox's certificate covers the +// name the deployed agent will dial. +// +// The agent verifies that name against a certificate it has not seen yet, from +// a network namespace where the name resolves and this one where it may not. +// Reading it from the endpoint here — the same endpoint detection just pinned — +// is the only way to be sure before the workload exists. +func verifySupervisorNameIsCovered(ctx context.Context, mailbox detectedMailbox, supervisor string) error { + if mailbox.Transport != transportHTTPS { + return nil + } + parsed, err := url.Parse(strings.TrimSpace(supervisor)) + if err != nil || parsed.Hostname() == "" { + return fmt.Errorf("supervisor address %q must be https://host[:port]", supervisor) + } + local, err := mailboxLoopbackURL(mailbox) + if err != nil { + return err + } + return gitagent.VerifyEndpointCoversName(ctx, local, parsed.Hostname()) +} + +// mailboxLoopbackURL is how this host reaches the mailbox it detected. +func mailboxLoopbackURL(mailbox detectedMailbox) (string, error) { + return mailboxRecord{Transport: mailbox.Transport, Listen: mailbox.Listen}.LoopbackURL() +} + +func startMailboxHint(record mailboxRecord) string { + if record.Transport == transportHTTPS { + return "start one with: captain serve --host 0.0.0.0 --tls --tls-host
" + } + return "start one with: captain sandbox git-agent serve --role mailbox --listen " + record.Listen +} + +// isLoopbackHost reports whether a bind host reaches only this host. An empty +// host means "all interfaces", which is what `:7422` yields. +func isLoopbackHost(host string) bool { + host = strings.TrimSpace(host) + if host == "" || host == "0.0.0.0" || host == "::" { + return false + } + if ip := net.ParseIP(host); ip != nil { + return ip.IsLoopback() + } + return strings.EqualFold(host, "localhost") +} + +// runningInCluster reports whether this process is itself a pod. Mirrors the +// test client-go uses, so the answer agrees with what the Kubernetes client +// will do when it loads its own config. +func runningInCluster() bool { + if os.Getenv("KUBERNETES_SERVICE_HOST") == "" || os.Getenv("KUBERNETES_SERVICE_PORT") == "" { + return false + } + info, err := os.Stat(serviceAccountTokenPath) + return err == nil && info.Mode().IsRegular() +} + +// resolveSupervisorAddress returns the address the DEPLOYED agent uses to reach +// this mailbox, and where that address came from. +func resolveSupervisorAddress(target deploy.Target, mailbox detectedMailbox, override string) (address, source string, err error) { + if given := strings.TrimSpace(override); given != "" { + return normalizeSupervisorAddress(given, mailbox.Port), "flag", nil + } + switch target { + case deploy.TargetDocker: + // host-gateway resolves to the bridge gateway on Linux and is built in on + // Docker Desktop, so one argv covers both and there is no platform branch + // to get wrong. Over https it must also be a name the certificate covers, + // which is why tlsSubjectNames includes it. + if mailbox.Transport == transportHTTPS { + return fmt.Sprintf("https://host.docker.internal:%d", mailbox.Port), "docker-host-gateway", nil + } + return fmt.Sprintf("ssh://captain@host.docker.internal:%d", mailbox.Port), "docker-host-gateway", nil + case deploy.TargetKubernetes: + if runningInCluster() { + return "", "", fmt.Errorf( + "captain is running in-cluster but cannot name the Service that fronts its own mailbox; "+ + "pass --supervisor-address %s://..svc.cluster.local:%d", + mailbox.Transport, mailbox.Port) + } + // Guessing the LAN address here would produce a pod that CrashLoops on + // enroll, holding a credential nothing revoked. + return "", "", fmt.Errorf( + "captain is not running in the target cluster, so no route back to this host can be proven; "+ + "pass --supervisor-address with an address the cluster can reach (this host answers on %s, "+ + "which is usually NOT reachable from a managed cluster)", + mailboxEndpointList(mailbox)) + } + return "", "", fmt.Errorf("unsupported target %q", target) +} + +// resolveAdvertiseAddress returns the address the SUPERVISOR dispatches to. +// +// It is always set explicitly. Left empty, the receiver derives it from the +// connection's source address (pkg/gitagent/server.go), which for a pod is a +// pod IP and for Docker Desktop is a VM-internal address — neither routable +// from the supervisor, and neither detectable as wrong until a dispatch fails. +func resolveAdvertiseAddress( + target deploy.Target, plan deploy.Plan, namespace, override string, inCluster bool, +) (address, source string, err error) { + if given := strings.TrimSpace(override); given != "" { + address, err := advertiseURL(given) + return address, "flag", err + } + switch target { + case deploy.TargetDocker: + if plan.HostPort == 0 { + return "", "", fmt.Errorf("docker deployment needs a published host port before it can advertise") + } + address, err := advertiseURL(fmt.Sprintf("captain@127.0.0.1:%d", plan.HostPort)) + return address, "docker-published-port", err + case deploy.TargetKubernetes: + if plan.HasExternalRoute() { + // Joined by the transport's own helper rather than formatted here, so + // the Ingress routing /git and the advertise URL cannot disagree. + address, err := gitagent.HTTPSRepoURL("https://"+plan.ExternalRoute.Host, SidecarRepoName) + return address, "cluster-ingress", err + } + if !inCluster { + return "", "", fmt.Errorf( + "captain is not running in the target cluster, so a ClusterIP address it cannot route to is "+ + "the only thing left to advertise — the agent would enroll, look healthy, and never "+ + "receive a dispatch. Pass --domain "+ + "to publish this agent at %s., or --advertise with a route you manage yourself", + plan.Name) + } + address, err := advertiseURL(fmt.Sprintf("captain@%s.%s.svc.cluster.local:%d", + plan.WorkloadName(), namespace, plan.ListenPort)) + return address, "cluster-service", err + } + return "", "", fmt.Errorf("unsupported target %q", target) +} + +// normalizeSupervisorAddress adds the scheme and, critically, a port. +// +// A portless endpoint defaults to :22 over ssh and :443 over https, so +// `--supervisor-address ssh://host` would otherwise silently probe sshd and +// `https://host` an unrelated web server, instead of the mailbox. A schemeless +// address is ssh, which is the form written before HTTPS existed. +func normalizeSupervisorAddress(address string, defaultPort int) string { + normalized := strings.TrimSuffix(strings.TrimSpace(address), "/") + if !strings.Contains(normalized, "://") { + normalized = "ssh://" + normalized + } + scheme, hostPort, _ := strings.Cut(normalized, "://") + if _, _, err := net.SplitHostPort(hostPort); err != nil { + return fmt.Sprintf("%s://%s:%d", scheme, hostPort, defaultPort) + } + return normalized +} + +// freeLoopbackPort reserves a port by binding and releasing it. +// +// The port has to be known before `docker run`, because the sidecar performs +// its join at startup and the join carries the advertise URL — so reading the +// port back from `docker port` afterwards would be too late. The window between +// release and bind is a real race; docker fails loudly if it is lost, and +// --host-port bypasses it. +func freeLoopbackPort(after int) (int, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return 0, fmt.Errorf("reserve a host port for the sidecar: %w", err) + } + defer listener.Close() + port := listener.Addr().(*net.TCPAddr).Port + if port == after { + return 0, fmt.Errorf("reserved port %d collides with the mailbox; retry or pass --host-port", port) + } + return port, nil +} diff --git a/pkg/cli/gitagent_deploy_detect_test.go b/pkg/cli/gitagent_deploy_detect_test.go new file mode 100644 index 00000000..deea72a5 --- /dev/null +++ b/pkg/cli/gitagent_deploy_detect_test.go @@ -0,0 +1,495 @@ +package cli + +import ( + "crypto/tls" + "fmt" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/captain/pkg/gitagent/deploy" + gossh "golang.org/x/crypto/ssh" +) + +// serveHostKey starts an SSH listener presenting signer, standing in for a +// mailbox: detection aborts at the key exchange and never issues a command. +func serveHostKey(t *testing.T, signer gossh.Signer) net.Listener { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = listener.Close() }) + + config := &gossh.ServerConfig{NoClientAuth: true} + config.AddHostKey(signer) + go func() { + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _, _, _, _ = gossh.NewServerConn(conn, config) + }() + } + }() + return listener +} + +// recordMailbox writes the record a serving process would. +func recordMailbox(t *testing.T, backend string, record mailboxRecord) { + t.Helper() + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + b, err := ensureGitAgentBackend(cfg, backend) + if err != nil { + return err + } + setMailboxRecord(b.Options, record) + cfg.Sandbox.Backends[backend] = b + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// recordMailboxListening writes an ssh mailbox record for the address given. +func recordMailboxListening(t *testing.T, backend, listen string) { + t.Helper() + recordMailbox(t, backend, mailboxRecord{Transport: transportSSH, Listen: listen, Encrypted: true}) +} + +// serveTLSPresenting starts an HTTPS listener holding a captain-generated +// certificate, and returns its listen address and public-key pin — what +// `captain serve --tls` records about itself. +func serveTLSPresenting(t *testing.T) (listen, pin string) { + t.Helper() + credential, err := gitagent.EnsureTLSCredential(t.TempDir(), nil) + if err != nil { + t.Fatal(err) + } + server := httptest.NewUnstartedServer(http.NotFoundHandler()) + server.TLS = &tls.Config{Certificates: []tls.Certificate{credential.Certificate}, MinVersion: tls.VersionTLS12} + server.StartTLS() + t.Cleanup(server.Close) + return server.Listener.Addr().String(), credential.PublicKeyPin +} + +// hostSigner returns this host's git-agent host key, creating it as serve would. +func hostSigner(t *testing.T) gossh.Signer { + t.Helper() + keysDir, err := gitAgentKeysDir() + if err != nil { + t.Fatal(err) + } + signer, _, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + if err != nil { + t.Fatal(err) + } + return signer +} + +// Without a record there is no address to enroll against. Both the +// no-such-backend and the backend-never-served paths must refuse and name both +// ways to fix it, rather than falling back to a hardcoded :7422 — that guess is +// exactly what the record replaces. +func TestDetectMailboxRequiresARecord(t *testing.T) { + for _, tc := range []struct{ name, backend string }{ + {"backend does not exist", "git-agent"}, + {"backend exists but never served", "configured"}, + } { + t.Run(tc.name, func(t *testing.T) { + isolatedConfig(t) + if tc.backend == "configured" { + if err := captainconfig.Update(func(cfg *captainconfig.Config) error { + _, err := ensureGitAgentBackend(cfg, tc.backend) + return err + }); err != nil { + t.Fatal(err) + } + } + + _, err := detectMailbox(t.Context(), mailboxDetection{Backend: tc.backend}) + if err == nil { + t.Fatal("detection succeeded with no recorded mailbox") + } + // Both transports host a mailbox, so a refusal that named only one + // would send an operator to start a process they do not need. + for _, want := range []string{"captain serve", "serve --role mailbox"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want it to name %q", err, want) + } + } + if strings.Contains(err.Error(), ":7422") { + t.Fatalf("a missing record must not resolve to a default port: %v", err) + } + }) + } +} + +func TestDetectMailboxProvesTheListenerIsOurs(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + listener := serveHostKey(t, hostSigner(t)) + _, port, _ := net.SplitHostPort(listener.Addr().String()) + recordMailboxListening(t, backend, ":"+port) + + mailbox, err := detectMailbox(t.Context(), mailboxDetection{Backend: backend}) + if err != nil { + t.Fatal(err) + } + if mailbox.Port != atoi(t, port) { + t.Fatalf("port = %d, want %s", mailbox.Port, port) + } + if mailbox.Transport != transportSSH { + t.Fatalf("transport = %q, want ssh", mailbox.Transport) + } + if mailbox.HostFingerprint == "" { + t.Fatal("no host fingerprint; the sidecar would have nothing to pin") + } +} + +// The https half of the same proof: `captain serve --tls` records its pin, and +// detection confirms the address is still held by the server that recorded it. +func TestDetectMailboxProvesAnHTTPSListenerIsOurs(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + listen, pin := serveTLSPresenting(t) + recordMailbox(t, backend, mailboxRecord{ + Transport: transportHTTPS, Listen: listen, Identity: pin, Encrypted: true, + }) + + mailbox, err := detectMailbox(t.Context(), mailboxDetection{Backend: backend}) + if err != nil { + t.Fatal(err) + } + if mailbox.Transport != transportHTTPS { + t.Fatalf("transport = %q, want https", mailbox.Transport) + } + if mailbox.HostFingerprint != pin { + t.Fatalf("identity = %q, want the served pin %q", mailbox.HostFingerprint, pin) + } +} + +// A TCP dial would call an unrelated sshd healthy, and enrolling against it +// hands a durable credential to a server that is not the supervisor. +func TestDetectMailboxRejectsAForeignSSHServer(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + foreign, _, err := gitagent.EnsureKeyPair(filepath.Join(t.TempDir(), "foreign_ed25519")) + if err != nil { + t.Fatal(err) + } + listener := serveHostKey(t, foreign) + _, port, _ := net.SplitHostPort(listener.Addr().String()) + recordMailboxListening(t, backend, ":"+port) + + _, err = detectMailbox(t.Context(), mailboxDetection{Backend: backend}) + if err == nil || !strings.Contains(err.Error(), "another server holds that address") { + t.Fatalf("err = %v, want a host-key mismatch", err) + } +} + +func TestDetectMailboxRefusesALoopbackBindForOffHostWorkloads(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + recordMailboxListening(t, backend, "127.0.0.1:7422") + + _, err := detectMailbox(t.Context(), mailboxDetection{Backend: backend, NeedOffHost: true}) + if err == nil || !strings.Contains(err.Error(), "no container or pod can reach") { + t.Fatalf("err = %v, want a refusal to enroll against a loopback-bound mailbox", err) + } + if !strings.Contains(err.Error(), "--listen :7422") { + t.Fatalf("err = %v, want the ssh mailbox's own restart flag", err) + } +} + +// This is the refusal a default `captain serve` earns: it hosts the mailbox, so +// there IS a record, but on loopback and over plain HTTP. Reporting "no mailbox +// has ever served here" would send the operator to start a process they already +// have running. +func TestDetectMailboxRefusesAPlainHTTPServe(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + recordMailbox(t, backend, mailboxRecord{Transport: transportHTTPS, Listen: "localhost:9020"}) + + _, err := detectMailbox(t.Context(), mailboxDetection{Backend: backend, NeedOffHost: true}) + if err == nil { + t.Fatal("detection accepted a mailbox that would carry a token in clear text") + } + for _, want := range []string{"plain HTTP", "--tls", "--host 0.0.0.0", "localhost:9020"} { + if !strings.Contains(err.Error(), want) { + t.Fatalf("err = %v, want it to name %q", err, want) + } + } +} + +func TestDetectMailboxRefusesALoopbackBoundServe(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + recordMailbox(t, backend, mailboxRecord{ + Transport: transportHTTPS, Listen: "127.0.0.1:9020", Identity: "sha256//x", Encrypted: true, + }) + + _, err := detectMailbox(t.Context(), mailboxDetection{Backend: backend, NeedOffHost: true}) + if err == nil || !strings.Contains(err.Error(), "--host 0.0.0.0") { + t.Fatalf("err = %v, want the flag that rebinds captain serve", err) + } + if strings.Contains(err.Error(), "--listen") { + t.Fatalf("err = %v names the ssh mailbox's flag for an https mailbox", err) + } +} + +// `captain serve` runs constantly for the web UI, so it must not displace a +// working ssh mailbox — and when it is the usable one, it wins. +func TestSelectMailboxRecordPrefersAUsableHTTPS(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + recordMailboxListening(t, backend, ":7422") + + t.Run("plain-HTTP serve does not displace a working ssh mailbox", func(t *testing.T) { + recordMailbox(t, backend, mailboxRecord{Transport: transportHTTPS, Listen: "localhost:9020"}) + + record, err := selectMailboxRecord(backend, "") + if err != nil { + t.Fatal(err) + } + if record.Transport != transportSSH { + t.Fatalf("transport = %q, want the ssh mailbox that still works", record.Transport) + } + }) + + t.Run("a TLS serve is preferred, needing no second process", func(t *testing.T) { + recordMailbox(t, backend, mailboxRecord{ + Transport: transportHTTPS, Listen: "0.0.0.0:9020", Identity: "sha256//x", Encrypted: true, + }) + + record, err := selectMailboxRecord(backend, "") + if err != nil { + t.Fatal(err) + } + if record.Transport != transportHTTPS { + t.Fatalf("transport = %q, want https", record.Transport) + } + }) + + t.Run("--transport forces the other one", func(t *testing.T) { + record, err := selectMailboxRecord(backend, transportSSH) + if err != nil { + t.Fatal(err) + } + if record.Listen != ":7422" { + t.Fatalf("listen = %q, want the ssh mailbox", record.Listen) + } + }) + + t.Run("--transport names what is recorded when it asks for what is not", func(t *testing.T) { + isolatedConfig(t) + recordMailboxListening(t, backend, ":7422") + + _, err := selectMailboxRecord(backend, transportHTTPS) + if err == nil || !strings.Contains(err.Error(), "recorded: ssh") { + t.Fatalf("err = %v, want it to say which transport IS recorded", err) + } + }) +} + +func TestResolveSupervisorAddress(t *testing.T) { + mailbox := detectedMailbox{ + Transport: transportSSH, Port: 7422, + OffHostAddresses: []string{"192.168.1.20", "172.17.0.1"}, + } + + t.Run("docker reaches the host through the gateway alias", func(t *testing.T) { + address, source, err := resolveSupervisorAddress(deploy.TargetDocker, mailbox, "") + if err != nil { + t.Fatal(err) + } + if address != "ssh://captain@host.docker.internal:7422" || source != "docker-host-gateway" { + t.Fatalf("address = %q, source = %q", address, source) + } + }) + + // The same alias, but the URL the HTTPS transport takes: no user, and the + // repository path is appended per-push rather than baked in here. + t.Run("an https mailbox yields an https supervisor URL", func(t *testing.T) { + served := detectedMailbox{Transport: transportHTTPS, Port: 9020} + address, source, err := resolveSupervisorAddress(deploy.TargetDocker, served, "") + if err != nil { + t.Fatal(err) + } + if address != "https://host.docker.internal:9020" || source != "docker-host-gateway" { + t.Fatalf("address = %q, source = %q", address, source) + } + }) + + // A laptop's LAN address is almost never routable from a managed cluster. + // Guessing yields a pod that crash-loops after the token is already spent. + t.Run("kubernetes refuses to guess a route into the cluster", func(t *testing.T) { + _, _, err := resolveSupervisorAddress(deploy.TargetKubernetes, mailbox, "") + if err == nil || !strings.Contains(err.Error(), "--supervisor-address") { + t.Fatalf("err = %v, want a demand for an explicit address", err) + } + // The operator has to pick one, so every address that answered is named + // rather than only the routing table's guess. + for _, want := range []string{"ssh://192.168.1.20:7422", "ssh://172.17.0.1:7422"} { + if !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to name %q", err, want) + } + } + }) + + t.Run("an explicit address wins and keeps its port", func(t *testing.T) { + address, source, err := resolveSupervisorAddress(deploy.TargetKubernetes, mailbox, "ssh://mailbox.internal:2222") + if err != nil { + t.Fatal(err) + } + if address != "ssh://mailbox.internal:2222" || source != "flag" { + t.Fatalf("address = %q, source = %q", address, source) + } + }) + + // A portless endpoint defaults to :22 over ssh and :443 over https, so a bare + // host would otherwise probe sshd or an unrelated web server, not the mailbox. + t.Run("a portless address gets the mailbox port, not the scheme default", func(t *testing.T) { + for given, want := range map[string]string{ + "mailbox.internal": "ssh://mailbox.internal:7422", + "ssh://mailbox.internal": "ssh://mailbox.internal:7422", + "https://mailbox.internal": "https://mailbox.internal:7422", + } { + address, _, err := resolveSupervisorAddress(deploy.TargetDocker, mailbox, given) + if err != nil { + t.Fatal(err) + } + if address != want { + t.Errorf("resolve(%q) = %q, want %q", given, address, want) + } + } + }) +} + +// A certificate that omits the name the agent dials produces a sidecar that +// enrolls and then fails every relay with a TLS error naming neither cause nor +// fix, so it is proven here against the certificate actually being served. +func TestVerifySupervisorNameIsCovered(t *testing.T) { + listen, pin := serveTLSPresenting(t) + mailbox := detectedMailbox{Transport: transportHTTPS, Listen: listen, HostFingerprint: pin} + + if err := verifySupervisorNameIsCovered(t.Context(), mailbox, "https://host.docker.internal:9020"); err != nil { + t.Fatalf("a generated certificate must cover the name docker sidecars dial: %v", err) + } + + err := verifySupervisorNameIsCovered(t.Context(), mailbox, "https://supervisor.corp:9020") + if err == nil || !strings.Contains(err.Error(), "--tls-host supervisor.corp") { + t.Fatalf("err = %v, want the flag that would cover the name", err) + } + + // An ssh mailbox has no certificate, so there is nothing to check rather + // than something that fails. + if err := verifySupervisorNameIsCovered(t.Context(), detectedMailbox{Transport: transportSSH}, "ssh://x:22"); err != nil { + t.Fatalf("ssh must not be certificate-checked: %v", err) + } +} + +func TestResolveAdvertiseAddress(t *testing.T) { + plan := deploy.Plan{Name: "worker-01", ListenPort: 7422, HostPort: 7423} + + t.Run("docker advertises its published loopback port", func(t *testing.T) { + address, source, err := resolveAdvertiseAddress(deploy.TargetDocker, plan, "", "", false) + if err != nil { + t.Fatal(err) + } + want := "ssh://captain@127.0.0.1:7423/" + SidecarRepoName + if address != want || source != "docker-published-port" { + t.Fatalf("address = %q, source = %q, want %q", address, source, want) + } + }) + + // A pod IP changes on restart, and RecordAgent stores the URL once at + // enrollment, so the Service name is the only durable answer — but only a + // supervisor inside the cluster can route to it. + t.Run("an in-cluster supervisor advertises the Service, not a pod IP", func(t *testing.T) { + address, source, err := resolveAdvertiseAddress(deploy.TargetKubernetes, plan, "agents", "", true) + if err != nil { + t.Fatal(err) + } + want := "ssh://captain@captain-git-agent-worker-01.agents.svc.cluster.local:7422/" + SidecarRepoName + if address != want || source != "cluster-service" { + t.Fatalf("address = %q, source = %q, want %q", address, source, want) + } + }) + + // A ClusterIP address the supervisor cannot route to would enroll, look + // healthy, and never receive a dispatch. + t.Run("an out-of-cluster supervisor refuses to advertise a cluster address", func(t *testing.T) { + _, _, err := resolveAdvertiseAddress(deploy.TargetKubernetes, plan, "agents", "", false) + if err == nil || !strings.Contains(err.Error(), "--domain") { + t.Fatalf("err = %v, want a demand for a route the supervisor can reach", err) + } + }) + + t.Run("an ingress advertises https under the git prefix", func(t *testing.T) { + routed := plan + routed.ExternalRoute = deploy.ExternalRoute{Host: "worker-01.agents.example.com", ClassName: "nginx"} + + address, source, err := resolveAdvertiseAddress(deploy.TargetKubernetes, routed, "agents", "", false) + if err != nil { + t.Fatal(err) + } + want := "https://worker-01.agents.example.com/git/" + SidecarRepoName + if address != want || source != "cluster-ingress" { + t.Fatalf("address = %q, source = %q, want %q", address, source, want) + } + + // awaitEnrollment compares the recorded URL to plan.Advertise byte for + // byte, and the sidecar re-normalizes what it was passed through the same + // advertiseURL. If that is not a fixed point every ingress deploy fails + // with "enrolled advertising X, but the deployment expects Y". + settled, err := advertiseURL(address) + if err != nil || settled != address { + t.Fatalf("advertiseURL(%q) = %q, %v; want it unchanged", address, settled, err) + } + }) + + t.Run("docker refuses to advertise before a port is published", func(t *testing.T) { + _, _, err := resolveAdvertiseAddress(deploy.TargetDocker, deploy.Plan{Name: "w"}, "", "", false) + if err == nil || !strings.Contains(err.Error(), "published host port") { + t.Fatalf("err = %v", err) + } + }) +} + +func TestIsLoopbackHost(t *testing.T) { + for host, want := range map[string]bool{ + "": false, // ":7422" binds every interface + "0.0.0.0": false, + "::": false, + "127.0.0.1": true, + "::1": true, + "localhost": true, + "10.0.0.4": false, + } { + if got := isLoopbackHost(host); got != want { + t.Errorf("isLoopbackHost(%q) = %v, want %v", host, got, want) + } + } +} + +func atoi(t *testing.T, s string) int { + t.Helper() + var n int + if _, err := fmt.Sscanf(s, "%d", &n); err != nil { + t.Fatal(err) + } + return n +} diff --git a/pkg/cli/gitagent_deploy_ingress.go b/pkg/cli/gitagent_deploy_ingress.go new file mode 100644 index 00000000..36cf683d --- /dev/null +++ b/pkg/cli/gitagent_deploy_ingress.go @@ -0,0 +1,286 @@ +// Resolving the external route for `captain sandbox git-agent deploy`. +// +// A Kubernetes sidecar's advertise address is the one thing in this command that +// cannot be detected: a supervisor outside the cluster cannot dial a ClusterIP, +// and the name that would work does not exist until someone creates a DNS record +// captain has no way to see. So the flags either name it or the deploy refuses, +// and the one change this feature does NOT make is stated out loud. +package cli + +import ( + "context" + "errors" + "fmt" + "net" + "net/url" + "regexp" + "strings" + + "github.com/flanksource/captain/pkg/gitagent/deploy" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// dnsLabel is what a single DNS name component may be. +// +// Agent names are validated as ^[a-z0-9-]{1,64}$, which is looser: it permits a +// leading or trailing hyphen and one more character than DNS allows. The API +// server accepts such a host in spec.rules[].host — it validates a laxer +// wildcard-subdomain shape — and the name then simply never resolves. +var dnsLabel = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + +// applyExternalRoute puts the external route on the plan, or leaves it empty for +// the in-cluster topology. +// +// Modelled on applyCredentialMount: a flag belonging to the other target is +// refused rather than ignored, because an ignored --domain looks configured and +// leaves an operator waiting on a hostname nothing ever created. +func applyExternalRoute(plan *deploy.Plan, opts GitAgentDeployOptions, target deploy.Target) error { + domain := strings.TrimSpace(opts.Domain) + issuer := strings.TrimSpace(opts.IngressIssuer) + secret := strings.TrimSpace(opts.IngressTLSSecret) + declared := domain != "" || issuer != "" || secret != "" || len(opts.IngressAnnotation) > 0 + + if declared && target != deploy.TargetKubernetes { + return fmt.Errorf("--domain needs --target kubernetes; a docker sidecar is reached on its published loopback port") + } + if !declared { + return nil + } + if domain == "" { + return fmt.Errorf( + "--ingress-issuer has no effect without --domain; without a domain no Ingress is created and the " + + "agent is only reachable inside the cluster") + } + switch { + case issuer != "" && secret != "": + return fmt.Errorf("--ingress-issuer and --ingress-tls-secret are mutually exclusive") + case issuer == "" && secret == "": + return fmt.Errorf( + "--domain %s needs a certificate for the agent's host: pass --ingress-issuer , or --ingress-tls-secret naming one you already have. Without either, the "+ + "controller answers for that host with its own default certificate and the supervisor's push "+ + "fails verification", domain) + } + host, err := resolveExternalHost(plan.Name, domain) + if err != nil { + return err + } + annotations, err := parseIngressAnnotations(opts.IngressAnnotation) + if err != nil { + return err + } + class := strings.TrimSpace(opts.IngressClass) + if err := refuseUntranslatedController(class, annotations); err != nil { + return err + } + if err := refuseConflictingAdvertise(opts.Advertise, host); err != nil { + return err + } + plan.ExternalRoute = deploy.ExternalRoute{ + Host: host, ClassName: class, ClusterIssuer: issuer, TLSSecret: secret, Annotations: annotations, + } + // The workload has to serve the protocol the route re-encrypts to; serving + // the other one accepts the connection and fails the handshake. + plan.Transport = string(transportHTTPS) + return nil +} + +// resolveExternalHost derives the name the supervisor dials, and proves it is a +// name that can resolve. +func resolveExternalHost(name, domain string) (string, error) { + host := name + "." + strings.Trim(strings.TrimSpace(domain), ".") + for _, label := range strings.Split(host, ".") { + if !dnsLabel.MatchString(label) { + return "", fmt.Errorf( + "the agent's host would be %q, which is not a valid DNS name (%q is not a label); agent names "+ + "are validated more loosely than DNS allows — rename the agent or pass a different --domain", + host, label) + } + } + if len(host) > 253 { + return "", fmt.Errorf("the agent's host would be %q, which is longer than a DNS name may be", host) + } + return host, nil +} + +// parseIngressAnnotations turns key=value entries into the map the route merges. +func parseIngressAnnotations(entries []string) (map[string]string, error) { + if len(entries) == 0 { + return nil, nil + } + annotations := make(map[string]string, len(entries)) + for _, entry := range entries { + key, value, found := strings.Cut(entry, "=") + key = strings.TrimSpace(key) + if !found || key == "" { + return nil, fmt.Errorf("--ingress-annotation %q must be key=value", entry) + } + annotations[key] = value + } + return annotations, nil +} + +// refuseUntranslatedController stops a deploy onto a controller whose vocabulary +// captain does not speak. +// +// It is an acknowledgement gate rather than a validation: any annotation +// satisfies it, because captain cannot check another controller's spelling. That +// is the strongest honest thing available, and the message says what has to be +// covered. +func refuseUntranslatedController(class string, annotations map[string]string) error { + if class == "" { + return fmt.Errorf("--ingress-class names the controller serving --domain; an Ingress with no class " + + "falls to whichever IngressClass is marked default, which may not be the one you mean") + } + if class == "nginx" || class == "traefik" || len(annotations) > 0 { + return nil + } + return fmt.Errorf( + "--ingress-class %q is not ingress-nginx, so the buffering, body-size and timeout settings this "+ + "transport depends on cannot be written as nginx.ingress.kubernetes.io annotations — they would be "+ + "applied and silently ignored. A push streams its verdict while it runs and can take minutes, so a "+ + "controller that buffers responses, caps the request body, or times out at 60s fails only on the "+ + "large or slow tasks. State the equivalents for %s with --ingress-annotation key=value (at minimum: "+ + "response buffering off, request buffering off, request body uncapped, read timeout of at least an "+ + "hour), or use --ingress-class nginx", class, class) +} + +// refuseConflictingAdvertise stops the two names that must agree from differing. +func refuseConflictingAdvertise(advertise, host string) error { + given := strings.TrimSpace(advertise) + if given == "" { + return nil + } + parsed, err := url.Parse(given) + if err != nil || parsed.Hostname() == "" { + return fmt.Errorf("--advertise %q must be https://host[:port] when used with --domain", advertise) + } + if parsed.Hostname() == host { + return nil + } + return fmt.Errorf( + "--advertise %s names host %q but --domain publishes this agent at %q; the Ingress routes only the "+ + "host in its rule, so a dispatch to the other name gets a 404 from the controller. Drop one of them", + advertise, parsed.Hostname(), host) +} + +// refuseUnroutableExternalRoute checks, before the mint, the two cluster facts +// that would otherwise produce an agent that enrolls, reports ready, and is +// never dispatchable. +// +// Both skip silently when the cluster will not answer, the same rule +// CheckPermissions applies: absence of the check is not a failed check. +func refuseUnroutableExternalRoute(ctx context.Context, client kubernetes.Interface, route deploy.ExternalRoute) error { + if err := refuseMissingIngressClass(ctx, client, route.ClassName); err != nil { + return err + } + if err := refuseMissingCertManager(ctx, client, route.ClusterIssuer); err != nil { + return err + } + return refuseUnresolvableHost(ctx, route.Host) +} + +// refuseMissingIngressClass is the highest-value check here. An Ingress naming a +// class no controller implements is accepted by the API server and then simply +// never routed: the deploy succeeds, the pod goes ready, the agent enrolls, and +// the first dispatch — hours later — gets no answer at all. +func refuseMissingIngressClass(ctx context.Context, client kubernetes.Interface, class string) error { + list, err := client.NetworkingV1().IngressClasses().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil //nolint:nilerr // absence of the check is not a failed check + } + available := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + if item.Name == class { + return nil + } + available = append(available, item.Name) + } + return fmt.Errorf( + "no IngressClass named %q exists in this cluster, so the Ingress would be created and never routed — "+ + "the agent would enroll, report ready, and answer no dispatch. Available: %s", + class, describeAvailable(available)) +} + +// refuseMissingCertManager catches an issuer annotation that is inert, which +// leaves the controller answering for the host with its own default certificate +// and the supervisor's push failing verification against a name it does not hold. +func refuseMissingCertManager(ctx context.Context, client kubernetes.Interface, issuer string) error { + if issuer == "" { + return nil + } + if _, err := client.Discovery().ServerResourcesForGroupVersion("cert-manager.io/v1"); err != nil { + if apiGroupMissing(err) { + return fmt.Errorf( + "--ingress-issuer %s names a cert-manager ClusterIssuer, but cert-manager is not installed in "+ + "this cluster, so the annotation would be ignored and no certificate issued; install "+ + "cert-manager, or pass --ingress-tls-secret naming a certificate you already have", issuer) + } + return nil //nolint:nilerr // absence of the check is not a failed check + } + return nil +} + +// refuseUnresolvableHost catches the precondition captain cannot create. +// +// With an HTTP-01 challenge the host must already resolve to the controller +// before the Ingress lands, or the challenge fails and the certificate never +// issues. NXDOMAIN is a refusal; a resolver that could not be reached is not, +// because "the name does not exist" and "I could not ask" call for different +// next steps. +func refuseUnresolvableHost(ctx context.Context, host string) error { + if _, err := net.DefaultResolver.LookupHost(ctx, host); err != nil { + var dnsErr *net.DNSError + if errors.As(err, &dnsErr) && dnsErr.IsNotFound { + return fmt.Errorf( + "%s does not resolve, so the supervisor could not reach this agent and a cert-manager HTTP-01 "+ + "challenge for it would fail; create a DNS record pointing it at the ingress controller "+ + "first, then re-run", host) + } + } + return nil +} + +// refuseDuplicateRouteHost stops two agents in one namespace claiming a host. +// +// ingress-nginx resolves a collision by oldest creationTimestamp with only a log +// line, so the newer agent's dispatches would go to the older pod. Only the +// target namespace is checked: another namespace or another cluster serving the +// same domain is genuinely outside what captain can see. +func refuseDuplicateRouteHost(ctx context.Context, client kubernetes.Interface, plan deploy.Plan, namespace string) error { + list, err := client.NetworkingV1().Ingresses(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil //nolint:nilerr // absence of the check is not a failed check + } + for _, existing := range list.Items { + if existing.Name == plan.IngressName() { + continue // this agent's own route, being re-applied + } + for _, rule := range existing.Spec.Rules { + if rule.Host != plan.ExternalRoute.Host { + continue + } + return fmt.Errorf( + "the Ingress %s/%s already routes %s, so this deploy would create a second claim on one host "+ + "and the controller would serve whichever was created first; pick another agent name or "+ + "another --domain", namespace, existing.Name, rule.Host) + } + } + return nil +} + +func describeAvailable(names []string) string { + if len(names) == 0 { + return "none — no ingress controller is installed" + } + return strings.Join(names, ", ") +} + +// apiGroupMissing distinguishes "this cluster does not have that API" from any +// other discovery failure. +func apiGroupMissing(err error) bool { + return strings.Contains(err.Error(), "could not find the requested resource") || + strings.Contains(err.Error(), "the server could not find the requested resource") || + strings.Contains(strings.ToLower(err.Error()), "not found") +} diff --git a/pkg/cli/gitagent_deploy_ingress_test.go b/pkg/cli/gitagent_deploy_ingress_test.go new file mode 100644 index 00000000..abc03cea --- /dev/null +++ b/pkg/cli/gitagent_deploy_ingress_test.go @@ -0,0 +1,209 @@ +package cli + +import ( + "strings" + "testing" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// routeOptions is a kubernetes deploy that would render an Ingress. +func routeOptions() GitAgentDeployOptions { + return GitAgentDeployOptions{ + Name: "worker-01", + Target: "kubernetes", + Domain: "agents.example.com", + IngressClass: "nginx", + IngressIssuer: "letsencrypt-prod", + } +} + +func TestApplyExternalRoute(t *testing.T) { + t.Run("resolves the host the supervisor will dial", func(t *testing.T) { + plan := deploy.Plan{Name: "worker-01"} + if err := applyExternalRoute(&plan, routeOptions(), deploy.TargetKubernetes); err != nil { + t.Fatal(err) + } + if plan.ExternalRoute.Host != "worker-01.agents.example.com" { + t.Fatalf("host = %q", plan.ExternalRoute.Host) + } + if plan.ExternalRoute.ClassName != "nginx" || plan.ExternalRoute.ClusterIssuer != "letsencrypt-prod" { + t.Fatalf("route = %+v", plan.ExternalRoute) + } + // The route re-encrypts to the pod, so the pod has to serve TLS. A plan + // where these disagree accepts the connection and fails the handshake. + if plan.Transport != string(transportHTTPS) { + t.Fatalf("transport = %q, want the route's own protocol", plan.Transport) + } + }) + + // No flags at all is the in-cluster topology, not an error. + t.Run("leaves the plan alone when no route is asked for", func(t *testing.T) { + plan := deploy.Plan{Name: "worker-01"} + if err := applyExternalRoute(&plan, GitAgentDeployOptions{Name: "worker-01"}, deploy.TargetKubernetes); err != nil { + t.Fatal(err) + } + if plan.HasExternalRoute() { + t.Fatalf("a route was invented: %+v", plan.ExternalRoute) + } + }) + + t.Run("an operator's own certificate replaces the issuer", func(t *testing.T) { + opts := routeOptions() + opts.IngressIssuer = "" + opts.IngressTLSSecret = "wildcard-agents" + + plan := deploy.Plan{Name: "worker-01"} + if err := applyExternalRoute(&plan, opts, deploy.TargetKubernetes); err != nil { + t.Fatal(err) + } + if plan.IngressTLSSecretName() != "wildcard-agents" { + t.Fatalf("secret = %q", plan.IngressTLSSecretName()) + } + }) + + t.Run("Traefik is accepted because captain renders its verified TLS transport", func(t *testing.T) { + opts := routeOptions() + opts.IngressClass = "traefik" + + plan := deploy.Plan{Name: "worker-01"} + if err := applyExternalRoute(&plan, opts, deploy.TargetKubernetes); err != nil { + t.Fatal(err) + } + if !plan.UsesTraefik() { + t.Fatalf("route = %+v, want Traefik", plan.ExternalRoute) + } + }) + + for _, tc := range []struct { + name string + mutate func(*GitAgentDeployOptions) + target deploy.Target + want string + }{{ + name: "a route on docker", + mutate: func(o *GitAgentDeployOptions) { o.Target = "docker" }, + target: deploy.TargetDocker, want: "--domain needs --target kubernetes", + }, { + name: "an issuer with no domain", + mutate: func(o *GitAgentDeployOptions) { o.Domain = "" }, + want: "has no effect without --domain", + }, { + name: "both certificate sources", + mutate: func(o *GitAgentDeployOptions) { o.IngressTLSSecret = "wildcard-agents" }, + want: "mutually exclusive", + }, { + // Without either, the controller answers with its own default certificate + // and the supervisor's push fails verification. + name: "neither certificate source", + mutate: func(o *GitAgentDeployOptions) { o.IngressIssuer = "" }, + want: "--ingress-issuer", + }, { + name: "no ingress class at all", + mutate: func(o *GitAgentDeployOptions) { o.IngressClass = "" }, + want: "--ingress-class", + }, { + name: "a malformed annotation", + mutate: func(o *GitAgentDeployOptions) { o.IngressAnnotation = []string{"nokey"} }, + want: "must be key=value", + }, { + // The Ingress routes only the host in its rule, so a dispatch to the other + // name gets a 404 from the controller. + name: "an advertise naming a different host", + mutate: func(o *GitAgentDeployOptions) { o.Advertise = "https://elsewhere.example.com/git/repo.git" }, + want: "Drop one of them", + }, { + // Agent names permit a leading hyphen and 64 characters; DNS does not. + name: "a name that is not a DNS label", + mutate: func(o *GitAgentDeployOptions) { + o.Name = "-worker" + }, + want: "is not a valid DNS name", + }, { + name: "a host longer than DNS allows", + mutate: func(o *GitAgentDeployOptions) { + o.Domain = strings.Repeat("a123456789.", 24) + "example.com" + }, + want: "longer than a DNS name may be", + }} { + t.Run(tc.name, func(t *testing.T) { + opts := routeOptions() + tc.mutate(&opts) + target := tc.target + if target == "" { + target = deploy.TargetKubernetes + } + plan := deploy.Plan{Name: opts.Name} + err := applyExternalRoute(&plan, opts, target) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to name %q", err, tc.want) + } + }) + } + + // An advertise naming the same host is redundant rather than contradictory. + t.Run("an advertise agreeing with the domain is accepted", func(t *testing.T) { + opts := routeOptions() + opts.Advertise = "https://worker-01.agents.example.com/git/repo.git" + + plan := deploy.Plan{Name: "worker-01"} + if err := applyExternalRoute(&plan, opts, deploy.TargetKubernetes); err != nil { + t.Fatal(err) + } + }) +} + +func TestParseIngressAnnotations(t *testing.T) { + got, err := parseIngressAnnotations([]string{ + "a=1", + // A value may itself contain '=', so only the first one splits. + "b=x=y", + "a=2", + }) + if err != nil { + t.Fatal(err) + } + want := map[string]string{"a": "2", "b": "x=y"} + for key, value := range want { + if got[key] != value { + t.Errorf("annotations[%q] = %q, want %q", key, got[key], value) + } + } + if len(got) != len(want) { + t.Fatalf("annotations = %v, want %v", got, want) + } + + for _, bad := range []string{"nokey", "=value", " =v"} { + if _, err := parseIngressAnnotations([]string{bad}); err == nil { + t.Errorf("parseIngressAnnotations(%q) was accepted", bad) + } + } + // An empty value is legitimate — some controllers read the key's presence. + if got, err := parseIngressAnnotations([]string{"k="}); err != nil || got["k"] != "" { + t.Fatalf("k= yielded %v, %v", got, err) + } +} + +func TestResolveExternalHost(t *testing.T) { + for _, tc := range []struct{ name, domain, want string }{ + {"worker-01", "agents.example.com", "worker-01.agents.example.com"}, + // A trailing dot is how a fully-qualified name is sometimes written; the + // Ingress rule wants it without. + {"worker-01", "agents.example.com.", "worker-01.agents.example.com"}, + } { + got, err := resolveExternalHost(tc.name, tc.domain) + if err != nil || got != tc.want { + t.Errorf("resolveExternalHost(%q, %q) = %q, %v; want %q", tc.name, tc.domain, got, err, tc.want) + } + } + for _, tc := range []struct{ name, domain string }{ + {"-worker", "agents.example.com"}, + {"worker-", "agents.example.com"}, + {"worker", "agents..example.com"}, + {"worker", "-agents.example.com"}, + } { + if _, err := resolveExternalHost(tc.name, tc.domain); err == nil { + t.Errorf("resolveExternalHost(%q, %q) was accepted", tc.name, tc.domain) + } + } +} diff --git a/pkg/cli/gitagent_deploy_reach.go b/pkg/cli/gitagent_deploy_reach.go new file mode 100644 index 00000000..6fe2c979 --- /dev/null +++ b/pkg/cli/gitagent_deploy_reach.go @@ -0,0 +1,276 @@ +// Which of this host's addresses a workload in another network namespace can +// reach the mailbox on. +// +// The routing table names exactly one — the source address a packet to the +// internet would carry — and on a laptop behind a VPN that is a tunnel address +// no container can reach, while the LAN address and the docker bridge gateway +// both work. It is also not the address the workload uses: a docker sidecar +// dials host.docker.internal, which resolves to the bridge gateway on Linux and +// to a VM-internal address on Docker Desktop. So this probes every address the +// host holds and reports all that answered, because the honest claim is "the +// mailbox answers off loopback", not "it answers on the one address that +// matters". +package cli + +import ( + "bytes" + "context" + "fmt" + "net" + "sort" + "strconv" + "strings" + "sync" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// proveOffHostReach returns every address of this host that answered as this +// mailbox, best-ranked first. +func proveOffHostReach(ctx context.Context, record mailboxRecord, mailbox detectedMailbox) ([]string, error) { + held, err := hostInterfaceIPs() + if err != nil { + return nil, err + } + host, _, err := net.SplitHostPort(record.Listen) + if err != nil { + return nil, fmt.Errorf("recorded mailbox listen address %q is not [host]:port: %w", record.Listen, err) + } + candidates := offHostCandidates(host, primaryOutboundAddress(), held) + if len(candidates) == 0 { + return nil, refuseNoOffHostCandidate(record, mailbox.Port, held) + } + return proveReachableOffHost(ctx, mailbox, candidates) +} + +// offHostCandidates ranks the addresses worth probing, best first. It is pure so +// the ranking can be tested without a network: primary is the routing table's +// answer or "", held is every unicast address this host carries. +func offHostCandidates(bindHost, primary string, held []net.IP) []string { + bindHost = strings.TrimSpace(bindHost) + // A listener bound to one address answers there and nowhere else, so every + // other candidate would be a guaranteed failure line in the refusal. A + // hostname parses as no IP and falls through to enumeration, which is right: + // we cannot know which address it resolved to. + if ip := net.ParseIP(bindHost); ip != nil && !ip.IsUnspecified() && !ip.IsLoopback() { + return []string{ip.String()} + } + // An IPv4 wildcard socket can never accept an IPv6 connection. "" and "::" + // keep both families, because Go's wildcard listener is dual-stack. + onlyV4 := bindHost == "0.0.0.0" + + ranked := make([]string, 0, len(held)+1) + seen := map[string]bool{} + add := func(ip net.IP) { + if onlyV4 && ip.To4() == nil { + return + } + if text := ip.String(); !seen[text] { + seen[text] = true + ranked = append(ranked, text) + } + } + // The routing table's answer first: it is the best single guess, and being + // wrong about it is no longer fatal now that the rest follow. + if ip := net.ParseIP(strings.TrimSpace(primary)); isUsableHostIP(ip) { + add(ip) + } + usable := usableHostIPs(held) + sortHostIPs(usable) + for _, ip := range usable { + add(ip) + } + return ranked +} + +// supervisorCandidates is every address of this host an operator could hand a +// workload as the supervisor endpoint, best-ranked first. +// +// It deliberately probes nothing, which is what separates it from +// proveOffHostReach above. The caller is the kubernetes path, where the workload +// runs in a cluster this host is not in: a probe from here proves the mailbox +// answers on an address, never that the cluster can route to it. Paying up to +// the transport's probe timeout for a claim that does not transfer would spend +// the web preflight's whole budget to narrow the list on the wrong axis. +// +// An address this cannot resolve yields no candidates rather than an error: the +// list is an offer, and detectMailbox already refuses a mailbox that is unusable. +func supervisorCandidates(mailbox detectedMailbox) []string { + held, err := hostInterfaceIPs() + if err != nil { + return nil + } + host, _, err := net.SplitHostPort(mailbox.Listen) + if err != nil { + return nil + } + return mailboxEndpoints(mailbox, offHostCandidates(host, primaryOutboundAddress(), held)) +} + +// isUsableHostIP reports whether an address could carry a connection from +// another network namespace. +// +// Link-local is excluded because InterfaceAddrs returns fe80:: without the zone +// index a dial would need, and 169.254/16 is not routed off the link either. +// IsPrivate is deliberately NOT a filter: a publicly addressed host is a +// legitimate supervisor. +func isUsableHostIP(ip net.IP) bool { + return ip != nil && !ip.IsLoopback() && !ip.IsUnspecified() && + !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() && !ip.IsMulticast() +} + +func usableHostIPs(held []net.IP) []net.IP { + usable := make([]net.IP, 0, len(held)) + for _, ip := range held { + if isUsableHostIP(ip) { + usable = append(usable, ip) + } + } + return usable +} + +// sortHostIPs orders IPv4 before IPv6 and then bytewise. +// +// For stability, not preference: net.InterfaceAddrs returns kernel order, and a +// refusal that lists every candidate in a different order on each run cannot be +// diffed against the last one. +func sortHostIPs(ips []net.IP) { + sort.Slice(ips, func(i, j int) bool { + left, right := ips[i].To4(), ips[j].To4() + if (left != nil) != (right != nil) { + return left != nil + } + return bytes.Compare(ips[i].To16(), ips[j].To16()) < 0 + }) +} + +// hostInterfaceIPs is every unicast address this host holds. +func hostInterfaceIPs() ([]net.IP, error) { + addrs, err := net.InterfaceAddrs() + if err != nil { + return nil, fmt.Errorf( + "could not enumerate this host's addresses, so no route to the mailbox can be proven; "+ + "pass --supervisor-address: %w", err) + } + ips := make([]net.IP, 0, len(addrs)) + for _, addr := range addrs { + if network, ok := addr.(*net.IPNet); ok && network.IP != nil { + ips = append(ips, network.IP) + } + } + return ips, nil +} + +// primaryOutboundAddress returns the routing table's source address for an +// off-host packet, or "" when it has none. +// +// The UDP "dial" transmits nothing — it only asks which source address a packet +// to that destination would carry. An empty answer is not an error: a host with +// no default route still holds a docker bridge a container reaches it on, and +// refusing here is the offline-laptop failure this ranking exists to remove. +func primaryOutboundAddress() string { + conn, err := net.Dial("udp", "203.0.113.1:9") // TEST-NET-3, never routed + if err != nil { + return "" + } + defer conn.Close() + addr, ok := conn.LocalAddr().(*net.UDPAddr) + if !ok { + return "" + } + return addr.IP.String() +} + +// proveReachableOffHost probes every candidate at once and returns those that +// answered as this mailbox, in the order given. +// +// Concurrent rather than serial because each probe costs up to the transport's +// 5s probe timeout while the web preflight budgets 8s for the whole detection, +// so sweeping a laptop's interfaces one at a time would report "the mailbox +// probe did not finish" instead of an answer. Every result is collected rather +// than cancelling siblings on the first success, because the addresses that did +// NOT answer are exactly what the refusal has to name. +// +// The candidate list is a parameter rather than something this derives, so a +// test can drive it with addresses a sandboxed host can actually serve. +func proveReachableOffHost(ctx context.Context, mailbox detectedMailbox, candidates []string) ([]string, error) { + failures := make([]error, len(candidates)) + var wait sync.WaitGroup + for i, candidate := range candidates { + wait.Add(1) + go func() { + defer wait.Done() + failures[i] = gitagent.VerifyEndpointIdentity(ctx, + mailboxEndpoint(mailbox.Transport, candidate, mailbox.Port), mailbox.HostFingerprint) + }() + } + wait.Wait() + + reachable := make([]string, 0, len(candidates)) + var refused strings.Builder + for i, candidate := range candidates { + if failures[i] == nil { + reachable = append(reachable, candidate) + continue + } + // The address is repeated even though the probe error already carries + // host:port, so a single grepped line still names what it is about. + fmt.Fprintf(&refused, "\n %s: %v", candidate, failures[i]) + } + if len(reachable) > 0 { + return reachable, nil + } + return nil, fmt.Errorf( + "the mailbox answers on %s but on no other address of this host, so a workload in another network "+ + "namespace cannot relay to it; a host firewall that accepts on loopback and drops the rest is the "+ + "usual cause. Pass --supervisor-address with an address the workload can reach to enroll without "+ + "this proof:%s", + mailboxEndpoint(mailbox.Transport, "127.0.0.1", mailbox.Port), refused.String()) +} + +// refuseNoOffHostCandidate explains an empty candidate list, which has two +// causes and two different fixes. +func refuseNoOffHostCandidate(record mailboxRecord, port int, held []net.IP) error { + if len(usableHostIPs(held)) == 0 { + return fmt.Errorf( + "this host holds no address outside loopback, so nothing in another network namespace could reach "+ + "the mailbox on %s; connect a network, or pass --supervisor-address with an address the "+ + "workload can reach", record.Listen) + } + // Everything this host holds was dropped by the family filter, so the bind + // is the thing to change. Names the flag the recording process takes, the + // way refuseUnusableMailbox already does. + rebind := fmt.Sprintf("--listen [::]:%d", port) + if record.Transport == transportHTTPS { + rebind = "--host ::" + } + return fmt.Errorf( + "the mailbox is bound to %s, an IPv4 socket, and this host holds no off-loopback IPv4 address; "+ + "restart it with %s so it answers on both families, or pass --supervisor-address with an address "+ + "the workload can reach", record.Listen, rebind) +} + +// mailboxEndpoint is the URL a client dials this mailbox on at one address. +func mailboxEndpoint(transport mailboxTransport, address string, port int) string { + return fmt.Sprintf("%s://%s", transport, net.JoinHostPort(address, strconv.Itoa(port))) +} + +// mailboxEndpoints renders addresses as endpoints of one mailbox, preserving +// their order. +func mailboxEndpoints(mailbox detectedMailbox, addresses []string) []string { + endpoints := make([]string, 0, len(addresses)) + for _, address := range addresses { + endpoints = append(endpoints, mailboxEndpoint(mailbox.Transport, address, mailbox.Port)) + } + return endpoints +} + +// mailboxEndpointList renders every proven address as an endpoint, for the +// Kubernetes refusal that has no other context to hang a port on. +func mailboxEndpointList(mailbox detectedMailbox) string { + endpoints := mailboxEndpoints(mailbox, mailbox.OffHostAddresses) + if len(endpoints) == 0 { + return "no address other than loopback" + } + return strings.Join(endpoints, ", ") +} diff --git a/pkg/cli/gitagent_deploy_reach_test.go b/pkg/cli/gitagent_deploy_reach_test.go new file mode 100644 index 00000000..2bbd2c91 --- /dev/null +++ b/pkg/cli/gitagent_deploy_reach_test.go @@ -0,0 +1,275 @@ +package cli + +import ( + "net" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/gitagent" + gossh "golang.org/x/crypto/ssh" +) + +func ips(t *testing.T, texts ...string) []net.IP { + t.Helper() + parsed := make([]net.IP, 0, len(texts)) + for _, text := range texts { + ip := net.ParseIP(text) + if ip == nil { + t.Fatalf("bad test address %q", text) + } + parsed = append(parsed, ip) + } + return parsed +} + +func TestOffHostCandidates(t *testing.T) { + for _, tc := range []struct { + name string + bindHost string + primary string + held []string + want []string + }{{ + // Every other address is a guaranteed failure line, because a listener + // bound to one address answers there and nowhere else. + name: "a specific bind address is the only candidate", bindHost: "192.168.1.20", + primary: "10.8.0.2", held: []string{"10.8.0.2", "192.168.1.20", "172.17.0.1"}, + want: []string{"192.168.1.20"}, + }, { + name: "the routing table's answer ranks first", bindHost: "", + primary: "192.168.1.20", held: []string{"172.17.0.1", "192.168.1.20"}, + want: []string{"192.168.1.20", "172.17.0.1"}, + }, { + // The offline-laptop refusal: primaryOutboundAddress finds nothing, but + // a container still reaches the host on the bridge gateway. + name: "no default route still yields the bridge", bindHost: "", + primary: "", held: []string{"172.17.0.1"}, + want: []string{"172.17.0.1"}, + }, { + // The reason this change exists: the tunnel address is first because the + // routing table named it, but it no longer hides the addresses that work. + name: "a VPN primary does not hide the LAN", bindHost: "", + primary: "10.8.0.2", held: []string{"10.8.0.2", "192.168.1.20", "172.17.0.1"}, + want: []string{"10.8.0.2", "172.17.0.1", "192.168.1.20"}, + }, { + name: "unreachable address families are never candidates", bindHost: "", + primary: "", held: []string{"127.0.0.1", "::1", "169.254.10.1", "fe80::1", "224.0.0.1", "192.168.1.20"}, + want: []string{"192.168.1.20"}, + }, { + // An IPv4 wildcard socket can never accept an IPv6 connection. + name: "an 0.0.0.0 bind drops IPv6 candidates", bindHost: "0.0.0.0", + primary: "", held: []string{"192.168.1.20", "2001:db8::5"}, + want: []string{"192.168.1.20"}, + }, { + name: "a wildcard bind keeps both families, IPv4 first", bindHost: "::", + primary: "", held: []string{"2001:db8::5", "192.168.1.20"}, + want: []string{"192.168.1.20", "2001:db8::5"}, + }, { + name: "the primary is not repeated when it is also held", bindHost: "", + primary: "192.168.1.20", held: []string{"192.168.1.20"}, + want: []string{"192.168.1.20"}, + }, { + // A loopback bind reaches refuseUnusableMailbox first, but enumeration + // must not treat it as a pinned single candidate if it ever gets here. + name: "a loopback bind falls through to enumeration", bindHost: "127.0.0.1", + primary: "", held: []string{"192.168.1.20"}, + want: []string{"192.168.1.20"}, + }} { + t.Run(tc.name, func(t *testing.T) { + got := offHostCandidates(tc.bindHost, tc.primary, ips(t, tc.held...)) + if !reflect.DeepEqual(got, tc.want) { + t.Fatalf("candidates = %v, want %v", got, tc.want) + } + }) + } + + // net.InterfaceAddrs returns kernel order. A refusal that lists candidates + // in a different order on each run cannot be diffed against the last one. + t.Run("order does not depend on the order the kernel reported", func(t *testing.T) { + forward := offHostCandidates("", "", ips(t, "192.168.1.20", "172.17.0.1", "2001:db8::5")) + reversed := offHostCandidates("", "", ips(t, "2001:db8::5", "172.17.0.1", "192.168.1.20")) + if !reflect.DeepEqual(forward, reversed) { + t.Fatalf("%v != %v", forward, reversed) + } + }) +} + +// sshMailboxOn records and serves an ssh mailbox holding this host's own key, +// and returns the detectedMailbox a caller would have proven on loopback. +func sshMailboxOn(t *testing.T, signer gossh.Signer) detectedMailbox { + t.Helper() + listener := serveHostKey(t, signer) + _, port, _ := net.SplitHostPort(listener.Addr().String()) + _, fingerprint, err := gitagent.EnsureKeyPair(filepath.Join(mustKeysDir(t), hostKeyName)) + if err != nil { + t.Fatal(err) + } + return detectedMailbox{Transport: transportSSH, Port: atoi(t, port), HostFingerprint: fingerprint} +} + +func mustKeysDir(t *testing.T) string { + t.Helper() + dir, err := gitAgentKeysDir() + if err != nil { + t.Fatal(err) + } + return dir +} + +func TestProveReachableOffHost(t *testing.T) { + // The candidate list is a parameter precisely so this can run on a sandboxed + // host, which cannot serve or reach a genuine off-loopback address. + t.Run("a failing higher-ranked candidate does not abort the run", func(t *testing.T) { + isolatedConfig(t) + mailbox := sshMailboxOn(t, hostSigner(t)) + + // 192.0.2.1 is TEST-NET-1 and never answers; it must not stop the probe + // of the candidate behind it, which is the whole point of the change. + reachable, err := proveReachableOffHost(t.Context(), mailbox, []string{"192.0.2.1", "127.0.0.1"}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(reachable, []string{"127.0.0.1"}) { + t.Fatalf("reachable = %v, want only the address that answered", reachable) + } + }) + + // Probes run concurrently, so whichever answers first would otherwise decide + // the reported address and the result would differ between runs. + t.Run("rank decides the order, not which probe returned first", func(t *testing.T) { + isolatedConfig(t) + mailbox := sshMailboxOn(t, hostSigner(t)) + + for _, candidates := range [][]string{{"localhost", "127.0.0.1"}, {"127.0.0.1", "localhost"}} { + reachable, err := proveReachableOffHost(t.Context(), mailbox, candidates) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(reachable, candidates) { + t.Fatalf("reachable = %v, want the candidate order %v", reachable, candidates) + } + } + }) + + // A TCP dial would call an unrelated sshd reachable. Collecting every result + // concurrently must not turn a mismatch into a success. + t.Run("an identity mismatch is a failure, not a reachable address", func(t *testing.T) { + isolatedConfig(t) + foreign, _, err := gitagent.EnsureKeyPair(filepath.Join(t.TempDir(), "foreign_ed25519")) + if err != nil { + t.Fatal(err) + } + mailbox := sshMailboxOn(t, foreign) + + _, err = proveReachableOffHost(t.Context(), mailbox, []string{"127.0.0.1"}) + if err == nil || !strings.Contains(err.Error(), "another server holds that address") { + t.Fatalf("err = %v, want a host-key mismatch", err) + } + }) + + t.Run("no candidate answering names every one and the escape hatch", func(t *testing.T) { + isolatedConfig(t) + dead, err := freeLoopbackPort(0) + if err != nil { + t.Fatal(err) + } + mailbox := detectedMailbox{Transport: transportSSH, Port: dead, HostFingerprint: "SHA256:absent"} + + // TEST-NET-1 rather than a plausible private address: a CI host that + // happens to sit on the same /24 would make this dial hang. + _, err = proveReachableOffHost(t.Context(), mailbox, []string{"127.0.0.1", "192.0.2.1"}) + if err == nil { + t.Fatal("an unreachable mailbox was reported as reachable") + } + for _, want := range []string{ + "no other address of this host", "--supervisor-address", "127.0.0.1", "192.0.2.1", + } { + if !strings.Contains(err.Error(), want) { + t.Errorf("err = %v, want it to name %q", err, want) + } + } + }) +} + +func TestRefuseNoOffHostCandidate(t *testing.T) { + t.Run("a v4 bind on a host holding only v6 names the rebind flag", func(t *testing.T) { + held := ips(t, "2001:db8::5") + for transport, want := range map[mailboxTransport]string{ + transportSSH: "--listen [::]:7422", + transportHTTPS: "--host ::", + } { + record := mailboxRecord{Transport: transport, Listen: "0.0.0.0:7422"} + err := refuseNoOffHostCandidate(record, 7422, held) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: err = %v, want it to name %q", transport, err, want) + } + } + }) + + t.Run("a host with nothing outside loopback says so", func(t *testing.T) { + record := mailboxRecord{Transport: transportSSH, Listen: ":7422"} + err := refuseNoOffHostCandidate(record, 7422, ips(t, "127.0.0.1", "::1")) + if err == nil || !strings.Contains(err.Error(), "no address outside loopback") { + t.Fatalf("err = %v, want the no-network branch", err) + } + if !strings.Contains(err.Error(), "--supervisor-address") { + t.Fatalf("err = %v, want the escape hatch", err) + } + }) +} + +func TestSupervisorCandidates(t *testing.T) { + // A recorded listen address that is not [host]:port yields no offer rather + // than a panic or a bogus endpoint: detectMailbox already refuses it, and the + // preflight renders the refusal instead of a picker. + t.Run("an unparseable listen address offers nothing", func(t *testing.T) { + mailbox := detectedMailbox{Transport: transportHTTPS, Listen: "9020", Port: 9020} + if got := supervisorCandidates(mailbox); got != nil { + t.Fatalf("candidates = %v, want none", got) + } + }) + + // A bind pinned to one address answers there and nowhere else, so the offer + // collapses to it — and it is rendered as the endpoint the field takes, not + // as a bare address the operator would have to wrap by hand. + t.Run("a pinned bind is offered as one endpoint of the right transport", func(t *testing.T) { + for transport, want := range map[mailboxTransport]string{ + transportHTTPS: "https://192.168.1.20:9020", + transportSSH: "ssh://192.168.1.20:9020", + } { + mailbox := detectedMailbox{Transport: transport, Listen: "192.168.1.20:9020", Port: 9020} + if got := supervisorCandidates(mailbox); !reflect.DeepEqual(got, []string{want}) { + t.Errorf("%s candidates = %v, want [%s]", transport, got, want) + } + } + }) +} + +func TestMailboxEndpoints(t *testing.T) { + mailbox := detectedMailbox{Transport: transportHTTPS, Port: 9020} + // IPv6 must keep its brackets, or the port reads as part of the address, and + // the ranking the caller computed must survive rendering. + got := mailboxEndpoints(mailbox, []string{"192.168.1.20", "2001:db8::5"}) + want := []string{"https://192.168.1.20:9020", "https://[2001:db8::5]:9020"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("endpoints = %v, want %v", got, want) + } +} + +func TestMailboxEndpointList(t *testing.T) { + // The Kubernetes refusal interpolates this mid-sentence, so an empty proof + // has to read as prose rather than leaving a dangling "answers on ,". + if got := mailboxEndpointList(detectedMailbox{Transport: transportSSH, Port: 7422}); got != "no address other than loopback" { + t.Fatalf("empty list = %q", got) + } + mailbox := detectedMailbox{ + Transport: transportHTTPS, Port: 9020, + OffHostAddresses: []string{"192.168.1.20", "2001:db8::5"}, + } + // IPv6 must keep its brackets, or the port reads as part of the address. + if got, want := mailboxEndpointList(mailbox), "https://192.168.1.20:9020, https://[2001:db8::5]:9020"; got != want { + t.Fatalf("list = %q, want %q", got, want) + } +} diff --git a/pkg/cli/gitagent_deploy_run.go b/pkg/cli/gitagent_deploy_run.go new file mode 100644 index 00000000..ca6d7c9c --- /dev/null +++ b/pkg/cli/gitagent_deploy_run.go @@ -0,0 +1,375 @@ +// The mutating half of `git-agent deploy`. +// +// New deployments mint after every cheap validation. Replacements retain the +// state volume and its enrollment, so they validate that identity before +// removing the old workload. +package cli + +import ( + "context" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent/deploy" + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/text" + "k8s.io/client-go/dynamic" + "k8s.io/client-go/kubernetes" +) + +func runDeploy(ctx context.Context, plan deploy.Plan, opts GitAgentDeployOptions, + result GitAgentDeployResult, timeout time.Duration) (any, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + client, namespace, err := deployPreflight(ctx, plan, opts) + if err != nil { + return nil, err + } + var customClient dynamic.Interface + if plan.Target == deploy.TargetKubernetes { + customClient, err = kubernetesDynamicClient(kubeClientOptions{Context: opts.KubeContext}) + if err != nil { + return nil, err + } + } + plan.Advertise, result.Advertise = reAdvertiseForNamespace(plan, opts, namespace, result.Advertise) + result.Namespace = namespace + result.EnrollmentReused = opts.reuseEnrollment + if opts.reuseEnrollment { + if err := validateReusableEnrollment(plan); err != nil { + return nil, err + } + } + + if opts.Replace { + if err := teardownWorkload(ctx, client, customClient, plan, namespace, false); err != nil { + return nil, fmt.Errorf("replace existing deployment: %w", err) + } + result.Replaced = true + } + + var enrollment GitAgentAddResult + if !opts.reuseEnrollment { + // The mint is the first irreversible step, and everything above has already + // proven it will be usable. + added, err := RunGitAgentAdd(ctx, GitAgentAddOptions{ + Name: plan.Name, Backend: plan.Backend, Endpoint: plan.Supervisor, + }) + if err != nil { + return nil, err + } + var ok bool + enrollment, ok = added.(GitAgentAddResult) + if !ok { + return nil, fmt.Errorf("unexpected enrollment result %T", added) + } + } + + objects, err := provision(ctx, client, customClient, plan, opts, namespace, enrollment.Token) + if err != nil { + // A durable token nothing ever claims does not lapse on its own, so a + // half-finished deploy would leave a live credential behind it. + if rollbackErr := revokeUnclaimedToken(ctx, enrollment.TokenID, plan.Name); rollbackErr != nil { + clicky.Printf("warning: could not revoke the unclaimed token for %q: %v\n", plan.Name, rollbackErr) + } + return nil, err + } + result.Objects = objects + + // Recorded now rather than after --wait: the workload exists from here on, + // so a teardown must be able to find it even if the wait times out. Without + // this an operator is left with a running sidecar and nothing that knows + // which runtime it is on. + if err := recordDeployment(plan, opts, namespace); err != nil { + clicky.Printf("warning: deployed, but the deployment record could not be saved; "+ + "pass --target to undeploy: %v\n", err) + } + + if !opts.Wait { + clicky.Printf("deployed %s; not waiting for enrollment (--wait=false)\n", plan.WorkloadName()) + return result, nil + } + if opts.reuseEnrollment { + if err := awaitReplacedWorkload(ctx, client, plan, namespace); err != nil { + return nil, err + } + } else { + if err := awaitEnrollment(ctx, client, plan, namespace); err != nil { + return nil, err + } + } + result.Ready, result.Enrolled = true, true + + // The Secret stays. The token is durable and the pod re-presents it on + // every start — deleting it here would leave a Deployment that works until + // its first reschedule and then cannot enroll, which is the failure mode + // this whole change exists to remove. `undeploy` removes it along with the + // workload, and revokes it. + clicky.Printf("agent %q is enrolled and dispatchable at %s\n", plan.Name, plan.Advertise) + return result, nil +} + +// deployPreflight fails before the mint on anything the runtime can tell us. +func deployPreflight(ctx context.Context, plan deploy.Plan, opts GitAgentDeployOptions) (kubernetes.Interface, string, error) { + if plan.Target == deploy.TargetDocker { + if err := deploy.DockerAvailable(ctx); err != nil { + return nil, "", err + } + // Pull before minting. This image carries a Go toolchain, Chromium and + // several agent CLIs, and a cold pull can outlast the token's 15-minute + // TTL — after which the token is burned on its first use, so the failure + // is permanent and repeats identically on every restart. + if !deploy.DockerImagePresent(ctx, plan.Image) { + clicky.Printf("pulling %s before minting a token...\n", plan.Image) + if err := deploy.DockerPull(ctx, plan.Image); err != nil { + return nil, "", err + } + } + return nil, "", nil + } + + client, namespace, err := kubernetesClient(kubeClientOptions{Context: opts.KubeContext, Namespace: opts.Namespace}) + if err != nil { + return nil, "", err + } + // Before the permission check, which is namespace-scoped and would report a + // missing namespace as a missing permission. + created, err := deploy.EnsureNamespace(ctx, client, namespace, opts.CreateNamespace) + if err != nil { + return nil, "", err + } + if created { + clicky.Printf("created namespace %s\n", namespace) + } + // Applying four objects — five with an external route — is not transactional; + // failing on the third leaves a Secret and a volume behind and an enrollment + // already recorded. + if err := deploy.CheckPermissions(ctx, client, namespace, plan.ExternalRoute.ClassName); err != nil { + return nil, "", err + } + if plan.HasExternalRoute() { + // Both of these produce an agent that enrolls, reports ready, and is never + // dispatchable — the failure this whole command exists to prevent — so + // they are checked before the mint rather than discovered hours later. + if err := refuseUnroutableExternalRoute(ctx, client, plan.ExternalRoute); err != nil { + return nil, "", err + } + if err := refuseDuplicateRouteHost(ctx, client, plan, namespace); err != nil { + return nil, "", err + } + } + return client, namespace, nil +} + +// reAdvertiseForNamespace recomputes the cluster address once the namespace is +// resolved from the kubeconfig, unless the operator pinned one. +func reAdvertiseForNamespace(plan deploy.Plan, opts GitAgentDeployOptions, namespace, current string) (string, string) { + if plan.Target != deploy.TargetKubernetes || opts.Advertise != "" || namespace == "" { + return plan.Advertise, current + } + // An ingress advertise carries no namespace, so there is nothing for the + // resolved one to change — and recomputing it would be a second derivation of + // the string awaitEnrollment compares byte for byte. + if plan.HasExternalRoute() { + return plan.Advertise, current + } + advertise, _, err := resolveAdvertiseAddress(plan.Target, plan, namespace, "", runningInCluster()) + if err != nil { + return plan.Advertise, current + } + return advertise, advertise +} + +func provision(ctx context.Context, client kubernetes.Interface, customClient dynamic.Interface, plan deploy.Plan, + opts GitAgentDeployOptions, namespace string, token text.SensitiveString) ([]string, error) { + if plan.Target == deploy.TargetDocker { + path := "" + if !token.IsEmpty() { + path = joinTokenPath(plan) + if err := writeJoinTokenFile(path, token); err != nil { + return nil, err + } + } + id, err := deploy.DockerRun(ctx, plan, path) + if err != nil { + if path != "" { + _ = os.Remove(path) + } + return nil, err + } + return []string{"container/" + id[:min(12, len(id))]}, nil + } + objects := []string{} + if plan.UsesTraefik() { + if err := deploy.ApplyTraefikServersTransport(ctx, customClient, plan, namespace); err != nil { + return nil, err + } + objects = append(objects, "ServersTransport/"+plan.WorkloadName()) + } + applied, err := deploy.KubernetesApply(ctx, client, plan, deploy.KubernetesOptions{ + Namespace: namespace, + StorageClass: opts.StorageClass, + ImagePullPolicy: opts.ImagePullPolicy, + ImagePullSecret: opts.ImagePullSecret, + JoinToken: token.Value(), + }) + return append(objects, applied...), err +} + +func validateReusableEnrollment(plan deploy.Plan) error { + cfg, _, err := captainconfig.Load() + if err != nil { + return err + } + entry, err := enrolledAgent(cfg, plan.Backend, plan.Name) + if err != nil { + return fmt.Errorf("reuse enrollment for agent %q: %w", plan.Name, err) + } + if ready, issue := gitAgentDispatchStatus(entry); !ready { + return fmt.Errorf("reuse enrollment for agent %q: %s", plan.Name, issue) + } + endpoint, _ := entry["url"].(string) + if endpoint != plan.Advertise { + return fmt.Errorf( + "reuse enrollment for agent %q: saved endpoint %s does not match requested endpoint %s", + plan.Name, endpoint, plan.Advertise) + } + return nil +} + +func awaitReplacedWorkload(ctx context.Context, client kubernetes.Interface, plan deploy.Plan, namespace string) error { + if plan.Target == deploy.TargetKubernetes { + return deploy.KubernetesReady(ctx, client, plan, namespace) + } + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + container, found, err := deploy.DockerInspect(ctx, plan.WorkloadName()) + if err != nil { + return err + } + if found && container.Running { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("container %s did not become ready: %w", plan.WorkloadName(), ctx.Err()) + case <-ticker.C: + } + } +} + +// writeJoinTokenFile puts the token where only the deploying user can read it. +// The workload bind-mounts it read-only and removes it once the join succeeds, +// so it never reaches argv, `docker inspect`, or /proc//cmdline — all of +// which the coding agent inside that same container can read. +func writeJoinTokenFile(path string, token text.SensitiveString) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return fmt.Errorf("create the join token directory: %w", err) + } + if err := os.WriteFile(path, []byte(token.Value()), 0o600); err != nil { + return fmt.Errorf("write the join token: %w", err) + } + return nil +} + +// awaitEnrollment waits for the cycle to close, not merely for a process to +// start. Readiness alone would report success for a sidecar that came up and +// could not reach the mailbox. +func awaitEnrollment(ctx context.Context, client kubernetes.Interface, plan deploy.Plan, namespace string) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + if url := enrolledAgentURL(plan.Backend, plan.Name); url != "" { + // The recorded address is what dispatch will actually use. If the + // sidecar advertised something else, every dispatch goes to the wrong + // place while the roster still looks healthy. + if url != plan.Advertise { + return fmt.Errorf( + "agent %q enrolled advertising %s, but the deployment expects %s; dispatch would go to the wrong address", + plan.Name, url, plan.Advertise) + } + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("agent %q did not enroll within the timeout%s", plan.Name, + workloadLogTail(ctx, client, plan, namespace)) + case <-ticker.C: + } + } +} + +// workloadLogTail attaches the sidecar's own output to a timeout, so the +// failure reports why rather than only that it happened. +func workloadLogTail(ctx context.Context, client kubernetes.Interface, plan deploy.Plan, namespace string) string { + // The caller's context is already expired; logs need a fresh deadline. + ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + + var logs string + if plan.Target == deploy.TargetDocker { + logs = deploy.DockerLogs(ctx, plan.WorkloadName(), 50) + } else if client != nil { + logs = deploy.KubernetesLogs(ctx, client, plan, namespace, 50) + } + if logs == "" { + return "" + } + return "\n\nlast output from " + plan.WorkloadName() + ":\n" + logs +} + +// enrolledAgentURL reports the address an enrolled agent advertised, empty when +// it has not enrolled. +func enrolledAgentURL(backendName, agentName string) string { + cfg, _, err := captainconfig.Load() + if err != nil { + return "" + } + entry, err := enrolledAgent(cfg, backendName, agentName) + if err != nil { + return "" + } + url, _ := entry["url"].(string) + return url +} + +// revokeUnclaimedToken withdraws the credential a failed deploy minted. +// +// A durable token that nothing ever claims does not expire on its own, so a +// half-finished deploy would leave a live credential for a workload that never +// started. Revocation is the honest undo: the row stays, with a reason. +func revokeUnclaimedToken(ctx context.Context, tokenID, agentName string) error { + if tokenID == "" { + return nil + } + db, err := captainServeDB(ctx) + if err != nil { + return err + } + return db.RevokeAPIToken(ctx, tokenID, "deploy of agent "+agentName+" failed before the workload started") +} + +// teardownWorkload removes the workload for a plan on either target. +func teardownWorkload(ctx context.Context, client kubernetes.Interface, customClient dynamic.Interface, plan deploy.Plan, + namespace string, purgeVolume bool) error { + if plan.Target == deploy.TargetDocker { + if err := deploy.DockerRemove(ctx, plan, purgeVolume); err != nil { + return err + } + _ = os.Remove(joinTokenPath(plan)) + return nil + } + if _, err := deploy.KubernetesRemove(ctx, client, plan, namespace, purgeVolume); err != nil { + return err + } + if !plan.UsesTraefik() { + return nil + } + _, err := deploy.DeleteTraefikServersTransport(ctx, customClient, plan, namespace) + return err +} diff --git a/pkg/cli/gitagent_deploy_test.go b/pkg/cli/gitagent_deploy_test.go new file mode 100644 index 00000000..ca84c90c --- /dev/null +++ b/pkg/cli/gitagent_deploy_test.go @@ -0,0 +1,294 @@ +package cli + +import ( + "net" + "os" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/database" +) + +// deployOptions is a valid docker deployment, for mutation by each test. +func deployOptions(name string) GitAgentDeployOptions { + return GitAgentDeployOptions{ + Name: name, Backend: "git-agent", Target: "docker", + Image: "ghcr.io/flanksource/captain:latest", Home: "/home/claude", + ListenPort: 7422, + CPURequest: "500m", CPULimit: "2", + MemoryRequest: "1Gi", MemoryLimit: "4Gi", + Storage: "20Gi", TmpSize: "1Gi", PidsLimit: 1024, + RunAsUser: 501, RunAsGroup: 20, ReadOnlyRoot: true, Network: "bridge", + Timeout: "5m", DryRun: true, + // Skips the off-loopback probe, which a sandboxed test host cannot pass. + SupervisorAddress: "ssh://host.docker.internal:7422", + } +} + +// liveMailbox starts a listener presenting this host's git-agent host key and +// records it as the backend's mailbox, which is what detection requires. +func liveMailbox(t *testing.T, backend string) { + t.Helper() + listener := serveHostKey(t, hostSigner(t)) + _, port, _ := net.SplitHostPort(listener.Addr().String()) + recordMailboxListening(t, backend, ":"+port) +} + +func TestDeployRefusesBeforeMintingAToken(t *testing.T) { + tests := []struct { + name string + mutate func(*GitAgentDeployOptions) + wantErr string + }{ + {"no target", func(o *GitAgentDeployOptions) { o.Target = "" }, "--target is required"}, + {"unknown target", func(o *GitAgentDeployOptions) { o.Target = "podman" }, "docker, kubernetes"}, + {"bad quantity", func(o *GitAgentDeployOptions) { o.MemoryLimit = "4GB" }, "--memory-limit"}, + {"bad timeout", func(o *GitAgentDeployOptions) { o.Timeout = "soon" }, "not a duration"}, + {"root user", func(o *GitAgentDeployOptions) { o.RunAsUser = 0 }, "--run-as-user 0"}, + {"host network", func(o *GitAgentDeployOptions) { o.Network = "host" }, "host network namespace"}, + {"no network", func(o *GitAgentDeployOptions) { o.Network = "none" }, "dispatch, relay"}, + {"invalid agent name", func(o *GitAgentDeployOptions) { o.Name = "Worker 01" }, "agent name"}, + // An ignored --domain would look configured and leave the operator + // waiting on a hostname nothing ever created. + {"a route on docker", func(o *GitAgentDeployOptions) { o.Domain = "agents.example.com" }, + "--domain needs --target kubernetes"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + path := isolatedConfig(t) + liveMailbox(t, "git-agent") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + opts := deployOptions("worker-01") + tt.mutate(&opts) + if _, err := RunGitAgentDeploy(t.Context(), opts); err == nil || + !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tt.wantErr) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("a refused deploy mutated the config; a token may have been minted") + } + }) + } +} + +// A preset that grants the container runtime socket is a full host escape +// (R5.3/A6.2), and it must be caught from the backend config, not just flags. +func TestDeployRefusesABackendGrantingTheRuntimeSocket(t *testing.T) { + isolatedConfig(t) + liveMailbox(t, "git-agent") + + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, "git-agent") + if err != nil { + return err + } + backend.Options["presets"] = []any{"golang", "claude"} + cfg.Sandbox.Backends["git-agent"] = backend + return nil + }) + if err != nil { + t.Fatal(err) + } + + if _, err := RunGitAgentDeploy(t.Context(), deployOptions("worker-01")); err == nil || + !strings.Contains(err.Error(), "container runtime socket") { + t.Fatalf("err = %v, want a refusal naming the runtime socket", err) + } +} + +// RecordAgent overwrites an agent entry wholesale, so a silent re-enroll would +// repoint the supervisor at a new key and leave the old sidecar running with +// one that is no longer authorized. +func TestDeployRefusesAnAlreadyEnrolledNameWithoutReplace(t *testing.T) { + isolatedConfig(t) + liveMailbox(t, "git-agent") + + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, "git-agent") + if err != nil { + return err + } + backend.Options["agents"] = map[string]any{ + "worker-01": map[string]any{"fingerprint": "SHA256:existing"}, + } + cfg.Sandbox.Backends["git-agent"] = backend + return nil + }) + if err != nil { + t.Fatal(err) + } + + _, err = RunGitAgentDeploy(t.Context(), deployOptions("worker-01")) + if err == nil || !strings.Contains(err.Error(), "already enrolled") { + t.Fatalf("err = %v, want a refusal to rebind the name", err) + } + if !strings.Contains(err.Error(), "--replace") { + t.Fatalf("the refusal must name the way forward: %v", err) + } +} + +func TestDeployDryRunRendersTheHardenedArgvWithoutMutating(t *testing.T) { + path := isolatedConfig(t) + liveMailbox(t, "git-agent") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + res, err := RunGitAgentDeploy(t.Context(), deployOptions("worker-01")) + if err != nil { + t.Fatal(err) + } + result, ok := res.(GitAgentDeployResult) + if !ok { + t.Fatalf("result = %T", res) + } + if !result.DryRun || result.Enrolled { + t.Fatalf("dry run reported enrolled: %+v", result) + } + // The mailbox's real host key, proven by the probe rather than assumed. + if result.HostFingerprint == "" { + t.Fatal("no mailbox host key was proven") + } + if result.SupervisorFrom != "flag" || result.AdvertiseFrom != "docker-published-port" { + t.Fatalf("addresses not resolved as expected: %+v", result) + } + // An operator who does not know this finds out at the first dispatch. + if !strings.Contains(result.Credentials, "none declared") { + t.Fatalf("credentials = %q, want it to flag that none were declared", result.Credentials) + } + if result.EgressRestricted { + t.Fatal("egress is not actually restricted today; reporting otherwise is a lie") + } + // deployOptions passes --supervisor-address, which skips the off-loopback + // proof entirely. Reporting addresses that were never probed would be the + // same lie as EgressRestricted above. + if len(result.OffHostAddresses) != 0 { + t.Fatalf("off-loopback proof = %v, but --supervisor-address skipped the probe", result.OffHostAddresses) + } + + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("--dry-run mutated the config") + } +} + +// The externally-routed topology, rendered without touching a cluster: the +// operator has to see the Ingress, the certificate source, and — most +// importantly — the DNS record captain will NOT create for them. +func TestKubernetesDryRunRendersTheIngress(t *testing.T) { + isolatedConfig(t) + liveMailbox(t, "git-agent") + + opts := deployOptions("worker-01") + opts.Target = "kubernetes" + opts.Namespace = "agents" + opts.SupervisorAddress = "https://mailbox.example.com:9020" + opts.Domain = "agents.example.com" + opts.IngressClass = "nginx" + opts.IngressIssuer = "letsencrypt-prod" + + res, err := RunGitAgentDeploy(t.Context(), opts) + if err != nil { + t.Fatal(err) + } + result, ok := res.(GitAgentDeployResult) + if !ok { + t.Fatalf("result = %T", res) + } + + want := "https://worker-01.agents.example.com/git/" + SidecarRepoName + if result.Advertise != want || result.AdvertiseFrom != "cluster-ingress" { + t.Fatalf("advertise = %q from %q, want %q", result.Advertise, result.AdvertiseFrom, want) + } + if result.Route != "worker-01.agents.example.com" || result.RouteClass != "nginx" { + t.Fatalf("route = %q class %q", result.Route, result.RouteClass) + } + + mutations := strings.Join(result.Mutations, "\n") + for _, name := range []string{ + "Ingress/captain-git-agent-worker-01", + "Secret/captain-git-agent-worker-01-tls", + "letsencrypt-prod", + // The most consequential thing about this feature is a change it does + // not make, so the dry run has to say so. + "NOT create the DNS record", + } { + if !strings.Contains(mutations, name) { + t.Errorf("mutations do not mention %q:\n%s", name, mutations) + } + } +} + +// Without a route and without a supervisor inside the cluster there is no +// address to advertise that a dispatch could reach. +func TestKubernetesWithoutARouteRefusesToAdvertise(t *testing.T) { + isolatedConfig(t) + liveMailbox(t, "git-agent") + + opts := deployOptions("worker-01") + opts.Target = "kubernetes" + opts.Namespace = "agents" + opts.SupervisorAddress = "https://mailbox.example.com:9020" + + _, err := RunGitAgentDeploy(t.Context(), opts) + if err == nil || !strings.Contains(err.Error(), "--domain") { + t.Fatalf("err = %v, want a demand for a reachable route", err) + } +} + +// A durable token that nothing claims does not lapse on its own, so a deploy +// that dies after the mint has to retire the credential it created — otherwise +// it leaves a live way in for a workload that never started. +func TestRevokeUnclaimedTokenRetiresOnlyItsOwnAgent(t *testing.T) { + isolatedConfig(t) + db := gitAgentTokenDB(t) + const backend = "git-agent" + + minted := map[string]GitAgentAddResult{} + for _, name := range []string{"worker-01", "worker-02"} { + res, err := RunGitAgentAdd(t.Context(), GitAgentAddOptions{Name: name, Backend: backend}) + if err != nil { + t.Fatal(err) + } + minted[name] = res.(GitAgentAddResult) + } + + if err := revokeUnclaimedToken(t.Context(), minted["worker-01"].TokenID, "worker-01"); err != nil { + t.Fatal(err) + } + + live, err := db.ListAPITokens(t.Context(), database.ListAPITokensFilter{}) + if err != nil { + t.Fatal(err) + } + if len(live) != 1 || live[0].Agent != "worker-02" { + t.Fatalf("live tokens = %+v; only the other agent's should survive", live) + } + + revoked, err := db.GetAPIToken(t.Context(), minted["worker-01"].TokenID) + if err != nil { + t.Fatal(err) + } + if revoked.RevokedAt == nil || !strings.Contains(revoked.RevocationReason, "worker-01") { + t.Fatalf("revocation should name the agent and why: %+v", revoked) + } + + // Nothing to withdraw is not a failure: a deploy can fail before the mint. + if err := revokeUnclaimedToken(t.Context(), "", "worker-03"); err != nil { + t.Fatal(err) + } +} diff --git a/pkg/cli/gitagent_deployment_edit_ginkgo_test.go b/pkg/cli/gitagent_deployment_edit_ginkgo_test.go new file mode 100644 index 00000000..1fdb94ce --- /dev/null +++ b/pkg/cli/gitagent_deployment_edit_ginkgo_test.go @@ -0,0 +1,302 @@ +package cli + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +var _ = Describe("editable git-agent deployments", func() { + DescribeTable("projects the resolved workload settings without secret values", func( + target deploy.Target, + namespace string, + mutate func(*GitAgentDeployOptions, *deploy.Plan), + assert func(GitAgentDeploymentConfig), + ) { + opts := deployOptions("worker-01") + opts.DryRun = false + opts.Target = string(target) + plan := deploy.Plan{ + Name: "worker-01", Backend: opts.Backend, Target: target, + Image: opts.Image, Home: opts.Home, ListenPort: opts.ListenPort, + Supervisor: opts.SupervisorAddress, Advertise: opts.Advertise, + } + mutate(&opts, &plan) + + config := deploymentConfig(plan, opts, namespace) + + Expect(config.Target).To(Equal(string(target))) + Expect(config.Transport).To(Equal(opts.Transport)) + Expect(config.Image).To(Equal(opts.Image)) + Expect(config.Namespace).To(Equal(namespace)) + Expect(config.SupervisorAddress).To(Equal(plan.Supervisor)) + Expect(config.Advertise).To(Equal(plan.Advertise)) + Expect(config.CPULimit).To(Equal(opts.CPULimit)) + Expect(config.MemoryLimit).To(Equal(opts.MemoryLimit)) + Expect(config.Storage).To(Equal(opts.Storage)) + Expect(config.ReadOnlyRoot).NotTo(BeNil()) + Expect(*config.ReadOnlyRoot).To(Equal(opts.ReadOnlyRoot)) + Expect(config.Wait).NotTo(BeNil()) + Expect(*config.Wait).To(Equal(opts.Wait)) + assert(config) + }, + Entry("docker", deploy.TargetDocker, "", func(opts *GitAgentDeployOptions, plan *deploy.Plan) { + opts.Transport = "https" + opts.HostPort = 7411 + opts.CredentialsDir = "/var/lib/captain/credentials" + opts.Env = []string{"ANTHROPIC_API_KEY"} + plan.HostPort = opts.HostPort + plan.Supervisor = "ssh://captain@host.docker.internal:7422" + plan.Advertise = "ssh://captain@127.0.0.1:7411/repo.git" + }, func(config GitAgentDeploymentConfig) { + Expect(config.HostPort).To(Equal(7411)) + Expect(config.CredentialsDir).To(Equal("/var/lib/captain/credentials")) + Expect(config.Env).To(Equal([]string{"ANTHROPIC_API_KEY"})) + }), + Entry("kubernetes", deploy.TargetKubernetes, "agents", func(opts *GitAgentDeployOptions, plan *deploy.Plan) { + opts.Transport = "https" + opts.Domain = "agents.example.com" + opts.IngressClass = "nginx" + opts.IngressIssuer = "letsencrypt-prod" + opts.EnvFromSecret = []string{"model-credentials"} + opts.CredentialsSecret = "captain-agent-credentials" + plan.Supervisor = "https://captain.example.com" + plan.Advertise = "https://worker-01.agents.example.com/git/repo.git" + }, func(config GitAgentDeploymentConfig) { + Expect(config.Domain).To(Equal("agents.example.com")) + Expect(config.IngressClass).To(Equal("nginx")) + Expect(config.IngressIssuer).To(Equal("letsencrypt-prod")) + Expect(config.EnvFromSecret).To(Equal([]string{"model-credentials"})) + Expect(config.CredentialsSecret).To(Equal("captain-agent-credentials")) + }), + ) + + It("round-trips the edit config through the saved deployment record", func() { + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + opts := deployOptions("worker-01") + opts.Target = "kubernetes" + opts.Domain = "agents.example.com" + opts.EnvFromSecret = []string{"model-credentials"} + plan := deploy.Plan{ + Name: "worker-01", Backend: opts.Backend, Target: deploy.TargetKubernetes, + Image: opts.Image, Home: opts.Home, ListenPort: opts.ListenPort, + Supervisor: "https://captain.example.com", + Advertise: "https://worker-01.agents.example.com/git/repo.git", + } + + Expect(recordDeployment(plan, opts, "agents")).To(Succeed()) + saved, found := lookupDeployment(opts.Backend, opts.Name) + + Expect(found).To(BeTrue()) + Expect(saved.Config).NotTo(BeNil()) + Expect(saved.Config.Domain).To(Equal(opts.Domain)) + Expect(saved.Config.EnvFromSecret).To(Equal(opts.EnvFromSecret)) + Expect(saved.Config.SupervisorAddress).To(Equal(plan.Supervisor)) + }) + + It("reuses enrollment when replacing a Captain-managed deployment", func() { + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + opts := deployOptions("worker-01") + opts.Replace = true + plan := deploy.Plan{Name: opts.Name, Backend: opts.Backend, Target: deploy.TargetDocker} + Expect(recordDeployment(plan, opts, "")).To(Succeed()) + Expect(captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := cfg.Sandbox.Backends[opts.Backend] + backend.Options["agents"] = map[string]any{opts.Name: map[string]any{ + "url": "https://worker-01.agents.example.com/git/repo.git", + }} + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + })).To(Succeed()) + + reuse, err := replacementReusesEnrollment(opts) + + Expect(err).NotTo(HaveOccurred()) + Expect(reuse).To(BeTrue()) + }) + + It("previews an enrollment-preserving replacement without requiring a live mailbox", func() { + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + opts := deployOptions("worker-01") + opts.Replace = true + opts.Transport = "ssh" + opts.HostPort = 7411 + plan := deploy.Plan{ + Name: opts.Name, Backend: opts.Backend, Target: deploy.TargetDocker, + Image: opts.Image, Home: opts.Home, ListenPort: opts.ListenPort, HostPort: opts.HostPort, + Supervisor: opts.SupervisorAddress, + Advertise: "ssh://captain@127.0.0.1:7411/repo.git", + } + Expect(recordDeployment(plan, opts, "")).To(Succeed()) + Expect(captainconfig.Update(func(cfg *captainconfig.Config) error { + backend := cfg.Sandbox.Backends[opts.Backend] + backend.Options["agents"] = map[string]any{opts.Name: map[string]any{ + "url": plan.Advertise, + "hostFingerprint": "SHA256:agent", + }} + cfg.Sandbox.Backends[opts.Backend] = backend + return nil + })).To(Succeed()) + + result, err := RunGitAgentDeploy(GinkgoT().Context(), opts) + + Expect(err).NotTo(HaveOccurred()) + Expect(result.(GitAgentDeployResult).EnrollmentReused).To(BeFalse(), "dry-run reports mutations, not completion") + }) + + It("refuses to replace enrollment that has no managed state volume", func() { + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + opts := deployOptions("worker-01") + opts.Replace = true + Expect(captainconfig.Save(captainconfig.Config{Sandbox: captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{opts.Backend: { + Kind: "git-agent", Options: map[string]any{"agents": map[string]any{ + opts.Name: map[string]any{"url": "ssh://worker-01.example.com/repo.git"}, + }}, + }}, + }})).To(Succeed()) + + _, err := replacementReusesEnrollment(opts) + + Expect(err).To(MatchError(ContainSubstring("has no Captain-managed deployment"))) + }) + + It("reconstructs every persisted option while pinning the path identity", func() { + opts := deployOptions("worker-01") + opts.Target = "docker" + opts.HostPort = 7411 + opts.CredentialsDir = "/var/lib/captain/credentials" + opts.ReadOnlyRoot = false + opts.Wait = false + plan := deploy.Plan{ + Name: opts.Name, Backend: opts.Backend, Target: deploy.TargetDocker, + Image: opts.Image, Home: opts.Home, ListenPort: opts.ListenPort, + HostPort: opts.HostPort, Supervisor: opts.SupervisorAddress, + Advertise: "ssh://captain@127.0.0.1:7411/repo.git", + } + + reconstructed := deploymentConfig(plan, opts, "").options("worker-edited", "pool-a") + + Expect(reconstructed.Name).To(Equal("worker-edited")) + Expect(reconstructed.Backend).To(Equal("pool-a")) + Expect(reconstructed.Target).To(Equal(opts.Target)) + Expect(reconstructed.HostPort).To(Equal(opts.HostPort)) + Expect(reconstructed.CredentialsDir).To(Equal(opts.CredentialsDir)) + Expect(reconstructed.ReadOnlyRoot).To(BeFalse()) + Expect(reconstructed.Wait).To(BeFalse()) + }) + + DescribeTable("refuses to move an edited deployment", + func(recorded GitAgentDeployment, requested GitAgentDeploymentConfig, message string) { + Expect(validateDeploymentEdit(recorded, requested)).To(MatchError(ContainSubstring(message))) + }, + Entry("between runtimes", + GitAgentDeployment{Target: "docker", Workload: "captain-git-agent-worker-01"}, + GitAgentDeploymentConfig{Target: "kubernetes"}, + "cannot move a deployment from docker to kubernetes"), + Entry("between namespaces", + GitAgentDeployment{Target: "kubernetes", Namespace: "agents", Workload: "captain-git-agent-worker-01"}, + GitAgentDeploymentConfig{Target: "kubernetes", Namespace: "other-agents"}, + "cannot move deployment captain-git-agent-worker-01 from namespace \"agents\" to \"other-agents\""), + ) + + It("refuses to change endpoint identity during an enrollment-preserving edit", func() { + recorded := GitAgentDeployment{ + Target: "kubernetes", Namespace: "agents", Workload: "captain-git-agent-worker-01", + Config: &GitAgentDeploymentConfig{ + Target: "kubernetes", Namespace: "agents", Transport: "https", + SupervisorAddress: "https://captain.example.com", + Advertise: "https://worker-01.agents.example.com/git/repo.git", + }, + } + requested := *recorded.Config + requested.Advertise = "https://worker-01.other.example.com/git/repo.git" + + Expect(validateDeploymentEdit(recorded, requested)).To(MatchError(ContainSubstring( + "cannot change the deployment advertised endpoint"))) + }) + + It("previews removal of the old workload before replacement", func() { + opts := deployOptions("worker-01") + opts.Replace = true + opts.reuseEnrollment = true + plan := deploy.Plan{Name: opts.Name, Backend: opts.Backend, Target: deploy.TargetDocker} + + mutations := deployMutations(plan, opts) + + Expect(mutations).NotTo(BeEmpty()) + Expect(mutations[0]).To(ContainSubstring("remove the existing workload")) + Expect(mutations[0]).To(ContainSubstring(plan.WorkloadName())) + Expect(mutations).To(ContainElement(ContainSubstring("reuse the existing durable enrollment"))) + Expect(mutations).NotTo(ContainElement(ContainSubstring("mint a durable captain token"))) + }) + + It("targets preflight at the saved mailbox transport and kubeconfig context", func() { + request := httptest.NewRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/deploy/preflight?backend=pool-a&target=kubernetes&transport=https&kubeContext=lab", + nil) + + preflight, err := parseGitAgentDeployPreflightRequest(request) + + Expect(err).NotTo(HaveOccurred()) + Expect(preflight.Backend).To(Equal("pool-a")) + Expect(preflight.Target).To(Equal(deploy.TargetKubernetes)) + Expect(preflight.Transport).To(Equal(transportHTTPS)) + Expect(preflight.KubeContext).To(Equal("lab")) + }) + + It("updates a Docker deployment through the path identity", func() { + captainconfig.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), ".captain.yaml")) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + opts := deployOptions("worker-01") + opts.CredentialsDir = "/var/lib/captain/credentials" + plan := deploy.Plan{ + Name: opts.Name, Backend: opts.Backend, Target: deploy.TargetDocker, + Image: opts.Image, Home: opts.Home, ListenPort: opts.ListenPort, + Supervisor: opts.SupervisorAddress, + } + Expect(recordDeployment(plan, opts, "")).To(Succeed()) + saved, found := lookupDeployment(opts.Backend, opts.Name) + Expect(found).To(BeTrue()) + request := gitAgentDeployRequest{Name: "ignored", GitAgentDeploymentConfig: *saved.Config, DryRun: true} + request.Image = "registry.example/captain:v2" + body, err := json.Marshal(request) + Expect(err).NotTo(HaveOccurred()) + + var received GitAgentDeployOptions + handler := handleGitAgentUpdateWithRunner(func(_ context.Context, update GitAgentDeployOptions) (any, error) { + received = update + return GitAgentDeployResult{Agent: update.Name, Image: update.Image, DryRun: update.DryRun}, nil + }) + mux := http.NewServeMux() + mux.Handle("PUT /api/captain/sandbox/git-agent/deployments/{name}", handler) + response := serveHandler(mux, loopbackRequest(http.MethodPut, + "/api/captain/sandbox/git-agent/deployments/worker-01?backend=git-agent", string(body))) + + Expect(response.Code).To(Equal(http.StatusOK), response.Body.String()) + Expect(received.Name).To(Equal("worker-01")) + Expect(received.Replace).To(BeTrue()) + Expect(received.reuseEnrollment).To(BeTrue()) + Expect(received.DryRun).To(BeTrue()) + Expect(received.Image).To(Equal("registry.example/captain:v2")) + Expect(received.CredentialsDir).To(Equal(opts.CredentialsDir)) + }) +}) + +func serveHandler(handler http.Handler, request *http.Request) *httptest.ResponseRecorder { + response := httptest.NewRecorder() + handler.ServeHTTP(response, request) + return response +} diff --git a/pkg/cli/gitagent_deployments.go b/pkg/cli/gitagent_deployments.go new file mode 100644 index 00000000..5b9655e7 --- /dev/null +++ b/pkg/cli/gitagent_deployments.go @@ -0,0 +1,279 @@ +// Where a deployed sidecar actually went. +// +// The agent roster records dispatch targeting — an endpoint and a host key — +// and says nothing about the runtime the workload runs on. Tearing one down +// needs that: `undeploy --target docker` against a Kubernetes agent removes +// nothing and reports success, because there is no container by that name. So +// deploy writes down where it put the workload, and undeploy reads it back. + +package cli + +import ( + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// GitAgentDeployment is one recorded placement. +type GitAgentDeployment struct { + Target string `json:"target" pretty:"label=Target"` + Namespace string `json:"namespace,omitempty" pretty:"label=Namespace"` + Workload string `json:"workload" pretty:"label=Workload"` + Image string `json:"image,omitempty" pretty:"label=Image"` + DeployedAt string `json:"deployedAt,omitempty" pretty:"label=Deployed"` + Config *GitAgentDeploymentConfig `json:"config,omitempty" pretty:"-"` +} + +// GitAgentDeploymentConfig is the resolved, non-secret deployment input needed +// to edit a workload without resetting settings the form does not expose. +type GitAgentDeploymentConfig struct { + Target string `json:"target"` + Transport string `json:"transport,omitempty"` + Namespace string `json:"namespace,omitempty"` + KubeContext string `json:"kubeContext,omitempty"` + Domain string `json:"domain,omitempty"` + IngressClass string `json:"ingressClass,omitempty"` + IngressIssuer string `json:"ingressIssuer,omitempty"` + IngressTLSSecret string `json:"ingressTlsSecret,omitempty"` + IngressAnnotation []string `json:"ingressAnnotation,omitempty"` + Image string `json:"image,omitempty"` + ImagePullPolicy string `json:"imagePullPolicy,omitempty"` + ImagePullSecret string `json:"imagePullSecret,omitempty"` + SupervisorAddress string `json:"supervisorAddress,omitempty"` + Advertise string `json:"advertise,omitempty"` + ListenPort *int `json:"listenPort,omitempty"` + HostPort int `json:"hostPort,omitempty"` + CPURequest string `json:"cpuRequest,omitempty"` + CPULimit string `json:"cpuLimit,omitempty"` + MemoryRequest string `json:"memoryRequest,omitempty"` + MemoryLimit string `json:"memoryLimit,omitempty"` + Storage string `json:"storage,omitempty"` + StorageClass string `json:"storageClass,omitempty"` + TmpSize string `json:"tmpSize,omitempty"` + PidsLimit *int `json:"pidsLimit,omitempty"` + RunAsUser *int `json:"runAsUser,omitempty"` + RunAsGroup *int `json:"runAsGroup,omitempty"` + Home string `json:"home,omitempty"` + ReadOnlyRoot *bool `json:"readOnlyRoot,omitempty"` + Network string `json:"network,omitempty"` + CapAdd []string `json:"capAdd,omitempty"` + Env []string `json:"env,omitempty"` + EnvFromSecret []string `json:"envFromSecret,omitempty"` + CredentialsSecret string `json:"credentialsSecret,omitempty"` + CredentialsDir string `json:"credentialsDir,omitempty"` + Wait *bool `json:"wait,omitempty"` + Timeout string `json:"timeout,omitempty"` +} + +func deploymentConfig(plan deploy.Plan, opts GitAgentDeployOptions, namespace string) GitAgentDeploymentConfig { + return GitAgentDeploymentConfig{ + Target: string(plan.Target), Transport: opts.Transport, + Namespace: namespace, KubeContext: opts.KubeContext, + Domain: opts.Domain, IngressClass: opts.IngressClass, + IngressIssuer: opts.IngressIssuer, IngressTLSSecret: opts.IngressTLSSecret, + IngressAnnotation: opts.IngressAnnotation, + Image: plan.Image, ImagePullPolicy: opts.ImagePullPolicy, ImagePullSecret: opts.ImagePullSecret, + SupervisorAddress: plan.Supervisor, Advertise: plan.Advertise, + ListenPort: intPointer(plan.ListenPort), HostPort: plan.HostPort, + CPURequest: opts.CPURequest, CPULimit: opts.CPULimit, + MemoryRequest: opts.MemoryRequest, MemoryLimit: opts.MemoryLimit, + Storage: opts.Storage, StorageClass: opts.StorageClass, TmpSize: opts.TmpSize, + PidsLimit: intPointer(opts.PidsLimit), + RunAsUser: intPointer(opts.RunAsUser), RunAsGroup: intPointer(opts.RunAsGroup), + Home: plan.Home, ReadOnlyRoot: boolPointer(opts.ReadOnlyRoot), + Network: opts.Network, CapAdd: opts.CapAdd, + Env: opts.Env, EnvFromSecret: opts.EnvFromSecret, + CredentialsSecret: opts.CredentialsSecret, CredentialsDir: opts.CredentialsDir, + Wait: boolPointer(opts.Wait), Timeout: opts.Timeout, + } +} + +func (config GitAgentDeploymentConfig) options(name, backend string) GitAgentDeployOptions { + opts := defaultGitAgentDeployOptions() + opts.Name, opts.Backend, opts.Target = name, backend, config.Target + opts.Transport = strings.TrimSpace(config.Transport) + opts.Namespace, opts.KubeContext = strings.TrimSpace(config.Namespace), strings.TrimSpace(config.KubeContext) + opts.Domain, opts.IngressIssuer = strings.TrimSpace(config.Domain), strings.TrimSpace(config.IngressIssuer) + opts.IngressTLSSecret, opts.IngressAnnotation = strings.TrimSpace(config.IngressTLSSecret), config.IngressAnnotation + opts.SupervisorAddress = strings.TrimSpace(config.SupervisorAddress) + opts.Advertise, opts.StorageClass = strings.TrimSpace(config.Advertise), strings.TrimSpace(config.StorageClass) + opts.HostPort = config.HostPort + opts.CapAdd, opts.Env, opts.EnvFromSecret = config.CapAdd, config.Env, config.EnvFromSecret + opts.CredentialsSecret = strings.TrimSpace(config.CredentialsSecret) + opts.CredentialsDir = strings.TrimSpace(config.CredentialsDir) + for _, override := range []struct { + value string + into *string + }{ + {config.IngressClass, &opts.IngressClass}, + {config.Image, &opts.Image}, + {config.ImagePullPolicy, &opts.ImagePullPolicy}, + {config.ImagePullSecret, &opts.ImagePullSecret}, + {config.CPURequest, &opts.CPURequest}, {config.CPULimit, &opts.CPULimit}, + {config.MemoryRequest, &opts.MemoryRequest}, {config.MemoryLimit, &opts.MemoryLimit}, + {config.Storage, &opts.Storage}, {config.TmpSize, &opts.TmpSize}, + {config.Home, &opts.Home}, {config.Network, &opts.Network}, {config.Timeout, &opts.Timeout}, + } { + if trimmed := strings.TrimSpace(override.value); trimmed != "" { + *override.into = trimmed + } + } + for _, field := range []struct{ value, into *int }{ + {config.ListenPort, &opts.ListenPort}, {config.PidsLimit, &opts.PidsLimit}, + {config.RunAsUser, &opts.RunAsUser}, {config.RunAsGroup, &opts.RunAsGroup}, + } { + if field.value != nil { + *field.into = *field.value + } + } + if config.ReadOnlyRoot != nil { + opts.ReadOnlyRoot = *config.ReadOnlyRoot + } + if config.Wait != nil { + opts.Wait = *config.Wait + } + return opts +} + +func intPointer(value int) *int { return &value } +func boolPointer(value bool) *bool { return &value } + +// recordDeployment stores where a workload was placed and the resolved inputs +// needed to edit it without resetting settings hidden by the web form. +func recordDeployment(plan deploy.Plan, opts GitAgentDeployOptions, namespace string) error { + config, err := deploymentConfigRecord(deploymentConfig(plan, opts, namespace)) + if err != nil { + return err + } + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, plan.Backend) + if err != nil { + return err + } + deployments, _ := backend.Options["deployments"].(map[string]any) + if deployments == nil { + deployments = map[string]any{} + } + record := map[string]any{ + "target": string(plan.Target), + "workload": plan.WorkloadName(), + "image": plan.Image, + "deployedAt": time.Now().UTC().Format(time.RFC3339), + "config": config, + } + if strings.TrimSpace(namespace) != "" { + record["namespace"] = namespace + } + deployments[plan.Name] = record + backend.Options["deployments"] = deployments + cfg.Sandbox.Backends[plan.Backend] = backend + return nil + }) +} + +// forgetDeployment drops the record once the workload is gone. A stale record +// would make the UI offer to tear down something that no longer exists. +func forgetDeployment(backendName, agentName string) error { + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return nil + } + deployments, _ := backend.Options["deployments"].(map[string]any) + delete(deployments, agentName) + if len(deployments) == 0 { + delete(backend.Options, "deployments") + } + cfg.Sandbox.Backends[backendName] = backend + return nil + }) +} + +// lookupDeployment reads back where an agent was deployed, if it was. +func lookupDeployment(backendName, agentName string) (GitAgentDeployment, bool) { + cfg, _, err := captainconfig.Load() + if err != nil { + return GitAgentDeployment{}, false + } + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return GitAgentDeployment{}, false + } + deployments, _ := backend.Options["deployments"].(map[string]any) + record, ok := deployments[agentName].(map[string]any) + if !ok { + return GitAgentDeployment{}, false + } + return deploymentFromRecord(record), true +} + +func deploymentFromRecord(record map[string]any) GitAgentDeployment { + deployment := GitAgentDeployment{} + deployment.Target, _ = record["target"].(string) + deployment.Namespace, _ = record["namespace"].(string) + deployment.Workload, _ = record["workload"].(string) + deployment.Image, _ = record["image"].(string) + deployment.DeployedAt, _ = record["deployedAt"].(string) + deployment.Config = deploymentConfigFromRecord(record["config"]) + return deployment +} + +func deploymentConfigRecord(config GitAgentDeploymentConfig) (map[string]any, error) { + raw, err := json.Marshal(config) + if err != nil { + return nil, fmt.Errorf("encode deployment edit config: %w", err) + } + record := map[string]any{} + if err := json.Unmarshal(raw, &record); err != nil { + return nil, fmt.Errorf("store deployment edit config: %w", err) + } + return record, nil +} + +func deploymentConfigFromRecord(value any) *GitAgentDeploymentConfig { + record, ok := value.(map[string]any) + if !ok { + return nil + } + raw, err := json.Marshal(record) + if err != nil { + return nil + } + var config GitAgentDeploymentConfig + if err := json.Unmarshal(raw, &config); err != nil { + return nil + } + return &config +} + +// resolveUndeployTarget picks the runtime to tear down from. +// +// An explicit --target wins, but it must agree with the record: tearing down +// with the wrong one removes nothing and reports success, leaving a live sidecar +// on the network holding a valid key and a checkout of the source tree. +func resolveUndeployTarget(backendName, agentName, override string) (deploy.Target, error) { + recorded, found := lookupDeployment(backendName, agentName) + given := strings.TrimSpace(override) + if given == "" { + if !found { + return "", fmt.Errorf( + "captain has no record of deploying %q, so it cannot tell which runtime to tear down; pass --target docker or --target kubernetes", + agentName) + } + return deploy.ParseTarget(recorded.Target) + } + target, err := deploy.ParseTarget(given) + if err != nil { + return "", err + } + if found && recorded.Target != "" && !strings.EqualFold(recorded.Target, string(target)) { + return "", fmt.Errorf( + "%q was deployed on %s, not %s; tearing down the wrong runtime removes nothing and would leave the sidecar running", + agentName, recorded.Target, target) + } + return target, nil +} diff --git a/pkg/cli/gitagent_directory.go b/pkg/cli/gitagent_directory.go index 47b9d246..b8f26545 100644 --- a/pkg/cli/gitagent_directory.go +++ b/pkg/cli/gitagent_directory.go @@ -1,24 +1,58 @@ package cli import ( + "context" + "errors" "fmt" + "maps" + "os" + "path/filepath" + "strings" "time" "github.com/flanksource/captain/pkg/api/registry" "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" ) // gitAgentDirectory satisfies the server's authorization source. var _ gitagent.AgentDirectory = gitAgentDirectory{} -// gitAgentDirectory implements gitagent.AgentDirectory over the sandbox -// backend's options block in ~/.captain.yaml. Every read loads the file fresh -// so a revocation takes effect for the next connection (R8.5), and every -// mutation goes through the flocked captainconfig.Update (A3.4) so a token -// burn is atomic. +// gitAgentDirectory implements gitagent.AgentDirectory over two sources. The +// agent roster lives in the sandbox backend's options block in ~/.captain.yaml, +// because it is dispatch targeting data — an endpoint and a host key, not a +// credential. Tokens live in the database, password-hashed. +// +// Every read of either goes to the live source, so a revocation takes effect +// for the next connection (R8.5), and every config mutation goes through the +// flocked captainconfig.Update (A3.4). type gitAgentDirectory struct { backend string + // ctx and db back AdmitToken. A directory built without them refuses + // enrollment rather than falling back to an unauthenticated one. + ctx context.Context + db *database.DB +} + +// gitAgentDirectoryFor builds the directory a receiver authorizes against. +// +// Only a mailbox enrolls agents, and only a mailbox needs the token store — +// which is also the only role that runs on the host holding it. A sidecar gets +// no database rather than one it would never read. +func gitAgentDirectoryFor(ctx context.Context, role gitagent.ReceiverRole, backend string) (gitAgentDirectory, error) { + directory := gitAgentDirectory{backend: backend, ctx: ctx} + if role != gitagent.RoleMailbox { + return directory, nil + } + db, err := captainServeDB(ctx) + if err != nil { + return gitAgentDirectory{}, fmt.Errorf("a mailbox verifies captain tokens against the database: %w", err) + } + directory.db = db + return directory, nil } func (d gitAgentDirectory) AgentByFingerprint(fingerprint string) (string, bool) { @@ -39,48 +73,57 @@ func (d gitAgentDirectory) AgentByFingerprint(fingerprint string) (string, bool) return "", false } -func (d gitAgentDirectory) ConsumeJoinToken(token string) (string, error) { - hash := gitagent.HashJoinToken(token) - var agentName string - var refusal error - // The refusal travels outside the Update callback: an error returned from - // the callback aborts the write, and burning an expired or malformed - // token must persist. - err := captainconfig.Update(func(cfg *captainconfig.Config) error { - refusal = fmt.Errorf("join token is unknown or already used") - backend, ok := cfg.Sandbox.Backends[d.backend] - if !ok { - return nil - } - pending, _ := backend.Options["pending"].(map[string]any) - entry, ok := pending[hash].(map[string]any) - if !ok { - return nil - } - // Burn before inspecting: a malformed entry must not stay redeemable. - delete(pending, hash) - if len(pending) == 0 { - delete(backend.Options, "pending") - } - cfg.Sandbox.Backends[d.backend] = backend - expires, _ := entry["expires"].(string) - if t, err := time.Parse(time.RFC3339, expires); err != nil || time.Now().After(t) { - refusal = fmt.Errorf("join token has expired; mint a new one with `captain sandbox git-agent add`") - return nil - } - name, _ := entry["agent"].(string) - if name == "" { - refusal = fmt.Errorf("join token has no agent recorded") - return nil - } - agentName = name - refusal = nil - return nil - }) +// AdmitToken verifies a presented captain token and resolves the agent it +// speaks for. +// +// The token is not spent. A sidecar that restarts presents the same one, which +// is the whole point of the change: the single-use token it replaces made every +// restart of a long-lived workload a crash loop, and every code path where +// enrollment could re-run needed a guard against it. +func (d gitAgentDirectory) AdmitToken(token, requested string) (string, error) { + if d.db == nil { + return "", fmt.Errorf("this endpoint cannot verify captain tokens: it was started without a database") + } + ctx := d.ctx + if ctx == nil { + ctx = context.Background() + } + record, err := captaintoken.NewVerifier(d.db.LookupAPIToken). + VerifyScope(ctx, token, captaintoken.ScopeGit) if err != nil { - return "", err + return "", enrollmentRefusal(err) + } + name, err := d.db.AdmitAPITokenAgent(ctx, record.ID, requested) + if err != nil { + return "", enrollmentRefusal(err) + } + if err := d.db.TouchAPIToken(ctx, record.ID); err != nil { + log.Warnf("record captain token use: %v", err) + } + return name, nil +} + +// enrollmentRefusal turns a verification failure into something an operator can +// act on. An unknown id and a wrong secret share one answer, but a revoked, +// expired or exhausted credential is a real one whose holder benefits from +// knowing which — and each calls for a different fix. +func enrollmentRefusal(err error) error { + switch { + case errors.Is(err, captaintoken.ErrRevoked): + return fmt.Errorf("this captain token has been revoked; mint a new one with `captain token create`") + case errors.Is(err, captaintoken.ErrExpired): + return fmt.Errorf("this captain token has expired; mint a new one with `captain token create`") + case errors.Is(err, captaintoken.ErrScope): + return fmt.Errorf("this captain token does not carry the %s scope, so it cannot enroll an agent", captaintoken.ScopeGit) + case errors.Is(err, database.ErrAPITokenPoolFull): + return err + case errors.Is(err, captaintoken.ErrUnknown), errors.Is(err, captaintoken.ErrMalformed): + return fmt.Errorf("captain token is not recognized") + default: + // A store outage is not a rejection. Saying so keeps an operator from + // hunting a phantom credential problem through a database failure. + return fmt.Errorf("cannot verify captain tokens right now: %w", err) } - return agentName, refusal } // RecordAgent stores everything a dispatch to this agent needs: its client @@ -89,10 +132,13 @@ func (d gitAgentDirectory) ConsumeJoinToken(token string) (string, error) { // dispatched to. func (d gitAgentDirectory) RecordAgent(e gitagent.AgentEnrollment) error { if e.URL == "" { - return fmt.Errorf("agent %q advertised no endpoint; rerun its serve with --advertise ssh://host:port", e.Name) + return fmt.Errorf( + "agent %q advertised no endpoint; rerun its serve with --advertise ssh://host:port or "+ + "--advertise https://host/git/%s", e.Name, SidecarRepoName) } - if e.HostFingerprint == "" { - return fmt.Errorf("agent %q advertised no host key fingerprint; its dispatch could not be verified", e.Name) + credential, err := recordDispatchCredential(e) + if err != nil { + return err } return captainconfig.Update(func(cfg *captainconfig.Config) error { backend, err := ensureGitAgentBackend(cfg, d.backend) @@ -103,18 +149,99 @@ func (d gitAgentDirectory) RecordAgent(e gitagent.AgentEnrollment) error { if agents == nil { agents = map[string]any{} } - agents[e.Name] = map[string]any{ - "fingerprint": e.Fingerprint, - "url": e.URL, - "hostFingerprint": e.HostFingerprint, - "addedAt": time.Now().UTC().Format(time.RFC3339), + entry := map[string]any{ + "fingerprint": e.Fingerprint, + "url": e.URL, + "addedAt": time.Now().UTC().Format(time.RFC3339), } + // Only the credential this transport uses is written, so re-enrolling an + // agent across transports cannot leave the other one's key beside it and + // make the entry look like it authenticates two ways. + maps.Copy(entry, credential) + agents[e.Name] = entry backend.Options["agents"] = agents cfg.Sandbox.Backends[d.backend] = backend return nil }) } +// recordDispatchCredential resolves what the supervisor must present to this +// agent when it dispatches, which is decided entirely by how the agent is +// reached: an ssh endpoint is pinned by host key, an https one authenticates +// with the bearer token the agent minted for exactly this purpose. +// +// There is deliberately no cross-transport leniency. An ssh agent with no host +// key and an https agent with no token are both endpoints the supervisor could +// reach but not authenticate to, and recording either would produce a roster +// that looks complete and fails at the first dispatch. +func recordDispatchCredential(e gitagent.AgentEnrollment) (map[string]any, error) { + switch scheme := gitagent.EndpointScheme(e.URL); scheme { + case "ssh": + if strings.TrimSpace(e.HostFingerprint) == "" { + return nil, fmt.Errorf("agent %q advertised no host key fingerprint; its dispatch could not be verified", e.Name) + } + return map[string]any{"hostFingerprint": strings.TrimSpace(e.HostFingerprint)}, nil + case "https": + if strings.TrimSpace(e.DispatchToken) == "" { + return nil, fmt.Errorf( + "agent %q advertised the https endpoint %s but issued no dispatch token, so this supervisor "+ + "has no way to authenticate to it; rerun its serve with --transport https", e.Name, e.URL) + } + path, err := writeDispatchTokenFile(e.Name, e.DispatchToken) + if err != nil { + return nil, err + } + return map[string]any{"tokenPath": path}, nil + default: + return nil, fmt.Errorf( + "agent %q advertised %s, whose scheme %q is not a transport captain speaks; want ssh:// or https://", + e.Name, e.URL, scheme) + } +} + +// dispatchTokensDir holds one file per agent this supervisor dispatches to over +// https. +// +// The config records the path, never the value. That is the rule the relay +// already follows in the other direction ("a path rather than the credential +// itself, exactly as KeyPath is"), and ~/.captain.yaml has no guaranteed mode, +// is read by hook shims running as whoever pushed, and is echoed in dry-run +// output. A per-agent file also means a leak of one is a leak of one. +// +// A database column was considered and rejected: the token store holds argon2 +// hashes of credentials captain ISSUES and is documented as never letting them +// leave the package, while this is a credential captain must be able to +// PRESENT. Storing it there would mean recoverable plaintext in Postgres, a +// migration, and wiring a *database.DB into pkg/sandbox/adapter, which cannot +// import this package. +const dispatchTokensDir = "dispatch-tokens" + +func writeDispatchTokenFile(agent, token string) (string, error) { + keysDir, err := gitAgentKeysDir() + if err != nil { + return "", err + } + path := filepath.Join(keysDir, dispatchTokensDir, agent+".token") + if err := gitagent.WriteTokenFile(path, text.NewSensitiveString(token)); err != nil { + return "", fmt.Errorf("store the dispatch token for agent %q: %w", agent, err) + } + return path, nil +} + +// removeDispatchTokenFile drops an agent's credential when its roster entry +// goes, so revoking an agent does not leave a live way in on disk. +func removeDispatchTokenFile(agent string) error { + keysDir, err := gitAgentKeysDir() + if err != nil { + return err + } + err = os.Remove(filepath.Join(keysDir, dispatchTokensDir, agent+".token")) + if errors.Is(err, os.ErrNotExist) { + return nil // an ssh agent never had one + } + return err +} + // ensureGitAgentBackend returns the named backend, creating a git-agent one // (with an initialized Options map) when absent so `add` works on a fresh // config. diff --git a/pkg/cli/gitagent_directory_test.go b/pkg/cli/gitagent_directory_test.go new file mode 100644 index 00000000..da4f018a --- /dev/null +++ b/pkg/cli/gitagent_directory_test.go @@ -0,0 +1,229 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" +) + +// recordedAgent reads back what RecordAgent wrote for one agent. +func recordedAgent(t *testing.T, backend, name string) map[string]any { + t.Helper() + cfg, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + entry, err := enrolledAgent(cfg, backend, name) + if err != nil { + t.Fatal(err) + } + return entry +} + +// Which credential the supervisor must present is decided by the endpoint's +// scheme, and recording an agent it can reach but not authenticate to would +// produce a roster that looks complete and fails at the first dispatch. +func TestRecordAgentRequiresTheCredentialItsTransportUses(t *testing.T) { + const backend = "git-agent" + const secret = "cptn_aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + t.Run("an ssh agent is pinned by host key", func(t *testing.T) { + isolatedConfig(t) + directory := gitAgentDirectory{backend: backend} + + err := directory.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "ssh://captain@h:7422/repo.git", HostFingerprint: "SHA256:abc", + }) + if err != nil { + t.Fatal(err) + } + entry := recordedAgent(t, backend, "w1") + if entry["hostFingerprint"] != "SHA256:abc" { + t.Fatalf("hostFingerprint = %v", entry["hostFingerprint"]) + } + if _, ok := entry["tokenPath"]; ok { + t.Fatal("an ssh agent was given a bearer token it does not use") + } + }) + + t.Run("an https agent authenticates by token", func(t *testing.T) { + isolatedConfig(t) + directory := gitAgentDirectory{backend: backend} + + err := directory.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "https://w1.example.com/git/repo.git", DispatchToken: secret, + }) + if err != nil { + t.Fatal(err) + } + entry := recordedAgent(t, backend, "w1") + if _, ok := entry["hostFingerprint"]; ok { + t.Fatal("an https agent was pinned by a host key it does not present") + } + path, _ := entry["tokenPath"].(string) + if path == "" { + t.Fatal("no token path was recorded") + } + token, err := gitagent.ReadTokenFile(path) + if err != nil { + t.Fatal(err) + } + if token.Value() != secret { + t.Fatalf("stored token = %q", token.Value()) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("token mode = %v, want 0600", info.Mode().Perm()) + } + }) + + // ~/.captain.yaml has no guaranteed mode, is read by hook shims running as + // whoever pushed, and is echoed in dry-run output. + t.Run("the secret never enters the config file", func(t *testing.T) { + path := isolatedConfig(t) + directory := gitAgentDirectory{backend: backend} + + err := directory.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "https://w1.example.com/git/repo.git", DispatchToken: secret, + }) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), secret) { + t.Fatalf("the dispatch token was written into %s", path) + } + }) + + t.Run("refusals name the fix", func(t *testing.T) { + for _, tc := range []struct { + name string + enrollment gitagent.AgentEnrollment + want string + }{{ + name: "no endpoint at all", + enrollment: gitagent.AgentEnrollment{Name: "w1"}, + want: "advertised no endpoint", + }, { + name: "ssh without a host key", + enrollment: gitagent.AgentEnrollment{Name: "w1", URL: "ssh://h:7422/repo.git"}, + want: "advertised no host key fingerprint", + }, { + // No cross-transport leniency: a token proves nothing to an ssh push. + name: "ssh carrying only a token", + enrollment: gitagent.AgentEnrollment{ + Name: "w1", URL: "ssh://h:7422/repo.git", DispatchToken: secret, + }, + want: "advertised no host key fingerprint", + }, { + name: "https without a token", + enrollment: gitagent.AgentEnrollment{Name: "w1", URL: "https://w1.example.com/git/repo.git"}, + want: "--transport https", + }, { + // And the mirror: a host key is not something an https client checks. + name: "https carrying only a host key", + enrollment: gitagent.AgentEnrollment{ + Name: "w1", URL: "https://w1.example.com/git/repo.git", HostFingerprint: "SHA256:abc", + }, + want: "issued no dispatch token", + }, { + name: "a scheme captain does not speak", + enrollment: gitagent.AgentEnrollment{Name: "w1", URL: "git://h/repo.git", HostFingerprint: "SHA256:abc"}, + want: "not a transport captain speaks", + }} { + t.Run(tc.name, func(t *testing.T) { + isolatedConfig(t) + err := gitAgentDirectory{backend: backend}.RecordAgent(tc.enrollment) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to name %q", err, tc.want) + } + }) + } + }) + + // An entry carrying both credentials would look like it authenticates two + // ways, and the stale one would outlive the transport that used it. + t.Run("re-enrolling across transports drops the stale credential", func(t *testing.T) { + isolatedConfig(t) + directory := gitAgentDirectory{backend: backend} + + if err := directory.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "ssh://captain@h:7422/repo.git", HostFingerprint: "SHA256:abc", + }); err != nil { + t.Fatal(err) + } + if err := directory.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "https://w1.example.com/git/repo.git", DispatchToken: secret, + }); err != nil { + t.Fatal(err) + } + entry := recordedAgent(t, backend, "w1") + if _, ok := entry["hostFingerprint"]; ok { + t.Fatal("the ssh host key survived a re-enrollment over https") + } + if _, ok := entry["tokenPath"]; !ok { + t.Fatal("the https token was not recorded") + } + }) +} + +// A revoked agent whose credential is still on disk is still a way in. +func TestRevokeRemovesTheDispatchToken(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + const secret = "cptn_aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + err := gitAgentDirectory{backend: backend}.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "https://w1.example.com/git/repo.git", DispatchToken: secret, + }) + if err != nil { + t.Fatal(err) + } + path, _ := recordedAgent(t, backend, "w1")["tokenPath"].(string) + + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Backend: backend, Name: "w1"}); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("the dispatch token survived revocation at %s (err = %v)", path, err) + } +} + +// Revoking an ssh agent has no token to remove, and must not fail looking. +func TestRevokeWithoutADispatchTokenSucceeds(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + err := gitAgentDirectory{backend: backend}.RecordAgent(gitagent.AgentEnrollment{ + Name: "w1", URL: "ssh://captain@h:7422/repo.git", HostFingerprint: "SHA256:abc", + }) + if err != nil { + t.Fatal(err) + } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Backend: backend, Name: "w1"}); err != nil { + t.Fatal(err) + } +} + +// The keys directory follows the config path, so a test never writes a +// credential into the developer's real home. +func TestDispatchTokensStayInsideTheIsolatedKeysDir(t *testing.T) { + configPath := isolatedConfig(t) + keysDir, err := gitAgentKeysDir() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(keysDir, filepath.Dir(configPath)) { + t.Fatalf("keys dir %s escaped the isolated config at %s", keysDir, configPath) + } +} diff --git a/pkg/cli/gitagent_e2e_test.go b/pkg/cli/gitagent_e2e_test.go index c180561c..effa7016 100644 --- a/pkg/cli/gitagent_e2e_test.go +++ b/pkg/cli/gitagent_e2e_test.go @@ -262,7 +262,7 @@ func newRepo(t *testing.T) string { func mailboxPathForRepo(t *testing.T, supervisor *host, repo string) string { t.Helper() - root := filepath.Join(supervisor.home, ".captain", "sandbox", servedReposDir) + root := filepath.Join(supervisor.home, ".captain", "sandbox", gitagent.ServedReposDirName) mailbox, err := gitagent.MailboxForRepository(context.Background(), root, repo) if err != nil { t.Fatal(err) @@ -320,7 +320,7 @@ func parseJoin(t *testing.T, join string) []string { t.Fatalf("join command does not invoke serve: %q", join) } args := fields[idx:] - for _, required := range []string{"--join", "--supervisor", "--host-fingerprint"} { + for _, required := range []string{"--token", "--supervisor", "--host-fingerprint"} { if !containsArg(args, required) { t.Fatalf("join command lacks %s, so an operator cannot complete enrollment: %q", required, join) } @@ -439,7 +439,7 @@ func TestFullCycleWithAManualAgent(t *testing.T) { }) // The agent's workspace is an ordinary worktree with a branch and upstream. - tasksDir := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + tasksDir := filepath.Join(agent.home, ".captain", "sandbox", gitagent.ServedReposDirName, SidecarRepoName, "captain", "tasks") var worktree, taskID string deadline := time.Now().Add(90 * time.Second) for time.Now().Before(deadline) && worktree == "" { @@ -515,23 +515,28 @@ func TestFullCycleWithAManualAgent(t *testing.T) { } } -// TestJoinTokenIsSingleUse pins that the bidirectional exchange keeps the -// single-use property (R8.2). -func TestJoinTokenIsSingleUse(t *testing.T) { +// TestCaptainTokenIsDurableAcrossRestarts pins the property that replaced the +// single-use join token (R8.2): presenting the same token again re-enrolls to +// the same identity rather than being refused. +// +// This is what a restarting or rescheduled sidecar does on every start. Under +// the old burn-on-use token it crash-looped, and joinOnce existed only to work +// around that; the workaround is gone, so the behaviour it hid is asserted here. +func TestCaptainTokenIsDurableAcrossRestarts(t *testing.T) { if testing.Short() { t.Skip("builds the captain binary") } _, _, _, _, add := enrollPair(t) replay := newHost(t) - args := append([]string{"sandbox", "git-agent", "serve", "--listen", "127.0.0.1:" + freeLocalPort(t)}, - parseJoin(t, add.JoinCommand)...) - out, err := replay.run(args...) - if err == nil { - t.Fatalf("token replay must be refused:\n%s", out) + replay.serve(freeLocalPort(t), parseJoin(t, add.JoinCommand)...) + + logs := replay.serveLogs() + if !strings.Contains(logs, "enrolled as worker-01") { + t.Fatalf("re-presenting a durable token must re-enroll to the same identity:\n%s", logs) } - if !strings.Contains(out, "already used") { - t.Fatalf("replay refusal must name the cause:\n%s", out) + if strings.Contains(logs, "already used") { + t.Fatalf("the token was burned on first use; a rescheduled sidecar would crash-loop:\n%s", logs) } } @@ -727,7 +732,7 @@ func TestOneEndpointRoutesTwoRepositories(t *testing.T) { // diagnosis available when a dispatch fails to conclude. func agentLogs(t *testing.T, agent *host) string { t.Helper() - tasks := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + tasks := filepath.Join(agent.home, ".captain", "sandbox", gitagent.ServedReposDirName, SidecarRepoName, "captain", "tasks") entries, err := os.ReadDir(tasks) if err != nil { return "no task directory: " + err.Error() @@ -800,7 +805,7 @@ func TestUnconfiguredDispatchLaunchesTheDefaultAgent(t *testing.T) { // An agent log appearing at all is the assertion: the sidecar launched // something rather than preparing a workspace and going quiet. - tasks := filepath.Join(agent.home, ".captain", "sandbox", servedReposDir, SidecarRepoName, "captain", "tasks") + tasks := filepath.Join(agent.home, ".captain", "sandbox", gitagent.ServedReposDirName, SidecarRepoName, "captain", "tasks") deadline := time.Now().Add(60 * time.Second) launched := "" for time.Now().Before(deadline) && launched == "" { diff --git a/pkg/cli/gitagent_hook.go b/pkg/cli/gitagent_hook.go index 33a993c6..4733158e 100644 --- a/pkg/cli/gitagent_hook.go +++ b/pkg/cli/gitagent_hook.go @@ -141,6 +141,7 @@ func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { if supervisor, ok := backend.Options["supervisor"].(map[string]any); ok { url, _ := supervisor["url"].(string) hostFP, _ := supervisor["hostFingerprint"].(string) + tokenPath, _ := supervisor["tokenPath"].(string) keysDir, err := gitAgentKeysDir() if err != nil { return rt, err @@ -149,12 +150,20 @@ func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { if err != nil { return rt, err } + if tokenPath == "" { + tokenPath = filepath.Join(keysDir, gitagent.TokenFileName) + } rt.Relay = gitagent.RelayTarget{ URL: url, HostFingerprint: hostFP, KeyPath: filepath.Join(keysDir, agentKeyName), SSHCommand: gitagent.SSHTransportCommand(exe), + // The path, never the credential: this struct is serialized into + // hooks.json, which every hook process can read. + TokenPath: tokenPath, + CAPath: supervisorCAPath(supervisor, keysDir), } + rt.Relay.PinnedPublicKey, _ = supervisor["pinnedPubkey"].(string) } return rt, nil } @@ -162,6 +171,16 @@ func hookRuntimeFromConfig(backendName string) (gitagent.HookRuntime, error) { // decodeWorkflow converts a YAML-decoded options value into an api.Workflow // via a JSON round-trip, so the receiver runs the exact schema the local run // path declares (A5.1). +// supervisorCAPath resolves the certificate the relay verifies the supervisor +// against. The default is where enrollment stores it; an explicit value covers +// a supervisor serving a real certificate from elsewhere. +func supervisorCAPath(supervisor map[string]any, keysDir string) string { + if path, _ := supervisor["caPath"].(string); strings.TrimSpace(path) != "" { + return strings.TrimSpace(path) + } + return filepath.Join(keysDir, supervisorCAName) +} + func decodeWorkflow(v any) (*api.Workflow, error) { if v == nil { return nil, nil diff --git a/pkg/cli/gitagent_mailbox_record.go b/pkg/cli/gitagent_mailbox_record.go new file mode 100644 index 00000000..887d2d12 --- /dev/null +++ b/pkg/cli/gitagent_mailbox_record.go @@ -0,0 +1,150 @@ +// What a serving process publishes about the mailbox it hosts, so other captain +// processes on the host can find it without being told. +// +// The record is keyed by transport because two different processes write it: +// `captain sandbox git-agent serve --role mailbox` serves ssh, and `captain +// serve` serves https. A single key would mean the second to start silently +// erased the first — and `captain serve` starts constantly, for the web UI. +package cli + +import ( + "fmt" + "net" + "strconv" + "strings" +) + +// mailboxTransport is the channel a recorded mailbox answers on. It is also the +// scheme of the URL an agent relays to, so the two cannot drift. +type mailboxTransport string + +const ( + transportSSH mailboxTransport = "ssh" + transportHTTPS mailboxTransport = "https" +) + +func parseMailboxTransport(value string) (mailboxTransport, error) { + switch transport := mailboxTransport(strings.ToLower(strings.TrimSpace(value))); transport { + case transportSSH, transportHTTPS: + return transport, nil + case "": + return "", nil + default: + return "", fmt.Errorf("unknown transport %q; captain speaks %s and %s", value, transportSSH, transportHTTPS) + } +} + +// mailboxRecord is one live-or-recently-live mailbox on this host. +type mailboxRecord struct { + Transport mailboxTransport + // Root is the served repository root; Listen the bind address as given. + Root string + Listen string + // Identity is what a client pins: an SSH host-key fingerprint, or a TLS + // public-key pin. Which one is decided by Transport, and both are compared + // the same way — against what the endpoint actually presents. + Identity string + // Encrypted reports whether a credential can cross this channel. It is + // recorded even when false: `captain serve` without --tls hosts the handler + // over plain HTTP, and saying so precisely is the difference between + // "restart it with --tls" and "no mailbox has ever served here". + Encrypted bool +} + +// Port parses the listen address's port, which is what a reach-back URL needs. +func (r mailboxRecord) Port() (int, error) { + _, portText, err := net.SplitHostPort(r.Listen) + if err != nil { + return 0, fmt.Errorf("recorded mailbox listen address %q is not [host]:port: %w", r.Listen, err) + } + port, err := strconv.Atoi(portText) + if err != nil { + return 0, fmt.Errorf("recorded mailbox port %q is not a number", portText) + } + return port, nil +} + +// LoopbackURL is the address this host probes the mailbox on to prove it is +// live and is this host's own. +func (r mailboxRecord) LoopbackURL() (string, error) { + port, err := r.Port() + if err != nil { + return "", err + } + return fmt.Sprintf("%s://%s", r.Transport, net.JoinHostPort("127.0.0.1", strconv.Itoa(port))), nil +} + +// mailboxOptionKey is the backend option holding the records, keyed by transport. +const mailboxOptionKey = "mailbox" + +// setMailboxRecord writes one transport's record, leaving the other's alone. +func setMailboxRecord(options map[string]any, record mailboxRecord) { + records, _ := options[mailboxOptionKey].(map[string]any) + if records == nil { + records = map[string]any{} + } + entry := map[string]any{ + "root": record.Root, + "listen": record.Listen, + "identity": record.Identity, + } + if record.Transport == transportHTTPS { + entry["tls"] = record.Encrypted + } + records[string(record.Transport)] = entry + options[mailboxOptionKey] = records +} + +// clearMailboxRecord drops a record that named the given address. +// +// One address serves one role, so a record left by an earlier mailbox would +// otherwise claim a port that now answers as something else — and over ssh both +// roles present the same host key, so no probe could tell them apart. +func clearMailboxRecord(options map[string]any, transport mailboxTransport, listen string) { + records, _ := options[mailboxOptionKey].(map[string]any) + entry, _ := records[string(transport)].(map[string]any) + if entry == nil { + return + } + if recorded, _ := entry["listen"].(string); recorded != listen { + return + } + delete(records, string(transport)) + if len(records) == 0 { + delete(options, mailboxOptionKey) + return + } + options[mailboxOptionKey] = records +} + +// mailboxRecords reads every recorded mailbox, keyed by transport. +func mailboxRecords(options map[string]any) map[mailboxTransport]mailboxRecord { + records, _ := options[mailboxOptionKey].(map[string]any) + out := map[mailboxTransport]mailboxRecord{} + for _, transport := range []mailboxTransport{transportHTTPS, transportSSH} { + entry, _ := records[string(transport)].(map[string]any) + if entry == nil { + continue + } + listen, _ := entry["listen"].(string) + if strings.TrimSpace(listen) == "" { + continue + } + root, _ := entry["root"].(string) + identity, _ := entry["identity"].(string) + // ssh is encrypted by construction; https only once the server actually + // negotiated TLS, which `captain serve` does only under --tls. + encrypted := transport == transportSSH + if transport == transportHTTPS { + encrypted, _ = entry["tls"].(bool) + } + out[transport] = mailboxRecord{ + Transport: transport, + Root: root, + Listen: strings.TrimSpace(listen), + Identity: strings.TrimSpace(identity), + Encrypted: encrypted, + } + } + return out +} diff --git a/pkg/cli/gitagent_restart_ginkgo_test.go b/pkg/cli/gitagent_restart_ginkgo_test.go new file mode 100644 index 00000000..66877915 --- /dev/null +++ b/pkg/cli/gitagent_restart_ginkgo_test.go @@ -0,0 +1,62 @@ +package cli + +import ( + "context" + "path/filepath" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +var _ = Describe("git-agent restart", func() { + It("starts from complete persisted enrollment while the supervisor is unavailable", func() { + home := GinkgoT().TempDir() + captainconfig.SetPathForTesting(filepath.Join(home, ".captain.yaml")) + DeferCleanup(func() { captainconfig.SetPathForTesting("") }) + keysDir := filepath.Join(home, ".captain", "sandbox") + joinToken := text.NewSensitiveString("cptn_restart.restart-secret") + joinPath := filepath.Join(home, "join-token") + storedTokenPath := filepath.Join(keysDir, gitagent.TokenFileName) + Expect(gitagent.WriteTokenFile(joinPath, joinToken)).To(Succeed()) + Expect(gitagent.WriteTokenFile(storedTokenPath, joinToken)).To(Succeed()) + _, err := gitagent.MintDispatchCredential(filepath.Join(keysDir, gitagent.DispatchCredentialName)) + Expect(err).NotTo(HaveOccurred()) + supervisorTLS, err := gitagent.EnsureTLSCredential(filepath.Join(home, "supervisor"), []string{"127.0.0.1"}) + Expect(err).NotTo(HaveOccurred()) + Expect(captainconfig.Save(captainconfig.Config{Sandbox: captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{ + "git-agent": {Kind: "git-agent", Options: map[string]any{ + "supervisor": map[string]any{ + "url": "https://127.0.0.1:1", + "hostFingerprint": supervisorTLS.PublicKeyPin, + "agent": "w03", + "tokenPath": storedTokenPath, + "caPath": supervisorTLS.CertPath, + "pinnedPubkey": supervisorTLS.PublicKeyPin, + }, + }}, + }, + }})).To(Succeed()) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { + _, runErr := RunGitAgentServe(ctx, GitAgentServeOptions{ + Backend: "git-agent", Role: string(gitagent.RoleSidecar), Transport: string(transportHTTPS), + Listen: "127.0.0.1:0", Advertise: "https://w03.example.com/git/repo.git", + Supervisor: "https://127.0.0.1:1", HostFingerprint: supervisorTLS.PublicKeyPin, + TokenFile: joinPath, Root: filepath.Join(home, "repos"), + }) + done <- runErr + }() + + Consistently(done, 300*time.Millisecond).ShouldNot(Receive()) + cancel() + Eventually(done).Should(Receive(BeNil())) + }) +}) diff --git a/pkg/cli/gitagent_serve.go b/pkg/cli/gitagent_serve.go index 7f0cb1a5..3ccb015e 100644 --- a/pkg/cli/gitagent_serve.go +++ b/pkg/cli/gitagent_serve.go @@ -4,26 +4,20 @@ import ( "context" "fmt" "net" + "net/url" "os" "path/filepath" "strings" "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/captaintoken" "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/captain/pkg/gitagent/deploy" "github.com/flanksource/clicky" + "github.com/flanksource/clicky/text" + gossh "golang.org/x/crypto/ssh" ) -type GitAgentServeOptions struct { - Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` - Listen string `flag:"listen" help:"Address to serve git-receive-pack on" default:":7422"` - Root string `flag:"root" help:"Directory of receivable repos (default /repos)"` - Role string `flag:"role" help:"Receiver role: sidecar (runs beside a coding agent) or mailbox (the supervisor's receiver)" default:"sidecar"` - Advertise string `flag:"advertise" help:"sidecar role: ssh://host:port the supervisor should dispatch to (default: the address the supervisor sees)"` - Join string `flag:"join" help:"Single-use join token printed by 'captain sandbox git-agent add'"` - Supervisor string `flag:"supervisor" help:"ssh://host:port of the supervisor to enroll with"` - HostFingerprint string `flag:"host-fingerprint" help:"Pinned SHA256 fingerprint of the supervisor's host key"` -} - // RunGitAgentServe runs the receive endpoint on this host, optionally // enrolling with a supervisor first. The agent keypair is generated locally // on first start and its private half never leaves this machine (R8.2). @@ -32,6 +26,10 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro if role != gitagent.RoleSidecar && role != gitagent.RoleMailbox { return nil, fmt.Errorf("--role must be %q or %q", gitagent.RoleSidecar, gitagent.RoleMailbox) } + transport, err := validateServeTransport(opts, role) + if err != nil { + return nil, err + } keysDir, err := gitAgentKeysDir() if err != nil { return nil, err @@ -46,10 +44,22 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro if err != nil { return nil, err } - if opts.Join != "" { - if err := joinSupervisor(ctx, opts, keysDir); err != nil { + token, err := opts.enrollmentToken() + if err != nil { + return nil, err + } + if !token.IsEmpty() { + agent, err := reusableEnrollment(opts, transport, token, keysDir) + if err != nil { return nil, err } + if agent == "" { + if err := joinSupervisor(ctx, opts, transport, token, keysDir); err != nil { + return nil, err + } + } else { + clicky.Printf("resuming enrollment as %s\n", agent) + } } if err := os.MkdirAll(root, 0o755); err != nil { return nil, err @@ -59,20 +69,42 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro if err := ensureServedRepos(ctx, root, role, opts); err != nil { return nil, err } - hostKey, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) - if err != nil { + // An https sidecar has no ssh host key: nothing presents it, and generating + // one would leave a private key on the box that runs agent-authored code for + // a listener that does not exist. + var hostKey gossh.Signer + var hostFP string + if transport == transportSSH { + if hostKey, hostFP, err = gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)); err != nil { + return nil, err + } + } + // After the host key exists: the record publishes this endpoint's identity, + // which is that key's fingerprint. + if err := recordServedEndpoint(opts, role, transport, root, hostFP); err != nil { return nil, err } + if transport == transportHTTPS { + startSidecarBackground(ctx, root) + return nil, serveSidecarHTTPS(ctx, sidecarHTTPSPlan{ + listen: opts.Listen, root: root, keysDir: keysDir, advertise: opts.Advertise, + certPath: opts.TLSCert, keyPath: opts.TLSKey, + }) + } offer, err := enrollmentOffer(role, keysDir) if err != nil { return nil, err } + directory, err := gitAgentDirectoryFor(ctx, role, opts.Backend) + if err != nil { + return nil, err + } server, err := gitagent.NewServer(gitagent.ServerConfig{ Listen: opts.Listen, Root: root, Role: role, HostKey: hostKey, - Directory: gitAgentDirectory{backend: opts.Backend}, + Directory: directory, Offer: offer, AgentRepoPath: SidecarRepoName, }) @@ -84,11 +116,7 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro if role == gitagent.RoleMailbox { clicky.Printf(" enroll an agent with: captain sandbox git-agent add --endpoint ssh://:\n") } else { - monitor := newAgentTaskLogMonitor(filepath.Join(root, SidecarRepoName), os.Stdout, os.Stderr, log.Infof) - if err := monitor.prime(); err != nil { - log.Warnf("git-agent task log monitor: %v", err) - } - go monitor.run(ctx) + startSidecarBackground(ctx, root) } go func() { <-ctx.Done() @@ -100,6 +128,70 @@ func RunGitAgentServe(ctx context.Context, opts GitAgentServeOptions) (any, erro return nil, nil } +// startSidecarBackground launches the work a sidecar does alongside its +// listener, whichever transport that listener speaks: streaming the agent's task +// log, and materializing the CLI logins the supervisor publishes. It returns +// once both are running. +func startSidecarBackground(ctx context.Context, root string) { + monitor := newAgentTaskLogMonitor(filepath.Join(root, SidecarRepoName), os.Stdout, os.Stderr, log.Infof) + if err := monitor.prime(); err != nil { + log.Warnf("git-agent task log monitor: %v", err) + } + go monitor.run(ctx) + + // Only the sidecar runs coding agents, so only the sidecar needs the + // agent CLI logins the supervisor publishes. + if home, err := os.UserHomeDir(); err != nil { + log.Warnf("git-agent credential materializer: resolve home directory: %v", err) + } else if credentials := newCredentialMaterializer(home); credentials.mounted() { + clicky.Printf(" credentials: %s\n", deploy.CredentialsMountPath) + go credentials.run(ctx) + } +} + +// validateServeTransport resolves which protocol the receive endpoint speaks, +// refusing every combination that would produce a listener the supervisor +// cannot reach or a roster entry that names one this process does not serve. +func validateServeTransport(opts GitAgentServeOptions, role gitagent.ReceiverRole) (mailboxTransport, error) { + transport, err := parseMailboxTransport(opts.Transport) + if err != nil { + return "", err + } + if transport == "" { + transport = transportSSH + } + if transport == transportSSH { + return transport, nil + } + // A mailbox over https is `captain serve`'s handler, which also needs the + // token store and the web UI. Keeping this command to one role means the + // https path has exactly one shape. + if role != gitagent.RoleSidecar { + return "", fmt.Errorf( + "a mailbox over https is hosted by `captain serve --tls`, not by this command; run " + + "`captain serve --host 0.0.0.0 --tls --tls-host
`") + } + // The supervisor derives an ssh:// URL from the connection when an agent + // advertises nothing (pkg/gitagent/server.go), so an https sidecar that said + // nothing would be recorded at an endpoint it does not serve. + advertise, err := advertiseURL(opts.Advertise) + if err != nil { + return "", err + } + if advertise == "" { + return "", fmt.Errorf( + "--transport https needs --advertise https:///git/%s; this agent's endpoint cannot be "+ + "inferred from the connection, and the supervisor would record an ssh:// URL nothing serves", + SidecarRepoName) + } + if scheme := gitagent.EndpointScheme(advertise); scheme != "https" { + return "", fmt.Errorf( + "--transport https serves an https endpoint but --advertise %s is %s://; the supervisor would "+ + "dispatch to a protocol this agent does not speak", opts.Advertise, scheme) + } + return transport, nil +} + // enrollmentOffer is what this endpoint hands a joining agent. Only a mailbox // has a dispatch key for the agent to authorize; task-specific mailbox routes // arrive later in authenticated dispatch envelopes. @@ -114,20 +206,46 @@ func enrollmentOffer(role gitagent.ReceiverRole, keysDir string) (gitagent.Enrol return gitagent.EnrollmentOffer{DispatchKey: dispatchFP}, nil } +// enrolledAgentName reports the name this host was given by an earlier +// enrollment, empty when it has none. +// +// Re-presenting it is what lets a pool member reclaim its slot across a restart +// instead of consuming another and eventually exhausting the pool. A read +// failure reports "no name", which costs a slot but never claims one that +// belongs to a sibling. +func enrolledAgentName(backendName string) string { + cfg, _, err := captainconfig.Load() + if err != nil { + return "" + } + backend, ok := cfg.Sandbox.Backends[backendName] + if !ok { + return "" + } + supervisor, _ := backend.Options["supervisor"].(map[string]any) + name, _ := supervisor["agent"].(string) + return strings.TrimSpace(name) +} + // joinSupervisor performs the enrollment exchange and records both directions // of trust: the supervisor's dispatch key is authorized locally, and its base // endpoint is retained for task-specific result relays. -func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir string) error { +func joinSupervisor( + ctx context.Context, opts GitAgentServeOptions, transport mailboxTransport, + token text.SensitiveString, keysDir string, +) error { if opts.Supervisor == "" { - return fmt.Errorf("--join requires --supervisor ssh://host:port") + return fmt.Errorf("a captain token requires --supervisor ssh://host:port or https://host:port") } signer, fp, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, agentKeyName)) if err != nil { return err } - // The supervisor must be able to verify this endpoint's host key when it - // dispatches, so the key has to exist before we advertise its fingerprint. - _, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + // Whichever credential this endpoint will be reached by has to exist before + // it is advertised, and it must be committed to disk before it is named: a + // supervisor holding a credential this host has not persisted would fail at + // the first dispatch instead of here. + hostFP, dispatchToken, err := issueReceiveCredential(transport, keysDir) if err != nil { return err } @@ -135,70 +253,154 @@ func joinSupervisor(ctx context.Context, opts GitAgentServeOptions, keysDir stri if err != nil { return fmt.Errorf("--listen %q must be [host]:port: %w", opts.Listen, err) } - // Verify the local half can be persisted before consuming the supervisor's - // single-use token. The exchange cannot be transactional across hosts, but - // this catches path, permission, and backend-kind failures up front. + // Verify the local half can be persisted before enrolling. The exchange + // cannot be transactional across hosts, so this catches path, permission + // and backend-kind failures up front rather than after the supervisor has + // already recorded this agent. if err := captainconfig.Update(func(cfg *captainconfig.Config) error { _, err := ensureGitAgentBackend(cfg, opts.Backend) return err }); err != nil { return fmt.Errorf("prepare local enrollment config: %w", err) } - resp, err := gitagent.Enroll(ctx, opts.Supervisor, opts.Join, opts.HostFingerprint, signer, gitagent.EnrollRequest{ - AdvertiseURL: advertiseURL(opts.Advertise), + advertise, err := advertiseURL(opts.Advertise) + if err != nil { + return err + } + resp, err := gitagent.Enroll(ctx, opts.Supervisor, token.Value(), opts.HostFingerprint, signer, gitagent.EnrollRequest{ + Agent: enrolledAgentName(opts.Backend), + AdvertiseURL: advertise, ListenPort: port, HostFingerprint: hostFP, + DispatchToken: dispatchToken.Value(), }) if err != nil { return err } + // Kept for the relay, which presents it to the mailbox on every push. The + // enrollment token and the relay credential are the same durable token — + // that is what makes a restart free. + tokenPath := filepath.Join(keysDir, gitagent.TokenFileName) + if err := gitagent.WriteTokenFile(tokenPath, token); err != nil { + return err + } + // The supervisor's certificate arrives over the exchange this agent already + // pinned, which is the only channel where receiving it proves anything. + caPath := filepath.Join(keysDir, supervisorCAName) + if resp.CACertificate != "" { + if err := os.WriteFile(caPath, []byte(resp.CACertificate), 0o644); err != nil { //nolint:gosec // a certificate is public + return fmt.Errorf("store the supervisor's certificate: %w", err) + } + } else { + caPath = "" + } err = captainconfig.Update(func(cfg *captainconfig.Config) error { backend, err := ensureGitAgentBackend(cfg, opts.Backend) if err != nil { return err } // The task supplies its repository-specific mailbox route; enrollment - // records only the stable supervisor endpoint and host identity. + // records only the stable supervisor endpoint and host identity, plus + // the name this agent was given so a restart reclaims it. backend.Options["supervisor"] = map[string]any{ "url": strings.TrimSuffix(opts.Supervisor, "/"), "hostFingerprint": strings.TrimSpace(opts.HostFingerprint), + "agent": resp.Agent, + "tokenPath": tokenPath, + "caPath": caPath, + "pinnedPubkey": resp.PinnedPublicKey, } // Authorize the supervisor's dispatch key so its push is accepted // here — the direction a one-way enrollment leaves broken. - agents, _ := backend.Options["agents"].(map[string]any) - if agents == nil { - agents = map[string]any{} + // + // Only over ssh. An https endpoint authenticates the supervisor by the + // token it just issued, so recording a key here would authorize a + // listener that does not exist: a dead credential rather than a spare. + if transport == transportSSH { + agents, _ := backend.Options["agents"].(map[string]any) + if agents == nil { + agents = map[string]any{} + } + agents[supervisorAgentID] = map[string]any{"fingerprint": resp.DispatchKey} + backend.Options["agents"] = agents } - agents[supervisorAgentID] = map[string]any{"fingerprint": resp.DispatchKey} - backend.Options["agents"] = agents cfg.Sandbox.Backends[opts.Backend] = backend return nil }) if err != nil { - return fmt.Errorf("supervisor enrolled agent %q, but the local relay config could not be saved; mint a new join token and retry after fixing the config: %w", resp.Agent, err) + return fmt.Errorf("supervisor enrolled agent %q, but the local relay config could not be saved; fix the config and rerun — the token is durable, so the same one still works: %w", resp.Agent, err) } clicky.Printf("enrolled as %s\n", resp.Agent) clicky.Printf(" this agent's key: %s\n", fp) - clicky.Printf(" this endpoint's host key: %s\n", hostFP) clicky.Printf(" relays to: %s/\n", strings.TrimSuffix(opts.Supervisor, "/")) - clicky.Printf(" authorized supervisor key: %s\n", resp.DispatchKey) + if transport == transportSSH { + clicky.Printf(" this endpoint's host key: %s\n", hostFP) + clicky.Printf(" authorized supervisor key: %s\n", resp.DispatchKey) + return nil + } + // The id, never the secret: it is enough to correlate a refused push with + // the credential in play, and it is not a credential itself. + if presented, err := captaintoken.Parse(dispatchToken.Value()); err == nil { + clicky.Printf(" issued the supervisor a dispatch token: %s\n", presented.ID) + } return nil } -// advertiseURL normalizes an operator-supplied endpoint, appending the sidecar -// repository path when only a host:port was given. -func advertiseURL(raw string) string { +// issueReceiveCredential creates whatever the supervisor will authenticate to +// this endpoint with, and returns it for the enrollment request. +// +// Exactly one of the two is ever produced, because exactly one listener is ever +// served. Over https the verifier is on disk before this returns, so the secret +// is only ever named after this host can already honour it. +func issueReceiveCredential(transport mailboxTransport, keysDir string) (string, text.SensitiveString, error) { + if transport == transportSSH { + _, hostFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, hostKeyName)) + return hostFP, "", err + } + token, err := gitagent.MintDispatchCredential(filepath.Join(keysDir, gitagent.DispatchCredentialName)) + return "", token, err +} + +// advertiseURL normalizes an endpoint into the full push URL the supervisor +// dispatches to, appending the sidecar repository when only an origin was given. +// +// The scheme decides where the repository goes: ssh serves it directly under the +// served root, https serves it under GitHTTPPrefix. It is parsed rather than +// matched on string prefixes because the earlier form tested the remainder after +// trimming "ssh://", which for an https URL is a no-op that leaves the scheme's +// own slashes in place — so no repository was ever appended to an https origin, +// and splitGitPath refused every push to it with a 403. +func advertiseURL(raw string) (string, error) { advertise := strings.TrimSpace(raw) if advertise == "" { - return "" + return "", nil } if !strings.Contains(advertise, "://") { - advertise = "ssh://" + advertise + advertise = "ssh://" + advertise // the form written before HTTPS existed + } + parsed, err := url.Parse(advertise) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("--advertise %q must be ssh://[user@]host[:port] or https://host[:port]", raw) } - if trimmed := strings.TrimSuffix(advertise, "/"); !strings.Contains(strings.TrimPrefix(trimmed, "ssh://"), "/") { - advertise = trimmed + "/" + SidecarRepoName + // Before the path, so an unsupported scheme is refused even when it already + // names a repository and would otherwise be taken as a full URL. + if parsed.Scheme != "ssh" && parsed.Scheme != "https" { + return "", fmt.Errorf("--advertise %q uses scheme %q; captain speaks ssh:// and https://", raw, parsed.Scheme) } - return advertise + // Rebuilt from the parsed parts rather than trimmed as a string: url.Parse + // splits userinfo off the host, and string surgery on the raw value mangles + // inputs like "ssh://" into a host named "ssh:". + origin := parsed.Scheme + "://" + parsed.Host + if parsed.User != nil { + origin = parsed.Scheme + "://" + parsed.User.String() + "@" + parsed.Host + } + if repo := strings.Trim(parsed.Path, "/"); repo != "" { + return origin + "/" + repo, nil // already a full URL, taken as given + } + if parsed.Scheme == "https" { + return gitagent.HTTPSRepoURL(origin, SidecarRepoName) + } + return origin + "/" + SidecarRepoName, nil } // ensureServedRepos creates the role's repository and (re-)installs the hook @@ -214,9 +416,6 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR if err := os.MkdirAll(filepath.Join(root, gitagent.MailboxesDir), 0o755); err != nil { return err } - if err := recordMailboxRoot(opts.Backend, root); err != nil { - return err - } } exe, err := os.Executable() if err != nil { @@ -253,16 +452,44 @@ func ensureServedRepos(ctx context.Context, root string, role gitagent.ReceiverR return nil } -// recordMailboxRoot lets dispatch processes create repository-specific -// mailboxes under the same root served by this long-running endpoint. -func recordMailboxRoot(backendName, root string) error { +// recordServedEndpoint publishes what this long-running SSH endpoint serves, so +// other captain processes on the host can find it without being told. +// +// For a mailbox it records the root (dispatch creates repository-specific +// mailboxes under it) plus the listen address and host-key fingerprint, which +// are what `git-agent deploy` needs to prove the mailbox it is about to enroll +// an agent against is live and is this host's. Nothing else writes those: the +// backend's `url` option is read in two places and produced by hand. +// +// A sidecar records nothing, but clears an ssh mailbox record that names the +// same listen address — see clearMailboxRecord for why. +func recordServedEndpoint( + opts GitAgentServeOptions, role gitagent.ReceiverRole, transport mailboxTransport, + root, hostFingerprint string, +) error { return captainconfig.Update(func(cfg *captainconfig.Config) error { - backend, err := ensureGitAgentBackend(cfg, backendName) + backend, err := ensureGitAgentBackend(cfg, opts.Backend) if err != nil { return err } - backend.Options["mailboxRoot"] = root - cfg.Sandbox.Backends[backendName] = backend + if role == gitagent.RoleMailbox { + // mailboxRoot stays a top-level key: gitagent.ServedRootFor reads it + // directly, and moving it would break dispatch mid-upgrade. + backend.Options["mailboxRoot"] = root + setMailboxRecord(backend.Options, mailboxRecord{ + Transport: transportSSH, + Root: root, + Listen: opts.Listen, + Identity: hostFingerprint, + Encrypted: true, + }) + } else { + // A sidecar now holds this address, so any mailbox record claiming it + // over the protocol the sidecar serves is stale — and over ssh no probe + // could tell the two apart, because both present the same host key. + clearMailboxRecord(backend.Options, transport, opts.Listen) + } + cfg.Sandbox.Backends[opts.Backend] = backend return nil }) } diff --git a/pkg/cli/gitagent_serve_https.go b/pkg/cli/gitagent_serve_https.go new file mode 100644 index 00000000..680fbff7 --- /dev/null +++ b/pkg/cli/gitagent_serve_https.go @@ -0,0 +1,236 @@ +// The sidecar's HTTPS receive endpoint. +// +// It is the same smart-HTTP handler `captain serve` mounts for the mailbox, with +// the two role-shaped differences the transport already models: Role tells the +// hook shims which admission tier they are, and a nil Enroll serves no +// enrollment endpoint — a sidecar receives pushes and enrolls nobody. +// +// What is genuinely new is the identity resolver. The mailbox authenticates +// agents against the token store in its database; a sidecar has no database by +// design, and authenticates exactly one peer against the credential it minted +// for that peer at enrollment. +package cli + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net/http" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky" +) + +// sidecarHTTPSPlan is what the listener needs, resolved by RunGitAgentServe. +type sidecarHTTPSPlan struct { + listen string + root string + keysDir string + advertise string + certPath string + keyPath string +} + +// serveSidecarHTTPS runs the receive endpoint until the context is cancelled. +func serveSidecarHTTPS(ctx context.Context, plan sidecarHTTPSPlan) error { + host, err := advertiseHostname(plan.advertise) + if err != nil { + return err + } + credential, tlsConfig, err := sidecarTLSConfig(plan, host) + if err != nil { + return err + } + dispatch, err := gitagent.LoadDispatchCredential(filepath.Join(plan.keysDir, gitagent.DispatchCredentialName)) + if err != nil { + return fmt.Errorf("%w\nthis endpoint authenticates its supervisor with a token minted at enrollment; "+ + "rerun with --token-file to enroll", err) + } + identify := sidecarIdentity(dispatch) + handler, err := gitagent.NewHTTPHandler(gitagent.HTTPServerConfig{ + Root: plan.root, + Role: gitagent.RoleSidecar, + Identify: identify, + // A sidecar enrolls nobody, so the endpoint is not served at all rather + // than served and refusing. + Enroll: nil, + Log: log.Warnf, + }) + if err != nil { + return err + } + mux := http.NewServeMux() + mux.Handle(gitagent.GitHTTPPrefix, handler) + mux.Handle("POST "+gitagent.AgentWhoamiPath, agentWhoamiHandler(identify, RunWhoami)) + + server := &http.Server{ + Addr: plan.listen, + Handler: mux, + TLSConfig: tlsConfig, + // ReadHeaderTimeout only, as `captain serve` does: a push runs the + // receive hooks inline and a prompt hook can take minutes, so a + // whole-request deadline would kill the work it is waiting for. + ReadHeaderTimeout: 30 * time.Second, + } + clicky.Printf("captain git-agent sidecar serving %s on https://%s\n", plan.root, plan.listen) + clicky.Printf(" certificate: %s (pin %s)\n", credential.CertPath, credential.PublicKeyPin) + clicky.Printf(" dispatched to at: %s\n", plan.advertise) + clicky.Printf(" runtime identity: %s\n", gitagent.AgentWhoamiPath) + + go func() { + <-ctx.Done() + _ = server.Close() + }() + if err := server.ListenAndServeTLS("", ""); err != nil && ctx.Err() == nil { + return err + } + return nil +} + +func sidecarTLSConfig(plan sidecarHTTPSPlan, host string) (*gitagent.TLSCredential, *tls.Config, error) { + certPath, keyPath := strings.TrimSpace(plan.certPath), strings.TrimSpace(plan.keyPath) + if (certPath == "") != (keyPath == "") { + return nil, nil, fmt.Errorf("--tls-cert and --tls-key must be given together") + } + if certPath == "" { + credential, err := gitagent.EnsureTLSCredential(plan.keysDir, []string{host}) + if err != nil { + return nil, nil, err + } + return credential, serveTLSConfig(credential), nil + } + load := func() (*gitagent.TLSCredential, error) { + credential, err := gitagent.LoadTLSCredential(certPath, keyPath) + if err != nil { + return nil, err + } + if err := credential.Covers([]string{host}); err != nil { + return nil, err + } + return credential, nil + } + credential, err := load() + if err != nil { + return nil, nil, err + } + return credential, &tls.Config{ + MinVersion: tls.VersionTLS12, + GetCertificate: func(*tls.ClientHelloInfo) (*tls.Certificate, error) { + current, err := load() + if err != nil { + return nil, err + } + return ¤t.Certificate, nil + }, + }, nil +} + +type whoamiRunner func(WhoamiOptions) (any, error) + +func agentWhoamiHandler(identify func(*http.Request) (string, error), run whoamiRunner) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if agent, err := identify(r); err != nil || agent == "" { + http.Error(w, "captain: this request carries no agent identity", http.StatusForbidden) + return + } + options, err := agentWhoamiOptions(r) + if err != nil { + http.Error(w, "captain: "+err.Error(), http.StatusBadRequest) + return + } + result, err := run(options) + if err != nil { + http.Error(w, "captain: inspect agent runtimes: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(result); err != nil { + log.Warnf("git-agent whoami: write response: %v", err) + } + }) +} + +func agentWhoamiOptions(r *http.Request) (WhoamiOptions, error) { + query := r.URL.Query() + options := WhoamiOptions{Backend: strings.TrimSpace(query.Get("backend")), Models: true} + var err error + if value := query.Get("models"); value != "" { + options.Models, err = strconv.ParseBool(value) + if err != nil { + return WhoamiOptions{}, fmt.Errorf("models must be true or false") + } + } + if value := query.Get("limit"); value != "" { + options.Limit, err = strconv.Atoi(value) + if err != nil || options.Limit < 0 { + return WhoamiOptions{}, fmt.Errorf("limit must be a non-negative integer") + } + } + if options.IncludeDisabled, err = queryBool(query.Get("disabled"), "disabled"); err != nil { + return WhoamiOptions{}, err + } + if options.NoCache, err = queryBool(query.Get("no-cache"), "no-cache"); err != nil { + return WhoamiOptions{}, err + } + return options, nil +} + +func queryBool(value, name string) (bool, error) { + if value == "" { + return false, nil + } + parsed, err := strconv.ParseBool(value) + if err != nil { + return false, fmt.Errorf("%s must be true or false", name) + } + return parsed, nil +} + +// advertiseHostname is the name the supervisor dials, and so the only name this +// endpoint's certificate has to cover. +func advertiseHostname(advertise string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(advertise)) + if err != nil || parsed.Hostname() == "" { + return "", fmt.Errorf("--advertise %q must be https://host[:port]/path to serve over https", advertise) + } + return parsed.Hostname(), nil +} + +// sidecarIdentity resolves the supervisor's bearer token to the single identity +// a sidecar accepts. +// +// It is the HTTPS counterpart of authorizing the supervisor's dispatch key, and +// deliberately resolves to the same name: over ssh the supervisor is admitted as +// supervisorAgentID, so returning anything else here would give one peer two ref +// namespaces (R8.3) and make the hook shims see a different agent depending only +// on which wire the push arrived over. +// +// The credential is read once and closed over rather than re-read per request. +// Enrollment is the only thing that rotates it and runs before this listener +// exists, so nothing can go stale inside one process lifetime. +func sidecarIdentity(credential *gitagent.DispatchCredential) func(*http.Request) (string, error) { + verifier := credential.Verifier(supervisorAgentID) + return func(r *http.Request) (string, error) { + presented, ok := captaintoken.BearerFromHeader(r.Header.Get("Authorization")) + if !ok { + // Logged here because the transport collapses every identity failure + // into one generic 403 with no detail, which is right for the client + // and useless for the operator. + log.Warnf("git-agent sidecar: a push presented no bearer token") + return "", fmt.Errorf("this endpoint authenticates its supervisor with the bearer token issued at enrollment") + } + record, err := verifier.VerifyScope(r.Context(), presented, captaintoken.ScopeGit) + if err != nil { + log.Warnf("git-agent sidecar: refused a dispatch push: %v", err) + return "", err + } + return record.Agent, nil + } +} diff --git a/pkg/cli/gitagent_serve_options.go b/pkg/cli/gitagent_serve_options.go new file mode 100644 index 00000000..10f7297c --- /dev/null +++ b/pkg/cli/gitagent_serve_options.go @@ -0,0 +1,87 @@ +package cli + +import ( + "crypto/subtle" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +type GitAgentServeOptions struct { + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Listen string `flag:"listen" help:"Address to serve git-receive-pack on" default:":7422"` + Root string `flag:"root" help:"Directory of receivable repos (default /repos)"` + Role string `flag:"role" help:"Receiver role: sidecar (runs beside a coding agent) or mailbox (the supervisor's receiver)" default:"sidecar"` + Transport string `flag:"transport" help:"sidecar role: protocol for the receive endpoint. ssh authenticates the supervisor by key; https terminates TLS here and authenticates it by a bearer token this agent issues, and needs --advertise https://host/git/repo.git" default:"ssh"` + Advertise string `flag:"advertise" help:"sidecar role: ssh:// or https:// endpoint the supervisor should dispatch to (default: the address the supervisor sees)"` + Token text.SensitiveString `flag:"token" help:"Captain token printed by 'captain sandbox git-agent add'"` + TokenFile string `flag:"token-file" help:"File holding the captain token. Use this rather than --token for a workload, where argv is world-readable"` + Supervisor string `flag:"supervisor" help:"ssh:// or https:// endpoint of the supervisor to enroll with"` + HostFingerprint string `flag:"host-fingerprint" help:"Supervisor identity to pin, printed by 'git-agent add': its SSH host key for ssh://, its TLS public-key pin for https://"` + TLSCert string `flag:"tls-cert" help:"HTTPS sidecar certificate file; requires --tls-key"` + TLSKey string `flag:"tls-key" help:"HTTPS sidecar private-key file; requires --tls-cert"` +} + +func (o GitAgentServeOptions) enrollmentToken() (text.SensitiveString, error) { + inline := strings.TrimSpace(o.Token.Value()) + path := strings.TrimSpace(o.TokenFile) + switch { + case inline != "" && path != "": + return "", fmt.Errorf("--token and --token-file are mutually exclusive") + case inline != "": + return text.NewSensitiveString(inline), nil + case path == "": + return "", nil + } + token, err := gitagent.ReadTokenFile(path) + if err != nil { + return "", fmt.Errorf("--token-file: %w", err) + } + return token, nil +} + +func reusableEnrollment( + opts GitAgentServeOptions, transport mailboxTransport, token text.SensitiveString, keysDir string, +) (string, error) { + cfg, exists, err := captainconfig.Load() + if err != nil { + return "", fmt.Errorf("load persisted enrollment: %w", err) + } + backend, ok := cfg.Sandbox.Backends[opts.Backend] + if !exists || !ok { + return "", nil + } + supervisor, ok := backend.Options["supervisor"].(map[string]any) + agent, _ := supervisor["agent"].(string) + url, _ := supervisor["url"].(string) + fingerprint, _ := supervisor["hostFingerprint"].(string) + if !ok || strings.TrimSpace(agent) == "" || strings.TrimSuffix(url, "/") != strings.TrimSuffix(opts.Supervisor, "/") || + strings.TrimSpace(fingerprint) != strings.TrimSpace(opts.HostFingerprint) { + return "", nil + } + tokenPath, _ := supervisor["tokenPath"].(string) + storedToken, err := gitagent.ReadTokenFile(tokenPath) + if err != nil || subtle.ConstantTimeCompare([]byte(storedToken.Value()), []byte(token.Value())) != 1 { + return "", nil + } + if transport == transportHTTPS { + if _, err := gitagent.LoadDispatchCredential(filepath.Join(keysDir, gitagent.DispatchCredentialName)); err != nil { + return "", nil + } + } else { + agents, _ := backend.Options["agents"].(map[string]any) + dispatch, _ := agents[supervisorAgentID].(map[string]any) + if fingerprint, _ := dispatch["fingerprint"].(string); strings.TrimSpace(fingerprint) == "" { + return "", nil + } + if _, err := os.Stat(filepath.Join(keysDir, agentKeyName)); err != nil { + return "", nil + } + } + return strings.TrimSpace(agent), nil +} diff --git a/pkg/cli/gitagent_serve_test.go b/pkg/cli/gitagent_serve_test.go new file mode 100644 index 00000000..f0b155bb --- /dev/null +++ b/pkg/cli/gitagent_serve_test.go @@ -0,0 +1,375 @@ +package cli + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +// servedBackend reads back what recordServedEndpoint wrote. +func servedBackend(t *testing.T, name string) map[string]any { + t.Helper() + cfg, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + return cfg.Sandbox.Backends[name].Options +} + +func TestEnrollmentTokenSources(t *testing.T) { + tokenFile := filepath.Join(t.TempDir(), "token") + if err := os.WriteFile(tokenFile, []byte(" file-token\n"), 0o600); err != nil { + t.Fatal(err) + } + empty := filepath.Join(t.TempDir(), "empty") + if err := os.WriteFile(empty, nil, 0o600); err != nil { + t.Fatal(err) + } + + tests := []struct { + name string + opts GitAgentServeOptions + want string + wantErr string + }{ + {name: "neither source", opts: GitAgentServeOptions{}}, + {name: "inline", opts: GitAgentServeOptions{Token: text.NewSensitiveString("inline-token")}, want: "inline-token"}, + {name: "file is trimmed", opts: GitAgentServeOptions{TokenFile: tokenFile}, want: "file-token"}, + { + name: "both refused rather than ranked", + opts: GitAgentServeOptions{Token: text.NewSensitiveString("inline-token"), TokenFile: tokenFile}, + wantErr: "mutually exclusive", + }, + {name: "empty file", opts: GitAgentServeOptions{TokenFile: empty}, wantErr: "is empty"}, + {name: "missing file", opts: GitAgentServeOptions{TokenFile: filepath.Join(t.TempDir(), "absent")}, wantErr: "--token-file"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := tt.opts.enrollmentToken() + if tt.wantErr != "" { + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("err = %v, want containing %q", err, tt.wantErr) + } + return + } + if err != nil { + t.Fatal(err) + } + if got.Value() != tt.want { + t.Fatalf("token = %q, want %q", got.Value(), tt.want) + } + }) + } +} + +// A pool member persists the name it was given so a restart reclaims its slot +// instead of consuming another and eventually exhausting the pool. +func TestEnrolledAgentNameIsRepresentedOnRestart(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + if name := enrolledAgentName(backend); name != "" { + t.Fatalf("a host that never joined reports the name %q", name) + } + + err := captainconfig.Update(func(cfg *captainconfig.Config) error { + b, err := ensureGitAgentBackend(cfg, backend) + if err != nil { + return err + } + b.Options["supervisor"] = map[string]any{ + "url": "ssh://supervisor:7422", "agent": "prod-pool-02", + } + cfg.Sandbox.Backends[backend] = b + return nil + }) + if err != nil { + t.Fatal(err) + } + + if name := enrolledAgentName(backend); name != "prod-pool-02" { + t.Fatalf("enrolled agent name = %q, want prod-pool-02", name) + } + // An unknown backend has no name rather than a stale one from another. + if name := enrolledAgentName("other-backend"); name != "" { + t.Fatalf("unrelated backend reported %q", name) + } +} + +func TestRecordServedEndpointPublishesMailboxIdentity(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + opts := GitAgentServeOptions{Backend: backend, Listen: ":7422"} + + if err := recordServedEndpoint(opts, gitagent.RoleMailbox, transportSSH, "/srv/repos", "SHA256:abc"); err != nil { + t.Fatal(err) + } + options := servedBackend(t, backend) + + // mailboxRoot stays top-level: gitagent.ServedRootFor reads it directly. + if root, _ := options["mailboxRoot"].(string); root != "/srv/repos" { + t.Fatalf("mailboxRoot = %q", root) + } + want := mailboxRecord{ + Transport: transportSSH, Root: "/srv/repos", Listen: ":7422", Identity: "SHA256:abc", Encrypted: true, + } + if got := mailboxRecords(options)[transportSSH]; got != want { + t.Fatalf("ssh record = %+v, want %+v", got, want) + } +} + +// `captain serve` runs constantly for the web UI. Keying the record by transport +// is what stops it erasing a working ssh mailbox on every start. +func TestServedMailboxRecordsCoexistPerTransport(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + if err := recordServedEndpoint( + GitAgentServeOptions{Backend: backend, Listen: ":7422"}, + gitagent.RoleMailbox, transportSSH, "/srv/repos", "SHA256:abc"); err != nil { + t.Fatal(err) + } + if err := recordServedGitMailbox("0.0.0.0:9020", "/srv/repos", nil); err != nil { + t.Fatal(err) + } + + records := mailboxRecords(servedBackend(t, backend)) + if len(records) != 2 { + t.Fatalf("records = %+v, want both transports", records) + } + if got := records[transportSSH].Listen; got != ":7422" { + t.Fatalf("ssh listen = %q; captain serve displaced the ssh mailbox", got) + } + // Recorded without a certificate, so detection can say "restart it with + // --tls" rather than "nothing has ever served here". + if https := records[transportHTTPS]; https.Encrypted || https.Listen != "0.0.0.0:9020" { + t.Fatalf("https record = %+v, want an unencrypted record of the real address", https) + } +} + +// Both roles present the same host key, so a probe cannot tell them apart. A +// stale mailbox record claiming an address that now serves a sidecar would send +// deploy's enrollment at the wrong endpoint. +func TestSidecarClearsStaleMailboxRecordForItsOwnAddress(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + mailbox := GitAgentServeOptions{Backend: backend, Listen: ":7422"} + if err := recordServedEndpoint(mailbox, gitagent.RoleMailbox, transportSSH, "/srv/repos", "SHA256:abc"); err != nil { + t.Fatal(err) + } + + elsewhere := GitAgentServeOptions{Backend: backend, Listen: ":7500"} + if err := recordServedEndpoint(elsewhere, gitagent.RoleSidecar, transportSSH, "/srv/other", "SHA256:abc"); err != nil { + t.Fatal(err) + } + if _, ok := mailboxRecords(servedBackend(t, backend))[transportSSH]; !ok { + t.Fatal("a sidecar on a different port cleared the mailbox record") + } + + sameAddress := GitAgentServeOptions{Backend: backend, Listen: ":7422"} + if err := recordServedEndpoint(sameAddress, gitagent.RoleSidecar, transportSSH, "/srv/other", "SHA256:abc"); err != nil { + t.Fatal(err) + } + if _, ok := mailboxRecords(servedBackend(t, backend))[transportSSH]; ok { + t.Fatal("stale mailbox record survived a sidecar taking over its address") + } +} + +// An https sidecar and an https mailbox cannot both hold one address, and the +// record is what deploy enrolls against — so the sidecar has to clear the record +// for the protocol it now serves, not for the other one. +func TestHTTPSSidecarClearsTheHTTPSRecordForItsAddress(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + if err := recordServedGitMailbox("0.0.0.0:9020", "/srv/repos", nil); err != nil { + t.Fatal(err) + } + ssh := GitAgentServeOptions{Backend: backend, Listen: ":7422"} + if err := recordServedEndpoint(ssh, gitagent.RoleMailbox, transportSSH, "/srv/repos", "SHA256:abc"); err != nil { + t.Fatal(err) + } + + sidecar := GitAgentServeOptions{Backend: backend, Listen: "0.0.0.0:9020"} + if err := recordServedEndpoint(sidecar, gitagent.RoleSidecar, transportHTTPS, "/srv/other", ""); err != nil { + t.Fatal(err) + } + records := mailboxRecords(servedBackend(t, backend)) + if _, ok := records[transportHTTPS]; ok { + t.Fatal("the https mailbox record survived a sidecar taking over its address") + } + // The ssh mailbox is a different listener on a different port. + if _, ok := records[transportSSH]; !ok { + t.Fatal("an https sidecar cleared an unrelated ssh mailbox record") + } +} + +// Every combination here produces either a listener the supervisor cannot reach +// or a roster entry naming one this process does not serve. +func TestValidateServeTransport(t *testing.T) { + const advertise = "https://w1.example.com/git/" + SidecarRepoName + + t.Run("defaults to ssh", func(t *testing.T) { + got, err := validateServeTransport(GitAgentServeOptions{}, gitagent.RoleSidecar) + if err != nil || got != transportSSH { + t.Fatalf("transport = %q, err = %v", got, err) + } + }) + + t.Run("https needs an advertised endpoint", func(t *testing.T) { + got, err := validateServeTransport( + GitAgentServeOptions{Transport: "https", Advertise: advertise}, gitagent.RoleSidecar) + if err != nil || got != transportHTTPS { + t.Fatalf("transport = %q, err = %v", got, err) + } + }) + + for _, tc := range []struct { + name string + opts GitAgentServeOptions + role gitagent.ReceiverRole + want string + }{{ + name: "an unknown transport names both", + opts: GitAgentServeOptions{Transport: "quic"}, role: gitagent.RoleSidecar, + want: "captain speaks ssh and https", + }, { + // `captain serve --tls` hosts the https mailbox; it also needs the token + // store and the web UI, neither of which this command has. + name: "a mailbox over https points at captain serve", + opts: GitAgentServeOptions{Transport: "https", Advertise: advertise}, role: gitagent.RoleMailbox, + want: "captain serve --tls", + }, { + // The supervisor would otherwise synthesize an ssh:// URL from the + // connection and record an endpoint nothing serves. + name: "https without an advertise is refused", + opts: GitAgentServeOptions{Transport: "https"}, role: gitagent.RoleSidecar, + want: "--transport https needs --advertise", + }, { + name: "a transport and advertise that disagree are refused", + opts: GitAgentServeOptions{Transport: "https", Advertise: "ssh://h:7422/repo.git"}, + role: gitagent.RoleSidecar, want: "does not speak", + }} { + t.Run(tc.name, func(t *testing.T) { + _, err := validateServeTransport(tc.opts, tc.role) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to name %q", err, tc.want) + } + }) + } +} + +// The certificate persists on the state volume and Covers() is a hard error, so +// a name that changes on reschedule would make the SECOND scheduling of a pod +// fail at startup with no way in to delete the file. +func TestSidecarCertificateCoversOnlyTheAdvertisedName(t *testing.T) { + host, err := advertiseHostname("https://w1.example.com/git/" + SidecarRepoName) + if err != nil { + t.Fatal(err) + } + if host != "w1.example.com" { + t.Fatalf("hostname = %q", host) + } + + credential, err := gitagent.EnsureTLSCredential(t.TempDir(), []string{host}) + if err != nil { + t.Fatal(err) + } + if err := credential.Covers([]string{host}); err != nil { + t.Fatalf("the certificate does not cover the name it was issued for: %v", err) + } + // A pod IP is exactly the kind of name that must NOT be a requirement, or a + // reschedule onto a new address would refuse to start. + if err := credential.Covers([]string{"10.1.2.3"}); err == nil { + t.Fatal("the certificate claims to cover a pod IP, which changes on every reschedule") + } + + if _, err := advertiseHostname("not a url"); err == nil { + t.Fatal("a malformed advertise was accepted as a certificate name") + } +} + +// deploy needs the token in-process; the HTTP surface must not gain it. +func TestGitAgentAddExposesTokenToCallersButNotToJSON(t *testing.T) { + isolatedConfig(t) + gitAgentTokenDB(t) + + res, err := RunGitAgentAdd(t.Context(), GitAgentAddOptions{Name: "worker-1", Backend: "git-agent"}) + if err != nil { + t.Fatal(err) + } + add, ok := res.(GitAgentAddResult) + if !ok { + t.Fatalf("result = %T", res) + } + + token := add.Token.Value() + if token == "" { + t.Fatal("no token returned; deploy would have to re-parse JoinCommand") + } + if !strings.Contains(add.JoinCommand, "--token "+token) { + t.Fatalf("Token %q is not the one in JoinCommand %q", token, add.JoinCommand) + } + + encoded, err := json.Marshal(add) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(encoded), `"token"`) { + t.Fatalf("token field crossed the JSON boundary: %s", encoded) + } +} + +// The supervisor records this string and pushes to it verbatim, so a repository +// path that is dropped here is a 403 on every dispatch and nothing earlier. +func TestAdvertiseURL(t *testing.T) { + for given, want := range map[string]string{ + "": "", + "host:7422": "ssh://host:7422/" + SidecarRepoName, + "captain@1.2.3.4:9": "ssh://captain@1.2.3.4:9/" + SidecarRepoName, + "ssh://h:1/other.git": "ssh://h:1/other.git", + "ssh://h:1/": "ssh://h:1/" + SidecarRepoName, + "https://a.example.com": "https://a.example.com/git/" + SidecarRepoName, + "https://a.example.com/": "https://a.example.com/git/" + SidecarRepoName, + "https://a.example.com:8": "https://a.example.com:8/git/" + SidecarRepoName, + "https://a.example.com/git/" + SidecarRepoName: "https://a.example.com/git/" + SidecarRepoName, + } { + got, err := advertiseURL(given) + if err != nil { + t.Errorf("advertiseURL(%q) errored: %v", given, err) + continue + } + if got != want { + t.Errorf("advertiseURL(%q) = %q, want %q", given, got, want) + } + } + + // awaitEnrollment compares the recorded URL to plan.Advertise byte for byte, + // and the sidecar re-normalizes what deploy passed it through this same + // function. If that is not a fixed point, every https deploy fails with + // "enrolled advertising X, but the deployment expects Y". + for _, settled := range []string{ + "ssh://captain@127.0.0.1:7423/" + SidecarRepoName, + "https://worker-01.agents.example.com/git/" + SidecarRepoName, + } { + got, err := advertiseURL(settled) + if err != nil || got != settled { + t.Errorf("advertiseURL(%q) = %q, %v; want it unchanged", settled, got, err) + } + } + + // http:// would put a bearer token on the wire in clear text, and a scheme + // captain does not speak has to be refused rather than pushed to. + for _, refused := range []string{"http://h", "git://h/repo.git", "ssh://"} { + if _, err := advertiseURL(refused); err == nil { + t.Errorf("advertiseURL(%q) was accepted", refused) + } + } +} diff --git a/pkg/cli/gitagent_test.go b/pkg/cli/gitagent_test.go index d2ec0410..28e97741 100644 --- a/pkg/cli/gitagent_test.go +++ b/pkg/cli/gitagent_test.go @@ -5,10 +5,11 @@ import ( "path/filepath" "strings" "testing" - "time" "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/database" "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/commons-db/dbtest" ) func isolatedConfig(t *testing.T) string { @@ -19,10 +20,14 @@ func isolatedConfig(t *testing.T) string { return path } -func TestGitAgentAddMintsSingleUseToken(t *testing.T) { +// The hand-off `git-agent add` prints is what an operator copies onto the agent +// host, so every part of it has to be there and the credential has to work more +// than once. +func TestGitAgentAddMintsADurableToken(t *testing.T) { path := isolatedConfig(t) + db := gitAgentTokenDB(t) - res, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent"}) + res, err := RunGitAgentAdd(t.Context(), GitAgentAddOptions{Name: "worker-1", Backend: "git-agent"}) if err != nil { t.Fatal(err) } @@ -30,71 +35,125 @@ func TestGitAgentAddMintsSingleUseToken(t *testing.T) { if !ok { t.Fatalf("result = %T", res) } - if add.HostFingerprint == "" || !strings.Contains(add.JoinCommand, "--join ") { + if add.HostFingerprint == "" || !strings.Contains(add.JoinCommand, "--token ") { t.Fatalf("join hand-off incomplete: %+v", add) } if !strings.Contains(add.JoinCommand, "--host-fingerprint "+add.HostFingerprint) { t.Fatalf("join command must pin the host key: %s", add.JoinCommand) } - if time.Until(add.Expires) > gitagent.JoinTokenTTL { - t.Fatalf("token TTL too long: %s", add.Expires) + if add.Expires != nil { + t.Fatalf("a token minted with no --expires should not expire, got %s", add.Expires) } - // The raw token never lands in the config file — only its hash (R8.2). - token := strings.Fields(strings.SplitAfter(add.JoinCommand, "--join ")[1])[0] + // The credential never lands in the config file: it lives in the database, + // hashed, and the config holds only dispatch targeting data (R8.2). + token := strings.Fields(strings.SplitAfter(add.JoinCommand, "--token ")[1])[0] raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if strings.Contains(string(raw), token) { - t.Fatal("the raw join token must not be persisted") + t.Fatal("the raw token must not be persisted to the config file") } - if !strings.Contains(string(raw), gitagent.HashJoinToken(token)) { - t.Fatal("the token hash must be persisted as pending") + if strings.Contains(string(raw), "pending") { + t.Fatal("pending enrollments are gone: a durable token needs no redemption record") } - // Consume: valid once, burned after (R8.2). - dir := gitAgentDirectory{backend: "git-agent"} - name, err := dir.ConsumeJoinToken(token) - if err != nil || name != "worker-1" { - t.Fatalf("consume = %q, %v", name, err) + // Admitted repeatedly, because a restarting sidecar presents the same one. + dir := gitAgentDirectory{backend: "git-agent", ctx: t.Context(), db: db} + for attempt := 1; attempt <= 3; attempt++ { + name, err := dir.AdmitToken(token, "") + if err != nil || name != "worker-1" { + t.Fatalf("admission %d = %q, %v", attempt, name, err) + } } - if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "already used") { - t.Fatalf("replay must fail, got %v", err) + + if _, err := dir.AdmitToken("cptn_nosuch.secret", ""); err == nil || + !strings.Contains(err.Error(), "not recognized") { + t.Fatalf("an unissued token must be refused, got %v", err) } } -func TestGitAgentExpiredTokenRefused(t *testing.T) { +// A revoked token stops working on the very next enrollment, and says so +// rather than reading as an unknown credential. +func TestGitAgentRevokedTokenIsRefusedWithItsReason(t *testing.T) { isolatedConfig(t) - token, hash, err := gitagent.MintJoinToken() + db := gitAgentTokenDB(t) + + res, err := RunGitAgentAdd(t.Context(), GitAgentAddOptions{Name: "worker-1", Backend: "git-agent"}) if err != nil { t.Fatal(err) } - err = captainconfig.Update(func(cfg *captainconfig.Config) error { - backend, err := ensureGitAgentBackend(cfg, "git-agent") - if err != nil { - return err - } - backend.Options["pending"] = map[string]any{ - hash: map[string]any{ - "agent": "worker-1", - "expires": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339), - }, - } - cfg.Sandbox.Backends["git-agent"] = backend - return nil + add := res.(GitAgentAddResult) + dir := gitAgentDirectory{backend: "git-agent", ctx: t.Context(), db: db} + if _, err := dir.AdmitToken(add.Token.Value(), ""); err != nil { + t.Fatal(err) + } + + if err := db.RevokeAPIToken(t.Context(), add.TokenID, "agent decommissioned"); err != nil { + t.Fatal(err) + } + if _, err := dir.AdmitToken(add.Token.Value(), ""); err == nil || !strings.Contains(err.Error(), "revoked") { + t.Fatalf("a revoked token must be refused as revoked, got %v", err) + } +} + +// A pool token names its members as they arrive and caps how many there can be, +// so one credential can serve a scaled deployment. +func TestGitAgentPoolTokenNamesItsMembers(t *testing.T) { + isolatedConfig(t) + db := gitAgentTokenDB(t) + + res, err := RunGitAgentAdd(t.Context(), GitAgentAddOptions{ + Name: "prod-pool", Backend: "git-agent", Pool: true, MaxAgents: 2, }) if err != nil { t.Fatal(err) } - dir := gitAgentDirectory{backend: "git-agent"} - if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "expired") { - t.Fatalf("expired token must fail, got %v", err) + add := res.(GitAgentAddResult) + if !add.Pool { + t.Fatal("a --pool mint must report itself as a pool") } - // And expiry burns it: a retry is unknown, not expired. - if _, err := dir.ConsumeJoinToken(token); err == nil || !strings.Contains(err.Error(), "already used") { - t.Fatalf("expired token must burn, got %v", err) + dir := gitAgentDirectory{backend: "git-agent", ctx: t.Context(), db: db} + + first, err := dir.AdmitToken(add.Token.Value(), "") + if err != nil { + t.Fatal(err) + } + second, err := dir.AdmitToken(add.Token.Value(), "") + if err != nil { + t.Fatal(err) + } + if first == second { + t.Fatalf("two members must get distinct names, both got %q", first) + } + + // A restart re-presents the name the member persisted, and must not consume + // a third slot — otherwise a rescheduled pod exhausts the pool. + if again, err := dir.AdmitToken(add.Token.Value(), first); err != nil || again != first { + t.Fatalf("returning member = %q, %v; want %q", again, err, first) + } + if _, err := dir.AdmitToken(add.Token.Value(), ""); err == nil || !strings.Contains(err.Error(), "members") { + t.Fatalf("a full pool must refuse a new member, got %v", err) + } +} + +// gitAgentTokenDB points the default database context at an embedded postgres, +// which is where tokens live. +func gitAgentTokenDB(t *testing.T) *database.DB { + t.Helper() + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_gitagent_tokens"}) + db, err := database.Open(t.Context(), database.WithDSN(handle.DSN()), database.WithMigrations()) + if err != nil { + t.Fatalf("open database: %v", err) } + setCaptainDBForTest(db) + t.Cleanup(func() { + setCaptainDBForTest(nil) + resetCaptainContextsForTest() + _ = db.Close() + }) + return db } func TestGitAgentEnrollListRevoke(t *testing.T) { @@ -139,7 +198,9 @@ func TestGitAgentEnrollListRevoke(t *testing.T) { func TestGitAgentAddDryRunTouchesNothing(t *testing.T) { path := isolatedConfig(t) - result, err := RunGitAgentAdd(GitAgentAddOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}) + // No database is wired: a dry run must reach no store either, or it is not + // dry. + result, err := RunGitAgentAdd(t.Context(), GitAgentAddOptions{Name: "worker-1", Backend: "git-agent", DryRun: true}) if err != nil { t.Fatal(err) } diff --git a/pkg/cli/gitagent_undeploy.go b/pkg/cli/gitagent_undeploy.go new file mode 100644 index 00000000..805fedf4 --- /dev/null +++ b/pkg/cli/gitagent_undeploy.go @@ -0,0 +1,157 @@ +package cli + +import ( + "context" + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/gitagent/deploy" + "github.com/flanksource/clicky" +) + +// GitAgentUndeployOptions tears down a deployed sidecar. +// +// It is separate from `revoke` because the two do different halves of the same +// job and either is useful alone: revoke refuses an agent's key from now on but +// leaves the machine running, while undeploy removes the machine. Run alone, +// revoke leaves a host on the network still holding a valid key, a checkout of +// the source tree, and any model credentials it was given. +type GitAgentUndeployOptions struct { + Name string `args:"true" help:"Deployed agent to tear down"` + Backend string `flag:"backend" help:"Sandbox backend in ~/.captain.yaml" default:"git-agent"` + Target string `flag:"target" help:"Where the sidecar runs: docker or kubernetes (default: whatever deploy recorded)"` + + Namespace string `flag:"namespace" help:"kubernetes: namespace holding the sidecar (default: the kubeconfig context's)"` + KubeContext string `flag:"kube-context" help:"kubernetes: kubeconfig context (default: current-context)"` + + Purge bool `flag:"purge" help:"Also delete the state volume, which holds the agent's private key"` + // Revoking is the default so the two halves cannot drift apart. + KeepEnrollment bool `flag:"keep-enrollment" help:"Leave the agent enrolled; by default undeploy also revokes it"` + DryRun bool `flag:"dry-run" help:"Print every intended mutation without touching anything" short:"n"` +} + +type GitAgentUndeployResult struct { + Backend string `json:"backend" pretty:"label=Backend"` + Agent string `json:"agent" pretty:"label=Agent"` + Target string `json:"target" pretty:"label=Target"` + Removed []string `json:"removed" pretty:"label=Removed"` + Revoked bool `json:"revoked" pretty:"label=Revoked"` + Retained string `json:"retained,omitempty" pretty:"label=Retained"` + DryRun bool `json:"dryRun,omitempty" pretty:"label=Dry Run"` +} + +func RunGitAgentUndeploy(ctx context.Context, opts GitAgentUndeployOptions) (any, error) { + target, err := resolveUndeployTarget(opts.Backend, opts.Name, opts.Target) + if err != nil { + return nil, err + } + plan := deploy.Plan{Name: opts.Name, Backend: opts.Backend, Target: target} + if recorded, ok := lookupDeployment(opts.Backend, opts.Name); ok && opts.Namespace == "" { + opts.Namespace = recorded.Namespace + } + result := GitAgentUndeployResult{Backend: opts.Backend, Agent: opts.Name, Target: string(target)} + + if opts.DryRun { + clicky.Printf("[dry-run] would remove the workload %s\n", plan.WorkloadName()) + if opts.Purge { + clicky.Printf("[dry-run] would delete the state volume %s, destroying the agent's private key\n", plan.VolumeName()) + } else { + clicky.Printf("[dry-run] would retain the state volume %s\n", plan.VolumeName()) + } + if !opts.KeepEnrollment { + clicky.Printf("[dry-run] would revoke agent %q from sandbox.backends.%s in %s\n", + opts.Name, opts.Backend, configPathForDisplay()) + } + result.DryRun = true + return result, nil + } + + var removed []string + if target == deploy.TargetDocker { + // teardownWorkload also removes the host-side join token file. + if err := teardownWorkload(ctx, nil, nil, plan, "", opts.Purge); err != nil { + return nil, err + } + removed = []string{"container/" + plan.WorkloadName()} + if opts.Purge { + removed = append(removed, "volume/"+plan.VolumeName()) + } + } else { + client, namespace, err := kubernetesClient(kubeClientOptions{Context: opts.KubeContext, Namespace: opts.Namespace}) + if err != nil { + return nil, err + } + customClient, err := kubernetesDynamicClient(kubeClientOptions{Context: opts.KubeContext}) + if err != nil { + return nil, err + } + if removed, err = deploy.KubernetesRemove(ctx, client, plan, namespace, opts.Purge); err != nil { + return nil, err + } + transportRemoved, err := deploy.DeleteTraefikServersTransport(ctx, customClient, plan, namespace) + if err != nil { + return nil, err + } + if transportRemoved { + removed = append(removed, "ServersTransport/"+plan.WorkloadName()) + } + } + result.Removed = removed + // The workload is gone, so the record that pointed at it is stale; leaving + // it would make the roster keep offering to tear down something that is no + // longer there. + if err := forgetDeployment(opts.Backend, opts.Name); err != nil { + clicky.Printf("warning: the workload is gone but its deployment record could not be cleared: %v\n", err) + } + + if !opts.Purge { + result.Retained = fmt.Sprintf("%s (holds the agent's private key; delete with --purge)", plan.VolumeName()) + } + if opts.KeepEnrollment { + clicky.Printf("agent %q is still enrolled; its key and token remain valid until you revoke them\n", opts.Name) + return result, nil + } + // The token outlives the workload unless it is revoked: it is durable, so + // nothing else ever retires it, and a live credential for a torn-down agent + // is exactly what R8.5 exists to prevent. + if err := revokeAgentTokens(ctx, opts.Name); err != nil { + clicky.Printf("warning: the workload is gone but its captain tokens could not be revoked: %v\n", err) + } + if _, err := RunGitAgentRevoke(GitAgentRevokeOptions{Name: opts.Name, Backend: opts.Backend}); err != nil { + // The workload is already gone, so a revoke failure is worth reporting + // loudly but does not undo the teardown. + if !strings.Contains(err.Error(), "is not enrolled") && !strings.Contains(err.Error(), "has no enrolled agents") { + return result, fmt.Errorf("workload removed, but revoking the agent failed: %w", err) + } + } else { + result.Revoked = true + } + return result, nil +} + +// revokeAgentTokens retires every live token that speaks for an agent. +// +// A bound token is retired outright. A pool token is left alone: it serves +// siblings that are still running, and revoking it to tear down one member +// would take the whole deployment offline. +func revokeAgentTokens(ctx context.Context, agent string) error { + db, err := captainServeDB(ctx) + if err != nil { + return err + } + tokens, err := db.ListAPITokens(ctx, database.ListAPITokensFilter{Agent: agent}) + if err != nil { + return err + } + for _, token := range tokens { + if token.Pool { + clicky.Printf("token %s serves pool %q and other members; leaving it in place\n", token.TokenID, token.Name) + continue + } + if err := db.RevokeAPIToken(ctx, token.TokenID, "agent "+agent+" was undeployed"); err != nil { + return err + } + } + return nil +} diff --git a/pkg/cli/prompt_schema_build.go b/pkg/cli/prompt_schema_build.go index c4e0b40c..9361c3ea 100644 --- a/pkg/cli/prompt_schema_build.go +++ b/pkg/cli/prompt_schema_build.go @@ -41,11 +41,12 @@ func buildPromptSchemaDocument(adapters []AdapterStatus, sandboxes captainconfig if err := injectSpecConditionals(specMap, adapters, reflected.args); err != nil { return nil, err } + // Must follow injectSpecConditionals, which assigns allOf; this appends. + injectSandboxModeConditionals(specMap, sandboxes) backends, err := buildBackendsCatalog(adapters, reflected.args) if err != nil { return nil, err } - return map[string]any{ "schemaVersion": 2, "source": "captain prompt --schema", @@ -53,6 +54,7 @@ func buildPromptSchemaDocument(adapters []AdapterStatus, sandboxes captainconfig "prompt": promptMap, "promptAction": actionMap, "backends": backends, + "sandboxes": buildSandboxCatalog(sandboxes), "runtimes": enabledRuntimes(), "models": flatModels(adapters), "efforts": enabledEffortNames(), diff --git a/pkg/cli/prompt_schema_sandboxes.go b/pkg/cli/prompt_schema_sandboxes.go new file mode 100644 index 00000000..a2036914 --- /dev/null +++ b/pkg/cli/prompt_schema_sandboxes.go @@ -0,0 +1,200 @@ +package cli + +import ( + "fmt" + "strings" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" +) + +// SandboxCatalog is the descriptive sandbox surface served to the workbench: +// what each adapter does, what it can do, which runtime modes it can serve, and +// which configured backends select it. +// +// It is the descriptive sibling of injectSandboxBackendEnum, which constrains +// the SandboxRef selector to the same set of names — the same split +// buildBackendsCatalog has against injectSpecConditionals. The enum tells a +// validator what is allowed; this tells the editor what the choices mean. +type SandboxCatalog struct { + // Default is the configured sandbox.default selector, empty when unset. + Default string `json:"default,omitempty"` + // Kinds are the adapter descriptors in canonical order, each carrying the + // configured backends that select it. + Kinds []SandboxCatalogEntry `json:"kinds"` + // Invalid are configured backends whose kind does not resolve to an adapter. + // They are reported rather than dropped: SandboxDefaults.Resolve refuses them + // at run time, so a silently missing backend would look like a config that + // never loaded instead of one that is wrong. + Invalid []SandboxBackendEntry `json:"invalid,omitempty"` +} + +// SandboxCatalogEntry is one sandbox adapter descriptor, projected for the UI. +type SandboxCatalogEntry struct { + Kind string `json:"kind"` + Description string `json:"description"` + // Capabilities are the optional behaviours the adapter declares. The editor + // uses them to decide what to offer: an agent picker needs remote-exec, and + // isolate-workspace conflicts with a worktree or setup checkout. + Capabilities []string `json:"capabilities"` + // Modes are the runtime modes the adapter can serve. A pairing outside this + // list is a hard validation error at dispatch (registry.Sandbox.ValidateMode). + Modes []string `json:"modes"` + // Default reports that sandbox.default names this bare kind. + Default bool `json:"default,omitempty"` + Backends []SandboxBackendEntry `json:"backends,omitempty"` +} + +// SandboxBackendEntry is one configured backend from ~/.captain.yaml. +type SandboxBackendEntry struct { + Name string `json:"name"` + Kind string `json:"kind"` + // Default reports that sandbox.default names this backend. + Default bool `json:"default,omitempty"` + // URL is the endpoint a git-agent backend dispatches through. + URL string `json:"url,omitempty"` + // Agents is the enrolled and pending roster, for git-agent backends only. + Agents []GitAgentListEntry `json:"agents,omitempty"` + // Error explains why this backend cannot be selected, when its kind is + // missing or unknown. + Error string `json:"error,omitempty"` +} + +// injectSandboxModeConditionals constrains `mode` once a sandbox is chosen, so +// an unrunnable pairing is a schema error in the editor rather than a failure at +// dispatch. registry.Sandbox.ValidateMode already rejects these hard; this is +// the same matrix expressed where the form can see it — git-agent, for +// instance, deliberately cannot serve ModeAPI. +// +// It must APPEND: injectSpecConditionals ends by assigning specMap["allOf"], so +// running before it would silently lose these rules and running after it with +// another assignment would lose the per-backend model/effort rules. +func injectSandboxModeConditionals(specMap map[string]any, defaults captainconfig.SandboxDefaults) { + allOf, _ := specMap["allOf"].([]any) + kinds := sandboxSelectorKinds(defaults) + for _, selector := range sortedKeys(kinds) { + descriptor, ok := api.SandboxFor(kinds[selector]) + if !ok || len(descriptor.Modes) == len(api.AllRuntimeModes()) { + // Serves every mode: there is nothing to constrain. + continue + } + // "" keeps an unset mode valid — the runtime resolves it from the backend. + modes := make([]any, 0, len(descriptor.Modes)+1) + modes = append(modes, "") + for _, mode := range descriptor.Modes { + modes = append(modes, string(mode)) + } + allOf = append(allOf, sandboxModeRule(selector, modes)) + } + if len(allOf) > 0 { + specMap["allOf"] = allOf + } +} + +// sandboxSelectorKinds maps every selector a user may write to the adapter it +// resolves to. A configured backend shadows a bare kind of the same name, +// because SandboxDefaults.Resolve looks in Backends first. +func sandboxSelectorKinds(defaults captainconfig.SandboxDefaults) map[string]api.SandboxKind { + out := map[string]api.SandboxKind{} + for _, descriptor := range api.AllSandboxes() { + out[string(descriptor.Kind)] = descriptor.Kind + } + for name, backend := range defaults.Backends { + declared := strings.TrimSpace(backend.Kind) + if declared == "" { + continue + } + if kind, ok := api.ParseSandboxKind(declared); ok { + out[name] = kind + } + } + return out +} + +// sandboxModeRule matches one selector in either SandboxRef form. Both branches +// pin a type: without "type": "object" the object branch's `required` would be +// vacuously true for a string, so every selector's rule would fire at once on a +// scalar sandbox and mode would be squeezed to the intersection of all adapters. +func sandboxModeRule(selector string, modes []any) map[string]any { + scalar := map[string]any{ + "required": []any{"sandbox"}, + "properties": map[string]any{ + "sandbox": map[string]any{"type": "string", "const": selector}, + }, + } + object := map[string]any{ + "required": []any{"sandbox"}, + "properties": map[string]any{ + "sandbox": map[string]any{ + "type": "object", + "required": []any{"backend"}, + "properties": map[string]any{"backend": map[string]any{"const": selector}}, + }, + }, + } + return map[string]any{ + "if": map[string]any{"anyOf": []any{scalar, object}}, + "then": map[string]any{"properties": map[string]any{"mode": map[string]any{"enum": modes}}}, + } +} + +// buildSandboxCatalog projects the adapter descriptor table and the user's +// configured backends into the catalog. It is pure: buildPromptSchemaDocument +// is documented as a no-I/O assembler, so the config arrives as an argument and +// is never loaded here. +func buildSandboxCatalog(defaults captainconfig.SandboxDefaults) SandboxCatalog { + selected := strings.TrimSpace(defaults.Default) + catalog := SandboxCatalog{Default: selected} + + configured := map[api.SandboxKind][]SandboxBackendEntry{} + for _, name := range sortedKeys(defaults.Backends) { + backend := defaults.Backends[name] + entry := SandboxBackendEntry{ + Name: name, + Kind: strings.TrimSpace(backend.Kind), + Default: name == selected, + } + kind, ok := api.ParseSandboxKind(entry.Kind) + // ParseSandboxKind maps "" to SandboxNone, which is the right default for + // an absent selector but wrong for a configured backend: a backend that + // declares no kind is a mistake, not a request to run unconfined. + if entry.Kind == "" { + entry.Error = fmt.Sprintf("backend declares no kind (valid: %s)", api.SandboxKindList()) + } else if !ok { + entry.Error = fmt.Sprintf("unknown kind %q (valid: %s)", entry.Kind, api.SandboxKindList()) + } + if entry.Error != "" { + catalog.Invalid = append(catalog.Invalid, entry) + continue + } + if url, _ := backend.Options["url"].(string); url != "" { + entry.URL = url + } + if kind == api.SandboxGitAgent { + if roster := gitAgentRoster(backend); len(roster) > 0 { + entry.Agents = roster + } + } + configured[kind] = append(configured[kind], entry) + } + + catalog.Kinds = make([]SandboxCatalogEntry, 0, len(api.AllSandboxes())) + for _, descriptor := range api.AllSandboxes() { + entry := SandboxCatalogEntry{ + Kind: string(descriptor.Kind), + Description: descriptor.Description, + Capabilities: make([]string, 0, len(descriptor.Capabilities)), + Modes: make([]string, 0, len(descriptor.Modes)), + Default: selected == string(descriptor.Kind), + Backends: configured[descriptor.Kind], + } + for _, capability := range descriptor.Capabilities { + entry.Capabilities = append(entry.Capabilities, string(capability)) + } + for _, mode := range descriptor.Modes { + entry.Modes = append(entry.Modes, string(mode)) + } + catalog.Kinds = append(catalog.Kinds, entry) + } + return catalog +} diff --git a/pkg/cli/prompt_schema_sandboxes_test.go b/pkg/cli/prompt_schema_sandboxes_test.go new file mode 100644 index 00000000..93a61f7f --- /dev/null +++ b/pkg/cli/prompt_schema_sandboxes_test.go @@ -0,0 +1,327 @@ +package cli + +import ( + "reflect" + "testing" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" +) + +// gitAgentBackendFixture is a configured git-agent backend carrying one fully +// enrolled agent, one enrolled without a host key (recorded by hand, so not +// dispatchable), and one whose workload captain placed but which has not +// enrolled back yet. +func gitAgentBackendFixture() captainconfig.SandboxBackend { + return captainconfig.SandboxBackend{ + Kind: "git-agent", + Options: map[string]any{ + "url": "ssh://supervisor.internal:7422", + "agents": map[string]any{ + "worker-01": map[string]any{ + "fingerprint": "SHA256:aaa", + "url": "ssh://worker-01:7422", + "hostFingerprint": "SHA256:bbb", + "addedAt": "2026-08-01T00:00:00Z", + }, + "worker-02": map[string]any{ + "fingerprint": "SHA256:ccc", + "url": "ssh://worker-02:7422", + }, + }, + // A deployment with no matching agent entry is the only "not yet + // enrolled" state there is: tokens are durable, so the pending-join + // record they used to need is gone. + "deployments": map[string]any{ + "worker-03": map[string]any{ + "target": "docker", + "workload": "captain-git-agent-worker-03", + "image": "ghcr.io/flanksource/captain:latest", + "deployedAt": "2026-08-02T00:00:00Z", + }, + }, + }, + } +} + +func catalogKind(t *testing.T, catalog SandboxCatalog, kind string) SandboxCatalogEntry { + t.Helper() + for _, entry := range catalog.Kinds { + if entry.Kind == kind { + return entry + } + } + t.Fatalf("catalog has no %q entry; got %+v", kind, catalog.Kinds) + return SandboxCatalogEntry{} +} + +func TestBuildSandboxCatalogListsEveryAdapterInCanonicalOrder(t *testing.T) { + catalog := buildSandboxCatalog(captainconfig.SandboxDefaults{}) + + got := make([]string, 0, len(catalog.Kinds)) + for _, entry := range catalog.Kinds { + got = append(got, entry.Kind) + } + want := make([]string, 0, len(api.AllSandboxes())) + for _, descriptor := range api.AllSandboxes() { + want = append(want, string(descriptor.Kind)) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("catalog kinds = %v, want %v (canonical AllSandboxes order)", got, want) + } +} + +func TestBuildSandboxCatalogProjectsDescriptorCapabilitiesAndModes(t *testing.T) { + catalog := buildSandboxCatalog(captainconfig.SandboxDefaults{}) + + gitAgent := catalogKind(t, catalog, "git-agent") + wantCapabilities := []string{"remote-exec", "isolate-workspace", "egress-proxy"} + if !reflect.DeepEqual(gitAgent.Capabilities, wantCapabilities) { + t.Errorf("git-agent capabilities = %v, want %v", gitAgent.Capabilities, wantCapabilities) + } + // git-agent deliberately excludes ModeAPI: its contract is that work returns + // as commits, and a direct API call produces no working-tree change to push. + wantModes := []string{"cli", "agent", "cmux"} + if !reflect.DeepEqual(gitAgent.Modes, wantModes) { + t.Errorf("git-agent modes = %v, want %v", gitAgent.Modes, wantModes) + } + if gitAgent.Description == "" { + t.Error("git-agent description is empty; the editor renders it as help text") + } + + none := catalogKind(t, catalog, "none") + if len(none.Capabilities) != 0 { + t.Errorf("none capabilities = %v, want empty", none.Capabilities) + } + if len(none.Modes) != len(api.AllRuntimeModes()) { + t.Errorf("none modes = %v, want every runtime mode", none.Modes) + } +} + +func TestBuildSandboxCatalogNestsConfiguredBackendsUnderTheirKind(t *testing.T) { + catalog := buildSandboxCatalog(captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{ + "prod-pool": gitAgentBackendFixture(), + "local-docker": {Kind: "container"}, + }, + }) + + gitAgent := catalogKind(t, catalog, "git-agent") + if len(gitAgent.Backends) != 1 || gitAgent.Backends[0].Name != "prod-pool" { + t.Fatalf("git-agent backends = %+v, want just prod-pool", gitAgent.Backends) + } + pool := gitAgent.Backends[0] + if pool.URL != "ssh://supervisor.internal:7422" { + t.Errorf("prod-pool url = %q, want the configured endpoint", pool.URL) + } + if pool.Kind != "git-agent" { + t.Errorf("prod-pool kind = %q, want git-agent", pool.Kind) + } + + if container := catalogKind(t, catalog, "container"); len(container.Backends) != 1 { + t.Errorf("container backends = %+v, want just local-docker", container.Backends) + } + if srt := catalogKind(t, catalog, "srt"); len(srt.Backends) != 0 { + t.Errorf("srt backends = %+v, want none configured", srt.Backends) + } + if len(catalog.Invalid) != 0 { + t.Errorf("catalog.Invalid = %+v, want empty", catalog.Invalid) + } +} + +func TestBuildSandboxCatalogSurfacesTheGitAgentRoster(t *testing.T) { + catalog := buildSandboxCatalog(captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{"prod-pool": gitAgentBackendFixture()}, + }) + + agents := catalogKind(t, catalog, "git-agent").Backends[0].Agents + want := []GitAgentListEntry{ + { + Name: "worker-01", + Fingerprint: "SHA256:aaa", + URL: "ssh://worker-01:7422", + HostFingerprint: "SHA256:bbb", + AddedAt: "2026-08-01T00:00:00Z", + Status: "enrolled", + Dispatchable: true, + }, + // Enrolled but missing a host fingerprint: the adapter cannot pin its host + // key, so dispatch fails. The roster reports it rather than hiding it. + { + Name: "worker-02", + Fingerprint: "SHA256:ccc", + URL: "ssh://worker-02:7422", + Status: "enrolled", + DispatchIssue: "missing host key", + }, + // Deployed but not yet joined. Listed so an operator who just deployed + // sees the workload rather than an empty roster and no way to remove it. + { + Name: "worker-03", + Status: "deployed — waiting to enroll", + Deployment: &GitAgentDeployment{ + Target: "docker", + Workload: "captain-git-agent-worker-03", + Image: "ghcr.io/flanksource/captain:latest", + DeployedAt: "2026-08-02T00:00:00Z", + }, + }, + } + if !reflect.DeepEqual(agents, want) { + t.Errorf("roster = %+v, want %+v", agents, want) + } +} + +func TestBuildSandboxCatalogMarksTheConfiguredDefault(t *testing.T) { + defaults := captainconfig.SandboxDefaults{ + Default: "prod-pool", + Backends: map[string]captainconfig.SandboxBackend{"prod-pool": gitAgentBackendFixture()}, + } + catalog := buildSandboxCatalog(defaults) + if catalog.Default != "prod-pool" { + t.Errorf("catalog.Default = %q, want prod-pool", catalog.Default) + } + if !catalogKind(t, catalog, "git-agent").Backends[0].Default { + t.Error("prod-pool backend should be flagged as the default") + } + if catalogKind(t, catalog, "git-agent").Default { + t.Error("the bare git-agent kind is not the default; the backend name is") + } + + // A bare kind as the default flags the kind, not any backend. + bare := buildSandboxCatalog(captainconfig.SandboxDefaults{Default: "srt"}) + if !catalogKind(t, bare, "srt").Default { + t.Error("srt kind should be flagged as the default") + } +} + +func TestBuildSandboxCatalogReportsBackendsWithAnUnusableKind(t *testing.T) { + catalog := buildSandboxCatalog(captainconfig.SandboxDefaults{ + Backends: map[string]captainconfig.SandboxBackend{ + "typo": {Kind: "git-agnet"}, + "kindles": {Kind: " "}, + "good": {Kind: "srt"}, + }, + }) + + if len(catalog.Invalid) != 2 { + t.Fatalf("catalog.Invalid = %+v, want the two unusable backends", catalog.Invalid) + } + byName := map[string]SandboxBackendEntry{} + for _, entry := range catalog.Invalid { + byName[entry.Name] = entry + } + // An empty kind must NOT resolve to "none": ParseSandboxKind maps "" to none + // because an absent selector means unconfined, but a backend that declares no + // kind is a mistake, and silently running unsandboxed is the failure the + // descriptor table exists to prevent. + if got := byName["kindles"].Error; got == "" { + t.Error("a backend with a blank kind must report an error, not resolve to none") + } + if got := byName["typo"].Error; got == "" { + t.Error("a backend with an unknown kind must report an error") + } + // Valid backends are unaffected by an invalid sibling. + if srt := catalogKind(t, catalog, "srt"); len(srt.Backends) != 1 { + t.Errorf("srt backends = %+v, want the valid 'good' backend", srt.Backends) + } + // And an unusable backend is never offered as a selectable choice. + for _, entry := range catalog.Kinds { + for _, backend := range entry.Backends { + if backend.Name == "typo" || backend.Name == "kindles" { + t.Errorf("unusable backend %q offered under kind %q", backend.Name, entry.Kind) + } + } + } +} + +func TestPromptSchemaSandboxModeConditionals(t *testing.T) { + defaults := captainconfig.SandboxDefaults{Backends: map[string]captainconfig.SandboxBackend{ + "prod-pool": {Kind: "git-agent"}, + }} + doc, err := buildPromptSchemaDocument(stubbedSchemaAdapters(t), defaults) + if err != nil { + t.Fatalf("buildPromptSchemaDocument: %v", err) + } + spec := doc["spec"].(map[string]any) + + // Regression against injectSpecConditionals' `specMap["allOf"] = allOf` + // assignment: appending the sandbox rules must not drop the backend rules. + backendRules := 0 + modeBySelector := map[string][]any{} + for _, raw := range spec["allOf"].([]any) { + rule := raw.(map[string]any) + condition := rule["if"].(map[string]any) + if props, ok := condition["properties"].(map[string]any); ok { + if _, isBackend := props["backend"]; isBackend { + backendRules++ + continue + } + } + scalar := condition["anyOf"].([]any)[0].(map[string]any) + selector := scalar["properties"].(map[string]any)["sandbox"].(map[string]any)["const"].(string) + then := rule["then"].(map[string]any)["properties"].(map[string]any) + modeBySelector[selector] = then["mode"].(map[string]any)["enum"].([]any) + } + if backendRules != len(api.AllBackends()) { + t.Errorf("backend conditionals = %d, want %d; the sandbox rules overwrote them", + backendRules, len(api.AllBackends())) + } + + // git-agent cannot serve ModeAPI, so choosing it constrains mode. + wantGitAgent := []any{"", "cli", "agent", "cmux"} + if !reflect.DeepEqual(modeBySelector["git-agent"], wantGitAgent) { + t.Errorf("git-agent mode enum = %v, want %v", modeBySelector["git-agent"], wantGitAgent) + } + // A configured backend gets the same constraint as the kind it resolves to. + if !reflect.DeepEqual(modeBySelector["prod-pool"], wantGitAgent) { + t.Errorf("prod-pool mode enum = %v, want %v", modeBySelector["prod-pool"], wantGitAgent) + } + // srt and container only hook the CLI exec site. + if want := []any{"", "cli"}; !reflect.DeepEqual(modeBySelector["srt"], want) { + t.Errorf("srt mode enum = %v, want %v", modeBySelector["srt"], want) + } + // "none" serves every mode, so it needs no rule at all. + if enum, ok := modeBySelector["none"]; ok { + t.Errorf("none should emit no mode constraint, got %v", enum) + } +} + +// A scalar `sandbox:` must match only its own rule. Without an explicit +// "type": "object" on the object branch, `required: [backend]` is vacuously +// true for a string, every selector's rule fires at once, and mode collapses to +// the intersection of all adapters. +func TestSandboxModeRuleObjectBranchIsTypePinned(t *testing.T) { + rule := sandboxModeRule("git-agent", []any{"", "cli"}) + branches := rule["if"].(map[string]any)["anyOf"].([]any) + + scalar := branches[0].(map[string]any)["properties"].(map[string]any)["sandbox"].(map[string]any) + if scalar["type"] != "string" { + t.Errorf("scalar branch type = %v, want string", scalar["type"]) + } + object := branches[1].(map[string]any)["properties"].(map[string]any)["sandbox"].(map[string]any) + if object["type"] != "object" { + t.Errorf("object branch type = %v, want object", object["type"]) + } +} + +func TestPromptSchemaDocumentServesTheSandboxCatalog(t *testing.T) { + defaults := captainconfig.SandboxDefaults{ + Default: "prod-pool", + Backends: map[string]captainconfig.SandboxBackend{"prod-pool": gitAgentBackendFixture()}, + } + doc, err := buildPromptSchemaDocument(stubbedSchemaAdapters(t), defaults) + if err != nil { + t.Fatalf("buildPromptSchemaDocument: %v", err) + } + catalog, ok := doc["sandboxes"].(SandboxCatalog) + if !ok { + t.Fatalf("doc[sandboxes] = %T, want SandboxCatalog", doc["sandboxes"]) + } + if catalog.Default != "prod-pool" { + t.Errorf("served default = %q, want prod-pool", catalog.Default) + } + if len(catalog.Kinds) != len(api.AllSandboxes()) { + t.Errorf("served kinds = %d, want %d", len(catalog.Kinds), len(api.AllSandboxes())) + } +} diff --git a/pkg/cli/prompt_schema_test.go b/pkg/cli/prompt_schema_test.go index 5edc6bfa..924d2173 100644 --- a/pkg/cli/prompt_schema_test.go +++ b/pkg/cli/prompt_schema_test.go @@ -111,14 +111,25 @@ func TestPromptSchemaDocumentBackendsAndConditionals(t *testing.T) { spec := doc["spec"].(map[string]any) allOf := spec["allOf"].([]any) - if len(allOf) != len(api.AllBackends()) { - t.Fatalf("spec.allOf length = %d, want %d", len(allOf), len(api.AllBackends())) - } + // allOf carries two independent rule families: one per backend (keyed on + // if.properties.backend) and one per sandbox selector (keyed on if.anyOf, + // asserted by TestPromptSchemaSandboxModeConditionals). Select the backend + // rules rather than assuming every entry is one. thenByBackend := map[string]map[string]any{} for _, c := range allOf { cm := c.(map[string]any) - backend := cm["if"].(map[string]any)["properties"].(map[string]any)["backend"].(map[string]any)["const"].(string) - thenByBackend[backend] = cm["then"].(map[string]any)["properties"].(map[string]any) + props, ok := cm["if"].(map[string]any)["properties"].(map[string]any) + if !ok { + continue + } + backend, ok := props["backend"].(map[string]any) + if !ok { + continue + } + thenByBackend[backend["const"].(string)] = cm["then"].(map[string]any)["properties"].(map[string]any) + } + if len(thenByBackend) != len(api.AllBackends()) { + t.Fatalf("spec.allOf backend rules = %d, want %d", len(thenByBackend), len(api.AllBackends())) } // cmux backends: cliArgs constrained by a $ref into $defs. diff --git a/pkg/cli/secret_catalog.go b/pkg/cli/secret_catalog.go index d3038d0b..0499d6dc 100644 --- a/pkg/cli/secret_catalog.go +++ b/pkg/cli/secret_catalog.go @@ -12,6 +12,7 @@ import ( "github.com/flanksource/captain/pkg/ai" corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/dynamic" "k8s.io/client-go/kubernetes" "k8s.io/client-go/tools/clientcmd" ) @@ -33,7 +34,7 @@ func handleSecretResources() http.HandlerFunc { http.Error(w, err.Error(), http.StatusBadRequest) return } - client, namespace, err := kubernetesClient(r.URL.Query().Get("namespace")) + client, namespace, err := kubernetesClient(kubeClientOptions{Namespace: r.URL.Query().Get("namespace")}) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -59,7 +60,7 @@ func handleSecretPreview() http.HandlerFunc { http.Error(w, "name is required", http.StatusBadRequest) return } - client, namespace, err := kubernetesClient(r.URL.Query().Get("namespace")) + client, namespace, err := kubernetesClient(kubeClientOptions{Namespace: r.URL.Query().Get("namespace")}) if err != nil { http.Error(w, err.Error(), http.StatusServiceUnavailable) return @@ -84,9 +85,27 @@ func secretKindFromRequest(r *http.Request) (string, error) { } } -func kubernetesClient(namespaceOverride string) (kubernetes.Interface, string, error) { +// kubeClientOptions selects which cluster and namespace to act on. Both are +// optional; empty means "whatever the kubeconfig's current context says". +type kubeClientOptions struct { + // Context names a kubeconfig context. Deploy sets it so an operator can see + // and pin which cluster a sidecar lands in. + Context string + // Namespace overrides the context's own namespace. + Namespace string +} + +// kubernetesClient builds a client for the selected cluster, returning the +// resolved namespace alongside it. +// +// This is deliberately not commons-db's kubernetes.NewClient: that helper +// returns a fake in-memory clientset with a nil error when neither a kubeconfig +// nor in-cluster config resolves, so a write would silently succeed against +// nothing. Here an unresolvable cluster is an error. +func kubernetesClient(opts kubeClientOptions) (kubernetes.Interface, string, error) { loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{}) + overrides := &clientcmd.ConfigOverrides{CurrentContext: strings.TrimSpace(opts.Context)} + config := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) restConfig, err := config.ClientConfig() if err != nil { return nil, "", fmt.Errorf("loading kubeconfig: %w", err) @@ -98,7 +117,7 @@ func kubernetesClient(namespaceOverride string) (kubernetes.Interface, string, e if namespace = strings.TrimSpace(namespace); namespace == "" { namespace = "default" } - if override := strings.TrimSpace(namespaceOverride); override != "" { + if override := strings.TrimSpace(opts.Namespace); override != "" { namespace = override } client, err := kubernetes.NewForConfig(restConfig) @@ -108,6 +127,27 @@ func kubernetesClient(namespaceOverride string) (kubernetes.Interface, string, e return client, namespace, nil } +// kubernetesDynamicClient builds an untyped client for the selected cluster. +// +// cert-manager's types are not in client-go, and taking a dependency on its Go +// module to read one list of names would pull a whole API surface captain never +// otherwise touches. +func kubernetesDynamicClient(opts kubeClientOptions) (dynamic.Interface, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + overrides := &clientcmd.ConfigOverrides{CurrentContext: strings.TrimSpace(opts.Context)} + restConfig, err := clientcmd. + NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides). + ClientConfig() + if err != nil { + return nil, fmt.Errorf("loading kubeconfig: %w", err) + } + client, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("creating kubernetes client: %w", err) + } + return client, nil +} + func listSecretResources(ctx context.Context, client kubernetes.Interface, namespace, kind string) ([]secretResource, error) { switch kind { case "secret": diff --git a/pkg/cli/serve_git.go b/pkg/cli/serve_git.go new file mode 100644 index 00000000..ecaa6b85 --- /dev/null +++ b/pkg/cli/serve_git.go @@ -0,0 +1,227 @@ +// Hosting the git-agent mailbox on `captain serve`. +// +// The supervisor's receive endpoint used to be a separate long-lived process +// (`captain sandbox git-agent serve --role mailbox`). Nothing about the +// protocol required that; it was a separate process only because it needed its +// own SSH listener. Over HTTPS it is a handler, and it belongs on the server +// that already holds the database the tokens live in. + +package cli + +import ( + "crypto/tls" + "fmt" + "net/http" + "path/filepath" + "strings" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/gitagent" +) + +// serveCertificate resolves the certificate this server presents, or nil when +// it serves plain HTTP. A supplied certificate is used as-is; otherwise one is +// generated beside the git-agent keys on first use and reused thereafter, +// because re-issuing invalidates every agent that pinned the previous one. +// +// The "not serving TLS" case returns nil here rather than in each caller, so a +// server on plain HTTP cannot hand a joining agent a certificate it will never +// present — the agent would pin it and every later relay would fail. +func serveCertificate(opts ServeOptions) (*gitagent.TLSCredential, error) { + cert, key := strings.TrimSpace(opts.TLSCert), strings.TrimSpace(opts.TLSKey) + if (cert == "") != (key == "") { + return nil, fmt.Errorf("--tls-cert and --tls-key must be given together") + } + if !opts.TLS && cert == "" { + return nil, nil + } + if cert != "" { + credential, err := gitagent.LoadTLSCredential(cert, key) + if err != nil { + return nil, err + } + // Checked at startup rather than discovered at first push: by then + // every agent has already been enrolled against this certificate. + return credential, credential.Covers(opts.TLSHosts) + } + keysDir, err := gitAgentKeysDir() + if err != nil { + return nil, err + } + return gitagent.EnsureTLSCredential(keysDir, opts.TLSHosts) +} + +// serveTLSConfig renders a resolved certificate for http.Server, or nil for +// plain HTTP. +func serveTLSConfig(credential *gitagent.TLSCredential) *tls.Config { + if credential == nil { + return nil + } + log.Infof("Serving TLS with %s (pin %s)", credential.CertPath, credential.PublicKeyPin) + return &tls.Config{ + Certificates: []tls.Certificate{credential.Certificate}, + MinVersion: tls.VersionTLS12, + } +} + +// registerGitHandlers mounts the git smart-HTTP transport, serving the +// supervisor's mailbox root. +// +// The whole /git/ subtree is registered rather than the two exact endpoints, +// because Go's mux would otherwise fall through to the single-page-app +// catch-all — which answers any path whose last segment has no dot with 200 and +// an HTML body. `/git/x.git/info/refs` ends in "refs", so a git client would +// receive HTML and report a protocol error instead of a 404. +func registerGitHandlers(mux *http.ServeMux, db *database.DB, addr string, credential *gitagent.TLSCredential) error { + root, err := gitAgentServedRoot() + if err != nil { + return err + } + offer, err := serveEnrollmentOffer(credential) + if err != nil { + return err + } + handler, err := gitagent.NewHTTPHandler(gitagent.HTTPServerConfig{ + Root: root, + Role: gitagent.RoleMailbox, + Identify: gitAgentIdentity(db), + Enroll: gitAgentEnroll(offer, gitAgentBackendName), + Log: log.Warnf, + }) + if err != nil { + return err + } + mux.Handle(gitagent.GitHTTPPrefix, handler) + if err := recordServedGitMailbox(addr, root, credential); err != nil { + // Fatal rather than logged: this server is the supervisor whether or not + // it managed to say so, and a mailbox that nothing can find is the exact + // silent half-configuration this package exists to prevent. + return fmt.Errorf("publish this server as a git-agent mailbox: %w", err) + } + return nil +} + +// recordServedGitMailbox publishes this server as a mailbox, the same way the +// standalone SSH one does, so `git-agent deploy` can find it without being told. +// +// It records the unusable shapes too — bound to loopback, or serving plain HTTP +// — because that is what lets deploy refuse with the flags to restart with +// instead of reporting that no mailbox has ever served here. +func recordServedGitMailbox(addr, root string, credential *gitagent.TLSCredential) error { + record := mailboxRecord{Transport: transportHTTPS, Root: root, Listen: addr} + if credential != nil { + record.Identity, record.Encrypted = credential.PublicKeyPin, true + } + return captainconfig.Update(func(cfg *captainconfig.Config) error { + backend, err := ensureGitAgentBackend(cfg, gitAgentBackendName) + if err != nil { + return err + } + // mailboxRoot stays a top-level key: gitagent.ServedRootFor reads it + // directly, and both transports serve the same root. + backend.Options["mailboxRoot"] = root + setMailboxRecord(backend.Options, record) + cfg.Sandbox.Backends[gitAgentBackendName] = backend + return nil + }) +} + +// gitAgentBackendName is the backend this server's mailbox enrolls into. The +// git-agent CLI defaults to the same name, so a supervisor and its operator +// address one roster. +const gitAgentBackendName = "git-agent" + +// serveEnrollmentOffer is what this supervisor hands a joining agent: the +// dispatch key it must authorize, and the certificate its relays verify +// against. The certificate travels over the already-pinned exchange, which is +// the only channel where handing it over proves anything. +func serveEnrollmentOffer(credential *gitagent.TLSCredential) (gitagent.EnrollmentOffer, error) { + keysDir, err := gitAgentKeysDir() + if err != nil { + return gitagent.EnrollmentOffer{}, err + } + _, dispatchFP, err := gitagent.EnsureKeyPair(filepath.Join(keysDir, dispatchKeyName)) + if err != nil { + return gitagent.EnrollmentOffer{}, err + } + offer := gitagent.EnrollmentOffer{DispatchKey: dispatchFP} + if credential == nil { + // No TLS: an agent enrolling here relays over ssh, and has nothing to + // verify a certificate against because there is none. + return offer, nil + } + pem, err := credential.PEM() + if err != nil { + return gitagent.EnrollmentOffer{}, err + } + offer.CACertificate, offer.PinnedPublicKey = string(pem), credential.PublicKeyPin + return offer, nil +} + +// gitAgentEnroll completes the reverse direction of trust: the supervisor +// records the agent's endpoint and host key so it can dispatch there, and +// answers with what the agent needs to accept that dispatch. +// +// The agent has already been resolved from its token by the time this runs, so +// re-enrollment is a re-record of the same values rather than a second identity. +func gitAgentEnroll(offer gitagent.EnrollmentOffer, backend string) func(*http.Request, string, gitagent.EnrollRequest) (*gitagent.EnrollResponse, error) { + return func(r *http.Request, agent string, req gitagent.EnrollRequest) (*gitagent.EnrollResponse, error) { + directory := gitAgentDirectory{backend: backend, ctx: r.Context()} + url := strings.TrimSpace(req.AdvertiseURL) + if url == "" { + return nil, fmt.Errorf( + "agent %q advertised no endpoint; rerun its serve with --advertise ssh://host:port — "+ + "this server cannot infer one, because a request may have crossed a proxy or NAT", agent) + } + // No client-key fingerprint is recorded. Over SSH the handshake proves + // one; here the agent could only assert it, and an asserted fingerprint + // in the roster would let one agent claim another's key and have that + // agent's pushes attributed to it. An HTTPS agent authenticates with its + // token, which is what AdmitToken already resolved. + err := directory.RecordAgent(gitagent.AgentEnrollment{ + Name: agent, + URL: url, + HostFingerprint: strings.TrimSpace(req.HostFingerprint), + DispatchToken: strings.TrimSpace(req.DispatchToken), + }) + if err != nil { + return nil, err + } + response := offer.ResponseFor(agent) + return &response, nil + } +} + +// gitAgentIdentity resolves which agent a push speaks for. +// +// The token is already verified by the auth middleware; this maps it onto a +// name. A pool token has no single name, so admission allocates or reclaims a +// member slot — which is also what enforces max-agents. +// +// A loopback request carries no token at all. That is deliberate for the API, +// but a push has to name an agent: the ref namespace R8.3 confines it to is +// derived from that name, so an anonymous push would have no namespace. The +// agent it claims comes from a header, and is honoured only on loopback. +func gitAgentIdentity(db *database.DB) func(*http.Request) (string, error) { + return func(r *http.Request) (string, error) { + record, ok := TokenFromContext(r.Context()) + if !ok { + return localGitAgentName(r) + } + return db.AdmitAPITokenAgent(r.Context(), record.ID, r.Header.Get(GitAgentNameHeader)) + } +} + +// GitAgentNameHeader lets a local push declare which agent it is acting as, +// for the loopback case where there is no token to derive it from. +const GitAgentNameHeader = "X-Captain-Agent" + +func localGitAgentName(r *http.Request) (string, error) { + name := r.Header.Get(GitAgentNameHeader) + if err := captaintoken.ValidateName(name); err != nil { + return "", fmt.Errorf("a local push must declare its agent in %s: %w", GitAgentNameHeader, err) + } + return name, nil +} diff --git a/pkg/cli/serve_sandbox.go b/pkg/cli/serve_sandbox.go new file mode 100644 index 00000000..905e1e31 --- /dev/null +++ b/pkg/cli/serve_sandbox.go @@ -0,0 +1,224 @@ +// HTTP surface for sandbox configuration and the git-agent roster. +// +// These are bespoke handlers rather than the auto-generated /api/v1 executor +// routes, for reasons the executor cannot satisfy: `add` mints a single-use +// join token and `revoke` rewrites ~/.captain.yaml, so both must sit behind +// validateLocalConfigurationRequest; and the webapp needs the raw result +// structs, not clicky's rendered command output. The git-agent command group is +// marked local-only (cmd/captain/main.go) precisely so this is the only surface. + +package cli + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "mime" + "net/http" + "strconv" + "strings" + + "github.com/flanksource/captain/pkg/database" +) + +const defaultSandboxBackend = "git-agent" + +func registerSandboxHandlers(mux *http.ServeMux) { + mux.Handle("GET /api/captain/sandboxes", handleSandboxCatalog()) + mux.Handle("GET /api/captain/sandbox/git-agent/agents", handleGitAgentList()) + mux.Handle("POST /api/captain/sandbox/git-agent/agents", handleGitAgentAdd()) + mux.Handle("POST /api/captain/sandbox/git-agent/agents/{name}/whoami", handleGitAgentWhoami()) + mux.Handle("DELETE /api/captain/sandbox/git-agent/agents/{name}", handleGitAgentRevoke()) + mux.Handle("GET /api/captain/sandbox/git-agent/tasks", handleGitAgentTaskList()) + mux.Handle("GET /api/captain/sandbox/git-agent/tasks/{taskId}", handleGitAgentTaskGet()) + registerSandboxDeployHandlers(mux) + registerSandboxCredentialHandlers(mux) +} + +// handleGitAgentTaskList serves remote-run history from the database, which the +// ingest watcher fills from the supervisor's mailbox tree. +func handleGitAgentTaskList() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + db, err := captainDB(r.Context()) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusServiceUnavailable)) + return + } + query := r.URL.Query() + limit := 0 + if raw := strings.TrimSpace(query.Get("limit")); raw != "" { + limit, _ = strconv.Atoi(raw) + } + tasks, err := db.ListGitAgentTasks(r.Context(), database.ListGitAgentTasksFilter{ + Backend: strings.TrimSpace(query.Get("backend")), + Agent: strings.TrimSpace(query.Get("agent")), + Status: database.GitAgentTaskStatus(strings.TrimSpace(query.Get("status"))), + Limit: limit, + }) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + writeServeJSON(w, http.StatusOK, tasks) + }) +} + +// handleGitAgentTaskGet serves one task with its per-attempt verdicts. A task id +// is unique only within its mailbox, so ?mailbox= disambiguates when one id +// exists in more than one. +func handleGitAgentTaskGet() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + taskID := strings.TrimSpace(r.PathValue("taskId")) + if taskID == "" { + http.Error(w, "task id is required", http.StatusBadRequest) + return + } + db, err := captainDB(r.Context()) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusServiceUnavailable)) + return + } + detail, ok, err := db.GetGitAgentTask(r.Context(), + strings.TrimSpace(r.URL.Query().Get("mailbox")), taskID) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + if !ok { + http.Error(w, fmt.Sprintf("task %q not found", taskID), http.StatusNotFound) + return + } + writeServeJSON(w, http.StatusOK, detail) + }) +} + +// sandboxBackendParam resolves the ?backend= selector every git-agent route +// accepts, defaulting to the same backend name the CLI flags default to. +func sandboxBackendParam(r *http.Request) string { + if backend := strings.TrimSpace(r.URL.Query().Get("backend")); backend != "" { + return backend + } + return defaultSandboxBackend +} + +// handleSandboxCatalog serves the same projection the prompt schema embeds, from +// the same builder, so /api/captain/sandboxes and promptSchema.sandboxes cannot +// drift apart. +func handleSandboxCatalog() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeServeJSON(w, http.StatusOK, buildSandboxCatalog(loadSavedConfig().Sandbox)) + }) +} + +func handleGitAgentList() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + result, err := RunGitAgentList(GitAgentListOptions{Backend: sandboxBackendParam(r)}) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +// gitAgentAddRequest is the enrollment body. Endpoint is optional: empty falls +// back to the backend's configured url, matching the CLI flag. +type gitAgentAddRequest struct { + Name string `json:"name"` + Endpoint string `json:"endpoint,omitempty"` + DryRun bool `json:"dryRun,omitempty"` +} + +func handleGitAgentAdd() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + var request gitAgentAddRequest + if err := decodeServeJSONBody(w, r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if strings.TrimSpace(request.Name) == "" { + http.Error(w, "agent name is required", http.StatusBadRequest) + return + } + result, err := RunGitAgentAdd(r.Context(), GitAgentAddOptions{ + Name: request.Name, + Backend: sandboxBackendParam(r), + Endpoint: request.Endpoint, + DryRun: request.DryRun, + }) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +func handleGitAgentRevoke() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + name := strings.TrimSpace(r.PathValue("name")) + if name == "" { + http.Error(w, "agent name is required", http.StatusBadRequest) + return + } + backend := sandboxBackendParam(r) + result, err := RunGitAgentRevoke(GitAgentRevokeOptions{ + Name: name, + Backend: backend, + DryRun: strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("dryRun")), "true"), + }) + if err != nil { + // "not enrolled" / "no enrolled agents" is a missing resource, not a + // malformed request; the UI distinguishes them to decide whether to + // refresh the roster or show a validation error. + http.Error(w, err.Error(), serveRunStatus(err, gitAgentRevokeStatus(err))) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +func gitAgentRevokeStatus(err error) int { + if err == nil { + return http.StatusOK + } + message := err.Error() + if strings.Contains(message, "is not enrolled") || strings.Contains(message, "has no enrolled agents") { + return http.StatusNotFound + } + return http.StatusBadRequest +} + +// sandboxBodyLimit matches providerTokenBodyLimit: these bodies are a handful +// of short strings, so anything larger is a mistake or an attack. +const sandboxBodyLimit = 8 << 10 + +// decodeServeJSONBody reads exactly one strict JSON object, mirroring +// decodeProviderTokenRequest: an unknown key is an error rather than a silently +// ignored field, so a typo'd "endpoint" cannot enroll an agent against the +// wrong address. +func decodeServeJSONBody(w http.ResponseWriter, r *http.Request, target any) error { + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + return fmt.Errorf("Content-Type must be application/json") + } + r.Body = http.MaxBytesReader(w, r.Body, sandboxBodyLimit) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(target); err != nil { + return fmt.Errorf("decode request: %w", err) + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + return fmt.Errorf("request must contain one JSON object") + } + return nil +} diff --git a/pkg/cli/serve_sandbox_deploy.go b/pkg/cli/serve_sandbox_deploy.go new file mode 100644 index 00000000..73ccd081 --- /dev/null +++ b/pkg/cli/serve_sandbox_deploy.go @@ -0,0 +1,368 @@ +// HTTP surface for deploying git-agent sidecars from the web UI. +// +// The CLI's `deploy` is built around refusing rather than guessing: the two +// addresses this topology needs point in opposite directions, and getting +// either wrong produces an agent that enrolls, looks healthy, and fails at the +// first dispatch. A form that only discovers that on submit would reintroduce +// exactly that gap, so the preflight route runs the same detection read-only and +// the UI blocks on it before an operator types anything. + +package cli + +import ( + "context" + "fmt" + "net/http" + "reflect" + "sort" + "strconv" + "strings" + "time" + + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +func registerSandboxDeployHandlers(mux *http.ServeMux) { + mux.Handle("GET /api/captain/sandbox/git-agent/deploy/preflight", handleGitAgentDeployPreflight()) + registerSandboxPickerHandlers(mux) + mux.Handle("POST /api/captain/sandbox/git-agent/deployments", handleGitAgentDeploy()) + mux.Handle("PUT /api/captain/sandbox/git-agent/deployments/{name}", handleGitAgentUpdate()) + mux.Handle("DELETE /api/captain/sandbox/git-agent/deployments/{name}", handleGitAgentUndeploy()) +} + +// gitAgentDeployPreflight is what the UI needs to decide whether a target is +// usable at all, and what to prefill when it is. +type gitAgentDeployPreflight struct { + Target string `json:"target"` + // Ready is false when this host cannot deploy to this target right now. + // Reason says why, in the same words the CLI would refuse with. + Ready bool `json:"ready"` + Reason string `json:"reason,omitempty"` + + // MailboxListen and HostFingerprint identify the supervisor a deployed agent + // would relay to; Transport is the channel it relays over, https when + // `captain serve` hosts the mailbox. All three come from a live probe, not + // from configuration. + MailboxListen string `json:"mailboxListen,omitempty"` + HostFingerprint string `json:"hostFingerprint,omitempty"` + Transport string `json:"transport,omitempty"` + + // Supervisor is the address the deployed agent uses to reach this mailbox, + // and SupervisorFrom how it was derived. Empty with SupervisorRequired set + // means the operator must supply one — no route back can be proven. + Supervisor string `json:"supervisor,omitempty"` + SupervisorFrom string `json:"supervisorFrom,omitempty"` + SupervisorRequired bool `json:"supervisorRequired"` + + // SupervisorCandidates are this host's non-loopback addresses rendered as + // endpoints of the mailbox that answered, so the field SupervisorRequired + // makes mandatory is a picker rather than a blank box. Enumerated rather + // than probed — see supervisorCandidates — so the list is an offer and a + // typed address remains just as valid. + SupervisorCandidates []string `json:"supervisorCandidates,omitempty"` + + Namespace string `json:"namespace,omitempty"` + KubeContext string `json:"kubeContext,omitempty"` + Runtime string `json:"runtime,omitempty"` + + // InCluster and DomainRequired mirror the CLI: outside the cluster there is + // no address to advertise that the supervisor could route to, so the form + // must demand a domain the same way SupervisorRequired makes it demand an + // address. Both are reported so the UI can say WHICH topology it is about to + // create rather than only which flag is missing. + InCluster bool `json:"inCluster"` + DomainRequired bool `json:"domainRequired"` + + // IngressClasses are the classes this cluster actually has. An Ingress naming + // a class no controller implements is accepted and then never routed, so + // turning this into a picker removes the most silent failure in the feature. + // Empty on a Forbidden list, exactly as the namespace picker allows a typed + // value — "none found" and "may not look" both leave the operator typing. + IngressClasses []string `json:"ingressClasses,omitempty"` + + // CertManagerInstalled comes from one discovery call. Without it an + // --ingress-issuer annotation is inert and the controller answers for the + // host with its own default certificate. + CertManagerInstalled bool `json:"certManagerInstalled"` +} + +// handleGitAgentDeployPreflight probes one target without changing anything. +// +// Read-only, so it is not behind validateLocalConfigurationRequest: it reports +// what the CLI would report, and answering "there is no live mailbox" to a +// caller who could already list the roster discloses nothing new. +func handleGitAgentDeployPreflight() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + request, err := parseGitAgentDeployPreflightRequest(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Bounded, because this runs when a modal opens rather than when an + // operator asked for a deploy: `docker info` against a stopped daemon + // can hang for tens of seconds, and a form that sits blank that long + // reads as broken. A timeout is itself a usable answer. + ctx, cancel := context.WithTimeout(r.Context(), preflightTimeout) + defer cancel() + writeServeJSON(w, http.StatusOK, gitAgentDeployPreflightFor(ctx, request)) + }) +} + +type gitAgentDeployPreflightRequest struct { + Target deploy.Target + Backend string + Transport mailboxTransport + KubeContext string +} + +func parseGitAgentDeployPreflightRequest(r *http.Request) (gitAgentDeployPreflightRequest, error) { + query := r.URL.Query() + target, err := deploy.ParseTarget(strings.TrimSpace(query.Get("target"))) + if err != nil { + return gitAgentDeployPreflightRequest{}, err + } + transport, err := parseMailboxTransport(query.Get("transport")) + if err != nil { + return gitAgentDeployPreflightRequest{}, err + } + return gitAgentDeployPreflightRequest{ + Target: target, Backend: sandboxBackendParam(r), Transport: transport, + KubeContext: strings.TrimSpace(query.Get("kubeContext")), + }, nil +} + +// preflightTimeout bounds the probes. Everything here is a local daemon socket, +// a loopback handshake, or one API call to a configured cluster; none of them is +// slow when it is going to succeed at all. +const preflightTimeout = 8 * time.Second + +// gitAgentDeployPreflightFor runs the detection a deploy would run, and reports +// the first thing that would stop it rather than erroring. +// +// A failed preflight is information the UI renders, not a failed request: "no +// live mailbox on this host" is the expected answer on a machine that has never +// run one, and a 500 would make it look like a bug. +func gitAgentDeployPreflightFor(ctx context.Context, request gitAgentDeployPreflightRequest) gitAgentDeployPreflight { + result := gitAgentDeployPreflight{ + Target: string(request.Target), + SupervisorRequired: request.Target == deploy.TargetKubernetes, + } + if request.Target == deploy.TargetDocker { + result.Runtime = dockerHostDescription() + if err := deploy.DockerAvailable(ctx); err != nil { + result.Reason = preflightReason(ctx, err, dockerHostDescription()+" did not answer") + return result + } + } + + // needOffHost mirrors the deploy: a Kubernetes deployment always supplies its + // own supervisor address, so proving this host answers on its LAN address + // would be a probe of something nothing uses. + mailbox, err := detectMailbox(ctx, mailboxDetection{ + Backend: request.Backend, NeedOffHost: request.Target == deploy.TargetDocker, + Transport: request.Transport, + }) + if err != nil { + result.Reason = preflightReason(ctx, err, "the mailbox probe did not finish") + return result + } + result.MailboxListen, result.HostFingerprint = mailbox.Listen, mailbox.HostFingerprint + result.Transport = string(mailbox.Transport) + result.SupervisorCandidates = supervisorCandidates(mailbox) + + if request.Target == deploy.TargetKubernetes { + client, namespace, err := kubernetesClient(kubeClientOptions{Context: request.KubeContext}) + if err != nil { + result.Reason = err.Error() + return result + } + result.Namespace = namespace + result.KubeContext = request.KubeContext + // A version call rather than a client construction: a kubeconfig that + // parses but points at a cluster that is gone would otherwise pass + // preflight and fail at apply, after the token is already minted. + version, err := client.Discovery().ServerVersion() + if err != nil { + result.Reason = fmt.Sprintf("the kubeconfig resolves but the cluster is unreachable: %v", err) + return result + } + result.Runtime = "kubernetes " + version.GitVersion + // The domain is required for exactly the reason the supervisor address is: + // captain cannot prove a route it did not create. + result.InCluster = runningInCluster() + result.DomainRequired = !result.InCluster + result.IngressClasses = listIngressClasses(ctx, client) + _, err = client.Discovery().ServerResourcesForGroupVersion("cert-manager.io/v1") + result.CertManagerInstalled = err == nil + // Deliberately ready without a supervisor address: the operator supplies + // it, and SupervisorRequired tells the form to demand one. Resolving it + // here would only produce the refusal the CLI already gives. + result.Ready = true + return result + } + + supervisor, from, err := resolveSupervisorAddress(request.Target, mailbox, "") + if err != nil { + result.Reason = err.Error() + return result + } + // The certificate has to cover the name the agent will dial. Left to the + // deploy, this surfaces after the token is minted; left to the agent, it + // surfaces as a TLS error on the first relay. + if err := verifySupervisorNameIsCovered(ctx, mailbox, supervisor); err != nil { + result.Reason = preflightReason(ctx, err, "the certificate probe did not finish") + return result + } + result.Supervisor, result.SupervisorFrom, result.Ready = supervisor, from, true + return result +} + +// gitAgentDeployRequest is the subset of the CLI's flags the UI exposes. +// +// Deliberately a subset: the command has thirty flags and most are sizing and +// security defaults that are already correct, so a form rendering all of them +// would be worse than the CLI rather than better. What is here is what cannot +// be defaulted — an identity, a route the runtime cannot prove, and the model +// credentials without which the agent enrolls and then fails its first task. +type gitAgentDeployRequest struct { + Name string `json:"name"` + GitAgentDeploymentConfig + CreateNamespace bool `json:"createNamespace,omitempty"` + Replace bool `json:"replace,omitempty"` + DryRun bool `json:"dryRun,omitempty"` +} + +// options merges the request over the CLI's own defaults, so the UI and the +// command deploy the same thing when the UI leaves a field blank. +func (req gitAgentDeployRequest) options(backend string) GitAgentDeployOptions { + opts := req.GitAgentDeploymentConfig.options(strings.TrimSpace(req.Name), backend) + opts.Replace, opts.DryRun, opts.CreateNamespace = req.Replace, req.DryRun, req.CreateNamespace + return opts +} + +func handleGitAgentDeploy() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + var request gitAgentDeployRequest + if err := decodeServeJSONBody(w, r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if strings.TrimSpace(request.Name) == "" { + http.Error(w, "agent name is required", http.StatusBadRequest) + return + } + result, err := RunGitAgentDeploy(r.Context(), request.options(sandboxBackendParam(r))) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +func handleGitAgentUndeploy() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + name := strings.TrimSpace(r.PathValue("name")) + if name == "" { + http.Error(w, "agent name is required", http.StatusBadRequest) + return + } + query := r.URL.Query() + result, err := RunGitAgentUndeploy(r.Context(), GitAgentUndeployOptions{ + Name: name, + Backend: sandboxBackendParam(r), + // Empty resolves to whatever deploy recorded, which is what the UI + // wants: tearing down the wrong runtime removes nothing and reports + // success, leaving a live sidecar on the network. + Target: strings.TrimSpace(query.Get("target")), + Purge: queryFlag(query.Get("purge")), + KeepEnrollment: queryFlag(query.Get("keepEnrollment")), + DryRun: queryFlag(query.Get("dryRun")), + }) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +// preflightReason keeps a probe that ran out of time from reading as a probe +// that answered. "docker is not reachable" and "docker did not answer in 8s" +// call for different next steps, and only one of them means anything is wrong. +func preflightReason(ctx context.Context, err error, timedOut string) string { + if ctx.Err() != nil { + return fmt.Sprintf("%s within %s; re-check once it is up", timedOut, preflightTimeout) + } + return err.Error() +} + +func queryFlag(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "true") +} + +// defaultGitAgentDeployOptions is the options struct the CLI would build from +// its own flag defaults. +// +// Read from the `default:` struct tags rather than restated here, because a +// restated copy drifts: the UI would keep deploying a 4Gi memory limit after the +// flag moved on, and nothing would fail to say so. +func defaultGitAgentDeployOptions() GitAgentDeployOptions { + var opts GitAgentDeployOptions + applyStructDefaults(&opts) + return opts +} + +// applyStructDefaults fills a struct's fields from their `default:` tags. It +// covers the kinds a CLI options struct uses; anything else keeps its zero +// value, which is what an untagged field would get from the flag binder too. +func applyStructDefaults(target any) { + value := reflect.ValueOf(target).Elem() + structType := value.Type() + for i := range structType.NumField() { + tag := structType.Field(i).Tag.Get("default") + if tag == "" || !value.Field(i).CanSet() { + continue + } + field := value.Field(i) + switch field.Kind() { + case reflect.String: + field.SetString(tag) + case reflect.Bool: + if parsed, err := strconv.ParseBool(tag); err == nil { + field.SetBool(parsed) + } + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + if parsed, err := strconv.ParseInt(tag, 10, 64); err == nil { + field.SetInt(parsed) + } + } + } +} + +// listIngressClasses reports the controllers this cluster has, or nothing when +// it will not say. An empty list is not a refusal: the operator can still type a +// class, the same way the namespace picker allows one. +func listIngressClasses(ctx context.Context, client kubernetes.Interface) []string { + list, err := client.NetworkingV1().IngressClasses().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil + } + names := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + names = append(names, item.Name) + } + sort.Strings(names) + return names +} diff --git a/pkg/cli/serve_sandbox_deploy_test.go b/pkg/cli/serve_sandbox_deploy_test.go new file mode 100644 index 00000000..d93ff4d3 --- /dev/null +++ b/pkg/cli/serve_sandbox_deploy_test.go @@ -0,0 +1,450 @@ +package cli + +import ( + "context" + "encoding/json" + "errors" + "net" + "net/http" + "net/url" + "slices" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// gitAgentEnrollmentFor is a complete, dispatchable enrollment — RecordAgent +// refuses one missing an endpoint or a host key. +func gitAgentEnrollmentFor(name string) gitagent.AgentEnrollment { + return gitagent.AgentEnrollment{ + Name: name, Fingerprint: "SHA256:" + name, + URL: "ssh://" + name + ":7422/repo.git", HostFingerprint: "SHA256:host-" + name, + } +} + +// The preflight is what the UI blocks on, so a host that cannot deploy has to +// say so as data rather than as a failed request: "no mailbox has served here" +// is the expected answer on a fresh machine, and a 500 would read as a bug. +// +// Targeted at kubernetes rather than docker so the assertion is about the +// mailbox: the docker branch probes a daemon first, and on a machine without one +// the reason under test would be replaced by "docker did not answer". +func TestDeployPreflightReportsRefusalAsData(t *testing.T) { + isolatedConfig(t) + + preflight := decodePreflight(t, "kubernetes") + if preflight.Ready { + t.Fatal("a host with no mailbox recorded must not report itself ready to deploy") + } + if preflight.Reason == "" { + t.Fatal("a refusal with no reason gives an operator nothing to act on") + } + // The refusal names both ways to fix it, which is the whole reason it is + // surfaced rather than left for the deploy to discover. + for _, want := range []string{"captain serve", "serve --role mailbox"} { + if !strings.Contains(preflight.Reason, want) { + t.Fatalf("reason %q should name %q", preflight.Reason, want) + } + } +} + +// Kubernetes cannot prove a route back to this host, so the address is the +// operator's to supply. The flag tells the form to demand one rather than +// letting a deploy fail after the token is minted. +func TestDeployPreflightMarksKubernetesSupervisorRequired(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/deploy/preflight?target=kubernetes", "")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + var preflight gitAgentDeployPreflight + if err := json.Unmarshal(w.Body.Bytes(), &preflight); err != nil { + t.Fatalf("decode preflight: %v", err) + } + if !preflight.SupervisorRequired { + t.Fatal("kubernetes must require an explicit supervisor address") + } + if preflight.Target != string(deploy.TargetKubernetes) { + t.Fatalf("target = %q", preflight.Target) + } +} + +// A probe that ran out of time and a probe that answered "no" call for +// different next steps, and only one of them means something is wrong. The +// preflight runs on modal open, and `docker info` against a stopped daemon can +// hang for tens of seconds. +func TestPreflightDistinguishesATimeoutFromARefusal(t *testing.T) { + refused := errors.New("docker daemon is not reachable") + + if got := preflightReason(t.Context(), refused, "docker did not answer"); got != refused.Error() { + t.Fatalf("a live context must report the real error, got %q", got) + } + + expired, cancel := context.WithCancel(t.Context()) + cancel() + got := preflightReason(expired, refused, "docker did not answer") + if !strings.Contains(got, "docker did not answer") || !strings.Contains(got, "re-check") { + t.Fatalf("a timed-out probe must say so and what to do, got %q", got) + } + if strings.Contains(got, "not reachable") { + t.Fatalf("a timeout must not be reported as a refusal, got %q", got) + } +} + +// The mailbox `captain serve` hosts has to be visible to the UI as the one that +// answered, or an operator on an https supervisor is told to start a second +// process they do not need. The kubernetes target is used because it reaches the +// mailbox probe without a docker daemon. +func TestDeployPreflightReportsTheHTTPSMailboxItProbed(t *testing.T) { + isolatedConfig(t) + listen, pin := serveTLSPresenting(t) + recordMailbox(t, "git-agent", mailboxRecord{ + Transport: transportHTTPS, Listen: listen, Identity: pin, Encrypted: true, + }) + + preflight := decodePreflight(t, "kubernetes") + if preflight.Transport != string(transportHTTPS) { + t.Fatalf("transport = %q, want https", preflight.Transport) + } + if preflight.MailboxListen != listen || preflight.HostFingerprint != pin { + t.Fatalf("preflight = %+v, want the probed address and pin", preflight) + } +} + +// The kubernetes form makes the supervisor address mandatory, so the preflight +// offers this host's own addresses to fill it with. What the count is depends on +// the machine — a sandboxed runner may hold none — so the contract asserted here +// is that every offer is directly usable: the probed mailbox's scheme and port, +// and never loopback, which is the one address a pod certainly cannot reach. +func TestDeployPreflightOffersThisHostsAddressesForTheSupervisor(t *testing.T) { + isolatedConfig(t) + listen, pin := serveTLSPresenting(t) + recordMailbox(t, "git-agent", mailboxRecord{ + Transport: transportHTTPS, Listen: listen, Identity: pin, Encrypted: true, + }) + _, port, err := net.SplitHostPort(listen) + if err != nil { + t.Fatal(err) + } + + candidates := decodePreflight(t, "kubernetes").SupervisorCandidates + // Guard rather than assertion, so the checks below are never vacuous on a + // machine that holds addresses and never fail on a runner that holds none. + if held, err := hostInterfaceIPs(); err != nil || len(usableHostIPs(held)) == 0 { + t.Skip("this host holds no address outside loopback, so there is nothing to offer") + } + if len(candidates) == 0 { + t.Fatal("this host holds a usable address but the preflight offered none") + } + + for _, candidate := range candidates { + parsed, err := url.Parse(candidate) + if err != nil { + t.Errorf("candidate %q is not a URL: %v", candidate, err) + continue + } + if parsed.Scheme != string(transportHTTPS) || parsed.Port() != port { + t.Errorf("candidate %q does not address the probed mailbox on %s", candidate, listen) + } + if ip := net.ParseIP(parsed.Hostname()); ip == nil || ip.IsLoopback() { + t.Errorf("candidate %q is not a non-loopback address of this host", candidate) + } + } +} + +// The refusal a default `captain serve` earns. It is the whole point of +// recording the unusable state: the operator has a supervisor running and needs +// two flags, not a second process. +func TestDeployPreflightRefusesAPlainHTTPServe(t *testing.T) { + isolatedConfig(t) + recordMailbox(t, "git-agent", mailboxRecord{Transport: transportHTTPS, Listen: "localhost:9020"}) + + preflight := decodePreflight(t, "kubernetes") + if preflight.Ready { + t.Fatal("a mailbox serving plain HTTP must not report itself deployable") + } + for _, want := range []string{"plain HTTP", "--tls"} { + if !strings.Contains(preflight.Reason, want) { + t.Fatalf("reason = %q, want it to name %q", preflight.Reason, want) + } + } +} + +func decodePreflight(t *testing.T, target string) gitAgentDeployPreflight { + t.Helper() + w := serveSandbox(t, loopbackRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/deploy/preflight?target="+target, "")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + var preflight gitAgentDeployPreflight + if err := json.Unmarshal(w.Body.Bytes(), &preflight); err != nil { + t.Fatalf("decode preflight: %v", err) + } + return preflight +} + +func TestDeployPreflightRejectsAnUnknownTarget(t *testing.T) { + isolatedConfig(t) + + for _, target := range []string{"", "podman", "Docker%20Swarm"} { + w := serveSandbox(t, loopbackRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/deploy/preflight?target="+target, "")) + if w.Code != http.StatusBadRequest { + t.Fatalf("target %q: status = %d, want 400", target, w.Code) + } + } +} + +// The deploy and undeploy routes create and destroy real infrastructure, so +// they stay on the same loopback-only footing as the rest of the configuration +// surface rather than riding the roster's read-only exemption. +func TestDeployRoutesAreLoopbackOnly(t *testing.T) { + isolatedConfig(t) + + deployRequest := loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/deployments", `{"name":"worker-01","target":"docker"}`) + deployRequest.RemoteAddr = "10.1.2.3:54321" + if w := serveSandbox(t, deployRequest); w.Code != http.StatusForbidden { + t.Fatalf("remote deploy status = %d, want 403", w.Code) + } + + undeployRequest := loopbackRequest(http.MethodDelete, + "/api/captain/sandbox/git-agent/deployments/worker-01", "") + undeployRequest.RemoteAddr = "10.1.2.3:54321" + if w := serveSandbox(t, undeployRequest); w.Code != http.StatusForbidden { + t.Fatalf("remote undeploy status = %d, want 403", w.Code) + } +} + +func TestDeployRouteRejectsAMissingName(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/deployments", `{"target":"docker"}`)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } +} + +// A blank field in the form means "leave it alone", so the request omits it and +// the CLI's own default applies. Restating the defaults in TypeScript would let +// the UI keep deploying a stale sizing after the flag moved on. +func TestDeployRequestFallsBackToCLIDefaults(t *testing.T) { + opts := gitAgentDeployRequest{ + Name: "worker-01", GitAgentDeploymentConfig: GitAgentDeploymentConfig{Target: "docker"}, + }.options("git-agent") + + if opts.Image != "ghcr.io/flanksource/captain:latest" { + t.Fatalf("image = %q", opts.Image) + } + if opts.MemoryLimit != "4Gi" || opts.CPULimit != "2" || opts.Storage != "20Gi" { + t.Fatalf("sizing = %q / %q / %q", opts.CPULimit, opts.MemoryLimit, opts.Storage) + } + if opts.ListenPort != 7422 || opts.RunAsUser != 501 || opts.PidsLimit != 1024 { + t.Fatalf("numeric defaults = %d / %d / %d", opts.ListenPort, opts.RunAsUser, opts.PidsLimit) + } + if !opts.Wait || !opts.ReadOnlyRoot { + t.Fatal("boolean defaults must survive; a deploy that does not wait or writes a mutable root is a different deployment") + } + + overridden := gitAgentDeployRequest{ + Name: "worker-01", GitAgentDeploymentConfig: GitAgentDeploymentConfig{ + Target: "docker", MemoryLimit: "8Gi", Image: " ", + }, + }.options("git-agent") + if overridden.MemoryLimit != "8Gi" { + t.Fatalf("explicit memory limit = %q", overridden.MemoryLimit) + } + if overridden.Image != "ghcr.io/flanksource/captain:latest" { + t.Fatalf("a blank override must not blank the default, got %q", overridden.Image) + } +} + +// The agent-login Secret is the difference between a sidecar that can reach a +// model provider and one that enrolls and then fails its first dispatch, so the +// UI's value has to survive the hop into the CLI's options. +func TestDeployRequestCarriesTheCredentialsSecret(t *testing.T) { + opts := gitAgentDeployRequest{ + Name: "worker-01", GitAgentDeploymentConfig: GitAgentDeploymentConfig{ + Target: "kubernetes", Namespace: "agents", + CredentialsSecret: " captain-agent-credentials ", + }, + }.options("git-agent") + + if opts.CredentialsSecret != "captain-agent-credentials" { + t.Fatalf("credentials secret = %q, want it trimmed and carried", opts.CredentialsSecret) + } + if describeCredentials(opts) == "" || strings.Contains(describeCredentials(opts), "none declared") { + t.Fatalf("a deploy with an agent-login Secret still reports no credentials: %q", describeCredentials(opts)) + } +} + +// Creating a namespace is the one cluster-scoped change a deploy makes, and it +// outlives an undeploy — so it travels as an explicit intent from the form +// rather than being inferred server-side from a name that is merely absent. +func TestDeployRequestCarriesTheCreateNamespaceIntent(t *testing.T) { + plain := gitAgentDeployRequest{ + Name: "worker-01", GitAgentDeploymentConfig: GitAgentDeploymentConfig{ + Target: "kubernetes", Namespace: "agents", + }, + } + if plain.options("git-agent").CreateNamespace { + t.Fatal("a namespace was created without being asked for") + } + if got := plain.options("git-agent").Namespace; got != "agents" { + t.Fatalf("namespace = %q", got) + } + + creating := plain + creating.CreateNamespace = true + opts := creating.options("git-agent") + if !opts.CreateNamespace { + t.Fatal("the create intent did not reach the deploy options") + } + + // The dry run has to name it: an operator previewing a deploy should see the + // change that undeploy will not reverse. + mutations := deployMutations(deploy.Plan{Name: "w", Backend: "git-agent", Target: deploy.TargetKubernetes}, opts) + if !slices.ContainsFunc(mutations, func(m string) bool { + return strings.Contains(m, "create namespace agents") + }) { + t.Fatalf("mutations do not mention creating the namespace: %v", mutations) + } + if slices.ContainsFunc(deployMutations(deploy.Plan{Name: "w", Backend: "git-agent", Target: deploy.TargetKubernetes}, + plain.options("git-agent")), func(m string) bool { + return strings.Contains(m, "create namespace") + }) { + t.Fatal("a deploy that creates nothing must not say it would") + } +} + +// Undeploy against the wrong runtime removes nothing and reports success, +// leaving a live sidecar on the network holding a valid key and a checkout of +// the source tree. So the target comes from what deploy recorded. +func TestUndeployTargetComesFromTheDeploymentRecord(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + if _, err := resolveUndeployTarget(backend, "worker-01", ""); err == nil || + !strings.Contains(err.Error(), "no record of deploying") { + t.Fatalf("an unrecorded agent must not be guessed at, got %v", err) + } + + plan := deploy.Plan{Name: "worker-01", Backend: backend, Target: deploy.TargetKubernetes} + opts := deployOptions(plan.Name) + opts.Target = string(plan.Target) + if err := recordDeployment(plan, opts, "captain"); err != nil { + t.Fatal(err) + } + + target, err := resolveUndeployTarget(backend, "worker-01", "") + if err != nil { + t.Fatal(err) + } + if target != deploy.TargetKubernetes { + t.Fatalf("target = %q, want kubernetes", target) + } + + // An explicit target that contradicts the record is refused rather than + // obeyed: obeying it would silently tear down nothing. + if _, err := resolveUndeployTarget(backend, "worker-01", "docker"); err == nil || + !strings.Contains(err.Error(), "was deployed on kubernetes") { + t.Fatalf("a contradicting target must be refused, got %v", err) + } + if _, err := resolveUndeployTarget(backend, "worker-01", "kubernetes"); err != nil { + t.Fatalf("an agreeing target must be accepted: %v", err) + } + + recorded, ok := lookupDeployment(backend, "worker-01") + if !ok || recorded.Namespace != "captain" || recorded.Workload != plan.WorkloadName() { + t.Fatalf("recorded = %+v, ok = %v", recorded, ok) + } + + if err := forgetDeployment(backend, "worker-01"); err != nil { + t.Fatal(err) + } + if _, ok := lookupDeployment(backend, "worker-01"); ok { + t.Fatal("a torn-down deployment must leave no record to offer again") + } +} + +// A workload that has been placed but has not finished joining is invisible in +// the roster otherwise, which leaves an operator with a running sidecar and no +// way to remove it from the UI. +func TestRosterShowsDeployedAgentsBeforeTheyEnroll(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + plan := deploy.Plan{Name: "worker-09", Backend: backend, Target: deploy.TargetDocker} + if err := recordDeployment(plan, deployOptions(plan.Name), ""); err != nil { + t.Fatal(err) + } + + res, err := RunGitAgentList(GitAgentListOptions{Backend: backend}) + if err != nil { + t.Fatal(err) + } + entries := res.([]GitAgentListEntry) + if len(entries) != 1 { + t.Fatalf("entries = %+v", entries) + } + if entries[0].Name != "worker-09" || !strings.Contains(entries[0].Status, "waiting to enroll") { + t.Fatalf("entry = %+v", entries[0]) + } + if entries[0].Deployment == nil || entries[0].Deployment.Target != string(deploy.TargetDocker) { + t.Fatalf("the roster must carry the runtime so the UI can offer to tear it down: %+v", entries[0]) + } + + // Once it enrolls it appears once, not twice, and keeps its deployment. + dir := gitAgentDirectory{backend: backend} + if err := dir.RecordAgent(gitAgentEnrollmentFor("worker-09")); err != nil { + t.Fatal(err) + } + res, err = RunGitAgentList(GitAgentListOptions{Backend: backend}) + if err != nil { + t.Fatal(err) + } + entries = res.([]GitAgentListEntry) + if len(entries) != 1 { + t.Fatalf("an enrolled deployment must not be listed twice: %+v", entries) + } + if entries[0].Status != "enrolled" || entries[0].Deployment == nil { + t.Fatalf("entry = %+v", entries[0]) + } +} + +// An agent captain did not place has no recorded runtime, so the UI must not be +// told it can tear it down. +func TestRosterOmitsDeploymentForASelfManagedAgent(t *testing.T) { + isolatedConfig(t) + const backend = "git-agent" + + dir := gitAgentDirectory{backend: backend} + if err := dir.RecordAgent(gitAgentEnrollmentFor("worker-01")); err != nil { + t.Fatal(err) + } + res, err := RunGitAgentList(GitAgentListOptions{Backend: backend}) + if err != nil { + t.Fatal(err) + } + entries := res.([]GitAgentListEntry) + if len(entries) != 1 || entries[0].Deployment != nil { + t.Fatalf("entries = %+v", entries) + } + + // And the config carries no deployments block at all, so nothing downstream + // can mistake an absent record for an empty one. + cfg, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + if _, exists := cfg.Sandbox.Backends[backend].Options["deployments"]; exists { + t.Fatal("a self-managed enrollment must not create a deployments block") + } +} diff --git a/pkg/cli/serve_sandbox_pickers.go b/pkg/cli/serve_sandbox_pickers.go new file mode 100644 index 00000000..0088f587 --- /dev/null +++ b/pkg/cli/serve_sandbox_pickers.go @@ -0,0 +1,160 @@ +// The cluster resources the deploy form offers instead of asking an operator to +// recall a name. +// +// Every field here names something that must already exist in the cluster, and +// every one of them fails the same way when it is wrong: the deploy succeeds, +// the objects are created, and the mistake surfaces at the first push — an +// Ingress whose TLS Secret holds no certificate for the host, or an issuer +// annotation no controller answers to. A typed name is still accepted, because +// a list this cannot read is not proof the name is wrong. + +package cli + +import ( + "context" + "fmt" + "net/http" + "sort" + "strings" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +func registerSandboxPickerHandlers(mux *http.ServeMux) { + mux.Handle("GET /api/captain/sandbox/git-agent/namespaces", handleGitAgentNamespaces()) + mux.Handle("GET /api/captain/sandbox/git-agent/secrets", handleGitAgentSecrets()) + mux.Handle("GET /api/captain/sandbox/git-agent/cluster-issuers", handleGitAgentClusterIssuers()) +} + +// handleGitAgentNamespaces lists the namespaces a kubernetes deploy could target. +// +// A bare JSON array, which is what clicky-ui's NamespacePicker loadNamespaces +// getter consumes. Failures are reported as failures rather than as an empty +// cluster — "no namespaces" and "no kubeconfig" are different answers, and the +// form's picker allows a typed value either way. +func handleGitAgentNamespaces() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), preflightTimeout) + defer cancel() + names, err := listKubernetesNamespaces(ctx, r.URL.Query().Get("kubeContext")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeServeJSON(w, http.StatusOK, names) + }) +} + +func listKubernetesNamespaces(ctx context.Context, kubeContext string) ([]string, error) { + client, _, err := kubernetesClient(kubeClientOptions{Context: strings.TrimSpace(kubeContext)}) + if err != nil { + return nil, err + } + list, err := client.CoreV1().Namespaces().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing namespaces: %w", err) + } + names := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + names = append(names, item.Name) + } + sort.Strings(names) + return names, nil +} + +// handleGitAgentSecrets lists the Secrets a deploy could name. +// +// `type` narrows to one Secret type, which is what makes the certificate field +// a picker rather than a text box: a Secret that is not kubernetes.io/tls +// cannot serve the agent's host, and the Ingress would be created pointing at +// it anyway. Empty lists them all, for the agent-login field. +func handleGitAgentSecrets() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), preflightTimeout) + defer cancel() + query := r.URL.Query() + names, err := listKubernetesSecrets(ctx, kubeClientOptions{ + Context: strings.TrimSpace(query.Get("kubeContext")), + // Empty falls through to the kubeconfig context's own namespace, + // which is the same default the form's namespace field shows. + Namespace: strings.TrimSpace(query.Get("namespace")), + }, strings.TrimSpace(query.Get("type"))) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeServeJSON(w, http.StatusOK, names) + }) +} + +func listKubernetesSecrets(ctx context.Context, opts kubeClientOptions, secretType string) ([]string, error) { + client, namespace, err := kubernetesClient(opts) + if err != nil { + return nil, err + } + list, err := client.CoreV1().Secrets(namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, fmt.Errorf("listing secrets in %s: %w", namespace, err) + } + names := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + if secretType != "" && string(item.Type) != secretType { + continue + } + names = append(names, item.Name) + } + sort.Strings(names) + return names, nil +} + +// handleGitAgentClusterIssuers lists the cert-manager issuers that could mint +// the agent's certificate. +// +// A name no ClusterIssuer answers to leaves the Ingress with an annotation +// nothing acts on: the controller serves its own default certificate and the +// supervisor's first push fails verification, long after the deploy reported +// success. Empty means cert-manager is absent or unreadable, and the field +// still accepts a typed name. +func handleGitAgentClusterIssuers() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx, cancel := context.WithTimeout(r.Context(), preflightTimeout) + defer cancel() + names, err := listClusterIssuers(ctx, strings.TrimSpace(r.URL.Query().Get("kubeContext"))) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeServeJSON(w, http.StatusOK, names) + }) +} + +// clusterIssuerResource is cert-manager's cluster-scoped issuer. +var clusterIssuerResource = schema.GroupVersionResource{ + Group: "cert-manager.io", Version: "v1", Resource: "clusterissuers", +} + +func listClusterIssuers(ctx context.Context, kubeContext string) ([]string, error) { + client, err := kubernetesDynamicClient(kubeClientOptions{Context: kubeContext}) + if err != nil { + return nil, err + } + list, err := client.Resource(clusterIssuerResource).List(ctx, metav1.ListOptions{}) + if err != nil { + // A cluster without cert-manager has no such resource, which is a fact + // about the cluster rather than a failure: certManagerInstalled already + // tells the form to steer towards an existing Secret. + if apierrors.IsNotFound(err) || meta.IsNoMatchError(err) || apierrors.IsForbidden(err) { + return nil, nil + } + return nil, fmt.Errorf("listing cluster issuers: %w", err) + } + names := make([]string, 0, len(list.Items)) + for _, item := range list.Items { + names = append(names, item.GetName()) + } + sort.Strings(names) + return names, nil +} diff --git a/pkg/cli/serve_sandbox_test.go b/pkg/cli/serve_sandbox_test.go new file mode 100644 index 00000000..41c82c91 --- /dev/null +++ b/pkg/cli/serve_sandbox_test.go @@ -0,0 +1,338 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/commons-db/dbtest" +) + +func sandboxMux() *http.ServeMux { + mux := http.NewServeMux() + registerSandboxHandlers(mux) + return mux +} + +// loopbackRequest mimics a same-origin browser call from the local webapp, which +// is what validateLocalConfigurationRequest admits. +func loopbackRequest(method, target string, body string) *http.Request { + var reader *strings.Reader + if body == "" { + reader = strings.NewReader("") + } else { + reader = strings.NewReader(body) + } + r := httptest.NewRequest(method, target, reader) + r.RemoteAddr = "127.0.0.1:54321" + r.Host = "127.0.0.1:9020" + if body != "" { + r.Header.Set("Content-Type", "application/json") + } + return r +} + +func serveSandbox(t *testing.T, r *http.Request) *httptest.ResponseRecorder { + t.Helper() + w := httptest.NewRecorder() + sandboxMux().ServeHTTP(w, r) + return w +} + +func TestSandboxCatalogRouteServesEveryAdapter(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodGet, "/api/captain/sandboxes", "")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + var catalog SandboxCatalog + if err := json.Unmarshal(w.Body.Bytes(), &catalog); err != nil { + t.Fatalf("decode catalog: %v", err) + } + byKind := map[string]SandboxCatalogEntry{} + for _, entry := range catalog.Kinds { + byKind[entry.Kind] = entry + } + for _, kind := range []string{"none", "srt", "container", "git-agent"} { + if _, ok := byKind[kind]; !ok { + t.Errorf("catalog missing %q", kind) + } + } + if got := byKind["git-agent"].Capabilities; len(got) == 0 { + t.Error("git-agent must advertise its capabilities so the editor can gate the agent picker") + } +} + +// An empty roster must serialize as [] rather than null so the page can iterate +// unconditionally — the same invariant RunGitAgentList guarantees for the CLI. +func TestGitAgentAgentsRouteEmitsAnArrayWhenEmpty(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodGet, "/api/captain/sandbox/git-agent/agents", "")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + if got := strings.TrimSpace(w.Body.String()); got != "[]" { + t.Errorf("body = %s, want []", got) + } +} + +func TestGitAgentAddRouteReturnsTheJoinHandOff(t *testing.T) { + isolatedConfig(t) + gitAgentTokenDB(t) + + w := serveSandbox(t, loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/agents", `{"name":"worker-01"}`)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + var result GitAgentAddResult + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode add result: %v", err) + } + if !strings.Contains(result.JoinCommand, "--token ") { + t.Errorf("join command = %q, want a captain token", result.JoinCommand) + } + if result.HostFingerprint == "" || !strings.Contains(result.JoinCommand, result.HostFingerprint) { + t.Errorf("join command must pin the host key: %+v", result) + } + // A7.1: the hand-off carries a token, never key material. + if strings.Contains(w.Body.String(), "PRIVATE KEY") { + t.Error("enrollment response leaked a private key") + } + // The public handle identifies the credential in listings and revocations; + // the secret itself must not cross this boundary. + if result.TokenID == "" { + t.Error("the hand-off must name the token so the UI can revoke it") + } + if strings.Contains(w.Body.String(), `"token"`) { + t.Errorf("the raw token crossed the JSON boundary: %s", w.Body) + } +} + +func TestGitAgentAddRouteDryRunLeavesConfigUntouched(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/agents", `{"name":"worker-01","dryRun":true}`)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + var result GitAgentAddResult + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode add result: %v", err) + } + if !result.DryRun { + t.Error("result must report that it was a dry run") + } + cfg, _, err := captainconfig.Load() + if err != nil { + t.Fatalf("load config: %v", err) + } + if backend, ok := cfg.Sandbox.Backends["git-agent"]; ok { + if pending, _ := backend.Options["pending"].(map[string]any); len(pending) > 0 { + t.Errorf("dry run recorded a pending enrollment: %+v", pending) + } + } +} + +func TestGitAgentAddRouteRejectsAMissingName(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/agents", `{"name":" "}`)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body) + } +} + +// A typo'd key must not be silently ignored: dropping "endpoint" would enroll +// the agent against the backend default instead of the address the caller asked +// for, and the mistake would only surface at first dispatch. +func TestGitAgentAddRouteRejectsUnknownFields(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodPost, + "/api/captain/sandbox/git-agent/agents", `{"name":"worker-01","endpiont":"ssh://x:7422"}`)) + if w.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body = %s", w.Code, w.Body) + } +} + +func TestGitAgentRevokeRouteRemovesAnEnrolledAgent(t *testing.T) { + isolatedConfig(t) + if err := captainconfig.Update(func(cfg *captainconfig.Config) error { + cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{ + "git-agent": {Kind: "git-agent", Options: map[string]any{ + "agents": map[string]any{"worker-01": map[string]any{"fingerprint": "SHA256:aaa"}}, + }}, + } + return nil + }); err != nil { + t.Fatalf("seed config: %v", err) + } + + w := serveSandbox(t, loopbackRequest(http.MethodDelete, + "/api/captain/sandbox/git-agent/agents/worker-01", "")) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, body = %s", w.Code, w.Body) + } + var result GitAgentRevokeResult + if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil { + t.Fatalf("decode revoke result: %v", err) + } + if !result.Revoked || result.Fingerprint != "SHA256:aaa" { + t.Errorf("revoke result = %+v, want the revoked fingerprint", result) + } +} + +func TestGitAgentRevokeRouteIsNotFoundForAnUnknownAgent(t *testing.T) { + isolatedConfig(t) + + w := serveSandbox(t, loopbackRequest(http.MethodDelete, + "/api/captain/sandbox/git-agent/agents/ghost", "")) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", w.Code, w.Body) + } +} + +// Enrollment mints a credential and revocation rewrites ~/.captain.yaml, so both +// carry the same loopback + same-origin guard as the provider-token routes. A +// page on another origin must not be able to drive them. +func TestSandboxMutatingRoutesRejectRemoteAndCrossOrigin(t *testing.T) { + isolatedConfig(t) + + cases := []struct { + name string + mutate func(*http.Request) + }{ + {"remote client", func(r *http.Request) { r.RemoteAddr = "203.0.113.7:44321" }}, + {"non-loopback host", func(r *http.Request) { r.Host = "captain.example.com" }}, + {"cross origin", func(r *http.Request) { r.Header.Set("Origin", "https://evil.example") }}, + } + for _, tc := range cases { + t.Run("add/"+tc.name, func(t *testing.T) { + r := loopbackRequest(http.MethodPost, "/api/captain/sandbox/git-agent/agents", `{"name":"worker-01"}`) + tc.mutate(r) + if w := serveSandbox(t, r); w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body = %s", w.Code, w.Body) + } + }) + t.Run("revoke/"+tc.name, func(t *testing.T) { + r := loopbackRequest(http.MethodDelete, "/api/captain/sandbox/git-agent/agents/worker-01", "") + tc.mutate(r) + if w := serveSandbox(t, r); w.Code != http.StatusForbidden { + t.Errorf("status = %d, want 403; body = %s", w.Code, w.Body) + } + }) + } + + // The read-only catalog and roster stay reachable: they expose no secrets and + // the dashboard may be viewed from another host. + r := loopbackRequest(http.MethodGet, "/api/captain/sandbox/git-agent/agents", "") + r.RemoteAddr = "203.0.113.7:44321" + if w := serveSandbox(t, r); w.Code != http.StatusOK { + t.Errorf("read-only roster status = %d, want 200", w.Code) + } +} + +// taskRouteDB points the default database context at an embedded postgres so +// the history routes have something to read. +func taskRouteDB(t *testing.T) *database.DB { + t.Helper() + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_sandbox_routes"}) + db, err := database.Open(t.Context(), database.WithDSN(handle.DSN()), database.WithMigrations()) + if err != nil { + t.Fatalf("open database: %v", err) + } + setCaptainDBForTest(db) + t.Cleanup(func() { + setCaptainDBForTest(nil) + resetCaptainContextsForTest() + _ = db.Close() + }) + return db +} + +func TestGitAgentTaskRoutesServeHistory(t *testing.T) { + isolatedConfig(t) + db := taskRouteDB(t) + + id, err := db.UpsertGitAgentTask(t.Context(), database.UpsertGitAgentTaskInput{ + TaskID: "task-1", Mailbox: "mailboxes/aaa.git", Base: "main", + DispatchCommit: "deadbeef", Backend: "prod-pool", Agent: "worker-01", + Status: database.GitAgentTaskRunning, + }) + if err != nil { + t.Fatalf("seed task: %v", err) + } + if err := db.RecordGitAgentAttempt(t.Context(), database.RecordGitAgentAttemptInput{ + TaskID: id, Attempt: 1, Tier: "supervisor", Status: database.GitAgentVerdictRejected, + Findings: []map[string]any{{"hook": "verify", "message": "make lint failed"}}, + }); err != nil { + t.Fatalf("seed attempt: %v", err) + } + + list := serveSandbox(t, loopbackRequest(http.MethodGet, "/api/captain/sandbox/git-agent/tasks", "")) + if list.Code != http.StatusOK { + t.Fatalf("list status = %d, body = %s", list.Code, list.Body) + } + var tasks []database.GitAgentTask + if err := json.Unmarshal(list.Body.Bytes(), &tasks); err != nil { + t.Fatalf("decode tasks: %v", err) + } + if len(tasks) != 1 || tasks[0].TaskID != "task-1" { + t.Fatalf("tasks = %+v, want the seeded task", tasks) + } + + detail := serveSandbox(t, loopbackRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/tasks/task-1?mailbox=mailboxes/aaa.git", "")) + if detail.Code != http.StatusOK { + t.Fatalf("detail status = %d, body = %s", detail.Code, detail.Body) + } + var got database.GitAgentTaskDetail + if err := json.Unmarshal(detail.Body.Bytes(), &got); err != nil { + t.Fatalf("decode detail: %v", err) + } + if len(got.Attempts) != 1 || got.Attempts[0].Tier != "supervisor" { + t.Fatalf("attempts = %+v, want the supervisor verdict", got.Attempts) + } + if got.Attempts[0].Findings[0]["message"] != "make lint failed" { + t.Errorf("findings = %+v, want the hook message", got.Attempts[0].Findings) + } +} + +func TestGitAgentTaskRouteFiltersAndMissingTask(t *testing.T) { + isolatedConfig(t) + db := taskRouteDB(t) + + for _, spec := range []struct{ task, agent string }{{"task-1", "worker-01"}, {"task-2", "worker-02"}} { + if _, err := db.UpsertGitAgentTask(t.Context(), database.UpsertGitAgentTaskInput{ + TaskID: spec.task, Mailbox: "mailboxes/aaa.git", Base: "main", + DispatchCommit: "deadbeef", Agent: spec.agent, + }); err != nil { + t.Fatalf("seed %s: %v", spec.task, err) + } + } + + filtered := serveSandbox(t, loopbackRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/tasks?agent=worker-02", "")) + var tasks []database.GitAgentTask + if err := json.Unmarshal(filtered.Body.Bytes(), &tasks); err != nil { + t.Fatalf("decode tasks: %v", err) + } + if len(tasks) != 1 || tasks[0].TaskID != "task-2" { + t.Fatalf("filtered tasks = %+v, want only worker-02's", tasks) + } + + missing := serveSandbox(t, loopbackRequest(http.MethodGet, + "/api/captain/sandbox/git-agent/tasks/ghost", "")) + if missing.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404; body = %s", missing.Code, missing.Body) + } +} diff --git a/pkg/cli/serve_sandbox_update.go b/pkg/cli/serve_sandbox_update.go new file mode 100644 index 00000000..6e78bbe2 --- /dev/null +++ b/pkg/cli/serve_sandbox_update.go @@ -0,0 +1,85 @@ +package cli + +import ( + "context" + "fmt" + "net/http" + "strings" +) + +type gitAgentDeployRunner func(context.Context, GitAgentDeployOptions) (any, error) + +func handleGitAgentUpdate() http.Handler { + return handleGitAgentUpdateWithRunner(RunGitAgentDeploy) +} + +func handleGitAgentUpdateWithRunner(run gitAgentDeployRunner) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + name := strings.TrimSpace(r.PathValue("name")) + if name == "" { + http.Error(w, "agent name is required", http.StatusBadRequest) + return + } + backend := sandboxBackendParam(r) + recorded, found := lookupDeployment(backend, name) + if !found { + http.Error(w, fmt.Sprintf("deployment %q was not found", name), http.StatusNotFound) + return + } + if recorded.Config == nil { + http.Error(w, "deployment has no saved edit configuration; redeploy it before editing", http.StatusConflict) + return + } + var request gitAgentDeployRequest + if err := decodeServeJSONBody(w, r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := validateDeploymentEdit(recorded, request.GitAgentDeploymentConfig); err != nil { + http.Error(w, err.Error(), http.StatusConflict) + return + } + request.Name, request.Replace, request.CreateNamespace = name, true, false + opts := request.options(backend) + opts.reuseEnrollment = true + result, err := run(r.Context(), opts) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadRequest)) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +func validateDeploymentEdit(recorded GitAgentDeployment, requested GitAgentDeploymentConfig) error { + if strings.TrimSpace(requested.Target) != recorded.Target { + return fmt.Errorf("editing cannot move a deployment from %s to %s; deploy a new agent instead", + recorded.Target, strings.TrimSpace(requested.Target)) + } + if strings.TrimSpace(requested.Namespace) != recorded.Namespace { + return fmt.Errorf("editing cannot move deployment %s from namespace %q to %q; deploy a new agent instead", + recorded.Workload, recorded.Namespace, strings.TrimSpace(requested.Namespace)) + } + if recorded.Config == nil { + return fmt.Errorf("deployment has no saved edit configuration") + } + for _, identity := range []struct { + label string + recorded string + requested string + }{ + {"transport", recorded.Config.Transport, requested.Transport}, + {"supervisor address", recorded.Config.SupervisorAddress, requested.SupervisorAddress}, + {"advertised endpoint", recorded.Config.Advertise, requested.Advertise}, + } { + if strings.TrimSpace(identity.requested) != strings.TrimSpace(identity.recorded) { + return fmt.Errorf("editing cannot change the deployment %s from %q to %q without re-enrollment", + identity.label, strings.TrimSpace(identity.recorded), strings.TrimSpace(identity.requested)) + } + } + return nil +} diff --git a/pkg/cli/serve_sandbox_whoami.go b/pkg/cli/serve_sandbox_whoami.go new file mode 100644 index 00000000..0f3f3eb3 --- /dev/null +++ b/pkg/cli/serve_sandbox_whoami.go @@ -0,0 +1,140 @@ +package cli + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +const agentWhoamiResponseLimit = 8 << 20 + +type agentWhoamiHTTPClient interface { + Do(*http.Request) (*http.Response, error) +} + +type agentWhoamiTarget struct { + url string + token text.SensitiveString +} + +func handleGitAgentWhoami() http.Handler { + return handleGitAgentWhoamiWithClient(&http.Client{Timeout: 30 * time.Second}) +} + +func handleGitAgentWhoamiWithClient(client agentWhoamiHTTPClient) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalRequest(r, "agent inspection requests"); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + name := strings.TrimSpace(r.PathValue("name")) + if name == "" { + http.Error(w, "agent name is required", http.StatusBadRequest) + return + } + target, err := resolveAgentWhoamiTarget(sandboxBackendParam(r), name) + if err != nil { + http.Error(w, err.Error(), gitAgentWhoamiTargetStatus(err)) + return + } + result, err := requestAgentWhoami(r.Context(), client, target) + if err != nil { + http.Error(w, err.Error(), http.StatusBadGateway) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +func resolveAgentWhoamiTarget(backendName, agentName string) (agentWhoamiTarget, error) { + cfg, _, err := captainconfig.Load() + if err != nil { + return agentWhoamiTarget{}, err + } + entry, err := enrolledAgent(cfg, backendName, agentName) + if err != nil { + return agentWhoamiTarget{}, err + } + endpoint, _ := entry["url"].(string) + if gitagent.EndpointScheme(endpoint) != "https" { + return agentWhoamiTarget{}, fmt.Errorf( + "agent %q does not expose the HTTPS whoami endpoint", agentName) + } + whoamiURL, err := gitAgentWhoamiURL(endpoint) + if err != nil { + return agentWhoamiTarget{}, err + } + tokenPath, _ := entry["tokenPath"].(string) + if strings.TrimSpace(tokenPath) == "" { + return agentWhoamiTarget{}, fmt.Errorf("agent %q has no dispatch token", agentName) + } + token, err := gitagent.ReadTokenFile(tokenPath) + if err != nil { + return agentWhoamiTarget{}, fmt.Errorf("agent %q has an unreadable dispatch token", agentName) + } + return agentWhoamiTarget{url: whoamiURL, token: token}, nil +} + +func gitAgentWhoamiURL(endpoint string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || parsed.Scheme != "https" || parsed.Host == "" { + return "", fmt.Errorf("agent endpoint %q must be https://host/path", endpoint) + } + parsed.Path = gitagent.AgentWhoamiPath + parsed.RawPath = "" + parsed.RawQuery = url.Values{ + "disabled": {"true"}, + "limit": {"0"}, + "models": {"true"}, + }.Encode() + parsed.Fragment = "" + return parsed.String(), nil +} + +func requestAgentWhoami(ctx context.Context, client agentWhoamiHTTPClient, target agentWhoamiTarget) (WhoamiResult, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodPost, target.url, bytes.NewReader([]byte("{}"))) + if err != nil { + return WhoamiResult{}, err + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+target.token.Value()) + response, err := client.Do(request) + if err != nil { + return WhoamiResult{}, fmt.Errorf("agent whoami request failed: %w", err) + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, agentWhoamiResponseLimit+1)) + if err != nil { + return WhoamiResult{}, fmt.Errorf("read agent whoami response: %w", err) + } + if len(payload) > agentWhoamiResponseLimit { + return WhoamiResult{}, fmt.Errorf("agent whoami response exceeds %d bytes", agentWhoamiResponseLimit) + } + if response.StatusCode != http.StatusOK { + return WhoamiResult{}, fmt.Errorf("agent whoami returned %s: %s", + response.Status, strings.TrimSpace(string(payload))) + } + var result WhoamiResult + if err := json.Unmarshal(payload, &result); err != nil { + return WhoamiResult{}, fmt.Errorf("decode agent whoami response: %w", err) + } + return result, nil +} + +func gitAgentWhoamiTargetStatus(err error) int { + if strings.Contains(err.Error(), "is not enrolled") || strings.Contains(err.Error(), "has no enrolled agents") { + return http.StatusNotFound + } + return http.StatusBadRequest +} diff --git a/pkg/cli/webapp/dist/.gitkeep b/pkg/cli/webapp/dist/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/pkg/cli/webapp/src/App.tsx b/pkg/cli/webapp/src/App.tsx index 62b6ff68..cf3f7b8a 100644 --- a/pkg/cli/webapp/src/App.tsx +++ b/pkg/cli/webapp/src/App.tsx @@ -25,6 +25,7 @@ import { subscribeSessionListSearch, } from "./sessionListFilters"; import { ShellActions } from "./shell"; +import { SandboxesPage } from "./SandboxesPage"; import { WhoamiPage } from "./WhoamiPage"; import { captainNavSections, @@ -105,6 +106,8 @@ export function App() { ) : route.kind === "whoami" ? ( + ) : route.kind === "sandboxes" ? ( + ) : route.kind === "operations" ? ( ( + key: K, + value: DeployRequest[K], +) => void; + +export function DeployForm({ + form, + preflight, + blockers, + namespaces, + willCreateNamespace, + identityLocked = false, + onChange, +}: { + form: DeployRequest; + preflight: DeployPreflight | undefined; + blockers: DeployBlockers; + namespaces: string[]; + willCreateNamespace: boolean; + identityLocked?: boolean; + onChange: DeployFieldSetter; +}) { + const [advanced, setAdvanced] = useState(false); + const kubernetes = form.target === "kubernetes"; + // Only once the advanced block is open: until then nothing renders the list, + // and a cluster call per modal open would be paid by every docker deploy too. + const namespace = (form.namespace ?? "").trim() || (preflight?.namespace ?? ""); + const secrets = useQuery({ + queryKey: ["git-agent-secrets", namespace, form.kubeContext], + queryFn: () => fetchSecrets({ namespace, kubeContext: form.kubeContext }), + enabled: advanced && kubernetes, + retry: false, + }); + const namespaceSecrets = useMemo( + () => (secrets.data ?? []).map((name) => ({ value: name, label: name })), + [secrets.data], + ); + // NamespacePicker loads once per getter identity, so the getter is memoized on + // the list the modal already fetched rather than issuing a second request. + const loadNamespaces = useCallback(() => Promise.resolve(namespaces), [namespaces]); + + return ( +
+ + onChange("name", value)} + placeholder="worker-01" + invalid={Boolean(blockers.name)} + disabled={identityLocked} + autoFocus + /> + + + {preflight?.supervisorRequired && ( + + )} + + {kubernetes && ( + + {/* + Not `strict`: a name absent from the cluster is the create half of + "select or create", so flagging it invalid would mark the intended + action as an error. The hint says which of the two is happening. + */} + {identityLocked ? ( + + ) : ( + onChange("namespace", value)} + loadNamespaces={loadNamespaces} + placeholder={preflight?.namespace ?? "default"} + /> + )} + + )} + + {kubernetes && ( + + )} + + + onChange("env", splitList(value))} + placeholder="ANTHROPIC_API_KEY, OPENAI_API_KEY" + /> + + + + + {advanced && ( +
+ + onChange("image", value)} + placeholder="ghcr.io/flanksource/captain:latest" + /> + +
+ + onChange("cpuLimit", value)} + placeholder="2" + /> + + + onChange("memoryLimit", value)} + placeholder="4Gi" + /> + + + onChange("storage", value)} + placeholder="20Gi" + /> + +
+ {kubernetes && ( + <> + + + onChange("envFromSecret", splitList(value)) + } + placeholder="captain-model-keys" + /> + + + {/* Every Secret, not just TLS: this one is written by the + credential sync and has no distinguishing type. */} + + onChange("credentialsSecret", value) + } + ariaLabel="Agent login Secret" + allowCustomValue + loading={secrets.isFetching} + placeholder="captain-agent-credentials" + /> + + + )} +
+ )} + + {!identityLocked && ( +
+ onChange("replace", checked)} + label="Replace an existing deployment of this name" + /> +
+ )} +
+ ); +} + +/** + * The address the deployed agent dials back on. + * + * A picker rather than a box because the addresses worth trying are facts about + * this host that the preflight already enumerated — but not a closed set: none + * of them is *proven* routable from a managed cluster, and the address that + * works is often a name or a NAT that this host cannot see at all. + */ +function SupervisorField({ + form, + preflight, + blocker, + onChange, +}: { + form: DeployRequest; + preflight: DeployPreflight; + blocker: string | undefined; + onChange: DeployFieldSetter; +}) { + const options = useMemo( + () => + (preflight.supervisorCandidates ?? []).map((address) => ({ + value: address, + label: address, + })), + [preflight.supervisorCandidates], + ); + + return ( + + onChange("supervisorAddress", value)} + ariaLabel="Supervisor address" + // The offers are ranked guesses, not a closed set, so a typed address is + // the expected path on any cluster reached through a name or a NAT. + allowCustomValue + invalid={Boolean(blocker)} + // The scheme has to match the mailbox that answered: an https + // supervisor address against an ssh mailbox reaches nothing. + placeholder={ + preflight.transport === "https" + ? "https://captain.example.internal:9020" + : "ssh://captain.example.internal:7422" + } + /> + + ); +} + +/** Splits a comma or whitespace separated list, dropping blanks. */ +export function splitList(value: string): string[] { + return value + .split(/[,\s]+/) + .map((entry) => entry.trim()) + .filter(Boolean); +} + +/** + * Splits `key=value` entries on commas and newlines only. + * + * Not splitList: an annotation value legitimately contains spaces — a + * source-range allowlist or a snippet — and splitting on whitespace would turn + * one annotation into several malformed ones. + */ +export function splitAnnotations(value: string): string[] { + return value + .split(/[\n,]+/) + .map((entry) => entry.trim()) + .filter(Boolean); +} diff --git a/pkg/cli/webapp/src/GitAgentDeployModal.tsx b/pkg/cli/webapp/src/GitAgentDeployModal.tsx new file mode 100644 index 00000000..c85d9fcb --- /dev/null +++ b/pkg/cli/webapp/src/GitAgentDeployModal.tsx @@ -0,0 +1,397 @@ +import { useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Button, FormErrorSummary, Modal } from "@flanksource/clicky-ui/components"; + +import { DeployForm } from "./GitAgentDeployForm"; +import { DeployedSummary, PreviewPlan } from "./GitAgentDeployResult"; +import { blockerSummary, deployBlockers } from "./gitAgentDeployValidation"; +import { + deployGitAgent, + fetchDeployPreflight, + fetchNamespaces, + updateGitAgent, + type DeployPreflight, + type DeployRequest, + type DeployResult, + type DeployTarget, + type GitAgentDeployment, +} from "./sandboxData"; + +const TARGETS: Array<{ value: DeployTarget; label: string; blurb: string }> = [ + { + value: "docker", + label: "Docker", + blurb: "A container on this machine's docker daemon.", + }, + { + value: "kubernetes", + label: "Kubernetes", + blurb: "A Deployment, Service and PVC in your kubeconfig's cluster.", + }, +]; + +/** The route fields, which belong to kubernetes and are refused on docker. */ +const ROUTING_KEYS = [ + "domain", + "ingressClass", + "ingressIssuer", + "ingressTlsSecret", + "ingressAnnotation", +] as const satisfies ReadonlyArray; + +/** + * Deploying is three steps because the middle one carries the risk. + * + * A git-agent needs two addresses pointing in opposite directions — one the + * agent reaches the supervisor on, one the supervisor dispatches back to — and + * getting either wrong produces an agent that enrolls, shows as healthy, and + * fails at its first dispatch hours later. So the target is probed before the + * form is usable, and the resolved plan is shown before anything is created. + */ +export function GitAgentDeployModal({ + open, + backend, + edit, + onClose, + onDeployed, +}: { + open: boolean; + backend: string; + edit?: { name: string; deployment: GitAgentDeployment }; + onClose: () => void; + onDeployed: () => void; +}) { + const initial = initialRequest(edit); + const editing = Boolean(edit); + const [target, setTarget] = useState(initial.target); + const [form, setForm] = useState(initial); + const [preview, setPreview] = useState(); + const [deployed, setDeployed] = useState(); + + const preflight = useQuery({ + queryKey: ["git-agent-preflight", backend, target, form.transport, form.kubeContext], + queryFn: () => + fetchDeployPreflight({ + backend, + target, + transport: form.transport, + kubeContext: form.kubeContext, + }), + enabled: open, + // A mailbox started (or stopped) while the modal is open changes the answer, + // and a stale "no live mailbox" would block a deploy that is now possible. + staleTime: 0, + }); + + const ready = preflight.data?.ready ?? false; + + // The namespaces the cluster already has, so the field can offer them and say + // whether a typed one would be created. Gated on the preflight: until it says + // the cluster is reachable there is nothing to list, and asking anyway would + // fail once per target switch for a form that is not even rendered yet. + const namespaces = useQuery({ + // No kubeconfig context: the form does not offer one, so this reads the + // current context — the same cluster the preflight probed. + queryKey: ["git-agent-namespaces", form.kubeContext], + queryFn: () => fetchNamespaces(form.kubeContext), + enabled: open && target === "kubernetes" && ready, + retry: false, + }); + const knownNamespaces = useMemo(() => namespaces.data ?? [], [namespaces.data]); + + // Empty means the kubeconfig context's own namespace, which exists by virtue + // of having been selected there. Only a name the cluster does not have is a + // creation, and only once the list actually loaded — otherwise every namespace + // would look new. + const chosenNamespace = form.namespace?.trim() ?? ""; + const willCreateNamespace = + target === "kubernetes" && + chosenNamespace !== "" && + knownNamespaces.length > 0 && + !knownNamespaces.includes(chosenNamespace); + + useEffect(() => { + setForm((current) => ({ + ...current, + target, + // A route left over from kubernetes is not ignored on docker, it is + // refused — "--domain needs --target kubernetes" — so switching away has + // to clear it rather than hide it. + ...(target === "kubernetes" + ? {} + : Object.fromEntries(ROUTING_KEYS.map((key) => [key, undefined]))), + })); + setPreview(undefined); + }, [target]); + + const set = (key: K, value: DeployRequest[K]) => + setForm((current) => ({ ...current, [key]: value })); + + const deploy = useMutation({ + mutationFn: (dryRun: boolean) => { + const request: DeployRequest = { + ...form, + target, + dryRun, + // Derived at submit rather than stored: it is a fact about the cluster + // and the typed name, so keeping a copy in the form would let the two + // drift once either changes. + createNamespace: editing ? false : willCreateNamespace, + }; + return edit + ? updateGitAgent(backend, edit.name, request) + : deployGitAgent(backend, request); + }, + onSuccess: (result) => { + if (result.dryRun) { + setPreview(result); + return; + } + setDeployed(result); + onDeployed(); + }, + }); + + const reset = () => { + const next = initialRequest(edit); + setTarget(next.target); + setForm(next); + setPreview(undefined); + setDeployed(undefined); + deploy.reset(); + onClose(); + }; + + const blockers = deployBlockers({ ...form, target }, preflight.data); + const canSubmit = ready && Object.keys(blockers).length === 0; + + return ( + +
+ {deployed ? ( + + ) : ( + <> + + void preflight.refetch()} + /> + + {ready && ( + + )} + + {preview && } + + {deploy.error && ( +

+ {deploy.error instanceof Error + ? deploy.error.message + : String(deploy.error)} +

+ )} + + {/* Beside the buttons it disables, because the fields it names sit + far up a long form and `disabled:pointer-events-none` means the + button itself can never explain anything on hover. */} + {ready && } + +
+ + + +
+ + )} + + {deployed && ( +
+ +
+ )} +
+
+ ); +} + +function initialRequest( + edit: { name: string; deployment: GitAgentDeployment } | undefined, +): DeployRequest { + if (!edit) return { name: "", target: "docker" }; + if (!edit.deployment.config) { + throw new Error(`deployment ${edit.name} has no saved configuration`); + } + return { + ...edit.deployment.config, + name: edit.name, + target: edit.deployment.target, + }; +} + +function TargetPicker({ + value, + onChange, + disabled = false, +}: { + value: DeployTarget; + onChange: (target: DeployTarget) => void; + disabled?: boolean; +}) { + return ( +
+ Where it runs +
+ {TARGETS.map((option) => ( + + ))} +
+
+ ); +} + +/** + * The preflight result, stated before the form rather than after a failed + * submit. A refusal here is the same one the CLI gives, and usually names the + * command that fixes it. + */ +function PreflightNotice({ + loading, + error, + preflight, + onRetry, +}: { + loading: boolean; + error: unknown; + preflight: DeployPreflight | undefined; + onRetry: () => void; +}) { + if (loading) { + return ( +

Checking this target…

+ ); + } + if (error) { + return ( +

+ {error instanceof Error ? error.message : String(error)} +

+ ); + } + if (!preflight) return null; + + if (!preflight.ready) { + return ( +
+

+ Cannot deploy to {preflight.target} from this host +

+

+ {preflight.reason} +

+
+ +
+
+ ); + } + + return ( +
+ {preflight.runtime && ( + <> +
Runtime
+
{preflight.runtime}
+ + )} +
Mailbox
+
+ {preflight.mailboxListen} + {/* Which process is the supervisor: https means `captain serve` hosts + it, ssh means a separate `git-agent serve --role mailbox`. */} + {preflight.transport && ( + + over {preflight.transport} + + )} +
+ {preflight.supervisor && ( + <> +
Agent reaches it at
+
+ {preflight.supervisor}{" "} + + ({preflight.supervisorFrom}) + +
+ + )} +
+ ); +} diff --git a/pkg/cli/webapp/src/GitAgentDeployResult.tsx b/pkg/cli/webapp/src/GitAgentDeployResult.tsx new file mode 100644 index 00000000..39db5367 --- /dev/null +++ b/pkg/cli/webapp/src/GitAgentDeployResult.tsx @@ -0,0 +1,115 @@ +import { Badge } from "@flanksource/clicky-ui/data"; + +import type { DeployResult } from "./sandboxData"; + +/** + * Every mutation the deploy intends, from the same builder the CLI's --dry-run + * prints — so the preview cannot describe a different deployment than the one + * that runs. + */ +export function PreviewPlan({ result }: { result: DeployResult }) { + return ( +
+

This would:

+
    + {(result.mutations ?? []).map((mutation, index) => ( +
  1. + {mutation} +
  2. + ))} +
+
+
Dispatched to at
+
+ {result.advertise}{" "} + ({result.advertiseFrom}) +
+ +
Security
+
{result.security}
+
Credentials
+
+ {result.credentials} +
+
+
+ ); +} + +export function DeployedSummary({ result }: { result: DeployResult }) { + return ( +
+

+ {result.enrolled + ? `${result.agent} is enrolled and dispatchable.` + : `${result.agent} was created; it enrolls when the sidecar finishes starting.`} +

+
+
Workload
+
+ {result.workload} + {result.namespace ? ` (${result.namespace})` : ""} +
+
Dispatched to at
+
{result.advertise}
+ +
State volume
+
{result.volume}
+ {(result.objects?.length ?? 0) > 0 && ( + <> +
Created
+
+
    + {result.objects?.map((object) => ( +
  • + {object} +
  • + ))} +
+
+ + )} +
+ {result.credentials.startsWith("none") && ( +

+ {result.credentials}. It will enrol and go ready, then fail its first + task — redeploy with model credentials to fix it. +

+ )} + {!result.egressRestricted && ( +

+ note The sidecar reaches model APIs, git remotes and + package registries directly; egress is not restricted. +

+ )} +
+ ); +} + +/** + * The Ingress host, when there is one. It is the name an operator has to create + * a DNS record for, and captain deliberately does not create it — so leaving it + * to be read out of the mutation list would bury the one manual step. + */ +function RouteRow({ result }: { result: DeployResult }) { + if (!result.route) return null; + return ( + <> +
Route
+
+ {result.route} + {result.routeClass && ( + + via {result.routeClass} + + )} +
+ + ); +} diff --git a/pkg/cli/webapp/src/GitAgentDeployRouting.tsx b/pkg/cli/webapp/src/GitAgentDeployRouting.tsx new file mode 100644 index 00000000..8d059e83 --- /dev/null +++ b/pkg/cli/webapp/src/GitAgentDeployRouting.tsx @@ -0,0 +1,324 @@ +import { useEffect, useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Combobox, Field, InputField } from "@flanksource/clicky-ui/components"; + +import { splitAnnotations, type DeployFieldSetter } from "./GitAgentDeployForm"; +import { externalHost, type DeployBlockers } from "./gitAgentDeployValidation"; +import { + fetchClusterIssuers, + fetchSecrets, + TLS_SECRET_TYPE, + type DeployPreflight, + type DeployRequest, +} from "./sandboxData"; + +/** + * What a controller other than ingress-nginx needs to serve a git push. + * + * captain refuses a non-nginx class with no annotations because its own + * defaults are written in nginx's vocabulary, and it cannot check another + * controller's spelling. That refusal is right, but on a cluster whose only + * IngressClass is traefik it is also a dead end — so where the translation is + * known, it is applied on selection rather than offered. + * + * traefik: the pod terminates its own TLS, so the hop from the controller has + * to be re-encrypted — serversscheme is its equivalent of nginx's + * backend-protocol: HTTPS. The rest of captain's nginx defaults have no + * counterpart and need none: traefik neither buffers nor caps request bodies, + * and its read/write timeouts are entryPoint-level (respondingTimeouts), which + * an Ingress annotation cannot reach at all. + */ +const CONTROLLER_EQUIVALENTS: Record = { + traefik: ["traefik.ingress.kubernetes.io/service.serversscheme=https"], +}; + +/** + * How a supervisor outside the cluster reaches the agent. + * + * This is the one input of a Kubernetes deploy that cannot be detected: captain + * cannot prove a route it did not create, and the name that would work does not + * exist until someone adds a DNS record. So it sits in the form proper rather + * than behind an advanced toggle — without it the deploy is refused, and with a + * wrong value the agent enrolls, looks healthy, and never receives a dispatch. + */ +export function RoutingSection({ + form, + preflight, + blockers, + onChange, +}: { + form: DeployRequest; + preflight: DeployPreflight | undefined; + blockers: DeployBlockers; + onChange: DeployFieldSetter; +}) { + const classes = useMemo( + () => + (preflight?.ingressClasses ?? []).map((name) => ({ value: name, label: name })), + [preflight?.ingressClasses], + ); + const host = externalHost(form); + const ingressClass = (form.ingressClass ?? "").trim(); + const equivalents = CONTROLLER_EQUIVALENTS[ingressClass]; + + // Applied on selection rather than offered: where captain knows a + // controller's translation there is nothing for the operator to decide, and + // the alternative is a refusal whose only remedy is retyping what we already + // know. Keyed on the class alone, so an operator who then edits or clears the + // annotations is not fought by the effect re-adding them. + useEffect(() => { + if (!equivalents) return; + const current = form.ingressAnnotation ?? []; + const missing = equivalents.filter((entry) => !current.includes(entry)); + if (missing.length > 0) onChange("ingressAnnotation", [...current, ...missing]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ingressClass]); + // Held here rather than derived from which field is filled, so choosing a + // source before typing into it does not immediately flip back. cert-manager's + // absence decides the default, because an issuer would be inert without it. + const [source, setSource] = useState(() => + preflight?.certManagerInstalled ? "issuer" : "secret", + ); + + return ( +
+ Routing + + {preflight?.inCluster && ( +

+ captain is running in this cluster, so the agent is reachable at its Service + address. An Ingress is only needed to reach it from outside. +

+ )} + + . behind an Ingress." + } + > + onChange("domain", value)} + placeholder="agents.example.com" + invalid={Boolean(blockers.domain)} + /> + + + + {/* + Deliberately never pre-filled with "nginx": the server applies that + default from the flag's own tag, so a blank field cannot drift from it + — and a form that sends a value it was not given makes every deploy + look configured. + */} + onChange("ingressClass", value)} + ariaLabel="Ingress class" + allowCustomValue + placeholder="nginx" + /> + + + { + setSource(chosen); + onChange(chosen === "issuer" ? "ingressTlsSecret" : "ingressIssuer", undefined); + }} + onChange={onChange} + /> + + {/* The blocker belongs here, not on Ingress class: the class is already + what the operator wanted, and annotations are the field that fixes it. */} + + + onChange("ingressAnnotation", splitAnnotations(value)) + } + // Follows the chosen class: offering an nginx example beside a traefik + // class is guidance that cannot work. + placeholder={ + equivalents + ? equivalents.join(", ") + : "nginx.ingress.kubernetes.io/whitelist-source-range=10.0.0.0/8" + } + invalid={Boolean(blockers.ingressAnnotation)} + /> + + + + onChange("advertise", value)} + placeholder="https://worker-01.agents.example.com" + invalid={Boolean(blockers.advertise)} + /> + +
+ ); +} + +type CertificateChoice = "issuer" | "secret"; + +/** + * Where the certificate for the agent's host comes from. + * + * A choice rather than two fields because the server refuses both at once, and + * refuses neither: without a certificate the controller answers for that host + * with its own, and the supervisor's push fails verification. + */ +function CertificateSource({ + form, + preflight, + blockers, + source, + onSource, + onChange, +}: { + form: DeployRequest; + preflight: DeployPreflight | undefined; + blockers: DeployBlockers; + source: CertificateChoice; + onSource: (chosen: CertificateChoice) => void; + onChange: DeployFieldSetter; +}) { + // An issuer annotation is inert without the controller that acts on it, so the + // option is closed rather than left to fail after the token is minted. + const certManager = preflight?.certManagerInstalled ?? false; + const blocker = blockers.ingressIssuer ?? blockers.ingressTlsSecret; + const usingSecret = source === "secret"; + // A certificate is only mandatory once there is a host to certify. + const hasDomain = Boolean((form.domain ?? "").trim()); + + // Scoped to the namespace the deploy targets, and re-read when it changes: + // the same Secret name means a different object in another namespace. + const namespace = (form.namespace ?? "").trim() || (preflight?.namespace ?? ""); + const secrets = useQuery({ + queryKey: ["git-agent-tls-secrets", namespace, form.kubeContext], + queryFn: () => + fetchSecrets({ + namespace, + kubeContext: form.kubeContext, + type: TLS_SECRET_TYPE, + }), + enabled: usingSecret, + retry: false, + }); + const clusterIssuers = useQuery({ + queryKey: ["git-agent-cluster-issuers", form.kubeContext], + queryFn: () => fetchClusterIssuers(form.kubeContext), + // An issuer is inert without the controller, so the list is only worth + // fetching where one exists. + enabled: !usingSecret && certManager, + retry: false, + }); + const tlsSecrets = useMemo( + () => (secrets.data ?? []).map((name) => ({ value: name, label: name })), + [secrets.data], + ); + const issuers = useMemo( + () => (clusterIssuers.data ?? []).map((name) => ({ value: name, label: name })), + [clusterIssuers.data], + ); + + return ( +
+
+ + +
+ + {!certManager && ( +

+ cert-manager is not installed in this cluster, so an issuer would be inert. + Name a Secret that already covers the agent's host. +

+ )} + + {usingSecret ? ( + + {/* + A picker, because a Secret that is not kubernetes.io/tls cannot + serve the agent's host and the Ingress would be created pointing at + it regardless. Still allowCustomValue: a namespace this cannot list + is not proof the name is wrong. + */} + onChange("ingressTlsSecret", value)} + ariaLabel="TLS Secret" + allowCustomValue + loading={secrets.isFetching} + invalid={Boolean(blocker)} + placeholder="agents-example-com-wildcard" + /> + + ) : ( + + onChange("ingressIssuer", value)} + ariaLabel="ClusterIssuer" + allowCustomValue + loading={clusterIssuers.isFetching} + disabled={!certManager} + invalid={Boolean(blocker)} + placeholder="letsencrypt-prod" + /> + + )} +
+ ); +} diff --git a/pkg/cli/webapp/src/GitAgentEnrollModal.tsx b/pkg/cli/webapp/src/GitAgentEnrollModal.tsx new file mode 100644 index 00000000..eb2c378a --- /dev/null +++ b/pkg/cli/webapp/src/GitAgentEnrollModal.tsx @@ -0,0 +1,190 @@ +import { useState } from "react"; +import { useMutation } from "@tanstack/react-query"; +import { + Button, + InputField, + Modal, + Switch, +} from "@flanksource/clicky-ui/components"; + +import { enrollGitAgent, type GitAgentEnrollment } from "./sandboxData"; + +/** + * Enrollment is a two-sided hand-off, so the modal is two steps rather than a + * form that "creates" an agent: the supervisor mints a durable token, and the + * operator runs the printed command on the agent host. Nothing exists on the + * remote side until they do. + */ +export function GitAgentEnrollModal({ + open, + backend, + onClose, + onEnrolled, +}: { + open: boolean; + backend: string; + onClose: () => void; + onEnrolled: () => void; +}) { + const [name, setName] = useState(""); + const [endpoint, setEndpoint] = useState(""); + const [dryRun, setDryRun] = useState(false); + const [result, setResult] = useState(); + + const enroll = useMutation({ + mutationFn: () => + enrollGitAgent({ + backend, + name: name.trim(), + ...(endpoint.trim() ? { endpoint: endpoint.trim() } : {}), + ...(dryRun ? { dryRun: true } : {}), + }), + onSuccess: (enrollment) => { + setResult(enrollment); + // A dry run records nothing, so there is no roster change to pick up. + if (!enrollment.dryRun) onEnrolled(); + }, + }); + + const reset = () => { + setName(""); + setEndpoint(""); + setDryRun(false); + setResult(undefined); + enroll.reset(); + onClose(); + }; + + return ( + +
+ {!result && ( + <> + + +
+ +
+ {enroll.error && ( +

+ {enroll.error instanceof Error + ? enroll.error.message + : String(enroll.error)} +

+ )} +
+ + +
+ + )} + + {result && ( + <> + {result.dryRun ? ( +

+ Dry run — nothing was written and no token was minted. +

+ ) : ( + <> +

+ The token is shown once and cannot be recovered afterwards. It + stays valid{" "} + {result.expires ? ( + <> + until{" "} + + {new Date(result.expires).toLocaleString()} + + + ) : ( + "until it is revoked" + )} + , so a restarting agent re-presents the same one. Running this + establishes trust both ways: the supervisor learns the + agent's endpoint and host key, and the agent authorizes the + supervisor's dispatch key. +

+ +
+ {result.tokenId && ( + <> +
Token
+
{result.tokenId}
+ + )} +
Host key
+
{result.hostFingerprint}
+
Dispatch key
+
{result.dispatchKey}
+
+ + )} +
+ +
+ + )} +
+
+ ); +} + +function CopyableCommand({ command }: { command: string }) { + const [copied, setCopied] = useState(false); + return ( +
+
+        {command}
+      
+
+ +
+
+ ); +} diff --git a/pkg/cli/webapp/src/GitAgentTasks.tsx b/pkg/cli/webapp/src/GitAgentTasks.tsx new file mode 100644 index 00000000..0f6309ae --- /dev/null +++ b/pkg/cli/webapp/src/GitAgentTasks.tsx @@ -0,0 +1,225 @@ +import { useState, type ReactNode } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@flanksource/clicky-ui/components"; +import { Badge } from "@flanksource/clicky-ui/data"; + +import { + fetchGitAgentTask, + fetchGitAgentTasks, + isTaskOpen, + type GitAgentTask, + type GitAgentTaskStatus, +} from "./sandboxData"; + +const STATUS_FILTERS: Array<{ id: string; label: string }> = [ + { id: "", label: "All" }, + { id: "running", label: "Running" }, + { id: "accepted", label: "Accepted" }, + { id: "rejected", label: "Rejected" }, + { id: "errored", label: "Errored" }, +]; + +const STATUS_TONE: Record = { + dispatched: "text-muted-foreground", + running: "text-sky-600 dark:text-sky-400", + accepted: "text-emerald-600 dark:text-emerald-400", + rejected: "text-amber-600 dark:text-amber-400", + errored: "text-destructive", + timed_out: "text-destructive", +}; + +export function GitAgentTasks() { + const [status, setStatus] = useState(""); + const [selected, setSelected] = useState(); + + const tasks = useQuery({ + queryKey: ["git-agent-tasks", status], + queryFn: () => fetchGitAgentTasks(status ? { status } : {}), + // History is written by the ingest watcher on its backfill pass, so this + // does not need to be live; a modest refetch keeps a long-lived tab current. + refetchInterval: 30_000, + }); + + if (tasks.error) { + return ( +

+ {tasks.error instanceof Error ? tasks.error.message : String(tasks.error)} +

+ ); + } + + return ( +
+
+ {STATUS_FILTERS.map((filter) => ( + + ))} +
+ + {tasks.isLoading ? ( +

Loading tasks…

+ ) : (tasks.data?.length ?? 0) === 0 ? ( +

+ No remote tasks recorded yet. Dispatching a prompt with a git-agent + sandbox records its history here. +

+ ) : ( + + + + + + + + + + + + + {tasks.data?.map((task) => ( + setSelected(task)} + > + + + + + + + + ))} + +
TaskStatusAgentAttemptsRepositoryDispatched
+ {task.taskId} + + {task.status} + {task.agent || "—"} + {task.attempts} + {task.maxAttempts ? ` / ${task.maxAttempts}` : ""} + + {task.repository || "—"} + {new Date(task.dispatchedAt).toLocaleString()}
+ )} + + {selected && ( + setSelected(undefined)} + /> + )} +
+ ); +} + +function GitAgentTaskDetailPanel({ + task, + onClose, +}: { + task: GitAgentTask; + onClose: () => void; +}) { + const detail = useQuery({ + queryKey: ["git-agent-task", task.mailbox, task.taskId], + queryFn: () => fetchGitAgentTask(task.taskId, task.mailbox), + // An open task is still accruing verdicts; a concluded one never changes. + refetchInterval: isTaskOpen(task) ? 5_000 : false, + }); + + return ( +
+
+

{task.taskId}

+ +
+ +
+
Status
+
{task.status}
+
Base
+
{task.base}
+
Dispatch commit
+
{task.dispatchCommit}
+ {task.integratedBranch && ( + <> +
Integrated onto
+
{task.integratedBranch}
+ + )} + {task.relay && ( + <> +
Relay
+
{task.relay}
+ + )} +
+ + {detail.error ? ( +

+ {detail.error instanceof Error + ? detail.error.message + : String(detail.error)} +

+ ) : (detail.data?.attempts.length ?? 0) === 0 ? ( +

+ No verdict yet — the agent has not submitted this attempt. +

+ ) : ( +
    + {detail.data?.attempts.map((attempt) => ( +
  1. +
    + attempt {attempt.attempt} + {attempt.tier} + + {attempt.status} + +
    + {(attempt.findings?.length ?? 0) > 0 && ( +
      + {attempt.findings?.map((finding, index) => ( +
    • + {String(finding.hook ?? "")} + {finding.message ? `: ${String(finding.message)}` : ""} +
    • + ))} +
    + )} +
  2. + ))} +
+ )} +
+ ); +} + +function Th({ children }: { children: ReactNode }) { + return {children}; +} + +function Td({ children }: { children: ReactNode }) { + return {children}; +} + +function Dt({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/pkg/cli/webapp/src/GitAgentWhoami.tsx b/pkg/cli/webapp/src/GitAgentWhoami.tsx new file mode 100644 index 00000000..6a10edab --- /dev/null +++ b/pkg/cli/webapp/src/GitAgentWhoami.tsx @@ -0,0 +1,168 @@ +import { useQuery } from "@tanstack/react-query"; +import { Button } from "@flanksource/clicky-ui/components"; +import { Badge } from "@flanksource/clicky-ui/data"; + +import { fetchGitAgentWhoami, type AgentWhoamiAdapter } from "./sandboxData"; + +export function GitAgentWhoami({ + backend, + agent, +}: { + backend: string; + agent: string; +}) { + const query = useQuery({ + queryKey: ["git-agent-whoami", backend, agent], + queryFn: () => fetchGitAgentWhoami({ backend, name: agent }), + }); + + if (query.isLoading) { + return ( +

+ Inspecting agent runtimes… +

+ ); + } + if (query.error) { + return ( +
+

+ {query.error instanceof Error + ? query.error.message + : String(query.error)} +

+ +
+ ); + } + + const adapters = query.data?.adapters ?? []; + const ready = adapters.filter(adapterReady).length; + return ( +
+
+
+ + {ready} ready adapter{ready === 1 ? "" : "s"} + + + {adapters.reduce((total, adapter) => total + adapter.modelCount, 0)}{" "} + models + +
+ +
+ {adapters.length === 0 ? ( +

No runtimes reported.

+ ) : ( +
+ {adapters.map((adapter) => ( + + ))} +
+ )} +
+ ); +} + +function AdapterIdentity({ adapter }: { adapter: AgentWhoamiAdapter }) { + const ready = adapterReady(adapter); + return ( +
+
+ + {adapter.backend} + + + {adapter.disabled ? "Disabled" : ready ? "Ready" : "Needs setup"} + + + {adapter.provider} / {adapter.mode} + +
+ {(adapter.authMethod || + adapter.authDetail || + adapter.binary || + adapter.binaryMissing || + adapter.dependencyMissing || + adapter.runtimeError) && ( +
+ {adapter.authMethod && ( + + )} + {adapter.authDetail && ( + + )} + {adapter.binary && } + {adapter.binaryMissing && ( + + )} + {adapter.dependencyMissing && ( + + )} + {adapter.runtimeError && ( + + )} +
+ )} + {adapter.modelError && ( +

+ {adapter.modelError} +

+ )} + {(adapter.models?.length ?? 0) > 0 ? ( +
    + {adapter.models?.map((model) => ( +
  • + {model} +
  • + ))} +
+ ) : ( +

No models reported.

+ )} +
+ ); +} + +function Detail({ label, value }: { label: string; value: string }) { + return ( + <> +
{label}
+
{value}
+ + ); +} + +function adapterReady(adapter: AgentWhoamiAdapter) { + if (!adapter.authenticated || adapter.disabled) return false; + if (adapter.type !== "cli") return true; + return ( + Boolean(adapter.binary || adapter.provisioner) && + !adapter.binaryMissing && + !adapter.dependencyMissing && + !adapter.runtimeError + ); +} diff --git a/pkg/cli/webapp/src/PromptWorkbench.tsx b/pkg/cli/webapp/src/PromptWorkbench.tsx index a0c232f0..657c33df 100644 --- a/pkg/cli/webapp/src/PromptWorkbench.tsx +++ b/pkg/cli/webapp/src/PromptWorkbench.tsx @@ -30,6 +30,7 @@ import { type AIPromptRunValue, type AISpecRuntimePermissionCatalog, type RuntimeCatalogFamily, + type SpecRuntimeSandboxCatalog, type ToolMeta, } from "@flanksource/clicky-ui/ai"; import { type ChatModel } from "@flanksource/clicky-ui/chat"; @@ -1072,6 +1073,9 @@ function PromptDetailPane({ {...(backendCliArgs ? { cliOptions: { schema: backendCliArgs } } : {})} + {...(promptSchema?.sandboxes + ? { sandboxCatalog: promptSchema.sandboxes } + : {})} {...(scratch ? { promptLabel: "Scratch prompt", @@ -1219,6 +1223,12 @@ type PromptSchemaDoc = { runtimes?: RuntimeCatalogFamily[]; /** The enabled effort tiers, for models the catalog does not describe. */ efforts?: string[]; + /** + * The sandbox adapter catalog: what confines a run, what each adapter can do, + * which runtime modes it serves, and the configured backends (with their + * enrolled git-agent rosters) that select it. + */ + sandboxes?: SpecRuntimeSandboxCatalog; spec?: JsonSchemaObject; }; diff --git a/pkg/cli/webapp/src/SandboxesPage.test.tsx b/pkg/cli/webapp/src/SandboxesPage.test.tsx new file mode 100644 index 00000000..46f91ff1 --- /dev/null +++ b/pkg/cli/webapp/src/SandboxesPage.test.tsx @@ -0,0 +1,1087 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SandboxesPage } from "./SandboxesPage"; + +const CATALOG = { + default: "prod-pool", + kinds: [ + { + kind: "none", + description: "Run the agent directly on the host, unconfined", + capabilities: [], + modes: ["api", "cli", "agent", "cmux"], + }, + { + kind: "git-agent", + description: "Relocate the run onto an enrolled remote agent over git", + capabilities: ["remote-exec", "isolate-workspace", "egress-proxy"], + modes: ["cli", "agent", "cmux"], + backends: [ + { name: "prod-pool", default: true, agents: [{ name: "worker-01" }] }, + ], + }, + ], + invalid: [ + { name: "typo", kind: "git-agnet", error: 'unknown kind "git-agnet"' }, + ], +}; + +const AGENTS = [ + { + name: "worker-01", + fingerprint: "SHA256:aaa", + hostFingerprint: "SHA256:bbb", + url: "ssh://worker-01:7422", + addedAt: "2026-08-01T00:00:00Z", + status: "enrolled", + dispatchable: true, + }, + // Enrolled by hand without a host key: dispatch pins the host key, so this + // one looks healthy but cannot actually be dispatched to. + { + name: "worker-02", + fingerprint: "SHA256:ccc", + url: "ssh://worker-02:7422", + status: "enrolled", + dispatchable: false, + dispatchIssue: "missing host key", + }, + // Placed by captain: it knows the runtime, so it can offer to tear it down. + { + name: "worker-03", + fingerprint: "SHA256:ddd", + url: "https://worker-03.agents.example.com/git/repo.git", + status: "enrolled", + dispatchable: true, + deployment: { + target: "kubernetes" as const, + namespace: "captain", + workload: "captain-git-agent-worker-03", + image: "ghcr.io/flanksource/captain:latest", + }, + }, + // The workload exists but has not finished joining. Invisible before deploy + // recorded it, which left an operator with a running sidecar and no way to + // remove it from here. + { + name: "worker-04", + status: "deployed — waiting to enroll", + dispatchable: false, + deployment: { + target: "docker" as const, + workload: "captain-git-agent-worker-04", + }, + }, +]; + +const PREFLIGHT = { + target: "docker" as const, + ready: true, + supervisorRequired: false, + mailboxListen: ":7422", + hostFingerprint: "SHA256:mailbox", + transport: "ssh", + supervisor: "ssh://captain@host.docker.internal:7422", + supervisorFrom: "docker-host-gateway", + runtime: "the local docker daemon", + inCluster: false, + domainRequired: false, + certManagerInstalled: false, +}; + +/** + * Commits a value into a Combobox. + * + * Typing alone only filters the menu; the value is emitted on Enter or on + * click-away, so a test that only fires `change` is asserting against a control + * the operator has not finished using. + */ +function selectCombobox(name: string, value: string) { + const input = screen.getByRole("combobox", { name }); + fireEvent.focus(input); + fireEvent.change(input, { target: { value } }); + fireEvent.keyDown(input, { key: "Enter" }); +} + +/** + * The blocker summary beside the submit buttons. + * + * Every blocker deliberately renders twice — once on its own field, once here — + * so assertions have to say which one they mean. + */ +function errorSummary() { + return screen.getByRole("alert", { name: "Form errors" }); +} + +/** The body of the deploy request, which is the contract with the server. */ +function deployBody( + fetchMock: ReturnType, +): Record { + const call = fetchMock.mock.calls.find( + ([url, init]) => + String(url).includes("/deployments") && + (init as RequestInit | undefined)?.method === "POST", + ); + if (!call) throw new Error("no deploy request was sent"); + return JSON.parse(String((call[1] as RequestInit).body)) as Record< + string, + unknown + >; +} + +const NAMESPACES = ["captain", "default", "kube-system"]; +const CLUSTER_ISSUERS = ["letsencrypt-prod", "letsencrypt-staging"]; +const SECRETS = ["agents-example-com-wildcard", "captain-agent-credentials"]; + +const CREDENTIALS = { + config: { + refreshMargin: "1h", + publish: [{ namespace: "captain", secret: "" }], + }, + status: [ + { + provider: "claude", + source: "keychain", + key: "oauth", + expiresAt: "2026-08-25T00:00:00Z", + expiresIn: "6d", + expired: false, + targets: ["secret captain/captain-agent-credentials"], + }, + ], + providers: ["claude", "codex"], + defaultSecret: "captain-agent-credentials", + defaultMargin: "5m0s", +}; + +const TASKS = [ + { + id: "11111111-1111-1111-1111-111111111111", + taskId: "task-1", + mailbox: "mailboxes/aaa.git", + repository: "/repo/project", + backend: "prod-pool", + agent: "worker-01", + base: "main", + dispatchCommit: "deadbeef", + attempts: 1, + maxAttempts: 3, + status: "running" as const, + dispatchedAt: "2026-08-16T09:00:00Z", + updatedAt: "2026-08-16T09:30:00Z", + }, +]; + +const AGENT_WHOAMI = { + adapters: [ + { + backend: "codex-agent", + type: "cli", + provider: "openai", + mode: "agent", + authenticated: true, + authMethod: "codex login", + binary: "/usr/local/bin/codex", + modelCount: 1, + models: ["gpt-5.6-sol"], + }, + ], + defaultProvider: "openai", + providerDefaults: {}, + disabled: {}, + axes: {}, + runtimes: [], +}; + +function jsonResponse(body: unknown) { + return { + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }; +} + +function stubFetch(overrides: Record = {}) { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url.startsWith("/api/captain/sandboxes")) { + return Promise.resolve(jsonResponse(overrides.catalog ?? CATALOG)); + } + if (url.includes("/sandbox/git-agent/tasks/")) { + return Promise.resolve( + jsonResponse({ + task: TASKS[0], + attempts: [ + { + attempt: 1, + tier: "supervisor", + status: "rejected", + findings: [{ hook: "verify", message: "make lint failed" }], + recordedAt: "2026-08-16T10:00:00Z", + }, + ], + }), + ); + } + if (url.includes("/sandbox/git-agent/tasks")) { + return Promise.resolve(jsonResponse(overrides.tasks ?? TASKS)); + } + if (url.includes("/sandbox/git-agent/deploy/preflight")) { + return Promise.resolve(jsonResponse(overrides.preflight ?? PREFLIGHT)); + } + if (url.includes("/sandbox/git-agent/namespaces")) { + return Promise.resolve(jsonResponse(overrides.namespaces ?? NAMESPACES)); + } + if (url.includes("/sandbox/git-agent/cluster-issuers")) { + return Promise.resolve( + jsonResponse(overrides.clusterIssuers ?? CLUSTER_ISSUERS), + ); + } + if (url.includes("/sandbox/git-agent/secrets")) { + return Promise.resolve(jsonResponse(overrides.secrets ?? SECRETS)); + } + if (url.includes("/sandbox/credentials")) { + return Promise.resolve( + jsonResponse(overrides.credentials ?? CREDENTIALS), + ); + } + if (url.includes("/sandbox/git-agent/agents/worker-03/whoami")) { + return Promise.resolve( + jsonResponse(overrides.agentWhoami ?? AGENT_WHOAMI), + ); + } + if (url.includes("/sandbox/git-agent/deployments")) { + if (init?.method === "DELETE") { + return Promise.resolve( + jsonResponse({ + backend: "git-agent", + agent: "worker-03", + target: "kubernetes", + removed: ["Deployment/captain-git-agent-worker-03"], + revoked: true, + }), + ); + } + const body = JSON.parse(String(init?.body ?? "{}")) as { + dryRun?: boolean; + }; + return Promise.resolve( + jsonResponse( + overrides.deploy ?? { + backend: "git-agent", + agent: "worker-09", + target: "docker", + image: "ghcr.io/flanksource/captain:latest", + workload: "captain-git-agent-worker-09", + volume: "captain-git-agent-worker-09-state", + supervisor: "ssh://captain@host.docker.internal:7422", + supervisorFrom: "docker-host-gateway", + advertise: "ssh://captain@127.0.0.1:41234/repo.git", + advertiseFrom: "docker-published-port", + hostFingerprint: "SHA256:mailbox", + security: "unprivileged, all capabilities dropped", + credentials: + "none declared — the agent cannot reach a model provider", + egressRestricted: false, + enrolled: !body.dryRun, + ready: !body.dryRun, + ...(body.dryRun + ? { + dryRun: true, + mutations: [ + 'mint a durable captain token for agent "worker-09"', + "run: docker run --rm captain", + ], + } + : { objects: ["container/abc123def456"] }), + }, + ), + ); + } + if (url.includes("/sandbox/git-agent/agents")) { + if (init?.method === "DELETE") { + return Promise.resolve( + jsonResponse({ + backend: "git-agent", + agent: "worker-01", + revoked: true, + }), + ); + } + if (init?.method === "POST") { + return Promise.resolve( + jsonResponse({ + backend: "git-agent", + agent: "worker-09", + tokenId: "abc123", + hostFingerprint: "SHA256:host", + dispatchKey: "SHA256:dispatch", + joinCommand: + "captain sandbox git-agent serve --token cptn_abc123.SECRET --supervisor ssh://s:7422 --host-fingerprint SHA256:host", + }), + ); + } + return Promise.resolve(jsonResponse(overrides.agents ?? AGENTS)); + } + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("SandboxesPage", () => { + it("lists each adapter with its capabilities and modes", async () => { + stubFetch(); + renderPage(); + await screen.findByText("git-agent"); + expect(screen.getByText("remote-exec")).toBeTruthy(); + expect(screen.getByText("egress-proxy")).toBeTruthy(); + // The configured backend appears under the kind it selects. + expect(screen.getByText("prod-pool")).toBeTruthy(); + }); + + it("reports a backend whose kind does not resolve instead of hiding it", async () => { + stubFetch(); + renderPage(); + await waitFor(() => + expect( + screen + .getAllByRole("alert") + .some((alert) => alert.textContent?.includes("git-agnet")), + ).toBe(true), + ); + }); + + it("distinguishes enrolled, still-joining, and undispatchable agents", async () => { + stubFetch(); + renderPage(); + await screen.findByText("worker-01"); + // worker-02 is enrolled over SSH but has no host key to pin. + expect( + screen.getByText("enrolled — not dispatchable (missing host key)"), + ).toBeTruthy(); + // worker-03 is reached over HTTPS and uses a dispatch token, not a host key. + const httpsRow = screen + .getByText("https://worker-03.agents.example.com/git/repo.git") + .closest("tr"); + expect(httpsRow?.textContent).toContain("enrolled"); + expect(httpsRow?.textContent).not.toContain("not dispatchable"); + // worker-04's workload exists but has not joined yet. + expect(screen.getByText(/deployed — waiting to enroll/)).toBeTruthy(); + }); + + it("loads an HTTPS agent's whoami details only when requested", async () => { + const fetchMock = stubFetch(); + renderPage(); + await screen.findByText("worker-03"); + + expect(screen.queryByText("codex login")).toBeNull(); + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes("/worker-03/whoami"), + ), + ).toBe(false); + + fireEvent.click( + screen.getByRole("button", { name: "Inspect worker-03 runtimes" }), + ); + + expect(await screen.findByText("codex login")).toBeInTheDocument(); + expect(screen.getByText("gpt-5.6-sol")).toBeInTheDocument(); + expect(screen.getByText("1 ready adapter")).toBeInTheDocument(); + expect(fetchMock).toHaveBeenCalledWith( + "/api/captain/sandbox/git-agent/agents/worker-03/whoami?backend=git-agent", + expect.objectContaining({ method: "POST", body: "{}" }), + ); + }); + + // Captain can only tear down what it placed: an agent enrolled by hand has no + // recorded runtime, so offering to undeploy it would be a guess. + it("offers undeploy only for agents captain deployed, and revoke only for the rest", async () => { + stubFetch(); + renderPage(); + await screen.findByText("worker-01"); + + expect(screen.getAllByRole("button", { name: "Undeploy" })).toHaveLength(2); + expect(screen.getAllByRole("button", { name: "Revoke" })).toHaveLength(2); + expect(screen.getAllByText("self-managed")).toHaveLength(2); + expect(screen.getByText(/kubernetes/)).toBeTruthy(); + }); + + it("undeploys once confirmed, without naming a target the server should resolve", async () => { + const fetchMock = stubFetch(); + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); + renderPage(); + await screen.findByText("worker-03"); + + fireEvent.click(screen.getAllByRole("button", { name: "Undeploy" })[0]!); + + await waitFor(() => + expect( + fetchMock.mock.calls.some( + ([url, init]) => + String(url).includes("/deployments/worker-03") && + (init as RequestInit)?.method === "DELETE", + ), + ).toBe(true), + ); + // Tearing down the wrong runtime removes nothing and reports success, so + // the target comes from what deploy recorded rather than from the browser. + const call = fetchMock.mock.calls.find(([url]) => + String(url).includes("/deployments/worker-03"), + ); + expect(String(call?.[0])).not.toContain("target="); + }); + + it("confirms before revoking, and does nothing when declined", async () => { + const fetchMock = stubFetch(); + vi.stubGlobal( + "confirm", + vi.fn(() => false), + ); + renderPage(); + await screen.findByText("worker-01"); + + fireEvent.click(screen.getAllByRole("button", { name: "Revoke" })[0]!); + await waitFor(() => expect(window.confirm).toHaveBeenCalled()); + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit)?.method === "DELETE", + ), + ).toBe(false); + }); + + it("revokes once confirmed", async () => { + const fetchMock = stubFetch(); + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ); + renderPage(); + await screen.findByText("worker-01"); + + fireEvent.click(screen.getAllByRole("button", { name: "Revoke" })[0]!); + await waitFor(() => + expect( + fetchMock.mock.calls.some( + ([, init]) => (init as RequestInit)?.method === "DELETE", + ), + ).toBe(true), + ); + }); + + it("shows the join command after enrolling, and never key material", async () => { + stubFetch(); + renderPage(); + await screen.findByText("worker-01"); + + fireEvent.click( + screen.getByRole("button", { name: "Enroll existing host" }), + ); + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Enroll" })); + + const command = await screen.findByText( + /captain sandbox git-agent serve --token/, + ); + expect(command.textContent).toContain("--host-fingerprint"); + // A7.1: the hand-off carries a token, never a private key. + expect(document.body.textContent).not.toContain("PRIVATE KEY"); + // A token with no expiry says so, rather than rendering "Invalid Date". + expect(document.body.textContent).toContain("until it is revoked"); + expect(document.body.textContent).not.toContain("Invalid Date"); + }); +}); + +describe("SandboxesPage deploy", () => { + const openDeploy = async () => { + renderPage(); + await screen.findByText("worker-01"); + fireEvent.click(screen.getByRole("button", { name: "Deploy agent" })); + }; + + it("shows what the target resolved to before asking for anything", async () => { + stubFetch(); + await openDeploy(); + + // The addresses are proven, not typed, so they are stated up front. + await screen.findByText("the local docker daemon"); + expect( + screen.getByText("ssh://captain@host.docker.internal:7422"), + ).toBeTruthy(); + expect(screen.getByText(/docker-host-gateway/)).toBeTruthy(); + }); + + // The deploy command refuses rather than guessing; discovering that only on + // submit would put the operator back where the command started. + it("blocks the form and explains when the target cannot be deployed to", async () => { + stubFetch({ + preflight: { + target: "docker", + ready: false, + supervisorRequired: false, + reason: + 'no mailbox has served from backend "git-agent" on this host; run `captain sandbox git-agent serve --role mailbox` first', + }, + }); + await openDeploy(); + + await screen.findByText(/Cannot deploy to docker from this host/); + expect(screen.getByText(/serve --role mailbox/)).toBeTruthy(); + expect(screen.queryByPlaceholderText("worker-01")).toBeNull(); + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(true); + }); + + // Which process is the supervisor decides which one an operator restarts to + // fix anything, and https means `captain serve` already is it — so the + // transport is stated rather than left to be inferred from a port. + it("names the transport the mailbox answered on", async () => { + stubFetch({ + preflight: { + ...PREFLIGHT, + transport: "https", + mailboxListen: "0.0.0.0:9020", + supervisor: "https://host.docker.internal:9020", + }, + }); + await openDeploy(); + + await screen.findByText("over https"); + expect( + screen.getByText("https://host.docker.internal:9020"), + ).toBeInTheDocument(); + }); + + // Namespace is select-or-create: the cluster's own namespaces are offered so + // an operator does not have to remember one, and a name that is not there is + // a creation rather than a typo to discover at apply time. + const openKubernetesForm = async () => { + await openDeploy(); + fireEvent.click(screen.getByRole("button", { name: /Kubernetes/ })); + await screen.findByText(/kubeconfig context's/); + }; + + const KUBERNETES_READY = { + target: "kubernetes", + ready: true, + supervisorRequired: true, + supervisorCandidates: ["ssh://192.168.1.20:7422", "ssh://172.17.0.1:7422"], + mailboxListen: ":7422", + transport: "ssh", + namespace: "default", + runtime: "kubernetes v1.31.0", + inCluster: false, + // ssh: an Ingress cannot front it, so the advertise URL is the route half. + domainRequired: true, + ingressClasses: ["nginx", "traefik"], + certManagerInstalled: true, + }; + + /** The same cluster reached from an https mailbox, where an Ingress can front it. */ + const KUBERNETES_HTTPS = { + ...KUBERNETES_READY, + transport: "https", + mailboxListen: "0.0.0.0:9020", + supervisorCandidates: ["https://192.168.1.20:9020"], + }; + + /** + * Everything an out-of-cluster kubernetes deploy cannot detect, so a test + * about one field is not also a test about the other two. Uses the advertise + * URL rather than a domain because the fixture's mailbox answered over ssh, + * which an Ingress cannot front. + */ + const fillRequiredKubernetesFields = async () => { + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + selectCombobox("Supervisor address", "ssh://captain.internal:7422"); + fireEvent.change( + screen.getByPlaceholderText("https://worker-01.agents.example.com"), + { target: { value: "ssh://captain@worker-09.agents.internal:7422" } }, + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(false), + ); + }; + + it("offers the cluster's namespaces", async () => { + stubFetch({ preflight: KUBERNETES_READY }); + await openKubernetesForm(); + + fireEvent.focus(screen.getByRole("combobox", { name: "Namespace" })); + expect( + await screen.findByRole("option", { name: "captain" }), + ).toBeInTheDocument(); + expect( + screen.getByRole("option", { name: "kube-system" }), + ).toBeInTheDocument(); + }); + + it("treats a name the cluster does not have as a namespace to create", async () => { + const fetchMock = stubFetch({ preflight: KUBERNETES_READY }); + await openKubernetesForm(); + + const namespace = screen.getByRole("combobox", { name: "Namespace" }); + fireEvent.focus(namespace); + fireEvent.change(namespace, { target: { value: "agents" } }); + fireEvent.keyDown(namespace, { key: "Enter" }); + + // Stated before submit: it is the one cluster-scoped change, and undeploy + // does not undo it. + await screen.findByText( + /agents does not exist in this cluster and will be created/, + ); + + await fillRequiredKubernetesFields(); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => { + const deployCall = fetchMock.mock.calls.find( + ([url, init]) => + String(url).includes("/deployments") && + (init as RequestInit | undefined)?.method === "POST", + ); + expect(deployCall).toBeTruthy(); + const body = JSON.parse( + String((deployCall![1] as RequestInit).body), + ) as Record; + expect(body.namespace).toBe("agents"); + expect(body.createNamespace).toBe(true); + }); + }); + + it("does not ask to create a namespace the cluster already has", async () => { + const fetchMock = stubFetch({ preflight: KUBERNETES_READY }); + await openKubernetesForm(); + + fireEvent.focus(screen.getByRole("combobox", { name: "Namespace" })); + fireEvent.mouseDown(await screen.findByRole("option", { name: "captain" })); + + expect(screen.queryByText(/will be created/)).toBeNull(); + + await fillRequiredKubernetesFields(); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => { + const deployCall = fetchMock.mock.calls.find( + ([url, init]) => + String(url).includes("/deployments") && + (init as RequestInit | undefined)?.method === "POST", + ); + expect(deployCall).toBeTruthy(); + const body = JSON.parse( + String((deployCall![1] as RequestInit).body), + ) as Record; + expect(body.namespace).toBe("captain"); + // Pruned rather than sent false, so an existing namespace never carries a + // create intent to the server. + expect(body).not.toHaveProperty("createNamespace"); + }); + }); + + // For kubernetes no route back can be proven, so the address is required + // rather than guessed — a guess produces a pod that CrashLoops on enroll. + it("requires a supervisor address for kubernetes and says why", async () => { + stubFetch({ preflight: KUBERNETES_READY }); + await openDeploy(); + fireEvent.click(screen.getByRole("button", { name: /Kubernetes/ })); + + await waitFor(() => + expect(errorSummary().textContent).toMatch( + /Supervisor address: .*no route back to this host can be proven/, + ), + ); + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(true); + + await fillRequiredKubernetesFields(); + // Everything supplied, so the summary is gone rather than left empty. + expect(screen.queryByRole("alert", { name: "Form errors" })).toBeNull(); + }); + + // The addresses worth trying are facts about this host the preflight already + // enumerated, so the operator picks rather than recalling which interface a + // cluster can reach. + it("offers this host's addresses for the supervisor, and sends the pick", async () => { + const fetchMock = stubFetch({ preflight: KUBERNETES_READY }); + await openKubernetesForm(); + + fireEvent.focus( + screen.getByRole("combobox", { name: "Supervisor address" }), + ); + expect( + await screen.findByRole("option", { name: "ssh://192.168.1.20:7422" }), + ).toBeInTheDocument(); + fireEvent.mouseDown( + screen.getByRole("option", { name: "ssh://172.17.0.1:7422" }), + ); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + fireEvent.change( + screen.getByPlaceholderText("https://worker-01.agents.example.com"), + { target: { value: "ssh://captain@worker-09.agents.internal:7422" } }, + ); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => + expect(deployBody(fetchMock).supervisorAddress).toBe( + "ssh://172.17.0.1:7422", + ), + ); + }); + + // A docker sidecar is reached on its published loopback port, and the server + // refuses route flags on that target rather than ignoring them. + it("asks about routing only for kubernetes", async () => { + stubFetch(); + await openDeploy(); + await screen.findByText("the local docker daemon"); + + expect(screen.queryByPlaceholderText("agents.example.com")).toBeNull(); + }); + + it("offers the cluster's ingress classes", async () => { + stubFetch({ preflight: KUBERNETES_HTTPS }); + await openKubernetesForm(); + + fireEvent.focus(screen.getByRole("combobox", { name: "Ingress class" })); + expect( + await screen.findByRole("option", { name: "traefik" }), + ).toBeInTheDocument(); + expect(screen.getByRole("option", { name: "nginx" })).toBeInTheDocument(); + }); + + // The name is the one thing captain does NOT create, so it is shown before + // submit rather than left to be reconstructed from the mutation list. + it("states the host it would publish, and that the DNS record is not created", async () => { + stubFetch({ preflight: KUBERNETES_HTTPS }); + await openKubernetesForm(); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + fireEvent.change(screen.getByPlaceholderText("agents.example.com"), { + target: { value: "agents.example.com" }, + }); + + await screen.findByText(/Published at worker-09\.agents\.example\.com/); + expect(screen.getByText(/does NOT create the DNS record/)).toBeTruthy(); + }); + + // Over https the Ingress is the supported route, so the form demands it the + // same way it demands the supervisor address — both are refusals the server + // would otherwise give after the modal is gone. + it("requires a domain and a certificate over an https mailbox", async () => { + const fetchMock = stubFetch({ preflight: KUBERNETES_HTTPS }); + await openKubernetesForm(); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + selectCombobox("Supervisor address", "https://192.168.1.20:9020"); + await waitFor(() => + expect(errorSummary().textContent).toMatch(/Domain: .*left to advertise/), + ); + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(true); + + fireEvent.change(screen.getByPlaceholderText("agents.example.com"), { + target: { value: "agents.example.com" }, + }); + // A domain alone still blocks: without a certificate the controller answers + // for that host with its own and the supervisor's push fails verification. + await waitFor(() => + expect(errorSummary().textContent).toMatch( + /ClusterIssuer: .*needs a certificate/, + ), + ); + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(true); + // And the field itself is marked, not just described in the summary. + expect( + screen.getByRole("combobox", { name: "ClusterIssuer" }), + ).toHaveAttribute("aria-invalid", "true"); + + selectCombobox("ClusterIssuer", "letsencrypt-prod"); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => { + const body = deployBody(fetchMock); + expect(body.domain).toBe("agents.example.com"); + expect(body.ingressIssuer).toBe("letsencrypt-prod"); + // Blank keeps the server's own default rather than the form restating it. + expect(body).not.toHaveProperty("ingressClass"); + }); + }); + + // An issuer annotation is inert without the controller that acts on it, so the + // option is closed rather than left to fail after the token is minted. + it("falls back to a TLS Secret when the cluster has no cert-manager", async () => { + const fetchMock = stubFetch({ + preflight: { ...KUBERNETES_HTTPS, certManagerInstalled: false }, + }); + await openKubernetesForm(); + + await screen.findByText(/cert-manager is not installed/); + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + selectCombobox("Supervisor address", "https://192.168.1.20:9020"); + fireEvent.change(screen.getByPlaceholderText("agents.example.com"), { + target: { value: "agents.example.com" }, + }); + selectCombobox("TLS Secret", "agents-wildcard"); + await waitFor(() => + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(false), + ); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => { + const body = deployBody(fetchMock); + expect(body.ingressTlsSecret).toBe("agents-wildcard"); + expect(body).not.toHaveProperty("ingressIssuer"); + }); + }); + + // Where captain knows a controller's translation there is nothing for the + // operator to decide, so it is applied on selection rather than demanded. + it("applies traefik's equivalents on selection instead of refusing", async () => { + const fetchMock = stubFetch({ preflight: KUBERNETES_HTTPS }); + await openKubernetesForm(); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + selectCombobox("Supervisor address", "https://192.168.1.20:9020"); + fireEvent.change(screen.getByPlaceholderText("agents.example.com"), { + target: { value: "agents.example.com" }, + }); + selectCombobox("ClusterIssuer", "letsencrypt-prod"); + selectCombobox("Ingress class", "traefik"); + + // No prompt to acknowledge anything, and no blocker left to clear. + await waitFor(() => + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(false), + ); + expect(screen.queryByRole("alert", { name: "Form errors" })).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => + expect(deployBody(fetchMock).ingressAnnotation).toEqual([ + "traefik.ingress.kubernetes.io/service.serversscheme=https", + ]), + ); + }); + + // A controller captain has no translation for still has to be acknowledged: + // its nginx defaults are inert there, and captain cannot invent equivalents. + it("still makes an unknown controller acknowledge what a git push needs", async () => { + stubFetch({ preflight: KUBERNETES_HTTPS }); + await openKubernetesForm(); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + selectCombobox("Supervisor address", "https://192.168.1.20:9020"); + fireEvent.change(screen.getByPlaceholderText("agents.example.com"), { + target: { value: "agents.example.com" }, + }); + selectCombobox("ClusterIssuer", "letsencrypt-prod"); + selectCombobox("Ingress class", "contour"); + + // Named against the annotations field, which is what fixes it — not against + // the class field, which is already what the operator wanted. + await waitFor(() => + expect(errorSummary().textContent).toMatch( + /Ingress annotations: contour is not ingress-nginx/, + ), + ); + expect( + screen.getByRole("button", { name: "Deploy" }).hasAttribute("disabled"), + ).toBe(true); + }); + + // A Secret that is not kubernetes.io/tls cannot serve the agent's host, and + // the Ingress would be created pointing at it regardless — so the field + // offers the cluster's real TLS Secrets, scoped to the target namespace. + it("offers the namespace's TLS Secrets and the cluster's issuers", async () => { + const fetchMock = stubFetch({ preflight: KUBERNETES_HTTPS }); + await openKubernetesForm(); + + fireEvent.focus(screen.getByRole("combobox", { name: "ClusterIssuer" })); + expect( + await screen.findByRole("option", { name: "letsencrypt-staging" }), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("radio", { name: /existing TLS Secret/ })); + fireEvent.focus( + await screen.findByRole("combobox", { name: "TLS Secret" }), + ); + expect( + await screen.findByRole("option", { + name: "agents-example-com-wildcard", + }), + ).toBeInTheDocument(); + + // Scoped, not cluster-wide: the same name is a different object elsewhere. + const secretCall = fetchMock.mock.calls.find(([url]) => + String(url).includes("/git-agent/secrets"), + ); + expect(String(secretCall?.[0])).toContain("type=kubernetes.io%2Ftls"); + expect(String(secretCall?.[0])).toContain("namespace="); + }); + + it("previews every intended mutation before creating anything", async () => { + const fetchMock = stubFetch(); + await openDeploy(); + await screen.findByText("the local docker daemon"); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Preview" })); + + await screen.findByText(/mint a durable captain token/); + expect(screen.getByText(/run: docker run/)).toBeTruthy(); + // A preview creates nothing: the request that produced it was a dry run. + const preview = fetchMock.mock.calls.find( + ([url, init]) => + String(url).includes("/deployments") && + (init as RequestInit)?.method === "POST", + ); + expect( + JSON.parse(String((preview?.[1] as RequestInit)?.body)), + ).toMatchObject({ + dryRun: true, + name: "worker-09", + target: "docker", + }); + }); + + // An agent with no model credentials enrols, goes ready, and fails its first + // task — so it is stated rather than discovered. + it("warns when the deployed agent has no way to reach a model provider", async () => { + stubFetch(); + await openDeploy(); + await screen.findByText("the local docker daemon"); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await screen.findByText(/fail its first task/); + expect(screen.getByText("container/abc123def456")).toBeTruthy(); + }); + + it("sends only the fields the operator set, so the rest keep CLI defaults", async () => { + const fetchMock = stubFetch(); + await openDeploy(); + await screen.findByText("the local docker daemon"); + + fireEvent.change(screen.getByPlaceholderText("worker-01"), { + target: { value: "worker-09" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Deploy" })); + + await waitFor(() => + expect( + fetchMock.mock.calls.some( + ([url, init]) => + String(url).includes("/deployments") && + (init as RequestInit)?.method === "POST", + ), + ).toBe(true), + ); + const call = fetchMock.mock.calls.find( + ([url, init]) => + String(url).includes("/deployments") && + (init as RequestInit)?.method === "POST", + ); + const body = JSON.parse(String((call?.[1] as RequestInit)?.body)); + expect(Object.keys(body).sort()).toEqual(["name", "target"]); + }); +}); + +describe("SandboxesPage remote tasks", () => { + it("lists recorded remote tasks", async () => { + stubFetch(); + renderPage(); + await screen.findByText("task-1"); + expect(screen.getByText("running")).toBeTruthy(); + expect(screen.getByText("1 / 3")).toBeTruthy(); + }); + + it("shows an empty state when nothing has been dispatched", async () => { + stubFetch({ tasks: [] }); + renderPage(); + await screen.findByText(/No remote tasks recorded yet/); + }); + + it("opens a task and shows its per-tier verdict findings", async () => { + stubFetch(); + renderPage(); + fireEvent.click(await screen.findByText("task-1")); + await screen.findByText(/make lint failed/); + expect(screen.getByText("attempt 1")).toBeTruthy(); + expect(screen.getByText("supervisor")).toBeTruthy(); + }); +}); diff --git a/pkg/cli/webapp/src/SandboxesPage.tsx b/pkg/cli/webapp/src/SandboxesPage.tsx new file mode 100644 index 00000000..284672a5 --- /dev/null +++ b/pkg/cli/webapp/src/SandboxesPage.tsx @@ -0,0 +1,492 @@ +import { useState, type ReactNode } from "react"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Panel } from "@flanksource/clicky-ui/components"; +import { Badge } from "@flanksource/clicky-ui/data"; +import type { SpecRuntimeSandboxKind } from "@flanksource/clicky-ui/ai"; + +import { GitAgentDeployModal } from "./GitAgentDeployModal"; +import { GitAgentEnrollModal } from "./GitAgentEnrollModal"; +import { GitAgentTasks } from "./GitAgentTasks"; +import { GitAgentWhoami } from "./GitAgentWhoami"; +import { SandboxCredentials } from "./SandboxCredentials"; +import { + fetchGitAgents, + fetchSandboxCatalog, + isDispatchable, + isPending, + revokeGitAgent, + undeployGitAgent, + type GitAgent, +} from "./sandboxData"; + +/** + * The name of the git-agent backend this page administers. The routes accept + * any configured backend; the page shows the one named after the adapter kind, + * which is what `captain sandbox git-agent` defaults to. + */ +const GIT_AGENT_BACKEND = "git-agent"; + +export function SandboxesPage() { + const queryClient = useQueryClient(); + const [enrolling, setEnrolling] = useState(false); + const [deploying, setDeploying] = useState(false); + const [editing, setEditing] = useState(); + + const catalog = useQuery({ + queryKey: ["sandbox-catalog"], + queryFn: fetchSandboxCatalog, + }); + const agents = useQuery({ + queryKey: ["git-agents", GIT_AGENT_BACKEND], + queryFn: () => fetchGitAgents(GIT_AGENT_BACKEND), + }); + + const refreshAgents = () => + void queryClient.invalidateQueries({ + queryKey: ["git-agents", GIT_AGENT_BACKEND], + }); + + return ( +
+ + {catalog.error ? ( + + ) : ( + + )} + {(catalog.data?.invalid?.length ?? 0) > 0 && ( +
+ {catalog.data?.invalid?.map((backend) => ( +

+ Backend {backend.name} is unusable:{" "} + {backend.error} +

+ ))} +
+ )} +
+ + + {/* Enroll prints a command to run elsewhere; deploy creates the + machine here. Deploy leads because it is the whole job. */} + + +
+ } + > + {agents.error ? ( + + ) : ( + + )} + + + {/* Below the roster it serves: the Secret this publishes is what the + deploy form's "Agent login Secret" names. */} + + + + + + + setDeploying(false)} + onDeployed={refreshAgents} + /> + {editing?.deployment && ( + setEditing(undefined)} + onDeployed={refreshAgents} + /> + )} + setEnrolling(false)} + onEnrolled={refreshAgents} + /> + + ); +} + +function SandboxKindTable({ + kinds, + defaultSelector, +}: { + kinds: SpecRuntimeSandboxKind[]; + defaultSelector?: string | undefined; +}) { + if (kinds.length === 0) { + return ( +

No sandbox adapters.

+ ); + } + return ( + + + + + + + + + + + {kinds.map((kind) => ( + + + + + + + ))} + +
KindCapabilitiesRuntime modesConfigured backends
+
+ {kind.kind} + {defaultSelector === kind.kind && default} +
+

{kind.description}

+
+ + + + + {(kind.backends?.length ?? 0) === 0 ? ( + + ) : ( +
    + {kind.backends?.map((backend) => ( +
  • + {backend.name} + {defaultSelector === backend.name && ( + default + )} + {(backend.agents?.length ?? 0) > 0 && ( + + {backend.agents?.length} agent + {backend.agents?.length === 1 ? "" : "s"} + + )} +
  • + ))} +
+ )} +
+ ); +} + +function GitAgentTable({ + agents, + loading, + onEdit, + onChanged, +}: { + agents: GitAgent[]; + loading: boolean; + onEdit: (agent: GitAgent) => void; + onChanged: () => void; +}) { + const [busy, setBusy] = useState(); + const [error, setError] = useState(); + const [inspecting, setInspecting] = useState(); + + if (loading) { + return

Loading agents…

; + } + if (agents.length === 0) { + return ( +

+ No agents yet. Deploying one creates a sidecar on docker or kubernetes; + enrolling one prints a join command to run on a host you already have. +

+ ); + } + + const run = async (name: string, action: () => Promise) => { + setBusy(name); + setError(undefined); + try { + await action(); + onChanged(); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } finally { + setBusy(undefined); + } + }; + + const revoke = (agent: GitAgent) => { + // Revocation rewrites ~/.captain.yaml and takes effect for connections + // established after it, so confirm before dropping a working agent. + if ( + !window.confirm( + `Revoke ${agent.name}? Its key is refused from now on and its token is revoked, ` + + `but the machine keeps running — use Undeploy to remove it.`, + ) + ) { + return; + } + void run(agent.name, () => + revokeGitAgent({ backend: GIT_AGENT_BACKEND, name: agent.name }), + ); + }; + + const undeploy = (agent: GitAgent) => { + const deployment = agent.deployment; + if (!deployment) return; + // Undeploy removes the workload and revokes the agent together. The state + // volume is kept unless asked: it holds the agent's private key, so purging + // it makes the agent unrecoverable rather than merely stopped. + if ( + !window.confirm( + `Undeploy ${agent.name}? This removes ${deployment.workload} from ` + + `${deployment.target}${deployment.namespace ? ` (${deployment.namespace})` : ""}, ` + + `revokes its key and token, and keeps the state volume.`, + ) + ) { + return; + } + void run(agent.name, () => + undeployGitAgent({ backend: GIT_AGENT_BACKEND, name: agent.name }), + ); + }; + + return ( +
+ {error && ( +

+ {error} +

+ )} + + + + + + + + + + + + + {agents.map((agent) => ( + + setInspecting((current) => + current === agent.name ? undefined : agent.name, + ) + } + onEdit={() => onEdit(agent)} + onUndeploy={() => undeploy(agent)} + onRevoke={() => revoke(agent)} + /> + ))} + +
AgentStatusRuns onEndpointAdded
+
+ ); +} + +function AgentRows({ + agent, + busy, + inspecting, + onInspect, + onEdit, + onUndeploy, + onRevoke, +}: { + agent: GitAgent; + busy: boolean; + inspecting: boolean; + onInspect: () => void; + onEdit: () => void; + onUndeploy: () => void; + onRevoke: () => void; +}) { + const inspectable = + isDispatchable(agent) && agent.url?.toLowerCase().startsWith("https://"); + return ( + <> + + + {agent.name} + + + + + + + + + {agent.url ?? "—"} + + + {agent.addedAt ? new Date(agent.addedAt).toLocaleString() : "—"} + + +
+ {inspectable && ( + + )} + {agent.deployment?.config && ( + + )} + {agent.deployment && ( + + )} + {!isPending(agent) && !agent.deployment && ( + + )} +
+ + + {inspecting && ( + + + + + + )} + + ); +} + +/** + * Where the sidecar runs, when captain placed it. An agent enrolled by hand + * shows nothing — captain does not know, which is also why it cannot offer to + * tear that one down. + */ +function AgentRuntime({ agent }: { agent: GitAgent }) { + const deployment = agent.deployment; + if (!deployment) { + return self-managed; + } + return ( + + {deployment.target} + {deployment.namespace && ( + / {deployment.namespace} + )} + + ); +} + +function AgentStatus({ agent }: { agent: GitAgent }) { + if (isPending(agent)) { + return ( + + deployed — waiting to enroll + + ); + } + if (!isDispatchable(agent)) { + return ( + + enrolled — not dispatchable ( + {agent.dispatchIssue ?? "missing dispatch credential"}) + + ); + } + return enrolled; +} + +function Chips({ values, empty }: { values: string[]; empty: string }) { + if (values.length === 0) { + return {empty}; + } + return ( +
    + {values.map((value) => ( +
  • + {value} +
  • + ))} +
+ ); +} + +function Th({ children }: { children: ReactNode }) { + return {children}; +} + +function Td({ children }: { children: ReactNode }) { + return {children}; +} + +function ErrorText({ error }: { error: unknown }) { + return ( +

+ {error instanceof Error ? error.message : String(error)} +

+ ); +} diff --git a/pkg/cli/webapp/src/SandboxesPageEdit.test.tsx b/pkg/cli/webapp/src/SandboxesPageEdit.test.tsx new file mode 100644 index 00000000..99b22f3a --- /dev/null +++ b/pkg/cli/webapp/src/SandboxesPageEdit.test.tsx @@ -0,0 +1,183 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SandboxesPage } from "./SandboxesPage"; +import type { DeployConfig, DeployTarget } from "./gitAgentDeploymentData"; + +function jsonResponse(body: unknown) { + return new Response(JSON.stringify(body), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} + +function agent(target: DeployTarget, config: DeployConfig) { + return { + name: `worker-${target}`, + status: "enrolled", + url: "ssh://captain@127.0.0.1:7423/repo.git", + hostFingerprint: "SHA256:agent", + deployment: { + target, + namespace: config.namespace, + workload: `captain-git-agent-worker-${target}`, + image: config.image, + config, + }, + }; +} + +function stubFetch(target: DeployTarget, config: DeployConfig) { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + if (url === "/api/captain/sandboxes") { + return Promise.resolve(jsonResponse({ kinds: [] })); + } + if (url.includes("/sandbox/git-agent/agents")) { + return Promise.resolve(jsonResponse([agent(target, config)])); + } + if (url.includes("/deploy/preflight")) { + return Promise.resolve(jsonResponse({ + target, + ready: true, + supervisorRequired: target === "kubernetes", + supervisorCandidates: ["https://captain.example.com"], + mailboxListen: ":9020", + transport: "https", + runtime: target === "docker" ? "the local docker daemon" : "kubernetes v1.31.0", + namespace: config.namespace, + inCluster: false, + domainRequired: target === "kubernetes", + ingressClasses: ["nginx"], + certManagerInstalled: true, + })); + } + if (url.includes("/namespaces")) { + return Promise.resolve(jsonResponse([config.namespace ?? "default"])); + } + if (url.includes("/cluster-issuers") || url.includes("/secrets")) { + return Promise.resolve(jsonResponse([])); + } + if (url.includes("/sandbox/credentials")) { + return Promise.resolve(jsonResponse({ + config: { refreshMargin: "", publish: [] }, + status: [], + providers: [], + defaultSecret: "captain-agent-credentials", + defaultMargin: "1h", + })); + } + if (url.includes("/sandbox/git-agent/tasks")) { + return Promise.resolve(jsonResponse([])); + } + if (init?.method === "PUT" && url.includes("/deployments/")) { + const request = JSON.parse(String(init.body)) as { name: string; image?: string; dryRun?: boolean }; + return Promise.resolve(jsonResponse({ + backend: "git-agent", + agent: request.name, + target, + image: request.image, + workload: `captain-git-agent-${request.name}`, + volume: `captain-git-agent-${request.name}-state`, + supervisor: config.supervisorAddress, + supervisorFrom: "flag", + advertise: config.advertise, + advertiseFrom: "saved deployment", + hostFingerprint: "SHA256:mailbox", + security: "read-only-root", + credentials: "configured", + egressRestricted: false, + enrolled: false, + ready: false, + replaced: true, + dryRun: request.dryRun, + mutations: ["replace the existing workload"], + })); + } + throw new Error(`unexpected fetch ${init?.method ?? "GET"} ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function renderPage() { + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + , + ); +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("SandboxesPage deployment editing", () => { + it.each([ + { + target: "docker" as const, + config: { + target: "docker" as const, + image: "registry.example/captain:v1", + supervisorAddress: "https://captain.example.com", + advertise: "ssh://captain@127.0.0.1:7423/repo.git", + credentialsDir: "/var/lib/captain/credentials", + }, + }, + { + target: "kubernetes" as const, + config: { + target: "kubernetes" as const, + transport: "https", + namespace: "agents", + kubeContext: "agents-lab", + image: "registry.example/captain:v1", + supervisorAddress: "https://captain.example.com", + advertise: "https://worker-kubernetes.agents.example.com/git/repo.git", + domain: "agents.example.com", + ingressClass: "nginx", + ingressIssuer: "letsencrypt-prod", + credentialsSecret: "captain-agent-credentials", + }, + }, + ])("prefills and previews an in-place $target update", async ({ target, config }) => { + const fetchMock = stubFetch(target, config); + renderPage(); + + fireEvent.click(await screen.findByRole("button", { name: "Edit" })); + expect(await screen.findByText(`Edit worker-${target}`)).toBeTruthy(); + + const name = await screen.findByDisplayValue(`worker-${target}`); + expect(name).toHaveAttribute("id", "deploy-name"); + expect(name).toBeDisabled(); + expect(screen.getByRole("button", { name: target === "docker" ? "Docker" : "Kubernetes" })).toBeDisabled(); + + fireEvent.click(screen.getByRole("button", { name: "Show image and sizing" })); + const image = screen.getByDisplayValue("registry.example/captain:v1"); + fireEvent.change(image, { target: { value: "registry.example/captain:v2" } }); + fireEvent.click(screen.getByRole("button", { name: "Preview update" })); + + await waitFor(() => { + const call = fetchMock.mock.calls.find( + ([url, init]) => String(url).includes("/deployments/worker-") && init?.method === "PUT", + ); + expect(call).toBeTruthy(); + const request = JSON.parse(String(call?.[1]?.body)) as Record; + expect(request.target).toBe(target); + expect(request.image).toBe("registry.example/captain:v2"); + expect(request.credentialsDir ?? request.credentialsSecret).toBe( + target === "docker" ? "/var/lib/captain/credentials" : "captain-agent-credentials", + ); + if (target === "kubernetes") { + expect( + fetchMock.mock.calls.some(([url]) => + String(url).includes("deploy/preflight?backend=git-agent&target=kubernetes&transport=https&kubeContext=agents-lab"), + ), + ).toBe(true); + } + }); + }); +}); diff --git a/pkg/cli/webapp/src/gitAgentDeployValidation.test.ts b/pkg/cli/webapp/src/gitAgentDeployValidation.test.ts new file mode 100644 index 00000000..a274bbcd --- /dev/null +++ b/pkg/cli/webapp/src/gitAgentDeployValidation.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from "vitest"; + +import { + blockerSummary, + deployBlockers, + externalHost, +} from "./gitAgentDeployValidation"; +import type { DeployPreflight, DeployRequest } from "./sandboxData"; + +/** A cluster captain is not running in — the topology that needs a route. */ +const OUTSIDE_CLUSTER: DeployPreflight = { + target: "kubernetes", + ready: true, + supervisorRequired: true, + supervisorCandidates: ["https://192.168.1.20:9020"], + mailboxListen: "0.0.0.0:9020", + transport: "https", + namespace: "captain", + inCluster: false, + domainRequired: true, + ingressClasses: ["nginx", "traefik"], + certManagerInstalled: true, +}; + +const DOCKER_READY: DeployPreflight = { + target: "docker", + ready: true, + supervisorRequired: false, + transport: "ssh", + inCluster: false, + domainRequired: false, + certManagerInstalled: false, +}; + +/** A deploy with everything the outside-cluster topology demands. */ +function completeKubernetesForm(overrides: Partial = {}): DeployRequest { + return { + name: "worker-01", + target: "kubernetes", + supervisorAddress: "https://192.168.1.20:9020", + domain: "agents.example.com", + ingressIssuer: "letsencrypt-prod", + ...overrides, + }; +} + +describe("deployBlockers", () => { + it("passes a form that supplies every address the topology cannot detect", () => { + expect(deployBlockers(completeKubernetesForm(), OUTSIDE_CLUSTER)).toEqual({}); + }); + + it("requires an agent name", () => { + const blockers = deployBlockers(completeKubernetesForm({ name: " " }), OUTSIDE_CLUSTER); + expect(blockers.name).toBeDefined(); + }); + + // Nothing downstream can be judged against a target that refused, and the + // preflight notice is already saying why — so the form must not pile on. + it("judges nothing but the name while the target is not deployable", () => { + const refused: DeployPreflight = { ...OUTSIDE_CLUSTER, ready: false, reason: "no mailbox" }; + expect(deployBlockers({ name: "", target: "kubernetes" }, refused)).toEqual({ + name: expect.any(String), + }); + }); + + it("requires the supervisor address the preflight could not resolve", () => { + const blockers = deployBlockers( + completeKubernetesForm({ supervisorAddress: "" }), + OUTSIDE_CLUSTER, + ); + expect(blockers.supervisorAddress).toContain("no route back to this host can be proven"); + }); + + // An Ingress is an HTTP router, so which field is mandatory follows the + // transport the mailbox answered on rather than being fixed. + it("demands a domain over https and an advertise URL over ssh", () => { + const bare = completeKubernetesForm({ domain: undefined, ingressIssuer: undefined }); + + const https = deployBlockers(bare, OUTSIDE_CLUSTER); + expect(https.domain).toContain("Ingress"); + expect(https.advertise).toBeUndefined(); + + const ssh = deployBlockers( + { ...bare, supervisorAddress: "ssh://192.168.1.20:7422" }, + { ...OUTSIDE_CLUSTER, transport: "ssh" }, + ); + expect(ssh.advertise).toContain("an Ingress cannot front"); + expect(ssh.domain).toBeUndefined(); + }); + + it("accepts an advertise URL in place of a domain, on either transport", () => { + const advertised = completeKubernetesForm({ + domain: undefined, + ingressIssuer: undefined, + advertise: "https://worker-01.agents.example.com", + }); + expect(deployBlockers(advertised, OUTSIDE_CLUSTER)).toEqual({}); + expect( + deployBlockers(advertised, { ...OUTSIDE_CLUSTER, transport: "ssh" }), + ).toEqual({}); + }); + + // In-cluster is the other supported topology, not a degraded one: the agent is + // reachable at its Service address, so no route is required. + it("requires no route when captain is itself in the cluster", () => { + const inCluster = { ...OUTSIDE_CLUSTER, inCluster: true, domainRequired: false }; + const bare = completeKubernetesForm({ domain: undefined, ingressIssuer: undefined }); + expect(deployBlockers(bare, inCluster)).toEqual({}); + }); + + // Without a certificate the controller answers for the host with its own, and + // the supervisor's push fails verification after everything is created. + it("requires exactly one certificate source once a domain is set", () => { + const neither = deployBlockers( + completeKubernetesForm({ ingressIssuer: undefined }), + OUTSIDE_CLUSTER, + ); + expect(neither.ingressIssuer).toContain("needs a certificate"); + + const both = deployBlockers( + completeKubernetesForm({ ingressTlsSecret: "wildcard" }), + OUTSIDE_CLUSTER, + ); + expect(both.ingressTlsSecret).toContain("mutually exclusive"); + + const secretOnly = completeKubernetesForm({ + ingressIssuer: undefined, + ingressTlsSecret: "wildcard", + }); + expect(deployBlockers(secretOnly, OUTSIDE_CLUSTER)).toEqual({}); + }); + + // captain cannot check another controller's spelling, so any annotation + // satisfies the gate — it is an acknowledgement, not a validation. + it("makes a non-nginx class acknowledge its own equivalents", () => { + const traefik = completeKubernetesForm({ ingressClass: "traefik" }); + expect(deployBlockers(traefik, OUTSIDE_CLUSTER).ingressAnnotation).toContain("traefik"); + + const acknowledged = { ...traefik, ingressAnnotation: ["traefik.ingress.kubernetes.io/x=y"] }; + expect(deployBlockers(acknowledged, OUTSIDE_CLUSTER)).toEqual({}); + }); + + // Blank keeps the server's own "nginx" default, so it must not trip the gate + // that only applies to a class captain has no settings for. + it("treats a blank class as nginx", () => { + const blank = completeKubernetesForm({ ingressClass: "" }); + expect(deployBlockers(blank, OUTSIDE_CLUSTER)).toEqual({}); + }); + + // A docker sidecar is reached on its published loopback port; the whole route + // question does not arise, and the server refuses the flags outright. + it("asks nothing about routing on docker", () => { + expect(deployBlockers({ name: "worker-01", target: "docker" }, DOCKER_READY)).toEqual({}); + }); +}); + +describe("blockerSummary", () => { + // The summary sits beside the submit button, often screens away from the + // field at fault, so a blocker that did not name its field would leave the + // operator hunting the same way an unexplained disabled button does. + it("names the field an operator has to go and fix", () => { + const blockers = deployBlockers( + completeKubernetesForm({ ingressIssuer: undefined }), + OUTSIDE_CLUSTER, + ); + + expect(blockerSummary(blockers)).toEqual([ + { + instancePath: "ClusterIssuer", + message: expect.stringContaining("needs a certificate"), + }, + ]); + }); + + it("is empty when nothing blocks, so the summary can render nothing", () => { + expect(blockerSummary(deployBlockers(completeKubernetesForm(), OUTSIDE_CLUSTER))).toEqual( + [], + ); + }); +}); + +describe("externalHost", () => { + it("joins the agent name to the domain the Ingress serves", () => { + expect(externalHost({ name: "worker-01", target: "kubernetes", domain: "agents.example.com" })) + .toBe("worker-01.agents.example.com"); + }); + + // A pasted domain routinely carries a trailing dot or a leading separator, and + // "worker-01..example.com" is a name that can never resolve. + it("tolerates a domain typed with stray separators", () => { + expect(externalHost({ name: "worker-01", target: "kubernetes", domain: ".example.com." })) + .toBe("worker-01.example.com"); + }); + + it("has no host until both halves are given", () => { + expect(externalHost({ name: "worker-01", target: "kubernetes" })).toBe(""); + expect(externalHost({ name: "", target: "kubernetes", domain: "example.com" })).toBe(""); + }); +}); diff --git a/pkg/cli/webapp/src/gitAgentDeployValidation.ts b/pkg/cli/webapp/src/gitAgentDeployValidation.ts new file mode 100644 index 00000000..677a6c18 --- /dev/null +++ b/pkg/cli/webapp/src/gitAgentDeployValidation.ts @@ -0,0 +1,135 @@ +import type { JsonSchemaFormError } from "@flanksource/clicky-ui/components"; + +import type { DeployPreflight, DeployRequest } from "./sandboxData"; + +/** + * What the deploy form refuses, and why — the same refusals the server would + * give, stated before submit rather than after. + * + * Pure so every rule is testable without rendering a modal, and so the modal's + * "can I submit" is one expression rather than a growing chain of booleans. Each + * rule below names the server check it mirrors; a rule with no counterpart there + * would be the form inventing policy. + */ +export type DeployBlockers = Partial>; + +/** The CLI's own default, applied server-side from the `default:` flag tag. */ +const DEFAULT_INGRESS_CLASS = "nginx"; + +const trimmed = (value: string | undefined) => (value ?? "").trim(); + +export function deployBlockers( + form: DeployRequest, + preflight: DeployPreflight | undefined, +): DeployBlockers { + const blockers: DeployBlockers = {}; + if (!trimmed(form.name)) blockers.name = "An agent name is required."; + // Nothing else can be judged against a target that has not answered, and the + // PreflightNotice is already saying why. + if (!preflight?.ready) return blockers; + + if (preflight.supervisorRequired && !trimmed(form.supervisorAddress)) { + blockers.supervisorAddress = + "Required: captain is not running in the target cluster, so no route back to this host " + + `can be proven. This host's mailbox listens on ${preflight.mailboxListen ?? "its recorded address"}, ` + + "which a managed cluster usually cannot reach."; + } + + Object.assign(blockers, routeBlockers(form, preflight)); + return blockers; +} + +/** + * The external route, which is the half of a kubernetes deploy nothing can + * detect: a supervisor outside the cluster cannot dial a ClusterIP, and the name + * that would work does not exist until someone creates a DNS record. + */ +function routeBlockers(form: DeployRequest, preflight: DeployPreflight): DeployBlockers { + if (form.target !== "kubernetes") return {}; + const blockers: DeployBlockers = {}; + const domain = trimmed(form.domain); + const issuer = trimmed(form.ingressIssuer); + const secret = trimmed(form.ingressTlsSecret); + + // resolveAdvertiseAddress refuses with neither, whichever transport answered. + // Which field carries the message is what the transport decides: an Ingress is + // an HTTP router, so it can only front a mailbox already speaking https. + if (preflight.domainRequired && !domain && !trimmed(form.advertise)) { + const missing = + preflight.transport === "https" + ? ("domain" as const) + : ("advertise" as const); + blockers[missing] = + preflight.transport === "https" + ? "Required: captain is not running in the target cluster, so a ClusterIP is the only thing " + + "left to advertise and the agent would never receive a dispatch. Publish it behind an " + + "Ingress, or give an advertise URL for a route you manage yourself." + : "Required: the mailbox answered over ssh, which an Ingress cannot front. Give the address " + + "of a route you manage yourself — a LoadBalancer or NodePort the supervisor can dial."; + } + if (!domain) return blockers; + + // Mirrors applyExternalRoute: a host with no certificate means the controller + // answers for it with its own, and the supervisor's push fails verification. + if (issuer && secret) { + blockers.ingressTlsSecret = + "A cert-manager issuer and an existing TLS Secret are mutually exclusive; keep one."; + } else if (!issuer && !secret) { + blockers.ingressIssuer = + `${domain} needs a certificate for the agent's host: a cert-manager ClusterIssuer, or an ` + + "existing TLS Secret. Without either, the controller answers for that host with its own " + + "default certificate and the supervisor's push fails verification."; + } + + // An acknowledgement gate, not a validation: captain cannot check another + // controller's spelling, so any annotation satisfies it. + const ingressClass = trimmed(form.ingressClass); + if ( + ingressClass && + ingressClass !== DEFAULT_INGRESS_CLASS && + (form.ingressAnnotation ?? []).length === 0 + ) { + blockers.ingressAnnotation = + `${ingressClass} is not ingress-nginx, so the buffering, body-size and timeout settings a git ` + + "push depends on are not set. Add that controller's equivalents as annotations."; + } + return blockers; +} + +/** + * What each blocker is called where the operator has to fix it. + * + * The form is long enough that a blocking field can sit off-screen from the + * disabled button, so the summary has to name the field rather than only state + * the problem. + */ +const FIELD_LABELS: Partial> = { + name: "Agent name", + supervisorAddress: "Supervisor address", + domain: "Domain", + ingressIssuer: "ClusterIssuer", + ingressTlsSecret: "TLS Secret", + ingressAnnotation: "Ingress annotations", + advertise: "Advertise URL", +}; + +/** + * The blockers as clicky-ui's FormErrorSummary consumes them. + * + * `instancePath` is that component's display-prefix slot — it renders + * `${instancePath}: ${message}` — so the field's human label goes there rather + * than a JSON pointer no operator would recognise. + */ +export function blockerSummary(blockers: DeployBlockers): JsonSchemaFormError[] { + return Object.entries(blockers).map(([key, message]) => ({ + instancePath: FIELD_LABELS[key as keyof DeployRequest] ?? key, + message: message as string, + })); +} + +/** The host the Ingress would publish, the same join resolveExternalHost makes. */ +export function externalHost(form: DeployRequest): string { + const domain = trimmed(form.domain).replace(/^\.+|\.+$/g, ""); + const name = trimmed(form.name); + return domain && name ? `${name}.${domain}` : ""; +} diff --git a/pkg/cli/webapp/src/gitAgentDeploymentData.ts b/pkg/cli/webapp/src/gitAgentDeploymentData.ts new file mode 100644 index 00000000..5064c18b --- /dev/null +++ b/pkg/cli/webapp/src/gitAgentDeploymentData.ts @@ -0,0 +1,236 @@ +export type DeployTarget = "docker" | "kubernetes"; + +export type DeployConfig = { + target: DeployTarget; + transport?: string; + namespace?: string; + kubeContext?: string; + domain?: string; + ingressClass?: string; + ingressIssuer?: string; + ingressTlsSecret?: string; + ingressAnnotation?: string[]; + image?: string; + imagePullPolicy?: string; + imagePullSecret?: string; + supervisorAddress?: string; + advertise?: string; + listenPort?: number; + hostPort?: number; + cpuRequest?: string; + cpuLimit?: string; + memoryRequest?: string; + memoryLimit?: string; + storage?: string; + storageClass?: string; + tmpSize?: string; + pidsLimit?: number; + runAsUser?: number; + runAsGroup?: number; + home?: string; + readOnlyRoot?: boolean; + network?: string; + capAdd?: string[]; + env?: string[]; + envFromSecret?: string[]; + credentialsSecret?: string; + credentialsDir?: string; + wait?: boolean; + timeout?: string; +}; + +export type DeployRequest = DeployConfig & { + name: string; + createNamespace?: boolean; + replace?: boolean; + dryRun?: boolean; +}; + +export type GitAgentDeployment = { + target: DeployTarget; + namespace?: string; + workload: string; + image?: string; + deployedAt?: string; + config?: DeployConfig; +}; + +export type DeployPreflight = { + target: DeployTarget; + ready: boolean; + reason?: string; + mailboxListen?: string; + hostFingerprint?: string; + transport?: string; + supervisor?: string; + supervisorFrom?: string; + supervisorRequired: boolean; + supervisorCandidates?: string[]; + namespace?: string; + kubeContext?: string; + runtime?: string; + inCluster: boolean; + domainRequired: boolean; + ingressClasses?: string[]; + certManagerInstalled: boolean; +}; + +export type DeployResult = { + backend: string; + agent: string; + target: string; + image: string; + workload: string; + namespace?: string; + objects?: string[]; + volume: string; + supervisor: string; + supervisorFrom: string; + advertise: string; + advertiseFrom: string; + route?: string; + routeClass?: string; + offHostAddresses?: string[]; + hostFingerprint: string; + security: string; + credentials: string; + egressRestricted: boolean; + enrolled: boolean; + ready: boolean; + replaced?: boolean; + dryRun?: boolean; + mutations?: string[]; +}; + +export type UndeployResult = { + backend: string; + agent: string; + target: string; + removed: string[]; + revoked: boolean; + retained?: string; + dryRun?: boolean; +}; + +async function readError(response: Response, fallback: string): Promise { + const message = (await response.text()).trim(); + throw new Error(message || `${fallback} (${response.status})`); +} + +async function getJSON(url: string, fallback: string): Promise { + const response = await fetch(url, { headers: { Accept: "application/json" } }); + if (!response.ok) await readError(response, fallback); + return (await response.json()) as T; +} + +export function fetchDeployPreflight(params: { + backend: string; + target: DeployTarget; + transport?: string; + kubeContext?: string; +}): Promise { + const query = new URLSearchParams({ backend: params.backend, target: params.target }); + if (params.transport) query.set("transport", params.transport); + if (params.kubeContext) query.set("kubeContext", params.kubeContext); + return getJSON( + `/api/captain/sandbox/git-agent/deploy/preflight?${query}`, + "Preflight failed", + ); +} + +export function fetchNamespaces(kubeContext?: string): Promise { + const query = kubeContext ? `?kubeContext=${encodeURIComponent(kubeContext)}` : ""; + return getJSON(`/api/captain/sandbox/git-agent/namespaces${query}`, "Listing namespaces failed"); +} + +export const TLS_SECRET_TYPE = "kubernetes.io/tls"; + +export function fetchSecrets(params: { + namespace?: string; + kubeContext?: string; + type?: string; +} = {}): Promise { + const query = new URLSearchParams(); + if (params.namespace) query.set("namespace", params.namespace); + if (params.kubeContext) query.set("kubeContext", params.kubeContext); + if (params.type) query.set("type", params.type); + const suffix = query.toString() ? `?${query}` : ""; + return getJSON(`/api/captain/sandbox/git-agent/secrets${suffix}`, "Listing secrets failed"); +} + +export function fetchClusterIssuers(kubeContext?: string): Promise { + const query = kubeContext ? `?kubeContext=${encodeURIComponent(kubeContext)}` : ""; + return getJSON( + `/api/captain/sandbox/git-agent/cluster-issuers${query}`, + "Listing cluster issuers failed", + ); +} + +export async function deployGitAgent( + backend: string, + request: DeployRequest, +): Promise { + return writeDeployment( + `/api/captain/sandbox/git-agent/deployments?backend=${encodeURIComponent(backend)}`, + "POST", + pruneEmpty(request), + "Deploy failed", + ); +} + +export async function updateGitAgent( + backend: string, + name: string, + request: DeployRequest, +): Promise { + return writeDeployment( + `/api/captain/sandbox/git-agent/deployments/${encodeURIComponent(name)}` + + `?backend=${encodeURIComponent(backend)}`, + "PUT", + request, + "Update failed", + ); +} + +async function writeDeployment( + url: string, + method: "POST" | "PUT", + request: Record | DeployRequest, + fallback: string, +): Promise { + const response = await fetch(url, { + method, + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(request), + }); + if (!response.ok) await readError(response, fallback); + return (await response.json()) as DeployResult; +} + +function pruneEmpty(request: DeployRequest): Record { + return Object.fromEntries( + Object.entries(request).filter(([, value]) => { + if (value === undefined || value === false) return false; + if (typeof value === "string") return value.trim() !== ""; + if (Array.isArray(value)) return value.length > 0; + return true; + }), + ); +} + +export async function undeployGitAgent(params: { + backend: string; + name: string; + purge?: boolean; + dryRun?: boolean; +}): Promise { + const query = new URLSearchParams({ backend: params.backend }); + if (params.purge) query.set("purge", "true"); + if (params.dryRun) query.set("dryRun", "true"); + const response = await fetch( + `/api/captain/sandbox/git-agent/deployments/${encodeURIComponent(params.name)}?${query}`, + { method: "DELETE", headers: { Accept: "application/json" } }, + ); + if (!response.ok) await readError(response, "Undeploy failed"); + return (await response.json()) as UndeployResult; +} diff --git a/pkg/cli/webapp/src/sandboxData.ts b/pkg/cli/webapp/src/sandboxData.ts new file mode 100644 index 00000000..366f4529 --- /dev/null +++ b/pkg/cli/webapp/src/sandboxData.ts @@ -0,0 +1,336 @@ +import type { SpecRuntimeSandboxCatalog } from "@flanksource/clicky-ui/ai"; +import type { GitAgentDeployment } from "./gitAgentDeploymentData"; + +export * from "./gitAgentDeploymentData"; + +/** + * One enrolled or pending git-agent, mirroring cli.GitAgentListEntry. + * + * Dispatch readiness is resolved by the server because the required credential + * depends on the endpoint transport: an SSH host key or an HTTPS token path. + * The token path stays server-side and never crosses this API. + */ +export type GitAgent = { + name: string; + fingerprint?: string; + hostFingerprint?: string; + url?: string; + addedAt?: string; + /** "enrolled", or "deployed — waiting to enroll" for a workload still starting. */ + status: string; + dispatchable: boolean; + dispatchIssue?: string; + /** + * Set only when captain placed this agent's sidecar itself. An agent joined by + * hand has none, and cannot be torn down from here — there is nothing that + * knows which runtime it runs on. + */ + deployment?: GitAgentDeployment; +}; + +/** + * The join hand-off from enrollment, mirroring cli.GitAgentAddResult. + * + * `expires` is optional because a token minted without a lifetime never + * expires; `tokenId` is the public handle a listing and a revocation use, and + * the secret itself is deliberately absent — it exists only in the join command. + */ +export type GitAgentEnrollment = { + backend: string; + agent: string; + tokenId?: string; + pool?: boolean; + expires?: string; + hostFingerprint: string; + dispatchKey: string; + joinCommand: string; + dryRun?: boolean; +}; + +export type GitAgentRevocation = { + backend: string; + agent: string; + fingerprint?: string; + revoked: boolean; + dryRun?: boolean; +}; + +/** A workload captain placed that has not completed its join yet. */ +export function isPending(agent: GitAgent) { + return agent.status.startsWith("deployed"); +} + +/** + * The server evaluates the transport-specific credential without exposing it. + */ +export function isDispatchable(agent: GitAgent) { + return !isPending(agent) && agent.dispatchable; +} + +async function readError(response: Response, fallback: string): Promise { + const message = (await response.text()).trim(); + throw new Error(message || `${fallback} (${response.status})`); +} + +async function getJSON(url: string, fallback: string): Promise { + const response = await fetch(url, { + headers: { Accept: "application/json" }, + }); + if (!response.ok) await readError(response, fallback); + return (await response.json()) as T; +} + +export function fetchSandboxCatalog(): Promise { + return getJSON("/api/captain/sandboxes", "Failed to load sandboxes"); +} + +export function fetchGitAgents(backend: string): Promise { + return getJSON( + `/api/captain/sandbox/git-agent/agents?backend=${encodeURIComponent(backend)}`, + "Failed to load agents", + ); +} + +export type AgentWhoamiAdapter = { + backend: string; + type: "api" | "cli"; + provider: string; + mode: string; + authenticated: boolean; + authMethod?: string; + authDetail?: string; + binary?: string; + binaryMissing?: string; + dependencyMissing?: string; + provisioner?: string; + runtimeError?: string; + modelError?: string; + modelCount: number; + models?: string[]; + disabled?: boolean; +}; + +export type AgentWhoamiResult = { + adapters: AgentWhoamiAdapter[]; + defaultProvider: string; +}; + +export async function fetchGitAgentWhoami(params: { + backend: string; + name: string; +}): Promise { + const response = await fetch( + `/api/captain/sandbox/git-agent/agents/${encodeURIComponent(params.name)}/whoami` + + `?backend=${encodeURIComponent(params.backend)}`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: "{}", + }, + ); + if (!response.ok) await readError(response, "Agent whoami failed"); + return (await response.json()) as AgentWhoamiResult; +} + +export async function enrollGitAgent(params: { + backend: string; + name: string; + endpoint?: string; + dryRun?: boolean; +}): Promise { + const response = await fetch( + `/api/captain/sandbox/git-agent/agents?backend=${encodeURIComponent(params.backend)}`, + { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + // Only the keys the server knows: it decodes strictly, so a stray field + // is a 400 rather than a silently ignored value. + body: JSON.stringify({ + name: params.name, + ...(params.endpoint ? { endpoint: params.endpoint } : {}), + ...(params.dryRun ? { dryRun: true } : {}), + }), + }, + ); + if (!response.ok) await readError(response, "Enrollment failed"); + return (await response.json()) as GitAgentEnrollment; +} + +/** Lifecycle of one dispatched task, mirroring the Go enum. */ +export type GitAgentTaskStatus = + | "dispatched" + | "running" + | "accepted" + | "rejected" + | "errored" + | "timed_out"; + +export type GitAgentVerdictStatus = "accepted" | "rejected" | "error"; + +export type GitAgentTask = { + id: string; + taskId: string; + mailbox: string; + repository?: string; + backend?: string; + agent?: string; + promptRunId?: string; + base: string; + dispatchCommit: string; + relay?: string; + policy?: Record; + attempts: number; + maxAttempts?: number; + status: GitAgentTaskStatus; + finalStatus?: GitAgentVerdictStatus; + integratedBranch?: string; + error?: string; + dispatchedAt: string; + concludedAt?: string; + updatedAt: string; +}; + +export type GitAgentTaskAttempt = { + attempt: number; + /** "sidecar" or "supervisor" — the tier that reached this verdict. */ + tier: string; + status: GitAgentVerdictStatus; + findings?: Array>; + resultCommit?: string; + feedback?: string; + recordedAt: string; +}; + +export type GitAgentTaskDetail = { + task: GitAgentTask; + attempts: GitAgentTaskAttempt[]; +}; + +/** A task still in flight; the rest are history. */ +export function isTaskOpen(task: GitAgentTask) { + return task.status === "dispatched" || task.status === "running"; +} + +export function fetchGitAgentTasks( + params: { + agent?: string; + status?: string; + } = {}, +): Promise { + const query = new URLSearchParams(); + if (params.agent) query.set("agent", params.agent); + if (params.status) query.set("status", params.status); + const suffix = query.toString() ? `?${query}` : ""; + return getJSON( + `/api/captain/sandbox/git-agent/tasks${suffix}`, + "Failed to load tasks", + ); +} + +export function fetchGitAgentTask( + taskId: string, + mailbox: string, +): Promise { + return getJSON( + `/api/captain/sandbox/git-agent/tasks/${encodeURIComponent(taskId)}` + + `?mailbox=${encodeURIComponent(mailbox)}`, + "Failed to load task", + ); +} + +/** One provider login and how long it stays valid. Mirrors cli.CredentialStatus. */ +export type CredentialStatus = { + provider: string; + source: string; + key: string; + expiresAt: string; + expiresIn: string; + expired: boolean; + targets?: string[]; +}; + +/** One destination in the `credentials.publish` list. */ +export type CredentialDestination = { + providers?: string[]; + directory?: string; + namespace?: string; + secret?: string; + kubeContext?: string; +}; + +export type CredentialsConfig = { + /** A Go duration such as "1h". Empty means the publisher's own default. */ + refreshMargin: string; + publish: CredentialDestination[]; +}; + +export type CredentialsView = { + config: CredentialsConfig; + status: CredentialStatus[]; + providers: string[]; + defaultSecret: string; + defaultMargin: string; +}; + +/** What one publish pass did. Mirrors credsync.Result. */ +export type CredentialsSyncResult = { + published?: string[]; + targets?: string[]; + nextPublish?: string; +}; + +export function fetchCredentials(): Promise { + return getJSON( + "/api/captain/sandbox/credentials", + "Failed to load agent credentials", + ); +} + +export async function saveCredentialsConfig( + config: CredentialsConfig, +): Promise { + const response = await fetch("/api/captain/sandbox/credentials/config", { + method: "PUT", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(config), + }); + if (!response.ok) + await readError(response, "Saving credential destinations failed"); + return (await response.json()) as CredentialsConfig; +} + +/** + * Publishes once, now. An empty body uses the saved destinations, which is what + * the panel's "Sync now" does; the server decodes strictly, so `{}` is sent + * rather than nothing. + */ +export async function syncCredentials( + override: CredentialDestination = {}, +): Promise { + const response = await fetch("/api/captain/sandbox/credentials/sync", { + method: "POST", + headers: { Accept: "application/json", "Content-Type": "application/json" }, + body: JSON.stringify(override), + }); + if (!response.ok) await readError(response, "Credential sync failed"); + return (await response.json()) as CredentialsSyncResult; +} + +export async function revokeGitAgent(params: { + backend: string; + name: string; +}): Promise { + const response = await fetch( + `/api/captain/sandbox/git-agent/agents/${encodeURIComponent(params.name)}` + + `?backend=${encodeURIComponent(params.backend)}`, + { method: "DELETE", headers: { Accept: "application/json" } }, + ); + if (!response.ok) await readError(response, "Revoke failed"); + return (await response.json()) as GitAgentRevocation; +} diff --git a/pkg/cli/webapp/src/shellHelpers.ts b/pkg/cli/webapp/src/shellHelpers.ts index c3ee7185..ba82d84c 100644 --- a/pkg/cli/webapp/src/shellHelpers.ts +++ b/pkg/cli/webapp/src/shellHelpers.ts @@ -4,6 +4,7 @@ import type { } from "@flanksource/clicky-ui/components"; import { UiActivity, + UiBox, UiFileText, UiFingerprint, UiHistory, @@ -24,6 +25,7 @@ export type PrimaryRoute = | "sessions" | "prompts" | "whoami" + | "sandboxes" | "operations"; export const CAPTAIN_SIDEBAR_COLLAPSE_KEY = "captain:sidebar:collapsed"; @@ -62,6 +64,13 @@ export function captainNavSections( active: active === "sessions", }, { key: "prompts", label: "Prompts", to: "/prompts", icon: UiFileText, active: active === "prompts" }, + { + key: "sandboxes", + label: "Sandboxes", + to: "/sandboxes", + icon: UiBox, + active: active === "sandboxes", + }, { key: "operations", label: "Operations", diff --git a/pkg/cli/webapp/vite.config.ts b/pkg/cli/webapp/vite.config.ts index 5eb3c64f..ac4ca31f 100644 --- a/pkg/cli/webapp/vite.config.ts +++ b/pkg/cli/webapp/vite.config.ts @@ -34,6 +34,10 @@ export default defineConfig(({ command }) => { proxy: { "/api": apiTarget, "/health": apiTarget, + // The git smart-HTTP transport. Without this, `captain serve --dev` + // answers a push with the dev server's index.html and the client + // reports a protocol error rather than a missing route. + "/git": apiTarget, }, }, build: { diff --git a/pkg/container/base/Dockerfile b/pkg/container/base/Dockerfile index bd072d68..de3a32eb 100644 --- a/pkg/container/base/Dockerfile +++ b/pkg/container/base/Dockerfile @@ -55,6 +55,7 @@ RUN curl -fsSL https://deb.nodesource.com/setup_${NODE_MAJOR}.x | bash - && \ man-db \ unzip \ gnupg2 \ + file \ gh \ iptables \ ipset \ @@ -123,13 +124,15 @@ RUN GOBIN=/usr/local/bin go install github.com/onsi/ginkgo/v2/ginkgo@${GINKGO_VE rm -rf /root/.cache/go-build /root/go && \ mkdir -p ${GOPATH}/bin && chown -R ${USER_UID}:${USER_GID} ${GOPATH} -# Flanksource + Go tooling via deps (already present in the base image). -# GITHUB_TOKEN arrives as a BuildKit secret so it never lands in image history. -COPY deps.yaml /tmp/deps/deps.yaml + RUN --mount=type=secret,id=GITHUB_TOKEN,env=GITHUB_TOKEN,required=false \ - cd /tmp/deps && \ - deps --no-progress install -c deps.yaml --bin-dir /usr/bin --app-dir /opt && \ - rm -rf /tmp/deps /root/.deps + deps --no-progress install \ + flanksource/repomap \ + flanksource/gavel \ + go-task/task \ + flanksource/captain \ + --bin-dir /usr/bin --app-dir /opt && \ + rm -rf /root/.deps COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh @@ -170,4 +173,5 @@ RUN npm install -g \ && npm cache clean --force USER root + ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/pkg/container/base/Dockerfile.flanksource b/pkg/container/base/Dockerfile.flanksource new file mode 100644 index 00000000..c75ca906 --- /dev/null +++ b/pkg/container/base/Dockerfile.flanksource @@ -0,0 +1,4 @@ +FROM captain + +RUN repomap cache-warm go https://github.com/flanksource/commons-db --build +RUN repomap cache-warm go https://github.com/flanksource/clicky-ui --build diff --git a/pkg/container/base/Dockerfile.lab b/pkg/container/base/Dockerfile.lab new file mode 100644 index 00000000..ad37247f --- /dev/null +++ b/pkg/container/base/Dockerfile.lab @@ -0,0 +1,16 @@ +# syntax=docker/dockerfile:1 +# Lab overlay: the published sandbox image with the working tree's captain +# binary swapped in. The base image installs flanksource/captain from its latest +# *release*, so without this overlay a lab push ships whatever was released last +# rather than the code under test. +# +# COPY-only on purpose — with no RUN, buildx assembles a linux/amd64 image on an +# arm64 host without qemu emulation. `task image:lab` cross-compiles the binary; +# building it in a stage here is not possible, because captain builds against the +# sibling checkouts in go.work, which are outside any build context. +ARG BASE_IMAGE=flanksource/captain:latest +FROM ${BASE_IMAGE} + +# Set by buildx from --platform; image:lab names its binary to match. +ARG TARGETARCH +COPY captain-linux-${TARGETARCH} /usr/bin/captain diff --git a/pkg/container/base/deps.yaml b/pkg/container/base/deps.yaml deleted file mode 100644 index d9bd5cad..00000000 --- a/pkg/container/base/deps.yaml +++ /dev/null @@ -1,68 +0,0 @@ -# Binaries installed into the agent sandbox image via `deps` (shipped by -# flanksource/base-image). -# -# Every entry is declared explicitly rather than relying on deps' owner/repo -# heuristic: `deps install` with no arguments resolves only through the registry -# (see InstallFromConfig in flanksource/deps), and the golangci-lint release -# publishes .deb/.rpm/.tar.gz for the same platform, so a glob would be ambiguous. -registry: - gavel: - name: gavel - repo: flanksource/gavel - asset_patterns: - linux-amd64: gavel_linux_amd64.tar.gz - linux-arm64: gavel_linux_arm64.tar.gz - darwin-amd64: gavel_darwin_amd64.tar.gz - darwin-arm64: gavel_darwin_arm64.tar.gz - checksum_file: checksums.txt - version_command: version - version_regex: 'gavel v?(\d+\.\d+\.\d+)' - - repomap: - name: repomap - repo: flanksource/repomap - asset_patterns: - linux-amd64: repomap-linux-amd64 - linux-arm64: repomap-linux-arm64 - darwin-amd64: repomap-darwin-amd64 - darwin-arm64: repomap-darwin-arm64 - checksum_file: checksums.txt - version_command: version - version_regex: 'repomap v?(\d+\.\d+\.\d+)' - - captain: - name: captain - repo: flanksource/captain - asset_patterns: - linux-amd64: captain_linux_amd64.tar.gz - linux-arm64: captain_linux_arm64.tar.gz - darwin-amd64: captain_darwin_amd64.tar.gz - darwin-arm64: captain_darwin_arm64.tar.gz - checksum_file: captain_{{.version}}_checksums.txt - version_command: --version - version_regex: 'captain version v?(\d+\.\d+\.\d+)' - - golangci-lint: - name: golangci-lint - repo: golangci/golangci-lint - asset_patterns: - linux-amd64: golangci-lint-{{.version}}-linux-amd64.tar.gz - linux-arm64: golangci-lint-{{.version}}-linux-arm64.tar.gz - darwin-amd64: golangci-lint-{{.version}}-darwin-amd64.tar.gz - darwin-arm64: golangci-lint-{{.version}}-darwin-arm64.tar.gz - checksum_file: golangci-lint-{{.version}}-checksums.txt - version_command: --version - version_regex: 'golangci-lint has version v?(\d+\.\d+\.\d+)' - -# Pinned so republishing an image tag reinstalls the same binaries; bump these -# deliberately when the image is refreshed. -dependencies: - task: v3.52.0 - golangci-lint: v2.12.2 - gavel: v0.0.54 - repomap: v0.4.0 - # captain is the one deliberate exception: this image ships the release that - # triggered the publish, and the image tag records which one. Pinning it here - # would mean editing this file on every release to say what the tag already - # says. publish-image.yml refuses to run before that release exists. - captain: latest diff --git a/pkg/container/base_image.go b/pkg/container/base_image.go index 188710b1..88e16726 100644 --- a/pkg/container/base_image.go +++ b/pkg/container/base_image.go @@ -16,9 +16,6 @@ var baseDockerfileContent []byte //go:embed base/entrypoint.sh var baseEntrypointContent []byte -//go:embed base/deps.yaml -var baseDepsContent []byte - const baseImageTag = "claude-env:base" func EnsureBaseImage(baseImage string) error { @@ -32,9 +29,6 @@ func writeBaseContext(dir string) error { if err := os.WriteFile(filepath.Join(dir, "Dockerfile"), baseDockerfileContent, 0o644); err != nil { return err } - if err := os.WriteFile(filepath.Join(dir, "deps.yaml"), baseDepsContent, 0o644); err != nil { - return err - } return os.WriteFile(filepath.Join(dir, "entrypoint.sh"), baseEntrypointContent, 0o755) } diff --git a/pkg/database/git_agent_store.go b/pkg/database/git_agent_store.go new file mode 100644 index 00000000..763bd734 --- /dev/null +++ b/pkg/database/git_agent_store.go @@ -0,0 +1,501 @@ +// Durable history for tasks dispatched to remote git-agents. +// +// Every write here is an idempotent upsert on a natural key, because the caller +// is a watcher that re-scans the same mailbox tree on every change and on a +// startup backfill. It must be able to replay the whole tree without producing +// duplicates or moving a task backwards. + +package database + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm/clause" +) + +// GitAgentTaskStatus is the lifecycle of one dispatched task. Only Dispatched +// and Running come from the protocol; the terminal states are derived, because +// the mailbox never records "this task is over". +type GitAgentTaskStatus string + +const ( + GitAgentTaskDispatched GitAgentTaskStatus = "dispatched" + GitAgentTaskRunning GitAgentTaskStatus = "running" + GitAgentTaskAccepted GitAgentTaskStatus = "accepted" + GitAgentTaskRejected GitAgentTaskStatus = "rejected" + GitAgentTaskErrored GitAgentTaskStatus = "errored" + GitAgentTaskTimedOut GitAgentTaskStatus = "timed_out" +) + +// GitAgentVerdictStatus mirrors gitagent.VerdictStatus. +type GitAgentVerdictStatus string + +const ( + GitAgentVerdictAccepted GitAgentVerdictStatus = "accepted" + GitAgentVerdictRejected GitAgentVerdictStatus = "rejected" + GitAgentVerdictError GitAgentVerdictStatus = "error" +) + +type gitAgentTaskRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + TaskID string `gorm:"column:task_id"` + Mailbox string `gorm:"column:mailbox"` + Repository *string `gorm:"column:repository"` + Backend *string `gorm:"column:backend"` + Agent *string `gorm:"column:agent"` + PromptRunID *uuid.UUID `gorm:"column:prompt_run_id;type:uuid"` + AdmissionKey *string `gorm:"column:admission_key"` + Base string `gorm:"column:base"` + DispatchCommit string `gorm:"column:dispatch_commit"` + ControlCommit *string `gorm:"column:control_commit"` + Relay *string `gorm:"column:relay"` + Policy []byte `gorm:"column:policy;type:jsonb"` + Hooks []byte `gorm:"column:hooks;type:jsonb"` + Attempts int `gorm:"column:attempts"` + MaxAttempts int `gorm:"column:max_attempts"` + Status GitAgentTaskStatus `gorm:"column:status"` + FinalStatus *GitAgentVerdictStatus `gorm:"column:final_status"` + IntegratedBranch *string `gorm:"column:integrated_branch"` + Error *string `gorm:"column:error"` + DispatchedAt time.Time `gorm:"column:dispatched_at"` + ConcludedAt *time.Time `gorm:"column:concluded_at"` + CreatedAt time.Time `gorm:"column:created_at"` + UpdatedAt time.Time `gorm:"column:updated_at"` +} + +func (gitAgentTaskRecord) TableName() string { return "captain_git_agent_tasks" } + +type gitAgentTaskAttemptRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + TaskID uuid.UUID `gorm:"column:task_id;type:uuid"` + Attempt int `gorm:"column:attempt"` + Tier string `gorm:"column:tier"` + Status GitAgentVerdictStatus `gorm:"column:status"` + ProtocolVersion int `gorm:"column:protocol_version"` + Findings []byte `gorm:"column:findings;type:jsonb"` + ResultCommit *string `gorm:"column:result_commit"` + Feedback *string `gorm:"column:feedback"` + RecordedAt time.Time `gorm:"column:recorded_at"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (gitAgentTaskAttemptRecord) TableName() string { return "captain_git_agent_task_attempts" } + +// GitAgentTask is one dispatched task as the API serves it. +type GitAgentTask struct { + ID uuid.UUID `json:"id"` + TaskID string `json:"taskId"` + Mailbox string `json:"mailbox"` + Repository string `json:"repository,omitempty"` + Backend string `json:"backend,omitempty"` + Agent string `json:"agent,omitempty"` + PromptRunID *uuid.UUID `json:"promptRunId,omitempty"` + Base string `json:"base"` + DispatchCommit string `json:"dispatchCommit"` + Relay string `json:"relay,omitempty"` + Policy map[string]any `json:"policy,omitempty"` + Attempts int `json:"attempts"` + MaxAttempts int `json:"maxAttempts,omitempty"` + Status GitAgentTaskStatus `json:"status"` + FinalStatus *GitAgentVerdictStatus `json:"finalStatus,omitempty"` + IntegratedBranch string `json:"integratedBranch,omitempty"` + Error string `json:"error,omitempty"` + DispatchedAt time.Time `json:"dispatchedAt"` + ConcludedAt *time.Time `json:"concludedAt,omitempty"` + UpdatedAt time.Time `json:"updatedAt"` +} + +// GitAgentTaskAttempt is one tier's verdict on one attempt. +type GitAgentTaskAttempt struct { + Attempt int `json:"attempt"` + Tier string `json:"tier"` + Status GitAgentVerdictStatus `json:"status"` + Findings []map[string]any `json:"findings,omitempty"` + ResultCommit string `json:"resultCommit,omitempty"` + Feedback string `json:"feedback,omitempty"` + RecordedAt time.Time `json:"recordedAt"` +} + +// GitAgentTaskDetail is a task with its verdicts, oldest attempt first. +type GitAgentTaskDetail struct { + Task GitAgentTask `json:"task"` + Attempts []GitAgentTaskAttempt `json:"attempts"` +} + +// UpsertGitAgentTaskInput is what one scan of a mailbox task directory yields. +type UpsertGitAgentTaskInput struct { + TaskID string + Mailbox string + Repository string + Backend string + Agent string + AdmissionKey string + Base string + DispatchCommit string + ControlCommit string + Relay string + Policy map[string]any + Hooks map[string]any + Attempts int + MaxAttempts int + Status GitAgentTaskStatus + DispatchedAt time.Time +} + +// UpsertGitAgentTask records or refreshes one task, keyed on (mailbox, task_id). +// +// `attempts` is raised with GREATEST rather than assigned: scans can arrive out +// of order (fsnotify coalesces, and the backfill walks the whole tree), and a +// stale scan must not walk the count backwards. `status` is likewise only +// advanced out of the non-terminal states — a re-scan after a task concluded +// must not reset it to "running". +func (db *DB) UpsertGitAgentTask(ctx context.Context, input UpsertGitAgentTaskInput) (uuid.UUID, error) { + if err := db.requireGorm(); err != nil { + return uuid.Nil, err + } + policy, err := marshalJSONColumn(input.Policy, "{}") + if err != nil { + return uuid.Nil, fmt.Errorf("encode git-agent policy: %w", err) + } + hooks, err := marshalJSONColumn(input.Hooks, "") + if err != nil { + return uuid.Nil, fmt.Errorf("encode git-agent hooks: %w", err) + } + status := input.Status + if status == "" { + status = GitAgentTaskDispatched + } + dispatchedAt := input.DispatchedAt + if dispatchedAt.IsZero() { + dispatchedAt = time.Now().UTC() + } + record := gitAgentTaskRecord{ + ID: uuid.New(), + TaskID: input.TaskID, + Mailbox: input.Mailbox, + Repository: nullableTrimmed(input.Repository), + Backend: nullableTrimmed(input.Backend), + Agent: nullableTrimmed(input.Agent), + AdmissionKey: nullableTrimmed(input.AdmissionKey), + Base: input.Base, + DispatchCommit: input.DispatchCommit, + ControlCommit: nullableTrimmed(input.ControlCommit), + Relay: nullableTrimmed(input.Relay), + Policy: policy, + Hooks: hooks, + Attempts: input.Attempts, + MaxAttempts: input.MaxAttempts, + Status: status, + DispatchedAt: dispatchedAt, + } + err = db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "mailbox"}, {Name: "task_id"}}, + DoUpdates: clause.Assignments(map[string]any{ + "repository": gorm0Coalesce("repository"), + "backend": gorm0Coalesce("backend"), + "agent": gorm0Coalesce("agent"), + "admission_key": gorm0Coalesce("admission_key"), + "control_commit": gorm0Coalesce("control_commit"), + "relay": gorm0Coalesce("relay"), + "policy": clause.Column{Table: "excluded", Name: "policy"}, + "hooks": gorm0Coalesce("hooks"), + "attempts": clause.Expr{SQL: "GREATEST(captain_git_agent_tasks.attempts, excluded.attempts)"}, + "max_attempts": clause.Expr{SQL: "GREATEST(captain_git_agent_tasks.max_attempts, excluded.max_attempts)"}, + "status": clause.Expr{SQL: gitAgentStatusAdvance}, + "updated_at": clause.Expr{SQL: "now()"}, + }), + }).Create(&record).Error + if err != nil { + return uuid.Nil, fmt.Errorf("upsert git-agent task: %w", err) + } + // Create returns the generated id only on insert; on conflict the row keeps + // its original id, so read it back rather than trusting the struct. Selected + // into the record type so gorm applies its uuid mapping — Pluck into a + // uuid.UUID would try to scan the text form into a [16]byte. + var existing gitAgentTaskRecord + if err := db.gorm.WithContext(ctx).Select("id"). + Where("mailbox = ? AND task_id = ?", input.Mailbox, input.TaskID). + Take(&existing).Error; err != nil { + return uuid.Nil, fmt.Errorf("read git-agent task id: %w", err) + } + return existing.ID, nil +} + +// gitAgentStatusAdvance keeps a concluded task concluded. Once a terminal state +// is recorded, a later scan of the same directory (which cannot tell that the +// task finished) must not drag it back to dispatched/running. +const gitAgentStatusAdvance = `CASE + WHEN captain_git_agent_tasks.status IN ('accepted','rejected','errored','timed_out') + THEN captain_git_agent_tasks.status + ELSE excluded.status +END` + +// gorm0Coalesce keeps a previously-recorded value when the incoming scan has +// none. A partial scan (a task directory read mid-write) must not blank a field +// that an earlier, more complete scan already established. +func gorm0Coalesce(column string) clause.Expr { + return clause.Expr{SQL: fmt.Sprintf("COALESCE(excluded.%s, captain_git_agent_tasks.%s)", column, column)} +} + +// RecordGitAgentAttemptInput is one verdict.json. +type RecordGitAgentAttemptInput struct { + TaskID uuid.UUID + Attempt int + Tier string + Status GitAgentVerdictStatus + ProtocolVersion int + Findings []map[string]any + ResultCommit string + Feedback string + RecordedAt time.Time +} + +// RecordGitAgentAttempt stores one tier's verdict, keyed on +// (task, attempt, tier) so re-scanning the verdicts directory is a no-op. +func (db *DB) RecordGitAgentAttempt(ctx context.Context, input RecordGitAgentAttemptInput) error { + if err := db.requireGorm(); err != nil { + return err + } + findings, err := marshalJSONColumn(input.Findings, "[]") + if err != nil { + return fmt.Errorf("encode git-agent findings: %w", err) + } + recordedAt := input.RecordedAt + if recordedAt.IsZero() { + recordedAt = time.Now().UTC() + } + version := input.ProtocolVersion + if version == 0 { + version = 1 + } + record := gitAgentTaskAttemptRecord{ + ID: uuid.New(), + TaskID: input.TaskID, + Attempt: input.Attempt, + Tier: input.Tier, + Status: input.Status, + ProtocolVersion: version, + Findings: findings, + ResultCommit: nullableTrimmed(input.ResultCommit), + Feedback: nullableTrimmed(input.Feedback), + RecordedAt: recordedAt, + } + err = db.gorm.WithContext(ctx).Clauses(clause.OnConflict{ + Columns: []clause.Column{{Name: "task_id"}, {Name: "attempt"}, {Name: "tier"}}, + DoUpdates: clause.AssignmentColumns([]string{ + "status", "protocol_version", "findings", "result_commit", "feedback", "recorded_at", + }), + }).Create(&record).Error + if err != nil { + return fmt.Errorf("record git-agent attempt: %w", err) + } + return nil +} + +// ConcludeGitAgentTask records the terminal state once a verdict decides the +// task. Separate from the upsert because the watcher derives it from the +// verdict set rather than reading it from any single file. +func (db *DB) ConcludeGitAgentTask(ctx context.Context, id uuid.UUID, + status GitAgentTaskStatus, verdict GitAgentVerdictStatus, integratedBranch string, when time.Time, +) error { + if err := db.requireGorm(); err != nil { + return err + } + if when.IsZero() { + when = time.Now().UTC() + } + updates := map[string]any{ + "status": status, + "final_status": verdict, + "concluded_at": when, + "updated_at": clause.Expr{SQL: "now()"}, + } + if branch := nullableTrimmed(integratedBranch); branch != nil { + updates["integrated_branch"] = *branch + } + if err := db.gorm.WithContext(ctx).Model(&gitAgentTaskRecord{}). + Where("id = ?", id).Updates(updates).Error; err != nil { + return fmt.Errorf("conclude git-agent task: %w", err) + } + return nil +} + +// LinkGitAgentTasksToPromptRuns fills prompt_run_id for tasks whose admission +// key now matches a persisted prompt run. +// +// The link cannot be written at dispatch: persistPromptRun creates the +// captain_prompt_runs row only after the run finishes, by which time the remote +// task has already concluded. So the task always lands first and the association +// is resolved on a later pass. +func (db *DB) LinkGitAgentTasksToPromptRuns(ctx context.Context) (int64, error) { + if err := db.requireGorm(); err != nil { + return 0, err + } + result := db.gorm.WithContext(ctx).Exec(` + UPDATE captain_git_agent_tasks t + SET prompt_run_id = r.id, updated_at = now() + FROM captain_prompt_runs r + WHERE t.prompt_run_id IS NULL + AND t.admission_key IS NOT NULL + AND r.admission_key = t.admission_key`) + if result.Error != nil { + return 0, fmt.Errorf("link git-agent tasks to prompt runs: %w", result.Error) + } + return result.RowsAffected, nil +} + +// ListGitAgentTasksFilter narrows the history list. +type ListGitAgentTasksFilter struct { + Backend string + Agent string + Status GitAgentTaskStatus + Limit int +} + +// ListGitAgentTasks returns task history newest first. It always returns a +// slice so a consumer can iterate it unconditionally. +func (db *DB) ListGitAgentTasks(ctx context.Context, filter ListGitAgentTasksFilter) ([]GitAgentTask, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + query := db.gorm.WithContext(ctx).Model(&gitAgentTaskRecord{}) + if backend := nullableTrimmed(filter.Backend); backend != nil { + query = query.Where("backend = ?", *backend) + } + if agent := nullableTrimmed(filter.Agent); agent != nil { + query = query.Where("agent = ?", *agent) + } + if filter.Status != "" { + query = query.Where("status = ?", filter.Status) + } + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + var records []gitAgentTaskRecord + if err := query.Order("dispatched_at DESC, id DESC").Limit(limit). + Find(&records).Error; err != nil { + return nil, fmt.Errorf("list git-agent tasks: %w", err) + } + tasks := make([]GitAgentTask, 0, len(records)) + for _, record := range records { + tasks = append(tasks, record.toTask()) + } + return tasks, nil +} + +// GetGitAgentTask reads one task with its verdicts. ok is false when unknown. +func (db *DB) GetGitAgentTask(ctx context.Context, mailbox, taskID string) (*GitAgentTaskDetail, bool, error) { + if err := db.requireGorm(); err != nil { + return nil, false, err + } + var records []gitAgentTaskRecord + query := db.gorm.WithContext(ctx).Model(&gitAgentTaskRecord{}).Where("task_id = ?", taskID) + if trimmed := nullableTrimmed(mailbox); trimmed != nil { + query = query.Where("mailbox = ?", *trimmed) + } + if err := query.Order("dispatched_at DESC").Limit(2).Find(&records).Error; err != nil { + return nil, false, fmt.Errorf("read git-agent task: %w", err) + } + if len(records) == 0 { + return nil, false, nil + } + // A task id is unique only within its mailbox, so an unscoped lookup that + // matches more than one row is ambiguous and must say so rather than + // silently returning whichever sorted first. + if len(records) > 1 { + return nil, false, fmt.Errorf("task %q exists in more than one mailbox; pass a mailbox to disambiguate", taskID) + } + var attempts []gitAgentTaskAttemptRecord + if err := db.gorm.WithContext(ctx).Model(&gitAgentTaskAttemptRecord{}). + Where("task_id = ?", records[0].ID). + Order("attempt ASC, tier ASC").Find(&attempts).Error; err != nil { + return nil, false, fmt.Errorf("read git-agent task attempts: %w", err) + } + detail := GitAgentTaskDetail{Task: records[0].toTask(), Attempts: make([]GitAgentTaskAttempt, 0, len(attempts))} + for _, attempt := range attempts { + detail.Attempts = append(detail.Attempts, attempt.toAttempt()) + } + return &detail, true, nil +} + +func (r gitAgentTaskRecord) toTask() GitAgentTask { + task := GitAgentTask{ + ID: r.ID, + TaskID: r.TaskID, + Mailbox: r.Mailbox, + Repository: derefString(r.Repository), + Backend: derefString(r.Backend), + Agent: derefString(r.Agent), + PromptRunID: r.PromptRunID, + Base: r.Base, + DispatchCommit: r.DispatchCommit, + Relay: derefString(r.Relay), + Attempts: r.Attempts, + MaxAttempts: r.MaxAttempts, + Status: r.Status, + FinalStatus: r.FinalStatus, + IntegratedBranch: derefString(r.IntegratedBranch), + Error: derefString(r.Error), + DispatchedAt: r.DispatchedAt, + ConcludedAt: r.ConcludedAt, + UpdatedAt: r.UpdatedAt, + } + if len(r.Policy) > 0 { + _ = json.Unmarshal(r.Policy, &task.Policy) + } + return task +} + +func (r gitAgentTaskAttemptRecord) toAttempt() GitAgentTaskAttempt { + attempt := GitAgentTaskAttempt{ + Attempt: r.Attempt, + Tier: r.Tier, + Status: r.Status, + ResultCommit: derefString(r.ResultCommit), + Feedback: derefString(r.Feedback), + RecordedAt: r.RecordedAt, + } + if len(r.Findings) > 0 { + _ = json.Unmarshal(r.Findings, &attempt.Findings) + } + return attempt +} + +// marshalJSONColumn encodes a jsonb column, returning nil for an empty value +// when fallback is empty so the column stays NULL rather than storing "null". +func marshalJSONColumn(value any, fallback string) ([]byte, error) { + switch typed := value.(type) { + case nil: + if fallback == "" { + return nil, nil + } + return []byte(fallback), nil + case map[string]any: + if len(typed) == 0 { + if fallback == "" { + return nil, nil + } + return []byte(fallback), nil + } + case []map[string]any: + if len(typed) == 0 { + if fallback == "" { + return nil, nil + } + return []byte(fallback), nil + } + } + return json.Marshal(value) +} + +func derefString(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/pkg/database/git_agent_store_integration_test.go b/pkg/database/git_agent_store_integration_test.go new file mode 100644 index 00000000..7da22796 --- /dev/null +++ b/pkg/database/git_agent_store_integration_test.go @@ -0,0 +1,260 @@ +package database + +import ( + "testing" + "time" + + "github.com/flanksource/commons-db/dbtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The ingest watcher re-scans the same mailbox tree on every change and again on +// a startup backfill, so every write has to be replayable: the same scan twice +// must leave one row, and a stale scan must never undo a newer one. +func TestGitAgentTaskStoreIsIdempotentUnderRescan(t *testing.T) { + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_git_agent_store"}) + db, err := Open(t.Context(), WithDSN(handle.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + dispatchedAt := time.Now().UTC().Add(-time.Hour).Truncate(time.Millisecond) + scan := UpsertGitAgentTaskInput{ + TaskID: "task-1", + Mailbox: "mailboxes/aaa.git", + Repository: "/repo/project", + Backend: "prod-pool", + Agent: "worker-01", + AdmissionKey: "run-key-1", + Base: "main", + DispatchCommit: "deadbeef", + Relay: "sync", + Policy: map[string]any{"paths": []string{"pkg/**"}, "maxAttempts": 3}, + Attempts: 1, + MaxAttempts: 3, + Status: GitAgentTaskRunning, + DispatchedAt: dispatchedAt, + } + + id, err := db.UpsertGitAgentTask(t.Context(), scan) + require.NoError(t, err) + + sameID, err := db.UpsertGitAgentTask(t.Context(), scan) + require.NoError(t, err) + assert.Equal(t, id, sameID, "re-scanning must reuse the row, not create a second one") + + tasks, err := db.ListGitAgentTasks(t.Context(), ListGitAgentTasksFilter{}) + require.NoError(t, err) + require.Len(t, tasks, 1) + assert.Equal(t, "worker-01", tasks[0].Agent) + assert.Equal(t, map[string]any{"paths": []any{"pkg/**"}, "maxAttempts": float64(3)}, tasks[0].Policy) + + t.Run("a later scan raises the attempt count", func(t *testing.T) { + next := scan + next.Attempts = 2 + _, err := db.UpsertGitAgentTask(t.Context(), next) + require.NoError(t, err) + detail, ok, err := db.GetGitAgentTask(t.Context(), scan.Mailbox, scan.TaskID) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 2, detail.Task.Attempts) + }) + + // fsnotify coalesces and the backfill walks the whole tree, so scans can + // arrive out of order. A stale one must not walk the count backwards. + t.Run("an out-of-order scan does not lower the attempt count", func(t *testing.T) { + stale := scan + stale.Attempts = 1 + _, err := db.UpsertGitAgentTask(t.Context(), stale) + require.NoError(t, err) + detail, ok, err := db.GetGitAgentTask(t.Context(), scan.Mailbox, scan.TaskID) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, 2, detail.Task.Attempts, "GREATEST must keep the highest attempt seen") + }) + + // A task directory read mid-write can yield a partial record; it must not + // blank fields an earlier, more complete scan already established. + t.Run("a partial scan does not blank known fields", func(t *testing.T) { + partial := UpsertGitAgentTaskInput{ + TaskID: scan.TaskID, Mailbox: scan.Mailbox, + Base: scan.Base, DispatchCommit: scan.DispatchCommit, + } + _, err := db.UpsertGitAgentTask(t.Context(), partial) + require.NoError(t, err) + detail, ok, err := db.GetGitAgentTask(t.Context(), scan.Mailbox, scan.TaskID) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "worker-01", detail.Task.Agent) + assert.Equal(t, "prod-pool", detail.Task.Backend) + }) + + t.Run("a concluded task is not dragged back to running", func(t *testing.T) { + require.NoError(t, db.ConcludeGitAgentTask( + t.Context(), id, GitAgentTaskAccepted, GitAgentVerdictAccepted, "captain/task-1", time.Now().UTC())) + + // The watcher cannot tell from the directory that the task finished, so + // it keeps reporting "running" on every rescan. + running := scan + running.Status = GitAgentTaskRunning + _, err := db.UpsertGitAgentTask(t.Context(), running) + require.NoError(t, err) + + detail, ok, err := db.GetGitAgentTask(t.Context(), scan.Mailbox, scan.TaskID) + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, GitAgentTaskAccepted, detail.Task.Status) + require.NotNil(t, detail.Task.FinalStatus) + assert.Equal(t, GitAgentVerdictAccepted, *detail.Task.FinalStatus) + assert.Equal(t, "captain/task-1", detail.Task.IntegratedBranch) + }) +} + +func TestGitAgentAttemptsRecordBothTiers(t *testing.T) { + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_git_agent_attempts"}) + db, err := Open(t.Context(), WithDSN(handle.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + id, err := db.UpsertGitAgentTask(t.Context(), UpsertGitAgentTaskInput{ + TaskID: "task-1", Mailbox: "mailboxes/aaa.git", Base: "main", DispatchCommit: "deadbeef", + }) + require.NoError(t, err) + + rejected := RecordGitAgentAttemptInput{ + TaskID: id, Attempt: 1, Tier: "supervisor", Status: GitAgentVerdictRejected, + Findings: []map[string]any{{"hook": "verify", "kind": "exec", "message": "make lint failed"}}, + Feedback: "fix the lint error", + } + require.NoError(t, db.RecordGitAgentAttempt(t.Context(), rejected)) + // Both tiers reach their own verdict on the same attempt. + require.NoError(t, db.RecordGitAgentAttempt(t.Context(), RecordGitAgentAttemptInput{ + TaskID: id, Attempt: 1, Tier: "sidecar", Status: GitAgentVerdictAccepted, + })) + // Re-scanning the verdicts directory must not duplicate them. + require.NoError(t, db.RecordGitAgentAttempt(t.Context(), rejected)) + + detail, ok, err := db.GetGitAgentTask(t.Context(), "mailboxes/aaa.git", "task-1") + require.NoError(t, err) + require.True(t, ok) + require.Len(t, detail.Attempts, 2) + assert.Equal(t, "sidecar", detail.Attempts[0].Tier, "attempts sort by attempt then tier") + assert.Equal(t, "supervisor", detail.Attempts[1].Tier) + assert.Equal(t, GitAgentVerdictRejected, detail.Attempts[1].Status) + require.Len(t, detail.Attempts[1].Findings, 1) + assert.Equal(t, "make lint failed", detail.Attempts[1].Findings[0]["message"]) +} + +// A task id is unique only within its mailbox — one endpoint routes many +// repositories — so an unscoped lookup that matches two rows must say so rather +// than silently return whichever sorted first. +func TestGetGitAgentTaskRefusesAnAmbiguousTaskID(t *testing.T) { + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_git_agent_ambiguous"}) + db, err := Open(t.Context(), WithDSN(handle.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + for _, mailbox := range []string{"mailboxes/aaa.git", "mailboxes/bbb.git"} { + _, err := db.UpsertGitAgentTask(t.Context(), UpsertGitAgentTaskInput{ + TaskID: "task-1", Mailbox: mailbox, Base: "main", DispatchCommit: "deadbeef", + }) + require.NoError(t, err) + } + + _, _, err = db.GetGitAgentTask(t.Context(), "", "task-1") + require.Error(t, err) + assert.Contains(t, err.Error(), "more than one mailbox") + + detail, ok, err := db.GetGitAgentTask(t.Context(), "mailboxes/bbb.git", "task-1") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, "mailboxes/bbb.git", detail.Task.Mailbox) +} + +// persistPromptRun writes the prompt_runs row only after the run finishes, by +// which time the remote task has already concluded — so the task always lands +// first and the link is resolved on a later pass. +func TestGitAgentTasksLinkToPromptRunsAfterTheFact(t *testing.T) { + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_git_agent_link"}) + db, err := Open(t.Context(), WithDSN(handle.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + _, err = db.UpsertGitAgentTask(t.Context(), UpsertGitAgentTaskInput{ + TaskID: "task-1", Mailbox: "mailboxes/aaa.git", Base: "main", + DispatchCommit: "deadbeef", AdmissionKey: "run-key-1", + }) + require.NoError(t, err) + + // Nothing to link yet: the run row does not exist. + linked, err := db.LinkGitAgentTasksToPromptRuns(t.Context()) + require.NoError(t, err) + assert.Equal(t, int64(0), linked) + + session, err := db.CreateOrGetSession(t.Context(), CreateSessionInput{ + Source: "claude", ProviderSessionID: "session-1", + }) + require.NoError(t, err) + run, err := db.CreatePromptRun(t.Context(), CreatePromptRunInput{ + SessionID: session.ID, RootSessionID: &session.ID, AdmissionKey: "run-key-1", + }) + require.NoError(t, err) + + linked, err = db.LinkGitAgentTasksToPromptRuns(t.Context()) + require.NoError(t, err) + assert.Equal(t, int64(1), linked) + + detail, ok, err := db.GetGitAgentTask(t.Context(), "mailboxes/aaa.git", "task-1") + require.NoError(t, err) + require.True(t, ok) + require.NotNil(t, detail.Task.PromptRunID) + assert.Equal(t, run.ID, *detail.Task.PromptRunID) + + // Re-running the linker is a no-op rather than rewriting the same rows. + linked, err = db.LinkGitAgentTasksToPromptRuns(t.Context()) + require.NoError(t, err) + assert.Equal(t, int64(0), linked) +} + +func TestListGitAgentTasksFilters(t *testing.T) { + handle := dbtest.ForT(t, dbtest.Options{Name: "captain_git_agent_list"}) + db, err := Open(t.Context(), WithDSN(handle.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + base := time.Now().UTC().Add(-time.Hour) + for i, spec := range []struct { + task, agent string + status GitAgentTaskStatus + }{ + {"task-1", "worker-01", GitAgentTaskAccepted}, + {"task-2", "worker-02", GitAgentTaskRejected}, + {"task-3", "worker-01", GitAgentTaskRunning}, + } { + _, err := db.UpsertGitAgentTask(t.Context(), UpsertGitAgentTaskInput{ + TaskID: spec.task, Mailbox: "mailboxes/aaa.git", Base: "main", + DispatchCommit: "deadbeef", Backend: "prod-pool", Agent: spec.agent, + Status: spec.status, DispatchedAt: base.Add(time.Duration(i) * time.Minute), + }) + require.NoError(t, err) + } + + all, err := db.ListGitAgentTasks(t.Context(), ListGitAgentTasksFilter{}) + require.NoError(t, err) + require.Len(t, all, 3) + assert.Equal(t, "task-3", all[0].TaskID, "newest dispatch first") + + byAgent, err := db.ListGitAgentTasks(t.Context(), ListGitAgentTasksFilter{Agent: "worker-01"}) + require.NoError(t, err) + assert.Len(t, byAgent, 2) + + byStatus, err := db.ListGitAgentTasks(t.Context(), ListGitAgentTasksFilter{Status: GitAgentTaskRejected}) + require.NoError(t, err) + require.Len(t, byStatus, 1) + assert.Equal(t, "task-2", byStatus[0].TaskID) + + none, err := db.ListGitAgentTasks(t.Context(), ListGitAgentTasksFilter{Backend: "other-pool"}) + require.NoError(t, err) + assert.Empty(t, none) + assert.NotNil(t, none, "an empty result must marshal as [] rather than null") +} diff --git a/pkg/gitagent/deploy/credentials_ginkgo_test.go b/pkg/gitagent/deploy/credentials_ginkgo_test.go new file mode 100644 index 00000000..a0dcf6b2 --- /dev/null +++ b/pkg/gitagent/deploy/credentials_ginkgo_test.go @@ -0,0 +1,117 @@ +package deploy_test + +import ( + "context" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// credentialSecretFixture stands in for the Secret the publisher maintains. +func credentialSecretFixture() *corev1.Secret { + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{Name: "captain-agent-credentials", Namespace: testNamespace}, + Type: corev1.SecretTypeOpaque, + Data: map[string][]byte{"claude.credentials.json": []byte(`{"claudeAiOauth":{}}`)}, + } +} + +// credentialsPlan is a kubernetes plan that mounts the shared login Secret. +func credentialsPlan() deploy.Plan { + plan := kubernetesPlan() + plan.CredentialsSecret = "captain-agent-credentials" + return plan +} + +var _ = Describe("credential mount", func() { + Describe("kubernetes", func() { + It("mounts the Secret as a directory, never through subPath", func() { + // A subPath Secret mount is never refreshed by kubelet after the pod + // starts, so it would pin the sidecar to the first credential it ever + // saw and silently defeat the supervisor's republish loop. + mounts := dig(asJSON(credentialsPlan().Deployment(testNamespace, "IfNotPresent", "")), + "spec", "template", "spec", "containers").([]any) + Expect(mounts).To(HaveLen(1)) + + var found map[string]any + for _, raw := range mounts[0].(map[string]any)["volumeMounts"].([]any) { + mount := raw.(map[string]any) + if mount["name"] == "credentials" { + found = mount + } + } + Expect(found).NotTo(BeNil(), "no credentials volume mount") + Expect(found["mountPath"]).To(Equal(deploy.CredentialsMountPath)) + Expect(found["readOnly"]).To(BeTrue()) + Expect(found).NotTo(HaveKey("subPath")) + }) + + It("marks the volume optional so the pod starts before the first publish", func() { + volumes := dig(asJSON(credentialsPlan().Deployment(testNamespace, "IfNotPresent", "")), + "spec", "template", "spec", "volumes").([]any) + + var secret map[string]any + for _, raw := range volumes { + volume := raw.(map[string]any) + if volume["name"] == "credentials" { + secret = volume["secret"].(map[string]any) + } + } + Expect(secret).NotTo(BeNil(), "no credentials volume") + Expect(secret["secretName"]).To(Equal("captain-agent-credentials")) + Expect(secret["optional"]).To(BeTrue()) + Expect(secret["defaultMode"]).To(BeEquivalentTo(0o400)) + }) + + It("adds no volume at all when no credential Secret is named", func() { + rendered := asJSON(kubernetesPlan().Deployment(testNamespace, "IfNotPresent", "")) + volumes := dig(rendered, "spec", "template", "spec", "volumes").([]any) + for _, raw := range volumes { + Expect(raw.(map[string]any)["name"]).NotTo(Equal("credentials")) + } + }) + }) + + Describe("docker", func() { + It("bind-mounts the published directory read-only", func() { + plan := dockerPlan() + plan.CredentialsDir = "/host/captain/credentials" + args := strings.Join(deploy.DockerArgs(plan, "/host/join"), " ") + Expect(args).To(ContainSubstring( + "--volume /host/captain/credentials:" + deploy.CredentialsMountPath + ":ro")) + }) + + It("mounts nothing when no directory is given", func() { + args := strings.Join(deploy.DockerArgs(dockerPlan(), "/host/join"), " ") + Expect(args).NotTo(ContainSubstring(deploy.CredentialsMountPath)) + }) + }) + + Describe("undeploy", func() { + It("leaves the shared credential Secret in place", func() { + // The Secret is owned by the credential publisher and shared by every + // agent in the namespace, so tearing one sidecar down must not take + // every other sidecar's login with it. + plan := credentialsPlan() + client := fake.NewClientset() + ctx := context.Background() + + _, err := client.CoreV1().Secrets(testNamespace).Create(ctx, credentialSecretFixture(), metav1.CreateOptions{}) + Expect(err).NotTo(HaveOccurred()) + + removed, err := deploy.KubernetesRemove(ctx, client, plan, testNamespace, true) + Expect(err).NotTo(HaveOccurred()) + Expect(removed).NotTo(ContainElement(ContainSubstring(plan.CredentialsSecret))) + + _, err = client.CoreV1().Secrets(testNamespace). + Get(ctx, plan.CredentialsSecret, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred(), "undeploy deleted the shared credential Secret") + }) + }) +}) diff --git a/pkg/gitagent/deploy/deploy_suite_test.go b/pkg/gitagent/deploy/deploy_suite_test.go new file mode 100644 index 00000000..7649d143 --- /dev/null +++ b/pkg/gitagent/deploy/deploy_suite_test.go @@ -0,0 +1,13 @@ +package deploy_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestDeploy(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "git-agent deploy") +} diff --git a/pkg/gitagent/deploy/docker.go b/pkg/gitagent/deploy/docker.go new file mode 100644 index 00000000..b353d821 --- /dev/null +++ b/pkg/gitagent/deploy/docker.go @@ -0,0 +1,227 @@ +package deploy + +import ( + "context" + "fmt" + "os" + "os/exec" + "strings" +) + +// JoinMountPath is where the token file appears inside the workload. +const JoinMountPath = "/run/captain/join" + +// CredentialsMountPath is where the redacted agent logins appear inside the +// workload, on both targets. A directory rather than a file, because the +// sidecar reads whichever providers the supervisor happens to publish and +// because a Kubernetes Secret mounted at a file path stops receiving updates. +const CredentialsMountPath = "/run/captain/credentials" + +// dockerBinary is the client this package drives. Captain shells out to the +// docker CLI everywhere rather than linking the SDK, and this follows suit: the +// CLI resolves DOCKER_HOST, contexts and credential helpers on its own. +const dockerBinary = "docker" + +// DockerArgs builds the `docker run` argv for a sidecar. +// +// It is pure so the security posture can be asserted in a unit test — the flags +// below are the entire containment boundary for agent-authored code, so a +// silent regression in any of them is the failure this package exists to +// prevent. joinHostPath is the host-side token file, bind-mounted read-only. +func DockerArgs(plan Plan, joinHostPath string) []string { + args := []string{ + "run", "--detach", + "--name", plan.WorkloadName(), + "--restart", "unless-stopped", + // Reap orphans: the coding agent is launched with Setsid, so its children + // reparent to PID 1, and captain is not a reaper. Without this, zombies + // accumulate against --pids-limit until forks start failing. + "--init", + } + for _, label := range sortedLabelArgs(plan.Labels()) { + args = append(args, "--label", label) + } + + // Override the image entrypoint rather than run through it. The published + // image ends `USER root` and its entrypoint calls gosu to drop privileges, + // which needs CAP_SETUID/CAP_SETGID — exactly what --cap-drop ALL removes. + // Invoking the binary directly and letting docker set the uid means the + // process never runs as root at all, which beats dropping from it. + args = append(args, + "--entrypoint", "captain", + "--user", fmt.Sprintf("%d:%d", plan.Security.RunAsUser, plan.Security.RunAsGroup), + "--cap-drop", "ALL", + "--security-opt", "no-new-privileges", + ) + for _, capability := range plan.Security.CapAdd { + args = append(args, "--cap-add", capability) + } + if plan.Security.ReadOnlyRoot { + // Safe only because every write target is relocated: state onto the + // volume under HOME, scratch onto this tmpfs. + args = append(args, "--read-only", + "--tmpfs", fmt.Sprintf("/tmp:rw,nosuid,nodev,mode=1777,size=%s", plan.Sizing.DockerTmpfsSize())) + } + + args = append(args, + "--network", plan.Security.Network, + // Resolves to the bridge gateway on Linux and is built in on Docker + // Desktop, so the sidecar reaches the mailbox the same way on both. + "--add-host", "host.docker.internal:host-gateway", + // Loopback-published: the supervisor dispatches from this host, and the + // sidecar has no reason to be reachable from the LAN. + "--publish", fmt.Sprintf("127.0.0.1:%d:%d", plan.HostPort, plan.ListenPort), + ) + + args = append(args, + "--cpus", plan.Sizing.DockerCPUs(), + "--memory", plan.Sizing.DockerMemoryBytes(), + // Equal to --memory: swapping lets a runaway agent exceed its ceiling by + // paging instead of failing. + "--memory-swap", plan.Sizing.DockerMemoryBytes(), + "--memory-reservation", plan.Sizing.DockerMemoryReservationBytes(), + ) + if plan.Sizing.PidsLimit > 0 { + args = append(args, "--pids-limit", fmt.Sprintf("%d", plan.Sizing.PidsLimit)) + } + + args = append(args, + "--volume", plan.VolumeName()+":"+plan.Home, + // A path, not a credential: passing HOME by name would clobber the docker + // CLIENT's own HOME and break registry auth on the pull. + "--env", "HOME="+plan.Home, + // Keep Go and npm caches off the RAM-backed tmpfs, which counts against + // the memory limit. + "--env", "TMPDIR="+plan.Home+"/.cache/tmp", + ) + if joinHostPath != "" { + args = append(args, "--volume", joinHostPath+":"+plan.JoinPath+":ro") + } + if plan.CredentialsDir != "" { + // Read-only: the sidecar copies out of here, and the supervisor is the + // only writer. + args = append(args, "--volume", plan.CredentialsDir+":"+CredentialsMountPath+":ro") + } + // Names only. Docker resolves each from the client environment, so a + // credential value never enters argv or `docker inspect`. + for _, name := range plan.EnvNames { + args = append(args, "--env", name) + } + + args = append(args, plan.Image) + return append(args, plan.ServeArgs()...) +} + +// sortedLabelArgs renders labels as key=value in a stable order, so a rendered +// argv can be compared against a golden value. +func sortedLabelArgs(labels map[string]string) []string { + rendered := make([]string, 0, len(labels)) + for _, key := range sortedKeys(labels) { + rendered = append(rendered, key+"="+labels[key]) + } + return rendered +} + +// DockerAvailable reports whether a usable daemon is reachable, so deploy fails +// before minting a token rather than after. +func DockerAvailable(ctx context.Context) error { + if _, err := exec.LookPath(dockerBinary); err != nil { + return fmt.Errorf("docker is not on PATH: %w", err) + } + if out, err := exec.CommandContext(ctx, dockerBinary, "info", "--format", "{{.ServerVersion}}").CombinedOutput(); err != nil { + return fmt.Errorf("docker daemon is not reachable: %s", strings.TrimSpace(string(out))) + } + return nil +} + +// DockerImagePresent reports whether the image is already local, matching the +// exit-code probe pkg/container uses. +func DockerImagePresent(ctx context.Context, image string) bool { + return exec.CommandContext(ctx, dockerBinary, "image", "inspect", "--format", "{{.Id}}", image).Run() == nil +} + +// DockerPull fetches the image before a token is minted. +// +// This image carries a Go toolchain, Chromium and several agent CLIs, so a cold +// pull is slow and is where a deploy is most likely to fail. Pulling before the +// mint keeps a failure from leaving behind a live credential for a workload +// that never started. +func DockerPull(ctx context.Context, image string) error { + cmd := exec.CommandContext(ctx, dockerBinary, "pull", image) + cmd.Stdout, cmd.Stderr = os.Stderr, os.Stderr // progress is not the result + if err := cmd.Run(); err != nil { + return fmt.Errorf("pull %s: %w", image, err) + } + return nil +} + +// DockerContainer is the state of an existing sidecar container. +type DockerContainer struct { + ID string + Running bool + Image string +} + +// DockerInspect reports an existing container, or ok=false when there is none. +func DockerInspect(ctx context.Context, name string) (DockerContainer, bool, error) { + out, err := exec.CommandContext(ctx, dockerBinary, "container", "inspect", + "--format", "{{.Id}} {{.State.Running}} {{.Config.Image}}", name).Output() + if err != nil { + // `inspect` exits non-zero for "no such container", which is the common + // case here rather than a failure. + return DockerContainer{}, false, nil + } + fields := strings.Fields(strings.TrimSpace(string(out))) + if len(fields) < 3 { + return DockerContainer{}, false, fmt.Errorf("unexpected docker inspect output for %s: %q", name, out) + } + return DockerContainer{ID: fields[0], Running: fields[1] == "true", Image: fields[2]}, true, nil +} + +// DockerRun starts the sidecar and returns its container id. +func DockerRun(ctx context.Context, plan Plan, joinHostPath string) (string, error) { + out, err := exec.CommandContext(ctx, dockerBinary, DockerArgs(plan, joinHostPath)...).CombinedOutput() + if err != nil { + return "", fmt.Errorf("docker run: %s", strings.TrimSpace(string(out))) + } + return strings.TrimSpace(string(out)), nil +} + +// DockerStart restarts an existing, stopped sidecar. +func DockerStart(ctx context.Context, name string) error { + if out, err := exec.CommandContext(ctx, dockerBinary, "start", name).CombinedOutput(); err != nil { + return fmt.Errorf("docker start %s: %s", name, strings.TrimSpace(string(out))) + } + return nil +} + +// DockerRemove deletes the container, and its state volume only when asked. +// +// The volume holds the agent's private key, so removing it is what makes an +// identity unrecoverable rather than merely stopped — it stays opt-in. +func DockerRemove(ctx context.Context, plan Plan, purgeVolume bool) error { + if out, err := exec.CommandContext(ctx, dockerBinary, "rm", "--force", plan.WorkloadName()).CombinedOutput(); err != nil { + if !strings.Contains(string(out), "No such container") { + return fmt.Errorf("docker rm %s: %s", plan.WorkloadName(), strings.TrimSpace(string(out))) + } + } + if !purgeVolume { + return nil + } + if out, err := exec.CommandContext(ctx, dockerBinary, "volume", "rm", plan.VolumeName()).CombinedOutput(); err != nil { + if !strings.Contains(string(out), "no such volume") { + return fmt.Errorf("docker volume rm %s: %s", plan.VolumeName(), strings.TrimSpace(string(out))) + } + } + return nil +} + +// DockerLogs returns the workload's recent output, so a timeout reports why +// rather than only that it happened. +func DockerLogs(ctx context.Context, name string, lines int) string { + out, err := exec.CommandContext(ctx, dockerBinary, "logs", "--tail", fmt.Sprintf("%d", lines), name).CombinedOutput() + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} diff --git a/pkg/gitagent/deploy/docker_ginkgo_test.go b/pkg/gitagent/deploy/docker_ginkgo_test.go new file mode 100644 index 00000000..05690271 --- /dev/null +++ b/pkg/gitagent/deploy/docker_ginkgo_test.go @@ -0,0 +1,169 @@ +package deploy_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +const joinHostPath = "/home/op/.captain/sandbox/deploy/worker-01/join" + +func dockerPlan() deploy.Plan { + sizing, err := deploy.ParseSizing(defaultSizingRequest()) + Expect(err).NotTo(HaveOccurred()) + return deploy.Plan{ + Name: "worker-01", + Backend: "git-agent", + Target: deploy.TargetDocker, + Image: "ghcr.io/flanksource/captain:latest", + Home: "/home/claude", + ListenPort: 7422, + HostPort: 7423, + Supervisor: "ssh://captain@host.docker.internal:7422", + Advertise: "ssh://captain@127.0.0.1:7423/repo.git", + HostFingerprint: "SHA256:abc", + JoinPath: deploy.JoinMountPath, + Sizing: sizing, + Security: deploy.HardenedSecurity(), + } +} + +// hasFlagValue reports whether argv contains `flag value` adjacently. +func hasFlagValue(argv []string, flag, value string) bool { + for i := 0; i < len(argv)-1; i++ { + if argv[i] == flag && argv[i+1] == value { + return true + } + } + return false +} + +var _ = Describe("DockerArgs", func() { + var argv []string + + BeforeEach(func() { argv = deploy.DockerArgs(dockerPlan(), joinHostPath) }) + + // Each of these is the containment boundary for agent-authored code. A + // silent regression in any one of them is the failure this package exists + // to prevent, so they are asserted individually rather than as a blob. + DescribeTable("applies the hardened posture", + func(flag, value string) { + Expect(hasFlagValue(argv, flag, value)).To(BeTrue(), "missing %s %s in %v", flag, value, argv) + }, + Entry("drops every capability", "--cap-drop", "ALL"), + Entry("forbids privilege escalation", "--security-opt", "no-new-privileges"), + Entry("runs as the image's unprivileged user", "--user", "501:20"), + // gosu would need CAP_SETUID/CAP_SETGID, which --cap-drop ALL removes. + Entry("bypasses the gosu entrypoint", "--entrypoint", "captain"), + Entry("caps CPU", "--cpus", "2"), + Entry("caps memory", "--memory", "4294967296"), + Entry("denies swap past the memory cap", "--memory-swap", "4294967296"), + Entry("reserves memory", "--memory-reservation", "1073741824"), + Entry("bounds processes", "--pids-limit", "1024"), + Entry("reaches the mailbox via the gateway alias", "--add-host", "host.docker.internal:host-gateway"), + Entry("publishes only on loopback", "--publish", "127.0.0.1:7423:7422"), + Entry("mounts state at the image's own home", "--volume", "captain-git-agent-worker-01-state:/home/claude"), + Entry("mounts the token read-only", "--volume", joinHostPath+":/run/captain/join:ro"), + Entry("steers scratch off the tmpfs", "--env", "TMPDIR=/home/claude/.cache/tmp"), + ) + + // The coding agent is launched with Setsid, so its children reparent to + // PID 1. captain is not a reaper, so without --init zombies accumulate + // against --pids-limit until forks start failing. + It("reaps orphaned children", func() { + Expect(argv).To(ContainElement("--init")) + }) + + It("mounts a read-only root with a sized tmpfs for scratch", func() { + Expect(argv).To(ContainElement("--read-only")) + Expect(hasFlagValue(argv, "--tmpfs", "/tmp:rw,nosuid,nodev,mode=1777,size=1073741824")).To(BeTrue()) + }) + + It("names the workload and labels it for teardown by selector", func() { + Expect(hasFlagValue(argv, "--name", "captain-git-agent-worker-01")).To(BeTrue()) + Expect(hasFlagValue(argv, "--label", "app.kubernetes.io/instance=worker-01")).To(BeTrue()) + Expect(hasFlagValue(argv, "--label", "captain.flanksource.com/backend=git-agent")).To(BeTrue()) + }) + + It("ends with the image followed by the serve argv", func() { + image := indexOf(argv, "ghcr.io/flanksource/captain:latest") + Expect(image).To(BeNumerically(">", 0)) + Expect(argv[image+1:]).To(Equal(dockerPlan().ServeArgs())) + }) + + // R5.3/A6.2: a runtime socket is a full host escape, and there is no flag + // that can add one. + It("never mounts a container runtime socket and is never privileged", func() { + joined := strings.Join(argv, " ") + Expect(joined).NotTo(ContainSubstring("docker.sock")) + Expect(joined).NotTo(ContainSubstring("containerd.sock")) + Expect(joined).NotTo(ContainSubstring("podman.sock")) + Expect(argv).NotTo(ContainElement("--privileged")) + Expect(hasFlagValue(argv, "--network", "host")).To(BeFalse()) + }) + + // R8.2: the token reaches the workload as a file. In argv it would show up + // in `docker inspect` and /proc/1/cmdline, readable by the coding agent this + // very container runs. + It("keeps the join token out of argv entirely", func() { + const token = "s3cret-join-token" + plan := dockerPlan() + for _, arg := range deploy.DockerArgs(plan, joinHostPath) { + Expect(arg).NotTo(ContainSubstring(token)) + } + Expect(deploy.DockerArgs(plan, joinHostPath)).NotTo(ContainElement("--join")) + }) + + It("does not mount a spent join token when restarting persisted enrollment", func() { + plan := dockerPlan() + plan.JoinPath = "" + argv := deploy.DockerArgs(plan, "") + + Expect(argv).To(ContainElement("--volume"), "the state volume must still be mounted") + Expect(argv).NotTo(ContainElement("--token-file")) + for _, arg := range argv { + Expect(arg).NotTo(HaveSuffix(":" + deploy.JoinMountPath + ":ro")) + } + }) + + It("forwards environment by name only, so values stay out of argv", func() { + plan := dockerPlan() + plan.EnvNames = []string{"ANTHROPIC_API_KEY"} + + argv := deploy.DockerArgs(plan, joinHostPath) + Expect(hasFlagValue(argv, "--env", "ANTHROPIC_API_KEY")).To(BeTrue()) + for _, arg := range argv { + Expect(arg).NotTo(HavePrefix("ANTHROPIC_API_KEY=")) + } + }) + + It("omits the read-only root and its tmpfs when disabled", func() { + plan := dockerPlan() + plan.Security.ReadOnlyRoot = false + + argv := deploy.DockerArgs(plan, joinHostPath) + Expect(argv).NotTo(ContainElement("--read-only")) + Expect(argv).NotTo(ContainElement("--tmpfs")) + }) + + It("adds back only the capabilities explicitly requested", func() { + plan := dockerPlan() + plan.Security.CapAdd = []string{"NET_ADMIN"} + + argv := deploy.DockerArgs(plan, joinHostPath) + Expect(hasFlagValue(argv, "--cap-drop", "ALL")).To(BeTrue()) + Expect(hasFlagValue(argv, "--cap-add", "NET_ADMIN")).To(BeTrue()) + }) +}) + +func indexOf(items []string, want string) int { + for i, item := range items { + if item == want { + return i + } + } + return -1 +} diff --git a/pkg/gitagent/deploy/kubernetes.go b/pkg/gitagent/deploy/kubernetes.go new file mode 100644 index 00000000..d3c09523 --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes.go @@ -0,0 +1,326 @@ +package deploy + +import ( + "context" + "fmt" + "strings" + "time" + + authv1 "k8s.io/api/authorization/v1" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// applyOptions force-owns the fields this package sets. Force resolves a +// conflict with a previous manager in our favour rather than erroring, which is +// what makes a re-deploy converge on the declared spec. +func applyOptions() metav1.ApplyOptions { + return metav1.ApplyOptions{FieldManager: FieldManager, Force: true} +} + +// requiredPermissions is what a deploy needs, checked before it starts. +// +// Applying four objects — five with an external route — is not transactional: failing on the third leaves a +// Secret and a PVC behind and an enrollment already recorded. A +// SelfSubjectAccessReview turns that into one refusal up front. +var requiredPermissions = []struct{ group, resource, verb string }{ + {"apps", "deployments", "create"}, + {"apps", "deployments", "patch"}, + {"", "services", "create"}, + {"", "services", "patch"}, + {"", "secrets", "create"}, + {"", "secrets", "delete"}, + // Server-side apply is a PATCH against a possibly-absent object, so the + // credential publisher needs get and patch as well as create. Checking them + // here means a namespace that cannot host the republish loop is refused at + // deploy time rather than at the supervisor's first publish. + {"", "secrets", "get"}, + {"", "secrets", "patch"}, + {"", "persistentvolumeclaims", "create"}, + {"", "pods", "list"}, +} + +// ingressPermissions are checked only when a route is rendered. +// +// A namespace that can host the in-cluster topology and not the externally +// routed one is a legitimate configuration, and demanding ingress rights there +// would refuse a deploy that would have succeeded. Delete is checked at DEPLOY +// time on purpose: teardown running with rights the deploy never verified is how +// a sidecar ends up unremovable and still routed. +var ingressPermissions = []struct{ group, resource, verb string }{ + {"networking.k8s.io", "ingresses", "create"}, + {"networking.k8s.io", "ingresses", "patch"}, + {"networking.k8s.io", "ingresses", "delete"}, +} + +var traefikPermissions = []struct{ group, resource, verb string }{ + {"traefik.io", "serverstransports", "get"}, + {"traefik.io", "serverstransports", "create"}, + {"traefik.io", "serverstransports", "update"}, + {"traefik.io", "serverstransports", "delete"}, +} + +// EnsureNamespace makes the target namespace exist, or refuses before anything +// is applied into it. +// +// Every object a deploy creates is namespaced, so a namespace that is not there +// fails on the first apply with an error naming neither the cause nor the fix — +// the same reason CheckPermissions runs up front. Creating one is opt-in: a +// typo'd namespace that silently appears is a cluster-scoped side effect nobody +// asked for, and nothing later would report it. +// +// It returns whether it created the namespace, so the caller can report it. +func EnsureNamespace(ctx context.Context, client kubernetes.Interface, namespace string, create bool) (bool, error) { + _, err := client.CoreV1().Namespaces().Get(ctx, namespace, metav1.GetOptions{}) + switch { + case err == nil: + return false, nil + case !apierrors.IsNotFound(err): + return false, fmt.Errorf("checking namespace %q: %w", namespace, err) + case !create: + return false, fmt.Errorf( + "namespace %q does not exist in this cluster; create it, or pass --create-namespace", namespace) + } + _, err = client.CoreV1().Namespaces().Create(ctx, &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: namespace, + Labels: map[string]string{"app.kubernetes.io/managed-by": "captain"}, + }, + }, metav1.CreateOptions{}) + if apierrors.IsAlreadyExists(err) { + // Another deploy won the race, or it appeared between the two calls. The + // postcondition — the namespace exists — is met either way. + return false, nil + } + if err != nil { + return false, fmt.Errorf("creating namespace %q: %w", namespace, err) + } + return true, nil +} + +// CheckPermissions refuses a deploy the caller is not allowed to complete. +func CheckPermissions(ctx context.Context, client kubernetes.Interface, namespace, ingressClass string) error { + var denied []string + permissions := requiredPermissions + if ingressClass != "" { + permissions = append(append([]struct{ group, resource, verb string }{}, permissions...), ingressPermissions...) + } + if ingressClass == "traefik" { + permissions = append(permissions, traefikPermissions...) + } + for _, permission := range permissions { + review := &authv1.SelfSubjectAccessReview{ + Spec: authv1.SelfSubjectAccessReviewSpec{ + ResourceAttributes: &authv1.ResourceAttributes{ + Namespace: namespace, + Group: permission.group, + Resource: permission.resource, + Verb: permission.verb, + }, + }, + } + result, err := client.AuthorizationV1().SelfSubjectAccessReviews(). + Create(ctx, review, metav1.CreateOptions{}) + if err != nil { + // The cluster may not serve SSAR to this identity. That is not itself a + // denial, and refusing here would block a deploy that would succeed. + return nil //nolint:nilerr // absence of the check is not a failed check + } + if !result.Status.Allowed { + denied = append(denied, fmt.Sprintf("%s %s", permission.verb, resourceLabel(permission.group, permission.resource))) + } + } + if len(denied) > 0 { + return fmt.Errorf("missing permission in namespace %q: %s", namespace, strings.Join(denied, ", ")) + } + return nil +} + +func resourceLabel(group, resource string) string { + if group == "" { + return resource + } + return group + "/" + resource +} + +// KubernetesApply creates or converges every object for one sidecar, in +// dependency order: the token and its volume exist before the pod that mounts +// them. +func KubernetesApply(ctx context.Context, client kubernetes.Interface, plan Plan, opts KubernetesOptions) ([]string, error) { + if err := plan.ValidateJoinPath(); err != nil { + return nil, err + } + namespace := opts.Namespace + applied := []string{} + + if opts.JoinToken != "" { + if _, err := client.CoreV1().Secrets(namespace). + Apply(ctx, plan.JoinSecret(namespace, opts.JoinToken), applyOptions()); err != nil { + return applied, fmt.Errorf("apply join secret: %w", err) + } + applied = append(applied, "Secret/"+plan.JoinSecretName()) + } + + // A PVC's storage request is immutable once bound, so a re-deploy asking for + // a different size is rejected by the API server. Report what to do rather + // than leaving the operator with a field-immutable error. + if _, err := client.CoreV1().PersistentVolumeClaims(namespace). + Apply(ctx, plan.StateClaim(namespace, opts.StorageClass), applyOptions()); err != nil { + if apierrors.IsInvalid(err) { + return applied, fmt.Errorf( + "the state volume %s already exists with different settings; resize it with kubectl, or run undeploy --purge first: %w", + plan.VolumeName(), err) + } + return applied, fmt.Errorf("apply state volume: %w", err) + } + applied = append(applied, "PersistentVolumeClaim/"+plan.VolumeName()) + + if _, err := client.CoreV1().Services(namespace). + Apply(ctx, plan.Service(namespace), applyOptions()); err != nil { + return applied, fmt.Errorf("apply service: %w", err) + } + applied = append(applied, "Service/"+plan.WorkloadName()) + + if _, err := client.AppsV1().Deployments(namespace). + Apply(ctx, plan.Deployment(namespace, opts.ImagePullPolicy, opts.ImagePullSecret), applyOptions()); err != nil { + return applied, fmt.Errorf("apply deployment: %w", err) + } + applied = append(applied, "Deployment/"+plan.WorkloadName()) + + if !plan.HasExternalRoute() { + return applied, nil + } + // Last, after the Service it names. A controller reconciling an Ingress whose + // backend Service does not exist logs an endpoint-not-found and, for some + // controllers, caches that until the next resync — a route that starts + // working only after a delay nobody can attribute. + if _, err := client.NetworkingV1().Ingresses(namespace). + Apply(ctx, plan.Ingress(namespace), applyOptions()); err != nil { + return applied, fmt.Errorf("apply ingress: %w", err) + } + return append(applied, "Ingress/"+plan.IngressName()), nil +} + +// KubernetesOptions are the cluster-side choices a Plan does not carry. +type KubernetesOptions struct { + Namespace string + StorageClass string + ImagePullPolicy string + ImagePullSecret string + JoinToken string +} + +// KubernetesReady blocks until the Deployment reports a ready replica. +func KubernetesReady(ctx context.Context, client kubernetes.Interface, plan Plan, namespace string) error { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + for { + deployment, err := client.AppsV1().Deployments(namespace). + Get(ctx, plan.WorkloadName(), metav1.GetOptions{}) + if err == nil && deployment.Status.ReadyReplicas >= 1 { + return nil + } + select { + case <-ctx.Done(): + return fmt.Errorf("deployment %s did not become ready: %w", plan.WorkloadName(), ctx.Err()) + case <-ticker.C: + } + } +} + +// KubernetesLogs returns the sidecar pod's recent output, so a timeout says why. +func KubernetesLogs(ctx context.Context, client kubernetes.Interface, plan Plan, namespace string, lines int64) string { + pods, err := client.CoreV1().Pods(namespace).List(ctx, metav1.ListOptions{ + LabelSelector: metav1.FormatLabelSelector(&metav1.LabelSelector{MatchLabels: plan.Labels()}), + }) + if err != nil || len(pods.Items) == 0 { + return "" + } + raw, err := client.CoreV1().Pods(namespace). + GetLogs(pods.Items[0].Name, &corev1.PodLogOptions{Container: containerName, TailLines: &lines}). + DoRaw(ctx) + if err != nil { + return "" + } + return strings.TrimSpace(string(raw)) +} + +// DeleteJoinSecret removes the token once enrollment has landed, so a spent +// credential does not sit in etcd for the life of the deployment. +func DeleteJoinSecret(ctx context.Context, client kubernetes.Interface, plan Plan, namespace string) error { + err := client.CoreV1().Secrets(namespace).Delete(ctx, plan.JoinSecretName(), metav1.DeleteOptions{}) + if err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil +} + +// KubernetesRemove tears the sidecar down. The state volume goes only when +// asked: it holds the agent's private key, so deleting it is what makes the +// identity unrecoverable rather than merely stopped. +func KubernetesRemove(ctx context.Context, client kubernetes.Interface, plan Plan, namespace string, purgeVolume bool) ([]string, error) { + removed := []string{} + ignoreMissing := func(err error) error { + if err != nil && !apierrors.IsNotFound(err) { + return err + } + return nil + } + + // First, and unconditionally. First because while the route exists the + // controller keeps sending pushes to a Service whose endpoints are draining, + // and a 502 mid-teardown is harder to read than a name that has stopped + // resolving. Unconditionally because undeploy reconstructs a Plan from a name + // and a target and cannot know whether a route was rendered — deleting by the + // derived name and ignoring NotFound is the only form that covers both + // topologies. + if err := ignoreMissing(client.NetworkingV1().Ingresses(namespace). + Delete(ctx, plan.IngressName(), metav1.DeleteOptions{})); err != nil { + return removed, fmt.Errorf("delete ingress: %w", err) + } + removed = append(removed, "Ingress/"+plan.IngressName()) + + if err := ignoreMissing(client.AppsV1().Deployments(namespace). + Delete(ctx, plan.WorkloadName(), metav1.DeleteOptions{})); err != nil { + return removed, fmt.Errorf("delete deployment: %w", err) + } + removed = append(removed, "Deployment/"+plan.WorkloadName()) + + if err := ignoreMissing(client.CoreV1().Services(namespace). + Delete(ctx, plan.WorkloadName(), metav1.DeleteOptions{})); err != nil { + return removed, fmt.Errorf("delete service: %w", err) + } + removed = append(removed, "Service/"+plan.WorkloadName()) + + if err := DeleteJoinSecret(ctx, client, plan, namespace); err != nil { + return removed, fmt.Errorf("delete join secret: %w", err) + } + removed = append(removed, "Secret/"+plan.JoinSecretName()) + + if !purgeVolume { + return removed, nil + } + if err := ignoreMissing(client.CoreV1().PersistentVolumeClaims(namespace). + Delete(ctx, plan.VolumeName(), metav1.DeleteOptions{})); err != nil { + return removed, fmt.Errorf("delete state volume: %w", err) + } + removed = append(removed, "PersistentVolumeClaim/"+plan.VolumeName()) + + // The certificate goes with the state volume rather than with the route. + // Let's Encrypt rate-limits duplicate certificates to five per week, so + // deleting it on every teardown turns the sixth redeploy of the same agent + // into a certificate that cannot be issued — and that surfaces as a TLS + // failure on the supervisor's first push, not at deploy time. cert-manager + // reuses the Secret when the Ingress comes back. + // + // Only the derived name: an operator-supplied Secret may be a wildcard shared + // with every other agent on the domain. + derived := plan.WorkloadName() + "-tls" + if err := ignoreMissing(client.CoreV1().Secrets(namespace). + Delete(ctx, derived, metav1.DeleteOptions{})); err != nil { + return removed, fmt.Errorf("delete route certificate: %w", err) + } + return append(removed, "Secret/"+derived), nil +} diff --git a/pkg/gitagent/deploy/kubernetes_apply_ginkgo_test.go b/pkg/gitagent/deploy/kubernetes_apply_ginkgo_test.go new file mode 100644 index 00000000..a1a11872 --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes_apply_ginkgo_test.go @@ -0,0 +1,245 @@ +package deploy_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + authv1 "k8s.io/api/authorization/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + dynamicfake "k8s.io/client-go/dynamic/fake" + "k8s.io/client-go/kubernetes/fake" + k8stesting "k8s.io/client-go/testing" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// resourcesTouched lists the resource each recorded action addressed, in order. +func resourcesTouched(actions []k8stesting.Action, verb string) []string { + var touched []string + for _, action := range actions { + if action.GetVerb() == verb { + touched = append(touched, action.GetResource().Resource) + } + } + return touched +} + +var _ = Describe("KubernetesApply", func() { + options := deploy.KubernetesOptions{ + Namespace: testNamespace, ImagePullPolicy: "IfNotPresent", JoinToken: "cptn_x.y", + } + + // A controller reconciling an Ingress whose backend Service does not exist + // logs endpoint-not-found and, for some controllers, caches that until the + // next resync — a route that works only after a delay nobody can attribute. + It("applies the route after the Service it names", func() { + client := fake.NewClientset() + + applied, err := deploy.KubernetesApply(context.Background(), client, routedPlan(), options) + Expect(err).NotTo(HaveOccurred()) + Expect(applied).To(HaveLen(5)) + Expect(applied[len(applied)-1]).To(Equal("Ingress/captain-git-agent-worker-01")) + + touched := resourcesTouched(client.Actions(), "patch") + Expect(touched).To(Equal([]string{ + "secrets", "persistentvolumeclaims", "services", "deployments", "ingresses", + })) + }) + + It("applies no route for the in-cluster topology", func() { + client := fake.NewClientset() + + applied, err := deploy.KubernetesApply(context.Background(), client, kubernetesPlan(), options) + Expect(err).NotTo(HaveOccurred()) + Expect(applied).To(HaveLen(4)) + Expect(resourcesTouched(client.Actions(), "patch")).NotTo(ContainElement("ingresses")) + }) + + It("recreates the workload without a join secret when its enrollment is persisted on the state volume", func() { + client := fake.NewClientset() + reuse := options + reuse.JoinToken = "" + plan := kubernetesPlan() + plan.JoinPath = "" + + applied, err := deploy.KubernetesApply(context.Background(), client, plan, reuse) + Expect(err).NotTo(HaveOccurred()) + Expect(applied).To(Equal([]string{ + "PersistentVolumeClaim/captain-git-agent-worker-01-state", + "Service/captain-git-agent-worker-01", + "Deployment/captain-git-agent-worker-01", + })) + Expect(resourcesTouched(client.Actions(), "patch")).NotTo(ContainElement("secrets")) + deployment, err := client.AppsV1().Deployments(testNamespace). + Get(context.Background(), plan.WorkloadName(), metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + container := deployment.Spec.Template.Spec.Containers[0] + Expect(container.Args).NotTo(ContainElement("--token-file")) + for _, volume := range deployment.Spec.Template.Spec.Volumes { + Expect(volume.Name).NotTo(Equal("join")) + } + }) +}) + +var _ = Describe("Traefik ServersTransport lifecycle", func() { + It("applies and removes the verified transport by its derived name", func() { + client := dynamicfake.NewSimpleDynamicClientWithCustomListKinds(runtime.NewScheme(), + map[schema.GroupVersionResource]string{ + deploy.TraefikServersTransportResource: "ServersTransportList", + }) + plan := routedPlan() + plan.ExternalRoute.ClassName = "traefik" + + Expect(deploy.ApplyTraefikServersTransport( + context.Background(), client, plan, testNamespace)).To(Succeed()) + transport, err := client.Resource(deploy.TraefikServersTransportResource). + Namespace(testNamespace).Get(context.Background(), plan.WorkloadName(), metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(transport.GetLabels()).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "captain")) + + removed, err := deploy.DeleteTraefikServersTransport( + context.Background(), client, plan, testNamespace) + Expect(err).NotTo(HaveOccurred()) + Expect(removed).To(BeTrue()) + _, err = client.Resource(deploy.TraefikServersTransportResource). + Namespace(testNamespace).Get(context.Background(), plan.WorkloadName(), metav1.GetOptions{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue()) + }) +}) + +var _ = Describe("KubernetesRemove", func() { + // undeploy rebuilds a Plan from a name, a backend and a target, so it cannot + // know a route was rendered. Deleting by the derived name and ignoring + // NotFound is the only form that covers both topologies. + It("deletes the route for a plan that does not know it had one", func() { + client := fake.NewClientset() + bare := deploy.Plan{Name: "worker-01", Backend: "git-agent", Target: deploy.TargetKubernetes} + + removed, err := deploy.KubernetesRemove(context.Background(), client, bare, testNamespace, false) + Expect(err).NotTo(HaveOccurred()) + Expect(removed).To(ContainElement("Ingress/captain-git-agent-worker-01")) + deleted := resourcesTouched(client.Actions(), "delete") + Expect(deleted).NotTo(BeEmpty()) + Expect(deleted[0]).To(Equal("ingresses"), + "the route must go first, or the controller keeps sending pushes to draining endpoints") + }) + + // Let's Encrypt rate-limits duplicate certificates to five per week, so + // deleting it on every teardown turns the sixth redeploy of the same agent + // into a certificate that cannot be issued. + It("retains the route certificate unless purging", func() { + client := fake.NewClientset() + plan := routedPlan() + + removed, err := deploy.KubernetesRemove(context.Background(), client, plan, testNamespace, false) + Expect(err).NotTo(HaveOccurred()) + Expect(removed).NotTo(ContainElement("Secret/captain-git-agent-worker-01-tls")) + + purged, err := deploy.KubernetesRemove(context.Background(), client, plan, testNamespace, true) + Expect(err).NotTo(HaveOccurred()) + Expect(purged).To(ContainElement("Secret/captain-git-agent-worker-01-tls")) + }) + + // Teardown deletes what it derives, never an operator's shared wildcard — + // removing that would take every other agent on the domain offline. + It("never deletes an operator-supplied certificate", func() { + client := fake.NewClientset() + plan := routedPlan() + plan.ExternalRoute.ClusterIssuer = "" + plan.ExternalRoute.TLSSecret = "wildcard-agents" + + purged, err := deploy.KubernetesRemove(context.Background(), client, plan, testNamespace, true) + Expect(err).NotTo(HaveOccurred()) + Expect(purged).NotTo(ContainElement("Secret/wildcard-agents")) + + _, err = client.CoreV1().Secrets(testNamespace).Get( + context.Background(), "wildcard-agents", metav1.GetOptions{}) + Expect(apierrors.IsNotFound(err)).To(BeTrue(), "the fake never held it; the point is no delete was issued") + for _, action := range client.Actions() { + if action.GetVerb() == "delete" && action.GetResource().Resource == "secrets" { + deleted := action.(k8stesting.DeleteAction).GetName() + Expect(deleted).NotTo(Equal("wildcard-agents")) + } + } + }) +}) + +// reviewingClient answers every SelfSubjectAccessReview with allowed, and +// records what was asked about. The bare fake returns an error instead, which +// CheckPermissions treats as "the cluster does not serve SSAR" and skips. +func reviewingClient(allowed bool) (*fake.Clientset, *[]authv1.ResourceAttributes) { + client := fake.NewClientset() + asked := &[]authv1.ResourceAttributes{} + client.PrependReactor("create", "selfsubjectaccessreviews", + func(action k8stesting.Action) (bool, runtime.Object, error) { + review := action.(k8stesting.CreateAction).GetObject().(*authv1.SelfSubjectAccessReview) + *asked = append(*asked, *review.Spec.ResourceAttributes) + review.Status.Allowed = allowed + return true, review, nil + }) + return client, asked +} + +func askedAbout(attributes []authv1.ResourceAttributes, resource string) bool { + for _, attribute := range attributes { + if attribute.Resource == resource { + return true + } + } + return false +} + +var _ = Describe("CheckPermissions", func() { + // A namespace that can host the in-cluster topology and not the externally + // routed one is legitimate, so ingress rights are demanded only when a route + // is actually rendered — demanding them always would refuse a deploy that + // would have succeeded. + It("asks for ingress rights only when a route is rendered", func() { + withRoute, routeAsked := reviewingClient(true) + Expect(deploy.CheckPermissions(context.Background(), withRoute, testNamespace, "nginx")).To(Succeed()) + Expect(askedAbout(*routeAsked, "ingresses")).To(BeTrue()) + + without, plainAsked := reviewingClient(true) + Expect(deploy.CheckPermissions(context.Background(), without, testNamespace, "")).To(Succeed()) + Expect(askedAbout(*plainAsked, "ingresses")).To(BeFalse()) + Expect(askedAbout(*plainAsked, "deployments")).To(BeTrue()) + }) + + // Checked at DEPLOY time on purpose: teardown running with rights the deploy + // never verified is how a sidecar ends up unremovable and still routed. + It("demands delete as well as create, so teardown cannot be the surprise", func() { + client, asked := reviewingClient(true) + Expect(deploy.CheckPermissions(context.Background(), client, testNamespace, "nginx")).To(Succeed()) + + var verbs []string + for _, attribute := range *asked { + if attribute.Resource == "ingresses" { + verbs = append(verbs, attribute.Verb) + } + } + Expect(verbs).To(ConsistOf("create", "patch", "delete")) + }) + + It("checks the Traefik transport permissions only for a Traefik route", func() { + traefik, asked := reviewingClient(true) + Expect(deploy.CheckPermissions(context.Background(), traefik, testNamespace, "traefik")).To(Succeed()) + Expect(askedAbout(*asked, "serverstransports")).To(BeTrue()) + + nginx, asked := reviewingClient(true) + Expect(deploy.CheckPermissions(context.Background(), nginx, testNamespace, "nginx")).To(Succeed()) + Expect(askedAbout(*asked, "serverstransports")).To(BeFalse()) + }) + + It("names the missing ingress permissions rather than failing at apply", func() { + client, _ := reviewingClient(false) + + err := deploy.CheckPermissions(context.Background(), client, testNamespace, "nginx") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("ingresses")) + }) +}) diff --git a/pkg/gitagent/deploy/kubernetes_ingress.go b/pkg/gitagent/deploy/kubernetes_ingress.go new file mode 100644 index 00000000..e086bca0 --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes_ingress.go @@ -0,0 +1,138 @@ +// The external route: how a supervisor outside the cluster reaches a sidecar. +// +// Almost everything below exists because a git push over smart-HTTP violates +// every default a reverse proxy has. The request body is unbounded, the response +// streams a verdict the client must see WHILE the push is still open, and the +// whole exchange can take minutes because a prompt hook runs inside it. Each +// default left in place fails late rather than loudly: a small, fast task +// succeeds and a real one hangs, 413s, or loses its rejection. +package deploy + +import ( + "maps" + "strings" + + netv1 "k8s.io/api/networking/v1" + netapply "k8s.io/client-go/applyconfigurations/networking/v1" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// routePathPrefix is the transport subtree published by the Ingress. +// +// Trimmed of its trailing slash because Prefix matching is by path element, so +// "/git" and "/git/" select the same set. Taken from the transport's own +// constant rather than restated, because the client hard-codes it in every push +// URL it builds and a second copy would drift. +var routePathPrefix = strings.TrimSuffix(gitagent.GitHTTPPrefix, "/") + +const traefikServiceAnnotationPrefix = "traefik.ingress.kubernetes.io/service." + +// Ingress fronts the sidecar for a supervisor outside the cluster. +// +// Only the git transport and the authenticated runtime identity endpoint are +// routed, both unrewritten. A rule for "/" would publish every future endpoint +// the agent binary grows; and stripping the git prefix would hand receive-pack a +// path it does not serve, because HTTPSRepoURL bakes it into every push URL. +func (p Plan) Ingress(namespace string) *netapply.IngressApplyConfiguration { + backend := netapply.IngressBackend().WithService(netapply.IngressServiceBackend(). + WithName(p.WorkloadName()). + // By name, not number, so the listen port can move without the route + // silently pointing at nothing. + WithPort(netapply.ServiceBackendPort().WithName(gitPortName))) + + paths := []*netapply.HTTPIngressPathApplyConfiguration{ + netapply.HTTPIngressPath(). + WithPath(routePathPrefix). + WithPathType(netv1.PathTypePrefix). + WithBackend(backend), + netapply.HTTPIngressPath(). + WithPath(gitagent.AgentWhoamiPath). + WithPathType(netv1.PathTypeExact). + WithBackend(backend), + } + spec := netapply.IngressSpec(). + WithIngressClassName(p.ExternalRoute.ClassName). + WithTLS(netapply.IngressTLS(). + WithHosts(p.ExternalRoute.Host). + WithSecretName(p.IngressTLSSecretName())). + WithRules(netapply.IngressRule(). + WithHost(p.ExternalRoute.Host). + WithHTTP(netapply.HTTPIngressRuleValue().WithPaths(paths...))) + + return netapply.Ingress(p.IngressName(), namespace). + WithLabels(p.Labels()). + WithAnnotations(p.routeAnnotations()). + WithSpec(spec) +} + +// nginxRouteAnnotations are the ingress-nginx settings this transport cannot +// work without. Each one names the failure it prevents. +func nginxRouteAnnotations() map[string]string { + return map[string]string{ + // The pod terminates its own TLS with a captain-generated certificate, so + // the hop from the controller is re-encrypted rather than crossing the + // cluster network in clear text with a bearer token in the header. + // ingress-nginx does not verify an upstream certificate by default, which + // is what lets a self-signed pod certificate work; the trust that matters + // is the token and the certificate the supervisor validates at the edge. + "nginx.ingress.kubernetes.io/backend-protocol": "HTTPS", + + // httpserver.go's flushWriter pushes each write through so a hook + // rejection streams during the push. nginx buffers responses by default, + // which holds the verdict until receive-pack exits: the operator sees a + // push sit silent for minutes and then fail all at once, with no way to + // tell a slow hook from a hung one. + "nginx.ingress.kubernetes.io/proxy-buffering": "off", + + // The request half. With buffering on, nginx spools the entire packfile to + // disk before opening the upstream connection, so receive-pack cannot + // begin until the last byte lands — and the controller acquires a + // disk-space failure mode nothing reports. + "nginx.ingress.kubernetes.io/proxy-request-buffering": "off", + + // nginx caps a request body at 1m. A packfile routinely exceeds that, and + // git reports the resulting 413 as "the remote end hung up unexpectedly", + // which names neither the proxy nor the limit. + "nginx.ingress.kubernetes.io/proxy-body-size": "0", + + // serve.go sets only ReadHeaderTimeout on purpose: a prompt hook can take + // minutes. nginx's 60s read timeout would kill the push mid-verdict and + // report a broken connection to the agent. + "nginx.ingress.kubernetes.io/proxy-read-timeout": "3600", + "nginx.ingress.kubernetes.io/proxy-send-timeout": "3600", + } +} + +// routeAnnotations layers the operator's annotations over the defaults. +func (p Plan) routeAnnotations() map[string]string { + annotations := nginxRouteAnnotations() + if p.ExternalRoute.ClusterIssuer != "" { + annotations["cert-manager.io/cluster-issuer"] = p.ExternalRoute.ClusterIssuer + } + // Merged last so --ingress-annotation wins: raising a timeout for a slow hook + // set has to be possible, and a controller that is not ingress-nginx needs + // its own equivalents beside the inert nginx ones. + maps.Copy(annotations, p.ExternalRoute.Annotations) + for key := range annotations { + if strings.HasPrefix(key, traefikServiceAnnotationPrefix) { + delete(annotations, key) + } + } + return annotations +} + +func (p Plan) serviceAnnotations(namespace string) map[string]string { + annotations := map[string]string{} + if p.ExternalRoute.ClassName == "traefik" { + annotations["traefik.ingress.kubernetes.io/service.serversscheme"] = "https" + annotations["traefik.ingress.kubernetes.io/service.serverstransport"] = + namespace + "-" + p.WorkloadName() + "@kubernetescrd" + } + for key, value := range p.ExternalRoute.Annotations { + if strings.HasPrefix(key, traefikServiceAnnotationPrefix) { + annotations[key] = value + } + } + return annotations +} diff --git a/pkg/gitagent/deploy/kubernetes_ingress_ginkgo_test.go b/pkg/gitagent/deploy/kubernetes_ingress_ginkgo_test.go new file mode 100644 index 00000000..8ea1a92c --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes_ingress_ginkgo_test.go @@ -0,0 +1,198 @@ +package deploy_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +const routedHost = "worker-01.agents.example.com" + +// routedPlan is the externally-routed topology: a supervisor outside the +// cluster, reaching the sidecar through an ingress controller over https. +func routedPlan() deploy.Plan { + plan := kubernetesPlan() + plan.Transport = "https" + plan.Advertise = "https://" + routedHost + "/git/repo.git" + plan.ExternalRoute = deploy.ExternalRoute{ + Host: routedHost, + ClassName: "nginx", + ClusterIssuer: "letsencrypt-prod", + } + return plan +} + +func routeAnnotations(plan deploy.Plan) map[string]any { + GinkgoHelper() + annotations, ok := dig(asJSON(plan.Ingress(testNamespace)), "metadata", "annotations").(map[string]any) + Expect(ok).To(BeTrue()) + return annotations +} + +var _ = Describe("Ingress", func() { + // Each entry names the failure the setting prevents, because every one of + // them fails late — on a large or slow task, never on a small fast one. + DescribeTable("carries the settings this transport cannot work without", + func(key, want string) { + Expect(routeAnnotations(routedPlan())).To(HaveKeyWithValue(key, want)) + }, + Entry("re-encrypts to the pod's own TLS instead of crossing the cluster in clear text", + "nginx.ingress.kubernetes.io/backend-protocol", "HTTPS"), + Entry("streams the verdict instead of holding it until receive-pack exits", + "nginx.ingress.kubernetes.io/proxy-buffering", "off"), + Entry("starts receive-pack before the last byte of the packfile lands", + "nginx.ingress.kubernetes.io/proxy-request-buffering", "off"), + Entry("does not cap a packfile at nginx's 1m default", + "nginx.ingress.kubernetes.io/proxy-body-size", "0"), + Entry("outlasts a prompt hook rather than killing the push mid-verdict", + "nginx.ingress.kubernetes.io/proxy-read-timeout", "3600"), + Entry("outlasts a slow upload too", + "nginx.ingress.kubernetes.io/proxy-send-timeout", "3600"), + Entry("asks cert-manager for the certificate the supervisor will verify", + "cert-manager.io/cluster-issuer", "letsencrypt-prod"), + ) + + It("routes only the git prefix and runtime identity endpoint without rewriting", func() { + object := asJSON(routedPlan().Ingress(testNamespace)) + rules := dig(object, "spec", "rules").([]any) + Expect(rules).To(HaveLen(1)) + + rule := rules[0].(map[string]any) + Expect(rule["host"]).To(Equal(routedHost)) + paths := dig(rule["http"].(map[string]any), "paths").([]any) + Expect(paths).To(HaveLen(2)) + Expect(paths).To(ConsistOf( + HaveKeyWithValue("path", "/git"), + HaveKeyWithValue("path", "/api/v1/whoami"), + )) + for _, raw := range paths { + path := raw.(map[string]any) + if path["path"] == "/git" { + Expect(path["pathType"]).To(Equal("Prefix")) + } else { + Expect(path["pathType"]).To(Equal("Exact")) + } + } + // HTTPSRepoURL bakes /git/ into every push URL, so stripping it here + // would hand receive-pack a path it does not serve. + for key := range routeAnnotations(routedPlan()) { + Expect(key).NotTo(ContainSubstring("rewrite-target")) + } + }) + + // The pod runs the same binary that serves captain's entire API. A rule for + // "/" would publish that to the internet alongside the one endpoint the + // supervisor needs. + It("publishes no catch-all route", func() { + object := asJSON(routedPlan().Ingress(testNamespace)) + Expect(object["spec"].(map[string]any)).NotTo(HaveKey("defaultBackend")) + + rule := dig(object, "spec", "rules").([]any)[0].(map[string]any) + for _, p := range dig(rule["http"].(map[string]any), "paths").([]any) { + Expect(p.(map[string]any)["path"]).NotTo(Equal("/")) + } + }) + + It("terminates TLS for its own host into a per-agent secret", func() { + tls := dig(asJSON(routedPlan().Ingress(testNamespace)), "spec", "tls").([]any)[0].(map[string]any) + Expect(tls["hosts"]).To(ConsistOf(routedHost)) + Expect(tls["secretName"]).To(Equal("captain-git-agent-worker-01-tls")) + }) + + // An Ingress naming no class falls to whichever controller carries the + // default annotation, and the wrong controller has none of the settings above. + It("names the controller rather than relying on a default ingress class", func() { + Expect(dig(asJSON(routedPlan().Ingress(testNamespace)), "spec", "ingressClassName")).To(Equal("nginx")) + }) + + It("backs onto the Service by port name, so the listen port can move", func() { + rule := dig(asJSON(routedPlan().Ingress(testNamespace)), "spec", "rules").([]any)[0].(map[string]any) + path := dig(rule["http"].(map[string]any), "paths").([]any)[0].(map[string]any) + service := dig(path["backend"].(map[string]any), "service").(map[string]any) + + Expect(service["name"]).To(Equal("captain-git-agent-worker-01")) + port := service["port"].(map[string]any) + Expect(port["name"]).To(Equal("git")) + Expect(port).NotTo(HaveKey("number")) + }) + + // Teardown deletes the name it derives. Removing an operator's shared + // wildcard would take every other agent on that domain offline. + It("uses an operator's own certificate without asking cert-manager", func() { + plan := routedPlan() + plan.ExternalRoute.ClusterIssuer = "" + plan.ExternalRoute.TLSSecret = "wildcard-agents" + + tls := dig(asJSON(plan.Ingress(testNamespace)), "spec", "tls").([]any)[0].(map[string]any) + Expect(tls["secretName"]).To(Equal("wildcard-agents")) + Expect(routeAnnotations(plan)).NotTo(HaveKey("cert-manager.io/cluster-issuer")) + }) + + It("lets an operator override a default", func() { + plan := routedPlan() + plan.ExternalRoute.Annotations = map[string]string{ + "nginx.ingress.kubernetes.io/proxy-read-timeout": "7200", + "nginx.ingress.kubernetes.io/whitelist-source-range": "203.0.113.7/32", + } + annotations := routeAnnotations(plan) + Expect(annotations).To(HaveKeyWithValue("nginx.ingress.kubernetes.io/proxy-read-timeout", "7200")) + Expect(annotations).To(HaveKeyWithValue( + "nginx.ingress.kubernetes.io/whitelist-source-range", "203.0.113.7/32")) + // Overriding one must not drop the rest. + Expect(annotations).To(HaveKeyWithValue("nginx.ingress.kubernetes.io/proxy-buffering", "off")) + }) + + It("pins Traefik's verified backend TLS to the route certificate", func() { + plan := routedPlan() + plan.ExternalRoute.ClassName = "traefik" + + service := asJSON(plan.Service(testNamespace)) + annotations := dig(service, "metadata", "annotations").(map[string]any) + Expect(annotations).To(HaveKeyWithValue( + "traefik.ingress.kubernetes.io/service.serversscheme", "https")) + Expect(annotations).To(HaveKeyWithValue( + "traefik.ingress.kubernetes.io/service.serverstransport", + "agents-captain-git-agent-worker-01@kubernetescrd")) + Expect(routeAnnotations(plan)).NotTo(HaveKey( + "traefik.ingress.kubernetes.io/service.serversscheme")) + }) + + It("verifies Traefik's backend with the route hostname and issuing CA", func() { + plan := routedPlan() + plan.ExternalRoute.ClassName = "traefik" + + transport := asJSON(plan.TraefikServersTransport(testNamespace)) + Expect(transport).To(HaveKeyWithValue("apiVersion", "traefik.io/v1alpha1")) + Expect(transport).To(HaveKeyWithValue("kind", "ServersTransport")) + Expect(dig(transport, "metadata", "name")).To(Equal("captain-git-agent-worker-01")) + Expect(dig(transport, "metadata", "namespace")).To(Equal(testNamespace)) + Expect(dig(transport, "spec", "serverName")).To(Equal(routedHost)) + Expect(dig(transport, "spec", "rootCAs")).To(ConsistOf( + map[string]any{"secret": "captain-git-agent-worker-01-tls"})) + Expect(dig(transport, "spec")).NotTo(HaveKey("insecureSkipVerify")) + }) + + It("labels the route so teardown can find it by selector", func() { + labels := dig(asJSON(routedPlan().Ingress(testNamespace)), "metadata", "labels").(map[string]any) + Expect(labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "captain")) + Expect(labels).To(HaveKeyWithValue("app.kubernetes.io/instance", "worker-01")) + }) +}) + +// undeploy rebuilds a Plan from a name, a backend and a target and nothing else, +// so it cannot know whether a route was rendered. Deriving every route name from +// the workload name is what lets it delete one anyway. +var _ = Describe("external route naming", func() { + It("derives the route names from the workload name alone", func() { + bare := deploy.Plan{Name: "worker-01"} + Expect(bare.HasExternalRoute()).To(BeFalse()) + Expect(bare.IngressName()).To(Equal("captain-git-agent-worker-01")) + Expect(bare.IngressTLSSecretName()).To(Equal("captain-git-agent-worker-01-tls")) + }) + + It("reports a route only once a host is resolved", func() { + Expect(routedPlan().HasExternalRoute()).To(BeTrue()) + Expect(kubernetesPlan().HasExternalRoute()).To(BeFalse()) + }) +}) diff --git a/pkg/gitagent/deploy/kubernetes_objects.go b/pkg/gitagent/deploy/kubernetes_objects.go new file mode 100644 index 00000000..d6507578 --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes_objects.go @@ -0,0 +1,292 @@ +package deploy + +import ( + "fmt" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "k8s.io/apimachinery/pkg/util/intstr" + appsapply "k8s.io/client-go/applyconfigurations/apps/v1" + coreapply "k8s.io/client-go/applyconfigurations/core/v1" + metaapply "k8s.io/client-go/applyconfigurations/meta/v1" +) + +// gitPortName is the transport port on the pod, the Service's targetPort, and +// the Ingress backend's port name. +// +// One name for both topologies: it is the git transport port whichever protocol +// rides it, and a name that branched would let the Service and the Ingress +// disagree about the same port. It was "git-ssh" while ssh was the only option. +const gitPortName = "git" + +// FieldManager owns every field this package sets under server-side apply. +// Scoping ownership this way is what makes a re-deploy converge instead of +// failing on AlreadyExists, and leaves fields an operator added untouched. +const FieldManager = "captain-sandbox-git-agent" + +// containerName is the single container in the sidecar pod. +const containerName = "git-agent" + +// joinSecretKey is the key inside the join Secret. +const joinSecretKey = "join" + +// credentialsVolumeName is the pod volume carrying the redacted agent logins. +const credentialsVolumeName = "credentials" + +const ( + routeTLSVolumeName = "route-tls" + routeTLSMountPath = "/run/captain/tls" +) + +// JoinSecret carries the single-use token to the pod as a mounted file. +// +// Immutable because a token is spent once: a Secret that could be edited in +// place would invite re-seeding a pod with a token the supervisor has burned, +// which fails identically on every restart. +func (p Plan) JoinSecret(namespace, token string) *coreapply.SecretApplyConfiguration { + return coreapply.Secret(p.JoinSecretName(), namespace). + WithLabels(p.Labels()). + WithType(corev1.SecretTypeOpaque). + WithImmutable(true). + WithStringData(map[string]string{joinSecretKey: token}) +} + +// StateClaim is the volume holding everything that must outlive a restart. +// +// That is more than the keys: the agent's private key, the served repositories +// and in-flight worktrees all live under HOME, but so does ~/.captain.yaml, +// which records the supervisor's authorized dispatch key. Losing the file while +// keeping the keys leaves an agent whose host key still matches but which +// refuses every dispatch — a storage failure that reads as an auth failure. +func (p Plan) StateClaim(namespace, storageClass string) *coreapply.PersistentVolumeClaimApplyConfiguration { + resources := coreapply.VolumeResourceRequirements(). + WithRequests(corev1.ResourceList{corev1.ResourceStorage: p.Sizing.Storage}) + spec := coreapply.PersistentVolumeClaimSpec(). + WithAccessModes(corev1.ReadWriteOnce). + WithResources(resources) + if storageClass != "" { + spec = spec.WithStorageClassName(storageClass) + } + return coreapply.PersistentVolumeClaim(p.VolumeName(), namespace). + WithLabels(p.Labels()). + WithSpec(spec) +} + +// Service gives the sidecar a stable address for the supervisor to dispatch to. +// +// It is not optional. The supervisor records the agent's URL once, at +// enrollment, so a pod IP would be wrong after the first reschedule — and the +// roster would still look healthy. +func (p Plan) Service(namespace string) *coreapply.ServiceApplyConfiguration { + return coreapply.Service(p.WorkloadName(), namespace). + WithLabels(p.Labels()). + WithAnnotations(p.serviceAnnotations(namespace)). + WithSpec(coreapply.ServiceSpec(). + WithType(corev1.ServiceTypeClusterIP). + WithSelector(p.Labels()). + WithPorts(coreapply.ServicePort(). + WithName(gitPortName). + WithPort(int32(p.ListenPort)). + WithTargetPort(intstr.FromString(gitPortName)))) +} + +// Deployment runs the sidecar. +// +// Recreate, not the default RollingUpdate: the state volume is ReadWriteOnce, +// so a rollout that starts the new pod before stopping the old one deadlocks on +// the claim. Recreate also gives the at-most-one semantics a single enrolled +// identity requires — two pods sharing one agent key would race the same +// mailbox. +func (p Plan) Deployment(namespace, pullPolicy, pullSecret string) *appsapply.DeploymentApplyConfiguration { + return appsapply.Deployment(p.WorkloadName(), namespace). + WithLabels(p.Labels()). + WithSpec(appsapply.DeploymentSpec(). + WithReplicas(1). + WithStrategy(appsapply.DeploymentStrategy().WithType("Recreate")). + WithSelector(metaapply.LabelSelector().WithMatchLabels(p.Labels())). + WithTemplate(p.podTemplate(pullPolicy, pullSecret))) +} + +func (p Plan) podTemplate(pullPolicy, pullSecret string) *coreapply.PodTemplateSpecApplyConfiguration { + spec := coreapply.PodSpec(). + // The pod runs agent-authored code (R5.2). A projected service-account + // token would hand the model a cluster credential, and the sidecar has no + // reason to talk to the API server. + WithAutomountServiceAccountToken(false). + // Keeps ambient *_SERVICE_HOST variables out of the untrusted process. + WithEnableServiceLinks(false). + WithTerminationGracePeriodSeconds(30). + WithSecurityContext(p.podSecurityContext()). + WithContainers(p.container(pullPolicy)). + WithVolumes( + coreapply.Volume().WithName("state"). + WithPersistentVolumeClaim(coreapply.PersistentVolumeClaimVolumeSource(). + WithClaimName(p.VolumeName())), + coreapply.Volume().WithName("tmp"). + WithEmptyDir(coreapply.EmptyDirVolumeSource().WithSizeLimit(p.Sizing.TmpSize)), + ) + if p.JoinPath != "" { + spec = spec.WithVolumes(coreapply.Volume().WithName("join"). + WithSecret(coreapply.SecretVolumeSource(). + WithSecretName(p.JoinSecretName()). + WithDefaultMode(0o400). + WithOptional(true))) + } + if p.HasExternalRoute() { + spec = spec.WithVolumes(coreapply.Volume().WithName(routeTLSVolumeName). + WithSecret(coreapply.SecretVolumeSource(). + WithSecretName(p.IngressTLSSecretName()). + WithDefaultMode(0o400))) + } + if p.CredentialsSecret != "" { + spec = spec.WithVolumes(coreapply.Volume().WithName(credentialsVolumeName). + WithSecret(coreapply.SecretVolumeSource(). + WithSecretName(p.CredentialsSecret). + WithDefaultMode(0o400). + // Optional so the pod still starts before the supervisor's first + // publish, rather than hanging in ContainerCreating. + WithOptional(true))) + } + if pullSecret != "" { + spec = spec.WithImagePullSecrets(coreapply.LocalObjectReference().WithName(pullSecret)) + } + return coreapply.PodTemplateSpec().WithLabels(p.Labels()).WithSpec(spec) +} + +// podSecurityContext runs the workload as the image's unprivileged user. +// +// fsGroup is load-bearing rather than decorative: a freshly provisioned volume +// is root-owned, and the first thing the sidecar does is create its key +// directory at mode 0700. Without it that fails with EACCES on first start. +func (p Plan) podSecurityContext() *coreapply.PodSecurityContextApplyConfiguration { + return coreapply.PodSecurityContext(). + WithRunAsNonRoot(true). + WithRunAsUser(int64(p.Security.RunAsUser)). + WithRunAsGroup(int64(p.Security.RunAsGroup)). + WithFSGroup(int64(p.Security.RunAsGroup)). + WithFSGroupChangePolicy(corev1.FSGroupChangeOnRootMismatch). + WithSeccompProfile(coreapply.SeccompProfile().WithType(corev1.SeccompProfileTypeRuntimeDefault)) +} + +func (p Plan) container(pullPolicy string) *coreapply.ContainerApplyConfiguration { + container := coreapply.Container(). + WithName(containerName). + WithImage(p.Image). + WithImagePullPolicy(corev1.PullPolicy(pullPolicy)). + // Override the image entrypoint for the same reason docker does: it ends + // `USER root` and calls gosu, which needs the CAP_SETUID/CAP_SETGID that + // dropping all capabilities removes. Running the binary directly under + // runAsUser means the process is never root. + WithCommand("captain"). + WithArgs(p.ServeArgs()...). + WithEnv( + coreapply.EnvVar().WithName("HOME").WithValue(p.Home), + coreapply.EnvVar().WithName("TMPDIR").WithValue(p.Home+"/.cache/tmp"), + ). + WithPorts(coreapply.ContainerPort().WithName(gitPortName).WithContainerPort(int32(p.ListenPort))). + WithResources(p.resources()). + WithSecurityContext(p.containerSecurityContext()). + WithVolumeMounts( + coreapply.VolumeMount().WithName("state").WithMountPath(p.Home), + coreapply.VolumeMount().WithName("tmp").WithMountPath("/tmp"), + ). + // The receive endpoint answering TCP is the honest readiness signal: the + // join exchange runs before ListenAndServe, so an open port means + // enrollment already succeeded. + WithReadinessProbe(tcpProbe(p.ListenPort, 5, 3)). + // Deliberately lenient. A hook set can hold the process for minutes, and a + // tight liveness probe would kill a blocked push mid-verification. + WithLivenessProbe(tcpProbe(p.ListenPort, 30, 6)) + if p.JoinPath != "" { + container = container.WithVolumeMounts(coreapply.VolumeMount(). + WithName("join").WithMountPath(joinMountDir(p.JoinPath)).WithReadOnly(true)) + } + + if p.CredentialsSecret != "" { + // A directory mount, deliberately not a subPath one. Kubelet never + // updates a subPath volume after the pod starts, so a subPath mount + // would pin the workload to the first credential it ever saw and defeat + // the republish loop entirely. + container = container.WithVolumeMounts(coreapply.VolumeMount(). + WithName(credentialsVolumeName). + WithMountPath(CredentialsMountPath). + WithReadOnly(true)) + } + if p.HasExternalRoute() { + container = container.WithVolumeMounts(coreapply.VolumeMount(). + WithName(routeTLSVolumeName). + WithMountPath(routeTLSMountPath). + WithReadOnly(true)) + } + for _, secret := range p.EnvFromSecrets { + container = container.WithEnvFrom(coreapply.EnvFromSource(). + WithSecretRef(coreapply.SecretEnvSource().WithName(secret))) + } + return container +} + +func (p Plan) containerSecurityContext() *coreapply.SecurityContextApplyConfiguration { + capabilities := coreapply.Capabilities().WithDrop(corev1.Capability("ALL")) + for _, capability := range p.Security.CapAdd { + capabilities = capabilities.WithAdd(corev1.Capability(capability)) + } + return coreapply.SecurityContext(). + WithPrivileged(false). + WithAllowPrivilegeEscalation(false). + WithReadOnlyRootFilesystem(p.Security.ReadOnlyRoot). + WithCapabilities(capabilities) +} + +// resources sets both halves of memory but only the CPU request. +// +// A CPU limit throttles rather than fails: the sidecar's work is compiling and +// testing, so a ceiling turns a slow build into a timed-out one without ever +// reporting why. +func (p Plan) resources() *coreapply.ResourceRequirementsApplyConfiguration { + return coreapply.ResourceRequirements(). + WithRequests(corev1.ResourceList{ + corev1.ResourceCPU: p.Sizing.CPURequest, + corev1.ResourceMemory: p.Sizing.MemoryRequest, + corev1.ResourceEphemeralStorage: resource.MustParse("1Gi"), + }). + WithLimits(corev1.ResourceList{ + corev1.ResourceMemory: p.Sizing.MemoryLimit, + corev1.ResourceEphemeralStorage: p.Sizing.TmpSize, + }) +} + +func tcpProbe(port int, periodSeconds, failureThreshold int32) *coreapply.ProbeApplyConfiguration { + return coreapply.Probe(). + WithTCPSocket(coreapply.TCPSocketAction().WithPort(intstr.FromInt32(int32(port)))). + WithInitialDelaySeconds(5). + WithPeriodSeconds(periodSeconds). + WithFailureThreshold(failureThreshold) +} + +// joinMountDir is the directory the token file sits in; Kubernetes mounts a +// Secret as a directory, with each key a file inside it. +func joinMountDir(joinPath string) string { + if dir := joinPath[:len(joinPath)-len(joinSecretKey)]; len(dir) > 1 { + return trimTrailingSlash(dir) + } + return joinPath +} + +func trimTrailingSlash(path string) string { + for len(path) > 1 && path[len(path)-1] == '/' { + path = path[:len(path)-1] + } + return path +} + +// ValidateJoinPath ensures the mount path and the Secret key agree, so the file +// actually lands where --join-file looks for it. +func (p Plan) ValidateJoinPath() error { + if p.JoinPath == "" { + return nil + } + if joinMountDir(p.JoinPath)+"/"+joinSecretKey != p.JoinPath { + return fmt.Errorf("join path %q must end in /%s so the mounted Secret key lands there", p.JoinPath, joinSecretKey) + } + return nil +} diff --git a/pkg/gitagent/deploy/kubernetes_objects_ginkgo_test.go b/pkg/gitagent/deploy/kubernetes_objects_ginkgo_test.go new file mode 100644 index 00000000..574f4f31 --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes_objects_ginkgo_test.go @@ -0,0 +1,267 @@ +package deploy_test + +import ( + "encoding/json" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +const testNamespace = "agents" + +func kubernetesPlan() deploy.Plan { + plan := dockerPlan() + plan.Target = deploy.TargetKubernetes + plan.HostPort = 0 + plan.Advertise = "ssh://captain@captain-git-agent-worker-01.agents.svc.cluster.local:7422/repo.git" + plan.Supervisor = "ssh://captain@mailbox.internal:7422" + return plan +} + +// asJSON marshals an apply configuration the way the API server will see it. +func asJSON(object any) map[string]any { + GinkgoHelper() + raw, err := json.Marshal(object) + Expect(err).NotTo(HaveOccurred()) + var decoded map[string]any + Expect(json.Unmarshal(raw, &decoded)).To(Succeed()) + return decoded +} + +// dig walks a nested JSON object, failing the spec if a step is missing. +func dig(object map[string]any, path ...string) any { + GinkgoHelper() + var current any = object + for _, step := range path { + asMap, ok := current.(map[string]any) + Expect(ok).To(BeTrue(), "expected an object at %q in %v", step, path) + current, ok = asMap[step] + Expect(ok).To(BeTrue(), "missing %q in %v", step, path) + } + return current +} + +var _ = Describe("Kubernetes objects", func() { + plan := kubernetesPlan() + + Describe("Deployment", func() { + var object map[string]any + + BeforeEach(func() { + object = asJSON(plan.Deployment(testNamespace, "IfNotPresent", "")) + }) + + // A ReadWriteOnce claim cannot be held by two pods, so the default + // RollingUpdate would deadlock the rollout on its own volume. + It("recreates rather than rolling, and runs exactly one replica", func() { + Expect(dig(object, "spec", "strategy", "type")).To(Equal("Recreate")) + Expect(dig(object, "spec", "replicas")).To(BeEquivalentTo(1)) + }) + + // The pod runs agent-authored code; a projected token is a cluster + // credential handed to the model. + It("mounts no service-account token and no service links", func() { + Expect(dig(object, "spec", "template", "spec", "automountServiceAccountToken")).To(BeFalse()) + Expect(dig(object, "spec", "template", "spec", "enableServiceLinks")).To(BeFalse()) + }) + + It("runs as the image's unprivileged user with a seccomp profile", func() { + security := dig(object, "spec", "template", "spec", "securityContext").(map[string]any) + Expect(security["runAsNonRoot"]).To(BeTrue()) + Expect(security["runAsUser"]).To(BeEquivalentTo(501)) + Expect(security["runAsGroup"]).To(BeEquivalentTo(20)) + // Without fsGroup a freshly provisioned volume is root-owned and the + // first key write fails EACCES. + Expect(security["fsGroup"]).To(BeEquivalentTo(20)) + Expect(dig(security, "seccompProfile", "type")).To(Equal("RuntimeDefault")) + }) + + It("drops every capability and forbids escalation", func() { + security := dig(object, "spec", "template", "spec", "containers").([]any)[0].(map[string]any)["securityContext"].(map[string]any) + Expect(security["privileged"]).To(BeFalse()) + Expect(security["allowPrivilegeEscalation"]).To(BeFalse()) + Expect(security["readOnlyRootFilesystem"]).To(BeTrue()) + Expect(dig(security, "capabilities", "drop")).To(ConsistOf("ALL")) + }) + + // gosu needs CAP_SETUID/CAP_SETGID, which dropping ALL removes. + It("overrides the image entrypoint so it never starts as root", func() { + container := dig(object, "spec", "template", "spec", "containers").([]any)[0].(map[string]any) + Expect(container["command"]).To(ConsistOf("captain")) + Expect(container["args"]).To(HaveLen(len(plan.ServeArgs()))) + }) + + // A CPU ceiling throttles a build into a timeout without reporting why. + It("limits memory but not CPU", func() { + resources := dig(object, "spec", "template", "spec", "containers").([]any)[0].(map[string]any)["resources"].(map[string]any) + Expect(resources["limits"]).To(HaveKey("memory")) + Expect(resources["limits"]).NotTo(HaveKey("cpu")) + Expect(resources["requests"]).To(HaveKey("cpu")) + }) + + It("mounts state at HOME so the config file survives with the keys", func() { + mounts := dig(object, "spec", "template", "spec", "containers").([]any)[0].(map[string]any)["volumeMounts"].([]any) + paths := map[string]string{} + for _, mount := range mounts { + entry := mount.(map[string]any) + paths[entry["name"].(string)] = entry["mountPath"].(string) + } + // ~/.captain.yaml is a sibling of ~/.captain/, and it holds the + // authorized dispatch key. Mounting only the keys dir would keep the + // keys and lose the authorization. + Expect(paths["state"]).To(Equal("/home/claude")) + Expect(paths["join"]).To(Equal("/run/captain")) + Expect(paths["tmp"]).To(Equal("/tmp")) + }) + + // Deleted once enrollment lands; the pod must still restart afterwards. + It("treats the join secret as optional and read-only", func() { + for _, volume := range dig(object, "spec", "template", "spec", "volumes").([]any) { + entry := volume.(map[string]any) + if entry["name"] != "join" { + continue + } + secret := entry["secret"].(map[string]any) + Expect(secret["optional"]).To(BeTrue()) + Expect(secret["defaultMode"]).To(BeEquivalentTo(0o400)) + return + } + Fail("no join volume") + }) + + It("adds an image pull secret only when one is given", func() { + Expect(dig(object, "spec", "template", "spec")).NotTo(HaveKey("imagePullSecrets")) + + withSecret := asJSON(plan.Deployment(testNamespace, "Always", "registry-creds")) + Expect(dig(withSecret, "spec", "template", "spec", "imagePullSecrets")). + To(ConsistOf(map[string]any{"name": "registry-creds"})) + }) + + It("mounts the route certificate and serves it instead of a pod-IP certificate", func() { + deployment := asJSON(routedPlan().Deployment(testNamespace, "IfNotPresent", "")) + podSpec := dig(deployment, "spec", "template", "spec").(map[string]any) + Expect(podSpec["volumes"]).To(ContainElement(map[string]any{ + "name": "route-tls", + "secret": map[string]any{ + "secretName": "captain-git-agent-worker-01-tls", + "defaultMode": float64(0o400), + }, + })) + + container := podSpec["containers"].([]any)[0].(map[string]any) + Expect(container["volumeMounts"]).To(ContainElement(map[string]any{ + "name": "route-tls", "mountPath": "/run/captain/tls", "readOnly": true, + })) + Expect(container["args"]).To(ContainElements( + "--tls-cert", "/run/captain/tls/tls.crt", + "--tls-key", "/run/captain/tls/tls.key", + )) + }) + }) + + Describe("Service", func() { + // The supervisor records the agent URL once at enrollment, so a pod IP + // would be stale after the first reschedule. + It("fronts the pod on a stable cluster name", func() { + object := asJSON(plan.Service(testNamespace)) + Expect(dig(object, "spec", "type")).To(Equal("ClusterIP")) + Expect(dig(object, "spec", "selector")).To(Equal(toAnyMap(plan.Labels()))) + port := dig(object, "spec", "ports").([]any)[0].(map[string]any) + Expect(port["port"]).To(BeEquivalentTo(7422)) + // By name, so the Ingress backend and the container agree on one + // port even if the listen port moves. Not "git-ssh": the same port + // carries https in the externally-routed topology. + Expect(port["targetPort"]).To(Equal("git")) + }) + + // The Service's targetPort dereferences the container's port name, and the + // Ingress backend dereferences the Service's. A name that differed between + // them would route to nothing. + It("names the port identically on the container in both topologies", func() { + for _, p := range []deploy.Plan{plan, routedPlan()} { + container := dig(asJSON(p.Deployment(testNamespace, "IfNotPresent", "")), + "spec", "template", "spec", "containers").([]any)[0].(map[string]any) + ports := container["ports"].([]any)[0].(map[string]any) + Expect(ports["name"]).To(Equal("git")) + } + }) + }) + + Describe("StateClaim", func() { + It("requests the configured size, defaulting the storage class to the cluster's", func() { + object := asJSON(plan.StateClaim(testNamespace, "")) + Expect(dig(object, "spec", "accessModes")).To(ConsistOf("ReadWriteOnce")) + Expect(dig(object, "spec", "resources", "requests", "storage")).To(Equal("20Gi")) + Expect(dig(object, "spec")).NotTo(HaveKey("storageClassName")) + + withClass := asJSON(plan.StateClaim(testNamespace, "fast")) + Expect(dig(withClass, "spec", "storageClassName")).To(Equal("fast")) + }) + }) + + Describe("JoinSecret", func() { + It("is immutable, because a spent token cannot be reused", func() { + object := asJSON(plan.JoinSecret(testNamespace, "s3cret-join-token")) + Expect(object["immutable"]).To(BeTrue()) + Expect(dig(object, "stringData", "join")).To(Equal("s3cret-join-token")) + }) + }) + + // R8.2. In a pod spec the token would be readable via `kubectl get -o yaml` + // and from etcd; in env it would additionally be readable from + // /proc/1/environ by the coding agent this pod runs. + It("keeps the token out of every object except the Secret", func() { + const token = "s3cret-join-token" + for name, object := range map[string]any{ + "Deployment": plan.Deployment(testNamespace, "IfNotPresent", ""), + "Service": plan.Service(testNamespace), + "PVC": plan.StateClaim(testNamespace, ""), + } { + raw, err := json.Marshal(object) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).NotTo(ContainSubstring(token), "%s carries the join token", name) + Expect(string(raw)).NotTo(ContainSubstring("--join "), "%s passes the token in argv", name) + } + + // And no env var carries it either. + deployment := asJSON(plan.Deployment(testNamespace, "IfNotPresent", "")) + container := dig(deployment, "spec", "template", "spec", "containers").([]any)[0].(map[string]any) + for _, env := range container["env"].([]any) { + Expect(env.(map[string]any)["value"]).NotTo(ContainSubstring(token)) + } + Expect(container).NotTo(HaveKey("envFrom")) + }) + + It("exposes declared secrets through envFrom", func() { + withEnv := plan + withEnv.EnvFromSecrets = []string{"model-credentials"} + container := dig(asJSON(withEnv.Deployment(testNamespace, "IfNotPresent", "")), + "spec", "template", "spec", "containers").([]any)[0].(map[string]any) + Expect(container["envFrom"]).To(ConsistOf( + map[string]any{"secretRef": map[string]any{"name": "model-credentials"}})) + }) + + Describe("ValidateJoinPath", func() { + It("accepts a path whose basename is the secret key", func() { + Expect(plan.ValidateJoinPath()).To(Succeed()) + Expect(strings.HasSuffix(plan.JoinPath, "/join")).To(BeTrue()) + }) + + It("refuses a path the mounted key would never land on", func() { + wrong := plan + wrong.JoinPath = "/run/captain/token" + Expect(wrong.ValidateJoinPath()).To(MatchError(ContainSubstring("must end in /join"))) + }) + }) +}) + +func toAnyMap(in map[string]string) map[string]any { + out := map[string]any{} + for key, value := range in { + out[key] = value + } + return out +} diff --git a/pkg/gitagent/deploy/kubernetes_traefik.go b/pkg/gitagent/deploy/kubernetes_traefik.go new file mode 100644 index 00000000..0e69394e --- /dev/null +++ b/pkg/gitagent/deploy/kubernetes_traefik.go @@ -0,0 +1,90 @@ +package deploy + +import ( + "context" + "fmt" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/dynamic" +) + +var TraefikServersTransportResource = schema.GroupVersionResource{ + Group: "traefik.io", Version: "v1alpha1", Resource: "serverstransports", +} + +func (p Plan) UsesTraefik() bool { + return p.HasExternalRoute() && p.ExternalRoute.ClassName == "traefik" +} + +func (p Plan) TraefikServersTransport(namespace string) *unstructured.Unstructured { + labels := map[string]any{} + for key, value := range p.Labels() { + labels[key] = value + } + return &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "traefik.io/v1alpha1", + "kind": "ServersTransport", + "metadata": map[string]any{ + "name": p.WorkloadName(), + "namespace": namespace, + "labels": labels, + }, + "spec": map[string]any{ + "serverName": p.ExternalRoute.Host, + "rootCAs": []any{ + map[string]any{"secret": p.IngressTLSSecretName()}, + }, + }, + }} +} + +func ApplyTraefikServersTransport( + ctx context.Context, client dynamic.Interface, plan Plan, namespace string, +) error { + if client == nil { + return fmt.Errorf("apply Traefik ServersTransport: dynamic Kubernetes client is nil") + } + resources := client.Resource(TraefikServersTransportResource).Namespace(namespace) + desired := plan.TraefikServersTransport(namespace) + current, err := resources.Get(ctx, plan.WorkloadName(), metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + _, err = resources.Create(ctx, desired, metav1.CreateOptions{FieldManager: FieldManager}) + } else if err == nil { + desired.SetResourceVersion(current.GetResourceVersion()) + _, err = resources.Update(ctx, desired, metav1.UpdateOptions{FieldManager: FieldManager}) + } + if err != nil { + return fmt.Errorf("apply Traefik ServersTransport: %w", err) + } + return nil +} + +func DeleteTraefikServersTransport( + ctx context.Context, client dynamic.Interface, plan Plan, namespace string, +) (bool, error) { + if client == nil { + return false, fmt.Errorf("delete Traefik ServersTransport: dynamic Kubernetes client is nil") + } + resources := client.Resource(TraefikServersTransportResource).Namespace(namespace) + current, err := resources.Get(ctx, plan.WorkloadName(), metav1.GetOptions{}) + if apierrors.IsNotFound(err) { + return false, nil + } + if err != nil { + return false, fmt.Errorf("get Traefik ServersTransport: %w", err) + } + for key, want := range plan.Labels() { + if got := current.GetLabels()[key]; got != want { + return false, fmt.Errorf( + "refusing to delete Traefik ServersTransport %s: label %s is %q, want %q", + plan.WorkloadName(), key, got, want) + } + } + if err := resources.Delete(ctx, plan.WorkloadName(), metav1.DeleteOptions{}); err != nil { + return false, fmt.Errorf("delete Traefik ServersTransport: %w", err) + } + return true, nil +} diff --git a/pkg/gitagent/deploy/namespace_ginkgo_test.go b/pkg/gitagent/deploy/namespace_ginkgo_test.go new file mode 100644 index 00000000..6a46f303 --- /dev/null +++ b/pkg/gitagent/deploy/namespace_ginkgo_test.go @@ -0,0 +1,64 @@ +package deploy_test + +import ( + "context" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +var _ = Describe("EnsureNamespace", func() { + var ctx context.Context + + BeforeEach(func() { ctx = context.Background() }) + + It("accepts an existing namespace without creating anything", func() { + client := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: testNamespace}, + }) + + created, err := deploy.EnsureNamespace(ctx, client, testNamespace, false) + Expect(err).NotTo(HaveOccurred()) + Expect(created).To(BeFalse()) + }) + + // Every object the deploy applies is namespaced, so a missing namespace fails + // on the first apply with an error that names neither the cause nor the fix. + // Refusing up front is the same reason CheckPermissions runs before applying. + It("refuses a missing namespace and names the flag that would create it", func() { + client := fake.NewSimpleClientset() + + _, err := deploy.EnsureNamespace(ctx, client, "absent", false) + Expect(err).To(MatchError(ContainSubstring("--create-namespace"))) + Expect(err).To(MatchError(ContainSubstring("absent"))) + }) + + It("creates a missing namespace when asked, labelled as captain's", func() { + client := fake.NewSimpleClientset() + + created, err := deploy.EnsureNamespace(ctx, client, "agents-2", true) + Expect(err).NotTo(HaveOccurred()) + Expect(created).To(BeTrue()) + + namespace, err := client.CoreV1().Namespaces().Get(ctx, "agents-2", metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(namespace.Labels).To(HaveKeyWithValue("app.kubernetes.io/managed-by", "captain")) + }) + + // Two deploys racing, or a namespace created between the check and the + // create: the second must converge rather than fail on AlreadyExists. + It("treats a namespace that appeared concurrently as created", func() { + client := fake.NewSimpleClientset(&corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "agents-3"}, + }) + + created, err := deploy.EnsureNamespace(ctx, client, "agents-3", true) + Expect(err).NotTo(HaveOccurred()) + Expect(created).To(BeFalse()) + }) +}) diff --git a/pkg/gitagent/deploy/plan.go b/pkg/gitagent/deploy/plan.go new file mode 100644 index 00000000..1f8550a1 --- /dev/null +++ b/pkg/gitagent/deploy/plan.go @@ -0,0 +1,317 @@ +// Package deploy places a git-agent sidecar onto a container runtime. +// +// A sidecar is the machine that executes dispatched work. It runs +// `captain sandbox git-agent serve --role sidecar`, which clones the dispatched +// tree, runs a coding agent over it, and pushes the result back. Everything it +// executes is agent-authored, and the run itself is unsandboxed inside the +// workload (pkg/cli/gitagent_runtask.go pins Sandbox "none"), so the container +// or pod IS the containment boundary — there is no inner one to fall back on. +// That is why sizing and security are first-class inputs here rather than +// deployment trivia. +// +// The package is split so that everything decidable without touching a runtime +// stays pure and testable: Plan below is target-neutral, security.go holds the +// refusals, and docker.go / kubernetes*.go render and apply it. +package deploy + +import ( + "fmt" + "sort" + "strings" + + "k8s.io/apimachinery/pkg/api/resource" +) + +// sortedKeys gives map iteration a stable order, so a rendered argv or object +// set is byte-comparable against a golden value in tests. +func sortedKeys[V any](m map[string]V) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +// Target names a container runtime a sidecar can be placed on. +type Target string + +const ( + TargetDocker Target = "docker" + TargetKubernetes Target = "kubernetes" +) + +// ParseTarget validates the selector, naming the alternatives. There is +// deliberately no default and no auto-detection: on a host with both a Docker +// daemon and a kubeconfig, guessing would silently pick where an agent with +// access to the source tree ends up running. +func ParseTarget(value string) (Target, error) { + switch target := Target(strings.ToLower(strings.TrimSpace(value))); target { + case TargetDocker, TargetKubernetes: + return target, nil + case "": + return "", fmt.Errorf("--target is required; want one of: %s, %s", TargetDocker, TargetKubernetes) + default: + return "", fmt.Errorf("invalid --target %q; want one of: %s, %s", value, TargetDocker, TargetKubernetes) + } +} + +// Sizing is the resource envelope, expressed in Kubernetes quantity notation +// for both targets so there is one input language. Docker's flags take bytes +// and fractional CPUs, which Quantity converts to exactly; going the other way +// (Docker notation into a k8s manifest) does not round-trip. +type Sizing struct { + CPURequest resource.Quantity + CPULimit resource.Quantity + MemoryRequest resource.Quantity + MemoryLimit resource.Quantity + Storage resource.Quantity + TmpSize resource.Quantity + PidsLimit int +} + +// DockerCPUs renders the CPU limit for `docker run --cpus`. +func (s Sizing) DockerCPUs() string { + return fmt.Sprintf("%g", s.CPULimit.AsApproximateFloat64()) +} + +// DockerMemoryBytes renders the memory limit for `docker run --memory`. +func (s Sizing) DockerMemoryBytes() string { return fmt.Sprintf("%d", s.MemoryLimit.Value()) } + +// DockerMemoryReservationBytes renders the request for `--memory-reservation`. +// There is no docker equivalent of a CPU *request*: `--cpu-shares` is a +// relative weight under contention, not a reservation, so mapping a quantity +// onto it would report a guarantee the runtime does not make. +func (s Sizing) DockerMemoryReservationBytes() string { + return fmt.Sprintf("%d", s.MemoryRequest.Value()) +} + +// DockerTmpfsSize renders the /tmp size for `--tmpfs`. +func (s Sizing) DockerTmpfsSize() string { return fmt.Sprintf("%d", s.TmpSize.Value()) } + +// SizingRequest is the unparsed form, straight off the CLI flags. +type SizingRequest struct { + CPURequest string + CPULimit string + MemoryRequest string + MemoryLimit string + Storage string + TmpSize string + PidsLimit int +} + +// ParseSizing validates every quantity up front, naming the flag that is wrong. +// resource.ParseQuantity rejects "4GB" and "2.5.1" that a hand-rolled parser +// would accept, and a bad value discovered at apply time would already have +// burned a single-use join token. +func ParseSizing(request SizingRequest) (Sizing, error) { + sizing := Sizing{PidsLimit: request.PidsLimit} + for _, field := range []struct { + flag string + raw string + into *resource.Quantity + wanted string + }{ + {"--cpu-request", request.CPURequest, &sizing.CPURequest, "a CPU quantity such as 500m or 2"}, + {"--cpu-limit", request.CPULimit, &sizing.CPULimit, "a CPU quantity such as 500m or 2"}, + {"--memory-request", request.MemoryRequest, &sizing.MemoryRequest, "a memory quantity such as 1Gi"}, + {"--memory-limit", request.MemoryLimit, &sizing.MemoryLimit, "a memory quantity such as 4Gi"}, + {"--storage", request.Storage, &sizing.Storage, "a storage quantity such as 20Gi"}, + {"--tmp-size", request.TmpSize, &sizing.TmpSize, "a memory quantity such as 1Gi"}, + } { + quantity, err := resource.ParseQuantity(strings.TrimSpace(field.raw)) + if err != nil { + return Sizing{}, fmt.Errorf("%s %q is not %s", field.flag, field.raw, field.wanted) + } + if quantity.Sign() <= 0 { + return Sizing{}, fmt.Errorf("%s must be greater than zero, got %q", field.flag, field.raw) + } + *field.into = quantity + } + if sizing.PidsLimit < 0 { + return Sizing{}, fmt.Errorf("--pids-limit must not be negative, got %d", sizing.PidsLimit) + } + if sizing.CPULimit.Cmp(sizing.CPURequest) < 0 { + return Sizing{}, fmt.Errorf("--cpu-limit %s is below --cpu-request %s", + sizing.CPULimit.String(), sizing.CPURequest.String()) + } + if sizing.MemoryLimit.Cmp(sizing.MemoryRequest) < 0 { + return Sizing{}, fmt.Errorf("--memory-limit %s is below --memory-request %s", + sizing.MemoryLimit.String(), sizing.MemoryRequest.String()) + } + return sizing, nil +} + +// Plan is everything needed to place one sidecar, resolved and validated, with +// no runtime touched yet. Both renderers consume it; --dry-run prints it. +// +// It deliberately does NOT carry the join token. The token reaches the workload +// through a file (see JoinPath), never through this struct, so no rendering of +// a Plan can leak it into argv, a pod spec, or a log line. +type Plan struct { + Name string + Backend string + Target Target + Image string + + // Home is where the state volume mounts. It must be the image's own home + // directory for its user: a Docker named volume is initialized from image + // content including ownership, so an invented path is created root-owned and + // the unprivileged process cannot write the keys it generates on first start. + Home string + + // ListenPort is the port the sidecar serves git-receive-pack on inside the + // workload; HostPort is the docker-published port on the loopback interface. + ListenPort int + HostPort int + + // Supervisor is the address the sidecar reaches the mailbox on, Advertise the + // address the supervisor dispatches back to. Both are resolved by detection + // and always passed explicitly — left unset, the receiver derives the agent's + // address from the connection source, which for a pod or a Docker Desktop VM + // is an address the supervisor cannot route to. The agent still enrolls, so + // the failure surfaces only at first dispatch. + Supervisor string + Advertise string + HostFingerprint string + + // Transport is the protocol the sidecar's own receive endpoint speaks. It + // must agree with Advertise's scheme: the supervisor dispatches to that URL, + // and a workload serving the other protocol would accept the connection and + // fail the handshake. Empty means ssh, which is what a docker sidecar on a + // published loopback port serves. + Transport string + + // JoinPath is where the token file is mounted inside the workload. + JoinPath string + + // CredentialsSecret names an existing Secret holding the redacted agent + // logins that pkg/credsync keeps fresh. Empty leaves the workload without + // one, which is the previous behaviour. + CredentialsSecret string + // CredentialsDir is the host directory a Docker workload bind-mounts for the + // same purpose — credsync's DirectoryTarget path. + CredentialsDir string + + // ExternalRoute is set only for the externally-routed topology; see its own + // type. The zero value means no route is rendered. + ExternalRoute ExternalRoute + + Sizing Sizing + Security Security + + // EnvNames are forwarded by NAME only; values are read from the deploying + // process, so a credential never enters argv. + EnvNames []string + EnvFromSecrets []string +} + +// WorkloadName is the container / object name. The agent name is already +// constrained to a DNS label by gitagent.ValidateTaskID at enrollment, so this +// is safe as both a container name and a Kubernetes object name. +func (p Plan) WorkloadName() string { return "captain-git-agent-" + p.Name } + +// VolumeName is the docker named volume / Kubernetes PVC holding agent state. +func (p Plan) VolumeName() string { return p.WorkloadName() + "-state" } + +// JoinSecretName is the Kubernetes Secret carrying the single-use join token. +func (p Plan) JoinSecretName() string { return p.WorkloadName() + "-join" } + +// Labels identify everything this package creates, so teardown can find the +// workload by selector even if it was renamed. +func (p Plan) Labels() map[string]string { + return map[string]string{ + "app.kubernetes.io/name": "captain-git-agent", + "app.kubernetes.io/instance": p.Name, + "app.kubernetes.io/managed-by": "captain", + "captain.flanksource.com/backend": p.Backend, + } +} + +// ServeArgs is the argv the workload runs, after the entrypoint. It is +// token-free by construction: the token arrives via --token-file, because argv +// is visible in `docker inspect`, in a pod spec, and in /proc//cmdline. +func (p Plan) ServeArgs() []string { + args := []string{ + "sandbox", "git-agent", "serve", + "--role", "sidecar", + "--transport", p.serveTransport(), + "--backend", p.Backend, + "--listen", fmt.Sprintf("0.0.0.0:%d", p.ListenPort), + "--advertise", p.Advertise, + "--supervisor", p.Supervisor, + "--host-fingerprint", p.HostFingerprint, + } + if p.JoinPath != "" { + args = append(args, "--token-file", p.JoinPath) + } + if p.HasExternalRoute() { + args = append(args, + "--tls-cert", routeTLSMountPath+"/tls.crt", + "--tls-key", routeTLSMountPath+"/tls.key") + } + return args +} + +// serveTransport is the protocol the workload serves, defaulting to ssh so a +// plan that predates the field renders the argv it always did. +func (p Plan) serveTransport() string { + if p.Transport == "" { + return "ssh" + } + return p.Transport +} + +// ExternalRoute is how a supervisor OUTSIDE the cluster reaches the sidecar. +// +// The zero value means there is none, which is not a degraded mode but the +// other supported topology: a supervisor that is itself a pod dispatches to +// captain-git-agent-x.ns.svc.cluster.local over ssh, and there is nothing for an +// ingress controller to front. Rendering is kubernetes_ingress.go's job — +// nothing here knows the word "Ingress". +type ExternalRoute struct { + // Host is the fully resolved name the supervisor dials. It is resolved on + // the CLI side rather than derived here, because spec.rules[0].host and the + // advertise URL must be the same string, and computing it twice is how the + // two come to differ. + Host string + + // ClassName selects the controller. Never empty: an Ingress naming no class + // falls to whichever IngressClass carries the default annotation, and the + // wrong controller has none of the settings this transport depends on. + ClassName string + + // ClusterIssuer names the cert-manager ClusterIssuer that mints the + // certificate for Host. Mutually exclusive with TLSSecret. + ClusterIssuer string + // TLSSecret names a certificate the operator already holds, for a cluster + // with no cert-manager or a pre-issued wildcard. + TLSSecret string + + // Annotations are merged over the controller defaults, last write wins, so an + // operator can raise a timeout or state a non-nginx equivalent. + Annotations map[string]string +} + +// HasExternalRoute reports which of the two supported topologies this is. +func (p Plan) HasExternalRoute() bool { return p.ExternalRoute.Host != "" } + +// IngressName is the route object. +// +// It is WorkloadName, exactly like the Service and the Deployment, because +// undeploy reconstructs a Plan from a name, a backend and a target and nothing +// else — so deriving every route name from the workload name is what lets it +// delete the route without knowing one was ever rendered. +func (p Plan) IngressName() string { return p.WorkloadName() } + +// IngressTLSSecretName is where the certificate for Host lives. +// +// An operator-supplied Secret deliberately falls outside the derived name: +// teardown deletes what it derives, and removing a shared wildcard would take +// every other agent on that domain offline. +func (p Plan) IngressTLSSecretName() string { + if p.ExternalRoute.TLSSecret != "" { + return p.ExternalRoute.TLSSecret + } + return p.WorkloadName() + "-tls" +} diff --git a/pkg/gitagent/deploy/plan_ginkgo_test.go b/pkg/gitagent/deploy/plan_ginkgo_test.go new file mode 100644 index 00000000..efbc12b1 --- /dev/null +++ b/pkg/gitagent/deploy/plan_ginkgo_test.go @@ -0,0 +1,153 @@ +package deploy_test + +import ( + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent/deploy" +) + +// defaultSizing mirrors the command's flag defaults. +func defaultSizingRequest() deploy.SizingRequest { + return deploy.SizingRequest{ + CPURequest: "500m", + CPULimit: "2", + MemoryRequest: "1Gi", + MemoryLimit: "4Gi", + Storage: "20Gi", + TmpSize: "1Gi", + PidsLimit: 1024, + } +} + +var _ = Describe("ParseTarget", func() { + It("accepts the two runtimes and normalizes case", func() { + Expect(deploy.ParseTarget("docker")).To(Equal(deploy.TargetDocker)) + Expect(deploy.ParseTarget(" KUBERNETES ")).To(Equal(deploy.TargetKubernetes)) + }) + + It("refuses an empty target rather than defaulting", func() { + _, err := deploy.ParseTarget("") + Expect(err).To(MatchError(ContainSubstring("--target is required"))) + }) + + It("names the valid set when the target is unknown", func() { + _, err := deploy.ParseTarget("podman") + Expect(err).To(MatchError(ContainSubstring("docker, kubernetes"))) + }) +}) + +var _ = Describe("ParseSizing", func() { + It("converts k8s quantities into the units docker's flags take", func() { + sizing, err := deploy.ParseSizing(defaultSizingRequest()) + Expect(err).NotTo(HaveOccurred()) + + Expect(sizing.DockerCPUs()).To(Equal("2")) + Expect(sizing.DockerMemoryBytes()).To(Equal("4294967296")) // 4Gi + Expect(sizing.DockerMemoryReservationBytes()).To(Equal("1073741824")) // 1Gi + Expect(sizing.DockerTmpfsSize()).To(Equal("1073741824")) + }) + + It("renders a fractional CPU limit", func() { + request := defaultSizingRequest() + request.CPULimit = "500m" + request.CPURequest = "250m" + + sizing, err := deploy.ParseSizing(request) + Expect(err).NotTo(HaveOccurred()) + Expect(sizing.DockerCPUs()).To(Equal("0.5")) + }) + + DescribeTable("refuses a malformed quantity, naming the flag", + func(mutate func(*deploy.SizingRequest), wantSubstring string) { + request := defaultSizingRequest() + mutate(&request) + _, err := deploy.ParseSizing(request) + Expect(err).To(MatchError(ContainSubstring(wantSubstring))) + }, + // "4GB" is not a k8s quantity; a hand-rolled parser would accept it. + Entry("GB is not a suffix", func(r *deploy.SizingRequest) { r.MemoryLimit = "4GB" }, "--memory-limit"), + Entry("not a number", func(r *deploy.SizingRequest) { r.CPULimit = "lots" }, "--cpu-limit"), + Entry("zero storage", func(r *deploy.SizingRequest) { r.Storage = "0" }, "greater than zero"), + Entry("negative pids", func(r *deploy.SizingRequest) { r.PidsLimit = -1 }, "--pids-limit"), + ) + + It("refuses a limit below its own request", func() { + request := defaultSizingRequest() + request.MemoryLimit = "512Mi" + _, err := deploy.ParseSizing(request) + Expect(err).To(MatchError(ContainSubstring("--memory-limit 512Mi is below --memory-request 1Gi"))) + }) +}) + +var _ = Describe("Plan", func() { + plan := deploy.Plan{ + Name: "worker-01", + Backend: "git-agent", + Target: deploy.TargetDocker, + Home: "/home/claude", + ListenPort: 7422, + HostPort: 7423, + Supervisor: "ssh://captain@host.docker.internal:7422", + Advertise: "ssh://captain@127.0.0.1:7423/repo.git", + HostFingerprint: "SHA256:abc", + JoinPath: "/run/captain/join", + } + + It("derives workload names that are valid DNS labels and object names", func() { + Expect(plan.WorkloadName()).To(Equal("captain-git-agent-worker-01")) + Expect(plan.VolumeName()).To(Equal("captain-git-agent-worker-01-state")) + Expect(plan.JoinSecretName()).To(Equal("captain-git-agent-worker-01-join")) + }) + + It("labels every object so teardown can find it by selector", func() { + Expect(plan.Labels()).To(Equal(map[string]string{ + "app.kubernetes.io/name": "captain-git-agent", + "app.kubernetes.io/instance": "worker-01", + "app.kubernetes.io/managed-by": "captain", + "captain.flanksource.com/backend": "git-agent", + })) + }) + + // Both addresses must be explicit. Omitted, the receiver derives the agent's + // address from the connection source — a pod IP or a Docker Desktop VM + // address the supervisor cannot route to — and the agent enrols anyway, so + // the failure appears only at the first dispatch. + It("passes both addresses and the token file, and never the token", func() { + Expect(plan.ServeArgs()).To(Equal([]string{ + "sandbox", "git-agent", "serve", + "--role", "sidecar", + "--transport", "ssh", + "--backend", "git-agent", + "--listen", "0.0.0.0:7422", + "--advertise", "ssh://captain@127.0.0.1:7423/repo.git", + "--supervisor", "ssh://captain@host.docker.internal:7422", + "--host-fingerprint", "SHA256:abc", + "--token-file", "/run/captain/join", + })) + // argv is visible in `docker inspect`, in a pod spec, and in + // /proc//cmdline, so the credential itself must never appear there. + Expect(plan.ServeArgs()).NotTo(ContainElement("--token")) + }) + + It("starts persisted enrollment without asking for the spent join token", func() { + restart := plan + restart.JoinPath = "" + + Expect(restart.ServeArgs()).NotTo(ContainElement("--token-file")) + Expect(restart.ServeArgs()).NotTo(ContainElement("/run/captain/join")) + }) + + // The workload has to serve the protocol the supervisor was told to dispatch + // to; serving the other one accepts the connection and fails the handshake. + It("renders the transport its advertise URL implies", func() { + plan.Transport = "https" + plan.Advertise = "https://w1.example.com/git/repo.git" + Expect(plan.ServeArgs()).To(ContainElements("--transport", "https")) + // Still never the credential, whichever transport carries it. + Expect(plan.ServeArgs()).NotTo(ContainElement("--token")) + Expect(strings.Join(plan.ServeArgs(), " ")).NotTo(ContainSubstring("cptn_")) + }) +}) diff --git a/pkg/gitagent/deploy/security.go b/pkg/gitagent/deploy/security.go new file mode 100644 index 00000000..efa1fe4c --- /dev/null +++ b/pkg/gitagent/deploy/security.go @@ -0,0 +1,145 @@ +package deploy + +import ( + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/flanksource/captain/pkg/sandbox" +) + +// Security is the confinement applied to the sidecar. +// +// The posture is fixed rather than a menu, because the workload's requirements +// pin almost all of it. What is adjustable is adjustable for a stated reason: +// ReadOnlyRoot for images that write outside $HOME, and CapAdd for runtimes +// that need a capability back. There is deliberately no privileged mode and no +// mount passthrough — see RefuseUnsafe. +type Security struct { + // RunAsUser/RunAsGroup must own Home in the image. + RunAsUser int + RunAsGroup int + + // ReadOnlyRoot mounts the image root read-only. This holds only because + // every write target is relocated: config, keys and repos live on the state + // volume under Home, and scratch goes to a writable /tmp with TMPDIR steered + // onto the volume so Go and npm caches do not exhaust it. + ReadOnlyRoot bool + + // CapAdd are capabilities restored on top of an otherwise empty set. + // + // The set is empty by default, which is only possible because the workload + // overrides the image entrypoint. The published image ends `USER root` with + // an entrypoint that calls gosu to drop privileges, and gosu needs + // CAP_SETUID/CAP_SETGID — exactly what dropping everything removes. Invoking + // the binary directly and letting the runtime set the uid means the process + // never runs as root at all, which is strictly better than dropping from it. + CapAdd []string + + // Network is the docker network. Ignored for Kubernetes. + Network string +} + +// HardenedSecurity is the default posture. +func HardenedSecurity() Security { + return Security{ + RunAsUser: 501, // the image's `claude` user (pkg/container/base/Dockerfile) + RunAsGroup: 20, + ReadOnlyRoot: true, + Network: "bridge", + } +} + +// Describe renders the posture for the command's result and for --dry-run. +func (s Security) Describe() string { + parts := []string{ + fmt.Sprintf("uid=%d:%d", s.RunAsUser, s.RunAsGroup), + "caps=none", + "no-new-privileges", + "seccomp=default", + } + if len(s.CapAdd) > 0 { + parts[1] = "caps=+" + strings.Join(s.CapAdd, ",") + } + if s.ReadOnlyRoot { + parts = append(parts, "read-only-root") + } + return strings.Join(parts, " ") +} + +// unsafeNetworks are docker network modes that dissolve the boundary the +// container is supposed to be. `host` puts the workload on the host's network +// namespace, where the mailbox's own loopback listener becomes reachable; +// `none` removes the inbound dispatch path and the outbound relay, so the +// sidecar cannot work at all. +var unsafeNetworks = map[string]string{ + "host": "shares the host network namespace, exposing loopback-bound services to agent-authored code", + "none": "removes the dispatch, relay and model-API paths the sidecar needs to function", +} + +// RefuseUnsafe rejects a configuration that would make the container boundary +// decorative. It refuses rather than filters, following the precedent set for +// untrusted container config: silently granting less than was asked for still +// grants something the operator never reviewed. +// +// home is the sandboxed user's home inside the workload, for the rootless +// Docker socket path. +func RefuseUnsafe(security Security, home string, mounts []string, presets []string) error { + if security.RunAsUser == 0 { + return fmt.Errorf("--run-as-user 0 runs agent-authored code as root inside the workload; pick the image's unprivileged uid") + } + if reason, unsafe := unsafeNetworks[strings.ToLower(strings.TrimSpace(security.Network))]; unsafe { + return fmt.Errorf("--network %s %s", security.Network, reason) + } + if err := refuseRuntimeSockets(home, mounts); err != nil { + return err + } + return refuseSocketPresets(presets) +} + +// refuseRuntimeSockets blocks any mount reaching a container-runtime endpoint. +// A process that can talk to the daemon can start a privileged container +// bind-mounting the host root, so this is a full escape and R5.3 makes it +// non-waivable. Comparison is on the cleaned source path, and the deny list is +// shared with the SRT adapter so the two cannot drift. +func refuseRuntimeSockets(home string, mounts []string) error { + denied := map[string]struct{}{} + for _, socket := range sandbox.ContainerRuntimeSockets(home) { + denied[filepath.Clean(socket)] = struct{}{} + } + for _, mount := range mounts { + source := filepath.Clean(strings.TrimSpace(strings.SplitN(mount, ":", 2)[0])) + if _, blocked := denied[source]; blocked { + return fmt.Errorf( + "refusing to mount the container runtime socket %s into a git-agent sidecar: "+ + "it is a full host escape and makes every other control here decorative (R5.3, A6.2)", source) + } + } + return nil +} + +// socketPresets expand into a real socket bind mount, so selecting one is the +// same escape by another name. `claude` additionally sets +// enableWeakerNetworkIsolation, which R5.3 forbids in the same sentence. +var socketPresets = []string{"claude", "docker"} + +func refuseSocketPresets(presets []string) error { + selected := map[string]struct{}{} + for _, preset := range presets { + selected[strings.ToLower(strings.TrimSpace(preset))] = struct{}{} + } + var found []string + for _, preset := range socketPresets { + if _, ok := selected[preset]; ok { + found = append(found, preset) + } + } + if len(found) == 0 { + return nil + } + sort.Strings(found) + return fmt.Errorf( + "sandbox preset %s grants the container runtime socket, which a git-agent sidecar must never hold (R5.3, A6.2); "+ + "remove it from the backend before deploying", strings.Join(found, " and ")) +} diff --git a/pkg/gitagent/deploy/security_ginkgo_test.go b/pkg/gitagent/deploy/security_ginkgo_test.go new file mode 100644 index 00000000..4bc1815f --- /dev/null +++ b/pkg/gitagent/deploy/security_ginkgo_test.go @@ -0,0 +1,95 @@ +package deploy_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent/deploy" + "github.com/flanksource/captain/pkg/sandbox" +) + +const workloadHome = "/home/claude" + +var _ = Describe("HardenedSecurity", func() { + It("runs unprivileged with no capabilities and a read-only root", func() { + hardened := deploy.HardenedSecurity() + Expect(hardened.RunAsUser).NotTo(BeZero()) + Expect(hardened.CapAdd).To(BeEmpty()) + Expect(hardened.ReadOnlyRoot).To(BeTrue()) + }) + + It("describes the posture for the operator", func() { + Expect(deploy.HardenedSecurity().Describe()).To(SatisfyAll( + ContainSubstring("uid=501:20"), + ContainSubstring("caps=none"), + ContainSubstring("no-new-privileges"), + ContainSubstring("read-only-root"), + )) + }) +}) + +var _ = Describe("RefuseUnsafe", func() { + It("accepts the hardened default", func() { + Expect(deploy.RefuseUnsafe(deploy.HardenedSecurity(), workloadHome, nil, nil)).To(Succeed()) + }) + + // A process that can reach the daemon can start a privileged container + // bind-mounting the host root, so this is a full escape (R5.3, A6.2). The + // list is shared with the SRT adapter, so this table cannot drift from it. + It("refuses every container-runtime socket the deny list names", func() { + sockets := sandbox.ContainerRuntimeSockets(workloadHome) + Expect(sockets).NotTo(BeEmpty()) + + for _, socket := range sockets { + mount := socket + ":" + socket + err := deploy.RefuseUnsafe(deploy.HardenedSecurity(), workloadHome, []string{mount}, nil) + Expect(err).To(MatchError(ContainSubstring("full host escape")), "socket %s was allowed", socket) + } + }) + + It("refuses a socket mount written with a non-canonical path", func() { + err := deploy.RefuseUnsafe(deploy.HardenedSecurity(), workloadHome, + []string{"/var/run/../run/docker.sock:/var/run/docker.sock"}, nil) + Expect(err).To(MatchError(ContainSubstring("full host escape"))) + }) + + It("allows an ordinary mount", func() { + Expect(deploy.RefuseUnsafe(deploy.HardenedSecurity(), workloadHome, + []string{"/srv/cache:/cache:ro"}, nil)).To(Succeed()) + }) + + // These presets expand into a real socket bind mount, so selecting one is + // the same escape by another name. + DescribeTable("refuses a preset that grants the runtime socket", + func(preset string) { + err := deploy.RefuseUnsafe(deploy.HardenedSecurity(), workloadHome, nil, []string{preset}) + Expect(err).To(MatchError(ContainSubstring("container runtime socket"))) + }, + Entry("claude", "claude"), + Entry("docker", "docker"), + Entry("case-insensitively", "Docker"), + ) + + It("allows presets that grant no socket", func() { + Expect(deploy.RefuseUnsafe(deploy.HardenedSecurity(), workloadHome, nil, + []string{"golang", "git"})).To(Succeed()) + }) + + It("refuses running agent-authored code as root", func() { + asRoot := deploy.HardenedSecurity() + asRoot.RunAsUser = 0 + Expect(deploy.RefuseUnsafe(asRoot, workloadHome, nil, nil)). + To(MatchError(ContainSubstring("--run-as-user 0"))) + }) + + DescribeTable("refuses a network mode that dissolves the boundary", + func(network, wantSubstring string) { + security := deploy.HardenedSecurity() + security.Network = network + Expect(deploy.RefuseUnsafe(security, workloadHome, nil, nil)). + To(MatchError(ContainSubstring(wantSubstring))) + }, + Entry("host exposes loopback services", "host", "host network namespace"), + Entry("none breaks dispatch and relay", "none", "dispatch, relay"), + ) +}) diff --git a/pkg/gitagent/dispatch.go b/pkg/gitagent/dispatch.go index 91c8cf9b..364459c4 100644 --- a/pkg/gitagent/dispatch.go +++ b/pkg/gitagent/dispatch.go @@ -15,6 +15,7 @@ import ( "time" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/clicky/text" ) // TaskPayload is task.json: what the agent is asked to do. It is materialized @@ -64,14 +65,28 @@ type DispatchRequest struct { MailboxRoute string // opaque path under the supervisor's served root Task string // generated when empty Agent string - SidecarURL string // ssh://host:port/repo.git + SidecarURL string // ssh://host:port/repo.git or https://host:port/git/repo.git SidecarHostFP string KeyPath string SSHCommand string // GIT_SSH_COMMAND; "" ⇒ this binary's git-agent ssh transport - Relay RelayMode - Policy Policy - TaskPayload TaskPayload - HooksJSON []byte // pre-serialized hooks.json (may be nil) + // Token, CAPath and PinnedPublicKey apply when SidecarURL is https://. + Token text.SensitiveString + CAPath string + PinnedPublicKey string + Relay RelayMode + Policy Policy + TaskPayload TaskPayload + HooksJSON []byte // pre-serialized hooks.json (may be nil) +} + +// Transport describes how to reach the sidecar, for whichever scheme its URL +// names. +func (r DispatchRequest) Transport() TransportTarget { + return TransportTarget{ + URL: r.SidecarURL, SSHCommand: r.SSHCommand, KeyPath: r.KeyPath, + HostFingerprint: r.SidecarHostFP, + Token: r.Token, CAPath: r.CAPath, PinnedPublicKey: r.PinnedPublicKey, + } } // DispatchResult reports the pushed hand-off. @@ -237,35 +252,19 @@ func pushDispatch(ctx context.Context, req DispatchRequest, task string, snapsho snapshot.Commit+":"+dispatchRef, control+":"+controlRef, ) - pairs, err := transportPairs(req.SSHCommand, req.KeyPath, req.SidecarHostFP) + env, err := TransportEnv(ScrubGitEnv(os.Environ()), req.Transport()) if err != nil { return err } - env := envWith(ScrubGitEnv(os.Environ()), pairs...) if _, err := runGit(ctx, req.RepoDir, env, args...); err != nil { return fmt.Errorf("dispatch push: %w", err) } return nil } -// transportPairs builds the env for a push riding captain's GIT_SSH_COMMAND -// transport: no system ssh, key from a captain-managed path, host key pinned -// by fingerprint. An empty sshCommand means this binary's own transport. -func transportPairs(sshCommand, keyPath, hostFingerprint string) ([]string, error) { - if sshCommand == "" { - exe, err := os.Executable() - if err != nil { - return nil, err - } - sshCommand = SSHTransportCommand(exe) - } - return []string{ - "GIT_SSH_COMMAND=" + sshCommand, - "GIT_SSH_VARIANT=ssh", // an unrecognized command defaults to "simple", which cannot pass -p - EnvSSHKey + "=" + keyPath, - EnvSSHHostFingerprint + "=" + hostFingerprint, - }, nil -} +// executablePath is indirected so the SSH transport's default command can be +// resolved without every caller reaching for os.Executable. +var executablePath = os.Executable // SSHTransportCommand renders this binary's SSH transport as a shell-safe // GIT_SSH_COMMAND value. Git evaluates the value through a shell. diff --git a/pkg/gitagent/dispatchtoken.go b/pkg/gitagent/dispatchtoken.go new file mode 100644 index 00000000..d81d6103 --- /dev/null +++ b/pkg/gitagent/dispatchtoken.go @@ -0,0 +1,115 @@ +// The credential a sidecar issues to its supervisor. +// +// Over ssh the supervisor authenticates by public key, and the sidecar +// authorizes it by recording the fingerprint the enrollment handed back. Over +// https there is no key exchange to authenticate anyone, so the same trust has +// to be carried by a bearer token — and it points the same way: the sidecar is +// the party that will verify it, so the sidecar is the party that mints it. + +package gitagent + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/clicky/text" +) + +// DispatchCredentialName is the verifier a sidecar keeps beside its keys. +const DispatchCredentialName = "dispatch_token.json" + +// DispatchCredential is the verifier half of the bearer token a sidecar issues +// to its supervisor at enrollment. +// +// Only the argon2id hash is stored. The plaintext exists exactly once, in the +// enrollment request, and nothing on this host can reconstruct it — the same +// guarantee the supervisor's own token store makes about the tokens it mints. +type DispatchCredential struct { + TokenID string `json:"tokenId"` + SecretHash string `json:"secretHash"` + IssuedAt time.Time `json:"issuedAt"` +} + +// MintDispatchCredential issues the token, persists the verifier at path, and +// returns the plaintext for the caller to hand over. +// +// The verifier is written BEFORE the caller may speak, so this host never names +// a credential it has not already committed to accepting. The mirrored order +// would leave a supervisor holding a token this endpoint rejects, surfacing as a +// 403 on the first dispatch rather than here. +func MintDispatchCredential(path string) (text.SensitiveString, error) { + minted, err := captaintoken.Mint() + if err != nil { + return "", err + } + credential := DispatchCredential{ + TokenID: minted.ID, + SecretHash: minted.Hash, + IssuedAt: time.Now().UTC(), + } + encoded, err := json.Marshal(credential) + if err != nil { + return "", err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return "", err + } + if err := writeFileAtomic(path, append(encoded, '\n'), 0o600); err != nil { + return "", fmt.Errorf("persist the dispatch credential at %s: %w", path, err) + } + return minted.Secret, nil +} + +// LoadDispatchCredential reads the verifier. +// +// A missing or unreadable file is an error naming the path, never an empty +// credential: an endpoint that verified nothing would accept every push. +func LoadDispatchCredential(path string) (*DispatchCredential, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read the dispatch credential %s: %w", path, err) + } + var credential DispatchCredential + if err := json.Unmarshal(data, &credential); err != nil { + return nil, fmt.Errorf("the dispatch credential %s is not readable JSON: %w", path, err) + } + if credential.TokenID == "" || credential.SecretHash == "" { + return nil, fmt.Errorf("the dispatch credential %s names no token, so nothing could be verified against it", path) + } + return &credential, nil +} + +// Verifier resolves a presented bearer against this credential, as the agent. +// +// It reuses the supervisor's own credential machinery over a single in-memory +// record — nothing in captaintoken.Verifier is database-bound. That buys three +// things a hand-rolled compare would not: the public id is matched first, so a +// flood of random credentials against an endpoint now reachable from outside the +// cluster costs a string compare rather than 19 MiB and ~60ms of argon2 apiece; +// the KDF cache means the several HTTP requests git makes for one push pay it +// once; and the secret compare is the same constant-time path the supervisor +// uses. +// +// No expiry is set. This credential is reissued on every enrollment, which is +// every restart of the workload, so an expiry could only brick a pod that had +// been running longer than someone guessed it would. +func (c *DispatchCredential) Verifier(agent string) *captaintoken.Verifier { + record := captaintoken.Record{ + ID: c.TokenID, + SecretHash: c.SecretHash, + Name: agent, + Agent: agent, + Scope: captaintoken.ScopeGit, + } + return captaintoken.NewVerifier(func(_ context.Context, id string) (captaintoken.Record, error) { + if id != c.TokenID { + return captaintoken.Record{}, captaintoken.ErrUnknown + } + return record, nil + }) +} diff --git a/pkg/gitagent/dispatchtoken_ginkgo_test.go b/pkg/gitagent/dispatchtoken_ginkgo_test.go new file mode 100644 index 00000000..e710e313 --- /dev/null +++ b/pkg/gitagent/dispatchtoken_ginkgo_test.go @@ -0,0 +1,173 @@ +package gitagent_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +var _ = Describe("DispatchCredential", func() { + var path string + var secret text.SensitiveString + + BeforeEach(func() { + path = filepath.Join(GinkgoT().TempDir(), "keys", gitagent.DispatchCredentialName) + var err error + secret, err = gitagent.MintDispatchCredential(path) + Expect(err).NotTo(HaveOccurred()) + }) + + It("hands back a credential the supervisor can present", func() { + presented, err := captaintoken.Parse(secret.Value()) + Expect(err).NotTo(HaveOccurred()) + Expect(presented.ID).NotTo(BeEmpty()) + }) + + It("keeps the plaintext out of the file it wrote", func() { + raw, err := os.ReadFile(path) + Expect(err).NotTo(HaveOccurred()) + Expect(string(raw)).NotTo(ContainSubstring(secret.Value())) + + // The secret half alone must not survive either — storing it under a + // different key would defeat the whole point of hashing it. + _, secretHalf, found := strings.Cut(secret.Value(), ".") + Expect(found).To(BeTrue()) + Expect(string(raw)).NotTo(ContainSubstring(secretHalf)) + }) + + It("stores the verifier readable only by its owner", func() { + info, err := os.Stat(path) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + + parent, err := os.Stat(filepath.Dir(path)) + Expect(err).NotTo(HaveOccurred()) + Expect(parent.Mode().Perm()).To(Equal(os.FileMode(0o700))) + }) + + Describe("Verifier", func() { + var verifier *captaintoken.Verifier + + BeforeEach(func() { + credential, err := gitagent.LoadDispatchCredential(path) + Expect(err).NotTo(HaveOccurred()) + verifier = credential.Verifier("supervisor") + }) + + It("admits the minted secret as the supervisor", func() { + record, err := verifier.VerifyScope(context.Background(), secret.Value(), captaintoken.ScopeGit) + Expect(err).NotTo(HaveOccurred()) + Expect(record.Agent).To(Equal("supervisor")) + }) + + It("refuses another agent's credential", func() { + other, err := gitagent.MintDispatchCredential(filepath.Join(GinkgoT().TempDir(), "other.json")) + Expect(err).NotTo(HaveOccurred()) + + _, err = verifier.Verify(context.Background(), other.Value()) + Expect(err).To(MatchError(captaintoken.ErrUnknown)) + }) + + It("refuses a credential that is not a captain token at all", func() { + _, err := verifier.Verify(context.Background(), "not-a-token") + Expect(err).To(HaveOccurred()) + }) + + // The id is matched first for cost, so a right-id/wrong-secret pair is + // the case that proves the KDF check is not being skipped. + It("refuses the right id with the wrong secret", func() { + id, _, _ := strings.Cut(strings.TrimPrefix(secret.Value(), captaintoken.Prefix+"_"), ".") + forged := captaintoken.Prefix + "_" + id + ".TvW4gNfLpQ2rXsYbCdEfGhJkLmNoPqRsTuVwXyZ012A" + + _, err := verifier.Verify(context.Background(), forged) + Expect(err).To(MatchError(captaintoken.ErrUnknown)) + }) + + // The credential is git-scoped, so it cannot be replayed against the + // /api/v1 executor even though the same verifier would recognize it. + It("refuses the api scope", func() { + _, err := verifier.VerifyScope(context.Background(), secret.Value(), captaintoken.ScopeAPI) + Expect(err).To(MatchError(captaintoken.ErrScope)) + }) + }) + + // Enrollment re-runs on every restart of the workload, so rotation has to be + // a replacement. Leaving the previous secret working would be exactly the + // fallback path CW-3 forbids. + It("replaces the previous credential rather than adding to it", func() { + rotated, err := gitagent.MintDispatchCredential(path) + Expect(err).NotTo(HaveOccurred()) + + credential, err := gitagent.LoadDispatchCredential(path) + Expect(err).NotTo(HaveOccurred()) + verifier := credential.Verifier("supervisor") + + _, err = verifier.Verify(context.Background(), rotated.Value()) + Expect(err).NotTo(HaveOccurred()) + _, err = verifier.Verify(context.Background(), secret.Value()) + Expect(err).To(MatchError(captaintoken.ErrUnknown)) + }) + + Describe("LoadDispatchCredential", func() { + It("names the path it could not read", func() { + missing := filepath.Join(GinkgoT().TempDir(), "absent.json") + _, err := gitagent.LoadDispatchCredential(missing) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring(missing)) + }) + + It("refuses a truncated file rather than verifying nothing", func() { + truncated := filepath.Join(GinkgoT().TempDir(), "truncated.json") + Expect(os.WriteFile(truncated, []byte(`{"tokenId":"abc"`), 0o600)).To(Succeed()) + + _, err := gitagent.LoadDispatchCredential(truncated) + Expect(err).To(HaveOccurred()) + }) + + // A credential with no hash would make every presented secret verify + // against an empty string, so it is refused at load rather than at use. + It("refuses a credential that names no token", func() { + empty := filepath.Join(GinkgoT().TempDir(), "empty.json") + Expect(os.WriteFile(empty, []byte(`{}`), 0o600)).To(Succeed()) + + _, err := gitagent.LoadDispatchCredential(empty) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("nothing could be verified")) + }) + }) +}) + +// The enrollment field carrying this secret must be a plain string. +// text.SensitiveString marshals to "[REDACTED]" and has no UnmarshalJSON, so +// typing the field for redaction would transmit that literal and the supervisor +// would record a credential the agent rejects at first dispatch. +var _ = Describe("EnrollRequest.DispatchToken", func() { + const secret = "cptn_aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + + It("round-trips the secret intact", func() { + encoded, err := json.Marshal(gitagent.EnrollRequest{DispatchToken: secret}) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).To(ContainSubstring(secret)) + + var decoded gitagent.EnrollRequest + Expect(json.Unmarshal(encoded, &decoded)).To(Succeed()) + Expect(decoded.DispatchToken).To(Equal(secret)) + }) + + It("would be redacted on the wire if it were a SensitiveString", func() { + encoded, err := json.Marshal(struct { + Token text.SensitiveString `json:"token"` + }{Token: text.NewSensitiveString(secret)}) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).NotTo(ContainSubstring(secret)) + }) +}) diff --git a/pkg/gitagent/enroll.go b/pkg/gitagent/enroll.go index 2362da4e..7a9f21fa 100644 --- a/pkg/gitagent/enroll.go +++ b/pkg/gitagent/enroll.go @@ -1,6 +1,13 @@ -// Enrollment (§8): a single-use, short-TTL join token authorizes exactly one -// key registration and is then burned. The private key never leaves the agent -// host (R8.2). +// Enrollment (§8): a captain token authorizes a key registration. The private +// key never leaves the agent host (R8.2). +// +// The token is durable rather than single-use (R8.2, amended). A burned token +// meant a long-lived sidecar — a container with --restart, a Deployment +// rescheduling — replayed a spent credential on every restart and crash-looped +// from the second start onward, which had to be worked around wherever +// enrollment could re-run. Bounding a credential by expiry and revocation +// instead of by one use removes that whole class of failure, and is what lets +// one token serve a scaled pool. // // The exchange is bidirectional because trust is: the supervisor dispatches TO // the sidecar and the sidecar relays TO the mailbox, so each side must learn @@ -10,10 +17,7 @@ package gitagent import ( "context" - "crypto/rand" - "crypto/sha256" "encoding/base64" - "encoding/hex" "encoding/json" "errors" "fmt" @@ -25,11 +29,13 @@ import ( gossh "golang.org/x/crypto/ssh" ) -// JoinTokenTTL bounds how long a minted token stays redeemable. -const JoinTokenTTL = 15 * time.Minute - // EnrollRequest is what a joining agent tells the supervisor about itself. type EnrollRequest struct { + // Agent is the name a returning member persisted from an earlier + // enrollment. It lets a pool member reclaim its slot across a restart + // instead of consuming another; the supervisor honours it only when it is + // already on file, so a client cannot invent an identity. + Agent string `json:"agent,omitempty"` // AdvertiseURL is the sidecar endpoint the supervisor should dispatch to. // When empty the supervisor derives it from the connection's source // address and ListenPort, which is right on a flat network and wrong @@ -38,8 +44,20 @@ type EnrollRequest struct { // ListenPort is the port the agent's own receive endpoint listens on. ListenPort string `json:"listenPort,omitempty"` // HostFingerprint is the agent endpoint's host key, for the supervisor to - // pin when it dispatches. + // pin when it dispatches. Set only for an ssh:// AdvertiseURL; an https + // endpoint has no host key and carries DispatchToken instead. HostFingerprint string `json:"hostFingerprint"` + // DispatchToken is a bearer credential this agent minted for the supervisor + // to present on every dispatch push. It is the https sibling of + // HostFingerprint: over ssh the supervisor is authenticated by the key + // exchange, and over https there is no exchange to authenticate it with. + // + // A plain string, not text.SensitiveString: that type's MarshalJSON emits + // "[REDACTED]" and it has no UnmarshalJSON, so the field would arrive as + // that literal and the supervisor would record a credential this agent + // rejects. Redaction belongs where the value is held — see + // DispatchRequest.Token — not where it is transmitted. + DispatchToken string `json:"dispatchToken,omitempty"` } // EnrollResponse is what the supervisor hands back so the agent can complete @@ -49,45 +67,59 @@ type EnrollResponse struct { // DispatchKey is the supervisor's client-key fingerprint. The agent // authorizes it locally so the supervisor's dispatch push is accepted. DispatchKey string `json:"dispatchKey"` + // CACertificate is the supervisor's TLS certificate, PEM-encoded, handed + // over the already-authenticated exchange so the agent's relays verify + // against the endpoint it joined rather than the system trust store. Empty + // for a supervisor that serves no HTTPS. + CACertificate string `json:"caCertificate,omitempty"` + // PinnedPublicKey is that certificate's sha256// pin, which survives a + // re-issue under the same key. + PinnedPublicKey string `json:"pinnedPubkey,omitempty"` } // EnrollmentOffer is the supervisor-side half of the exchange, supplied to // the server by whatever runs it. type EnrollmentOffer struct { - DispatchKey string + DispatchKey string + CACertificate string + PinnedPublicKey string +} + +// ResponseFor renders the offer for one admitted agent, so both transports +// hand back the same thing. +func (o EnrollmentOffer) ResponseFor(agent string) EnrollResponse { + return EnrollResponse{ + Agent: agent, DispatchKey: o.DispatchKey, + CACertificate: o.CACertificate, PinnedPublicKey: o.PinnedPublicKey, + } } // AgentEnrollment is one recorded agent: its key, its endpoint, and the host // key to pin when dispatching there. type AgentEnrollment struct { - Name string - Fingerprint string - URL string + Name string + Fingerprint string + URL string + // HostFingerprint and DispatchToken are the two ways a supervisor proves + // itself to this agent, and which one applies is decided by URL's scheme: + // an ssh endpoint is pinned by host key, an https one authenticates with the + // bearer token the agent minted. Exactly one is ever set. HostFingerprint string + DispatchToken string } -// MintJoinToken returns a fresh token and its storage hash. Only the hash is -// persisted, so a leaked config file does not leak redeemable tokens. -func MintJoinToken() (token, hash string, err error) { - raw := make([]byte, 32) - if _, err := rand.Read(raw); err != nil { - return "", "", err - } - token = base64.RawURLEncoding.EncodeToString(raw) - return token, HashJoinToken(token), nil -} - -// HashJoinToken maps a presented token onto its storage hash. -func HashJoinToken(token string) string { - sum := sha256.Sum256([]byte(token)) - return hex.EncodeToString(sum[:]) -} - -// Enroll dials the supervisor endpoint, presents the join token along with -// this agent's endpoint details, and returns what the supervisor offered -// back. The host key is verified against the fingerprint printed by -// `git-agent add` — never trusted on first use. +// Enroll reaches the supervisor endpoint, presents the captain token along +// with this agent's endpoint details, and returns what the supervisor offered +// back. The supervisor's identity is verified against the fingerprint printed +// by `git-agent add` — never trusted on first use — which for ssh:// is its +// host key and for https:// is its certificate pin. +// +// The endpoint's scheme selects the channel. Both carry the same exchange, so +// everything downstream of the response is identical. func Enroll(ctx context.Context, endpoint, token, hostFingerprint string, signer gossh.Signer, req EnrollRequest) (*EnrollResponse, error) { + if EndpointScheme(endpoint) == "https" { + return enrollHTTPS(ctx, endpoint, token, hostFingerprint, req) + } hostFingerprint = strings.TrimSpace(hostFingerprint) if hostFingerprint == "" { return nil, fmt.Errorf("enrollment requires the supervisor's host-key fingerprint (printed by `captain sandbox git-agent add`)") @@ -166,6 +198,9 @@ func MailboxURL(endpoint, mailboxPath string) (string, error) { if err := ValidateMailboxRoute(mailboxPath); err != nil { return "", err } + if EndpointScheme(endpoint) == "https" { + return HTTPSRepoURL(endpoint, mailboxPath) + } addr, user, err := splitSSHEndpoint(endpoint) if err != nil { return "", err diff --git a/pkg/gitagent/enrollhttps.go b/pkg/gitagent/enrollhttps.go new file mode 100644 index 00000000..eb39582b --- /dev/null +++ b/pkg/gitagent/enrollhttps.go @@ -0,0 +1,99 @@ +// Enrollment over HTTPS (§8). Same exchange as the SSH one, same response — +// only the channel and how the supervisor's identity is proven differ. + +package gitagent + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// enrollHTTPS presents a captain token to an HTTPS supervisor and returns what +// it offers back, including the certificate the agent's later relays verify +// against. +// +// Trust is pinned, never taken on first use: pin is the sha256// public-key pin +// printed by `git-agent add`, and it is the HTTPS counterpart of the SSH host +// fingerprint. A supervisor that presents a different certificate is refused +// before the token is sent, so a credential is never handed to an impostor. +func enrollHTTPS(ctx context.Context, endpoint, token, pin string, req EnrollRequest) (*EnrollResponse, error) { + pin = strings.TrimSpace(pin) + if pin == "" { + return nil, fmt.Errorf("enrollment requires the supervisor's certificate pin (printed by `captain sandbox git-agent add`)") + } + url, err := HTTPSRepoURL(endpoint, EnrollEndpoint) + if err != nil { + return nil, err + } + body, err := json.Marshal(req) + if err != nil { + return nil, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + request.Header.Set("Content-Type", "application/json") + request.Header.Set("Authorization", "Bearer "+token) + + response, err := pinnedClient(pin).Do(request) + if err != nil { + return nil, fmt.Errorf("enrollment refused: %w", err) + } + defer response.Body.Close() + payload, err := io.ReadAll(io.LimitReader(response.Body, maxEnrollRequestBytes)) + if err != nil { + return nil, err + } + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("enrollment refused: %s", strings.TrimSpace(string(payload))) + } + var resp EnrollResponse + if err := json.Unmarshal(payload, &resp); err != nil { + return nil, fmt.Errorf("unparseable enrollment response %q: %w", strings.TrimSpace(string(payload)), err) + } + if resp.Agent == "" || resp.DispatchKey == "" { + return nil, fmt.Errorf("enrollment response is missing the agent name or the supervisor's dispatch key") + } + return &resp, nil +} + +// pinnedClient verifies the server by its public-key pin rather than by a +// chain, because a self-signed supervisor has no chain and the agent has not +// been handed its certificate yet — that is what this exchange delivers. +func pinnedClient(pin string) *http.Client { + return &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{ + // Verification is not skipped, it is replaced: the callback below + // is the whole check, and it is stricter than a chain walk. + InsecureSkipVerify: true, //nolint:gosec // VerifyPeerCertificate pins the exact key + MinVersion: tls.VersionTLS12, + VerifyPeerCertificate: func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + if len(rawCerts) == 0 { + return fmt.Errorf("supervisor presented no certificate") + } + leaf, err := x509.ParseCertificate(rawCerts[0]) + if err != nil { + return fmt.Errorf("parse supervisor certificate: %w", err) + } + got, err := publicKeyPin(leaf) + if err != nil { + return err + } + if got != pin { + return fmt.Errorf("supervisor certificate pin %s does not match the pinned %s", got, pin) + } + return nil + }, + }}, + } +} diff --git a/pkg/gitagent/httpclient.go b/pkg/gitagent/httpclient.go new file mode 100644 index 00000000..24ab749c --- /dev/null +++ b/pkg/gitagent/httpclient.go @@ -0,0 +1,175 @@ +// The client half of the HTTPS transport (§8). Both directions of a task ride +// `git push`, so selecting a transport means selecting the environment git +// runs under, not writing a second protocol. + +package gitagent + +import ( + "fmt" + "net/url" + "strings" + + "github.com/flanksource/clicky/text" +) + +// TransportTarget is everything a push needs to reach an endpoint. The URL's +// scheme selects the transport; the other transport's fields are ignored. +type TransportTarget struct { + // URL is the push URL: ssh://captain@host:7422/repo.git, or + // https://host:8080/git/repo.git. + URL string + + // SSHCommand, KeyPath and HostFingerprint drive ssh://: captain's own + // transport rather than system ssh, a captain-managed key, and a host key + // pinned by fingerprint. An empty SSHCommand means this binary. + SSHCommand string + KeyPath string + HostFingerprint string + + // Token, CAPath and PinnedPublicKey drive https://. CAPath is the + // endpoint's own certificate, which is its own trust anchor; leaving it + // empty means the system trust store, which is right for a real + // certificate and fails loudly for a self-signed one. + Token text.SensitiveString + CAPath string + PinnedPublicKey string +} + +// TransportEnv prepares env for a push to target. +// +// Inherited git configuration is stripped first. The HTTPS transport carries +// its settings in GIT_CONFIG_COUNT/KEY_n/VALUE_n, and git reads those by index: +// an inherited count higher than the one written here would leave a stale +// KEY_n/VALUE_n pair in force, silently overriding the credential or the trust +// anchor this push depends on. +func TransportEnv(env []string, target TransportTarget) ([]string, error) { + switch scheme := EndpointScheme(target.URL); scheme { + case "https": + pairs, err := httpsTransportPairs(target) + if err != nil { + return nil, err + } + return envWith(withoutGitConfigPairs(env), pairs...), nil + case "http": + // Refused rather than downgraded: the bearer token rides an + // Authorization header, so http:// would put a durable credential on + // the wire in clear text. + return nil, fmt.Errorf("endpoint %q uses http://; a captain token would cross the network in clear text — use https://", target.URL) + case "ssh": + pairs, err := sshTransportPairs(target) + if err != nil { + return nil, err + } + return envWith(env, pairs...), nil + default: + return nil, fmt.Errorf("endpoint %q uses unsupported scheme %q; captain speaks ssh:// and https://", target.URL, scheme) + } +} + +// sshTransportPairs builds the env for a push riding captain's GIT_SSH_COMMAND +// transport: no system ssh, key from a captain-managed path, host key pinned by +// fingerprint. +func sshTransportPairs(target TransportTarget) ([]string, error) { + command := target.SSHCommand + if command == "" { + exe, err := executablePath() + if err != nil { + return nil, err + } + command = SSHTransportCommand(exe) + } + return []string{ + "GIT_SSH_COMMAND=" + command, + "GIT_SSH_VARIANT=ssh", // an unrecognized command defaults to "simple", which cannot pass -p + EnvSSHKey + "=" + target.KeyPath, + EnvSSHHostFingerprint + "=" + target.HostFingerprint, + }, nil +} + +// httpsTransportPairs renders the transport as git configuration injected +// through the environment, so nothing is written to a config file that could +// outlive the push or be read by another process's git. +func httpsTransportPairs(target TransportTarget) ([]string, error) { + if target.Token.IsEmpty() { + return nil, fmt.Errorf("pushing to %s needs a captain token; mint one with `captain token create`", target.URL) + } + scope, err := configScope(target.URL) + if err != nil { + return nil, err + } + // Every setting is scoped to this endpoint. An unscoped extraHeader would + // offer the credential to any host git contacted during the push, and an + // unscoped sslCAInfo would make this self-signed certificate a trust anchor + // for every HTTPS URL git touched. + settings := [][2]string{ + {"http." + scope + ".extraHeader", "Authorization: Bearer " + target.Token.Value()}, + } + if target.CAPath != "" { + settings = append(settings, [2]string{"http." + scope + ".sslCAInfo", target.CAPath}) + } + if target.PinnedPublicKey != "" { + settings = append(settings, [2]string{"http." + scope + ".pinnedPubkey", target.PinnedPublicKey}) + } + pairs := []string{fmt.Sprintf("GIT_CONFIG_COUNT=%d", len(settings))} + for i, setting := range settings { + pairs = append(pairs, + fmt.Sprintf("GIT_CONFIG_KEY_%d=%s", i, setting[0]), + fmt.Sprintf("GIT_CONFIG_VALUE_%d=%s", i, setting[1])) + } + return pairs, nil +} + +// configScope is the URL git matches a http..* setting against: the +// endpoint's origin, whose path "/" prefixes every repository under it. +func configScope(pushURL string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(pushURL)) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("push URL %q must be https://host[:port]/path", pushURL) + } + return parsed.Scheme + "://" + parsed.Host + "/", nil +} + +// withoutGitConfigPairs drops inherited GIT_CONFIG_COUNT/KEY_n/VALUE_n. +// +// Deliberately not every GIT_CONFIG_* variable: GIT_CONFIG_GLOBAL and +// GIT_CONFIG_SYSTEM are how a sandbox or a test isolates git from the real +// user configuration, and dropping them would silently re-admit it. +func withoutGitConfigPairs(env []string) []string { + out := make([]string, 0, len(env)) + for _, kv := range env { + name, _, _ := strings.Cut(kv, "=") + if name == "GIT_CONFIG_COUNT" || + strings.HasPrefix(name, "GIT_CONFIG_KEY_") || + strings.HasPrefix(name, "GIT_CONFIG_VALUE_") { + continue + } + out = append(out, kv) + } + return out +} + +// EndpointScheme reports which transport an endpoint selects. An endpoint with +// no scheme is ssh, which is the form written before HTTPS existed. +func EndpointScheme(endpoint string) string { + scheme, _, found := strings.Cut(strings.TrimSpace(endpoint), "://") + if !found { + return "ssh" + } + return strings.ToLower(scheme) +} + +// HTTPSRepoURL joins an https endpoint with a repository path, placing it under +// the transport's prefix. +func HTTPSRepoURL(endpoint, repoPath string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("endpoint %q must be https://host[:port]", endpoint) + } + if parsed.Scheme != "https" { + return "", fmt.Errorf("endpoint %q must use https://", endpoint) + } + if repoPath = strings.TrimPrefix(strings.TrimSpace(repoPath), "/"); repoPath == "" { + return "", fmt.Errorf("endpoint %q needs a repository path", endpoint) + } + return parsed.Scheme + "://" + parsed.Host + GitHTTPPrefix + repoPath, nil +} diff --git a/pkg/gitagent/httpserver.go b/pkg/gitagent/httpserver.go new file mode 100644 index 00000000..a9db8d65 --- /dev/null +++ b/pkg/gitagent/httpserver.go @@ -0,0 +1,292 @@ +// The git smart-HTTP transport (§2/§8). It is the same endpoint as the SSH one +// wearing a different coat: the identity is resolved differently, but the +// repository is still resolved through ResolveRepoPath, receive-pack is still +// the only verb served (R2.3), and the hooks still do all the vetting. +// +// git is shelled out to rather than reimplemented, exactly as the SSH path does +// it, because the installed hook set is the entire admission tier — a native Go +// implementation would not run it. + +package gitagent + +import ( + "compress/gzip" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "strings" +) + +// GitHTTPPrefix is the path the transport is served under. The whole subtree is +// registered so it takes precedence over a single-page-app catch-all, which +// would otherwise answer /git/x.git/info/refs with 200 and an HTML body — and a +// git client reports that as a baffling protocol error rather than a 404. +const GitHTTPPrefix = "/git/" + +// AgentWhoamiPath is the authenticated runtime identity endpoint a sidecar exposes. +const AgentWhoamiPath = "/api/v1/whoami" + +const ( + receivePackService = "git-receive-pack" + uploadPackService = "git-upload-pack" + // EnrollEndpoint completes the bidirectional trust exchange over HTTPS, so + // a supervisor hosted on `captain serve` needs no SSH listener at all. It + // sits under the same prefix because it is authorized by the same token and + // the same middleware. + EnrollEndpoint = "enroll" +) + +// HTTPServerConfig configures the smart-HTTP transport. +type HTTPServerConfig struct { + // Root is the directory whose repositories may be pushed to. + Root string + Role ReceiverRole + // Identify resolves the agent a request speaks for. Returning an error + // refuses the push: the agent name is what the ref-namespace rules (R8.3) + // are enforced against, so an anonymous push would have no namespace to be + // confined to. + Identify func(*http.Request) (string, error) + // Enroll completes the reverse direction of trust for an already-identified + // agent. Leaving it nil serves no enrollment endpoint, which is right for a + // transport that only receives pushes. + Enroll func(r *http.Request, agent string, req EnrollRequest) (*EnrollResponse, error) + // Log receives transport-level failures that never reach the client, such + // as receive-pack's own stderr. Optional. + Log func(format string, args ...any) +} + +// NewHTTPHandler builds the transport. Mount it at GitHTTPPrefix. +func NewHTTPHandler(cfg HTTPServerConfig) (http.Handler, error) { + if cfg.Root == "" || cfg.Identify == nil { + return nil, errors.New("git-agent HTTP transport needs a repo root and an identity resolver") + } + if cfg.Log == nil { + cfg.Log = func(string, ...any) {} + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg.serve(w, r) }), nil +} + +func (cfg HTTPServerConfig) serve(w http.ResponseWriter, r *http.Request) { + if strings.TrimPrefix(r.URL.Path, GitHTTPPrefix) == EnrollEndpoint { + cfg.enroll(w, r) + return + } + repoArg, endpoint, ok := splitGitPath(r.URL.Path) + if !ok { + // Deliberately not a 404: the dumb-HTTP paths (/objects/…, /HEAD, + // /info/packs) would serve the whole repository to anyone who can reach + // it, and saying "not found" invites a client to keep guessing. + refuse(w, http.StatusForbidden, "this endpoint speaks "+receivePackService+" only (R2.3)") + return + } + agent, err := cfg.Identify(r) + if err != nil || agent == "" { + refuse(w, http.StatusForbidden, "this request carries no agent identity, so it has no ref namespace to be confined to") + return + } + repo, err := ResolveRepoPath(cfg.Root, repoArg) + if err != nil { + // ResolveRepoPath answers containment violations and missing + // repositories alike; both are "there is nothing here for you". + refuse(w, http.StatusNotFound, err.Error()) + return + } + switch { + case r.Method == http.MethodGet && endpoint == "info/refs": + cfg.advertise(w, r, repo, agent) + case r.Method == http.MethodPost && endpoint == receivePackService: + cfg.receivePack(w, r, repo, agent) + case endpoint == uploadPackService: + // A shared upload-pack leaks every task namespace to every enrolled + // agent, which is the reason R2.3 exists. + refuse(w, http.StatusForbidden, uploadPackService+" is not served (R2.3)") + default: + refuse(w, http.StatusMethodNotAllowed, r.Method+" is not served on "+endpoint) + } +} + +// enroll completes the bidirectional trust exchange. +// +// The agent is already authenticated by the time this runs — the token is what +// authorizes enrollment, and resolving it is what allocates or reclaims a pool +// member's name. This is idempotent by construction: a durable token admitted +// twice yields the same agent, which is what lets a restarting sidecar re-run +// its whole startup path unguarded. +func (cfg HTTPServerConfig) enroll(w http.ResponseWriter, r *http.Request) { + if cfg.Enroll == nil { + refuse(w, http.StatusNotFound, "this endpoint does not enroll agents") + return + } + if r.Method != http.MethodPost { + refuse(w, http.StatusMethodNotAllowed, r.Method+" is not served on "+EnrollEndpoint) + return + } + var request EnrollRequest + if err := json.NewDecoder(io.LimitReader(r.Body, maxEnrollRequestBytes)).Decode(&request); err != nil { + refuse(w, http.StatusBadRequest, "unparseable enrollment request: "+err.Error()) + return + } + agent, err := cfg.Identify(r) + if err != nil || agent == "" { + // The message carries the reason: unlike a push, an operator is + // watching this one and a bare 403 gives them nothing to act on. + refuse(w, http.StatusForbidden, enrollRefusalDetail(err)) + return + } + response, err := cfg.Enroll(r, agent, request) + if err != nil { + cfg.Log("git-agent enroll %s: %v", agent, err) + refuse(w, http.StatusBadRequest, err.Error()) + return + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(response); err != nil { + cfg.Log("git-agent enroll %s: write response: %v", agent, err) + } +} + +// maxEnrollRequestBytes bounds an enrollment body. It carries a URL, a port and +// a fingerprint; anything larger is not a captain agent. +const maxEnrollRequestBytes = 64 << 10 + +func enrollRefusalDetail(err error) string { + if err != nil { + return "enrollment refused: " + err.Error() + } + return "enrollment refused: this request carries no agent identity" +} + +// advertise answers the ref advertisement that opens a push. The advertisement +// includes side-band-64k, which is what carries the verdict feedback the hooks +// write — so nothing under admit.go or feedback.go changes for this transport. +func (cfg HTTPServerConfig) advertise(w http.ResponseWriter, r *http.Request, repo, agent string) { + if service := r.URL.Query().Get("service"); service != receivePackService { + refuse(w, http.StatusForbidden, "service "+service+" is not served: this endpoint speaks "+receivePackService+" only (R2.3)") + return + } + proc := exec.CommandContext(r.Context(), "git", "receive-pack", "--stateless-rpc", "--advertise-refs", repo) + proc.Env = cfg.processEnv(agent) + var stderr strings.Builder + proc.Stderr = &stderr + advertisement, err := proc.Output() + if err != nil { + cfg.Log("git-agent advertise %s: %v: %s", repo, err, stderr.String()) + refuse(w, http.StatusInternalServerError, "cannot advertise refs for this repository") + return + } + writeGitHeaders(w, "application/x-"+receivePackService+"-advertisement") + // Smart HTTP frames the advertisement with a service banner and a flush + // packet; without them git falls back to the dumb protocol and fails. + if _, err := io.WriteString(w, pktLine("# service="+receivePackService+"\n")+"0000"); err != nil { + return + } + _, _ = w.Write(advertisement) +} + +// receivePack relays the push itself. +func (cfg HTTPServerConfig) receivePack(w http.ResponseWriter, r *http.Request, repo, agent string) { + body, err := requestBody(r) + if err != nil { + refuse(w, http.StatusBadRequest, err.Error()) + return + } + defer body.Close() + + proc := exec.CommandContext(r.Context(), "git", "receive-pack", "--stateless-rpc", repo) + proc.Env = cfg.processEnv(agent) + proc.Stdin = body + // Flushed per write so hook output reaches the client while the push is + // still running. Buffering it would hold a rejection until the hooks + // finished, and a prompt hook can take minutes. + proc.Stdout = &flushWriter{writer: w, controller: http.NewResponseController(w)} + var stderr strings.Builder + proc.Stderr = &stderr + + writeGitHeaders(w, "application/x-"+receivePackService+"-result") + if err := proc.Run(); err != nil { + // The status is already sent, so the client learns of a failure through + // the truncated stream. Everything a client can act on rides the + // sideband; this is for the operator. + cfg.Log("git-agent receive-pack %s for %s: %v: %s", repo, agent, err, stderr.String()) + return + } + if stderr.Len() > 0 { + cfg.Log("git-agent receive-pack %s for %s: %s", repo, agent, stderr.String()) + } +} + +// processEnv injects the identity the hook shims read, exactly as the SSH +// transport does — which is why the hooks need no knowledge of either. +func (cfg HTTPServerConfig) processEnv(agent string) []string { + return envWith(os.Environ(), EnvAgentName+"="+agent, EnvRole+"="+string(cfg.Role)) +} + +// splitGitPath separates the repository from the endpoint it is being asked +// for. The repository can contain slashes, so it is taken as whatever precedes +// a known endpoint rather than as a fixed number of segments. +func splitGitPath(path string) (repo, endpoint string, ok bool) { + trimmed, found := strings.CutPrefix(path, GitHTTPPrefix) + if !found { + return "", "", false + } + for _, candidate := range []string{"info/refs", receivePackService, uploadPackService} { + if repo, found := strings.CutSuffix(trimmed, "/"+candidate); found && repo != "" { + return repo, candidate, true + } + } + return "", "", false +} + +// requestBody unwraps the transfer encoding git may have applied. git gzips a +// push body when it is large enough, and receive-pack expects it decoded. +func requestBody(r *http.Request) (io.ReadCloser, error) { + if !strings.EqualFold(strings.TrimSpace(r.Header.Get("Content-Encoding")), "gzip") { + return r.Body, nil + } + reader, err := gzip.NewReader(r.Body) + if err != nil { + return nil, fmt.Errorf("decode gzipped push body: %w", err) + } + return reader, nil +} + +func writeGitHeaders(w http.ResponseWriter, contentType string) { + header := w.Header() + header.Set("Content-Type", contentType) + // Smart HTTP requires these: a caching proxy that served a stale ref + // advertisement would make a push fail on refs that no longer exist. + header.Set("Cache-Control", "no-cache, max-age=0, must-revalidate") + header.Set("Pragma", "no-cache") + header.Set("Expires", "Fri, 01 Jan 1980 00:00:00 GMT") +} + +func refuse(w http.ResponseWriter, status int, message string) { + http.Error(w, "captain: "+message, status) +} + +// pktLine frames a string in git's length-prefixed packet format. +func pktLine(payload string) string { + return fmt.Sprintf("%04x%s", len(payload)+4, payload) +} + +// flushWriter pushes each write through to the client immediately, so sideband +// progress and hook rejections stream rather than arriving at the end. +type flushWriter struct { + writer io.Writer + controller *http.ResponseController +} + +func (f *flushWriter) Write(p []byte) (int, error) { + n, err := f.writer.Write(p) + if err != nil { + return n, err + } + // A writer that cannot flush is not an error: the response still arrives, + // it just arrives all at once. + _ = f.controller.Flush() + return n, nil +} diff --git a/pkg/gitagent/httpserver_ginkgo_test.go b/pkg/gitagent/httpserver_ginkgo_test.go new file mode 100644 index 00000000..02e70acb --- /dev/null +++ b/pkg/gitagent/httpserver_ginkgo_test.go @@ -0,0 +1,260 @@ +package gitagent_test + +import ( + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "io" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +// gitHTTPFixture is a served root with one bare repository, fronted by the +// smart-HTTP transport over real TLS — the same shape a supervisor runs. +type gitHTTPFixture struct { + root string + repoRoute string + server *httptest.Server + credential *gitagent.TLSCredential + agent string + tokenPath string + // identifyErr makes the identity resolver fail, so a test can prove the + // transport refuses an unattributable push before git ever runs. + identifyErr error +} + +func readAll(body io.Reader) string { + GinkgoHelper() + data, err := io.ReadAll(body) + Expect(err).NotTo(HaveOccurred()) + return string(data) +} + +func newGitHTTPFixture() *gitHTTPFixture { + GinkgoHelper() + // A real mailbox route: ValidateMailboxRoute requires the sha256 of the + // canonical repository, so a readable placeholder would be rejected before + // the transport was ever reached. + digest := sha256.Sum256([]byte("https://example.com/acme/project")) + fixture := &gitHTTPFixture{ + root: GinkgoT().TempDir(), + repoRoute: "mailboxes/" + hex.EncodeToString(digest[:]) + ".git", + agent: "worker-01", + } + repo := filepath.Join(fixture.root, filepath.FromSlash(fixture.repoRoute)) + Expect(os.MkdirAll(repo, 0o755)).To(Succeed()) + runGitIn(repo, "init", "--bare", "-q", ".") + // receive-pack refuses a push to the branch a bare repo has checked out. + runGitIn(repo, "symbolic-ref", "HEAD", "refs/heads/placeholder") + + handler, err := gitagent.NewHTTPHandler(gitagent.HTTPServerConfig{ + Root: fixture.root, + Role: gitagent.RoleMailbox, + Identify: func(*http.Request) (string, error) { + if fixture.identifyErr != nil { + return "", fixture.identifyErr + } + return fixture.agent, nil + }, + }) + Expect(err).NotTo(HaveOccurred()) + + keysDir := GinkgoT().TempDir() + fixture.credential, err = gitagent.EnsureTLSCredential(keysDir, nil) + Expect(err).NotTo(HaveOccurred()) + + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{ + Certificates: []tls.Certificate{fixture.credential.Certificate}, + MinVersion: tls.VersionTLS12, + } + server.StartTLS() + DeferCleanup(server.Close) + fixture.server = server + + fixture.tokenPath = filepath.Join(keysDir, gitagent.TokenFileName) + Expect(gitagent.WriteTokenFile(fixture.tokenPath, text.NewSensitiveString("cptn_id.secret"))).To(Succeed()) + return fixture +} + +// endpoint is the https://host:port form an agent is enrolled with. +func (f *gitHTTPFixture) endpoint() string { return f.server.URL } + +func (f *gitHTTPFixture) client() *http.Client { + GinkgoHelper() + pem, err := f.credential.PEM() + Expect(err).NotTo(HaveOccurred()) + pool := x509.NewCertPool() + Expect(pool.AppendCertsFromPEM(pem)).To(BeTrue()) + return &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }} +} + +func runGitIn(dir string, args ...string) string { + GinkgoHelper() + cmd := exec.Command("git", args...) + cmd.Dir = dir + cmd.Env = append(os.Environ(), + "GIT_AUTHOR_NAME=captain", "GIT_AUTHOR_EMAIL=captain@example.com", + "GIT_COMMITTER_NAME=captain", "GIT_COMMITTER_EMAIL=captain@example.com") + out, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "git %s: %s", strings.Join(args, " "), out) + return string(out) +} + +var _ = Describe("git smart-HTTP transport", func() { + It("advertises receive-pack with the sideband the verdict feedback rides", func() { + fixture := newGitHTTPFixture() + + response, err := fixture.client().Get( + fixture.endpoint() + gitagent.GitHTTPPrefix + fixture.repoRoute + "/info/refs?service=git-receive-pack") + Expect(err).NotTo(HaveOccurred()) + defer response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusOK)) + Expect(response.Header.Get("Content-Type")).To(Equal("application/x-git-receive-pack-advertisement")) + Expect(response.Header.Get("Cache-Control")).To(ContainSubstring("no-cache")) + + body := readAll(response.Body) + Expect(body).To(HavePrefix("001f# service=git-receive-pack\n0000"), + "smart HTTP needs the service banner; without it git falls back to the dumb protocol") + // The hooks write their verdict to receive-pack's sideband. If the + // advertisement did not offer it the feedback would be silently lost. + Expect(body).To(ContainSubstring("side-band-64k")) + }) + + // A shared upload-pack would leak every task namespace to every enrolled + // agent, which is what R2.3 exists to prevent. + It("refuses upload-pack and the dumb-protocol paths (R2.3)", func() { + fixture := newGitHTTPFixture() + base := fixture.endpoint() + gitagent.GitHTTPPrefix + fixture.repoRoute + client := fixture.client() + + response, err := client.Get(base + "/info/refs?service=git-upload-pack") + Expect(err).NotTo(HaveOccurred()) + defer response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusForbidden)) + Expect(readAll(response.Body)).To(ContainSubstring("R2.3")) + + for _, path := range []string{ + "/git-upload-pack", "/HEAD", "/objects/info/packs", "/info/refs/../../etc/passwd", + } { + response, err := client.Get(base + path) + Expect(err).NotTo(HaveOccurred()) + Expect(response.StatusCode).To(BeNumerically(">=", http.StatusBadRequest), + "%s must not be served", path) + response.Body.Close() + } + }) + + // R8.4: containment is checked after resolving, because stripping slashes + // and quotes does not stop `..`. + It("refuses a repository path that escapes the served root (R8.4)", func() { + fixture := newGitHTTPFixture() + + response, err := fixture.client().Get( + fixture.endpoint() + gitagent.GitHTTPPrefix + "../../../etc/info/refs?service=git-receive-pack") + Expect(err).NotTo(HaveOccurred()) + defer response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusNotFound)) + }) + + // A push has to name an agent: the ref namespace R8.3 confines it to is + // derived from that name, so an unidentified push has no namespace at all. + It("refuses a push it cannot attribute to an agent", func() { + fixture := newGitHTTPFixture() + fixture.identifyErr = os.ErrPermission + + response, err := fixture.client().Get( + fixture.endpoint() + gitagent.GitHTTPPrefix + fixture.repoRoute + "/info/refs?service=git-receive-pack") + Expect(err).NotTo(HaveOccurred()) + defer response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusForbidden)) + Expect(readAll(response.Body)).To(ContainSubstring("ref namespace")) + }) + + // The proof the whole path works: a real `git push` through the real client + // transport, over TLS, authenticated by a token, landing a commit. + It("accepts a real git push driven by the client transport", func() { + fixture := newGitHTTPFixture() + + work := GinkgoT().TempDir() + runGitIn(work, "init", "-q", "-b", "main", ".") + Expect(os.WriteFile(filepath.Join(work, "file.txt"), []byte("work\n"), 0o600)).To(Succeed()) + runGitIn(work, "add", "file.txt") + runGitIn(work, "commit", "-q", "-m", "agent work") + + pushURL, err := gitagent.MailboxURL(fixture.endpoint(), fixture.repoRoute) + Expect(err).NotTo(HaveOccurred()) + Expect(pushURL).To(HavePrefix("https://")) + Expect(pushURL).To(ContainSubstring(gitagent.GitHTTPPrefix + fixture.repoRoute)) + + target := gitagent.RelayTarget{ + URL: fixture.endpoint(), + TokenPath: fixture.tokenPath, + CAPath: fixture.credential.CertPath, + // Pinning is optional; exercising it here proves the value is in the + // form git accepts, which a wrong encoding would fail on. + PinnedPublicKey: fixture.credential.PublicKeyPin, + } + transport, err := target.Transport(pushURL) + Expect(err).NotTo(HaveOccurred()) + env, err := gitagent.TransportEnv(os.Environ(), transport) + Expect(err).NotTo(HaveOccurred()) + + cmd := exec.Command("git", "push", pushURL, "HEAD:refs/heads/pushed") + cmd.Dir = work + cmd.Env = env + out, err := cmd.CombinedOutput() + Expect(err).NotTo(HaveOccurred(), "push failed: %s", out) + + repo := filepath.Join(fixture.root, filepath.FromSlash(fixture.repoRoute)) + Expect(runGitIn(repo, "log", "-1", "--format=%s", "refs/heads/pushed")).To(ContainSubstring("agent work")) + }) + + // The identity the transport resolved has to reach the hooks, because that + // is the only thing the ref-namespace rules can be enforced against. + It("injects the pushing agent into receive-pack's environment", func() { + fixture := newGitHTTPFixture() + repo := filepath.Join(fixture.root, filepath.FromSlash(fixture.repoRoute)) + hookPath := filepath.Join(repo, "hooks", "pre-receive") + Expect(os.MkdirAll(filepath.Dir(hookPath), 0o755)).To(Succeed()) + Expect(os.WriteFile(hookPath, []byte( + "#!/bin/sh\ncat >/dev/null\necho \"pushed by ${"+gitagent.EnvAgentName+"} as ${"+gitagent.EnvRole+"}\" >&2\nexit 1\n", + ), 0o755)).To(Succeed()) + + work := GinkgoT().TempDir() + runGitIn(work, "init", "-q", "-b", "main", ".") + Expect(os.WriteFile(filepath.Join(work, "file.txt"), []byte("work\n"), 0o600)).To(Succeed()) + runGitIn(work, "add", "file.txt") + runGitIn(work, "commit", "-q", "-m", "agent work") + + pushURL, err := gitagent.MailboxURL(fixture.endpoint(), fixture.repoRoute) + Expect(err).NotTo(HaveOccurred()) + transport, err := gitagent.RelayTarget{ + URL: fixture.endpoint(), TokenPath: fixture.tokenPath, CAPath: fixture.credential.CertPath, + }.Transport(pushURL) + Expect(err).NotTo(HaveOccurred()) + env, err := gitagent.TransportEnv(os.Environ(), transport) + Expect(err).NotTo(HaveOccurred()) + + cmd := exec.Command("git", "push", pushURL, "HEAD:refs/heads/rejected") + cmd.Dir = work + cmd.Env = env + out, _ := cmd.CombinedOutput() + // The hook's stderr rides the sideband back to the client, which is how + // a verdict reaches the agent unchanged over this transport. + Expect(string(out)).To(ContainSubstring("pushed by worker-01 as mailbox")) + }) +}) diff --git a/pkg/gitagent/httpserver_sidecar_ginkgo_test.go b/pkg/gitagent/httpserver_sidecar_ginkgo_test.go new file mode 100644 index 00000000..38a34a54 --- /dev/null +++ b/pkg/gitagent/httpserver_sidecar_ginkgo_test.go @@ -0,0 +1,240 @@ +package gitagent_test + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +// sidecarFixture is the other end of the topology: an agent's own receive +// endpoint, over TLS, authenticating the SUPERVISOR by the bearer token the +// agent minted for it at enrollment. Enroll is nil because a sidecar enrolls +// nobody. +type sidecarFixture struct { + root string + server *httptest.Server + credential *gitagent.TLSCredential + secret text.SensitiveString + tokenPath string +} + +const sidecarPeer = "supervisor" + +func newSidecarFixture() *sidecarFixture { + GinkgoHelper() + fixture := &sidecarFixture{root: GinkgoT().TempDir()} + + repo := filepath.Join(fixture.root, "repo.git") + Expect(os.MkdirAll(repo, 0o755)).To(Succeed()) + runGitIn(repo, "init", "--bare", "-q", ".") + runGitIn(repo, "symbolic-ref", "HEAD", "refs/heads/placeholder") + + keysDir := GinkgoT().TempDir() + var err error + fixture.secret, err = gitagent.MintDispatchCredential( + filepath.Join(keysDir, gitagent.DispatchCredentialName)) + Expect(err).NotTo(HaveOccurred()) + credential, err := gitagent.LoadDispatchCredential( + filepath.Join(keysDir, gitagent.DispatchCredentialName)) + Expect(err).NotTo(HaveOccurred()) + verifier := credential.Verifier(sidecarPeer) + + handler, err := gitagent.NewHTTPHandler(gitagent.HTTPServerConfig{ + Root: fixture.root, + Role: gitagent.RoleSidecar, + Identify: func(r *http.Request) (string, error) { + presented, ok := captaintoken.BearerFromHeader(r.Header.Get("Authorization")) + if !ok { + return "", fmt.Errorf("no bearer token") + } + record, err := verifier.VerifyScope(r.Context(), presented, captaintoken.ScopeGit) + if err != nil { + return "", err + } + return record.Agent, nil + }, + // A sidecar receives pushes and enrolls nobody. + Enroll: nil, + }) + Expect(err).NotTo(HaveOccurred()) + + fixture.credential, err = gitagent.EnsureTLSCredential(keysDir, nil) + Expect(err).NotTo(HaveOccurred()) + server := httptest.NewUnstartedServer(handler) + server.TLS = &tls.Config{ + Certificates: []tls.Certificate{fixture.credential.Certificate}, + MinVersion: tls.VersionTLS12, + } + server.StartTLS() + DeferCleanup(server.Close) + fixture.server = server + + fixture.tokenPath = filepath.Join(keysDir, "dispatch.token") + Expect(gitagent.WriteTokenFile(fixture.tokenPath, fixture.secret)).To(Succeed()) + return fixture +} + +func (f *sidecarFixture) client() *http.Client { + GinkgoHelper() + pem, err := f.credential.PEM() + Expect(err).NotTo(HaveOccurred()) + pool := x509.NewCertPool() + Expect(pool.AppendCertsFromPEM(pem)).To(BeTrue()) + return &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }} +} + +// get issues a request with whatever Authorization header a test wants, so the +// three auth outcomes can be compared against each other. +func (f *sidecarFixture) get(path, authorization string) *http.Response { + GinkgoHelper() + req, err := http.NewRequest(http.MethodGet, f.server.URL+path, nil) + Expect(err).NotTo(HaveOccurred()) + if authorization != "" { + req.Header.Set("Authorization", authorization) + } + response, err := f.client().Do(req) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(response.Body.Close) + return response +} + +var _ = Describe("sidecar receive endpoint over https", func() { + const refs = gitagent.GitHTTPPrefix + "repo.git/info/refs?service=git-receive-pack" + + It("admits the supervisor's dispatch token", func() { + fixture := newSidecarFixture() + + response := fixture.get(refs, "Bearer "+fixture.secret.Value()) + Expect(response.StatusCode).To(Equal(http.StatusOK)) + Expect(readAll(response.Body)).To(ContainSubstring("side-band-64k")) + }) + + // The endpoint is reachable from outside the cluster, so a missing and a + // wrong credential must be indistinguishable — anything else would reveal + // which half of the token an attacker got right. + It("refuses a missing and a wrong token identically", func() { + fixture := newSidecarFixture() + + absent := fixture.get(refs, "") + wrong := fixture.get(refs, "Bearer cptn_aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + Expect(absent.StatusCode).To(Equal(http.StatusForbidden)) + Expect(wrong.StatusCode).To(Equal(absent.StatusCode)) + Expect(readAll(wrong.Body)).To(Equal(readAll(absent.Body))) + }) + + // The credential is git-scoped and issued by this agent alone, so another + // agent's token must not open this one. + It("refuses another agent's dispatch token", func() { + fixture := newSidecarFixture() + other, err := gitagent.MintDispatchCredential( + filepath.Join(GinkgoT().TempDir(), gitagent.DispatchCredentialName)) + Expect(err).NotTo(HaveOccurred()) + + Expect(fixture.get(refs, "Bearer "+other.Value()).StatusCode).To(Equal(http.StatusForbidden)) + }) + + // Enroll is nil, so the endpoint is not served at all. A sidecar that could + // be made to enrol something would be a second way into the roster. + It("serves no enrollment endpoint", func() { + fixture := newSidecarFixture() + + req, err := http.NewRequest(http.MethodPost, fixture.server.URL+gitagent.GitHTTPPrefix+"enroll", nil) + Expect(err).NotTo(HaveOccurred()) + req.Header.Set("Authorization", "Bearer "+fixture.secret.Value()) + response, err := fixture.client().Do(req) + Expect(err).NotTo(HaveOccurred()) + defer response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusNotFound)) + }) + + It("refuses upload-pack even for the supervisor (R2.3)", func() { + fixture := newSidecarFixture() + + response := fixture.get( + gitagent.GitHTTPPrefix+"repo.git/git-upload-pack", "Bearer "+fixture.secret.Value()) + Expect(response.StatusCode).To(Equal(http.StatusForbidden)) + }) + + // The whole point: a dispatch is a git push, driven by the same TransportEnv + // the supervisor uses, and the hooks must see the peer as the supervisor so + // the ref-namespace rules have an identity to enforce against. + It("accepts a real dispatch push and attributes it to the supervisor", func() { + fixture := newSidecarFixture() + repo := filepath.Join(fixture.root, "repo.git") + hookPath := filepath.Join(repo, "hooks", "pre-receive") + Expect(os.MkdirAll(filepath.Dir(hookPath), 0o755)).To(Succeed()) + Expect(os.WriteFile(hookPath, []byte( + "#!/bin/sh\ncat >/dev/null\necho \"pushed by ${"+gitagent.EnvAgentName+"} as ${"+gitagent.EnvRole+"}\" >&2\nexit 1\n", + ), 0o755)).To(Succeed()) + + work := GinkgoT().TempDir() + runGitIn(work, "init", "-q", "-b", "main", ".") + Expect(os.WriteFile(filepath.Join(work, "file.txt"), []byte("work\n"), 0o600)).To(Succeed()) + runGitIn(work, "add", "file.txt") + runGitIn(work, "commit", "-q", "-m", "dispatched work") + + pushURL, err := gitagent.HTTPSRepoURL(fixture.server.URL, "repo.git") + Expect(err).NotTo(HaveOccurred()) + env, err := gitagent.TransportEnv(os.Environ(), gitagent.TransportTarget{ + URL: pushURL, + Token: fixture.secret, + CAPath: fixture.credential.CertPath, + }) + Expect(err).NotTo(HaveOccurred()) + + cmd := exec.Command("git", "push", pushURL, "HEAD:refs/heads/dispatched") + cmd.Dir = work + cmd.Env = env + out, err := cmd.CombinedOutput() + // The hook exits 1 on purpose, so the push is refused — but only after + // the transport authenticated the peer and ran receive-pack. + Expect(err).To(HaveOccurred()) + Expect(string(out)).To(ContainSubstring("pushed by " + sidecarPeer + " as " + string(gitagent.RoleSidecar))) + }) + + // A push with no credential must fail at the transport, not inside git, and + // must leave no ref behind. + It("leaves the repository untouched when the token is wrong", func() { + fixture := newSidecarFixture() + + work := GinkgoT().TempDir() + runGitIn(work, "init", "-q", "-b", "main", ".") + Expect(os.WriteFile(filepath.Join(work, "file.txt"), []byte("work\n"), 0o600)).To(Succeed()) + runGitIn(work, "add", "file.txt") + runGitIn(work, "commit", "-q", "-m", "dispatched work") + + pushURL, err := gitagent.HTTPSRepoURL(fixture.server.URL, "repo.git") + Expect(err).NotTo(HaveOccurred()) + env, err := gitagent.TransportEnv(os.Environ(), gitagent.TransportTarget{ + URL: pushURL, + Token: text.NewSensitiveString("cptn_aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"), + CAPath: fixture.credential.CertPath, + }) + Expect(err).NotTo(HaveOccurred()) + + cmd := exec.Command("git", "push", pushURL, "HEAD:refs/heads/dispatched") + cmd.Dir = work + cmd.Env = env + out, err := cmd.CombinedOutput() + Expect(err).To(HaveOccurred(), "an unauthenticated push succeeded: %s", out) + + repo := filepath.Join(fixture.root, "repo.git") + refs := runGitIn(repo, "for-each-ref", "--format=%(refname)") + Expect(strings.TrimSpace(refs)).To(BeEmpty()) + }) +}) diff --git a/pkg/gitagent/probe.go b/pkg/gitagent/probe.go new file mode 100644 index 00000000..947a570a --- /dev/null +++ b/pkg/gitagent/probe.go @@ -0,0 +1,180 @@ +package gitagent + +import ( + "context" + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "net" + "net/url" + "strings" + "time" + + gossh "golang.org/x/crypto/ssh" +) + +// probeTimeout bounds the whole probe. It is short because every use is a +// preflight against an endpoint expected to be up: a slow answer is itself the +// answer, and the caller has a mutation waiting behind it. +const probeTimeout = 5 * time.Second + +// errProbeDone aborts the handshake once the host key is in hand. Returning it +// from the host-key callback stops the exchange before authentication is +// attempted, which is what makes the probe credential-free — in SSH the key +// exchange precedes auth, so the fingerprint is already known by this point. +var errProbeDone = errors.New("probe complete") + +// ProbeEndpoint returns the SSH host-key fingerprint an endpoint presents. +// +// This is deliberately stronger than dialling the port. A git-agent endpoint +// and an unrelated sshd both accept a TCP connection on the address a config +// file claims, and enrolling against the wrong one burns a single-use token +// before the client learns its mistake. The fingerprint identifies which +// process is actually there, so the caller can compare it against the host key +// it expects and refuse rather than guess. +// +// It reports a distinguishable error for each way the address can be wrong: +// nothing listening, something listening that does not speak SSH, or an SSH +// server that is not the one expected. +func ProbeEndpoint(ctx context.Context, endpoint string) (string, error) { + addr, user, err := splitSSHEndpoint(endpoint) + if err != nil { + return "", err + } + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return "", fmt.Errorf("nothing is listening on %s: %w", addr, err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + var fingerprint string + _, _, _, err = gossh.NewClientConn(conn, addr, &gossh.ClientConfig{ + User: user, + HostKeyCallback: func(_ string, _ net.Addr, key gossh.PublicKey) error { + fingerprint = gossh.FingerprintSHA256(key) + return errProbeDone + }, + }) + if fingerprint == "" { + return "", fmt.Errorf("the process on %s did not complete an SSH key exchange, so it is not a captain git-agent endpoint: %w", addr, err) + } + return fingerprint, nil +} + +// ProbeTLSPin returns the public-key pin an HTTPS endpoint presents. +// +// The HTTPS counterpart of ProbeEndpoint, and the same argument applies: a +// captain supervisor and an unrelated web server both accept a connection on +// the address a config file claims. The pin identifies which process is there, +// because it can only be produced by whoever holds the matching private key — +// the same proof an SSH host key gives, so no request has to follow. +// +// Chain verification is replaced rather than skipped: the certificate is +// self-signed by design and has no chain, and the caller compares the pin. +func ProbeTLSPin(ctx context.Context, endpoint string) (string, error) { + leaf, err := ProbeTLSCertificate(ctx, endpoint) + if err != nil { + return "", err + } + return publicKeyPin(leaf) +} + +// VerifyEndpointCoversName proves the certificate an endpoint presents covers +// the name an agent will dial it by. +// +// Hostname verification happens at the agent, against a name this host may not +// even resolve — host.docker.internal exists only inside a container. Checking +// it here, against the certificate actually being served, is the difference +// between a refusal now and a sidecar that enrolls and then fails every relay +// with a TLS error naming neither the cause nor the fix. +func VerifyEndpointCoversName(ctx context.Context, endpoint, name string) error { + leaf, err := ProbeTLSCertificate(ctx, endpoint) + if err != nil { + return err + } + if err := leaf.VerifyHostname(name); err != nil { + return fmt.Errorf( + "the certificate served at %s does not cover %q, which is the name the agent will dial (it covers %s); "+ + "restart the supervisor with --tls-host %s — if its certificate was generated, delete it and its key "+ + "first, and note that re-issuing means every enrolled agent must be re-enrolled", + endpoint, name, certificateCoverage(leaf), name) + } + return nil +} + +// ProbeTLSCertificate returns the leaf certificate an HTTPS endpoint presents. +func ProbeTLSCertificate(ctx context.Context, endpoint string) (*x509.Certificate, error) { + addr, err := splitHTTPSEndpoint(endpoint) + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(ctx, probeTimeout) + defer cancel() + + conn, err := (&net.Dialer{}).DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("nothing is listening on %s: %w", addr, err) + } + defer conn.Close() + if deadline, ok := ctx.Deadline(); ok { + _ = conn.SetDeadline(deadline) + } + + client := tls.Client(conn, &tls.Config{ + // The pin is the whole check and the caller makes it, so hostname and + // chain verification here would only reject the self-signed certificate + // this exists to identify. + InsecureSkipVerify: true, //nolint:gosec // the caller compares the pin or the names + MinVersion: tls.VersionTLS12, + }) + if err := client.HandshakeContext(ctx); err != nil { + return nil, fmt.Errorf( + "the process on %s did not complete a TLS handshake, so it is not a captain HTTPS endpoint "+ + "(a `captain serve` without --tls serves plain HTTP): %w", addr, err) + } + defer client.Close() + certs := client.ConnectionState().PeerCertificates + if len(certs) == 0 { + return nil, fmt.Errorf("the endpoint at %s presented no certificate", addr) + } + return certs[0], nil +} + +// splitHTTPSEndpoint returns the host:port an https endpoint names, defaulting +// the port to 443 the way a browser would. +func splitHTTPSEndpoint(endpoint string) (string, error) { + parsed, err := url.Parse(strings.TrimSpace(endpoint)) + if err != nil || parsed.Host == "" { + return "", fmt.Errorf("endpoint %q must be https://host[:port]", endpoint) + } + if parsed.Port() == "" { + return net.JoinHostPort(parsed.Hostname(), "443"), nil + } + return parsed.Host, nil +} + +// VerifyEndpointIdentity probes an endpoint and requires it to present an +// expected identity, naming the mismatch when it does not. The scheme selects +// what identity means: an SSH host key, or a TLS public-key pin. +func VerifyEndpointIdentity(ctx context.Context, endpoint, wantIdentity string) error { + kind, probe := "SSH endpoint", ProbeEndpoint + if EndpointScheme(endpoint) == "https" { + kind, probe = "HTTPS endpoint", ProbeTLSPin + } + got, err := probe(ctx, endpoint) + if err != nil { + return err + } + if got != wantIdentity { + return fmt.Errorf("the %s at %s presents %s, not the expected %s; another server holds that address", + kind, endpoint, got, wantIdentity) + } + return nil +} diff --git a/pkg/gitagent/probe_ginkgo_test.go b/pkg/gitagent/probe_ginkgo_test.go new file mode 100644 index 00000000..a4f2440e --- /dev/null +++ b/pkg/gitagent/probe_ginkgo_test.go @@ -0,0 +1,160 @@ +package gitagent_test + +import ( + "context" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + gossh "golang.org/x/crypto/ssh" + + "github.com/flanksource/captain/pkg/gitagent" +) + +// sshServerPresenting starts a minimal SSH server on loopback with a freshly +// generated host key, and returns its endpoint and that key's fingerprint. It +// stands in for a git-agent receiver: the probe aborts at the key exchange, so +// nothing past the host key needs to be real. +func sshServerPresenting(keyPath string) (endpoint, fingerprint string) { + GinkgoHelper() + signer, fingerprint, err := gitagent.EnsureKeyPair(keyPath) + Expect(err).NotTo(HaveOccurred()) + + listener, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { _ = listener.Close() }) + + config := &gossh.ServerConfig{NoClientAuth: true} + config.AddHostKey(signer) + go func() { + defer GinkgoRecover() + for { + conn, err := listener.Accept() + if err != nil { + return + } + go func() { + defer conn.Close() + _, _, _, _ = gossh.NewServerConn(conn, config) + }() + } + }() + return "ssh://" + listener.Addr().String(), fingerprint +} + +var _ = Describe("ProbeEndpoint", func() { + var ctx context.Context + + BeforeEach(func() { ctx = context.Background() }) + + It("reports the host key an endpoint presents", func() { + endpoint, want := sshServerPresenting(filepath.Join(GinkgoT().TempDir(), "host_ed25519")) + + Expect(gitagent.ProbeEndpoint(ctx, endpoint)).To(Equal(want)) + Expect(gitagent.VerifyEndpointIdentity(ctx, endpoint, want)).To(Succeed()) + }) + + // Enrolling against the wrong SSH server burns a single-use token before the + // client discovers the mistake, so the mismatch has to be caught up front. + It("refuses an endpoint presenting a different host key", func() { + endpoint, _ := sshServerPresenting(filepath.Join(GinkgoT().TempDir(), "host_ed25519")) + _, other := sshServerPresenting(filepath.Join(GinkgoT().TempDir(), "other_ed25519")) + + Expect(gitagent.VerifyEndpointIdentity(ctx, endpoint, other)). + To(MatchError(ContainSubstring("another server holds that address"))) + }) + + It("distinguishes nothing listening from something that is not SSH", func() { + free, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + closed := "ssh://" + free.Addr().String() + Expect(free.Close()).To(Succeed()) + + _, err = gitagent.ProbeEndpoint(ctx, closed) + Expect(err).To(MatchError(ContainSubstring("nothing is listening"))) + + // A listener that accepts and then says nothing: a TCP dial would call + // this healthy. + silent, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(silent.Close) + go func() { + defer GinkgoRecover() + conn, err := silent.Accept() + if err == nil { + DeferCleanup(conn.Close) + } + }() + + _, err = gitagent.ProbeEndpoint(ctx, "ssh://"+silent.Addr().String()) + Expect(err).To(MatchError(ContainSubstring("not a captain git-agent endpoint"))) + }) +}) + +// tlsServerPresenting starts an HTTPS server holding a captain-generated +// certificate, and returns its endpoint plus the pin computed independently +// from the leaf — so the assertion has a reference value the probe did not produce. +func tlsServerPresenting(dir string) (endpoint, pin string) { + GinkgoHelper() + credential, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + + server := httptest.NewUnstartedServer(http.NotFoundHandler()) + server.TLS = &tls.Config{Certificates: []tls.Certificate{credential.Certificate}, MinVersion: tls.VersionTLS12} + server.StartTLS() + DeferCleanup(server.Close) + + spki, err := x509.MarshalPKIXPublicKey(credential.Leaf.PublicKey) + Expect(err).NotTo(HaveOccurred()) + sum := sha256.Sum256(spki) + return "https://" + server.Listener.Addr().String(), "sha256//" + base64.StdEncoding.EncodeToString(sum[:]) +} + +var _ = Describe("ProbeTLSPin", func() { + var ctx context.Context + + BeforeEach(func() { ctx = context.Background() }) + + It("reports the public-key pin an endpoint presents", func() { + endpoint, want := tlsServerPresenting(GinkgoT().TempDir()) + + Expect(gitagent.ProbeTLSPin(ctx, endpoint)).To(Equal(want)) + Expect(gitagent.VerifyEndpointIdentity(ctx, endpoint, want)).To(Succeed()) + }) + + // The supervisor hands an agent a durable credential on this connection, so + // reaching a different server than the one expected has to be caught before + // the token is sent rather than after. + It("refuses an endpoint presenting a different certificate", func() { + endpoint, _ := tlsServerPresenting(GinkgoT().TempDir()) + _, other := tlsServerPresenting(GinkgoT().TempDir()) + + Expect(gitagent.VerifyEndpointIdentity(ctx, endpoint, other)). + To(MatchError(ContainSubstring("another server holds that address"))) + }) + + It("distinguishes nothing listening from something that is not TLS", func() { + free, err := net.Listen("tcp", "127.0.0.1:0") + Expect(err).NotTo(HaveOccurred()) + closed := "https://" + free.Addr().String() + Expect(free.Close()).To(Succeed()) + + _, err = gitagent.ProbeTLSPin(ctx, closed) + Expect(err).To(MatchError(ContainSubstring("nothing is listening"))) + + // Plain HTTP on the port an https:// endpoint names: a TCP dial would + // call this healthy, and `captain serve` without --tls is exactly it. + plain := httptest.NewServer(http.NotFoundHandler()) + DeferCleanup(plain.Close) + + _, err = gitagent.ProbeTLSPin(ctx, "https://"+plain.Listener.Addr().String()) + Expect(err).To(MatchError(ContainSubstring("did not complete a TLS handshake"))) + }) +}) diff --git a/pkg/gitagent/relay.go b/pkg/gitagent/relay.go index 77e244dc..ca4d2919 100644 --- a/pkg/gitagent/relay.go +++ b/pkg/gitagent/relay.go @@ -22,6 +22,35 @@ type RelayTarget struct { HostFingerprint string `json:"hostFingerprint"` KeyPath string `json:"keyPath"` SSHCommand string `json:"sshCommand,omitempty"` // "" ⇒ this binary's transport + // TokenPath, CAPath and PinnedPublicKey apply when URL is https://. + // + // A path rather than the credential itself, exactly as KeyPath is: this + // struct is serialized into hooks.json, which travels between hosts and is + // readable by every hook process. The sidecar already holds its own token + // from enrollment, so there is nothing to send it. + TokenPath string `json:"tokenPath,omitempty"` + CAPath string `json:"caPath,omitempty"` + PinnedPublicKey string `json:"pinnedPubkey,omitempty"` +} + +// Transport describes how to reach pushURL, which is the target's endpoint +// joined with one repository's mailbox route. The token is read at the moment +// of use so a revoked-and-reissued credential takes effect without a redeploy. +func (t RelayTarget) Transport(pushURL string) (TransportTarget, error) { + target := TransportTarget{ + URL: pushURL, SSHCommand: t.SSHCommand, KeyPath: t.KeyPath, + HostFingerprint: t.HostFingerprint, + CAPath: t.CAPath, PinnedPublicKey: t.PinnedPublicKey, + } + if EndpointScheme(pushURL) != "https" { + return target, nil + } + token, err := ReadTokenFile(t.TokenPath) + if err != nil { + return TransportTarget{}, err + } + target.Token = token + return target, nil } // upstreamRejectedError distinguishes a supervisor verdict from a failure to @@ -173,13 +202,16 @@ func Relay(ctx context.Context, repo string, hookEnv []string, target RelayTarge } args = append(args, mailboxURL, result+":"+resultRef, control+":"+controlRef) - pairs, err := transportPairs(target.SSHCommand, target.KeyPath, target.HostFingerprint) + transport, err := target.Transport(mailboxURL) if err != nil { return err } // R1.4: unset only GIT_QUARANTINE_PATH; the object-directory variables // stay so the quarantined objects remain readable for the outbound pack. - env := envWith(RelayEnv(hookEnv), pairs...) + env, err := TransportEnv(RelayEnv(hookEnv), transport) + if err != nil { + return err + } feedback := &relayFeedbackWriter{dst: sideband} code, out, err := gitExitCodeStderr(ctx, repo, env, feedback, args...) if flushErr := feedback.flush(); err == nil && flushErr != nil { diff --git a/pkg/gitagent/scan.go b/pkg/gitagent/scan.go new file mode 100644 index 00000000..4dab28db --- /dev/null +++ b/pkg/gitagent/scan.go @@ -0,0 +1,276 @@ +// Snapshot reads of a receiver's task tree, for consumers that persist history +// rather than tail it. +// +// This is deliberately separate from the sidecar's log monitor +// (pkg/cli/gitagent_task_logs.go), which is incremental and stateful: it holds +// byte offsets so it can emit only newly-appended output to a terminal. An +// ingest pass wants the opposite — a complete, stateless picture it can upsert +// idempotently, so a re-scan after a crash converges instead of replaying. One +// shape cannot serve both without carrying the other's baggage. + +package gitagent + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + + "github.com/flanksource/captain/pkg/captainconfig" +) + +// TaskSnapshot is everything a receiver knows about one task at one instant. +type TaskSnapshot struct { + Task string + // State is nil when the directory exists but state.json has not landed yet + // (or is being rewritten), which is normal mid-dispatch. + State *TaskState + // Verdicts are every tier decision found, ordered by attempt then tier. + Verdicts []TierVerdict +} + +// Concluded reports the terminal outcome of a task, if it has one. +// +// The filesystem never records "this task is over", so it is derived: an +// accepted verdict at the supervisor tier ends the task, and so does a +// non-accepted one once the attempt budget is spent. Anything else is still in +// flight — a rejection with attempts remaining is explicitly not terminal +// (SPEC-git-agent-protocol §6.3, "rejection is not termination"). +func (s TaskSnapshot) Concluded() (VerdictStatus, bool) { + final, ok := s.finalVerdict() + if !ok { + return "", false + } + if final.Status == StatusAccepted { + return StatusAccepted, true + } + budget := 0 + if s.State != nil { + budget = s.State.Policy.MaxAttempts + } + if budget > 0 && final.Attempt >= budget { + return final.Status, true + } + return "", false +} + +// finalVerdict is the highest-attempt supervisor decision, falling back to the +// highest-attempt verdict of any tier when the supervisor has not spoken. +func (s TaskSnapshot) finalVerdict() (TierVerdict, bool) { + var best TierVerdict + found := false + for _, verdict := range s.Verdicts { + if found && verdict.Attempt < best.Attempt { + continue + } + // At equal attempt the supervisor's decision is the one that counts: + // it is the tier that integrates. + if found && verdict.Attempt == best.Attempt && best.Tier == "supervisor" { + continue + } + best, found = verdict, true + } + return best, found +} + +// IntegratedBranch is the branch an accepted task was integrated onto, taken +// from the integrate hook's finding. +func (s TaskSnapshot) IntegratedBranch() string { + for _, verdict := range s.Verdicts { + for _, finding := range verdict.Findings { + if finding.Hook == "integrate" && finding.Path != "" { + return finding.Path + } + } + } + return "" +} + +// Feedback is the first message the concluding verdict carried, for display. +func (v TierVerdict) Feedback() string { + for _, finding := range v.Findings { + if finding.Feedback != "" { + return finding.Feedback + } + if finding.Message != "" { + return finding.Message + } + } + return "" +} + +// ScanTasks reads every task recorded under a receiver repository. A repository +// with no task tree yields no snapshots and no error — that is the normal state +// of a mailbox nothing has been dispatched to yet. +func ScanTasks(repo string) ([]TaskSnapshot, error) { + root := filepath.Join(repo, "captain", "tasks") + entries, err := os.ReadDir(root) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + snapshots := make([]TaskSnapshot, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + // A directory whose name is not a valid task id is not ours; skip it + // rather than failing the whole scan. + if err := ValidateTaskID(entry.Name()); err != nil { + continue + } + snapshot, err := ScanTask(repo, entry.Name()) + if err != nil { + return nil, fmt.Errorf("task %s: %w", entry.Name(), err) + } + snapshots = append(snapshots, snapshot) + } + sort.Slice(snapshots, func(i, j int) bool { return snapshots[i].Task < snapshots[j].Task }) + return snapshots, nil +} + +// ScanTask reads one task's state and every verdict recorded for it. +func ScanTask(repo, task string) (TaskSnapshot, error) { + if err := ValidateTaskID(task); err != nil { + return TaskSnapshot{}, err + } + snapshot := TaskSnapshot{Task: task} + state, found, err := LoadTaskState(repo, task) + if err != nil { + return TaskSnapshot{}, err + } + if found { + snapshot.State = state + } + verdicts, err := scanVerdictDir(repo, task) + if err != nil { + return TaskSnapshot{}, err + } + snapshot.Verdicts = verdicts + return snapshot, nil +} + +func scanVerdictDir(repo, task string) ([]TierVerdict, error) { + entries, err := os.ReadDir(filepath.Join(taskStateDir(repo, task), "verdicts")) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + verdicts := make([]TierVerdict, 0, len(entries)) + for _, entry := range entries { + attempt, ok := VerdictAttempt(entry.Name()) + if !ok { + continue + } + verdict, found, err := LoadVerdict(repo, task, attempt) + if err != nil { + return nil, err + } + if !found || verdict == nil { + continue + } + verdicts = append(verdicts, *verdict) + } + sort.Slice(verdicts, func(i, j int) bool { + if verdicts[i].Attempt != verdicts[j].Attempt { + return verdicts[i].Attempt < verdicts[j].Attempt + } + return verdicts[i].Tier < verdicts[j].Tier + }) + return verdicts, nil +} + +// VerdictAttempt parses "3.json" into the attempt number it records. +func VerdictAttempt(name string) (int, bool) { + trimmed := strings.TrimSuffix(name, ".json") + if trimmed == name { + return 0, false + } + attempt, err := strconv.Atoi(trimmed) + if err != nil || attempt < 1 { + return 0, false + } + return attempt, true +} + +// The fixed on-disk layout every git-agent host uses, so enrollment, dispatch +// and ingest agree on where key material and repositories live without +// configuration. +const ( + // KeysDirName is the per-host directory beside the config file. + KeysDirName = ".captain" + // SandboxDirName holds this host's sandbox state under KeysDirName. + SandboxDirName = "sandbox" + // ServedReposDirName is the served root under the sandbox directory. + ServedReposDirName = "repos" +) + +// DefaultKeysDir anchors key material beside the config file: with the default +// ~/.captain.yaml this is ~/.captain/sandbox. Tests that redirect the config +// path get an isolated keys dir for free. +func DefaultKeysDir() (string, error) { + path, err := captainconfig.Path() + if err != nil { + return "", err + } + return filepath.Join(filepath.Dir(path), KeysDirName, SandboxDirName), nil +} + +// DefaultServedRoot is where a receiver keeps the repositories it serves. +func DefaultServedRoot() (string, error) { + keysDir, err := DefaultKeysDir() + if err != nil { + return "", err + } + return filepath.Join(keysDir, ServedReposDirName), nil +} + +// ServedRootFor resolves a backend's served root, honouring an explicit +// mailboxRoot option and falling back to the default layout. +func ServedRootFor(options map[string]any) (string, error) { + if root, _ := options["mailboxRoot"].(string); strings.TrimSpace(root) != "" { + return strings.TrimSpace(root), nil + } + return DefaultServedRoot() +} + +// ScanMailboxes lists the receiver repositories under a served root. Each is a +// bare mailbox serving one canonical repository. +func ScanMailboxes(servedRoot string) ([]Mailbox, error) { + entries, err := os.ReadDir(filepath.Join(servedRoot, MailboxesDir)) + if os.IsNotExist(err) { + return nil, nil + } + if err != nil { + return nil, err + } + mailboxes := make([]Mailbox, 0, len(entries)) + for _, entry := range entries { + if !entry.IsDir() { + continue + } + route := MailboxesDir + "/" + entry.Name() + if err := ValidateMailboxRoute(route); err != nil { + continue + } + mailbox := Mailbox{ + Path: filepath.Join(servedRoot, MailboxesDir, entry.Name()), + Route: route, + } + // The binding names the repository accepted work integrates into. A + // mailbox that has not been bound yet is still worth reporting; its + // tasks simply have no repository to display. + if binding, err := LoadMailboxBinding(mailbox.Path); err == nil { + mailbox.Repository = binding.Repository + } + mailboxes = append(mailboxes, mailbox) + } + sort.Slice(mailboxes, func(i, j int) bool { return mailboxes[i].Route < mailboxes[j].Route }) + return mailboxes, nil +} diff --git a/pkg/gitagent/scan_ginkgo_test.go b/pkg/gitagent/scan_ginkgo_test.go new file mode 100644 index 00000000..81694bde --- /dev/null +++ b/pkg/gitagent/scan_ginkgo_test.go @@ -0,0 +1,211 @@ +package gitagent + +import ( + "os" + "path/filepath" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The ingest watcher upserts whatever ScanTasks returns, so the scanner is the +// component that decides what history looks like. Two properties matter most: +// it must survive a half-written task directory (dispatch is not atomic across +// files), and it must not call a task finished while the protocol would still +// let the agent retry. +var _ = Describe("task scanning", func() { + var repo string + + BeforeEach(func() { + repo = GinkgoT().TempDir() + }) + + writeState := func(task string, state TaskState) { + state.Task = task + Expect(SaveTaskState(repo, &state)).To(Succeed()) + } + writeVerdict := func(task string, verdict TierVerdict) { + verdict.Task = task + Expect(SaveVerdict(repo, verdict)).To(Succeed()) + } + + It("returns nothing for a repository nothing was dispatched to", func() { + snapshots, err := ScanTasks(repo) + Expect(err).NotTo(HaveOccurred()) + Expect(snapshots).To(BeEmpty()) + }) + + It("reads a task's state and every tier's verdict, ordered", func() { + writeState("task-1", TaskState{Base: "main", DispatchCommit: "deadbeef", Attempts: 2}) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 2, Tier: "supervisor", Status: StatusAccepted}) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 1, Tier: "supervisor", Status: StatusRejected}) + + snapshots, err := ScanTasks(repo) + Expect(err).NotTo(HaveOccurred()) + Expect(snapshots).To(HaveLen(1)) + + snapshot := snapshots[0] + Expect(snapshot.Task).To(Equal("task-1")) + Expect(snapshot.State).NotTo(BeNil()) + Expect(snapshot.State.Attempts).To(Equal(2)) + Expect(snapshot.Verdicts).To(HaveLen(2)) + Expect(snapshot.Verdicts[0].Attempt).To(Equal(1), "verdicts sort by attempt") + Expect(snapshot.Verdicts[1].Attempt).To(Equal(2)) + }) + + // Dispatch writes state.json and the verdicts directory at different + // moments, so a scan can land between them. That must yield a partial + // snapshot, not an error that aborts the whole ingest pass. + It("tolerates a task directory with no state yet", func() { + Expect(os.MkdirAll(filepath.Join(repo, "captain", "tasks", "task-1"), 0o755)).To(Succeed()) + + snapshots, err := ScanTasks(repo) + Expect(err).NotTo(HaveOccurred()) + Expect(snapshots).To(HaveLen(1)) + Expect(snapshots[0].State).To(BeNil()) + Expect(snapshots[0].Verdicts).To(BeEmpty()) + }) + + It("ignores directories that are not task ids", func() { + Expect(os.MkdirAll(filepath.Join(repo, "captain", "tasks", "../escape"), 0o755)). + To(Or(Succeed(), HaveOccurred())) + Expect(os.MkdirAll(filepath.Join(repo, "captain", "tasks", "not a task id"), 0o755)).To(Succeed()) + writeState("task-1", TaskState{Base: "main", DispatchCommit: "deadbeef"}) + + snapshots, err := ScanTasks(repo) + Expect(err).NotTo(HaveOccurred()) + Expect(snapshots).To(HaveLen(1)) + Expect(snapshots[0].Task).To(Equal("task-1")) + }) + + Describe("deriving a terminal state", func() { + It("treats an accepted supervisor verdict as the end", func() { + writeState("task-1", TaskState{Base: "main", DispatchCommit: "c", Attempts: 1}) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 1, Tier: "supervisor", Status: StatusAccepted}) + + snapshot, err := ScanTask(repo, "task-1") + Expect(err).NotTo(HaveOccurred()) + status, done := snapshot.Concluded() + Expect(done).To(BeTrue()) + Expect(status).To(Equal(StatusAccepted)) + }) + + // §6.3: rejection is not termination. The agent may push again, so a + // rejected attempt with budget left is still in flight. + It("does not treat a rejection with attempts remaining as the end", func() { + writeState("task-1", TaskState{ + Base: "main", DispatchCommit: "c", Attempts: 1, + Policy: Policy{MaxAttempts: 3}, + }) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 1, Tier: "supervisor", Status: StatusRejected}) + + snapshot, err := ScanTask(repo, "task-1") + Expect(err).NotTo(HaveOccurred()) + _, done := snapshot.Concluded() + Expect(done).To(BeFalse()) + }) + + It("treats a rejection that exhausts the attempt budget as the end", func() { + writeState("task-1", TaskState{ + Base: "main", DispatchCommit: "c", Attempts: 3, + Policy: Policy{MaxAttempts: 3}, + }) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 3, Tier: "supervisor", Status: StatusRejected}) + + snapshot, err := ScanTask(repo, "task-1") + Expect(err).NotTo(HaveOccurred()) + status, done := snapshot.Concluded() + Expect(done).To(BeTrue()) + Expect(status).To(Equal(StatusRejected)) + }) + + // On disk a verdict is keyed by attempt alone (verdicts/.json), so one + // receiver holds at most one per attempt and a second write for the same + // attempt replaces the first. The store still keys on (attempt, tier) + // because the supervisor's mailbox and a sidecar's repo are separate + // trees whose verdicts coexist once both are ingested. + It("keeps only the last verdict written for an attempt", func() { + writeState("task-1", TaskState{Base: "main", DispatchCommit: "c", Attempts: 1}) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 1, Tier: "sidecar", Status: StatusAccepted}) + writeVerdict("task-1", TierVerdict{V: 1, Attempt: 1, Tier: "supervisor", Status: StatusRejected}) + + snapshot, err := ScanTask(repo, "task-1") + Expect(err).NotTo(HaveOccurred()) + Expect(snapshot.Verdicts).To(HaveLen(1)) + Expect(snapshot.Verdicts[0].Tier).To(Equal("supervisor")) + }) + + // Pure logic, exercised directly: once both tiers' verdicts have been + // ingested from their separate trees they sit side by side, and the + // supervisor's is the one that concludes the task because it integrates. + It("prefers the supervisor's decision over the sidecar's at equal attempt", func() { + snapshot := TaskSnapshot{ + Task: "task-1", + State: &TaskState{Attempts: 1, Policy: Policy{MaxAttempts: 1}}, + Verdicts: []TierVerdict{ + {Attempt: 1, Tier: "sidecar", Status: StatusAccepted}, + {Attempt: 1, Tier: "supervisor", Status: StatusRejected}, + }, + } + status, done := snapshot.Concluded() + Expect(done).To(BeTrue()) + Expect(status).To(Equal(StatusRejected), + "the sidecar accepting must not mask the supervisor rejecting") + }) + + It("reports no conclusion when no verdict has landed", func() { + writeState("task-1", TaskState{Base: "main", DispatchCommit: "c"}) + snapshot, err := ScanTask(repo, "task-1") + Expect(err).NotTo(HaveOccurred()) + _, done := snapshot.Concluded() + Expect(done).To(BeFalse()) + }) + }) + + It("surfaces the branch accepted work was integrated onto", func() { + writeState("task-1", TaskState{Base: "main", DispatchCommit: "c", Attempts: 1}) + writeVerdict("task-1", TierVerdict{ + V: 1, Attempt: 1, Tier: "supervisor", Status: StatusAccepted, + Findings: []Finding{{Hook: "integrate", Kind: "commit", Path: "captain/task-1"}}, + }) + + snapshot, err := ScanTask(repo, "task-1") + Expect(err).NotTo(HaveOccurred()) + Expect(snapshot.IntegratedBranch()).To(Equal("captain/task-1")) + }) + + Describe("VerdictAttempt", func() { + It("accepts a positive attempt file and rejects anything else", func() { + attempt, ok := VerdictAttempt("3.json") + Expect(ok).To(BeTrue()) + Expect(attempt).To(Equal(3)) + + for _, name := range []string{"0.json", "-1.json", "latest.json", "3", "3.txt", ".json"} { + _, ok := VerdictAttempt(name) + Expect(ok).To(BeFalse(), name) + } + }) + }) +}) + +var _ = Describe("mailbox scanning", func() { + It("returns nothing when no mailbox has been created", func() { + mailboxes, err := ScanMailboxes(GinkgoT().TempDir()) + Expect(err).NotTo(HaveOccurred()) + Expect(mailboxes).To(BeEmpty()) + }) + + It("lists only entries in the opaque mailbox namespace", func() { + root := GinkgoT().TempDir() + valid := "mailboxes/" + "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90" + ".git" + Expect(os.MkdirAll(filepath.Join(root, valid), 0o755)).To(Succeed()) + // A stray directory must not be reported as a mailbox: its tasks would + // be ingested under a route the protocol never issued. + Expect(os.MkdirAll(filepath.Join(root, "mailboxes", "scratch"), 0o755)).To(Succeed()) + + mailboxes, err := ScanMailboxes(root) + Expect(err).NotTo(HaveOccurred()) + Expect(mailboxes).To(HaveLen(1)) + Expect(mailboxes[0].Route).To(Equal(valid)) + }) +}) diff --git a/pkg/gitagent/server.go b/pkg/gitagent/server.go index de08bb59..83c94a06 100644 --- a/pkg/gitagent/server.go +++ b/pkg/gitagent/server.go @@ -34,14 +34,18 @@ const EnrollCommand = "captain-enroll" // AgentDirectory is the server's authorization source. Implementations read // live state on every call so revocation takes effect for new connections -// (R8.5) and a join token can be burned atomically (R8.2). +// (R8.5). type AgentDirectory interface { // AgentByFingerprint maps an SSH public-key SHA256 fingerprint to an // enrolled agent name. AgentByFingerprint(fingerprint string) (string, bool) - // ConsumeJoinToken validates and burns a single-use join token, returning - // the agent name it enrolls. - ConsumeJoinToken(token string) (string, error) + // AdmitToken verifies a durable captain token and resolves the agent it + // speaks for. requested is the name a returning member persisted from an + // earlier enrollment, honoured only when it is already on file. + // + // The token is not spent: an agent that restarts presents the same one + // (R8.2, amended), so this must be safe to call repeatedly. + AdmitToken(token, requested string) (string, error) // RecordAgent binds a key, an endpoint and a host key to an enrolled // agent — everything a dispatch to it needs. RecordAgent(AgentEnrollment) error @@ -113,7 +117,7 @@ func handleSession(s ssh.Session, root string, cfg ServerConfig) { // authorize later task-specific reverse pushes. func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []string) { if len(cmd) < 2 || strings.TrimSpace(cmd[1]) == "" { - fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll [request]") + fmt.Fprintln(s.Stderr(), "captain: usage: captain-enroll [request]") _ = s.Exit(1) return } @@ -123,7 +127,7 @@ func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []str _ = s.Exit(1) return } - name, err := cfg.Directory.ConsumeJoinToken(strings.TrimSpace(cmd[1])) + name, err := cfg.Directory.AdmitToken(strings.TrimSpace(cmd[1]), strings.TrimSpace(req.Agent)) if err != nil { fmt.Fprintf(s.Stderr(), "captain: enrollment refused: %v\n", err) _ = s.Exit(1) @@ -134,16 +138,14 @@ func handleEnroll(s ssh.Session, cfg ServerConfig, fingerprint string, cmd []str Fingerprint: fingerprint, URL: agentDispatchURL(req, s.RemoteAddr(), cfg.AgentRepoPath), HostFingerprint: strings.TrimSpace(req.HostFingerprint), + DispatchToken: strings.TrimSpace(req.DispatchToken), } if err := cfg.Directory.RecordAgent(enrollment); err != nil { fmt.Fprintf(s.Stderr(), "captain: enrollment failed: %v\n", err) _ = s.Exit(1) return } - resp, err := json.Marshal(EnrollResponse{ - Agent: name, - DispatchKey: cfg.Offer.DispatchKey, - }) + resp, err := json.Marshal(cfg.Offer.ResponseFor(name)) if err != nil { fmt.Fprintf(s.Stderr(), "captain: %v\n", err) _ = s.Exit(1) diff --git a/pkg/gitagent/server_ginkgo_test.go b/pkg/gitagent/server_ginkgo_test.go index 0cef98e7..6c2198b0 100644 --- a/pkg/gitagent/server_ginkgo_test.go +++ b/pkg/gitagent/server_ginkgo_test.go @@ -32,14 +32,20 @@ func (d *memoryDirectory) AgentByFingerprint(fp string) (string, bool) { return name, ok } -func (d *memoryDirectory) ConsumeJoinToken(token string) (string, error) { +// AdmitToken resolves a durable token. It does not spend it: the point of the +// change from a single-use join token is that a restarting sidecar can present +// the same one, so the stub has to be repeatable too or the suite would pass +// while production crash-looped. +func (d *memoryDirectory) AdmitToken(token, requested string) (string, error) { d.mu.Lock() defer d.mu.Unlock() - name, ok := d.pending[gitagent.HashJoinToken(token)] + name, ok := d.pending[token] if !ok { - return "", fmt.Errorf("join token is unknown or already used") + return "", fmt.Errorf("captain token is not recognized") + } + if requested != "" && requested != name { + return "", fmt.Errorf("token is bound to agent %q and cannot act as %q", name, requested) } - delete(d.pending, gitagent.HashJoinToken(token)) return name, nil } @@ -173,12 +179,11 @@ var _ = Describe("the git-agent SSH endpoint", func() { Expect(out).To(ContainSubstring("not served")) }) - It("enrolls both directions through a single-use join token and refuses replay (R8.2)", func() { + It("enrolls both directions through a durable captain token, and re-enrolls on replay (R8.2)", func() { root := GinkgoT().TempDir() dir := &memoryDirectory{agents: map[string]string{}, pending: map[string]string{}} - token, hash, err := gitagent.MintJoinToken() - Expect(err).NotTo(HaveOccurred()) - dir.pending[hash] = "worker-2" + const token = "cptn_worker2id.worker2secret" + dir.pending[token] = "worker-2" addr, hostFP := startTestServerWithOffer(dir, root, gitagent.RoleMailbox, gitagent.EnrollmentOffer{DispatchKey: "SHA256:dispatch"}) @@ -205,9 +210,18 @@ var _ = Describe("the git-agent SSH endpoint", func() { _, err = gitagent.MailboxURL("ssh://"+addr, "../other.git") Expect(err).To(MatchError(ContainSubstring("mailbox route"))) - // Replay fails: the token burned. - _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) - Expect(err).To(MatchError(ContainSubstring("already used"))) + // Replay succeeds and converges on the same agent. A long-lived sidecar + // restarts — a container with --restart, a Deployment rescheduling — and + // re-runs its whole startup path with the same token. Under the burned + // token this replaced, that was a permanent crash loop from the second + // start onward. + again, err := gitagent.Enroll(context.Background(), "ssh://"+addr, token, hostFP, signer, request) + Expect(err).NotTo(HaveOccurred()) + Expect(again.Agent).To(Equal("worker-2")) + + // A token that was never issued is still refused. + _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, "cptn_other.secret", hostFP, signer, request) + Expect(err).To(MatchError(ContainSubstring("not recognized"))) // A wrong host fingerprint is refused before the token is offered. _, err = gitagent.Enroll(context.Background(), "ssh://"+addr, token, "SHA256:bogus", signer, request) diff --git a/pkg/gitagent/tlscert.go b/pkg/gitagent/tlscert.go new file mode 100644 index 00000000..15216ea7 --- /dev/null +++ b/pkg/gitagent/tlscert.go @@ -0,0 +1,272 @@ +// TLS for the HTTPS transport (§8). The certificate lives beside the SSH host +// key and plays the same role: it is the thing an agent pins so that reaching +// the supervisor proves it is the supervisor, not merely something at that +// address. + +package gitagent + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/base64" + "encoding/pem" + "fmt" + "math/big" + "net" + "os" + "path/filepath" + "slices" + "sort" + "time" +) + +const ( + tlsCertName = "tls_cert.pem" + tlsKeyName = "tls_key.pem" + // tlsValidity is deliberately long. The certificate is pinned by the agents + // that hold it rather than chained to a CA, so replacing it costs a + // re-enrollment of every agent — an expiry would arrive as an unexplained + // outage months after anyone touched this. + tlsValidity = 10 * 365 * 24 * time.Hour +) + +// TLSCredential is the endpoint's certificate plus what a client needs in order +// to trust it. +type TLSCredential struct { + Certificate tls.Certificate + Leaf *x509.Certificate + // CertPath is the PEM a client passes as http.sslCAInfo. The certificate is + // its own trust anchor, so this file is both the leaf and the CA. + CertPath string + KeyPath string + // PublicKeyPin is the sha256// form git and curl accept for + // http.pinnedPubkey. Pinning is optional for a client — sslCAInfo already + // fixes trust to this exact certificate — but it survives a re-issue under + // the same key, which sslCAInfo does not. + PublicKeyPin string +} + +// EnsureTLSCredential loads the endpoint's certificate from dir, generating a +// self-signed one on first use that covers hosts as well as every address this +// machine can plausibly be reached on. +// +// It never silently re-issues. An agent is handed this exact certificate when +// its token is minted, so rotating it invalidates every enrolled agent at once +// — and the failure surfaces hours later as an unexplained push rejection +// rather than at the moment of the change. A certificate that does not cover a +// requested address is therefore an error naming the fix. +func EnsureTLSCredential(dir string, hosts []string) (*TLSCredential, error) { + certPath, keyPath := filepath.Join(dir, tlsCertName), filepath.Join(dir, tlsKeyName) + credential, err := LoadTLSCredential(certPath, keyPath) + if err == nil { + return credential, credential.Covers(hosts) + } + if !os.IsNotExist(err) { + return nil, err + } + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, err + } + err = withFileLock(certPath+".lock", 0o600, func() error { + // Another captain may have generated it while this one waited. + existing, loadErr := LoadTLSCredential(certPath, keyPath) + if loadErr == nil { + credential = existing + return credential.Covers(hosts) + } + if !os.IsNotExist(loadErr) { + return loadErr + } + credential, loadErr = generateTLSCredential(certPath, keyPath, hosts) + return loadErr + }) + if err != nil { + return nil, err + } + return credential, nil +} + +// LoadTLSCredential reads a certificate and key, which may be a real one an +// operator supplied rather than a generated self-signed pair. +func LoadTLSCredential(certPath, keyPath string) (*TLSCredential, error) { + // Statted first so a missing pair is os.ErrNotExist, which EnsureTLSCredential + // distinguishes from a broken one — LoadX509KeyPair wraps both alike. + for _, path := range []string{certPath, keyPath} { + if _, err := os.Stat(path); err != nil { + return nil, err + } + } + certificate, err := tls.LoadX509KeyPair(certPath, keyPath) + if err != nil { + return nil, fmt.Errorf("load TLS certificate %s: %w", certPath, err) + } + leaf, err := x509.ParseCertificate(certificate.Certificate[0]) + if err != nil { + return nil, fmt.Errorf("parse TLS certificate %s: %w", certPath, err) + } + certificate.Leaf = leaf + pin, err := publicKeyPin(leaf) + if err != nil { + return nil, err + } + return &TLSCredential{ + Certificate: certificate, Leaf: leaf, + CertPath: certPath, KeyPath: keyPath, PublicKeyPin: pin, + }, nil +} + +// Covers reports whether the certificate is valid for every host an agent will +// dial, naming the first one it is not. +func (c *TLSCredential) Covers(hosts []string) error { + for _, host := range hosts { + if host == "" { + continue + } + if err := c.Leaf.VerifyHostname(host); err != nil { + return fmt.Errorf( + "the TLS certificate at %s does not cover %q (it covers %s); "+ + "delete it and its key to re-issue, or supply your own certificate — "+ + "note that re-issuing means every enrolled agent must be re-enrolled", + c.CertPath, host, certificateCoverage(c.Leaf)) + } + } + return nil +} + +// certificateCoverage lists the names a certificate answers to, so a refusal +// says what is covered rather than only what is not. +func certificateCoverage(cert *x509.Certificate) string { + names := slices.Clone(cert.DNSNames) + for _, ip := range cert.IPAddresses { + names = append(names, ip.String()) + } + if len(names) == 0 { + return "nothing" + } + sort.Strings(names) + return fmt.Sprint(names) +} + +// PEM returns the certificate in the form a client stores as its trust anchor. +func (c *TLSCredential) PEM() ([]byte, error) { + return os.ReadFile(c.CertPath) +} + +func generateTLSCredential(certPath, keyPath string, hosts []string) (*TLSCredential, error) { + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("generate TLS key: %w", err) + } + serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128)) + if err != nil { + return nil, fmt.Errorf("generate TLS serial: %w", err) + } + dnsNames, ips := tlsSubjectNames(hosts) + now := time.Now().UTC() + template := x509.Certificate{ + SerialNumber: serial, + Subject: pkix.Name{CommonName: "captain git-agent", Organization: []string{"captain"}}, + // Backdated an hour so a client whose clock runs slightly behind does + // not reject a certificate generated moments ago. + NotBefore: now.Add(-time.Hour), + NotAfter: now.Add(tlsValidity), + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + // Its own trust anchor: a client passes this file as sslCAInfo, and a + // chain of one only verifies if the leaf is also a CA. + BasicConstraintsValid: true, + IsCA: true, + DNSNames: dnsNames, + IPAddresses: ips, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return nil, fmt.Errorf("create TLS certificate: %w", err) + } + keyDER, err := x509.MarshalECPrivateKey(key) + if err != nil { + return nil, fmt.Errorf("encode TLS key: %w", err) + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + // The key first: a certificate on disk without its key would make + // LoadTLSCredential fail as broken rather than absent, and the retry path + // would never regenerate it. + if err := writeFileAtomic(keyPath, keyPEM, 0o600); err != nil { + return nil, err + } + if err := writeFileAtomic(certPath, certPEM, 0o644); err != nil { + return nil, err + } + return LoadTLSCredential(certPath, keyPath) +} + +// tlsSubjectNames is everything a freshly generated certificate covers: the +// addresses the caller named, plus every address this host answers on. The +// generous default exists because the alternative — discovering at first push +// that the certificate omits the address agents actually dial — costs a +// re-enrollment of all of them to fix. +func tlsSubjectNames(extra []string) (dnsNames []string, ips []net.IP) { + // host.docker.internal is what a docker sidecar dials to reach the host, and + // it resolves only inside a container — so it is never this machine's own + // name and has to be named here rather than discovered from an interface. + names := map[string]struct{}{"localhost": {}, "host.docker.internal": {}} + addresses := map[string]net.IP{} + add := func(value string) { + if value = trimBrackets(value); value == "" { + return + } + if ip := net.ParseIP(value); ip != nil { + addresses[ip.String()] = ip + return + } + names[value] = struct{}{} + } + add("127.0.0.1") + add("::1") + if hostname, err := os.Hostname(); err == nil { + add(hostname) + } + if interfaceAddrs, err := net.InterfaceAddrs(); err == nil { + for _, addr := range interfaceAddrs { + if network, ok := addr.(*net.IPNet); ok { + add(network.IP.String()) + } + } + } + for _, host := range extra { + add(host) + } + for name := range names { + dnsNames = append(dnsNames, name) + } + for _, ip := range addresses { + ips = append(ips, ip) + } + sort.Strings(dnsNames) + sort.Slice(ips, func(i, j int) bool { return ips[i].String() < ips[j].String() }) + return dnsNames, ips +} + +func trimBrackets(value string) string { + if len(value) > 1 && value[0] == '[' && value[len(value)-1] == ']' { + return value[1 : len(value)-1] + } + return value +} + +// publicKeyPin renders the SubjectPublicKeyInfo digest in the sha256// +// form git and curl accept. +func publicKeyPin(cert *x509.Certificate) (string, error) { + spki, err := x509.MarshalPKIXPublicKey(cert.PublicKey) + if err != nil { + return "", fmt.Errorf("encode TLS public key: %w", err) + } + sum := sha256.Sum256(spki) + return "sha256//" + base64.StdEncoding.EncodeToString(sum[:]), nil +} diff --git a/pkg/gitagent/tlscert_ginkgo_test.go b/pkg/gitagent/tlscert_ginkgo_test.go new file mode 100644 index 00000000..eaeaafaa --- /dev/null +++ b/pkg/gitagent/tlscert_ginkgo_test.go @@ -0,0 +1,172 @@ +package gitagent_test + +import ( + "crypto/tls" + "crypto/x509" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/gitagent" +) + +var _ = Describe("TLS credential", func() { + var dir string + + BeforeEach(func() { dir = GinkgoT().TempDir() }) + + It("generates once and reuses the same certificate", func() { + first, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(first.CertPath).To(BeARegularFile()) + Expect(first.KeyPath).To(BeARegularFile()) + + second, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + // Re-issuing would invalidate every agent that pinned the first one, so + // the serial has to be identical rather than merely valid. + Expect(second.Leaf.SerialNumber).To(Equal(first.Leaf.SerialNumber)) + Expect(second.PublicKeyPin).To(Equal(first.PublicKeyPin)) + }) + + It("keeps the private key unreadable to other users", func() { + credential, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + + info, err := os.Stat(credential.KeyPath) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600))) + }) + + // The certificate has to cover the address agents actually dial. Discovering + // otherwise at first push costs a re-enrollment of every agent, so the + // default is generous. + It("covers loopback, this host, and any address the caller names", func() { + credential, err := gitagent.EnsureTLSCredential(dir, []string{"supervisor.internal", "203.0.113.7"}) + Expect(err).NotTo(HaveOccurred()) + + Expect(credential.Covers([]string{ + "localhost", "127.0.0.1", "::1", "supervisor.internal", "203.0.113.7", + // The name a docker sidecar dials; it resolves only inside a + // container, so nothing on this host would contribute it. + "host.docker.internal", + })).To(Succeed()) + + hostname, err := os.Hostname() + Expect(err).NotTo(HaveOccurred()) + Expect(credential.Covers([]string{hostname})).To(Succeed()) + }) + + // Silently re-issuing on a new address would break every enrolled agent at + // once, and the breakage would surface hours later as a push rejection. + It("refuses an address it does not cover rather than re-issuing", func() { + credential, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + serial := credential.Leaf.SerialNumber + + _, err = gitagent.EnsureTLSCredential(dir, []string{"elsewhere.example"}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("elsewhere.example")) + Expect(err.Error()).To(ContainSubstring("re-enrolled"), + "the error must say what re-issuing costs, not just that it failed") + + reloaded, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(reloaded.Leaf.SerialNumber).To(Equal(serial), "the refusal must not have rotated the certificate") + }) + + It("publishes a pin in the sha256// form git and curl accept", func() { + credential, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + + Expect(credential.PublicKeyPin).To(HavePrefix("sha256//")) + // The pin is over the SubjectPublicKeyInfo, so an independently derived + // digest of the same key must match. + spki, err := x509.MarshalPKIXPublicKey(credential.Leaf.PublicKey) + Expect(err).NotTo(HaveOccurred()) + Expect(spki).NotTo(BeEmpty()) + Expect(credential.PublicKeyPin).To(HaveLen(len("sha256//") + 44)) + }) + + // The proof that all of it is right: a real handshake, verified against the + // PEM an agent is handed, reaching the server over an IP address. This is + // what catches a missing IP SAN or the leaf not being its own CA. + It("serves a handshake that a client trusting only its PEM completes", func() { + credential, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + + server := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = io.WriteString(w, "ok") + })) + server.TLS = &tls.Config{Certificates: []tls.Certificate{credential.Certificate}, MinVersion: tls.VersionTLS12} + server.StartTLS() + DeferCleanup(server.Close) + + pemBytes, err := credential.PEM() + Expect(err).NotTo(HaveOccurred()) + pool := x509.NewCertPool() + Expect(pool.AppendCertsFromPEM(pemBytes)).To(BeTrue()) + + trusting := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, + }} + response, err := trusting.Get(server.URL) + Expect(err).NotTo(HaveOccurred()) + defer response.Body.Close() + Expect(response.StatusCode).To(Equal(http.StatusOK)) + + // A client that does not hold the PEM must be refused: trust comes from + // the pinned certificate, not from the connection succeeding. + stranger := &http.Client{Transport: &http.Transport{ + TLSClientConfig: &tls.Config{RootCAs: x509.NewCertPool(), MinVersion: tls.VersionTLS12}, + }} + _, err = stranger.Get(server.URL) + Expect(err).To(HaveOccurred()) + }) + + // A certificate whose key went missing must read as absent, so the next call + // regenerates instead of failing forever on a half-written pair. + It("regenerates when only the certificate survives", func() { + credential, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(os.Remove(credential.KeyPath)).To(Succeed()) + + regenerated, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + Expect(regenerated.Leaf.SerialNumber).NotTo(Equal(credential.Leaf.SerialNumber)) + }) + + It("loads a certificate an operator supplied instead", func() { + generated, err := gitagent.EnsureTLSCredential(dir, nil) + Expect(err).NotTo(HaveOccurred()) + + elsewhere := GinkgoT().TempDir() + certPath := filepath.Join(elsewhere, "server.crt") + keyPath := filepath.Join(elsewhere, "server.key") + for _, copied := range []struct{ from, to string }{ + {generated.CertPath, certPath}, {generated.KeyPath, keyPath}, + } { + data, err := os.ReadFile(copied.from) + Expect(err).NotTo(HaveOccurred()) + Expect(os.WriteFile(copied.to, data, 0o600)).To(Succeed()) + } + + supplied, err := gitagent.LoadTLSCredential(certPath, keyPath) + Expect(err).NotTo(HaveOccurred()) + Expect(supplied.PublicKeyPin).To(Equal(generated.PublicKeyPin)) + + _, err = gitagent.LoadTLSCredential(filepath.Join(elsewhere, "absent.crt"), keyPath) + Expect(os.IsNotExist(err)).To(BeTrue(), "a missing file must be distinguishable from a broken one") + + Expect(os.WriteFile(certPath, []byte("not a certificate"), 0o600)).To(Succeed()) + _, err = gitagent.LoadTLSCredential(certPath, keyPath) + Expect(err).To(HaveOccurred()) + Expect(strings.ToLower(err.Error())).To(ContainSubstring("tls certificate")) + }) +}) diff --git a/pkg/gitagent/tokenfile.go b/pkg/gitagent/tokenfile.go new file mode 100644 index 00000000..4a9c10ad --- /dev/null +++ b/pkg/gitagent/tokenfile.go @@ -0,0 +1,62 @@ +// Where an agent keeps the captain token it authenticates with. +// +// The token lives on the host that presents it and never travels in dispatch +// state, exactly like the SSH key it parallels — hooks.json carries the path, +// not the credential. + +package gitagent + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/flanksource/clicky/text" +) + +// TokenFileName is the agent's own credential, beside its keys. +const TokenFileName = "token" + +// DefaultTokenPath is where an agent stores the token it was enrolled with. +func DefaultTokenPath() (string, error) { + keysDir, err := DefaultKeysDir() + if err != nil { + return "", err + } + return filepath.Join(keysDir, TokenFileName), nil +} + +// WriteTokenFile stores a credential readable only by its owner. +func WriteTokenFile(path string, token text.SensitiveString) error { + if strings.TrimSpace(path) == "" { + return fmt.Errorf("a token path is required") + } + if token.IsEmpty() { + return fmt.Errorf("refusing to write an empty captain token to %s", path) + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + return writeFileAtomic(path, []byte(token.Value()+"\n"), 0o600) +} + +// ReadTokenFile loads the credential at path. +// +// An empty path is an error rather than an empty token: a push that reached +// this point over https needs a credential, and continuing without one would +// fail at the server as a 401 that looks like a revocation. +func ReadTokenFile(path string) (text.SensitiveString, error) { + if strings.TrimSpace(path) == "" { + return "", fmt.Errorf("this endpoint is reached over https, which needs a captain token, but none is configured for it") + } + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("read captain token %s: %w", path, err) + } + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("captain token file %s is empty", path) + } + return text.NewSensitiveString(token), nil +} diff --git a/pkg/monitor/backfill.go b/pkg/monitor/backfill.go index ef559946..2d5fa6f8 100644 --- a/pkg/monitor/backfill.go +++ b/pkg/monitor/backfill.go @@ -29,6 +29,10 @@ func (m *Monitor) backfill(ctx context.Context, ingestor *ingestor) { roots, agents := discoverTranscripts() ingestChanged(ctx, ingestor, roots) ingestChanged(ctx, ingestor, agents) + // Remote task history rides the same pass: it is cheap when no mailbox + // exists, and live task views read the mailbox directly rather than the + // database, so this cadence only bounds how stale *history* can be. + m.ingestGitAgentTasks(ctx) } func discoverTranscripts() (roots, agents []transcriptRef) { diff --git a/pkg/monitor/gitagent.go b/pkg/monitor/gitagent.go new file mode 100644 index 00000000..1b84117f --- /dev/null +++ b/pkg/monitor/gitagent.go @@ -0,0 +1,204 @@ +// Ingest of remote git-agent task history into Postgres. +// +// This runs on the supervisor, in captain serve, alongside the transcript +// backfill — and only there, for two reasons. Every supervisor-side write to a +// task tree happens under one deterministic root (the mailbox), and serve is +// already the single DB writer holding the monitor's advisory lock. The agent +// host runs `git-agent serve` with no database at all, so a watcher that finds +// no mailbox root simply does nothing. +// +// It is a watcher rather than a write-through from the dispatch adapter because +// AwaitOutcome returns only the *final* verdict: every intermediate rejected +// attempt, and the relay error path, would be invisible to a write-through. It +// also means a plain `captain ai prompt` run, which has no DB handle, still +// gets its history recorded by whatever serve is running. + +package monitor + +import ( + "context" + "time" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/gitagent" +) + +// ingestGitAgentTasks records every task found under every configured +// git-agent backend's mailbox root. It is idempotent: the store upserts on +// natural keys, so re-running it over unchanged state changes nothing. +func (m *Monitor) ingestGitAgentTasks(ctx context.Context) { + for _, root := range gitAgentMailboxRoots() { + mailboxes, err := gitagent.ScanMailboxes(root.servedRoot) + if err != nil { + log.Warnf("git-agent ingest: scan %s: %v", root.servedRoot, err) + continue + } + for _, mailbox := range mailboxes { + if err := m.ingestMailbox(ctx, root.backend, mailbox); err != nil { + log.Warnf("git-agent ingest: mailbox %s: %v", mailbox.Route, err) + } + if ctx.Err() != nil { + return + } + } + } + // Fill in prompt-run links that could not exist when the task was recorded: + // the run row is written only after the run finishes. + if linked, err := m.db.LinkGitAgentTasksToPromptRuns(ctx); err != nil { + log.Warnf("git-agent ingest: link prompt runs: %v", err) + } else if linked > 0 { + log.Infof("git-agent ingest: linked %d task(s) to their prompt run", linked) + } +} + +type gitAgentRoot struct { + backend string + servedRoot string +} + +// gitAgentMailboxRoots resolves the served root of every configured git-agent +// backend. A malformed config is logged and skipped rather than aborting the +// pass: one bad backend must not stop history for the others. +func gitAgentMailboxRoots() []gitAgentRoot { + cfg, _, err := captainconfig.Load() + if err != nil { + log.Warnf("git-agent ingest: load config: %v", err) + return nil + } + roots := make([]gitAgentRoot, 0, len(cfg.Sandbox.Backends)) + seen := map[string]bool{} + for name, backend := range cfg.Sandbox.Backends { + kind, ok := api.ParseSandboxKind(backend.Kind) + if !ok || kind != api.SandboxGitAgent { + continue + } + servedRoot, err := gitagent.ServedRootFor(backend.Options) + if err != nil { + log.Warnf("git-agent ingest: backend %s: %v", name, err) + continue + } + // Two backends may share a root; scanning it twice would be harmless but + // wasteful, and would attribute the same task to both. + if seen[servedRoot] { + continue + } + seen[servedRoot] = true + roots = append(roots, gitAgentRoot{backend: name, servedRoot: servedRoot}) + } + return roots +} + +func (m *Monitor) ingestMailbox(ctx context.Context, backend string, mailbox gitagent.Mailbox) error { + snapshots, err := gitagent.ScanTasks(mailbox.Path) + if err != nil { + return err + } + for _, snapshot := range snapshots { + if ctx.Err() != nil { + return ctx.Err() + } + if err := m.ingestTask(ctx, backend, mailbox, snapshot); err != nil { + log.Warnf("git-agent ingest: task %s: %v", snapshot.Task, err) + } + } + return nil +} + +func (m *Monitor) ingestTask(ctx context.Context, backend string, + mailbox gitagent.Mailbox, snapshot gitagent.TaskSnapshot, +) error { + input := database.UpsertGitAgentTaskInput{ + TaskID: snapshot.Task, + Mailbox: mailbox.Route, + Repository: mailbox.Repository, + Backend: backend, + Status: gitAgentLiveStatus(snapshot), + } + if state := snapshot.State; state != nil { + input.Agent = state.Agent + input.Base = state.Base + input.DispatchCommit = state.DispatchCommit + input.ControlCommit = state.ControlCommit + input.Relay = string(state.Relay) + input.Attempts = state.Attempts + input.MaxAttempts = state.Policy.MaxAttempts + input.DispatchedAt = state.UpdatedAt + input.Policy = map[string]any{ + "paths": state.Policy.Paths, + "maxAttempts": state.Policy.MaxAttempts, + "maxBlobSize": state.Policy.MaxBlobSize, + } + } + id, err := m.db.UpsertGitAgentTask(ctx, input) + if err != nil { + return err + } + + for _, verdict := range snapshot.Verdicts { + findings := make([]map[string]any, 0, len(verdict.Findings)) + for _, finding := range verdict.Findings { + findings = append(findings, map[string]any{ + "hook": finding.Hook, "kind": finding.Kind, + "path": finding.Path, "message": finding.Message, + "feedback": finding.Feedback, + }) + } + tier := verdict.Tier + if tier == "" { + // The schema constrains tier to the two the protocol defines; a + // verdict written without one is the supervisor's, since that is the + // only tier whose verdicts reach a mailbox. + tier = "supervisor" + } + if err := m.db.RecordGitAgentAttempt(ctx, database.RecordGitAgentAttemptInput{ + TaskID: id, + Attempt: verdict.Attempt, + Tier: tier, + Status: database.GitAgentVerdictStatus(verdict.Status), + ProtocolVersion: verdict.V, + Findings: findings, + Feedback: verdict.Feedback(), + }); err != nil { + return err + } + } + + if status, done := snapshot.Concluded(); done { + return m.db.ConcludeGitAgentTask(ctx, id, + gitAgentTerminalStatus(status), database.GitAgentVerdictStatus(status), + snapshot.IntegratedBranch(), gitAgentConcludedAt(snapshot)) + } + return nil +} + +// gitAgentLiveStatus is the non-terminal state a scan can observe. The store +// never lets these overwrite a task that has already concluded. +func gitAgentLiveStatus(snapshot gitagent.TaskSnapshot) database.GitAgentTaskStatus { + if snapshot.State != nil && snapshot.State.Attempts > 0 { + return database.GitAgentTaskRunning + } + return database.GitAgentTaskDispatched +} + +func gitAgentTerminalStatus(status gitagent.VerdictStatus) database.GitAgentTaskStatus { + switch status { + case gitagent.StatusAccepted: + return database.GitAgentTaskAccepted + case gitagent.StatusRejected: + return database.GitAgentTaskRejected + default: + return database.GitAgentTaskErrored + } +} + +// gitAgentConcludedAt uses the task state's last write as the conclusion time. +// The verdict file carries no timestamp of its own, and state.json is rewritten +// as part of recording the verdict. +func gitAgentConcludedAt(snapshot gitagent.TaskSnapshot) time.Time { + if snapshot.State != nil && !snapshot.State.UpdatedAt.IsZero() { + return snapshot.State.UpdatedAt + } + return time.Now().UTC() +} diff --git a/pkg/monitor/gitagent_integration_test.go b/pkg/monitor/gitagent_integration_test.go new file mode 100644 index 00000000..28a00403 --- /dev/null +++ b/pkg/monitor/gitagent_integration_test.go @@ -0,0 +1,138 @@ +package monitor + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/gitagent" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const fixtureMailbox = "mailboxes/" + + "a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f90.git" + +// gitAgentFixture stands up an isolated config naming one git-agent backend, +// plus its mailbox tree, and returns the mailbox path tasks are written into. +func gitAgentFixture(t *testing.T) string { + t.Helper() + configPath := filepath.Join(t.TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(configPath) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) + + servedRoot := t.TempDir() + require.NoError(t, captainconfig.Update(func(cfg *captainconfig.Config) error { + cfg.Sandbox.Backends = map[string]captainconfig.SandboxBackend{ + "prod-pool": {Kind: "git-agent", Options: map[string]any{"mailboxRoot": servedRoot}}, + } + return nil + })) + + mailbox := filepath.Join(servedRoot, fixtureMailbox) + require.NoError(t, os.MkdirAll(mailbox, 0o755)) + return mailbox +} + +func TestGitAgentIngestRecordsDispatchAndVerdicts(t *testing.T) { + db := openMonitorTestDB(t) + monitor, err := New(Config{DB: db, HostID: "test"}) + require.NoError(t, err) + mailbox := gitAgentFixture(t) + + require.NoError(t, gitagent.SaveTaskState(mailbox, &gitagent.TaskState{ + Task: "task-1", Agent: "worker-01", Base: "main", DispatchCommit: "deadbeef", + Attempts: 1, Relay: "sync", Policy: gitagent.Policy{Paths: []string{"pkg/**"}, MaxAttempts: 3}, + UpdatedAt: time.Now().UTC(), + })) + + monitor.ingestGitAgentTasks(t.Context()) + + tasks, err := db.ListGitAgentTasks(t.Context(), database.ListGitAgentTasksFilter{}) + require.NoError(t, err) + require.Len(t, tasks, 1) + assert.Equal(t, "task-1", tasks[0].TaskID) + assert.Equal(t, "prod-pool", tasks[0].Backend) + assert.Equal(t, "worker-01", tasks[0].Agent) + assert.Equal(t, fixtureMailbox, tasks[0].Mailbox) + // One attempt recorded but no verdict yet: the task is still in flight. + assert.Equal(t, database.GitAgentTaskRunning, tasks[0].Status) + + t.Run("a rejection with attempts left keeps the task open", func(t *testing.T) { + require.NoError(t, gitagent.SaveVerdict(mailbox, gitagent.TierVerdict{ + V: 1, Task: "task-1", Attempt: 1, Tier: "supervisor", Status: gitagent.StatusRejected, + Findings: []gitagent.Finding{{Hook: "verify", Kind: "exec", Message: "make lint failed"}}, + })) + monitor.ingestGitAgentTasks(t.Context()) + + detail, ok, err := db.GetGitAgentTask(t.Context(), fixtureMailbox, "task-1") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, database.GitAgentTaskRunning, detail.Task.Status, + "rejection is not termination while the attempt budget has room") + require.Len(t, detail.Attempts, 1) + assert.Equal(t, database.GitAgentVerdictRejected, detail.Attempts[0].Status) + require.Len(t, detail.Attempts[0].Findings, 1) + assert.Equal(t, "make lint failed", detail.Attempts[0].Findings[0]["message"]) + }) + + t.Run("an accepted verdict concludes the task", func(t *testing.T) { + require.NoError(t, gitagent.SaveTaskState(mailbox, &gitagent.TaskState{ + Task: "task-1", Agent: "worker-01", Base: "main", DispatchCommit: "deadbeef", + Attempts: 2, Policy: gitagent.Policy{MaxAttempts: 3}, UpdatedAt: time.Now().UTC(), + })) + require.NoError(t, gitagent.SaveVerdict(mailbox, gitagent.TierVerdict{ + V: 1, Task: "task-1", Attempt: 2, Tier: "supervisor", Status: gitagent.StatusAccepted, + Findings: []gitagent.Finding{{Hook: "integrate", Kind: "commit", Path: "captain/task-1"}}, + })) + monitor.ingestGitAgentTasks(t.Context()) + + detail, ok, err := db.GetGitAgentTask(t.Context(), fixtureMailbox, "task-1") + require.NoError(t, err) + require.True(t, ok) + assert.Equal(t, database.GitAgentTaskAccepted, detail.Task.Status) + require.NotNil(t, detail.Task.FinalStatus) + assert.Equal(t, database.GitAgentVerdictAccepted, *detail.Task.FinalStatus) + assert.Equal(t, "captain/task-1", detail.Task.IntegratedBranch) + assert.Equal(t, 2, detail.Task.Attempts) + require.NotNil(t, detail.Task.ConcludedAt) + }) + + // The pass runs on every backfill tick, so replaying unchanged state must be + // a no-op rather than duplicating rows or reopening a concluded task. + t.Run("re-running the pass over unchanged state changes nothing", func(t *testing.T) { + monitor.ingestGitAgentTasks(t.Context()) + monitor.ingestGitAgentTasks(t.Context()) + + tasks, err := db.ListGitAgentTasks(t.Context(), database.ListGitAgentTasksFilter{}) + require.NoError(t, err) + require.Len(t, tasks, 1) + assert.Equal(t, database.GitAgentTaskAccepted, tasks[0].Status) + + detail, ok, err := db.GetGitAgentTask(t.Context(), fixtureMailbox, "task-1") + require.NoError(t, err) + require.True(t, ok) + assert.Len(t, detail.Attempts, 2, "verdicts are keyed by attempt, not appended") + }) +} + +// The agent host runs the receiver with no database and no configured backend; +// the pass must find nothing to do rather than error. +func TestGitAgentIngestIsANoOpWithoutAMailbox(t *testing.T) { + db := openMonitorTestDB(t) + monitor, err := New(Config{DB: db, HostID: "test"}) + require.NoError(t, err) + + configPath := filepath.Join(t.TempDir(), ".captain.yaml") + captainconfig.SetPathForTesting(configPath) + t.Cleanup(func() { captainconfig.SetPathForTesting("") }) + + monitor.ingestGitAgentTasks(t.Context()) + + tasks, err := db.ListGitAgentTasks(t.Context(), database.ListGitAgentTasksFilter{}) + require.NoError(t, err) + assert.Empty(t, tasks) +} diff --git a/pkg/sandbox/adapter/gitagent.go b/pkg/sandbox/adapter/gitagent.go index c95aa5f9..bf739bdf 100644 --- a/pkg/sandbox/adapter/gitagent.go +++ b/pkg/sandbox/adapter/gitagent.go @@ -17,6 +17,7 @@ import ( "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" ) // GitAgent constructs the remote-execution adapter. Backend options carry the @@ -98,6 +99,7 @@ func (g *gitAgentSandbox) Execute(ctx context.Context, spec api.Spec) (*api.Resp Agent: target.agent, SidecarURL: target.url, SidecarHostFP: target.hostFingerprint, + Token: target.token, KeyPath: target.keyPath, Relay: target.relay, Policy: target.policy, @@ -158,11 +160,14 @@ type gitAgentTarget struct { agent string url string hostFingerprint string - keyPath string - mailboxRoot string - relay gitagent.RelayMode - policy gitagent.Policy - waitTimeout time.Duration + // token authenticates this supervisor to an https agent, and is empty for an + // ssh one, which authenticates by key instead. + token text.SensitiveString + keyPath string + mailboxRoot string + relay gitagent.RelayMode + policy gitagent.Policy + waitTimeout time.Duration } // resolveTarget picks the enrolled agent — pinned by the spec's sandbox.agent, @@ -185,13 +190,16 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { return nil, fmt.Errorf("agent %q is not enrolled in backend %q", name, g.cfg.Name) } url, _ := entry["url"].(string) - hostFP, _ := entry["hostFingerprint"].(string) - if url == "" || hostFP == "" { + if url == "" { return nil, fmt.Errorf( "agent %q has no endpoint recorded: it enrolled before advertising one. "+ - "Re-enroll it (captain sandbox git-agent add %s) so its serve reports its URL and host key", + "Re-enroll it (captain sandbox git-agent add %s) so its serve reports its URL", name, name) } + hostFP, token, err := dispatchCredentials(name, url, entry) + if err != nil { + return nil, err + } keysDir, err := gitAgentKeysDir() if err != nil { return nil, err @@ -200,6 +208,7 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { agent: name, url: url, hostFingerprint: hostFP, + token: token, keyPath: stringOption(opts, "key", filepath.Join(keysDir, dispatchKeyFile)), // The long-running endpoint serves this root; each dispatch derives a // repository-specific mailbox beneath it from the request working tree. @@ -213,6 +222,45 @@ func (g *gitAgentSandbox) resolveTarget() (*gitAgentTarget, error) { return target, nil } +// dispatchCredentials resolves how this supervisor authenticates to an agent, +// which the agent's own URL scheme decides. +// +// CAPath and PinnedPublicKey are deliberately left empty for https. The agent is +// fronted by an ingress holding a publicly trusted certificate for its own +// hostname, so the git client verifies it by name through the system trust +// store; pinning here would break on every certificate renewal. +func dispatchCredentials(name, endpoint string, entry map[string]any) (string, text.SensitiveString, error) { + switch scheme := gitagent.EndpointScheme(endpoint); scheme { + case "ssh": + hostFP, _ := entry["hostFingerprint"].(string) + if hostFP == "" { + return "", "", fmt.Errorf( + "agent %q has no host key recorded: it enrolled before advertising one. "+ + "Re-enroll it (captain sandbox git-agent add %s) so its serve reports its URL and host key", + name, name) + } + return hostFP, "", nil + case "https": + path, _ := entry["tokenPath"].(string) + if path == "" { + return "", "", fmt.Errorf( + "agent %q is reached over https but issued this supervisor no dispatch token, so there is "+ + "nothing to authenticate with; re-enroll it (captain sandbox git-agent add %s)", name, name) + } + // Read at the moment of use, exactly as the relay does, so a re-enrolled + // agent's rotated token takes effect without restarting the supervisor. + token, err := gitagent.ReadTokenFile(path) + if err != nil { + return "", "", fmt.Errorf("agent %q: %w", name, err) + } + return "", token, nil + default: + return "", "", fmt.Errorf( + "agent %q advertised %s, whose scheme %q is not a transport captain speaks; want ssh:// or https://", + name, endpoint, scheme) + } +} + func stringOption(opts map[string]any, key, fallback string) string { if v, ok := opts[key].(string); ok && v != "" { return v diff --git a/pkg/sandbox/adapter/gitagent_dispatch_credentials_test.go b/pkg/sandbox/adapter/gitagent_dispatch_credentials_test.go new file mode 100644 index 00000000..766ed88f --- /dev/null +++ b/pkg/sandbox/adapter/gitagent_dispatch_credentials_test.go @@ -0,0 +1,118 @@ +package adapter + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/gitagent" + "github.com/flanksource/clicky/text" +) + +const testDispatchToken = "cptn_aaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + +// Which credential is required is decided by the agent's own URL scheme, so an +// agent the supervisor can reach but not authenticate to is refused here rather +// than at the first push. +func TestDispatchCredentials(t *testing.T) { + t.Run("an ssh agent dispatches with its pinned host key", func(t *testing.T) { + hostFP, token, err := dispatchCredentials("w1", "ssh://captain@h:7422/repo.git", + map[string]any{"hostFingerprint": "SHA256:abc"}) + if err != nil { + t.Fatal(err) + } + if hostFP != "SHA256:abc" { + t.Fatalf("hostFingerprint = %q", hostFP) + } + if !token.IsEmpty() { + t.Fatal("an ssh dispatch was given a bearer token it does not send") + } + }) + + t.Run("an https agent dispatches with its bearer token", func(t *testing.T) { + path := filepath.Join(t.TempDir(), "w1.token") + if err := gitagent.WriteTokenFile(path, text.NewSensitiveString(testDispatchToken)); err != nil { + t.Fatal(err) + } + hostFP, token, err := dispatchCredentials("w1", "https://w1.example.com/git/repo.git", + map[string]any{"tokenPath": path}) + if err != nil { + t.Fatal(err) + } + if token.Value() != testDispatchToken { + t.Fatalf("token = %q", token.Value()) + } + // The ingress presents a publicly trusted certificate for the agent's own + // name, so there is nothing to pin and no host key to compare. + if hostFP != "" { + t.Fatalf("hostFingerprint = %q, want none for https", hostFP) + } + }) + + t.Run("refusals name the agent and the fix", func(t *testing.T) { + for _, tc := range []struct { + name, url string + entry map[string]any + want string + }{{ + name: "ssh with no host key", url: "ssh://h:7422/repo.git", + entry: map[string]any{}, want: "no host key recorded", + }, { + name: "https with no token path", url: "https://w1.example.com/git/repo.git", + entry: map[string]any{}, want: "issued this supervisor no dispatch token", + }, { + // A host key proves nothing about an HTTPS endpoint, so carrying only + // one must not be mistaken for a usable credential. + name: "https carrying only a host key", url: "https://w1.example.com/git/repo.git", + entry: map[string]any{"hostFingerprint": "SHA256:abc"}, want: "no dispatch token", + }, { + name: "https whose token file is gone", url: "https://w1.example.com/git/repo.git", + entry: map[string]any{"tokenPath": "/nonexistent/w1.token"}, want: "/nonexistent/w1.token", + }, { + name: "a scheme captain does not speak", url: "git://h/repo.git", + entry: map[string]any{}, want: "not a transport captain speaks", + }} { + t.Run(tc.name, func(t *testing.T) { + _, _, err := dispatchCredentials("w1", tc.url, tc.entry) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("err = %v, want it to name %q", err, tc.want) + } + }) + } + }) +} + +// The token rides a DispatchRequest that error paths format with %w around +// values that may be printed. text.SensitiveString redacting under every verb is +// what keeps it out of logs, so it is asserted rather than assumed. +func TestDispatchRequestRedactsTheToken(t *testing.T) { + req := gitagent.DispatchRequest{ + SidecarURL: "https://w1.example.com/git/repo.git", + Token: text.NewSensitiveString(testDispatchToken), + } + for _, rendered := range []string{ + fmt.Sprintf("%v", req), fmt.Sprintf("%+v", req), fmt.Sprintf("%s", req.Token), req.Token.String(), + } { + if strings.Contains(rendered, testDispatchToken) { + t.Fatalf("the dispatch token survived formatting: %s", rendered) + } + } +} + +// The supervisor holds this credential at rest, so its file must be no more +// readable than the ssh key it replaces. +func TestDispatchTokenFileIsOwnerOnly(t *testing.T) { + path := filepath.Join(t.TempDir(), "w1.token") + if err := gitagent.WriteTokenFile(path, text.NewSensitiveString(testDispatchToken)); err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Fatalf("mode = %v, want 0600", info.Mode().Perm()) + } +} diff --git a/pkg/sandbox/runtime_sockets.go b/pkg/sandbox/runtime_sockets.go new file mode 100644 index 00000000..370a81d1 --- /dev/null +++ b/pkg/sandbox/runtime_sockets.go @@ -0,0 +1,28 @@ +package sandbox + +import "path/filepath" + +// ContainerRuntimeSockets lists the container-runtime endpoints that must never +// be reachable from a sandbox. +// +// Write access to any of these is a full host escape: a process that can talk +// to the daemon can start a privileged container bind-mounting `/`, which makes +// every other confinement in the system decorative. SPEC-git-agent-protocol +// R5.3 states this is "not waivable by configuration". +// +// It lives here, rather than beside either caller, because two unrelated +// subsystems have to agree on it: the SRT adapter denies reads of these paths, +// and git-agent deployment refuses to mount them. A second copy would drift, +// and the failure mode of drift is a silent escape hatch. +// +// home is the sandboxed user's home directory; rootless Docker keeps its socket +// under it. +func ContainerRuntimeSockets(home string) []string { + return []string{ + filepath.Join(home, ".docker", "run", "docker.sock"), + "/var/run/docker.sock", + "/run/docker.sock", + "/run/containerd/containerd.sock", + "/run/podman/podman.sock", + } +} From ecf4e15bbdfaa1daaeab9b60d4cad6724b264730 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 18 Aug 2026 19:25:16 +0300 Subject: [PATCH 02/22] feat(sandbox): Add redacted agent login mirroring to sandbox destinations Add opt-in mirroring for Claude and Codex subscription logins with refresh-token redaction and expiry tracking. Publish credentials atomically to host directories or Kubernetes Secrets, refresh them from `serve`, and expose local-only status and sync controls. Wire acquired logins through sandbox isolation while centralizing token-provider selection and resolving model credentials from the Captain vault. --- pkg/agentcreds/agentcreds.go | 81 +++++ pkg/agentcreds/agentcreds_suite_test.go | 13 + pkg/agentcreds/expiry.go | 53 +++ pkg/agentcreds/redact.go | 153 ++++++++ pkg/agentcreds/redact_test.go | 187 ++++++++++ pkg/agentcreds/source.go | 161 +++++++++ pkg/captainconfig/config.go | 1 + pkg/captainconfig/credentials.go | 100 ++++++ pkg/cli/ai_models.go | 74 ++-- pkg/cli/ai_models_ginkgo_test.go | 58 ++++ pkg/cli/ai_models_test.go | 21 +- pkg/cli/container_interactive.go | 76 +--- pkg/cli/sandbox.go | 4 +- pkg/cli/sandbox_credentials.go | 257 ++++++++++++++ pkg/cli/serve_credentials.go | 54 +++ pkg/cli/serve_sandbox_credentials.go | 246 +++++++++++++ pkg/cli/serve_sandbox_credentials_test.go | 151 ++++++++ .../webapp/src/SandboxCredentials.test.tsx | 170 +++++++++ pkg/cli/webapp/src/SandboxCredentials.tsx | 327 ++++++++++++++++++ pkg/container/tui.go | 57 +-- pkg/credsync/credsync_suite_test.go | 13 + pkg/credsync/publisher.go | 197 +++++++++++ pkg/credsync/publisher_test.go | 253 ++++++++++++++ pkg/credsync/target_dir.go | 73 ++++ pkg/credsync/target_k8s.go | 77 +++++ pkg/sandbox/adapter/cli_env.go | 8 +- pkg/sandbox/adapter/srt.go | 81 ++++- pkg/sandbox/adapter/srt_test.go | 52 ++- pkg/sandbox/adapter/tokens.go | 137 ++++++++ pkg/sandbox/token_providers.go | 141 ++++++++ pkg/sandbox/token_providers_test.go | 99 ++++++ pkg/sandbox/tokens.go | 24 ++ pkg/sandbox/tokens_agentcli.go | 115 ++++++ pkg/sandbox/tokens_agentcli_test.go | 199 +++++++++++ 34 files changed, 3516 insertions(+), 197 deletions(-) create mode 100644 pkg/agentcreds/agentcreds.go create mode 100644 pkg/agentcreds/agentcreds_suite_test.go create mode 100644 pkg/agentcreds/expiry.go create mode 100644 pkg/agentcreds/redact.go create mode 100644 pkg/agentcreds/redact_test.go create mode 100644 pkg/agentcreds/source.go create mode 100644 pkg/captainconfig/credentials.go create mode 100644 pkg/cli/ai_models_ginkgo_test.go create mode 100644 pkg/cli/sandbox_credentials.go create mode 100644 pkg/cli/serve_credentials.go create mode 100644 pkg/cli/serve_sandbox_credentials.go create mode 100644 pkg/cli/serve_sandbox_credentials_test.go create mode 100644 pkg/cli/webapp/src/SandboxCredentials.test.tsx create mode 100644 pkg/cli/webapp/src/SandboxCredentials.tsx create mode 100644 pkg/credsync/credsync_suite_test.go create mode 100644 pkg/credsync/publisher.go create mode 100644 pkg/credsync/publisher_test.go create mode 100644 pkg/credsync/target_dir.go create mode 100644 pkg/credsync/target_k8s.go create mode 100644 pkg/sandbox/adapter/tokens.go create mode 100644 pkg/sandbox/token_providers.go create mode 100644 pkg/sandbox/token_providers_test.go create mode 100644 pkg/sandbox/tokens_agentcli.go create mode 100644 pkg/sandbox/tokens_agentcli_test.go diff --git a/pkg/agentcreds/agentcreds.go b/pkg/agentcreds/agentcreds.go new file mode 100644 index 00000000..1f1375da --- /dev/null +++ b/pkg/agentcreds/agentcreds.go @@ -0,0 +1,81 @@ +// Package agentcreds reads the subscription logins the agent CLIs keep on the +// host, strips the refresh token, and reports when the remainder expires. +// +// Captain has always detected these logins by existence alone +// (pkg/ai/adapters.go stats ~/.claude/.credentials.json and ~/.codex/auth.json +// without opening them). This package is the first thing that reads them, and +// it exists so a sandbox can be handed a credential that authenticates but +// cannot mint a new one: the refresh token stays on the host. +// +// That deliberately makes the redacted credential short-lived, which is why +// ExpiresAt is part of the contract rather than an afterthought — every +// consumer has to republish before it lapses. +package agentcreds + +import ( + "fmt" + "strings" + "time" +) + +// Provider names a credential source. The values are the tokens used in +// configuration (`tokens: {claude: {}}`) and as CLI arguments. +type Provider string + +const ( + ProviderClaude Provider = "claude" + ProviderCodex Provider = "codex" +) + +// Providers returns every supported provider in a stable order. +func Providers() []Provider { return []Provider{ProviderClaude, ProviderCodex} } + +// ParseProvider resolves a user-supplied provider name. +func ParseProvider(name string) (Provider, error) { + switch p := Provider(strings.ToLower(strings.TrimSpace(name))); p { + case ProviderClaude, ProviderCodex: + return p, nil + default: + return "", fmt.Errorf("unknown credential provider %q (want one of: claude, codex)", name) + } +} + +// Credential is one provider's redacted login, ready to be written where the +// CLI expects to find it. +type Credential struct { + Provider Provider + // Filename is the base name the CLI reads, and doubles as the key this + // credential occupies in a Kubernetes Secret or a published directory. + Filename string + // Payload is the redacted JSON document, exactly as it should land on disk. + Payload []byte + // ExpiresAt is when Payload stops authenticating. Never zero: a credential + // whose expiry cannot be determined is an error at read time, because a + // consumer that cannot schedule a republish would silently serve a dead + // token. + ExpiresAt time.Time +} + +// Expired reports whether the credential has already lapsed at now. +func (c Credential) Expired(now time.Time) bool { return !now.Before(c.ExpiresAt) } + +// The file names each CLI reads. RelPath is where the file sits under the +// CLI's own config directory (CLAUDE_CONFIG_DIR / CODEX_HOME). +const ( + ClaudeFilename = "claude.credentials.json" + CodexFilename = "codex.auth.json" + + // ClaudeRelPath is the name Claude Code reads inside CLAUDE_CONFIG_DIR. + ClaudeRelPath = ".credentials.json" + // CodexRelPath is the name codex reads inside CODEX_HOME. + CodexRelPath = "auth.json" +) + +// RelPath is where this credential must be written inside the provider's +// config directory for the CLI to find it. +func (c Credential) RelPath() string { + if c.Provider == ProviderClaude { + return ClaudeRelPath + } + return CodexRelPath +} diff --git a/pkg/agentcreds/agentcreds_suite_test.go b/pkg/agentcreds/agentcreds_suite_test.go new file mode 100644 index 00000000..bea19285 --- /dev/null +++ b/pkg/agentcreds/agentcreds_suite_test.go @@ -0,0 +1,13 @@ +package agentcreds_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestAgentCreds(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "AgentCreds Suite") +} diff --git a/pkg/agentcreds/expiry.go b/pkg/agentcreds/expiry.go new file mode 100644 index 00000000..da169617 --- /dev/null +++ b/pkg/agentcreds/expiry.go @@ -0,0 +1,53 @@ +package agentcreds + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "strings" + "time" +) + +// The two providers disagree on how expiry is expressed, and getting it wrong +// is silent rather than loud — a millisecond value read as seconds lands in the +// year 56000 and the credential looks permanently fresh. Both conversions live +// here so there is one place to be right. + +// epochMillis converts Claude's expiresAt, which is epoch milliseconds. +func epochMillis(value int64) time.Time { + return time.UnixMilli(value).UTC() +} + +// epochSeconds converts a JWT exp claim, which RFC 7519 defines in seconds. +func epochSeconds(value int64) time.Time { + return time.Unix(value, 0).UTC() +} + +// jwtExpiry reads the exp claim out of a JWT without verifying its signature. +// +// Captain is not the audience for these tokens and holds none of the keys that +// signed them; it only needs to know when to republish. Decoding the payload +// segment is therefore the whole job, and treating this as authentication would +// be wrong — the value is a scheduling hint about a token whose real validation +// happens at the provider. +func jwtExpiry(token string) (time.Time, error) { + segments := strings.Split(token, ".") + if len(segments) != 3 { + return time.Time{}, fmt.Errorf("not a JWT: expected 3 dot-separated segments, got %d", len(segments)) + } + // JWTs use unpadded base64url. + payload, err := base64.RawURLEncoding.DecodeString(segments[1]) + if err != nil { + return time.Time{}, fmt.Errorf("decode JWT payload: %w", err) + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil { + return time.Time{}, fmt.Errorf("parse JWT claims: %w", err) + } + if claims.Exp == 0 { + return time.Time{}, fmt.Errorf("JWT carries no exp claim") + } + return epochSeconds(claims.Exp), nil +} diff --git a/pkg/agentcreds/redact.go b/pkg/agentcreds/redact.go new file mode 100644 index 00000000..17821c44 --- /dev/null +++ b/pkg/agentcreds/redact.go @@ -0,0 +1,153 @@ +package agentcreds + +import ( + "encoding/json" + "fmt" + "time" +) + +// Redaction here is allowlist-shaped: every output document is rebuilt from +// named fields rather than produced by deleting known-bad keys from the input. +// A denylist would leak by default the first time a provider adds a field, and +// these documents are handed to a sandbox that captain does not trust. + +// claudeSource is the subset of the Keychain blob (or ~/.claude/.credentials.json) +// that is read. Fields absent here are dropped, including the whole mcpOAuth map: +// it holds accessToken/refreshToken/clientSecret triples for every MCP server the +// user has authorized, which have nothing to do with the agent's own login. +type claudeSource struct { + ClaudeAiOauth struct { + AccessToken string `json:"accessToken"` + ExpiresAt int64 `json:"expiresAt"` + Scopes []string `json:"scopes,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` + RateLimitTier string `json:"rateLimitTier,omitempty"` + } `json:"claudeAiOauth"` +} + +// claudeRedacted is what the sandbox receives. refreshToken and +// refreshTokenExpiresAt are absent by construction. +type claudeRedacted struct { + ClaudeAiOauth claudeOauthRedacted `json:"claudeAiOauth"` +} + +type claudeOauthRedacted struct { + AccessToken string `json:"accessToken"` + ExpiresAt int64 `json:"expiresAt"` + Scopes []string `json:"scopes,omitempty"` + SubscriptionType string `json:"subscriptionType,omitempty"` + RateLimitTier string `json:"rateLimitTier,omitempty"` +} + +// RedactClaude strips the refresh token from a Claude Code credential document +// and reports when what remains stops working. +func RedactClaude(raw []byte) (Credential, error) { + var source claudeSource + if err := json.Unmarshal(raw, &source); err != nil { + return Credential{}, fmt.Errorf("parse claude credentials: %w", err) + } + oauth := source.ClaudeAiOauth + if oauth.AccessToken == "" { + return Credential{}, fmt.Errorf("claude credentials carry no claudeAiOauth.accessToken; run `claude` on this host to log in") + } + if oauth.ExpiresAt == 0 { + return Credential{}, fmt.Errorf("claude credentials carry no claudeAiOauth.expiresAt, so a republish cannot be scheduled") + } + payload, err := json.MarshalIndent(claudeRedacted{ClaudeAiOauth: claudeOauthRedacted(oauth)}, "", " ") + if err != nil { + return Credential{}, fmt.Errorf("marshal redacted claude credentials: %w", err) + } + return Credential{ + Provider: ProviderClaude, + Filename: ClaudeFilename, + Payload: append(payload, '\n'), + ExpiresAt: epochMillis(oauth.ExpiresAt), + }, nil +} + +// codexSource mirrors ~/.codex/auth.json. OPENAI_API_KEY is a *string so the +// distinction between "absent" and "explicitly null" survives the round trip — +// codex writes null in ChatGPT-subscription mode and reads it back. +type codexSource struct { + AuthMode string `json:"auth_mode,omitempty"` + OpenAIAPIKey *string `json:"OPENAI_API_KEY"` + Tokens *struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id,omitempty"` + } `json:"tokens"` + LastRefresh string `json:"last_refresh,omitempty"` +} + +type codexRedacted struct { + AuthMode string `json:"auth_mode,omitempty"` + OpenAIAPIKey *string `json:"OPENAI_API_KEY"` + Tokens *codexTokens `json:"tokens,omitempty"` + LastRefresh string `json:"last_refresh,omitempty"` +} + +// codexTokens keeps refresh_token as a present-but-empty string rather than +// omitting the key: codex-rs models TokenData.refresh_token as a non-optional +// String, so dropping the field risks failing deserialization outright. +// +// Verified against the real CLI with hack/credspike.go — `codex login status` +// reports "Logged in using ChatGPT" against a document redacted this way. +type codexTokens struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + AccountID string `json:"account_id,omitempty"` +} + +// RedactCodex strips the refresh token from a codex credential document. +// +// An API-key login has no tokens block at all and no expiry; it is passed +// through unchanged with a far-future expiry, because there is nothing to +// refresh and nothing to strip. +func RedactCodex(raw []byte, now time.Time) (Credential, error) { + var source codexSource + if err := json.Unmarshal(raw, &source); err != nil { + return Credential{}, fmt.Errorf("parse codex auth.json: %w", err) + } + + redacted := codexRedacted{ + AuthMode: source.AuthMode, + OpenAIAPIKey: source.OpenAIAPIKey, + LastRefresh: source.LastRefresh, + } + expiry := now.Add(apiKeyCredentialLifetime) + + if source.Tokens != nil && source.Tokens.AccessToken != "" { + tokenExpiry, err := jwtExpiry(source.Tokens.AccessToken) + if err != nil { + return Credential{}, fmt.Errorf("read codex access_token expiry: %w", err) + } + expiry = tokenExpiry + redacted.Tokens = &codexTokens{ + IDToken: source.Tokens.IDToken, + AccessToken: source.Tokens.AccessToken, + AccountID: source.Tokens.AccountID, + // RefreshToken deliberately left as the zero value. + } + } else if source.OpenAIAPIKey == nil || *source.OpenAIAPIKey == "" { + return Credential{}, fmt.Errorf("codex auth.json has neither tokens.access_token nor OPENAI_API_KEY; run `codex login` on this host") + } + + payload, err := json.MarshalIndent(redacted, "", " ") + if err != nil { + return Credential{}, fmt.Errorf("marshal redacted codex auth: %w", err) + } + return Credential{ + Provider: ProviderCodex, + Filename: CodexFilename, + Payload: append(payload, '\n'), + ExpiresAt: expiry, + }, nil +} + +// apiKeyCredentialLifetime is the nominal expiry given to a credential that +// carries no expiring token. An API key does not lapse, but every consumer +// schedules off ExpiresAt, so it needs a value; a day keeps the republish loop +// running without pretending the key is eternal. +const apiKeyCredentialLifetime = 24 * time.Hour diff --git a/pkg/agentcreds/redact_test.go b/pkg/agentcreds/redact_test.go new file mode 100644 index 00000000..a2fbffd4 --- /dev/null +++ b/pkg/agentcreds/redact_test.go @@ -0,0 +1,187 @@ +package agentcreds_test + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The fixtures use fixed instants so every expiry assertion compares against a +// value computed by hand here, never against the parser's own output. +var ( + claudeExpiry = time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) + codexExpiry = time.Date(2026, 8, 17, 13, 30, 0, 0, time.UTC) + fixedNow = time.Date(2026, 8, 17, 11, 0, 0, 0, time.UTC) +) + +// jwtWithExp builds a signature-less JWT whose exp claim is at instant. +// The exp claim is seconds; the Claude fixture below uses milliseconds. The two +// units differing is the whole reason these tests exist. +func jwtWithExp(instant time.Time) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`)) + claims := base64.RawURLEncoding.EncodeToString( + []byte(fmt.Sprintf(`{"exp":%d,"sub":"user-fixture"}`, instant.Unix()))) + return header + "." + claims + ".not-a-real-signature" +} + +func claudeFixture() []byte { + return []byte(fmt.Sprintf(`{ + "claudeAiOauth": { + "accessToken": "sk-ant-oat-fixture-access", + "refreshToken": "sk-ant-ort-fixture-refresh", + "expiresAt": %d, + "refreshTokenExpiresAt": %d, + "scopes": ["user:inference", "user:profile"], + "subscriptionType": "max", + "rateLimitTier": "default_claude_max_20x" + }, + "mcpOAuth": { + "example-server|0123456789abcdef": { + "accessToken": "mcp-fixture-access", + "refreshToken": "mcp-fixture-refresh", + "clientSecret": "mcp-fixture-client-secret", + "serverName": "example-server" + } + } + }`, claudeExpiry.UnixMilli(), claudeExpiry.Add(30*24*time.Hour).UnixMilli())) +} + +func codexPlanFixture() []byte { + return []byte(fmt.Sprintf(`{ + "auth_mode": "chatgpt", + "OPENAI_API_KEY": null, + "tokens": { + "id_token": %q, + "access_token": %q, + "refresh_token": "codex-fixture-refresh", + "account_id": "00000000-0000-4000-8000-000000000000" + }, + "last_refresh": "2026-08-17T10:30:00.000000000Z" + }`, jwtWithExp(codexExpiry.Add(time.Hour)), jwtWithExp(codexExpiry))) +} + +var _ = Describe("RedactClaude", func() { + It("removes the refresh token and the whole mcpOAuth map", func() { + credential, err := agentcreds.RedactClaude(claudeFixture()) + Expect(err).NotTo(HaveOccurred()) + + var got map[string]any + Expect(json.Unmarshal(credential.Payload, &got)).To(Succeed()) + Expect(got).To(HaveLen(1), "only claudeAiOauth survives redaction") + Expect(got).To(HaveKey("claudeAiOauth")) + Expect(got).NotTo(HaveKey("mcpOAuth")) + + oauth := got["claudeAiOauth"].(map[string]any) + Expect(oauth).To(Equal(map[string]any{ + "accessToken": "sk-ant-oat-fixture-access", + "expiresAt": float64(claudeExpiry.UnixMilli()), + "scopes": []any{"user:inference", "user:profile"}, + "subscriptionType": "max", + "rateLimitTier": "default_claude_max_20x", + })) + }) + + It("never lets a secret survive anywhere in the payload", func() { + credential, err := agentcreds.RedactClaude(claudeFixture()) + Expect(err).NotTo(HaveOccurred()) + for _, secret := range []string{ + "sk-ant-ort-fixture-refresh", + "mcp-fixture-access", + "mcp-fixture-refresh", + "mcp-fixture-client-secret", + } { + Expect(string(credential.Payload)).NotTo(ContainSubstring(secret)) + } + }) + + It("reads expiresAt as epoch milliseconds", func() { + credential, err := agentcreds.RedactClaude(claudeFixture()) + Expect(err).NotTo(HaveOccurred()) + Expect(credential.ExpiresAt).To(BeTemporally("==", claudeExpiry)) + Expect(credential.Provider).To(Equal(agentcreds.ProviderClaude)) + Expect(credential.Filename).To(Equal(agentcreds.ClaudeFilename)) + Expect(credential.RelPath()).To(Equal(".credentials.json")) + }) + + It("refuses a document with no access token", func() { + _, err := agentcreds.RedactClaude([]byte(`{"claudeAiOauth":{"expiresAt":1}}`)) + Expect(err).To(MatchError(ContainSubstring("no claudeAiOauth.accessToken"))) + }) + + It("refuses a document with no expiry, because a republish cannot be scheduled", func() { + _, err := agentcreds.RedactClaude([]byte(`{"claudeAiOauth":{"accessToken":"x"}}`)) + Expect(err).To(MatchError(ContainSubstring("no claudeAiOauth.expiresAt"))) + }) +}) + +var _ = Describe("RedactCodex", func() { + It("blanks the refresh token but keeps the key present", func() { + credential, err := agentcreds.RedactCodex(codexPlanFixture(), fixedNow) + Expect(err).NotTo(HaveOccurred()) + + var got struct { + AuthMode string `json:"auth_mode"` + OpenAIAPIKey *string `json:"OPENAI_API_KEY"` + Tokens struct { + IDToken string `json:"id_token"` + AccessToken string `json:"access_token"` + RefreshToken *string `json:"refresh_token"` + AccountID string `json:"account_id"` + } `json:"tokens"` + LastRefresh string `json:"last_refresh"` + } + Expect(json.Unmarshal(credential.Payload, &got)).To(Succeed()) + + Expect(got.AuthMode).To(Equal("chatgpt")) + Expect(got.OpenAIAPIKey).To(BeNil(), "null must survive as null, not vanish") + Expect(got.Tokens.AccessToken).To(Equal(jwtWithExp(codexExpiry))) + Expect(got.Tokens.AccountID).To(Equal("00000000-0000-4000-8000-000000000000")) + Expect(got.LastRefresh).To(Equal("2026-08-17T10:30:00.000000000Z")) + + Expect(got.Tokens.RefreshToken).NotTo(BeNil(), + "codex-rs models refresh_token as a non-optional String; omitting the key fails deserialization") + Expect(*got.Tokens.RefreshToken).To(BeEmpty()) + Expect(string(credential.Payload)).NotTo(ContainSubstring("codex-fixture-refresh")) + }) + + It("reads expiry from the access_token exp claim, in seconds", func() { + credential, err := agentcreds.RedactCodex(codexPlanFixture(), fixedNow) + Expect(err).NotTo(HaveOccurred()) + Expect(credential.ExpiresAt).To(BeTemporally("==", codexExpiry)) + Expect(credential.Provider).To(Equal(agentcreds.ProviderCodex)) + Expect(credential.RelPath()).To(Equal("auth.json")) + }) + + It("passes an API-key login through with a relative expiry", func() { + credential, err := agentcreds.RedactCodex( + []byte(`{"auth_mode":"apikey","OPENAI_API_KEY":"sk-fixture-api-key"}`), fixedNow) + Expect(err).NotTo(HaveOccurred()) + Expect(string(credential.Payload)).To(ContainSubstring("sk-fixture-api-key")) + Expect(credential.ExpiresAt).To(BeTemporally("==", fixedNow.Add(24*time.Hour))) + }) + + It("refuses a document carrying neither tokens nor an API key", func() { + _, err := agentcreds.RedactCodex([]byte(`{"auth_mode":"chatgpt","OPENAI_API_KEY":null}`), fixedNow) + Expect(err).To(MatchError(ContainSubstring("run `codex login`"))) + }) + + It("refuses an access token that is not a JWT, rather than guessing an expiry", func() { + _, err := agentcreds.RedactCodex( + []byte(`{"tokens":{"access_token":"opaque-not-a-jwt","refresh_token":"r"}}`), fixedNow) + Expect(err).To(MatchError(ContainSubstring("not a JWT"))) + }) +}) + +var _ = Describe("Credential.Expired", func() { + It("treats the expiry instant itself as expired", func() { + credential := agentcreds.Credential{ExpiresAt: claudeExpiry} + Expect(credential.Expired(claudeExpiry.Add(-time.Second))).To(BeFalse()) + Expect(credential.Expired(claudeExpiry)).To(BeTrue()) + Expect(credential.Expired(claudeExpiry.Add(time.Second))).To(BeTrue()) + }) +}) diff --git a/pkg/agentcreds/source.go b/pkg/agentcreds/source.go new file mode 100644 index 00000000..0e090daa --- /dev/null +++ b/pkg/agentcreds/source.go @@ -0,0 +1,161 @@ +package agentcreds + +import ( + "context" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "time" +) + +// keychainService is the generic-password item Claude Code writes on macOS. +// On every other platform the same JSON document lives in a file instead. +const keychainService = "Claude Code-credentials" + +// Reader resolves raw credential documents from the host. The function fields +// exist so tests can drive redaction and expiry without a Keychain or a real +// home directory; OSReader wires them to the host. +type Reader struct { + Home string + // ReadFile reads a credential file. + ReadFile func(path string) ([]byte, error) + // ReadKeychain reads the Claude Code Keychain item. Nil on platforms that + // have no Keychain, which is how Read chooses the file path instead. + ReadKeychain func(ctx context.Context) ([]byte, error) + // Now supplies the clock for credentials whose expiry is relative. + Now func() time.Time +} + +// OSReader wires a Reader to this host. +func OSReader() (Reader, error) { + home, err := os.UserHomeDir() + if err != nil { + return Reader{}, fmt.Errorf("resolve home directory: %w", err) + } + reader := Reader{Home: home, ReadFile: os.ReadFile, Now: time.Now} + if runtime.GOOS == "darwin" { + reader.ReadKeychain = readKeychainItem + } + return reader, nil +} + +// ClaudePath is where Claude Code keeps its credential file when it is not +// using a Keychain. +func (r Reader) ClaudePath() string { + return filepath.Join(r.Home, ".claude", ".credentials.json") +} + +// CodexPath is where codex keeps its credential file on every platform. +func (r Reader) CodexPath() string { + return filepath.Join(r.Home, ".codex", "auth.json") +} + +// Read returns one provider's redacted credential. +// +// A missing or unreadable source is an error rather than an empty result: the +// callers publish credentials into sandboxes, and a silently-skipped provider +// would surface as an unexplained 401 inside an agent hours later. +func (r Reader) Read(ctx context.Context, provider Provider) (Credential, error) { + switch provider { + case ProviderClaude: + raw, err := r.readClaude(ctx) + if err != nil { + return Credential{}, err + } + return RedactClaude(raw) + case ProviderCodex: + raw, err := r.ReadFile(r.CodexPath()) + if err != nil { + return Credential{}, describeMissing(err, provider, r.CodexPath(), "codex login") + } + return RedactCodex(raw, r.now()) + default: + return Credential{}, fmt.Errorf("unknown credential provider %q", provider) + } +} + +// ReadAll returns the redacted credentials for every requested provider, +// failing on the first that cannot be read. +func (r Reader) ReadAll(ctx context.Context, providers []Provider) ([]Credential, error) { + out := make([]Credential, 0, len(providers)) + for _, provider := range providers { + credential, err := r.Read(ctx, provider) + if err != nil { + return nil, err + } + out = append(out, credential) + } + return out, nil +} + +func (r Reader) now() time.Time { + if r.Now == nil { + return time.Now() + } + return r.Now() +} + +// readClaude prefers the Keychain where one exists and falls back to the file, +// because a single machine can have either: macOS Claude Code uses the +// Keychain, while a Linux or container install writes the same document to +// ~/.claude/.credentials.json. +func (r Reader) readClaude(ctx context.Context) ([]byte, error) { + if r.ReadKeychain != nil { + raw, keychainErr := r.ReadKeychain(ctx) + if keychainErr == nil { + return raw, nil + } + raw, fileErr := r.ReadFile(r.ClaudePath()) + if fileErr == nil { + return raw, nil + } + return nil, fmt.Errorf( + "no claude login found: keychain item %q: %w; %s: %v", + keychainService, keychainErr, r.ClaudePath(), fileErr) + } + raw, err := r.ReadFile(r.ClaudePath()) + if err != nil { + // `claude`, not `claude login`: Claude Code has no login subcommand, it + // starts the login flow when run unauthenticated. + return nil, describeMissing(err, ProviderClaude, r.ClaudePath(), "claude") + } + return raw, nil +} + +// describeMissing turns a bare "no such file" into something that names the fix. +func describeMissing(err error, provider Provider, path, loginCommand string) error { + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("no %s credential at %s; run `%s` on this host", provider, path, loginCommand) + } + return fmt.Errorf("read %s credential %s: %w", provider, path, err) +} + +// readKeychainItem shells out to /usr/bin/security, which is the only supported +// way to read a generic-password item without linking a Cgo Keychain binding. +// +// The value reaches captain on stdout, so it never lands in argv where another +// process could read it from /proc or `ps`. +func readKeychainItem(ctx context.Context) ([]byte, error) { + cmd := exec.CommandContext(ctx, "/usr/bin/security", + "find-generic-password", "-s", keychainService, "-w") + var stderr strings.Builder + cmd.Stderr = &stderr + out, err := cmd.Output() + if err != nil { + detail := strings.TrimSpace(stderr.String()) + if detail == "" { + detail = err.Error() + } + return nil, fmt.Errorf("read keychain item %q: %s", keychainService, detail) + } + trimmed := strings.TrimSpace(string(out)) + if trimmed == "" { + return nil, fmt.Errorf("keychain item %q is empty", keychainService) + } + return []byte(trimmed), nil +} diff --git a/pkg/captainconfig/config.go b/pkg/captainconfig/config.go index 7d0c9f2f..a8b24c02 100644 --- a/pkg/captainconfig/config.go +++ b/pkg/captainconfig/config.go @@ -23,6 +23,7 @@ type Config struct { Prompts PromptDefaults `yaml:"prompts"` Attachments AttachmentDefaults `yaml:"attachments"` Sandbox SandboxDefaults `yaml:"sandbox,omitempty"` + Credentials CredentialDefaults `yaml:"credentials,omitempty"` } // SandboxDefaults is the sandbox block of ~/.captain.yaml: a default selector diff --git a/pkg/captainconfig/credentials.go b/pkg/captainconfig/credentials.go new file mode 100644 index 00000000..dda677a7 --- /dev/null +++ b/pkg/captainconfig/credentials.go @@ -0,0 +1,100 @@ +package captainconfig + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" +) + +// CredentialDefaults is the `credentials:` block of ~/.captain.yaml. It tells +// the supervisor which agent logins to mirror and where to keep them fresh. +// +// This package stays data-only: it validates the shape and expands paths, but +// building publishers and talking to a cluster belongs to pkg/credsync. +type CredentialDefaults struct { + // RefreshMargin is how far ahead of expiry a credential is republished. + // Zero means the publisher's own default. + RefreshMargin time.Duration `yaml:"refreshMargin,omitempty"` + // Publish is the set of destinations. Empty disables publishing entirely, + // which is the default: mirroring a credential off this host is opt-in. + Publish []CredentialPublish `yaml:"publish,omitempty"` +} + +// IsZero lets yaml omit an empty credentials block. +func (c CredentialDefaults) IsZero() bool { + return c.RefreshMargin == 0 && len(c.Publish) == 0 +} + +// CredentialPublish is one destination and the providers written to it. +type CredentialPublish struct { + // Providers names the logins to mirror ("claude", "codex"). Empty means all + // supported providers. + Providers []string `yaml:"providers,omitempty"` + // Directory is a path on this host that a Docker workload bind-mounts. + Directory string `yaml:"directory,omitempty"` + // Kubernetes publishes into a Secret that a sidecar mounts. + Kubernetes *CredentialSecretRef `yaml:"kubernetes,omitempty"` +} + +// CredentialSecretRef locates the Secret credentials are written to. +type CredentialSecretRef struct { + // Context names a kubeconfig context; empty uses the current one. + Context string `yaml:"context,omitempty"` + // Namespace is required — defaulting it would publish a credential into + // whichever namespace a kubeconfig happens to point at. + Namespace string `yaml:"namespace"` + // Secret defaults to credsync.DefaultSecretName when empty. + Secret string `yaml:"secret,omitempty"` +} + +// Validate refuses a destination that names nowhere to write, or that names +// both kinds at once. +// +// Both are configuration mistakes that would otherwise surface as a supervisor +// that starts, logs nothing, and quietly publishes no credentials. +func (p CredentialPublish) Validate() error { + hasDirectory := strings.TrimSpace(p.Directory) != "" + if !hasDirectory && p.Kubernetes == nil { + return fmt.Errorf("credentials.publish entry names neither a directory nor a kubernetes secret") + } + if hasDirectory && p.Kubernetes != nil { + return fmt.Errorf("credentials.publish entry names both a directory and a kubernetes secret; use one entry per destination") + } + if p.Kubernetes != nil && strings.TrimSpace(p.Kubernetes.Namespace) == "" { + return fmt.Errorf("credentials.publish kubernetes entry requires a namespace") + } + return nil +} + +// Validate checks every destination. +func (c CredentialDefaults) Validate() error { + for i, publish := range c.Publish { + if err := publish.Validate(); err != nil { + return fmt.Errorf("credentials.publish[%d]: %w", i, err) + } + } + return nil +} + +// ResolvedDirectory expands ~ and makes the path absolute, so a configured +// destination means the same thing regardless of the supervisor's cwd. +func (p CredentialPublish) ResolvedDirectory() (string, error) { + path := strings.TrimSpace(p.Directory) + if path == "" { + return "", nil + } + if path == "~" || strings.HasPrefix(path, "~/") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("resolve home directory for credentials.publish directory: %w", err) + } + path = filepath.Join(home, strings.TrimPrefix(strings.TrimPrefix(path, "~"), "/")) + } + absolute, err := filepath.Abs(path) + if err != nil { + return "", fmt.Errorf("resolve credentials.publish directory %q: %w", p.Directory, err) + } + return absolute, nil +} diff --git a/pkg/cli/ai_models.go b/pkg/cli/ai_models.go index 12f3f975..f92dd318 100644 --- a/pkg/cli/ai_models.go +++ b/pkg/cli/ai_models.go @@ -3,7 +3,6 @@ package cli import ( "context" "fmt" - "os" "sort" "strings" @@ -41,24 +40,21 @@ func RunAIModels(opts AIModelsOptions) (any, error) { return runLiveModels(opts) } -// runLiveModels lists what the user's API keys can actually call by hitting -// OpenAI and Anthropic /v1/models, then augments each row with pricing and -// context-window data from the OpenRouter registry. There is no static -// fallback: if a backend has no API key set or the call fails, the user -// learns about it directly so they can fix their environment instead of -// being shown a stale hard-coded catalog. +// runLiveModels lists what the user's API credentials can actually call by +// hitting provider model endpoints, then augments each row with pricing and +// context-window data from the OpenRouter registry. There is no static fallback: +// if a backend has no configured credential or the call fails, the user learns +// about it directly instead of being shown a stale hard-coded catalog. func runLiveModels(opts AIModelsOptions) (any, error) { backendFilter := ai.Backend(strings.TrimSpace(opts.Backend)) + if backendFilter != "" && !backendFilter.Valid() { + return nil, fmt.Errorf("--backend must be one of: %s (got %q)", ai.BackendList(), opts.Backend) + } // CLI/agent backends authenticate internally, so their models come from the // static catalog without an API key. if backendFilter != "" && backendFilter.Kind() == "cli" { return catalogModelsResult(opts, backendFilter), nil } - switch backendFilter { - case "", ai.BackendOpenAI, ai.BackendAnthropic: - default: - return nil, fmt.Errorf("--backend must be one of: openai, anthropic, or a CLI backend (%s) (got %q)", strings.Join(cliBackendNames(), ", "), opts.Backend) - } ctx := context.Background() type fetched struct { @@ -68,13 +64,13 @@ func runLiveModels(opts AIModelsOptions) (any, error) { } var results []fetched - if backendFilter == "" || backendFilter == ai.BackendOpenAI { - m, err := ai.FetchOpenAIModels(ctx, openAIAPIKey()) - results = append(results, fetched{ai.BackendOpenAI, m, err}) + backends := []ai.Backend{ai.BackendOpenAI, ai.BackendAnthropic} + if backendFilter != "" { + backends = []ai.Backend{backendFilter} } - if backendFilter == "" || backendFilter == ai.BackendAnthropic { - m, err := ai.FetchAnthropicModels(ctx, anthropicAPIKey()) - results = append(results, fetched{ai.BackendAnthropic, m, err}) + for _, backend := range backends { + models, err := ai.ListModels(ctx, backend) + results = append(results, fetched{backend, models, err}) } // Surface the first hard error. With no static fallback, an error means @@ -163,47 +159,17 @@ func catalogModelsResult(opts AIModelsOptions, backend ai.Backend) AIModelsResul return AIModelsResult{Total: len(rows), Rows: rows} } -// cliBackendNames lists the CLI/agent backends, for the --backend error message. -func cliBackendNames() []string { - out := make([]string, 0) - for _, b := range ai.AllBackends() { - if b.Kind() == "cli" { - out = append(out, string(b)) - } - } - return out -} - -func openAIAPIKey() string { return strings.TrimSpace(os.Getenv("OPENAI_API_KEY")) } -func anthropicAPIKey() string { return strings.TrimSpace(os.Getenv("ANTHROPIC_API_KEY")) } - -// lookupPricing tries the model id as-is first, then with the OpenRouter -// "provider/model" prefix that the registry actually uses for the major -// providers (OpenAI's `gpt-5` is keyed as `openai/gpt-5` upstream). +// lookupPricing uses the provider registry's canonical OpenRouter candidates so +// API and local-agent backends resolve the same model price. func lookupPricing(backend ai.Backend, id string) (pricing.ModelInfo, bool) { - if info, ok := pricing.GetModelInfo(id); ok { - return info, true - } - prefix := openRouterPrefix(backend) - if prefix == "" { - return pricing.ModelInfo{}, false - } - if info, ok := pricing.GetModelInfo(prefix + "/" + id); ok { - return info, true + for _, candidate := range ai.PricingIDs(backend, id) { + if info, ok := pricing.GetModelInfo(candidate); ok { + return info, true + } } return pricing.ModelInfo{}, false } -func openRouterPrefix(backend ai.Backend) string { - switch backend { - case ai.BackendOpenAI: - return "openai" - case ai.BackendAnthropic: - return "anthropic" - } - return "" -} - // runAllModels shows every model in the OpenRouter pricing registry. Pricing // is the only source of truth; without the static catalog there is no // `Default` or `Reasoning` flag to surface, so those columns are blank for diff --git a/pkg/cli/ai_models_ginkgo_test.go b/pkg/cli/ai_models_ginkgo_test.go new file mode 100644 index 00000000..f4685b5b --- /dev/null +++ b/pkg/cli/ai_models_ginkgo_test.go @@ -0,0 +1,58 @@ +package cli + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + + "github.com/flanksource/captain/pkg/credentials" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ai models credential resolution", Serial, func() { + DescribeTable("uses the Captain vault when the provider environment variable is unset", func( + backend, provider, envVar, path, authHeader, authValue string, + response map[string]any, + ) { + GinkgoT().Setenv(envVar, "") + credentials.SetPathForTesting(filepath.Join(GinkgoT().TempDir(), "vault")) + DeferCleanup(func() { credentials.SetPathForTesting("") }) + + vault, err := credentials.DefaultVault() + Expect(err).NotTo(HaveOccurred()) + Expect(vault.Set(provider, "vault-token")).To(Succeed()) + + authorization := "" + var encodeErr error + mux := http.NewServeMux() + mux.HandleFunc(path, func(w http.ResponseWriter, r *http.Request) { + authorization = r.Header.Get(authHeader) + encodeErr = json.NewEncoder(w).Encode(response) + }) + server := httptest.NewServer(mux) + DeferCleanup(server.Close) + + originalTransport := http.DefaultClient.Transport + http.DefaultClient.Transport = aiModelsRewriteTransport{base: server.URL, inner: server.Client().Transport} + DeferCleanup(func() { http.DefaultClient.Transport = originalTransport }) + + got, err := RunAIModels(AIModelsOptions{Backend: backend, Limit: 10}) + + Expect(err).NotTo(HaveOccurred()) + Expect(encodeErr).NotTo(HaveOccurred()) + Expect(authorization).To(Equal(authValue)) + Expect(got).To(Equal(AIModelsResult{ + Total: 1, + Rows: []AIModelRow{{Model: "model-vault-test", Backend: backend, Input: "-", Output: "-", Context: "-", MaxTokens: "-"}}, + })) + }, + Entry("for OpenAI", "openai", "openai", "OPENAI_API_KEY", "/v1/models", "Authorization", "Bearer vault-token", + map[string]any{"data": []map[string]any{{"id": "model-vault-test"}}}), + Entry("for Gemini", "gemini", "gemini", "GEMINI_API_KEY", "/v1beta/models", "x-goog-api-key", "vault-token", + map[string]any{"models": []map[string]any{{"name": "models/model-vault-test"}}}), + Entry("for DeepSeek", "deepseek", "deepseek", "DEEPSEEK_API_KEY", "/models", "Authorization", "Bearer vault-token", + map[string]any{"data": []map[string]any{{"id": "model-vault-test"}}}), + ) +}) diff --git a/pkg/cli/ai_models_test.go b/pkg/cli/ai_models_test.go index 966ef372..93051a5a 100644 --- a/pkg/cli/ai_models_test.go +++ b/pkg/cli/ai_models_test.go @@ -4,10 +4,12 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/credentials" ) type aiModelsRewriteTransport struct { @@ -50,7 +52,14 @@ func withMockedProviders(t *testing.T, openai http.HandlerFunc, anthropic http.H t.Cleanup(func() { http.DefaultClient.Transport = orig }) } +func isolateAIModelsVault(t *testing.T) { + t.Helper() + credentials.SetPathForTesting(filepath.Join(t.TempDir(), "vault")) + t.Cleanup(func() { credentials.SetPathForTesting("") }) +} + func TestRunAIModels_LiveOpenAIOnly(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "") @@ -82,6 +91,7 @@ func TestRunAIModels_LiveOpenAIOnly(t *testing.T) { } func TestRunAIModels_LiveErrorIsSurfaced(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "") @@ -102,6 +112,7 @@ func TestRunAIModels_LiveErrorIsSurfaced(t *testing.T) { } func TestRunAIModels_NoKeyIsSurfaced(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "") t.Setenv("ANTHROPIC_API_KEY", "") @@ -112,10 +123,13 @@ func TestRunAIModels_NoKeyIsSurfaced(t *testing.T) { } func TestRunAIModels_RejectsUnsupportedBackend(t *testing.T) { - _, err := RunAIModels(AIModelsOptions{Backend: "gemini"}) + _, err := RunAIModels(AIModelsOptions{Backend: "unknown"}) if err == nil { t.Fatal("expected error for unsupported backend") } + if !strings.Contains(err.Error(), "--backend must be one of") { + t.Fatalf("error = %q, want canonical backend validation", err) + } } func TestIsLegacyModelID(t *testing.T) { @@ -211,6 +225,7 @@ func TestIsLegacyModelID(t *testing.T) { } func TestRunAIModels_HidesLegacyByDefault(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "") @@ -264,6 +279,7 @@ func TestRunAIModels_HidesLegacyByDefault(t *testing.T) { } func TestRunAIModels_FilterOverridesBlacklist(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "") @@ -291,6 +307,7 @@ func TestRunAIModels_FilterOverridesBlacklist(t *testing.T) { } func TestRunAIModels_SortsByBackendThenModel(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "ant-test") @@ -336,6 +353,7 @@ func TestRunAIModels_SortsByBackendThenModel(t *testing.T) { } func TestRunAIModels_LimitTruncatesAfterSort(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "") @@ -370,6 +388,7 @@ func TestRunAIModels_LimitTruncatesAfterSort(t *testing.T) { } func TestRunAIModels_FilterAppliesToLiveResults(t *testing.T) { + isolateAIModelsVault(t) t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("ANTHROPIC_API_KEY", "") diff --git a/pkg/cli/container_interactive.go b/pkg/cli/container_interactive.go index 0c772694..489d5f46 100644 --- a/pkg/cli/container_interactive.go +++ b/pkg/cli/container_interactive.go @@ -15,12 +15,10 @@ func InteractiveRunConfig(cfg *container.SandboxConfig) error { var envInput string var selectedPassthrough []string - tokenOptions := []huh.Option[string]{ - huh.NewOption("AWS", "aws"), - huh.NewOption("GCP", "gcp"), - huh.NewOption("Azure", "azure"), - huh.NewOption("GitHub", "github"), - huh.NewOption("Kubernetes", "kubernetes"), + tokenProviders := sandbox.TokenProviders() + tokenOptions := make([]huh.Option[string], len(tokenProviders)) + for i, provider := range tokenProviders { + tokenOptions[i] = huh.NewOption(provider.Label, provider.Name) } presetNames := presets.List() @@ -30,23 +28,7 @@ func InteractiveRunConfig(cfg *container.SandboxConfig) error { } // Pre-select already-configured values - if cfg.Tokens != nil { - if cfg.Tokens.AWS != nil { - selectedTokens = append(selectedTokens, "aws") - } - if cfg.Tokens.GCP != nil { - selectedTokens = append(selectedTokens, "gcp") - } - if cfg.Tokens.Azure != nil { - selectedTokens = append(selectedTokens, "azure") - } - if cfg.Tokens.GitHub != nil { - selectedTokens = append(selectedTokens, "github") - } - if cfg.Tokens.Kubernetes != nil { - selectedTokens = append(selectedTokens, "kubernetes") - } - } + selectedTokens = append(selectedTokens, sandbox.SelectedTokenProviders(cfg.Tokens)...) selectedPresets = append(selectedPresets, cfg.Presets...) selectedPassthrough = append(selectedPassthrough, cfg.EnvPassthrough...) @@ -83,7 +65,7 @@ func InteractiveRunConfig(cfg *container.SandboxConfig) error { return err } - applyTokenSelections(cfg, selectedTokens) + cfg.Tokens = sandbox.ApplyTokenSelection(cfg.Tokens, selectedTokens) cfg.Presets = selectedPresets for _, line := range strings.Split(envInput, "\n") { @@ -100,49 +82,3 @@ func InteractiveRunConfig(cfg *container.SandboxConfig) error { return nil } - -func applyTokenSelections(cfg *container.SandboxConfig, selected []string) { - if len(selected) == 0 { - cfg.Tokens = nil - return - } - - if cfg.Tokens == nil { - cfg.Tokens = &sandbox.TokensConfig{} - } - - has := make(map[string]bool, len(selected)) - for _, s := range selected { - has[s] = true - } - - if has["aws"] && cfg.Tokens.AWS == nil { - cfg.Tokens.AWS = &sandbox.AWSTokenConfig{} - } else if !has["aws"] { - cfg.Tokens.AWS = nil - } - - if has["gcp"] && cfg.Tokens.GCP == nil { - cfg.Tokens.GCP = &sandbox.GCPTokenConfig{} - } else if !has["gcp"] { - cfg.Tokens.GCP = nil - } - - if has["azure"] && cfg.Tokens.Azure == nil { - cfg.Tokens.Azure = &sandbox.AzureTokenConfig{} - } else if !has["azure"] { - cfg.Tokens.Azure = nil - } - - if has["github"] && cfg.Tokens.GitHub == nil { - cfg.Tokens.GitHub = &sandbox.GitHubTokenConfig{} - } else if !has["github"] { - cfg.Tokens.GitHub = nil - } - - if has["kubernetes"] && cfg.Tokens.Kubernetes == nil { - cfg.Tokens.Kubernetes = &sandbox.K8sTokenConfig{} - } else if !has["kubernetes"] { - cfg.Tokens.Kubernetes = nil - } -} diff --git a/pkg/cli/sandbox.go b/pkg/cli/sandbox.go index 4eafb04e..ab1b33a3 100644 --- a/pkg/cli/sandbox.go +++ b/pkg/cli/sandbox.go @@ -23,7 +23,9 @@ func SandboxHelp() api.Text { AddText(" captain sandbox presets", "text-green-400"). AddText(" — list available presets with details", "text-gray-500").NewLine(). AddText(" captain sandbox git-agent", "text-green-400"). - AddText(" — enroll and serve remote coding agents", "text-gray-500").NewLine().NewLine(). + AddText(" — enroll and serve remote coding agents", "text-gray-500").NewLine(). + AddText(" captain sandbox credentials", "text-green-400"). + AddText(" — mirror agent CLI logins to sandbox destinations", "text-gray-500").NewLine().NewLine(). AddText("See also:", "font-bold text-blue-400").NewLine(). AddText(" captain container", "text-green-400"). AddText(" — build container images with preset support", "text-gray-500").NewLine() diff --git a/pkg/cli/sandbox_credentials.go b/pkg/cli/sandbox_credentials.go new file mode 100644 index 00000000..51bec25a --- /dev/null +++ b/pkg/cli/sandbox_credentials.go @@ -0,0 +1,257 @@ +package cli + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/credsync" +) + +// CredentialsOptions are the flags shared by `captain sandbox credentials` +// subcommands. Each overrides the matching ~/.captain.yaml setting, so a +// destination can be tried once without editing the file first. +type CredentialsOptions struct { + Providers []string `flag:"provider" help:"Credential providers to publish (claude, codex). Defaults to every configured provider"` + Directory string `flag:"directory" help:"Publish into this host directory instead of the configured destinations"` + Namespace string `flag:"namespace" help:"Publish into a Kubernetes Secret in this namespace"` + Secret string `flag:"secret" help:"Name of the Kubernetes Secret to publish into"` + Context string `flag:"kube-context" help:"kubeconfig context for the Kubernetes destination"` + Margin time.Duration `flag:"refresh-margin" help:"How far before expiry to republish"` +} + +// CredentialStatus reports what would be published and where, without +// publishing. It carries expiry and size, never a credential value. +type CredentialStatus struct { + Provider string `json:"provider" pretty:"label=Provider"` + Source string `json:"source" pretty:"label=Source"` + Key string `json:"key" pretty:"label=Key"` + ExpiresAt time.Time `json:"expiresAt" pretty:"label=Expires"` + ExpiresIn string `json:"expiresIn" pretty:"label=Expires in"` + Expired bool `json:"expired" pretty:"label=Expired"` + Targets []string `json:"targets,omitempty" pretty:"label=Targets"` +} + +// RunCredentialsStatus reads each provider and reports its lifetime. +// +// A provider that cannot be read is reported as a row with its reason rather +// than aborting the command: seeing "codex: not logged in" beside a healthy +// claude row is the whole point of a status command. +func RunCredentialsStatus(ctx context.Context, opts CredentialsOptions) ([]CredentialStatus, error) { + reader, err := agentcreds.OSReader() + if err != nil { + return nil, err + } + providers, err := resolveCredentialProviders(opts.Providers) + if err != nil { + return nil, err + } + targets, err := describeCredentialTargets(opts) + if err != nil { + return nil, err + } + + now := time.Now() + rows := make([]CredentialStatus, 0, len(providers)) + for _, provider := range providers { + row := CredentialStatus{ + Provider: string(provider), + Source: credentialSourceLabel(reader, provider), + Targets: targets, + } + credential, err := reader.Read(ctx, provider) + if err != nil { + row.Expired = true + row.ExpiresIn = firstLine(err.Error()) + rows = append(rows, row) + continue + } + row.Key = credential.Filename + row.ExpiresAt = credential.ExpiresAt + row.Expired = credential.Expired(now) + row.ExpiresIn = time.Until(credential.ExpiresAt).Round(time.Second).String() + rows = append(rows, row) + } + return rows, nil +} + +// RunCredentialsSync publishes once, for deploy time and for debugging. +func RunCredentialsSync(ctx context.Context, opts CredentialsOptions) (credsync.Result, error) { + publisher, err := buildCredentialPublisher(opts) + if err != nil { + return credsync.Result{}, err + } + return publisher.PublishOnce(ctx) +} + +// buildCredentialPublisher assembles a publisher from the flags, falling back to +// ~/.captain.yaml when no destination is passed. +func buildCredentialPublisher(opts CredentialsOptions) (credsync.Publisher, error) { + reader, err := agentcreds.OSReader() + if err != nil { + return credsync.Publisher{}, err + } + providers, err := resolveCredentialProviders(opts.Providers) + if err != nil { + return credsync.Publisher{}, err + } + + saved, _, err := captainconfig.Load() + if err != nil { + return credsync.Publisher{}, err + } + margin := opts.Margin + if margin == 0 { + margin = saved.Credentials.RefreshMargin + } + + targets, err := credentialTargetsFromFlags(opts) + if err != nil { + return credsync.Publisher{}, err + } + if len(targets) == 0 { + targets, err = credentialTargetsFromConfig(saved.Credentials) + if err != nil { + return credsync.Publisher{}, err + } + } + if len(targets) == 0 { + configPath, _ := captainconfig.Path() + return credsync.Publisher{}, fmt.Errorf( + "no credential destination configured; pass --directory or --namespace, or add a credentials.publish entry to %s", + configPath) + } + + return credsync.Publisher{ + Reader: reader, + Providers: providers, + Targets: targets, + Margin: margin, + }, nil +} + +// credentialTargetsFromFlags builds the destinations named on the command line. +func credentialTargetsFromFlags(opts CredentialsOptions) ([]credsync.Target, error) { + var targets []credsync.Target + if directory := strings.TrimSpace(opts.Directory); directory != "" { + resolved, err := captainconfig.CredentialPublish{Directory: directory}.ResolvedDirectory() + if err != nil { + return nil, err + } + targets = append(targets, credsync.DirectoryTarget{Path: resolved}) + } + if namespace := strings.TrimSpace(opts.Namespace); namespace != "" { + client, resolvedNamespace, err := kubernetesClient(kubeClientOptions{ + Context: opts.Context, + Namespace: namespace, + }) + if err != nil { + return nil, err + } + targets = append(targets, credsync.KubernetesTarget{ + Client: client, Namespace: resolvedNamespace, Secret: opts.Secret, + }) + } + return targets, nil +} + +// credentialTargetsFromConfig builds the destinations declared in +// ~/.captain.yaml. A cluster that cannot be reached is an error rather than a +// skipped destination, so a supervisor never reports success having published +// to only some of its configured targets. +func credentialTargetsFromConfig(defaults captainconfig.CredentialDefaults) ([]credsync.Target, error) { + if err := defaults.Validate(); err != nil { + return nil, err + } + var targets []credsync.Target + for _, publish := range defaults.Publish { + if publish.Kubernetes != nil { + client, namespace, err := kubernetesClient(kubeClientOptions{ + Context: publish.Kubernetes.Context, + Namespace: publish.Kubernetes.Namespace, + }) + if err != nil { + return nil, err + } + targets = append(targets, credsync.KubernetesTarget{ + Client: client, Namespace: namespace, Secret: publish.Kubernetes.Secret, + }) + continue + } + directory, err := publish.ResolvedDirectory() + if err != nil { + return nil, err + } + targets = append(targets, credsync.DirectoryTarget{Path: directory}) + } + return targets, nil +} + +// describeCredentialTargets names the destinations for status output without +// requiring them to be reachable — status must still work when the cluster is +// unavailable, which is exactly when someone runs it. +func describeCredentialTargets(opts CredentialsOptions) ([]string, error) { + var names []string + if directory := strings.TrimSpace(opts.Directory); directory != "" { + names = append(names, "directory "+directory) + } + if namespace := strings.TrimSpace(opts.Namespace); namespace != "" { + names = append(names, fmt.Sprintf("secret %s/%s", namespace, credentialSecretName(opts.Secret))) + } + if len(names) > 0 { + return names, nil + } + + saved, _, err := captainconfig.Load() + if err != nil { + return nil, err + } + for _, publish := range saved.Credentials.Publish { + if publish.Kubernetes != nil { + names = append(names, fmt.Sprintf("secret %s/%s", + publish.Kubernetes.Namespace, credentialSecretName(publish.Kubernetes.Secret))) + continue + } + names = append(names, "directory "+publish.Directory) + } + return names, nil +} + +func credentialSecretName(name string) string { + if strings.TrimSpace(name) == "" { + return credsync.DefaultSecretName + } + return name +} + +// resolveCredentialProviders parses the requested provider names, defaulting to +// every supported provider. +func resolveCredentialProviders(names []string) ([]agentcreds.Provider, error) { + if len(names) == 0 { + return agentcreds.Providers(), nil + } + providers := make([]agentcreds.Provider, 0, len(names)) + for _, name := range names { + provider, err := agentcreds.ParseProvider(name) + if err != nil { + return nil, err + } + providers = append(providers, provider) + } + return providers, nil +} + +// credentialSourceLabel names where the credential is being read from, which is +// the first thing to check when a provider reads as unavailable. +func credentialSourceLabel(reader agentcreds.Reader, provider agentcreds.Provider) string { + if provider == agentcreds.ProviderCodex { + return reader.CodexPath() + } + if reader.ReadKeychain != nil { + return "keychain: Claude Code-credentials" + } + return reader.ClaudePath() +} diff --git a/pkg/cli/serve_credentials.go b/pkg/cli/serve_credentials.go new file mode 100644 index 00000000..b90ecb69 --- /dev/null +++ b/pkg/cli/serve_credentials.go @@ -0,0 +1,54 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/flanksource/captain/pkg/captainconfig" +) + +// startCredentialPublisher runs the credential republish loop for the lifetime +// of `captain serve`. +// +// This is the supervisor half of the sandbox-credentials feature: the sandbox +// receives an access token with no refresh token, so it cannot renew itself and +// something here has to. It sits beside the session monitor rather than inside +// it — the monitor's backfill pass runs on a 24h interval, which is two orders +// of magnitude too slow for a credential that lapses within the hour. +// +// Publishing is opt-in: with no credentials.publish entries this does nothing +// and the server starts as before. +func startCredentialPublisher(ctx context.Context, stdout io.Writer) error { + saved, _, err := captainconfig.Load() + if err != nil { + return err + } + if len(saved.Credentials.Publish) == 0 { + return nil + } + // A malformed destination is a startup error, not a warning: a supervisor + // that comes up having silently published nothing looks healthy while every + // agent it serves fails to authenticate. + if err := saved.Credentials.Validate(); err != nil { + return err + } + + publisher, err := buildCredentialPublisher(CredentialsOptions{}) + if err != nil { + return err + } + + names := make([]string, 0, len(publisher.Targets)) + for _, target := range publisher.Targets { + names = append(names, target.Name()) + } + fmt.Fprintf(stdout, " credentials: %d provider(s) -> %v\n", len(publisher.Providers), names) + + go func() { + if err := publisher.Run(ctx); err != nil && ctx.Err() == nil { + log.Errorf("credential publisher stopped: %v", err) + } + }() + return nil +} diff --git a/pkg/cli/serve_sandbox_credentials.go b/pkg/cli/serve_sandbox_credentials.go new file mode 100644 index 00000000..4003b5b0 --- /dev/null +++ b/pkg/cli/serve_sandbox_credentials.go @@ -0,0 +1,246 @@ +// HTTP surface for the agent-login sync — the mirror that lets a deployed agent +// reach a model provider with this host's own claude/codex logins. +// +// A sidecar with no credential enrolls, goes ready, and fails its first task, so +// the thing an operator most needs is to see what is published, where, and how +// long it stays valid. The CLI has had `sandbox credentials status|sync` for +// that; this exposes the same two calls plus the destinations they read from. +// +// `credentials sync` is clicky.MarkLocalOnly for a reason (cmd/captain/main.go): +// it reads this host's keychain and writes a credential into a directory or a +// cluster, which must never be reachable as unauthenticated REST under /api/v1. +// Nothing here weakens that — these are hand-written routes behind +// validateLocalConfigurationRequest, the same loopback-and-same-origin gate the +// deploy routes use, and they stay out of the auto-published executor surface. + +package cli + +import ( + "context" + "fmt" + "net/http" + "strings" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" + "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/credsync" +) + +func registerSandboxCredentialHandlers(mux *http.ServeMux) { + mux.Handle("GET /api/captain/sandbox/credentials", handleCredentialsStatus()) + mux.Handle("PUT /api/captain/sandbox/credentials/config", handleCredentialsConfig()) + mux.Handle("POST /api/captain/sandbox/credentials/sync", handleCredentialsSync()) +} + +// credentialsView is the whole panel in one response: what is configured, and +// what each provider's login currently looks like. +type credentialsView struct { + Config credentialsConfig `json:"config"` + Status []CredentialStatus `json:"status"` + // Providers are the logins captain knows how to mirror, so the destination + // editor offers them rather than asking an operator to spell them. + Providers []string `json:"providers"` + // DefaultSecret is what an unnamed Kubernetes destination resolves to. + DefaultSecret string `json:"defaultSecret"` + // DefaultMargin is what an unset refresh margin resolves to. + DefaultMargin string `json:"defaultMargin"` +} + +// credentialsConfig is the `credentials:` block of ~/.captain.yaml on the wire. +// +// It is a separate shape from captainconfig.CredentialDefaults rather than json +// tags on it, because that package stays yaml-only and a duration crossing JSON +// has to be "1h" — the form an operator writes in the file and passes to +// --refresh-margin — not the nanosecond count time.Duration marshals to. +type credentialsConfig struct { + RefreshMargin string `json:"refreshMargin"` + Publish []credentialDestination `json:"publish"` +} + +type credentialDestination struct { + Providers []string `json:"providers,omitempty"` + Directory string `json:"directory,omitempty"` + Namespace string `json:"namespace,omitempty"` + Secret string `json:"secret,omitempty"` + Context string `json:"kubeContext,omitempty"` +} + +// isKubernetes distinguishes the two destination kinds the UI presents as one +// row. A directory and a Secret are mutually exclusive per entry, which +// CredentialPublish.Validate enforces on the way back in. +func (d credentialDestination) isKubernetes() bool { + return strings.TrimSpace(d.Directory) == "" +} + +func handleCredentialsStatus() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // Read-only, but still gated: the response names this host's directories + // and the clusters it publishes into. + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + saved, _, err := captainconfig.Load() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + ctx, cancel := context.WithTimeout(r.Context(), preflightTimeout) + defer cancel() + // A provider that cannot be read is a row with a reason, not a failed + // request: "codex: not logged in" beside a healthy claude row is the + // whole point of the panel. + status, err := RunCredentialsStatus(ctx, CredentialsOptions{}) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadGateway)) + return + } + writeServeJSON(w, http.StatusOK, credentialsView{ + Config: credentialsConfigFrom(saved.Credentials), + Status: status, + Providers: supportedCredentialProviders(), + DefaultSecret: credsync.DefaultSecretName, + DefaultMargin: credsync.DefaultMargin.String(), + }) + }) +} + +// handleCredentialsConfig replaces the `credentials:` block. +// +// A whole-block PUT rather than a patch: Publish is a list whose entries have no +// stable identity, so a partial update would have to invent one and would make +// "remove the last destination" indistinguishable from "send nothing". +func handleCredentialsConfig() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + var request credentialsConfig + if err := decodeServeJSONBody(w, r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defaults, err := credentialDefaultsFrom(request) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Validated before the write, so a destination naming nowhere is refused + // here rather than silently publishing nothing on the next tick. + if err := defaults.Validate(); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + saved, _, err := captainconfig.Load() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + saved.Credentials = defaults + if err := captainconfig.Save(saved); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + // Echoed back through the same conversion the GET uses, so the form + // renders what was actually stored rather than what it sent. + writeServeJSON(w, http.StatusOK, credentialsConfigFrom(saved.Credentials)) + }) +} + +// handleCredentialsSync publishes once, now. +// +// The saved destinations are used unless the body names one, which mirrors the +// CLI: a destination can be tried once without editing the file first. +func handleCredentialsSync() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := validateLocalConfigurationRequest(r); err != nil { + http.Error(w, err.Error(), http.StatusForbidden) + return + } + var request credentialDestination + if err := decodeServeJSONBody(w, r, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, err := RunCredentialsSync(r.Context(), CredentialsOptions{ + Providers: request.Providers, + Directory: strings.TrimSpace(request.Directory), + Namespace: strings.TrimSpace(request.Namespace), + Secret: strings.TrimSpace(request.Secret), + Context: strings.TrimSpace(request.Context), + }) + if err != nil { + http.Error(w, err.Error(), serveRunStatus(err, http.StatusBadGateway)) + return + } + writeServeJSON(w, http.StatusOK, result) + }) +} + +// supportedCredentialProviders names the logins captain can mirror, so the +// destination editor offers them instead of accepting a typo that resolves to +// "publish nothing". +func supportedCredentialProviders() []string { + providers := agentcreds.Providers() + names := make([]string, 0, len(providers)) + for _, provider := range providers { + names = append(names, string(provider)) + } + return names +} + +// credentialsConfigFrom flattens the stored block onto the wire shape. +func credentialsConfigFrom(defaults captainconfig.CredentialDefaults) credentialsConfig { + view := credentialsConfig{Publish: make([]credentialDestination, 0, len(defaults.Publish))} + if defaults.RefreshMargin > 0 { + view.RefreshMargin = defaults.RefreshMargin.String() + } + for _, publish := range defaults.Publish { + destination := credentialDestination{ + Providers: publish.Providers, + Directory: publish.Directory, + } + if publish.Kubernetes != nil { + destination.Namespace = publish.Kubernetes.Namespace + destination.Secret = publish.Kubernetes.Secret + destination.Context = publish.Kubernetes.Context + } + view.Publish = append(view.Publish, destination) + } + return view +} + +// credentialDefaultsFrom is the inverse, and the only place a submitted +// duration is parsed. +func credentialDefaultsFrom(request credentialsConfig) (captainconfig.CredentialDefaults, error) { + defaults := captainconfig.CredentialDefaults{ + Publish: make([]captainconfig.CredentialPublish, 0, len(request.Publish)), + } + if margin := strings.TrimSpace(request.RefreshMargin); margin != "" { + parsed, err := time.ParseDuration(margin) + if err != nil { + return defaults, fmt.Errorf( + "refresh margin %q is not a duration such as 1h or 30m", request.RefreshMargin) + } + if parsed < 0 { + return defaults, fmt.Errorf("refresh margin must not be negative, got %q", request.RefreshMargin) + } + defaults.RefreshMargin = parsed + } + for _, destination := range request.Publish { + publish := captainconfig.CredentialPublish{Providers: destination.Providers} + if destination.isKubernetes() { + publish.Kubernetes = &captainconfig.CredentialSecretRef{ + Context: strings.TrimSpace(destination.Context), + Namespace: strings.TrimSpace(destination.Namespace), + Secret: strings.TrimSpace(destination.Secret), + } + } else { + publish.Directory = strings.TrimSpace(destination.Directory) + } + defaults.Publish = append(defaults.Publish, publish) + } + return defaults, nil +} diff --git a/pkg/cli/serve_sandbox_credentials_test.go b/pkg/cli/serve_sandbox_credentials_test.go new file mode 100644 index 00000000..e770b6e4 --- /dev/null +++ b/pkg/cli/serve_sandbox_credentials_test.go @@ -0,0 +1,151 @@ +package cli + +import ( + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/flanksource/captain/pkg/captainconfig" +) + +// putCredentialsConfig is the round trip the form performs: send a block, get +// back what was actually stored. +func putCredentialsConfig(t *testing.T, body string) *http.Response { + t.Helper() + return serveSandbox(t, loopbackRequest( + http.MethodPut, "/api/captain/sandbox/credentials/config", body)).Result() +} + +// Mirroring a login off this host is opt-in, and a destination naming nowhere +// would publish nothing while looking configured — so it is refused at the +// write rather than discovered as silence on the next tick. +func TestCredentialsConfigRefusesADestinationThatNamesNowhere(t *testing.T) { + isolatedConfig(t) + + response := putCredentialsConfig(t, `{"refreshMargin":"1h","publish":[{"providers":["claude"]}]}`) + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want a refusal", response.StatusCode) + } + + // And nothing was written: a rejected block must not half-apply. + saved, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + if len(saved.Credentials.Publish) != 0 || saved.Credentials.RefreshMargin != 0 { + t.Fatalf("credentials = %+v, want the block untouched", saved.Credentials) + } +} + +// A duration crosses JSON as "1h" because that is what the yaml holds and what +// --refresh-margin takes; the nanosecond count time.Duration marshals to would +// be unreadable in the file this writes. +func TestCredentialsConfigRoundTripsADurationAsText(t *testing.T) { + isolatedConfig(t) + + response := putCredentialsConfig(t, + `{"refreshMargin":"90m","publish":[{"directory":"/tmp/creds","providers":["claude"]}]}`) + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + var echoed credentialsConfig + if err := json.NewDecoder(response.Body).Decode(&echoed); err != nil { + t.Fatal(err) + } + if echoed.RefreshMargin != "1h30m0s" { + t.Fatalf("echoed margin = %q, want a duration string", echoed.RefreshMargin) + } + + saved, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + if saved.Credentials.RefreshMargin != 90*time.Minute { + t.Fatalf("stored margin = %s, want 90m", saved.Credentials.RefreshMargin) + } + if len(saved.Credentials.Publish) != 1 || saved.Credentials.Publish[0].Directory != "/tmp/creds" { + t.Fatalf("stored publish = %+v", saved.Credentials.Publish) + } +} + +func TestCredentialsConfigRejectsAnUnparseableDuration(t *testing.T) { + isolatedConfig(t) + + response := putCredentialsConfig(t, `{"refreshMargin":"soon","publish":[]}`) + if response.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want a refusal", response.StatusCode) + } +} + +// An empty publish list is the documented way to turn mirroring off, so it has +// to be storable rather than read as "no change". +func TestCredentialsConfigStoresAnEmptyPublishList(t *testing.T) { + isolatedConfig(t) + + if code := putCredentialsConfig(t, + `{"refreshMargin":"","publish":[{"directory":"/tmp/creds"}]}`).StatusCode; code != http.StatusOK { + t.Fatalf("seed status = %d", code) + } + if code := putCredentialsConfig(t, `{"refreshMargin":"","publish":[]}`).StatusCode; code != http.StatusOK { + t.Fatalf("clear status = %d", code) + } + + saved, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + if len(saved.Credentials.Publish) != 0 { + t.Fatalf("publish = %+v, want it cleared", saved.Credentials.Publish) + } +} + +// A Kubernetes destination is the shape the deploy form's "Agent login Secret" +// consumes, so the namespace/secret/context triple has to survive the trip. +func TestCredentialsConfigRoundTripsAKubernetesDestination(t *testing.T) { + isolatedConfig(t) + + response := putCredentialsConfig(t, + `{"refreshMargin":"","publish":[{"namespace":"captain","secret":"agent-creds","kubeContext":"k3s"}]}`) + if response.StatusCode != http.StatusOK { + t.Fatalf("status = %d", response.StatusCode) + } + + saved, _, err := captainconfig.Load() + if err != nil { + t.Fatal(err) + } + if len(saved.Credentials.Publish) != 1 { + t.Fatalf("publish = %+v", saved.Credentials.Publish) + } + target := saved.Credentials.Publish[0].Kubernetes + if target == nil { + t.Fatal("kubernetes destination was stored as a directory") + } + if target.Namespace != "captain" || target.Secret != "agent-creds" || target.Context != "k3s" { + t.Fatalf("kubernetes = %+v", *target) + } +} + +// The sync route writes a credential into a directory or a cluster, which is +// exactly why the CLI command is local-only. Reaching it from anywhere but +// loopback must be refused before it reads the keychain. +func TestCredentialsRoutesAreLoopbackOnly(t *testing.T) { + isolatedConfig(t) + + for _, route := range []struct { + method string + target string + body string + }{ + {http.MethodGet, "/api/captain/sandbox/credentials", ""}, + {http.MethodPut, "/api/captain/sandbox/credentials/config", `{"refreshMargin":"","publish":[]}`}, + {http.MethodPost, "/api/captain/sandbox/credentials/sync", `{}`}, + } { + request := loopbackRequest(route.method, route.target, route.body) + request.RemoteAddr = "203.0.113.7:41000" + if code := serveSandbox(t, request).Code; code != http.StatusForbidden { + t.Errorf("%s %s from off-host = %d, want 403", route.method, route.target, code) + } + } +} diff --git a/pkg/cli/webapp/src/SandboxCredentials.test.tsx b/pkg/cli/webapp/src/SandboxCredentials.test.tsx new file mode 100644 index 00000000..3f259a17 --- /dev/null +++ b/pkg/cli/webapp/src/SandboxCredentials.test.tsx @@ -0,0 +1,170 @@ +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { SandboxCredentials } from "./SandboxCredentials"; + +const VIEW = { + config: { + refreshMargin: "1h", + publish: [{ namespace: "captain", secret: "", providers: ["claude"] }], + }, + status: [ + { + provider: "claude", + source: "keychain", + key: "oauth", + expiresAt: "2026-08-25T00:00:00Z", + expiresIn: "6d", + expired: false, + targets: ["secret captain/captain-agent-credentials"], + }, + // A provider that cannot be read is a row with a reason, not a missing row. + { + provider: "codex", + source: "not logged in", + key: "", + expiresAt: "0001-01-01T00:00:00Z", + expiresIn: "", + expired: true, + }, + ], + providers: ["claude", "codex"], + defaultSecret: "captain-agent-credentials", + defaultMargin: "5m0s", +}; + +function stubFetch(overrides: Record = {}) { + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input); + const ok = (body: unknown) => + Promise.resolve({ + ok: true, + status: 200, + json: () => Promise.resolve(body), + text: () => Promise.resolve(JSON.stringify(body)), + }); + if (url.includes("/sandbox/credentials/config")) { + return ok(JSON.parse(String(init?.body ?? "{}"))); + } + if (url.includes("/sandbox/credentials/sync")) { + return ok(overrides.sync ?? { published: ["claude"], targets: ["secret captain/x"] }); + } + if (url.includes("/sandbox/credentials")) return ok(overrides.view ?? VIEW); + throw new Error(`unexpected fetch: ${url}`); + }); + vi.stubGlobal("fetch", fetchMock); + return fetchMock; +} + +function renderPanel() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render( + + + , + ); +} + +/** The PUT body, which is the contract with ~/.captain.yaml. */ +function savedConfig(fetchMock: ReturnType) { + const call = fetchMock.mock.calls.find( + ([url, init]) => + String(url).includes("/credentials/config") && + (init as RequestInit | undefined)?.method === "PUT", + ); + if (!call) throw new Error("no config was saved"); + return JSON.parse(String((call[1] as RequestInit).body)) as Record; +} + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("SandboxCredentials", () => { + // A sidecar with no credential enrolls, goes ready, and fails its first task, + // so which login expires when is the panel's whole reason to exist. + it("reports each login's expiry and where it publishes", async () => { + stubFetch(); + renderPanel(); + + await screen.findByText("claude"); + expect(screen.getByText(/expires in 6d/)).toBeInTheDocument(); + expect(screen.getByText(/secret captain\/captain-agent-credentials/)).toBeInTheDocument(); + // A login that cannot be read is stated rather than omitted. + expect(screen.getByText("codex")).toBeInTheDocument(); + expect(screen.getByText("expired")).toBeInTheDocument(); + }); + + it("saves an edited refresh margin as a duration string", async () => { + const fetchMock = stubFetch(); + renderPanel(); + + const margin = await screen.findByDisplayValue("1h"); + fireEvent.change(margin, { target: { value: "90m" } }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(savedConfig(fetchMock).refreshMargin).toBe("90m")); + }); + + // Empty is the documented way to turn mirroring off, so removing the last + // destination has to reach the server as an empty list rather than as nothing. + it("sends an empty publish list when the last destination is removed", async () => { + const fetchMock = stubFetch(); + renderPanel(); + + fireEvent.click(await screen.findByRole("button", { name: "Remove" })); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => expect(savedConfig(fetchMock).publish).toEqual([])); + }); + + // A directory and a Secret are mutually exclusive server-side, so the row an + // operator adds has to commit to one of them. + it("adds a directory destination without a namespace", async () => { + const fetchMock = stubFetch(); + renderPanel(); + + fireEvent.click(await screen.findByRole("button", { name: "Add directory" })); + fireEvent.change(screen.getByPlaceholderText("~/.captain/credentials"), { + target: { value: "/srv/creds" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Save" })); + + await waitFor(() => { + const publish = savedConfig(fetchMock).publish as Array>; + expect(publish[1]).toMatchObject({ directory: "/srv/creds" }); + expect(publish[1]).not.toHaveProperty("namespace"); + }); + }); + + it("publishes once and reports what it wrote", async () => { + const fetchMock = stubFetch(); + renderPanel(); + + // The button renders immediately but stays disabled until the panel knows + // what it would publish, so clicking before then does nothing. + await screen.findByText("claude"); + fireEvent.click(screen.getByRole("button", { name: "Sync now" })); + + await screen.findByText(/Published claude/); + // The saved destinations are used, so the body carries no override. + const call = fetchMock.mock.calls.find(([url]) => + String(url).includes("/credentials/sync"), + ); + expect(JSON.parse(String((call?.[1] as RequestInit)?.body))).toEqual({}); + }); + + // No destinations means nothing is mirrored, which looks identical to a + // working setup until an agent fails its first task. + it("says plainly when nothing is mirrored", async () => { + stubFetch({ view: { ...VIEW, config: { refreshMargin: "", publish: [] } } }); + renderPanel(); + + await screen.findByText(/No destinations, so nothing is mirrored/); + }); +}); diff --git a/pkg/cli/webapp/src/SandboxCredentials.tsx b/pkg/cli/webapp/src/SandboxCredentials.tsx new file mode 100644 index 00000000..e40f0c3b --- /dev/null +++ b/pkg/cli/webapp/src/SandboxCredentials.tsx @@ -0,0 +1,327 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Button, Combobox, Field, InputField, Panel } from "@flanksource/clicky-ui/components"; +import { Badge } from "@flanksource/clicky-ui/data"; + +import { + fetchCredentials, + saveCredentialsConfig, + syncCredentials, + type CredentialDestination, + type CredentialStatus, + type CredentialsConfig, + type CredentialsSyncResult, +} from "./sandboxData"; + +/** + * The agent-login mirror: which of this host's model-provider logins get copied + * where, and how long they stay valid. + * + * A deployed sidecar with no credential enrolls, reports ready, and fails its + * first task minutes later — the same late failure the deploy preflight exists + * to prevent — so expiry and destinations are stated rather than left to a CLI + * an operator has to remember to run. + */ +export function SandboxCredentials() { + const client = useQueryClient(); + const credentials = useQuery({ + queryKey: ["sandbox-credentials"], + queryFn: fetchCredentials, + retry: false, + }); + + // Held locally so a half-edited destination is not written on every keystroke, + // and reseeded whenever the server's copy changes under us. + const [draft, setDraft] = useState(); + useEffect(() => { + if (credentials.data) setDraft(credentials.data.config); + }, [credentials.data]); + + const save = useMutation({ + mutationFn: (config: CredentialsConfig) => saveCredentialsConfig(config), + onSuccess: () => void client.invalidateQueries({ queryKey: ["sandbox-credentials"] }), + }); + const sync = useMutation({ + // No override: the button publishes to what is configured, which is what + // the supervisor's own loop would do on its next tick. + mutationFn: () => syncCredentials(), + onSuccess: () => void client.invalidateQueries({ queryKey: ["sandbox-credentials"] }), + }); + + const error = credentials.error ?? save.error ?? sync.error; + + return ( + sync.mutate()} + > + {sync.isPending ? "Syncing…" : "Sync now"} + + } + > +
+

+ Redacted claude and codex logins mirrored to the places a sandbox reads + them from. Values never leave this host in plain text, and nothing is + published until a destination is configured. +

+ + {error && ( +

+ {error instanceof Error ? error.message : String(error)} +

+ )} + + {sync.data && ( + // One string rather than interleaved expressions: split across text + // nodes the sentence reads correctly but cannot be matched as one. +

{describeSync(sync.data)}

+ )} + + + + {draft && ( + save.mutate(draft)} + /> + )} +
+
+ ); +} + +/** What one publish pass did, as a sentence. */ +function describeSync(result: CredentialsSyncResult): string { + const published = (result.published ?? []).join(", "); + const targets = (result.targets ?? []).join(", "); + if (!published) return "Published nothing: no destination is configured."; + return targets + ? `Published ${published} to ${targets}.` + : `Published ${published}.`; +} + +/** + * One row per provider, with its expiry. + * + * A provider that cannot be read is a row with a reason rather than a missing + * row: "codex: not logged in" beside a healthy claude row is what tells an + * operator which login to renew. + */ +function ProviderStatus({ rows }: { rows: CredentialStatus[] }) { + if (rows.length === 0) { + return ( +

No agent logins found on this host.

+ ); + } + return ( +
+ {rows.map((row) => ( + + ))} +
+ ); +} + +function Row({ row }: { row: CredentialStatus }) { + return ( + <> +
{row.provider}
+
+ {row.expired ? ( + expired + ) : ( + {row.expiresIn ? `expires in ${row.expiresIn}` : row.source} + )} + {(row.targets?.length ?? 0) > 0 && ( + + → {row.targets?.join(", ")} + + )} +
+ + ); +} + +/** + * The `credentials.publish` list. + * + * Each row is one destination, and a destination is either a host directory (a + * docker workload bind-mounts it) or a Kubernetes Secret (a sidecar mounts it). + * The server refuses an entry naming both or neither, so the row makes the two + * exclusive rather than letting an operator write a refusal. + */ +function DestinationEditor({ + config, + providers, + defaultSecret, + defaultMargin, + saving, + onChange, + onSave, +}: { + config: CredentialsConfig; + providers: string[]; + defaultSecret: string; + defaultMargin: string; + saving: boolean; + onChange: (config: CredentialsConfig) => void; + onSave: () => void; +}) { + const update = (index: number, next: CredentialDestination) => + onChange({ + ...config, + publish: config.publish.map((entry, at) => (at === index ? next : entry)), + }); + + return ( +
+ + onChange({ ...config, refreshMargin: value })} + placeholder={defaultMargin} + /> + + + {config.publish.length === 0 ? ( +

+ note No destinations, so nothing is mirrored. A sandbox + started now reaches no model provider unless it carries its own keys. +

+ ) : ( + config.publish.map((destination, index) => ( + update(index, next)} + onRemove={() => + onChange({ + ...config, + publish: config.publish.filter((_, at) => at !== index), + }) + } + /> + )) + )} + +
+
+ + +
+ +
+
+ ); +} + +function Destination({ + destination, + providers, + defaultSecret, + onChange, + onRemove, +}: { + destination: CredentialDestination; + providers: string[]; + defaultSecret: string; + onChange: (next: CredentialDestination) => void; + onRemove: () => void; +}) { + // Which kind this row is comes from which field it carries, because the two + // are mutually exclusive server-side and the row was created as one or the + // other. A directory of "" is still a directory row. + const isDirectory = destination.directory !== undefined; + + return ( +
+ {isDirectory ? ( + + onChange({ ...destination, directory: value })} + placeholder="~/.captain/credentials" + /> + + ) : ( +
+ + onChange({ ...destination, namespace: value })} + placeholder="captain" + invalid={!(destination.namespace ?? "").trim()} + /> + + + onChange({ ...destination, secret: value })} + placeholder={defaultSecret} + /> + +
+ )} + + + ({ value: name, label: name }))} + value={destination.providers ?? []} + onChange={(value: string[]) => onChange({ ...destination, providers: value })} + ariaLabel="Providers" + multiple + allowCustomValue={false} + placeholder="all" + /> + + +
+ +
+
+ ); +} diff --git a/pkg/container/tui.go b/pkg/container/tui.go index 5387d0d9..9f1bb3d6 100644 --- a/pkg/container/tui.go +++ b/pkg/container/tui.go @@ -100,29 +100,13 @@ func RunWizard(components []Component, existing *SandboxConfig) (*WizardResult, } var selectedTokens []string - if existing != nil && existing.Tokens != nil { - if existing.Tokens.AWS != nil { - selectedTokens = append(selectedTokens, "aws") - } - if existing.Tokens.GCP != nil { - selectedTokens = append(selectedTokens, "gcp") - } - if existing.Tokens.Azure != nil { - selectedTokens = append(selectedTokens, "azure") - } - if existing.Tokens.GitHub != nil { - selectedTokens = append(selectedTokens, "github") - } - if existing.Tokens.Kubernetes != nil { - selectedTokens = append(selectedTokens, "kubernetes") - } + if existing != nil { + selectedTokens = sandbox.SelectedTokenProviders(existing.Tokens) } - tokenOptions := []huh.Option[string]{ - huh.NewOption("AWS", "aws"), - huh.NewOption("GCP", "gcp"), - huh.NewOption("Azure", "azure"), - huh.NewOption("GitHub", "github"), - huh.NewOption("Kubernetes", "kubernetes"), + tokenProviders := sandbox.TokenProviders() + tokenOptions := make([]huh.Option[string], len(tokenProviders)) + for i, provider := range tokenProviders { + tokenOptions[i] = huh.NewOption(provider.Label, provider.Name) } var envKeys []string @@ -284,7 +268,7 @@ func RunWizard(components []Component, existing *SandboxConfig) (*WizardResult, Presets: selectedPresets, } - result.Tokens = buildTokensConfig(selectedTokens) + result.Tokens = sandbox.ApplyTokenSelection(nil, selectedTokens) result.Env = make(map[string]string) if existing != nil { @@ -308,33 +292,6 @@ func pageTitle(counter *int, name string) string { return fmt.Sprintf("%d. %s", *counter, name) } -func buildTokensConfig(selected []string) *sandbox.TokensConfig { - if len(selected) == 0 { - return nil - } - has := make(map[string]bool, len(selected)) - for _, s := range selected { - has[s] = true - } - tc := &sandbox.TokensConfig{} - if has["aws"] { - tc.AWS = &sandbox.AWSTokenConfig{} - } - if has["gcp"] { - tc.GCP = &sandbox.GCPTokenConfig{} - } - if has["azure"] { - tc.Azure = &sandbox.AzureTokenConfig{} - } - if has["github"] { - tc.GitHub = &sandbox.GitHubTokenConfig{} - } - if has["kubernetes"] { - tc.Kubernetes = &sandbox.K8sTokenConfig{} - } - return tc -} - // SelectComponents is the simple single-page picker (used by non-interactive flows). func SelectComponents(components []Component) ([]Component, error) { options := buildAllOptions(components) diff --git a/pkg/credsync/credsync_suite_test.go b/pkg/credsync/credsync_suite_test.go new file mode 100644 index 00000000..ad3afbd7 --- /dev/null +++ b/pkg/credsync/credsync_suite_test.go @@ -0,0 +1,13 @@ +package credsync_test + +import ( + "testing" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +func TestCredSync(t *testing.T) { + RegisterFailHandler(Fail) + RunSpecs(t, "CredSync Suite") +} diff --git a/pkg/credsync/publisher.go b/pkg/credsync/publisher.go new file mode 100644 index 00000000..08e5e76e --- /dev/null +++ b/pkg/credsync/publisher.go @@ -0,0 +1,197 @@ +// Package credsync keeps the agent CLIs' redacted subscription logins fresh +// wherever sandboxed workloads read them — a shared folder for Docker, a +// Kubernetes Secret for a deployed sidecar. +// +// It exists because the redaction that makes those credentials safe to hand out +// also makes them short-lived: the sandbox holds an access token with no +// refresh token, so it cannot renew itself. Something on the supervisor has to, +// and the schedule is driven by the credential's own expiry rather than a fixed +// interval, so what lands in the target is as fresh as the host can make it. +package credsync + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" + "github.com/flanksource/commons/logger" +) + +var log = logger.GetLogger("credsync") + +// Target is one place credentials are published to. +type Target interface { + // Name identifies the target in logs and status output. + Name() string + Publish(ctx context.Context, credentials []agentcreds.Credential) error +} + +// Scheduling bounds. The interval between publishes is derived from the +// credential's expiry, then clamped: the floor stops a credential that is +// always near expiry from becoming a hot loop, and the ceiling keeps a +// long-lived token (a Codex access token runs for days) on a cadence that still +// notices a host re-login promptly. +const ( + DefaultMargin = 5 * time.Minute + MinimumInterval = time.Minute + MaximumInterval = 30 * time.Minute + // RetryInterval is how soon a failed publish is retried. Shorter than + // MinimumInterval because a failure usually means a stale source that a + // human is about to refresh by using the CLI. + RetryInterval = 2 * time.Minute +) + +// Publisher reads the host's logins and pushes the redacted result to targets. +type Publisher struct { + Reader agentcreds.Reader + Providers []agentcreds.Provider + Targets []Target + // Margin is how far before expiry a republish is scheduled. + Margin time.Duration + Now func() time.Time +} + +// Result records one publish attempt, for `captain sandbox credentials status`. +type Result struct { + Published []PublishedCredential `json:"published" pretty:"label=Credentials"` + Targets []string `json:"targets" pretty:"label=Targets"` + // NextPublish is when the loop will run again. + NextPublish time.Time `json:"nextPublish" pretty:"label=Next publish"` +} + +// PublishedCredential is one credential's identity and lifetime. It never +// carries the credential itself — status output is not a way to read a token. +type PublishedCredential struct { + Provider string `json:"provider" pretty:"label=Provider"` + Key string `json:"key" pretty:"label=Key"` + Bytes int `json:"bytes" pretty:"label=Size"` + ExpiresAt time.Time `json:"expiresAt" pretty:"label=Expires"` +} + +func (p Publisher) now() time.Time { + if p.Now == nil { + return time.Now() + } + return p.Now() +} + +func (p Publisher) margin() time.Duration { + if p.Margin <= 0 { + return DefaultMargin + } + return p.Margin +} + +// PublishOnce reads every configured provider and writes to every target. +// +// An expired source is refused rather than published: the existing target keeps +// whatever it already holds, because a dead token in a Secret turns a problem +// the supervisor can see and name into a 401 inside an agent hours later. +func (p Publisher) PublishOnce(ctx context.Context) (Result, error) { + if len(p.Providers) == 0 { + return Result{}, fmt.Errorf("no credential providers configured") + } + if len(p.Targets) == 0 { + return Result{}, fmt.Errorf("no credential targets configured") + } + + credentials, err := p.Reader.ReadAll(ctx, p.Providers) + if err != nil { + return Result{}, err + } + now := p.now() + for _, credential := range credentials { + if credential.Expired(now) { + return Result{}, fmt.Errorf( + "%s credential expired %s ago; run `%s` on this host to refresh it (nothing was published, the previous credential is untouched)", + credential.Provider, now.Sub(credential.ExpiresAt).Round(time.Second), + refreshCommand(credential.Provider)) + } + } + + result := Result{NextPublish: now.Add(p.interval(credentials, now))} + for _, credential := range credentials { + result.Published = append(result.Published, PublishedCredential{ + Provider: string(credential.Provider), + Key: credential.Filename, + Bytes: len(credential.Payload), + ExpiresAt: credential.ExpiresAt, + }) + } + + var errs []error + for _, target := range p.Targets { + if err := target.Publish(ctx, credentials); err != nil { + errs = append(errs, err) + continue + } + result.Targets = append(result.Targets, target.Name()) + } + if len(errs) > 0 { + return result, errors.Join(errs...) + } + return result, nil +} + +// Run publishes now and then keeps republishing ahead of expiry until ctx ends. +func (p Publisher) Run(ctx context.Context) error { + timer := time.NewTimer(0) + defer timer.Stop() + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + } + + wait := RetryInterval + result, err := p.PublishOnce(ctx) + if err != nil { + if ctx.Err() != nil { + return ctx.Err() + } + log.Warnf("Credential publish failed, retrying in %s: %v", wait, err) + } else { + wait = time.Until(result.NextPublish) + if wait < MinimumInterval { + wait = MinimumInterval + } + log.Infof("Published %d credential(s) to %d target(s); next publish in %s", + len(result.Published), len(result.Targets), wait.Round(time.Second)) + } + timer.Reset(wait) + } +} + +// interval is how long until the next publish: enough before the earliest +// expiry to leave the margin, clamped to the bounds above. +func (p Publisher) interval(credentials []agentcreds.Credential, now time.Time) time.Duration { + earliest := time.Time{} + for _, credential := range credentials { + if earliest.IsZero() || credential.ExpiresAt.Before(earliest) { + earliest = credential.ExpiresAt + } + } + if earliest.IsZero() { + return MaximumInterval + } + interval := earliest.Sub(now) - p.margin() + if interval < MinimumInterval { + return MinimumInterval + } + if interval > MaximumInterval { + return MaximumInterval + } + return interval +} + +// refreshCommand names what a human should run to renew a lapsed login. The +// CLIs refresh their own tokens when used, so "use it" is the actual fix. +func refreshCommand(provider agentcreds.Provider) string { + if provider == agentcreds.ProviderCodex { + return "codex login" + } + return "claude" +} diff --git a/pkg/credsync/publisher_test.go b/pkg/credsync/publisher_test.go new file mode 100644 index 00000000..531cc5d4 --- /dev/null +++ b/pkg/credsync/publisher_test.go @@ -0,0 +1,253 @@ +package credsync_test + +import ( + "context" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" + "github.com/flanksource/captain/pkg/credsync" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" +) + +// Fixed instants, so every scheduling assertion compares against an interval +// worked out here rather than against the publisher's own arithmetic. +var ( + now = time.Date(2026, 8, 17, 12, 0, 0, 0, time.UTC) + claudeExpiry = now.Add(45 * time.Minute) + codexExpiry = now.Add(72 * time.Hour) +) + +func jwtWithExp(instant time.Time) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + claims := base64.RawURLEncoding.EncodeToString( + []byte(fmt.Sprintf(`{"exp":%d}`, instant.Unix()))) + return header + "." + claims + ".sig" +} + +// stubReader serves fixture documents in place of the Keychain and ~/.codex. +func stubReader(claudeExp, codexExp time.Time) agentcreds.Reader { + claude := fmt.Sprintf( + `{"claudeAiOauth":{"accessToken":"claude-access","refreshToken":"claude-refresh","expiresAt":%d}}`, + claudeExp.UnixMilli()) + codex := fmt.Sprintf( + `{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"id_token":%q,"access_token":%q,"refresh_token":"codex-refresh","account_id":"acct"}}`, + jwtWithExp(codexExp.Add(time.Hour)), jwtWithExp(codexExp)) + + return agentcreds.Reader{ + Home: "/fixture/home", + Now: func() time.Time { return now }, + ReadFile: func(path string) ([]byte, error) { + switch { + case filepath.Base(path) == "auth.json": + return []byte(codex), nil + case filepath.Base(path) == ".credentials.json": + return []byte(claude), nil + } + return nil, os.ErrNotExist + }, + } +} + +// recordingTarget captures what it was handed, and can be told to fail. +type recordingTarget struct { + label string + fail error + published [][]agentcreds.Credential +} + +func (t *recordingTarget) Name() string { return t.label } + +func (t *recordingTarget) Publish(_ context.Context, credentials []agentcreds.Credential) error { + if t.fail != nil { + return t.fail + } + t.published = append(t.published, credentials) + return nil +} + +func newPublisher(target credsync.Target, claudeExp, codexExp time.Time) credsync.Publisher { + return credsync.Publisher{ + Reader: stubReader(claudeExp, codexExp), + Providers: agentcreds.Providers(), + Targets: []credsync.Target{target}, + Now: func() time.Time { return now }, + } +} + +// mustPublish publishes and fails the spec on error, returning the result. +// Gomega's Expect(...).Error() cannot be used here: it requires every other +// return value to be zero, and a Result is never zero on success. +func mustPublish(publisher credsync.Publisher) credsync.Result { + GinkgoHelper() + result, err := publisher.PublishOnce(context.Background()) + Expect(err).NotTo(HaveOccurred()) + return result +} + +var _ = Describe("Publisher.PublishOnce", func() { + It("hands every target the redacted credential for each provider", func() { + target := &recordingTarget{label: "test"} + result, err := newPublisher(target, claudeExpiry, codexExpiry).PublishOnce(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + Expect(result.Targets).To(ConsistOf("test")) + Expect(target.published).To(HaveLen(1)) + + delivered := target.published[0] + Expect(delivered).To(HaveLen(2)) + // The codex document legitimately keeps a `refresh_token` key, so the + // assertion is about the secret VALUE never surviving, not the field name. + for _, credential := range delivered { + payload := string(credential.Payload) + Expect(payload).NotTo(ContainSubstring("claude-refresh")) + Expect(payload).NotTo(ContainSubstring("codex-refresh")) + } + Expect(result.Published).To(Equal([]credsync.PublishedCredential{ + { + Provider: "claude", Key: agentcreds.ClaudeFilename, + Bytes: len(delivered[0].Payload), ExpiresAt: claudeExpiry, + }, + { + Provider: "codex", Key: agentcreds.CodexFilename, + Bytes: len(delivered[1].Payload), ExpiresAt: codexExpiry, + }, + })) + }) + + It("schedules the next publish a margin before the earliest expiry", func() { + // Claude expires in 45m and Codex in 72h, so the earliest governs: + // 45m - 5m margin = 40m, inside the 1m..30m clamp ceiling -> 30m. + result, err := newPublisher(&recordingTarget{label: "t"}, claudeExpiry, codexExpiry). + PublishOnce(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(result.NextPublish).To(BeTemporally("==", now.Add(30*time.Minute))) + }) + + It("clamps to the margin when expiry is closer than the margin", func() { + // 3m to expiry, minus a 5m margin, is negative -> the 1m floor. + result, err := newPublisher(&recordingTarget{label: "t"}, now.Add(3*time.Minute), codexExpiry). + PublishOnce(context.Background()) + Expect(err).NotTo(HaveOccurred()) + Expect(result.NextPublish).To(BeTemporally("==", now.Add(time.Minute))) + }) + + It("publishes nothing at all when any source has already expired", func() { + target := &recordingTarget{label: "t"} + _, err := newPublisher(target, now.Add(-12*time.Minute), codexExpiry). + PublishOnce(context.Background()) + + Expect(err).To(MatchError(ContainSubstring("claude credential expired 12m0s ago"))) + Expect(err).To(MatchError(ContainSubstring("run `claude`"))) + Expect(target.published).To(BeEmpty(), + "a live codex credential must not be published when claude is dead — targets stay as they were") + }) + + It("reports a failing target without claiming it was published", func() { + target := &recordingTarget{label: "broken", fail: fmt.Errorf("permission denied")} + result, err := newPublisher(target, claudeExpiry, codexExpiry).PublishOnce(context.Background()) + Expect(err).To(MatchError(ContainSubstring("permission denied"))) + Expect(result.Targets).To(BeEmpty()) + }) + + It("refuses a configuration with no targets", func() { + publisher := newPublisher(&recordingTarget{}, claudeExpiry, codexExpiry) + publisher.Targets = nil + _, err := publisher.PublishOnce(context.Background()) + Expect(err).To(MatchError(ContainSubstring("no credential targets configured"))) + }) +}) + +var _ = Describe("DirectoryTarget", func() { + var dir string + + BeforeEach(func() { dir = filepath.Join(GinkgoT().TempDir(), "creds") }) + + It("writes private files into a private directory", func() { + target := credsync.DirectoryTarget{Path: dir} + _, err := newPublisher(target, claudeExpiry, codexExpiry).PublishOnce(context.Background()) + Expect(err).NotTo(HaveOccurred()) + + dirInfo, err := os.Stat(dir) + Expect(err).NotTo(HaveOccurred()) + Expect(dirInfo.Mode().Perm()).To(Equal(os.FileMode(0o700))) + + for _, name := range []string{agentcreds.ClaudeFilename, agentcreds.CodexFilename} { + info, err := os.Stat(filepath.Join(dir, name)) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o600)), name) + } + }) + + It("replaces a previous credential and leaves no temp files behind", func() { + target := credsync.DirectoryTarget{Path: dir} + publisher := newPublisher(target, claudeExpiry, codexExpiry) + mustPublish(publisher) + + first, err := os.ReadFile(filepath.Join(dir, agentcreds.ClaudeFilename)) + Expect(err).NotTo(HaveOccurred()) + + // A later expiry is a different document, so the file must change. + later := newPublisher(target, claudeExpiry.Add(time.Hour), codexExpiry) + mustPublish(later) + + second, err := os.ReadFile(filepath.Join(dir, agentcreds.ClaudeFilename)) + Expect(err).NotTo(HaveOccurred()) + Expect(second).NotTo(Equal(first)) + + entries, err := os.ReadDir(dir) + Expect(err).NotTo(HaveOccurred()) + Expect(entries).To(HaveLen(2), "only the two credential files remain") + }) + + It("tightens an inherited world-readable directory", func() { + Expect(os.MkdirAll(dir, 0o755)).To(Succeed()) + Expect(os.Chmod(dir, 0o755)).To(Succeed()) + + target := credsync.DirectoryTarget{Path: dir} + mustPublish(newPublisher(target, claudeExpiry, codexExpiry)) + + info, err := os.Stat(dir) + Expect(err).NotTo(HaveOccurred()) + Expect(info.Mode().Perm()).To(Equal(os.FileMode(0o700))) + }) +}) + +var _ = Describe("KubernetesTarget", func() { + It("applies both credentials as Secret keys and converges on republish", func() { + client := fake.NewClientset() + target := credsync.KubernetesTarget{Client: client, Namespace: "agents"} + + publisher := newPublisher(target, claudeExpiry, codexExpiry) + mustPublish(publisher) + + secret, err := client.CoreV1().Secrets("agents"). + Get(context.Background(), credsync.DefaultSecretName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(secret.Data).To(HaveKey(agentcreds.ClaudeFilename)) + Expect(secret.Data).To(HaveKey(agentcreds.CodexFilename)) + Expect(string(secret.Data[agentcreds.ClaudeFilename])).NotTo(ContainSubstring("claude-refresh")) + + // A second publish must update in place rather than fail on AlreadyExists. + later := newPublisher(target, claudeExpiry.Add(time.Hour), codexExpiry) + mustPublish(later) + + updated, err := client.CoreV1().Secrets("agents"). + Get(context.Background(), credsync.DefaultSecretName, metav1.GetOptions{}) + Expect(err).NotTo(HaveOccurred()) + Expect(updated.Data[agentcreds.ClaudeFilename]). + NotTo(Equal(secret.Data[agentcreds.ClaudeFilename])) + }) + + It("refuses to publish without a namespace rather than guessing one", func() { + target := credsync.KubernetesTarget{Client: fake.NewClientset()} + _, err := newPublisher(target, claudeExpiry, codexExpiry).PublishOnce(context.Background()) + Expect(err).To(MatchError(ContainSubstring("no namespace"))) + }) +}) diff --git a/pkg/credsync/target_dir.go b/pkg/credsync/target_dir.go new file mode 100644 index 00000000..50d5d241 --- /dev/null +++ b/pkg/credsync/target_dir.go @@ -0,0 +1,73 @@ +package credsync + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/flanksource/captain/pkg/agentcreds" +) + +// DirectoryTarget publishes credentials into a directory on this host, which a +// Docker workload bind-mounts read-only. +// +// It is the docker-side counterpart of KubernetesTarget: the git-agent deploy +// path already does exactly this for the join token, writing it to a host file +// and mounting it at /run/captain/join. +type DirectoryTarget struct { + Path string +} + +func (t DirectoryTarget) Name() string { return "directory " + t.Path } + +// Publish writes each credential as its own file, replacing the previous +// contents atomically so a workload reading the directory never observes a +// half-written credential. +func (t DirectoryTarget) Publish(_ context.Context, credentials []agentcreds.Credential) error { + if err := os.MkdirAll(t.Path, 0o700); err != nil { + return fmt.Errorf("create credential directory %s: %w", t.Path, err) + } + // MkdirAll leaves an existing directory's mode alone, so an inherited + // world-readable directory is tightened rather than trusted. + if err := os.Chmod(t.Path, 0o700); err != nil { + return fmt.Errorf("secure credential directory %s: %w", t.Path, err) + } + for _, credential := range credentials { + if err := writeFileAtomic(filepath.Join(t.Path, credential.Filename), credential.Payload); err != nil { + return err + } + } + return nil +} + +// writeFileAtomic replaces path via a temp file and rename, so a reader sees +// either the old credential or the new one. +func writeFileAtomic(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), ".credsync-*") + if err != nil { + return fmt.Errorf("create temp file beside %s: %w", path, err) + } + tempPath := temp.Name() + defer func() { _ = os.Remove(tempPath) }() + + if err := temp.Chmod(0o600); err != nil { + _ = temp.Close() + return fmt.Errorf("secure temp file for %s: %w", path, err) + } + if _, err := temp.Write(data); err != nil { + _ = temp.Close() + return fmt.Errorf("write %s: %w", path, err) + } + if err := temp.Sync(); err != nil { + _ = temp.Close() + return fmt.Errorf("sync %s: %w", path, err) + } + if err := temp.Close(); err != nil { + return fmt.Errorf("close temp file for %s: %w", path, err) + } + if err := os.Rename(tempPath, path); err != nil { + return fmt.Errorf("replace %s: %w", path, err) + } + return nil +} diff --git a/pkg/credsync/target_k8s.go b/pkg/credsync/target_k8s.go new file mode 100644 index 00000000..f6e36e5d --- /dev/null +++ b/pkg/credsync/target_k8s.go @@ -0,0 +1,77 @@ +package credsync + +import ( + "context" + "fmt" + + "github.com/flanksource/captain/pkg/agentcreds" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + coreapply "k8s.io/client-go/applyconfigurations/core/v1" + "k8s.io/client-go/kubernetes" +) + +// FieldManager owns the fields this package sets under server-side apply. +// +// Deliberately distinct from the git-agent deploy manager +// (deploy.FieldManager, "captain-sandbox-git-agent"): the two write different +// Secrets in the same namespace, and sharing a manager name would make each +// one's apply look like it should prune the other's fields. +const FieldManager = "captain-credentials" + +// DefaultSecretName is the Secret credentials are published to when a target +// does not name one. +const DefaultSecretName = "captain-agent-credentials" + +// KubernetesTarget publishes credentials into a Secret that agent workloads +// mount. Unlike the git-agent join Secret it is mutable by design — being +// re-written before the access token expires is its entire purpose. +type KubernetesTarget struct { + Client kubernetes.Interface + Namespace string + Secret string +} + +func (t KubernetesTarget) Name() string { + return fmt.Sprintf("secret %s/%s", t.Namespace, t.secretName()) +} + +func (t KubernetesTarget) secretName() string { + if t.Secret == "" { + return DefaultSecretName + } + return t.Secret +} + +// Publish converges the Secret onto the current credentials. +// +// Server-side apply with a dedicated field manager means a republish updates +// the keys this package owns and leaves anything an operator added — extra +// keys, annotations — alone, rather than failing on AlreadyExists or silently +// replacing the whole object. +func (t KubernetesTarget) Publish(ctx context.Context, credentials []agentcreds.Credential) error { + if t.Client == nil { + return fmt.Errorf("kubernetes credential target has no client") + } + if t.Namespace == "" { + return fmt.Errorf("kubernetes credential target has no namespace") + } + data := make(map[string][]byte, len(credentials)) + for _, credential := range credentials { + data[credential.Filename] = credential.Payload + } + apply := coreapply.Secret(t.secretName(), t.Namespace). + WithLabels(map[string]string{ + "app.kubernetes.io/managed-by": FieldManager, + }). + WithType(corev1.SecretTypeOpaque). + WithData(data) + + if _, err := t.Client.CoreV1().Secrets(t.Namespace).Apply(ctx, apply, metav1.ApplyOptions{ + FieldManager: FieldManager, + Force: true, + }); err != nil { + return fmt.Errorf("apply credential secret %s/%s: %w", t.Namespace, t.secretName(), err) + } + return nil +} diff --git a/pkg/sandbox/adapter/cli_env.go b/pkg/sandbox/adapter/cli_env.go index c9f84c82..bca415a8 100644 --- a/pkg/sandbox/adapter/cli_env.go +++ b/pkg/sandbox/adapter/cli_env.go @@ -6,12 +6,16 @@ import "path/filepath" // agent CLI needs passed through into its confinement. One list, shared by // every adapter, so a variable added for a new CLI cannot reach one sandbox // kind and silently miss another. +// CLAUDE_CONFIG_DIR and CODEX_HOME are here for the same reason as the keys +// beside them: a subscription login reaches the sandbox as a redacted +// credential file, and the CLI only finds it if the variable naming its config +// directory crosses the confinement too. func cliCredentialEnv(command string) []string { switch filepath.Base(command) { case "claude": - return []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN"} + return []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR"} case "codex": - return []string{"OPENAI_API_KEY", "OPENAI_BASE_URL"} + return []string{"OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_HOME"} case "gemini": return []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"} } diff --git a/pkg/sandbox/adapter/srt.go b/pkg/sandbox/adapter/srt.go index a513a37b..9c0a6c61 100644 --- a/pkg/sandbox/adapter/srt.go +++ b/pkg/sandbox/adapter/srt.go @@ -12,6 +12,7 @@ import ( "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/captainconfig" + "github.com/flanksource/captain/pkg/sandbox" sandboxruntime "github.com/flanksource/sandbox-runtime/sandbox" ) @@ -37,6 +38,12 @@ var NewSRTRuntime = func(ctx context.Context, cfg sandboxruntime.Config) (Runtim type srtSandbox struct { cwd string + // options are the backend's own settings from ~/.captain.yaml, kept so + // Prepare can acquire the credentials the `tokens:` block declares. + options map[string]any + // tokens are those acquired credentials, nil when none are declared. + tokens *sandboxTokens + // hook selects the generic exec-hook profile (api.SandboxProfileHook). // Hook policy is a construction-time choice, never inferred from the // wrapped argv: keying it off the binary name would hand agent-authored @@ -57,7 +64,7 @@ type srtSandbox struct { // SRT is the SandboxFactory for the sandbox-runtime adapter. func SRT(cfg api.SandboxConfig) (api.Sandbox, error) { - s := &srtSandbox{} + s := &srtSandbox{options: cfg.Options} if profile, _ := cfg.Options[api.SandboxOptionProfile].(string); profile == api.SandboxProfileHook { s.hook = true s.hookDenyRead = stringSliceOption(cfg.Options, api.SandboxOptionDenyRead) @@ -69,7 +76,7 @@ func init() { api.RegisterSandbox(api.SandboxSRT, SRT) } func (s *srtSandbox) Kind() api.SandboxKind { return api.SandboxSRT } -func (s *srtSandbox) Prepare(_ context.Context, spec *api.Spec) (*api.SandboxSession, error) { +func (s *srtSandbox) Prepare(ctx context.Context, spec *api.Spec) (*api.SandboxSession, error) { s.cwd = spec.Cwd() if s.hook { // The hook profile confines to an explicit workspace or not at all: a @@ -83,8 +90,18 @@ func (s *srtSandbox) Prepare(_ context.Context, spec *api.Spec) (*api.SandboxSes return nil, fmt.Errorf("create hook scratch directory: %w", err) } s.scratch = scratch + // The hook profile confines agent-authored repository code, which is + // exactly what must never hold a credential. Tokens are not acquired + // for it at all, rather than acquired and then withheld. + return &api.SandboxSession{}, nil + } + + tokens, err := acquireSandboxTokens(ctx, s.options) + if err != nil { + return nil, err } - return &api.SandboxSession{}, nil + s.tokens = tokens + return &api.SandboxSession{Env: tokens.Env()}, nil } func (s *srtSandbox) Wrap(ctx context.Context, command string, args, env []string) (string, []string, []string, error) { @@ -93,7 +110,7 @@ func (s *srtSandbox) Wrap(ctx context.Context, command string, args, env []strin if s.hook { cfg, err = srtHookConfigFor(s.cwd, s.scratch, s.hookDenyRead) } else { - cfg, err = srtConfigFor(command, s.cwd) + cfg, err = srtConfigFor(command, s.cwd, s.tokens) } if err != nil { return "", nil, nil, err @@ -149,12 +166,23 @@ func (s *srtSandbox) Close() error { } s.scratch = "" } + // The credential directory holds a live access token, so it goes with the + // sandbox rather than outliving it. + s.tokens.Cleanup() + s.tokens = nil return errors.Join(errs...) } // srtConfigFor builds the per-CLI confinement policy: the provider's API // domains, its credential env vars, and its state directory — nothing else. -func srtConfigFor(command, cwd string) (sandboxruntime.Config, error) { +// +// When tokens carry a redacted login for the CLI being wrapped, the policy +// changes shape: the private credential directory becomes writable and the +// host's own credential file becomes unreadable. The swap is conditional +// because it is only safe once a replacement exists — hiding ~/.claude's +// credentials unconditionally would break every sandbox that authenticates +// from the host login today. +func srtConfigFor(command, cwd string, tokens *sandboxTokens) (sandboxruntime.Config, error) { if cwd == "" { var err error cwd, err = os.Getwd() @@ -172,20 +200,33 @@ func srtConfigFor(command, cwd string) (sandboxruntime.Config, error) { } var domains, statePaths []string + // tokenProvider names the agent-login token provider that supplies this + // CLI's credential, empty when the CLI has none. + var tokenProvider string switch filepath.Base(command) { case "claude": domains = []string{"anthropic.com", "*.anthropic.com", "claude.ai", "*.claude.ai"} statePaths = []string{filepath.Join(home, ".claude"), filepath.Join(home, ".claude.json")} + tokenProvider = "claude" case "codex": domains = []string{"openai.com", "*.openai.com", "chatgpt.com", "*.chatgpt.com"} statePaths = []string{filepath.Join(home, ".codex")} + tokenProvider = "codex" case "gemini": domains = []string{"google.com", "*.google.com", "googleapis.com", "*.googleapis.com"} statePaths = []string{filepath.Join(home, ".gemini")} default: return sandboxruntime.Config{}, fmt.Errorf("sandbox-runtime does not support CLI command %q", command) } - passthroughEnv := cliCredentialEnv(command) + + allowWrite := append([]string{absoluteCwd, "/tmp"}, statePaths...) + denyRead := hostCredentialDenyRead(home) + if dir := tokens.Dir(); dir != "" { + allowWrite = append(allowWrite, dir) + } + if tokenProvider != "" && tokens.Has(tokenProvider) { + denyRead = append(denyRead, agentLoginDenyRead(home, tokenProvider)...) + } return sandboxruntime.Config{ Network: sandboxruntime.NetworkConfig{ @@ -193,14 +234,27 @@ func srtConfigFor(command, cwd string) (sandboxruntime.Config, error) { DeniedDomains: []string{}, }, Filesystem: sandboxruntime.FilesystemConfig{ - AllowWrite: append([]string{absoluteCwd, "/tmp"}, statePaths...), - DenyRead: hostCredentialDenyRead(home), + AllowWrite: allowWrite, + DenyRead: denyRead, DenyWrite: []string{}, }, - PassthroughEnv: passthroughEnv, + PassthroughEnv: cliCredentialEnv(command), }, nil } +// agentLoginDenyRead is the host credential a redacted copy replaces. Only the +// credential file is hidden, not the whole state directory: the CLI still needs +// to read its own settings, history and project state from there. +func agentLoginDenyRead(home, provider string) []string { + switch provider { + case "claude": + return []string{filepath.Join(home, ".claude", ".credentials.json")} + case "codex": + return []string{filepath.Join(home, ".codex", "auth.json")} + } + return nil +} + // srtHookConfigFor builds the generic exec-hook confinement. The wrapped // command is agent-authored repository code (issue #40 R5.2), so the policy is // the inverse of the CLI ones: write access to the materialized workspace and @@ -260,7 +314,7 @@ func srtHookConfigFor(workspace, scratch string, extraDenyRead []string) (sandbo // may read, shared by every SRT policy: SSH, cloud, git, container and // package-manager credentials, plus container runtime sockets. func hostCredentialDenyRead(home string) []string { - return []string{ + credentials := []string{ filepath.Join(home, ".ssh"), filepath.Join(home, ".aws"), filepath.Join(home, ".azure"), @@ -272,12 +326,9 @@ func hostCredentialDenyRead(home string) []string { filepath.Join(home, ".docker", "config.json"), filepath.Join(home, ".npmrc"), filepath.Join(home, ".pypirc"), - filepath.Join(home, ".docker", "run", "docker.sock"), - "/var/run/docker.sock", - "/run/docker.sock", - "/run/containerd/containerd.sock", - "/run/podman/podman.sock", } + // Shared with git-agent deployment, which refuses to mount the same paths. + return append(credentials, sandbox.ContainerRuntimeSockets(home)...) } func stringSliceOption(options map[string]any, key string) []string { diff --git a/pkg/sandbox/adapter/srt_test.go b/pkg/sandbox/adapter/srt_test.go index 26ff9569..e0f049a9 100644 --- a/pkg/sandbox/adapter/srt_test.go +++ b/pkg/sandbox/adapter/srt_test.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "reflect" + "slices" "strings" "testing" @@ -93,13 +94,15 @@ func TestSRTConfigFor(t *testing.T) { env []string state []string }{ - {"claude", []string{"anthropic.com", "*.anthropic.com", "claude.ai", "*.claude.ai"}, []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN"}, []string{filepath.Join(home, ".claude"), filepath.Join(home, ".claude.json")}}, - {"codex", []string{"openai.com", "*.openai.com", "chatgpt.com", "*.chatgpt.com"}, []string{"OPENAI_API_KEY", "OPENAI_BASE_URL"}, []string{filepath.Join(home, ".codex")}}, + {"claude", []string{"anthropic.com", "*.anthropic.com", "claude.ai", "*.claude.ai"}, []string{"ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL", "CLAUDE_CODE_OAUTH_TOKEN", "CLAUDE_CONFIG_DIR"}, []string{filepath.Join(home, ".claude"), filepath.Join(home, ".claude.json")}}, + {"codex", []string{"openai.com", "*.openai.com", "chatgpt.com", "*.chatgpt.com"}, []string{"OPENAI_API_KEY", "OPENAI_BASE_URL", "CODEX_HOME"}, []string{filepath.Join(home, ".codex")}}, {"gemini", []string{"google.com", "*.google.com", "googleapis.com", "*.googleapis.com"}, []string{"GEMINI_API_KEY", "GOOGLE_API_KEY"}, []string{filepath.Join(home, ".gemini")}}, } for _, tt := range tests { t.Run(tt.command, func(t *testing.T) { - got, err := srtConfigFor(tt.command, cwd) + // No tokens acquired: the baseline policy must be unchanged, so a + // sandbox that authenticates from the host login still works. + got, err := srtConfigFor(tt.command, cwd, nil) if err != nil { t.Fatal(err) } @@ -119,11 +122,52 @@ func TestSRTConfigFor(t *testing.T) { } t.Run("unsupported command fails loud", func(t *testing.T) { - _, err := srtConfigFor("bash", cwd) + _, err := srtConfigFor("bash", cwd, nil) if err == nil || !strings.Contains(err.Error(), "does not support CLI command") { t.Fatalf("err = %v", err) } }) + + // The redacted-credential swap: once a login has been acquired, the private + // directory becomes writable and the host's own credential file is hidden. + // Both halves matter — hiding without a replacement breaks authentication, + // and replacing without hiding leaves the refresh token reachable. + t.Run("an acquired login hides the host credential it replaces", func(t *testing.T) { + acquired := &sandboxTokens{ + credDir: "/tmp/captain-creds-fixture", + providers: map[string]bool{"claude": true}, + } + got, err := srtConfigFor("claude", cwd, acquired) + if err != nil { + t.Fatal(err) + } + if !slices.Contains(got.Filesystem.AllowWrite, acquired.credDir) { + t.Errorf("credential directory is not writable: %v", got.Filesystem.AllowWrite) + } + hostCredential := filepath.Join(home, ".claude", ".credentials.json") + if !slices.Contains(got.Filesystem.DenyRead, hostCredential) { + t.Errorf("host credential %s is still readable: %v", hostCredential, got.Filesystem.DenyRead) + } + // Only the credential file, not the whole state directory: the CLI still + // needs its settings, history and project state. + if slices.Contains(got.Filesystem.DenyRead, filepath.Join(home, ".claude")) { + t.Error("the whole ~/.claude directory must not be denied") + } + }) + + t.Run("another CLI's login does not hide this one's credential", func(t *testing.T) { + codexOnly := &sandboxTokens{ + credDir: "/tmp/captain-creds-fixture", + providers: map[string]bool{"codex": true}, + } + got, err := srtConfigFor("claude", cwd, codexOnly) + if err != nil { + t.Fatal(err) + } + if slices.Contains(got.Filesystem.DenyRead, filepath.Join(home, ".claude", ".credentials.json")) { + t.Error("claude's host credential was hidden with no claude replacement acquired") + } + }) } func specWithCwd(cwd string) *api.Spec { diff --git a/pkg/sandbox/adapter/tokens.go b/pkg/sandbox/adapter/tokens.go new file mode 100644 index 00000000..c0dd0dd4 --- /dev/null +++ b/pkg/sandbox/adapter/tokens.go @@ -0,0 +1,137 @@ +package adapter + +import ( + "context" + "fmt" + "os" + "sort" + + "github.com/flanksource/captain/pkg/sandbox" + "gopkg.in/yaml.v3" +) + +// Token acquisition for the local adapters. +// +// sandbox.TokenManager has existed — with per-provider acquirers, expiry and a +// refresh loop — but had no callers: a backend could declare `tokens:` in +// ~/.captain.yaml and nothing would ever acquire them. This is the seam that +// runs it, so the declaration finally means something for every provider, not +// just the two added for agent logins. + +// sandboxTokens is one sandbox's acquired credentials: a private directory of +// credential files plus the environment that points tools at them. +type sandboxTokens struct { + manager *sandbox.TokenManager + credDir string + env map[string]string + // providers is which providers were acquired, so a policy can tell whether + // a redacted replacement exists before hiding the host's original. + providers map[string]bool +} + +// decodeTokensOption reads the backend's `tokens:` block. The options map comes +// from yaml.v3, so a round-trip through yaml decodes it into the same struct +// the configuration file declares. +func decodeTokensOption(options map[string]any) (*sandbox.TokensConfig, error) { + raw, ok := options["tokens"] + if !ok || raw == nil { + return nil, nil + } + encoded, err := yaml.Marshal(raw) + if err != nil { + return nil, fmt.Errorf("re-encode sandbox tokens option: %w", err) + } + var config sandbox.TokensConfig + if err := yaml.Unmarshal(encoded, &config); err != nil { + return nil, fmt.Errorf("parse sandbox tokens option: %w", err) + } + return &config, nil +} + +// acquireSandboxTokens resolves the backend's declared credentials into a +// private directory. It returns nil when no tokens are declared. +// +// Acquisition failure is returned, never swallowed: a sandbox that starts +// without the credential it was configured to carry fails later, inside the +// agent, as an unexplained authentication error. +func acquireSandboxTokens(ctx context.Context, options map[string]any) (*sandboxTokens, error) { + config, err := decodeTokensOption(options) + if err != nil { + return nil, err + } + if config == nil || len(sandbox.SelectedTokenProviders(config)) == 0 { + return nil, nil + } + + credDir, err := os.MkdirTemp("", "captain-sandbox-creds-") + if err != nil { + return nil, fmt.Errorf("create sandbox credential directory: %w", err) + } + if err := os.Chmod(credDir, 0o700); err != nil { + return nil, fmt.Errorf("secure sandbox credential directory: %w", err) + } + + manager := sandbox.NewTokenManager(credDir) + results, err := manager.Acquire(ctx, config) + if err != nil { + manager.Cleanup() + return nil, err + } + + tokens := &sandboxTokens{ + manager: manager, + credDir: credDir, + env: map[string]string{}, + providers: map[string]bool{}, + } + for _, result := range results { + tokens.providers[result.Provider] = true + for key, value := range result.EnvVars { + tokens.env[key] = value + } + } + return tokens, nil +} + +// Env renders the acquired environment as KEY=VALUE, sorted so a wrapped +// command's environment is reproducible. +func (t *sandboxTokens) Env() []string { + if t == nil { + return nil + } + keys := make([]string, 0, len(t.env)) + for key := range t.env { + keys = append(keys, key) + } + sort.Strings(keys) + out := make([]string, 0, len(keys)) + for _, key := range keys { + out = append(out, key+"="+t.env[key]) + } + return out +} + +// Has reports whether a provider was acquired. +func (t *sandboxTokens) Has(provider string) bool { + if t == nil { + return false + } + return t.providers[provider] +} + +// Dir is the private credential directory, or "" when nothing was acquired. +func (t *sandboxTokens) Dir() string { + if t == nil { + return "" + } + return t.credDir +} + +// Cleanup removes the credential directory. Safe on a nil receiver so callers +// can defer it unconditionally. +func (t *sandboxTokens) Cleanup() { + if t == nil || t.manager == nil { + return + } + t.manager.Cleanup() +} diff --git a/pkg/sandbox/token_providers.go b/pkg/sandbox/token_providers.go new file mode 100644 index 00000000..c8668732 --- /dev/null +++ b/pkg/sandbox/token_providers.go @@ -0,0 +1,141 @@ +package sandbox + +// The provider table. Every surface that lets a user choose token providers — +// the container wizard, the interactive run config, and Acquire itself — ranges +// over this one slice. +// +// It replaces four parallel hardcoded lists (a huh.NewOption slice and a +// set/clear switch in each of pkg/container/tui.go and +// pkg/cli/container_interactive.go), where adding a provider meant editing all +// four and a miss showed up as a provider that could be selected but never +// acquired. + +// TokenProvider describes one acquirable credential source. +type TokenProvider struct { + // Name is the configuration token (`tokens: {github: {}}`) and the value + // carried by selection widgets. + Name string + // Label is how the provider is shown to a human. + Label string + // Enabled reports whether a config already selects this provider. + Enabled func(*TokensConfig) bool + // Set selects the provider, leaving any existing settings alone. + Set func(*TokensConfig) + // Clear deselects the provider. + Clear func(*TokensConfig) +} + +// TokenProviders returns every selectable provider in display order. +func TokenProviders() []TokenProvider { + return []TokenProvider{ + { + Name: "aws", Label: "AWS", + Enabled: func(c *TokensConfig) bool { return c.AWS != nil }, + Set: func(c *TokensConfig) { + if c.AWS == nil { + c.AWS = &AWSTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.AWS = nil }, + }, + { + Name: "gcp", Label: "GCP", + Enabled: func(c *TokensConfig) bool { return c.GCP != nil }, + Set: func(c *TokensConfig) { + if c.GCP == nil { + c.GCP = &GCPTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.GCP = nil }, + }, + { + Name: "azure", Label: "Azure", + Enabled: func(c *TokensConfig) bool { return c.Azure != nil }, + Set: func(c *TokensConfig) { + if c.Azure == nil { + c.Azure = &AzureTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.Azure = nil }, + }, + { + Name: "github", Label: "GitHub", + Enabled: func(c *TokensConfig) bool { return c.GitHub != nil }, + Set: func(c *TokensConfig) { + if c.GitHub == nil { + c.GitHub = &GitHubTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.GitHub = nil }, + }, + { + Name: "kubernetes", Label: "Kubernetes", + Enabled: func(c *TokensConfig) bool { return c.Kubernetes != nil }, + Set: func(c *TokensConfig) { + if c.Kubernetes == nil { + c.Kubernetes = &K8sTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.Kubernetes = nil }, + }, + { + Name: "claude", Label: "Claude (subscription login)", + Enabled: func(c *TokensConfig) bool { return c.Claude != nil }, + Set: func(c *TokensConfig) { + if c.Claude == nil { + c.Claude = &ClaudeTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.Claude = nil }, + }, + { + Name: "codex", Label: "Codex (subscription login)", + Enabled: func(c *TokensConfig) bool { return c.Codex != nil }, + Set: func(c *TokensConfig) { + if c.Codex == nil { + c.Codex = &CodexTokenConfig{} + } + }, + Clear: func(c *TokensConfig) { c.Codex = nil }, + }, + } +} + +// SelectedTokenProviders lists the provider names a config enables. +func SelectedTokenProviders(config *TokensConfig) []string { + if config == nil { + return nil + } + var selected []string + for _, provider := range TokenProviders() { + if provider.Enabled(config) { + selected = append(selected, provider.Name) + } + } + return selected +} + +// ApplyTokenSelection converges config onto exactly the named providers, +// preserving the settings of providers that stay selected. A nil config with an +// empty selection stays nil, so an untouched wizard does not write an empty +// tokens block into a user's file. +func ApplyTokenSelection(config *TokensConfig, selected []string) *TokensConfig { + if len(selected) == 0 { + return nil + } + if config == nil { + config = &TokensConfig{} + } + chosen := make(map[string]bool, len(selected)) + for _, name := range selected { + chosen[name] = true + } + for _, provider := range TokenProviders() { + if chosen[provider.Name] { + provider.Set(config) + continue + } + provider.Clear(config) + } + return config +} diff --git a/pkg/sandbox/token_providers_test.go b/pkg/sandbox/token_providers_test.go new file mode 100644 index 00000000..0febaabc --- /dev/null +++ b/pkg/sandbox/token_providers_test.go @@ -0,0 +1,99 @@ +package sandbox_test + +import ( + "testing" + + "github.com/flanksource/captain/pkg/sandbox" +) + +// The provider table replaced two hand-written switches that built a +// TokensConfig from selected names. These assert the table reproduces what +// those switches did, and that the two new providers are reachable through it. + +func TestApplyTokenSelectionMatchesTheReplacedSwitches(t *testing.T) { + config := sandbox.ApplyTokenSelection(nil, []string{"aws", "gcp", "azure", "github", "kubernetes"}) + if config == nil { + t.Fatal("selecting five providers produced no config") + } + for name, present := range map[string]bool{ + "aws": config.AWS != nil, + "gcp": config.GCP != nil, + "azure": config.Azure != nil, + "github": config.GitHub != nil, + "kubernetes": config.Kubernetes != nil, + } { + if !present { + t.Errorf("provider %q was selected but not set", name) + } + } + if config.Claude != nil || config.Codex != nil { + t.Error("unselected agent-login providers must stay nil") + } +} + +func TestApplyTokenSelectionReachesTheAgentLoginProviders(t *testing.T) { + config := sandbox.ApplyTokenSelection(nil, []string{"claude", "codex"}) + if config.Claude == nil || config.Codex == nil { + t.Fatalf("claude/codex not selectable through the table: %+v", config) + } + if config.AWS != nil { + t.Error("aws must not be set when it was not selected") + } +} + +func TestApplyTokenSelectionDeselectsWithoutDiscardingTheRest(t *testing.T) { + config := &sandbox.TokensConfig{ + AWS: &sandbox.AWSTokenConfig{Profile: "prod"}, + Claude: &sandbox.ClaudeTokenConfig{}, + } + updated := sandbox.ApplyTokenSelection(config, []string{"aws"}) + + if updated.Claude != nil { + t.Error("deselected provider must be cleared") + } + if updated.AWS == nil || updated.AWS.Profile != "prod" { + t.Errorf("a provider that stays selected must keep its settings, got %+v", updated.AWS) + } +} + +func TestApplyTokenSelectionOfNothingProducesNoBlock(t *testing.T) { + // An untouched wizard must not write `tokens: {}` into a user's config. + if config := sandbox.ApplyTokenSelection(&sandbox.TokensConfig{}, nil); config != nil { + t.Errorf("empty selection produced %+v, want nil", config) + } +} + +func TestSelectedTokenProvidersRoundTrips(t *testing.T) { + want := []string{"gcp", "claude"} + selected := sandbox.SelectedTokenProviders(sandbox.ApplyTokenSelection(nil, want)) + + // SelectedTokenProviders reports in table order, which puts gcp before claude. + if len(selected) != 2 || selected[0] != "gcp" || selected[1] != "claude" { + t.Errorf("round trip produced %v, want [gcp claude]", selected) + } +} + +func TestSelectedTokenProvidersOfNilIsEmpty(t *testing.T) { + if selected := sandbox.SelectedTokenProviders(nil); len(selected) != 0 { + t.Errorf("nil config reported providers: %v", selected) + } +} + +func TestEveryTokenProviderIsWiredIntoTheTable(t *testing.T) { + // A provider whose Set does not actually set anything would be selectable in + // the wizard and then never acquired — the failure the table exists to stop. + for _, provider := range sandbox.TokenProviders() { + config := &sandbox.TokensConfig{} + provider.Set(config) + if !provider.Enabled(config) { + t.Errorf("provider %q: Set did not make Enabled true", provider.Name) + } + provider.Clear(config) + if provider.Enabled(config) { + t.Errorf("provider %q: Clear did not make Enabled false", provider.Name) + } + if provider.Label == "" { + t.Errorf("provider %q has no label for the selection widget", provider.Name) + } + } +} diff --git a/pkg/sandbox/tokens.go b/pkg/sandbox/tokens.go index b63e989d..3dbc9efa 100644 --- a/pkg/sandbox/tokens.go +++ b/pkg/sandbox/tokens.go @@ -38,12 +38,22 @@ type K8sTokenConfig struct { Context string `yaml:"context,omitempty" json:"context,omitempty"` } +// ClaudeTokenConfig and CodexTokenConfig select the host's subscription login +// for the matching agent CLI. They carry no fields: the credential's location +// is fixed by the CLI, and what captain does to it — strip the refresh token — +// is not negotiable per-sandbox. +type ClaudeTokenConfig struct{} + +type CodexTokenConfig struct{} + type TokensConfig struct { AWS *AWSTokenConfig `yaml:"aws,omitempty" json:"aws,omitempty"` GCP *GCPTokenConfig `yaml:"gcp,omitempty" json:"gcp,omitempty"` Azure *AzureTokenConfig `yaml:"azure,omitempty" json:"azure,omitempty"` GitHub *GitHubTokenConfig `yaml:"github,omitempty" json:"github,omitempty"` Kubernetes *K8sTokenConfig `yaml:"kubernetes,omitempty" json:"kubernetes,omitempty"` + Claude *ClaudeTokenConfig `yaml:"claude,omitempty" json:"claude,omitempty"` + Codex *CodexTokenConfig `yaml:"codex,omitempty" json:"codex,omitempty"` } type TokenResult struct { @@ -120,6 +130,14 @@ func (tm *TokenManager) Acquire(ctx context.Context, config *TokensConfig) ([]To cfg := *config.Kubernetes providers = append(providers, providerEntry{"kubernetes", func() (*TokenResult, error) { return acquireK8sToken(ctx, cfg, tm.credDir) }}) } + if config.Claude != nil { + cfg := *config.Claude + providers = append(providers, providerEntry{"claude", func() (*TokenResult, error) { return acquireClaudeToken(ctx, cfg, tm.credDir) }}) + } + if config.Codex != nil { + cfg := *config.Codex + providers = append(providers, providerEntry{"codex", func() (*TokenResult, error) { return acquireCodexToken(ctx, cfg, tm.credDir) }}) + } for _, p := range providers { log.Infof("Acquiring %s token", p.name) @@ -218,5 +236,11 @@ func MergeTokensConfig(base, other *TokensConfig) *TokensConfig { if other.Kubernetes != nil { merged.Kubernetes = other.Kubernetes } + if other.Claude != nil { + merged.Claude = other.Claude + } + if other.Codex != nil { + merged.Codex = other.Codex + } return &merged } diff --git a/pkg/sandbox/tokens_agentcli.go b/pkg/sandbox/tokens_agentcli.go new file mode 100644 index 00000000..746d4fa1 --- /dev/null +++ b/pkg/sandbox/tokens_agentcli.go @@ -0,0 +1,115 @@ +package sandbox + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/flanksource/captain/pkg/agentcreds" +) + +// Claude and Codex are acquired through the same three steps — read the host +// login, strip the refresh token, drop the result into a private config +// directory — so they share one implementation and differ only by the table +// below. +// +// Unlike the cloud providers, these do not export a credential *value*; they +// redirect the CLI's whole config directory (CLAUDE_CONFIG_DIR / CODEX_HOME) at +// a redacted copy. That is what lets srt.go add the host's real credential +// files to DenyRead: the sandboxed CLI authenticates from the copy and can no +// longer reach the original, which still holds the refresh token. + +// NewAgentCredentialReader resolves the host reader. A variable for the same +// reason adapter.NewSRTRuntime is one: it lets the acquisition path be tested +// against fixture documents rather than requiring a real Keychain login on +// whatever machine runs the suite. +var NewAgentCredentialReader = agentcreds.OSReader + +type agentCLIProvider struct { + provider agentcreds.Provider + // dirName is the subdirectory of credDir that becomes the CLI's config home. + dirName string + // homeEnv is the variable pointing the CLI at that directory. + homeEnv string + // seedFiles are host config files copied in alongside the credential. + // Redirecting CODEX_HOME moves the entire codex home, so without + // config.toml the sandboxed CLI silently loses the user's model and + // provider configuration. + seedFiles []string +} + +func agentCLIProviders() map[agentcreds.Provider]agentCLIProvider { + return map[agentcreds.Provider]agentCLIProvider{ + agentcreds.ProviderClaude: { + provider: agentcreds.ProviderClaude, + dirName: "claude", + homeEnv: "CLAUDE_CONFIG_DIR", + }, + agentcreds.ProviderCodex: { + provider: agentcreds.ProviderCodex, + dirName: "codex", + homeEnv: "CODEX_HOME", + seedFiles: []string{"config.toml"}, + }, + } +} + +func acquireClaudeToken(ctx context.Context, _ ClaudeTokenConfig, credDir string) (*TokenResult, error) { + return acquireAgentCLIToken(ctx, agentcreds.ProviderClaude, credDir) +} + +func acquireCodexToken(ctx context.Context, _ CodexTokenConfig, credDir string) (*TokenResult, error) { + return acquireAgentCLIToken(ctx, agentcreds.ProviderCodex, credDir) +} + +func acquireAgentCLIToken(ctx context.Context, provider agentcreds.Provider, credDir string) (*TokenResult, error) { + spec, ok := agentCLIProviders()[provider] + if !ok { + return nil, fmt.Errorf("no agent CLI provider named %q", provider) + } + reader, err := NewAgentCredentialReader() + if err != nil { + return nil, err + } + credential, err := reader.Read(ctx, provider) + if err != nil { + return nil, err + } + + configDir := filepath.Join(credDir, spec.dirName) + if err := atomicWriteFile(filepath.Join(configDir, credential.RelPath()), credential.Payload, 0o600); err != nil { + return nil, fmt.Errorf("write %s credentials: %w", provider, err) + } + if err := seedAgentCLIConfig(reader, spec, configDir); err != nil { + return nil, err + } + + return &TokenResult{ + Provider: string(provider), + EnvVars: map[string]string{spec.homeEnv: configDir}, + WritePaths: []string{configDir}, + Expiry: credential.ExpiresAt, + }, nil +} + +// seedAgentCLIConfig copies the host's non-credential configuration into the +// redirected config directory. A missing source file is fine — the user simply +// has no such config — but an unreadable one is an error rather than a silently +// differently-configured CLI. +func seedAgentCLIConfig(reader agentcreds.Reader, spec agentCLIProvider, configDir string) error { + for _, name := range spec.seedFiles { + source := filepath.Join(reader.Home, "."+spec.dirName, name) + data, err := os.ReadFile(source) + if os.IsNotExist(err) { + continue + } + if err != nil { + return fmt.Errorf("read %s config %s: %w", spec.provider, source, err) + } + if err := atomicWriteFile(filepath.Join(configDir, name), data, 0o600); err != nil { + return fmt.Errorf("seed %s config %s: %w", spec.provider, name, err) + } + } + return nil +} diff --git a/pkg/sandbox/tokens_agentcli_test.go b/pkg/sandbox/tokens_agentcli_test.go new file mode 100644 index 00000000..87c04c14 --- /dev/null +++ b/pkg/sandbox/tokens_agentcli_test.go @@ -0,0 +1,199 @@ +package sandbox + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/agentcreds" +) + +// The acquirers redirect each CLI's whole config directory at a redacted copy, +// so what these assert is the contract the CLI depends on: the credential is at +// the path it reads, the env var points there, and the refresh token is gone. + +func jwtWithExp(instant time.Time) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + claims := base64.RawURLEncoding.EncodeToString([]byte(fmt.Sprintf(`{"exp":%d}`, instant.Unix()))) + return header + "." + claims + ".sig" +} + +// stubHostLogins points the acquirers at fixture documents in a fake home, +// restoring the real reader when the test ends. +func stubHostLogins(t *testing.T, claudeExpiry, codexExpiry time.Time) string { + t.Helper() + home := t.TempDir() + for path, content := range map[string]string{ + filepath.Join(home, ".claude", ".credentials.json"): fmt.Sprintf( + `{"claudeAiOauth":{"accessToken":"claude-access","refreshToken":"claude-refresh","expiresAt":%d},"mcpOAuth":{"srv|abc":{"clientSecret":"mcp-secret"}}}`, + claudeExpiry.UnixMilli()), + filepath.Join(home, ".codex", "auth.json"): fmt.Sprintf( + `{"auth_mode":"chatgpt","OPENAI_API_KEY":null,"tokens":{"id_token":%q,"access_token":%q,"refresh_token":"codex-refresh","account_id":"acct"}}`, + jwtWithExp(codexExpiry), jwtWithExp(codexExpiry)), + // Seeded alongside the credential: redirecting CODEX_HOME moves the whole + // codex home, so losing this would silently reconfigure the CLI. + filepath.Join(home, ".codex", "config.toml"): "model = \"gpt-5.6-sol\"\n", + } { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + } + + original := NewAgentCredentialReader + NewAgentCredentialReader = func() (agentcreds.Reader, error) { + // No ReadKeychain, so the file path is used — the Linux/container shape. + return agentcreds.Reader{Home: home, ReadFile: os.ReadFile, Now: time.Now}, nil + } + t.Cleanup(func() { NewAgentCredentialReader = original }) + return home +} + +func TestAcquireClaudeTokenRedirectsTheConfigDirectory(t *testing.T) { + expiry := time.Now().Add(2 * time.Hour).Truncate(time.Millisecond) + stubHostLogins(t, expiry, time.Now().Add(48*time.Hour)) + credDir := t.TempDir() + + result, err := acquireClaudeToken(context.Background(), ClaudeTokenConfig{}, credDir) + if err != nil { + t.Fatal(err) + } + + configDir := filepath.Join(credDir, "claude") + if got := result.EnvVars["CLAUDE_CONFIG_DIR"]; got != configDir { + t.Errorf("CLAUDE_CONFIG_DIR = %q, want %q", got, configDir) + } + if !result.Expiry.Equal(expiry.UTC()) { + t.Errorf("Expiry = %v, want %v", result.Expiry, expiry.UTC()) + } + + // The path Claude Code actually reads inside CLAUDE_CONFIG_DIR. + written, err := os.ReadFile(filepath.Join(configDir, ".credentials.json")) + if err != nil { + t.Fatal(err) + } + for _, secret := range []string{"claude-refresh", "mcp-secret"} { + if strings.Contains(string(written), secret) { + t.Errorf("%q survived redaction into the sandbox credential", secret) + } + } + var document map[string]any + if err := json.Unmarshal(written, &document); err != nil { + t.Fatal(err) + } + if _, present := document["mcpOAuth"]; present { + t.Error("mcpOAuth reached the sandbox") + } +} + +func TestAcquireCodexTokenSeedsConfigAlongsideTheCredential(t *testing.T) { + stubHostLogins(t, time.Now().Add(2*time.Hour), time.Now().Add(48*time.Hour)) + credDir := t.TempDir() + + result, err := acquireCodexToken(context.Background(), CodexTokenConfig{}, credDir) + if err != nil { + t.Fatal(err) + } + + configDir := filepath.Join(credDir, "codex") + if got := result.EnvVars["CODEX_HOME"]; got != configDir { + t.Errorf("CODEX_HOME = %q, want %q", got, configDir) + } + config, err := os.ReadFile(filepath.Join(configDir, "config.toml")) + if err != nil { + t.Fatalf("host config.toml was not seeded: %v", err) + } + if !strings.Contains(string(config), "gpt-5.6-sol") { + t.Errorf("seeded config.toml = %q", config) + } + + written, err := os.ReadFile(filepath.Join(configDir, "auth.json")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(written), "codex-refresh") { + t.Error("the refresh token reached the sandbox") + } + // Present but empty, not absent: codex-rs models refresh_token as a + // non-optional String. + var document struct { + Tokens struct { + RefreshToken *string `json:"refresh_token"` + } `json:"tokens"` + } + if err := json.Unmarshal(written, &document); err != nil { + t.Fatal(err) + } + if document.Tokens.RefreshToken == nil || *document.Tokens.RefreshToken != "" { + t.Errorf("refresh_token = %v, want a present empty string", document.Tokens.RefreshToken) + } +} + +func TestAcquireWritesPrivateCredentialFiles(t *testing.T) { + stubHostLogins(t, time.Now().Add(2*time.Hour), time.Now().Add(48*time.Hour)) + credDir := t.TempDir() + + if _, err := acquireClaudeToken(context.Background(), ClaudeTokenConfig{}, credDir); err != nil { + t.Fatal(err) + } + info, err := os.Stat(filepath.Join(credDir, "claude", ".credentials.json")) + if err != nil { + t.Fatal(err) + } + if info.Mode().Perm() != 0o600 { + t.Errorf("credential mode = %v, want 0600", info.Mode().Perm()) + } +} + +func TestTokenManagerAcquiresBothAgentLogins(t *testing.T) { + // The manager is the seam the sandbox adapters call, and until this change it + // had no callers at all — so this asserts the wiring, not just the acquirers. + stubHostLogins(t, time.Now().Add(2*time.Hour), time.Now().Add(48*time.Hour)) + credDir := t.TempDir() + + manager := NewTokenManager(credDir) + results, err := manager.Acquire(context.Background(), &TokensConfig{ + Claude: &ClaudeTokenConfig{}, + Codex: &CodexTokenConfig{}, + }) + if err != nil { + t.Fatal(err) + } + if len(results) != 2 { + t.Fatalf("acquired %d results, want 2", len(results)) + } + providers := map[string]bool{} + for _, result := range results { + providers[result.Provider] = true + if result.Expiry.IsZero() { + t.Errorf("%s reported no expiry, so a refresh cannot be scheduled", result.Provider) + } + } + if !providers["claude"] || !providers["codex"] { + t.Errorf("acquired providers = %v", providers) + } +} + +func TestAcquireFailsLoudlyWhenTheHostIsNotLoggedIn(t *testing.T) { + original := NewAgentCredentialReader + NewAgentCredentialReader = func() (agentcreds.Reader, error) { + return agentcreds.Reader{Home: t.TempDir(), ReadFile: os.ReadFile, Now: time.Now}, nil + } + t.Cleanup(func() { NewAgentCredentialReader = original }) + + _, err := acquireClaudeToken(context.Background(), ClaudeTokenConfig{}, t.TempDir()) + if err == nil { + t.Fatal("a missing host login was accepted; the sandbox would start unauthenticated") + } + if !strings.Contains(err.Error(), "run `claude`") { + t.Errorf("error does not name the remedy: %v", err) + } +} From cbbb8350618e2c14e498aa4d0e78b809f2b8cc6a Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 18 Aug 2026 19:25:34 +0300 Subject: [PATCH 03/22] feat(api): Add durable token authentication and TLS for remote Captain access Protect off-host API and git-agent requests with durable, revocable bearer tokens while preserving loopback access for the local UI and CLI. Add TLS certificate management and local-only command registration to prevent host-administering commands from being exposed through the REST executor. Persist hashed credentials with scoped, bound or pooled identities, expiry, revocation, and usage tracking. BREAKING CHANGE: Non-loopback requests to /api/v1 and /git now require a valid scoped Captain bearer token provisioned with `captain token create`. --- cmd/captain/help.go | 33 -- cmd/captain/help_test.go | 3 +- cmd/captain/internal/rootcmd/help.go | 27 ++ cmd/captain/{ => internal/rootcmd}/version.go | 26 +- cmd/captain/local_only_test.go | 91 ++++ cmd/captain/main.go | 94 +++- cmd/captain/standalone_test.go | 25 ++ cmd/captain/version_test.go | 13 +- migrations/00_types.pg.hcl | 24 + migrations/36_api_tokens.pg.hcl | 116 +++++ migrations/migrations_test.go | 16 + pkg/captaintoken/hash.go | 97 +++++ pkg/captaintoken/token.go | 173 ++++++++ pkg/captaintoken/token_test.go | 215 +++++++++ pkg/captaintoken/verify.go | 174 ++++++++ pkg/captaintoken/verify_test.go | 244 +++++++++++ pkg/cli/serve.go | 77 +++- pkg/cli/serve_auth.go | 160 +++++++ pkg/cli/serve_auth_test.go | 290 ++++++++++++ pkg/cli/serve_provider_tokens.go | 10 +- pkg/cli/token.go | 242 ++++++++++ pkg/cli/token_test.go | 100 +++++ pkg/database/api_token_store.go | 412 ++++++++++++++++++ .../api_token_store_integration_test.go | 365 ++++++++++++++++ 24 files changed, 2944 insertions(+), 83 deletions(-) delete mode 100644 cmd/captain/help.go create mode 100644 cmd/captain/internal/rootcmd/help.go rename cmd/captain/{ => internal/rootcmd}/version.go (66%) create mode 100644 cmd/captain/local_only_test.go create mode 100644 cmd/captain/standalone_test.go create mode 100644 migrations/36_api_tokens.pg.hcl create mode 100644 pkg/captaintoken/hash.go create mode 100644 pkg/captaintoken/token.go create mode 100644 pkg/captaintoken/token_test.go create mode 100644 pkg/captaintoken/verify.go create mode 100644 pkg/captaintoken/verify_test.go create mode 100644 pkg/cli/serve_auth.go create mode 100644 pkg/cli/serve_auth_test.go create mode 100644 pkg/cli/token.go create mode 100644 pkg/cli/token_test.go create mode 100644 pkg/database/api_token_store.go create mode 100644 pkg/database/api_token_store_integration_test.go diff --git a/cmd/captain/help.go b/cmd/captain/help.go deleted file mode 100644 index 7f4d4fb5..00000000 --- a/cmd/captain/help.go +++ /dev/null @@ -1,33 +0,0 @@ -package main - -import ( - "fmt" - - "github.com/flanksource/clicky" - "github.com/flanksource/commons/help" - "github.com/spf13/cobra" -) - -// installRootHelp appends commons' documentation of the runtime knobs captain -// inherits — log verbosity, HTTP wire logging, HAR capture and output -// formatting — to `captain --help`. -// -// Cobra resolves a command's help function by walking up to the root, so this -// one runs for every command; the block is emitted only when the target is the -// root itself, leaving subcommand help as cobra renders it and leaving the -// sandbox/container SetHelpFunc overrides untouched. -func installRootHelp(root *cobra.Command) { - cobraHelp := root.HelpFunc() - root.SetHelpFunc(func(cmd *cobra.Command, args []string) { - cobraHelp(cmd, args) - if cmd != root { - return - } - text := help.Help() - out := text.ANSI() - if clicky.Flags.NoColor { - out = text.String() - } - fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", out) - }) -} diff --git a/cmd/captain/help_test.go b/cmd/captain/help_test.go index ec21d08b..8f86eb20 100644 --- a/cmd/captain/help_test.go +++ b/cmd/captain/help_test.go @@ -3,6 +3,7 @@ package main import ( "bytes" + "github.com/flanksource/captain/cmd/captain/internal/rootcmd" "github.com/flanksource/clicky" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" @@ -21,7 +22,7 @@ var _ = Describe("root help", func() { root.AddCommand(child) root.SetOut(out) root.SetErr(out) - installRootHelp(root) + rootcmd.InstallRootHelp(root) return root, child, out } diff --git a/cmd/captain/internal/rootcmd/help.go b/cmd/captain/internal/rootcmd/help.go new file mode 100644 index 00000000..6f30050d --- /dev/null +++ b/cmd/captain/internal/rootcmd/help.go @@ -0,0 +1,27 @@ +package rootcmd + +import ( + "fmt" + + "github.com/flanksource/clicky" + "github.com/flanksource/commons/help" + "github.com/spf13/cobra" +) + +// InstallRootHelp appends commons' documentation of the runtime knobs captain +// inherits to the root command without changing subcommand help. +func InstallRootHelp(root *cobra.Command) { + cobraHelp := root.HelpFunc() + root.SetHelpFunc(func(cmd *cobra.Command, args []string) { + cobraHelp(cmd, args) + if cmd != root { + return + } + text := help.Help() + out := text.ANSI() + if clicky.Flags.NoColor { + out = text.String() + } + fmt.Fprintf(cmd.OutOrStdout(), "\n%s\n", out) + }) +} diff --git a/cmd/captain/version.go b/cmd/captain/internal/rootcmd/version.go similarity index 66% rename from cmd/captain/version.go rename to cmd/captain/internal/rootcmd/version.go index d197a911..ce110ce1 100644 --- a/cmd/captain/version.go +++ b/cmd/captain/internal/rootcmd/version.go @@ -1,4 +1,4 @@ -package main +package rootcmd import ( "fmt" @@ -6,36 +6,20 @@ import ( "github.com/spf13/cobra" ) -var ( - version = "dev" - commit = "unknown" - date = "unknown" - dirty = "unknown" -) - -type buildInfo struct { +type BuildInfo struct { Version string Commit string Date string Dirty string } -func currentBuildInfo() buildInfo { - return buildInfo{ - Version: version, - Commit: commit, - Date: date, - Dirty: dirty, - } -} - -func configureVersion(root *cobra.Command, info buildInfo) { +func ConfigureVersion(root *cobra.Command, info BuildInfo) { root.Version = info.String() root.SetVersionTemplate("{{.Version}}\n") root.AddCommand(newVersionCommand(info)) } -func newVersionCommand(info buildInfo) *cobra.Command { +func newVersionCommand(info BuildInfo) *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print version information", @@ -46,7 +30,7 @@ func newVersionCommand(info buildInfo) *cobra.Command { } } -func (info buildInfo) String() string { +func (info BuildInfo) String() string { version := info.Version status := info.Dirty switch info.Dirty { diff --git a/cmd/captain/local_only_test.go b/cmd/captain/local_only_test.go new file mode 100644 index 00000000..c828fcf3 --- /dev/null +++ b/cmd/captain/local_only_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "net/http" + "net/http/httptest" + + "github.com/flanksource/clicky/rpc" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The REST executor publishes every cobra command as an unauthenticated route +// under /api/v1. Commands that administer the host rather than serve a resource +// must stay off it: `git-agent serve` would block the server on an SSH listener, +// `hook` runs hook sets against a caller-chosen repo, `run-task` launches an +// agent in an arbitrary worktree, `ssh` exits the process, `add` mints a join +// token, and `serve` starts a nested server. +// +// Cobra's Hidden flag does NOT exclude a command from the executor — only the +// clicky local-only annotation does — so `hook`, `run-task` and `ssh` were all +// reachable despite being hidden. This pins the exclusion at the route level +// rather than trusting the annotation, so it still fails if clicky changes how +// the executor filters commands. +var _ = Describe("REST executor exposure", func() { + newExecutorMux := func() *http.ServeMux { + root := newRootCommand() + openAPIConfig := &rpc.OpenAPIConfig{Title: "Captain", Description: "test", Version: "test"} + server := rpc.NewSwaggerServer(&rpc.ServeConfig{ + Title: openAPIConfig.Title, + Description: openAPIConfig.Description, + Version: openAPIConfig.Version, + Executor: &rpc.ExecutorConfig{ + Enabled: true, + SkipPreRun: true, + PathPrefix: "/api/v1", + }, + }, root, openAPIConfig) + mux := http.NewServeMux() + server.RegisterExecutionRoutes(mux) + return mux + } + + // routed reports whether the mux has a real handler for method+path, as + // opposed to falling through to net/http's NotFoundHandler. + routed := func(mux *http.ServeMux, method, path string) bool { + _, pattern := mux.Handler(httptest.NewRequest(method, path, nil)) + return pattern != "" + } + + DescribeTable("host-administering commands are not routable", + func(method, path string) { + Expect(routed(newExecutorMux(), method, path)).To(BeFalse(), + "%s %s is published as an unauthenticated REST route", method, path) + }, + Entry("git-agent serve", http.MethodPost, "/api/v1/sandbox/git-agent/serve"), + Entry("git-agent hook", http.MethodPost, "/api/v1/sandbox/git-agent/hook"), + Entry("git-agent run-task", http.MethodPost, "/api/v1/sandbox/git-agent/run-task"), + Entry("git-agent ssh", http.MethodPost, "/api/v1/sandbox/git-agent/ssh"), + Entry("git-agent add", http.MethodPost, "/api/v1/sandbox/git-agent"), + Entry("git-agent list", http.MethodGet, "/api/v1/sandbox/git-agent"), + Entry("captain serve", http.MethodPost, "/api/v1/serve"), + ) + + // The token group is the load-bearing case: these routes are what stands in + // front of an off-box caller, so publishing `create` unauthenticated would + // let anyone who could already reach the API mint themselves a durable + // credential. Every method is probed rather than the one the command maps + // to, so a change in how clicky derives verbs cannot quietly open a route. + It("publishes no token route under any method", func() { + mux := newExecutorMux() + for _, path := range []string{ + "/api/v1/token", "/api/v1/token/create", "/api/v1/token/list", + "/api/v1/token/revoke", "/api/v1/token/some-token-id", + } { + for _, method := range []string{ + http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, + } { + Expect(routed(mux, method, path)).To(BeFalse(), + "%s %s would let a caller mint or revoke a credential over the API it authenticates", method, path) + } + } + }) + + // A guard on the guard: if the executor stopped registering anything at all + // the table above would pass vacuously. + It("still routes ordinary resource commands", func() { + mux := newExecutorMux() + Expect(routed(mux, http.MethodGet, "/api/v1/sessions")).To(BeTrue(), + "executor registered no routes at all; the exclusion table proves nothing") + }) +}) diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 892deaf5..66764eaf 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -5,6 +5,7 @@ import ( "os" "reflect" + "github.com/flanksource/captain/cmd/captain/internal/rootcmd" "github.com/flanksource/captain/pkg/cli" "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/clicky" @@ -14,7 +15,27 @@ import ( "github.com/spf13/cobra" ) +var ( + version = "dev" + commit = "unknown" + date = "unknown" + dirty = "unknown" +) + func main() { + err := newRootCommand().Execute() + // Flush before exiting: PersistentPostRun does not run when a command + // fails, and a failed run is the one whose HAR you want. + cli.FlushHAR() + if err != nil { + os.Exit(1) + } +} + +// newRootCommand assembles the whole CLI. It is separate from main so tests can +// inspect the real command tree — notably that host-administering commands stay +// out of the REST executor (see the MarkLocalOnly calls below). +func newRootCommand() *cobra.Command { rootCmd := &cobra.Command{ Use: "captain", Short: "Search and analyze Claude Code tool use history", @@ -50,7 +71,12 @@ func main() { return nil }, } - configureVersion(rootCmd, currentBuildInfo()) + rootcmd.ConfigureVersion(rootCmd, rootcmd.BuildInfo{ + Version: version, + Commit: commit, + Date: date, + Dirty: dirty, + }) clicky.BindAllFlags(rootCmd.PersistentFlags(), "format") cli.BindDatabaseFlags(rootCmd.PersistentFlags()) @@ -61,7 +87,7 @@ func main() { // Document those properties where they are discoverable: appended to the // root --help, after the flags they complement. - installRootHelp(rootCmd) + rootcmd.InstallRootHelp(rootCmd) // Bind HistoryOptions directly on rootCmd so 'captain' IS 'captain history'. // All history flags (--tool, --category, --since, --limit, -f, ...) work @@ -124,6 +150,24 @@ func main() { clicky.AddNamedCommand("generate", sandboxCmd, cli.SRTGenerateOptions{}, cli.RunSRTGenerate).Short = "Generate sandbox-runtime config" clicky.AddNamedCommand("presets", sandboxCmd, cli.SandboxPresetsOptions{}, cli.RunSandboxPresets).Short = "List available sandbox-runtime presets" + credentialsCmd := &cobra.Command{ + Use: "credentials", + Short: "Mirror the host's agent CLI logins to sandbox destinations", + } + // Same reason git-agent sets its own: without this it inherits the parent's + // help func, which prints the sandbox command list and makes this group + // undiscoverable. + credentialsCmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + fmt.Fprint(os.Stderr, c.UsageString()) + }) + sandboxCmd.AddCommand(credentialsCmd) + // Local-only for the same reason as the git-agent subtree: `sync` reads this + // host's keychain and writes a credential to a directory or a cluster, which + // must not be reachable as unauthenticated REST under /api/v1. + clicky.MarkLocalOnly(credentialsCmd) + clicky.AddNamedCommandWithContext("status", credentialsCmd, cli.CredentialsOptions{}, cli.RunCredentialsStatus).Short = "Report each agent login's expiry and where it publishes" + clicky.AddNamedCommandWithContext("sync", credentialsCmd, cli.CredentialsOptions{}, cli.RunCredentialsSync).Short = "Publish the redacted logins to their destinations once" + gitAgentCmd := &cobra.Command{ Use: "git-agent", Short: "Enroll and serve remote git-agent sandboxes (SPEC-git-agent-protocol)", @@ -138,9 +182,19 @@ func main() { fmt.Fprint(os.Stderr, c.UsageString()) }) sandboxCmd.AddCommand(gitAgentCmd) - clicky.AddNamedCommand("add", gitAgentCmd, cli.GitAgentAddOptions{}, cli.RunGitAgentAdd).Short = "Enroll a new agent: mint a join token and print the join command" + // Every leaf here administers the host rather than serving a resource, and + // RegisterExecutionRoutes publishes cobra commands as unauthenticated REST + // under /api/v1. Cobra's Hidden flag does NOT exclude them — shouldConvertCommand + // only honours this annotation — so without it `serve` blocks the server on an + // SSH listener, `hook` runs hook sets against a caller-chosen repo, `run-task` + // launches an agent in an arbitrary worktree, `ssh` exits the process, and `add` + // mints a join token. IsLocalOnly walks parents, so this covers the subtree. + clicky.MarkLocalOnly(gitAgentCmd) + clicky.AddNamedCommandWithContext("add", gitAgentCmd, cli.GitAgentAddOptions{}, cli.RunGitAgentAdd).Short = "Enroll a new agent: mint a captain token and print the join command" clicky.AddNamedCommand("list", gitAgentCmd, cli.GitAgentListOptions{}, cli.RunGitAgentList).Short = "List enrolled agents and pending enrollments" clicky.AddNamedCommand("revoke", gitAgentCmd, cli.GitAgentRevokeOptions{}, cli.RunGitAgentRevoke).Short = "Revoke an enrolled agent's key" + clicky.AddNamedCommandWithContext("deploy", gitAgentCmd, cli.GitAgentDeployOptions{}, cli.RunGitAgentDeploy).Short = "Enroll an agent and run its sidecar on docker or kubernetes" + clicky.AddNamedCommandWithContext("undeploy", gitAgentCmd, cli.GitAgentUndeployOptions{}, cli.RunGitAgentUndeploy).Short = "Tear down a deployed sidecar and revoke its key" clicky.AddNamedCommandWithContext("serve", gitAgentCmd, cli.GitAgentServeOptions{}, cli.RunGitAgentServe).Short = "Run the receive endpoint on this host (agent sidecar or supervisor mailbox)" hookLeaf := clicky.AddNamedCommandWithContext("hook", gitAgentCmd, cli.GitAgentHookOptions{}, cli.RunGitAgentHook) hookLeaf.Short = "Internal: receive-hook entrypoint invoked by the installed shims" @@ -159,6 +213,26 @@ func main() { }, }) + tokenCmd := &cobra.Command{ + Use: "token", + Short: "Mint, list and revoke the bearer tokens that reach this captain over the network", + } + tokenCmd.SetHelpFunc(func(c *cobra.Command, _ []string) { + if c == tokenCmd { + fmt.Fprint(os.Stderr, cli.TokenHelp().ANSI()) + return + } + fmt.Fprint(os.Stderr, c.UsageString()) + }) + rootCmd.AddCommand(tokenCmd) + // Local-only, and this one matters more than most: published as REST under + // /api/v1, `create` would let a caller who can already reach the API mint + // itself a durable credential — the bootstrap hole these tokens close. + clicky.MarkLocalOnly(tokenCmd) + clicky.AddNamedCommandWithContext("create", tokenCmd, cli.TokenCreateOptions{}, cli.RunTokenCreate).Short = "Mint a token and reveal its secret once" + clicky.AddNamedCommandWithContext("list", tokenCmd, cli.TokenListOptions{}, cli.RunTokenList).Short = "List tokens and what each can reach" + clicky.AddNamedCommandWithContext("revoke", tokenCmd, cli.TokenRevokeOptions{}, cli.RunTokenRevoke).Short = "Refuse a token from now on" + aiCmd := &cobra.Command{ Use: "ai", Short: "AI provider commands", @@ -192,7 +266,11 @@ func main() { configureCmd.Short = "Configure provider defaults or validate and save an API token" configureCmd.Long = "Run without a provider for the interactive ~/.captain.yaml wizard. Run `captain configure ` to securely prompt for, validate, and save an API token in ~/.config/captain/vault, or pass --agent, --model, --effort, and --active to save provider-specific runtime defaults. Token flags and runtime-default flags cannot be combined. Automation may pass the hidden --token flag, but command-line arguments can be retained in shell history or process listings. Use --test to validate the effective token without saving." - rootCmd.AddCommand(cli.NewServeCommand(version)) + // Local-only for the same reason as the git-agent group: published as REST it + // lets a request start a nested server inside the running one. + serveCmd := cli.NewServeCommand(version) + clicky.MarkLocalOnly(serveCmd) + rootCmd.AddCommand(serveCmd) attachmentsCmd := &cobra.Command{Use: "attachments", Short: "Manage durable prompt attachments"} rootCmd.AddCommand(attachmentsCmd) @@ -322,13 +400,7 @@ func main() { portKillCmd.Short = "Kill the process listening on a TCP port" portKillCmd.Long = "Find the process bound to the specified TCP port using lsof and kill it with SIGKILL. Reports the process name and PID before killing." - err := rootCmd.Execute() - // Flush before exiting: PersistentPostRun does not run when a command - // fails, and a failed run is the one whose HAR you want. - cli.FlushHAR() - if err != nil { - os.Exit(1) - } + return rootCmd } // bindHistoryAtRoot binds HistoryOptions directly to the root cobra command, diff --git a/cmd/captain/standalone_test.go b/cmd/captain/standalone_test.go new file mode 100644 index 00000000..60a27cdb --- /dev/null +++ b/cmd/captain/standalone_test.go @@ -0,0 +1,25 @@ +package main + +import ( + "context" + "os/exec" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("standalone entrypoint", func() { + It("runs the serve command when only main.go is passed to go run", func() { + const compileTimeout = time.Minute + ctx, cancel := context.WithTimeout(context.Background(), compileTimeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "go", "run", "./main.go", "serve", "--help") + output, err := cmd.CombinedOutput() + + Expect(ctx.Err()).NotTo(Equal(context.DeadlineExceeded), string(output)) + Expect(err).NotTo(HaveOccurred(), string(output)) + Expect(string(output)).To(ContainSubstring("With --dev, Captain also starts the Vite dev server")) + }) +}) diff --git a/cmd/captain/version_test.go b/cmd/captain/version_test.go index 5940a09e..24080e13 100644 --- a/cmd/captain/version_test.go +++ b/cmd/captain/version_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/flanksource/captain/cmd/captain/internal/rootcmd" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/spf13/cobra" @@ -21,9 +22,9 @@ var _ = Describe("version information", func() { ) It("formats clean, dirty, and unstamped builds", func() { - clean := buildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "false"} - dirty := buildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "true"} - unstamped := buildInfo{Version: "dev", Commit: "unknown", Date: "unknown", Dirty: "unknown"} + clean := rootcmd.BuildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "false"} + dirty := rootcmd.BuildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "true"} + unstamped := rootcmd.BuildInfo{Version: "dev", Commit: "unknown", Date: "unknown", Dirty: "unknown"} Expect(clean.String()).To(Equal(cleanVersion)) Expect(dirty.String()).To(Equal(dirtyVersion)) @@ -31,7 +32,7 @@ var _ = Describe("version information", func() { }) It("rejects an invalid Git state", func() { - info := buildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "maybe"} + info := rootcmd.BuildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "maybe"} Expect(func() { info.String() @@ -39,14 +40,14 @@ var _ = Describe("version information", func() { }) It("prints identical details for version and --version", func() { - info := buildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "false"} + info := rootcmd.BuildInfo{Version: "v1.2.3", Commit: "abc1234", Date: "2026-07-27T12:34:56Z", Dirty: "false"} for _, args := range [][]string{{"version"}, {"--version"}} { root := &cobra.Command{Use: "captain"} var stdout bytes.Buffer root.SetOut(&stdout) root.SetArgs(args) - configureVersion(root, info) + rootcmd.ConfigureVersion(root, info) Expect(root.Execute()).To(Succeed()) Expect(stdout.String()).To(Equal(cleanVersion + "\n")) diff --git a/migrations/00_types.pg.hcl b/migrations/00_types.pg.hcl index 6b1b8a9e..ae15d535 100644 --- a/migrations/00_types.pg.hcl +++ b/migrations/00_types.pg.hcl @@ -54,3 +54,27 @@ enum "captain_plan_approval_state" { schema = schema.public values = ["pending", "approved", "rejected", "revision_requested"] } + +# The lifecycle of one task dispatched to a remote git-agent. Only "dispatched" +# and "running" come from the protocol; the terminal states are derived by the +# ingest watcher, because the mailbox never records "this task is over". +enum "captain_git_agent_task_status" { + schema = schema.public + values = ["dispatched", "running", "accepted", "rejected", "errored", "timed_out"] +} + +# Mirrors gitagent.VerdictStatus (pkg/gitagent/verdict.go). "error" means the +# tier could not reach a verdict, which rejects the push. +enum "captain_git_agent_verdict_status" { + schema = schema.public + values = ["accepted", "rejected", "error"] +} + +# What an API token may reach. "git" authorizes pushing to a served repository +# and nothing else, so a token held by a remote coding agent cannot reach the +# /api/v1 executor — which runs arbitrary captain commands. Mirrors +# captaintoken.Scope. +enum "captain_api_token_scope" { + schema = schema.public + values = ["git", "api"] +} diff --git a/migrations/36_api_tokens.pg.hcl b/migrations/36_api_tokens.pg.hcl new file mode 100644 index 00000000..2d3487de --- /dev/null +++ b/migrations/36_api_tokens.pg.hcl @@ -0,0 +1,116 @@ +# Bearer credentials for reaching this captain over the network. +# +# A token is durable, not a bootstrap coupon: it stays valid until it expires or +# is revoked. That is what lets a restarting or rescheduled sidecar re-present +# the same credential instead of crash-looping on a spent one. +# +# Only the hash is stored, and unlike captain_session_mcp_credentials it is an +# argon2id encoded string rather than a raw sha256 digest — hence text, and no +# octet_length check. The presented secret is high-entropy, so the KDF is +# defence in depth against a leaked database rather than against guessing. + +table "captain_api_tokens" { + schema = schema.public + + column "id" { + null = false + type = uuid + default = sql("gen_random_uuid()") + } + # The public half of the token, carried in plaintext by the client and safe to + # log. Verification looks this up on its unique index and only then runs the + # KDF, so a presented token costs one indexed read rather than a table scan. + column "token_id" { + null = false + type = text + } + column "secret_hash" { + null = false + type = text + } + column "name" { + null = false + type = text + } + column "scope" { + null = false + type = enum.captain_api_token_scope + } + # Set when the token is bound to a single agent identity. Null on a pool + # token, whose members are named as they arrive. + column "agent" { + null = true + type = text + } + column "pool" { + null = false + type = boolean + default = false + } + # Members already admitted under a pool token, so max_agents can be enforced + # and a returning member keeps its name across restarts. + column "pool_agents" { + null = false + type = jsonb + default = sql("'[]'::jsonb") + } + column "max_agents" { + null = true + type = int + } + column "expires_at" { + null = true + type = timestamptz + } + column "revoked_at" { + null = true + type = timestamptz + } + column "revocation_reason" { + null = true + type = text + } + column "last_used_at" { + null = true + type = timestamptz + } + column "created_at" { + null = false + type = timestamptz + default = sql("now()") + } + + primary_key { + columns = [column.id] + } + + index "captain_api_tokens_token_id_key" { + unique = true + columns = [column.token_id] + } + index "captain_api_tokens_live_idx" { + columns = [column.created_at] + where = "revoked_at IS NULL" + } + index "captain_api_tokens_agent_idx" { + columns = [column.agent] + where = "agent IS NOT NULL AND revoked_at IS NULL" + } + + check "captain_api_tokens_expiry" { + expr = "expires_at IS NULL OR expires_at > created_at" + } + check "captain_api_tokens_revocation" { + expr = "(revoked_at IS NULL AND revocation_reason IS NULL) OR revoked_at IS NOT NULL" + } + # A token names one identity or serves a pool, never both: the two answer + # "who is this?" differently, and a row claiming both would leave the + # namespace owner ambiguous. Pooling is a git-scope concept — an API caller + # has no member name to derive — so the api scope is neither pooled nor bound. + check "captain_api_tokens_identity" { + expr = "(scope = 'git' AND pool AND agent IS NULL) OR (scope = 'git' AND NOT pool AND agent IS NOT NULL) OR (scope = 'api' AND NOT pool AND agent IS NULL)" + } + check "captain_api_tokens_max_agents" { + expr = "max_agents IS NULL OR (pool AND max_agents > 0)" + } +} diff --git a/migrations/migrations_test.go b/migrations/migrations_test.go index e241d7e7..71ddcb75 100644 --- a/migrations/migrations_test.go +++ b/migrations/migrations_test.go @@ -21,6 +21,7 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { "30_execution.pg.hcl", "31_execution_events.pg.hcl", "32_execution_approvals.pg.hcl", + "35_git_agent.pg.hcl", "40_artifacts.pg.hcl", "50_constraints.sql", "51_state_triggers.sql", @@ -39,6 +40,9 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { "72_ingest_storage_params.sql", "73_normalize_session_cwd.sql", "74_turn_request_approval_identity.sql", + "75_model_call_provider_cost.sql", + "76_model_call_cost_backfill.sql", + "77_drop_session_files_view.sql", } for _, name := range expectedFiles { if _, err := fs.Stat(schemaFS, name); err != nil { @@ -47,6 +51,8 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { } assertContainsAll(t, "00_types.pg.hcl", `values = ["created", "running", "succeeded", "partial", "failed", "cancelled", "interrupted"]`, + `enum "captain_git_agent_task_status"`, + `enum "captain_git_agent_verdict_status"`, ) assertContainsAll(t, "01_session_lifecycle_partial.sql", "-- phase: pre", @@ -99,6 +105,16 @@ func TestSchemaBundleContainsGavelIntegrationContract(t *testing.T) { `column "state"`, `column "version"`, ) + assertContainsAll(t, "35_git_agent.pg.hcl", + `table "captain_git_agent_tasks"`, + `column "mailbox"`, + `column "prompt_run_id"`, + `column "admission_key"`, + `index "captain_git_agent_tasks_mailbox_task_key"`, + `table "captain_git_agent_task_attempts"`, + `column "tier"`, + `index "captain_git_agent_task_attempts_task_attempt_tier_key"`, + ) assertContainsAll(t, "50_constraints.sql", "-- phase: post", "ALTER CONSTRAINT captain_prompt_runs_input_plan_id_fkey", diff --git a/pkg/captaintoken/hash.go b/pkg/captaintoken/hash.go new file mode 100644 index 00000000..bcae3d42 --- /dev/null +++ b/pkg/captaintoken/hash.go @@ -0,0 +1,97 @@ +package captaintoken + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "errors" + "fmt" + "strings" + + "golang.org/x/crypto/argon2" +) + +// ErrMalformed is the single answer to every unparseable credential. Callers +// map it to 401 without echoing which part was wrong. +var ErrMalformed = errors.New("malformed captain token") + +// argon2id parameters. Tuned down from the interactive-login defaults on +// purpose: the secret being protected is 256 bits of crypto/rand, not a +// human-chosen password, so the KDF exists to slow an attacker who has already +// stolen the database rather than to resist guessing. Verification also sits on +// the request path — git smart-HTTP makes several requests per push — so the +// cost has to stay bounded. The verification cache in verify.go is what keeps a +// burst from paying this repeatedly. +const ( + argonTime = 1 + argonMemory = 19 * 1024 // 19 MiB — the OWASP minimum for argon2id + argonThreads = 1 + argonKeyLen = 32 + argonSaltLen = 16 +) + +// hashPrefix identifies the encoding, so a future parameter change can be +// recognized and rehashed rather than silently mis-verified. +const hashPrefix = "$argon2id$v=19" + +// HashSecret derives the stored form of a token secret. +// +// The encoding carries its own parameters, so a row hashed under today's cost +// still verifies after the constants above change. +func HashSecret(secret string) (string, error) { + salt := make([]byte, argonSaltLen) + if _, err := rand.Read(salt); err != nil { + return "", fmt.Errorf("generate token salt: %w", err) + } + key := argon2.IDKey([]byte(secret), salt, argonTime, argonMemory, argonThreads, argonKeyLen) + return fmt.Sprintf("%s$m=%d,t=%d,p=%d$%s$%s", + hashPrefix, argonMemory, argonTime, argonThreads, + base64.RawStdEncoding.EncodeToString(salt), + base64.RawStdEncoding.EncodeToString(key)), nil +} + +// VerifySecret checks a secret against an encoded hash in constant time. +// +// A malformed hash verifies as false rather than erroring: it can only come +// from a corrupt row, and a caller that treated "unreadable" differently from +// "wrong" would turn database damage into an authentication bypass. +func VerifySecret(secret, encoded string) bool { + memory, time, threads, salt, want, ok := parseHash(encoded) + if !ok { + return false + } + got := argon2.IDKey([]byte(secret), salt, time, memory, threads, uint32(len(want))) + return constantTimeEqual(string(got), string(want)) +} + +func parseHash(encoded string) (memory, time uint32, threads uint8, salt, key []byte, ok bool) { + if !strings.HasPrefix(encoded, hashPrefix+"$") { + return 0, 0, 0, nil, nil, false + } + parts := strings.Split(encoded, "$") + // "", "argon2id", "v=19", "m=..,t=..,p=..", salt, key + if len(parts) != 6 { + return 0, 0, 0, nil, nil, false + } + if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &time, &threads); err != nil { + return 0, 0, 0, nil, nil, false + } + salt, err := base64.RawStdEncoding.DecodeString(parts[4]) + if err != nil { + return 0, 0, 0, nil, nil, false + } + key, err = base64.RawStdEncoding.DecodeString(parts[5]) + if err != nil || len(key) == 0 || memory == 0 || time == 0 || threads == 0 { + return 0, 0, 0, nil, nil, false + } + return memory, time, threads, salt, key, true +} + +// fastDigest is a non-reversible key for the in-memory verification cache. It +// is SHA-256 rather than argon2 precisely because it must be cheap — it never +// leaves the process and never reaches storage, so it protects only against a +// heap dump exposing the plaintext. +func fastDigest(secret string) string { + sum := sha256.Sum256([]byte(secret)) + return base64.RawURLEncoding.EncodeToString(sum[:]) +} diff --git a/pkg/captaintoken/token.go b/pkg/captaintoken/token.go new file mode 100644 index 00000000..430a2c71 --- /dev/null +++ b/pkg/captaintoken/token.go @@ -0,0 +1,173 @@ +// Package captaintoken mints and verifies the bearer credentials that reach a +// captain server over the network. +// +// A token is durable rather than single-use. The git-agent join token it +// replaces was burned on first redemption, which meant a restarting sidecar +// replayed a spent token and crash-looped for the life of the workload — the +// reason pkg/cli/gitagent_serve.go carries a joinOnce guard at all. Bounding a +// credential by expiry and revocation, instead of by one use, removes the whole +// class of problem. +// +// Only the hash is ever stored. Verification is deliberately two-step: an +// indexed lookup on the public id, then a constant-time KDF check of the +// secret. A scan comparing secrets with == would leak timing information. +package captaintoken + +import ( + "crypto/rand" + "crypto/subtle" + "encoding/base64" + "fmt" + "regexp" + "strings" + + "github.com/flanksource/clicky/text" +) + +// Prefix marks a captain token in logs, config files and secret scanners. It is +// part of the wire format so a leaked credential is recognizable on sight. +const Prefix = "cptn" + +// separator divides the public id from the secret. +const separator = "." + +const ( + idBytes = 9 // 12 base64 chars: enough to be unique, short enough to read + secretBytes = 32 // 256 bits; the KDF guards a stolen database, not guessing +) + +// Scope is what a token may reach. It mirrors the captain_api_token_scope enum. +type Scope string + +const ( + // ScopeGit authorizes pushing to a served repository and nothing else. An + // agent token gets this, so a leaked one cannot reach the /api/v1 executor + // and run arbitrary captain commands. + ScopeGit Scope = "git" + // ScopeAPI authorizes the HTTP API. + ScopeAPI Scope = "api" +) + +// ParseScope validates a scope selector, naming the alternatives. +func ParseScope(value string) (Scope, error) { + switch scope := Scope(strings.ToLower(strings.TrimSpace(value))); scope { + case ScopeGit, ScopeAPI: + return scope, nil + case "": + return "", fmt.Errorf("a token scope is required; want one of: %s, %s", ScopeGit, ScopeAPI) + default: + return "", fmt.Errorf("invalid token scope %q; want one of: %s, %s", value, ScopeGit, ScopeAPI) + } +} + +// Valid reports whether s names a scope. +func (s Scope) Valid() bool { return s == ScopeGit || s == ScopeAPI } + +// nameRe is the shape a token name must take. It is the §3.2 ref-segment shape +// deliberately, not by coincidence: a git-scoped token's name becomes an agent +// name, and an agent name becomes a path segment in the ref namespace that R8.3 +// uses to keep one agent out of another's refs. A name that cannot be a ref +// segment would be rejected far downstream, at push time. +var nameRe = regexp.MustCompile(`^[a-z0-9-]{1,64}$`) + +// ValidateName checks a token or agent name against that shape. +func ValidateName(name string) error { + if !nameRe.MatchString(name) { + return fmt.Errorf("name %q must match %s", name, nameRe) + } + return nil +} + +// Minted is a freshly created token: the public id to store, the hash to store, +// and the one-time plaintext to hand the operator. +type Minted struct { + // ID is the public half. It is stored, indexed, logged, and shown in + // listings. + ID string + // Hash is the argon2id encoding of the secret half, safe at rest. + Hash string + // Secret is the whole credential, id included. It exists only in this + // struct and is never stored — reveal it once or it is lost. + Secret text.SensitiveString +} + +// Mint generates a token. The returned Secret is the only time the plaintext +// exists; nothing derived from it can reconstruct it. +func Mint() (Minted, error) { + id, err := randomBase64(idBytes) + if err != nil { + return Minted{}, fmt.Errorf("generate token id: %w", err) + } + secret, err := randomBase64(secretBytes) + if err != nil { + return Minted{}, fmt.Errorf("generate token secret: %w", err) + } + hash, err := HashSecret(secret) + if err != nil { + return Minted{}, err + } + return Minted{ + ID: id, + Hash: hash, + Secret: text.NewSensitiveString(Prefix + "_" + id + separator + secret), + }, nil +} + +// Presented is a token as it arrived from a client, split but not yet verified. +type Presented struct { + ID string + secret string +} + +// Parse splits a presented credential into its public and secret halves. +// +// It reports a single generic error for every malformed shape: telling a caller +// which part of a credential was wrong is a probing aid, and none of the +// distinctions help a legitimate client that simply has the token. +func Parse(raw string) (Presented, error) { + trimmed := strings.TrimSpace(raw) + body, ok := strings.CutPrefix(trimmed, Prefix+"_") + if !ok { + return Presented{}, ErrMalformed + } + id, secret, ok := strings.Cut(body, separator) + if !ok || id == "" || secret == "" { + return Presented{}, ErrMalformed + } + return Presented{ID: id, secret: secret}, nil +} + +// Verify checks the presented secret against a stored hash in constant time. +func (p Presented) Verify(storedHash string) bool { + return VerifySecret(p.secret, storedHash) +} + +// CacheKey derives a stable, non-reversible key for a verification cache, so a +// repeated request can skip the KDF without the cache holding the secret. +func (p Presented) CacheKey() string { return p.ID + separator + fastDigest(p.secret) } + +// BearerFromHeader extracts the credential from an Authorization header, +// reporting whether one was present at all. +func BearerFromHeader(header string) (string, bool) { + const scheme = "bearer " + trimmed := strings.TrimSpace(header) + if len(trimmed) < len(scheme) || !strings.EqualFold(trimmed[:len(scheme)], scheme) { + return "", false + } + value := strings.TrimSpace(trimmed[len(scheme):]) + return value, value != "" +} + +func randomBase64(size int) (string, error) { + raw := make([]byte, size) + if _, err := rand.Read(raw); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(raw), nil +} + +// constantTimeEqual compares two strings without leaking their contents through +// timing. Length is not secret here — both sides are fixed-width encodings. +func constantTimeEqual(a, b string) bool { + return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1 +} diff --git a/pkg/captaintoken/token_test.go b/pkg/captaintoken/token_test.go new file mode 100644 index 00000000..86aceda3 --- /dev/null +++ b/pkg/captaintoken/token_test.go @@ -0,0 +1,215 @@ +package captaintoken + +import ( + "strings" + "testing" +) + +func TestMintProducesAVerifiableCredential(t *testing.T) { + minted, err := Mint() + if err != nil { + t.Fatal(err) + } + + raw := minted.Secret.Value() + if !strings.HasPrefix(raw, Prefix+"_") { + t.Fatalf("token %q lacks the %s_ prefix that makes a leak recognizable", raw, Prefix) + } + + presented, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + if presented.ID != minted.ID { + t.Fatalf("parsed id %q != minted id %q", presented.ID, minted.ID) + } + if !presented.Verify(minted.Hash) { + t.Fatal("a freshly minted token did not verify against its own hash") + } +} + +// The stored hash is what an attacker gets from a database dump. It must not +// contain the credential, and it must not be recomputable without the salt. +func TestHashNeverContainsTheSecret(t *testing.T) { + minted, err := Mint() + if err != nil { + t.Fatal(err) + } + presented, err := Parse(minted.Secret.Value()) + if err != nil { + t.Fatal(err) + } + + if strings.Contains(minted.Hash, presented.secret) { + t.Fatal("the stored hash embeds the secret") + } + if strings.Contains(minted.Hash, minted.Secret.Value()) { + t.Fatal("the stored hash embeds the whole credential") + } + + // Salted: the same secret hashed twice must differ, or a dump would reveal + // which accounts share a credential. + again, err := HashSecret(presented.secret) + if err != nil { + t.Fatal(err) + } + if again == minted.Hash { + t.Fatal("hashing is unsalted; identical secrets produce identical hashes") + } + if !VerifySecret(presented.secret, again) { + t.Fatal("a re-hash of the same secret does not verify") + } +} + +func TestVerifyRejectsAWrongSecret(t *testing.T) { + first, err := Mint() + if err != nil { + t.Fatal(err) + } + second, err := Mint() + if err != nil { + t.Fatal(err) + } + other, err := Parse(second.Secret.Value()) + if err != nil { + t.Fatal(err) + } + if other.Verify(first.Hash) { + t.Fatal("one token's secret verified against another's hash") + } +} + +// A corrupt row must read as "wrong", never as "skip the check". +func TestVerifyRejectsAMalformedHash(t *testing.T) { + for _, encoded := range []string{ + "", + "not-a-hash", + "$argon2id$v=19$m=19456,t=1,p=1$only-four-parts", + "$argon2id$v=19$m=x,t=y,p=z$c2FsdA$a2V5", + "$bcrypt$v=19$m=19456,t=1,p=1$c2FsdA$a2V5", + "$argon2id$v=19$m=0,t=0,p=0$c2FsdA$a2V5", + } { + if VerifySecret("anything", encoded) { + t.Fatalf("malformed hash %q verified", encoded) + } + } +} + +// Every malformed shape gets one answer: distinguishing them tells a prober +// which half they got right. +func TestParseRefusesMalformedCredentialsUniformly(t *testing.T) { + for _, raw := range []string{ + "", + "cptn_", + "cptn_abc", // no separator + "cptn_.secret", // empty id + "cptn_abc.", // empty secret + "bearer cptn_a.b", // scheme not stripped + "ghp_somethingelse", // another product's token + "abc.def", // no prefix + } { + if _, err := Parse(raw); err != ErrMalformed { + t.Fatalf("Parse(%q) = %v, want ErrMalformed", raw, err) + } + } +} + +func TestParseToleratesSurroundingWhitespace(t *testing.T) { + minted, err := Mint() + if err != nil { + t.Fatal(err) + } + presented, err := Parse(" " + minted.Secret.Value() + "\n") + if err != nil { + t.Fatal(err) + } + if !presented.Verify(minted.Hash) { + t.Fatal("a trimmed credential did not verify") + } +} + +// The cache key stands in for the secret in memory, so it must not be the +// secret, and it must separate tokens that differ in either half. +func TestCacheKeyIsDerivedNotLiteral(t *testing.T) { + minted, err := Mint() + if err != nil { + t.Fatal(err) + } + presented, err := Parse(minted.Secret.Value()) + if err != nil { + t.Fatal(err) + } + + key := presented.CacheKey() + if strings.Contains(key, presented.secret) { + t.Fatal("the cache key contains the secret") + } + if !strings.HasPrefix(key, presented.ID) { + t.Fatalf("cache key %q should be scoped by token id", key) + } + + wrongSecret := Presented{ID: presented.ID, secret: "different"} + if wrongSecret.CacheKey() == key { + t.Fatal("two different secrets share a cache key") + } + wrongID := Presented{ID: "other", secret: presented.secret} + if wrongID.CacheKey() == key { + t.Fatal("two different token ids share a cache key") + } +} + +func TestBearerFromHeader(t *testing.T) { + tests := []struct { + header string + want string + ok bool + }{ + {"Bearer cptn_a.b", "cptn_a.b", true}, + {"bearer cptn_a.b", "cptn_a.b", true}, // RFC 7235 scheme is case-insensitive + {"BEARER cptn_a.b ", "cptn_a.b", true}, + {"", "", false}, + {"Bearer", "", false}, + {"Bearer ", "", false}, + {"Basic dXNlcjpwYXNz", "", false}, + {"cptn_a.b", "", false}, // no scheme + } + for _, tt := range tests { + got, ok := BearerFromHeader(tt.header) + if got != tt.want || ok != tt.ok { + t.Errorf("BearerFromHeader(%q) = (%q, %v), want (%q, %v)", tt.header, got, ok, tt.want, tt.ok) + } + } +} + +func TestParseScope(t *testing.T) { + for _, raw := range []string{"git", " API ", "Git"} { + scope, err := ParseScope(raw) + if err != nil { + t.Fatalf("ParseScope(%q): %v", raw, err) + } + if !scope.Valid() { + t.Fatalf("ParseScope(%q) produced an invalid scope %q", raw, scope) + } + } + if _, err := ParseScope(""); err == nil || !strings.Contains(err.Error(), "required") { + t.Fatalf("empty scope err = %v", err) + } + if _, err := ParseScope("admin"); err == nil || !strings.Contains(err.Error(), "git, api") { + t.Fatalf("unknown scope must name the valid set, got %v", err) + } +} + +// Two mints must not collide; the id is a primary lookup key. +func TestMintIsUnique(t *testing.T) { + seen := map[string]struct{}{} + for range 50 { + minted, err := Mint() + if err != nil { + t.Fatal(err) + } + if _, dup := seen[minted.ID]; dup { + t.Fatalf("duplicate token id %q", minted.ID) + } + seen[minted.ID] = struct{}{} + } +} diff --git a/pkg/captaintoken/verify.go b/pkg/captaintoken/verify.go new file mode 100644 index 00000000..60a7728d --- /dev/null +++ b/pkg/captaintoken/verify.go @@ -0,0 +1,174 @@ +package captaintoken + +import ( + "context" + "errors" + "fmt" + "sync" + "time" +) + +var ( + // ErrUnknown is returned for a token id that is not on file. It is + // deliberately indistinguishable, to a caller, from a wrong secret. + ErrUnknown = errors.New("unknown captain token") + // ErrRevoked and ErrExpired are separated from ErrUnknown because a + // legitimate holder of a real credential benefits from knowing which. + ErrRevoked = errors.New("captain token has been revoked") + ErrExpired = errors.New("captain token has expired") + // ErrScope means the credential is real but not permitted here. + ErrScope = errors.New("captain token does not carry the required scope") +) + +// Record is a stored token, as the verifier needs to see it. The store owns +// persistence; this package owns the credential arithmetic. +type Record struct { + ID string + SecretHash string + Name string + Scope Scope + Agent string + Pool bool + PoolAgents []string + MaxAgents int + ExpiresAt *time.Time + RevokedAt *time.Time +} + +// Active reports why a token cannot be used, or nil. +func (r Record) Active(now time.Time) error { + if r.RevokedAt != nil { + return ErrRevoked + } + if r.ExpiresAt != nil && !now.Before(*r.ExpiresAt) { + return ErrExpired + } + return nil +} + +// Lookup fetches a token by its public id. It returns ErrUnknown when there is +// no such row. +type Lookup func(ctx context.Context, id string) (Record, error) + +// Verifier turns a presented credential into a Record. +// +// The order of operations is the load-bearing part. The id is looked up first, +// on an index, and the KDF runs only for an id that exists — so a flood of +// random credentials costs an indexed miss each rather than 19 MiB and ~60ms of +// argon2. Only an attacker who already knows a real token id can force the +// expensive path, and the cache below bounds even that. +type Verifier struct { + lookup Lookup + ttl time.Duration + now func() time.Time + + mu sync.Mutex + cache map[string]time.Time + maxKeys int +} + +// DefaultCacheTTL bounds how long a successful KDF verification is trusted +// without re-running. Revocation is still prompt: the record is re-read from +// the store on every request even on a cache hit, so only the argon2 step is +// skipped, never the revoked/expired check. +const DefaultCacheTTL = 30 * time.Second + +// maxCacheKeys caps the cache so a stream of distinct valid-id credentials +// cannot grow it without bound. +const maxCacheKeys = 1024 + +// NewVerifier builds a verifier over a store lookup. +func NewVerifier(lookup Lookup) *Verifier { + return &Verifier{ + lookup: lookup, + ttl: DefaultCacheTTL, + now: time.Now, + cache: map[string]time.Time{}, + maxKeys: maxCacheKeys, + } +} + +// Verify resolves a raw credential to its record, checking that it exists, that +// its secret matches, and that it is neither revoked nor expired. +func (v *Verifier) Verify(ctx context.Context, raw string) (Record, error) { + presented, err := Parse(raw) + if err != nil { + return Record{}, err + } + record, err := v.lookup(ctx, presented.ID) + if err != nil { + return Record{}, err + } + // Liveness is checked against the freshly read record on every request, + // cache hit or not, so a revocation takes effect immediately (R8.5). + if err := record.Active(v.now()); err != nil { + return Record{}, err + } + if !v.verifySecret(presented, record.SecretHash) { + return Record{}, ErrUnknown + } + return record, nil +} + +// VerifyScope resolves a credential and requires a scope. +func (v *Verifier) VerifyScope(ctx context.Context, raw string, want Scope) (Record, error) { + record, err := v.Verify(ctx, raw) + if err != nil { + return Record{}, err + } + if record.Scope != want { + return Record{}, fmt.Errorf("%w: token %q has scope %q, not %q", ErrScope, record.ID, record.Scope, want) + } + return record, nil +} + +// verifySecret runs the KDF unless a recent identical presentation already +// passed. The cache key is a digest, so the map never holds a secret. +func (v *Verifier) verifySecret(presented Presented, storedHash string) bool { + key := presented.CacheKey() + if v.cached(key) { + return true + } + if !presented.Verify(storedHash) { + return false + } + v.remember(key) + return true +} + +func (v *Verifier) cached(key string) bool { + v.mu.Lock() + defer v.mu.Unlock() + expires, ok := v.cache[key] + if !ok { + return false + } + if !v.now().Before(expires) { + delete(v.cache, key) + return false + } + return true +} + +func (v *Verifier) remember(key string) { + v.mu.Lock() + defer v.mu.Unlock() + if len(v.cache) >= v.maxKeys { + v.evictExpiredLocked() + // Still full of live entries: drop the cache rather than grow past the + // cap. Verification stays correct, it just costs the KDF again. + if len(v.cache) >= v.maxKeys { + v.cache = map[string]time.Time{} + } + } + v.cache[key] = v.now().Add(v.ttl) +} + +func (v *Verifier) evictExpiredLocked() { + now := v.now() + for key, expires := range v.cache { + if !now.Before(expires) { + delete(v.cache, key) + } + } +} diff --git a/pkg/captaintoken/verify_test.go b/pkg/captaintoken/verify_test.go new file mode 100644 index 00000000..89f3f58e --- /dev/null +++ b/pkg/captaintoken/verify_test.go @@ -0,0 +1,244 @@ +package captaintoken + +import ( + "context" + "errors" + "sync/atomic" + "testing" + "time" +) + +// stubStore counts lookups so a test can tell a cache hit from a miss. +type stubStore struct { + record Record + err error + lookups atomic.Int64 +} + +func (s *stubStore) lookup(context.Context, string) (Record, error) { + s.lookups.Add(1) + if s.err != nil { + return Record{}, s.err + } + return s.record, nil +} + +// mintedRecord returns a token and the stored record that matches it. +func mintedRecord(t *testing.T, mutate func(*Record)) (string, Record) { + t.Helper() + minted, err := Mint() + if err != nil { + t.Fatal(err) + } + record := Record{ + ID: minted.ID, SecretHash: minted.Hash, + Name: "worker-01", Scope: ScopeGit, Agent: "worker-01", + } + if mutate != nil { + mutate(&record) + } + return minted.Secret.Value(), record +} + +func TestVerifyAcceptsALiveToken(t *testing.T) { + raw, record := mintedRecord(t, nil) + store := &stubStore{record: record} + + got, err := NewVerifier(store.lookup).Verify(t.Context(), raw) + if err != nil { + t.Fatal(err) + } + if got.Agent != "worker-01" || got.Scope != ScopeGit { + t.Fatalf("record = %+v", got) + } +} + +// A durable token is the whole point: presenting it twice must keep working, +// unlike the single-use join token it replaces. +func TestVerifyIsReusable(t *testing.T) { + raw, record := mintedRecord(t, nil) + store := &stubStore{record: record} + verifier := NewVerifier(store.lookup) + + for i := range 3 { + if _, err := verifier.Verify(t.Context(), raw); err != nil { + t.Fatalf("presentation %d failed: %v", i+1, err) + } + } +} + +func TestVerifyRejectsRevokedAndExpired(t *testing.T) { + revokedAt := time.Now().Add(-time.Minute) + expiredAt := time.Now().Add(-time.Minute) + + tests := []struct { + name string + mutate func(*Record) + want error + }{ + {"revoked", func(r *Record) { r.RevokedAt = &revokedAt }, ErrRevoked}, + {"expired", func(r *Record) { r.ExpiresAt = &expiredAt }, ErrExpired}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + raw, record := mintedRecord(t, tt.mutate) + store := &stubStore{record: record} + + if _, err := NewVerifier(store.lookup).Verify(t.Context(), raw); !errors.Is(err, tt.want) { + t.Fatalf("err = %v, want %v", err, tt.want) + } + }) + } +} + +// A wrong secret and an unknown id must be indistinguishable to the caller. +func TestVerifyRejectsAWrongSecretAsUnknown(t *testing.T) { + _, record := mintedRecord(t, nil) + other, err := Mint() + if err != nil { + t.Fatal(err) + } + // Same id, different secret. + forged := Prefix + "_" + record.ID + separator + "wrong-secret" + store := &stubStore{record: record} + + if _, err := NewVerifier(store.lookup).Verify(t.Context(), forged); !errors.Is(err, ErrUnknown) { + t.Fatalf("forged secret err = %v, want ErrUnknown", err) + } + _ = other +} + +// The KDF must not run for an id that is not on file, or a flood of random +// credentials becomes a memory-and-CPU amplification attack. +func TestVerifyDoesNotHashAnUnknownID(t *testing.T) { + store := &stubStore{err: ErrUnknown} + verifier := NewVerifier(store.lookup) + + start := time.Now() + for range 20 { + if _, err := verifier.Verify(t.Context(), Prefix+"_unknownid.secret"); !errors.Is(err, ErrUnknown) { + t.Fatalf("err = %v, want ErrUnknown", err) + } + } + // 20 argon2 runs at ~19MiB each would take well over a second; an indexed + // miss is microseconds. A generous bound still catches a regression that + // moves hashing ahead of the lookup. + if elapsed := time.Since(start); elapsed > 300*time.Millisecond { + t.Fatalf("unknown ids took %s — the KDF is running before the lookup", elapsed) + } +} + +// A malformed credential must not reach the store at all. +func TestVerifyRejectsMalformedBeforeLookup(t *testing.T) { + store := &stubStore{} + if _, err := NewVerifier(store.lookup).Verify(t.Context(), "garbage"); !errors.Is(err, ErrMalformed) { + t.Fatalf("err = %v, want ErrMalformed", err) + } + if store.lookups.Load() != 0 { + t.Fatal("a malformed credential reached the store") + } +} + +func TestVerifyScope(t *testing.T) { + raw, record := mintedRecord(t, func(r *Record) { r.Scope = ScopeGit }) + store := &stubStore{record: record} + verifier := NewVerifier(store.lookup) + + if _, err := verifier.VerifyScope(t.Context(), raw, ScopeGit); err != nil { + t.Fatal(err) + } + // A git-scoped agent token must not reach the API, which executes commands. + _, err := verifier.VerifyScope(t.Context(), raw, ScopeAPI) + if !errors.Is(err, ErrScope) { + t.Fatalf("err = %v, want ErrScope", err) + } +} + +// The cache skips the KDF, never the liveness check — otherwise a revocation +// would not take effect until the entry aged out. +func TestCacheSkipsHashingButNotRevocation(t *testing.T) { + raw, record := mintedRecord(t, nil) + store := &stubStore{record: record} + verifier := NewVerifier(store.lookup) + + if _, err := verifier.Verify(t.Context(), raw); err != nil { + t.Fatal(err) + } + + // Second presentation is a cache hit: it must still consult the store. + before := store.lookups.Load() + if _, err := verifier.Verify(t.Context(), raw); err != nil { + t.Fatal(err) + } + if store.lookups.Load() != before+1 { + t.Fatal("a cache hit skipped the store read, so revocation would be delayed") + } + + // Revoke between presentations; the very next call must fail. + revokedAt := time.Now() + store.record.RevokedAt = &revokedAt + if _, err := verifier.Verify(t.Context(), raw); !errors.Is(err, ErrRevoked) { + t.Fatalf("err = %v, want ErrRevoked immediately after revocation", err) + } +} + +func TestCacheExpires(t *testing.T) { + raw, record := mintedRecord(t, nil) + store := &stubStore{record: record} + verifier := NewVerifier(store.lookup) + + clock := time.Now() + verifier.now = func() time.Time { return clock } + verifier.ttl = time.Minute + + if _, err := verifier.Verify(t.Context(), raw); err != nil { + t.Fatal(err) + } + presented, err := Parse(raw) + if err != nil { + t.Fatal(err) + } + if !verifier.cached(presented.CacheKey()) { + t.Fatal("a verified token was not cached") + } + + clock = clock.Add(2 * time.Minute) + if verifier.cached(presented.CacheKey()) { + t.Fatal("the cache entry outlived its TTL") + } +} + +// A stream of distinct valid credentials must not grow the cache without bound. +func TestCacheIsBounded(t *testing.T) { + verifier := NewVerifier(func(context.Context, string) (Record, error) { return Record{}, ErrUnknown }) + verifier.maxKeys = 8 + + for i := range 100 { + verifier.remember(string(rune('a'+i%26)) + string(rune('a'+i/26))) + } + if len(verifier.cache) > verifier.maxKeys { + t.Fatalf("cache holds %d entries, cap is %d", len(verifier.cache), verifier.maxKeys) + } +} + +func TestRecordActive(t *testing.T) { + now := time.Now() + past, future := now.Add(-time.Hour), now.Add(time.Hour) + + if err := (Record{}).Active(now); err != nil { + t.Fatalf("a token with no expiry or revocation should be active, got %v", err) + } + if err := (Record{ExpiresAt: &future}).Active(now); err != nil { + t.Fatalf("a token expiring later should be active, got %v", err) + } + if err := (Record{ExpiresAt: &past}).Active(now); !errors.Is(err, ErrExpired) { + t.Fatalf("err = %v, want ErrExpired", err) + } + if err := (Record{RevokedAt: &past}).Active(now); !errors.Is(err, ErrRevoked) { + t.Fatalf("err = %v, want ErrRevoked", err) + } + // Revocation outranks expiry: it is the more specific reason. + if err := (Record{RevokedAt: &past, ExpiresAt: &past}).Active(now); !errors.Is(err, ErrRevoked) { + t.Fatalf("err = %v, want ErrRevoked", err) + } +} diff --git a/pkg/cli/serve.go b/pkg/cli/serve.go index 016f68f1..0c779ab3 100644 --- a/pkg/cli/serve.go +++ b/pkg/cli/serve.go @@ -21,7 +21,9 @@ import ( "github.com/flanksource/captain/pkg/aichat" "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/captain/pkg/captaintoken" "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/captain/pkg/gitagent" "github.com/flanksource/captain/pkg/monitor" "github.com/flanksource/clicky/rpc" rpchttp "github.com/flanksource/clicky/rpc/http" @@ -45,6 +47,20 @@ type ServeOptions struct { Open bool PromptDirs []string MCPServers []aichat.MCPServer + // TLS serves HTTPS, generating and reusing a self-signed certificate + // beside the git-agent keys. It is off by default because the ordinary + // case is a loopback UI, and turning every local URL into https would + // mean a certificate warning for no gain. + TLS bool + // TLSCert and TLSKey supply a real certificate instead of the generated + // one. Both or neither. + TLSCert string + TLSKey string + // TLSHosts are the addresses agents will reach this server on. They are + // added to a generated certificate's subject names, and checked against a + // supplied one — a certificate that omits the address agents dial fails at + // the client, and by then every agent is already enrolled against it. + TLSHosts []string } func NewServeCommand(version string) *cobra.Command { @@ -80,6 +96,10 @@ proxies /api back to this Go process.`, cmd.Flags().IntVar(&opts.UIPort, "ui-port", opts.UIPort, "Port for the Vite dev server when --dev is set (random by default)") cmd.Flags().BoolVar(&opts.Open, "open", false, "Open the web UI in the default browser") cmd.Flags().StringArrayVar(&opts.PromptDirs, "prompt-dir", nil, "Additional local directory containing .prompt files (repeatable)") + cmd.Flags().BoolVar(&opts.TLS, "tls", false, "Serve HTTPS, generating and reusing a self-signed certificate beside the git-agent keys") + cmd.Flags().StringVar(&opts.TLSCert, "tls-cert", "", "PEM certificate to serve instead of the generated one") + cmd.Flags().StringVar(&opts.TLSKey, "tls-key", "", "PEM private key for --tls-cert") + cmd.Flags().StringArrayVar(&opts.TLSHosts, "tls-host", nil, "Address agents will reach this server on; added to the certificate (repeatable)") return cmd } @@ -120,6 +140,13 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve return err } defer listener.Close() + // Resolved once, before anything is registered: the certificate decides both + // what this server presents and what a joining agent is told to pin, and + // resolving it twice could hand out one that is not the one being served. + certificate, err := serveCertificate(opts) + if err != nil { + return err + } openAPIConfig := &rpc.OpenAPIConfig{ Title: "Captain", Description: "Captain command and agent launcher API.", @@ -176,6 +203,7 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve mux.HandleFunc("POST /api/captain/hooks/{provider}", handleMonitorHookEvent()) mux.HandleFunc("GET /api/captain/ai/permissions/catalog", handlePermissionCatalog(cwd)) mux.HandleFunc("GET /api/captain/ai/prompt/schema", handlePromptSchema()) + registerSandboxHandlers(mux) registerProviderTokenHandlers(mux) registerProviderDefaultsHandlers(mux) registerDisabledHandlers(mux) @@ -197,6 +225,12 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve mux.Handle("/api/chat", chatHandler) mux.Handle("/api/chat/", chatHandler) + // The supervisor's git-agent mailbox, hosted here rather than in its own + // process: this is the process that holds the database the tokens live in. + if err := registerGitHandlers(mux, db, addr, certificate); err != nil { + return err + } + uiHandler, err := newCaptainWebappHandler() if err != nil { return err @@ -208,17 +242,33 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve root.Handle("/health", mux) root.Handle("/", uiHandler) + tlsConfig := serveTLSConfig(certificate) + scheme := "http" + if tlsConfig != nil { + scheme = "https" + } // Export the serve URL so every captain-launched agent (and the hook // receiver subprocesses its sessions spawn) delivers hook events to this // instance even off the default port. - if err := os.Setenv(api.ServeURLEnv, "http://"+addr); err != nil { + if err := os.Setenv(api.ServeURLEnv, scheme+"://"+addr); err != nil { return err } + // Auth sits outside the database-context middleware so an unauthenticated + // request is refused before it can resolve a context or open a pool. + auth := TokenAuthMiddleware(TokenAuthConfig{ + Verifier: captaintoken.NewVerifier(db.LookupAPIToken), + Touch: db.TouchAPIToken, + }) httpSrv := &http.Server{ Addr: addr, Handler: rpchttp.TimingMiddleware( - DatabaseContextMiddleware(PromptDirsMiddleware(root, opts.PromptDirs))), - ReadTimeout: 30 * time.Second, + auth(DatabaseContextMiddleware(PromptDirsMiddleware(root, opts.PromptDirs)))), + TLSConfig: tlsConfig, + // Bounded at the headers rather than the whole request: a synchronous + // relay runs the supervisor's hook set inside the push, and a prompt + // hook can take minutes. A 30s whole-request deadline would kill it + // mid-verdict and report it to the agent as a broken connection. + ReadHeaderTimeout: 30 * time.Second, // /api/chat streams SSE; a fixed write timeout truncates long turns. IdleTimeout: 60 * time.Second, } @@ -258,13 +308,24 @@ func RunServe(ctx context.Context, rootCmd *cobra.Command, opts ServeOptions, ve go prunePromptRuns(ctx, promptRuns) defer promptChats.stopAll() + if err := startCredentialPublisher(ctx, stdout); err != nil { + return err + } + errCh := make(chan error, 1) go func() { - fmt.Fprintf(stdout, "Captain API listening on http://%s\n", addr) - fmt.Fprintf(stdout, " UI: http://%s/\n", addr) - fmt.Fprintf(stdout, " OpenAPI JSON: http://%s/api/openapi.json\n", addr) - fmt.Fprintf(stdout, " AI Chat: http://%s/api/chat\n", addr) - if err := httpSrv.Serve(listener); err != nil && err != http.ErrServerClosed { + fmt.Fprintf(stdout, "Captain API listening on %s://%s\n", scheme, addr) + fmt.Fprintf(stdout, " UI: %s://%s/\n", scheme, addr) + fmt.Fprintf(stdout, " OpenAPI JSON: %s://%s/api/openapi.json\n", scheme, addr) + fmt.Fprintf(stdout, " AI Chat: %s://%s/api/chat\n", scheme, addr) + fmt.Fprintf(stdout, " git-agent: %s://%s%s\n", scheme, addr, gitagent.GitHTTPPrefix) + // ServeTLS with an already-configured TLSConfig: the certificate comes + // from serveTLSConfig, which reuses one rather than issuing per start. + serve := httpSrv.Serve + if tlsConfig != nil { + serve = func(l net.Listener) error { return httpSrv.ServeTLS(l, "", "") } + } + if err := serve(listener); err != nil && err != http.ErrServerClosed { errCh <- err } }() diff --git a/pkg/cli/serve_auth.go b/pkg/cli/serve_auth.go new file mode 100644 index 00000000..642673b9 --- /dev/null +++ b/pkg/cli/serve_auth.go @@ -0,0 +1,160 @@ +// Token authentication for `captain serve`. +// +// Every route on this server used to be unauthenticated, and the only thing +// standing in front of /api/v1 — which executes arbitrary captain commands — +// was that the listener bound localhost. Hosting a git endpoint here means the +// server has to be reachable off-box, so that protection is gone and something +// has to replace it. + +package cli + +import ( + "context" + "errors" + "net" + "net/http" + "strings" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/gitagent" +) + +const ( + // gitPathPrefix is the subtree the git smart-HTTP transport is served + // under. Taken from the transport rather than restated, so the routing, the + // auth scope and the database-context exemption cannot drift apart. + gitPathPrefix = gitagent.GitHTTPPrefix + // apiPathPrefix is the REST executor's subtree. A request here runs a + // captain command, which is why it needs the stronger of the two scopes. + apiPathPrefix = "/api/v1" +) + +// tokenContextKey carries the verified credential to the handler. +type tokenContextKey struct{} + +// TokenFromContext returns the credential a request authenticated with. ok is +// false for a loopback request, which carries none — a handler that needs an +// identity must say so rather than assume one. +func TokenFromContext(ctx context.Context) (captaintoken.Record, bool) { + record, ok := ctx.Value(tokenContextKey{}).(captaintoken.Record) + return record, ok +} + +// TokenAuthConfig is what the middleware needs from storage. +type TokenAuthConfig struct { + Verifier *captaintoken.Verifier + // Touch records that a token was used. It runs only after a credential + // verifies, and its failure never fails the request: bookkeeping must not + // turn a working push into a 500. + Touch func(ctx context.Context, tokenID string) error +} + +// TokenAuthMiddleware requires a captain token for requests that arrive from +// off this machine. +// +// Loopback is exempt, so the local webapp, CLI and hook subprocesses are +// untouched. That is not just convenience: an EventSource stream cannot set an +// Authorization header, so requiring one would break the UI for the ordinary +// local case. The exemption rests entirely on RemoteAddr, which is why a +// request carrying proxy forwarding headers is treated as remote wherever it +// connected from — otherwise anything behind a same-host reverse proxy would +// inherit it. +func TokenAuthMiddleware(config TokenAuthConfig) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + scope, protected := requiredScope(r.URL.Path) + if !protected || isLoopbackRequest(r) { + next.ServeHTTP(w, r) + return + } + if config.Verifier == nil { + // Reachable only if serve wired the chain without a verifier. + // Refusing beats passing an unauthenticated request through to + // the command executor. + http.Error(w, "captain token verification is not configured", http.StatusServiceUnavailable) + return + } + raw, ok := captaintoken.BearerFromHeader(r.Header.Get("Authorization")) + if !ok { + writeTokenChallenge(w, http.StatusUnauthorized, + "this endpoint requires a captain token; mint one with `captain token create`") + return + } + record, err := config.Verifier.VerifyScope(r.Context(), raw, scope) + if err != nil { + writeTokenRejection(w, err, scope) + return + } + if config.Touch != nil { + if err := config.Touch(r.Context(), record.ID); err != nil { + log.Warnf("record captain token use: %v", err) + } + } + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), tokenContextKey{}, record))) + }) + } +} + +// requiredScope reports which scope a path needs, and whether it is protected +// at all. The SPA and its assets are deliberately open: a browser loading the +// UI from another machine has nowhere to put a bearer token. +func requiredScope(path string) (captaintoken.Scope, bool) { + switch { + case strings.HasPrefix(path, gitPathPrefix): + return captaintoken.ScopeGit, true + case path == apiPathPrefix || strings.HasPrefix(path, apiPathPrefix+"/"): + return captaintoken.ScopeAPI, true + default: + return "", false + } +} + +// writeTokenRejection maps a verification failure to a status. +// +// The distinction that matters is between "this credential is not good" and "I +// could not tell": a database outage must not read as an authentication +// failure, or an operator chases a phantom auth bug through a downtime. +func writeTokenRejection(w http.ResponseWriter, err error, want captaintoken.Scope) { + switch { + case errors.Is(err, captaintoken.ErrScope): + // The credential is real, so naming the scope it lacks is not a probing + // aid — and it is the one thing that tells the holder what to fix. + http.Error(w, "this captain token does not carry the "+string(want)+" scope", http.StatusForbidden) + case errors.Is(err, captaintoken.ErrRevoked): + writeTokenChallenge(w, http.StatusUnauthorized, "this captain token has been revoked") + case errors.Is(err, captaintoken.ErrExpired): + writeTokenChallenge(w, http.StatusUnauthorized, "this captain token has expired") + case errors.Is(err, captaintoken.ErrUnknown), errors.Is(err, captaintoken.ErrMalformed): + // One answer for an unknown id and a wrong secret: telling a prober + // which half they got right halves the search. + writeTokenChallenge(w, http.StatusUnauthorized, "invalid captain token") + default: + log.Errorf("verify captain token: %v", err) + http.Error(w, "cannot verify captain tokens right now", http.StatusServiceUnavailable) + } +} + +func writeTokenChallenge(w http.ResponseWriter, status int, message string) { + w.Header().Set("WWW-Authenticate", `Bearer realm="captain"`) + http.Error(w, message, status) +} + +// forwardedHeaders are set by proxies. Their presence means the request did not +// originate on this machine, whatever RemoteAddr says. +var forwardedHeaders = []string{"X-Forwarded-For", "X-Real-Ip", "X-Forwarded-Host", "Forwarded"} + +func isLoopbackRequest(r *http.Request) bool { + for _, header := range forwardedHeaders { + if strings.TrimSpace(r.Header.Get(header)) != "" { + return false + } + } + host, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + host = r.RemoteAddr + } + // An address that will not parse is treated as remote: failing closed costs + // a token, failing open costs command execution. + ip := net.ParseIP(strings.Trim(host, "[]")) + return ip != nil && ip.IsLoopback() +} diff --git a/pkg/cli/serve_auth_test.go b/pkg/cli/serve_auth_test.go new file mode 100644 index 00000000..76fa7464 --- /dev/null +++ b/pkg/cli/serve_auth_test.go @@ -0,0 +1,290 @@ +package cli + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// authFixture is a middleware wired over one minted token, plus the state a +// test needs to tell what the chain did. +type authFixture struct { + handler http.Handler + raw string + record *captaintoken.Record + reached bool + seen captaintoken.Record + touched []string + touchErr error + lookupFn func(context.Context, string) (captaintoken.Record, error) +} + +func newAuthFixture(t *testing.T, scope captaintoken.Scope) *authFixture { + t.Helper() + minted, err := captaintoken.Mint() + require.NoError(t, err) + + fixture := &authFixture{raw: minted.Secret.Value()} + fixture.record = &captaintoken.Record{ + ID: minted.ID, SecretHash: minted.Hash, Name: "worker-01", Scope: scope, Agent: "worker-01", + } + lookup := func(ctx context.Context, id string) (captaintoken.Record, error) { + if fixture.lookupFn != nil { + return fixture.lookupFn(ctx, id) + } + if id != fixture.record.ID { + return captaintoken.Record{}, captaintoken.ErrUnknown + } + return *fixture.record, nil + } + middleware := TokenAuthMiddleware(TokenAuthConfig{ + Verifier: captaintoken.NewVerifier(lookup), + Touch: func(_ context.Context, tokenID string) error { + fixture.touched = append(fixture.touched, tokenID) + return fixture.touchErr + }, + }) + fixture.handler = middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + fixture.reached = true + if record, ok := TokenFromContext(r.Context()); ok { + fixture.seen = record + } + w.WriteHeader(http.StatusOK) + })) + return fixture +} + +// call drives one request. remoteAddr chooses whether it looks local. +func (f *authFixture) call(method, path, remoteAddr string, headers map[string]string) *httptest.ResponseRecorder { + f.reached, f.seen = false, captaintoken.Record{} + request := httptest.NewRequest(method, path, nil) + request.RemoteAddr = remoteAddr + for name, value := range headers { + request.Header.Set(name, value) + } + recorder := httptest.NewRecorder() + f.handler.ServeHTTP(recorder, request) + return recorder +} + +const ( + loopbackAddr = "127.0.0.1:54321" + remoteAddr = "10.1.2.3:54321" +) + +// The webapp, the local CLI and hook subprocesses all talk to this server from +// 127.0.0.1, and an EventSource stream cannot set an Authorization header — so +// requiring a token locally would break the UI rather than secure it. +func TestLoopbackNeedsNoToken(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + for _, addr := range []string{loopbackAddr, "[::1]:54321", "127.0.0.5:9"} { + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", addr, nil) + assert.Equal(t, http.StatusOK, recorder.Code, "loopback %s should not need a token", addr) + assert.True(t, fixture.reached) + } + + // A loopback request carries no credential, so a handler must not be able to + // mistake it for an authenticated one. + _, ok := TokenFromContext(context.Background()) + assert.False(t, ok) + assert.Empty(t, fixture.touched, "a loopback request touches no token") +} + +// The loopback exemption rests entirely on RemoteAddr. Behind a same-host +// reverse proxy every request would arrive from 127.0.0.1 and inherit it, so +// the forwarding headers a proxy adds have to revoke it. +func TestForwardedRequestIsNotTreatedAsLoopback(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + for _, header := range []string{"X-Forwarded-For", "X-Real-Ip", "X-Forwarded-Host", "Forwarded"} { + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", loopbackAddr, map[string]string{header: "203.0.113.9"}) + assert.Equalf(t, http.StatusUnauthorized, recorder.Code, + "%s means the request did not originate on this machine", header) + assert.False(t, fixture.reached) + } +} + +func TestRemoteRequestWithoutATokenIsChallenged(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + for _, header := range []string{"", "Basic dXNlcjpwYXNz", "Bearer", "Bearer "} { + headers := map[string]string{} + if header != "" { + headers["Authorization"] = header + } + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, headers) + require.Equal(t, http.StatusUnauthorized, recorder.Code) + assert.Equal(t, `Bearer realm="captain"`, recorder.Header().Get("WWW-Authenticate")) + assert.Contains(t, recorder.Body.String(), "captain token create", "the challenge should say how to get one") + assert.False(t, fixture.reached) + } +} + +func TestRemoteRequestWithAValidTokenReachesTheHandler(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, + map[string]string{"Authorization": "Bearer " + fixture.raw}) + + require.Equal(t, http.StatusOK, recorder.Code) + require.True(t, fixture.reached) + assert.Equal(t, "worker-01", fixture.seen.Agent, "the handler needs to know which identity is calling") + assert.Equal(t, []string{fixture.record.ID}, fixture.touched) +} + +// A leaked agent token must not reach the executor, which runs captain +// commands — that separation is the whole reason there are two scopes. +func TestGitScopedTokenIsRefusedByTheCommandAPI(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeGit) + authorized := map[string]string{"Authorization": "Bearer " + fixture.raw} + + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, authorized) + require.Equal(t, http.StatusForbidden, recorder.Code) + assert.Contains(t, recorder.Body.String(), "api scope") + assert.False(t, fixture.reached) + + // The same token is exactly right for the endpoint it was minted for. + recorder = fixture.call(http.MethodPost, "/git/mailboxes/aaa.git/git-receive-pack", remoteAddr, authorized) + assert.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, fixture.reached) +} + +func TestAPIScopedTokenIsRefusedByTheGitEndpoint(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + recorder := fixture.call(http.MethodGet, "/git/mailboxes/aaa.git/info/refs", remoteAddr, + map[string]string{"Authorization": "Bearer " + fixture.raw}) + + require.Equal(t, http.StatusForbidden, recorder.Code) + assert.Contains(t, recorder.Body.String(), "git scope") +} + +func TestRejectionsDistinguishWhatTheHolderCanAct(t *testing.T) { + revokedAt := time.Now().Add(-time.Minute) + expiredAt := time.Now().Add(-time.Minute) + + tests := []struct { + name string + mutate func(*authFixture) + body string + }{ + {"revoked", func(f *authFixture) { f.record.RevokedAt = &revokedAt }, "revoked"}, + {"expired", func(f *authFixture) { f.record.ExpiresAt = &expiredAt }, "expired"}, + // An unknown id and a wrong secret get one answer: distinguishing them + // tells a prober which half of the credential they got right. + {"unknown", func(f *authFixture) { f.record.ID = "someotherid" }, "invalid captain token"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + tt.mutate(fixture) + + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, + map[string]string{"Authorization": "Bearer " + fixture.raw}) + + require.Equal(t, http.StatusUnauthorized, recorder.Code) + assert.Equal(t, `Bearer realm="captain"`, recorder.Header().Get("WWW-Authenticate")) + assert.Contains(t, recorder.Body.String(), tt.body) + assert.False(t, fixture.reached) + }) + } + + t.Run("a garbage credential never reaches the store", func(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + var lookups int + fixture.lookupFn = func(context.Context, string) (captaintoken.Record, error) { + lookups++ + return captaintoken.Record{}, captaintoken.ErrUnknown + } + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, + map[string]string{"Authorization": "Bearer not-a-captain-token"}) + assert.Equal(t, http.StatusUnauthorized, recorder.Code) + assert.Zero(t, lookups) + }) +} + +// "I cannot tell" is not "you are not allowed". A 401 during a database outage +// sends an operator hunting a phantom auth bug instead of the downtime. +func TestAStoreOutageIsNotAnAuthenticationFailure(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + fixture.lookupFn = func(context.Context, string) (captaintoken.Record, error) { + return captaintoken.Record{}, errors.New("dial tcp 127.0.0.1:7432: connection refused") + } + + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, + map[string]string{"Authorization": "Bearer " + fixture.raw}) + + assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) + assert.NotContains(t, recorder.Body.String(), "connection refused", "an internal failure should not leak the DSN") + assert.False(t, fixture.reached) +} + +// The SPA has nowhere to put a bearer token, so the pages a browser loads stay +// open; only the two subtrees that act on the host are protected. +func TestOnlyTheCommandAndGitSubtreesAreProtected(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + for _, path := range []string{"/", "/health", "/assets/index.js", "/api/captain/prompt/runs", "/api/chat"} { + recorder := fixture.call(http.MethodGet, path, remoteAddr, nil) + assert.Equalf(t, http.StatusOK, recorder.Code, "%s should not require a token", path) + } + + for _, path := range []string{"/api/v1", "/api/v1/sessions", "/git/mailboxes/aaa.git/info/refs"} { + recorder := fixture.call(http.MethodGet, path, remoteAddr, nil) + assert.Equalf(t, http.StatusUnauthorized, recorder.Code, "%s must require a token", path) + } + + // A path that merely starts with the same letters is not the API subtree. + recorder := fixture.call(http.MethodGet, "/api/v1beta/sessions", remoteAddr, nil) + assert.Equal(t, http.StatusOK, recorder.Code) +} + +// Recording that a token was used is bookkeeping. Failing it would turn a +// working push into a 500 for no reason a caller could act on. +func TestATouchFailureDoesNotFailTheRequest(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + fixture.touchErr = errors.New("write conflict") + + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", remoteAddr, + map[string]string{"Authorization": "Bearer " + fixture.raw}) + + assert.Equal(t, http.StatusOK, recorder.Code) + assert.True(t, fixture.reached) +} + +// A chain wired without a verifier must refuse rather than pass an +// unauthenticated request through to the command executor. +func TestAMisconfiguredChainRefusesRatherThanOpensUp(t *testing.T) { + var reached bool + handler := TokenAuthMiddleware(TokenAuthConfig{})(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + reached = true + w.WriteHeader(http.StatusOK) + })) + + request := httptest.NewRequest(http.MethodPost, "/api/v1/sessions", nil) + request.RemoteAddr = remoteAddr + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + + assert.Equal(t, http.StatusServiceUnavailable, recorder.Code) + assert.False(t, reached) +} + +// An unparseable RemoteAddr must fail closed: the cost of failing closed is a +// token, the cost of failing open is command execution. +func TestAnUnparseableRemoteAddressIsTreatedAsRemote(t *testing.T) { + fixture := newAuthFixture(t, captaintoken.ScopeAPI) + + for _, addr := range []string{"", "not-an-address", "example.com:443"} { + recorder := fixture.call(http.MethodPost, "/api/v1/sessions", addr, nil) + assert.Equalf(t, http.StatusUnauthorized, recorder.Code, "RemoteAddr %q should not be trusted as local", addr) + } +} diff --git a/pkg/cli/serve_provider_tokens.go b/pkg/cli/serve_provider_tokens.go index d197f33a..846fd269 100644 --- a/pkg/cli/serve_provider_tokens.go +++ b/pkg/cli/serve_provider_tokens.go @@ -94,13 +94,17 @@ func handleProviderToken(testOnly bool) http.Handler { } func validateLocalConfigurationRequest(r *http.Request) error { + return validateLocalRequest(r, "configuration changes") +} + +func validateLocalRequest(r *http.Request, action string) error { host, _, err := net.SplitHostPort(r.RemoteAddr) if err != nil { host = r.RemoteAddr } ip := net.ParseIP(host) if ip == nil || !ip.IsLoopback() { - return fmt.Errorf("configuration changes are restricted to loopback clients") + return fmt.Errorf("%s are restricted to loopback clients", action) } requestHost := r.Host if parsed, _, err := net.SplitHostPort(requestHost); err == nil { @@ -108,7 +112,7 @@ func validateLocalConfigurationRequest(r *http.Request) error { } requestIP := net.ParseIP(requestHost) if !strings.EqualFold(requestHost, "localhost") && (requestIP == nil || !requestIP.IsLoopback()) { - return fmt.Errorf("configuration changes require a loopback request host") + return fmt.Errorf("%s require a loopback request host", action) } origin := strings.TrimSpace(r.Header.Get("Origin")) if origin == "" { @@ -116,7 +120,7 @@ func validateLocalConfigurationRequest(r *http.Request) error { } parsed, err := url.Parse(origin) if err != nil || !strings.EqualFold(parsed.Host, r.Host) { - return fmt.Errorf("configuration changes require a same-origin request") + return fmt.Errorf("%s require a same-origin request", action) } return nil } diff --git a/pkg/cli/token.go b/pkg/cli/token.go new file mode 100644 index 00000000..9d24e895 --- /dev/null +++ b/pkg/cli/token.go @@ -0,0 +1,242 @@ +// The captain token command group: mint, list and revoke the bearer +// credentials that let something off this box reach `captain serve`. +// +// The whole group is local-only. RegisterExecutionRoutes publishes cobra +// commands as REST under /api/v1, so a published `create` would let anyone who +// could already reach the API mint themselves a durable credential — the +// bootstrap hole the tokens exist to close. +package cli + +import ( + "context" + "fmt" + "strconv" + "strings" + "time" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/captain/pkg/database" + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" +) + +// TokenHelp documents the group, because which of bound/pool/scope to pick is +// the part that is not guessable from the flags. +func TokenHelp() api.Textable { + return clicky.Text("Captain API tokens", "font-bold text-blue-400").NewLine().NewLine(). + AddText("A token authenticates a caller that is not on this machine. Requests from", "text-gray-400").NewLine(). + AddText("127.0.0.1 need none, so the local webapp, CLI and hooks are unaffected.", "text-gray-400").NewLine().NewLine(). + AddText("Scopes:", "font-bold text-blue-400").NewLine(). + AddText(" git", "text-green-400"). + AddText(" — push to a served repository, and nothing else", "text-gray-500").NewLine(). + AddText(" api", "text-green-400"). + AddText(" — the HTTP API, which executes captain commands", "text-gray-500").NewLine().NewLine(). + AddText("A git token names who is pushing, in one of two ways:", "font-bold text-blue-400").NewLine(). + AddText(" captain token create worker-01", "text-green-400"). + AddText(" — bound to one agent", "text-gray-500").NewLine(). + AddText(" captain token create prod-pool --pool --max-agents 5", "text-green-400"). + AddText(" — one pool, many members", "text-gray-500").NewLine().NewLine(). + AddText("Pool members share a secret, so any member can act as any other; their ref", "text-gray-400").NewLine(). + AddText("namespaces are owned by the pool rather than the individual. Prefer a bound", "text-gray-400").NewLine(). + AddText("token unless one credential has to serve a scaled deployment.", "text-gray-400").NewLine().NewLine(). + AddText("Tokens are durable: valid until they expire or are revoked, so a restarting", "text-gray-400").NewLine(). + AddText("sidecar re-presents the same credential instead of needing a new one.", "text-gray-400").NewLine(). + AddText("The secret is shown once, at creation, and cannot be recovered afterwards.", "text-gray-400").NewLine() +} + +type TokenCreateOptions struct { + Name string `args:"true" help:"Name for the token; a pool derives its member names from it"` + Scope string `flag:"scope" help:"What the token may reach: git or api" default:"git"` + Agent string `flag:"agent" help:"Agent this token speaks for (defaults to the token name)"` + Pool bool `flag:"pool" help:"Serve many agents from one token, naming each member as it arrives"` + MaxAgents int `flag:"max-agents" help:"Cap a pool's members; 0 leaves it unbounded"` + Expires string `flag:"expires" help:"Lifetime, e.g. 90d or 720h; empty never expires"` +} + +// TokenCreateResult is the one and only sighting of the credential. +// +// Token is a plain string rather than a SensitiveString on purpose: a redacted +// field would defeat the command, which exists to reveal the secret exactly +// once. Nothing stored can reconstruct it, so a caller that loses it mints +// again. +type TokenCreateResult struct { + TokenID string `json:"tokenId" pretty:"label=Token ID"` + Name string `json:"name" pretty:"label=Name"` + Scope string `json:"scope" pretty:"label=Scope"` + Agent string `json:"agent,omitempty" pretty:"label=Agent"` + Pool bool `json:"pool,omitempty" pretty:"label=Pool"` + MaxAgents int `json:"maxAgents,omitempty" pretty:"label=Max agents"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" pretty:"label=Expires"` + Token string `json:"token" pretty:"label=Token"` +} + +func RunTokenCreate(ctx context.Context, opts TokenCreateOptions) (any, error) { + scope, err := captaintoken.ParseScope(opts.Scope) + if err != nil { + return nil, err + } + expiresAt, err := parseTokenLifetime(opts.Expires) + if err != nil { + return nil, err + } + input := database.CreateAPITokenInput{ + Name: strings.TrimSpace(opts.Name), Scope: scope, Agent: strings.TrimSpace(opts.Agent), + Pool: opts.Pool, MaxAgents: opts.MaxAgents, ExpiresAt: expiresAt, + } + // The common case is one token per agent, named after it. A pool names its + // members as they arrive, and an api token speaks for no agent at all. + if input.Agent == "" && scope == captaintoken.ScopeGit && !opts.Pool { + input.Agent = input.Name + } + db, err := captainServeDB(ctx) + if err != nil { + return nil, err + } + token, secret, err := db.CreateAPIToken(ctx, input) + if err != nil { + return nil, err + } + return TokenCreateResult{ + TokenID: token.TokenID, Name: token.Name, Scope: string(token.Scope), + Agent: token.Agent, Pool: token.Pool, MaxAgents: token.MaxAgents, + ExpiresAt: token.ExpiresAt, Token: secret.Value(), + }, nil +} + +// parseTokenLifetime accepts a Go duration or a day count, because a credential +// lifetime is naturally expressed in days and time.ParseDuration stops at hours. +func parseTokenLifetime(value string) (*time.Time, error) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return nil, nil + } + lifetime, err := parseLifetimeDuration(trimmed) + if err != nil { + return nil, fmt.Errorf("lifetime %q: expected a day count like 90d, or a duration like 720h", value) + } + if lifetime <= 0 { + return nil, fmt.Errorf("lifetime %q must be positive; omit --expires for a token that never expires", value) + } + expiresAt := time.Now().UTC().Add(lifetime) + return &expiresAt, nil +} + +func parseLifetimeDuration(value string) (time.Duration, error) { + if days, ok := strings.CutSuffix(value, "d"); ok { + count, err := strconv.Atoi(days) + if err != nil { + return 0, err + } + return time.Duration(count) * 24 * time.Hour, nil + } + return time.ParseDuration(value) +} + +type TokenListOptions struct { + Scope string `flag:"scope" help:"Only tokens with this scope: git or api"` + Agent string `flag:"agent" help:"Only tokens that speak for this agent, bound or pooled"` + Revoked bool `flag:"revoked" help:"Include revoked tokens"` + Limit int `flag:"limit" help:"Maximum tokens to list" default:"100"` +} + +type TokenListEntry struct { + TokenID string `json:"tokenId" pretty:"label=Token ID"` + Name string `json:"name" pretty:"label=Name"` + Scope string `json:"scope" pretty:"label=Scope"` + // Agent names a bound token's identity, or a pool's members joined, so one + // column answers "who can push as this?" for both shapes. + Agent string `json:"agent,omitempty" pretty:"label=Agent"` + Status string `json:"status" pretty:"label=Status"` + ExpiresAt *time.Time `json:"expiresAt,omitempty" pretty:"label=Expires"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty" pretty:"label=Last used"` + CreatedAt time.Time `json:"createdAt" pretty:"label=Created"` +} + +// RunTokenList always returns a slice — an empty result renders as [] in JSON +// rather than null, so a consumer can iterate it unconditionally. +func RunTokenList(ctx context.Context, opts TokenListOptions) (any, error) { + filter := database.ListAPITokensFilter{ + Agent: opts.Agent, IncludeRevoked: opts.Revoked, Limit: opts.Limit, + } + if strings.TrimSpace(opts.Scope) != "" { + scope, err := captaintoken.ParseScope(opts.Scope) + if err != nil { + return nil, err + } + filter.Scope = scope + } + db, err := captainServeDB(ctx) + if err != nil { + return nil, err + } + tokens, err := db.ListAPITokens(ctx, filter) + if err != nil { + return nil, err + } + entries := make([]TokenListEntry, 0, len(tokens)) + for _, token := range tokens { + entries = append(entries, TokenListEntry{ + TokenID: token.TokenID, Name: token.Name, Scope: string(token.Scope), + Agent: tokenAgentColumn(token), Status: tokenStatus(token), + ExpiresAt: token.ExpiresAt, LastUsedAt: token.LastUsedAt, CreatedAt: token.CreatedAt, + }) + } + return entries, nil +} + +func tokenAgentColumn(token database.APIToken) string { + if !token.Pool { + return token.Agent + } + if len(token.PoolAgents) == 0 { + return "(pool, no members yet)" + } + return strings.Join(token.PoolAgents, ", ") +} + +// tokenStatus reports why a token cannot be used, so a listing distinguishes a +// credential that lapsed from one that was deliberately retired. +func tokenStatus(token database.APIToken) string { + switch { + case token.RevokedAt != nil && token.RevocationReason != "": + return "revoked: " + token.RevocationReason + case token.RevokedAt != nil: + return "revoked" + case token.ExpiresAt != nil && !time.Now().Before(*token.ExpiresAt): + return "expired" + default: + return "active" + } +} + +type TokenRevokeOptions struct { + TokenID string `args:"true" help:"Token ID to revoke, as shown by captain token list"` + Reason string `flag:"reason" help:"Why it was revoked, recorded alongside the token"` +} + +type TokenRevokeResult struct { + TokenID string `json:"tokenId" pretty:"label=Token ID"` + Name string `json:"name" pretty:"label=Name"` + Revoked bool `json:"revoked" pretty:"label=Revoked"` + Reason string `json:"reason,omitempty" pretty:"label=Reason"` +} + +func RunTokenRevoke(ctx context.Context, opts TokenRevokeOptions) (any, error) { + db, err := captainServeDB(ctx) + if err != nil { + return nil, err + } + if err := db.RevokeAPIToken(ctx, opts.TokenID, opts.Reason); err != nil { + return nil, err + } + // Effective for requests that arrive after now: the verifier re-reads the + // row on every presentation, and caches only the KDF result (R8.5). + token, err := db.GetAPIToken(ctx, opts.TokenID) + if err != nil { + return nil, err + } + return TokenRevokeResult{ + TokenID: token.TokenID, Name: token.Name, + Revoked: true, Reason: token.RevocationReason, + }, nil +} diff --git a/pkg/cli/token_test.go b/pkg/cli/token_test.go new file mode 100644 index 00000000..d3d485a0 --- /dev/null +++ b/pkg/cli/token_test.go @@ -0,0 +1,100 @@ +package cli + +import ( + "testing" + "time" + + "github.com/flanksource/captain/pkg/database" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// A credential lifetime is naturally expressed in days, which time.ParseDuration +// does not accept — so the day form has to work, and the hour form has to keep +// working alongside it. +func TestParseTokenLifetime(t *testing.T) { + tests := []struct { + value string + want time.Duration + }{ + {"90d", 90 * 24 * time.Hour}, + {"1d", 24 * time.Hour}, + {"720h", 720 * time.Hour}, + {"30m", 30 * time.Minute}, + {" 7d ", 7 * 24 * time.Hour}, + } + for _, tt := range tests { + t.Run(tt.value, func(t *testing.T) { + before := time.Now() + expiresAt, err := parseTokenLifetime(tt.value) + require.NoError(t, err) + require.NotNil(t, expiresAt) + // Compare against an independently computed instant, allowing for the + // clock advancing between the two Now() reads. + assert.WithinDuration(t, before.Add(tt.want), *expiresAt, time.Second) + }) + } + + t.Run("an empty lifetime means the token never expires", func(t *testing.T) { + expiresAt, err := parseTokenLifetime(" ") + require.NoError(t, err) + assert.Nil(t, expiresAt) + }) + + // A lifetime that is already spent would create a token that can never be + // used; refusing it names the mistake instead of storing it. + for _, value := range []string{"0d", "-1d", "0h", "-720h"} { + t.Run("refuses "+value, func(t *testing.T) { + _, err := parseTokenLifetime(value) + require.Error(t, err) + assert.Contains(t, err.Error(), "must be positive") + }) + } + + for _, value := range []string{"ninety", "90days", "d", "90 d", "90y"} { + t.Run("refuses "+value, func(t *testing.T) { + _, err := parseTokenLifetime(value) + require.Error(t, err) + assert.Contains(t, err.Error(), "90d") + }) + } +} + +// A listing has to distinguish a credential that lapsed from one that was +// deliberately retired: they call for different actions. +func TestTokenStatus(t *testing.T) { + past, future := time.Now().Add(-time.Hour), time.Now().Add(time.Hour) + + tests := []struct { + name string + token database.APIToken + want string + }{ + {"no expiry or revocation", database.APIToken{}, "active"}, + {"expiring later", database.APIToken{ExpiresAt: &future}, "active"}, + {"expired", database.APIToken{ExpiresAt: &past}, "expired"}, + {"revoked without a reason", database.APIToken{RevokedAt: &past}, "revoked"}, + {"revoked with a reason", database.APIToken{ + RevokedAt: &past, RevocationReason: "agent decommissioned", + }, "revoked: agent decommissioned"}, + {"revocation outranks expiry", database.APIToken{ + RevokedAt: &past, ExpiresAt: &past, + }, "revoked"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, tokenStatus(tt.token)) + }) + } +} + +// One column answers "who can push as this?" for both token shapes, so a pool +// with no members yet has to say so rather than render as an empty cell that +// reads like a bound token missing its agent. +func TestTokenAgentColumn(t *testing.T) { + assert.Equal(t, "worker-01", tokenAgentColumn(database.APIToken{Agent: "worker-01"})) + assert.Equal(t, "(pool, no members yet)", tokenAgentColumn(database.APIToken{Pool: true})) + assert.Equal(t, "prod-pool-01, prod-pool-02", tokenAgentColumn(database.APIToken{ + Pool: true, PoolAgents: []string{"prod-pool-01", "prod-pool-02"}, + })) +} diff --git a/pkg/database/api_token_store.go b/pkg/database/api_token_store.go new file mode 100644 index 00000000..c2a94ef7 --- /dev/null +++ b/pkg/database/api_token_store.go @@ -0,0 +1,412 @@ +// Storage for the bearer credentials that reach this captain over the network. +// +// A token here is durable rather than single-use: it stays valid until it +// expires or is revoked, which is what lets a restarting or rescheduled sidecar +// re-present the same credential instead of crash-looping on a spent one. Only +// the argon2id hash is stored, and it never leaves this package except through +// LookupAPIToken, whose result feeds captaintoken's constant-time verifier. + +package database + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "slices" + "strings" + "time" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/clicky/text" + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +var ( + ErrAPITokenInvalid = errors.New("invalid captain API token") + ErrAPITokenNotFound = errors.New("captain API token not found") + // ErrAPITokenPoolFull means the credential is good but its pool has no slot + // left. It is separated from a rejection so an operator sees a capacity + // problem rather than hunting a phantom auth failure. + ErrAPITokenPoolFull = errors.New("captain API token pool is full") +) + +const ( + // maxPoolTokenNameLen leaves room for the "-999" a derived member name adds + // without pushing the result past the 64-character ref-segment bound. + maxPoolTokenNameLen = 60 + // maxPoolAgents bounds the derived ordinals, matching that suffix width. + maxPoolAgents = 999 + // touchInterval throttles last_used_at. Git smart-HTTP makes several + // requests per push, so writing on each one would amplify a single push into + // a burst of row updates that carry no extra information. + touchInterval = time.Minute +) + +type apiTokenRecord struct { + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + TokenID string `gorm:"column:token_id"` + SecretHash string `gorm:"column:secret_hash"` + Name string `gorm:"column:name"` + Scope captaintoken.Scope `gorm:"column:scope"` + Agent *string `gorm:"column:agent"` + Pool bool `gorm:"column:pool"` + PoolAgents []string `gorm:"column:pool_agents;serializer:json;type:jsonb"` + MaxAgents *int `gorm:"column:max_agents"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + RevokedAt *time.Time `gorm:"column:revoked_at"` + RevocationReason *string `gorm:"column:revocation_reason"` + LastUsedAt *time.Time `gorm:"column:last_used_at"` + CreatedAt time.Time `gorm:"column:created_at"` +} + +func (apiTokenRecord) TableName() string { return "captain_api_tokens" } + +// APIToken is a token as listings and the CLI see it. It deliberately has no +// hash field: the stored secret cannot leak through a struct that never carries +// it, which is a stronger guarantee than a `json:"-"` tag someone can drop. +type APIToken struct { + ID uuid.UUID `json:"id"` + TokenID string `json:"tokenId"` + Name string `json:"name"` + Scope captaintoken.Scope `json:"scope"` + Agent string `json:"agent,omitempty"` + Pool bool `json:"pool"` + PoolAgents []string `json:"poolAgents,omitempty"` + MaxAgents int `json:"maxAgents,omitempty"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` + RevocationReason string `json:"revocationReason,omitempty"` + LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` + CreatedAt time.Time `json:"createdAt"` +} + +// CreateAPITokenInput describes the credential to mint. Pool and Agent are the +// two ways of answering "who is this?" and exactly one applies. +type CreateAPITokenInput struct { + Name string + Scope captaintoken.Scope + Agent string + Pool bool + MaxAgents int + ExpiresAt *time.Time +} + +// CreateAPIToken mints a token and stores only its hash. +// +// The returned SensitiveString is the sole existence of the plaintext: nothing +// stored can reconstruct it, so a caller that discards it has to mint again. +func (db *DB) CreateAPIToken( + ctx context.Context, + input CreateAPITokenInput, +) (*APIToken, text.SensitiveString, error) { + if err := db.requireGorm(); err != nil { + return nil, "", err + } + input.Name = strings.TrimSpace(input.Name) + input.Agent = strings.TrimSpace(input.Agent) + if err := validateAPITokenInput(input); err != nil { + return nil, "", err + } + minted, err := captaintoken.Mint() + if err != nil { + return nil, "", err + } + record := apiTokenRecord{ + ID: uuid.New(), TokenID: minted.ID, SecretHash: minted.Hash, + Name: input.Name, Scope: input.Scope, Agent: nullableTrimmed(input.Agent), + Pool: input.Pool, PoolAgents: []string{}, ExpiresAt: input.ExpiresAt, + } + if input.MaxAgents > 0 { + record.MaxAgents = &input.MaxAgents + } + if err := db.gorm.WithContext(ctx).Create(&record).Error; err != nil { + return nil, "", fmt.Errorf("create captain API token: %w", err) + } + token := record.toAPIToken() + return &token, minted.Secret, nil +} + +func validateAPITokenInput(input CreateAPITokenInput) error { + if err := captaintoken.ValidateName(input.Name); err != nil { + return fmt.Errorf("%w: token %s", ErrAPITokenInvalid, err) + } + if !input.Scope.Valid() { + return fmt.Errorf("%w: scope %q must be %s or %s", + ErrAPITokenInvalid, input.Scope, captaintoken.ScopeGit, captaintoken.ScopeAPI) + } + if err := validateAPITokenIdentity(input); err != nil { + return err + } + if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { + return fmt.Errorf("%w: expiry must be in the future", ErrAPITokenInvalid) + } + return nil +} + +// validateAPITokenIdentity mirrors the captain_api_tokens_identity check so an +// impossible combination is named in prose rather than surfacing as a constraint +// violation an operator has to decode. +func validateAPITokenIdentity(input CreateAPITokenInput) error { + if input.Scope == captaintoken.ScopeAPI { + if input.Pool || input.Agent != "" { + return fmt.Errorf("%w: an %s-scoped token is neither pooled nor bound to an agent", + ErrAPITokenInvalid, captaintoken.ScopeAPI) + } + return nil + } + if !input.Pool { + if input.MaxAgents != 0 { + return fmt.Errorf("%w: max-agents applies to a pool token; this one is bound to a single agent", ErrAPITokenInvalid) + } + if err := captaintoken.ValidateName(input.Agent); err != nil { + return fmt.Errorf("%w: a %s-scoped token must name the agent it belongs to, or be created as a pool: %s", + ErrAPITokenInvalid, captaintoken.ScopeGit, err) + } + return nil + } + if input.Agent != "" { + return fmt.Errorf("%w: a pool token names its members as they arrive; it cannot also be bound to agent %q", + ErrAPITokenInvalid, input.Agent) + } + if len(input.Name) > maxPoolTokenNameLen { + return fmt.Errorf("%w: a pool name is at most %d characters, so a derived member name still fits a ref segment", + ErrAPITokenInvalid, maxPoolTokenNameLen) + } + if input.MaxAgents < 0 || input.MaxAgents > maxPoolAgents { + return fmt.Errorf("%w: max-agents must be between 1 and %d, or 0 for unbounded", ErrAPITokenInvalid, maxPoolAgents) + } + return nil +} + +// LookupAPIToken resolves a token by its public id. The signature matches +// captaintoken.Lookup so it can be handed to a verifier directly. +// +// An absent row is ErrUnknown, the same answer a wrong secret gets: telling a +// caller which half they got right is a probing aid. +func (db *DB) LookupAPIToken(ctx context.Context, tokenID string) (captaintoken.Record, error) { + if err := db.requireGorm(); err != nil { + return captaintoken.Record{}, err + } + var record apiTokenRecord + if err := db.gorm.WithContext(ctx).First(&record, "token_id = ?", tokenID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return captaintoken.Record{}, captaintoken.ErrUnknown + } + return captaintoken.Record{}, fmt.Errorf("look up captain API token: %w", err) + } + return record.toVerifierRecord(), nil +} + +// GetAPIToken reads one token for display. ok is false when there is no such id. +func (db *DB) GetAPIToken(ctx context.Context, tokenID string) (*APIToken, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + var record apiTokenRecord + if err := db.gorm.WithContext(ctx).First(&record, "token_id = ?", tokenID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, fmt.Errorf("%w: %s", ErrAPITokenNotFound, tokenID) + } + return nil, fmt.Errorf("get captain API token: %w", err) + } + token := record.toAPIToken() + return &token, nil +} + +// AdmitAPITokenAgent resolves a verified credential to the agent identity it +// speaks for, allocating a pool slot when it needs one. +// +// requestedName is what a returning member persisted from an earlier admission. +// It is honoured only when it is already on file: names are derived by the +// supervisor, so a client can neither invent an identity outside the pool's +// naming nor squat on a sibling's ref namespace. That is also what makes a +// restart free — a returning member reclaims its name instead of burning a slot. +func (db *DB) AdmitAPITokenAgent(ctx context.Context, tokenID, requestedName string) (string, error) { + if err := db.requireGorm(); err != nil { + return "", err + } + requestedName = strings.TrimSpace(requestedName) + var admitted string + err := db.gorm.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // FOR UPDATE, because two sidecars starting together would otherwise + // each read the same member list and the second write would silently + // erase the first — overrunning max_agents by exactly the race width. + var record apiTokenRecord + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}). + First(&record, "token_id = ?", tokenID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return captaintoken.ErrUnknown + } + return fmt.Errorf("read captain API token: %w", err) + } + if err := record.toVerifierRecord().Active(time.Now()); err != nil { + return err + } + name, err := admitAgainst(&record, requestedName) + if err != nil { + return err + } + admitted = name + if slices.Contains(record.PoolAgents, name) { + return nil + } + return persistPoolMember(tx, record, name) + }) + if err != nil { + return "", err + } + return admitted, nil +} + +// admitAgainst decides which name a presented token speaks for, without writing. +func admitAgainst(record *apiTokenRecord, requestedName string) (string, error) { + if !record.Pool { + bound := derefString(record.Agent) + if requestedName != "" && requestedName != bound { + return "", fmt.Errorf("%w: token %q is bound to agent %q and cannot act as %q", + ErrAPITokenInvalid, record.Name, bound, requestedName) + } + return bound, nil + } + if requestedName != "" && slices.Contains(record.PoolAgents, requestedName) { + return requestedName, nil + } + if record.MaxAgents != nil && len(record.PoolAgents) >= *record.MaxAgents { + return "", fmt.Errorf("%w: %q already has its %d members; revoke one or raise max-agents", + ErrAPITokenPoolFull, record.Name, *record.MaxAgents) + } + return nextPoolAgentName(record.Name, record.PoolAgents) +} + +// nextPoolAgentName derives the lowest unused member name under a pool. +func nextPoolAgentName(pool string, taken []string) (string, error) { + for ordinal := 1; ordinal <= maxPoolAgents; ordinal++ { + if candidate := fmt.Sprintf("%s-%02d", pool, ordinal); !slices.Contains(taken, candidate) { + return candidate, nil + } + } + return "", fmt.Errorf("%w: %q has exhausted its %d derivable member names", ErrAPITokenPoolFull, pool, maxPoolAgents) +} + +// persistPoolMember appends a newly derived name. The value is cast explicitly +// because gorm applies a field serializer to a model write, not to the column +// update this needs. +func persistPoolMember(tx *gorm.DB, record apiTokenRecord, name string) error { + encoded, err := json.Marshal(append(slices.Clone(record.PoolAgents), name)) + if err != nil { + return fmt.Errorf("encode captain API token pool members: %w", err) + } + err = tx.Model(&apiTokenRecord{}).Where("id = ?", record.ID). + Update("pool_agents", gorm.Expr("?::jsonb", string(encoded))).Error + if err != nil { + return fmt.Errorf("admit %q to captain API token pool %q: %w", name, record.Name, err) + } + return nil +} + +// TouchAPIToken records that a token was used, at most once per touchInterval. +func (db *DB) TouchAPIToken(ctx context.Context, tokenID string) error { + if err := db.requireGorm(); err != nil { + return err + } + err := db.gorm.WithContext(ctx).Model(&apiTokenRecord{}). + Where("token_id = ? AND (last_used_at IS NULL OR last_used_at < ?)", tokenID, time.Now().Add(-touchInterval)). + Update("last_used_at", clause.Expr{SQL: "now()"}).Error + if err != nil { + return fmt.Errorf("record captain API token use: %w", err) + } + return nil +} + +// ListAPITokensFilter narrows a listing. Revoked tokens are hidden by default: +// the list answers "what can reach this captain right now?". +type ListAPITokensFilter struct { + Scope captaintoken.Scope + Agent string + IncludeRevoked bool + Limit int +} + +// ListAPITokens returns tokens newest first, always as a slice so a caller can +// iterate it unconditionally. +func (db *DB) ListAPITokens(ctx context.Context, filter ListAPITokensFilter) ([]APIToken, error) { + if err := db.requireGorm(); err != nil { + return nil, err + } + query := db.gorm.WithContext(ctx).Model(&apiTokenRecord{}) + if filter.Scope != "" { + query = query.Where("scope = ?", filter.Scope) + } + if agent := nullableTrimmed(filter.Agent); agent != nil { + // A pool member is named in pool_agents, not in the bound agent column, + // so a search by agent has to look in both or it silently misses pools. + query = query.Where("agent = ? OR pool_agents @> ?::jsonb", *agent, `["`+*agent+`"]`) + } + if !filter.IncludeRevoked { + query = query.Where("revoked_at IS NULL") + } + limit := filter.Limit + if limit <= 0 || limit > 500 { + limit = 100 + } + var records []apiTokenRecord + if err := query.Order("created_at DESC, id DESC").Limit(limit).Find(&records).Error; err != nil { + return nil, fmt.Errorf("list captain API tokens: %w", err) + } + tokens := make([]APIToken, 0, len(records)) + for _, record := range records { + tokens = append(tokens, record.toAPIToken()) + } + return tokens, nil +} + +// RevokeAPIToken retires a token. Revoking an already-revoked token succeeds: +// the caller's intent is satisfied, and an operator racing a script should not +// have to distinguish the two. +func (db *DB) RevokeAPIToken(ctx context.Context, tokenID, reason string) error { + if err := db.requireGorm(); err != nil { + return err + } + result := db.gorm.WithContext(ctx).Model(&apiTokenRecord{}). + Where("token_id = ? AND revoked_at IS NULL", tokenID). + Updates(map[string]any{"revoked_at": time.Now().UTC(), "revocation_reason": nullableTrimmed(reason)}) + if result.Error != nil { + return fmt.Errorf("revoke captain API token: %w", result.Error) + } + if result.RowsAffected == 1 { + return nil + } + _, err := db.GetAPIToken(ctx, tokenID) + return err +} + +// toVerifierRecord hands captaintoken exactly what it needs to check a +// credential, and nothing that would tempt a caller to compare secrets itself. +func (r apiTokenRecord) toVerifierRecord() captaintoken.Record { + record := captaintoken.Record{ + ID: r.TokenID, SecretHash: r.SecretHash, Name: r.Name, Scope: r.Scope, + Agent: derefString(r.Agent), Pool: r.Pool, PoolAgents: slices.Clone(r.PoolAgents), + ExpiresAt: r.ExpiresAt, RevokedAt: r.RevokedAt, + } + if r.MaxAgents != nil { + record.MaxAgents = *r.MaxAgents + } + return record +} + +func (r apiTokenRecord) toAPIToken() APIToken { + token := APIToken{ + ID: r.ID, TokenID: r.TokenID, Name: r.Name, Scope: r.Scope, + Agent: derefString(r.Agent), Pool: r.Pool, PoolAgents: slices.Clone(r.PoolAgents), + ExpiresAt: r.ExpiresAt, RevokedAt: r.RevokedAt, + RevocationReason: optionalString(r.RevocationReason), + LastUsedAt: r.LastUsedAt, CreatedAt: r.CreatedAt, + } + if r.MaxAgents != nil { + token.MaxAgents = *r.MaxAgents + } + return token +} diff --git a/pkg/database/api_token_store_integration_test.go b/pkg/database/api_token_store_integration_test.go new file mode 100644 index 00000000..cc5c92a9 --- /dev/null +++ b/pkg/database/api_token_store_integration_test.go @@ -0,0 +1,365 @@ +package database + +import ( + "strings" + "testing" + "time" + + "github.com/flanksource/captain/pkg/captaintoken" + "github.com/flanksource/commons-db/dbtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func openTokenDB(t *testing.T, name string) *DB { + t.Helper() + handle := dbtest.ForT(t, dbtest.Options{Name: name}) + db, err := Open(t.Context(), WithDSN(handle.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + return db +} + +// secretHalf is the part of a credential that must never be recoverable from +// storage. Parse keeps it unexported, so a caller outside the package splits it +// the same way the wire format documents. +func secretHalf(t *testing.T, raw string) string { + t.Helper() + _, secret, ok := strings.Cut(strings.TrimPrefix(raw, captaintoken.Prefix+"_"), ".") + require.True(t, ok, "credential %q is not in cptn_. form", raw) + return secret +} + +// The stored row is what an attacker gets from a database dump. Rendering the +// whole row as text catches the secret landing in any column, not just the one +// this test happened to think of. +func TestAPITokenSecretNeverReachesTheDatabase(t *testing.T) { + db := openTokenDB(t, "captain_api_token_secrecy") + + token, raw, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", + }) + require.NoError(t, err) + + var dump string + require.NoError(t, db.Gorm().WithContext(t.Context()). + Raw("SELECT captain_api_tokens::text FROM captain_api_tokens WHERE token_id = ?", token.TokenID). + Scan(&dump).Error) + + require.NotEmpty(t, dump) + assert.NotContains(t, dump, raw.Value(), "the whole credential is stored verbatim") + assert.NotContains(t, dump, secretHalf(t, raw.Value()), "the secret half is stored verbatim") + assert.Contains(t, dump, token.TokenID, "the public id is stored, and is how a lookup finds the row") + assert.Contains(t, dump, "argon2id", "the stored hash should be the argon2id encoding") +} + +// The store and the verifier are two halves of one mechanism; this exercises +// them together against real Postgres rather than a stub lookup. +func TestAPITokenVerifiesThroughTheStoreAndStaysReusable(t *testing.T) { + db := openTokenDB(t, "captain_api_token_verify") + verifier := captaintoken.NewVerifier(db.LookupAPIToken) + + _, raw, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", + }) + require.NoError(t, err) + + // Durability is the whole point of replacing the single-use join token: a + // sidecar that restarts re-presents the same credential. + for attempt := 1; attempt <= 3; attempt++ { + record, err := verifier.Verify(t.Context(), raw.Value()) + require.NoErrorf(t, err, "presentation %d", attempt) + assert.Equal(t, "worker-01", record.Agent) + } + + // A git-scoped agent token must not reach the API, which executes commands. + _, err = verifier.VerifyScope(t.Context(), raw.Value(), captaintoken.ScopeAPI) + assert.ErrorIs(t, err, captaintoken.ErrScope) + + _, err = verifier.Verify(t.Context(), captaintoken.Prefix+"_nosuchid.secret") + assert.ErrorIs(t, err, captaintoken.ErrUnknown, "an unknown id must not be distinguishable from a wrong secret") +} + +// Revocation has to bite on the very next request. The verifier caches a +// successful KDF for 30s, so a cache that also cached liveness would leave a +// revoked credential working for that window. +func TestRevokedAPITokenFailsOnTheNextRequestDespiteTheVerifierCache(t *testing.T) { + db := openTokenDB(t, "captain_api_token_revoke") + verifier := captaintoken.NewVerifier(db.LookupAPIToken) + + token, raw, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", + }) + require.NoError(t, err) + + _, err = verifier.Verify(t.Context(), raw.Value()) + require.NoError(t, err, "the token should work before it is revoked, so the cache is warm") + + require.NoError(t, db.RevokeAPIToken(t.Context(), token.TokenID, "agent decommissioned")) + + _, err = verifier.Verify(t.Context(), raw.Value()) + assert.ErrorIs(t, err, captaintoken.ErrRevoked) + + // Revoking twice satisfies the caller's intent rather than erroring on a race. + require.NoError(t, db.RevokeAPIToken(t.Context(), token.TokenID, "again")) + + stored, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + require.NotNil(t, stored.RevokedAt) + assert.Equal(t, "agent decommissioned", stored.RevocationReason, "the first reason stands") + + err = db.RevokeAPIToken(t.Context(), "nosuchid", "") + assert.ErrorIs(t, err, ErrAPITokenNotFound) +} + +func TestExpiredAPITokenIsRejected(t *testing.T) { + db := openTokenDB(t, "captain_api_token_expiry") + verifier := captaintoken.NewVerifier(db.LookupAPIToken) + + expiresAt := time.Now().Add(time.Hour) + token, raw, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", ExpiresAt: &expiresAt, + }) + require.NoError(t, err) + _, err = verifier.Verify(t.Context(), raw.Value()) + require.NoError(t, err) + + // Age the whole row rather than sleeping through the hour. created_at moves + // with it because the table refuses an expiry that precedes creation. + require.NoError(t, db.Gorm().WithContext(t.Context()).Exec(` + UPDATE captain_api_tokens + SET created_at = now() - interval '2 hours', expires_at = now() - interval '1 hour' + WHERE token_id = ?`, token.TokenID).Error) + + _, err = verifier.Verify(t.Context(), raw.Value()) + assert.ErrorIs(t, err, captaintoken.ErrExpired) + + past := time.Now().Add(-time.Minute) + _, _, err = db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-02", Scope: captaintoken.ScopeGit, Agent: "worker-02", ExpiresAt: &past, + }) + assert.ErrorIs(t, err, ErrAPITokenInvalid, "a token that is born expired is a mistake, not a valid state") +} + +// One credential serving a scaled Deployment is the reason pool tokens exist. +// The supervisor derives every member name, so a client cannot invent one. +func TestPoolTokenAdmitsMembersAndKeepsTheirNamesAcrossRestarts(t *testing.T) { + db := openTokenDB(t, "captain_api_token_pool") + + token, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "prod-pool", Scope: captaintoken.ScopeGit, Pool: true, MaxAgents: 2, + }) + require.NoError(t, err) + + first, err := db.AdmitAPITokenAgent(t.Context(), token.TokenID, "") + require.NoError(t, err) + assert.Equal(t, "prod-pool-01", first) + + second, err := db.AdmitAPITokenAgent(t.Context(), token.TokenID, "") + require.NoError(t, err) + assert.Equal(t, "prod-pool-02", second) + + // A restart re-presents the name the member persisted, and must not consume + // a third slot — otherwise a rescheduled pod would exhaust the pool. + returning, err := db.AdmitAPITokenAgent(t.Context(), token.TokenID, first) + require.NoError(t, err) + assert.Equal(t, first, returning) + + _, err = db.AdmitAPITokenAgent(t.Context(), token.TokenID, "") + assert.ErrorIs(t, err, ErrAPITokenPoolFull) + + // A name the supervisor never issued is not admitted under it; the pool is + // full, so the attempt is refused rather than quietly granted. + _, err = db.AdmitAPITokenAgent(t.Context(), token.TokenID, "attacker-chosen") + assert.ErrorIs(t, err, ErrAPITokenPoolFull) + + stored, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + assert.Equal(t, []string{"prod-pool-01", "prod-pool-02"}, stored.PoolAgents) +} + +func TestBoundAPITokenAdmitsOnlyItsOwnAgent(t *testing.T) { + db := openTokenDB(t, "captain_api_token_bound") + + token, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", + }) + require.NoError(t, err) + + name, err := db.AdmitAPITokenAgent(t.Context(), token.TokenID, "") + require.NoError(t, err) + assert.Equal(t, "worker-01", name) + + name, err = db.AdmitAPITokenAgent(t.Context(), token.TokenID, "worker-01") + require.NoError(t, err) + assert.Equal(t, "worker-01", name) + + _, err = db.AdmitAPITokenAgent(t.Context(), token.TokenID, "worker-02") + assert.ErrorIs(t, err, ErrAPITokenInvalid, "a bound token must not be able to act as another agent") + + _, err = db.AdmitAPITokenAgent(t.Context(), "nosuchid", "") + assert.ErrorIs(t, err, captaintoken.ErrUnknown) +} + +// An unbounded pool still has to stop somewhere, and the derived names have to +// stay inside the 64-character ref segment R8.3 splits on. +func TestUnboundedPoolDerivesNamesThatRemainValidRefSegments(t *testing.T) { + db := openTokenDB(t, "captain_api_token_unbounded") + + token, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "prod-pool", Scope: captaintoken.ScopeGit, Pool: true, + }) + require.NoError(t, err) + + for range 3 { + name, err := db.AdmitAPITokenAgent(t.Context(), token.TokenID, "") + require.NoError(t, err) + require.NoError(t, captaintoken.ValidateName(name)) + } + + stored, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + assert.Equal(t, 0, stored.MaxAgents, "an unbounded pool records no cap") + assert.Len(t, stored.PoolAgents, 3) +} + +// The identity combinations the table's CHECK enforces are validated in prose +// first, so an operator sees the reason rather than a constraint violation. +func TestCreateAPITokenRefusesImpossibleIdentities(t *testing.T) { + db := openTokenDB(t, "captain_api_token_identity") + + tests := []struct { + name string + input CreateAPITokenInput + want string + }{ + {"an api token bound to an agent", CreateAPITokenInput{ + Name: "ci", Scope: captaintoken.ScopeAPI, Agent: "ci", + }, "neither pooled nor bound"}, + {"a pooled api token", CreateAPITokenInput{ + Name: "ci", Scope: captaintoken.ScopeAPI, Pool: true, + }, "neither pooled nor bound"}, + {"a git token naming no agent", CreateAPITokenInput{ + Name: "worker", Scope: captaintoken.ScopeGit, + }, "must name the agent"}, + {"a pool token also bound to an agent", CreateAPITokenInput{ + Name: "prod-pool", Scope: captaintoken.ScopeGit, Pool: true, Agent: "worker-01", + }, "names its members as they arrive"}, + {"max-agents on a bound token", CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", MaxAgents: 4, + }, "max-agents applies to a pool token"}, + {"a name that cannot be a ref segment", CreateAPITokenInput{ + Name: "Prod Pool", Scope: captaintoken.ScopeGit, Pool: true, + }, "token name"}, + {"a pool name too long to leave room for a member suffix", CreateAPITokenInput{ + Name: strings.Repeat("a", maxPoolTokenNameLen+1), Scope: captaintoken.ScopeGit, Pool: true, + }, "at most 60 characters"}, + {"an unknown scope", CreateAPITokenInput{ + Name: "worker-01", Scope: "admin", Agent: "worker-01", + }, "must be git or api"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, err := db.CreateAPIToken(t.Context(), tt.input) + require.ErrorIs(t, err, ErrAPITokenInvalid) + assert.Contains(t, err.Error(), tt.want) + }) + } + + // An api-scoped token needs no agent at all, which is the one combination + // the git scope forbids. + _, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{Name: "ci", Scope: captaintoken.ScopeAPI}) + require.NoError(t, err) +} + +func TestListAPITokensFiltersIncludingPoolMembership(t *testing.T) { + db := openTokenDB(t, "captain_api_token_list") + + bound, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", + }) + require.NoError(t, err) + pool, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "prod-pool", Scope: captaintoken.ScopeGit, Pool: true, + }) + require.NoError(t, err) + _, _, err = db.CreateAPIToken(t.Context(), CreateAPITokenInput{Name: "ci", Scope: captaintoken.ScopeAPI}) + require.NoError(t, err) + + member, err := db.AdmitAPITokenAgent(t.Context(), pool.TokenID, "") + require.NoError(t, err) + + all, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{}) + require.NoError(t, err) + assert.Len(t, all, 3) + + byScope, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{Scope: captaintoken.ScopeAPI}) + require.NoError(t, err) + require.Len(t, byScope, 1) + assert.Equal(t, "ci", byScope[0].Name) + + byAgent, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{Agent: "worker-01"}) + require.NoError(t, err) + require.Len(t, byAgent, 1) + assert.Equal(t, bound.TokenID, byAgent[0].TokenID) + + // A pool member is named in pool_agents, not in the bound agent column; a + // search that only looked at the column would report the agent as unknown. + byMember, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{Agent: member}) + require.NoError(t, err) + require.Len(t, byMember, 1) + assert.Equal(t, pool.TokenID, byMember[0].TokenID) + + require.NoError(t, db.RevokeAPIToken(t.Context(), bound.TokenID, "retired")) + + live, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{}) + require.NoError(t, err) + assert.Len(t, live, 2, "a listing answers what can reach this captain right now") + + withRevoked, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{IncludeRevoked: true}) + require.NoError(t, err) + assert.Len(t, withRevoked, 3) + + none, err := db.ListAPITokens(t.Context(), ListAPITokensFilter{Agent: "worker-99"}) + require.NoError(t, err) + assert.Empty(t, none) + assert.NotNil(t, none, "an empty result must marshal as [] rather than null") +} + +// Git smart-HTTP makes several requests per push, so an unthrottled touch would +// turn one push into a burst of writes carrying no extra information. +func TestTouchAPITokenIsThrottled(t *testing.T) { + db := openTokenDB(t, "captain_api_token_touch") + + token, _, err := db.CreateAPIToken(t.Context(), CreateAPITokenInput{ + Name: "worker-01", Scope: captaintoken.ScopeGit, Agent: "worker-01", + }) + require.NoError(t, err) + + stored, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + assert.Nil(t, stored.LastUsedAt, "an unused token has no last-used stamp") + + require.NoError(t, db.TouchAPIToken(t.Context(), token.TokenID)) + first, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + require.NotNil(t, first.LastUsedAt) + + require.NoError(t, db.TouchAPIToken(t.Context(), token.TokenID)) + second, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + require.NotNil(t, second.LastUsedAt) + assert.Equal(t, *first.LastUsedAt, *second.LastUsedAt, "a touch inside the interval must not rewrite the row") + + // Age the stamp past the interval; the next touch should advance it. + require.NoError(t, db.Gorm().WithContext(t.Context()).Exec( + "UPDATE captain_api_tokens SET last_used_at = now() - interval '1 hour' WHERE token_id = ?", + token.TokenID).Error) + require.NoError(t, db.TouchAPIToken(t.Context(), token.TokenID)) + third, err := db.GetAPIToken(t.Context(), token.TokenID) + require.NoError(t, err) + require.NotNil(t, third.LastUsedAt) + assert.True(t, third.LastUsedAt.After(*second.LastUsedAt), "a touch past the interval records the new use") + + require.NoError(t, db.TouchAPIToken(t.Context(), "nosuchid"), "touching an unknown token is a no-op, not an error") +} From ecf3e91d5f72b4e917d7f645d4213f59644a3b9e Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 18 Aug 2026 19:25:34 +0300 Subject: [PATCH 04/22] chore: update generated and lock files --- pkg/cli/webapp/dist/index.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pkg/cli/webapp/dist/index.html b/pkg/cli/webapp/dist/index.html index 902b3a23..3e5b56ae 100644 --- a/pkg/cli/webapp/dist/index.html +++ b/pkg/cli/webapp/dist/index.html @@ -4,8 +4,8 @@ Captain - - + +
From 8345ecea41b950ea3592e020e13bd0e5a6c8ea1d Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Tue, 18 Aug 2026 22:41:40 +0300 Subject: [PATCH 05/22] fix(cli): Support buffered providers in workflow streaming Allow non-streaming workflow providers to satisfy the runner's event contract via buffered execution instead of failing. Add coverage for completed text and result events. --- pkg/cli/prompt_run_live.go | 6 +++--- pkg/cli/prompt_run_workflow_test.go | 22 ++++++++++++++++++++++ 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/pkg/cli/prompt_run_live.go b/pkg/cli/prompt_run_live.go index 7df5f247..ee125610 100644 --- a/pkg/cli/prompt_run_live.go +++ b/pkg/cli/prompt_run_live.go @@ -129,10 +129,10 @@ func workflowRunnerProvider(provider ai.Provider, noStream, verifyOnly bool) (ai return bufferedWorkflowProvider{Provider: provider}, nil } streamer, ok := provider.(ai.StreamingProvider) - if !ok && !verifyOnly { - return nil, fmt.Errorf("backend %s does not support streaming", provider.GetBackend()) + if ok || verifyOnly { + return streamer, nil } - return streamer, nil + return bufferedWorkflowProvider{Provider: provider}, nil } // bufferedWorkflowProvider preserves the agent runner's event contract while diff --git a/pkg/cli/prompt_run_workflow_test.go b/pkg/cli/prompt_run_workflow_test.go index 44dff561..55f139ca 100644 --- a/pkg/cli/prompt_run_workflow_test.go +++ b/pkg/cli/prompt_run_workflow_test.go @@ -57,6 +57,28 @@ func (p *streamingWorkflowProvider) ExecuteStream(context.Context, ai.Request) ( } func TestWorkflowRunnerProviderHonorsNoStream(t *testing.T) { + t.Run("buffered-only provider uses completed events", func(t *testing.T) { + provider := &bufferedOnlyWorkflowProvider{} + runner, err := workflowRunnerProvider(provider, false, false) + if err != nil { + t.Fatal(err) + } + events, err := runner.ExecuteStream(context.Background(), ai.Request{}) + if err != nil { + t.Fatal(err) + } + var got []ai.Event + for event := range events { + got = append(got, event) + } + if provider.executeCalls != 1 { + t.Fatalf("Execute calls = %d, want 1", provider.executeCalls) + } + if len(got) != 2 || got[0].Kind != ai.EventText || got[0].Text != "done" || got[1].Kind != ai.EventResult { + t.Fatalf("events = %+v, want final text and result", got) + } + }) + t.Run("buffered-only provider", func(t *testing.T) { provider := &bufferedOnlyWorkflowProvider{} runner, err := workflowRunnerProvider(provider, true, false) From b15c2d4248162a6537bb373b15114af4df0af400 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 21 Aug 2026 08:40:04 +0300 Subject: [PATCH 06/22] feat(api): declare per-backend permission capabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other configurable axis — model, effort, cliArgs, sandbox — is declared in the registry, served to clients, and guarded server-side. Permissions had none of that: any posture or per-tool policy could be written for any backend, and the mismatch surfaced only when a provider built argv, minutes into a run. Declare what each backend actually does with a permissions block: which postures it honours (native, approximated, or not at all, plus the argv or sandbox/approval pair each compiles to), which per-tool policies it can enforce, and which resources it can switch. Project it through RuntimeModeEntry and backends[] so clients read it from the static catalog rather than from a TTL-d probe, where an unprobed backend would appear to support nothing. Tool-policy capability is keyed by provenance, not by backend alone, because where a tool came from decides what is enforceable. Captain builds the caller-tool list itself and omits a denied tool, so deny is honoured on codex-agent — which has no tool filter of its own — while deny on a codex built-in is not. Resources are keyed by the value requested as well as the kind: MCP is only switchable off and skills only on, so one cell per kind would report "supported" for a request that is accepted and dropped. The table states what the code does today, warts included, and the tests prove it against CodexSafety, the three CLI arg builders, the cmux command builder and the claude-agent initialize params, so a mapper and its declaration cannot drift apart in silence. Claude-Session-Id: c3c29851-27f6-4cce-831d-ca61a67461d4 --- .../permission_capabilities_test.go | 80 +++ .../cmux/permission_capabilities_test.go | 66 ++ .../provider/permission_capabilities_test.go | 230 +++++++ pkg/api/aliases.go | 19 +- pkg/api/permission_capabilities.go | 568 ++++++++++++++++++ .../permission_capabilities_ginkgo_test.go | 264 ++++++++ pkg/api/registry/backend.go | 27 +- pkg/api/runtime_catalog.go | 9 + pkg/cli/prompt_schema_build.go | 4 + 9 files changed, 1256 insertions(+), 11 deletions(-) create mode 100644 pkg/ai/provider/claudeagent/permission_capabilities_test.go create mode 100644 pkg/ai/provider/cmux/permission_capabilities_test.go create mode 100644 pkg/ai/provider/permission_capabilities_test.go create mode 100644 pkg/api/permission_capabilities.go create mode 100644 pkg/api/permission_capabilities_ginkgo_test.go diff --git a/pkg/ai/provider/claudeagent/permission_capabilities_test.go b/pkg/ai/provider/claudeagent/permission_capabilities_test.go new file mode 100644 index 00000000..c05254db --- /dev/null +++ b/pkg/ai/provider/claudeagent/permission_capabilities_test.go @@ -0,0 +1,80 @@ +package claudeagent + +import ( + "context" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// claude-agent is the transport that makes PermissionEffects.Flag per-backend: +// the posture rides on a `permissionMode` initialize param rather than argv, and +// unlike claude-cli it sends `default` explicitly. These tests hold the declared +// table to what initializeParams actually produces. + +func TestDeclaredPostureMatchesInitializeParams(t *testing.T) { + caps := api.PermissionCapabilitiesFor(api.BackendClaudeAgent) + p := &Provider{} + for _, mode := range api.AllPermissionModes() { + t.Run(string(mode), func(t *testing.T) { + support := caps.ModeSupport(mode) + require.True(t, support.Honoured(), "claude-agent should honour every posture, %s is %s", mode, support.Kind) + + want, ok := strings.CutPrefix(support.Effects.Flag, "permissionMode=") + require.True(t, ok, "claude-agent declares its posture as an initialize param, got %q", support.Effects.Flag) + + params := p.initializeParams(ai.Request{Permissions: api.Permissions{Mode: mode}}) + assert.Equal(t, want, params.PermissionMode) + }) + } +} + +// TestDeclaredDefaultPostureIsSentExplicitly is the difference from claude-cli, +// which omits the flag and lets the CLI pick. Declaring an empty Flag here would +// be wrong in a way no posture test would otherwise notice. +func TestDeclaredDefaultPostureIsSentExplicitly(t *testing.T) { + declared := api.PermissionCapabilitiesFor(api.BackendClaudeAgent). + ModeSupport(api.PermissionDefault).Effects.Flag + require.NotEmpty(t, declared, "claude-agent sends the unset posture explicitly") + + params := (&Provider{}).initializeParams(ai.Request{}) + assert.Equal(t, "permissionMode="+params.PermissionMode, declared) +} + +// TestDeclaredCallerToolBrokerMatchesApprovalMode pins the requires-broker cell. +// `ask` on a caller tool is only enforceable when CanUseTool is attached — the +// SDK is told to consult the broker instead of auto-approving — which is exactly +// what SupportRequiresBroker means and why it is not simply "supported". +func TestDeclaredCallerToolBrokerMatchesApprovalMode(t *testing.T) { + support := api.PermissionCapabilitiesFor(api.BackendClaudeAgent). + ToolPolicySupport(api.ProvenanceCaller, api.ToolPolicyAsk) + require.Equal(t, api.SupportRequiresBroker, support.Kind) + + unbrokered := (&Provider{}).initializeParams(ai.Request{}) + assert.Equal(t, "auto", unbrokered.ApprovalMode, "without a broker there is nothing to ask") + + brokered := (&Provider{cfg: ai.Config{CanUseTool: func(context.Context, ai.PermissionRequest) (ai.PermissionDecision, error) { + return ai.PermissionDecision{Allow: true}, nil + }}}).initializeParams(ai.Request{}) + assert.Equal(t, "ask", brokered.ApprovalMode, "a broker is what makes ask enforceable") +} + +// TestDeclaredMCPDisableIsRefusedNotDropped pins why claude-agent declares +// ResourceKindMCP/disabled unsupported rather than native: with caller tools in +// play the request is refused, and with none it is silently dropped. Neither is +// enforcement, and the refusal is the only part a caller can see. +func TestDeclaredMCPDisableIsRefusedNotDropped(t *testing.T) { + require.Equal(t, api.SupportUnsupported, + api.PermissionCapabilitiesFor(api.BackendClaudeAgent). + ResourceSupport(api.ResourceKindMCP, api.ResourceDisabled).Kind) + + // No caller tools: the request reaches nothing at all. + p := &Provider{} + require.NoError(t, p.prepareCallerTools(ai.Request{Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}})) + assert.Nil(t, callerToolServers(p.callerTools), + "mcp.disabled is accepted here and changes nothing") +} diff --git a/pkg/ai/provider/cmux/permission_capabilities_test.go b/pkg/ai/provider/cmux/permission_capabilities_test.go new file mode 100644 index 00000000..fd53a8b7 --- /dev/null +++ b/pkg/ai/provider/cmux/permission_capabilities_test.go @@ -0,0 +1,66 @@ +package cmux + +import ( + "strings" + "testing" + + "github.com/flanksource/captain/pkg/api" +) + +// The cmux transports are the reason PermissionEffects.Flag is per-backend rather +// than per-provider: claude-cmux emits `--permission-mode default` for the unset +// posture where claude-cli omits the flag entirely. These tests pin that +// difference so the declaration keeps telling the truth about both. + +func TestDeclaredClaudeCmuxPostureMatchesCommand(t *testing.T) { + caps := api.PermissionCapabilitiesFor(api.BackendClaudeCmux) + for _, mode := range api.AllPermissionModes() { + t.Run(string(mode), func(t *testing.T) { + support := caps.ModeSupport(mode) + if !support.Honoured() { + t.Skipf("claude-cmux declares %s as %s", mode, support.Kind) + } + cmd := AgentCommand(AgentCommandOpts{Agent: "claude", PermissionMode: mode}) + if !strings.Contains(cmd, support.Effects.Flag) { + t.Fatalf("declared %q for %s, command was %q", support.Effects.Flag, mode, cmd) + } + }) + } +} + +// TestDeclaredCodexCmuxCarriesNoPermissionFlag pins the shape that makes codex's +// row a sandbox/approval pair rather than a flag: the command itself never +// mentions the posture, which rides on the typed cmux options instead. +func TestDeclaredCodexCmuxCarriesNoPermissionFlag(t *testing.T) { + caps := api.PermissionCapabilitiesFor(api.BackendCodexCmux) + for _, mode := range api.AllPermissionModes() { + if flag := caps.ModeSupport(mode).Effects.Flag; flag != "" { + t.Fatalf("codex-cmux declares flag %q for %s, but codex has no permission-mode flag", flag, mode) + } + if cmd := AgentCommand(AgentCommandOpts{Agent: "codex", PermissionMode: mode}); strings.Contains(cmd, "--permission-mode") { + t.Fatalf("codex command carried a permission flag: %q", cmd) + } + } +} + +// TestDeclaredClaudeCmuxToolPolicyMatchesCommand proves the agent-provenance row +// for the terminal transport: cmux forwards both claude tool flags, so allow and +// deny are declared native there just as they are on claude-cli. +func TestDeclaredClaudeCmuxToolPolicyMatchesCommand(t *testing.T) { + caps := api.PermissionCapabilitiesFor(api.BackendClaudeCmux) + cmd := AgentCommand(AgentCommandOpts{ + Agent: "claude", AllowedTools: []string{"Read"}, DisallowedTools: []string{"Bash"}, + }) + for _, pair := range []struct { + policy api.ToolPolicy + flag string + }{ + {api.ToolPolicyAllow, "--allowedTools"}, + {api.ToolPolicyDeny, "--disallowedTools"}, + } { + declared := caps.ToolPolicySupport(api.ProvenanceAgent, pair.policy).Kind == api.SupportNative + if got := strings.Contains(cmd, pair.flag); got != declared { + t.Fatalf("declared %s native=%v but command carries %s = %v: %q", pair.policy, declared, pair.flag, got, cmd) + } + } +} diff --git a/pkg/ai/provider/permission_capabilities_test.go b/pkg/ai/provider/permission_capabilities_test.go new file mode 100644 index 00000000..9d3f900c --- /dev/null +++ b/pkg/ai/provider/permission_capabilities_test.go @@ -0,0 +1,230 @@ +package provider + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" +) + +// The capability table in pkg/api claims to describe these providers. A +// declaration nobody checks is just a comment, so these tests drive the real arg +// builders for every posture and compare the argv against the declared Effects. +// +// They live here rather than beside the table because the builders are +// unexported and sit above pkg/api: the declaration is the leaf, the +// implementation is what has to agree with it. + +// argvFor is the real argv one backend produces for one posture. +func argvFor(t *testing.T, backend api.Backend, mode api.PermissionMode) []string { + t.Helper() + req := ai.Request{Prompt: api.Prompt{User: "hi"}, Permissions: api.Permissions{Mode: mode}} + switch backend { + case api.BackendClaudeCLI: + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", req) + if err != nil { + t.Fatalf("buildClaudeCLIArgs(%s): %v", mode, err) + } + t.Cleanup(cleanup) + return args + case api.BackendGeminiCLI: + args, err := buildGeminiCLIArgs("gemini-3.5-flash", req) + if err != nil { + t.Fatalf("buildGeminiCLIArgs(%s): %v", mode, err) + } + return args + case api.BackendCodexCLI: + args, cleanup, err := buildCodexCLIArgs(codexCLIConfig{Model: "gpt-5.5"}, req) + if err != nil { + t.Fatalf("buildCodexCLIArgs(%s): %v", mode, err) + } + t.Cleanup(cleanup) + return args + default: + t.Fatalf("no argv builder for backend %s", backend) + return nil + } +} + +// containsFlag reports whether "--flag value" appears as adjacent argv entries. +// Matching the pair rather than the value alone keeps `plan` from matching a +// model name or a prompt. +func containsFlag(args []string, flag string) bool { + parts := strings.SplitN(flag, " ", 2) + for i, arg := range args { + if arg != parts[0] { + continue + } + if len(parts) == 1 { + return true + } + if i+1 < len(args) && args[i+1] == parts[1] { + return true + } + } + return false +} + +// TestDeclaredPostureMatchesArgv is the flag-shaped half of the proof: every +// posture the table declares native or approximated must put its declared Flag +// into argv, and a declared-empty Flag must leave the knob unset so the CLI's +// own default stands. +func TestDeclaredPostureMatchesArgv(t *testing.T) { + for _, backend := range []api.Backend{api.BackendClaudeCLI, api.BackendGeminiCLI} { + caps := api.PermissionCapabilitiesFor(backend) + knob := strings.Fields(caps.ModeSupport(api.PermissionPlan).Effects.Flag)[0] + + for _, mode := range api.AllPermissionModes() { + t.Run(fmt.Sprintf("%s/%s", backend, mode), func(t *testing.T) { + support := caps.ModeSupport(mode) + if !support.Honoured() { + t.Skipf("%s declares %s as %s", backend, mode, support.Kind) + } + args := argvFor(t, backend, mode) + if support.Effects.Flag == "" { + if slices.Contains(args, knob) { + t.Fatalf("%s declares no flag for %s but argv carries %s: %v", backend, mode, knob, args) + } + return + } + if !containsFlag(args, support.Effects.Flag) { + t.Fatalf("%s declares %q for %s, argv was %v", backend, support.Effects.Flag, mode, args) + } + }) + } + } +} + +// TestDeclaredCodexPostureMatchesArgv is the sandbox/approval half. codex has no +// permission-mode flag at all: the posture is a --sandbox tier plus an +// approval_policy config override, which is why those are separate Effects +// fields rather than one Flag string. +func TestDeclaredCodexPostureMatchesArgv(t *testing.T) { + caps := api.PermissionCapabilitiesFor(api.BackendCodexCLI) + for _, mode := range api.AllPermissionModes() { + t.Run(string(mode), func(t *testing.T) { + support := caps.ModeSupport(mode) + if !support.Honoured() { + t.Skipf("codex-cli declares %s as %s", mode, support.Kind) + } + args := argvFor(t, api.BackendCodexCLI, mode) + if !containsFlag(args, "--sandbox "+support.Effects.Sandbox) { + t.Fatalf("declared sandbox %q for %s, argv was %v", support.Effects.Sandbox, mode, args) + } + if !containsFlag(args, fmt.Sprintf("-c approval_policy=%q", support.Effects.Approval)) { + t.Fatalf("declared approval %q for %s, argv was %v", support.Effects.Approval, mode, args) + } + }) + } +} + +// TestDeclaredUnsupportedCodexDontAskIsTheDefault pins the *reason* dontAsk is +// declared unsupported rather than approximated: CodexSafety has no case for it, +// so it lands on the read-only default — a posture that prompts more, not less. +// If someone gives it a case, this fails and the cell must be re-declared. +func TestDeclaredUnsupportedCodexDontAskIsTheDefault(t *testing.T) { + dontAsk := argvFor(t, api.BackendCodexCLI, api.PermissionDontAsk) + unset := argvFor(t, api.BackendCodexCLI, "") + if !slices.Equal(dontAsk, unset) { + t.Fatalf("dontAsk is declared unsupported because it is indistinguishable from the unset posture,\n dontAsk: %v\n unset: %v", dontAsk, unset) + } +} + +// TestDeclaredAgentToolPolicyMatchesArgv proves the agent-provenance row two +// ways: where the table says native, allow and deny reach their flags; where it +// says unsupported, the builder refuses outright rather than dropping the policy. +// +// The refusal half matters most. RequireToolPolicySupport is the one place +// captain already fails loud instead of silently ignoring a permission field, and +// this pins the table to that behaviour so the two cannot drift apart. +func TestDeclaredAgentToolPolicyMatchesArgv(t *testing.T) { + perms := api.Permissions{Tools: api.Tools{Deny: []string{"Bash"}, Allow: []string{"Read"}}} + req := ai.Request{Prompt: api.Prompt{User: "hi"}, Permissions: perms} + cases := []struct { + backend api.Backend + argv func() ([]string, error) + }{ + {api.BackendClaudeCLI, func() ([]string, error) { + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", req) + if cleanup != nil { + t.Cleanup(cleanup) + } + return args, err + }}, + {api.BackendGeminiCLI, func() ([]string, error) { + return buildGeminiCLIArgs("gemini-3.5-flash", req) + }}, + {api.BackendCodexCLI, func() ([]string, error) { + args, cleanup, err := buildCodexCLIArgs(codexCLIConfig{Model: "gpt-5.5"}, req) + if cleanup != nil { + t.Cleanup(cleanup) + } + return args, err + }}, + } + for _, tc := range cases { + t.Run(string(tc.backend), func(t *testing.T) { + caps := api.PermissionCapabilitiesFor(tc.backend) + enforces := caps.ToolPolicySupport(api.ProvenanceAgent, api.ToolPolicyDeny).Kind == api.SupportNative + args, err := tc.argv() + + if !enforces { + if err == nil { + t.Fatalf("%s declares agent tool policy unsupported, but the run was accepted: %v", tc.backend, args) + } + if !strings.Contains(err.Error(), string(tc.backend)) { + t.Fatalf("refusal should name the backend, got %v", err) + } + return + } + if err != nil { + t.Fatalf("%s declares agent tool policy native but refused: %v", tc.backend, err) + } + for _, pair := range []struct { + policy api.ToolPolicy + flag string + }{ + {api.ToolPolicyDeny, "--disallowedTools"}, + {api.ToolPolicyAllow, "--allowedTools"}, + } { + declared := caps.ToolPolicySupport(api.ProvenanceAgent, pair.policy).Kind == api.SupportNative + if got := slices.Contains(args, pair.flag); got != declared { + t.Fatalf("declared %s/%s native=%v but argv carries %s = %v: %v", + tc.backend, pair.policy, declared, pair.flag, got, args) + } + } + }) + } +} + +// TestDeclaredMCPDisableMatchesArgv proves the resource row for claude-cli: the +// only backend whose argv genuinely silences ambient MCP servers is the only one +// declaring ResourceKindMCP/disabled as native. +func TestDeclaredMCPDisableMatchesArgv(t *testing.T) { + req := ai.Request{Prompt: api.Prompt{User: "hi"}, Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}} + args, cleanup, err := buildClaudeCLIArgs("claude-sonnet-5", req) + if err != nil { + t.Fatalf("buildClaudeCLIArgs: %v", err) + } + t.Cleanup(cleanup) + + declared := api.PermissionCapabilitiesFor(api.BackendClaudeCLI). + ResourceSupport(api.ResourceKindMCP, api.ResourceDisabled).Kind == api.SupportNative + if got := slices.Contains(args, "--strict-mcp-config"); got != declared { + t.Fatalf("claude-cli declares mcp/disabled native=%v, argv was %v", declared, args) + } + + // gemini-cli declares it unsupported: the request is accepted and dropped. + geminiArgs, err := buildGeminiCLIArgs("gemini-3.5-flash", req) + if err != nil { + t.Fatalf("buildGeminiCLIArgs: %v", err) + } + for _, arg := range geminiArgs { + if strings.Contains(arg, "mcp") { + t.Fatalf("gemini-cli declares mcp/disabled unsupported but argv mentions mcp: %v", geminiArgs) + } + } +} diff --git a/pkg/api/aliases.go b/pkg/api/aliases.go index e0464af3..11cb2177 100644 --- a/pkg/api/aliases.go +++ b/pkg/api/aliases.go @@ -9,12 +9,13 @@ import "github.com/flanksource/captain/pkg/api/registry" // downstream (including pkg/ai's own re-exports) has to know registry exists. type ( - Backend = registry.Backend - DisabledSet = registry.DisabledSet - Effort = registry.Effort - Model = registry.Model - ModelList = registry.ModelList - RuntimeMode = registry.RuntimeMode + Backend = registry.Backend + DisabledSet = registry.DisabledSet + Effort = registry.Effort + Model = registry.Model + ModelList = registry.ModelList + ModeCapabilities = registry.ModeCapabilities + RuntimeMode = registry.RuntimeMode ) const ( @@ -68,6 +69,12 @@ func BackendList() string { return registry.BackendList() } // key, in priority order. func AuthEnvVars(b Backend) []string { return registry.AuthEnvVars(b) } +// CapsFor returns the declared capability cell for a backend. +func CapsFor(b Backend) (ModeCapabilities, bool) { return registry.CapsFor(b) } + +// SupportsCallerTools reports whether a backend can expose caller-supplied tools. +func SupportsCallerTools(b Backend) bool { return registry.SupportsCallerTools(b) } + // InferBackend resolves the backend from a model name prefix, failing loud when // the name matches nothing. func InferBackend(model string) (Backend, error) { return registry.InferBackend(model) } diff --git a/pkg/api/permission_capabilities.go b/pkg/api/permission_capabilities.go new file mode 100644 index 00000000..2df79a29 --- /dev/null +++ b/pkg/api/permission_capabilities.go @@ -0,0 +1,568 @@ +package api + +// This file is the declared answer to "what can this backend actually do with a +// permissions block?". +// +// It exists because every other configurable axis — model, effort, cliArgs, +// sandbox — is gated by a capability that is declared, served, and enforced, +// while permissions were offered unconditionally and reconciled only when a +// provider built argv. The mapping functions (CodexSafety here, +// cliClaudePermissionMode in the claude-cli provider, geminiApprovalMode in the +// gemini one) remain the implementation; this table is the static declaration +// they are proven against, so drift becomes a failing test and a printed row +// rather than a surprise at dispatch. +// +// It deliberately describes what captain does *today*, warts included: a cell +// that reads SupportUnsupported is a statement about the current code, not an +// aspiration. Changing behaviour means changing a cell, which shows up in +// review as a diff to the matrix. +// +// It lives in pkg/api rather than pkg/api/registry because the vocabulary it is +// keyed by — PermissionMode, ToolPolicy, ResourceMode — lives here, and nothing +// below pkg/api consults it. The "completeness" specs in +// permission_capabilities_ginkgo_test.go pin this table against +// registry.AllBackends so a new backend cannot be added there without a decision +// being made here. + +// SupportKind is how faithfully a backend honours one permission setting. +type SupportKind string + +const ( + // SupportNative: the backend expresses the setting exactly. + SupportNative SupportKind = "native" + // SupportApproximated: the backend expresses something close, described by + // Effects. The run is honoured, but not literally as written. + SupportApproximated SupportKind = "approximated" + // SupportRequiresBroker: enforceable only when an approval broker is + // attached (Config.CanUseTool, or a cmux terminal interceptor). Without one + // the setting cannot be honoured and the run must be refused. + SupportRequiresBroker SupportKind = "requires-broker" + // SupportUnsupported: the backend cannot express it at all. + SupportUnsupported SupportKind = "unsupported" +) + +// PermissionEffects is the structured target state a setting compiles to. It is +// structured rather than prose so a test can compare it against what the mapper +// actually returns, and a UI can key off it instead of parsing a sentence. +type PermissionEffects struct { + // Flag is the literal argv the backend emits, when it emits one. An empty + // Flag on a native mode means the backend omits the flag deliberately and + // inherits its own default. + Flag string `json:"flag,omitempty"` + // Sandbox and Approval are codex's two-part posture (CodexSafety). + Sandbox string `json:"sandbox,omitempty"` + Approval string `json:"approval,omitempty"` + // Note explains an approximation or a caveat a caller must know about. + Note string `json:"note,omitempty"` +} + +// Support is one cell: how a backend honours one setting, and what it becomes. +type Support struct { + Kind SupportKind `json:"kind"` + Effects PermissionEffects `json:"effects,omitzero"` +} + +// Honoured reports whether the setting reaches the agent in some form. A +// requires-broker cell is not honoured on its own: it depends on runtime state +// this table cannot see. +func (s Support) Honoured() bool { + return s.Kind == SupportNative || s.Kind == SupportApproximated +} + +// ToolProvenance is where a tool came from, which is what determines whether a +// per-tool policy can be enforced at all. +// +// This is the dimension a per-backend boolean misses. Captain owns the MCP +// server it builds for caller tools, so it enforces a deny by simply not +// registering the tool — on backends whose own CLI has no tool filter at all. +type ToolProvenance string + +const ( + // ProvenanceAgent is the agent CLI's own built-ins: claude's Read/Edit/Bash, + // codex's shell/apply_patch. Only the CLI's own flags can filter these. + ProvenanceAgent ToolProvenance = "agent" + // ProvenanceCaller is a tool captain serves over its own MCP server. + // ResolveDefinitions drops denied tools before the server is built, so a + // deny is enforced by omission wherever caller tools are supported. + ProvenanceCaller ToolProvenance = "caller" + // ProvenanceMCP is a third-party server from .mcp.json or config.toml. + // Captain controls whether the server loads, not which of its tools do — + // until a captain-owned gateway proxies them. + ProvenanceMCP ToolProvenance = "mcp" +) + +// AllToolProvenances lists provenances in canonical order. +func AllToolProvenances() []ToolProvenance { + return []ToolProvenance{ProvenanceAgent, ProvenanceCaller, ProvenanceMCP} +} + +// ResourceKind is a class of loadable resource governed by enabled/disabled. +// This is the availability axis, distinct from the authority axis a ToolPolicy +// expresses: enabling an MCP server *creates* tools, which then carry policies. +type ResourceKind string + +const ( + ResourceKindMCP ResourceKind = "mcp" + ResourceKindSkills ResourceKind = "skills" + ResourceKindPlugins ResourceKind = "plugins" +) + +// AllResourceKinds lists resource kinds in canonical order. +func AllResourceKinds() []ResourceKind { + return []ResourceKind{ResourceKindMCP, ResourceKindSkills, ResourceKindPlugins} +} + +// AllResourceModes lists the availability values in canonical order. +func AllResourceModes() []ResourceMode { + return []ResourceMode{ResourceEnabled, ResourceDisabled} +} + +// PermissionCapabilities is one backend's declared permission surface. +type PermissionCapabilities struct { + // Modes is the per-posture support map. Every recognised PermissionMode has + // an entry, so an absent key is a bug rather than "unsupported". + Modes map[PermissionMode]Support `json:"modes"` + // ToolPolicies is keyed by provenance first, because the same policy value + // is enforceable from one source and not another on the same backend. + ToolPolicies map[ToolProvenance]map[ToolPolicy]Support `json:"toolPolicies"` + // Resources is the availability axis, keyed by kind and then by the value + // requested. Both keys matter because the two directions are independent and + // today they are opposites: MCP is only switchable *off* (there is no + // per-server enable), while skills are only switchable *on* (a disabled entry + // is dropped before it reaches any provider). One Support per kind would + // report "supported" for a request that is silently ignored. + Resources map[ResourceKind]map[ResourceMode]Support `json:"resources"` + // Tools is the backend's built-in tool vocabulary — the names a per-tool + // policy can legitimately mention for ProvenanceAgent. + Tools []string `json:"tools,omitempty"` +} + +// ModeSupport returns the cell for a posture, defaulting to unsupported for an +// unrecognised mode rather than a zero Support with an empty Kind. +func (c PermissionCapabilities) ModeSupport(mode PermissionMode) Support { + if s, ok := c.Modes[mode]; ok { + return s + } + return Support{Kind: SupportUnsupported} +} + +// ToolPolicySupport returns the cell for one (provenance, policy) pair. +func (c PermissionCapabilities) ToolPolicySupport(p ToolProvenance, policy ToolPolicy) Support { + if byPolicy, ok := c.ToolPolicies[p]; ok { + if s, ok := byPolicy[policy]; ok { + return s + } + } + return Support{Kind: SupportUnsupported} +} + +// ResourceSupport returns the cell for one (kind, requested value) pair. +func (c PermissionCapabilities) ResourceSupport(kind ResourceKind, mode ResourceMode) Support { + if byMode, ok := c.Resources[kind]; ok { + if s, ok := byMode[mode]; ok { + return s + } + } + return Support{Kind: SupportUnsupported} +} + +// PermissionCapabilitiesFor returns the declared surface for a backend. An +// unknown backend gets a fully-unsupported row rather than a zero value, so a +// caller that forgets to check still fails closed. +func PermissionCapabilitiesFor(b Backend) PermissionCapabilities { + if caps, ok := permissionCapabilities[b]; ok { + return caps + } + return PermissionCapabilities{ + Modes: unsupportedModes(), + ToolPolicies: toolPolicies(noToolFilter(), noToolFilter(), noToolFilter()), + Resources: resources(false, false), + } +} + +// PermissionCapabilityBackends lists the backends the table declares, in +// canonical AllBackends order. +func PermissionCapabilityBackends() []Backend { + out := make([]Backend, 0, len(permissionCapabilities)) + for _, b := range AllBackends() { + if _, ok := permissionCapabilities[b]; ok { + out = append(out, b) + } + } + return out +} + +// --- construction helpers ------------------------------------------------- +// +// The table below is dense by design: it is meant to be read as a matrix, and +// helpers keep each row to one line per setting so a reviewer can diff a cell. + +func native(flag string) Support { + return Support{Kind: SupportNative, Effects: PermissionEffects{Flag: flag}} +} + +func nativeNote(flag, note string) Support { + return Support{Kind: SupportNative, Effects: PermissionEffects{Flag: flag, Note: note}} +} + +func unsupported(note string) Support { + return Support{Kind: SupportUnsupported, Effects: PermissionEffects{Note: note}} +} + +func broker(note string) Support { + return Support{Kind: SupportRequiresBroker, Effects: PermissionEffects{Note: note}} +} + +func codexPosture(sandbox, approval, note string) Support { + return Support{ + Kind: SupportApproximated, + Effects: PermissionEffects{Sandbox: sandbox, Approval: approval, Note: note}, + } +} + +func approxFlag(flag, note string) Support { + return Support{Kind: SupportApproximated, Effects: PermissionEffects{Flag: flag, Note: note}} +} + +func unsupportedModes() map[PermissionMode]Support { + out := make(map[PermissionMode]Support, len(AllPermissionModes())) + for _, m := range AllPermissionModes() { + out[m] = unsupported("this backend never reads permissions.mode") + } + return out +} + +func toolPolicies(agent, caller, mcp map[ToolPolicy]Support) map[ToolProvenance]map[ToolPolicy]Support { + return map[ToolProvenance]map[ToolPolicy]Support{ + ProvenanceAgent: agent, + ProvenanceCaller: caller, + ProvenanceMCP: mcp, + } +} + +// noToolFilter is the row for a source captain cannot filter: auto constrains +// nothing so it is always fine, everything else is unenforceable. +func noToolFilter() map[ToolPolicy]Support { + return map[ToolPolicy]Support{ + ToolPolicyAuto: native(""), + ToolPolicyAllow: unsupported("no tool filter for this source"), + ToolPolicyDeny: unsupported("no tool filter for this source"), + ToolPolicyAsk: unsupported("no per-tool prompt for this source"), + } +} + +// claudeAgentTools is the row for claude's own built-ins, the only agent +// built-ins any transport can filter. +func claudeAgentTools() map[ToolPolicy]Support { + return map[ToolPolicy]Support{ + ToolPolicyAuto: native(""), + ToolPolicyAllow: nativeNote("--allowedTools", + "claude's --allowedTools auto-approves rather than restricting: unlisted tools are not denied by it"), + ToolPolicyDeny: native("--disallowedTools"), + ToolPolicyAsk: unsupported("no per-tool prompt for agent built-ins on any transport"), + } +} + +// callerTools is the row for tools captain serves itself. Deny is enforced by +// omission in ResolveDefinitions, which needs no cooperation from the agent. +func callerTools() map[ToolPolicy]Support { + return map[ToolPolicy]Support{ + ToolPolicyAuto: native(""), + ToolPolicyAllow: native("registered without an approval gate"), + ToolPolicyDeny: native("omitted from the served tool list"), + ToolPolicyAsk: broker("enforced through Config.CanUseTool when a broker is attached"), + } +} + +// resources builds the availability rows. Only two cells across the whole matrix +// are ever honoured, so they are the only two parameters: +// +// - mcpOff: does `permissions.mcp.disabled` actually silence ambient servers. +// - skillsOn: does `permissions.skills: {dir: enabled}` actually load them. +// +// Everything else is unsupported on every backend today, and says why: +// `mcp.servers` and `mcp.modes` have no reader outside Validate, a disabled skill +// is dropped by ResourcePolicies.Enabled before any provider sees it, and +// `permissions.plugins` reaches req.Permissions.Plugins and stops there. +func resources(mcpOff, skillsOn bool) map[ResourceKind]map[ResourceMode]Support { + mcpDisabled := unsupported("permissions.mcp.disabled is accepted and then dropped on this backend") + if mcpOff { + mcpDisabled = native("all MCP servers silenced") + } + skillsEnabled := unsupported("skill directories are not loaded on this backend") + if skillsOn { + skillsEnabled = native("--plugin-dir") + } + return map[ResourceKind]map[ResourceMode]Support{ + ResourceKindMCP: { + ResourceEnabled: unsupported("mcp.servers / mcp.modes have no reader: per-server enabling is not implemented"), + ResourceDisabled: mcpDisabled, + }, + ResourceKindSkills: { + ResourceEnabled: skillsEnabled, + ResourceDisabled: unsupported("ResourcePolicies.Enabled drops disabled skills before any provider sees them"), + }, + ResourceKindPlugins: { + ResourceEnabled: unsupported("permissions.plugins reaches req.Permissions.Plugins and has no reader beyond it"), + ResourceDisabled: unsupported("permissions.plugins reaches req.Permissions.Plugins and has no reader beyond it"), + }, + } +} + +// claudeModes is the posture row shared by every claude transport: the enum was +// modelled on `claude --permission-mode`, so all six map one-to-one. +// +// setting is how that transport spells the knob — the CLI and cmux emit the +// literal flag, the SDK bridge sends a `permissionMode` initialize param — so +// Effects.Flag stays a truthful record of what is actually sent. +// +// emitsDefault distinguishes the two treatments of the unset posture: claude-cli +// omits the flag entirely, while cmux and the agent bridge send `default` +// explicitly. Claude 2.1.237 advertises `manual` in place of `default` yet still +// accepts `default`, so both spellings work today; the alias is undocumented and +// may not survive. +func claudeModes(setting string, emitsDefault bool) map[PermissionMode]Support { + def := native("") + if emitsDefault { + def = nativeNote(setting+"default", + "claude 2.1.237 advertises `manual` but still accepts `default` as an undocumented alias") + } + return map[PermissionMode]Support{ + PermissionDefault: def, + PermissionPlan: native(setting + "plan"), + PermissionAcceptEdits: native(setting + "acceptEdits"), + PermissionAuto: native(setting + "auto"), + PermissionBypass: native(setting + "bypassPermissions"), + PermissionDontAsk: native(setting + "dontAsk"), + } +} + +// codexModes is the posture row for every codex transport, derived from the one +// shared CodexSafety mapper. Two cells are the interesting ones: +// +// - plan has no codex flag at all. On codex-agent the app-server additionally +// refuses every escalation request (codexPosture.allowsEscalation); on +// codex-cli and codex-cmux the read-only sandbox is the whole enforcement. +// - dontAsk has no case in CodexSafety, so it falls through to the read-only +// default — the precise inversion of what the name asks for. Declared +// unsupported rather than approximated, because "never prompt" becoming +// "restricted, and escalations are refused" is not an approximation of the +// request. +// +// extra is appended to every cell's note, carrying a transport-wide caveat. +func codexModes(suppressesEscalation bool, extra string) map[PermissionMode]Support { + planNote := "codex has no plan flag; the read-only sandbox is the whole enforcement" + if suppressesEscalation { + planNote = "the app-server additionally refuses every escalation request while in plan mode" + } + out := map[PermissionMode]Support{ + PermissionDefault: codexPosture("read-only", "on-request", + "codex has no default posture of its own; an unset mode resolves to the read-only sandbox"), + PermissionPlan: codexPosture("read-only", "on-request", planNote), + PermissionAcceptEdits: codexPosture("workspace-write", "on-request", + "codex grants workspace writes wholesale rather than auto-approving each edit prompt"), + PermissionAuto: codexPosture("workspace-write", "on-request", + "codex has one write tier, so auto and acceptEdits are indistinguishable here"), + PermissionBypass: codexPosture("danger-full-access", "never", + "danger-full-access removes the sandbox as well as the prompts"), + PermissionDontAsk: unsupported( + "CodexSafety has no dontAsk case, so it resolves to the read-only default — the opposite of the request"), + } + if extra == "" { + return out + } + for mode, s := range out { + s.Effects.Note = joinNotes(s.Effects.Note, extra) + out[mode] = s + } + return out +} + +// geminiModes is derived from geminiApprovalMode, which emits no flag at all for +// the default posture. +func geminiModes() map[PermissionMode]Support { + const setting = "--approval-mode " + return map[PermissionMode]Support{ + PermissionDefault: native(""), + PermissionPlan: native(setting + "plan"), + PermissionAcceptEdits: approxFlag(setting+"auto_edit", "gemini auto-approves edit tools rather than all edits"), + PermissionAuto: approxFlag(setting+"auto_edit", "gemini auto-approves edit tools rather than all edits"), + // yolo auto-approves every tool call, which is exactly what + // bypassPermissions asks for — the one gemini posture that matches outright. + PermissionBypass: native(setting + "yolo"), + PermissionDontAsk: approxFlag(setting+"yolo", "yolo also grants write access, which dontAsk does not itself request"), + } +} + +func joinNotes(a, b string) string { + switch { + case a == "": + return b + case b == "": + return a + default: + return a + "; " + b + } +} + +// claudeBuiltinTools and the rest are the built-in vocabularies a per-tool +// policy may name. codexBuiltinTools is taken from the codex→claude +// normalisation table in pkg/ai/history, which is the only accurate list in the +// tree; the permission catalog's hardcoded claude names were served for every +// backend regardless. +var ( + claudeBuiltinTools = []string{ + "Bash", "Edit", "Glob", "Grep", "MultiEdit", "Read", "TodoWrite", "WebFetch", "WebSearch", "Write", + } + codexBuiltinTools = []string{ + "apply_patch", "close_agent", "request_user_input", "resume_agent", "send_input", + "shell", "spawn_agent", "update_plan", "wait", "wait_agent", + } + geminiBuiltinTools = []string{ + "google_web_search", "read_file", "replace", "run_shell_command", "write_file", + } +) + +// permissionCapabilities is the matrix. One row per backend; every backend in +// AllBackends must appear, which TestPermissionCapabilitiesCoverEveryBackend +// enforces so that adding a backend forces a decision here. +var permissionCapabilities = map[Backend]PermissionCapabilities{ + // --- API backends: genkit reads Permissions.Tools only to refuse an + // unenforceable policy, and never reads Permissions.Mode at all. Caller + // tools are the one axis these can honour, and they honour it fully. + BackendAnthropic: { + Modes: unsupportedModes(), + ToolPolicies: toolPolicies(noToolFilter(), callerTools(), noToolFilter()), + Resources: resources(false, false), + }, + BackendOpenAI: { + Modes: unsupportedModes(), + ToolPolicies: toolPolicies(noToolFilter(), callerTools(), noToolFilter()), + Resources: resources(false, false), + }, + BackendGemini: { + Modes: unsupportedModes(), + ToolPolicies: toolPolicies(noToolFilter(), callerTools(), noToolFilter()), + Resources: resources(false, false), + }, + BackendDeepSeek: { + Modes: unsupportedModes(), + ToolPolicies: toolPolicies(noToolFilter(), callerTools(), noToolFilter()), + Resources: resources(false, false), + }, + + // --- claude transports: the enum's native home. + BackendClaudeCLI: { + Modes: claudeModes("--permission-mode ", false), + ToolPolicies: toolPolicies(claudeAgentTools(), noToolFilter(), noToolFilter()), + // --mcp-config {} --strict-mcp-config genuinely disables ambient MCP; + // --plugin-dir carries permissions.skills. This is the only backend that + // honours either. + Resources: resources(true, true), + Tools: claudeBuiltinTools, + }, + BackendClaudeAgent: { + Modes: claudeModes("permissionMode=", true), + ToolPolicies: toolPolicies(claudeAgentTools(), callerTools(), noToolFilter()), + // The SDK bridge sends only the caller-tool server list, so an + // mcp.disabled request never reaches ambient servers. prepareCallerTools + // errors when caller tools and mcp.disabled are combined, which is a + // compatibility refusal rather than enforcement. + Resources: resources(false, false), + Tools: claudeBuiltinTools, + }, + BackendClaudeCmux: { + Modes: claudeModes("--permission-mode ", true), + ToolPolicies: toolPolicies(claudeAgentTools(), noToolFilter(), noToolFilter()), + Resources: resources(false, false), + Tools: claudeBuiltinTools, + }, + + // --- codex transports: posture is a sandbox/approval pair, never a mode. + BackendCodexCLI: { + Modes: codexModes(false, ""), + ToolPolicies: toolPolicies(noToolFilter(), noToolFilter(), noToolFilter()), + Resources: resources(false, false), + Tools: codexBuiltinTools, + }, + BackendCodexAgent: { + Modes: codexModes(true, ""), + // The caller row is the point of the provenance dimension: codex-agent + // has no tool filter of its own, yet a denied caller tool is simply + // never registered, so the policy is fully enforced. + ToolPolicies: toolPolicies(noToolFilter(), callerTools(), noToolFilter()), + // codexThreadConfig sends an empty mcp_servers map when MCP is disabled. + Resources: resources(true, false), + Tools: codexBuiltinTools, + }, + BackendCodexCmux: { + // cmuxExtraArgs seeds the posture from CodexSafety only when the caller + // left the option unset, so an explicit cliArgs value silently wins over + // permissions.mode here and nowhere else. + Modes: codexModes(false, + "cliArgs.sandbox / cliArgs.askForApproval override this posture when set"), + ToolPolicies: toolPolicies(noToolFilter(), noToolFilter(), noToolFilter()), + Resources: resources(false, false), + Tools: codexBuiltinTools, + }, + + // --- gemini CLI: an approval-mode flag and nothing else. The provider + // documents that it has no per-run MCP override. + BackendGeminiCLI: { + Modes: geminiModes(), + ToolPolicies: toolPolicies(noToolFilter(), noToolFilter(), noToolFilter()), + Resources: resources(false, false), + Tools: geminiBuiltinTools, + }, +} + +// SupportedPermissionModes lists the postures a backend honours natively or by +// approximation, in canonical order. It is what a picker should offer. +func SupportedPermissionModes(b Backend) []PermissionMode { + caps := PermissionCapabilitiesFor(b) + out := make([]PermissionMode, 0, len(caps.Modes)) + for _, mode := range AllPermissionModes() { + if caps.ModeSupport(mode).Honoured() { + out = append(out, mode) + } + } + return out +} + +// SupportedToolPolicies lists the policy values a backend can enforce for one +// provenance, in canonical order. A requires-broker value is included because +// whether it is usable depends on runtime state this table cannot see; the +// caller decides. +func SupportedToolPolicies(b Backend, p ToolProvenance) []ToolPolicy { + caps := PermissionCapabilitiesFor(b) + out := make([]ToolPolicy, 0, 4) + for _, policy := range AllToolPolicies() { + if s := caps.ToolPolicySupport(p, policy); s.Kind != SupportUnsupported { + out = append(out, policy) + } + } + return out +} + +// ToolPolicyProvenances lists the provenances that can carry a constraining +// policy on a backend, in canonical order. Empty means no per-tool policy is +// enforceable there from any source. +func ToolPolicyProvenances(b Backend) []ToolProvenance { + caps := PermissionCapabilitiesFor(b) + var out []ToolProvenance + for _, p := range AllToolProvenances() { + for _, policy := range []ToolPolicy{ToolPolicyAllow, ToolPolicyDeny} { + if caps.ToolPolicySupport(p, policy).Honoured() { + out = append(out, p) + break + } + } + } + return out +} + +// AllToolPolicies lists the policy values in canonical order. ToolPolicy had no +// All* helper of its own because, until now, nothing enumerated it. +func AllToolPolicies() []ToolPolicy { + return []ToolPolicy{ToolPolicyAuto, ToolPolicyAsk, ToolPolicyAllow, ToolPolicyDeny} +} diff --git a/pkg/api/permission_capabilities_ginkgo_test.go b/pkg/api/permission_capabilities_ginkgo_test.go new file mode 100644 index 00000000..c2f3dc08 --- /dev/null +++ b/pkg/api/permission_capabilities_ginkgo_test.go @@ -0,0 +1,264 @@ +package api_test + +import ( + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + "github.com/flanksource/captain/pkg/api" +) + +// codexBackends are the three transports that share api.CodexSafety. +var codexBackends = []api.Backend{api.BackendCodexCLI, api.BackendCodexAgent, api.BackendCodexCmux} + +var _ = Describe("PermissionCapabilities", func() { + Describe("completeness", func() { + // The declaration is only useful if it is total: a missing cell reads as + // "unsupported" through the accessors, which would quietly downgrade a + // backend rather than failing. Adding a backend must force a decision here + // the way tool_policy_support_test.go already forces one for ToolPolicy. + It("declares a row for every backend", func() { + Expect(api.PermissionCapabilityBackends()).To(Equal(api.AllBackends())) + }) + + for _, backend := range api.AllBackends() { + Context(string(backend), func() { + caps := api.PermissionCapabilitiesFor(backend) + + It("declares every permission mode", func() { + Expect(caps.Modes).To(HaveLen(len(api.AllPermissionModes()))) + for _, mode := range api.AllPermissionModes() { + Expect(caps.Modes).To(HaveKey(mode)) + Expect(string(caps.Modes[mode].Kind)).ToNot(BeEmpty(), + "mode %s has a zero Support, which is not a decision", mode) + } + }) + + It("declares every policy value at every provenance", func() { + for _, provenance := range api.AllToolProvenances() { + Expect(caps.ToolPolicies).To(HaveKey(provenance)) + for _, policy := range api.AllToolPolicies() { + Expect(string(caps.ToolPolicySupport(provenance, policy).Kind)).ToNot(BeEmpty(), + "%s/%s has a zero Support", provenance, policy) + } + } + }) + + It("declares every resource kind in both directions", func() { + for _, kind := range api.AllResourceKinds() { + for _, mode := range api.AllResourceModes() { + Expect(string(caps.ResourceSupport(kind, mode).Kind)).ToNot(BeEmpty(), + "%s/%s has a zero Support", kind, mode) + } + } + }) + + It("explains every unsupported and approximated cell", func() { + // An unexplained refusal is what the editor cannot render and a + // reviewer cannot audit. `auto` is exempt: it constrains nothing, + // so "unsupported" there is a vacuous cell, not a story. + for mode, support := range caps.Modes { + if support.Kind == api.SupportUnsupported || support.Kind == api.SupportApproximated { + Expect(support.Effects.Note).ToNot(BeEmpty(), + "mode %s is %s with no explanation", mode, support.Kind) + } + } + for _, provenance := range api.AllToolProvenances() { + for _, policy := range api.AllToolPolicies() { + if policy == api.ToolPolicyAuto { + continue + } + support := caps.ToolPolicySupport(provenance, policy) + if support.Kind != api.SupportNative { + Expect(support.Effects.Note).ToNot(BeEmpty(), + "%s/%s is %s with no explanation", provenance, policy, support.Kind) + } + } + } + }) + }) + } + + It("fails closed for an unknown backend", func() { + caps := api.PermissionCapabilitiesFor(api.Backend("not-a-backend")) + Expect(api.SupportedPermissionModes("not-a-backend")).To(BeEmpty()) + Expect(caps.ToolPolicySupport(api.ProvenanceCaller, api.ToolPolicyDeny).Kind). + To(Equal(api.SupportUnsupported)) + Expect(caps.ResourceSupport(api.ResourceKindMCP, api.ResourceDisabled).Kind). + To(Equal(api.SupportUnsupported)) + }) + }) + + // The declaration claims to describe the mappers. These specs check it against + // the one mapper that lives in this package; the claude and gemini halves are + // pinned next to their own mappers, which is where they can be reached. + Describe("agreement with CodexSafety", func() { + for _, backend := range codexBackends { + Context(string(backend), func() { + caps := api.PermissionCapabilitiesFor(backend) + + for _, mode := range api.AllPermissionModes() { + It("matches the declared posture for "+string(mode), func() { + sandbox, approval := api.CodexSafety(api.Permissions{Mode: mode}) + support := caps.ModeSupport(mode) + if support.Kind == api.SupportUnsupported { + // dontAsk is declared unsupported precisely because it lands + // on the read-only default rather than on anything resembling + // "stop asking". Pinning that keeps the reason true. + defSandbox, defApproval := api.CodexSafety(api.Permissions{}) + Expect(sandbox).To(Equal(defSandbox)) + Expect(approval).To(Equal(defApproval)) + return + } + Expect(support.Effects.Sandbox).To(Equal(string(sandbox))) + Expect(support.Effects.Approval).To(Equal(string(approval))) + }) + } + }) + } + }) + + Describe("supported vocabularies", func() { + It("offers no posture on the API backends", func() { + // The four API backends never read Permissions.Mode. Offering a posture + // picker there is the F6 half of what this table exists to stop. + for _, backend := range []api.Backend{ + api.BackendAnthropic, api.BackendOpenAI, api.BackendGemini, api.BackendDeepSeek, + } { + Expect(api.SupportedPermissionModes(backend)).To(BeEmpty(), string(backend)) + } + }) + + It("offers every posture on every claude transport", func() { + for _, backend := range []api.Backend{ + api.BackendClaudeCLI, api.BackendClaudeAgent, api.BackendClaudeCmux, + } { + Expect(api.SupportedPermissionModes(backend)).To(Equal(api.AllPermissionModes()), string(backend)) + } + }) + + It("drops dontAsk on codex and keeps the rest", func() { + for _, backend := range codexBackends { + Expect(api.SupportedPermissionModes(backend)).ToNot(ContainElement(api.PermissionDontAsk), string(backend)) + Expect(api.SupportedPermissionModes(backend)).To(ContainElement(api.PermissionPlan), string(backend)) + } + }) + }) + + // This is the finding the provenance dimension exists to express: a per-tool + // deny is enforceable on codex-agent — by omission from the tool list captain + // itself builds — even though codex has no --disallowedTools of its own. + // A single backend→bool cannot say that, and RequireToolPolicySupport refuses + // the whole run today because of it. + Describe("provenance", func() { + It("enforces deny on a caller tool but not an agent built-in, on codex-agent", func() { + caps := api.PermissionCapabilitiesFor(api.BackendCodexAgent) + Expect(caps.ToolPolicySupport(api.ProvenanceCaller, api.ToolPolicyDeny).Kind). + To(Equal(api.SupportNative)) + Expect(caps.ToolPolicySupport(api.ProvenanceAgent, api.ToolPolicyDeny).Kind). + To(Equal(api.SupportUnsupported)) + }) + + It("enforces deny on an agent built-in but not a caller tool, on claude-cli", func() { + caps := api.PermissionCapabilitiesFor(api.BackendClaudeCLI) + Expect(caps.ToolPolicySupport(api.ProvenanceAgent, api.ToolPolicyDeny).Kind). + To(Equal(api.SupportNative)) + Expect(caps.ToolPolicySupport(api.ProvenanceCaller, api.ToolPolicyDeny).Kind). + To(Equal(api.SupportUnsupported)) + }) + + It("declares caller-tool policy exactly where the registry declares caller tools", func() { + // The caller row is not free-standing: it is enforceable precisely when + // the adapter can carry caller-supplied tools at all. Deriving the + // expectation from the registry keeps the two declarations from drifting + // into disagreement. + for _, backend := range api.AllBackends() { + enforced := api.PermissionCapabilitiesFor(backend). + ToolPolicySupport(api.ProvenanceCaller, api.ToolPolicyDeny).Kind == api.SupportNative + Expect(enforced).To(Equal(api.SupportsCallerTools(backend)), string(backend)) + } + }) + + It("never claims per-tool policy over a third-party MCP server", func() { + // Captain can stop a server loading; it cannot filter that server's tools + // until a captain-owned gateway proxies them. Declaring it per provenance + // means that arriving is a data change in this row. + for _, backend := range api.AllBackends() { + Expect(api.ToolPolicyProvenances(backend)).ToNot(ContainElement(api.ProvenanceMCP), string(backend)) + } + }) + + It("reports ask as brokered wherever caller tools exist and unsupported elsewhere", func() { + for _, backend := range api.AllBackends() { + want := api.SupportUnsupported + if api.SupportsCallerTools(backend) { + want = api.SupportRequiresBroker + } + Expect(api.PermissionCapabilitiesFor(backend). + ToolPolicySupport(api.ProvenanceCaller, api.ToolPolicyAsk).Kind).To(Equal(want), string(backend)) + } + }) + }) + + // The resource axis is asymmetric today and the table has to say so, because + // the opposite direction is accepted and then dropped in both cases. + Describe("resources", func() { + It("silences MCP only where a provider actually sends the empty server set", func() { + for _, backend := range api.AllBackends() { + want := api.SupportUnsupported + if backend == api.BackendClaudeCLI || backend == api.BackendCodexAgent { + want = api.SupportNative + } + Expect(api.PermissionCapabilitiesFor(backend). + ResourceSupport(api.ResourceKindMCP, api.ResourceDisabled).Kind).To(Equal(want), string(backend)) + } + }) + + It("loads skills only on claude-cli, and never unloads them anywhere", func() { + for _, backend := range api.AllBackends() { + caps := api.PermissionCapabilitiesFor(backend) + want := api.SupportUnsupported + if backend == api.BackendClaudeCLI { + want = api.SupportNative + } + Expect(caps.ResourceSupport(api.ResourceKindSkills, api.ResourceEnabled).Kind). + To(Equal(want), string(backend)) + Expect(caps.ResourceSupport(api.ResourceKindSkills, api.ResourceDisabled).Kind). + To(Equal(api.SupportUnsupported), string(backend)) + } + }) + + It("declares permissions.plugins inert on every backend", func() { + // The evidence for deleting the field in the next phase: it is declared + // dead everywhere, and the matrix prints it. + for _, backend := range api.AllBackends() { + caps := api.PermissionCapabilitiesFor(backend) + for _, mode := range api.AllResourceModes() { + Expect(caps.ResourceSupport(api.ResourceKindPlugins, mode).Kind). + To(Equal(api.SupportUnsupported), string(backend)) + } + } + }) + }) + + Describe("tool vocabulary", func() { + It("gives each agent family its own built-in names", func() { + // F15: the permission catalog served Claude's tool names for every + // backend. codex has never had a tool called Bash. + Expect(api.PermissionCapabilitiesFor(api.BackendClaudeCLI).Tools).To(ContainElement("Bash")) + Expect(api.PermissionCapabilitiesFor(api.BackendCodexCLI).Tools).To(ContainElement("shell")) + Expect(api.PermissionCapabilitiesFor(api.BackendCodexCLI).Tools).ToNot(ContainElement("Bash")) + Expect(api.PermissionCapabilitiesFor(api.BackendGeminiCLI).Tools).To(ContainElement("run_shell_command")) + }) + + It("declares no built-in vocabulary for the API backends", func() { + // An API backend has no built-in tools at all: everything it can call is + // caller-supplied. An empty list here is the honest answer, and it is why + // the editor must not render an agent-tool tree there. + for _, backend := range []api.Backend{ + api.BackendAnthropic, api.BackendOpenAI, api.BackendGemini, api.BackendDeepSeek, + } { + Expect(api.PermissionCapabilitiesFor(backend).Tools).To(BeEmpty(), string(backend)) + } + }) + }) +}) diff --git a/pkg/api/registry/backend.go b/pkg/api/registry/backend.go index 286e8574..dab89b23 100644 --- a/pkg/api/registry/backend.go +++ b/pkg/api/registry/backend.go @@ -128,17 +128,34 @@ func AuthEnvVars(b Backend) []string { return p.SupportedEnvVars() } -// SupportsToolPolicy reports whether a backend can carry Permissions.Tools to -// the agent it drives. -func SupportsToolPolicy(b Backend) bool { +// CapsFor returns the whole capability cell for a backend. Provider.Caps is +// exported but `modes` is not, so a backend-keyed caller had to walk Providers +// itself — which is why RuntimeCatalog projects a handful of fields and drops +// the rest. ok is false for an unknown backend. +func CapsFor(b Backend) (ModeCapabilities, bool) { p, mode, ok := ProviderFor(b) if !ok { - return false + return ModeCapabilities{}, false } - caps, ok := p.Caps(mode) + return p.Caps(mode) +} + +// SupportsToolPolicy reports whether a backend can carry Permissions.Tools to +// the agent it drives. +func SupportsToolPolicy(b Backend) bool { + caps, ok := CapsFor(b) return ok && caps.ToolPolicy } +// SupportsCallerTools reports whether a backend can expose caller-supplied tools +// rather than only its own built-in ecosystem. It is the axis that decides +// whether a per-tool policy is enforceable by omission — captain builds that tool +// list itself, so it can drop a denied tool without the agent's cooperation. +func SupportsCallerTools(b Backend) bool { + caps, ok := CapsFor(b) + return ok && caps.CallerTools +} + // ToolPolicyBackends lists the backends that can carry a per-tool policy, in // canonical order, for help and error text. func ToolPolicyBackends() []Backend { diff --git a/pkg/api/runtime_catalog.go b/pkg/api/runtime_catalog.go index 40e619c0..daa20da9 100644 --- a/pkg/api/runtime_catalog.go +++ b/pkg/api/runtime_catalog.go @@ -58,6 +58,14 @@ type RuntimeModeEntry struct { // "provider deepseek", "backend claude-agent" — or "" when enabled. DisabledReason string `json:"disabledReason,omitempty"` Availability Availability `json:"availability"` + // Permissions is the declared permission surface for this backend: which + // postures it honours, which per-tool policies it can enforce and from which + // source, and which resources it can switch. It is served here, on the static + // catalog, rather than on the probe result, because it is a property of the + // adapter rather than of the machine — an unprobed backend still has an + // honest answer, and a client that reads it from a TTL'd cache would render + // an empty tree as "this backend supports nothing". + Permissions PermissionCapabilities `json:"permissions"` } // RuntimeCatalog projects the provider registry into the picker descriptor, @@ -99,6 +107,7 @@ func RuntimeCatalog() []RuntimeFamily { Disabled: disabled.Backend(caps.Backend), DisabledReason: reason, Availability: availability, + Permissions: PermissionCapabilitiesFor(caps.Backend), }) } out = append(out, family) diff --git a/pkg/cli/prompt_schema_build.go b/pkg/cli/prompt_schema_build.go index 9361c3ea..32675d77 100644 --- a/pkg/cli/prompt_schema_build.go +++ b/pkg/cli/prompt_schema_build.go @@ -229,6 +229,10 @@ func buildBackendsCatalog(adapters []AdapterStatus, argsByBackend map[api.Backen "kind": b.Kind(), "authenticated": a.Authenticated, "ready": a.Ready(), + // Declared, not probed: served on every entry including an unready + // one, so the editor can render "this backend cannot enforce a deny" + // rather than an empty tree that reads as "no tools here". + "permissions": api.PermissionCapabilitiesFor(b), } if env := api.AuthEnvVars(b); len(env) > 0 { entry["authEnvVars"] = env From 5fb5c084fa4ab9cc331e8a99b0315f167b49b9fc Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 21 Aug 2026 08:40:14 +0300 Subject: [PATCH 07/22] fix(api): describe the permissions block in the generated schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tools and MCP carry every field as json:"-" behind hand-written marshalers, so reflection reported {} for both — the two fields that decide what an agent may do validated anything at all and told a client nothing. The permission enums are plain string types, so they reflected to a bare string with no values, which is why the editor grew its own hardcoded copies that then went stale. Add invopop describers for PermissionMode, ToolPolicy, ResourceMode, Preset, Tools, MCP and ResourcePolicies. ResourcePolicies also declares the legacy string-array form it has always decoded, so a document using it no longer fails validation against captain's own schema. pkg/api hosts both invopop and clicky describer signatures and a type given the wrong one is silently ignored; the rule is the consumer, so anything reachable from api.Spec uses invopop. Claude-Session-Id: c3c29851-27f6-4cce-831d-ca61a67461d4 --- pkg/api/permissions_schema.go | 114 +++++++++++++++++++++ pkg/api/permissions_schema_test.go | 153 +++++++++++++++++++++++++++++ 2 files changed, 267 insertions(+) create mode 100644 pkg/api/permissions_schema.go create mode 100644 pkg/api/permissions_schema_test.go diff --git a/pkg/api/permissions_schema.go b/pkg/api/permissions_schema.go new file mode 100644 index 00000000..c760b810 --- /dev/null +++ b/pkg/api/permissions_schema.go @@ -0,0 +1,114 @@ +package api + +import "github.com/invopop/jsonschema" + +// Schema describers for the permissions block. +// +// Reflection cannot see any of this. The enums are plain string types, so they +// reflect to a bare `{"type": "string"}` that constrains nothing and offers a +// client no values to render. Tools and MCP are worse: every field on them is +// `json:"-"` behind a hand-written marshaler, so they reflect to `{}` — a schema +// that accepts anything and describes nothing, for the two fields that decide +// what an agent is allowed to do. +// +// These use invopop's *jsonschema.Schema, not clicky's map[string]any. Both +// conventions live in this package (SandboxRef uses invopop, the cmux CodexSandbox +// options use clicky) and a type handed the wrong one is silently ignored rather +// than rejected. The rule is the consumer: anything reachable from api.Spec is +// reflected by api.Schema through invopop and must use this signature. + +// enumValues lifts a typed enum slice into the `any` slice invopop wants. +func enumValues[T ~string](values []T) []any { + out := make([]any, len(values)) + for i, v := range values { + out[i] = string(v) + } + return out +} + +// JSONSchema declares the permission postures. Without it the editor had to +// carry its own copy of this list — twice, in TypeScript — and the copies went +// stale: `dontAsk` reached the enum here long before either of them. +func (PermissionMode) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Enum: enumValues(AllPermissionModes()), + Description: "Base permission posture. Which of these a backend honours is declared per backend; see the permissions capability matrix.", + } +} + +// JSONSchema declares the per-tool policy values. +func (ToolPolicy) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Enum: enumValues(AllToolPolicies()), + Description: "Authority for one tool: auto inherits the posture, ask requires approval, allow auto-approves, deny forbids.", + } +} + +// JSONSchema declares the resource availability values. This is a different axis +// from ToolPolicy — whether a resource is loaded at all, rather than what the +// agent may do with it — and keeping the two enums distinct in the schema is what +// stops an editor rendering them through one control. +func (ResourceMode) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Enum: enumValues(AllResourceModes()), + Description: "Whether this resource is loaded for the run.", + } +} + +// JSONSchema declares the presets. It is the enum a client renders as the +// picker, and the constraint that rejects a name nothing will expand. +func (Preset) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "string", + Enum: enumValues([]Preset{PresetEdit, PresetBare}), + Description: "Named bundle of safety defaults applied before per-tool rules.", + } +} + +// JSONSchema declares the wire form of Tools, which reflection reports as `{}` +// because Allow, Deny and Modes are all json:"-" behind MarshalJSON. The wire +// shape has always been a tool→policy map; this is the first time the schema +// says so. +func (Tools) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Type: "object", + Description: "Per-tool policy, keyed by tool name. An absent tool inherits the posture.", + AdditionalProperties: ToolPolicy("").JSONSchema(), + } +} + +// JSONSchema declares the wire form of MCP, which reflection also reports as +// `{}` for the same reason. +func (MCP) JSONSchema() *jsonschema.Schema { + properties := jsonschema.NewProperties() + properties.Set("disabled", &jsonschema.Schema{Type: "boolean", + Description: "Turn off all MCP servers. Honoured on claude-cli and codex-agent; accepted and dropped elsewhere."}) + properties.Set("servers", &jsonschema.Schema{Type: "array", Items: &jsonschema.Schema{Type: "string"}, + Description: "Allowlist subset of configured servers."}) + properties.Set("modes", &jsonschema.Schema{Type: "object", + Description: "Enable or disable named configured servers.", + AdditionalProperties: ResourceMode("").JSONSchema()}) + + return &jsonschema.Schema{ + Type: "object", + Description: "Model-Context-Protocol server controls.", + Properties: properties, + AdditionalProperties: jsonschema.FalseSchema, + } +} + +// JSONSchema declares ResourcePolicies, which decodes from either a name→mode +// map or a legacy string array. Reflection sees only the map half, so a document +// using the array form failed validation against its own schema. +func (ResourcePolicies) JSONSchema() *jsonschema.Schema { + return &jsonschema.Schema{ + Description: "Resource IDs mapped to enabled|disabled. A bare string array is accepted and means every listed item is enabled.", + OneOf: []*jsonschema.Schema{ + {Type: "object", AdditionalProperties: ResourceMode("").JSONSchema()}, + {Type: "array", Items: &jsonschema.Schema{Type: "string"}}, + }, + } +} diff --git a/pkg/api/permissions_schema_test.go b/pkg/api/permissions_schema_test.go new file mode 100644 index 00000000..4d8d5ae0 --- /dev/null +++ b/pkg/api/permissions_schema_test.go @@ -0,0 +1,153 @@ +package api + +import ( + "encoding/json" + "testing" +) + +// schemaDefs reflects the spec and returns its $defs, which is where every named +// type lands. +func schemaDefs(t *testing.T) map[string]any { + t.Helper() + data, err := SchemaJSON(&Spec{}) + if err != nil { + t.Fatalf("SchemaJSON: %v", err) + } + var doc map[string]any + if err := json.Unmarshal(data, &doc); err != nil { + t.Fatalf("schema is not valid JSON: %v", err) + } + defs, ok := doc["$defs"].(map[string]any) + if !ok { + t.Fatalf("schema has no $defs:\n%s", data) + } + return defs +} + +func def(t *testing.T, defs map[string]any, name string) map[string]any { + t.Helper() + d, ok := defs[name].(map[string]any) + if !ok { + t.Fatalf("schema has no definition for %s", name) + } + return d +} + +func enumOf(t *testing.T, schema map[string]any) []string { + t.Helper() + raw, ok := schema["enum"].([]any) + if !ok { + t.Fatalf("schema has no enum: %v", schema) + } + out := make([]string, len(raw)) + for i, v := range raw { + out[i], _ = v.(string) + } + return out +} + +// TestPermissionEnumsAreDescribed pins that every permission enum ships its +// values in the schema. Each of these is a plain string type, so reflection alone +// emits `{"type":"string"}` — a schema that validates "banana" and hands a client +// nothing to render, which is why the editor grew its own hardcoded copies. +// A named type reached only through additionalProperties is inlined rather than +// given a $defs entry, so each case says where its enum actually lands. +func TestPermissionEnumsAreDescribed(t *testing.T) { + defs := schemaDefs(t) + nested := func(outer string, path ...string) func(*testing.T) map[string]any { + return func(t *testing.T) map[string]any { + t.Helper() + schema := def(t, defs, outer) + for _, key := range path { + next, ok := schema[key].(map[string]any) + if !ok { + t.Fatalf("%s has no %q: %v", outer, key, schema) + } + schema = next + } + return schema + } + } + cases := []struct { + name string + at func(*testing.T) map[string]any + want []string + }{ + {"PermissionMode", func(t *testing.T) map[string]any { return def(t, defs, "PermissionMode") }, stringsOf(AllPermissionModes())}, + {"Preset", func(t *testing.T) map[string]any { return def(t, defs, "Preset") }, stringsOf([]Preset{PresetEdit, PresetBare})}, + {"ToolPolicy", nested("Tools", "additionalProperties"), stringsOf(AllToolPolicies())}, + {"ResourceMode", nested("MCP", "properties", "modes", "additionalProperties"), stringsOf(AllResourceModes())}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := enumOf(t, tc.at(t)) + if len(got) != len(tc.want) { + t.Fatalf("%s enum = %v, want %v", tc.name, got, tc.want) + } + for i := range got { + if got[i] != tc.want[i] { + t.Fatalf("%s enum = %v, want %v", tc.name, got, tc.want) + } + } + }) + } +} + +// TestToolsAndMCPAreNotEmptySchemas is the one that matters most. Tools and MCP +// carry every field as json:"-" behind hand-written marshalers, so reflection +// reported `{}` for both: the two fields that decide what an agent may do +// validated anything at all and told a client nothing. +func TestToolsAndMCPAreNotEmptySchemas(t *testing.T) { + defs := schemaDefs(t) + + tools := def(t, defs, "Tools") + if tools["type"] != "object" { + t.Fatalf("Tools should be an object map, got %v", tools) + } + policy, ok := tools["additionalProperties"].(map[string]any) + if !ok { + t.Fatalf("Tools should constrain its values to a policy, got %v", tools) + } + if got := enumOf(t, policy); len(got) != len(AllToolPolicies()) { + t.Fatalf("Tools values should enumerate every policy, got %v", got) + } + + mcp := def(t, defs, "MCP") + properties, ok := mcp["properties"].(map[string]any) + if !ok { + t.Fatalf("MCP should declare its properties, got %v", mcp) + } + for _, field := range []string{"disabled", "servers", "modes"} { + if _, ok := properties[field]; !ok { + t.Fatalf("MCP schema is missing %q: %v", field, properties) + } + } +} + +// TestResourcePoliciesAcceptsBothWireForms pins the array shorthand. It has +// always decoded, but reflection saw only the map half, so a document using the +// legacy list form failed validation against captain's own schema. +func TestResourcePoliciesAcceptsBothWireForms(t *testing.T) { + schema := def(t, schemaDefs(t), "ResourcePolicies") + oneOf, ok := schema["oneOf"].([]any) + if !ok || len(oneOf) != 2 { + t.Fatalf("ResourcePolicies should declare both wire forms, got %v", schema) + } + kinds := make(map[string]bool, 2) + for _, branch := range oneOf { + if m, ok := branch.(map[string]any); ok { + kinds[m["type"].(string)] = true + } + } + if !kinds["object"] || !kinds["array"] { + t.Fatalf("ResourcePolicies should accept an object or an array, got %v", oneOf) + } +} + +func stringsOf[T ~string](values []T) []string { + out := make([]string, len(values)) + for i, v := range values { + out[i] = string(v) + } + return out +} From f768f80f466653472259bfc00fcc70c5208a2abf Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 21 Aug 2026 08:40:26 +0300 Subject: [PATCH 08/22] feat(cli): add captain permissions matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Print the declared capability table so a silent drop is visible before a run is spent on it. Settings x backends, grouped one table per agent family: eleven backend names do not fit a terminal, and truncating them collapses claude-cli and claude-cmux into the same ambiguous header. Grouping also matches the question people actually ask — I picked claude, which transport honours this? Approximated cells get their own mark rather than rounding to yes or no; that conflation is what let dontAsk read as supported on codex, where it resolves to the read-only default. --notes prints the reason attached to every cell that is not honoured exactly. A separate namespace from sandbox presets, which already owns that word for container presets. Deliberately not excluded from REST/MCP auto-exposure: it reads a compile-time table and touches no host state. local_only_test pins POST /api/v1/permissions/matrix as the group's only route so a future mutating command cannot slip in beside it. The gavel fixture is the second pinning mechanism: 44 cases over the printed grid and the JSON, so a change to what captain does with a permissions block has to arrive as a reviewable diff to the declared truth. Claude-Session-Id: c3c29851-27f6-4cce-831d-ca61a67461d4 --- Taskfile.yaml | 1 + cmd/captain/local_only_test.go | 24 ++ cmd/captain/main.go | 11 + pkg/cli/permissions_matrix.go | 264 ++++++++++++++++++++ pkg/cli/permissions_matrix_test.go | 167 +++++++++++++ pkg/cli/testdata/permissions_matrix_test.md | 112 +++++++++ 6 files changed, 579 insertions(+) create mode 100644 pkg/cli/permissions_matrix.go create mode 100644 pkg/cli/permissions_matrix_test.go create mode 100644 pkg/cli/testdata/permissions_matrix_test.md diff --git a/Taskfile.yaml b/Taskfile.yaml index e4ea20d1..7f4abc11 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -134,6 +134,7 @@ tasks: deps: [build] cmds: - PATH="$PWD/.bin:$PATH" gavel fixtures pkg/cli/testdata/history_test.md + - PATH="$PWD/.bin:$PATH" gavel fixtures pkg/cli/testdata/permissions_matrix_test.md install: desc: Build and install captain to the default Go bin diff --git a/cmd/captain/local_only_test.go b/cmd/captain/local_only_test.go index c828fcf3..80fb704b 100644 --- a/cmd/captain/local_only_test.go +++ b/cmd/captain/local_only_test.go @@ -88,4 +88,28 @@ var _ = Describe("REST executor exposure", func() { Expect(routed(mux, http.MethodGet, "/api/v1/sessions")).To(BeTrue(), "executor registered no routes at all; the exclusion table proves nothing") }) + + // `permissions matrix` is deliberately NOT excluded. It reads a compile-time + // table, touches no host state, and answers the question a client most needs + // answered before it builds a permissions block — so publishing it is the + // point, not an oversight. This spec records that as a decision rather than + // leaving the absence from the exclusion table ambiguous. + It("routes the permission capability matrix", func() { + mux := newExecutorMux() + var found []string + for _, path := range []string{"/api/v1/permissions", "/api/v1/permissions/matrix"} { + for _, method := range []string{ + http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete, + } { + if routed(mux, method, path) { + found = append(found, method+" "+path) + } + } + } + // Pinned exactly rather than "at least one": the group must publish one + // read and nothing else, so a future `permissions set`-style command + // cannot slip a mutating route in beside it unnoticed. + Expect(found).To(Equal([]string{"POST /api/v1/permissions/matrix"}), + "the declared capability matrix is read-only and is meant to be the only route this group publishes") + }) }) diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 66764eaf..0b44472e 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -139,6 +139,17 @@ func newRootCommand() *cobra.Command { psCmd.Long = "Detect running claude/codex agent processes (via ps + lsof), resolve each one's session id, sub-agent ids, cmux surface (from CMUX_* env vars), and last activity, then augment with cached token/cost/context data. Only currently-active sessions are listed." clicky.AddNamedCommandWithContext("ps", sessionsCmd, cli.PSOptions{}, cli.RunPS).Short = psCmd.Short + // A separate namespace from `sandbox presets`, which already owns that word + // for container presets. Read-only: it prints the declared capability table + // and probes nothing. + permissionsCmd := &cobra.Command{ + Use: "permissions", + Short: "Inspect what each backend can do with a permissions block", + } + rootCmd.AddCommand(permissionsCmd) + clicky.AddNamedCommand("matrix", permissionsCmd, cli.PermissionsMatrixOptions{}, cli.RunPermissionsMatrix).Short = + "Print the declared permission capability matrix (settings × backends)" + sandboxCmd := &cobra.Command{ Use: "sandbox", Short: "Sandbox configuration tools", diff --git a/pkg/cli/permissions_matrix.go b/pkg/cli/permissions_matrix.go new file mode 100644 index 00000000..8738f025 --- /dev/null +++ b/pkg/cli/permissions_matrix.go @@ -0,0 +1,264 @@ +package cli + +import ( + "fmt" + "slices" + "sort" + "strings" + + clickyapi "github.com/flanksource/clicky/api" + + "github.com/flanksource/captain/pkg/api" +) + +// `captain permissions matrix` prints the declared permission capability table: +// which posture each backend honours, which per-tool policies it can enforce and +// from which source, and which resources it can switch. +// +// It is a reporting command over static data — nothing is probed, nothing is run +// — and that is the point. The table is what makes a silent drop visible before a +// run is spent on it, and the golden fixture makes every later change to that +// truth an explicit diff in review rather than a behaviour change nobody noticed. + +type PermissionsMatrixOptions struct { + Backend string `flag:"backend" help:"Restrict the matrix to one backend" short:"b"` + Provenance string `flag:"provenance" help:"Tool-policy source to show: agent, caller, or mcp" default:"agent" short:"p"` + Notes bool `flag:"notes" help:"Print the caveat attached to each approximated or unsupported cell" short:"n"` +} + +// PermissionsMatrixResult is the whole declaration. The JSON form is per-backend +// because that is how a client consumes it; the pretty form transposes to +// settings × backends because that is how a human compares them. +type PermissionsMatrixResult struct { + Provenance string `json:"provenance"` + Backends []PermissionsMatrixEntry `json:"backends"` + Notes []PermissionsMatrixNote `json:"notes,omitempty"` + legend map[api.SupportKind]string +} + +// PermissionsMatrixEntry is one backend's row, carrying the served capability +// object verbatim so `--json` and the HTTP catalog cannot disagree. +type PermissionsMatrixEntry struct { + Backend string `json:"backend"` + Kind string `json:"kind"` + Permissions api.PermissionCapabilities `json:"permissions"` +} + +// PermissionsMatrixNote is one caveat, addressed to a cell. +type PermissionsMatrixNote struct { + Backend string `json:"backend" pretty:"label=Backend,table"` + Setting string `json:"setting" pretty:"label=Setting,table"` + Support string `json:"support" pretty:"label=Support,table"` + Note string `json:"note" pretty:"label=Note,table"` +} + +func RunPermissionsMatrix(opts PermissionsMatrixOptions) (any, error) { + provenance, err := parseProvenance(opts.Provenance) + if err != nil { + return nil, err + } + backends, err := matrixBackends(opts.Backend) + if err != nil { + return nil, err + } + + result := PermissionsMatrixResult{Provenance: string(provenance), legend: supportGlyphs()} + for _, backend := range backends { + result.Backends = append(result.Backends, PermissionsMatrixEntry{ + Backend: string(backend), + Kind: backend.Kind(), + Permissions: api.PermissionCapabilitiesFor(backend), + }) + } + if opts.Notes { + result.Notes = collectMatrixNotes(backends, provenance) + } + return result, nil +} + +func parseProvenance(s string) (api.ToolProvenance, error) { + want := api.ToolProvenance(strings.ToLower(strings.TrimSpace(s))) + if want == "" { + return api.ProvenanceAgent, nil + } + if slices.Contains(api.AllToolProvenances(), want) { + return want, nil + } + names := make([]string, len(api.AllToolProvenances())) + for i, p := range api.AllToolProvenances() { + names[i] = string(p) + } + return "", fmt.Errorf("unknown tool provenance %q (valid: %s)", s, strings.Join(names, ", ")) +} + +func matrixBackends(selector string) ([]api.Backend, error) { + if strings.TrimSpace(selector) == "" { + return api.AllBackends(), nil + } + want := api.Backend(strings.TrimSpace(selector)) + if !slices.Contains(api.AllBackends(), want) { + return nil, fmt.Errorf("unknown backend %q (valid: %s)", selector, api.BackendList()) + } + return []api.Backend{want}, nil +} + +// supportGlyphs keeps the four kinds distinguishable in a dense grid, using the +// ✓/✗/— vocabulary the rest of captain's output already speaks. A cell that is +// merely approximated must not look like a cell honoured exactly — that +// conflation is what let `dontAsk` read as supported on codex — so it gets its +// own mark rather than rounding to one of the two ends. +func supportGlyphs() map[api.SupportKind]string { + return map[api.SupportKind]string{ + api.SupportNative: "✓", + api.SupportApproximated: "~", + api.SupportRequiresBroker: "?", + api.SupportUnsupported: "✗", + } +} + +func (r PermissionsMatrixResult) Pretty() clickyapi.Text { + if len(r.Backends) == 0 { + return clickyapi.Text{Content: "no backends"} + } + text := clickyapi.Text{}. + Append("Declared permission capabilities", "font-bold"). + Append(fmt.Sprintf(" (tool policies for the %q source)", r.Provenance), "text-gray-500") + + // One table per family rather than one 11-column grid: eleven backend names + // do not fit a terminal, and truncating them collapses claude-cli and + // claude-cmux into the same ambiguous header. Grouping also matches the + // question people actually ask — "I picked claude, which transport honours + // this?" — and the families come from RuntimeCatalog so the grouping is the + // same one clients see. + for _, family := range api.RuntimeCatalog() { + entries := r.entriesIn(family) + if len(entries) == 0 { + continue + } + text = text.NewLine().NewLine(). + Append(family.Family, "font-bold text-blue-400"). + NewLine(). + Add(r.table(entries)) + } + + text = text.NewLine(). + Append("✓ honoured exactly ~ approximated ? needs an approval broker ✗ not expressible", "text-gray-500") + if len(r.Notes) > 0 { + text = text.NewLine().NewLine().Append("Caveats", "font-bold").NewLine().Add(notesTable(r.Notes)) + } + return text +} + +// entriesIn selects this result's entries that belong to one family, in the +// family's own mode order. +func (r PermissionsMatrixResult) entriesIn(family api.RuntimeFamily) []PermissionsMatrixEntry { + var out []PermissionsMatrixEntry + for _, mode := range family.Modes { + for _, entry := range r.Backends { + if entry.Backend == mode.Backend { + out = append(out, entry) + } + } + } + return out +} + +func (r PermissionsMatrixResult) table(entries []PermissionsMatrixEntry) clickyapi.TextTable { + table := clickyapi.TextTable{ + Headers: clickyapi.TextList{textCell("Setting")}, + FieldNames: []string{"setting"}, + } + for i, entry := range entries { + table.FieldNames = append(table.FieldNames, matrixColumnField(i)) + table.Headers = append(table.Headers, textCell(entry.Backend)) + } + + add := func(setting string, value func(api.PermissionCapabilities) string) { + row := clickyapi.TableRow{"setting": cell(setting)} + for i, entry := range entries { + row[matrixColumnField(i)] = cell(value(entry.Permissions)) + } + table.Rows = append(table.Rows, row) + } + for _, setting := range matrixSettings(api.ToolProvenance(r.Provenance)) { + add(setting.label, func(c api.PermissionCapabilities) string { + return r.legend[setting.support(c).Kind] + }) + } + add("built-in tools", func(c api.PermissionCapabilities) string { + if len(c.Tools) == 0 { + return "—" + } + return fmt.Sprintf("%d", len(c.Tools)) + }) + return table +} + +// matrixSetting is one row of the matrix: a label and the cell it reads. Both +// the grid and the caveat list walk this, so a row can never appear in one with +// a label the other spells differently. +type matrixSetting struct { + label string + support func(api.PermissionCapabilities) api.Support +} + +func matrixSettings(provenance api.ToolProvenance) []matrixSetting { + var out []matrixSetting + for _, mode := range api.AllPermissionModes() { + out = append(out, matrixSetting{"mode " + string(mode), + func(c api.PermissionCapabilities) api.Support { return c.ModeSupport(mode) }}) + } + for _, policy := range api.AllToolPolicies() { + out = append(out, matrixSetting{"tool " + string(policy), + func(c api.PermissionCapabilities) api.Support { return c.ToolPolicySupport(provenance, policy) }}) + } + for _, kind := range api.AllResourceKinds() { + for _, mode := range api.AllResourceModes() { + out = append(out, matrixSetting{fmt.Sprintf("%s %s", kind, mode), + func(c api.PermissionCapabilities) api.Support { return c.ResourceSupport(kind, mode) }}) + } + } + return out +} + +func notesTable(notes []PermissionsMatrixNote) clickyapi.TextTable { + table := clickyapi.TextTable{ + Headers: clickyapi.TextList{textCell("Backend"), textCell("Setting"), textCell("Support"), textCell("Note")}, + FieldNames: []string{"backend", "setting", "support", "note"}, + } + for _, n := range notes { + table.Rows = append(table.Rows, clickyapi.TableRow{ + "backend": cell(n.Backend), "setting": cell(n.Setting), + "support": cell(n.Support), "note": cell(n.Note), + }) + } + return table +} + +// collectMatrixNotes gathers every explained cell. Sorting by backend then +// setting keeps the golden fixture stable regardless of map iteration. +func collectMatrixNotes(backends []api.Backend, provenance api.ToolProvenance) []PermissionsMatrixNote { + var out []PermissionsMatrixNote + for _, backend := range backends { + caps := api.PermissionCapabilitiesFor(backend) + for _, setting := range matrixSettings(provenance) { + support := setting.support(caps) + if support.Effects.Note == "" { + continue + } + out = append(out, PermissionsMatrixNote{ + Backend: string(backend), Setting: setting.label, + Support: string(support.Kind), Note: support.Effects.Note, + }) + } + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Backend != out[j].Backend { + return out[i].Backend < out[j].Backend + } + return out[i].Setting < out[j].Setting + }) + return out +} + +func matrixColumnField(index int) string { return fmt.Sprintf("b%d", index+1) } diff --git a/pkg/cli/permissions_matrix_test.go b/pkg/cli/permissions_matrix_test.go new file mode 100644 index 00000000..5e931380 --- /dev/null +++ b/pkg/cli/permissions_matrix_test.go @@ -0,0 +1,167 @@ +package cli + +import ( + "testing" + + clickyapi "github.com/flanksource/clicky/api" + + "github.com/flanksource/captain/pkg/api" +) + +func matrixResult(t *testing.T, opts PermissionsMatrixOptions) PermissionsMatrixResult { + t.Helper() + out, err := RunPermissionsMatrix(opts) + if err != nil { + t.Fatalf("RunPermissionsMatrix(%+v): %v", opts, err) + } + result, ok := out.(PermissionsMatrixResult) + if !ok { + t.Fatalf("RunPermissionsMatrix returned %T, want PermissionsMatrixResult", out) + } + return result +} + +// matrixTables collects every rendered table, since Pretty emits one per family. +func matrixTables(t *testing.T, text clickyapi.Text) []clickyapi.TextTable { + t.Helper() + var out []clickyapi.TextTable + for _, child := range text.Children { + if table, ok := child.(clickyapi.TextTable); ok { + out = append(out, table) + } + } + if len(out) == 0 { + t.Fatalf("no table child in %#v", text.Children) + } + return out +} + +// matrixCell reads one (setting, backend) cell out of the rendered tables, which +// is the grid a reader actually sees rather than the struct behind it. +func matrixCell(t *testing.T, text clickyapi.Text, setting, backend string) string { + t.Helper() + for _, table := range matrixTables(t, text) { + field := "" + for i, header := range table.Headers { + if header.String() == backend && i < len(table.FieldNames) { + field = table.FieldNames[i] + } + } + if field == "" { + continue + } + for _, row := range table.Rows { + if row["setting"].String() == setting { + return row[field].String() + } + } + t.Fatalf("no %q row for %s", setting, backend) + } + t.Fatalf("no column for backend %q", backend) + return "" +} + +// TestPermissionsMatrixCells pins the cells that carry a real finding, so a +// change in behaviour has to change a visible row rather than sliding through as +// an implementation detail. +func TestPermissionsMatrixCells(t *testing.T) { + agent := matrixResult(t, PermissionsMatrixOptions{}).Pretty() + caller := matrixResult(t, PermissionsMatrixOptions{Provenance: "caller"}).Pretty() + + cases := []struct { + name string + text clickyapi.Text + setting string + backend string + want string + }{ + // The four API backends never read permissions.mode; the editor offered + // it on all of them anyway. + {"API backends honour no posture", agent, "mode plan", "anthropic", "✗"}, + {"claude honours plan exactly", agent, "mode plan", "claude-cli", "✓"}, + // codex has no plan flag: the read-only sandbox is an approximation, and + // it must not render the same as claude's native support. + {"codex approximates plan", agent, "mode plan", "codex-agent", "~"}, + // dontAsk resolves to codex's read-only default — more prompting, not + // less — so it is declared unsupported rather than approximated. + {"codex cannot express dontAsk", agent, "mode dontAsk", "codex-cli", "✗"}, + + // The provenance split: the same policy, the same backend, two answers. + {"codex-agent cannot deny a built-in", agent, "tool deny", "codex-agent", "✗"}, + {"codex-agent can deny a caller tool", caller, "tool deny", "codex-agent", "✓"}, + {"claude-cli can deny a built-in", agent, "tool deny", "claude-cli", "✓"}, + {"claude-cli serves no caller tools", caller, "tool deny", "claude-cli", "✗"}, + {"ask needs a broker where caller tools exist", caller, "tool ask", "claude-agent", "?"}, + + // The resource axis is asymmetric in both directions. + {"claude-cli silences MCP", agent, "mcp disabled", "claude-cli", "✓"}, + {"claude-agent does not", agent, "mcp disabled", "claude-agent", "✗"}, + {"no backend enables MCP per server", agent, "mcp enabled", "codex-agent", "✗"}, + {"only claude-cli loads skills", agent, "skills enabled", "claude-cli", "✓"}, + {"nothing unloads a skill", agent, "skills disabled", "claude-cli", "✗"}, + {"plugins are inert", agent, "plugins enabled", "claude-cli", "✗"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := matrixCell(t, tc.text, tc.setting, tc.backend); got != tc.want { + t.Fatalf("%s / %s = %q, want %q", tc.backend, tc.setting, got, tc.want) + } + }) + } +} + +// TestPermissionsMatrixCoversEveryBackend keeps the printed grid total. A backend +// missing from the output would read as "not applicable" rather than as an +// undeclared cell. +func TestPermissionsMatrixCoversEveryBackend(t *testing.T) { + result := matrixResult(t, PermissionsMatrixOptions{}) + if len(result.Backends) != len(api.AllBackends()) { + t.Fatalf("matrix covers %d backends, want %d", len(result.Backends), len(api.AllBackends())) + } + printed := map[string]bool{} + for _, table := range matrixTables(t, result.Pretty()) { + for _, header := range table.Headers { + printed[header.String()] = true + } + } + for _, backend := range api.AllBackends() { + if !printed[string(backend)] { + t.Errorf("backend %s has a declared row but no printed column", backend) + } + } +} + +// TestPermissionsMatrixRejectsUnknownSelectors keeps a typo from silently +// producing an empty or full matrix — the same fail-loud rule the declaration +// itself follows. +func TestPermissionsMatrixRejectsUnknownSelectors(t *testing.T) { + if _, err := RunPermissionsMatrix(PermissionsMatrixOptions{Backend: "claude"}); err == nil { + t.Fatal("a backend name that is really a family should be refused") + } + if _, err := RunPermissionsMatrix(PermissionsMatrixOptions{Provenance: "builtin"}); err == nil { + t.Fatal("an unknown provenance should be refused") + } +} + +// TestPermissionsMatrixNotesExplainEveryNonNativeCell pins the --notes contract: +// anything not honoured exactly must arrive with a reason a reader can act on. +func TestPermissionsMatrixNotesExplainEveryNonNativeCell(t *testing.T) { + result := matrixResult(t, PermissionsMatrixOptions{Backend: "codex-agent", Notes: true}) + if len(result.Notes) == 0 { + t.Fatal("codex-agent has approximated and unsupported cells but produced no caveats") + } + byLabel := map[string]PermissionsMatrixNote{} + for _, note := range result.Notes { + byLabel[note.Setting] = note + } + for _, want := range []string{"mode dontAsk", "mode plan", "tool deny"} { + note, ok := byLabel[want] + if !ok { + t.Errorf("no caveat for %q", want) + continue + } + if note.Note == "" { + t.Errorf("caveat for %q has no explanation", want) + } + } +} diff --git a/pkg/cli/testdata/permissions_matrix_test.md b/pkg/cli/testdata/permissions_matrix_test.md new file mode 100644 index 00000000..056a1ab9 --- /dev/null +++ b/pkg/cli/testdata/permissions_matrix_test.md @@ -0,0 +1,112 @@ +--- +exec: bash +args: ["-c", "captain permissions matrix {{.flags}}"] +flags: "" +--- + +# Permission Capability Matrix + +The printed matrix is the contract: it declares what each backend actually does +with a `permissions` block, so a setting that is silently dropped shows up as a +row rather than as a surprise minutes into a run. + +These cases pin the cells that carry a real finding. Changing captain's +behaviour is meant to change a cell here, and that change is meant to be visible +in review. + +## Grid shape + +| Name | flags | CEL Validation | +|------|-------|----------------| +| groups by agent family | | stdout.contains("claude") && stdout.contains("codex") && stdout.contains("gemini") | +| names every backend | | stdout.contains("claude-cli") && stdout.contains("claude-agent") && stdout.contains("claude-cmux") && stdout.contains("codex-cli") && stdout.contains("codex-agent") && stdout.contains("codex-cmux") && stdout.contains("gemini-cli") | +| names every API backend | | stdout.contains("anthropic") && stdout.contains("openai") && stdout.contains("deepseek") | +| rows cover every posture | | stdout.contains("mode acceptEdits") && stdout.contains("mode bypassPermissions") && stdout.contains("mode dontAsk") && stdout.contains("mode plan") | +| rows cover both axes | | stdout.contains("tool deny") && stdout.contains("mcp disabled") && stdout.contains("skills enabled") | +| prints the legend | | stdout.contains("approximated") && stdout.contains("approval broker") | + +## Postures + +| Name | flags | CEL Validation | +|------|-------|----------------| +| claude honours every posture natively | --backend claude-cli --format json | json.backends[0].permissions.modes["plan"].kind == "native" && json.backends[0].permissions.modes["dontAsk"].kind == "native" | +| claude-cli omits the flag for the unset posture | --backend claude-cli --format json | json.backends[0].permissions.modes["default"].effects == null | +| claude-agent sends the unset posture explicitly | --backend claude-agent --format json | json.backends[0].permissions.modes["default"].effects.flag == "permissionMode=default" | +| codex approximates every posture it honours | --backend codex-cli --format json | json.backends[0].permissions.modes["plan"].kind == "approximated" && json.backends[0].permissions.modes["plan"].effects.sandbox == "read-only" | +| codex cannot express dontAsk | --backend codex-cli --format json | json.backends[0].permissions.modes["dontAsk"].kind == "unsupported" | +| every unsupported posture says why | --backend codex-cli --format json | json.backends[0].permissions.modes["dontAsk"].effects.note != "" | +| API backends honour no posture at all | --backend anthropic --format json | json.backends[0].permissions.modes.all(m, json.backends[0].permissions.modes[m].kind == "unsupported") | +| gemini bypass maps exactly to yolo | --backend gemini-cli --format json | json.backends[0].permissions.modes["bypassPermissions"].effects.flag == "--approval-mode yolo" | + +## Tool policy by provenance + +The same policy on the same backend has two different answers depending on where +the tool came from. codex has no tool filter of its own, but captain builds the +caller-tool list itself and simply omits a denied tool — so `deny` is enforced +there while `deny` on a codex built-in is not. + +| Name | flags | CEL Validation | +|------|-------|----------------| +| codex-agent cannot filter its own built-ins | --backend codex-agent --format json | json.backends[0].permissions.toolPolicies.agent["deny"].kind == "unsupported" | +| codex-agent enforces deny on a caller tool | --backend codex-agent --format json | json.backends[0].permissions.toolPolicies.caller["deny"].kind == "native" | +| claude-cli filters its built-ins | --backend claude-cli --format json | json.backends[0].permissions.toolPolicies.agent["deny"].kind == "native" | +| claude-cli serves no caller tools | --backend claude-cli --format json | json.backends[0].permissions.toolPolicies.caller["deny"].kind == "unsupported" | +| allow is an auto-approve list, not a restriction | --backend claude-cli --format json | json.backends[0].permissions.toolPolicies.agent["allow"].effects.note.contains("auto-approve") | +| ask on a caller tool needs a broker | --backend claude-agent --format json | json.backends[0].permissions.toolPolicies.caller["ask"].kind == "requires-broker" | +| no per-tool policy over a third-party MCP server | --backend claude-agent --format json | json.backends[0].permissions.toolPolicies.mcp["deny"].kind == "unsupported" | +| auto constrains nothing anywhere | --backend deepseek --format json | json.backends[0].permissions.toolPolicies.agent["auto"].kind == "native" | +| the grid shows the selected provenance | --provenance caller | stdout.contains("caller") | + +## Resources + +Both resource kinds are one-directional today, in opposite directions: MCP can +only be switched off, skills can only be switched on, and `plugins` does nothing +at all. + +| Name | flags | CEL Validation | +|------|-------|----------------| +| claude-cli silences ambient MCP | --backend claude-cli --format json | json.backends[0].permissions.resources.mcp["disabled"].kind == "native" | +| codex-agent silences ambient MCP | --backend codex-agent --format json | json.backends[0].permissions.resources.mcp["disabled"].kind == "native" | +| claude-agent accepts and drops it | --backend claude-agent --format json | json.backends[0].permissions.resources.mcp["disabled"].kind == "unsupported" | +| no backend enables MCP per server | --backend claude-cli --format json | json.backends[0].permissions.resources.mcp["enabled"].kind == "unsupported" | +| only claude-cli loads skills | --backend claude-cli --format json | json.backends[0].permissions.resources.skills["enabled"].kind == "native" | +| nothing unloads a skill | --backend claude-cli --format json | json.backends[0].permissions.resources.skills["disabled"].kind == "unsupported" | +| plugins are inert in both directions | --backend claude-cli --format json | json.backends[0].permissions.resources.plugins["enabled"].kind == "unsupported" && json.backends[0].permissions.resources.plugins["disabled"].kind == "unsupported" | + +## Built-in tool vocabulary + +The permission catalog served Claude's tool names for every backend. codex has +never had a tool called Bash. + +| Name | flags | CEL Validation | +|------|-------|----------------| +| claude names its own tools | --backend claude-cli --format json | json.backends[0].permissions.tools.exists(t, t == "Bash") && json.backends[0].permissions.tools.exists(t, t == "WebFetch") | +| codex names its own tools | --backend codex-cli --format json | json.backends[0].permissions.tools.exists(t, t == "shell") && json.backends[0].permissions.tools.exists(t, t == "apply_patch") | +| codex has no Bash | --backend codex-cli --format json | !json.backends[0].permissions.tools.exists(t, t == "Bash") | +| gemini names its own tools | --backend gemini-cli --format json | json.backends[0].permissions.tools.exists(t, t == "run_shell_command") | +| API backends have no built-ins | --backend anthropic --format json | !has(json.backends[0].permissions.tools) | + +## Caveats + +| Name | flags | CEL Validation | +|------|-------|----------------| +| notes are off by default | --backend codex-cli --format json | !has(json.notes) | +| notes explain the dontAsk inversion | --backend codex-cli --notes --format json | json.notes.exists(n, n.setting == "mode dontAsk" && n.support == "unsupported" && n.note.contains("read-only")) | +| notes explain the codex plan approximation | --backend codex-agent --notes --format json | json.notes.exists(n, n.setting == "mode plan" && n.support == "approximated") | +| notes are sorted for a stable diff | --notes --format json | json.notes.size() > 0 | +| the pretty form prints the caveat table | --backend codex-cli --notes | stdout.contains("Caveats") | + +## Selectors + +| Name | flags | CEL Validation | +|------|-------|----------------| +| default covers all eleven backends | --format json | json.backends.size() == 11 | +| backend narrows to one | --backend codex-agent --format json | json.backends.size() == 1 && json.backends[0].backend == "codex-agent" | + +A mistyped selector fails loud rather than quietly printing the full matrix or an +empty one — the same rule the declaration itself follows. + +| Name | flags | Exit Code | CEL Validation | +|------|-------|-----------|----------------| +| a family name is not a backend | --backend claude | 1 | stderr.contains("unknown backend") && stderr.contains("claude-cli") | +| an unknown provenance is refused | --provenance builtin | 1 | stderr.contains("unknown tool provenance") && stderr.contains("caller") | From 34f89a0f016bec32595e44ba084ac43e75854e67 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Fri, 21 Aug 2026 14:17:39 +0300 Subject: [PATCH 09/22] fix(agent): Enforce budget timeout on agent runner invocations Claude-Session-Id: a317c3d6-1ab9-47ea-8118-4897be0ec96c --- pkg/ai/agent/runner.go | 13 +++ pkg/ai/agent/runner_timeout_test.go | 106 ++++++++++++++++++++++++ pkg/ai/provider/claudeagent/provider.go | 7 ++ pkg/ai/provider/codex_appserver.go | 4 + pkg/api/budget.go | 26 +++++- 5 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 pkg/ai/agent/runner_timeout_test.go diff --git a/pkg/ai/agent/runner.go b/pkg/ai/agent/runner.go index d58338ac..3cbad8a0 100644 --- a/pkg/ai/agent/runner.go +++ b/pkg/ai/agent/runner.go @@ -236,6 +236,19 @@ type Result[T any] struct { // Verify hooks runs generate-only. func (r *Runner[T]) Run(ctx context.Context) (Result[T], error) { var zero Result[T] + // The spec's budget.timeout bounds the whole run, not one model call. Only + // pkg/cli applied it before, so every caller driving the Runner directly + // (gavel's `pr status --ai-fix`) ran unbounded and a wedged turn could hang + // indefinitely with a 45m ceiling declared and silently ignored. + timeout, err := r.Request.Budget.ParseTimeout() + if err != nil { + return zero, fmt.Errorf("agent: %w", err) + } + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } scope := r.Scope if scope == "" { scope = ScopeAll diff --git a/pkg/ai/agent/runner_timeout_test.go b/pkg/ai/agent/runner_timeout_test.go new file mode 100644 index 00000000..e624a917 --- /dev/null +++ b/pkg/ai/agent/runner_timeout_test.go @@ -0,0 +1,106 @@ +package agent + +import ( + "context" + "testing" + "time" + + "github.com/flanksource/captain/pkg/ai" + "github.com/flanksource/captain/pkg/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// hangingProvider holds its event stream open until the context is done — the +// shape of a wedged agent turn (a supervised provider process that stops +// answering, but never exits). +type hangingProvider struct{} + +func (h *hangingProvider) GetModel() string { return "hanging" } +func (h *hangingProvider) GetBackend() ai.Backend { return ai.Backend("fake") } + +func (h *hangingProvider) Execute(ctx context.Context, _ ai.Request) (*ai.Response, error) { + <-ctx.Done() + return nil, ctx.Err() +} + +func (h *hangingProvider) ExecuteStream(ctx context.Context, _ ai.Request) (<-chan ai.Event, error) { + ch := make(chan ai.Event) + go func() { + defer close(ch) + <-ctx.Done() + }() + return ch, nil +} + +// runWithin runs r and reports whether it returned inside limit. The run's +// context is cancelled on cleanup so a deliberately unbounded case does not +// leave the provider goroutine parked for the rest of the suite. +func runWithin(t *testing.T, r *Runner[string], limit time.Duration) (Result[string], error, bool) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(cancel) + + type outcome struct { + res Result[string] + err error + } + done := make(chan outcome, 1) + go func() { + res, err := r.Run(ctx) + done <- outcome{res, err} + }() + select { + case o := <-done: + return o.res, o.err, true + case <-time.After(limit): + return Result[string]{}, nil, false + } +} + +// TestRunner_BudgetTimeoutBoundsTheRun is the backstop for the reported +// `gavel pr status --ai-fix` hang: the prompt declared budget.timeout 45m, the +// request carried it, and nothing enforced it because only pkg/cli converted it +// to a deadline. A caller driving the Runner directly ran unbounded. +func TestRunner_BudgetTimeoutBoundsTheRun(t *testing.T) { + r := &Runner[string]{ + Provider: &hangingProvider{}, + Request: ai.Request{ + Prompt: api.Prompt{User: "go"}, + Budget: api.Budget{Timeout: "150ms"}, + }, + } + + _, _, returned := runWithin(t, r, 10*time.Second) + assert.True(t, returned, "Run ignored the declared budget.timeout and hung") +} + +// TestRunner_NoBudgetTimeoutStaysUnbounded guards the opt-in: an undeclared +// timeout must not invent a deadline that truncates a long legitimate run. +func TestRunner_NoBudgetTimeoutStaysUnbounded(t *testing.T) { + r := &Runner[string]{ + Provider: &hangingProvider{}, + Request: ai.Request{Prompt: api.Prompt{User: "go"}}, + } + + _, _, returned := runWithin(t, r, 300*time.Millisecond) + assert.False(t, returned, "Run applied a deadline that no budget declared") +} + +// TestRunner_RejectsUnparseableBudgetTimeout keeps a bad ceiling loud rather +// than silently falling back to a default the caller never asked for. +func TestRunner_RejectsUnparseableBudgetTimeout(t *testing.T) { + r := &Runner[string]{ + Provider: &fakeProvider{events: func(int) []ai.Event { + return []ai.Event{{Kind: ai.EventResult, Success: true}} + }}, + Request: ai.Request{ + Prompt: api.Prompt{User: "go"}, + Budget: api.Budget{Timeout: "45minutes"}, + }, + } + + _, err := r.Run(context.Background()) + require.Error(t, err) + assert.Contains(t, err.Error(), "45minutes") +} diff --git a/pkg/ai/provider/claudeagent/provider.go b/pkg/ai/provider/claudeagent/provider.go index 6ef7d7bb..00b9b467 100644 --- a/pkg/ai/provider/claudeagent/provider.go +++ b/pkg/ai/provider/claudeagent/provider.go @@ -361,6 +361,13 @@ func (p *Provider) provisionAndSupervise(req ai.Request) error { sup := proc.Supervise(exec.SuperviseOptions{ RestartPolicy: exec.RestartNo, + // One query() session stays alive for the whole run, so this process + // outlives any wait its caller makes. Left as a foreground task it + // deadlocks every global task drain issued mid-run — a commit hook + // generating an AI message between turns is the reported case: the drain + // blocks the work that would send `shutdown`, and the process keeps the + // drain from returning. Only Close stops it. + Task: exec.SupervisedTaskOptions{Background: true}, OnStarted: func(child *exec.Process) { p.procMu.Lock() p.proc = child diff --git a/pkg/ai/provider/codex_appserver.go b/pkg/ai/provider/codex_appserver.go index 3b3bf106..27364c63 100644 --- a/pkg/ai/provider/codex_appserver.go +++ b/pkg/ai/provider/codex_appserver.go @@ -242,6 +242,10 @@ func (c *CodexAppServer) ensureStarted(ctx context.Context) error { sup := newCodexAppServerProcess(c.cfg).WithStdioPipe().Supervise(exec.SuperviseOptions{ // No restart: a crash surfaces as EventError, never a silent retry. RestartPolicy: exec.RestartNo, + // The app-server outlives any wait its caller makes, so it must not be + // counted by a global task drain — see the claude-agent provider for the + // deadlock this avoids. + Task: exec.SupervisedTaskOptions{Background: true}, OnStarted: func(p *exec.Process) { process = p rpc := jsonrpc.New(p.Stdin(), p.StdoutReader(), true, jsonrpc.Handlers{ diff --git a/pkg/api/budget.go b/pkg/api/budget.go index f5d84ac5..26786214 100644 --- a/pkg/api/budget.go +++ b/pkg/api/budget.go @@ -1,6 +1,9 @@ package api -import "fmt" +import ( + "fmt" + "time" +) // Budget caps a run's resource consumption. The zero value is unbounded. type Budget struct { @@ -30,5 +33,26 @@ func (b Budget) Validate() error { if b.MaxTurns < 0 || b.MaxTurns > 100 { return fmt.Errorf("invalid maxTurns %d (valid: 0-100, 0=backend default)", b.MaxTurns) } + if _, err := b.ParseTimeout(); err != nil { + return err + } return nil } + +// ParseTimeout resolves Timeout to a duration. Zero means "no bound declared" — +// the caller's own default applies. An unparseable or non-positive value is an +// error rather than a silent fallback: a declared ceiling that quietly does +// nothing is worse than no ceiling, because it reads as enforced. +func (b Budget) ParseTimeout() (time.Duration, error) { + if b.Timeout == "" { + return 0, nil + } + timeout, err := time.ParseDuration(b.Timeout) + if err != nil { + return 0, fmt.Errorf("invalid budget timeout %q: %w", b.Timeout, err) + } + if timeout <= 0 { + return 0, fmt.Errorf("invalid budget timeout %q (must be > 0)", b.Timeout) + } + return timeout, nil +} From d0dffa91d5e54d32d7814be773f45e0aebabaa9f Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sat, 22 Aug 2026 21:44:04 +0300 Subject: [PATCH 10/22] refactor(commit): skip git-ignored paths in commit attribution Runs that only touch git-ignored paths (scratch dirs) no longer fail over the caller's unrelated dirty files. committable() filters ignored paths; the refusal error lists only committable paths, improving diagnostic accuracy. --- pkg/ai/agent/commit/commit.go | 66 ++++++++++++++++- pkg/ai/agent/commit/git.go | 46 ++++++++++++ pkg/ai/agent/commit/stage_test.go | 119 ++++++++++++++++++++++++++++++ 3 files changed, 229 insertions(+), 2 deletions(-) diff --git a/pkg/ai/agent/commit/commit.go b/pkg/ai/agent/commit/commit.go index 065fc9b4..6372c922 100644 --- a/pkg/ai/agent/commit/commit.go +++ b/pkg/ai/agent/commit/commit.go @@ -340,15 +340,77 @@ func (h *Hook) resolvePaths(hc *agent.HookContext, dir string) ([]string, api.Co return dirty, mode, nil } recorded := hc.Workspace().Changed - changed := attributable(dir, recordBase(hc), dirty, recorded) + stageable, ignored, err := committable(dir, recordBase(hc), recorded) + if err != nil { + return nil, mode, err + } + if len(ignored) > 0 { + // Never silent: a dropped edit that goes unmentioned reads as the agent + // having done nothing, which is the harder bug to chase of the two. + ai.LoggerFromContext(hc, fallbackLog).V(1).Infof( + "commit: skipping %d recorded edit(s) git ignores and cannot stage: %s", + len(ignored), strings.Join(elide(ignored, 3), ", ")) + } + if len(recorded) > 0 && len(stageable) == 0 { + // Every edit the run recorded is a path this repo ignores, so there was + // never anything here to commit. The dirty tree is somebody else's work, + // not a mystery — refusing would fail a run that did nothing wrong. + return nil, mode, nil + } + changed := attributable(dir, recordBase(hc), dirty, stageable) if len(changed) == 0 { // Staging the tree anyway would sweep the caller's own uncommitted work // into an agent commit. Refusing is the whole point of the changed mode. - return nil, mode, unattributableErr(dir, dirty, recorded) + return nil, mode, unattributableErr(dir, dirty, stageable) } return changed, mode, nil } +// committable splits the recorded set into the paths this tree could commit and +// the ones git ignores. An ignored path can never appear in `git status` and +// `git add` refuses it outright, so counting it toward attribution turns "the +// agent only touched scratch files" into a refusal — which is how a run whose +// every edit landed in .tmp/ came to fail on the caller's one unrelated dirty +// file. +// +// Paths outside dir are kept rather than dropped: "the run edited another tree" +// is a real diagnosis the refusal exists to report, and it is only the +// `git check-ignore` query they have to be held back from, which fails outright +// on a path it cannot place in the repository. +// +// Both returned slices are in the caller's own namespace, not the repo-relative +// one used for the query, because attributable resolves them against recordBase. +func committable(dir, base string, recorded []string) (stageable, skipped []string, err error) { + if len(recorded) == 0 { + return nil, nil, nil + } + rel := make(map[string]string, len(recorded)) + inRepo := make([]string, 0, len(recorded)) + for _, r := range recorded { + p, relErr := filepath.Rel(dir, resolveAgainst(base, r)) + if relErr != nil || p == ".." || strings.HasPrefix(p, ".."+string(filepath.Separator)) { + continue + } + rel[r] = p + inRepo = append(inRepo, p) + } + ignored, err := ignoredPaths(dir, inRepo) + if err != nil { + return nil, nil, err + } + stageable = make([]string, 0, len(recorded)) + for _, r := range recorded { + if p, ok := rel[r]; ok { + if _, skip := ignored[p]; skip { + skipped = append(skipped, r) + continue + } + } + stageable = append(stageable, r) + } + return stageable, skipped, nil +} + // stageMode resolves the staging policy: an isolated run holds nothing but the // agent's work, so it commits the whole tree; a run sharing the caller's tree is // restricted to the files the agent is recorded as having touched. diff --git a/pkg/ai/agent/commit/git.go b/pkg/ai/agent/commit/git.go index 34ce4b90..bd939dac 100644 --- a/pkg/ai/agent/commit/git.go +++ b/pkg/ai/agent/commit/git.go @@ -94,6 +94,52 @@ func parseStatusZ(out string) []string { return paths } +// ignoredPaths returns the subset of relPaths git ignores in dir, asking git +// itself rather than reimplementing its pattern language — so .gitignore files at +// any depth, .git/info/exclude and core.excludesFile are all honoured, and a +// linked worktree resolves the same way git does. +// +// --no-index widens the answer to tracked files that also match an ignore rule, +// and that is deliberate: `git add` refuses such a path outright ("The following +// paths are ignored by one of your .gitignore files ... use -f"), tracked or not, +// so a force-added build bundle the agent rebuilt is dirty in `git status` yet +// impossible to stage. Without the flag it would be treated as attributable and +// the whole run would die inside stage() rather than committing what it could. +// +// Every path must be inside dir: git fails the whole invocation with exit 128 on +// an out-of-tree path, so callers filter those out first. +// +// The -z --stdin form is used for the same reason as in dirtyPaths — paths with +// spaces round-trip verbatim instead of arriving quoted and escaped. +func ignoredPaths(dir string, relPaths []string) (map[string]struct{}, error) { + ignored := make(map[string]struct{}, len(relPaths)) + if len(relPaths) == 0 { + return ignored, nil + } + res := exec.NewExec("git", "check-ignore", "--no-index", "-z", "--stdin"). + WithCwd(dir). + WithStdin(strings.NewReader(strings.Join(relPaths, "\x00"))). + Run().Result() + // Exit 1 is `git check-ignore` reporting that nothing matched — an answer, not + // a failure. Only a higher code (128, fatal) is a real error, and res.Error is + // consulted after the code because clicky reports every non-zero exit as one. + if res.ExitCode == 1 { + return ignored, nil + } + if res.ExitCode != 0 { + return nil, fmt.Errorf("git check-ignore in %s: exit %d: %s", dir, res.ExitCode, strings.TrimSpace(res.Stderr)) + } + if res.Error != nil { + return nil, fmt.Errorf("git check-ignore in %s: %w: %s", dir, res.Error, strings.TrimSpace(res.Stderr)) + } + for _, p := range strings.Split(res.Stdout, "\x00") { + if p != "" { + ignored[p] = struct{}{} + } + } + return ignored, nil +} + // stage adds exactly the named paths. The pathspecs are what bound the commit — // unlike a bare `git add --all` this cannot pick up a file the policy did not // select. diff --git a/pkg/ai/agent/commit/stage_test.go b/pkg/ai/agent/commit/stage_test.go index dba3ece1..71df4c6a 100644 --- a/pkg/ai/agent/commit/stage_test.go +++ b/pkg/ai/agent/commit/stage_test.go @@ -137,6 +137,125 @@ func TestRefusalReportsWhatWasRecorded(t *testing.T) { } } +// TestIgnoredRecordedEditsAreNotARefusal: an agent whose every edit landed in a +// directory the repo ignores has nothing to commit, and that is an outcome, not a +// refusal. Counting those paths toward attribution used to fail the run over the +// caller's unrelated dirty file — a scratch-writing agent could not coexist with +// any uncommitted work at all. +func TestIgnoredRecordedEditsAreNotARefusal(t *testing.T) { + dir := newRepo(t) + hc := shared(dir) + h := New(api.Commit{On: api.CommitOnAgent, Message: "feat: scratch only"}) + + write(t, dir, ".gitignore", ".tmp/\n") + mustGit(t, dir, "add", ".gitignore") + mustGit(t, dir, "commit", "-m", "chore: ignore scratch") + before := commitCount(t, dir) + + write(t, dir, ".tmp/status.json", "{}\n") + write(t, dir, "mine.go", "// the caller's own uncommitted work\n") + changed(hc, ".tmp/status.json") + + if err := h.Post(hc, agent.PhaseAgent); err != nil { + t.Fatalf("a run that only wrote ignored files should be a no-op: %v", err) + } + if got := commitCount(t, dir); got != before { + t.Errorf("cut %d commit(s), want none; subjects: %v", got-before, subjects(t, dir)) + } + if isClean(t, dir) { + t.Error("the caller's mine.go was swept into a commit; it must still be dirty") + } +} + +// TestRefusalNamesOnlyCommittableRecordedEdits: when the refusal is still right, +// the paths it names have to be ones the reader can act on. Listing the ignored +// scratch files alongside them — or instead of them, once elide caps the list — +// points the investigation at files that were never candidates. +func TestRefusalNamesOnlyCommittableRecordedEdits(t *testing.T) { + dir := newRepo(t) + hc := shared(dir) + h := New(api.Commit{On: api.CommitOnAgent, Message: "feat: nothing dirty of mine"}) + + write(t, dir, ".gitignore", ".tmp/\n") + write(t, dir, "src/real.go", "package src\n") + mustGit(t, dir, "add", ".gitignore", "src/real.go") + mustGit(t, dir, "commit", "-m", "chore: seed src") + + write(t, dir, ".tmp/scratch.json", "{}\n") + write(t, dir, "mine.go", "// the caller's own uncommitted work\n") + changed(hc, ".tmp/scratch.json", "src/real.go") + + err := h.Post(hc, agent.PhaseAgent) + if err == nil { + t.Fatalf("expected a refusal; instead the tree was committed as %v", subjects(t, dir)) + } + if !strings.Contains(err.Error(), "src/real.go") { + t.Errorf("error should name the committable recorded edit, got: %v", err) + } + if strings.Contains(err.Error(), ".tmp/scratch.json") { + t.Errorf("error should not name an ignored path that was never a candidate, got: %v", err) + } +} + +// TestTrackedButIgnoredEditsAreSkippedNotFatal covers the awkward middle case a +// force-added build bundle creates: it is tracked, so `git status` reports it +// dirty, but `git add` still refuses it for matching an ignore rule. Attributing +// it therefore does not produce a commit — it kills the run inside stage(). It is +// dropped instead, and a sibling edit in the same turn still gets committed. +func TestTrackedButIgnoredEditsAreSkippedNotFatal(t *testing.T) { + dir := newRepo(t) + hc := shared(dir) + h := New(api.Commit{On: api.CommitOnAgent, Gates: api.CommitGatesNone, Message: "feat: rebuild"}) + + write(t, dir, ".gitignore", "dist/\n") + write(t, dir, "dist/bundle.js", "// v1\n") + write(t, dir, "src/app.go", "package src\n") + mustGit(t, dir, "add", ".gitignore", "src/app.go") + mustGit(t, dir, "add", "-f", "dist/bundle.js") + mustGit(t, dir, "commit", "-m", "chore: seed bundle") + + write(t, dir, "dist/bundle.js", "// v2\n") + write(t, dir, "src/app.go", "package src // rebuilt\n") + changed(hc, "dist/bundle.js", "src/app.go") + + if err := h.Post(hc, agent.PhaseAgent); err != nil { + t.Fatalf("agent phase: %v", err) + } + head := filesInHead(t, dir, "HEAD") + if !contains(head, "src/app.go") { + t.Errorf("the stageable edit should have been committed, HEAD holds: %v", head) + } + if contains(head, "dist/bundle.js") { + t.Errorf("git add refuses an ignored path, so it must not be in the commit: %v", head) + } +} + +// TestOnlyTrackedIgnoredEditIsANoOp: when the ignored bundle is the *only* thing +// the turn touched there is nothing left to commit, and that has to be an outcome +// rather than a `git add` failure that takes the whole run down. +func TestOnlyTrackedIgnoredEditIsANoOp(t *testing.T) { + dir := newRepo(t) + hc := shared(dir) + h := New(api.Commit{On: api.CommitOnAgent, Gates: api.CommitGatesNone, Message: "chore: rebuild"}) + + write(t, dir, ".gitignore", "dist/\n") + write(t, dir, "dist/bundle.js", "// v1\n") + mustGit(t, dir, "add", ".gitignore") + mustGit(t, dir, "add", "-f", "dist/bundle.js") + mustGit(t, dir, "commit", "-m", "chore: seed bundle") + before := commitCount(t, dir) + + write(t, dir, "dist/bundle.js", "// v2\n") + changed(hc, "dist/bundle.js") + + if err := h.Post(hc, agent.PhaseAgent); err != nil { + t.Fatalf("a turn that only touched an unstageable path should be a no-op: %v", err) + } + if got := commitCount(t, dir); got != before { + t.Errorf("cut %d commit(s), want none; subjects: %v", got-before, subjects(t, dir)) + } +} + // TestIsolatedTreeCommitsEverything: in a worktree the branch is disposable and // holds no work but the agent's, including files it changed via a shell command // rather than an edit tool — which is why staging there is not restricted to the From c80210829035be1eed74ecfa7c8fe94cc4390cb9 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 23 Aug 2026 10:54:42 +0300 Subject: [PATCH 11/22] refactor(aichat): refactor rename runtime_settings to runtime_profile --- pkg/aichat/{runtime_settings.go => runtime_profile.go} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename pkg/aichat/{runtime_settings.go => runtime_profile.go} (100%) diff --git a/pkg/aichat/runtime_settings.go b/pkg/aichat/runtime_profile.go similarity index 100% rename from pkg/aichat/runtime_settings.go rename to pkg/aichat/runtime_profile.go From 03bea37df1dca4259286ad71be6954f711dfa7a7 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 23 Aug 2026 11:02:58 +0300 Subject: [PATCH 12/22] refactor: unify tool permission vocabulary and layering --- cmd/captain/main.go | 4 + pkg/ai/callertools/credential_ginkgo_test.go | 2 +- pkg/ai/callertools/runtime.go | 4 +- pkg/ai/callertools/runtime_ginkgo_test.go | 20 +- pkg/ai/prompt/prompt_test.go | 13 +- pkg/ai/prompt/testdata/options.prompt | 6 + pkg/ai/provider/caller_tools_ginkgo_test.go | 6 +- pkg/ai/provider/claude_cli_test.go | 5 +- pkg/ai/provider/claudeagent/caller_tools.go | 2 +- .../claudeagent/caller_tools_ginkgo_test.go | 8 +- .../provider/claudeagent/permissions_test.go | 22 +- pkg/ai/provider/codex_appserver.go | 2 +- pkg/ai/provider/genkit/options.go | 3 +- .../genkit/tool_approval_ginkgo_test.go | 6 +- .../genkit/tool_lifecycle_ginkgo_test.go | 6 +- .../genkit/tool_preferences_ginkgo_test.go | 31 +- pkg/ai/provider/genkit/tools.go | 8 +- pkg/ai/provider/genkit/tools_test.go | 11 +- .../provider/permission_capabilities_test.go | 2 +- pkg/ai/tools/definitions_ginkgo_test.go | 82 +++-- pkg/ai/tools/preferences_ginkgo_test.go | 14 +- pkg/ai/tools/tools.go | 277 +++++++--------- .../aimock_lifecycle_integration_test.go | 2 +- pkg/aichat/approval_execution.go | 2 +- .../database_threads_integration_test.go | 4 +- pkg/aichat/execution_authority_ginkgo_test.go | 4 +- pkg/aichat/execution_database.go | 2 +- .../execution_database_integration_test.go | 2 +- pkg/aichat/mcp_provider.go | 4 +- pkg/aichat/service.go | 2 +- pkg/aichat/service_ginkgo_test.go | 8 +- pkg/aichat/session_title.go | 2 +- pkg/aichat/session_title_ginkgo_test.go | 2 +- pkg/aichat/wire_ginkgo_test.go | 6 +- pkg/api/enums.go | 109 ++++--- pkg/api/is_empty_test.go | 4 +- pkg/api/permissions.go | 213 ++++++------ pkg/api/permissions_schema.go | 25 +- pkg/api/permissions_test.go | 18 +- pkg/api/pretty.go | 4 +- pkg/api/spec.go | 12 +- pkg/api/spec_marshal_ginkgo_test.go | 2 +- pkg/api/spec_merge_differential_test.go | 31 +- pkg/api/spec_test.go | 2 +- pkg/api/tool_policy_support_test.go | 49 ++- pkg/api/tool_preferences_ginkgo_test.go | 51 ++- pkg/api/toolcatalog_ginkgo_test.go | 67 ++++ pkg/api/tooldef.go | 64 +++- pkg/api/toolpolicy.go | 283 ++++++++++++++++ pkg/api/toolpolicy_ginkgo_test.go | 306 ++++++++++++++++++ pkg/cli/ai.go | 2 +- pkg/cli/ai_prompt_file.go | 4 +- pkg/cli/ai_test.go | 10 +- pkg/cli/prompt_help.go | 149 +++++++++ pkg/cli/prompt_help_content.go | 150 +++++++++ pkg/cli/prompt_help_ginkgo_test.go | 93 ++++++ pkg/cli/prompt_render.go | 29 +- pkg/cli/prompt_render_test.go | 7 +- pkg/cli/prompt_spec.go | 14 - pkg/cli/serve_chat.go | 12 +- pkg/cli/webapp/src/ChatLayer.tsx | 2 +- .../caller_tool_legacy_policy_test.go | 50 +++ pkg/database/caller_tool_store.go | 75 +++-- .../caller_tool_store_integration_test.go | 2 +- 64 files changed, 1831 insertions(+), 582 deletions(-) create mode 100644 pkg/api/toolcatalog_ginkgo_test.go create mode 100644 pkg/api/toolpolicy.go create mode 100644 pkg/api/toolpolicy_ginkgo_test.go create mode 100644 pkg/cli/prompt_help.go create mode 100644 pkg/cli/prompt_help_content.go create mode 100644 pkg/cli/prompt_help_ginkgo_test.go create mode 100644 pkg/database/caller_tool_legacy_policy_test.go diff --git a/cmd/captain/main.go b/cmd/captain/main.go index 0b44472e..f8975943 100644 --- a/cmd/captain/main.go +++ b/cmd/captain/main.go @@ -368,6 +368,10 @@ func newRootCommand() *cobra.Command { fmt.Fprintf(os.Stderr, "failed to attach prompt schema flag: %v\n", err) os.Exit(1) } + if err := cli.AttachPromptHelp(rootCmd); err != nil { + fmt.Fprintf(os.Stderr, "failed to attach prompt help: %v\n", err) + os.Exit(1) + } mcpConfig := &mcp.Config{ Name: "captain", diff --git a/pkg/ai/callertools/credential_ginkgo_test.go b/pkg/ai/callertools/credential_ginkgo_test.go index 203caf79..5ce3254b 100644 --- a/pkg/ai/callertools/credential_ginkgo_test.go +++ b/pkg/ai/callertools/credential_ginkgo_test.go @@ -18,7 +18,7 @@ var _ = Describe("Caller-tool credential lease", func() { var revoked atomic.Bool runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "account_edit", DefaultPermission: api.ToolModeOn, + Name: "account_edit", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return map[string]any{"updated": true}, nil }, diff --git a/pkg/ai/callertools/runtime.go b/pkg/ai/callertools/runtime.go index acbf2be2..0d675e64 100644 --- a/pkg/ai/callertools/runtime.go +++ b/pkg/ai/callertools/runtime.go @@ -39,6 +39,8 @@ const ( type Options struct { Definitions []api.ToolDefinition Preferences api.ToolPreferences + // Policy is the ordered, last-match-wins rule list layered after Preferences. + Policy api.PermissionPolicy CanUseTool api.PermissionFunc SessionID string ExpiresAt time.Time @@ -85,7 +87,7 @@ func New(options Options) (*Runtime, error) { if options.ApprovalTimeout == 0 { options.ApprovalTimeout = defaultApprovalTimeout } - definitions, err := aitools.ResolveDefinitions(options.Definitions, options.Preferences) + definitions, err := aitools.ResolveDefinitions(options.Definitions, aitools.ResolveOptions{Preferences: options.Preferences, Policy: options.Policy}) if err != nil { return nil, err } diff --git a/pkg/ai/callertools/runtime_ginkgo_test.go b/pkg/ai/callertools/runtime_ginkgo_test.go index aaa1c938..23cd24f1 100644 --- a/pkg/ai/callertools/runtime_ginkgo_test.go +++ b/pkg/ai/callertools/runtime_ginkgo_test.go @@ -25,20 +25,20 @@ var _ = Describe("Authenticated caller-tool runtime", func() { { Name: "invoice_get", Description: "Read an invoice", InputSchema: map[string]any{"type": "object", "properties": map[string]any{"id": map[string]any{"type": "string"}}}, - DefaultPermission: api.ToolModeOn, + DefaultPermission: api.ToolPolicyAllow, Handler: func(_ context.Context, input map[string]any) (any, error) { return map[string]any{"id": input["id"], "status": "draft"}, nil }, }, { - Name: "invoice_delete", DefaultPermission: api.ToolModeOn, + Name: "invoice_delete", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { hiddenCalls.Add(1) return "deleted", nil }, }, }, - Preferences: api.ToolPreferences{"invoice_delete": api.ToolModeOff}, + Preferences: api.ToolPreferences{"invoice_delete": api.ToolPolicyDeny}, SessionID: "captain-session-1", }) Expect(err).NotTo(HaveOccurred()) @@ -68,7 +68,7 @@ var _ = Describe("Authenticated caller-tool runtime", func() { var calls atomic.Int32 runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + Name: "invoice_update", DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { calls.Add(1) return input, nil @@ -103,7 +103,7 @@ var _ = Describe("Authenticated caller-tool runtime", func() { permissionRequests := make(chan api.PermissionRequest, 1) runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + Name: "invoice_update", DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { handledInput = input return input, nil @@ -166,7 +166,7 @@ var _ = Describe("Authenticated caller-tool runtime", func() { It("expires and explicitly revokes capabilities", func() { expiring, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "lookup", DefaultPermission: api.ToolModeOn, + Name: "lookup", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, }}, SessionID: "expiring-session", @@ -188,7 +188,7 @@ var _ = Describe("Authenticated caller-tool runtime", func() { var calls atomic.Int32 runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + Name: "invoice_update", DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { calls.Add(1) return nil, errors.New("must not execute") @@ -218,7 +218,7 @@ var _ = Describe("Authenticated caller-tool runtime", func() { var calls atomic.Int32 runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { calls.Add(1) return nil, errors.New("invoice unavailable") @@ -243,7 +243,7 @@ var _ = Describe("Authenticated caller-tool runtime", func() { var calls atomic.Int32 runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "invoice_update", DefaultPermission: api.ToolModeAsk, + Name: "invoice_update", DefaultPermission: api.ToolPolicyAsk, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{ @@ -340,7 +340,7 @@ func authenticatedStatus(endpoint api.CallerToolEndpoint) int { func newRuntime(sessionID, marker string) *callertools.Runtime { runtime, err := callertools.New(callertools.Options{ Definitions: []api.ToolDefinition{{ - Name: "identity", DefaultPermission: api.ToolModeOn, + Name: "identity", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return map[string]any{"session": marker}, nil }, diff --git a/pkg/ai/prompt/prompt_test.go b/pkg/ai/prompt/prompt_test.go index f01dafb3..8640f795 100644 --- a/pkg/ai/prompt/prompt_test.go +++ b/pkg/ai/prompt/prompt_test.go @@ -85,11 +85,22 @@ func TestRender_SpecFrontmatter(t *testing.T) { // Spec-native keys from the second parse. assert.Equal(t, api.PermissionAcceptEdits, req.Permissions.Mode) assert.Equal(t, []api.Preset{api.PresetEdit}, req.Permissions.Presets) - assert.Equal(t, []string{"Read", "Edit"}, req.Permissions.Tools.Allow) + assert.Equal(t, []string{"Edit", "Read"}, req.Permissions.Tools.AllowList()) assert.True(t, req.Permissions.MCP.Disabled) assert.True(t, req.Memory.SkipUser) assert.Equal(t, 3, req.Budget.MaxTurns) + // An ordered toolPolicy: block reaches the spec verbatim, which is what lets + // a .prompt govern tools on a non-chat agent run. Order is the contract, so + // it is asserted as a sequence rather than a set. + require.Len(t, req.ToolPolicy, 2) + assert.Equal(t, api.MatchPatterns{"provider.*"}, req.ToolPolicy[0].Group) + assert.Equal(t, api.ToolPolicyDeny, req.ToolPolicy[0].Policy) + assert.Equal(t, api.MatchPatterns{"Read"}, req.ToolPolicy[1].Name) + assert.Equal(t, api.ToolPolicyAllow, req.ToolPolicy[1].Policy) + require.NotNil(t, req.ToolPolicy[1].ReadOnly) + assert.True(t, *req.ToolPolicy[1].ReadOnly) + // The dotprompt config: block wins for the knobs it owns: config.maxOutputTokens // (1024) overrides the spec-native budget.maxTokens (5000), and config.temperature // sets the model temperature. diff --git a/pkg/ai/prompt/testdata/options.prompt b/pkg/ai/prompt/testdata/options.prompt index 7a290cee..019dac83 100644 --- a/pkg/ai/prompt/testdata/options.prompt +++ b/pkg/ai/prompt/testdata/options.prompt @@ -13,6 +13,12 @@ permissions: - Edit mcp: disabled: true +toolPolicy: + - group: provider.* + policy: deny + - name: Read + readOnly: true + policy: allow memory: skipUser: true budget: diff --git a/pkg/ai/provider/caller_tools_ginkgo_test.go b/pkg/ai/provider/caller_tools_ginkgo_test.go index aac33ad0..7414e3be 100644 --- a/pkg/ai/provider/caller_tools_ginkgo_test.go +++ b/pkg/ai/provider/caller_tools_ginkgo_test.go @@ -44,7 +44,7 @@ var _ = Describe("Codex Agent caller tools", func() { CaptainSessionID: "captain-thread-1", SessionID: "provider-session-1", Tools: []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, }}, }) @@ -62,7 +62,7 @@ var _ = Describe("Codex Agent caller tools", func() { It("does not require MCP when request preferences disable every caller tool", func() { provider, err := NewCodexAppServer(ai.Config{ Tools: []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, }}, }) @@ -70,7 +70,7 @@ var _ = Describe("Codex Agent caller tools", func() { DeferCleanup(provider.Close) request := ai.Request{ - ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolModeOff}, + ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolPolicyDeny}, Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}, } Expect(provider.prepareCallerTools(request)).To(Succeed()) diff --git a/pkg/ai/provider/claude_cli_test.go b/pkg/ai/provider/claude_cli_test.go index 2c56326e..fcbc6ce3 100644 --- a/pkg/ai/provider/claude_cli_test.go +++ b/pkg/ai/provider/claude_cli_test.go @@ -30,8 +30,9 @@ func TestBuildClaudeCLIArgs(t *testing.T) { Permissions: api.Permissions{ Mode: api.PermissionAcceptEdits, Tools: api.Tools{ - Allow: []string{"Read", "Grep"}, - Deny: []string{"Bash"}, + "Read": api.ToolPolicyAllow, + "Grep": api.ToolPolicyAllow, + "Bash": api.ToolPolicyDeny, }, MCP: api.MCP{Disabled: true}, }, diff --git a/pkg/ai/provider/claudeagent/caller_tools.go b/pkg/ai/provider/claudeagent/caller_tools.go index e7c07599..fe78c20f 100644 --- a/pkg/ai/provider/claudeagent/caller_tools.go +++ b/pkg/ai/provider/claudeagent/caller_tools.go @@ -27,7 +27,7 @@ func (p *Provider) prepareCallerTools(req ai.Request) error { if len(p.cfg.Tools) == 0 { return nil } - definitions, err := aitools.ResolveDefinitions(p.cfg.Tools, req.ToolPreferences) + definitions, err := aitools.ResolveDefinitions(p.cfg.Tools, aitools.ResolveOptions{Preferences: req.ToolPreferences, Policy: req.ToolPolicy}) if err != nil { return fmt.Errorf("claude-agent caller tools: %w", err) } diff --git a/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go b/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go index 83640334..045567e9 100644 --- a/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go +++ b/pkg/ai/provider/claudeagent/caller_tools_ginkgo_test.go @@ -57,7 +57,7 @@ var _ = Describe("Claude Agent caller tools", func() { return api.PermissionDecision{Allow: true}, nil }, Tools: []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeAsk, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { calls.Add(1) return map[string]any{"id": input["id"], "status": "draft"}, nil @@ -96,7 +96,7 @@ var _ = Describe("Claude Agent caller tools", func() { CaptainSessionID: "captain-thread-1", SessionID: "provider-session-1", Tools: []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, }}, }) @@ -114,7 +114,7 @@ var _ = Describe("Claude Agent caller tools", func() { It("does not require MCP when request preferences disable every caller tool", func() { provider, err := New(ai.Config{ Tools: []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return "ok", nil }, }}, }) @@ -122,7 +122,7 @@ var _ = Describe("Claude Agent caller tools", func() { DeferCleanup(provider.Close) request := ai.Request{ - ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolModeOff}, + ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolPolicyDeny}, Permissions: api.Permissions{MCP: api.MCP{Disabled: true}}, } Expect(provider.prepareCallerTools(request)).To(Succeed()) diff --git a/pkg/ai/provider/claudeagent/permissions_test.go b/pkg/ai/provider/claudeagent/permissions_test.go index 8f5cec95..00c5e1e2 100644 --- a/pkg/ai/provider/claudeagent/permissions_test.go +++ b/pkg/ai/provider/claudeagent/permissions_test.go @@ -2,11 +2,13 @@ package claudeagent import ( "context" + "encoding/json" "testing" "github.com/flanksource/captain/pkg/ai" "github.com/flanksource/captain/pkg/api" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) // TestInitializeParams_PermissionMode pins the posture the SDK child is started @@ -82,7 +84,7 @@ func TestInitializeParams_EditPresetAllowlist(t *testing.T) { explicit := p.initializeParams(ai.Request{ Permissions: api.Permissions{ Presets: []api.Preset{api.PresetEdit}, - Tools: api.Tools{Allow: []string{"Read"}}, + Tools: api.Tools{"Read": api.ToolPolicyAllow}, }, }) assert.Equal(t, []string{"Read"}, explicit.AllowedTools, @@ -90,18 +92,16 @@ func TestInitializeParams_EditPresetAllowlist(t *testing.T) { } // TestInitializeParams_NormalizesToolModes pins that the SDK child is configured -// from the canonical policy map, not the raw Allow/Deny slices: `tools: {Bash: -// off}` lands in Modes only, so forwarding Tools.Deny verbatim would let a tool -// the spec turned off run. +// from AllowList/DenyList rather than by scanning the map for one spelling of a +// deny: the legacy `modes: {Bash: off}` and an explicit deny are the same policy +// once decoded, and both must reach disallowedTools. func TestInitializeParams_NormalizesToolModes(t *testing.T) { p := &Provider{} + var tools api.Tools + require.NoError(t, json.Unmarshal( + []byte(`{"deny":["WebFetch"],"modes":{"Bash":"off","Read":"on"}}`), &tools)) params := p.initializeParams(ai.Request{ - Permissions: api.Permissions{ - Tools: api.Tools{ - Deny: []string{"WebFetch"}, - Modes: map[string]api.ToolMode{"Bash": api.ToolModeOff, "Read": api.ToolModeOn}, - }, - }, + Permissions: api.Permissions{Tools: tools}, }) assert.Equal(t, []string{"Bash", "WebFetch"}, params.DisallowedTools, "an off tool mode is a deny and must reach disallowedTools") @@ -117,7 +117,7 @@ func TestExecuteStream_RefusesUnenforceableAskPolicy(t *testing.T) { p := &Provider{} _, err := p.ExecuteStream(context.Background(), ai.Request{ Permissions: api.Permissions{ - Tools: api.Tools{Modes: map[string]api.ToolMode{"Bash": api.ToolModeAsk}}, + Tools: api.Tools{"Bash": api.ToolPolicyAsk}, }, }) assert.ErrorContains(t, err, "ask") diff --git a/pkg/ai/provider/codex_appserver.go b/pkg/ai/provider/codex_appserver.go index 27364c63..3bfe1257 100644 --- a/pkg/ai/provider/codex_appserver.go +++ b/pkg/ai/provider/codex_appserver.go @@ -422,7 +422,7 @@ func (c *CodexAppServer) prepareCallerTools(req ai.Request) error { if len(c.cfg.Tools) == 0 { return nil } - definitions, err := aitools.ResolveDefinitions(c.cfg.Tools, req.ToolPreferences) + definitions, err := aitools.ResolveDefinitions(c.cfg.Tools, aitools.ResolveOptions{Preferences: req.ToolPreferences, Policy: req.ToolPolicy}) if err != nil { return fmt.Errorf("codex app-server caller tools: %w", err) } diff --git a/pkg/ai/provider/genkit/options.go b/pkg/ai/provider/genkit/options.go index 13f583f8..fed202c4 100644 --- a/pkg/ai/provider/genkit/options.go +++ b/pkg/ai/provider/genkit/options.go @@ -8,6 +8,7 @@ import ( "os" "github.com/flanksource/captain/pkg/ai" + captools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" "github.com/flanksource/captain/pkg/api/registry" @@ -51,7 +52,7 @@ func generateOptions(p *Provider, req ai.Request, stream gkai.ModelStreamCallbac gkai.WithModelName(p.modelRef), gkai.WithUse(gkai.MiddlewareFunc(captureGenkitRequests)), } - toolOptions, err := p.toolOptions(req.ToolPreferences, emit) + toolOptions, err := p.toolOptions(captools.ResolveOptions{Preferences: req.ToolPreferences, Policy: req.ToolPolicy}, emit) if err != nil { return nil, err } diff --git a/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go b/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go index 65518a53..7021bcdf 100644 --- a/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go +++ b/pkg/ai/provider/genkit/tool_approval_ginkgo_test.go @@ -26,7 +26,7 @@ var _ = Describe("Genkit resumable tool approval", func() { Name: "invoice_update", Ref: "call-update", Input: map[string]any{"amount": 10}, }), api.ToolDefinition{ Name: "invoice_update", - DefaultPermission: api.ToolModeAsk, + DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { ran = true return "updated", nil @@ -123,11 +123,11 @@ var _ = Describe("Genkit resumable tool approval", func() { cfg: ai.Config{ Model: api.Model{Name: "resumable-approval", Backend: api.BackendOpenAI}, Tools: []api.ToolDefinition{ - {Name: "invoice_get", DefaultPermission: api.ToolModeOn, Handler: func(context.Context, map[string]any) (any, error) { + {Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { readRuns.Add(1) return map[string]any{"amount": 10}, nil }}, - {Name: "invoice_update", DefaultPermission: api.ToolModeAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { + {Name: "invoice_update", DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { updateRuns.Add(1) updatedAmount.Store(int32(input["amount"].(float64))) return map[string]any{"updated": true}, nil diff --git a/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go b/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go index 0f77d9fd..d532db01 100644 --- a/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go +++ b/pkg/ai/provider/genkit/tool_lifecycle_ginkgo_test.go @@ -29,7 +29,7 @@ var _ = Describe("Genkit tool event correlation", func() { emit = func(event ai.Event) { events = append(events, event) } tool = api.ToolDefinition{ Name: "lookup", - DefaultPermission: api.ToolModeOn, + DefaultPermission: api.ToolPolicyAllow, Handler: func(_ context.Context, input map[string]any) (any, error) { return map[string]any{"city": input["city"]}, nil }, @@ -97,7 +97,7 @@ var _ = Describe("Genkit tool event correlation", func() { Expect(request.ToolUseID).To(Equal("toolu_approved")) return api.PermissionDecision{Allow: true}, nil }) - tool.DefaultPermission = api.ToolModeAsk + tool.DefaultPermission = api.ToolPolicyAsk correlation := newToolEventCorrelation() request := &gkai.ToolRequest{Name: tool.Name, Ref: "toolu_approved", Input: map[string]any{"city": "Cape Town"}} _, err := chunkToEvents(toolRequestChunk(request), provider.GetModel(), correlation) @@ -132,7 +132,7 @@ var _ = Describe("Genkit tool event correlation", func() { Model: api.Model{Name: "parallel-journals", Backend: api.BackendAnthropic}, Tools: []api.ToolDefinition{{ Name: "journals", - DefaultPermission: api.ToolModeOn, + DefaultPermission: api.ToolPolicyAllow, InputSchema: map[string]any{ "type": "object", "properties": map[string]any{"limit": map[string]any{"type": "integer"}}, diff --git a/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go b/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go index 14f8220d..270e87db 100644 --- a/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go +++ b/pkg/ai/provider/genkit/tool_preferences_ginkgo_test.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/flanksource/captain/pkg/ai" + captools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" gkai "github.com/firebase/genkit/go/ai" @@ -17,16 +18,16 @@ var _ = Describe("Genkit tool policy", func() { It("resolves tool preferences before exposing tools", func() { defs := []api.ToolDefinition{ - {Name: "invoice_list", Group: "billing", DefaultPermission: api.ToolModeAsk, Handler: noop}, - {Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolModeOn, Handler: noop}, - {Name: "search", DefaultPermission: api.ToolModeOff, Handler: noop}, + {Name: "invoice_list", Group: "billing", DefaultPermission: api.ToolPolicyAsk, Handler: noop}, + {Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolPolicyAllow, Handler: noop}, + {Name: "search", DefaultPermission: api.ToolPolicyDeny, Handler: noop}, } - selected, err := resolveToolDefinitions(defs, api.ToolPreferences{ - "billing": api.ToolModeOff, - "invoice_list": api.ToolModeOn, - "search": api.ToolModeAsk, - }) + selected, err := resolveToolDefinitions(defs, captools.ResolveOptions{Preferences: api.ToolPreferences{ + "billing": api.ToolPolicyDeny, + "invoice_list": api.ToolPolicyAllow, + "search": api.ToolPolicyAsk, + }}) Expect(err).NotTo(HaveOccurred()) Expect(selected).To(HaveLen(2)) Expect(selected[0].Name).To(Equal("invoice_list")) @@ -43,12 +44,12 @@ var _ = Describe("Genkit tool policy", func() { return api.PermissionDecision{Allow: true}, nil }) def := api.ToolDefinition{ - Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolModeOn, Handler: noop, + Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolPolicyAllow, Handler: noop, } - selected, err := resolveToolDefinitions([]api.ToolDefinition{def}, api.ToolPreferences{ - "billing": api.ToolModeOn, - "invoice_delete": api.ToolModeAsk, - }) + selected, err := resolveToolDefinitions([]api.ToolDefinition{def}, captools.ResolveOptions{Preferences: api.ToolPreferences{ + "billing": api.ToolPolicyAllow, + "invoice_delete": api.ToolPolicyAsk, + }}) Expect(err).NotTo(HaveOccurred()) Expect(selected).To(HaveLen(1)) @@ -58,14 +59,14 @@ var _ = Describe("Genkit tool policy", func() { }) It("rejects invalid preferences instead of silently using defaults", func() { - _, err := resolveToolDefinitions([]api.ToolDefinition{{Name: "search", Handler: noop}}, api.ToolPreferences{"search": "sometimes"}) + _, err := resolveToolDefinitions([]api.ToolDefinition{{Name: "search", Handler: noop}}, captools.ResolveOptions{Preferences: api.ToolPreferences{"search": "sometimes"}}) Expect(err).To(MatchError(ContainSubstring(`invalid tool preference "sometimes" for "search"`))) }) It("rejects an invalid tool default even when a preference disables the tool", func() { _, err := resolveToolDefinitions([]api.ToolDefinition{ {Name: "search", DefaultPermission: "sometimes", Handler: noop}, - }, api.ToolPreferences{"search": api.ToolModeOff}) + }, captools.ResolveOptions{Preferences: api.ToolPreferences{"search": api.ToolPolicyDeny}}) Expect(err).To(MatchError(ContainSubstring(`tool "search" has invalid default permission "sometimes"`))) }) diff --git a/pkg/ai/provider/genkit/tools.go b/pkg/ai/provider/genkit/tools.go index 4bc0ef7a..4ff5921b 100644 --- a/pkg/ai/provider/genkit/tools.go +++ b/pkg/ai/provider/genkit/tools.go @@ -28,8 +28,8 @@ func (p *Provider) SupportsCallerTools() bool { return true } // non-nil, receives EventToolUse / EventPermission / EventToolResult as the // model calls them. Returns nil when there are no caller tools, so a run with // none is byte-for-byte unchanged. -func (p *Provider) toolOptions(preferences api.ToolPreferences, emit func(ai.Event)) ([]gkai.GenerateOption, error) { - definitions, err := resolveToolDefinitions(p.cfg.Tools, preferences) +func (p *Provider) toolOptions(opts captools.ResolveOptions, emit func(ai.Event)) ([]gkai.GenerateOption, error) { + definitions, err := resolveToolDefinitions(p.cfg.Tools, opts) if err != nil { return nil, err } @@ -49,8 +49,8 @@ func (p *Provider) toolOptions(preferences api.ToolPreferences, emit func(ai.Eve return []gkai.GenerateOption{gkai.WithTools(refs...), gkai.WithMaxTurns(maxToolTurns)}, nil } -func resolveToolDefinitions(definitions []api.ToolDefinition, preferences api.ToolPreferences) ([]api.ToolDefinition, error) { - return captools.ResolveDefinitions(definitions, preferences) +func resolveToolDefinitions(definitions []api.ToolDefinition, opts captools.ResolveOptions) ([]api.ToolDefinition, error) { + return captools.ResolveDefinitions(definitions, opts) } func anthropicStrictToolDefinitions(definitions []api.ToolDefinition) []api.ToolDefinition { diff --git a/pkg/ai/provider/genkit/tools_test.go b/pkg/ai/provider/genkit/tools_test.go index fe0bb6cf..d65ca6fb 100644 --- a/pkg/ai/provider/genkit/tools_test.go +++ b/pkg/ai/provider/genkit/tools_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/flanksource/captain/pkg/ai" + captools "github.com/flanksource/captain/pkg/ai/tools" "github.com/flanksource/captain/pkg/api" gkai "github.com/firebase/genkit/go/ai" @@ -26,7 +27,7 @@ func TestRunToolAutoRunsAndEmitsCorrelatedEvents(t *testing.T) { var gotInput map[string]any def := api.ToolDefinition{ Name: "echo", - DefaultPermission: api.ToolModeOn, + DefaultPermission: api.ToolPolicyAllow, Handler: func(_ context.Context, in map[string]any) (any, error) { gotInput = in return map[string]any{"ok": true}, nil @@ -69,7 +70,7 @@ func TestRunToolApprovalDeniedSkipsHandler(t *testing.T) { def := api.ToolDefinition{ Name: "danger", - DefaultPermission: api.ToolModeAsk, + DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { ran = true; return "ran", nil }, } @@ -102,7 +103,7 @@ func TestRunToolApprovalAllowsAndSubstitutesInput(t *testing.T) { var seen map[string]any def := api.ToolDefinition{ Name: "pay", - DefaultPermission: api.ToolModeAsk, + DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, in map[string]any) (any, error) { seen = in; return "done", nil }, } @@ -119,7 +120,7 @@ func TestRunToolHandlerErrorFedBack(t *testing.T) { emit, events := collectEvents() def := api.ToolDefinition{ Name: "boom", - DefaultPermission: api.ToolModeOn, + DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return nil, context.Canceled }, } out, err := runCorrelatedTool(p, def, map[string]any{}, emit) @@ -137,7 +138,7 @@ func TestRunToolHandlerErrorFedBack(t *testing.T) { func TestToolOptionsEmptyWhenNoTools(t *testing.T) { p := newToolProvider(nil) - if opts, err := p.toolOptions(nil, nil); err != nil || opts != nil { + if opts, err := p.toolOptions(captools.ResolveOptions{}, nil); err != nil || opts != nil { t.Errorf("toolOptions with no tools = %v, want nil", opts) } if !p.SupportsCallerTools() { diff --git a/pkg/ai/provider/permission_capabilities_test.go b/pkg/ai/provider/permission_capabilities_test.go index 9d3f900c..773ad142 100644 --- a/pkg/ai/provider/permission_capabilities_test.go +++ b/pkg/ai/provider/permission_capabilities_test.go @@ -141,7 +141,7 @@ func TestDeclaredUnsupportedCodexDontAskIsTheDefault(t *testing.T) { // captain already fails loud instead of silently ignoring a permission field, and // this pins the table to that behaviour so the two cannot drift apart. func TestDeclaredAgentToolPolicyMatchesArgv(t *testing.T) { - perms := api.Permissions{Tools: api.Tools{Deny: []string{"Bash"}, Allow: []string{"Read"}}} + perms := api.Permissions{Tools: api.Tools{"Bash": api.ToolPolicyDeny, "Read": api.ToolPolicyAllow}} req := ai.Request{Prompt: api.Prompt{User: "hi"}, Permissions: perms} cases := []struct { backend api.Backend diff --git a/pkg/ai/tools/definitions_ginkgo_test.go b/pkg/ai/tools/definitions_ginkgo_test.go index d53d9f1e..d2947ae2 100644 --- a/pkg/ai/tools/definitions_ginkgo_test.go +++ b/pkg/ai/tools/definitions_ginkgo_test.go @@ -13,29 +13,46 @@ import ( var _ = Describe("Caller tool definitions", func() { noop := func(context.Context, map[string]any) (any, error) { return "ok", nil } + It("resolves defaults and preferences through the one policy vocabulary", func() { + readOnly, nonDestructive := true, false + definitions, err := tools.ResolveDefinitions([]api.ToolDefinition{ + { + Name: "invoice_get", ReadOnlyHint: &readOnly, DestructiveHint: &nonDestructive, + DefaultPermission: api.ToolPolicyAuto, Handler: noop, + }, + {Name: "invoice_update", DefaultPermission: api.ToolPolicyAsk, Handler: noop}, + {Name: "invoice_delete", DefaultPermission: api.ToolPolicyDeny, Handler: noop}, + }, tools.ResolveOptions{Preferences: api.ToolPreferences{"invoice_update": api.ToolPolicyAllow}}) + + Expect(err).NotTo(HaveOccurred()) + Expect(definitions).To(HaveLen(2)) + Expect(definitions[0].DefaultPermission).To(Equal(api.ToolPolicyAllow)) + Expect(definitions[1].DefaultPermission).To(Equal(api.ToolPolicyAllow)) + }) + It("resolves exact preferences before groups and omits disabled tools", func() { definitions, err := tools.ResolveDefinitions([]api.ToolDefinition{ - {Name: "invoice_list", Group: "billing", DefaultPermission: api.ToolModeAsk, Handler: noop}, - {Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolModeOn, Handler: noop}, - {Name: "search", DefaultPermission: api.ToolModeOff, Handler: noop}, - }, api.ToolPreferences{ - "billing": api.ToolModeOff, - "invoice_list": api.ToolModeOn, - "search": api.ToolModeAsk, - }) + {Name: "invoice_list", Group: "billing", DefaultPermission: api.ToolPolicyAsk, Handler: noop}, + {Name: "invoice_delete", Group: "billing", DefaultPermission: api.ToolPolicyAllow, Handler: noop}, + {Name: "search", DefaultPermission: api.ToolPolicyDeny, Handler: noop}, + }, tools.ResolveOptions{Preferences: api.ToolPreferences{ + "billing": api.ToolPolicyDeny, + "invoice_list": api.ToolPolicyAllow, + "search": api.ToolPolicyAsk, + }}) Expect(err).NotTo(HaveOccurred()) Expect(definitions).To(HaveLen(2)) Expect(definitions[0].Name).To(Equal("invoice_list")) - Expect(definitions[0].DefaultPermission).To(Equal(api.ToolModeOn)) + Expect(definitions[0].DefaultPermission).To(Equal(api.ToolPolicyAllow)) Expect(definitions[1].Name).To(Equal("search")) - Expect(definitions[1].DefaultPermission).To(Equal(api.ToolModeAsk)) + Expect(definitions[1].DefaultPermission).To(Equal(api.ToolPolicyAsk)) }) It("validates definitions even when a preference disables them", func() { _, err := tools.ResolveDefinitions([]api.ToolDefinition{{ Name: "search", DefaultPermission: "sometimes", Handler: noop, - }}, api.ToolPreferences{"search": api.ToolModeOff}) + }}, tools.ResolveOptions{Preferences: api.ToolPreferences{"search": api.ToolPolicyDeny}}) Expect(err).To(MatchError(ContainSubstring(`tool "search" has invalid default permission "sometimes"`))) }) @@ -45,27 +62,56 @@ var _ = Describe("Caller tool definitions", func() { definitions, err := tools.ResolveDefinitions([]api.ToolDefinition{ { Name: "invoice_get", ReadOnlyHint: &readOnly, DestructiveHint: &nonDestructive, - DefaultPermission: api.ToolModeAuto, Handler: noop, + DefaultPermission: api.ToolPolicyAuto, Handler: noop, }, - {Name: "invoice_update", DefaultPermission: api.ToolModeAuto, Handler: noop}, - }, nil) + {Name: "invoice_update", DefaultPermission: api.ToolPolicyAuto, Handler: noop}, + }, tools.ResolveOptions{}) Expect(err).NotTo(HaveOccurred()) Expect(definitions).To(HaveLen(2)) - Expect(definitions[0].DefaultPermission).To(Equal(api.ToolModeOn)) - Expect(definitions[1].DefaultPermission).To(Equal(api.ToolModeAsk)) + Expect(definitions[0].DefaultPermission).To(Equal(api.ToolPolicyAllow)) + Expect(definitions[1].DefaultPermission).To(Equal(api.ToolPolicyAsk)) }) + // The whole path an MCP server's declared permission travels: published in + // _meta, folded onto the catalog entry, copied onto the definition, then + // resolved into the set the model is shown. A tool published as "off" must + // fall out of that set entirely — the projection sets no safety hints, so if + // the legacy spelling ever resolves to auto again the tool comes back as an + // ask rather than being omitted, and nothing downstream would catch it. + DescribeTable("omits a tool an MCP server published as off", + func(published string, wantNames []string) { + entry := api.ToolCatalogEntry{Name: "invoice_delete", DefaultPermission: api.ToolPolicyAuto} + api.ApplyToolMetadata(&entry, map[string]any{"defaultPermission": published}) + + definitions, err := tools.ResolveDefinitions([]api.ToolDefinition{ + {Name: "invoice_get", DefaultPermission: api.ToolPolicyAsk, Handler: noop}, + {Name: entry.Name, DefaultPermission: entry.DefaultPermission, Handler: noop}, + }, tools.ResolveOptions{}) + + Expect(err).NotTo(HaveOccurred()) + names := make([]string, 0, len(definitions)) + for _, definition := range definitions { + names = append(names, definition.Name) + } + Expect(names).To(Equal(wantNames)) + }, + Entry("legacy off", "off", []string{"invoice_get"}), + Entry("canonical deny", "deny", []string{"invoice_get"}), + Entry("legacy on stays visible", "on", []string{"invoice_get", "invoice_delete"}), + Entry("auto stays visible", "auto", []string{"invoice_get", "invoice_delete"}), + ) + It("rejects duplicate and provider-unsafe tool names", func() { _, err := tools.ResolveDefinitions([]api.ToolDefinition{ {Name: "invoice_get", Handler: noop}, {Name: "invoice_get", Handler: noop}, - }, nil) + }, tools.ResolveOptions{}) Expect(err).To(MatchError(ContainSubstring(`duplicate caller tool "invoice_get"`))) _, err = tools.ResolveDefinitions([]api.ToolDefinition{{ Name: "invoice/get", Handler: noop, - }}, nil) + }}, tools.ResolveOptions{}) Expect(err).To(MatchError(ContainSubstring(`caller tool name "invoice/get"`))) }) }) diff --git a/pkg/ai/tools/preferences_ginkgo_test.go b/pkg/ai/tools/preferences_ginkgo_test.go index 97850e1f..2d701428 100644 --- a/pkg/ai/tools/preferences_ginkgo_test.go +++ b/pkg/ai/tools/preferences_ginkgo_test.go @@ -11,22 +11,22 @@ import ( var _ = Describe("Tool preference resolution", func() { It("prefers an exact tool entry over its group", func() { mode, ok := tools.EffectivePreference(api.ToolPreferences{ - "billing": api.ToolModeOff, - "invoice_delete": api.ToolModeAsk, + "billing": api.ToolPolicyDeny, + "invoice_delete": api.ToolPolicyAsk, }, tools.ToolInfo{Name: "invoice_delete", Group: "billing"}) Expect(ok).To(BeTrue()) - Expect(mode).To(Equal(api.ToolModeAsk)) + Expect(mode).To(Equal(api.ToolPolicyAsk)) }) It("normalizes only the canonical modes", func() { - on, ok := tools.NormalizedPreference(api.ToolPreferences{"search": api.ToolModeOn}, "search") + on, ok := tools.NormalizedPreference(api.ToolPreferences{"search": api.ToolPolicyAllow}, "search") Expect(ok).To(BeTrue()) - Expect(on).To(Equal(api.ToolModeOn)) + Expect(on).To(Equal(api.ToolPolicyAllow)) - off, ok := tools.NormalizedPreference(api.ToolPreferences{"search": api.ToolModeOff}, "search") + off, ok := tools.NormalizedPreference(api.ToolPreferences{"search": api.ToolPolicyDeny}, "search") Expect(ok).To(BeTrue()) - Expect(off).To(Equal(api.ToolModeOff)) + Expect(off).To(Equal(api.ToolPolicyDeny)) _, ok = tools.NormalizedPreference(api.ToolPreferences{"search": "enabled"}, "search") Expect(ok).To(BeFalse()) diff --git a/pkg/ai/tools/tools.go b/pkg/ai/tools/tools.go index 3dea1cf6..26240163 100644 --- a/pkg/ai/tools/tools.go +++ b/pkg/ai/tools/tools.go @@ -1,114 +1,58 @@ -// Package tools is captain's home for the chat tool registry's data model and -// approval policy: the tool definition/info types, the per-request tool mode and -// preferences, the tool catalog DTO, and the approval-decision logic. It is -// genkit- and clicky-free — the genkit binding (registering these as model tools) -// and the clicky-RPC→tool mapping live in the consumer (clicky/aichat), which -// imports this package. Clicky-specific metadata (the originating verb/method/ -// path) rides in the opaque Annotations map rather than as typed fields. +// Package tools is captain's chat tool registry runtime: the approval gate, the +// per-request preference resolution, and the definition resolver every provider +// runs so the API and agent runtimes cannot disagree about the visible tool set. +// +// The data model it operates on — ToolPolicy, ToolInfo, ToolDefinition and the +// catalog DTOs — is owned by pkg/api and aliased here. There is one permission +// vocabulary and one set of rules for it; this package holds none of them. +// +// It is genkit- and clicky-free: the genkit binding (registering these as model +// tools) and the clicky-RPC→tool mapping live in the consumer (clicky/aichat), +// which imports this package. Clicky-specific metadata (the originating +// verb/method/path) rides in the opaque Annotations map, not as typed fields. package tools import ( "context" "fmt" - "sort" "github.com/flanksource/captain/pkg/api" ) -// ToolMode controls how a tool is exposed for one request. -type ToolMode = api.ToolMode +// The tool permission vocabulary is owned by pkg/api. These are aliases so a +// consumer already importing this package does not need both imports; there is +// exactly one vocabulary and one set of rules behind them. +type ( + ToolPolicy = api.ToolPolicy + ToolPreferences = api.ToolPreferences + ToolInfo = api.ToolInfo + ToolDefinition = api.ToolDefinition + ToolCatalog = api.ToolCatalog + ToolCatalogEntry = api.ToolCatalogEntry + ToolMatch = api.ToolMatch + PermissionRule = api.PermissionRule + PermissionPolicy = api.PermissionPolicy +) const ( - ToolModeOn = api.ToolModeOn - ToolModeAsk = api.ToolModeAsk - ToolModeOff = api.ToolModeOff - ToolModeAuto = api.ToolModeAuto + ToolPolicyAuto = api.ToolPolicyAuto + ToolPolicyAsk = api.ToolPolicyAsk + ToolPolicyAllow = api.ToolPolicyAllow + ToolPolicyDeny = api.ToolPolicyDeny ) -// NormalizeToolMode canonicalizes a mode string. The bool is false for an -// unrecognized value. -func NormalizeToolMode(mode ToolMode) (ToolMode, bool) { - return api.NormalizeToolMode(mode) -} - -// DefaultPermissionMode resolves a mode to its canonical value, defaulting an -// unset/unknown value to Auto (defer to the approval policy). -func DefaultPermissionMode(mode ToolMode) ToolMode { - if normalized, ok := NormalizeToolMode(mode); ok { - return normalized - } - return ToolModeAuto -} - -// ApprovalDecisionForMode maps a resolved mode to an approve/auto decision. The -// second bool is false only for Auto, which defers to the policy. -func ApprovalDecisionForMode(mode ToolMode) (require bool, handled bool) { - switch DefaultPermissionMode(mode) { - case ToolModeOn: - return false, true - case ToolModeAsk: - return true, true - case ToolModeOff: - return false, true - case ToolModeAuto: - return false, false - default: - return false, false - } -} - -// ToolPreferences carries the clicky-ui tool preference payload. The UI sends -// "on", "ask", "off", or "auto". -type ToolPreferences = api.ToolPreferences - -// ToolInfo is the concrete tool being considered for approval and preference -// resolution. Clicky-RPC specifics (verb/method/path/operation) live in -// Annotations, not as typed fields. -type ToolInfo struct { - Name string - // Group is the tool-group this tool belongs to. When non-empty the - // preferences UI presents the group as one entry governing every member. - Group string - Parent string - Icon string - DefaultPermission ToolMode - Strict *bool - ReadOnlyHint *bool - DestructiveHint *bool - IdempotentHint *bool - // Annotations carries opaque caller metadata (e.g. clicky/verb, clicky/method, - // clicky/path, clicky/operation) for policies that want the raw values. - Annotations map[string]string -} - -// Annotation returns the named annotation (empty when absent). -func (i ToolInfo) Annotation(key string) string { - if i.Annotations == nil { - return "" - } - return i.Annotations[key] -} - -// ToolDefinition describes an app-owned tool registered alongside clicky RPC and -// MCP tools. Handlers should return JSON-serializable values. -type ToolDefinition struct { - Name string - Description string - InputSchema map[string]any - Parent string - Icon string - DefaultPermission ToolMode - Strict *bool - ReadOnlyHint *bool - DestructiveHint *bool - IdempotentHint *bool - // Group places this custom tool in a tool-group so the preferences UI presents - // it under the group rather than individually. - Group string - // Annotations carries opaque caller metadata (see ToolInfo.Annotations). - Annotations map[string]string - Handler func(context.Context, any) (any, error) -} +// Re-exported so catalog builders can stay on this package's import. +var ( + CustomCatalogEntry = api.CustomCatalogEntry + ApplyToolMetadata = api.ApplyToolMetadata + PreferenceKey = api.PreferenceKey + ObjectSchema = api.ObjectSchema + StringMetadata = api.StringMetadata + BoolMetadata = api.BoolMetadata + DefaultToolPolicy = api.DefaultToolPolicy + NormalizeToolPolicy = api.NormalizeToolPolicy + ParseToolPolicy = api.ParseToolPolicy +) // ApprovalPolicy reports whether a tool call must be approved before it runs. type ApprovalPolicy func(toolName string, input any) bool @@ -173,12 +117,12 @@ func runtimeConfig(ctx context.Context) (toolRuntimeConfig, bool) { func ShouldRequireApproval(ctx context.Context, fallback ApprovalPredicate, tool ToolInfo, input any) bool { if ctx != nil { if cfg, ok := runtimeConfig(ctx); ok { - if mode, ok := EffectivePreference(cfg.preferences, tool); ok { - if decision, handled := ApprovalDecisionForMode(mode); handled { + if preferred, ok := EffectivePreference(cfg.preferences, tool); ok { + if decision, handled := preferred.ApprovalDecision(); handled { return decision } } - if decision, handled := ApprovalDecisionForMode(DefaultPermissionMode(tool.DefaultPermission)); handled { + if decision, handled := tool.DefaultPermission.ApprovalDecision(); handled { return decision } if cfg.defaultApproval != nil { @@ -186,7 +130,7 @@ func ShouldRequireApproval(ctx context.Context, fallback ApprovalPredicate, tool } } } - if decision, handled := ApprovalDecisionForMode(DefaultPermissionMode(tool.DefaultPermission)); handled { + if decision, handled := tool.DefaultPermission.ApprovalDecision(); handled { return decision } if fallback == nil { @@ -195,12 +139,12 @@ func ShouldRequireApproval(ctx context.Context, fallback ApprovalPredicate, tool return fallback(tool, input) } -// EffectivePreference resolves the ToolMode for a tool: an exact tool-name +// EffectivePreference resolves the policy for a tool: an exact tool-name // preference wins, else the tool's group preference; ungrouped tools resolve by // their own name. -func EffectivePreference(prefs ToolPreferences, info ToolInfo) (ToolMode, bool) { - if mode, ok := NormalizedPreference(prefs, info.Name); ok { - return mode, true +func EffectivePreference(prefs ToolPreferences, info ToolInfo) (ToolPolicy, bool) { + if policy, ok := NormalizedPreference(prefs, info.Name); ok { + return policy, true } if info.Group != "" { return NormalizedPreference(prefs, info.Group) @@ -209,25 +153,71 @@ func EffectivePreference(prefs ToolPreferences, info ToolInfo) (ToolMode, bool) } // NormalizedPreference looks up and normalizes a preference by key. -func NormalizedPreference(prefs ToolPreferences, name string) (ToolMode, bool) { +func NormalizedPreference(prefs ToolPreferences, name string) (ToolPolicy, bool) { if len(prefs) == 0 { return "", false } - mode, ok := prefs[name] + policy, ok := prefs[name] if !ok { return "", false } - return NormalizeToolMode(mode) + return NormalizeToolPolicy(string(policy)) +} + +// ResolveOptions carries the two shapes a caller may express tool authority in. +// +// Both are accepted and evaluated through ONE ordered list, so a spec that sets +// each cannot get two different answers for the same tool. Preferences are +// lowered first and the policy appended after, which makes an explicit rule beat +// an inherited preference — the layering the whole design turns on. +type ResolveOptions struct { + // Preferences is the legacy flat tool→policy map, keyed by tool name or + // group. Lowered through api.FromPreferences rather than matched separately. + Preferences ToolPreferences + // Policy is the ordered, last-match-wins rule list. + Policy PermissionPolicy +} + +// EffectivePolicy is the single ordered list these options resolve through. +func (o ResolveOptions) EffectivePolicy() PermissionPolicy { + return api.FromPreferences(o.Preferences).Append(o.Policy) +} + +// toolInfo projects a definition onto the subject a rule matches against. It +// carries the full identity — parent, hints and the clicky annotations — because +// a rule may select on any of them; passing only name and group is what limited +// matching to exact strings before. +func toolInfo(definition api.ToolDefinition) ToolInfo { + return ToolInfo{ + Name: definition.Name, + Group: definition.Group, + Parent: definition.Parent, + Icon: definition.Icon, + DefaultPermission: definition.DefaultPermission, + Strict: definition.Strict, + ReadOnlyHint: definition.ReadOnlyHint, + DestructiveHint: definition.DestructiveHint, + IdempotentHint: definition.IdempotentHint, + Annotations: definition.Annotations, + } } -// ResolveDefinitions validates caller tools, applies exact/group preferences, -// omits disabled tools, and writes the effective permission onto a copy of each -// selected definition. Every provider uses this function so API and agent -// runtimes cannot disagree about the visible tool set. -func ResolveDefinitions(definitions []api.ToolDefinition, preferences ToolPreferences) ([]api.ToolDefinition, error) { - if err := preferences.Validate(); err != nil { +// ResolveDefinitions validates caller tools, resolves each against the ordered +// permission policy, omits denied tools, and writes the effective permission onto +// a copy of each selected definition. Every provider uses this function so the +// API and agent runtimes cannot disagree about the visible tool set. +// +// A denied tool is dropped rather than marked: for a caller tool captain owns the +// MCP server, so omission IS the enforcement — which is why a deny is honoured +// even on backends whose own CLI has no tool filter. +func ResolveDefinitions(definitions []api.ToolDefinition, opts ResolveOptions) ([]api.ToolDefinition, error) { + if err := opts.Preferences.Validate(); err != nil { + return nil, err + } + if err := opts.Policy.Validate(); err != nil { return nil, err } + effective := opts.EffectivePolicy() selected := make([]api.ToolDefinition, 0, len(definitions)) seen := make(map[string]struct{}, len(definitions)) for _, definition := range definitions { @@ -244,31 +234,29 @@ func ResolveDefinitions(definitions []api.ToolDefinition, preferences ToolPrefer if definition.Handler == nil { return nil, fmt.Errorf("caller tool %q has no handler", definition.Name) } - mode := ToolModeAuto + policy := ToolPolicyAuto if definition.DefaultPermission != "" { var ok bool - mode, ok = NormalizeToolMode(definition.DefaultPermission) + policy, ok = NormalizeToolPolicy(string(definition.DefaultPermission)) if !ok { return nil, fmt.Errorf("tool %q has invalid default permission %q", definition.Name, definition.DefaultPermission) } } - if preferred, ok := EffectivePreference(preferences, ToolInfo{ - Name: definition.Name, Group: definition.Group, - }); ok && preferred != ToolModeAuto { - mode = preferred + if resolved, matched := effective.Resolve(toolInfo(definition)); matched && resolved != ToolPolicyAuto { + policy = resolved } - if mode == ToolModeOff { + if policy == ToolPolicyDeny { continue } - if mode == ToolModeAuto { + if policy == ToolPolicyAuto { if definition.ReadOnlyHint != nil && *definition.ReadOnlyHint && definition.DestructiveHint != nil && !*definition.DestructiveHint { - mode = ToolModeOn + policy = ToolPolicyAllow } else { - mode = ToolModeAsk + policy = ToolPolicyAsk } } - definition.DefaultPermission = mode + definition.DefaultPermission = policy selected = append(selected, definition) } return selected, nil @@ -287,40 +275,3 @@ func validCallerToolName(name string) bool { } return true } - -// ToolEntry is one row in the tool-preferences UI: a single ungrouped tool, or a -// collapsed group listing its member names. -type ToolEntry struct { - Key string `json:"key"` - Group string `json:"group,omitempty"` - Tools []string `json:"tools"` - Mode ToolMode `json:"mode,omitempty"` -} - -// ListToolEntries collapses grouped tools into one entry per group and leaves -// ungrouped tools individual, sorted by Key. prefs (may be nil) annotates Mode. -func ListToolEntries(infos []ToolInfo, prefs ToolPreferences) []ToolEntry { - groups := map[string][]string{} - var entries []ToolEntry - for _, info := range infos { - if g := info.Group; g != "" { - groups[g] = append(groups[g], info.Name) - continue - } - entry := ToolEntry{Key: info.Name, Tools: []string{info.Name}} - if mode, ok := NormalizedPreference(prefs, info.Name); ok { - entry.Mode = mode - } - entries = append(entries, entry) - } - for group, members := range groups { - sort.Strings(members) - entry := ToolEntry{Key: group, Group: group, Tools: members} - if mode, ok := NormalizedPreference(prefs, group); ok { - entry.Mode = mode - } - entries = append(entries, entry) - } - sort.Slice(entries, func(i, j int) bool { return entries[i].Key < entries[j].Key }) - return entries -} diff --git a/pkg/aichat/aimock_lifecycle_integration_test.go b/pkg/aichat/aimock_lifecycle_integration_test.go index b0c7db5d..14470cd9 100644 --- a/pkg/aichat/aimock_lifecycle_integration_test.go +++ b/pkg/aichat/aimock_lifecycle_integration_test.go @@ -138,7 +138,7 @@ var _ = Describe("Mocked Captain chat lifecycle", func() { }}, nil }), Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ - Name: "accounts_edit", DefaultPermission: api.ToolModeAsk, + Name: "accounts_edit", DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { toolCalls.Add(1) inputMu.Lock() diff --git a/pkg/aichat/approval_execution.go b/pkg/aichat/approval_execution.go index 6c729901..668db982 100644 --- a/pkg/aichat/approval_execution.go +++ b/pkg/aichat/approval_execution.go @@ -67,7 +67,7 @@ func (s *Service) resumeToolApproval(ctx context.Context, threadID string, conti if err != nil { return false, err } - definitions, err := aitools.ResolveDefinitions(set.Definitions, continuation.Spec.ToolPreferences) + definitions, err := aitools.ResolveDefinitions(set.Definitions, aitools.ResolveOptions{Preferences: continuation.Spec.ToolPreferences, Policy: continuation.Spec.ToolPolicy}) if err != nil { return false, err } diff --git a/pkg/aichat/database_threads_integration_test.go b/pkg/aichat/database_threads_integration_test.go index 15d7c71f..f2c91188 100644 --- a/pkg/aichat/database_threads_integration_test.go +++ b/pkg/aichat/database_threads_integration_test.go @@ -400,7 +400,7 @@ var _ = Describe("Database chat sessions", func() { service := aichat.NewService(aichat.ServiceOptions{ Resolver: &fakeResolver{provider: provider}, Threads: aichat.FixedThreadStore(store), Authority: authority, Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ - Name: "accounts_edit", DefaultPermission: api.ToolModeAsk, + Name: "accounts_edit", DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, }}), }) @@ -466,7 +466,7 @@ var _ = Describe("Database chat sessions", func() { service := aichat.NewService(aichat.ServiceOptions{ Resolver: &fakeResolver{provider: provider}, Threads: aichat.FixedThreadStore(store), Authority: authority, Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ - Name: "accounts_edit", DefaultPermission: api.ToolModeAsk, + Name: "accounts_edit", DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { Fail("failed approval must not execute its tool") return nil, nil diff --git a/pkg/aichat/execution_authority_ginkgo_test.go b/pkg/aichat/execution_authority_ginkgo_test.go index 0479953c..2359ea7c 100644 --- a/pkg/aichat/execution_authority_ginkgo_test.go +++ b/pkg/aichat/execution_authority_ginkgo_test.go @@ -179,7 +179,7 @@ var _ = Describe("Authoritative aichat execution", func() { }), nil }), Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ - Name: "account_edit", DefaultPermission: api.ToolModeAsk, + Name: "account_edit", DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, }}), }) @@ -229,7 +229,7 @@ var _ = Describe("Authoritative aichat execution", func() { Resolver: &fakeResolver{provider: provider}, Threads: aichat.FixedThreadStore(store), Authority: &fakeExecutionAuthority{execution: execution}, Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ - Name: "account_edit", DefaultPermission: api.ToolModeAsk, + Name: "account_edit", DefaultPermission: api.ToolPolicyAsk, Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, }}), }) diff --git a/pkg/aichat/execution_database.go b/pkg/aichat/execution_database.go index aede242e..541e12e5 100644 --- a/pkg/aichat/execution_database.go +++ b/pkg/aichat/execution_database.go @@ -145,7 +145,7 @@ func (e *databaseExecution) startCallerTools(ctx context.Context, backend api.Ba if err != nil { return err } - policy := make(map[string]api.ToolMode, len(e.definitions)) + policy := make(map[string]api.ToolPolicy, len(e.definitions)) for _, definition := range e.definitions { policy[definition.Name] = definition.DefaultPermission } diff --git a/pkg/aichat/execution_database_integration_test.go b/pkg/aichat/execution_database_integration_test.go index c25e6141..7cddc10d 100644 --- a/pkg/aichat/execution_database_integration_test.go +++ b/pkg/aichat/execution_database_integration_test.go @@ -37,7 +37,7 @@ var _ = Describe("Database execution authority", func() { Name: "sonnet", Backend: api.BackendClaudeAgent, }.Capabilities()}, Definitions: []api.ToolDefinition{{ - Name: "account_edit", DefaultPermission: api.ToolModeAsk, + Name: "account_edit", DefaultPermission: api.ToolPolicyAsk, Handler: func(_ context.Context, input map[string]any) (any, error) { calls.Add(1) return input, nil diff --git a/pkg/aichat/mcp_provider.go b/pkg/aichat/mcp_provider.go index 88641223..8262f10d 100644 --- a/pkg/aichat/mcp_provider.go +++ b/pkg/aichat/mcp_provider.go @@ -250,7 +250,7 @@ func projectMCPTool(server string, tool mcp.Tool, client mcpClient) (api.ToolDef catalog := aitools.ToolCatalogEntry{ Name: name, Title: title, Description: tool.Description, Source: "mcp", Server: server, PreferenceKey: name, - DefaultPermission: api.ToolModeAuto, InputSchema: aitools.ObjectSchema(inputSchema), + DefaultPermission: api.ToolPolicyAuto, InputSchema: aitools.ObjectSchema(inputSchema), OutputSchema: outputSchema, } // _meta is where an MCP server publishes the grouping, icon and permission @@ -263,7 +263,7 @@ func projectMCPTool(server string, tool mcp.Tool, client mcpClient) (api.ToolDef Name: name, Description: tool.Description, InputSchema: maps.Clone(catalog.InputSchema), Group: catalog.Group, Parent: catalog.Parent, Icon: catalog.Icon, Strict: catalog.Strict, - DefaultPermission: api.ToolMode(catalog.DefaultPermission), + DefaultPermission: catalog.DefaultPermission, Handler: func(ctx context.Context, input map[string]any) (any, error) { for _, field := range required { if _, present := input[field]; !present { diff --git a/pkg/aichat/service.go b/pkg/aichat/service.go index 96e03dcd..03fd3b18 100644 --- a/pkg/aichat/service.go +++ b/pkg/aichat/service.go @@ -207,7 +207,7 @@ func (s *Service) handleChat(w http.ResponseWriter, request *http.Request) { http.Error(w, err.Error(), http.StatusInternalServerError) return } - definitions, err := aitools.ResolveDefinitions(set.Definitions, spec.ToolPreferences) + definitions, err := aitools.ResolveDefinitions(set.Definitions, aitools.ResolveOptions{Preferences: spec.ToolPreferences, Policy: spec.ToolPolicy}) if err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return diff --git a/pkg/aichat/service_ginkgo_test.go b/pkg/aichat/service_ginkgo_test.go index a1a09a29..7f645432 100644 --- a/pkg/aichat/service_ginkgo_test.go +++ b/pkg/aichat/service_ginkgo_test.go @@ -303,7 +303,7 @@ var _ = Describe("Captain aichat service", func() { {Type: "text", Text: "inspect"}, {Type: "file", URL: "https://example.com/image.png", Filename: "image.png", MediaType: "image/png"}, }}}, - Context: "invoice editor", ToolPreferences: api.ToolPreferences{"billing": api.ToolModeAsk}, + Context: "invoice editor", ToolPreferences: api.ToolPreferences{"billing": api.ToolPolicyAsk}, ReasoningEffort: api.EffortHigh, PermissionMode: api.PermissionAcceptEdits, } response := httptest.NewRecorder() @@ -316,7 +316,7 @@ var _ = Describe("Captain aichat service", func() { spec := provider.specs[0] Expect(spec.Model.Name).To(Equal("openai/test-model")) Expect(spec.Model.Effort).To(Equal(api.EffortHigh)) - Expect(spec.ToolPreferences).To(Equal(api.ToolPreferences{"billing": api.ToolModeAsk})) + Expect(spec.ToolPreferences).To(Equal(api.ToolPreferences{"billing": api.ToolPolicyAsk})) Expect(spec.Permissions.Mode).To(Equal(api.PermissionAcceptEdits)) Expect(spec.Messages).To(HaveLen(2)) Expect(spec.Messages[0]).To(Equal(api.Message{Role: api.RoleSystem, Parts: []api.Part{{ @@ -380,7 +380,7 @@ var _ = Describe("Captain aichat service", func() { service := aichat.NewService(aichat.ServiceOptions{ Resolver: resolver, Tools: aichat.StaticToolProvider([]api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, }}), }) @@ -388,7 +388,7 @@ var _ = Describe("Captain aichat service", func() { response := httptest.NewRecorder() service.Handler().ServeHTTP(response, requestJSON(http.MethodPost, "/api/chat", aichat.ChatRequest{ Model: "openai/test-model", - ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolModeOff}, + ToolPreferences: api.ToolPreferences{"invoice_get": api.ToolPolicyDeny}, Messages: []aichat.UIMessage{{ Role: "user", Parts: []aichat.UIPart{{Type: "text", Text: "hello"}}, }}, diff --git a/pkg/aichat/session_title.go b/pkg/aichat/session_title.go index 995b9608..65be7a1a 100644 --- a/pkg/aichat/session_title.go +++ b/pkg/aichat/session_title.go @@ -148,7 +148,7 @@ func (s *Service) sessionTitleTool(threadID string) api.ToolDefinition { "required": []any{sessionTitleInput}, }, ReadOnlyHint: &readOnly, - DefaultPermission: api.ToolModeOn, + DefaultPermission: api.ToolPolicyAllow, Handler: func(ctx context.Context, input map[string]any) (any, error) { title, _ := input[sessionTitleInput].(string) if strings.TrimSpace(title) == "" { diff --git a/pkg/aichat/session_title_ginkgo_test.go b/pkg/aichat/session_title_ginkgo_test.go index 7f87620f..d435647a 100644 --- a/pkg/aichat/session_title_ginkgo_test.go +++ b/pkg/aichat/session_title_ginkgo_test.go @@ -76,7 +76,7 @@ var _ = Describe("chat session titles", func() { thread, err := store.Create(context.Background(), "") Expect(err).NotTo(HaveOccurred()) service, provider := newService(store, nil, []api.ToolDefinition{{ - Name: "invoice_get", DefaultPermission: api.ToolModeOn, + Name: "invoice_get", DefaultPermission: api.ToolPolicyAllow, Handler: func(context.Context, map[string]any) (any, error) { return nil, nil }, }}) diff --git a/pkg/aichat/wire_ginkgo_test.go b/pkg/aichat/wire_ginkgo_test.go index 31aa5104..e5eaf833 100644 --- a/pkg/aichat/wire_ginkgo_test.go +++ b/pkg/aichat/wire_ginkgo_test.go @@ -44,8 +44,8 @@ var _ = Describe("AI SDK v6 wire types", func() { Expect(*request.Temperature).To(Equal(0.0)) Expect(request.Budget).To(Equal(api.Budget{Cost: 1.5, MaxTokens: 2048, MaxTurns: 4})) Expect(request.ToolPreferences).To(Equal(api.ToolPreferences{ - "billing": api.ToolModeAsk, - "invoice_get": api.ToolModeOn, + "billing": api.ToolPolicyAsk, + "invoice_get": api.ToolPolicyAllow, })) Expect(request.PermissionMode).To(Equal(api.PermissionAcceptEdits)) Expect(request.ToolApproval).To(BeNil()) @@ -96,7 +96,7 @@ var _ = Describe("AI SDK v6 wire types", func() { }} tools := aichat.ToolCatalogResponse{Tools: []aichat.ToolCatalogEntry{{ Name: "invoice_get", Source: "custom", Group: "billing", - PreferenceKey: "billing", DefaultPermission: api.ToolModeAsk, + PreferenceKey: "billing", DefaultPermission: api.ToolPolicyAsk, Strict: &strict, Method: "GET", Path: "/invoices/{id}", OperationName: "invoice get", InputSchema: map[string]any{"type": "object"}, }}} diff --git a/pkg/api/enums.go b/pkg/api/enums.go index 4300e8de..67550255 100644 --- a/pkg/api/enums.go +++ b/pkg/api/enums.go @@ -83,56 +83,91 @@ func (s VerifyScope) Validate() error { return fmt.Errorf("invalid verify scope %q; want one of: all, changed", s) } -// ToolMode is the per-tool exposure for one request. -type ToolMode string +// ToolPolicy is the per-tool exposure for one request, and the only tool +// permission vocabulary: auto, ask, allow, deny. It keeps the wire shape close +// to coding-agent UX. +// +// The legacy "on"/"off" spellings are accepted on decode via ParseToolPolicy, +// never stored and never emitted. "on" is deliberately not a global synonym — +// see ParseToolPolicyOptions.LegacyOn. +type ToolPolicy string const ( - ToolModeOn ToolMode = "on" - ToolModeAsk ToolMode = "ask" - ToolModeOff ToolMode = "off" - ToolModeAuto ToolMode = "auto" + ToolPolicyAuto ToolPolicy = "auto" + ToolPolicyAsk ToolPolicy = "ask" + ToolPolicyAllow ToolPolicy = "allow" + ToolPolicyDeny ToolPolicy = "deny" ) -// NormalizeToolMode canonicalizes a mode. -func NormalizeToolMode(m ToolMode) (ToolMode, bool) { - switch ToolMode(strings.ToLower(strings.TrimSpace(string(m)))) { - case ToolModeOn: - return ToolModeOn, true - case ToolModeAsk: - return ToolModeAsk, true - case ToolModeOff: - return ToolModeOff, true - case ToolModeAuto: - return ToolModeAuto, true +// Legacy tool-permission spellings, accepted on decode only. They are not +// ToolPolicy values: "on" resolves differently per encoding (see LegacyOn). +const ( + legacyToolOn = "on" + legacyToolOff = "off" +) + +// ParseToolPolicyOptions configures which legacy spellings a decoder accepts. +type ParseToolPolicyOptions struct { + // LegacyOn is what the legacy "on" spelling means in the caller's encoding. + // The two encodings differ in arity, not in meaning: + // + // - allow, where the encoding has no separate allow slot and "on" is the + // only way to say auto-run (spec.toolPreferences, MCP _meta, stored + // caller-tool credentials). + // - auto, where an Allow list already carries allow, leaving "on" free to + // mean "enabled, defer gating" (the legacy permissions.tools shape). + // + // The zero value rejects "on" and "off" outright. + LegacyOn ToolPolicy +} + +// ParseToolPolicy canonicalizes a tool policy, optionally accepting the legacy +// "on"/"off" spellings. "off" always means deny — both legacy encodings agree. +func ParseToolPolicy(value string, opts ParseToolPolicyOptions) (ToolPolicy, bool) { + normalized := strings.ToLower(strings.TrimSpace(value)) + if opts.LegacyOn != "" { + switch normalized { + case legacyToolOn: + return opts.LegacyOn, true + case legacyToolOff: + return ToolPolicyDeny, true + } + } + switch ToolPolicy(normalized) { + case ToolPolicyAuto: + return ToolPolicyAuto, true + case ToolPolicyAsk: + return ToolPolicyAsk, true + case ToolPolicyAllow: + return ToolPolicyAllow, true + case ToolPolicyDeny: + return ToolPolicyDeny, true default: return "", false } } -// Valid reports whether m is a recognised tool mode. -func (m ToolMode) Valid() bool { - _, ok := NormalizeToolMode(m) - return ok +// NormalizeToolPolicy canonicalizes a policy, rejecting the legacy spellings. +func NormalizeToolPolicy(value string) (ToolPolicy, bool) { + return ParseToolPolicy(value, ParseToolPolicyOptions{}) } -// ToolPolicy is the runtime-spec policy map value for one tool. It keeps the -// wire shape close to coding-agent UX: auto, ask, allow, deny. -type ToolPolicy string - -const ( - ToolPolicyAuto ToolPolicy = "auto" - ToolPolicyAsk ToolPolicy = "ask" - ToolPolicyAllow ToolPolicy = "allow" - ToolPolicyDeny ToolPolicy = "deny" -) - // Valid reports whether p is a recognised runtime tool policy. func (p ToolPolicy) Valid() bool { - switch p { - case ToolPolicyAuto, ToolPolicyAsk, ToolPolicyAllow, ToolPolicyDeny: - return true - default: - return false + _, ok := NormalizeToolPolicy(string(p)) + return ok +} + +// ApprovalDecision maps a policy to an approve/auto decision. handled is false +// only for auto, which defers to the runtime's default gate. +func (p ToolPolicy) ApprovalDecision() (require, handled bool) { + switch policy, ok := NormalizeToolPolicy(string(p)); { + case !ok, policy == ToolPolicyAuto: + return false, false + case policy == ToolPolicyAsk: + return true, true + default: // allow, deny — both run without an approval round-trip + return false, true } } diff --git a/pkg/api/is_empty_test.go b/pkg/api/is_empty_test.go index 48f5c11b..1ce40dbd 100644 --- a/pkg/api/is_empty_test.go +++ b/pkg/api/is_empty_test.go @@ -27,14 +27,14 @@ func TestIsEmpty(t *testing.T) { {name: "setup only", spec: Spec{Setup: &shell.Setup{Cwd: "/work"}}}, {name: "session only", spec: Spec{SessionID: "sess-1"}}, {name: "cli args only", spec: Spec{CLIArgs: map[string]any{"verbose": true}}}, - {name: "tool preferences only", spec: Spec{ToolPreferences: ToolPreferences{"billing": ToolModeAsk}}}, + {name: "tool preferences only", spec: Spec{ToolPreferences: ToolPreferences{"billing": ToolPolicyAsk}}}, {name: "model name only", spec: Spec{Model: Model{Name: "claude-sonnet-4-6"}}}, {name: "prompt user only", spec: Spec{Prompt: Prompt{User: "do the thing"}}}, // Tools and MCP marshal as derived views, so their emptiness is a domain // rule: a Tools block is empty iff it yields no policies, and an MCP block // iff it is not disabled and names nothing. - {name: "tools deny only", spec: Spec{Permissions: Permissions{Tools: Tools{Deny: []string{"Bash"}}}}}, + {name: "tools deny only", spec: Spec{Permissions: Permissions{Tools: Tools{"Bash": ToolPolicyDeny}}}}, {name: "mcp disabled", spec: Spec{Permissions: Permissions{MCP: MCP{Disabled: true}}}}, {name: "empty tools block", spec: Spec{Permissions: Permissions{Tools: Tools{}}}, want: true}, {name: "empty mcp block", spec: Spec{Permissions: Permissions{MCP: MCP{}}}, want: true}, diff --git a/pkg/api/permissions.go b/pkg/api/permissions.go index a94cfccf..0dda2562 100644 --- a/pkg/api/permissions.go +++ b/pkg/api/permissions.go @@ -29,13 +29,12 @@ type Permissions struct { Skills ResourcePolicies `json:"skills,omitempty" yaml:"skills,omitempty" pretty:"label=Skills"` } -// Tools is the per-tool policy. Allow/Deny/Modes are retained for legacy callers; -// JSON/YAML marshals as map[tool]auto|ask|allow|deny. -type Tools struct { - Allow []string `json:"-" yaml:"-" pretty:"label=Allow"` - Deny []string `json:"-" yaml:"-" pretty:"label=Deny"` - Modes map[string]ToolMode `json:"-" yaml:"-" pretty:"label=Modes"` -} +// Tools is the per-tool policy map, and its own wire shape: +// map[tool]auto|ask|allow|deny. +// +// The legacy {allow: [], deny: [], modes: {}} object is still accepted on +// decode and folded into this map — see UnmarshalJSON. It is never emitted. +type Tools map[string]ToolPolicy // MCP controls Model-Context-Protocol servers. type MCP struct { @@ -56,11 +55,11 @@ func (p Permissions) HasPreset(x Preset) bool { return slices.Contains(p.Presets, x) } -// AllowList and DenyList project the canonical policy map onto the two lists -// every claude transport speaks (--allowedTools / --disallowedTools). They are -// the only correct source for those flags: Policies() folds an `off` tool mode -// into a deny, so reading Tools.Deny directly lets `tools: {Bash: off}` past the -// filter and the tool runs. +// AllowList and DenyList project the policy map onto the two lists every claude +// transport speaks (--allowedTools / --disallowedTools). They are the only +// correct source for those flags: a legacy `tools: {Bash: off}` decodes to deny, +// so anything that filters on its own notion of "denied" lets it past and the +// tool runs. func (t Tools) AllowList() []string { return t.toolsWithPolicy(ToolPolicyAllow) } // DenyList is AllowList's counterpart; see its documentation. @@ -68,7 +67,7 @@ func (t Tools) DenyList() []string { return t.toolsWithPolicy(ToolPolicyDeny) } func (t Tools) toolsWithPolicy(want ToolPolicy) []string { var out []string - for tool, policy := range t.Policies() { + for tool, policy := range t { if policy == want { out = append(out, tool) } @@ -124,13 +123,8 @@ func (p Permissions) Validate() error { return fmt.Errorf("invalid preset %q (valid: edit, bare)", preset) } } - for tool, mode := range p.Tools.Modes { - if !mode.Valid() { - return fmt.Errorf("invalid tool mode %q for tool %q (valid: on, ask, off, auto)", mode, tool) - } - } - for tool, policy := range p.Tools.Policies() { - if !policy.Valid() { + for _, tool := range sortedKeys(p.Tools) { + if policy := p.Tools[tool]; !policy.Valid() { return fmt.Errorf("invalid tool policy %q for tool %q (valid: auto, ask, allow, deny)", policy, tool) } } @@ -152,41 +146,44 @@ func (p Permissions) Validate() error { return nil } -// Policies returns the canonical tool policy map. -func (t Tools) Policies() map[string]ToolPolicy { - out := map[string]ToolPolicy{} - for _, tool := range t.Allow { - if tool != "" { - out[tool] = ToolPolicyAllow - } +// Policies returns the tool policy map. Tools is that map, so this is an +// identity projection kept for callers that read the policy view by name. +func (t Tools) Policies() map[string]ToolPolicy { return t } + +// ToolsFromLists builds a policy map from the two flag-shaped lists the CLI +// carries (--allowed-tools / --disallowed-tools). A tool named in both is +// denied: the deny list exists solely to forbid, so honouring allow instead +// would grant more than the caller asked for. +func ToolsFromLists(allow, deny []string) Tools { + var tools Tools + for _, tool := range compactStrings(allow) { + tools.put(tool, ToolPolicyAllow) } - for _, tool := range t.Deny { - if tool != "" { - out[tool] = ToolPolicyDeny - } + for _, tool := range compactStrings(deny) { + tools.put(tool, ToolPolicyDeny) } - for tool, mode := range t.Modes { - if tool == "" { - continue - } - switch mode { - case ToolModeOn: - out[tool] = ToolPolicyAuto - case ToolModeAsk: - out[tool] = ToolPolicyAsk - case ToolModeOff: - out[tool] = ToolPolicyDeny + return tools +} + +// SetList replaces every tool currently carrying policy with the named tools. +// It is how a CLI flag overrides an inherited allow/deny list wholesale rather +// than merging into it. +func (t *Tools) SetList(policy ToolPolicy, tools []string) { + for tool, current := range *t { + if current == policy { + delete(*t, tool) } } - return out + for _, tool := range compactStrings(tools) { + t.put(tool, policy) + } } func (t Tools) MarshalJSON() ([]byte, error) { - policies := t.Policies() - if len(policies) == 0 { + if len(t) == 0 { return []byte("{}"), nil } - return json.Marshal(policies) + return json.Marshal(map[string]ToolPolicy(t)) } func (t *Tools) UnmarshalJSON(data []byte) error { @@ -196,31 +193,31 @@ func (t *Tools) UnmarshalJSON(data []byte) error { } if hasRawKey(raw, "allow") || hasRawKey(raw, "deny") || hasRawKey(raw, "modes") { var legacy struct { - Allow []string `json:"allow"` - Deny []string `json:"deny"` - Modes map[string]ToolMode `json:"modes"` + Allow []string `json:"allow"` + Deny []string `json:"deny"` + Modes map[string]string `json:"modes"` } if err := json.Unmarshal(data, &legacy); err != nil { return err } - t.Allow = compactStrings(legacy.Allow) - t.Deny = compactStrings(legacy.Deny) - t.Modes = compactToolModes(legacy.Modes) - for key, value := range raw { + if err := t.setLegacy(legacy.Allow, legacy.Deny, legacy.Modes); err != nil { + return err + } + for _, key := range sortedKeys(raw) { if key == "allow" || key == "deny" || key == "modes" { continue } - var policy ToolPolicy - if err := json.Unmarshal(value, &policy); err != nil { + var policy string + if err := json.Unmarshal(raw[key], &policy); err != nil { return err } - if err := t.applyPolicy(key, policy); err != nil { + if err := t.set(key, policy, ParseToolPolicyOptions{}); err != nil { return err } } return nil } - var policies map[string]ToolPolicy + var policies map[string]string if err := json.Unmarshal(data, &policies); err != nil { return err } @@ -228,73 +225,84 @@ func (t *Tools) UnmarshalJSON(data []byte) error { } func (t Tools) MarshalYAML() (any, error) { - return t.Policies(), nil + return map[string]ToolPolicy(t), nil } func (t *Tools) UnmarshalYAML(value *yaml.Node) error { if mappingHas(value, "allow") || mappingHas(value, "deny") || mappingHas(value, "modes") { var legacy struct { - Allow []string `yaml:"allow"` - Deny []string `yaml:"deny"` - Modes map[string]ToolMode `yaml:"modes"` + Allow []string `yaml:"allow"` + Deny []string `yaml:"deny"` + Modes map[string]string `yaml:"modes"` } if err := value.Decode(&legacy); err != nil { return err } - t.Allow = compactStrings(legacy.Allow) - t.Deny = compactStrings(legacy.Deny) - t.Modes = compactToolModes(legacy.Modes) - return nil + return t.setLegacy(legacy.Allow, legacy.Deny, legacy.Modes) } - var policies map[string]ToolPolicy + var policies map[string]string if err := value.Decode(&policies); err != nil { return err } return t.setPolicies(policies) } -func (t *Tools) setPolicies(policies map[string]ToolPolicy) error { - t.Allow = nil - t.Deny = nil - t.Modes = nil - for _, key := range sortedKeys(policies) { - if err := t.applyPolicy(key, policies[key]); err != nil { +// setLegacy folds the legacy {allow, deny, modes} object into the policy map. +// +// modes is parsed with LegacyOn: auto rather than allow, because this encoding +// carries allow in its own Allow list — leaving "on" to mean "enabled, defer +// gating". spec.toolPreferences has no such list and so reads "on" as allow. +// Both preserve what the respective configs meant before the vocabularies were +// unified; see ParseToolPolicyOptions.LegacyOn. +func (t *Tools) setLegacy(allow, deny []string, modes map[string]string) error { + *t = nil + for _, tool := range compactStrings(allow) { + t.put(tool, ToolPolicyAllow) + } + for _, tool := range compactStrings(deny) { + t.put(tool, ToolPolicyDeny) + } + for _, tool := range sortedKeys(modes) { + if err := t.set(tool, modes[tool], ParseToolPolicyOptions{LegacyOn: ToolPolicyAuto}); err != nil { return err } } return nil } -// applyPolicy folds one tool's policy into the canonical allow/deny/modes -// representation. An unrecognised policy is an error rather than a no-op: the -// policy map is the only place it appears, so dropping it here leaves nothing -// for Permissions.Validate to catch and the tool silently runs under the -// inherited default instead of the one that was configured. -func (t *Tools) applyPolicy(tool string, policy ToolPolicy) error { - if tool == "" { +func (t *Tools) setPolicies(policies map[string]string) error { + *t = nil + for _, tool := range sortedKeys(policies) { + if err := t.set(tool, policies[tool], ParseToolPolicyOptions{}); err != nil { + return err + } + } + return nil +} + +// set parses one tool's policy into the map. An unrecognised value is an error +// rather than a no-op: the policy map is the only place it appears, so dropping +// it here leaves nothing for Permissions.Validate to catch and the tool silently +// runs under the inherited default instead of the one that was configured. +func (t *Tools) set(tool, value string, opts ParseToolPolicyOptions) error { + if strings.TrimSpace(tool) == "" { return nil } - switch policy { - case ToolPolicyAllow: - t.Allow = append(t.Allow, tool) - case ToolPolicyDeny: - t.Deny = append(t.Deny, tool) - case ToolPolicyAsk: - if t.Modes == nil { - t.Modes = map[string]ToolMode{} - } - t.Modes[tool] = ToolModeAsk - case ToolPolicyAuto: - if t.Modes == nil { - t.Modes = map[string]ToolMode{} - } - t.Modes[tool] = ToolModeOn - default: - return fmt.Errorf("invalid tool policy %q for tool %q (valid: auto, ask, allow, deny)", policy, tool) + policy, ok := ParseToolPolicy(value, opts) + if !ok { + return fmt.Errorf("invalid tool policy %q for tool %q (valid: auto, ask, allow, deny)", value, tool) } + t.put(tool, policy) return nil } +func (t *Tools) put(tool string, policy ToolPolicy) { + if *t == nil { + *t = Tools{} + } + (*t)[tool] = policy +} + func (m MCP) MarshalJSON() ([]byte, error) { return json.Marshal(m.asMap()) } @@ -489,19 +497,6 @@ func compactStrings(in []string) []string { return out } -func compactToolModes(in map[string]ToolMode) map[string]ToolMode { - if len(in) == 0 { - return nil - } - out := map[string]ToolMode{} - for _, key := range sortedKeys(in) { - if key != "" { - out[key] = in[key] - } - } - return out -} - func sortedKeys[V any](m map[string]V) []string { keys := make([]string, 0, len(m)) for key := range m { diff --git a/pkg/api/permissions_schema.go b/pkg/api/permissions_schema.go index c760b810..42053679 100644 --- a/pkg/api/permissions_schema.go +++ b/pkg/api/permissions_schema.go @@ -68,10 +68,14 @@ func (Preset) JSONSchema() *jsonschema.Schema { } } -// JSONSchema declares the wire form of Tools, which reflection reports as `{}` -// because Allow, Deny and Modes are all json:"-" behind MarshalJSON. The wire -// shape has always been a tool→policy map; this is the first time the schema -// says so. +// JSONSchema declares the wire form of Tools. Tools is now the tool→policy map +// itself, so reflection would very nearly get this right — but it keeps a +// hand-written schema to carry the description and the policy enum. +// +// The deprecated {allow, deny, modes} object is still accepted on decode and is +// deliberately absent here: the schema is what an editor renders a form from, +// and offering the legacy shape as an alternative would invite new configs into +// the encoding whose "on" means auto rather than allow. See Tools.setLegacy. func (Tools) JSONSchema() *jsonschema.Schema { return &jsonschema.Schema{ Type: "object", @@ -80,6 +84,19 @@ func (Tools) JSONSchema() *jsonschema.Schema { } } +// JSONSchema declares the per-turn tool preferences. Reflection would report a +// bare `{"type":"string"}` for the values, so nothing told a client which words +// this field takes — the gap that let the UI and the server drift apart. +func (ToolPreferences) JSONSchema() *jsonschema.Schema { + values := ToolPolicy("").JSONSchema() + values.Description = "Preference for one tool or group. The legacy on|off spellings are accepted and mean allow|deny." + return &jsonschema.Schema{ + Type: "object", + Description: "Per-turn preference keyed by tool name or group. A tool-name entry overrides its group entry.", + AdditionalProperties: values, + } +} + // JSONSchema declares the wire form of MCP, which reflection also reports as // `{}` for the same reason. func (MCP) JSONSchema() *jsonschema.Schema { diff --git a/pkg/api/permissions_test.go b/pkg/api/permissions_test.go index 52cd9038..86274905 100644 --- a/pkg/api/permissions_test.go +++ b/pkg/api/permissions_test.go @@ -12,12 +12,10 @@ import ( func TestPermissions_JSONPolicyShape(t *testing.T) { in := Permissions{ Tools: Tools{ - Allow: []string{"Read"}, - Deny: []string{"Bash"}, - Modes: map[string]ToolMode{ - "WebSearch": ToolModeAsk, - "Write": ToolModeOn, - }, + "Read": ToolPolicyAllow, + "Bash": ToolPolicyDeny, + "WebSearch": ToolPolicyAsk, + "Write": ToolPolicyAuto, }, MCP: MCP{ Servers: []string{"filesystem", "gavel"}, @@ -122,10 +120,10 @@ skills: } // TestTools_UnrecognisedPolicyFailsAtDecode pins the decode boundary as the place -// a mistyped tool policy surfaces. The policy map is the only representation the -// value ever has: applyPolicy translates it into allow/deny/modes, so a policy it -// does not recognise leaves no trace for Permissions.Validate to inspect -// afterwards, and the tool runs under whatever posture it inherited instead. +// a mistyped tool policy surfaces. Tools.set is what rejects it; drop the check +// there and an unrecognised value is simply absent from the map afterwards, with +// nothing left for Permissions.Validate to inspect and the tool running under +// whatever posture it inherited instead. func TestTools_UnrecognisedPolicyFailsAtDecode(t *testing.T) { for _, tc := range []struct { name string diff --git a/pkg/api/pretty.go b/pkg/api/pretty.go index 668d7203..5028ec55 100644 --- a/pkg/api/pretty.go +++ b/pkg/api/pretty.go @@ -29,10 +29,10 @@ func (p Permissions) Pretty() clickyapi.Text { mode = "default" } t := clickyapi.Text{}.Append("mode=").Append(mode, "font-medium") - if n := len(p.Tools.Allow); n > 0 { + if n := len(p.Tools.AllowList()); n > 0 { t = t.Appendf(" · %d allow", n) } - if n := len(p.Tools.Deny); n > 0 { + if n := len(p.Tools.DenyList()); n > 0 { t = t.Appendf(" · %d deny", n) } if p.MCP.Disabled { diff --git a/pkg/api/spec.go b/pkg/api/spec.go index b8d9aae0..b4d1d729 100644 --- a/pkg/api/spec.go +++ b/pkg/api/spec.go @@ -27,7 +27,12 @@ type Spec struct { // ToolPreferences is the serializable per-turn tool/group selection policy. // Executable tool handlers remain in Config.Tools. ToolPreferences ToolPreferences `json:"toolPreferences,omitempty" yaml:"toolPreferences,omitempty" pretty:"-"` - ToolApproval *ToolApprovalResume `json:"toolApproval,omitempty" yaml:"toolApproval,omitempty" pretty:"-"` + // ToolPolicy is the ordered, last-match-wins rule list governing tool + // authority. It supersedes ToolPreferences' flat exact-name map, but both are + // accepted: ResolveDefinitions lowers the map through FromPreferences and + // evaluates one list, so the two shapes cannot disagree about a tool. + ToolPolicy PermissionPolicy `json:"toolPolicy,omitempty" yaml:"toolPolicy,omitempty" pretty:"-"` + ToolApproval *ToolApprovalResume `json:"toolApproval,omitempty" yaml:"toolApproval,omitempty" pretty:"-"` Setup *shell.Setup `json:"setup,omitempty" yaml:"setup,omitempty"` // Sandbox selects the sandbox backend the run executes under. Absent = the @@ -55,6 +60,7 @@ type specMarshal struct { Memory *Memory `json:"memory,omitempty" yaml:"memory,omitempty"` Permissions *Permissions `json:"permissions,omitempty" yaml:"permissions,omitempty"` Preferences *ToolPreferences `json:"toolPreferences,omitempty" yaml:"toolPreferences,omitempty"` + ToolPolicy PermissionPolicy `json:"toolPolicy,omitempty" yaml:"toolPolicy,omitempty"` Approval *ToolApprovalResume `json:"toolApproval,omitempty" yaml:"toolApproval,omitempty"` Setup *shell.Setup `json:"setup,omitempty" yaml:"setup,omitempty"` Sandbox *SandboxRef `json:"sandbox,omitempty" yaml:"sandbox,omitempty"` @@ -146,6 +152,7 @@ func (s Spec) marshalValue() specMarshal { Memory: omitEmptyValue(s.Memory), Permissions: omitEmptyValue(s.Permissions), Preferences: omitEmptyValue(s.ToolPreferences), + ToolPolicy: s.ToolPolicy, Approval: omitEmptyPointer(s.ToolApproval), Setup: omitEmptyPointer(s.Setup), Sandbox: omitEmptyPointer(s.Sandbox), @@ -206,6 +213,9 @@ func (s Spec) Validate() error { if err := s.ToolPreferences.Validate(); err != nil { return err } + if err := s.ToolPolicy.Validate(); err != nil { + return err + } if err := s.Workflow.Validate(); err != nil { return fmt.Errorf("workflow: %w", err) } diff --git a/pkg/api/spec_marshal_ginkgo_test.go b/pkg/api/spec_marshal_ginkgo_test.go index ec0b511e..83f4252c 100644 --- a/pkg/api/spec_marshal_ginkgo_test.go +++ b/pkg/api/spec_marshal_ginkgo_test.go @@ -14,7 +14,7 @@ var _ = Describe("Spec serialization", func() { func(marshal func(any) ([]byte, error), decode func([]byte, any) error) { encoded, err := marshal(Spec{ Memory: Memory{Skills: []string{}}, - Permissions: Permissions{Tools: Tools{Modes: map[string]ToolMode{}}}, + Permissions: Permissions{Tools: Tools{}}, Setup: &shell.Setup{}, Workflow: &Workflow{}, }) diff --git a/pkg/api/spec_merge_differential_test.go b/pkg/api/spec_merge_differential_test.go index a8284a18..42956795 100644 --- a/pkg/api/spec_merge_differential_test.go +++ b/pkg/api/spec_merge_differential_test.go @@ -23,7 +23,7 @@ import ( // merges identically or forces an explicit decision here. // legacyMerge is the hand-written implementation Spec.Merge had before the -// structural engine. It is kept only as the differential oracle. +// structural engine. It is kept only as the differential reference. func legacyMerge(s, override Spec) Spec { s.Model = legacyMergeModel(s.Model, override.Model) s.Prompt = legacyMergePrompt(s.Prompt, override.Prompt) @@ -36,6 +36,12 @@ func legacyMerge(s, override Spec) Spec { if len(override.ToolPreferences) > 0 { s.ToolPreferences = override.ToolPreferences } + // An ordered rule list is replaced wholesale, never element-wise: the list's + // meaning is its order, so splicing one layer's rules into another's + // positions would produce a precedence neither author wrote. + if len(override.ToolPolicy) > 0 { + s.ToolPolicy = override.ToolPolicy + } if override.ToolApproval != nil { s.ToolApproval = override.ToolApproval } @@ -161,7 +167,7 @@ func legacyMergePermissions(p, o Permissions) Permissions { if len(o.Presets) > 0 { p.Presets = o.Presets } - if len(o.Tools.Allow) > 0 || len(o.Tools.Deny) > 0 || len(o.Tools.Modes) > 0 { + if len(o.Tools) > 0 { p.Tools = o.Tools } if o.MCP.Disabled || len(o.MCP.Servers) > 0 || len(o.MCP.Modes) > 0 { @@ -220,13 +226,12 @@ func TestSpec_Merge_MatchesLegacy(t *testing.T) { func TestSpec_Merge_IntentionalPolicyChanges(t *testing.T) { t.Run("a partial tool override composes with the inherited allow-list", func(t *testing.T) { - base := Spec{Permissions: Permissions{Tools: Tools{Allow: []string{"Read", "Grep"}}}} - got := base.Merge(Spec{Permissions: Permissions{Tools: Tools{Deny: []string{"Bash"}}}}) - if !reflect.DeepEqual(got.Permissions.Tools.Allow, []string{"Read", "Grep"}) { - t.Errorf("Allow = %v, want the inherited allow-list kept", got.Permissions.Tools.Allow) - } - if !reflect.DeepEqual(got.Permissions.Tools.Deny, []string{"Bash"}) { - t.Errorf("Deny = %v, want [Bash]", got.Permissions.Tools.Deny) + base := Spec{Permissions: Permissions{Tools: Tools{"Read": ToolPolicyAllow, "Grep": ToolPolicyAllow}}} + got := base.Merge(Spec{Permissions: Permissions{Tools: Tools{"Bash": ToolPolicyDeny}}}) + if !reflect.DeepEqual(got.Permissions.Tools, Tools{ + "Read": ToolPolicyAllow, "Grep": ToolPolicyAllow, "Bash": ToolPolicyDeny, + }) { + t.Errorf("Tools = %v, want the inherited allow entries kept alongside the new deny", got.Permissions.Tools) } }) @@ -270,16 +275,16 @@ func TestSpec_Merge_IntentionalPolicyChanges(t *testing.T) { // never had: a merged spec must not share mutable memory with the layers it came // from, so editing it cannot reach back into the config it inherited. func TestSpec_Merge_ResultIsIndependent(t *testing.T) { - base := Spec{Permissions: Permissions{Tools: Tools{Allow: []string{"Read"}}}} + base := Spec{Permissions: Permissions{Tools: Tools{"Read": ToolPolicyAllow}}} override := Spec{Setup: &shell.Setup{Cwd: "/work", DotEnv: []string{".env"}}} got := base.Merge(override) - got.Permissions.Tools.Allow[0] = "mutated" + got.Permissions.Tools["Read"] = ToolPolicyDeny got.Setup.DotEnv[0] = ".env.local" got.Setup.Cwd = "/elsewhere" - if base.Permissions.Tools.Allow[0] != "Read" { - t.Errorf("base allow-list mutated through the merged spec: %v", base.Permissions.Tools.Allow) + if base.Permissions.Tools["Read"] != ToolPolicyAllow { + t.Errorf("base tool policy mutated through the merged spec: %v", base.Permissions.Tools) } if override.Setup.DotEnv[0] != ".env" || override.Setup.Cwd != "/work" { t.Errorf("override setup mutated through the merged spec: %+v", override.Setup) diff --git a/pkg/api/spec_test.go b/pkg/api/spec_test.go index e2f92b6e..a4a22b62 100644 --- a/pkg/api/spec_test.go +++ b/pkg/api/spec_test.go @@ -22,7 +22,7 @@ func sampleSpec() Spec { Permissions: Permissions{ Mode: PermissionAcceptEdits, Presets: []Preset{PresetEdit}, - Tools: Tools{Allow: []string{"Edit", "Read"}, Modes: map[string]ToolMode{"Bash": ToolModeAsk}}, + Tools: Tools{"Edit": ToolPolicyAllow, "Read": ToolPolicyAllow, "Bash": ToolPolicyAsk}, MCP: MCP{Disabled: true}, Plugins: ResourcePolicies{"/plugins": ResourceEnabled}, Skills: ResourcePolicies{"/skills/b": ResourceDisabled}, diff --git a/pkg/api/tool_policy_support_test.go b/pkg/api/tool_policy_support_test.go index 257e4cae..f847fd15 100644 --- a/pkg/api/tool_policy_support_test.go +++ b/pkg/api/tool_policy_support_test.go @@ -1,6 +1,7 @@ package api import ( + "encoding/json" "strings" "testing" ) @@ -16,7 +17,7 @@ func TestRequireToolPolicySupport(t *testing.T) { BackendClaudeCmux: true, } - policy := Permissions{Tools: Tools{Deny: []string{"Bash"}}} + policy := Permissions{Tools: Tools{"Bash": ToolPolicyDeny}} for _, backend := range AllBackends() { t.Run(string(backend), func(t *testing.T) { err := RequireToolPolicySupport(backend, policy) @@ -54,11 +55,22 @@ func TestRequireToolPolicySupport_EmptyPolicyAlwaysPasses(t *testing.T) { } } +// legacyTools decodes the deprecated {allow, deny, modes} object, which is the +// only way "on"/"off" enter the policy map now that Tools is the map itself. +func legacyTools(t *testing.T, body string) Tools { + t.Helper() + var tools Tools + if err := json.Unmarshal([]byte(body), &tools); err != nil { + t.Fatalf("decode legacy tools %s: %v", body, err) + } + return tools +} + // TestRequireToolPolicySupport_NormalizesToolModes pins that the guard reads the -// canonical policy map: `tools: {Bash: off}` is a deny expressed through Modes, -// so a backend with no tool filter must refuse it exactly like Tools.Deny. +// canonical policy map: legacy `modes: {Bash: off}` decodes to a deny, so a +// backend with no tool filter must refuse it exactly like an explicit deny. func TestRequireToolPolicySupport_NormalizesToolModes(t *testing.T) { - policy := Permissions{Tools: Tools{Modes: map[string]ToolMode{"Bash": ToolModeOff}}} + policy := Permissions{Tools: legacyTools(t, `{"modes":{"Bash":"off"}}`)} err := RequireToolPolicySupport(BackendCodexCLI, policy) if err == nil { t.Fatal("codex-cli silently drops an off tool mode; want a loud refusal") @@ -69,9 +81,12 @@ func TestRequireToolPolicySupport_NormalizesToolModes(t *testing.T) { if err := RequireToolPolicySupport(BackendClaudeCLI, policy); err != nil { t.Fatalf("claude-cli must carry an off tool mode, got %v", err) } - // `on` resolves to auto, which constrains nothing and so needs no backend - // support. - auto := Permissions{Tools: Tools{Modes: map[string]ToolMode{"Read": ToolModeOn}}} + // Legacy `on` resolves to auto in this encoding — the allow list already + // carries allow — and auto constrains nothing, so it needs no backend support. + auto := Permissions{Tools: legacyTools(t, `{"modes":{"Read":"on"}}`)} + if auto.Tools["Read"] != ToolPolicyAuto { + t.Fatalf(`legacy modes "on" = %q, want auto`, auto.Tools["Read"]) + } if err := RequireToolPolicySupport(BackendCodexCLI, auto); err != nil { t.Errorf("an auto policy constrains nothing but was refused: %v", err) } @@ -81,7 +96,7 @@ func TestRequireToolPolicySupport_NormalizesToolModes(t *testing.T) { // policy cannot express: no transport has a per-tool prompt, so an `ask` would // resolve to "allowed" even on the backends that advertise support. func TestRequireToolPolicySupport_AskIsRefusedEverywhere(t *testing.T) { - policy := Permissions{Tools: Tools{Modes: map[string]ToolMode{"Bash": ToolModeAsk}}} + policy := Permissions{Tools: Tools{"Bash": ToolPolicyAsk}} for _, backend := range AllBackends() { err := RequireToolPolicySupport(backend, policy) if err == nil { @@ -94,28 +109,28 @@ func TestRequireToolPolicySupport_AskIsRefusedEverywhere(t *testing.T) { } } -// TestToolsAllowDenyLists pins the projection every claude transport reads: the -// raw Allow/Deny slices miss the tool modes, which is how an `off` tool escaped -// the filter. +// TestToolsAllowDenyLists pins the projection every claude transport reads, +// exercised through the legacy shape because that is where the two spellings of +// a deny meet: an explicit `deny` list and a `modes: off` entry must both land +// in DenyList, and a `modes: on` entry must land in neither — it means auto here. func TestToolsAllowDenyLists(t *testing.T) { - tools := Tools{ - Allow: []string{"Read"}, - Deny: []string{"WebFetch"}, - Modes: map[string]ToolMode{"Bash": ToolModeOff, "Glob": ToolModeOn}, - } + tools := legacyTools(t, `{"allow":["Read"],"deny":["WebFetch"],"modes":{"Bash":"off","Glob":"on"}}`) if got := tools.DenyList(); len(got) != 2 || got[0] != "Bash" || got[1] != "WebFetch" { t.Errorf("DenyList() = %v, want [Bash WebFetch]", got) } if got := tools.AllowList(); len(got) != 1 || got[0] != "Read" { t.Errorf("AllowList() = %v, want [Read]", got) } + if tools["Glob"] != ToolPolicyAuto { + t.Errorf(`Glob = %q, want auto — legacy "on" is not allow in this encoding`, tools["Glob"]) + } } // TestRequireToolPolicySupport_AllowListToo pins that an allow-list is refused on // the same backends: where there is no tool filter, an allowlist is equally // unenforced, and silently ignoring it grants more than the spec allowed. func TestRequireToolPolicySupport_AllowListToo(t *testing.T) { - policy := Permissions{Tools: Tools{Allow: []string{"Read"}}} + policy := Permissions{Tools: Tools{"Read": ToolPolicyAllow}} if err := RequireToolPolicySupport(BackendCodexCLI, policy); err == nil { t.Fatal("codex-cli silently drops an allow-list; want a loud refusal") } diff --git a/pkg/api/tool_preferences_ginkgo_test.go b/pkg/api/tool_preferences_ginkgo_test.go index e5fab6d6..d4b4302f 100644 --- a/pkg/api/tool_preferences_ginkgo_test.go +++ b/pkg/api/tool_preferences_ginkgo_test.go @@ -14,7 +14,7 @@ var _ = Describe("Tool preferences", func() { in := api.Spec{ Model: api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic}, Prompt: api.Prompt{User: "inspect invoices"}, - ToolPreferences: api.ToolPreferences{"billing": api.ToolModeAsk, "invoice_delete": api.ToolModeOff}, + ToolPreferences: api.ToolPreferences{"billing": api.ToolPolicyAsk, "invoice_delete": api.ToolPolicyDeny}, } encoded, err := json.Marshal(in) @@ -23,7 +23,7 @@ var _ = Describe("Tool preferences", func() { Expect(json.Unmarshal(encoded, &wire)).To(Succeed()) Expect(wire["toolPreferences"]).To(Equal(map[string]any{ "billing": "ask", - "invoice_delete": "off", + "invoice_delete": "deny", })) var out api.Spec @@ -31,20 +31,41 @@ var _ = Describe("Tool preferences", func() { Expect(out.ToolPreferences).To(Equal(in.ToolPreferences)) }) + // This encoding has no separate allow list, so "on" was always its way of + // saying auto-run and must decode to allow — not to auto, which is what the + // legacy permissions.tools modes map means by the same word. + It("decodes the legacy on/off spellings as allow/deny", func() { + var out api.Spec + Expect(json.Unmarshal([]byte( + `{"toolPreferences":{"billing":"on","search":"off","audit":"auto"}}`), &out)).To(Succeed()) + + Expect(out.ToolPreferences).To(Equal(api.ToolPreferences{ + "billing": api.ToolPolicyAllow, + "search": api.ToolPolicyDeny, + "audit": api.ToolPolicyAuto, + })) + + // Decoded once, they are re-emitted in the canonical vocabulary only. + encoded, err := json.Marshal(out) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).To(ContainSubstring(`"billing":"allow"`)) + Expect(string(encoded)).NotTo(ContainSubstring(`"on"`)) + }) + It("merges per-turn preferences key-wise, leaving untouched tools alone", func() { base := api.Spec{ToolPreferences: api.ToolPreferences{ - "billing": api.ToolModeAsk, - "search": api.ToolModeOff, + "billing": api.ToolPolicyAsk, + "search": api.ToolPolicyDeny, }} override := api.Spec{ToolPreferences: api.ToolPreferences{ - "billing": api.ToolModeOn, + "billing": api.ToolPolicyAllow, }} // Each key is an independent decision, so an override that speaks about // billing says nothing about search — it must not silently re-enable it. Expect(base.Merge(override).ToolPreferences).To(Equal(api.ToolPreferences{ - "billing": api.ToolModeOn, - "search": api.ToolModeOff, + "billing": api.ToolPolicyAllow, + "search": api.ToolPolicyDeny, })) Expect(base.Merge(api.Spec{}).ToolPreferences).To(Equal(base.ToolPreferences)) }) @@ -59,17 +80,25 @@ var _ = Describe("Tool preferences", func() { Expect(spec.Validate()).To(MatchError(ContainSubstring(`invalid tool preference "sometimes" for "billing"`))) }) + It("rejects an unknown preference at the decode boundary too", func() { + var out api.Spec + Expect(json.Unmarshal([]byte(`{"toolPreferences":{"billing":"sometimes"}}`), &out)). + To(MatchError(ContainSubstring(`invalid tool preference "sometimes" for "billing"`))) + }) + It("rejects removed enabled and disabled labels", func() { - for _, mode := range []api.ToolMode{"enabled", "disabled"} { + for _, policy := range []api.ToolPolicy{"enabled", "disabled"} { spec := api.Spec{ Model: api.Model{Name: "claude-sonnet-4-6", Backend: api.BackendAnthropic}, Prompt: api.Prompt{User: "inspect invoices"}, - ToolPreferences: api.ToolPreferences{"billing": mode}, + ToolPreferences: api.ToolPreferences{"billing": policy}, } Expect(spec.Validate()).To(MatchError(ContainSubstring(`invalid tool preference`))) - permissions := api.Permissions{Tools: api.Tools{Modes: map[string]api.ToolMode{"billing": mode}}} - Expect(permissions.Validate()).To(MatchError(ContainSubstring(`invalid tool mode`))) + var permissions api.Permissions + Expect(json.Unmarshal([]byte( + `{"tools":{"modes":{"billing":"`+string(policy)+`"}}}`), &permissions)). + To(MatchError(ContainSubstring(`invalid tool policy`))) } }) }) diff --git a/pkg/api/toolcatalog_ginkgo_test.go b/pkg/api/toolcatalog_ginkgo_test.go new file mode 100644 index 00000000..54133de2 --- /dev/null +++ b/pkg/api/toolcatalog_ginkgo_test.go @@ -0,0 +1,67 @@ +package api_test + +import ( + "github.com/flanksource/captain/pkg/api" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// The publishers on the other side of the MCP _meta wire still speak the legacy +// vocabulary: clicky/entity.ToolPermission is on|off|ask|auto and +// clicky/mcp/registry copies it verbatim into _meta.defaultPermission. Reading +// those words as policies without the legacy mapping silently resolves them to +// auto, which turns "off" — a request to hide the tool — into a tool that is +// merely gated. These pin the mapping rather than the symptom. +var _ = Describe("ApplyToolMetadata", func() { + entryWith := func(meta map[string]any) api.ToolCatalogEntry { + entry := api.ToolCatalogEntry{Name: "invoice_delete", DefaultPermission: api.ToolPolicyAuto} + api.ApplyToolMetadata(&entry, meta) + return entry + } + + DescribeTable("resolves the published permission", + func(published string, want api.ToolPolicy) { + Expect(entryWith(map[string]any{"defaultPermission": published}).DefaultPermission).To(Equal(want)) + }, + Entry("legacy off means deny, never auto", "off", api.ToolPolicyDeny), + Entry("legacy on means allow, never auto", "on", api.ToolPolicyAllow), + Entry("ask is spelled the same in both", "ask", api.ToolPolicyAsk), + Entry("auto is spelled the same in both", "auto", api.ToolPolicyAuto), + Entry("allow passes through", "allow", api.ToolPolicyAllow), + Entry("deny passes through", "deny", api.ToolPolicyDeny), + Entry("case and padding are folded", " Off ", api.ToolPolicyDeny), + Entry("an unrecognised word falls back to auto", "sometimes", api.ToolPolicyAuto), + ) + + It("reads the nested clicky vendor block, where clicky actually publishes it", func() { + entry := entryWith(map[string]any{ + "com.flanksource.clicky/tool": map[string]any{"defaultPermission": "off"}, + }) + Expect(entry.DefaultPermission).To(Equal(api.ToolPolicyDeny)) + }) + + It("accepts the legacy defaultMode key too", func() { + Expect(entryWith(map[string]any{"defaultMode": "on"}).DefaultPermission).To(Equal(api.ToolPolicyAllow)) + }) +}) + +// A deny must remove the tool from the set the model is shown. This is the half +// of the contract ApplyToolMetadata's mapping exists to protect: if "off" ever +// resolves to auto again, the tool reappears here behind an approval prompt +// rather than being omitted, and nothing else in the stack would notice. +var _ = Describe("ToolPolicy.ApprovalDecision", func() { + DescribeTable("maps a policy to an approval decision", + func(policy api.ToolPolicy, wantRequire, wantHandled bool) { + require, handled := policy.ApprovalDecision() + Expect(handled).To(Equal(wantHandled)) + Expect(require).To(Equal(wantRequire)) + }, + Entry("ask requires approval", api.ToolPolicyAsk, true, true), + Entry("allow runs unprompted", api.ToolPolicyAllow, false, true), + Entry("deny is settled without an approval round-trip", api.ToolPolicyDeny, false, true), + Entry("auto defers to the runtime gate", api.ToolPolicyAuto, false, false), + Entry("an unset policy defers", api.ToolPolicy(""), false, false), + Entry("a legacy spelling is not a policy and defers", api.ToolPolicy("on"), false, false), + ) +}) diff --git a/pkg/api/tooldef.go b/pkg/api/tooldef.go index 717a9a0d..e8115a81 100644 --- a/pkg/api/tooldef.go +++ b/pkg/api/tooldef.go @@ -2,23 +2,63 @@ package api import ( "context" + "encoding/json" "fmt" + + "gopkg.in/yaml.v3" ) -// ToolPreferences selects the effective per-turn mode for a tool name or group. -// An exact tool-name entry takes precedence over its group entry. -type ToolPreferences map[string]ToolMode +// ToolPreferences selects the effective per-turn policy for a tool name or +// group. An exact tool-name entry takes precedence over its group entry. +// +// This encoding has no separate allow list, so the legacy "on" spelling is the +// only way it could say auto-run and decodes to allow — unlike the legacy +// permissions.tools modes map, where an Allow list already carries allow and +// "on" means auto. See ParseToolPolicyOptions.LegacyOn. +type ToolPreferences map[string]ToolPolicy + +const legacyPreferenceOn = ToolPolicyAllow -// Validate rejects unknown modes before a provider request is assembled. +// Validate rejects unknown policies before a provider request is assembled. func (p ToolPreferences) Validate() error { if _, exists := p[""]; exists { return fmt.Errorf("tool preference key cannot be empty") } for _, key := range sortedKeys(p) { - mode := p[key] - if _, ok := NormalizeToolMode(mode); !ok { - return fmt.Errorf("invalid tool preference %q for %q (valid: on, ask, off, auto)", mode, key) + if policy := p[key]; !policy.Valid() { + return fmt.Errorf("invalid tool preference %q for %q (valid: auto, ask, allow, deny)", policy, key) + } + } + return nil +} + +func (p *ToolPreferences) UnmarshalJSON(data []byte) error { + var raw map[string]string + if err := json.Unmarshal(data, &raw); err != nil { + return err + } + return p.setAll(raw) +} + +func (p *ToolPreferences) UnmarshalYAML(value *yaml.Node) error { + var raw map[string]string + if err := value.Decode(&raw); err != nil { + return err + } + return p.setAll(raw) +} + +func (p *ToolPreferences) setAll(raw map[string]string) error { + *p = nil + for _, key := range sortedKeys(raw) { + policy, ok := ParseToolPolicy(raw[key], ParseToolPolicyOptions{LegacyOn: legacyPreferenceOn}) + if !ok { + return fmt.Errorf("invalid tool preference %q for %q (valid: auto, ask, allow, deny)", raw[key], key) + } + if *p == nil { + *p = ToolPreferences{} } + (*p)[key] = policy } return nil } @@ -56,9 +96,9 @@ type ToolDefinition struct { IdempotentHint *bool // Handler executes the tool in-process. Required. Handler ToolHandler `json:"-"` - // DefaultPermission controls exposure: off omits the tool, ask routes calls - // through Config.CanUseTool, on auto-runs, and auto defers to runtime policy. - DefaultPermission ToolMode + // DefaultPermission controls exposure: deny omits the tool, ask routes calls + // through Config.CanUseTool, allow auto-runs, and auto defers to runtime policy. + DefaultPermission ToolPolicy // Annotations carries opaque caller metadata (e.g. the originating CLI // verb/method/path) for policies that want the raw values; providers ignore it. Annotations map[string]string `json:",omitempty"` @@ -67,6 +107,6 @@ type ToolDefinition struct { // NeedsApproval reports whether a call to this tool must go through // Config.CanUseTool before running. func (t ToolDefinition) NeedsApproval() bool { - mode, ok := NormalizeToolMode(t.DefaultPermission) - return ok && mode == ToolModeAsk + policy, ok := NormalizeToolPolicy(string(t.DefaultPermission)) + return ok && policy == ToolPolicyAsk } diff --git a/pkg/api/toolpolicy.go b/pkg/api/toolpolicy.go new file mode 100644 index 00000000..264bad84 --- /dev/null +++ b/pkg/api/toolpolicy.go @@ -0,0 +1,283 @@ +package api + +import ( + "encoding/json" + "fmt" + "sort" + "strings" + + "github.com/flanksource/commons/collections" + "gopkg.in/yaml.v3" +) + +// This file is the one place a tool's authority is decided. +// +// It replaces four mechanisms that each owned part of the answer and disagreed: +// a static group→permission table, clicky's verb-default annotation stamping, an +// app-specific chat permission callback, and MCP metadata overlay. All four wrote +// into the same DefaultPermission slot, so the last writer won by accident of +// call order rather than by intent — which is how a group baseline like +// `provider.xero.read: off` could never reach the execution path, and how the +// OpenAPI catalog and the chat executor came to disagree about the same tool. +// +// The replacement is an ordered rule list evaluated last-match-wins. Order is the +// whole contract: weakest first, strongest last — group baselines, verb defaults, +// hint/method defaults, app rules, surface (.prompt) rules, then user rules. A +// later rule overrides an earlier one, so "stronger" is expressed by position +// rather than by a precedence number that every producer would have to keep +// consistent with every other. +// +// Matching is glob-based rather than exact-string. Exact matching is what forced +// callers to enumerate every tool name, and an enumeration goes stale silently +// the moment a tool is added — the new tool simply matches nothing and falls back +// to a default nobody chose. + +// MatchPatterns is a glob pattern list that decodes from either a scalar or a +// sequence, so `.prompt` frontmatter can write `group: provider.xero.*` without +// list ceremony. +// +// Patterns are matched with commons/collections.MatchItems: `!` negates and takes +// precedence over any positive match, `*` wildcards a prefix, suffix, or the +// whole item, matching is case-insensitive, and one string may carry +// comma-separated alternatives. An empty list matches everything, which is why +// ToolMatch.Empty is a validation error rather than a wildcard rule. +type MatchPatterns []string + +// Matches reports whether item satisfies these patterns. An undeclared list +// imposes no constraint. +func (p MatchPatterns) Matches(item string) bool { + if len(p) == 0 { + return true + } + return collections.MatchItems(item, p...) +} + +func (p *MatchPatterns) UnmarshalJSON(data []byte) error { + var single string + if err := json.Unmarshal(data, &single); err == nil { + *p = compactPatterns([]string{single}) + return nil + } + var list []string + if err := json.Unmarshal(data, &list); err != nil { + return fmt.Errorf("match patterns must be a string or a list of strings: %w", err) + } + *p = compactPatterns(list) + return nil +} + +func (p *MatchPatterns) UnmarshalYAML(value *yaml.Node) error { + if value.Kind == yaml.ScalarNode { + var single string + if err := value.Decode(&single); err != nil { + return err + } + *p = compactPatterns([]string{single}) + return nil + } + var list []string + if err := value.Decode(&list); err != nil { + return fmt.Errorf("match patterns must be a string or a list of strings: %w", err) + } + *p = compactPatterns(list) + return nil +} + +func compactPatterns(in []string) MatchPatterns { + out := make(MatchPatterns, 0, len(in)) + for _, pattern := range in { + if pattern = strings.TrimSpace(pattern); pattern != "" { + out = append(out, pattern) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// ToolMatch selects the tools a rule applies to. +// +// Every declared facet must match (AND across facets); within one facet the +// patterns are alternatives (OR). An undeclared facet does not constrain, so a +// rule says only as much as it means to. +// +// Verb, Method and Scope read the clicky annotations a tool carries rather than +// typed fields, because they are clicky-RPC specifics that only some tools have — +// see ToolInfo. +type ToolMatch struct { + Name MatchPatterns `json:"name,omitempty" yaml:"name,omitempty"` + Group MatchPatterns `json:"group,omitempty" yaml:"group,omitempty"` + Parent MatchPatterns `json:"parent,omitempty" yaml:"parent,omitempty"` + Verb MatchPatterns `json:"verb,omitempty" yaml:"verb,omitempty"` + Method MatchPatterns `json:"method,omitempty" yaml:"method,omitempty"` + Scope MatchPatterns `json:"scope,omitempty" yaml:"scope,omitempty"` + + // ReadOnly, Destructive and Idempotent match the tool's safety hints. A + // declared hint requires the tool to declare the same hint with the same + // value: an undeclared hint on the tool does NOT match, because "this tool + // never said whether it is read-only" and "this tool said it is not + // read-only" are different claims, and treating the first as the second is + // how an unannotated tool would quietly inherit a permissive rule. + ReadOnly *bool `json:"readOnly,omitempty" yaml:"readOnly,omitempty"` + Destructive *bool `json:"destructive,omitempty" yaml:"destructive,omitempty"` + Idempotent *bool `json:"idempotent,omitempty" yaml:"idempotent,omitempty"` +} + +// Annotation keys the match facets read. +const ( + AnnotationVerb = "clicky/verb" + AnnotationMethod = "clicky/method" + AnnotationScope = "clicky/scope" +) + +// Empty reports whether the match declares no facet at all. Such a rule would +// match every tool and, being last-match-wins, silently become the final word on +// every one of them. +func (m ToolMatch) Empty() bool { + return len(m.Name) == 0 && len(m.Group) == 0 && len(m.Parent) == 0 && + len(m.Verb) == 0 && len(m.Method) == 0 && len(m.Scope) == 0 && + m.ReadOnly == nil && m.Destructive == nil && m.Idempotent == nil +} + +// Matches reports whether this match selects the given tool. +func (m ToolMatch) Matches(info ToolInfo) bool { + return m.Name.Matches(info.Name) && + m.Group.Matches(info.Group) && + m.Parent.Matches(info.Parent) && + m.Verb.Matches(info.Annotation(AnnotationVerb)) && + m.Method.Matches(info.Annotation(AnnotationMethod)) && + m.Scope.Matches(info.Annotation(AnnotationScope)) && + matchesHint(m.ReadOnly, info.ReadOnlyHint) && + matchesHint(m.Destructive, info.DestructiveHint) && + matchesHint(m.Idempotent, info.IdempotentHint) +} + +func matchesHint(want, have *bool) bool { + if want == nil { + return true + } + return have != nil && *have == *want +} + +// PermissionRule is one ordered rule: the tools it selects and the authority they +// get. The match facets are inlined so a rule reads as one flat mapping in +// `.prompt` frontmatter. +type PermissionRule struct { + ToolMatch `json:",inline" yaml:",inline"` + + // Policy is the authority granted to matching tools. `auto` is meaningful + // here: it hands the decision back to the tool's own safety hints rather + // than asserting an answer. + Policy ToolPolicy `json:"policy" yaml:"policy"` +} + +// Validate rejects a rule that would match everything or grant an unrecognised +// authority. Both are fail-loud rather than fail-quiet: an empty match becomes +// the last word on every tool, and an unrecognised policy would otherwise fall +// back to a default the author did not choose. +func (r PermissionRule) Validate() error { + if r.ToolMatch.Empty() { + return fmt.Errorf("permission rule must declare at least one match facet (name, group, parent, verb, method, scope, or a hint)") + } + if !r.Policy.Valid() { + return fmt.Errorf("invalid policy %q in permission rule (valid: %s)", r.Policy, toolPolicyList()) + } + return nil +} + +// PermissionPolicy is the ordered rule list, evaluated last-match-wins. +type PermissionPolicy []PermissionRule + +// Validate checks every rule, reporting the offending index so an author can find +// it in a long list. +func (p PermissionPolicy) Validate() error { + for i, rule := range p { + if err := rule.Validate(); err != nil { + return fmt.Errorf("permission rule %d: %w", i, err) + } + } + return nil +} + +// Resolve returns the authority for one tool: the policy of the LAST rule that +// matches it, and whether any rule matched at all. +// +// Last, not first, because the layer order this list encodes runs weakest to +// strongest — a user rule appended after a group baseline is meant to win. A +// first-match-wins reading would invert the entire contract. +func (p PermissionPolicy) Resolve(info ToolInfo) (ToolPolicy, bool) { + policy, matched := ToolPolicyAuto, false + for _, rule := range p { + if rule.ToolMatch.Matches(info) { + policy, matched = rule.Policy, true + } + } + return policy, matched +} + +// Append returns the concatenation of two policies, the later winning. It exists +// so call sites express layering as composition rather than by mutating a shared +// slice, which would let one caller's user rules leak into another's. +func (p PermissionPolicy) Append(later PermissionPolicy) PermissionPolicy { + if len(p) == 0 { + return append(PermissionPolicy(nil), later...) + } + if len(later) == 0 { + return append(PermissionPolicy(nil), p...) + } + out := make(PermissionPolicy, 0, len(p)+len(later)) + out = append(out, p...) + return append(out, later...) +} + +// FromPreferences lowers the flat tool→policy preference map into ordered rules, +// so the legacy shape and the rule list share one evaluation path instead of two +// that can disagree. +// +// A preference key is ambiguous — PreferenceKey yields a tool's group when it has +// one and its name otherwise, so the map alone cannot say which a given key is. +// Rather than guess, each key emits both a group rule and a name rule, with every +// group rule placed before every name rule. A key that names a group matches no +// tool name and vice versa, so the ambiguity costs nothing; and where a key is +// both, the name rule comes later and wins. That is exactly the precedence the +// UI needs, where a per-tool toggle must beat the group toggle above it. +// +// Keys are sorted so the result is deterministic: this list is compared in tests +// and serialized into specs, and map iteration order would make both flap. +func FromPreferences(prefs ToolPreferences) PermissionPolicy { + if len(prefs) == 0 { + return nil + } + keys := make([]string, 0, len(prefs)) + for key := range prefs { + if strings.TrimSpace(key) != "" { + keys = append(keys, key) + } + } + sort.Strings(keys) + + out := make(PermissionPolicy, 0, len(keys)*2) + for _, key := range keys { + out = append(out, PermissionRule{ + ToolMatch: ToolMatch{Group: MatchPatterns{key}}, + Policy: prefs[key], + }) + } + for _, key := range keys { + out = append(out, PermissionRule{ + ToolMatch: ToolMatch{Name: MatchPatterns{key}}, + Policy: prefs[key], + }) + } + return out +} + +func toolPolicyList() string { + policies := AllToolPolicies() + out := make([]string, len(policies)) + for i, policy := range policies { + out[i] = string(policy) + } + return strings.Join(out, ", ") +} diff --git a/pkg/api/toolpolicy_ginkgo_test.go b/pkg/api/toolpolicy_ginkgo_test.go new file mode 100644 index 00000000..aced2212 --- /dev/null +++ b/pkg/api/toolpolicy_ginkgo_test.go @@ -0,0 +1,306 @@ +package api_test + +import ( + "encoding/json" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "gopkg.in/yaml.v3" + + "github.com/flanksource/captain/pkg/api" +) + +func boolPtr(v bool) *bool { return &v } + +// xeroListTool is the shape the adversarial review's F1 defect turned on: a live +// list tool parented by a provider, carrying clicky's verb/method annotations. +func xeroListTool() api.ToolInfo { + return api.ToolInfo{ + Name: "xero_invoices_list", + Group: "provider.xero.read", + Parent: "xero", + ReadOnlyHint: boolPtr(true), + Annotations: map[string]string{ + api.AnnotationVerb: "list", + api.AnnotationMethod: "GET", + api.AnnotationScope: "accounting", + }, + } +} + +var _ = Describe("PermissionPolicy", func() { + Describe("Resolve", func() { + It("returns no match when no rule selects the tool", func() { + policy := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"admin.*"}}, + Policy: api.ToolPolicyDeny, + }} + + _, matched := policy.Resolve(xeroListTool()) + + Expect(matched).To(BeFalse()) + }) + + // The layer order this list encodes runs weakest to strongest, so the last + // matching rule is the intended answer. A first-match-wins reading would + // invert the whole contract and make every user override unreachable. + It("lets a later rule override an earlier one", func() { + policy := api.PermissionPolicy{ + {ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"provider.xero.*"}}, Policy: api.ToolPolicyDeny}, + {ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"xero_invoices_list"}}, Policy: api.ToolPolicyAllow}, + } + + resolved, matched := policy.Resolve(xeroListTool()) + + Expect(matched).To(BeTrue()) + Expect(resolved).To(Equal(api.ToolPolicyAllow)) + }) + + // The F1 defect: a group baseline of `off` never reached the execution + // path because a verb default was stamped into the same slot. Here the + // group rule is the only rule, so it must decide. + It("honours a group baseline that turns a whole provider off", func() { + policy := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"provider.xero.read"}}, + Policy: api.ToolPolicyDeny, + }} + + resolved, matched := policy.Resolve(xeroListTool()) + + Expect(matched).To(BeTrue()) + Expect(resolved).To(Equal(api.ToolPolicyDeny)) + }) + + It("requires every declared facet to match", func() { + policy := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{ + Group: api.MatchPatterns{"provider.xero.*"}, + Method: api.MatchPatterns{"POST"}, + }, + Policy: api.ToolPolicyDeny, + }} + + _, matched := policy.Resolve(xeroListTool()) + + Expect(matched).To(BeFalse()) + }) + }) + + Describe("glob matching", func() { + resolveWith := func(match api.ToolMatch) bool { + _, matched := api.PermissionPolicy{{ToolMatch: match, Policy: api.ToolPolicyDeny}}. + Resolve(xeroListTool()) + return matched + } + + It("matches a suffix wildcard", func() { + Expect(resolveWith(api.ToolMatch{Name: api.MatchPatterns{"xero_*"}})).To(BeTrue()) + }) + + It("matches case-insensitively", func() { + Expect(resolveWith(api.ToolMatch{Method: api.MatchPatterns{"get"}})).To(BeTrue()) + }) + + It("accepts comma-separated alternatives in one pattern", func() { + Expect(resolveWith(api.ToolMatch{Verb: api.MatchPatterns{"create,list,delete"}})).To(BeTrue()) + }) + + // Negation takes precedence over any positive match, which is what lets a + // rule say "this whole group except these". + It("lets a negation veto a positive match", func() { + Expect(resolveWith(api.ToolMatch{ + Group: api.MatchPatterns{"provider.xero.*", "!provider.xero.read"}, + })).To(BeFalse()) + }) + }) + + Describe("hint facets", func() { + It("matches a declared hint with the same value", func() { + _, matched := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{ReadOnly: boolPtr(true)}, + Policy: api.ToolPolicyAllow, + }}.Resolve(xeroListTool()) + + Expect(matched).To(BeTrue()) + }) + + // "never said whether it is read-only" and "said it is not read-only" are + // different claims. Treating the first as the second is how an unannotated + // tool would inherit a permissive rule it was never meant to match. + It("does not match a tool that never declared the hint", func() { + tool := xeroListTool() + tool.ReadOnlyHint = nil + + _, matched := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{ReadOnly: boolPtr(false)}, + Policy: api.ToolPolicyAllow, + }}.Resolve(tool) + + Expect(matched).To(BeFalse()) + }) + }) + + Describe("Validate", func() { + // A rule with no facet matches every tool and, being last-match-wins, + // becomes the final word on all of them. + It("rejects a rule that declares no match facet", func() { + err := api.PermissionPolicy{{Policy: api.ToolPolicyDeny}}.Validate() + + Expect(err).To(MatchError(ContainSubstring("must declare at least one match facet"))) + }) + + It("rejects an unrecognised policy", func() { + err := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"Read"}}, + Policy: api.ToolPolicy("sometimes"), + }}.Validate() + + Expect(err).To(MatchError(ContainSubstring(`invalid policy "sometimes"`))) + }) + + It("reports the offending rule index", func() { + err := api.PermissionPolicy{ + {ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"Read"}}, Policy: api.ToolPolicyAllow}, + {Policy: api.ToolPolicyDeny}, + }.Validate() + + Expect(err).To(MatchError(ContainSubstring("permission rule 1:"))) + }) + + It("accepts a well-formed policy", func() { + Expect(api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"provider.*"}}, + Policy: api.ToolPolicyAsk, + }}.Validate()).To(Succeed()) + }) + }) + + Describe("FromPreferences", func() { + It("returns nothing for an empty map", func() { + Expect(api.FromPreferences(nil)).To(BeEmpty()) + }) + + // A preference key is ambiguous — PreferenceKey yields a group when the + // tool has one and a name otherwise — so each key emits both forms, with + // every group rule before every name rule. Sorting keeps the output + // deterministic; map iteration order would make specs and tests flap. + It("emits sorted group rules before sorted name rules", func() { + policy := api.FromPreferences(api.ToolPreferences{ + "Write": api.ToolPolicyDeny, + "Bash": api.ToolPolicyAsk, + "provider": api.ToolPolicyAllow, + }) + + Expect(policy).To(Equal(api.PermissionPolicy{ + {ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"Bash"}}, Policy: api.ToolPolicyAsk}, + {ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"Write"}}, Policy: api.ToolPolicyDeny}, + {ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"provider"}}, Policy: api.ToolPolicyAllow}, + {ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"Bash"}}, Policy: api.ToolPolicyAsk}, + {ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"Write"}}, Policy: api.ToolPolicyDeny}, + {ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"provider"}}, Policy: api.ToolPolicyAllow}, + })) + }) + + // The precedence the UI depends on: a per-tool toggle must beat the group + // toggle above it, and the name-rules-last ordering is what delivers that. + It("lets a name preference beat a group preference for the same tool", func() { + policy := api.FromPreferences(api.ToolPreferences{ + "provider.xero.read": api.ToolPolicyDeny, + "xero_invoices_list": api.ToolPolicyAllow, + }) + + resolved, matched := policy.Resolve(xeroListTool()) + + Expect(matched).To(BeTrue()) + Expect(resolved).To(Equal(api.ToolPolicyAllow)) + }) + }) + + Describe("Append", func() { + It("layers a later policy over an earlier one without mutating either", func() { + base := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"provider.*"}}, + Policy: api.ToolPolicyDeny, + }} + later := api.PermissionPolicy{{ + ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"xero_*"}}, + Policy: api.ToolPolicyAllow, + }} + + combined := base.Append(later) + + Expect(combined).To(HaveLen(2)) + Expect(base).To(HaveLen(1)) + resolved, matched := combined.Resolve(xeroListTool()) + Expect(matched).To(BeTrue()) + Expect(resolved).To(Equal(api.ToolPolicyAllow)) + }) + }) + + Describe("decoding", func() { + // `.prompt` frontmatter authors write a bare selector; requiring a list + // there is ceremony that buys nothing. + It("accepts a scalar or a list for a match facet", func() { + var scalar api.PermissionRule + Expect(yaml.Unmarshal([]byte("group: provider.xero.*\npolicy: deny\n"), &scalar)).To(Succeed()) + Expect(scalar.Group).To(Equal(api.MatchPatterns{"provider.xero.*"})) + + var list api.PermissionRule + Expect(json.Unmarshal([]byte(`{"group":["a","b"],"policy":"deny"}`), &list)).To(Succeed()) + Expect(list.Group).To(Equal(api.MatchPatterns{"a", "b"})) + }) + + It("rejects a non-string match facet", func() { + var rule api.PermissionRule + Expect(json.Unmarshal([]byte(`{"group":{"x":1},"policy":"deny"}`), &rule)). + To(MatchError(ContainSubstring("must be a string or a list of strings"))) + }) + }) + + Describe("Spec round-trip", func() { + spec := func() api.Spec { + return api.Spec{ToolPolicy: api.PermissionPolicy{ + {ToolMatch: api.ToolMatch{Group: api.MatchPatterns{"provider.*"}}, Policy: api.ToolPolicyDeny}, + {ToolMatch: api.ToolMatch{Name: api.MatchPatterns{"Read"}, ReadOnly: boolPtr(true)}, Policy: api.ToolPolicyAllow}, + }} + } + + It("survives a JSON round-trip through specMarshal", func() { + encoded, err := json.Marshal(spec()) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).To(ContainSubstring(`"toolPolicy"`)) + + var decoded api.Spec + Expect(json.Unmarshal(encoded, &decoded)).To(Succeed()) + Expect(decoded.ToolPolicy).To(Equal(spec().ToolPolicy)) + }) + + It("survives a YAML round-trip through specMarshal", func() { + encoded, err := yaml.Marshal(spec()) + Expect(err).NotTo(HaveOccurred()) + + var decoded api.Spec + Expect(yaml.Unmarshal(encoded, &decoded)).To(Succeed()) + Expect(decoded.ToolPolicy).To(Equal(spec().ToolPolicy)) + }) + + It("omits an empty policy rather than emitting a null", func() { + encoded, err := json.Marshal(api.Spec{}) + Expect(err).NotTo(HaveOccurred()) + Expect(string(encoded)).NotTo(ContainSubstring("toolPolicy")) + }) + + It("rejects an invalid rule through Spec.Validate", func() { + // Model and prompt are set so validation reaches the policy at all: + // Spec.Validate checks both first and would otherwise fail for an + // unrelated reason, and the test would pass without proving anything. + invalid := api.Spec{ + Model: api.Model{Name: "claude-sonnet-5"}, + Prompt: api.Prompt{User: "summarize the ledger"}, + ToolPolicy: api.PermissionPolicy{{Policy: api.ToolPolicyDeny}}, + } + + Expect(invalid.Validate()).To(MatchError(ContainSubstring("must declare at least one match facet"))) + }) + }) +}) diff --git a/pkg/cli/ai.go b/pkg/cli/ai.go index 2a9a0717..89cc7ab1 100644 --- a/pkg/cli/ai.go +++ b/pkg/cli/ai.go @@ -298,7 +298,7 @@ func (o AIRuntimeOptions) ToRequest(systemPrompt, appendSystemPrompt, userPrompt perms := api.Permissions{ Mode: api.PermissionMode(o.PermissionMode), - Tools: api.Tools{Allow: o.AllowedTools, Deny: o.DisallowedTools}, + Tools: api.ToolsFromLists(o.AllowedTools, o.DisallowedTools), MCP: api.MCP{Disabled: o.NoMCP || saved.NoMCP}, } if o.Edit { diff --git a/pkg/cli/ai_prompt_file.go b/pkg/cli/ai_prompt_file.go index f405ea96..099c8d36 100644 --- a/pkg/cli/ai_prompt_file.go +++ b/pkg/cli/ai_prompt_file.go @@ -167,10 +167,10 @@ func overlayCLI(base ai.Request, baseCfg ai.Config, o AIPromptOptions) (ai.Reque req.Permissions.Presets = append(req.Permissions.Presets, api.PresetEdit) } if o.AllowedTools != nil { - req.Permissions.Tools.Allow = o.AllowedTools + req.Permissions.Tools.SetList(api.ToolPolicyAllow, o.AllowedTools) } if o.DisallowedTools != nil { - req.Permissions.Tools.Deny = o.DisallowedTools + req.Permissions.Tools.SetList(api.ToolPolicyDeny, o.DisallowedTools) } req.Permissions.MCP.Disabled = o.NoMCP || base.Permissions.MCP.Disabled || saved.NoMCP diff --git a/pkg/cli/ai_test.go b/pkg/cli/ai_test.go index 81e5f766..92116d93 100644 --- a/pkg/cli/ai_test.go +++ b/pkg/cli/ai_test.go @@ -196,11 +196,11 @@ func TestAIPromptOptions_ToRequest_PassesScalars(t *testing.T) { if !req.Permissions.HasPreset(api.PresetEdit) { t.Error("Edit preset not propagated") } - if !reflect.DeepEqual(req.Permissions.Tools.Allow, []string{"Read", "Bash"}) { - t.Errorf("AllowedTools = %v", req.Permissions.Tools.Allow) + if !reflect.DeepEqual(req.Permissions.Tools.AllowList(), []string{"Bash", "Read"}) { + t.Errorf("AllowedTools = %v", req.Permissions.Tools.AllowList()) } - if !reflect.DeepEqual(req.Permissions.Tools.Deny, []string{"Write"}) { - t.Errorf("DisallowedTools = %v", req.Permissions.Tools.Deny) + if !reflect.DeepEqual(req.Permissions.Tools.DenyList(), []string{"Write"}) { + t.Errorf("DisallowedTools = %v", req.Permissions.Tools.DenyList()) } if !reflect.DeepEqual(req.Memory.Skills, []string{"/skills/a", "/skills/b"}) { t.Errorf("SkillDirs = %v", req.Memory.Skills) @@ -447,7 +447,7 @@ func TestRunBuffered_JSONIncludesFullInputSpec(t *testing.T) { Setup: &shell.Setup{Cwd: "/repo"}, Permissions: api.Permissions{ Presets: []api.Preset{api.PresetEdit}, - Tools: api.Tools{Allow: []string{"Read"}}, + Tools: api.Tools{"Read": api.ToolPolicyAllow}, }, SessionID: "resume-1", } diff --git a/pkg/cli/prompt_help.go b/pkg/cli/prompt_help.go new file mode 100644 index 00000000..92162744 --- /dev/null +++ b/pkg/cli/prompt_help.go @@ -0,0 +1,149 @@ +package cli + +import ( + "fmt" + "io" + "strings" + + "github.com/flanksource/clicky" + "github.com/flanksource/clicky/api" + "github.com/spf13/cobra" +) + +type promptHelpRenderOptions struct { + LLMSession bool + NoColor bool +} + +// AttachPromptHelp replaces the generated prompt root help while preserving +// the generated help for its list, CRUD, render, and run subcommands. +func AttachPromptHelp(root *cobra.Command) error { + promptCmd, _, err := root.Find([]string{"prompt"}) + if err != nil { + return fmt.Errorf("find prompt command: %w", err) + } + if promptCmd == nil || promptCmd.Name() != "prompt" { + return fmt.Errorf("find prompt command: got %v", promptCmd) + } + + defaultHelp := promptCmd.HelpFunc() + promptCmd.SetHelpFunc(func(cmd *cobra.Command, args []string) { + if cmd != promptCmd { + defaultHelp(cmd, args) + return + } + opts := promptHelpRenderOptions{ + LLMSession: CurrentEnvironmentSession() != nil, + NoColor: clicky.Flags.NoColor, + } + if err := writePromptHelp(cmd.OutOrStdout(), cmd, opts); err != nil { + cmd.PrintErrf("render prompt help: %v\n", err) + } + }) + return nil +} + +func renderPromptHelp(cmd *cobra.Command, opts promptHelpRenderOptions) (string, error) { + format := "pretty" + if opts.LLMSession { + format = "markdown" + opts.NoColor = true + } + formatOpts := clicky.FormatOptions{Format: format, NoColor: opts.NoColor} + formatOpts.ResolveNoColor() + out, err := clicky.Format(promptHelpGuide(cmd, !opts.LLMSession), formatOpts) + if err != nil { + return "", fmt.Errorf("format prompt help as %s: %w", format, err) + } + if out != "" && !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out, nil +} + +func writePromptHelp(w io.Writer, cmd *cobra.Command, opts promptHelpRenderOptions) error { + out, err := renderPromptHelp(cmd, opts) + if err != nil { + return err + } + _, err = io.WriteString(w, out) + return err +} + +func promptHelpGuide(cmd *cobra.Command, styled bool) api.Text { + doc := api.Text{}. + Add(clicky.Heading(1, promptHelpText("Captain .prompt Files", styled, "font-bold text-blue-600"))). + NewLine().NewLine(). + Add(clicky.Text("A .prompt file combines YAML frontmatter with a Handlebars body. The frontmatter configures the model and complete Captain run; the body supplies the rendered system and user messages.")). + NewLine().NewLine(). + Add(promptHelpHeading("Start here", styled)).NewLine(). + Add(promptHelpBullets(styled, + "Validate and inspect a rendered file without calling a model: captain prompt render ", + "Execute it: captain prompt run ", + "Print the generated JSON schemas, catalogs, enum values, and backend-specific cliArgs: captain prompt --schema", + )). + NewLine().NewLine(). + Add(promptHelpHeading("File format", styled)).NewLine(). + Add(clicky.Text("Put YAML frontmatter between the opening and closing --- lines. Everything after it is the Handlebars body; a body without frontmatter is also valid.")). + NewLine().NewLine(). + Add(clicky.CodeBlock("yaml", strings.TrimSpace(promptHelpExample))). + NewLine().NewLine(). + Add(promptHelpHeading("Templates and precedence", styled)).NewLine(). + Add(promptHelpBullets(styled, + `Use {{role "system"}} and {{role "user"}} to split the Handlebars body into messages. Without role markers, dotprompt treats the body as its default message.`, + "Use {{name}}, conditionals, loops, partials, and other Handlebars expressions in the body. The same variables may template YAML frontmatter, including schema constraints.", + "Supply variables with --var/-V key=value or --vars JSON. input.default supplies defaults and input.schema describes or validates the input contract.", + "Rendered body messages override prompt.user and prompt.system. config.maxOutputTokens, config.temperature, and config.reasoning override their spec-native equivalents.", + "output.schema may be Picoschema or raw JSON Schema and becomes prompt.schemaJSON. A caller-provided Go output target takes precedence over the file schema.", + )). + NewLine().NewLine(). + Add(promptHelpHeading("Dotprompt frontmatter", styled)).NewLine(). + Add(promptHelpFieldReference(promptDotpromptHelpFields(), styled)). + NewLine().NewLine(). + Add(promptHelpHeading("Captain run frontmatter", styled)).NewLine(). + Add(clicky.Text("All other frontmatter keys are decoded strictly into Captain's run spec. Unknown keys fail instead of being ignored.")). + NewLine().NewLine(). + Add(promptHelpFieldReference(promptSpecHelpFields(), styled)). + NewLine().NewLine(). + Add(promptHelpHeading("Prompt source selection", styled)).NewLine(). + Add(promptHelpBullets(styled, + "Pass a prompt ID, catalog name, or .prompt path as the positional source.", + "Use -p/--prompt for inline text. If neither a positional source nor -p is supplied, Captain reads the prompt body from stdin.", + "Use runtimes[] for prompt-owned parallel defaults, or repeat -M/--multi-models at execution time to compare explicit runtime targets.", + )). + NewLine().NewLine(). + Add(promptHelpHeading("Command usage", styled)).NewLine(). + Add(clicky.CodeBlock("text", strings.TrimSpace(cmd.UsageString()))) + return doc +} + +func promptHelpHeading(text string, styled bool) api.Heading { + return clicky.Heading(2, promptHelpText(text, styled, "font-bold text-cyan-600")) +} + +func promptHelpBullets(styled bool, items ...string) api.Text { + out := api.Text{} + for i, item := range items { + if i > 0 { + out = out.NewLine() + } + out = out.Add(promptHelpText("- ", styled, "text-muted").Append(item)) + } + return out +} + +func promptHelpFieldReference(fields []promptHelpField, styled bool) api.Textable { + list := api.List{Bullet: promptHelpText("- ", styled, "text-muted"), MaxInline: 1} + for _, field := range fields { + list.Items = append(list.Items, + promptHelpText(field.Path, styled, "font-mono text-yellow-600").Append(": ").Append(field.Meaning)) + } + return list +} + +func promptHelpText(content string, styled bool, style string) api.Text { + if !styled { + return clicky.Text(content) + } + return clicky.Text(content, style) +} diff --git a/pkg/cli/prompt_help_content.go b/pkg/cli/prompt_help_content.go new file mode 100644 index 00000000..4572126e --- /dev/null +++ b/pkg/cli/prompt_help_content.go @@ -0,0 +1,150 @@ +package cli + +type promptHelpField struct { + Path string + Meaning string +} + +const promptHelpExample = `--- +name: Release notes +description: Summarize a change as structured release notes +model: agent:sonnet +effort: high +fallbacks: + - api:gemini-3.5-flash:high +config: + maxOutputTokens: 2000 + temperature: 0.2 +input: + schema: + type: object + required: [change] + properties: + change: {type: string} + default: + audience: operators +output: + schema: + type: object + required: [summary] + properties: + summary: {type: string} +runtimes: + - agent:sonnet:high + - model: gemini-3.5-flash + backend: gemini + effort: high +budget: + cost: 0.50 + maxTokens: 2000 + maxTurns: 4 + timeout: 20m +permissions: + mode: acceptEdits + tools: + Read: allow + Edit: ask +memory: + skipUser: true +setup: + cwd: . +sandbox: + backend: srt + policy: + paths: ["pkg/**", "!secrets/**"] + maxAttempts: 2 +--- +{{role "system"}} +Write concise release notes for {{audience}}. +{{role "user"}} +Summarize this change: + +{{change}}` + +func promptDotpromptHelpFields() []promptHelpField { + return []promptHelpField{ + {"name, description", "Human-readable prompt catalog metadata."}, + {"model", "Default model selector. Compact selectors may include runtime mode and effort, for example agent:sonnet:high."}, + {"config.maxOutputTokens", "Maximum output tokens for each model call; takes precedence over budget.maxTokens when both are present."}, + {"config.temperature", "Sampling temperature; takes precedence over top-level temperature."}, + {"config.reasoning", "Reasoning-effort string; takes precedence over the top-level effort field."}, + {"input.schema", "Input Picoschema or JSON Schema used by dotprompt to describe and validate template variables."}, + {"input.default", "Default values merged into missing template variables."}, + {"output.schema", "Output Picoschema or raw JSON Schema sent to the model as the structured-output contract."}, + {"runtimes[]", "Two or more default parallel targets. Each entry is a compact model selector or a full model/backend/effort object."}, + } +} + +func promptSpecHelpFields() []promptHelpField { + return []promptHelpField{ + {"model, id, backend", "Catalog model name, optional fully qualified provider ID, and optional backend override."}, + {"mode", "Runtime mechanism: api, cli, agent, or cmux."}, + {"temperature", "Sampling temperature from 0 through 2. config.temperature wins when both are set."}, + {"effort", "Reasoning effort: low, medium, high, xhigh, max, or ultra."}, + {"noCache", "Disable Captain's response cache for this run."}, + {"fallbacks[]", "Ordered alternative models, each as a compact selector or full model object. Nested fallback lists are ignored."}, + {"streaming, mediaTypes, resume, interrupt, steer, callerTools", "Resolved runtime capabilities. These are read-only output fields and must not be authored to claim unsupported behavior."}, + {"prompt.user, prompt.system", "Single-turn user and system text. Role-marked Handlebars body text overrides these values."}, + {"prompt.appendSystem", "Text appended to the runtime's default system prompt."}, + {"prompt.source", "Diagnostic source label; Captain normally fills this from the .prompt path."}, + {"prompt.schemaJSON", "Raw JSON Schema in the native run spec. In .prompt files, prefer output.schema."}, + {"prompt.schemaStrictness", "Schema failure policy: backend default, none, warning, error, or retry."}, + {"prompt.metadata", "Arbitrary string-to-string diagnostic metadata."}, + {"prompt.attachments[]", "Ordered multimodal inputs. Each has exactly one source (id, path, or url), plus optional filename, mediaType, size, and sha256 metadata."}, + {"messages[].role", "Role for one provider-neutral history entry: system, user, assistant, or tool."}, + {"messages[].parts[]", "Each part has type text, reasoning, attachment, tool-request, or tool-result and exactly one matching payload."}, + {"messages[].parts[].toolRequest", "Tool request with name, toolCallId, and JSON input; tool results must correlate with these stable call IDs."}, + {"messages[].parts[].toolResult", "Tool result with toolCallId and either JSON output or an error. messages[] is mutually exclusive with prompt body fields and toolApproval."}, + {"budget.cost", "Maximum spend in USD; zero means no ceiling."}, + {"budget.maxTokens", "Maximum output tokens per call; zero uses the backend default. config.maxOutputTokens wins when set."}, + {"budget.maxTurns", "Maximum agent turns from 0 through 100; zero uses the backend default."}, + {"budget.timeout", "Overall run duration such as 30m; empty uses the caller default."}, + {"memory.skills[]", "Additional skill or plugin directories to load."}, + {"memory.skipProject, memory.skipUser", "Skip project-local or user-level ambient settings."}, + {"memory.skipSkills, memory.skipHooks, memory.skipMemory", "Disable skills, hooks, or auto-memory/agent instruction files."}, + {"memory.bare", "Skip hooks, skills, memory, and ambient settings together."}, + {"permissions.mode", "Base posture: default, plan, acceptEdits, auto, bypassPermissions, or dontAsk."}, + {"permissions.presets[]", "Named safety bundles: edit or bare."}, + {"permissions.tools.", "Per-tool policy: auto, ask, allow, or deny. Captain fails if the selected backend cannot enforce it."}, + {"permissions.mcp.disabled", "Disable all MCP servers."}, + {"permissions.mcp.servers[]", "Optional allowlist of configured MCP servers."}, + {"permissions.mcp.", "Enable or disable one configured MCP server."}, + {"permissions.plugins., permissions.skills.", "Enable or disable a plugin or skill directory."}, + {"toolPreferences.", "Per-turn exposure policy for a tool name or group: auto, ask, allow, or deny."}, + {"toolApproval.state.messages", "Complete provider-neutral conversation ending with the suspended assistant tool requests."}, + {"toolApproval.state.calls[]", "Recorded calls: request.toolCallId, request.tool, optional JSON request.input, and an optional completed result."}, + {"toolApproval.decisions[]", "One decision per pending call: approvalId, toolCallId, tool, action (approve, deny, or respond), and action-specific input, message, or result."}, + {"toolApproval", "Advanced durable resume mode; it is mutually exclusive with prompt text and messages. Use --schema for the exact nested result shapes."}, + {"setup.cwd, setup.baseDir", "Working directory and base directory used while preparing the run."}, + {"setup.dotenv[], setup.envVars[]", "Dotenv files plus environment entries with name and either value or valueFrom, resolved at the runtime boundary."}, + {"setup.connections", "External connection material projected into the prepared environment. Use --schema for the provider-owned nested forms."}, + {"setup.checkout.mode", "Checkout mode: none, local, or remote."}, + {"setup.checkout.url, setup.checkout.path, setup.checkout.connection", "Remote URL, local source path, or named connection used for checkout."}, + {"setup.checkout.ref, setup.checkout.depth, setup.checkout.since", "Git ref, clone depth, and optional commit-ish used for dirty-file reporting."}, + {"setup.checkout.dirty", "Deprecated no-op retained only for decoding existing files; do not use in new prompt files."}, + {"setup.checkout.worktree.mode", "Worktree mode: none, new, or existing."}, + {"setup.checkout.worktree.prefix, setup.checkout.worktree.base, setup.checkout.worktree.path", "Branch prefix, base ref, and explicit worktree path."}, + {"setup.checkout.worktree.keep", "Keep the worktree after the run."}, + {"setup.checkout.worktree.uncommitted", "clone or skip staged, unstaged, and untracked source changes without mutating the source tree."}, + {"setup.checkout.worktree.ignored", "clone or skip gitignored content such as dependency and build directories."}, + {"sandbox", "Scalar backend name, or an object. Bare adapters are none, srt, container, and git-agent."}, + {"sandbox.backend, sandbox.agent", "Configured sandbox/bare adapter and optional pinned git-agent worker."}, + {"sandbox.policy.paths[]", "Gitignore-style allow/deny paths; ! negates a pattern."}, + {"sandbox.policy.maxAttempts", "Maximum submit attempts; zero inherits the configured sandbox policy."}, + {"workflow.verify.commands[]", "Shell commands whose exit status votes on the generated result."}, + {"workflow.verify.fixture", "Gavel fixture markdown carried in the spec; Gavel, not Captain, executes it."}, + {"workflow.verify.prompts[]", "LLM-judge .prompt paths; each must return ok, reason, and feedback."}, + {"workflow.verify.scope", "Verify all files or only changed files."}, + {"workflow.verify.maxIterations", "Maximum generate/verify iterations; zero uses the run default of one."}, + {"workflow.commits[].on", "Commit phase: turn, agent, or run."}, + {"workflow.commits[].mode", "Commit shape: commit, fixup, or amend."}, + {"workflow.commits[].when", "Outcome gate: always, onSuccess, or onVerify."}, + {"workflow.commits[].message, .anchor, .squash, .base", "Commit subject and fixup/autosquash controls."}, + {"workflow.commits[].stage", "Stage the isolated worktree or only Captain-recorded changed files."}, + {"workflow.commits[].gates", "Pre-commit checks: none, cheap, or full."}, + {"workflow.commits[].dryRun", "Report the proposed commit without writing it."}, + {"workflow.autoVerifyWithoutFixture", "Allow a successful generate-only run to become durably verified without a fixture."}, + {"sessionId", "Resume a provider session by ID when the selected runtime supports resume."}, + {"cliArgs", "Backend-specific cmux CLI arguments keyed by their JSON field names; ignored by non-cmux backends. Use --schema for the selected backend's exact fields."}, + } +} diff --git a/pkg/cli/prompt_help_ginkgo_test.go b/pkg/cli/prompt_help_ginkgo_test.go new file mode 100644 index 00000000..60a402b3 --- /dev/null +++ b/pkg/cli/prompt_help_ginkgo_test.go @@ -0,0 +1,93 @@ +package cli + +import ( + "bytes" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "github.com/spf13/cobra" +) + +var _ = Describe("prompt help", func() { + clearSessionMarkers := func() { + for _, marker := range []string{ + "CODEX_THREAD_ID", "CODEX_SESSION_ID", "CODEX_SANDBOX", + "CLAUDE_CODE_SESSION_ID", "CLAUDE_SESSION_ID", "CLAUDECODE", + "GEMINI_SESSION_ID", "GEMINI_CLI", "CAPTAIN_SESSION_ID", + } { + GinkgoT().Setenv(marker, "") + } + } + + promptCommand := func() *cobra.Command { + return &cobra.Command{Use: "prompt", Short: "Manage prompt resources"} + } + + It("renders colored terminal help for a human session", func() { + output, err := renderPromptHelp(promptCommand(), promptHelpRenderOptions{}) + + Expect(err).NotTo(HaveOccurred()) + Expect(output).To(ContainSubstring("Captain .prompt Files")) + Expect(output).To(ContainSubstring("\x1b[")) + }) + + It("renders uncolored Markdown help for an LLM session", func() { + output, err := renderPromptHelp(promptCommand(), promptHelpRenderOptions{LLMSession: true}) + + Expect(err).NotTo(HaveOccurred()) + Expect(output).To(ContainSubstring("# Captain .prompt Files")) + Expect(output).NotTo(ContainSubstring("\x1b[")) + }) + + It("documents the complete prompt-file authoring contract", func() { + output, err := renderPromptHelp(promptCommand(), promptHelpRenderOptions{LLMSession: true}) + + Expect(err).NotTo(HaveOccurred()) + for _, required := range []string{ + "YAML frontmatter", + "Handlebars body", + `{{role "system"}}`, + "config.maxOutputTokens", + "input.schema", + "input.default", + "output.schema", + "runtimes[]", + "fallbacks[]", + "prompt.schemaStrictness", + "permissions.tools.", + "permissions.mcp.", + "memory.skipProject", + "setup.checkout.worktree.uncommitted", + "sandbox.policy.maxAttempts", + "workflow.verify.maxIterations", + "workflow.commits[].gates", + "toolPreferences.", + "toolApproval", + "cliArgs", + "captain prompt --schema", + "captain prompt render", + "captain prompt run", + } { + Expect(output).To(ContainSubstring(required), "missing prompt help detail %q", required) + } + Expect(strings.Count(output, "```yaml")).To(BeNumerically(">=", 1)) + }) + + It("selects Markdown when an LLM environment marker is present", func() { + clearSessionMarkers() + GinkgoT().Setenv("CODEX_THREAD_ID", "thread-example") + root := &cobra.Command{Use: "captain"} + root.AddCommand(promptCommand()) + Expect(AttachPromptHelp(root)).To(Succeed()) + var output bytes.Buffer + root.SetOut(&output) + root.SetErr(&output) + root.SetArgs([]string{"prompt", "--help"}) + + Expect(root.Execute()).To(Succeed()) + Expect(output.String()).To(HavePrefix("# Captain .prompt Files")) + Expect(output.String()).NotTo(ContainSubstring("\x1b[")) + Expect(output.String()).NotTo(ContainSubstring("style=")) + }) +}) diff --git a/pkg/cli/prompt_render.go b/pkg/cli/prompt_render.go index 9e6e621f..9ce1cc65 100644 --- a/pkg/cli/prompt_render.go +++ b/pkg/cli/prompt_render.go @@ -3,6 +3,7 @@ package cli import ( "context" "fmt" + "maps" "os" "strings" @@ -285,31 +286,11 @@ func overlayRuntimeSpec(req *ai.Request, cfg *ai.Config, spec api.Spec) { req.Permissions.Mode = spec.Permissions.Mode } req.Permissions.Presets = mergePresets(req.Permissions.Presets, spec.Permissions.Presets) - toolPolicies := spec.Permissions.Tools.Policies() - if len(toolPolicies) > 0 { - req.Permissions.Tools.Allow = nil - req.Permissions.Tools.Deny = nil - req.Permissions.Tools.Modes = nil - for _, tool := range sortedStringKeys(toolPolicies) { - switch toolPolicies[tool] { - case api.ToolPolicyAllow: - req.Permissions.Tools.Allow = append(req.Permissions.Tools.Allow, tool) - case api.ToolPolicyDeny: - req.Permissions.Tools.Deny = append(req.Permissions.Tools.Deny, tool) - case api.ToolPolicyAsk: - if req.Permissions.Tools.Modes == nil { - req.Permissions.Tools.Modes = map[string]api.ToolMode{} - } - req.Permissions.Tools.Modes[tool] = api.ToolModeAsk - case api.ToolPolicyAuto: - if req.Permissions.Tools.Modes == nil { - req.Permissions.Tools.Modes = map[string]api.ToolMode{} - } - req.Permissions.Tools.Modes[tool] = api.ToolModeOn - } - } + // The spec's tool policy replaces the request's wholesale rather than merging + // key-wise: a half-applied policy names an authority neither side asked for. + if len(spec.Permissions.Tools) > 0 { + req.Permissions.Tools = maps.Clone(spec.Permissions.Tools) } - req.Permissions.Tools.Modes = mergeToolModes(req.Permissions.Tools.Modes, spec.Permissions.Tools.Modes) req.Permissions.MCP.Disabled = req.Permissions.MCP.Disabled || spec.Permissions.MCP.Disabled if servers := spec.Permissions.MCP.EnabledServers(); len(servers) > 0 { req.Permissions.MCP.Servers = servers diff --git a/pkg/cli/prompt_render_test.go b/pkg/cli/prompt_render_test.go index 38278d2d..14d22f2e 100644 --- a/pkg/cli/prompt_render_test.go +++ b/pkg/cli/prompt_render_test.go @@ -59,9 +59,8 @@ Hello {{name}} Mode: api.PermissionAcceptEdits, Presets: []api.Preset{api.PresetEdit}, Tools: api.Tools{ - Allow: []string{"Read"}, - Deny: []string{"Bash"}, - Modes: map[string]api.ToolMode{"Bash": api.ToolModeOff}, + "Read": api.ToolPolicyAllow, + "Bash": api.ToolPolicyDeny, }, MCP: api.MCP{ Disabled: true, @@ -123,7 +122,7 @@ Hello {{name}} t.Fatalf("prompt source/metadata = %+v, want runtime overrides", rendered.Input.Prompt) } if rendered.Input.Permissions.Mode != api.PermissionAcceptEdits || - rendered.Input.Permissions.Tools.Modes["Bash"] != api.ToolModeOff || + rendered.Input.Permissions.Tools["Bash"] != api.ToolPolicyDeny || !rendered.Input.Permissions.MCP.Disabled { t.Fatalf("permissions = %+v, want runtime overrides", rendered.Input.Permissions) } diff --git a/pkg/cli/prompt_spec.go b/pkg/cli/prompt_spec.go index 0d944146..246090e1 100644 --- a/pkg/cli/prompt_spec.go +++ b/pkg/cli/prompt_spec.go @@ -76,20 +76,6 @@ func mergeStringMaps(base, overlay map[string]string) map[string]string { return out } -func mergeToolModes(base, overlay map[string]api.ToolMode) map[string]api.ToolMode { - if len(overlay) == 0 { - return base - } - out := make(map[string]api.ToolMode, collections.SafeAdd(len(base), len(overlay))) - for k, v := range base { - out[k] = v - } - for k, v := range overlay { - out[k] = v - } - return out -} - func mergePresets(base, overlay []api.Preset) []api.Preset { if len(overlay) == 0 { return base diff --git a/pkg/cli/serve_chat.go b/pkg/cli/serve_chat.go index 72b6252a..d4e41404 100644 --- a/pkg/cli/serve_chat.go +++ b/pkg/cli/serve_chat.go @@ -131,21 +131,21 @@ func captainChatToolEnabled(tool tools.ToolInfo) bool { } } -func captainChatToolPermission(tool tools.ToolInfo) api.ToolMode { +func captainChatToolPermission(tool tools.ToolInfo) api.ToolPolicy { switch strings.ToUpper(tool.Annotation("clicky/method")) { case http.MethodGet, http.MethodHead, http.MethodOptions: - return api.ToolModeOn + return api.ToolPolicyAllow } - if tool.DefaultPermission == api.ToolModeOn { - return api.ToolModeOn + if tool.DefaultPermission == api.ToolPolicyAllow { + return api.ToolPolicyAllow } if isReadOnlyCaptainTool(tool.Annotation("clicky/verb")) || isReadOnlyCaptainTool(tool.Name) || isReadOnlyCaptainTool(tool.Annotation("clicky/operation")) || isReadOnlyCaptainTool(tool.Annotation("clicky/path")) { - return api.ToolModeOn + return api.ToolPolicyAllow } - return api.ToolModeAsk + return api.ToolPolicyAsk } func isReadOnlyCaptainTool(value string) bool { diff --git a/pkg/cli/webapp/src/ChatLayer.tsx b/pkg/cli/webapp/src/ChatLayer.tsx index d7a52383..1bb7e51d 100644 --- a/pkg/cli/webapp/src/ChatLayer.tsx +++ b/pkg/cli/webapp/src/ChatLayer.tsx @@ -27,7 +27,7 @@ export function ChatLayer() { sessionsApi="/api/chat/sessions" runtimesApi="/api/chat/runtimes" tools={tools} - defaultToolMode="auto" + defaultToolPolicy="auto" chat={{ api: "/api/chat", modelsApi: "/api/chat/models", diff --git a/pkg/database/caller_tool_legacy_policy_test.go b/pkg/database/caller_tool_legacy_policy_test.go new file mode 100644 index 00000000..c76f8944 --- /dev/null +++ b/pkg/database/caller_tool_legacy_policy_test.go @@ -0,0 +1,50 @@ +package database + +import ( + "crypto/sha256" + "testing" + + "github.com/flanksource/captain/pkg/api" + "github.com/flanksource/commons-db/dbtest" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestCallerToolCredentialReadsLegacyPolicySpelling pins the read side of the +// tool-vocabulary migration. +// +// The policy column is untyped jsonb with no check constraint and no migration +// rewrote it, so rows written before the vocabularies were unified still hold +// "on" — a value api.ToolPolicy does not recognise. Nothing validates on read, +// so without normalization such a row loads as ToolPolicy("on") and silently +// matches no policy comparison downstream. +// +// The row is written with raw SQL on purpose: CreateCallerToolCredential's +// validator rejects "on" now, which is exactly why an old row can only be +// reproduced by going around it. +func TestCallerToolCredentialReadsLegacyPolicySpelling(t *testing.T) { + testDB := dbtest.ForT(t, dbtest.Options{Name: "captain_caller_tools_legacy"}) + db, err := Open(t.Context(), WithDSN(testDB.DSN()), WithMigrations()) + require.NoError(t, err) + t.Cleanup(func() { require.NoError(t, db.Close()) }) + + session, _, run, _ := createCallerToolRun(t, db) + secretHash := sha256.Sum256([]byte("legacy-credential-secret")) + credential, err := db.CreateCallerToolCredential(t.Context(), CreateCallerToolCredentialInput{ + SessionID: session.ID, PromptRunID: run.ID, Backend: api.BackendClaudeAgent, + SecretHash: secretHash[:], Policy: map[string]api.ToolPolicy{"account_edit": api.ToolPolicyAsk}, + }) + require.NoError(t, err) + + require.NoError(t, db.gorm.WithContext(t.Context()). + Exec(`UPDATE captain_session_mcp_credentials SET policy = ? WHERE id = ?`, + `{"account_edit":"ask","account_read":"on"}`, credential.ID).Error) + + loaded, err := db.GetCallerToolCredential(t.Context(), credential.ID) + require.NoError(t, err) + + assert.Equal(t, api.ToolPolicyAllow, loaded.Policy["account_read"], + `a persisted "on" is a tool cleared to run unprompted and must read back as allow`) + assert.Equal(t, api.ToolPolicyAsk, loaded.Policy["account_edit"], + "ask is spelled the same in both vocabularies and must survive untouched") +} diff --git a/pkg/database/caller_tool_store.go b/pkg/database/caller_tool_store.go index 30b4e131..42d13f08 100644 --- a/pkg/database/caller_tool_store.go +++ b/pkg/database/caller_tool_store.go @@ -24,16 +24,16 @@ var ( ) type CallerToolCredential struct { - ID uuid.UUID `json:"id"` - SessionID uuid.UUID `json:"sessionId"` - PromptRunID uuid.UUID `json:"promptRunId"` - Backend api.Backend `json:"backend"` - SecretHash []byte `json:"-"` - Policy map[string]api.ToolMode `json:"policy"` - ExpiresAt *time.Time `json:"expiresAt,omitempty"` - RevokedAt *time.Time `json:"revokedAt,omitempty"` - RevocationReason string `json:"revocationReason,omitempty"` - CreatedAt time.Time `json:"createdAt"` + ID uuid.UUID `json:"id"` + SessionID uuid.UUID `json:"sessionId"` + PromptRunID uuid.UUID `json:"promptRunId"` + Backend api.Backend `json:"backend"` + SecretHash []byte `json:"-"` + Policy map[string]api.ToolPolicy `json:"policy"` + ExpiresAt *time.Time `json:"expiresAt,omitempty"` + RevokedAt *time.Time `json:"revokedAt,omitempty"` + RevocationReason string `json:"revocationReason,omitempty"` + CreatedAt time.Time `json:"createdAt"` } type CreateCallerToolCredentialInput struct { @@ -41,21 +41,21 @@ type CreateCallerToolCredentialInput struct { PromptRunID uuid.UUID Backend api.Backend SecretHash []byte - Policy map[string]api.ToolMode + Policy map[string]api.ToolPolicy ExpiresAt *time.Time } type callerToolCredentialRecord struct { - ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` - SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` - PromptRunID uuid.UUID `gorm:"column:prompt_run_id;type:uuid"` - Backend api.Backend `gorm:"column:backend"` - SecretHash []byte `gorm:"column:secret_hash"` - Policy map[string]api.ToolMode `gorm:"column:policy;serializer:json;type:jsonb"` - ExpiresAt *time.Time `gorm:"column:expires_at"` - RevokedAt *time.Time `gorm:"column:revoked_at"` - RevocationReason *string `gorm:"column:revocation_reason"` - CreatedAt time.Time `gorm:"column:created_at"` + ID uuid.UUID `gorm:"column:id;type:uuid;primaryKey"` + SessionID uuid.UUID `gorm:"column:session_id;type:uuid"` + PromptRunID uuid.UUID `gorm:"column:prompt_run_id;type:uuid"` + Backend api.Backend `gorm:"column:backend"` + SecretHash []byte `gorm:"column:secret_hash"` + Policy map[string]api.ToolPolicy `gorm:"column:policy;serializer:json;type:jsonb"` + ExpiresAt *time.Time `gorm:"column:expires_at"` + RevokedAt *time.Time `gorm:"column:revoked_at"` + RevocationReason *string `gorm:"column:revocation_reason"` + CreatedAt time.Time `gorm:"column:created_at"` } func (callerToolCredentialRecord) TableName() string { @@ -103,9 +103,9 @@ func validateCallerToolCredentialInput(input CreateCallerToolCredentialInput) er if len(input.Policy) == 0 { return fmt.Errorf("%w: resolved policy is required", ErrCallerToolCredentialInvalid) } - for tool, mode := range input.Policy { - if strings.TrimSpace(tool) == "" || (mode != api.ToolModeOn && mode != api.ToolModeAsk) { - return fmt.Errorf("%w: tool %q has unresolved mode %q", ErrCallerToolCredentialInvalid, tool, mode) + for tool, policy := range input.Policy { + if strings.TrimSpace(tool) == "" || (policy != api.ToolPolicyAllow && policy != api.ToolPolicyAsk) { + return fmt.Errorf("%w: tool %q has unresolved policy %q", ErrCallerToolCredentialInvalid, tool, policy) } } if input.ExpiresAt != nil && !input.ExpiresAt.After(time.Now()) { @@ -252,7 +252,7 @@ func (db *DB) CreateToolApprovalRequest( input.ToolCallID == "" || input.Tool == "" || !input.ExpiresAt.After(time.Now()) { return nil, fmt.Errorf("%w: session, turn, prompt run, model call, tool call, tool, and future expiry are required", ErrTurnRequestInvalid) } - if credential != nil && credential.Policy[input.Tool] != api.ToolModeAsk { + if credential != nil && credential.Policy[input.Tool] != api.ToolPolicyAsk { return nil, fmt.Errorf("%w: tool %q is not approved by ask policy", ErrTurnRequestInvalid, input.Tool) } if credential != nil && credential.ExpiresAt != nil && input.ExpiresAt.After(*credential.ExpiresAt) { @@ -467,10 +467,27 @@ func turnRequestFromRecord(record turnRequestRecord) TurnRequest { } } -func cloneToolPolicy(policy map[string]api.ToolMode) map[string]api.ToolMode { - cloned := make(map[string]api.ToolMode, len(policy)) - for tool, mode := range policy { - cloned[tool] = mode +// cloneToolPolicy copies the policy map, normalizing the legacy spelling rows +// written before the tool vocabulary was unified. +// +// The policy column is untyped jsonb with no check constraint, so rows persisted +// by the old validator still hold "on" — a value api.ToolPolicy no longer +// recognises. It is read back with LegacyOn: allow because that is what "on" +// meant here: this map has no separate allow list, and the old validator +// accepted only "on" or "ask", so an "on" row is a tool that was cleared to run +// unprompted. An unrecognised value is left verbatim rather than defaulted, so +// it fails the caller's own check instead of silently becoming an authority +// nobody granted. +func cloneToolPolicy(policy map[string]api.ToolPolicy) map[string]api.ToolPolicy { + cloned := make(map[string]api.ToolPolicy, len(policy)) + for tool, stored := range policy { + if normalized, ok := api.ParseToolPolicy(string(stored), api.ParseToolPolicyOptions{ + LegacyOn: api.ToolPolicyAllow, + }); ok { + cloned[tool] = normalized + continue + } + cloned[tool] = stored } return cloned } diff --git a/pkg/database/caller_tool_store_integration_test.go b/pkg/database/caller_tool_store_integration_test.go index 2591bfe4..df8d2f85 100644 --- a/pkg/database/caller_tool_store_integration_test.go +++ b/pkg/database/caller_tool_store_integration_test.go @@ -22,7 +22,7 @@ func TestCallerToolCredentialAndApprovalLifecycle(t *testing.T) { secretHash := sha256.Sum256([]byte("credential-secret")) credential, err := db.CreateCallerToolCredential(t.Context(), CreateCallerToolCredentialInput{ SessionID: session.ID, PromptRunID: run.ID, Backend: api.BackendClaudeAgent, - SecretHash: secretHash[:], Policy: map[string]api.ToolMode{"account_edit": api.ToolModeAsk}, + SecretHash: secretHash[:], Policy: map[string]api.ToolPolicy{"account_edit": api.ToolPolicyAsk}, }) require.NoError(t, err) assert.Equal(t, session.ID, credential.SessionID) From a219b4f6f191647ef79496d203a1afc63a12a392 Mon Sep 17 00:00:00 2001 From: Moshe Immerman Date: Sun, 23 Aug 2026 11:03:00 +0300 Subject: [PATCH 13/22] chore(docs): Update docs build config and dependencies with pnpm workspace rules --- docs/astro.config.mjs | 6 ++++-- docs/package.json | 7 ++----- docs/pnpm-workspace.yaml | 13 +++++++++++++ docs/src/components/RuntimeSpecDemo.tsx | 2 +- docs/src/layouts/DocsLayout.astro | 4 ++-- package.json | 3 +++ 6 files changed, 25 insertions(+), 10 deletions(-) create mode 100644 package.json diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 6158d3fc..0a668059 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -8,8 +8,10 @@ export default defineConfig({ vite: { plugins: [tailwindcss()], resolve: { - dedupe: ["react", "react-dom", "@tanstack/react-query"], + // WORKAROUND(astro-prerender-cookie-resolution): Resolve Astro's generated bare cookie import from this package instead of an unrelated ancestor package. + // Correct fix: Astro should preserve its resolved cookie dependency when generating the prerender entry. + // Ref: discussed with user 2026-08-23 + dedupe: ["react", "react-dom", "@tanstack/react-query", "cookie"], }, }, }); - diff --git a/docs/package.json b/docs/package.json index cb0a452d..9e67125e 100644 --- a/docs/package.json +++ b/docs/package.json @@ -32,13 +32,10 @@ "tailwindcss": "^4.3.2" }, "devDependencies": { + "@astrojs/check": "^0.9.10", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", + "cookie": "2.0.1", "typescript": "~6.0.3" - }, - "pnpm": { - "overrides": { - "js-yaml": ">=4.3.0" - } } } diff --git a/docs/pnpm-workspace.yaml b/docs/pnpm-workspace.yaml index 22452057..224de095 100644 --- a/docs/pnpm-workspace.yaml +++ b/docs/pnpm-workspace.yaml @@ -3,3 +3,16 @@ packages: minimumReleaseAge: 10080 trustPolicy: no-downgrade +trustPolicyExclude: + # @astrojs/check requires this pre-provenance release; pnpm documents it as an exact exclusion. + - chokidar@4.0.3 + # @babel/core requires this pre-provenance release; pnpm/pnpm#10202 documents the warning. + - semver@6.3.1 + +overrides: + # 3.34.1 dropped the trusted-publisher provenance present in 3.34.0. + cytoscape: 3.34.0 + js-yaml: ">=4.3.0 <5" + +allowBuilds: + esbuild: true diff --git a/docs/src/components/RuntimeSpecDemo.tsx b/docs/src/components/RuntimeSpecDemo.tsx index 7b3803e8..fd93a785 100644 --- a/docs/src/components/RuntimeSpecDemo.tsx +++ b/docs/src/components/RuntimeSpecDemo.tsx @@ -29,7 +29,7 @@ const tools: ToolMeta[] = [ name: "WebSearch", label: "Web search", group: "Web", - defaultPermission: "off", + defaultPermission: "deny", description: "Search external documentation.", }, ]; diff --git a/docs/src/layouts/DocsLayout.astro b/docs/src/layouts/DocsLayout.astro index c626800f..73136892 100644 --- a/docs/src/layouts/DocsLayout.astro +++ b/docs/src/layouts/DocsLayout.astro @@ -40,7 +40,7 @@ const currentPath = Astro.url.pathname;