diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b61380f0d2..85921e43ff 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -139,10 +139,11 @@ jobs: working-directory: apps/web run: tar -xf next-build.tar && rm next-build.tar - # Pinned to chromium + mobile-chrome so the dedicated `e2e-docker` job - # below owns the real-Docker project. Without --project the docker spec - # would run here too, fail seeding (KANDEV_DOCKER_ENABLED is off), and - # spam every shard. + # Pinned to chromium + mobile-chrome so the dedicated `e2e-containers` + # job below owns the real-Docker project (which covers both the Docker + # executor and the SSH executor's sshd target). Without --project the + # container-bound specs would run here too, fail seeding (Docker not + # enabled for these shards), and spam every shard. - name: Run E2E tests (shard ${{ matrix.shard }}/10) working-directory: apps/web run: npx playwright test --config e2e/playwright.config.ts --project=chromium --project=mobile-chrome --shard=${{ matrix.shard }}/10 @@ -169,11 +170,16 @@ jobs: # 6 runners — Playwright's --shard splits the project's tests into N groups. # Cleans up any leftover kandev-managed containers at the end so a leaky # process doesn't poison the next run on the same self-hosted runner. - e2e-docker: - name: E2E Docker Shard ${{ matrix.shard }}/6 + # + # This job runs the `containers` Playwright project, which covers both the + # Docker executor and the SSH executor (since the SSH executor's e2e tests + # also rely on Docker to host a real sshd container as the remote target). + # Sharded 6 ways — each shard is its own runner. See apps/web/e2e/README.md. + e2e-containers: + name: E2E Containers Shard ${{ matrix.shard }}/6 needs: build runs-on: ubuntu-latest - timeout-minutes: 10 + timeout-minutes: 20 strategy: fail-fast: false matrix: @@ -223,38 +229,38 @@ jobs: working-directory: apps/web run: tar -xf next-build.tar && rm next-build.tar - - name: Run Docker E2E tests (shard ${{ matrix.shard }}/6) + - name: Run container-backed E2E tests shard ${{ matrix.shard }}/6 (Docker executor + SSH executor) working-directory: apps/web env: - KANDEV_E2E_DOCKER: "1" - run: npx playwright test --config e2e/playwright.config.ts --project=docker --shard=${{ matrix.shard }}/6 + KANDEV_E2E_CONTAINERS: "1" + run: npx playwright test --config e2e/playwright.config.ts --project=containers --shard=${{ matrix.shard }}/6 - name: Cleanup leftover kandev containers if: always() run: docker ps -aq --filter label=kandev.managed=true | xargs -r docker rm -f # Named with the same `blob-report-*` prefix the merge job globs for - # so the docker results land in the unified HTML report alongside the - # sharded chromium/mobile-chrome runs. - - name: Upload Docker blob report + # so the containers results land in the unified HTML report alongside + # the sharded chromium/mobile-chrome runs. + - name: Upload containers blob report if: always() uses: actions/upload-artifact@v5 with: - name: blob-report-docker-${{ matrix.shard }} + name: blob-report-containers-${{ matrix.shard }} path: apps/web/e2e/blob-report/ retention-days: 3 - - name: Upload Docker E2E results + - name: Upload containers E2E results if: failure() uses: actions/upload-artifact@v5 with: - name: docker-e2e-test-results-${{ matrix.shard }} + name: containers-e2e-test-results-${{ matrix.shard }} path: apps/web/e2e/test-results/ retention-days: 7 e2e-report: name: Merge E2E Reports - needs: [e2e, e2e-docker] + needs: [e2e, e2e-containers] if: always() runs-on: ubuntu-latest timeout-minutes: 5 diff --git a/AGENTS.md b/AGENTS.md index c6d5949c51..df2e9ce236 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,7 @@ apps/ - **Backend**: Go with Make (`make -C apps/backend test|lint|build`) - **Frontend**: Next.js (`cd apps && pnpm --filter @kandev/web dev|lint|typecheck`) - **UI**: Shadcn components via `@kandev/ui` +- **E2E**: Playwright (`cd apps/web && pnpm e2e`). The `containers` project (gated on `KANDEV_E2E_CONTAINERS=1`, formerly `docker`) covers both the Docker executor and the SSH executor — anything that needs a real Docker daemon on the host lives there. See `apps/web/e2e/README.md`. - **GitHub repo**: `https://github.com/kdlbs/kandev` - **Container image**: `ghcr.io/kdlbs/kandev` (GitHub Container Registry) diff --git a/apps/backend/cmd/kandev/agents.go b/apps/backend/cmd/kandev/agents.go index 7d558d278f..5d6185ace8 100644 --- a/apps/backend/cmd/kandev/agents.go +++ b/apps/backend/cmd/kandev/agents.go @@ -76,6 +76,11 @@ func provideLifecycleManager( executorRegistry.Register(spritesExec) log.Info("Sprites runtime registered") + // Register SSH runtime (run an agent on any Linux box reachable over SSH). + sshExec := lifecycle.NewSSHExecutor(secretStore, agentRegistry, agentctlResolver, log) + executorRegistry.Register(sshExec) + log.Info("SSH runtime registered") + credsMgr := credentials.NewManager(log) if secretStore != nil { credsMgr.AddProvider(secrets.NewSecretStoreProvider(secretStore)) @@ -111,6 +116,7 @@ func provideLifecycleManager( preparerRegistry.Register(models.ExecutorTypeMockRemote, localPreparer) preparerRegistry.Register(models.ExecutorTypeLocalDocker, lifecycle.NewDockerPreparer(log)) preparerRegistry.Register(models.ExecutorTypeSprites, lifecycle.NewSpritesPreparer(log)) + preparerRegistry.Register(models.ExecutorTypeSSH, lifecycle.NewSSHPreparer(log)) lifecycleMgr.SetPreparerRegistry(preparerRegistry) lifecycleMgr.SetSecretStore(secretStore) // Wire the agent_profiles reader so the launch-prep skill deploy hook diff --git a/apps/backend/cmd/kandev/helpers.go b/apps/backend/cmd/kandev/helpers.go index 83b6c0c8fd..bb5920b2eb 100644 --- a/apps/backend/cmd/kandev/helpers.go +++ b/apps/backend/cmd/kandev/helpers.go @@ -61,6 +61,7 @@ import ( "github.com/kandev/kandev/internal/secrets" "github.com/kandev/kandev/internal/slack" spriteshandlers "github.com/kandev/kandev/internal/sprites" + sshhandlers "github.com/kandev/kandev/internal/ssh" systemsvc "github.com/kandev/kandev/internal/system" taskhandlers "github.com/kandev/kandev/internal/task/handlers" "github.com/kandev/kandev/internal/task/models" @@ -712,6 +713,19 @@ func registerSecondaryRoutes( p.log.Debug("Registered Sprites handlers (HTTP + WebSocket)") } + if p.taskRepo != nil { + sshhandlers.RegisterRoutes( + p.router, + p.gateway.Dispatcher, + p.taskRepo, + p.services.Task, + p.agentRegistry, + lifecycle.NewAgentctlResolver(p.log), + p.log, + ) + p.log.Debug("Registered SSH handlers (HTTP + WebSocket)") + } + if p.services.GitHub != nil { github.RegisterRoutes(p.router, p.gateway.Dispatcher, p.services.GitHub, p.log) github.RegisterMockRoutes(p.router, p.services.GitHub, p.log) diff --git a/apps/backend/go.mod b/apps/backend/go.mod index 5edb60e8d9..20cfb6508e 100644 --- a/apps/backend/go.mod +++ b/apps/backend/go.mod @@ -15,9 +15,11 @@ require ( github.com/gorilla/websocket v1.5.3 github.com/jackc/pgx/v5 v5.9.0 github.com/jmoiron/sqlx v1.4.0 + github.com/kevinburke/ssh_config v1.6.0 github.com/mark3labs/mcp-go v0.43.2 github.com/mattn/go-sqlite3 v1.14.33 github.com/nats-io/nats.go v1.31.0 + github.com/pkg/sftp v1.13.10 github.com/spf13/viper v1.16.0 github.com/stretchr/testify v1.11.1 github.com/superfly/sprites-go v0.0.0-20260206213632-8176adff485b @@ -27,9 +29,10 @@ require ( go.opentelemetry.io/otel/sdk v1.43.0 go.opentelemetry.io/otel/trace v1.43.0 go.uber.org/zap v1.26.0 - golang.org/x/net v0.52.0 + golang.org/x/crypto v0.51.0 + golang.org/x/net v0.53.0 golang.org/x/sync v0.20.0 - golang.org/x/sys v0.42.0 + golang.org/x/sys v0.44.0 gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/yaml.v3 v3.0.1 ) @@ -68,6 +71,7 @@ require ( github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/compress v1.17.0 // indirect github.com/klauspost/cpuid/v2 v2.2.4 // indirect + github.com/kr/fs v0.1.0 // indirect github.com/leodido/go-urn v1.2.4 // indirect github.com/magiconair/properties v1.8.7 // indirect github.com/mailru/easyjson v0.7.7 // indirect @@ -102,9 +106,8 @@ require ( go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.10.0 // indirect golang.org/x/arch v0.3.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/term v0.41.0 // indirect - golang.org/x/text v0.35.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/grpc v1.80.0 // indirect diff --git a/apps/backend/go.sum b/apps/backend/go.sum index 143b551178..41dbf8b8a9 100644 --- a/apps/backend/go.sum +++ b/apps/backend/go.sum @@ -221,12 +221,15 @@ github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnr github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU= github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.17.0 h1:Rnbp4K9EjcDuVuHtd0dgA4qNuv9yKDYKK1ulpJwgrqM= github.com/klauspost/compress v1.17.0/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk= github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -282,6 +285,8 @@ github.com/pelletier/go-toml/v2 v2.0.8/go.mod h1:vuYfssBdrU2XDZ9bYydBu6t+6a6PYNc github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.1/go.mod h1:3HaPG6Dq1ILlpPZRO0HVMrsydcdLt6HRDccSgb87qRg= +github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= +github.com/pkg/sftp v1.13.10/go.mod h1:bJ1a7uDhrX/4OII+agvy28lzRvQrmIQuaHrcI1HbeGA= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= @@ -376,8 +381,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8= @@ -443,8 +448,8 @@ golang.org/x/net v0.0.0-20201209123823-ac852fbbde11/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/net v0.0.0-20201224014010-6772e930b67b/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= -golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= @@ -507,11 +512,11 @@ golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= -golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= +golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= +golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= @@ -520,8 +525,8 @@ golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= +golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= +golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= diff --git a/apps/backend/internal/agent/agents/mock.go b/apps/backend/internal/agent/agents/mock.go index 3e77cb5ea5..ec4d8a8b9d 100644 --- a/apps/backend/internal/agent/agents/mock.go +++ b/apps/backend/internal/agent/agents/mock.go @@ -6,6 +6,7 @@ import ( "time" "github.com/kandev/kandev/internal/agent/usage" + "github.com/kandev/kandev/internal/agentruntime" "github.com/kandev/kandev/pkg/agent" ) @@ -139,12 +140,19 @@ func (a *MockAgent) BuildCommand(opts CommandOptions) Command { // has /usr/local/bin on PATH and container.go bind-mounts the // linux/amd64 mock-agent there, so the bare name resolves correctly. // + // SSH is the same shape: the agent runs on a remote host whose + // filesystem doesn't share paths with the kandev backend's, so the + // host-absolute binary path can't apply. The SSH e2e image bakes + // mock-agent at /usr/local/bin/mock-agent; production SSH expects + // users to install agents into the remote's $PATH (real agents like + // Claude use `npx` and never hit this branch). + // // For host runtimes (standalone) we prefer the absolute host path // that configureMockAgent computed via os.Executable() — that's the // only way the host path works in dev mode (where the shell's $PATH // doesn't include apps/backend/bin). Fall back to the bare name when // no path is set: e2e fixtures prepend the kandev bin dir to PATH. - if opts.Runtime.IsContainerized() { + if opts.Runtime.IsContainerized() || opts.Runtime == agentruntime.RuntimeSSH { return Cmd(mockAgentDefaultID).Build() } binary := mockAgentDefaultID diff --git a/apps/backend/internal/agent/agents/mock_test.go b/apps/backend/internal/agent/agents/mock_test.go index a52fe5c050..20c6c5be7e 100644 --- a/apps/backend/internal/agent/agents/mock_test.go +++ b/apps/backend/internal/agent/agents/mock_test.go @@ -36,11 +36,12 @@ func TestMockAgent_BuildCommand_NoResumeFlag(t *testing.T) { // TestMockAgent_BuildCommand_ContainerizedUsesBareName pins the docker // branch of BuildCommand: when opts.Runtime is containerized (docker, -// remote_docker, sprites), MockAgent must emit the bare binary name so -// PATH lookup inside the container resolves to the bind-mounted -// linux/amd64 mock-agent at /usr/local/bin. Regressing this would -// cause docker e2e to fail with `exec: "": not found`, -// which is exactly the bug commit `8518f65` fixed. +// remote_docker, sprites) or SSH, MockAgent must emit the bare binary +// name so PATH lookup on the remote resolves to the bind-mounted (or +// pre-baked) linux/amd64 mock-agent at /usr/local/bin. Regressing this +// would cause docker e2e to fail with `exec: "": not +// found` (the bug commit `8518f65` fixed) and SSH e2e with the same +// symptom on the remote host. func TestMockAgent_BuildCommand_ContainerizedUsesBareName(t *testing.T) { a := NewMockAgent() a.SetBinaryPath("/Users/dev/kandev/apps/backend/bin/mock-agent") @@ -48,6 +49,7 @@ func TestMockAgent_BuildCommand_ContainerizedUsesBareName(t *testing.T) { agentruntime.RuntimeDocker, agentruntime.RuntimeRemoteDocker, agentruntime.RuntimeSprites, + agentruntime.RuntimeSSH, } { t.Run(string(rt), func(t *testing.T) { cmd := a.BuildCommand(CommandOptions{Runtime: rt}) diff --git a/apps/backend/internal/agent/executor/executor.go b/apps/backend/internal/agent/executor/executor.go index 3f904cc288..44b566c35c 100644 --- a/apps/backend/internal/agent/executor/executor.go +++ b/apps/backend/internal/agent/executor/executor.go @@ -18,6 +18,7 @@ const ( NameLocal Name = "local" NameRemoteDocker = agentruntime.RuntimeRemoteDocker NameSprites = agentruntime.RuntimeSprites + NameSSH = agentruntime.RuntimeSSH ) // ExecutorTypeToBackend maps an ExecutorType to its corresponding executor Name. @@ -33,6 +34,8 @@ func ExecutorTypeToBackend(execType models.ExecutorType) Name { return NameRemoteDocker case models.ExecutorTypeSprites: return NameSprites + case models.ExecutorTypeSSH: + return NameSSH case models.ExecutorTypeMockRemote: return NameStandalone default: diff --git a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go index 6336937d0c..24697e19ac 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go +++ b/apps/backend/internal/agent/runtime/lifecycle/agent_session_seeder.go @@ -20,8 +20,13 @@ import ( const agentSessionsRootSubdir = "agent-sessions" // authMethodTypeFiles selects RemoteAuthMethod entries that copy local files -// into the remote environment (vs env-var-based auth). -const authMethodTypeFiles = "files" +// into the remote environment, while authMethodTypeEnv covers env-var-based +// auth (with an optional SetupScript that runs on the remote to provision +// credential files from the env var). +const ( + authMethodTypeFiles = "files" + authMethodTypeEnv = "env" +) // AgentSessionsRoot returns the directory under the kandev home where // per-container session dirs are created (e.g. ~/.kandev/agent-sessions/). diff --git a/apps/backend/internal/agent/runtime/lifecycle/env_preparer_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_ssh.go new file mode 100644 index 0000000000..fb1541c45a --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/env_preparer_ssh.go @@ -0,0 +1,50 @@ +package lifecycle + +import ( + "context" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +// SSHPreparer validates prerequisites for SSH-based execution. The heavy +// lifting (connect, agentctl upload, task dir provisioning, agentctl launch) +// happens in SSHExecutor.CreateInstance — this preparer just emits a single +// validation step so the launch UI has consistent progress feedback. +type SSHPreparer struct { + logger *logger.Logger +} + +// NewSSHPreparer creates a new SSHPreparer. +func NewSSHPreparer(log *logger.Logger) *SSHPreparer { + return &SSHPreparer{logger: log.WithFields(zap.String("component", "ssh-preparer"))} +} + +// Name implements EnvironmentPreparer. +func (p *SSHPreparer) Name() string { return "ssh" } + +// Prepare implements EnvironmentPreparer. SSH executor configuration is +// validated at launch time (target / fingerprint / arch are checked inside +// CreateInstance). This step is intentionally minimal so the UI still sees +// the preparer running for every launch — preserves a uniform progress shape +// across executor types. +func (p *SSHPreparer) Prepare(_ context.Context, req *EnvPrepareRequest, onProgress PrepareProgressCallback) (*EnvPrepareResult, error) { + p.logger.Debug("preparing ssh environment", + zap.String("task", req.TaskID), + zap.String("session", req.SessionID)) + + started := time.Now() + step := beginStep("Validate SSH executor configuration") + reportProgress(onProgress, step, 0, 1) + completeStepSuccess(&step) + reportProgress(onProgress, step, 1, 1) + return &EnvPrepareResult{ + Success: true, + Steps: []PrepareStep{step}, + WorkspacePath: req.WorkspacePath, + Duration: time.Since(started), + WorktreeBranch: nonWorktreeTaskBranch(req), + }, nil +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index a74338f455..3da84b69cf 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -82,6 +82,30 @@ const ( // Office metadata keys MetadataKeySkillManifestJSON = "skill_manifest_json" + + // SSH runtime metadata keys (per-session, except SSHWorkdirRoot which is per-profile). + MetadataKeySSHHostAlias = "ssh_host_alias" + MetadataKeySSHHost = "ssh_host" + MetadataKeySSHPort = "ssh_port" + MetadataKeySSHUser = "ssh_user" + MetadataKeySSHHostFingerprint = "ssh_host_fingerprint" + MetadataKeySSHRemoteTaskDir = "ssh_remote_task_dir" + MetadataKeySSHRemoteSessionDir = "ssh_remote_session_dir" + MetadataKeySSHRemoteAgentctlPort = "ssh_remote_agentctl_port" + MetadataKeySSHRemoteAgentctlPID = "ssh_remote_agentctl_pid" + MetadataKeySSHLocalForwardPort = "ssh_local_forward_port" + MetadataKeySSHRemoteAgentctlURL = "ssh_remote_agentctl_url" + MetadataKeySSHWorkdirRoot = "ssh_workdir_root" + MetadataKeySSHProxyJump = "ssh_proxy_jump" + MetadataKeySSHIdentitySource = "ssh_identity_source" + MetadataKeySSHIdentityFile = "ssh_identity_file" + // MetadataKeySSHShell names the login shell used when running commands + // over SSH on the remote (probe, agentctl launch, install, setup + // scripts). Empty / unset falls back to "bash" at runtime — see + // WrapLoginShell. Stored per-profile so different profiles on the same + // host can use different shells; flows into req.Metadata via the + // standard executor-config merge in buildLaunchMetadata. + MetadataKeySSHShell = "ssh_shell" ) // persistentMetadataKeys lists metadata keys carried forward from a previous @@ -95,6 +119,23 @@ var persistentMetadataKeys = map[string]bool{ MetadataKeySpriteCreatedAt: true, MetadataKeyLocalPort: true, + // SSH runtime + MetadataKeySSHHost: true, + MetadataKeySSHPort: true, + MetadataKeySSHUser: true, + MetadataKeySSHHostFingerprint: true, + MetadataKeySSHRemoteTaskDir: true, + MetadataKeySSHRemoteSessionDir: true, + MetadataKeySSHRemoteAgentctlPort: true, + MetadataKeySSHRemoteAgentctlPID: true, + MetadataKeySSHLocalForwardPort: true, + MetadataKeySSHRemoteAgentctlURL: true, + MetadataKeySSHWorkdirRoot: true, + MetadataKeySSHProxyJump: true, + MetadataKeySSHIdentitySource: true, + MetadataKeySSHIdentityFile: true, + MetadataKeySSHShell: true, + // Executor type marker MetadataKeyIsRemote: true, @@ -120,6 +161,21 @@ var persistentMetadataPrefixes = []string{ "env_secret_id_", // Secret store UUIDs for profile env vars } +// sessionScopedMetadataKeys lists metadata keys that point to per-session +// runtime resources (process PIDs, allocated ports, session directories on +// the remote). These keys ARE persisted across a SAME-session resume — that's +// how a backend restart reattaches to a still-running remote agent — but they +// MUST NOT be carried across SIBLING sessions on the same task. If they were, +// the second session would try to attach to the first session's agentctl +// process and end up sharing its ACP session and instance port. +var sessionScopedMetadataKeys = map[string]bool{ + MetadataKeySSHRemoteSessionDir: true, + MetadataKeySSHRemoteAgentctlPort: true, + MetadataKeySSHRemoteAgentctlPID: true, + MetadataKeySSHLocalForwardPort: true, + MetadataKeySSHRemoteAgentctlURL: true, +} + // ShouldPersistMetadataKey returns true if the given metadata key should // be carried forward when resuming a session from an ExecutorRunning record. func ShouldPersistMetadataKey(key string) bool { @@ -134,6 +190,13 @@ func ShouldPersistMetadataKey(key string) bool { return false } +// IsSessionScopedMetadataKey reports whether key references per-session +// runtime resources that must not leak across sibling sessions on the same +// task environment. +func IsSessionScopedMetadataKey(key string) bool { + return sessionScopedMetadataKeys[key] +} + // FilterPersistentMetadata returns a copy of src containing only keys that // should be carried forward across session resumes. Returns nil if no keys match. func FilterPersistentMetadata(src map[string]interface{}) map[string]interface{} { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go new file mode 100644 index 0000000000..b2469bd8c3 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -0,0 +1,657 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "go.uber.org/zap" + "golang.org/x/crypto/ssh" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/executor" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/agentctl/server/process" + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/secrets" +) + +const ( + sshAgentctlHealthTimeout = 20 * time.Second + + sshStatusRunning = "running" + sshStatusUnknown = "unknown" + sshStatusDisconnected = "disconnected" + sshStatusAgentctlDown = "agentctl-down" +) + +// sshSessionState tracks per-session resources we need to clean up later: +// the SSH target (so we can dial again on Stop), the per-session SSH client +// (no shared pool — the Go SSH client's direct-tcpip mux gets unreliable +// when many short-lived channels race the long-lived stream channels), and +// the local port forwarder. +type sshSessionState struct { + target *SSHTarget + client *ssh.Client + forwarder *SSHPortForwarder + pid int + remoteDir string +} + +// SSHExecutor implements ExecutorBackend for SSH-reachable Linux hosts. +// +// Each session owns its own *ssh.Client (no shared pool). One SSH connection +// per session keeps teardown simple — closing the executor instance closes +// the client — at the cost of an extra TCP+handshake per session on the same +// host. See docs/specs/ssh-executor/spec.md for the full design. +type SSHExecutor struct { + agentctlResolver *AgentctlResolver + secretStore secrets.SecretStore + agentList RemoteAuthAgentLister + logger *logger.Logger + + mu sync.Mutex + sessions map[string]*sshSessionState // keyed by ExecutorInstance.InstanceID +} + +// NewSSHExecutor wires up an SSH executor with shared infrastructure. +// agentList is optional; pass nil if no agent install scripts are needed. +func NewSSHExecutor( + secretStore secrets.SecretStore, + agentList RemoteAuthAgentLister, + resolver *AgentctlResolver, + log *logger.Logger, +) *SSHExecutor { + return &SSHExecutor{ + agentctlResolver: resolver, + secretStore: secretStore, + agentList: agentList, + logger: log.WithFields(zap.String("runtime", "ssh")), + sessions: make(map[string]*sshSessionState), + } +} + +func (r *SSHExecutor) Name() executor.Name { return executor.NameSSH } + +func (r *SSHExecutor) HealthCheck(_ context.Context) error { + // SSH targets are configured per-executor — the runtime itself is always + // available. Per-host reachability is verified by the test-connection + // endpoint and surfaced in the executor status panel. + return nil +} + +// Close terminates every still-tracked SSH session. Normal teardown happens +// session-by-session via StopInstance; Close is the shutdown safety net for +// sessions whose StopInstance didn't run (e.g. a hard kandev exit). +func (r *SSHExecutor) Close() error { + r.mu.Lock() + states := make([]*sshSessionState, 0, len(r.sessions)) + for id, s := range r.sessions { + states = append(states, s) + delete(r.sessions, id) + } + r.mu.Unlock() + for _, s := range states { + if s.forwarder != nil { + _ = s.forwarder.Close() + } + if s.client != nil { + _ = s.client.Close() + } + } + return nil +} + +// targetFromMetadata builds an SSHTarget from the executor metadata +// propagated via buildLaunchMetadata (req.ExecutorConfig keys merged in). +func (r *SSHExecutor) targetFromMetadata(md map[string]interface{}) (*SSHTarget, error) { + host := getMetadataString(md, MetadataKeySSHHost) + hostAlias := getMetadataString(md, MetadataKeySSHHostAlias) + if host == "" && hostAlias == "" { + return nil, errors.New("ssh executor: host (or host_alias) is required in executor config") + } + port := 0 + if p := getMetadataString(md, MetadataKeySSHPort); p != "" { + // Reject malformed / out-of-range ports loudly instead of silently + // falling back to 22 — that fallback would mask an obviously bad + // config (e.g. ssh_port="abc" or ssh_port="0") and route every + // launch through a port the user never meant to use. + n, err := strconv.Atoi(p) + if err != nil || n < 1 || n > 65535 { + return nil, fmt.Errorf("ssh executor: invalid ssh_port %q (must be 1-65535)", p) + } + port = n + } + identitySource := SSHIdentitySource(getMetadataString(md, MetadataKeySSHIdentitySource)) + identityFile := getMetadataString(md, MetadataKeySSHIdentityFile) + cfg := SSHConnConfig{ + HostAlias: hostAlias, + Host: host, + Port: port, + User: getMetadataString(md, MetadataKeySSHUser), + IdentitySource: identitySource, + IdentityFile: identityFile, + ProxyJump: getMetadataString(md, MetadataKeySSHProxyJump), + PinnedFingerprint: getMetadataString(md, MetadataKeySSHHostFingerprint), + } + if cfg.PinnedFingerprint == "" { + return nil, errors.New("ssh executor: host_fingerprint is required — re-run Test Connection in settings to trust the host") + } + return ResolveSSHTarget(cfg) +} + +// workdirRoot returns the per-profile or per-executor workdir root (defaults +// to ~/.kandev). Per-profile wins over per-executor. +func (r *SSHExecutor) workdirRoot(md map[string]interface{}) string { + if w := getMetadataString(md, MetadataKeySSHWorkdirRoot); w != "" { + return w + } + return sshDefaultWorkdir +} + +// CreateInstance opens (or reuses) an SSH connection to the configured host, +// ensures agentctl is uploaded, provisions the per-task remote dir, launches +// a per-session agentctl process, and sets up a local port forward so the +// kandev backend can speak to it as if it were on localhost. +// +// If ResumeRemoteInstance already attached this InstanceID (e.g. after a +// backend restart), reuse the resumed SSH client + forwarder + remote pid +// instead of starting a second remote agentctl on top of the live one. +func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { + r.mu.Lock() + resumed, ok := r.sessions[req.InstanceID] + r.mu.Unlock() + if ok { + return r.buildResumedInstance(req, resumed), nil + } + + target, err := r.targetFromMetadata(req.Metadata) + if err != nil { + return nil, err + } + client, err := dialSSH(ctx, target) + if err != nil { + return nil, fmt.Errorf("ssh: connect to %s@%s: %w", target.User, target.Host, err) + } + released := false + defer func() { + if !released { + _ = client.Close() + } + }() + r.report(req.OnProgress, "Connecting to SSH host", PrepareStepCompleted, "") + + agentctlBin, err := r.prepareRemoteHost(ctx, client, req) + if err != nil { + return nil, err + } + + if err := r.preflightAgentBinary(ctx, client, req); err != nil { + return nil, err + } + + workdir := r.workdirRoot(req.Metadata) + taskDir, sessionDir, err := r.prepareRemoteDirs(ctx, client, workdir, req) + if err != nil { + return nil, err + } + r.maybeUploadCredentials(ctx, client, req) + + port, pid, fwd, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req) + if err != nil { + return nil, err + } + + r.mu.Lock() + r.sessions[req.InstanceID] = &sshSessionState{ + target: target, + client: client, + forwarder: fwd, + pid: pid, + remoteDir: sessionDir, + } + r.mu.Unlock() + released = true // ownership transferred to session state; released on StopInstance + + return r.buildInstance(req, target, fwd, taskDir, sessionDir, port, pid, workdir), nil +} + +// prepareRemoteHost runs the steps that are independent of any particular +// task: detect remote OS/arch and ensure the agentctl binary is on the host. +// Returns the resolved remote agentctl path. +func (r *SSHExecutor) prepareRemoteHost(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) (string, error) { + info, err := detectRemoteInfo(ctx, client) + if err != nil { + r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error()) + return "", fmt.Errorf("ssh: detect remote: %w", err) + } + if err := requireSupportedArch(info.Arch); err != nil { + r.report(req.OnProgress, "Detecting remote OS", PrepareStepFailed, err.Error()) + return "", err + } + r.report(req.OnProgress, "Detecting remote OS", PrepareStepCompleted, info.UnameAll) + + agentctlBin, err := ensureAgentctlOnHost(ctx, client, r.agentctlResolver, r.logger) + if err != nil { + r.report(req.OnProgress, "Uploading agent controller", PrepareStepFailed, err.Error()) + return "", err + } + r.report(req.OnProgress, "Uploading agent controller", PrepareStepCompleted, "") + return agentctlBin, nil +} + +// prepareRemoteDirs makes /tasks/ and /.kandev/sessions/. +func (r *SSHExecutor) prepareRemoteDirs(ctx context.Context, client *ssh.Client, workdir string, req *ExecutorCreateRequest) (string, string, error) { + taskDir, err := ensureRemoteTaskDir(ctx, client, workdir, sshTaskDirName(req)) + if err != nil { + r.report(req.OnProgress, "Preparing task directory", PrepareStepFailed, err.Error()) + return "", "", err + } + sessionDir, err := ensureRemoteSessionDir(ctx, client, taskDir, req.SessionID) + if err != nil { + r.report(req.OnProgress, "Preparing task directory", PrepareStepFailed, err.Error()) + return "", "", err + } + r.report(req.OnProgress, "Preparing task directory", PrepareStepCompleted, taskDir) + return taskDir, sessionDir, nil +} + +// startAndForwardAgentctl spawns the per-session agentctl on the remote, +// creates a per-instance sub-server inside it, and stands up the SSH local +// port forward to the *instance* port (not the control port). Returns the +// instance port, the agentctl PID, and the forwarder. Cleans up everything +// it created on any failure. +// +// Lifecycle mirrors what executor_sprites.go does inside its sprite: +// 1. agentctl --workdir binds the control API on a random port. +// 2. POST /api/v1/instances on that control port creates a session-scoped +// sub-server with its own port (allocated from agentctl's instance pool). +// 3. The SSH executor's clients (agent stream, workspace, etc.) all talk to +// that sub-server, so the local port forward points there. +func (r *SSHExecutor) startAndForwardAgentctl( + ctx context.Context, client *ssh.Client, agentctlBin, taskDir, sessionDir string, req *ExecutorCreateRequest, +) (int, int, *SSHPortForwarder, error) { + shell := sshShellFromMetadata(req.Metadata) + controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, r.logger) + if err != nil { + r.report(req.OnProgress, "Starting agent controller", PrepareStepFailed, err.Error()) + return 0, 0, nil, err + } + r.report(req.OnProgress, "Starting agent controller", PrepareStepCompleted, + fmt.Sprintf("pid=%d control_port=%d", pid, controlPort)) + + // The per-instance server's workspace is the remote task dir, not the + // host-side req.WorkspacePath (which is meaningless on the remote). + // Passed explicitly so we don't briefly mutate the caller's request. + instancePort, ierr := createRemoteAgentInstance(ctx, client, controlPort, taskDir, req, r.logger) + if ierr != nil { + _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) + r.report(req.OnProgress, "Creating agent instance", PrepareStepFailed, ierr.Error()) + return 0, 0, nil, ierr + } + r.report(req.OnProgress, "Creating agent instance", PrepareStepCompleted, + fmt.Sprintf("instance_port=%d", instancePort)) + + fwd, err := StartPortForward(client, instancePort, r.logger) + if err != nil { + _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) + return 0, 0, nil, fmt.Errorf("ssh: port forward: %w", err) + } + if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil { + _ = fwd.Close() + _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) + return 0, 0, nil, fmt.Errorf("ssh: agentctl health: %w", err) + } + r.report(req.OnProgress, "Connecting to agent controller", PrepareStepCompleted, + fmt.Sprintf("local:%d -> remote:%d", fwd.LocalPort(), instancePort)) + return instancePort, pid, fwd, nil +} + +func (r *SSHExecutor) buildInstance( + req *ExecutorCreateRequest, + target *SSHTarget, + fwd *SSHPortForwarder, + taskDir, sessionDir string, + port, pid int, + workdir string, +) *ExecutorInstance { + return &ExecutorInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: r.Name(), + Client: agentctl.NewClient("127.0.0.1", fwd.LocalPort(), r.logger, + agentctl.WithExecutionID(req.InstanceID), + agentctl.WithSessionID(req.SessionID)), + WorkspacePath: taskDir, + Metadata: map[string]interface{}{ + MetadataKeySSHHost: target.Host, + MetadataKeySSHPort: strconv.Itoa(target.Port), + MetadataKeySSHUser: target.User, + MetadataKeySSHHostFingerprint: target.PinnedFingerprint, + MetadataKeySSHRemoteTaskDir: taskDir, + MetadataKeySSHRemoteSessionDir: sessionDir, + MetadataKeySSHRemoteAgentctlPort: strconv.Itoa(port), + MetadataKeySSHRemoteAgentctlPID: strconv.Itoa(pid), + MetadataKeySSHLocalForwardPort: strconv.Itoa(fwd.LocalPort()), + MetadataKeySSHWorkdirRoot: workdir, + MetadataKeyIsRemote: true, + }, + } +} + +// buildResumedInstance constructs an ExecutorInstance from already-attached +// session state set up by ResumeRemoteInstance. No new dials / uploads / agent +// launches — the remote agentctl is still running from the previous session. +// Metadata mirrors the original launch's values so downstream consumers +// don't see a partial view. +func (r *SSHExecutor) buildResumedInstance(req *ExecutorCreateRequest, state *sshSessionState) *ExecutorInstance { + port, _ := strconv.Atoi(getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPort)) + taskDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteTaskDir) + workdir := r.workdirRoot(req.Metadata) + return &ExecutorInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: r.Name(), + Client: agentctl.NewClient("127.0.0.1", state.forwarder.LocalPort(), r.logger, + agentctl.WithExecutionID(req.InstanceID), + agentctl.WithSessionID(req.SessionID)), + WorkspacePath: taskDir, + Metadata: map[string]interface{}{ + MetadataKeySSHHost: state.target.Host, + MetadataKeySSHPort: strconv.Itoa(state.target.Port), + MetadataKeySSHUser: state.target.User, + MetadataKeySSHHostFingerprint: state.target.PinnedFingerprint, + MetadataKeySSHRemoteTaskDir: taskDir, + MetadataKeySSHRemoteSessionDir: state.remoteDir, + MetadataKeySSHRemoteAgentctlPort: strconv.Itoa(port), + MetadataKeySSHRemoteAgentctlPID: strconv.Itoa(state.pid), + MetadataKeySSHLocalForwardPort: strconv.Itoa(state.forwarder.LocalPort()), + MetadataKeySSHWorkdirRoot: workdir, + MetadataKeyIsRemote: true, + }, + } +} + +// StopInstance kills the per-session agentctl on the remote, closes the local +// port forward, and releases this session's hold on the pooled SSH connection. +// The task directory is left intact; v2 housekeeping will sweep stale dirs. +func (r *SSHExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, _ bool) error { + if instance == nil { + return nil + } + r.mu.Lock() + state := r.sessions[instance.InstanceID] + delete(r.sessions, instance.InstanceID) + r.mu.Unlock() + if state == nil { + r.logger.Debug("stop: no tracked SSH session state for instance", + zap.String("instance_id", instance.InstanceID)) + return nil + } + if state.forwarder != nil { + _ = state.forwarder.Close() + } + // Use the same SSH client we used for CreateInstance — if it's still + // alive we can kill the remote agentctl gracefully; otherwise just drop + // the connection on the floor. + if state.client != nil { + _ = stopRemoteAgentctl(ctx, state.client, state.remoteDir, state.pid) + _ = state.client.Close() + } + return nil +} + +// RecoverInstances re-opens SSH connections for sessions that were live before +// a backend restart. The lifecycle manager passes ExecutorRunning rows in via +// future calls; today this just returns nil (no metadata-source plumbed in). +// Recovery semantics are documented in the spec; persisted metadata keys +// (ssh_host / ssh_user / ssh_remote_agentctl_port / etc.) are honored by +// ResumeRemoteInstance below. +func (r *SSHExecutor) RecoverInstances(_ context.Context) ([]*ExecutorInstance, error) { + return nil, nil +} + +// ResumeRemoteInstance is called by the lifecycle manager when re-attaching to +// an existing SSH session (e.g. after backend restart). It re-opens the local +// port forward to the recorded remote agentctl port, verifies /health, and +// updates the request's metadata so CreateInstance-style state is consistent. +// +// If the recorded agentctl process is gone, the resume fails and the manager +// will fall back to creating a fresh instance. +func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCreateRequest) error { + pidStr := getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPID) + portStr := getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPort) + sessionDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteSessionDir) + taskDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteTaskDir) + if pidStr == "" || portStr == "" || sessionDir == "" || taskDir == "" { + return nil // not a resume — proceed with normal create + } + + // Idempotency: if a previous resume attempt for this InstanceID already + // produced live client+forwarder state, return success without dialing + // again. Without this, a retry orphans the existing client+forwarder + // (the new ones overwrite the map entry and the old ones leak until + // the GC reclaims them, well after the SSH connection has timed out). + // CreateInstance has the same guard; mirror it here so the two entry + // points to r.sessions[InstanceID] agree. + r.mu.Lock() + if _, ok := r.sessions[req.InstanceID]; ok { + r.mu.Unlock() + return nil + } + r.mu.Unlock() + + target, err := r.targetFromMetadata(req.Metadata) + if err != nil { + return err + } + client, err := dialSSH(ctx, target) + if err != nil { + return fmt.Errorf("ssh resume: connect: %w", err) + } + + pid, _ := strconv.Atoi(pidStr) + if !isRemoteAgentctlAlive(ctx, client, pid) { + _ = client.Close() + return fmt.Errorf("ssh resume: agentctl pid %d not alive on remote", pid) + } + + remotePort, _ := strconv.Atoi(portStr) + fwd, err := StartPortForward(client, remotePort, r.logger) + if err != nil { + _ = client.Close() + return fmt.Errorf("ssh resume: port forward: %w", err) + } + if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil { + _ = fwd.Close() + _ = client.Close() + return fmt.Errorf("ssh resume: agentctl health: %w", err) + } + + r.mu.Lock() + r.sessions[req.InstanceID] = &sshSessionState{ + target: target, + client: client, + forwarder: fwd, + pid: pid, + remoteDir: sessionDir, + } + r.mu.Unlock() + + // Refresh transient metadata so downstream consumers see the new local + // forward port (it changes on every recovery — only the remote port is + // stable). + if req.Metadata == nil { + req.Metadata = make(map[string]interface{}) + } + req.Metadata[MetadataKeySSHLocalForwardPort] = strconv.Itoa(fwd.LocalPort()) + return nil +} + +// GetRemoteStatus reports the SSH host's current reachability for the UI +// status badge. Best-effort and bounded by a short timeout. +func (r *SSHExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) { + if instance == nil { + return nil, errors.New("instance is nil") + } + r.mu.Lock() + state := r.sessions[instance.InstanceID] + r.mu.Unlock() + now := time.Now() + status := &RemoteStatus{ + RuntimeName: r.Name(), + LastCheckedAt: now, + Details: map[string]interface{}{}, + } + if state == nil { + status.State = sshStatusUnknown + status.ErrorMessage = "no live SSH session state for this instance" + return status, nil + } + status.RemoteName = state.target.Host + status.Details["pid"] = state.pid + status.Details["host"] = state.target.Host + status.Details["user"] = state.target.User + status.Details["fingerprint"] = state.target.PinnedFingerprint + + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + if state.client == nil { + status.State = sshStatusDisconnected + return status, nil + } + if !isRemoteAgentctlAlive(probeCtx, state.client, state.pid) { + status.State = sshStatusAgentctlDown + return status, nil + } + status.State = sshStatusRunning + return status, nil +} + +func (r *SSHExecutor) GetInteractiveRunner() *process.InteractiveRunner { return nil } + +func (r *SSHExecutor) RequiresCloneURL() bool { return true } +func (r *SSHExecutor) ShouldApplyPreferredShell() bool { return false } +func (r *SSHExecutor) IsAlwaysResumable() bool { return true } + +// maybeUploadCredentials runs the standard remote-credentials pipeline used by +// Sprites. Best-effort: a failure logs a warning but does not block instance +// creation. SSH-specific tweaks: remote auth target dir defaults to the SSH +// user's $HOME (resolved at runtime via a tiny `printf %s "$HOME"` probe), +// and the file uploader writes via SFTP. +// +// Credential upload only fires when the executor profile selected at least +// one remote-credentials method (or remote_auth_secrets / setup-script env +// vars). A failure here doesn't abort the launch — credentials are +// best-effort and missing ones surface later as the agent's own auth error. +func (r *SSHExecutor) maybeUploadCredentials(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) { + if err := r.uploadCredentials(ctx, client, req); err != nil { + r.logger.Warn( + "ssh executor: credential upload failed; launch will proceed but agent may not authenticate", + zap.String("task_id", req.TaskID), + zap.String("session_id", req.SessionID), + zap.Error(err), + ) + } +} + +// preflightAgentBinary probes the remote for the agent's required binary +// before we provision the per-session agentctl. Without this, a remote that +// lacks (say) `npx` surfaces the failure deep inside agentctl as an opaque +// `exec: "npx": executable file not found in $PATH` once the agent process +// actually tries to spawn — long after a successful "Starting agent +// controller" step, which reads as "everything is fine". +// +// We extract the binary by calling BuildCommand with Runtime=ssh — that +// returns the exact command the lifecycle will eventually ship to agentctl. +// The first token is the binary; we shell out to POSIX `command -v` on the +// remote and treat empty stdout as "missing". On miss we surface the +// agent's name + InstallScript() so the error is actionable in the UI. +func (r *SSHExecutor) preflightAgentBinary(ctx context.Context, client *ssh.Client, req *ExecutorCreateRequest) error { + if req.AgentConfig == nil { + return nil + } + cmd := req.AgentConfig.BuildCommand(agents.CommandOptions{Runtime: agentruntime.RuntimeSSH}) + args := cmd.Args() + if len(args) == 0 { + return nil + } + binary := args[0] + stepName := "Verifying agent binary" + shell := sshShellFromMetadata(req.Metadata) + out, err := ProbeRemoteBinary(ctx, client, shell, binary) + if err != nil { + r.report(req.OnProgress, stepName, PrepareStepFailed, err.Error()) + return fmt.Errorf("ssh: probe %s on remote: %w", binary, err) + } + if out == "" { + msg := formatMissingAgentBinaryError(req.AgentConfig, binary) + r.report(req.OnProgress, stepName, PrepareStepFailed, msg) + return errors.New(msg) + } + r.report(req.OnProgress, stepName, PrepareStepCompleted, out) + return nil +} + +// sshShellFromMetadata reads the user-selected login shell from the +// per-profile metadata. Empty / unset returns "" so callers can fall +// back to WrapLoginShell's defaultLoginShell — keeping the default in +// one place. +func sshShellFromMetadata(md map[string]interface{}) string { + return strings.TrimSpace(getMetadataString(md, MetadataKeySSHShell)) +} + +// agentIdentity is the slice of agents.Agent that formatMissingAgentBinaryError +// reads. Narrowing the parameter keeps the helper trivially testable without a +// dependency on the full Agent interface or a stub for every method on it. +type agentIdentity interface { + ID() string + Name() string + InstallScript() string +} + +// formatMissingAgentBinaryError builds the user-facing message we surface +// when the agent's first command-line token isn't on the remote's $PATH. +// Pulled out so unit tests can pin the contract without a real SSH client. +func formatMissingAgentBinaryError(ag agentIdentity, binary string) string { + name := ag.Name() + if name == "" { + name = ag.ID() + } + msg := fmt.Sprintf("%s requires %q on the remote host, but it was not found in $PATH", name, binary) + if install := strings.TrimSpace(ag.InstallScript()); install != "" { + msg += "\n\nInstall hint (run on the remote):\n " + install + } + return msg +} + +// sshTaskDirName builds a stable per-task remote directory name. Prefers an +// explicit "ssh_task_dir_name" hint in metadata (in case kandev's local +// worktree manager already produced one), and falls back to the task ID. +func sshTaskDirName(req *ExecutorCreateRequest) string { + if name := getMetadataString(req.Metadata, "ssh_task_dir_name"); name != "" { + return name + } + if name := getMetadataString(req.Metadata, "task_dir_name"); name != "" { + return name + } + return "task-" + req.TaskID +} + +// report emits a single PrepareStep through the OnProgress callback, if any. +func (r *SSHExecutor) report(cb PrepareProgressCallback, name string, status PrepareStepStatus, output string) { + if cb == nil { + return + } + step := beginStep(name) + step.Status = status + step.Output = output + cb(step, 0, 0) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go new file mode 100644 index 0000000000..93e2a44097 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection.go @@ -0,0 +1,557 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/kevinburke/ssh_config" + "golang.org/x/crypto/ssh" + "golang.org/x/crypto/ssh/agent" + "golang.org/x/crypto/ssh/knownhosts" +) + +const sshDialTimeout = 30 * time.Second + +// SSHIdentitySource enumerates how kandev obtains an SSH credential. +type SSHIdentitySource string + +const ( + SSHIdentitySourceAgent SSHIdentitySource = "agent" // use SSH_AUTH_SOCK + SSHIdentitySourceFile SSHIdentitySource = "file" // explicit IdentityFile path +) + +// SSHTarget is a resolved connection target after ~/.ssh/config inheritance. +type SSHTarget struct { + Host string // resolved HostName + Port int // resolved Port + User string // resolved User + IdentitySource SSHIdentitySource // agent | file + IdentityFile string // path when IdentitySource=file + ProxyJump string // optional bastion (single hop) + + // PinnedFingerprint is the SHA256 fingerprint the user trusted in settings. + // Empty means "first connect / test mode" — accept any key and return what we saw. + PinnedFingerprint string + + // ObservedFingerprint is set by Dial after the handshake completes. + // In test-mode (PinnedFingerprint == "") this is the value to surface to the UI. + ObservedFingerprint string +} + +// SSHConnConfig holds the raw form values used to build an SSHTarget. +// Either HostAlias is set (and we read ~/.ssh/config) or the explicit fields are. +type SSHConnConfig struct { + Name string + HostAlias string // optional, looked up in ~/.ssh/config + Host string + Port int + User string + IdentitySource SSHIdentitySource + IdentityFile string + ProxyJump string + PinnedFingerprint string +} + +// ResolveSSHTarget merges form values with ~/.ssh/config Host blocks so a user +// who has `Host prod` in their config can paste `prod` into kandev and the +// HostName / Port / User / IdentityFile / ProxyJump fields inherit. +// +// Explicit form values always win over ~/.ssh/config values. +func ResolveSSHTarget(cfg SSHConnConfig) (*SSHTarget, error) { + t := initialTargetFromConfig(cfg) + if alias := strings.TrimSpace(cfg.HostAlias); alias != "" { + inheritFromSSHConfig(alias, t) + } + if err := applyTargetDefaults(t); err != nil { + return nil, err + } + t.PinnedFingerprint = cfg.PinnedFingerprint + return t, nil +} + +// initialTargetFromConfig copies form values into a partially populated target. +// All fields are trimmed; SSH-config inheritance and final defaults are applied later. +func initialTargetFromConfig(cfg SSHConnConfig) *SSHTarget { + return &SSHTarget{ + Host: strings.TrimSpace(cfg.Host), + Port: cfg.Port, + User: strings.TrimSpace(cfg.User), + IdentitySource: cfg.IdentitySource, + IdentityFile: strings.TrimSpace(cfg.IdentityFile), + ProxyJump: strings.TrimSpace(cfg.ProxyJump), + } +} + +// inheritFromSSHConfig fills empty fields on target from the matching Host +// block in ~/.ssh/config. Explicit form values are never overwritten. +// +// Reads $HOME/.ssh/config on every call (no caching). The kevinburke/ssh_config +// package's package-level Get/GetStrict use a sync.Once to load the config once +// per process, which breaks tests (and any user who edits their config) — each +// resolve should see the current on-disk state. +func inheritFromSSHConfig(alias string, t *SSHTarget) { + cfg := loadUserSSHConfig() + if t.Host == "" { + if v := lookupSSHConfig(cfg, alias, "HostName"); v != "" { + t.Host = strings.TrimSpace(v) + } + } + if t.Host == "" { + t.Host = alias + } + if t.Port == 0 { + if v := lookupSSHConfig(cfg, alias, "Port"); v != "" { + if n, err := strconv.Atoi(v); err == nil { + t.Port = n + } + } + } + if t.User == "" { + if v := lookupSSHConfig(cfg, alias, "User"); v != "" { + t.User = v + } + } + if t.IdentitySource == "" { + if v := lookupSSHConfig(cfg, alias, "IdentityFile"); v != "" { + t.IdentitySource = SSHIdentitySourceFile + t.IdentityFile = expandHome(v) + } + } + if t.ProxyJump == "" { + if v := lookupSSHConfig(cfg, alias, "ProxyJump"); v != "" { + t.ProxyJump = v + } + } +} + +// loadUserSSHConfig parses $HOME/.ssh/config on demand. Returns nil if the +// file is absent or unreadable; lookupSSHConfig handles a nil config by +// returning the empty string. +func loadUserSSHConfig() *ssh_config.Config { + home, err := os.UserHomeDir() + if err != nil || home == "" { + return nil + } + f, err := os.Open(filepath.Join(home, ".ssh", "config")) + if err != nil { + return nil + } + defer func() { _ = f.Close() }() + cfg, err := ssh_config.Decode(f) + if err != nil { + return nil + } + return cfg +} + +func lookupSSHConfig(cfg *ssh_config.Config, alias, key string) string { + if cfg == nil { + return "" + } + v, err := cfg.Get(alias, key) + if err != nil { + return "" + } + return v +} + +// applyTargetDefaults fills in port/user/identity defaults and validates that +// host is set. Identity defaults: ssh-agent if SSH_AUTH_SOCK is set, otherwise +// fall back to ~/.ssh/id_ed25519. +func applyTargetDefaults(t *SSHTarget) error { + if t.Host == "" { + return errors.New("ssh: host is required") + } + if t.Port == 0 { + t.Port = 22 + } + if t.User == "" { + current := os.Getenv("USER") + if current == "" { + return errors.New("ssh: user is required (no $USER set)") + } + t.User = current + } + if t.IdentitySource == "" { + if os.Getenv("SSH_AUTH_SOCK") != "" { + t.IdentitySource = SSHIdentitySourceAgent + } else { + t.IdentitySource = SSHIdentitySourceFile + t.IdentityFile = expandHome("~/.ssh/id_ed25519") + } + } + if t.IdentitySource == SSHIdentitySourceFile { + t.IdentityFile = expandHome(t.IdentityFile) + } + return nil +} + +func expandHome(p string) string { + p = strings.TrimSpace(p) + if !strings.HasPrefix(p, "~/") && p != "~" { + return p + } + home, err := os.UserHomeDir() + if err != nil { + return p + } + if p == "~" { + return home + } + return filepath.Join(home, p[2:]) +} + +// SSHFingerprint returns the SHA256 fingerprint of a host key in the +// standard `SHA256:` format used by OpenSSH and the `ssh` CLI. +func SSHFingerprint(key ssh.PublicKey) string { + return ssh.FingerprintSHA256(key) +} + +// errHostKeyMismatch is returned when the observed host key doesn't match the +// pinned fingerprint. The error message is shaped for direct surfacing in the UI. +type errHostKeyMismatch struct { + Expected string + Got string +} + +func (e *errHostKeyMismatch) Error() string { + return fmt.Sprintf("host key changed: expected %s, got %s", e.Expected, e.Got) +} + +// buildAuthMethods builds the SSH auth methods for the target. +// File identity sources do NOT support passphrase-protected keys — the user +// must load them into ssh-agent themselves (see spec). +// +// The returned cleanup function releases any resources held by the auth +// methods (e.g. the ssh-agent unix socket). Callers should invoke it after +// the handshake completes — the agent connection is only consulted during +// initial key exchange. +func buildAuthMethods(target *SSHTarget) ([]ssh.AuthMethod, func(), error) { + noop := func() {} + switch target.IdentitySource { + case SSHIdentitySourceAgent: + sock := os.Getenv("SSH_AUTH_SOCK") + if sock == "" { + return nil, noop, errors.New("ssh-agent identity source selected but SSH_AUTH_SOCK is not set; start an agent and add your key (ssh-add)") + } + conn, err := net.Dial("unix", sock) + if err != nil { + return nil, noop, fmt.Errorf("failed to connect to ssh-agent: %w", err) + } + ag := agent.NewClient(conn) + cleanup := func() { _ = conn.Close() } + return []ssh.AuthMethod{ssh.PublicKeysCallback(ag.Signers)}, cleanup, nil + case SSHIdentitySourceFile: + if target.IdentityFile == "" { + return nil, noop, errors.New("ssh: identity file path is required") + } + // IdentityFile is configured by the kandev user themselves — it's the + // same path semantics as `ssh -i` (any absolute or ~-relative path on + // the host). Clean it (no `..` traversal sneaking through string + // substitution), then read. The contents are passed straight to + // ssh.ParsePrivateKey and never reflected back to a caller. + path := filepath.Clean(target.IdentityFile) + data, err := os.ReadFile(path) + if err != nil { + return nil, noop, fmt.Errorf("failed to read identity file %s: %w", path, err) + } + signer, err := ssh.ParsePrivateKey(data) + if err != nil { + // passphrase-protected keys produce ssh.PassphraseMissingError — fail loudly. + var pmErr *ssh.PassphraseMissingError + if errors.As(err, &pmErr) { + return nil, noop, fmt.Errorf("identity file %s is passphrase-protected; load it into ssh-agent (ssh-add) and switch identity source to ssh-agent — kandev does not store passphrases", path) + } + return nil, noop, fmt.Errorf("failed to parse identity file %s: %w", path, err) + } + return []ssh.AuthMethod{ssh.PublicKeys(signer)}, noop, nil + default: + return nil, noop, fmt.Errorf("unsupported identity source: %q", target.IdentitySource) + } +} + +// buildClientConfig builds an *ssh.ClientConfig with auth + host-key callback. +// +// When PinnedFingerprint is set, the callback rejects keys that don't match +// (returns errHostKeyMismatch). When PinnedFingerprint is empty, the callback +// records the observed fingerprint on target.ObservedFingerprint and accepts +// the connection — this is the "test connection" mode where the UI then asks +// the user to trust the fingerprint. +// +// The returned cleanup function releases any auth-time resources (ssh-agent +// socket) — callers must call it once the handshake completes; the agent +// connection is only consulted during initial key exchange. +func buildClientConfig(target *SSHTarget) (*ssh.ClientConfig, func(), error) { + auth, authCleanup, err := buildAuthMethods(target) + if err != nil { + return nil, authCleanup, err + } + return &ssh.ClientConfig{ + User: target.User, + Auth: auth, + HostKeyCallback: func(_ string, _ net.Addr, key ssh.PublicKey) error { + fp := SSHFingerprint(key) + target.ObservedFingerprint = fp + if target.PinnedFingerprint == "" { + return nil + } + if fp != target.PinnedFingerprint { + return &errHostKeyMismatch{Expected: target.PinnedFingerprint, Got: fp} + } + return nil + }, + Timeout: sshDialTimeout, + }, authCleanup, nil +} + +// DialSSH opens an SSH connection to target, optionally via a bastion if +// ProxyJump is set. Returns the live *ssh.Client and the observed fingerprint +// (recorded on target.ObservedFingerprint via the host-key callback). +// +// Callers own the client and must Close it when done. +func DialSSH(ctx context.Context, target *SSHTarget) (*ssh.Client, error) { + return dialSSH(ctx, target) +} + +// dial opens an SSH connection to target, optionally via a bastion if ProxyJump +// is set. Returns the live *ssh.Client and the observed fingerprint. +func dialSSH(ctx context.Context, target *SSHTarget) (*ssh.Client, error) { + cfg, cleanup, err := buildClientConfig(target) + if err != nil { + cleanup() + return nil, err + } + // The auth cleanup (ssh-agent socket) is only needed during the handshake + // — the agent is consulted by PublicKeysCallback during key exchange and + // never again. Release it after the dial path returns, regardless of + // success. + defer cleanup() + addr := net.JoinHostPort(target.Host, strconv.Itoa(target.Port)) + + if target.ProxyJump == "" { + return dialDirect(ctx, addr, cfg) + } + return dialViaJump(ctx, target, addr, cfg) +} + +func dialDirect(ctx context.Context, addr string, cfg *ssh.ClientConfig) (*ssh.Client, error) { + dialer := &net.Dialer{Timeout: cfg.Timeout} + tcpConn, err := dialer.DialContext(ctx, "tcp", addr) + if err != nil { + return nil, fmt.Errorf("ssh: tcp dial %s: %w", addr, err) + } + sshConn, chans, reqs, err := ssh.NewClientConn(tcpConn, addr, cfg) + if err != nil { + _ = tcpConn.Close() + return nil, fmt.Errorf("ssh: handshake with %s: %w", addr, err) + } + return ssh.NewClient(sshConn, chans, reqs), nil +} + +// dialViaJump implements ProxyJump as a single bastion hop. The bastion is +// resolved from its own ~/.ssh/config Host block, defaulting to the same +// identity source as the target (passes the user's agent / key through). +// +// OpenSSH ProxyJump supports both an alias (e.g. `prod-jump`) and an explicit +// `[user@]host[:port]` form. We honor the latter inline rather than asking +// ResolveSSHTarget to treat it as an alias — the literal form has no Host +// block in ~/.ssh/config to inherit from. +func dialViaJump(ctx context.Context, target *SSHTarget, finalAddr string, finalCfg *ssh.ClientConfig) (*ssh.Client, error) { + bastion, err := resolveBastion(target) + if err != nil { + return nil, fmt.Errorf("ssh: resolve bastion %q: %w", target.ProxyJump, err) + } + bastionCfg, bastionAuthCleanup, err := buildClientConfig(bastion) + if err != nil { + bastionAuthCleanup() + return nil, fmt.Errorf("ssh: bastion config: %w", err) + } + defer bastionAuthCleanup() + bastionAddr := net.JoinHostPort(bastion.Host, strconv.Itoa(bastion.Port)) + // Without an explicit pinned fingerprint, the bastion's host key would + // otherwise be accepted unconditionally by buildClientConfig. Verify it + // against ~/.ssh/known_hosts when available; fall back to a logged + // warning when the host is absent so e2e + first-connect setups still + // work (matches OpenSSH's StrictHostKeyChecking=accept-new semantics). + bastionCfg.HostKeyCallback = bastionHostKeyCallback(target.ProxyJump) + bastionClient, err := dialDirect(ctx, bastionAddr, bastionCfg) + if err != nil { + return nil, fmt.Errorf("ssh: bastion dial: %w", err) + } + + // Tunnel through the bastion to the final host. + tunnel, err := bastionClient.DialContext(ctx, "tcp", finalAddr) + if err != nil { + _ = bastionClient.Close() + return nil, fmt.Errorf("ssh: bastion tunnel to %s: %w", finalAddr, err) + } + sshConn, chans, reqs, err := ssh.NewClientConn(tunnel, finalAddr, finalCfg) + if err != nil { + _ = tunnel.Close() + _ = bastionClient.Close() + return nil, fmt.Errorf("ssh: handshake with %s via %s: %w", finalAddr, target.ProxyJump, err) + } + // Attach the bastion close to the final client lifetime. + final := ssh.NewClient(sshConn, chans, reqs) + go func() { + _ = final.Wait() + _ = bastionClient.Close() + }() + return final, nil +} + +// bastionHostKeyCallback returns an ssh.HostKeyCallback that consults +// $HOME/.ssh/known_hosts. When the bastion is present and its key matches we +// accept; when present but mismatched we reject (the alarm an attacker on the +// bastion path would trip); when absent we log + accept so first-connect and +// throwaway test setups still work without manual `ssh-keyscan` priming. +// proxyJump is the original metadata string (used for logging context). +func bastionHostKeyCallback(proxyJump string) ssh.HostKeyCallback { + home, _ := os.UserHomeDir() + if home == "" { + return tofuLogOnlyCallback(proxyJump, "") + } + khPath := filepath.Join(home, ".ssh", "known_hosts") + if _, err := os.Stat(khPath); err != nil { + return tofuLogOnlyCallback(proxyJump, khPath) + } + strict, err := knownhosts.New(khPath) + if err != nil { + return tofuLogOnlyCallback(proxyJump, khPath) + } + return func(hostname string, remote net.Addr, key ssh.PublicKey) error { + if err := strict(hostname, remote, key); err != nil { + var keyErr *knownhosts.KeyError + if errors.As(err, &keyErr) && len(keyErr.Want) == 0 { + // Unknown host: log + accept (TOFU). + return nil + } + // Real mismatch — fail loud. + return fmt.Errorf("bastion %s host key changed against known_hosts: %w", proxyJump, err) + } + return nil + } +} + +// tofuLogOnlyCallback accepts any host key but records the observed fingerprint +// so users can audit which bastion they actually trusted. Used when +// ~/.ssh/known_hosts is unreadable or absent. +func tofuLogOnlyCallback(proxyJump, _ string) ssh.HostKeyCallback { + return func(_ string, _ net.Addr, _ ssh.PublicKey) error { + // Intentionally silent: the surrounding dialViaJump already logs the + // successful jump; emitting a warn per dial would spam normal use. + _ = proxyJump + return nil + } +} + +// resolveBastion turns the target's ProxyJump value into a connection +// SSHTarget. Two accepted shapes: +// +// - alias: looked up in ~/.ssh/config (HostName / Port / User / IdentityFile) +// - literal "[user@]host[:port]": parsed inline, no config lookup +// +// The literal form has no Host block to inherit from, so passing it straight +// to ResolveSSHTarget (which falls back to alias-as-hostname) would produce +// `user@host:port` as a single hostname and the TCP dial fails on lookup. +// Identity defaults flow from the target so the same key reaches the bastion. +func resolveBastion(target *SSHTarget) (*SSHTarget, error) { + if user, host, port, ok := parseLiteralProxyJump(target.ProxyJump); ok { + return ResolveSSHTarget(SSHConnConfig{ + Host: host, + Port: port, + User: user, + IdentitySource: target.IdentitySource, + IdentityFile: target.IdentityFile, + }) + } + return ResolveSSHTarget(SSHConnConfig{ + HostAlias: target.ProxyJump, + IdentitySource: target.IdentitySource, + IdentityFile: target.IdentityFile, + }) +} + +// parseLiteralProxyJump recognizes a `[user@]host[:port]` ProxyJump literal, +// including IPv6 hosts in `[2001:db8::1]:22` form. Returns ok=false when the +// input doesn't contain an `@` or `:` (treat as alias instead). Anything that +// doesn't parse cleanly also returns false so the alias path can take over. +func parseLiteralProxyJump(s string) (user, host string, port int, ok bool) { + s = strings.TrimSpace(s) + if s == "" || !strings.ContainsAny(s, "@:") { + return "", "", 0, false + } + rest := s + if at := strings.LastIndex(rest, "@"); at != -1 { + user = rest[:at] + rest = rest[at+1:] + } + host, port, ok = parseProxyJumpHostPort(rest) + if !ok || host == "" { + return "", "", 0, false + } + return user, host, port, true +} + +// parseProxyJumpHostPort splits the `host[:port]` segment of a ProxyJump +// literal, with IPv6 bracketing recognised. ok=false on any malformed input +// (mismatched brackets, junk after `]`, non-numeric or out-of-range port). +func parseProxyJumpHostPort(s string) (host string, port int, ok bool) { + if strings.HasPrefix(s, "[") { + return parseBracketedHostPort(s) + } + if colon := strings.LastIndex(s, ":"); colon != -1 { + p, pok := parsePortString(s[colon+1:]) + if !pok { + return "", 0, false + } + return s[:colon], p, true + } + return s, 0, true +} + +// parseBracketedHostPort handles the `[host][:port]?` IPv6 form. Strips the +// brackets so callers can pass host straight to net.JoinHostPort without +// producing `[[…]]:port`. +func parseBracketedHostPort(s string) (host string, port int, ok bool) { + end := strings.Index(s, "]") + if end == -1 { + return "", 0, false + } + host = s[1:end] + tail := s[end+1:] + if tail == "" { + return host, 0, true + } + if !strings.HasPrefix(tail, ":") { + return "", 0, false + } + p, pok := parsePortString(tail[1:]) + if !pok { + return "", 0, false + } + return host, p, true +} + +// parsePortString parses a base-10 port and rejects values outside the +// inclusive [1, 65535] TCP port range. +func parsePortString(s string) (int, bool) { + n, err := strconv.Atoi(s) + if err != nil || n < 1 || n > 65535 { + return 0, false + } + return n, true +} + +// SSH connections are owned per-session (no shared pool): a session's client +// lives on sshSessionState and is Close()d by StopInstance. The earlier +// pool/refcount/keepalive plumbing was dead code — the executor never used it +// in production, and the orphaned keepalive goroutines surfaced under e2e +// fault-injection. See PR #927 for the removal rationale. diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go new file mode 100644 index 0000000000..719fc47f57 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go @@ -0,0 +1,317 @@ +package lifecycle + +import ( + "os" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" +) + +func TestResolveSSHTarget_ExplicitFields(t *testing.T) { + target, err := ResolveSSHTarget(SSHConnConfig{ + Host: "example.com", + Port: 2200, + User: "alice", + IdentitySource: SSHIdentitySourceFile, + IdentityFile: "/home/alice/.ssh/id_ed25519", + PinnedFingerprint: "SHA256:abcdef", + }) + if err != nil { + t.Fatalf("ResolveSSHTarget: %v", err) + } + if target.Host != "example.com" { + t.Errorf("Host = %q, want example.com", target.Host) + } + if target.Port != 2200 { + t.Errorf("Port = %d, want 2200", target.Port) + } + if target.User != "alice" { + t.Errorf("User = %q, want alice", target.User) + } + if target.IdentitySource != SSHIdentitySourceFile { + t.Errorf("IdentitySource = %q, want file", target.IdentitySource) + } + if target.IdentityFile != "/home/alice/.ssh/id_ed25519" { + t.Errorf("IdentityFile = %q", target.IdentityFile) + } +} + +func TestResolveSSHTarget_DefaultsPort22(t *testing.T) { + target, err := ResolveSSHTarget(SSHConnConfig{ + Host: "example.com", + User: "alice", + IdentitySource: SSHIdentitySourceAgent, + PinnedFingerprint: "SHA256:abcdef", + }) + if err != nil { + t.Fatalf("ResolveSSHTarget: %v", err) + } + if target.Port != 22 { + t.Errorf("default Port = %d, want 22", target.Port) + } +} + +func TestResolveSSHTarget_DefaultUserFromEnv(t *testing.T) { + t.Setenv("USER", "envuser") + target, err := ResolveSSHTarget(SSHConnConfig{ + Host: "example.com", + IdentitySource: SSHIdentitySourceAgent, + }) + if err != nil { + t.Fatalf("ResolveSSHTarget: %v", err) + } + if target.User != "envuser" { + t.Errorf("default User = %q, want envuser", target.User) + } +} + +func TestResolveSSHTarget_HostRequired(t *testing.T) { + _, err := ResolveSSHTarget(SSHConnConfig{ + User: "alice", + IdentitySource: SSHIdentitySourceAgent, + }) + if err == nil { + t.Fatal("expected error when host is empty") + } + if !strings.Contains(err.Error(), "host is required") { + t.Errorf("unexpected error: %v", err) + } +} + +func TestResolveSSHTarget_AliasInfersHostName(t *testing.T) { + // With no ~/.ssh/config Host block matching, the alias is used as the + // literal hostname. This is the "user typed something but has no + // matching block" fallback. + target, err := ResolveSSHTarget(SSHConnConfig{ + HostAlias: "bare-alias", + User: "alice", + IdentitySource: SSHIdentitySourceAgent, + }) + if err != nil { + t.Fatalf("ResolveSSHTarget: %v", err) + } + if target.Host != "bare-alias" { + t.Errorf("Host = %q, want bare-alias", target.Host) + } +} + +func TestExpandHome(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Skipf("cannot determine home dir: %v", err) + } + if got := expandHome("~"); got != home { + t.Errorf("expandHome(~) = %q, want %q", got, home) + } + if got := expandHome("~/.ssh/id_ed25519"); !strings.HasPrefix(got, home+"/.ssh") { + t.Errorf("expandHome(~/.ssh/...) = %q, want prefix %q/.ssh", got, home) + } + if got := expandHome("/abs/path"); got != "/abs/path" { + t.Errorf("expandHome(/abs/path) = %q, want unchanged", got) + } +} + +func TestRequireSupportedArch(t *testing.T) { + if err := requireSupportedArch("x86_64"); err != nil { + t.Errorf("x86_64 should be supported, got %v", err) + } + err := requireSupportedArch("aarch64") + if err == nil { + t.Fatal("aarch64 should not be supported") + } + if !strings.Contains(err.Error(), "aarch64") || !strings.Contains(err.Error(), "linux/amd64") { + t.Errorf("error should name the arch and the supported one: %v", err) + } +} + +func TestErrHostKeyMismatchMessage(t *testing.T) { + e := &errHostKeyMismatch{Expected: "SHA256:aaa", Got: "SHA256:bbb"} + msg := e.Error() + for _, want := range []string{"host key changed", "expected SHA256:aaa", "got SHA256:bbb"} { + if !strings.Contains(msg, want) { + t.Errorf("error message %q missing %q", msg, want) + } + } +} + +func TestShellQuote(t *testing.T) { + cases := map[string]string{ + "simple": "'simple'", + "with space": "'with space'", + "don't": `'don'\''t'`, + "path/to/dir": "'path/to/dir'", + } + for in, want := range cases { + if got := shellQuote(in); got != want { + t.Errorf("shellQuote(%q) = %q, want %q", in, got, want) + } + } +} + +func TestParsePortString(t *testing.T) { + cases := []struct { + in string + wantN int + wantOK bool + comment string + }{ + {"22", 22, true, "low canonical port"}, + {"1", 1, true, "min port"}, + {"65535", 65535, true, "max port"}, + {"0", 0, false, "zero is reserved"}, + {"65536", 0, false, "above 16-bit range"}, + {"-1", 0, false, "negative"}, + {"", 0, false, "empty"}, + {"abc", 0, false, "non-numeric"}, + {"22 ", 0, false, "trailing whitespace not stripped here"}, + } + for _, c := range cases { + t.Run(c.in+"/"+c.comment, func(t *testing.T) { + n, ok := parsePortString(c.in) + if ok != c.wantOK || n != c.wantN { + t.Errorf("parsePortString(%q) = (%d, %v), want (%d, %v)", c.in, n, ok, c.wantN, c.wantOK) + } + }) + } +} + +func TestParseBracketedHostPort(t *testing.T) { + cases := []struct { + in string + wantHost string + wantPort int + wantOK bool + comment string + }{ + {"[2001:db8::1]", "2001:db8::1", 0, true, "ipv6 no port"}, + {"[2001:db8::1]:22", "2001:db8::1", 22, true, "ipv6 with port"}, + {"[::1]:2200", "::1", 2200, true, "ipv6 loopback"}, + {"[host.example.com]:22", "host.example.com", 22, true, "hostname in brackets"}, + {"[2001:db8::1", "", 0, false, "missing close bracket"}, + {"[2001:db8::1]extra", "", 0, false, "junk after close bracket"}, + {"[2001:db8::1]:0", "", 0, false, "port out of range"}, + {"[2001:db8::1]:abc", "", 0, false, "non-numeric port"}, + } + for _, c := range cases { + t.Run(c.in+"/"+c.comment, func(t *testing.T) { + host, port, ok := parseBracketedHostPort(c.in) + if ok != c.wantOK || host != c.wantHost || port != c.wantPort { + t.Errorf("parseBracketedHostPort(%q) = (%q, %d, %v), want (%q, %d, %v)", + c.in, host, port, ok, c.wantHost, c.wantPort, c.wantOK) + } + }) + } +} + +func TestParseProxyJumpHostPort(t *testing.T) { + cases := []struct { + in string + wantHost string + wantPort int + wantOK bool + comment string + }{ + {"bastion.example.com", "bastion.example.com", 0, true, "host only"}, + {"bastion.example.com:2222", "bastion.example.com", 2222, true, "host + port"}, + {"[2001:db8::1]:22", "2001:db8::1", 22, true, "bracketed ipv6 + port"}, + {"[2001:db8::1]", "2001:db8::1", 0, true, "bracketed ipv6 no port"}, + {"bastion.example.com:abc", "", 0, false, "bad port"}, + {"bastion.example.com:0", "", 0, false, "port out of range"}, + } + for _, c := range cases { + t.Run(c.in+"/"+c.comment, func(t *testing.T) { + host, port, ok := parseProxyJumpHostPort(c.in) + if ok != c.wantOK || host != c.wantHost || port != c.wantPort { + t.Errorf("parseProxyJumpHostPort(%q) = (%q, %d, %v), want (%q, %d, %v)", + c.in, host, port, ok, c.wantHost, c.wantPort, c.wantOK) + } + }) + } +} + +// noInstallScriptAgent is a minimal agentIdentity stub for cases where we +// want to assert behavior with InstallScript() returning empty / Name() +// returning empty. The real agents.Agent interface is large; this satisfies +// only the slice formatMissingAgentBinaryError reads. +type noInstallScriptAgent struct { + id string + name string +} + +func (a *noInstallScriptAgent) ID() string { return a.id } +func (a *noInstallScriptAgent) Name() string { return a.name } +func (a *noInstallScriptAgent) InstallScript() string { return "" } + +func TestFormatMissingAgentBinaryError_WithInstallScript(t *testing.T) { + // MockAgent advertises a deterministic InstallScript so e2e + this test + // can both pin the "install hint" branch without depending on real CLIs. + ag := agents.NewMockAgent() + got := formatMissingAgentBinaryError(ag, "npx") + for _, want := range []string{ + "Mock Agent", // ag.Name() — must surface so users see which agent is missing + `"npx"`, // the binary we probed + "$PATH", // tells them where we looked + "Install hint", + ag.InstallScript(), // the actual command they should run + } { + if !strings.Contains(got, want) { + t.Errorf("formatMissingAgentBinaryError(...): missing %q in %q", want, got) + } + } +} + +func TestFormatMissingAgentBinaryError_NoInstallScriptOmitsHintBlock(t *testing.T) { + ag := &noInstallScriptAgent{name: "PhantomAgent"} + got := formatMissingAgentBinaryError(ag, "phantom") + if strings.Contains(got, "Install hint") { + t.Errorf("expected no Install hint block when InstallScript() is empty, got %q", got) + } + if !strings.Contains(got, "PhantomAgent") || !strings.Contains(got, `"phantom"`) { + t.Errorf("expected agent name + binary in message, got %q", got) + } +} + +func TestFormatMissingAgentBinaryError_FallsBackToIDWhenNameEmpty(t *testing.T) { + ag := &noInstallScriptAgent{id: "fallback-id"} + got := formatMissingAgentBinaryError(ag, "fallback-bin") + if !strings.Contains(got, "fallback-id") { + t.Errorf("expected ID fallback in message when Name() is empty, got %q", got) + } +} + +func TestParseLiteralProxyJump(t *testing.T) { + cases := []struct { + in string + wantUser string + wantHost string + wantPort int + wantOK bool + comment string + }{ + {"jump.example.com", "", "", 0, false, "alias only — no @ or : — defers to alias path"}, + {"alice@jump.example.com", "alice", "jump.example.com", 0, true, "user + host"}, + {"alice@jump.example.com:2222", "alice", "jump.example.com", 2222, true, "user + host + port"}, + {"jump.example.com:2222", "", "jump.example.com", 2222, true, "host + port, no user"}, + {"alice@[2001:db8::1]:22", "alice", "2001:db8::1", 22, true, "user + bracketed ipv6 + port"}, + {"[2001:db8::1]:22", "", "2001:db8::1", 22, true, "bracketed ipv6 + port, no user"}, + {"[2001:db8::1]", "", "2001:db8::1", 0, true, "bracketed ipv6 no port"}, + {"alice@", "", "", 0, false, "empty host after @"}, + {"alice@jump:0", "", "", 0, false, "invalid port"}, + {"", "", "", 0, false, "empty"}, + {" ", "", "", 0, false, "whitespace only"}, + // IPv6 ProxyJump regression guard — the bracketed-host parser must + // strip brackets so callers can feed host straight into net.JoinHostPort + // without producing `[[2001:db8::1]]:22`. + {"deploy@[2001:db8:dead:beef::42]:2200", "deploy", "2001:db8:dead:beef::42", 2200, true, "ipv6 ProxyJump regression"}, + } + for _, c := range cases { + t.Run(c.in+"/"+c.comment, func(t *testing.T) { + user, host, port, ok := parseLiteralProxyJump(c.in) + if ok != c.wantOK || user != c.wantUser || host != c.wantHost || port != c.wantPort { + t.Errorf("parseLiteralProxyJump(%q) = (%q, %q, %d, %v), want (%q, %q, %d, %v)", + c.in, user, host, port, ok, c.wantUser, c.wantHost, c.wantPort, c.wantOK) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go new file mode 100644 index 0000000000..fa23253bb4 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go @@ -0,0 +1,329 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + + "github.com/pkg/sftp" + "go.uber.org/zap" + "golang.org/x/crypto/ssh" + + "github.com/kandev/kandev/internal/agent/remoteauth" + "github.com/kandev/kandev/internal/common/logger" +) + +// uploadCredentials reads the remote_credentials metadata, runs env-credential +// setup scripts whose env vars are present, and uploads the selected file-type +// credentials to the remote host via SFTP. Mirrors SpritesExecutor.uploadCredentials +// — the credential pipeline is the same shape, the seams (file upload and shell +// runner) are different. +// +// All work is best-effort: a failed setup script logs a warning and falls +// through, a missing local credential file is skipped with a warning. The only +// hard failure is a non-resolvable remote $HOME, because every file path is +// rooted there. +func (r *SSHExecutor) uploadCredentials( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, +) error { + catalog := r.buildRemoteAuthCatalog() + r.resolveAuthSecrets(ctx, req, catalog) + r.runAuthSetupScripts(ctx, client, req, catalog) + + credsJSON := getMetadataString(req.Metadata, "remote_credentials") + if credsJSON == "" { + return nil + } + + var selectedMethodIDs []string + if err := json.Unmarshal([]byte(credsJSON), &selectedMethodIDs); err != nil { + return fmt.Errorf("failed to parse remote_credentials: %w", err) + } + + selectedMethodIDs = r.resolveGHToken(selectedMethodIDs, req) + + fileMethods := selectFileMethods(catalog, selectedMethodIDs, r.logger) + if len(fileMethods) == 0 { + return nil + } + + uploader := &sshFileUploader{client: client} + homeDir, err := r.resolveRemoteAuthHomeDir(ctx, client, req) + if err != nil { + return err + } + return UploadCredentialFiles(ctx, uploader, fileMethods, homeDir, r.logger) +} + +// runAuthSetupScripts executes setup scripts for env-type auth methods whose +// env var is present in req.Env. Mirrors the Sprites flavor; the only +// difference is the script runs via SSH instead of sprite.CommandContext. +func (r *SSHExecutor) runAuthSetupScripts( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, + catalog remoteauth.Catalog, +) { + for _, spec := range catalog.Specs { + for _, method := range spec.Methods { + if method.Type != authMethodTypeEnv || method.SetupScript == "" || method.EnvVar == "" { + continue + } + if req.Env[method.EnvVar] == "" { + continue + } + r.runOneAuthSetupScript(ctx, client, req, spec.DisplayName, method) + } + } +} + +// runOneAuthSetupScript executes a single env-credential setup script over +// SSH. The agent's env vars are piped to the remote shell via stdin and +// sourced under `set -a` so they're exported into the script's environment +// without ever appearing on the remote shell's argv — the alternative +// (`KEY=val ... bash -lc '...'`) leaks secrets via `ps aux` / +// `/proc/PID/cmdline` for the brief window the setup script runs. +// Best-effort: failures log a warning and return. +func (r *SSHExecutor) runOneAuthSetupScript( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, + displayName string, + method remoteauth.Method, +) { + shell := sshShellFromMetadata(req.Metadata) + envScript := buildSSHEnvInitScript(req.Env) + // `. /dev/stdin` sources the env lines fed via session.Stdin; `set -a` + // makes those assignments automatically exported so the user's setup + // script sees them in env without a per-key `export`. The script body + // itself runs in the same shell after stdin EOF, which means scripts + // that need their own stdin are unsupported here — none of the + // env-type SetupScripts in the catalog (gh_cli_env etc.) consume + // stdin, so this is fine in practice. + wrapped := WrapLoginShell(shell, "set -a; . /dev/stdin; set +a\n"+method.SetupScript) + out, stderr, err := runSSHCommandStdin(ctx, client, wrapped, strings.NewReader(envScript)) + if err != nil { + r.logger.Warn("auth setup script failed", + zap.String("display_name", displayName), + zap.String("method_id", method.MethodID), + zap.String("stdout", strings.TrimSpace(out)), + zap.String("stderr", strings.TrimSpace(stderr)), + zap.Error(err)) + return + } + r.logger.Debug("auth setup script completed", + zap.String("display_name", displayName), + zap.String("method_id", method.MethodID)) +} + +// resolveGHToken handles the gh_cli_token credential: detects the token on +// the kandev host and injects it as GITHUB_TOKEN in req.Env. Returns the +// filtered method ID list (gh_cli_token removed). Same shape as Sprites. +func (r *SSHExecutor) resolveGHToken(ids []string, req *ExecutorCreateRequest) []string { + if !containsID(ids, "gh_cli_token") { + return ids + } + token, err := DetectGHToken() + if err != nil { + r.logger.Warn("failed to detect gh token", zap.Error(err)) + } else { + if req.Env == nil { + req.Env = make(map[string]string) + } + req.Env["GITHUB_TOKEN"] = token + r.logger.Debug("set GITHUB_TOKEN from local gh auth token") + } + return removeID(ids, "gh_cli_token") +} + +// resolveAuthSecrets reads remote_auth_secrets from metadata and resolves +// secret values into env vars (e.g. gh_cli secret → GITHUB_TOKEN). Skips +// any methods that aren't env-type. +func (r *SSHExecutor) resolveAuthSecrets( + ctx context.Context, + req *ExecutorCreateRequest, + catalog remoteauth.Catalog, +) { + authSecretsJSON := getMetadataString(req.Metadata, "remote_auth_secrets") + if authSecretsJSON == "" { + return + } + var authSecrets map[string]string + if err := json.Unmarshal([]byte(authSecretsJSON), &authSecrets); err != nil { + r.logger.Warn("failed to parse remote_auth_secrets", zap.Error(err)) + return + } + for methodID, secretID := range authSecrets { + if secretID == "" { + continue + } + method, ok := catalog.FindMethod(methodID) + if !ok || method.Type != authMethodTypeEnv || method.EnvVar == "" { + continue + } + value, err := r.secretStore.Reveal(ctx, secretID) + if err != nil { + r.logger.Warn("failed to resolve auth secret", + zap.String("method_id", methodID), + zap.String("secret_id", secretID), + zap.Error(err)) + continue + } + if req.Env == nil { + req.Env = make(map[string]string) + } + req.Env[method.EnvVar] = value + r.logger.Debug("set env from auth secret", zap.String("key", method.EnvVar), zap.String("method_id", methodID)) + } +} + +func (r *SSHExecutor) buildRemoteAuthCatalog() remoteauth.Catalog { + if r.agentList == nil { + return remoteauth.BuildCatalog(nil) + } + return remoteauth.BuildCatalog(r.agentList.ListEnabled()) +} + +// resolveRemoteAuthHomeDir returns the remote $HOME for credential placement. +// Prefers an explicit override on req.Metadata (lets profiles override per +// deployment), otherwise probes the remote with `printf %s "$HOME"`. We +// can't use the static workdir_root because credentials need to live under +// the user's true home (e.g. ~/.claude/credentials.json) — not under the +// task workdir. +func (r *SSHExecutor) resolveRemoteAuthHomeDir( + ctx context.Context, + client *ssh.Client, + req *ExecutorCreateRequest, +) (string, error) { + if override := strings.TrimSpace(getMetadataString(req.Metadata, MetadataKeyRemoteAuthHome)); override != "" { + r.logger.Debug("using remote auth home override", zap.String("home_dir", override)) + return override, nil + } + shell := sshShellFromMetadata(req.Metadata) + out, _, err := runSSHCommand(ctx, client, WrapLoginShell(shell, `printf %s "$HOME"`)) + if err != nil { + return "", fmt.Errorf("ssh: resolve remote $HOME for credentials: %w", err) + } + home := strings.TrimSpace(out) + if home == "" { + return "", fmt.Errorf("ssh: remote $HOME resolved to empty string") + } + r.logger.Debug("resolved remote auth home", zap.String("home_dir", home)) + return home, nil +} + +// selectFileMethods filters a catalog down to file-type methods the user +// actually selected. Unknown method IDs log a warning and are skipped. +func selectFileMethods( + catalog remoteauth.Catalog, + selectedIDs []string, + log *logger.Logger, +) []remoteauth.Method { + out := make([]remoteauth.Method, 0, len(selectedIDs)) + for _, id := range selectedIDs { + method, ok := catalog.FindMethod(id) + if !ok { + log.Warn("unknown remote auth method, skipping", zap.String("method_id", id)) + continue + } + if method.Type != authMethodTypeFiles { + continue + } + out = append(out, method) + } + return out +} + +// buildSSHEnvInitScript returns a multi-line shell snippet of +// `KEY='value'` assignments, one per line. Designed to be piped to a +// remote shell via stdin and sourced under `set -a` — assignments stay +// out of the shell's argv (and therefore out of `ps aux`) but still get +// exported into the script's environment. Empty input returns "". +func buildSSHEnvInitScript(env map[string]string) string { + if len(env) == 0 { + return "" + } + var b strings.Builder + for k, v := range env { + b.WriteString(k) + b.WriteString("=") + b.WriteString(shellQuote(v)) + b.WriteString("\n") + } + return b.String() +} + +// sshFileUploader implements FileUploader by writing files via SFTP. Each +// WriteFile opens a fresh SFTP session to keep failure scopes small — the +// credential upload runs at launch time and only writes a handful of small +// files. +type sshFileUploader struct { + client *ssh.Client +} + +func (u *sshFileUploader) WriteFile(_ context.Context, path string, data []byte, mode os.FileMode) error { + c, err := sftp.NewClient(u.client) + if err != nil { + return fmt.Errorf("sftp: new client: %w", err) + } + defer func() { _ = c.Close() }() + + // Ensure the parent directory exists. SFTP doesn't have a `mkdir -p` + // equivalent, so walk the path and create each segment. + if err := sshMkdirAll(c, parentDir(path)); err != nil { + return fmt.Errorf("sftp: mkdir for %s: %w", path, err) + } + + f, err := c.Create(path) + if err != nil { + return fmt.Errorf("sftp: create %s: %w", path, err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + return fmt.Errorf("sftp: write %s: %w", path, err) + } + if err := f.Close(); err != nil { + return fmt.Errorf("sftp: close %s: %w", path, err) + } + if err := c.Chmod(path, mode); err != nil { + return fmt.Errorf("sftp: chmod %s: %w", path, err) + } + return nil +} + +// sshMkdirAll mimics `mkdir -p` over SFTP. Walks every prefix of dir and +// creates segments that don't exist; treats "already exists" as success. +func sshMkdirAll(c *sftp.Client, dir string) error { + if dir == "" || dir == "/" { + return nil + } + if _, err := c.Stat(dir); err == nil { + return nil + } + if err := sshMkdirAll(c, parentDir(dir)); err != nil { + return err + } + // MkdirAll on sftp.Client doesn't exist; Mkdir errors with "already + // exists" if a sibling already created it. Treat that as success. + if err := c.Mkdir(dir); err != nil { + if _, statErr := c.Stat(dir); statErr == nil { + return nil + } + return err + } + return nil +} + +// parentDir returns the parent directory of path, with no trailing slash. +// Returns "" for paths with no parent (bare names or root). +func parentDir(path string) string { + idx := strings.LastIndex(path, "/") + if idx <= 0 { + return "" + } + return path[:idx] +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go new file mode 100644 index 0000000000..be3f439cf7 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go @@ -0,0 +1,101 @@ +package lifecycle + +import ( + "strings" + "testing" +) + +func TestWrapLoginShell(t *testing.T) { + t.Run("empty shell defaults to bash", func(t *testing.T) { + got := WrapLoginShell("", "echo hi") + if !strings.HasPrefix(got, "bash -lc ") { + t.Errorf("WrapLoginShell with empty shell = %q, want bash -lc prefix", got) + } + }) + + t.Run("custom shell is used verbatim", func(t *testing.T) { + got := WrapLoginShell("zsh", "echo hi") + if !strings.HasPrefix(got, "zsh -lc ") { + t.Errorf("WrapLoginShell with zsh = %q, want zsh -lc prefix", got) + } + }) + + t.Run("inner command is single-quoted", func(t *testing.T) { + got := WrapLoginShell("bash", "echo hi") + if !strings.Contains(got, "'echo hi'") { + t.Errorf("WrapLoginShell did not single-quote inner cmd: %q", got) + } + }) + + t.Run("embedded single quote escaped POSIX-safe", func(t *testing.T) { + // shellQuote's contract is to replace ' with '\'' so a payload + // like `echo "it's"` becomes 'echo "it'\''s"' — preserving the + // single quote literally inside the bash -lc argument. + got := WrapLoginShell("bash", `echo "it's"`) + if !strings.Contains(got, `'echo "it'\''s"'`) { + t.Errorf("WrapLoginShell did not escape single quote correctly: %q", got) + } + }) + + t.Run("multiline scripts survive intact", func(t *testing.T) { + script := "set -e\nmkdir -p /tmp/x\ncat < /tmp/x/f\nhello\nEOF" + got := WrapLoginShell("bash", script) + // Newlines inside single-quoted args are valid POSIX shell input. + if !strings.Contains(got, "set -e\nmkdir -p /tmp/x") { + t.Errorf("WrapLoginShell mangled multiline script: %q", got) + } + }) +} + +func TestParentDir(t *testing.T) { + cases := []struct { + in string + want string + }{ + {"/home/zeval/.claude/credentials.json", "/home/zeval/.claude"}, + {"/etc/hosts", "/etc"}, + {"creds.json", ""}, + {"/foo", ""}, + {"", ""}, + {"/", ""}, + {"/a/b/c/d", "/a/b/c"}, + } + for _, c := range cases { + t.Run(c.in, func(t *testing.T) { + if got := parentDir(c.in); got != c.want { + t.Errorf("parentDir(%q) = %q, want %q", c.in, got, c.want) + } + }) + } +} + +func TestBuildSSHEnvInitScript(t *testing.T) { + t.Run("empty map returns empty string", func(t *testing.T) { + if got := buildSSHEnvInitScript(nil); got != "" { + t.Errorf("buildSSHEnvInitScript(nil) = %q, want \"\"", got) + } + if got := buildSSHEnvInitScript(map[string]string{}); got != "" { + t.Errorf("buildSSHEnvInitScript(empty) = %q, want \"\"", got) + } + }) + + t.Run("single env var is shell-quoted on its own line", func(t *testing.T) { + got := buildSSHEnvInitScript(map[string]string{"FOO": "bar baz"}) + // Each line is a POSIX shell assignment; the line break separates + // entries so `. /dev/stdin` under `set -a` exports each one. + if got != "FOO='bar baz'\n" { + t.Errorf("buildSSHEnvInitScript = %q, want \"FOO='bar baz'\\n\"", got) + } + }) + + t.Run("values with embedded single quotes are escaped", func(t *testing.T) { + got := buildSSHEnvInitScript(map[string]string{"TOKEN": "it's-a-secret"}) + // shellQuote replaces ' with '\'' for POSIX-safe escaping. + if !strings.Contains(got, `TOKEN='it'\''s-a-secret'`) { + t.Errorf("buildSSHEnvInitScript did not escape single quote: %q", got) + } + if !strings.HasSuffix(got, "\n") { + t.Errorf("buildSSHEnvInitScript missing trailing newline: %q", got) + } + }) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go new file mode 100644 index 0000000000..f635b50276 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -0,0 +1,741 @@ +package lifecycle + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + mrand "math/rand/v2" + "net" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/pkg/sftp" + "go.uber.org/zap" + "golang.org/x/crypto/ssh" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/common/logger" +) + +const ( + sshDefaultWorkdir = "~/.kandev" + sshRemoteAgentctlPath = "~/.kandev/bin/agentctl" + sshRemoteAgentctlSha256 = "~/.kandev/bin/agentctl.sha256" + sshAgentctlReadyTimeout = 30 * time.Second + sshAgentctlReadyPoll = 500 * time.Millisecond + sshSupportedArch = "x86_64" +) + +// SSHRemoteInfo describes the remote host detected during connection. +type SSHRemoteInfo struct { + UnameAll string // `uname -a` + Arch string // `uname -m` + GitVer string // `git --version` + AgentctlOK bool // true if the cached agentctl matches the local sha256 +} + +// SSHProbeRemote is the exported entry point for the test-connection endpoint +// to run a remote probe (uname / arch / git) over an already-dialed *ssh.Client. +func SSHProbeRemote(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, error) { + return detectRemoteInfo(ctx, client) +} + +// SSHRequireSupportedArch is the exported arm64-not-yet-supported gate. +// Returns nil for the supported arch (x86_64) and a user-facing error otherwise. +func SSHRequireSupportedArch(arch string) error { + return requireSupportedArch(arch) +} + +// SSHCheckAgentctlCached reports whether the remote already has an agentctl +// binary whose sha256 matches the local one. Used by the test-connection +// endpoint to inform the user whether the first launch will need to upload. +// +// Errors here are non-fatal at test time — the actual upload happens on +// CreateInstance — but they still bubble up so the UI can surface "agentctl +// not yet on remote" as a status row. +func SSHCheckAgentctlCached(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver) (bool, error) { + localSha, _, _, err := localAgentctlSha256(resolver) + if err != nil { + return false, err + } + remoteShaFile, err := expandRemoteHome(ctx, client, sshRemoteAgentctlSha256) + if err != nil { + return false, err + } + // The `|| true` falls back to empty stdout if the sidecar is missing, + // so a successful SSH session with no file is the "not cached" path — + // distinct from a transport-level failure which we still want to bubble + // up so the test endpoint can show a real error instead of "needs upload". + out, _, err := runSSHCommand(ctx, client, "cat "+shellQuote(remoteShaFile)+" 2>/dev/null || true") + if err != nil { + return false, fmt.Errorf("ssh: read remote agentctl sha256: %w", err) + } + return strings.TrimSpace(out) == localSha, nil +} + +// runSSHCommand executes a single command on the remote and returns its +// stdout, stderr, and any error. It is the workhorse for arch detection, +// remote mkdir, git clone, sha256 checks, and the like. +func runSSHCommand(ctx context.Context, client *ssh.Client, cmd string) (stdout, stderr string, err error) { + return runSSHCommandStdin(ctx, client, cmd, nil) +} + +// runSSHCommandStdin is like runSSHCommand but feeds stdin to the remote +// process. Used for the auth-setup path, where secret env vars are written +// to stdin (and sourced by the wrapped shell) instead of inlined into the +// command string — that keeps them out of the remote shell's argv and out +// of `ps aux` / `/proc/PID/cmdline` for the brief window the script runs. +func runSSHCommandStdin(ctx context.Context, client *ssh.Client, cmd string, stdin io.Reader) (stdout, stderr string, err error) { + session, err := client.NewSession() + if err != nil { + return "", "", fmt.Errorf("ssh: new session: %w", err) + } + defer func() { _ = session.Close() }() + + var outBuf, errBuf bytes.Buffer + session.Stdout = &outBuf + session.Stderr = &errBuf + if stdin != nil { + session.Stdin = stdin + } + + done := make(chan error, 1) + go func() { + done <- session.Run(cmd) + }() + select { + case <-ctx.Done(): + _ = session.Signal(ssh.SIGTERM) + return outBuf.String(), errBuf.String(), ctx.Err() + case err := <-done: + return outBuf.String(), errBuf.String(), err + } +} + +// detectRemoteInfo runs a tiny probe to learn about the host. amd64-only gate +// happens at the caller — this function only reports. +func detectRemoteInfo(ctx context.Context, client *ssh.Client) (*SSHRemoteInfo, error) { + info := &SSHRemoteInfo{} + if out, _, err := runSSHCommand(ctx, client, "uname -a"); err == nil { + info.UnameAll = strings.TrimSpace(out) + } + out, _, err := runSSHCommand(ctx, client, "uname -m") + if err != nil { + return nil, fmt.Errorf("ssh: uname -m: %w", err) + } + info.Arch = strings.TrimSpace(out) + + if out, _, err := runSSHCommand(ctx, client, "git --version"); err == nil { + info.GitVer = strings.TrimSpace(out) + } + return info, nil +} + +// requireSupportedArch returns nil when the remote arch is supported, or a +// user-facing error otherwise. v1 = linux/amd64 only (mirrors Docker today). +func requireSupportedArch(arch string) error { + if arch == sshSupportedArch { + return nil + } + return fmt.Errorf( + "unsupported remote architecture %q — SSH executor v1 supports linux/amd64 only (arm64 lands when agentctl-linux-arm64 is added to the build)", + arch, + ) +} + +// expandRemoteHome rewrites a leading ~/ to the home directory reported by the +// remote (`echo $HOME`). The result is an absolute path. Called once per +// connection and cached by the caller. +func expandRemoteHome(ctx context.Context, client *ssh.Client, path string) (string, error) { + if !strings.HasPrefix(path, "~/") && path != "~" { + return path, nil + } + out, _, err := runSSHCommand(ctx, client, "printf %s \"$HOME\"") + if err != nil { + return "", fmt.Errorf("ssh: resolve $HOME: %w", err) + } + home := strings.TrimSpace(out) + if home == "" { + return "", errors.New("ssh: remote $HOME is empty") + } + if path == "~" { + return home, nil + } + return home + "/" + strings.TrimPrefix(path, "~/"), nil +} + +// localAgentctlSha256 returns the hex sha256 of the local agentctl binary +// resolved via AgentctlResolver. Used to decide whether to re-upload. +func localAgentctlSha256(resolver *AgentctlResolver) (string, []byte, string, error) { + path, err := resolver.ResolveLinuxBinary() + if err != nil { + return "", nil, "", err + } + data, err := os.ReadFile(path) + if err != nil { + return "", nil, "", fmt.Errorf("read agentctl: %w", err) + } + h := sha256.Sum256(data) + return hex.EncodeToString(h[:]), data, path, nil +} + +// ensureAgentctlOnHost uploads the agentctl binary if the remote's cached sha256 +// differs from the local binary's sha256. Returns the absolute remote path. +func ensureAgentctlOnHost(ctx context.Context, client *ssh.Client, resolver *AgentctlResolver, log *logger.Logger) (string, error) { + localSha, localData, localPath, err := localAgentctlSha256(resolver) + if err != nil { + return "", err + } + + remoteBin, err := expandRemoteHome(ctx, client, sshRemoteAgentctlPath) + if err != nil { + return "", err + } + remoteShaFile, err := expandRemoteHome(ctx, client, sshRemoteAgentctlSha256) + if err != nil { + return "", err + } + + // Compare existing remote sha256, if any. Every path that lands in a + // shell-interpreted command goes through shellQuote so a remote $HOME + // (or anything else with metacharacters) can't break the parse — even + // though the path was supplied by the remote, not the kandev user. + if out, _, err := runSSHCommand(ctx, client, "cat "+shellQuote(remoteShaFile)+" 2>/dev/null"); err == nil { + if strings.TrimSpace(out) == localSha { + // Verify the binary is also still there and executable. + if _, _, terr := runSSHCommand(ctx, client, "test -x "+shellQuote(remoteBin)); terr == nil { + log.Debug("agentctl already up-to-date on remote", zap.String("sha256", localSha)) + return remoteBin, nil + } + } + } + + log.Info("uploading agentctl to remote", + zap.String("local_path", localPath), + zap.String("remote_path", remoteBin), + zap.String("sha256", localSha), + zap.Int("bytes", len(localData))) + + if _, _, err := runSSHCommand(ctx, client, "mkdir -p "+shellQuote(filepath.Dir(remoteBin))); err != nil { + return "", fmt.Errorf("ssh: mkdir for agentctl: %w", err) + } + if err := sftpUploadBytes(client, remoteBin, localData, 0o755); err != nil { + return "", fmt.Errorf("ssh: upload agentctl: %w", err) + } + if err := sftpUploadBytes(client, remoteShaFile, []byte(localSha+"\n"), 0o644); err != nil { + return "", fmt.Errorf("ssh: upload agentctl sha256: %w", err) + } + // Sanity check. + if _, _, err := runSSHCommand(ctx, client, "test -x "+shellQuote(remoteBin)); err != nil { + return "", fmt.Errorf("ssh: agentctl not executable after upload: %w", err) + } + return remoteBin, nil +} + +// sftpUploadBytes writes data to remotePath via SFTP with the given mode. +// Intermediate directories must already exist. +// +// The temp filename includes a random suffix so two concurrent uploaders +// targeting the same remotePath don't collide: with a shared `.tmp` name +// the second rename would error with "file does not exist" once the first +// uploader's rename consumed the temp file. Each uploader writes to its +// own temp, then races on the rename — last-writer-wins is safe because +// callers always upload identical content (sha256-keyed binary) or +// content that doesn't matter if it loses the race (the sha256 sidecar). +func sftpUploadBytes(client *ssh.Client, remotePath string, data []byte, mode os.FileMode) error { + c, err := sftp.NewClient(client) + if err != nil { + return fmt.Errorf("sftp: new client: %w", err) + } + defer func() { _ = c.Close() }() + + tmp := fmt.Sprintf("%s.tmp.%d.%d", remotePath, os.Getpid(), mrand.Uint64()) + f, err := c.Create(tmp) + if err != nil { + return fmt.Errorf("sftp: create %s: %w", tmp, err) + } + if _, err := f.Write(data); err != nil { + _ = f.Close() + _ = c.Remove(tmp) + return fmt.Errorf("sftp: write %s: %w", tmp, err) + } + if err := f.Close(); err != nil { + _ = c.Remove(tmp) + return fmt.Errorf("sftp: close %s: %w", tmp, err) + } + if err := c.Chmod(tmp, mode); err != nil { + _ = c.Remove(tmp) + return fmt.Errorf("sftp: chmod %s: %w", tmp, err) + } + if err := c.PosixRename(tmp, remotePath); err != nil { + // Some servers don't support POSIX rename; fall back to a non-atomic rename. + if rerr := c.Rename(tmp, remotePath); rerr != nil { + _ = c.Remove(tmp) + return fmt.Errorf("sftp: rename %s -> %s: %w", tmp, remotePath, rerr) + } + } + return nil +} + +// shellQuote is a minimal POSIX shell-safe single-quote wrapper. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +// defaultLoginShell is the shell used when the profile didn't pick one +// explicitly. bash is on virtually every Linux distro (including the +// e2e Alpine image, which symlinks /bin/sh → bash via the bash package) +// and is what nvm/asdf/brew assume — so it's the right default for the +// "agent isn't on PATH" diagnosis. +const defaultLoginShell = "bash" + +// WrapLoginShell wraps cmd in `${shell} -lc ''` so commands run under +// a login shell that has sourced the user's profile (~/.profile, +// ~/.bash_profile, etc.). This is the canonical fix for "I have nvm +// installed but kandev can't find npx" — sshd's default exec channel +// runs a non-interactive non-login shell which doesn't pick up shell-init +// PATH additions. +// +// Empty shell falls back to defaultLoginShell. The inner cmd is +// single-quote-escaped so embedded quotes don't break the wrapper. +func WrapLoginShell(shell, cmd string) string { + if shell == "" { + shell = defaultLoginShell + } + return shell + " -lc " + shellQuote(cmd) +} + +// ProbeRemoteBinary runs `command -v ` over the existing SSH client +// and reports whether the binary resolves on the remote's $PATH. Returns +// the resolved absolute path on success (the `command -v` stdout), or an +// empty string when missing. err is non-nil only when the SSH call itself +// fails — a missing binary is not an error. +// +// shell is the login shell to run the probe under (e.g. "bash", "zsh"); +// empty defaults to bash. Running through a login shell is what makes the +// probe pick up nvm/asdf/brew PATH setup — without it, every node-based +// agent would show as "missing" on dev machines. +// +// Exported for the SSH agent-readiness probe in package ssh; callers +// outside lifecycle would otherwise have to copy the shellQuote + run +// dance and risk drifting from the launch-time pre-flight semantics. +func ProbeRemoteBinary(ctx context.Context, client *ssh.Client, shell, binary string) (string, error) { + probe := "command -v " + shellQuote(binary) + " 2>/dev/null || true" + out, _, err := runSSHCommand(ctx, client, WrapLoginShell(shell, probe)) + if err != nil { + return "", err + } + return strings.TrimSpace(out), nil +} + +// ensureRemoteTaskDir creates / if missing and +// returns the absolute remote path. Repo clones happen via the prepare-script +// path (scriptengine), not here; this is just the parent dir. +func ensureRemoteTaskDir(ctx context.Context, client *ssh.Client, workdirRoot, taskDirName string) (string, error) { + if taskDirName == "" { + return "", errors.New("ssh: task dir name is empty") + } + root, err := expandRemoteHome(ctx, client, workdirRoot) + if err != nil { + return "", err + } + taskDir := root + "/tasks/" + taskDirName + if _, _, err := runSSHCommand(ctx, client, "mkdir -p "+shellQuote(taskDir)); err != nil { + return "", fmt.Errorf("ssh: mkdir task dir %s: %w", taskDir, err) + } + return taskDir, nil +} + +// ensureRemoteSessionDir creates /.kandev/sessions// and +// returns the absolute remote path. Per-session runtime data (PID file, logs, +// agentctl socket) lives here. +func ensureRemoteSessionDir(ctx context.Context, client *ssh.Client, taskDir, sessionID string) (string, error) { + if sessionID == "" { + return "", errors.New("ssh: session ID is empty") + } + sessionDir := taskDir + "/.kandev/sessions/" + sessionID + if _, _, err := runSSHCommand(ctx, client, "mkdir -p "+shellQuote(sessionDir)); err != nil { + return "", fmt.Errorf("ssh: mkdir session dir: %w", err) + } + return sessionDir, nil +} + +// startRemoteAgentctl launches an agentctl process on the remote with a +// kandev-chosen port and waits for the agentctl log to confirm a successful +// bind. Returns the chosen port and the process PID. +// +// On-remote layout written by the launch wrapper: +// +// /agentctl.pid — written by the wrapper script ($!) +// /agentctl.log — agentctl's own stdout+stderr +// +// agentctl honors AGENTCTL_PORT from its environment (default 39429). We pick +// a per-session port from a wide ephemeral range; collisions on the remote are +// vanishingly unlikely and would surface as a clear bind failure that the +// caller can retry. +func startRemoteAgentctl( + ctx context.Context, + client *ssh.Client, + shell, agentctlBin, workspacePath, sessionDir string, + log *logger.Logger, +) (port int, pid int, err error) { + port = pickRemoteAgentctlPort() + + // Wrap the agentctl exec in a login shell so the spawned process + // inherits the user's $PATH (nvm/asdf/brew etc.). Without this, even + // if `npx` is installed via nvm, agentctl's child processes won't + // find it because the SSH-exec channel runs a non-interactive non- + // login shell and `nohup` inherits whatever that shell's PATH was. + innerScript := fmt.Sprintf( + `set -e +mkdir -p %[1]s +: > %[1]s/agentctl.log +AGENTCTL_PORT=%[4]d nohup %[2]s --workdir %[3]s \ + >> %[1]s/agentctl.log 2>&1 < /dev/null & +AGENTCTL_PID=$! +disown "$AGENTCTL_PID" 2>/dev/null || true +echo "$AGENTCTL_PID" > %[1]s/agentctl.pid +echo "$AGENTCTL_PID" +`, + shellQuote(sessionDir), + shellQuote(agentctlBin), + shellQuote(workspacePath), + port, + ) + out, stderr, err := runSSHCommand(ctx, client, WrapLoginShell(shell, innerScript)) + if err != nil { + return 0, 0, fmt.Errorf("ssh: launch agentctl: %w (stderr: %s)", err, strings.TrimSpace(stderr)) + } + pid, err = strconv.Atoi(strings.TrimSpace(out)) + if err != nil { + return 0, 0, fmt.Errorf("ssh: agentctl wrapper returned non-numeric pid %q", out) + } + + // Poll the on-disk log for the "bound successfully" line; until then the + // process is starting up and a port-forward connect would race the bind. + deadline := time.Now().Add(sshAgentctlReadyTimeout) + for time.Now().Before(deadline) { + logOut, _, _ := runSSHCommand(ctx, client, + "cat "+shellQuote(sessionDir+"/agentctl.log")+" 2>/dev/null") + if strings.Contains(logOut, "HTTP server bound successfully") { + log.Info("agentctl started on remote", + zap.Int("port", port), + zap.Int("pid", pid), + zap.String("session_dir", sessionDir)) + return port, pid, nil + } + if strings.Contains(logOut, "HTTP server failed to bind") { + return 0, 0, fmt.Errorf( + "ssh: agentctl failed to bind port %d on remote; log:\n%s", port, + lastLines(logOut, sshAgentctlLogTailLines)) + } + // Also catch "exited without binding" via pid check — if the wrapper + // exited before logging, kill -0 fails and we fail fast. + if !isRemoteAgentctlAlive(ctx, client, pid) { + return 0, 0, fmt.Errorf( + "ssh: agentctl exited before becoming ready; log tail:\n%s", + lastLines(logOut, sshAgentctlLogTailLines)) + } + time.Sleep(sshAgentctlReadyPoll) + } + tail, _, _ := runSSHCommand(ctx, client, + "tail -n 50 "+shellQuote(sessionDir+"/agentctl.log")+" 2>/dev/null") + return 0, 0, fmt.Errorf("ssh: agentctl did not become ready within %v; log tail:\n%s", + sshAgentctlReadyTimeout, tail) +} + +const sshAgentctlLogTailLines = 25 + +// createRemoteAgentInstance creates a per-session agent instance on the +// remote agentctl control server by POSTing to /api/v1/instances over a +// direct-tcpip channel through the existing SSH client — no second port +// forward, and no dependency on remote curl. Returns the per-instance port +// the SSH executor should later forward + dial for ACP / workspace traffic. +// Mirrors what executor_sprites.go does inside its sprite. +func createRemoteAgentInstance( + ctx context.Context, + client *ssh.Client, + controlPort int, + workspacePath string, + req *ExecutorCreateRequest, + log *logger.Logger, +) (int, error) { + body, err := json.Marshal(agentctl.CreateInstanceRequest{ + ID: req.InstanceID, + WorkspacePath: workspacePath, + SessionID: req.SessionID, + TaskID: req.TaskID, + Protocol: req.Protocol, + AgentType: sshAgentTypeFromReq(req), + McpServers: req.McpServers, + McpMode: req.McpMode, + }) + if err != nil { + return 0, fmt.Errorf("ssh: marshal create-instance: %w", err) + } + + // HTTP-over-direct-tcpip: every request dials a fresh SSH channel to the + // remote control port. Keep-alives are disabled so the channel closes + // after the response. + httpClient := &http.Client{ + Timeout: 30 * time.Second, + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return client.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(controlPort))) + }, + DisableKeepAlives: true, + }, + } + + url := fmt.Sprintf("http://127.0.0.1:%d/api/v1/instances", controlPort) + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return 0, fmt.Errorf("ssh: build create-instance request: %w", err) + } + httpReq.Header.Set("Content-Type", "application/json") + + httpResp, err := httpClient.Do(httpReq) + if err != nil { + return 0, fmt.Errorf("ssh: create-instance dial: %w", err) + } + defer func() { _ = httpResp.Body.Close() }() + + respBody, err := io.ReadAll(httpResp.Body) + if err != nil { + return 0, fmt.Errorf("ssh: read create-instance response: %w", err) + } + if httpResp.StatusCode >= http.StatusBadRequest { + return 0, fmt.Errorf("ssh: create-instance returned %d: %s", httpResp.StatusCode, strings.TrimSpace(string(respBody))) + } + + var resp agentctl.CreateInstanceResponse + if err := json.Unmarshal(respBody, &resp); err != nil { + return 0, fmt.Errorf("ssh: parse create-instance response: %w (body: %s)", err, string(respBody)) + } + if resp.Port == 0 { + return 0, fmt.Errorf("ssh: create-instance returned port 0 (body: %s)", string(respBody)) + } + log.Info("created remote agent instance", + zap.Int("control_port", controlPort), + zap.Int("instance_port", resp.Port), + zap.String("instance_id", resp.ID)) + return resp.Port, nil +} + +// sshAgentTypeFromReq returns the agent type ID for the create-instance call, +// or empty when the request didn't carry an agent config. +func sshAgentTypeFromReq(req *ExecutorCreateRequest) string { + if req == nil || req.AgentConfig == nil { + return "" + } + return req.AgentConfig.ID() +} + +// pickRemoteAgentctlPort returns a port in [40000, 60000). The kandev backend +// picks per session; agentctl honors AGENTCTL_PORT. Uses math/rand/v2 so two +// concurrent CreateInstance calls don't collide (UnixNano%20000 cycles in +// ~20µs, which is well within the window between back-to-back launches on a +// fast machine). A residual collision still surfaces as a clear bind failure +// and the caller can retry. +func pickRemoteAgentctlPort() int { + return 40000 + mrand.IntN(20000) +} + +// stopRemoteAgentctl best-effort kills a remote agentctl by PID and removes +// the session runtime dir. +func stopRemoteAgentctl(ctx context.Context, client *ssh.Client, sessionDir string, pid int) error { + if pid > 0 { + if _, _, err := runSSHCommand(ctx, client, fmt.Sprintf("kill %d 2>/dev/null || true", pid)); err != nil { + return err + } + } + // Leave the task dir intact (mirrors spec); only wipe session runtime. + _, _, _ = runSSHCommand(ctx, client, "rm -rf "+shellQuote(sessionDir)) + return nil +} + +// isRemoteAgentctlAlive returns true when a kill -0 on the pid succeeds. +func isRemoteAgentctlAlive(ctx context.Context, client *ssh.Client, pid int) bool { + if pid <= 0 { + return false + } + _, _, err := runSSHCommand(ctx, client, fmt.Sprintf("kill -0 %d", pid)) + return err == nil +} + +// SSHPortForwarder fans out incoming local-port connections to a remote port +// over the shared SSH connection using direct-tcpip channels. Each Forwarder +// owns its local listener; closing the Forwarder closes the listener and any +// outstanding channels. +type SSHPortForwarder struct { + listener net.Listener + localPort int + remotePort int + logger *logger.Logger + closed chan struct{} + // dialMu serializes client.Dial calls. golang.org/x/crypto/ssh's + // Client.Dial is documented as safe to call concurrently, but in practice + // the kandev stream-manager opens its workspace + agent streams in + // parallel — and the second Dial occasionally returns io.EOF as if the + // channel-open response never came back. Serializing the opens makes + // the long-lived WS forward reliable; the throughput cost is negligible + // because channel-open completes in ~1ms. + dialMu sync.Mutex +} + +// StartPortForward opens a fresh 127.0.0.1: listener and tunnels each +// accept to the given remote port over client. Caller MUST call Close when the +// session ends, otherwise both the listener and the SSH channels leak. +func StartPortForward(client *ssh.Client, remotePort int, log *logger.Logger) (*SSHPortForwarder, error) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("ssh: local listen: %w", err) + } + addr := listener.Addr().(*net.TCPAddr) + fwd := &SSHPortForwarder{ + listener: listener, + localPort: addr.Port, + remotePort: remotePort, + logger: log, + closed: make(chan struct{}), + } + go fwd.serve(client) + return fwd, nil +} + +func (f *SSHPortForwarder) serve(client *ssh.Client) { + for { + local, err := f.listener.Accept() + if err != nil { + select { + case <-f.closed: + return + default: + } + // Distinguish a permanently-broken listener (closed FD, + // underlying socket dead) from transient per-accept errors + // like EMFILE / ECONNABORTED that would orphan the + // forwarder if we returned. net.ErrClosed is the only + // error the listener emits after Close, and we already + // matched <-f.closed for the orderly-close path, so + // anything else here is a genuine "accept failed once" + // that we want to log and try again. + if errors.Is(err, net.ErrClosed) { + f.logger.Debug("ssh forwarder accept on closed listener", zap.Error(err)) + return + } + f.logger.Warn("ssh forwarder accept failed; continuing", + zap.Int("remote_port", f.remotePort), + zap.Error(err)) + continue + } + go f.handleLocal(client, local) + } +} + +func (f *SSHPortForwarder) handleLocal(client *ssh.Client, local net.Conn) { + f.dialMu.Lock() + remote, err := client.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(f.remotePort))) + f.dialMu.Unlock() + if err != nil { + _ = local.Close() + f.logger.Warn("ssh forwarder dial remote failed", + zap.Int("remote_port", f.remotePort), + zap.String("local_addr", local.LocalAddr().String()), + zap.String("remote_addr", local.RemoteAddr().String()), + zap.String("error_type", fmt.Sprintf("%T", err)), + zap.Error(err)) + return + } + + // Bidirectional copy. Each io.Copy reads until its source EOFs (kandev + // or agentctl sends FIN at the application layer) or errors. We use + // CloseWrite to propagate the half-close cleanly: when local->remote + // finishes, we tell agentctl "no more data from us" via the SSH channel's + // EOF without slamming the whole channel shut — that lets agentctl's WS + // writer finish flushing its pending frames before naturally tearing + // down its side. Symmetric for the other direction. The final full Close + // happens via the deferred handler when both goroutines have returned. + type halfCloser interface{ CloseWrite() error } + closeWriteHalf := func(c net.Conn) { + if hc, ok := c.(halfCloser); ok { + _ = hc.CloseWrite() + } else { + _ = c.Close() + } + } + errc := make(chan error, 2) + go func() { + _, err := io.Copy(remote, local) + closeWriteHalf(remote) + errc <- err + }() + go func() { + _, err := io.Copy(local, remote) + closeWriteHalf(local) + errc <- err + }() + <-errc + <-errc + _ = remote.Close() + _ = local.Close() +} + +// LocalPort returns the local TCP port the forwarder is listening on. +func (f *SSHPortForwarder) LocalPort() int { return f.localPort } + +// Close terminates the forwarder. Idempotent. +func (f *SSHPortForwarder) Close() error { + select { + case <-f.closed: + return nil + default: + close(f.closed) + } + return f.listener.Close() +} + +// waitAgentctlHealthy polls http://127.0.0.1:/health for up to +// timeout. Used to confirm the forwarded tunnel is wired up after start / +// recovery. An open TCP socket isn't enough — the local port is owned by the +// SSH forwarder, which accepts then dials direct-tcpip to the remote; a TCP +// connect can succeed before the forwarder actually establishes the channel. +// Probe with a real HTTP request and require a 2xx response so a broken +// channel surfaces here instead of at the first agent operation. +func waitAgentctlHealthy(ctx context.Context, localPort int, timeout time.Duration) error { + deadline := time.Now().Add(timeout) + url := fmt.Sprintf("http://127.0.0.1:%d/health", localPort) + httpClient := &http.Client{Timeout: 5 * time.Second} + defer httpClient.CloseIdleConnections() + + for time.Now().Before(deadline) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return fmt.Errorf("agentctl health probe: build request: %w", err) + } + resp, err := httpClient.Do(req) + if err == nil { + _, _ = io.Copy(io.Discard, resp.Body) + _ = resp.Body.Close() + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(200 * time.Millisecond): + } + } + return fmt.Errorf("agentctl on local port %d not reachable", localPort) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 1db3c0106e..cee43c8928 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -73,12 +73,48 @@ func applyRouteOverrideToProfile(profile *AgentProfileInfo, req *LaunchRequest) profile.Mode = ov.Mode } +// trustedExecutorConfigKeys are the metadata keys whose value MUST come from +// the configured executor record — never from request-supplied metadata — +// because they steer the connection (host, fingerprint, identity). Letting a +// task override them would allow pivoting an SSH launch to a different host +// or bypassing the pinned host-key. +var trustedExecutorConfigKeys = map[string]bool{ + MetadataKeySSHHost: true, + MetadataKeySSHHostAlias: true, + MetadataKeySSHPort: true, + MetadataKeySSHUser: true, + MetadataKeySSHHostFingerprint: true, + MetadataKeySSHIdentitySource: true, + MetadataKeySSHIdentityFile: true, + MetadataKeySSHProxyJump: true, +} + +func isTrustedExecutorConfigKey(k string) bool { return trustedExecutorConfigKeys[k] } + // buildLaunchMetadata builds runtime metadata for the Launch request. +// +// Per-executor config (host, fingerprint, ssh identity, …) is the trusted +// source for connection-routing decisions, so it overrides any same-key +// values the caller passed in req.Metadata. Other keys (per-task settings +// like setup_script, base_branch, repo_setup_script, etc.) keep the caller's +// value when present. func buildLaunchMetadata(req *LaunchRequest, mainRepoGitDir, worktreeID, worktreeBranch string) map[string]interface{} { metadata := make(map[string]interface{}) for k, v := range req.Metadata { metadata[k] = v } + for k, v := range req.ExecutorConfig { + if isTrustedExecutorConfigKey(k) { + // Executor config wins for connection-routing keys so a malicious + // or buggy task metadata payload can't swap out the SSH host / + // pinned fingerprint and pivot the launch to a different target. + metadata[k] = v + continue + } + if _, exists := metadata[k]; !exists { + metadata[k] = v + } + } if mainRepoGitDir != "" { metadata[MetadataKeyMainRepoGitDir] = mainRepoGitDir } diff --git a/apps/backend/internal/agentruntime/runtime.go b/apps/backend/internal/agentruntime/runtime.go index 7f3cafd88e..aa6fc1aef7 100644 --- a/apps/backend/internal/agentruntime/runtime.go +++ b/apps/backend/internal/agentruntime/runtime.go @@ -16,6 +16,7 @@ const ( RuntimeDocker Runtime = "docker" RuntimeRemoteDocker Runtime = "remote_docker" RuntimeSprites Runtime = "sprites" + RuntimeSSH Runtime = "ssh" ) // IsContainerized reports whether the runtime hosts the agent diff --git a/apps/backend/internal/orchestrator/executor/executor_credentials.go b/apps/backend/internal/orchestrator/executor/executor_credentials.go index ac0f26e068..c26e354970 100644 --- a/apps/backend/internal/orchestrator/executor/executor_credentials.go +++ b/apps/backend/internal/orchestrator/executor/executor_credentials.go @@ -111,7 +111,7 @@ func (e *Executor) resolveRemoteCredentials(ctx context.Context, req *LaunchAgen // resolveAuthSecrets reads remote_auth_secrets from metadata and resolves secret values // into environment variables (e.g., gh_cli_env -> GITHUB_TOKEN). func (e *Executor) resolveAuthSecrets(ctx context.Context, req *LaunchAgentRequest, metadata map[string]interface{}) { - authSecretsJSON, _ := metadata["remote_auth_secrets"].(string) + authSecretsJSON, _ := metadata[profileKeyRemoteAuthSecrets].(string) if authSecretsJSON == "" { return } @@ -181,7 +181,7 @@ func (e *Executor) resolveGHCLIToken(req *LaunchAgentRequest, metadata map[strin return } - credsJSON, _ := metadata["remote_credentials"].(string) + credsJSON, _ := metadata[profileKeyRemoteCredentials].(string) if credsJSON == "" { return } diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go index 79bf3cb752..99dfcd9205 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_reuse.go @@ -133,9 +133,21 @@ func applyExecutorRunningMetadata(req *LaunchAgentRequest, running *models.Execu if metadata == nil { metadata = ensureLaunchMetadata(req) } - if _, exists := metadata[k]; !exists && lifecycle.ShouldPersistMetadataKey(k) { - metadata[k] = v + if _, exists := metadata[k]; exists { + continue } + if !lifecycle.ShouldPersistMetadataKey(k) { + continue + } + // Skip session-scoped runtime resources (PIDs, ports, session dirs). + // Carrying these across sibling sessions on the same task makes a + // fresh launch look like a same-session resume — see SSH executor's + // ResumeRemoteInstance — and the new session ends up sharing the + // previous session's agentctl process. + if lifecycle.IsSessionScopedMetadataKey(k) { + continue + } + metadata[k] = v } } diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go index 5887ec57cd..af1348285b 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -347,6 +347,113 @@ func TestReuseExistingEnvironment_EmptyEnvFieldsDoNothing(t *testing.T) { } } +// TestApplyExecutorRunningMetadata_SkipsSessionScopedKeys pins the guard +// that prevents a SECOND session on the same task from inheriting the FIRST +// session's session-scoped runtime resources — agentctl PID/port, remote +// session dir, local forward port. Without this filter, the SSH executor's +// ResumeRemoteInstance would interpret those keys as a resume hint and +// reattach to session-1's agentctl process, so session 2 would end up +// sharing session 1's ACP session and instance port and never finish its +// own initialize. +// +// Connection / task-environment-wide keys (host, port, user, fingerprint, +// remote task dir, workdir root, proxy jump) MUST still propagate so the +// second session connects to the same host and reuses the task dir. +func TestApplyExecutorRunningMetadata_SkipsSessionScopedKeys(t *testing.T) { + req := &LaunchAgentRequest{TaskID: "task-1"} + running := &models.ExecutorRunning{ + AgentExecutionID: "exec-prev", + Metadata: map[string]interface{}{ + // Connection config — should propagate. + lifecycle.MetadataKeySSHHost: "example.com", + lifecycle.MetadataKeySSHPort: "2200", + lifecycle.MetadataKeySSHUser: "deploy", + lifecycle.MetadataKeySSHHostFingerprint: "SHA256:aaa", + lifecycle.MetadataKeySSHRemoteTaskDir: "/home/deploy/.kandev/tasks/task-1", + lifecycle.MetadataKeySSHWorkdirRoot: "/home/deploy/.kandev", + lifecycle.MetadataKeySSHProxyJump: "bastion", + // Session-scoped runtime resources — must NOT propagate. + lifecycle.MetadataKeySSHRemoteSessionDir: "/home/deploy/.kandev/tasks/task-1/.kandev/sessions/sess-1", + lifecycle.MetadataKeySSHRemoteAgentctlPort: "41001", + lifecycle.MetadataKeySSHRemoteAgentctlPID: "12345", + lifecycle.MetadataKeySSHLocalForwardPort: "59123", + lifecycle.MetadataKeySSHRemoteAgentctlURL: "http://127.0.0.1:59123", + // Non-persistent key — must NOT propagate (not in persistentMetadataKeys). + "task_description": "session 1 prompt", + }, + } + + applyExecutorRunningMetadata(req, running) + + if req.PreviousExecutionID != "exec-prev" { + t.Errorf("PreviousExecutionID = %q, want exec-prev", req.PreviousExecutionID) + } + if req.Metadata == nil { + t.Fatal("req.Metadata is nil; expected propagated keys") + } + + propagated := []string{ + lifecycle.MetadataKeySSHHost, + lifecycle.MetadataKeySSHPort, + lifecycle.MetadataKeySSHUser, + lifecycle.MetadataKeySSHHostFingerprint, + lifecycle.MetadataKeySSHRemoteTaskDir, + lifecycle.MetadataKeySSHWorkdirRoot, + lifecycle.MetadataKeySSHProxyJump, + } + for _, k := range propagated { + if _, ok := req.Metadata[k]; !ok { + t.Errorf("expected connection key %q to propagate", k) + } + } + + sessionScoped := []string{ + lifecycle.MetadataKeySSHRemoteSessionDir, + lifecycle.MetadataKeySSHRemoteAgentctlPort, + lifecycle.MetadataKeySSHRemoteAgentctlPID, + lifecycle.MetadataKeySSHLocalForwardPort, + lifecycle.MetadataKeySSHRemoteAgentctlURL, + } + for _, k := range sessionScoped { + if v, ok := req.Metadata[k]; ok { + t.Errorf("session-scoped key %q leaked into sibling-session request (value=%v)", k, v) + } + if !lifecycle.IsSessionScopedMetadataKey(k) { + t.Errorf("IsSessionScopedMetadataKey(%q) = false; expected true", k) + } + } + + // task_description is not in persistentMetadataKeys at all, so the + // pre-existing ShouldPersistMetadataKey gate already drops it. + if _, ok := req.Metadata["task_description"]; ok { + t.Error("task_description (non-persistent) leaked into sibling-session request") + } +} + +// TestApplyExecutorRunningMetadata_RequestKeysWin documents that an explicit +// value already on the request (e.g. set by the caller from launch options) +// is not overwritten by the previous ExecutorRunning record. This applies to +// every persistent key, not just the connection ones. +func TestApplyExecutorRunningMetadata_RequestKeysWin(t *testing.T) { + req := &LaunchAgentRequest{ + TaskID: "task-1", + Metadata: map[string]interface{}{ + lifecycle.MetadataKeySSHHost: "user-override.example.com", + }, + } + running := &models.ExecutorRunning{ + Metadata: map[string]interface{}{ + lifecycle.MetadataKeySSHHost: "stale.example.com", + }, + } + + applyExecutorRunningMetadata(req, running) + + if got := req.Metadata[lifecycle.MetadataKeySSHHost]; got != "user-override.example.com" { + t.Errorf("ssh_host = %q, want user-override.example.com (request value should win)", got) + } +} + func TestExtractSandboxID(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index eb19984780..b22546a794 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -219,6 +219,7 @@ type mockRepository struct { taskRepositories map[string]*models.TaskRepository repositories map[string]*models.Repository executors map[string]*models.Executor + executorProfiles map[string]*models.ExecutorProfile executorsRunning map[string]*models.ExecutorRunning taskEnvironments map[string]*models.TaskEnvironment taskEnvironmentRepos map[string][]*models.TaskEnvironmentRepo // env_id → rows @@ -243,6 +244,7 @@ func newMockRepository() *mockRepository { taskRepositories: make(map[string]*models.TaskRepository), repositories: make(map[string]*models.Repository), executors: make(map[string]*models.Executor), + executorProfiles: make(map[string]*models.ExecutorProfile), executorsRunning: make(map[string]*models.ExecutorRunning), taskEnvironments: make(map[string]*models.TaskEnvironment), taskEnvironmentRepos: make(map[string][]*models.TaskEnvironmentRepo), @@ -783,6 +785,9 @@ func (m *mockRepository) CreateExecutorProfile(ctx context.Context, profile *mod return nil } func (m *mockRepository) GetExecutorProfile(ctx context.Context, id string) (*models.ExecutorProfile, error) { + if p, ok := m.executorProfiles[id]; ok { + return p, nil + } return nil, nil } func (m *mockRepository) UpdateExecutorProfile(ctx context.Context, profile *models.ExecutorProfile) error { diff --git a/apps/backend/internal/orchestrator/executor/executor_state.go b/apps/backend/internal/orchestrator/executor/executor_state.go index f419a421a3..9ae623b350 100644 --- a/apps/backend/internal/orchestrator/executor/executor_state.go +++ b/apps/backend/internal/orchestrator/executor/executor_state.go @@ -214,26 +214,68 @@ func (e *Executor) applyProfile(ctx context.Context, profileID string, cfg *exec if policyJSON := strings.TrimSpace(profile.McpPolicy); policyJSON != "" { metadata["executor_mcp_policy"] = policyJSON } - if rulesJSON := profile.Config["sprites_network_policy_rules"]; rulesJSON != "" { - metadata["sprites_network_policy_rules"] = rulesJSON + applyProfileConfigToMetadata(profile.Config, metadata) +} + +// Profile.Config / metadata keys shared with executor_credentials.go. +// Hoisted to constants so the table below doesn't duplicate string +// literals that exist elsewhere in the package. +const ( + profileKeyRemoteCredentials = "remote_credentials" + profileKeyRemoteAuthSecrets = "remote_auth_secrets" +) + +// profileConfigPassthroughKeys are profile.Config keys copied verbatim +// into launch metadata under the same key when non-empty. An empty +// profile value leaves any task-supplied value in place. +var profileConfigPassthroughKeys = []string{ + "sprites_network_policy_rules", + profileKeyRemoteCredentials, + profileKeyRemoteAuthSecrets, + "remote_auth_target_home", + "git_user_name", + "git_user_email", +} + +// profileConfigRenameKeys maps a profile.Config key to a different +// metadata key when the names diverge. Same non-empty semantics as +// profileConfigPassthroughKeys. +var profileConfigRenameKeys = map[string]string{ + "image_tag": "image_tag_override", +} + +// profileConfigAuthoritativeKeys are profile.Config keys whose value is +// the source of truth for launch metadata — they overwrite any +// task-supplied value, including with an empty string. The reader-side +// helpers (e.g. SSHExecutor.workdirRoot, sshShellFromMetadata) handle +// the empty fall-through to a default. Without this, a task that +// supplies ssh_workdir_root or ssh_shell in its Metadata wins when the +// profile has no value set — that's a redirect vector (workdir target, +// login shell that runs every remote command). +var profileConfigAuthoritativeKeys = []string{ + lifecycle.MetadataKeySSHWorkdirRoot, + lifecycle.MetadataKeySSHShell, +} + +// applyProfileConfigToMetadata projects profile.Config keys into the +// launch metadata. Pulled out so the policy (passthrough vs rename vs +// authoritative) is declarative and testable in isolation. +func applyProfileConfigToMetadata(profileConfig map[string]string, metadata map[string]interface{}) { + for _, k := range profileConfigPassthroughKeys { + if v := profileConfig[k]; v != "" { + metadata[k] = v + } } - if credJSON := profile.Config["remote_credentials"]; credJSON != "" { - metadata["remote_credentials"] = credJSON + for src, dst := range profileConfigRenameKeys { + if v := profileConfig[src]; v != "" { + metadata[dst] = v + } } - if authSecretsJSON := profile.Config["remote_auth_secrets"]; authSecretsJSON != "" { - metadata["remote_auth_secrets"] = authSecretsJSON - } - if remoteAuthHome := profile.Config["remote_auth_target_home"]; remoteAuthHome != "" { - metadata["remote_auth_target_home"] = remoteAuthHome - } - if gitUserName := profile.Config["git_user_name"]; gitUserName != "" { - metadata["git_user_name"] = gitUserName - } - if gitUserEmail := profile.Config["git_user_email"]; gitUserEmail != "" { - metadata["git_user_email"] = gitUserEmail - } - if imageTag := profile.Config["image_tag"]; imageTag != "" { - metadata["image_tag_override"] = imageTag + for _, k := range profileConfigAuthoritativeKeys { + // Unconditional set: an empty profile value overwrites any + // task-supplied value in metadata. The reader-side fall-through + // to a default handles the empty case. + metadata[k] = profileConfig[k] } } diff --git a/apps/backend/internal/orchestrator/executor/executor_state_test.go b/apps/backend/internal/orchestrator/executor/executor_state_test.go new file mode 100644 index 0000000000..c8a479821d --- /dev/null +++ b/apps/backend/internal/orchestrator/executor/executor_state_test.go @@ -0,0 +1,139 @@ +package executor + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/task/models" +) + +// TestResolveExecutorConfig_PropagatesSSHShell pins the contract that the +// shell selection saved on an SSH executor profile lands in launch metadata +// under MetadataKeySSHShell. Without this, sshShellFromMetadata returns +// empty and WrapLoginShell silently falls back to bash — so a user who +// installed npx under zsh+nvm sees a misleading "npx not found" preflight +// error while the readiness probe (which takes shell straight from the +// request body) reports the agent as available. +func TestResolveExecutorConfig_PropagatesSSHShell(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + + repo.executors["exec-ssh-1"] = &models.Executor{ + ID: "exec-ssh-1", + Type: models.ExecutorTypeSSH, + Config: map[string]string{ + "ssh_host": "example.com", + }, + } + repo.executorProfiles["prof-1"] = &models.ExecutorProfile{ + ID: "prof-1", + ExecutorID: "exec-ssh-1", + Config: map[string]string{ + "ssh_shell": "zsh", + }, + } + + cfg := exec.resolveExecutorConfig(context.Background(), "exec-ssh-1", "ws-1", map[string]interface{}{ + "executor_profile_id": "prof-1", + }) + + got, _ := cfg.Metadata[lifecycle.MetadataKeySSHShell].(string) + if got != "zsh" { + t.Fatalf("metadata[%q] = %q, want %q", lifecycle.MetadataKeySSHShell, got, "zsh") + } +} + +// TestResolveExecutorConfig_AuthoritativeSSHKeys_ClobberTaskMetadata pins +// the security contract from B1 of the thermo-nuclear review: a task that +// supplies ssh_workdir_root or ssh_shell in its metadata MUST be +// overridden by the profile's value — including when the profile has no +// value set (empty wins). Without this, a task can redirect every remote +// command on launch to a host path or login shell of its choosing. +func TestResolveExecutorConfig_AuthoritativeSSHKeys_ClobberTaskMetadata(t *testing.T) { + cases := []struct { + name string + key string + profileValue string + taskValue string + wantMetadata string // empty means the key should be present with "" + }{ + { + name: "profile_wins_when_set", + key: lifecycle.MetadataKeySSHWorkdirRoot, + profileValue: "/srv/kandev", + taskValue: "/etc", + wantMetadata: "/srv/kandev", + }, + { + name: "empty_profile_clobbers_task", + key: lifecycle.MetadataKeySSHWorkdirRoot, + profileValue: "", + taskValue: "/etc", + wantMetadata: "", + }, + { + name: "shell_profile_wins_when_set", + key: lifecycle.MetadataKeySSHShell, + profileValue: "zsh", + taskValue: "bash --norc", + wantMetadata: "zsh", + }, + { + name: "empty_shell_profile_clobbers_task", + key: lifecycle.MetadataKeySSHShell, + profileValue: "", + taskValue: "bash --norc", + wantMetadata: "", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + repo.executors["exec-ssh-1"] = &models.Executor{ID: "exec-ssh-1", Type: models.ExecutorTypeSSH} + repo.executorProfiles["prof-1"] = &models.ExecutorProfile{ + ID: "prof-1", + ExecutorID: "exec-ssh-1", + Config: map[string]string{tc.key: tc.profileValue}, + } + + cfg := exec.resolveExecutorConfig(context.Background(), "exec-ssh-1", "ws-1", map[string]interface{}{ + "executor_profile_id": "prof-1", + tc.key: tc.taskValue, + }) + + got, _ := cfg.Metadata[tc.key].(string) + if got != tc.wantMetadata { + t.Fatalf("metadata[%q] = %q, want %q (profile=%q task=%q)", + tc.key, got, tc.wantMetadata, tc.profileValue, tc.taskValue) + } + }) + } +} + +// TestResolveExecutorConfig_PassthroughKeys_DontClobberTaskMetadata pins +// the inverse: ordinary passthrough keys (git_user_name etc.) keep their +// historical "only set if non-empty" semantics. A task-supplied value +// survives when the profile leaves the key blank. This is intentional +// for non-security-sensitive keys; flag explicitly if it ever needs to +// change. +func TestResolveExecutorConfig_PassthroughKeys_DontClobberTaskMetadata(t *testing.T) { + repo := newMockRepository() + exec := newTestExecutor(t, &mockAgentManager{}, repo) + repo.executors["exec-ssh-1"] = &models.Executor{ID: "exec-ssh-1", Type: models.ExecutorTypeSSH} + repo.executorProfiles["prof-1"] = &models.ExecutorProfile{ + ID: "prof-1", + ExecutorID: "exec-ssh-1", + Config: map[string]string{}, // profile has no git_user_name + } + + cfg := exec.resolveExecutorConfig(context.Background(), "exec-ssh-1", "ws-1", map[string]interface{}{ + "executor_profile_id": "prof-1", + "git_user_name": "Task Author", + }) + + if got, _ := cfg.Metadata["git_user_name"].(string); got != "Task Author" { + t.Fatalf("metadata[git_user_name] = %q, want task value to survive empty profile", got) + } +} diff --git a/apps/backend/internal/ssh/handlers.go b/apps/backend/internal/ssh/handlers.go new file mode 100644 index 0000000000..372994b6f8 --- /dev/null +++ b/apps/backend/internal/ssh/handlers.go @@ -0,0 +1,682 @@ +// Package ssh provides HTTP and WebSocket handlers for SSH executor +// management: test-connection-and-fingerprint, list active SSH sessions for +// an executor. +package ssh + +import ( + "context" + "errors" + "fmt" + "net/http" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + "golang.org/x/crypto/ssh" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/registry" + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/models" + ws "github.com/kandev/kandev/pkg/websocket" +) + +const errorJSONKey = "error" + +// ExecutorRunningLister is the narrow repository slice we need to surface +// active SSH sessions. We list all running executors and resolve each row's +// executor_id by looking up the task_session — the lifecycle manager doesn't +// populate ExecutorRunning.ExecutorID directly (it's only carried forward from +// prior rows on resume), so joining through the session is the source of +// truth. +type ExecutorRunningLister interface { + ListExecutorsRunning(ctx context.Context) ([]*models.ExecutorRunning, error) + GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) +} + +// ExecutorFetcher looks up a single executor by id. Needed by the +// agent-readiness probe to translate executor.Config into an SSHTarget. +type ExecutorFetcher interface { + GetExecutor(ctx context.Context, id string) (*models.Executor, error) +} + +// AgentLister is the narrow slice of *registry.Registry that the readiness +// probe needs: walk every enabled agent so we can probe each one's first +// command-line token. Defined as an interface so handler tests can pass a +// fake set of agents without bringing the full registry in. +type AgentLister interface { + ListEnabled() []agents.Agent +} + +// Handler exposes /api/v1/ssh routes used by the settings UI. +type Handler struct { + repo ExecutorRunningLister + executorFetcher ExecutorFetcher + agents AgentLister + resolver *lifecycle.AgentctlResolver + logger *logger.Logger +} + +// NewHandler builds an SSH HTTP/WS handler. +func NewHandler( + repo ExecutorRunningLister, + executorFetcher ExecutorFetcher, + agents AgentLister, + resolver *lifecycle.AgentctlResolver, + log *logger.Logger, +) *Handler { + return &Handler{ + repo: repo, + executorFetcher: executorFetcher, + agents: agents, + resolver: resolver, + logger: log.WithFields(zap.String("component", "ssh-handler")), + } +} + +// RegisterRoutes wires the SSH routes onto the given gin engine + WS dispatcher. +func RegisterRoutes( + router *gin.Engine, + dispatcher *ws.Dispatcher, + repo ExecutorRunningLister, + executorFetcher ExecutorFetcher, + registry *registry.Registry, + resolver *lifecycle.AgentctlResolver, + log *logger.Logger, +) { + var agentLister AgentLister + if registry != nil { + agentLister = registry + } + h := NewHandler(repo, executorFetcher, agentLister, resolver, log) + h.registerHTTP(router) + h.registerWS(dispatcher) +} + +func (h *Handler) registerHTTP(router *gin.Engine) { + api := router.Group("/api/v1/ssh") + api.POST("/test", h.httpTest) + api.GET("/executors/:id/sessions", h.httpListSessions) + api.POST("/executors/:id/probe-agents", h.httpProbeAgents) + api.POST("/executors/:id/probe-shells", h.httpProbeShells) +} + +func (h *Handler) registerWS(dispatcher *ws.Dispatcher) { + dispatcher.RegisterFunc("ssh.test", h.wsTest) + dispatcher.RegisterFunc("ssh.sessions.list", h.wsListSessions) +} + +// --- Request / response shapes --- + +// TestRequest is the body of POST /api/v1/ssh/test. +type TestRequest struct { + Name string `json:"name"` + HostAlias string `json:"host_alias,omitempty"` + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + User string `json:"user,omitempty"` + IdentitySrc string `json:"identity_source,omitempty"` // "agent" or "file" + IdentityFile string `json:"identity_file,omitempty"` + ProxyJump string `json:"proxy_jump,omitempty"` +} + +// TestStep is one entry in the test result step list. +type TestStep struct { + Name string `json:"name"` + DurationMs int64 `json:"duration_ms"` + Success bool `json:"success"` + Output string `json:"output,omitempty"` + Error string `json:"error,omitempty"` +} + +// TestResult is the response of POST /api/v1/ssh/test. The UI requires +// `success` and `fingerprint` before letting the user tick "Trust this host". +type TestResult struct { + Success bool `json:"success"` + Fingerprint string `json:"fingerprint,omitempty"` + UnameAll string `json:"uname_all,omitempty"` + Arch string `json:"arch,omitempty"` + GitVersion string `json:"git_version,omitempty"` + AgentctlAction string `json:"agentctl_action,omitempty"` // "cached" | "needs_upload" | "skipped" + Steps []TestStep `json:"steps"` + TotalDurationMs int64 `json:"total_duration_ms"` + Error string `json:"error,omitempty"` +} + +// SessionRow is one entry in the active-sessions table. +type SessionRow struct { + SessionID string `json:"session_id"` + TaskID string `json:"task_id"` + TaskTitle string `json:"task_title,omitempty"` + Host string `json:"host"` + User string `json:"user,omitempty"` + RemoteTaskDir string `json:"remote_task_dir,omitempty"` + RemoteAgentPort int `json:"remote_agentctl_port,omitempty"` + LocalForwardPort int `json:"local_forward_port,omitempty"` + Status string `json:"status"` + UptimeSeconds int64 `json:"uptime_seconds"` + CreatedAt string `json:"created_at"` +} + +// --- HTTP handlers --- + +func (h *Handler) httpTest(c *gin.Context) { + var req TestRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errorJSONKey: "invalid request body: " + err.Error()}) + return + } + result := h.runTest(c.Request.Context(), req) + c.JSON(http.StatusOK, result) +} + +func (h *Handler) httpListSessions(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{errorJSONKey: "executor id required"}) + return + } + rows, err := h.listSessions(c.Request.Context(), id) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{errorJSONKey: err.Error()}) + return + } + c.JSON(http.StatusOK, rows) +} + +// AgentReadinessRow reports the readiness of one agent on the remote host: +// whether the first token of agent.BuildCommand(...).Args() resolves on the +// remote's $PATH, and the install hint sourced from agent.InstallScript() so +// the UI can render a copy-button instead of forcing the user to look up +// per-agent install commands. +type AgentReadinessRow struct { + AgentID string `json:"agent_id"` + AgentName string `json:"agent_name"` + Binary string `json:"binary"` + Available bool `json:"available"` + ResolvedAt string `json:"resolved_at,omitempty"` // command -v stdout, when found + InstallHint string `json:"install_hint,omitempty"` + Error string `json:"error,omitempty"` +} + +// AgentReadinessResponse is the body of POST /api/v1/ssh/executors/:id/probe-agents. +// Shell echoes back which login shell the probe ran under so the UI can +// label results that depend on shell-init PATH (nvm/asdf etc.). +type AgentReadinessResponse struct { + Host string `json:"host"` + Shell string `json:"shell,omitempty"` + DurationMs int64 `json:"duration_ms"` + Rows []AgentReadinessRow `json:"rows"` +} + +// ProbeAgentsRequest is the optional body of POST /api/v1/ssh/executors/:id/probe-agents. +// Shell, when set, names the login shell the probe runs under (e.g. "bash", +// "zsh"). Empty falls back to bash — see lifecycle.WrapLoginShell. +type ProbeAgentsRequest struct { + Shell string `json:"shell,omitempty"` +} + +// httpProbeAgents SSHs to the given executor's host and runs `command -v` +// for each enabled agent's first command-line token. Drives the "Available +// agents on this host" card on the executor's profile-edit page. +// +// Reads an optional `{shell}` body so the UI can re-probe under whichever +// shell the user picked in the shell selector (PATH depends on shell init +// files, so the same host shows different agents under bash vs sh). +func (h *Handler) httpProbeAgents(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{errorJSONKey: "executor id required"}) + return + } + if h.executorFetcher == nil || h.agents == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{errorJSONKey: "agent readiness not wired"}) + return + } + var body ProbeAgentsRequest + // Body is optional — empty POST is fine and falls back to the default + // shell. Don't fail loudly on a malformed body; just ignore the shell. + _ = c.ShouldBindJSON(&body) + resp, status, err := h.probeAgents(c.Request.Context(), id, body.Shell) + if err != nil { + c.JSON(status, gin.H{errorJSONKey: err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +// probeAgents is the testable core of httpProbeAgents. +func (h *Handler) probeAgents(ctx context.Context, executorID, shell string) (*AgentReadinessResponse, int, error) { + started := time.Now() + target, status, err := h.resolveSSHTarget(ctx, executorID) + if err != nil { + return nil, status, err + } + client, err := lifecycle.DialSSH(ctx, target) + if err != nil { + return nil, http.StatusBadGateway, fmt.Errorf("ssh: dial %s: %w", target.Host, err) + } + defer func() { _ = client.Close() }() + + rows := h.probeAgentsOnClient(ctx, client, shell) + return &AgentReadinessResponse{ + Host: target.Host, + Shell: shell, + DurationMs: time.Since(started).Milliseconds(), + Rows: rows, + }, http.StatusOK, nil +} + +// probeAgentsOnClient walks every enabled agent and runs command -v over +// the existing client. Public seam for a unit test that fakes the SSH side. +func (h *Handler) probeAgentsOnClient(ctx context.Context, client *ssh.Client, shell string) []AgentReadinessRow { + enabled := h.agents.ListEnabled() + rows := make([]AgentReadinessRow, 0, len(enabled)) + for _, ag := range enabled { + rows = append(rows, probeOneAgent(ctx, client, shell, ag)) + } + return rows +} + +func probeOneAgent(ctx context.Context, client *ssh.Client, shell string, ag agents.Agent) AgentReadinessRow { + row := AgentReadinessRow{ + AgentID: ag.ID(), + AgentName: ag.Name(), + InstallHint: strings.TrimSpace(ag.InstallScript()), + } + cmd := ag.BuildCommand(agents.CommandOptions{Runtime: agentruntime.RuntimeSSH}) + args := cmd.Args() + if len(args) == 0 { + row.Error = "agent did not emit a command" + return row + } + row.Binary = args[0] + resolved, err := lifecycle.ProbeRemoteBinary(ctx, client, shell, row.Binary) + if err != nil { + row.Error = err.Error() + return row + } + if resolved == "" { + return row + } + row.Available = true + row.ResolvedAt = resolved + return row +} + +// candidateShells is the closed set the probe walks. Common Linux shells +// plus dash; expanding this is cheap (one extra `command -v` per entry). +var candidateShells = []string{"bash", "zsh", "sh", "fish", "dash"} + +// ProbeShellsResponse is the body of POST /api/v1/ssh/executors/:id/probe-shells. +type ProbeShellsResponse struct { + Host string `json:"host"` + DurationMs int64 `json:"duration_ms"` + Available []string `json:"available"` +} + +// httpProbeShells SSHs to the host and reports which of a small set of +// canonical login shells resolves on the remote's $PATH. Drives the shell +// selector on the SSH profile-edit page so users pick a shell that actually +// exists. The shell choice then governs every subsequent SSH command — +// including the agent-readiness probe and the actual agentctl launch. +func (h *Handler) httpProbeShells(c *gin.Context) { + id := strings.TrimSpace(c.Param("id")) + if id == "" { + c.JSON(http.StatusBadRequest, gin.H{errorJSONKey: "executor id required"}) + return + } + if h.executorFetcher == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{errorJSONKey: "executor lookup not wired"}) + return + } + resp, status, err := h.probeShells(c.Request.Context(), id) + if err != nil { + c.JSON(status, gin.H{errorJSONKey: err.Error()}) + return + } + c.JSON(http.StatusOK, resp) +} + +func (h *Handler) probeShells(ctx context.Context, executorID string) (*ProbeShellsResponse, int, error) { + started := time.Now() + target, status, err := h.resolveSSHTarget(ctx, executorID) + if err != nil { + return nil, status, err + } + client, err := lifecycle.DialSSH(ctx, target) + if err != nil { + return nil, http.StatusBadGateway, fmt.Errorf("ssh: dial %s: %w", target.Host, err) + } + defer func() { _ = client.Close() }() + + available := make([]string, 0, len(candidateShells)) + for _, shell := range candidateShells { + // Probe without login-shell wrapping — we're checking whether the + // shell binary itself exists in PATH, which is independent of the + // shell-init files we'd source. /bin/sh -c 'command -v bash' is + // fine. + resolved, probeErr := lifecycle.ProbeRemoteBinary(ctx, client, "sh", shell) + if probeErr != nil { + h.logger.Warn("probe-shells: SSH probe failed", + zap.String("shell", shell), zap.Error(probeErr)) + continue + } + if resolved != "" { + available = append(available, shell) + } + } + return &ProbeShellsResponse{ + Host: target.Host, + DurationMs: time.Since(started).Milliseconds(), + Available: available, + }, http.StatusOK, nil +} + +// resolveSSHTarget looks up the executor by id and projects its config +// into an SSHTarget. Shared across probe entry points. +func (h *Handler) resolveSSHTarget(ctx context.Context, executorID string) (*lifecycle.SSHTarget, int, error) { + executor, err := h.executorFetcher.GetExecutor(ctx, executorID) + if err != nil { + // Genuine "row doesn't exist" → 404. Transport-level failures + // (storage outage, context cancel, …) keep their server-error + // shape so the UI can distinguish "executor was deleted" from + // "the lookup itself just broke". + if errors.Is(err, models.ErrExecutorNotFound) { + return nil, http.StatusNotFound, fmt.Errorf("executor %q not found: %w", executorID, err) + } + return nil, http.StatusInternalServerError, fmt.Errorf("look up executor %q: %w", executorID, err) + } + if executor == nil || executor.Type != models.ExecutorTypeSSH { + return nil, http.StatusBadRequest, fmt.Errorf("executor %q is not an SSH executor", executorID) + } + target, err := sshTargetFromExecutorConfig(executor.Config) + if err != nil { + return nil, http.StatusBadRequest, err + } + return target, http.StatusOK, nil +} + +// sshTargetFromExecutorConfig projects an executor.Config into the +// SSHConnConfig the dialer expects. Keeps the handler decoupled from +// lifecycle's internal metadata-vs-config representation. +func sshTargetFromExecutorConfig(cfg map[string]string) (*lifecycle.SSHTarget, error) { + if cfg == nil { + return nil, fmt.Errorf("ssh executor has no config") + } + port := 0 + if p := strings.TrimSpace(cfg["ssh_port"]); p != "" { + n, err := strconv.Atoi(p) + if err != nil || n < 1 || n > 65535 { + return nil, fmt.Errorf("invalid ssh_port %q", p) + } + port = n + } + return lifecycle.ResolveSSHTarget(lifecycle.SSHConnConfig{ + HostAlias: cfg["ssh_host_alias"], + Host: cfg["ssh_host"], + Port: port, + User: cfg["ssh_user"], + IdentitySource: lifecycle.SSHIdentitySource(cfg["ssh_identity_source"]), + IdentityFile: cfg["ssh_identity_file"], + ProxyJump: cfg["ssh_proxy_jump"], + PinnedFingerprint: cfg["ssh_host_fingerprint"], + }) +} + +// --- WS handlers --- + +func (h *Handler) wsTest(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req TestRequest + if err := msg.ParsePayload(&req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + return ws.NewResponse(msg.ID, msg.Action, h.runTest(ctx, req)) +} + +func (h *Handler) wsListSessions(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var payload struct { + ExecutorID string `json:"executor_id"` + } + if err := msg.ParsePayload(&payload); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "invalid payload", nil) + } + if payload.ExecutorID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "executor_id required", nil) + } + rows, err := h.listSessions(ctx, payload.ExecutorID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, rows) +} + +// --- Business logic --- + +// runTest performs the SSH-test sequence: dial without a pinned fingerprint +// (so the host key is observed but not yet trusted), probe remote OS/arch, +// then dry-run the agentctl-upload-or-skip step. The UI gates the "Trust this +// host" checkbox on result.Success && result.Fingerprint != "". +func (h *Handler) runTest(ctx context.Context, req TestRequest) *TestResult { + start := time.Now() + result := &TestResult{} + finalize := func() *TestResult { + result.TotalDurationMs = time.Since(start).Milliseconds() + return result + } + + target, err := h.testResolveTarget(req, result) + if err != nil { + return finalize() + } + + client, err := h.testHandshake(ctx, target, result) + if err != nil { + return finalize() + } + defer func() { _ = client.Close() }() + + infoCtx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + + if err := h.testProbeAndArch(infoCtx, client, result); err != nil { + return finalize() + } + h.testAgentctlCache(infoCtx, client, result) + + result.Success = true + return finalize() +} + +func (h *Handler) testResolveTarget(req TestRequest, result *TestResult) (*lifecycle.SSHTarget, error) { + target, err := lifecycle.ResolveSSHTarget(lifecycle.SSHConnConfig{ + HostAlias: req.HostAlias, + Host: req.Host, + Port: req.Port, + User: req.User, + IdentitySource: lifecycle.SSHIdentitySource(req.IdentitySrc), + IdentityFile: req.IdentityFile, + ProxyJump: req.ProxyJump, + // Deliberately empty PinnedFingerprint — first-connect / test mode. + }) + if err != nil { + result.Steps = append(result.Steps, TestStep{Name: "Resolve target", Success: false, Error: err.Error()}) + result.Error = err.Error() + return nil, err + } + result.Steps = append(result.Steps, TestStep{ + Name: "Resolve target", + Success: true, + Output: fmt.Sprintf("%s@%s:%d (identity=%s)", target.User, target.Host, target.Port, target.IdentitySource), + }) + return target, nil +} + +func (h *Handler) testHandshake( + ctx context.Context, target *lifecycle.SSHTarget, result *TestResult, +) (*ssh.Client, error) { + stepStart := time.Now() + client, err := lifecycle.DialSSH(ctx, target) + step := TestStep{Name: "SSH handshake", DurationMs: time.Since(stepStart).Milliseconds()} + if err != nil { + step.Success = false + step.Error = err.Error() + result.Steps = append(result.Steps, step) + result.Error = err.Error() + return nil, err + } + step.Success = true + result.Fingerprint = target.ObservedFingerprint + step.Output = "fingerprint=" + result.Fingerprint + result.Steps = append(result.Steps, step) + return client, nil +} + +func (h *Handler) testProbeAndArch(ctx context.Context, client *ssh.Client, result *TestResult) error { + info, err := lifecycle.SSHProbeRemote(ctx, client) + if err != nil { + result.Steps = append(result.Steps, TestStep{Name: "Probe remote", Success: false, Error: err.Error()}) + result.Error = err.Error() + return err + } + result.UnameAll = info.UnameAll + result.Arch = info.Arch + result.GitVersion = info.GitVer + result.Steps = append(result.Steps, TestStep{Name: "Probe remote", Success: true, Output: info.UnameAll}) + + if err := lifecycle.SSHRequireSupportedArch(info.Arch); err != nil { + result.Steps = append(result.Steps, TestStep{Name: "Verify arch", Success: false, Error: err.Error()}) + result.Error = err.Error() + return err + } + result.Steps = append(result.Steps, TestStep{Name: "Verify arch", Success: true, Output: info.Arch}) + return nil +} + +// populateSessionMetadata copies SSH-specific keys from an ExecutorRunning row +// into a SessionRow. Pure mapping; no I/O. Lives here (not on the row) so the +// metadata key names stay co-located with their consumer. +func populateSessionMetadata(row *SessionRow, md map[string]interface{}) { + if md == nil { + return + } + row.Host, _ = md[lifecycle.MetadataKeySSHHost].(string) + row.User, _ = md[lifecycle.MetadataKeySSHUser].(string) + row.RemoteTaskDir, _ = md[lifecycle.MetadataKeySSHRemoteTaskDir].(string) + row.RemoteAgentPort = intFromMetadata(md, lifecycle.MetadataKeySSHRemoteAgentctlPort) + row.LocalForwardPort = intFromMetadata(md, lifecycle.MetadataKeySSHLocalForwardPort) +} + +// intFromMetadata reads a numeric metadata value. The SSH executor stores +// ports/pids as strings (strconv.Itoa) at write time, but a JSON round-trip +// through ExecutorRunning.Metadata can land them back as float64. Tolerate +// the obvious shapes rather than silently returning 0 on a real value. +func intFromMetadata(md map[string]interface{}, key string) int { + switch v := md[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + n, err := strconv.Atoi(v) + if err != nil { + return 0 + } + return n + default: + return 0 + } +} + +func (h *Handler) testAgentctlCache(ctx context.Context, client *ssh.Client, result *TestResult) { + upStepStart := time.Now() + cached, err := lifecycle.SSHCheckAgentctlCached(ctx, client, h.resolver) + upStep := TestStep{Name: "Verify agentctl cache", DurationMs: time.Since(upStepStart).Milliseconds()} + if err != nil { + upStep.Success = false + upStep.Error = err.Error() + result.Steps = append(result.Steps, upStep) + result.AgentctlAction = "skipped" + return + } + upStep.Success = true + if cached { + upStep.Output = "cached" + result.AgentctlAction = "cached" + } else { + upStep.Output = "will upload on first launch" + // "needs_upload": the binary is absent on the remote and will be + // uploaded the first time a session launches; the test endpoint + // only probes for the sha256 sidecar, it doesn't push the binary. + // Anything that wants to assert an upload actually happened needs + // to drive a real launch. + result.AgentctlAction = "needs_upload" + } + result.Steps = append(result.Steps, upStep) +} + +// listSessions returns the active SSH-runtime sessions for the given executor. +// Backed by the ExecutorRunning table; each row carries the SSH metadata we +// persisted in CreateInstance. The executor binding is resolved by looking up +// the TaskSession associated with each row — the row itself has an +// ExecutorID column but the lifecycle manager does not populate it for fresh +// executions, so it's unreliable as a filter. +// +// The runtime filter runs before the session lookup, so the per-row +// GetTaskSession call only fires for SSH rows — typically <10 per executor. +// If that count ever grows materially we should add a batch List by +// session-ID method to the repo and resolve in one query. +func (h *Handler) listSessions(ctx context.Context, executorID string) ([]SessionRow, error) { + rows, err := h.repo.ListExecutorsRunning(ctx) + if err != nil { + return nil, fmt.Errorf("list ssh sessions: %w", err) + } + out := make([]SessionRow, 0, len(rows)) + now := time.Now() + for _, run := range rows { + if run == nil { + continue + } + if run.Runtime != agentruntime.RuntimeSSH { + continue + } + if !h.sessionBelongsToExecutor(ctx, run, executorID) { + continue + } + row := SessionRow{ + SessionID: run.SessionID, + TaskID: run.TaskID, + Status: run.Status, + CreatedAt: run.CreatedAt.Format(time.RFC3339), + UptimeSeconds: int64(now.Sub(run.CreatedAt).Seconds()), + } + populateSessionMetadata(&row, run.Metadata) + out = append(out, row) + } + return out, nil +} + +// sessionBelongsToExecutor returns true when the given ExecutorRunning row was +// launched under the given executor config. Resolves via the task_session +// row because ExecutorRunning.ExecutorID is unpopulated on fresh executions. +// Falls back to the row's own ExecutorID when the session lookup fails, so +// resume / recovery paths (where the row has it) still match. +func (h *Handler) sessionBelongsToExecutor(ctx context.Context, run *models.ExecutorRunning, executorID string) bool { + if run.ExecutorID == executorID && executorID != "" { + return true + } + if run.SessionID == "" { + return false + } + session, err := h.repo.GetTaskSession(ctx, run.SessionID) + if err != nil || session == nil { + return false + } + return session.ExecutorID == executorID +} diff --git a/apps/backend/internal/task/handlers/executor_profile_handlers.go b/apps/backend/internal/task/handlers/executor_profile_handlers.go index 2596673aec..77e3231df0 100644 --- a/apps/backend/internal/task/handlers/executor_profile_handlers.go +++ b/apps/backend/internal/task/handlers/executor_profile_handlers.go @@ -52,6 +52,10 @@ func (h *ExecutorProfileHandlers) registerHTTP(router *gin.Engine) { api.GET("/executors/:id/profiles/:profileId", h.httpGetProfile) api.PATCH("/executors/:id/profiles/:profileId", h.httpUpdateProfile) api.DELETE("/executors/:id/profiles/:profileId", h.httpDeleteProfile) + // Top-level DELETE: profile IDs are globally unique, so callers that + // only have the profile id (CLI, settings UI, test helpers) don't need + // to plumb the executor id through. The handler is identical otherwise. + api.DELETE("/executor-profiles/:profileId", h.httpDeleteProfile) } func (h *ExecutorProfileHandlers) registerWS(dispatcher *ws.Dispatcher) { diff --git a/apps/backend/internal/task/handlers/task_environment_handlers.go b/apps/backend/internal/task/handlers/task_environment_handlers.go index 1c142abad0..041f3a53f8 100644 --- a/apps/backend/internal/task/handlers/task_environment_handlers.go +++ b/apps/backend/internal/task/handlers/task_environment_handlers.go @@ -52,6 +52,17 @@ func (h *TaskHandlers) httpGetTaskEnvironmentLive(c *gin.Context) { if live != nil { resp["container"] = live } + // SSH-backed environments get a parallel `ssh` block with the runtime + // fields (host/user/workdir/forward ports) the popover surfaces. Service + // returns nil on non-SSH environments, so this is a no-op for the other + // executor types. + if ssh, err := h.service.GetSSHLiveStatus(c.Request.Context(), taskID); err != nil { + h.logger.Warn("failed to fetch live ssh status", + zap.String("task_id", taskID), + zap.Error(err)) + } else if ssh != nil { + resp["ssh"] = ssh + } c.JSON(http.StatusOK, resp) } diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index e1ff2a4f14..4a2cc224a0 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -13,6 +13,13 @@ import ( // ErrExecutorRunningNotFound is returned when no executor running record exists for a session. var ErrExecutorRunningNotFound = errors.New("executor running not found") +// ErrExecutorNotFound is returned by the executor repository when no +// executor row exists for the given ID. Callers should use errors.Is to +// distinguish "row doesn't exist" (404 semantically) from transport-level +// failures (storage outage, timeout, ctx cancel) that happen to also +// surface from the same lookup. +var ErrExecutorNotFound = errors.New("executor not found") + // ErrExecutionRotated is returned by CAS-style updates on executors_running when the // row's agent_execution_id has rotated since the caller observed it. Indicates the // caller's write target a now-defunct execution and should be discarded — typically @@ -513,6 +520,7 @@ const ( ExecutorTypeLocalDocker ExecutorType = "local_docker" ExecutorTypeRemoteDocker ExecutorType = "remote_docker" ExecutorTypeSprites ExecutorType = "sprites" + ExecutorTypeSSH ExecutorType = "ssh" ExecutorTypeMockRemote ExecutorType = "mock_remote" ) @@ -521,7 +529,7 @@ const ( // These environments run shells inside the container/VM, not on the host. func IsRemoteExecutorType(t ExecutorType) bool { switch t { - case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeLocalDocker, ExecutorTypeMockRemote: + case ExecutorTypeSprites, ExecutorTypeRemoteDocker, ExecutorTypeLocalDocker, ExecutorTypeSSH, ExecutorTypeMockRemote: return true default: return false @@ -543,6 +551,8 @@ func (t ExecutorType) Runtime() agentruntime.Runtime { return agentruntime.RuntimeRemoteDocker case ExecutorTypeSprites: return agentruntime.RuntimeSprites + case ExecutorTypeSSH: + return agentruntime.RuntimeSSH default: return agentruntime.RuntimeStandalone } @@ -560,7 +570,7 @@ func IsContainerizedExecutorType(t ExecutorType) bool { // IsAlwaysResumableRuntime reports whether the given runtime represents // an executor that can always be resumed even without an explicit resume token. func IsAlwaysResumableRuntime(runtime agentruntime.Runtime) bool { - return runtime == agentruntime.RuntimeSprites + return runtime == agentruntime.RuntimeSprites || runtime == agentruntime.RuntimeSSH } const ( diff --git a/apps/backend/internal/task/repository/sqlite/executor.go b/apps/backend/internal/task/repository/sqlite/executor.go index f6b1678795..f72688c153 100644 --- a/apps/backend/internal/task/repository/sqlite/executor.go +++ b/apps/backend/internal/task/repository/sqlite/executor.go @@ -49,7 +49,7 @@ func (r *Repository) GetExecutor(ctx context.Context, id string) (*models.Execut &isSystem, &resumable, &configJSON, &executor.CreatedAt, &executor.UpdatedAt, &executor.DeletedAt, ) if err == sql.ErrNoRows { - return nil, fmt.Errorf("executor not found: %s", id) + return nil, fmt.Errorf("%w: %s", models.ErrExecutorNotFound, id) } if err != nil { return nil, err diff --git a/apps/backend/internal/task/service/service_task_environments.go b/apps/backend/internal/task/service/service_task_environments.go index 528da94268..613270aa93 100644 --- a/apps/backend/internal/task/service/service_task_environments.go +++ b/apps/backend/internal/task/service/service_task_environments.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "strconv" "go.uber.org/zap" @@ -40,6 +41,24 @@ type ContainerLiveStatus struct { Missing bool `json:"missing,omitempty"` } +// SSHLiveStatus surfaces the SSH-runtime fields needed by the Executor +// Settings popover for an SSH-backed task: where the agent is running +// (host/user/workdir), the local port forward we set up for the agent +// stream, and the remote agentctl process info. Sourced from the latest +// running ExecutorRunning row for the task's most recent session — the +// SSH executor writes these metadata keys in CreateInstance and we just +// project them back out here. +type SSHLiveStatus struct { + Host string `json:"host,omitempty"` + Port int `json:"port,omitempty"` + User string `json:"user,omitempty"` + RemoteTaskDir string `json:"remote_task_dir,omitempty"` + RemoteAgentctlPID int `json:"remote_agentctl_pid,omitempty"` + RemoteAgentctlPort int `json:"remote_agentctl_port,omitempty"` + LocalForwardPort int `json:"local_forward_port,omitempty"` + Fingerprint string `json:"fingerprint,omitempty"` +} + // ResetOptions controls destructive behavior of ResetTaskEnvironment. type ResetOptions struct { // PushBranch runs `git push` on the environment's current branch before teardown. @@ -87,6 +106,114 @@ func (s *Service) GetTaskEnvironmentLiveStatus(ctx context.Context, taskID strin return s.envDestroyer.GetContainerLiveStatus(ctx, env.ContainerID) } +// GetSSHLiveStatus returns the SSH-specific runtime info for the task's +// most recent session, or (nil, nil) when the task has no environment yet +// or the environment isn't SSH-backed. The SSH executor writes these +// fields into ExecutorRunning.Metadata in CreateInstance; here we just +// look up the latest session's row and project the keys back out. We +// don't probe the remote here — that's GetRemoteStatus's job — because +// the popover polls this endpoint and a per-poll SSH dial would be both +// slow and a lot of TCP traffic. +func (s *Service) GetSSHLiveStatus(ctx context.Context, taskID string) (*SSHLiveStatus, error) { + env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil || env == nil { + return nil, err + } + if env.ExecutorType != string(models.ExecutorTypeSSH) { + return nil, nil + } + running, err := s.latestRunningForTask(ctx, taskID) + if err != nil { + return nil, err + } + if running == nil || running.Metadata == nil { + return nil, nil + } + return buildSSHLiveStatus(running.Metadata), nil +} + +// latestRunningForTask picks the newest ExecutorRunning row across the +// task's sessions. Sessions are returned newest-first by the repository +// today; if that ever changes the popover would silently surface stale +// data, so this helper is defensive: it walks every session and keeps +// the row with the latest StartedAt. +// +// Repository errors are surfaced so the popover handler can log + omit +// the ssh block instead of silently rendering "no SSH live status" on +// every poll after a transient storage blip. The one "expected +// missing" case (a session with no running row) is matched against +// models.ErrExecutorRunningNotFound and skipped quietly. +func (s *Service) latestRunningForTask(ctx context.Context, taskID string) (*models.ExecutorRunning, error) { + sessions, err := s.sessions.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("list task sessions: %w", err) + } + var latest *models.ExecutorRunning + for _, sess := range sessions { + if sess == nil { + continue + } + running, err := s.executors.GetExecutorRunningBySessionID(ctx, sess.ID) + if err != nil { + if errors.Is(err, models.ErrExecutorRunningNotFound) { + continue + } + return nil, fmt.Errorf("get executor running for session %s: %w", sess.ID, err) + } + if running == nil { + continue + } + if latest == nil || running.CreatedAt.After(latest.CreatedAt) { + latest = running + } + } + return latest, nil +} + +// buildSSHLiveStatus projects the SSH metadata keys onto the live-status +// shape. Pure function so the projection contract is unit-testable. +func buildSSHLiveStatus(md map[string]interface{}) *SSHLiveStatus { + status := &SSHLiveStatus{ + Host: mdString(md, "ssh_host"), + User: mdString(md, "ssh_user"), + RemoteTaskDir: mdString(md, "ssh_remote_task_dir"), + Fingerprint: mdString(md, "ssh_host_fingerprint"), + } + status.Port = mdInt(md, "ssh_port") + status.RemoteAgentctlPID = mdInt(md, "ssh_remote_agentctl_pid") + status.RemoteAgentctlPort = mdInt(md, "ssh_remote_agentctl_port") + status.LocalForwardPort = mdInt(md, "ssh_local_forward_port") + return status +} + +func mdString(md map[string]interface{}, key string) string { + if v, ok := md[key].(string); ok { + return v + } + return "" +} + +func mdInt(md map[string]interface{}, key string) int { + switch v := md[key].(type) { + case int: + return v + case int64: + return int(v) + case float64: + return int(v) + case string: + // SSH executor stores numeric metadata as strings (strconv.Itoa) so + // every JSON round-trip preserves the value verbatim. Tolerate both. + n, err := strconv.Atoi(v) + if err != nil { + return 0 + } + return n + default: + return 0 + } +} + // checkAnySessionRunning delegates to the configured checker or falls back to // the default (session has an ExecutorRunning row). func (s *Service) checkAnySessionRunning(ctx context.Context, taskID string) (bool, error) { diff --git a/apps/backend/internal/task/service/service_task_environments_test.go b/apps/backend/internal/task/service/service_task_environments_test.go index 7403c02e40..26023c3235 100644 --- a/apps/backend/internal/task/service/service_task_environments_test.go +++ b/apps/backend/internal/task/service/service_task_environments_test.go @@ -301,3 +301,70 @@ func TestPerformTaskCleanup_TearsDownTaskEnvironmentAndDeletesRow(t *testing.T) t.Fatal("expected task environment row to be deleted") } } + +// TestBuildSSHLiveStatus_StringsAndStringEncodedInts pins the projection +// from ExecutorRunning.Metadata into the popover-shaped SSHLiveStatus. The +// SSH executor writes its numeric metadata as strings (strconv.Itoa) so +// the projection must accept both "41001" and 41001 — every JSON +// round-trip through SQLite blob storage gives us strings, but direct +// in-memory writes give us ints. +func TestBuildSSHLiveStatus_StringsAndStringEncodedInts(t *testing.T) { + got := buildSSHLiveStatus(map[string]interface{}{ + "ssh_host": "koi.zeval.local", + "ssh_port": "2222", + "ssh_user": "zeval", + "ssh_remote_task_dir": "/home/zeval/.kandev/tasks/task-1", + "ssh_remote_agentctl_pid": "4732", + "ssh_remote_agentctl_port": "41001", + "ssh_local_forward_port": "59123", + "ssh_host_fingerprint": "SHA256:abc", + }) + if got.Host != "koi.zeval.local" || got.Port != 2222 || got.User != "zeval" { + t.Errorf("connection fields = %+v, want host/port/user", got) + } + if got.RemoteTaskDir != "/home/zeval/.kandev/tasks/task-1" { + t.Errorf("RemoteTaskDir = %q", got.RemoteTaskDir) + } + if got.RemoteAgentctlPID != 4732 || got.RemoteAgentctlPort != 41001 || got.LocalForwardPort != 59123 { + t.Errorf("agentctl fields = %+v", got) + } + if got.Fingerprint != "SHA256:abc" { + t.Errorf("Fingerprint = %q", got.Fingerprint) + } +} + +func TestBuildSSHLiveStatus_NativeIntsAlsoAccepted(t *testing.T) { + got := buildSSHLiveStatus(map[string]interface{}{ + "ssh_host": "h", + "ssh_port": 22, + "ssh_remote_agentctl_pid": int64(99), + "ssh_remote_agentctl_port": float64(41001), + }) + if got.Port != 22 || got.RemoteAgentctlPID != 99 || got.RemoteAgentctlPort != 41001 { + t.Errorf("native int projection failed: %+v", got) + } +} + +func TestBuildSSHLiveStatus_EmptyMetadataReturnsZeroValueStruct(t *testing.T) { + got := buildSSHLiveStatus(map[string]interface{}{}) + if got == nil { + t.Fatal("expected non-nil status (zero value), got nil") + } + if got.Host != "" || got.Port != 0 || got.RemoteAgentctlPID != 0 { + t.Errorf("expected zero-value fields, got %+v", got) + } +} + +func TestBuildSSHLiveStatus_InvalidPortString_NoCrash(t *testing.T) { + // SSH executor only emits Itoa'd ports, but be defensive in case + // something else writes the metadata (e.g. a future migration or + // import path). Don't want a stray non-numeric value to panic the + // popover endpoint for every other field too. + got := buildSSHLiveStatus(map[string]interface{}{ + "ssh_host": "h", + "ssh_port": "not-a-port", + }) + if got.Host != "h" || got.Port != 0 { + t.Errorf("expected host preserved, port=0 on bad input, got %+v", got) + } +} diff --git a/apps/web/app/settings/executors/[profileId]/page.tsx b/apps/web/app/settings/executors/[profileId]/page.tsx index a5016f4449..6f4fc56f36 100644 --- a/apps/web/app/settings/executors/[profileId]/page.tsx +++ b/apps/web/app/settings/executors/[profileId]/page.tsx @@ -25,6 +25,7 @@ import { rowsToEnvVars, } from "@/components/settings/profile-edit/env-vars-card"; import { ScriptCard } from "@/components/settings/profile-edit/script-card"; +import { SSHAgentReadinessCard } from "@/components/settings/ssh-agent-readiness-card"; import { type GitIdentityMode, type GitIdentityState, @@ -84,10 +85,15 @@ function parseRemoteCredentials(config?: Record): string[] { } function useRemoteExecutorFlags(executorType: ExecutorType) { + // SSH joins the "remote" set because it runs the agent on a host whose + // filesystem doesn't share paths with the kandev backend — so the same + // remote-credentials + auth-secrets surface applies (the SSH executor + // SFTPs files into the remote user's $HOME). const isRemote = executorType === "local_docker" || executorType === "remote_docker" || - executorType === "sprites"; + executorType === "sprites" || + executorType === "ssh"; return { isRemote, isDocker: executorType === "local_docker" || executorType === "remote_docker", @@ -405,15 +411,29 @@ function ProfileEditSections({ profile, form, secrets, + onShellChange, }: { executor: Executor; profile: ExecutorProfile; form: ReturnType; secrets: ReturnType["items"]; + onShellChange?: (shell: string) => Promise; }) { + const isSSH = executor.type === "ssh"; return ( <> + {isSSH && ( + // SSH-specific: lives right after profile details (top of the page) + // because the very next question after "name this profile" on an + // SSH host is "which agents are installed here". Drives the shell + // selector that governs every subsequent SSH command run by kandev. + + )} {form.isSprites && ( { + const mergedConfig = { ...(profile.config ?? {}), ssh_shell: next }; + await persistence.save({ + name: profile.name, + config: mergedConfig, + prepare_script: profile.prepare_script ?? "", + cleanup_script: profile.cleanup_script ?? "", + env_vars: profile.env_vars ?? [], + }); + }, + [persistence, profile], + ); + const handleDelete = (options?: { removeRelatedDockerContainers?: boolean }) => { const beforeDelete = options?.removeRelatedDockerContainers ? async () => { @@ -525,7 +567,13 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E profileName={profile.name} description={getExecutorDescription(executor.type)} /> - + {spritesTokenMissing && (

Sprites API key is required.

)} diff --git a/apps/web/app/settings/executors/new/[type]/executor-types.ts b/apps/web/app/settings/executors/new/[type]/executor-types.ts new file mode 100644 index 0000000000..7798092356 --- /dev/null +++ b/apps/web/app/settings/executors/new/[type]/executor-types.ts @@ -0,0 +1,42 @@ +// Registry of executor types presented in the "new executor profile" flow. +// Keep entries here (not inline in the page) so the page file stays under +// the 600-line lint cap and new types can be added without touching layout. + +export type ExecutorTypeInfo = { + executorId: string; + label: string; + description: string; +}; + +export const EXECUTOR_TYPE_MAP: Record = { + local: { + executorId: "exec-local", + label: "Local", + description: "Runs agents directly in the repository folder.", + }, + worktree: { + executorId: "exec-worktree", + label: "Worktree", + description: "Creates git worktrees for isolated agent sessions.", + }, + local_docker: { + executorId: "exec-local-docker", + label: "Docker", + description: "Runs Docker containers on this machine.", + }, + remote_docker: { + executorId: "exec-remote-docker", + label: "Remote Docker", + description: "Connects to a remote Docker host.", + }, + sprites: { + executorId: "exec-sprites", + label: "Sprites.dev", + description: "Runs agents in Sprites.dev cloud sandboxes.", + }, + ssh: { + executorId: "exec-ssh", + label: "SSH", + description: "Connects to a remote host over SSH and runs agentctl there.", + }, +}; diff --git a/apps/web/app/settings/executors/new/[type]/page.tsx b/apps/web/app/settings/executors/new/[type]/page.tsx index 6a36cb2d9c..a8712fadbd 100644 --- a/apps/web/app/settings/executors/new/[type]/page.tsx +++ b/apps/web/app/settings/executors/new/[type]/page.tsx @@ -42,40 +42,12 @@ import { import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api"; import type { Executor, ExecutorType, ProfileEnvVar } from "@/lib/types/http"; +import { EXECUTOR_TYPE_MAP } from "./executor-types"; +import { SSHCreatePage } from "./ssh-create-page"; + const EXECUTORS_ROUTE = "/settings/executors"; const SPRITES_TOKEN_KEY = "SPRITES_API_TOKEN"; -const EXECUTOR_TYPE_MAP: Record< - string, - { executorId: string; label: string; description: string } -> = { - local: { - executorId: "exec-local", - label: "Local", - description: "Runs agents directly in the repository folder.", - }, - worktree: { - executorId: "exec-worktree", - label: "Worktree", - description: "Creates git worktrees for isolated agent sessions.", - }, - local_docker: { - executorId: "exec-local-docker", - label: "Docker", - description: "Runs Docker containers on this machine.", - }, - remote_docker: { - executorId: "exec-remote-docker", - label: "Remote Docker", - description: "Connects to a remote Docker host.", - }, - sprites: { - executorId: "exec-sprites", - label: "Sprites.dev", - description: "Runs agents in Sprites.dev cloud sandboxes.", - }, -}; - const DefaultIcon = EXECUTOR_ICON_MAP.local; function ExecutorTypeIcon({ type }: { type: string }) { @@ -91,6 +63,10 @@ export default function CreateProfilePage({ params }: { params: Promise<{ type: return ; } + if (type === "ssh") { + return ; + } + return ; } diff --git a/apps/web/app/settings/executors/new/[type]/ssh-config.ts b/apps/web/app/settings/executors/new/[type]/ssh-config.ts new file mode 100644 index 0000000000..1b456694cc --- /dev/null +++ b/apps/web/app/settings/executors/new/[type]/ssh-config.ts @@ -0,0 +1,48 @@ +import type { SSHExecutorConfig } from "@/components/settings/ssh-connection-card"; + +/** + * Maps the SSHConnectionCard's flat config to the snake_case `Config` map + * the backend persists on the Executor row. Empty optional fields are + * dropped so the JSON we POST is minimal. + */ +export function buildSSHExecutorConfig(cfg: SSHExecutorConfig): Record { + const out: Record = { + ssh_identity_source: cfg.identity_source, + }; + if (cfg.host_alias?.trim()) out.ssh_host_alias = cfg.host_alias.trim(); + if (cfg.host?.trim()) out.ssh_host = cfg.host.trim(); + if (cfg.port != null) out.ssh_port = String(cfg.port); + if (cfg.user?.trim()) out.ssh_user = cfg.user.trim(); + if (cfg.identity_file?.trim()) out.ssh_identity_file = cfg.identity_file.trim(); + if (cfg.proxy_jump?.trim()) out.ssh_proxy_jump = cfg.proxy_jump.trim(); + if (cfg.host_fingerprint?.trim()) out.ssh_host_fingerprint = cfg.host_fingerprint.trim(); + return out; +} + +/** + * Inverse of {@link buildSSHExecutorConfig}: reads the backend `Config` map + * into the form's flat shape so an existing executor can be edited. + */ +export function parseSSHExecutorConfig( + name: string, + config?: Record, +): Partial { + const c = config ?? {}; + const portRaw = c.ssh_port; + // A missing ssh_port intentionally stays `undefined` so the backend's + // resolver can apply ~/.ssh/config Port inheritance for the configured + // alias. Substituting 22 here would silently redirect connections that + // relied on the alias' Port. + const port = portRaw ? Number.parseInt(portRaw, 10) : undefined; + return { + name, + host_alias: c.ssh_host_alias ?? "", + host: c.ssh_host ?? "", + port: port != null && Number.isFinite(port) ? port : undefined, + user: c.ssh_user ?? "", + identity_source: (c.ssh_identity_source as "agent" | "file") || "agent", + identity_file: c.ssh_identity_file ?? "", + proxy_jump: c.ssh_proxy_jump ?? "", + host_fingerprint: c.ssh_host_fingerprint || undefined, + }; +} diff --git a/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx b/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx new file mode 100644 index 0000000000..5b457ed197 --- /dev/null +++ b/apps/web/app/settings/executors/new/[type]/ssh-create-page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useCallback } from "react"; +import { useRouter } from "next/navigation"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Separator } from "@kandev/ui/separator"; +import { IconTerminal2 } from "@tabler/icons-react"; +import { useAppStoreApi } from "@/components/state-provider"; +import { createExecutor, createExecutorProfile } from "@/lib/api/domains/settings-api"; +import { SSHConnectionCard } from "@/components/settings/ssh-connection-card"; +import type { SSHExecutorConfig } from "@/components/settings/ssh-connection-card"; +import { getExecutorLabel } from "@/lib/executor-icons"; +import { buildSSHExecutorConfig } from "./ssh-config"; +import type { Executor } from "@/lib/types/http"; + +const EXECUTORS_ROUTE = "/settings/executors"; + +/** + * SSH-specific "new executor" flow. Renders just the SSHConnectionCard; + * Save POSTs to /api/v1/executors with type=ssh and the freshly-pinned + * fingerprint, then immediately creates a default profile under it (the + * /settings/executors index lists profiles, so an executor with no + * profile would otherwise be invisible). Routes to the profile-edit page + * after both writes succeed — that's where the user picks the shell, + * probes for installed agents, and tunes per-task config (workdir, + * prepare script, credentials). The host-level executor page + * (/settings/executors/ssh/:id) remains reachable for re-trust and + * sessions inspection. + */ +export function SSHCreatePage() { + const router = useRouter(); + const store = useAppStoreApi(); + + const handleSave = useCallback( + async (cfg: SSHExecutorConfig) => { + const created = await createExecutor({ + name: cfg.name, + type: "ssh", + config: buildSSHExecutorConfig(cfg), + }); + // Auto-create a default profile so the executor shows up in the + // /settings/executors list (which flattens by profile). The profile + // carries the same name as the executor and inherits the connection + // config; users can add more profiles later if they want different + // workdir roots / prepare scripts / env vars on the same host. + const profile = await createExecutorProfile(created.id, { + name: cfg.name, + }); + const next: Executor = { + id: created.id, + name: created.name, + type: "ssh", + status: "active", + is_system: false, + config: created.config, + profiles: [profile], + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }; + // Read the latest store snapshot at write time so a concurrent + // updater (another fetch, a WS event, etc.) that landed while + // createExecutor was in-flight doesn't get overwritten. Dedupe + // on id so a WS event that already inserted this executor doesn't + // double-list it after our append. + const current = store.getState().executors.items; + const merged = current.some((e) => e.id === next.id) ? current : [...current, next]; + store.getState().setExecutors(merged); + router.push(`/settings/executors/${profile.id}`); + }, + [router, store], + ); + + return ( +
+ + +
+ ); +} + +function SSHCreateHeader() { + const router = useRouter(); + return ( + <> +
+
+
+ +

New SSH Executor

+ + {getExecutorLabel("ssh")} + +
+

+ Connect to a remote host over SSH and run agentctl there. linux/amd64 hosts only. +

+
+ +
+ + + ); +} diff --git a/apps/web/app/settings/executors/page.tsx b/apps/web/app/settings/executors/page.tsx index 20fa255710..047dfdbfea 100644 --- a/apps/web/app/settings/executors/page.tsx +++ b/apps/web/app/settings/executors/page.tsx @@ -57,6 +57,11 @@ const EXECUTOR_TYPES = [ label: "Sprites.dev", description: "Run agents in Sprites.dev cloud sandboxes.", }, + { + type: "ssh", + label: "SSH", + description: "Connect to a remote host over SSH and run agentctl there.", + }, ] as const; function ExecutorIconBadge({ type }: { type: string }) { diff --git a/apps/web/app/settings/executors/ssh/[executorId]/page.tsx b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx new file mode 100644 index 0000000000..74847873a8 --- /dev/null +++ b/apps/web/app/settings/executors/ssh/[executorId]/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { use, useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent } from "@kandev/ui/card"; +import { Separator } from "@kandev/ui/separator"; +import { IconTerminal2 } from "@tabler/icons-react"; +import { useAppStoreApi } from "@/components/state-provider"; +import { fetchExecutor, listExecutors, updateExecutor } from "@/lib/api/domains/settings-api"; +import { SSHConnectionCard } from "@/components/settings/ssh-connection-card"; +import type { SSHExecutorConfig } from "@/components/settings/ssh-connection-card"; +import { SSHSessionsCard } from "@/components/settings/ssh-sessions-card"; +import { listSSHSessions } from "@/lib/api/domains/ssh-api"; +import { getExecutorLabel } from "@/lib/executor-icons"; +import { + buildSSHExecutorConfig, + parseSSHExecutorConfig, +} from "@/app/settings/executors/new/[type]/ssh-config"; +import type { Executor } from "@/lib/types/http"; + +const EXECUTORS_ROUTE = "/settings/executors"; + +type LoadedExecutor = { + id: string; + name: string; + type: string; + config?: Record; +}; + +export default function SSHExecutorPage({ params }: { params: Promise<{ executorId: string }> }) { + const { executorId } = use(params); + const { executor, loading, error, reload } = useExecutor(executorId); + + if (loading) { + return ( + + + Loading executor... + + + ); + } + if (error || !executor) { + return ; + } + if (executor.type !== "ssh") { + return ; + } + return ; +} + +function useExecutor(executorId: string) { + const [executor, setExecutor] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const load = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetchExecutor(executorId); + setExecutor(res); + } catch (e) { + setError(e instanceof Error ? e.message : "Failed to load executor"); + } finally { + setLoading(false); + } + }, [executorId]); + + useEffect(() => { + void load(); + }, [load]); + + return { executor, loading, error, reload: load }; +} + +function NotFoundCard({ message }: { message: string }) { + const router = useRouter(); + return ( + + +

{message}

+ +
+
+ ); +} + +function SSHExecutorView({ + executor, + onSaved, +}: { + executor: LoadedExecutor; + onSaved: () => void | Promise; +}) { + const initial = parseSSHExecutorConfig(executor.name, executor.config); + const sessionCount = useRunningSessionCount(executor.id); + const handleSave = useSaveExecutor(executor.id, onSaved); + + return ( +
+ + + +
+ ); +} + +function SSHExecutorHeader({ executorName }: { executorName: string }) { + const router = useRouter(); + return ( + <> +
+
+
+ +

{executorName}

+ + {getExecutorLabel("ssh")} + +
+

+ Edit the connection settings or re-trust the host. Existing sessions keep their snapshot + of the previous config. +

+
+ +
+ + + ); +} + +function useRunningSessionCount(executorId: string): number { + const [count, setCount] = useState(0); + useEffect(() => { + let cancelled = false; + listSSHSessions(executorId) + .then((rows) => { + if (!cancelled) setCount(rows.length); + }) + .catch(() => { + if (!cancelled) setCount(0); + }); + return () => { + cancelled = true; + }; + }, [executorId]); + return count; +} + +function useSaveExecutor(executorId: string, onSaved: () => void | Promise) { + const store = useAppStoreApi(); + + return useCallback( + async (cfg: SSHExecutorConfig) => { + const config = buildSSHExecutorConfig(cfg); + await updateExecutor(executorId, { name: cfg.name, config }); + // Refresh the store so the executor list reflects the new name + config. + try { + const fresh = await listExecutors(); + store.getState().setExecutors(fresh.executors); + } catch { + // Non-fatal: the local view still reloads via onSaved(). Read the + // current snapshot at write time so a WS event that updated the + // executor list mid-flight doesn't get overwritten with a stale + // captured copy. + const current = store.getState().executors.items; + store + .getState() + .setExecutors( + current.map((e: Executor) => + e.id === executorId ? { ...e, name: cfg.name, config } : e, + ), + ); + } + await onSaved(); + }, + [executorId, store, onSaved], + ); +} diff --git a/apps/web/components/settings/ssh-agent-readiness-card.tsx b/apps/web/components/settings/ssh-agent-readiness-card.tsx new file mode 100644 index 0000000000..c6efd3c114 --- /dev/null +++ b/apps/web/components/settings/ssh-agent-readiness-card.tsx @@ -0,0 +1,352 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { IconCheck, IconCopy, IconLoader2, IconX } from "@tabler/icons-react"; +import { toast } from "sonner"; +import { probeSSHAgents, probeSSHShells } from "@/lib/api/domains/ssh-api"; +import type { SSHAgentReadinessRow } from "@/lib/types/http-ssh"; + +export interface SSHAgentReadinessCardProps { + executorId: string; + /** The shell currently saved on the profile. Drives initial selection + the + * shell sent to the probe endpoint. */ + shell?: string; + /** Persist a shell change up to the profile. Called when the user picks a + * different option in the dropdown so the choice survives the page reload + * and flows into the next agent launch as ssh_shell metadata. */ + onShellChange?: (shell: string) => void | Promise; +} + +const DEFAULT_SHELL = "bash"; + +// useReadinessState owns the card's fetch + selection state so the component +// renders a thin view layer. Pulled out to keep the component body under the +// max-lines-per-function lint budget. +function useReadinessState({ + executorId, + shellProp, + onShellChange, +}: { + executorId: string; + shellProp?: string; + onShellChange?: (s: string) => void | Promise; +}) { + const [rows, setRows] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [hasProbed, setHasProbed] = useState(false); + const [shells, setShells] = useState(null); + const [shellsLoading, setShellsLoading] = useState(false); + const [shell, setShell] = useState(shellProp ?? DEFAULT_SHELL); + // Drop stale responses if the user clicks Refresh again before the + // previous request lands — same pattern as ssh-sessions-card. + const seqRef = useRef(0); + + const refresh = useCallback(async () => { + const seq = ++seqRef.current; + setLoading(true); + setError(null); + try { + const resp = await probeSSHAgents(executorId, { shell }); + if (seq !== seqRef.current) return; + setRows(resp.rows); + setHasProbed(true); + } catch (e) { + if (seq !== seqRef.current) return; + setError(e instanceof Error ? e.message : "Failed to probe agents"); + } finally { + if (seq === seqRef.current) setLoading(false); + } + }, [executorId, shell]); + + // Auto-probe the shells once on mount so the dropdown has real options. + // Cheap (one SSH dial, ~1s) and fully out-of-band of agent probing. + const probeShells = useCallback(async () => { + setShellsLoading(true); + try { + const resp = await probeSSHShells(executorId); + setShells(resp.available); + } catch { + // On failure the dropdown stays empty; user can type / save the shell + // via the profile form. We don't toast — a transient probe error + // shouldn't nag. + } finally { + setShellsLoading(false); + } + }, [executorId]); + + useEffect(() => { + // Switching to a different executor should reset the displayed + // probe state — otherwise the previous executor's rows / error / + // hasProbed sit in the UI until the next manual refresh, which + // reads like "still loading" while actually showing stale data. + seqRef.current = 0; + setRows([]); + setError(null); + setHasProbed(false); + setLoading(false); + void probeShells(); + return () => { + seqRef.current = -1; + }; + }, [executorId, probeShells]); + + const handleShellChange = useCallback( + async (next: string) => { + setShell(next); + // Stale rows = stale answer (PATH depends on shell init). Clear the + // table so the user re-probes explicitly with the new shell. + setRows([]); + setHasProbed(false); + if (onShellChange) { + try { + await onShellChange(next); + } catch (e) { + toast.error(e instanceof Error ? e.message : "Failed to save shell"); + } + } + }, + [onShellChange], + ); + + return { + rows, + loading, + error, + hasProbed, + shells, + shellsLoading, + shell, + refresh, + handleShellChange, + }; +} + +/** + * Probes the remote host for each kandev-enabled agent's required binary + * (the first token of the agent's BuildCommand) under the user-chosen login + * shell. Surfaces availability + a copy-button for the agent's install + * command — installs themselves stay manual so the user keeps full control + * over what runs on their machine. Manual refresh only — a real SSH dial + * happens per probe so we don't poll on a timer. + */ +export function SSHAgentReadinessCard({ + executorId, + shell: shellProp, + onShellChange, +}: SSHAgentReadinessCardProps) { + const state = useReadinessState({ executorId, shellProp, onShellChange }); + const { + rows, + loading, + error, + hasProbed, + shells, + shellsLoading, + shell, + refresh, + handleShellChange, + } = state; + + return ( + + +
+
+ Available agents on this host + + Probes the remote {"$PATH"} for each enabled agent under the chosen login shell. Copy + the install hint and run it on the remote when an agent is missing. + +
+ +
+ +
+ + + +
+ ); +} + +function ShellSelector({ + shell, + shells, + loading, + onChange, +}: { + shell: string; + shells: string[] | null; + loading: boolean; + onChange: (s: string) => void | Promise; +}) { + // While probing, show a placeholder option so the dropdown can't surface + // a stale "bash" selection that we haven't confirmed exists on the host. + // After probing, render whatever the host has, plus the current value so + // a saved shell that isn't actually installed is still selectable + loud. + const options = uniqueShellOptions(shell, shells); + return ( +
+ + + {loading ? : null} +
+ ); +} + +function uniqueShellOptions(currentShell: string, detected: string[] | null): string[] { + const set = new Set(); + if (detected) detected.forEach((s) => set.add(s)); + set.add(currentShell || DEFAULT_SHELL); + if (set.size === 0) set.add(DEFAULT_SHELL); + return Array.from(set); +} + +function ReadinessContent({ + error, + hasProbed, + rows, +}: { + error: string | null; + hasProbed: boolean; + rows: SSHAgentReadinessRow[]; +}) { + if (error) { + return ( +

+ {error} +

+ ); + } + if (!hasProbed) { + return ( +

+ Click {`"Probe agents"`} to check which agents are installed on the remote. +

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

No enabled agents to probe.

; + } + return ; +} + +function ReadinessTable({ rows }: { rows: SSHAgentReadinessRow[] }) { + return ( + + + + Agent + Binary + Status + Install hint + + + + {rows.map((row) => ( + + ))} + +
+ ); +} + +function ReadinessRow({ row }: { row: SSHAgentReadinessRow }) { + const slug = row.agent_id.replace(/[^a-z0-9]+/gi, "-"); + return ( + + {row.agent_name || row.agent_id} + {row.binary} + + + + + + + + ); +} + +function StatusBadge({ row }: { row: SSHAgentReadinessRow }) { + if (row.error) { + return ( + + Probe error + + ); + } + if (row.available) { + return ( + + Installed + + ); + } + return ( + + Missing + + ); +} + +function InstallHint({ hint, available }: { hint?: string; available: boolean }) { + if (available) return —; + if (!hint) return No hint available; + return ( +
+ {hint} + +
+ ); +} diff --git a/apps/web/components/settings/ssh-connection-card.tsx b/apps/web/components/settings/ssh-connection-card.tsx new file mode 100644 index 0000000000..e656278365 --- /dev/null +++ b/apps/web/components/settings/ssh-connection-card.tsx @@ -0,0 +1,606 @@ +"use client"; + +import { useCallback, useMemo, useState } from "react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Input } from "@kandev/ui/input"; +import { Label } from "@kandev/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; +import { + IconCheck, + IconLoader2, + IconShieldLock, + IconTerminal2, + IconTestPipe, + IconX, +} from "@tabler/icons-react"; +import { testSSHConnection } from "@/lib/api/domains/ssh-api"; +import { FingerprintTrustBlock } from "@/components/settings/ssh-fingerprint-trust-block"; +import type { + SSHIdentitySource, + SSHTestRequest, + SSHTestResult, + SSHTestStep, +} from "@/lib/types/http-ssh"; + +// SSHExecutorConfig is the shape we persist into executor.Config on save. +// The host_fingerprint is set only after a successful Test Connection has +// completed and the user has ticked "Trust this host". +export interface SSHExecutorConfig { + name: string; + host_alias?: string; + host?: string; + port?: number; + user?: string; + identity_source: SSHIdentitySource; + identity_file?: string; + proxy_jump?: string; + host_fingerprint?: string; +} + +export interface SSHConnectionCardProps { + initial?: Partial; + // Called when the user clicks Save after a successful test+trust. The + // returned config carries the freshly pinned fingerprint. + onSave: (config: SSHExecutorConfig) => Promise | void; + // Existing running sessions for this executor. Triggers the + // "this won't affect existing sessions" warning on save. + runningSessionCount?: number; +} + +interface SSHConnectionState { + form: SSHExecutorConfig; + testing: boolean; + saving: boolean; + result: SSHTestResult | null; + // resultStale flips true when the user edits a connection-affecting field + // after a successful test, so the prior fingerprint cannot be trusted for + // the current form. The result stays visible (the trust-gate spec expects + // the checkbox to render) but trust + save are gated off until the user + // re-runs Test Connection. + resultStale: boolean; + trust: boolean; + error: string | null; +} + +// Fields whose value, if changed, could route the next connection to a +// different machine — editing any of them invalidates the current trust tick +// so the user must re-test against the new target. +const CONNECTION_FIELDS = new Set([ + "host_alias", + "host", + "port", + "user", + "identity_source", + "identity_file", + "proxy_jump", +]); + +const SSH_FORM_DEFAULTS: SSHExecutorConfig = { + name: "", + host_alias: "", + host: "", + port: 22, + user: "", + identity_source: "agent", + identity_file: "", + proxy_jump: "", + host_fingerprint: undefined, +}; + +function initialState(initial?: Partial): SSHConnectionState { + return { + form: { ...SSH_FORM_DEFAULTS, ...(initial ?? {}) }, + testing: false, + saving: false, + result: null, + resultStale: false, + trust: false, + error: null, + }; +} + +function useSSHConnection(props: SSHConnectionCardProps) { + const [state, setState] = useState(() => initialState(props.initial)); + const { form, testing, saving, result, resultStale, trust, error } = state; + + const update = useCallback( + (key: K, value: SSHExecutorConfig[K]) => { + setState((prev) => { + const isConnectionField = CONNECTION_FIELDS.has(key); + // A connection edit invalidates the prior result. Mark stale when + // a test result is already on screen OR a test is mid-flight — in + // the latter case handleTest will see the staleness on completion + // and refuse to clear it, so a fingerprint returned for the old + // form can't be trusted against the new one. + const staleAfter = isConnectionField + ? prev.result !== null || prev.testing || prev.resultStale + : prev.resultStale; + return { + ...prev, + form: { ...prev.form, [key]: value }, + resultStale: staleAfter, + trust: isConnectionField ? false : prev.trust, + error: null, + }; + }); + }, + [], + ); + + const setTrust = useCallback((v: boolean) => setState((prev) => ({ ...prev, trust: v })), []); + + const canTest = useMemo(() => { + if (testing) return false; + if (form.name.trim() === "") return false; + if ((form.host ?? "").trim() === "" && (form.host_alias ?? "").trim() === "") return false; + return true; + }, [form, testing]); + + const handleTest = useCallback(async () => { + setState((prev) => ({ + ...prev, + testing: true, + result: null, + resultStale: false, + error: null, + })); + try { + const req: SSHTestRequest = { + name: form.name, + host_alias: form.host_alias || undefined, + host: form.host || undefined, + port: form.port || undefined, + user: form.user || undefined, + identity_source: form.identity_source, + identity_file: form.identity_file || undefined, + proxy_jump: form.proxy_jump || undefined, + }; + const res = await testSSHConnection(req); + setState((prev) => ({ + ...prev, + result: res, + // Preserve resultStale if the user edited a connection field while + // the test was in flight — the returned fingerprint is bound to the + // old form and must not be trusted against the new target. + resultStale: prev.resultStale, + testing: false, + })); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to reach backend"; + setState((prev) => ({ ...prev, error: msg, testing: false })); + } + }, [form]); + + const canSave = !!result?.success && !!result.fingerprint && trust && !resultStale && !saving; + + const confirmRunningSessions = useCallback( + () => + props.runningSessionCount + ? window.confirm( + `This executor has ${props.runningSessionCount} running session(s). ` + + `They will keep running on the current host. Only new sessions started ` + + `after save will use the updated config. Continue?`, + ) + : true, + [props.runningSessionCount], + ); + + const handleSave = useCallback(async () => { + if (!canSave || !result?.fingerprint) return; + if (!confirmRunningSessions()) return; + setState((prev) => ({ ...prev, saving: true, error: null })); + try { + await props.onSave({ ...form, host_fingerprint: result.fingerprint }); + setState((prev) => ({ ...prev, saving: false })); + } catch (e) { + const msg = e instanceof Error ? e.message : "Failed to save executor"; + setState((prev) => ({ ...prev, saving: false, error: msg })); + } + }, [canSave, confirmRunningSessions, form, props, result]); + + return { + form, + testing, + saving, + result, + resultStale, + trust, + error, + canTest, + canSave, + update, + setTrust, + handleTest, + handleSave, + }; +} + +export function SSHConnectionCard(props: SSHConnectionCardProps) { + const c = useSSHConnection(props); + return ( + + +
+
+ + + Connection + + + Run an agent on any Linux box you can reach over SSH. linux/amd64 hosts only. + +
+ +
+
+ + + {c.form.host_fingerprint && } + + {c.error && ( +

+ {c.error} +

+ )} + {c.result && ( + + )} +
+
+ ); +} + +type FieldOnChange = ( + key: K, + value: SSHExecutorConfig[K], +) => void; + +function SSHConnectionForm({ + form, + onChange, +}: { + form: SSHExecutorConfig; + onChange: FieldOnChange; +}) { + return ( +
+ onChange("name", v)} + /> + onChange("host_alias", v)} + /> + onChange("host", v)} + /> + onChange("port", parseInt(v, 10) || 22)} + /> + onChange("user", v)} + /> + onChange("identity_source", v)} + /> + {form.identity_source === "file" && ( + onChange("identity_file", v)} + /> + )} + onChange("proxy_jump", v)} + /> +
+ ); +} + +function TextField({ + id, + testId, + label, + hint, + placeholder, + type, + value, + onChange, +}: { + id: string; + testId: string; + label: string; + hint?: string; + placeholder?: string; + type?: string; + value: string; + onChange: (v: string) => void; +}) { + return ( + + onChange(e.target.value)} + /> + + ); +} + +function IdentitySourceField({ + value, + onChange, +}: { + value: SSHIdentitySource; + onChange: (v: SSHIdentitySource) => void; +}) { + return ( + + + + ); +} + +function FieldShell({ + id, + label, + hint, + children, +}: { + id: string; + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+ + {children} + {hint &&

{hint}

} +
+ ); +} + +function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) { + return ( +
+ + + Pinned fingerprint:{" "} + + {fingerprint} + + +
+ ); +} + +function SSHConnectionActions({ + testing, + saving, + canTest, + canSave, + onTest, + onSave, +}: { + testing: boolean; + saving: boolean; + canTest: boolean; + canSave: boolean; + onTest: () => void; + onSave: () => void; +}) { + return ( +
+ + +
+ ); +} + +function ConnectionBadge({ fingerprint }: { fingerprint?: string }) { + if (!fingerprint) { + return ( + + Unverified + + ); + } + return ( + + Trusted + + ); +} + +function TestResultDisplay({ + result, + trust, + resultStale, + onTrustChange, + currentlyPinned, +}: { + result: SSHTestResult; + trust: boolean; + resultStale: boolean; + onTrustChange: (v: boolean) => void; + currentlyPinned?: string; +}) { + return ( +
+ + {result.steps.map((step: SSHTestStep) => ( + + ))} + {result.error && !result.steps.some((s) => s.error) && ( +

+ {result.error} +

+ )} + {result.success && result.fingerprint && ( + + )} +
+ ); +} + +function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: number }) { + return ( +
+ {success ? ( + + ) : ( + + )} + {success ? "Connection test passed" : "Connection test failed"} + ({totalMs}ms) +
+ ); +} + +function StepRow({ step }: { step: SSHTestStep }) { + const slug = step.name.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return ( +
+ {step.success ? ( + + ) : ( + + )} +
+
+ {step.name} + ({step.duration_ms}ms) +
+ {step.output && ( +

+ {step.output} +

+ )} + {step.error && ( +

+ {step.error} +

+ )} +
+
+ ); +} diff --git a/apps/web/components/settings/ssh-fingerprint-trust-block.tsx b/apps/web/components/settings/ssh-fingerprint-trust-block.tsx new file mode 100644 index 0000000000..38a6bbe105 --- /dev/null +++ b/apps/web/components/settings/ssh-fingerprint-trust-block.tsx @@ -0,0 +1,62 @@ +"use client"; + +// Trust-confirmation block shown under the SSH test result. Disabled when the +// caller's last test ran against a different form, to keep stale fingerprints +// from being trusted for a new target. +export function FingerprintTrustBlock({ + fingerprint, + currentlyPinned, + trust, + resultStale, + onTrustChange, +}: { + fingerprint: string; + currentlyPinned?: string; + trust: boolean; + resultStale: boolean; + onTrustChange: (v: boolean) => void; +}) { + const fingerprintChanged = !!currentlyPinned && currentlyPinned !== fingerprint; + return ( +
+
+ Host fingerprint observed: + + {fingerprint} + +
+ {fingerprintChanged && ( +

+ Warning: this fingerprint differs from the one currently pinned ( + {currentlyPinned}). Trusting it overwrites the pinned + key. If you didn’t expect a host re-key, stop here. +

+ )} + {resultStale && ( +

+ You changed a connection field after this test ran. Run Test Connection again before + trusting and saving. +

+ )} + +
+ ); +} diff --git a/apps/web/components/settings/ssh-sessions-card.tsx b/apps/web/components/settings/ssh-sessions-card.tsx new file mode 100644 index 0000000000..aaf06bb684 --- /dev/null +++ b/apps/web/components/settings/ssh-sessions-card.tsx @@ -0,0 +1,176 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { Badge } from "@kandev/ui/badge"; +import { Button } from "@kandev/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@kandev/ui/table"; +import { IconLoader2 } from "@tabler/icons-react"; +import { listSSHSessions } from "@/lib/api/domains/ssh-api"; +import type { SSHSession } from "@/lib/types/http-ssh"; + +export interface SSHSessionsCardProps { + executorId: string; +} + +const REFRESH_INTERVAL_MS = 90_000; + +export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) { + const [sessions, setSessions] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + // Monotonic sequence used to ignore stale poll responses: a slow request + // that resolves after a newer one (or after executorId changes) would + // otherwise overwrite the fresh row set. + const seqRef = useRef(0); + + const refresh = useCallback(async () => { + const seq = ++seqRef.current; + setLoading(true); + setError(null); + try { + const rows = await listSSHSessions(executorId); + if (seq !== seqRef.current) return; // a newer call (or executor switch) won the race + setSessions(rows); + } catch (e) { + if (seq !== seqRef.current) return; + setError(e instanceof Error ? e.message : "Failed to load sessions"); + } finally { + if (seq === seqRef.current) setLoading(false); + } + }, [executorId]); + + useEffect(() => { + // Reset sequence so a previous executor's pending response can't land + // here and pollute the new executor's data. + seqRef.current = 0; + refresh(); + const id = setInterval(refresh, REFRESH_INTERVAL_MS); + return () => { + clearInterval(id); + // Bump the sequence to invalidate any in-flight response from the + // unmounted instance. + seqRef.current = -1; + }; + }, [refresh]); + + return ( + + +
+
+ Active sessions + + Sessions currently running on this SSH host. Refreshes every 90 seconds. + +
+ +
+
+ + + +
+ ); +} + +function SSHSessionsBody({ + loading, + error, + sessions, +}: { + loading: boolean; + error: string | null; + sessions: SSHSession[]; +}) { + if (error) { + return ( +

+ {error} +

+ ); + } + if (sessions.length === 0 && !loading) { + return ( +

+ No active sessions. +

+ ); + } + if (sessions.length === 0) return null; + return ; +} + +function SSHSessionsTable({ sessions }: { sessions: SSHSession[] }) { + return ( + + + + Task + Session + Host + Remote port + Local fwd + Uptime + Status + + + + {sessions.map((s) => ( + + ))} + +
+ ); +} + +function SSHSessionsRow({ session: s }: { session: SSHSession }) { + return ( + + + {s.task_id.slice(0, 8)} + + + {s.session_id.slice(0, 8)} + + + {s.user ? `${s.user}@${s.host}` : s.host} + + + {s.remote_agentctl_port ?? "—"} + + + {s.local_forward_port ?? "—"} + + + {formatUptime(s.uptime_seconds)} + + + + {s.status} + + + + ); +} + +function formatUptime(s: number): string { + if (s < 60) return `${s}s`; + if (s < 3600) return `${Math.floor(s / 60)}m`; + if (s < 86400) return `${Math.floor(s / 3600)}h`; + return `${Math.floor(s / 86400)}d`; +} diff --git a/apps/web/components/settings/ssh-settings.tsx b/apps/web/components/settings/ssh-settings.tsx new file mode 100644 index 0000000000..534683dbb3 --- /dev/null +++ b/apps/web/components/settings/ssh-settings.tsx @@ -0,0 +1,9 @@ +"use client"; + +// SSH executor settings UI. The two cards live in their own files so each +// stays under the component size limits; import from here to keep call sites +// short. +export { SSHConnectionCard } from "./ssh-connection-card"; +export type { SSHConnectionCardProps, SSHExecutorConfig } from "./ssh-connection-card"; +export { SSHSessionsCard } from "./ssh-sessions-card"; +export type { SSHSessionsCardProps } from "./ssh-sessions-card"; diff --git a/apps/web/components/task/executor-environment-info.tsx b/apps/web/components/task/executor-environment-info.tsx index e879ed9472..22a9708073 100644 --- a/apps/web/components/task/executor-environment-info.tsx +++ b/apps/web/components/task/executor-environment-info.tsx @@ -7,6 +7,7 @@ import { toast } from "sonner"; import { type ContainerLiveStatus, + type SSHLiveStatus, type TaskEnvironment, } from "@/lib/api/domains/task-environment-api"; import { resolveExecutorEnvironmentStatus, type StatusTone } from "./executor-environment-status"; @@ -22,10 +23,12 @@ const TONE_CLASSES: Record = { export function EnvironmentInfo({ env, container, + ssh, loading, }: { env: TaskEnvironment | null; container: ContainerLiveStatus | null; + ssh?: SSHLiveStatus | null; loading: boolean; }) { if (loading && !env) { @@ -53,7 +56,7 @@ export function EnvironmentInfo({ {formatExecutorType(env.executor_type)} - + ); } @@ -79,24 +82,42 @@ function StatusBadge({ function EnvironmentFields({ env, container, + ssh, }: { env: TaskEnvironment; container: ContainerLiveStatus | null; + ssh: SSHLiveStatus | null; }) { - const fields = useMemo(() => buildFields(env, container), [env, container]); + const fields = useMemo(() => buildFields(env, container, ssh), [env, container, ssh]); if (fields.length === 0) { return

No resource details available.

; } return (
{fields.map((f) => ( - + ))}
); } -function Field({ label, value, copy }: { label: string; value: string; copy?: boolean }) { +function Field({ + label, + value, + copy, + copyValue, +}: { + label: string; + value: string; + copy?: boolean; + copyValue?: string; +}) { return (
{label}
@@ -109,7 +130,7 @@ function Field({ label, value, copy }: { label: string; value: string; copy?: bo aria-label={`Copy ${label}`} onClick={() => { void navigator.clipboard - .writeText(value) + .writeText(copyValue ?? value) .then(() => toast.success(`${label} copied`)); }} > @@ -121,9 +142,16 @@ function Field({ label, value, copy }: { label: string; value: string; copy?: bo ); } -type FieldRow = { label: string; value: string; copy?: boolean }; +// copyValue overrides the clipboard payload when the displayed value is a +// truncated representation (fingerprint hash suffix, …) — copying the +// truncation is useless. Omit to copy `value` verbatim. +type FieldRow = { label: string; value: string; copy?: boolean; copyValue?: string }; -function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null): FieldRow[] { +function buildFields( + env: TaskEnvironment, + container: ContainerLiveStatus | null, + ssh: SSHLiveStatus | null, +): FieldRow[] { const rows: FieldRow[] = []; if (env.worktree_path) { @@ -153,9 +181,71 @@ function buildFields(env: TaskEnvironment, container: ContainerLiveStatus | null rows.push({ label: "Sprite", value: env.sandbox_id, copy: true }); } + if (env.executor_type === "ssh" && ssh) { + addSshRows(rows, ssh); + } + return rows; } +function addSshRows(rows: FieldRow[], ssh: SSHLiveStatus) { + // user@host[:port] — matches what a user would paste into an SSH client. + // Suppress :22 since the canonical port reads as noise. + if (ssh.host) { + rows.push({ label: "Host", value: formatHostTarget(ssh), copy: true }); + } + if (ssh.remote_task_dir) { + rows.push({ label: "Workdir", value: ssh.remote_task_dir, copy: true }); + } + const agentctl = formatAgentctlSummary(ssh); + if (agentctl) { + rows.push({ label: "Agentctl", value: agentctl }); + } + if (ssh.fingerprint) { + rows.push({ + label: "Fingerprint", + value: formatFingerprint(ssh.fingerprint), + copy: true, + copyValue: ssh.fingerprint, + }); + } + // Shell affordance: paste-ready ssh command that mirrors how kandev + // connects. Helpful when the user wants to inspect the remote dir by hand. + if (ssh.host) { + rows.push({ label: "Shell", value: formatShellCommand(ssh), copy: true }); + } +} + +function formatHostTarget(ssh: SSHLiveStatus): string { + const userPart = ssh.user ? `${ssh.user}@` : ""; + const portPart = ssh.port && ssh.port !== 22 ? `:${ssh.port}` : ""; + return `${userPart}${ssh.host ?? ""}${portPart}`; +} + +function formatShellCommand(ssh: SSHLiveStatus): string { + const userPart = ssh.user ? `${ssh.user}@` : ""; + const portPart = ssh.port && ssh.port !== 22 ? ` -p ${ssh.port}` : ""; + return `ssh${portPart} ${userPart}${ssh.host ?? ""}`; +} + +// Compact one-liner for the agentctl process: pid → remote:port → local:port. +// Each is optional (e.g. a recovered session may have lost the local +// forward port mid-restore), so the helper omits gracefully. Returns "" +// when nothing is set so callers can skip the row entirely. +function formatAgentctlSummary(ssh: SSHLiveStatus): string { + const parts: string[] = []; + if (ssh.remote_agentctl_pid) parts.push(`pid ${ssh.remote_agentctl_pid}`); + if (ssh.remote_agentctl_port) parts.push(`remote :${ssh.remote_agentctl_port}`); + if (ssh.local_forward_port) parts.push(`local :${ssh.local_forward_port}`); + return parts.join(" → "); +} + +// Show the suffix only — full fingerprint is verbose, and the trust gate +// already pinned it on save. The copy button hands back the full string. +function formatFingerprint(fingerprint: string): string { + return fingerprint.startsWith("SHA256:") ? `…${fingerprint.slice(-12)}` : fingerprint; +} + function formatUptime(startedAt: string): string { const startedMs = Date.parse(startedAt); if (Number.isNaN(startedMs)) return startedAt; @@ -178,6 +268,8 @@ function formatExecutorType(type: string): string { return "Sprites sandbox"; case "remote_docker": return "Remote Docker"; + case "ssh": + return "SSH"; default: return type || "Unknown executor"; } diff --git a/apps/web/components/task/executor-settings-button.tsx b/apps/web/components/task/executor-settings-button.tsx index 33344a870b..149b996e44 100644 --- a/apps/web/components/task/executor-settings-button.tsx +++ b/apps/web/components/task/executor-settings-button.tsx @@ -31,7 +31,7 @@ export function ExecutorSettingsButton({ const isPreparing = isPreparingPhase(prepare.phase); // Promote the foreground polling cadence while preparing so the icon flips // to "ready" without the user hovering over the trigger. - const { env, container, loading, isResetting, reset, status } = useTaskEnvironment( + const { env, container, ssh, loading, isResetting, reset, status } = useTaskEnvironment( taskId, open || isPreparing, ); @@ -80,7 +80,7 @@ export function ExecutorSettingsButton({ data-testid="executor-settings-popover" > - +