From 2711727c3c94e32f966dc396deb35a7b8e8d4254 Mon Sep 17 00:00:00 2001 From: jack Date: Tue, 23 Jun 2026 02:44:11 +0800 Subject: [PATCH 1/2] feat(web): Docker container workspaces (executor, lifecycle, terminal) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bind a task to a Docker container from the remote-connect wizard, alongside SSH. Implemented with the Docker Go SDK. Backend - DockerExecutor (internal/tools/docker_exec.go): runs all file/command ops inside the container via `docker exec`, mirroring SSHExecutor (cat / base64 / test / mkdir). Shared daemon client (client.FromEnv → honors DOCKER_HOST). - Lifecycle (A1 + ref-count): a stopped container is started on connect and stopped again once the last task using it is torn down. Containers the user already had running are never stopped. A one-shot container that exits right after start is rejected with its logs instead of failing silently. - Generalized the engine/remote plumbing from *tools.SSHExecutor to a new tools.RemoteExecutor interface (SetRemote, IsRemote, ProjectLabel as a method; CloseRemote released on engine teardown). - Terminal into the container: ptyManager grew a backend interface; a TTY `docker exec` backend (bash→sh) powers the embedded terminal for container-bound engines (SSH/local stay on a local shell). - Wizard endpoints: GET /api/docker/containers, docker branch in /api/remote/connect, docker save-alias; docker_aliases config + switch_env support so the agent can switch into a container too. Frontend - RemoteConnectWizard: enabled the Docker method with a container picker; bind/reconnect handle docker:// workspaces. project store parses docker:// labels; types/api + composable updated. Tests - Daemon-gated integration tests (skip without Docker) create and clean up their own throwaway container and verify A1 start, exec, file roundtrip, stat, and ref-count auto-stop, plus one-shot rejection. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + go.mod | 34 +- go.sum | 93 ++++- internal/command/web.go | 9 +- internal/config/config.go | 8 + internal/remote/docker.go | 57 +++ internal/tools/docker_exec.go | 392 +++++++++++++++++++++ internal/tools/docker_exec_test.go | 130 +++++++ internal/tools/env.go | 41 ++- internal/tools/switch_env.go | 65 +++- internal/web/engine.go | 10 +- internal/web/pty.go | 184 ++++++++-- internal/web/remote.go | 116 +++++- internal/web/server.go | 36 +- web/src/components/RemoteConnectWizard.vue | 206 +++++++++-- web/src/composables/api.ts | 9 +- web/src/i18n/locales/en.ts | 8 + web/src/stores/project.ts | 17 +- web/src/types/api.ts | 30 +- 19 files changed, 1316 insertions(+), 130 deletions(-) create mode 100644 internal/remote/docker.go create mode 100644 internal/tools/docker_exec.go create mode 100644 internal/tools/docker_exec_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index a141739..e2480cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Built-in color themes, unified across terminal and web.** A new single source of truth (`internal/theme`) defines 7 themes — 4 dark (jcode Dark, Midnight, Dracula, Nord) and 3 light (jcode Light, GitHub Light, Solarized Light) — as a typed semantic palette. `go generate` emits the web CSS (`[data-theme]` blocks) and the picker registry from that one Go file, so the two renderers can never drift. - **`/theme` command** in the TUI opens a live-preview selector: arrow keys repaint the whole UI, Enter applies and persists to `config.theme`, Esc reverts. When no theme is persisted, the startup default is auto-selected from the terminal background. New `theme` config field. - **Appearance settings tab** in the web UI: a System (follow-OS) option plus dark/light swatch grids that render a true mini-preview of each theme. Themes apply via `html[data-theme]`; the legacy light/dark/system localStorage values migrate automatically. +- **Docker container workspaces (web).** The remote-connect wizard can now bind a task to a Docker container, alongside SSH. A new `DockerExecutor` (Docker Go SDK, `client.FromEnv` → honors `DOCKER_HOST`) runs all agent file/command operations inside the container via `docker exec`, mirroring the SSH executor. A stopped container is started on connect and stopped again (ref-counted) once no task is using it; a one-shot container that exits immediately is reported with its logs rather than failing silently. The embedded terminal opens a real TTY *inside* the bound container (`docker exec`, bash→sh). Container-bound tasks are keyed `docker:///`, and the `switch_env` tool plus saved Docker aliases (`docker_aliases` in config) cover reconnects. Daemon-gated integration tests cover the lifecycle. ### Changed - Renamed the session modes to **Ask for approval / Plan / Full access** across the web UI, terminal UI, and ACP. Their canonical IDs are now `approval` / `plan` / `full_access`; the old `ask`, `agent`, and `autopilot` IDs are no longer accepted. diff --git a/go.mod b/go.mod index 31e1419..7acc7fb 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/cloudwego/eino-ext/libs/acl/langfuse v0.1.1 github.com/coder/acp-go-sdk v0.13.5 github.com/creack/pty v1.1.24 + github.com/docker/docker v28.5.2+incompatible github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/mark3labs/mcp-go v0.54.1 @@ -20,11 +21,12 @@ require ( github.com/rivo/uniseg v0.4.7 github.com/sashabaranov/go-openai v1.41.2 github.com/spf13/cobra v1.10.2 - golang.org/x/crypto v0.50.0 + golang.org/x/crypto v0.51.0 tinygo.org/x/bluetooth v0.15.0 ) require ( + github.com/Microsoft/go-winio v0.6.2 // indirect github.com/alecthomas/chroma/v2 v2.14.0 // indirect github.com/atotto/clipboard v0.1.4 // indirect github.com/aymerick/douceur v0.2.0 // indirect @@ -35,6 +37,7 @@ require ( github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect @@ -44,10 +47,19 @@ require ( github.com/clipperhouse/displaywidth v0.11.0 // indirect github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect + github.com/containerd/errdefs v1.0.0 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/distribution/reference v0.6.0 // indirect github.com/dlclark/regexp2 v1.11.0 // indirect + github.com/docker/go-connections v0.7.0 // indirect + github.com/docker/go-units v0.5.0 // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/eino-contrib/jsonschema v1.0.3 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect @@ -60,10 +72,16 @@ require ( github.com/mailru/easyjson v0.9.0 // indirect github.com/mattn/go-runewidth v0.0.23 // indirect github.com/microcosm-cc/bluemonday v1.0.27 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/sys/atomicwriter v0.1.0 // indirect + github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/morikuni/aec v1.1.0 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/nikolalohinski/gonja v1.5.3 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.2-0.20201214064552-5dd12d0cfe7f // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -86,13 +104,21 @@ require ( github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.8 // indirect github.com/yuin/goldmark-emoji v1.0.5 // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 // indirect + go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect + go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/trace v1.44.0 // indirect golang.org/x/arch v0.19.0 // indirect golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect - golang.org/x/net v0.52.0 // indirect + golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.45.0 // indirect - golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect + golang.org/x/term v0.43.0 // indirect + golang.org/x/text v0.37.0 // indirect + golang.org/x/time v0.15.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect + gotest.tools/v3 v3.5.2 // indirect rsc.io/qr v0.2.0 // indirect ) diff --git a/go.sum b/go.sum index 291d69c..ba1f852 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,12 @@ charm.land/glamour/v2 v2.0.0 h1:IDBoqLEy7Hdpb9VOXN+khLP/XSxtJy1VsHuW/yF87+U= charm.land/glamour/v2 v2.0.0/go.mod h1:kjq9WB0s8vuUYZNYey2jp4Lgd9f4cKdzAw88FZtpj/w= charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU= charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/airbrake/gobrake v3.6.1+incompatible/go.mod h1:wM4gu3Cn0W0K7GUuVWnlXZU11AGBXMILnrdOU8Kn00o= github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE= github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= @@ -41,7 +45,11 @@ github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiD github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/certifi/gocertifi v0.0.0-20190105021004-abcd57078448/go.mod h1:GJKEexRPVJrBSOjoqN5VNOIKJ5Q3RViH6eu3puDRwx4= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q= github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q= github.com/charmbracelet/ultraviolet v0.0.0-20260525132238-948f4557a654 h1:FpSYhY28ucg9ZRr+2wj67FAQ0Ey5yiK0072PmRDJNek= @@ -72,6 +80,12 @@ github.com/cloudwego/eino-ext/libs/acl/langfuse v0.1.1 h1:y7LwRyPGcSLPkt/A9HJf+0 github.com/cloudwego/eino-ext/libs/acl/langfuse v0.1.1/go.mod h1:mQDhDvELAUjbAl/iJkFqMa1dM8y152OGUpCsy3wBCis= github.com/coder/acp-go-sdk v0.13.5 h1:LI9jq5xon7xslaYlnoktvTVyDlE37yIk2daT7N9ASYk= github.com/coder/acp-go-sdk v0.13.5/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= @@ -79,18 +93,33 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eino-contrib/jsonschema v1.0.3 h1:2Kfsm1xlMV0ssY2nuxshS4AwbLFuqmPmzIjLVJ1Fsp0= github.com/eino-contrib/jsonschema v1.0.3/go.mod h1:cpnX4SyKjWjGC7iN2EbhxaTdLqGjCi0e9DxpLYxddD4= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/getsentry/raven-go v0.2.0/go.mod h1:KungGk8q33+aIAZUIVWZDr2OfAEBsO49PX4NzFV5kcQ= github.com/go-check/check v0.0.0-20180628173108-788fd7840127 h1:0gkP6mzaMqkmpcJYCFOLkIBwI7xFExG03bbkOkCvUPI= github.com/go-check/check v0.0.0-20180628173108-788fd7840127/go.mod h1:9ES+weclKsC9YodN5RgxqK/VD9HM9JsCSh7rNhMZE98= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= @@ -112,6 +141,8 @@ github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= @@ -152,11 +183,21 @@ github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1f github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/nikolalohinski/gonja v1.5.3 h1:GsA+EEaZDZPGJ8JtpeGN78jidhOlxeJROpqMT9fTj9c= @@ -164,6 +205,10 @@ github.com/nikolalohinski/gonja v1.5.3/go.mod h1:RmjwxNiXAEqcq1HeK5SSMmqFJvKOfTf github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.8.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/gomega v1.5.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -243,19 +288,39 @@ github.com/yuin/goldmark v1.7.8 h1:iERMLn0/QJeHFhxSt3p6PeN9mGnvIKSpG9YYorDMnic= github.com/yuin/goldmark v1.7.8/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0 h1:8tvICD4vSTOOsNrsI4Ljf6C+6UKvpTEH5XY3JMoyPoo= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.69.0/go.mod h1:z9+yiacE0IHRqM4qFfkbt/JYlmYXgss8GY/jXoNuPJI= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/arch v0.19.0 h1:LmbDQUodHThXE+htjrnmVD73M//D9GTH6wFZjyDkjyU= golang.org/x/arch v0.19.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +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-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -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.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= +golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= @@ -266,11 +331,21 @@ golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +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.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +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.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8= +google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= @@ -280,6 +355,8 @@ gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= tinygo.org/x/bluetooth v0.15.0 h1:hLn8+iZFXvVxBzPIdZfvc6TD8JP32ixF22lCEWHAbIo= diff --git a/internal/command/web.go b/internal/command/web.go index ec12f2d..7b6e262 100644 --- a/internal/command/web.go +++ b/internal/command/web.go @@ -28,7 +28,6 @@ import ( internalmodel "github.com/cnjack/jcode/internal/model" weixin "github.com/cnjack/jcode/internal/pkg/weixin" "github.com/cnjack/jcode/internal/prompts" - "github.com/cnjack/jcode/internal/remote" "github.com/cnjack/jcode/internal/runner" "github.com/cnjack/jcode/internal/session" "github.com/cnjack/jcode/internal/skills" @@ -209,7 +208,7 @@ func runWebServer(port int, host string, openBrowser bool) error { // approval state, plan store, and event handler — so concurrent tasks never // share mutable execution state. exec != nil binds the task to a remote SSH // target instead of a local pwd. taskID != "" resumes an existing session. - buildWebTask := func(taskID, taskPwd, modeStr string, exec *tools.SSHExecutor) (*web.EngineConfig, error) { + buildWebTask := func(taskID, taskPwd, modeStr string, exec tools.RemoteExecutor) (*web.EngineConfig, error) { startMode := startupMode if modeStr != "" { startMode = mode.Parse(modeStr) @@ -227,10 +226,10 @@ func runWebServer(port int, host string, openBrowser bool) error { // for the path-agnostic slash/list/toggle management UI.) taskLoader := skills.NewLoaderWithDisabled(cfg.DisabledSkills) if exec != nil { - tenv.SetSSH(exec, taskPwd) + tenv.SetRemote(exec, taskPwd) promptPlatform = exec.Platform() envLabel = fmt.Sprintf("%s (pwd: %s)", exec.Label(), taskPwd) - projectKey = remote.ProjectLabel(exec, taskPwd) + projectKey = exec.ProjectLabel(taskPwd) } else { taskLoader.ScanProjectSkills(taskPwd) taskEnvInfo = util.CollectEnvInfo(taskPwd) @@ -509,7 +508,7 @@ func runWebServer(port int, host string, openBrowser bool) error { NewEngine: func(taskID, taskPwd, modeStr string) (*web.EngineConfig, error) { return buildWebTask(taskID, taskPwd, modeStr, nil) }, - NewRemoteEngine: func(taskID string, exec *tools.SSHExecutor, remotePwd, modeStr string) (*web.EngineConfig, error) { + NewRemoteEngine: func(taskID string, exec tools.RemoteExecutor, remotePwd, modeStr string) (*web.EngineConfig, error) { return buildWebTask(taskID, remotePwd, modeStr, exec) }, InitialMode: startupMode.String(), diff --git a/internal/config/config.go b/internal/config/config.go index 734c735..46dc096 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -41,6 +41,13 @@ type SSHAlias struct { Path string `json:"path,omitempty"` // remote working directory } +// DockerAlias represents a saved Docker container alias +type DockerAlias struct { + Name string `json:"name"` + Container string `json:"container"` // container name or id + Path string `json:"path,omitempty"` // working directory inside the container +} + // MCPServer represents a configured MCP server connection type MCPServer struct { Type string `json:"type,omitempty"` @@ -151,6 +158,7 @@ type Config struct { MaxIterations int `json:"max_iterations,omitempty"` SSHAliases []SSHAlias `json:"ssh_aliases,omitempty"` + DockerAliases []DockerAlias `json:"docker_aliases,omitempty"` MCPServers map[string]*MCPServer `json:"mcp_servers,omitempty"` Telemetry *TelemetryConfig `json:"telemetry,omitempty"` Budget *BudgetConfig `json:"budget,omitempty"` diff --git a/internal/remote/docker.go b/internal/remote/docker.go new file mode 100644 index 0000000..65a6f7f --- /dev/null +++ b/internal/remote/docker.go @@ -0,0 +1,57 @@ +package remote + +import ( + "context" + "strings" + + "github.com/docker/docker/api/types/container" + + "github.com/cnjack/jcode/internal/tools" +) + +// ContainerInfo is a UI-friendly summary of a Docker container for the +// remote-connect wizard's container picker. +type ContainerInfo struct { + ID string `json:"id"` + Name string `json:"name"` + Image string `json:"image"` + State string `json:"state"` // created / running / exited / ... + Status string `json:"status"` // human string, e.g. "Up 3 hours" + Running bool `json:"running"` +} + +// ListContainers returns all containers (running and stopped), most useful +// first is left to the caller. It talks to the daemon configured via DOCKER_HOST +// (local socket by default). +func ListContainers(ctx context.Context) ([]ContainerInfo, error) { + cli, err := tools.DockerClient() + if err != nil { + return nil, err + } + summaries, err := cli.ContainerList(ctx, container.ListOptions{All: true}) + if err != nil { + return nil, err + } + out := make([]ContainerInfo, 0, len(summaries)) + for _, s := range summaries { + name := "" + if len(s.Names) > 0 { + name = strings.TrimPrefix(s.Names[0], "/") + } + out = append(out, ContainerInfo{ + ID: s.ID, + Name: name, + Image: s.Image, + State: string(s.State), + Status: s.Status, + Running: string(s.State) == "running", + }) + } + return out, nil +} + +// ConnectDocker binds to a container by id or name, starting it if stopped +// (A1 semantics; see tools.AcquireDockerContainer). +func ConnectDocker(ctx context.Context, containerRef string) (*tools.DockerExecutor, error) { + return tools.AcquireDockerContainer(ctx, containerRef) +} diff --git a/internal/tools/docker_exec.go b/internal/tools/docker_exec.go new file mode 100644 index 0000000..ea741c8 --- /dev/null +++ b/internal/tools/docker_exec.go @@ -0,0 +1,392 @@ +package tools + +import ( + "bytes" + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + + appconfig "github.com/cnjack/jcode/internal/config" +) + +// --------------------------------------------------------------------------- +// Shared daemon client — one per process. FromEnv honors DOCKER_HOST / TLS, so +// a remote daemon (tcp:// or ssh://) works transparently. The client is never +// closed by an executor: many executors share it. +// --------------------------------------------------------------------------- + +var ( + dockerOnce sync.Once + dockerCli *client.Client + dockerErr error +) + +// DockerClient returns the process-wide Docker client, creating it on first use. +func DockerClient() (*client.Client, error) { + dockerOnce.Do(func() { + dockerCli, dockerErr = client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + }) + if dockerErr != nil { + return nil, dockerErr + } + return dockerCli, nil +} + +// --------------------------------------------------------------------------- +// Lifecycle ref-counting — only for containers WE started (A1). A container the +// user already had running is never counted and never stopped by us. The last +// release of a container we started stops it. +// --------------------------------------------------------------------------- + +var ( + dockerRefMu sync.Mutex + dockerRefs = map[string]int{} +) + +func dockerAcquireRef(id string) { + dockerRefMu.Lock() + dockerRefs[id]++ + dockerRefMu.Unlock() +} + +// dockerReleaseRef decrements the ref-count for a container we started and, when +// it reaches zero, stops the container. stop=false skips the stop (used when the +// container already exited on its own). +func dockerReleaseRef(id string, stop bool) { + dockerRefMu.Lock() + n := dockerRefs[id] - 1 + if n <= 0 { + delete(dockerRefs, id) + } else { + dockerRefs[id] = n + } + dockerRefMu.Unlock() + if n <= 0 && stop { + if cli, err := DockerClient(); err == nil { + _ = cli.ContainerStop(context.Background(), id, container.StopOptions{}) + appconfig.Logger().Printf("[docker] stopped container %s (last reference released)", shortID(id)) + } + } +} + +// --------------------------------------------------------------------------- +// DockerExecutor — runs everything inside a container via `docker exec`. File +// operations are shelled (cat / base64 / test), identical in spirit to +// SSHExecutor, so symlinks and text/binary content behave the same way. +// --------------------------------------------------------------------------- + +type DockerExecutor struct { + cli *client.Client + containerID string + name string // display name without the leading slash + platform string + startedByUs bool +} + +// AcquireDockerContainer binds to a container by id or name. A1 semantics: a +// stopped container is started as-is (no CMD override) and polled until it +// reports Running; a container whose main process exits immediately yields an +// error carrying its last logs. Containers we start are ref-counted and stopped +// on the last Close. +func AcquireDockerContainer(ctx context.Context, containerRef string) (*DockerExecutor, error) { + cli, err := DockerClient() + if err != nil { + return nil, fmt.Errorf("docker client: %w", err) + } + info, err := cli.ContainerInspect(ctx, containerRef) + if err != nil { + return nil, fmt.Errorf("inspect container %q: %w", containerRef, err) + } + name := strings.TrimPrefix(info.Name, "/") + running := info.State != nil && info.State.Running + + // A container is "ours" while we hold any ref on it (we started it earlier). + dockerRefMu.Lock() + alreadyOurs := dockerRefs[info.ID] > 0 + dockerRefMu.Unlock() + + startedByUs := false + switch { + case !running: + appconfig.Logger().Printf("[docker] starting stopped container %s (%s)", shortID(info.ID), name) + if err := cli.ContainerStart(ctx, info.ID, container.StartOptions{}); err != nil { + return nil, fmt.Errorf("start container %q: %w", name, err) + } + dockerAcquireRef(info.ID) + startedByUs = true + if err := waitRunning(ctx, cli, info.ID); err != nil { + // The container's main process exited right after start: it is not a + // long-running container. Roll back our ref (it already stopped) and + // surface the tail of its logs so the user understands why. + dockerReleaseRef(info.ID, false) + logs := tailDockerLogs(ctx, cli, info.ID, 20) + if logs != "" { + return nil, fmt.Errorf("%w\n--- container logs (last lines) ---\n%s", err, logs) + } + return nil, err + } + case alreadyOurs: + // Running because WE started it for another engine/session: take an + // additional ref so the container isn't stopped until every user releases. + dockerAcquireRef(info.ID) + startedByUs = true + default: + // Running independently of us (the user's own container): never counted, + // never stopped by us. + } + + platform := detectDockerPlatform(ctx, cli, info.ID) + appconfig.Logger().Printf("[docker] bound container %s (%s) platform=%s startedByUs=%v", shortID(info.ID), name, platform, startedByUs) + return &DockerExecutor{ + cli: cli, + containerID: info.ID, + name: name, + platform: platform, + startedByUs: startedByUs, + }, nil +} + +// waitRunning polls until the container has been Running for the full settle +// window (catching one-shot containers that exit within milliseconds), or +// returns an error if it exits first. +func waitRunning(ctx context.Context, cli *client.Client, id string) error { + deadline := time.Now().Add(3 * time.Second) + for { + info, err := cli.ContainerInspect(ctx, id) + if err != nil { + return fmt.Errorf("inspect after start: %w", err) + } + if info.State != nil && !info.State.Running { + return fmt.Errorf("container %q exited immediately after start (exit code %d): its main process is not long-running, so it cannot host a workspace", strings.TrimPrefix(info.Name, "/"), info.State.ExitCode) + } + if info.State != nil && info.State.Running && time.Now().After(deadline) { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(200 * time.Millisecond): + } + } +} + +func (d *DockerExecutor) Close() error { + if d.startedByUs { + dockerReleaseRef(d.containerID, true) + } + return nil // never close the shared client +} + +// ContainerID exposes the bound container id (used by the terminal backend). +func (d *DockerExecutor) ContainerID() string { return d.containerID } + +func (d *DockerExecutor) ReadFile(ctx context.Context, path string) ([]byte, error) { + out, serr, err := d.run(ctx, fmt.Sprintf("cat %s", ShellQuote(path)), 30*time.Second) + if err != nil { + if detail := strings.TrimSpace(serr); detail != "" { + return nil, fmt.Errorf("%s", detail) + } + return nil, err + } + return []byte(out), nil +} + +func (d *DockerExecutor) WriteFile(ctx context.Context, path string, data []byte, perm os.FileMode) error { + mkdirCmd := fmt.Sprintf("mkdir -p %s", ShellQuote(filepath.Dir(path))) + if _, _, err := d.run(ctx, mkdirCmd, 10*time.Second); err != nil { + return fmt.Errorf("mkdir failed: %w", err) + } + encoded := base64Encode(data) + writeCmd := fmt.Sprintf("echo %s | base64 -d > %s && chmod %o %s", + ShellQuote(encoded), ShellQuote(path), perm, ShellQuote(path)) + if _, serr, err := d.run(ctx, writeCmd, 30*time.Second); err != nil { + return fmt.Errorf("write failed: %s %w", serr, err) + } + return nil +} + +func (d *DockerExecutor) MkdirAll(ctx context.Context, path string, _ os.FileMode) error { + if _, serr, err := d.run(ctx, fmt.Sprintf("mkdir -p %s", ShellQuote(path)), 10*time.Second); err != nil { + return fmt.Errorf("mkdir -p failed: %s %w", serr, err) + } + return nil +} + +func (d *DockerExecutor) Stat(ctx context.Context, path string) (*FileInfo, error) { + out, _, err := d.run(ctx, fmt.Sprintf( + `if [ -e %s ]; then if [ -d %s ]; then echo "dir"; else echo "file"; fi; else echo "none"; fi`, + ShellQuote(path), ShellQuote(path), + ), 5*time.Second) + if err != nil { + return nil, err + } + switch strings.TrimSpace(out) { + case "dir": + return &FileInfo{Exists: true, IsDir: true}, nil + case "file": + return &FileInfo{Exists: true, IsDir: false}, nil + default: + return &FileInfo{Exists: false}, nil + } +} + +func (d *DockerExecutor) Exec(ctx context.Context, command, workDir string, timeout time.Duration) (string, string, error) { + envPrefix := "export GIT_TERMINAL_PROMPT=0 GIT_PAGER=cat PAGER=cat GIT_EDITOR=true; " + fullCmd := envPrefix + command + if workDir != "" { + fullCmd = fmt.Sprintf("cd %s && %s", ShellQuote(workDir), envPrefix+command) + } + return d.run(ctx, fullCmd, timeout) +} + +func (d *DockerExecutor) Platform() string { return d.platform } + +// Name returns the container's display name. +func (d *DockerExecutor) Name() string { return d.name } + +func (d *DockerExecutor) Label() string { + if d.name != "" { + return "docker:" + d.name + } + return "docker:" + shortID(d.containerID) +} + +// ProjectLabel returns a stable, container-qualified session key. +func (d *DockerExecutor) ProjectLabel(pwd string) string { + ref := d.name + if ref == "" { + ref = shortID(d.containerID) + } + return fmt.Sprintf("docker://%s%s", ref, normalizeAbs(pwd)) +} + +// run executes a command inside the container via `sh -c`, honoring both the +// context and the timeout. stdout/stderr are demultiplexed (Tty:false). +func (d *DockerExecutor) run(ctx context.Context, command string, timeout time.Duration) (string, string, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + resp, err := d.cli.ContainerExecCreate(ctx, d.containerID, container.ExecOptions{ + Cmd: []string{"sh", "-c", command}, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", "", fmt.Errorf("docker exec create: %w", err) + } + + att, err := d.cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{}) + if err != nil { + return "", "", fmt.Errorf("docker exec attach: %w", err) + } + defer att.Close() + + var stdout, stderr bytes.Buffer + copyDone := make(chan error, 1) + go func() { + _, e := stdcopy.StdCopy(&stdout, &stderr, att.Reader) + copyDone <- e + }() + + select { + case <-copyDone: + // stream drained: command finished + case <-ctx.Done(): + att.Close() // unblock the StdCopy goroutine + <-copyDone + return stdout.String(), stderr.String(), fmt.Errorf("command timed out or cancelled: %w", ctx.Err()) + } + + // Use a fresh context for the inspect: the exec ctx may already be at its + // deadline, but the exec itself completed and its exit code is available. + inspect, ierr := d.cli.ContainerExecInspect(context.Background(), resp.ID) + if ierr == nil && inspect.ExitCode != 0 { + return stdout.String(), stderr.String(), fmt.Errorf("command exited with code %d", inspect.ExitCode) + } + return stdout.String(), stderr.String(), nil +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func detectDockerPlatform(ctx context.Context, cli *client.Client, id string) string { + platform := "linux/amd64" + out, _, err := dockerExecCapture(ctx, cli, id, "uname -sm") + if err != nil { + return platform + } + parts := strings.Fields(strings.TrimSpace(out)) + if len(parts) == 2 { + osName := strings.ToLower(parts[0]) + arch := strings.ToLower(parts[1]) + switch arch { + case "x86_64": + arch = "amd64" + case "aarch64": + arch = "arm64" + } + platform = osName + "/" + arch + } + return platform +} + +// dockerExecCapture runs a one-off command and returns its stdout/stderr. Used +// before a DockerExecutor exists (platform/shell detection). +func dockerExecCapture(ctx context.Context, cli *client.Client, id, command string) (string, string, error) { + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + resp, err := cli.ContainerExecCreate(ctx, id, container.ExecOptions{ + Cmd: []string{"sh", "-c", command}, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", "", err + } + att, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{}) + if err != nil { + return "", "", err + } + defer att.Close() + var stdout, stderr bytes.Buffer + _, err = stdcopy.StdCopy(&stdout, &stderr, att.Reader) + return stdout.String(), stderr.String(), err +} + +func tailDockerLogs(ctx context.Context, cli *client.Client, id string, lines int) string { + rc, err := cli.ContainerLogs(ctx, id, container.LogsOptions{ + ShowStdout: true, + ShowStderr: true, + Tail: fmt.Sprintf("%d", lines), + }) + if err != nil { + return "" + } + defer func() { _ = rc.Close() }() + var buf bytes.Buffer + _, _ = stdcopy.StdCopy(&buf, &buf, rc) + return strings.TrimSpace(buf.String()) +} + +func shortID(id string) string { + if len(id) > 12 { + return id[:12] + } + return id +} + +func normalizeAbs(p string) string { + if !strings.HasPrefix(p, "/") { + return "/" + p + } + return p +} diff --git a/internal/tools/docker_exec_test.go b/internal/tools/docker_exec_test.go new file mode 100644 index 0000000..a48a560 --- /dev/null +++ b/internal/tools/docker_exec_test.go @@ -0,0 +1,130 @@ +package tools + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" +) + +// dockerTestClient returns a client if a daemon is reachable, otherwise skips +// the test. This keeps the docker integration tests inert in CI environments +// without a Docker daemon. +func dockerTestClient(t *testing.T) *client.Client { + t.Helper() + cli, err := DockerClient() + if err != nil { + t.Skipf("docker client unavailable: %v", err) + } + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + if _, err := cli.ContainerList(ctx, container.ListOptions{}); err != nil { + t.Skipf("docker daemon unreachable: %v", err) + } + return cli +} + +// createTestContainer creates (but does not start) a throwaway container running +// cmd from alpine. It registers cleanup and skips if alpine is not present +// locally (so the test never triggers a network pull). +func createTestContainer(t *testing.T, cli *client.Client, name string, cmd []string) string { + t.Helper() + ctx := context.Background() + // Remove any leftover from a previous interrupted run. + _ = cli.ContainerRemove(ctx, name, container.RemoveOptions{Force: true}) + + created, err := cli.ContainerCreate(ctx, &container.Config{ + Image: "alpine:latest", + Cmd: cmd, + }, &container.HostConfig{}, nil, nil, name) + if err != nil { + if strings.Contains(err.Error(), "No such image") { + t.Skip("alpine:latest not present locally; skipping docker integration test") + } + t.Fatalf("create container: %v", err) + } + t.Cleanup(func() { + _ = cli.ContainerRemove(context.Background(), created.ID, container.RemoveOptions{Force: true}) + }) + return created.ID +} + +// TestDockerExecutorSmoke exercises the full DockerExecutor surface against a +// real, self-created container: A1 start, exec, write/read roundtrip, stat, and +// ref-count auto-stop on Close. +func TestDockerExecutorSmoke(t *testing.T) { + cli := dockerTestClient(t) + ctx := context.Background() + id := createTestContainer(t, cli, "jcode-docker-smoke-test", []string{"sleep", "600"}) + + // The container is created but not running → AcquireDockerContainer must + // start it (A1) and mark it as started-by-us. + exec, err := AcquireDockerContainer(ctx, "jcode-docker-smoke-test") + if err != nil { + t.Fatalf("acquire: %v", err) + } + if !exec.startedByUs { + t.Fatalf("expected startedByUs=true for a stopped container") + } + + if !strings.HasPrefix(exec.Platform(), "linux/") { + t.Errorf("platform = %q, want linux/*", exec.Platform()) + } + + out, _, err := exec.Exec(ctx, "echo hello-docker", "", 10*time.Second) + if err != nil || strings.TrimSpace(out) != "hello-docker" { + t.Fatalf("exec echo: out=%q err=%v", out, err) + } + + content := []byte("line1\nline2\n") + if err := exec.WriteFile(ctx, "/tmp/jcode/test.txt", content, 0o644); err != nil { + t.Fatalf("write: %v", err) + } + got, err := exec.ReadFile(ctx, "/tmp/jcode/test.txt") + if err != nil || string(got) != string(content) { + t.Fatalf("read roundtrip: got=%q err=%v", got, err) + } + + if fi, err := exec.Stat(ctx, "/tmp/jcode"); err != nil || !fi.Exists || !fi.IsDir { + t.Fatalf("stat dir: %+v err=%v", fi, err) + } + if fi, err := exec.Stat(ctx, "/tmp/jcode/test.txt"); err != nil || !fi.Exists || fi.IsDir { + t.Fatalf("stat file: %+v err=%v", fi, err) + } + if fi, err := exec.Stat(ctx, "/no/such/path"); err != nil || fi.Exists { + t.Fatalf("stat missing: %+v err=%v", fi, err) + } + + if lbl := exec.ProjectLabel("/tmp"); !strings.HasPrefix(lbl, "docker://") { + t.Errorf("ProjectLabel = %q, want docker://...", lbl) + } + + // Close releases our ref-count; as the last holder it must stop the container. + if err := exec.Close(); err != nil { + t.Fatalf("close: %v", err) + } + deadline := time.Now().Add(20 * time.Second) + for time.Now().Before(deadline) { + info, ierr := cli.ContainerInspect(ctx, id) + if ierr == nil && info.State != nil && !info.State.Running { + return // success: auto-stopped + } + time.Sleep(300 * time.Millisecond) + } + t.Errorf("container still running after Close; expected ref-count auto-stop") +} + +// TestDockerExecutorOneShotFails verifies A1 semantics: a container whose main +// process exits immediately cannot host a workspace and yields an error. +func TestDockerExecutorOneShotFails(t *testing.T) { + cli := dockerTestClient(t) + ctx := context.Background() + createTestContainer(t, cli, "jcode-docker-oneshot-test", []string{"true"}) + + if _, err := AcquireDockerContainer(ctx, "jcode-docker-oneshot-test"); err == nil { + t.Fatalf("expected an error for a one-shot container, got nil") + } +} diff --git a/internal/tools/env.go b/internal/tools/env.go index 2edea14..43b85c6 100644 --- a/internal/tools/env.go +++ b/internal/tools/env.go @@ -49,13 +49,28 @@ func NewEnv(pwd, platform string) *Env { } } -// SetSSH switches this Env to use a remote SSH executor. -func (e *Env) SetSSH(executor *SSHExecutor, remotePwd string) { +// SetRemote switches this Env to use a remote executor (SSH or Docker). +func (e *Env) SetRemote(executor RemoteExecutor, remotePwd string) { e.Exec = executor e.pwd = remotePwd e.platform = executor.Platform() } +// SetSSH switches this Env to use a remote SSH executor. Thin wrapper kept for +// existing callers; SetRemote is the general form. +func (e *Env) SetSSH(executor *SSHExecutor, remotePwd string) { + e.SetRemote(executor, remotePwd) +} + +// CloseRemote closes the executor if it is remote (SSH/Docker), releasing the +// SSH connection or the Docker container hold (ref-count). No-op when local. +func (e *Env) CloseRemote() error { + if re, ok := e.Exec.(RemoteExecutor); ok { + return re.Close() + } + return nil +} + // ResetToLocal restores this Env to use the original local executor. func (e *Env) ResetToLocal(pwd, platform string) { if e.origExec != nil { @@ -108,10 +123,20 @@ func (e *Env) CanNest() bool { return e.Depth < MaxSubagentDepth } -// IsRemote returns true if operating over SSH. +// IsRemote returns true if operating over a remote executor (SSH or Docker), +// i.e. anything that is not the local executor. func (e *Env) IsRemote() bool { - _, ok := e.Exec.(*SSHExecutor) - return ok + _, ok := e.Exec.(*LocalExecutor) + return !ok +} + +// RemoteExecutor is an Executor backed by a remote target (SSH host or Docker +// container) that owns a connection/hold needing release and can produce a +// stable, scheme-qualified session key. +type RemoteExecutor interface { + Executor + Close() error + ProjectLabel(pwd string) string } // Executor abstracts file and command operations so tools can work @@ -371,6 +396,12 @@ func (s *SSHExecutor) Label() string { return fmt.Sprintf("%s@%s", s.user, s.host) } +// ProjectLabel returns a stable, host-qualified session key of the form +// ssh://user@host:port/remote/path. +func (s *SSHExecutor) ProjectLabel(pwd string) string { + return fmt.Sprintf("ssh://%s@%s%s", s.user, s.host, normalizeAbs(pwd)) +} + // run executes a command over SSH, respecting both the context and timeout. func (s *SSHExecutor) run(ctx context.Context, command, _ string, timeout time.Duration) (string, string, error) { session, err := s.client.NewSession() diff --git a/internal/tools/switch_env.go b/internal/tools/switch_env.go index b30ad77..b8718e2 100644 --- a/internal/tools/switch_env.go +++ b/internal/tools/switch_env.go @@ -18,11 +18,11 @@ type SwitchEnvInput struct { func (e *Env) NewSwitchEnvTool() tool.InvokableTool { info := &schema.ToolInfo{ Name: "switch_env", - Desc: "Switch the execution environment between the local machine and SSH servers.", + Desc: "Switch the execution environment between the local machine, SSH servers, and Docker containers.", ParamsOneOf: schema.NewParamsOneOfByParams(map[string]*schema.ParameterInfo{ "target": { Type: schema.String, - Desc: "The destination environment. Must be 'local' or an exact SSH alias name.", + Desc: "The destination environment. Must be 'local', an exact SSH alias name, or an exact Docker alias name.", Required: true, }, }), @@ -53,14 +53,13 @@ func (s *switchEnvTool) InvokableRun(ctx context.Context, argumentsInJSON string return "", fmt.Errorf("target is required") } + // Remember the outgoing executor so we can release its hold (SSH connection + // or Docker container ref-count) once the switch succeeds. + prev := s.env.Exec + if input.Target == "local" { - // Just reuse current locally stored if possible or prompt for reset. - // Since we don't have util imported, we'll avoid it. We can just keep existing platform/pwd if they were stored previously in initial creation. - // Wait, if we are remote, env pwd/platform are remote. We need original ones. - // Actually, Env doesn't have original local pwd/platform, but we can just use the tool's platform? - // No, `ResetToLocal` needs it. Let's add them to Env later or just use OS calls. - // For now, if we cannot cleanly switch to local without UI's help, let's keep it simple. - // But in MVP, let `OnEnvChange` handle UI resets, right? Yes. + s.env.ResetToLocal("", "") + closeIfRemote(prev) if s.env.OnEnvChange != nil { s.env.OnEnvChange("local", true, nil) } @@ -72,16 +71,42 @@ func (s *switchEnvTool) InvokableRun(ctx context.Context, argumentsInJSON string return "", fmt.Errorf("failed to load config: %w", err) } + // Docker alias? + for i := range cfg.DockerAliases { + if cfg.DockerAliases[i].Name != input.Target { + continue + } + da := cfg.DockerAliases[i] + dexec, derr := AcquireDockerContainer(ctx, da.Container) + if derr != nil { + if s.env.OnEnvChange != nil { + s.env.OnEnvChange("", false, fmt.Errorf("failed to connect to docker '%s': %v", input.Target, derr)) + } + return "", fmt.Errorf("failed to connect to docker '%s': %v", input.Target, derr) + } + path := da.Path + if path == "" { + path = "/" + } + s.env.SetRemote(dexec, path) + closeIfRemote(prev) + label := dexec.Label() + if s.env.OnEnvChange != nil { + s.env.OnEnvChange(label, false, nil) + } + return fmt.Sprintf("Switched to '%s' (%s: %s).", input.Target, label, path), nil + } + + // SSH alias? var match *config.SSHAlias - for _, alias := range cfg.SSHAliases { - if alias.Name == input.Target { - match = &alias + for i := range cfg.SSHAliases { + if cfg.SSHAliases[i].Name == input.Target { + match = &cfg.SSHAliases[i] break } } - if match == nil { - return "", fmt.Errorf("SSH alias '%s' not found locally. Switch to 'local' or valid alias", input.Target) + return "", fmt.Errorf("environment '%s' not found locally. Switch to 'local' or a valid SSH/Docker alias", input.Target) } authMethods := BuildSSHAuthMethods() @@ -89,9 +114,6 @@ func (s *switchEnvTool) InvokableRun(ctx context.Context, argumentsInJSON string addr := match.Addr if idx := strings.Index(addr, "@"); idx > 0 { user = addr[:idx] - // Don't modify addr, NewSSHExecutor expects "host:port" in addr? - // Wait, NewSSHExecutor expects "user@host" as addr? No, looking at env.go, it expects addr=host:port, user=user. Let's extract correctly. - // Wait, my env.go says NewSSHExecutor(addr, user string, authMethods []ssh.AuthMethod) addr = addr[idx+1:] } @@ -104,6 +126,7 @@ func (s *switchEnvTool) InvokableRun(ctx context.Context, argumentsInJSON string } s.env.SetSSH(sshExec, match.Path) + closeIfRemote(prev) label := sshExec.Label() if s.env.OnEnvChange != nil { @@ -112,3 +135,11 @@ func (s *switchEnvTool) InvokableRun(ctx context.Context, argumentsInJSON string return fmt.Sprintf("Switched to '%s' (%s: %s).", input.Target, label, match.Path), nil } + +// closeIfRemote releases a remote executor's underlying hold (SSH connection or +// Docker container ref-count). No-op for the local executor or nil. +func closeIfRemote(e Executor) { + if re, ok := e.(RemoteExecutor); ok { + _ = re.Close() + } +} diff --git a/internal/web/engine.go b/internal/web/engine.go index 2c352a5..b16fd52 100644 --- a/internal/web/engine.go +++ b/internal/web/engine.go @@ -338,8 +338,8 @@ func (s *Server) buildLocalEngine(taskID, pwd, modeStr string) (*Engine, error) return eng, nil } -// buildRemoteEngine creates and registers a fresh remote (SSH) task engine. -func (s *Server) buildRemoteEngine(taskID string, exec *tools.SSHExecutor, remotePwd, modeStr string) (*Engine, error) { +// buildRemoteEngine creates and registers a fresh remote (SSH or Docker) task engine. +func (s *Server) buildRemoteEngine(taskID string, exec tools.RemoteExecutor, remotePwd, modeStr string) (*Engine, error) { if s.newRemoteEngine == nil { return nil, fmt.Errorf("remote task creation is not supported") } @@ -421,6 +421,12 @@ func (e *Engine) teardown() { if e.recorder != nil { e.recorder.Close() } + // Release the remote target, if any: closes the SSH connection or + // decrements the Docker container ref-count (stopping it on last release). + // No-op for local engines. + if e.env != nil { + _ = e.env.CloseRemote() + } } // setTaskStatus broadcasts a global task_status event (so every client's sidebar diff --git a/internal/web/pty.go b/internal/web/pty.go index eb0da3a..d3de43f 100644 --- a/internal/web/pty.go +++ b/internal/web/pty.go @@ -1,6 +1,7 @@ package web import ( + "context" "encoding/json" "fmt" "io" @@ -8,19 +9,31 @@ import ( "os" "os/exec" "sync" + "time" "github.com/creack/pty" + dockertypes "github.com/docker/docker/api/types" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/client" "github.com/gorilla/websocket" "github.com/cnjack/jcode/internal/config" ) -// ptySession represents a running PTY session. +// ptyBackend abstracts the transport behind a terminal session: a local PTY, or +// a `docker exec` TTY stream into a bound container. +type ptyBackend interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Resize(cols, rows uint16) error + Close() error +} + +// ptySession represents a running terminal session. type ptySession struct { id string ownerID string // task id that created it, so a project/remote switch only closes its own - cmd *exec.Cmd - ptmx *os.File + backend ptyBackend } // ptyManager manages PTY sessions. @@ -40,7 +53,24 @@ var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } -// create starts a new PTY session owned by ownerID and returns its ID. +// register stores a backend under a fresh session id owned by ownerID. +func (m *ptyManager) register(ownerID string, backend ptyBackend) string { + m.mu.Lock() + m.nextID++ + id := fmt.Sprintf("pty_%d", m.nextID) + m.sessions[id] = &ptySession{id: id, ownerID: ownerID, backend: backend} + m.mu.Unlock() + return id +} + +// remove drops a session from the map (without closing its backend). +func (m *ptyManager) remove(id string) { + m.mu.Lock() + delete(m.sessions, id) + m.mu.Unlock() +} + +// create starts a new local PTY session owned by ownerID and returns its ID. func (m *ptyManager) create(workDir, ownerID string) (string, error) { shell := os.Getenv("SHELL") if shell == "" { @@ -55,23 +85,59 @@ func (m *ptyManager) create(workDir, ownerID string) (string, error) { return "", err } - m.mu.Lock() - m.nextID++ - id := fmt.Sprintf("pty_%d", m.nextID) - sess := &ptySession{id: id, ownerID: ownerID, cmd: cmd, ptmx: ptmx} - m.sessions[id] = sess - m.mu.Unlock() + backend := &localPTYBackend{cmd: cmd, ptmx: ptmx} + id := m.register(ownerID, backend) - // Clean up when shell exits. + // Clean up when the shell exits. go func() { _ = cmd.Wait() - m.mu.Lock() - delete(m.sessions, id) - m.mu.Unlock() + m.remove(id) _ = ptmx.Close() }() - config.Logger().Printf("[pty] created session %s (shell=%s, dir=%s)", id, shell, workDir) + config.Logger().Printf("[pty] created local session %s (shell=%s, dir=%s)", id, shell, workDir) + return id, nil +} + +// createDocker starts a TTY `docker exec` session inside containerID and returns +// its ID. The shell is bash if present, otherwise sh. +func (m *ptyManager) createDocker(cli *client.Client, containerID, workDir, ownerID string) (string, error) { + ctx := context.Background() + resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{ + Cmd: []string{"sh", "-c", "exec bash 2>/dev/null || exec sh"}, + WorkingDir: workDir, + Env: []string{"TERM=xterm-256color"}, + Tty: true, + AttachStdin: true, + AttachStdout: true, + AttachStderr: true, + }) + if err != nil { + return "", err + } + att, err := cli.ContainerExecAttach(ctx, resp.ID, container.ExecAttachOptions{Tty: true}) + if err != nil { + return "", err + } + + backend := &dockerPTYBackend{cli: cli, execID: resp.ID, att: att} + id := m.register(ownerID, backend) + + // Clean up when the exec process exits. Nothing else waits on it, so poll + // the exec state (mirrors the local cmd.Wait cleanup). + go func() { + for { + time.Sleep(time.Second) + insp, ierr := cli.ContainerExecInspect(context.Background(), resp.ID) + if ierr != nil || !insp.Running { + m.remove(id) + _ = backend.Close() + return + } + } + }() + + config.Logger().Printf("[pty] created docker session %s (container=%s, dir=%s)", id, shortContainer(containerID), workDir) return id, nil } @@ -100,8 +166,7 @@ func (m *ptyManager) kill(id string) { delete(m.sessions, id) m.mu.Unlock() if sess != nil { - _ = sess.cmd.Process.Kill() - _ = sess.ptmx.Close() + _ = sess.backend.Close() } } @@ -115,8 +180,7 @@ func (m *ptyManager) closeAll() { m.sessions = make(map[string]*ptySession) m.mu.Unlock() for _, s := range sessions { - _ = s.cmd.Process.Kill() - _ = s.ptmx.Close() + _ = s.backend.Close() } } @@ -136,13 +200,12 @@ func (m *ptyManager) closeForTask(taskID string) { } m.mu.Unlock() for _, s := range sessions { - _ = s.cmd.Process.Kill() - _ = s.ptmx.Close() + _ = s.backend.Close() } } // serveWS handles the WebSocket connection for a PTY session. -// Data flows: PTY stdout → WebSocket → client, client → WebSocket → PTY stdin. +// Data flows: backend stdout → WebSocket → client, client → WebSocket → backend stdin. func (m *ptyManager) serveWS(w http.ResponseWriter, r *http.Request, id string) { sess := m.get(id) if sess == nil { @@ -157,17 +220,18 @@ func (m *ptyManager) serveWS(w http.ResponseWriter, r *http.Request, id string) } defer func() { _ = conn.Close() }() - // Handle resize messages from client. - // Client sends JSON: {"type":"resize","cols":80,"rows":24} - // or raw bytes for stdin input. - - // PTY → WebSocket (read from PTY, send to client) + // backend → WebSocket (read from backend, send to client) done := make(chan struct{}) go func() { defer close(done) buf := make([]byte, 4096) for { - n, err := sess.ptmx.Read(buf) + n, err := sess.backend.Read(buf) + if n > 0 { + if werr := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); werr != nil { + return + } + } if err != nil { if err != io.EOF { config.Logger().Printf("[pty] read error: %v", err) @@ -176,27 +240,23 @@ func (m *ptyManager) serveWS(w http.ResponseWriter, r *http.Request, id string) websocket.FormatCloseMessage(websocket.CloseNormalClosure, "")) return } - if err := conn.WriteMessage(websocket.BinaryMessage, buf[:n]); err != nil { - return - } } }() - // WebSocket → PTY (read from client, write to PTY) + // WebSocket → backend (read from client, write to backend stdin) for { msgType, msg, err := conn.ReadMessage() if err != nil { break } if msgType == websocket.TextMessage { - // Check for resize command + // Check for resize command: {"type":"resize","cols":80,"rows":24} if len(msg) > 0 && msg[0] == '{' { m.handleControlMessage(sess, msg) continue } } - // Write input to PTY - if _, err := sess.ptmx.Write(msg); err != nil { + if _, err := sess.backend.Write(msg); err != nil { break } } @@ -215,9 +275,55 @@ func (m *ptyManager) handleControlMessage(sess *ptySession, msg []byte) { return } if ctrl.Type == "resize" && ctrl.Cols > 0 && ctrl.Rows > 0 { - _ = pty.Setsize(sess.ptmx, &pty.Winsize{ - Cols: ctrl.Cols, - Rows: ctrl.Rows, - }) + _ = sess.backend.Resize(ctrl.Cols, ctrl.Rows) + } +} + +// --------------------------------------------------------------------------- +// Backends +// --------------------------------------------------------------------------- + +// localPTYBackend is a PTY attached to a local shell process. +type localPTYBackend struct { + cmd *exec.Cmd + ptmx *os.File +} + +func (b *localPTYBackend) Read(p []byte) (int, error) { return b.ptmx.Read(p) } +func (b *localPTYBackend) Write(p []byte) (int, error) { return b.ptmx.Write(p) } +func (b *localPTYBackend) Resize(cols, rows uint16) error { + return pty.Setsize(b.ptmx, &pty.Winsize{Cols: cols, Rows: rows}) +} +func (b *localPTYBackend) Close() error { + if b.cmd != nil && b.cmd.Process != nil { + _ = b.cmd.Process.Kill() + } + return b.ptmx.Close() +} + +// dockerPTYBackend is a TTY `docker exec` stream into a container. +type dockerPTYBackend struct { + cli *client.Client + execID string + att dockertypes.HijackedResponse +} + +func (b *dockerPTYBackend) Read(p []byte) (int, error) { return b.att.Reader.Read(p) } +func (b *dockerPTYBackend) Write(p []byte) (int, error) { return b.att.Conn.Write(p) } +func (b *dockerPTYBackend) Resize(cols, rows uint16) error { + return b.cli.ContainerExecResize(context.Background(), b.execID, container.ResizeOptions{ + Height: uint(rows), + Width: uint(cols), + }) +} +func (b *dockerPTYBackend) Close() error { + b.att.Close() + return nil +} + +func shortContainer(id string) string { + if len(id) > 12 { + return id[:12] } + return id } diff --git a/internal/web/remote.go b/internal/web/remote.go index f6d595a..ff00148 100644 --- a/internal/web/remote.go +++ b/internal/web/remote.go @@ -21,13 +21,17 @@ import ( // kept alive while the user works through the wizard's directory picker. const pendingConnTTL = 10 * time.Minute -// pendingConn is an SSH connection created by the remote-connect wizard that has -// not yet been bound to the live env. +// pendingConn is a remote connection (SSH or Docker) created by the +// remote-connect wizard that has not yet been bound to the live env. type pendingConn struct { - exec *tools.SSHExecutor - host string // host:port as dialed - user string - port int // originally requested port (for reconnect prefill) + exec tools.RemoteExecutor + kind string // "ssh" | "docker" + // SSH reconnect prefill. + host string // host:port as dialed + user string + port int // originally requested port + // Docker reconnect prefill. + container string // container name or id createdAt time.Time } @@ -99,7 +103,7 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { return } var req struct { - Type string `json:"type"` // "ssh" (docker reserved for later) + Type string `json:"type"` // "ssh" | "docker" Host string `json:"host"` Port int `json:"port"` User string `json:"user"` @@ -107,13 +111,19 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { Password string `json:"password"` KeyPath string `json:"key_path"` Passphrase string `json:"passphrase"` + Container string `json:"container"` // docker: container id or name } if err := json.NewDecoder(io.LimitReader(r.Body, 1<<20)).Decode(&req); err != nil { writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) return } + + if req.Type == "docker" { + s.connectDocker(w, r, strings.TrimSpace(req.Container)) + return + } if req.Type != "" && req.Type != "ssh" { - writeJSON(w, http.StatusBadRequest, map[string]string{"error": "only ssh connections are supported"}) + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "unsupported connection type"}) return } if strings.TrimSpace(req.Host) == "" { @@ -138,6 +148,7 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { remotePwd := remote.DiscoverPwd(r.Context(), exec, "/root") id := s.remoteConns.add(&pendingConn{ exec: exec, + kind: "ssh", host: exec.Host(), user: exec.User(), port: req.Port, @@ -153,6 +164,47 @@ func (s *Server) handleRemoteConnect(w http.ResponseWriter, r *http.Request) { }) } +// connectDocker binds (starting if stopped) the named container and parks it in +// the pending registry, mirroring the SSH connect flow. +func (s *Server) connectDocker(w http.ResponseWriter, r *http.Request, containerRef string) { + if containerRef == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "container is required"}) + return + } + exec, err := remote.ConnectDocker(r.Context(), containerRef) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + remotePwd := remote.DiscoverPwd(r.Context(), exec, "/") + id := s.remoteConns.add(&pendingConn{ + exec: exec, + kind: "docker", + container: exec.Name(), + createdAt: time.Now(), + }) + writeJSON(w, http.StatusOK, map[string]any{ + "connection_id": id, + "remote_pwd": remotePwd, + "platform": exec.Platform(), + "container": exec.Name(), + }) +} + +// handleListContainers returns the daemon's containers for the wizard picker. +func (s *Server) handleListContainers(w http.ResponseWriter, r *http.Request) { + if s.needsSetup { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "setup required: please configure a provider first"}) + return + } + containers, err := remote.ListContainers(r.Context()) + if err != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]any{"containers": containers}) +} + // handleRemoteListDir lists sub-directories of a path on a pending connection, // driving the wizard's remote directory picker. func (s *Server) handleRemoteListDir(w http.ResponseWriter, r *http.Request) { @@ -223,7 +275,7 @@ func (s *Server) handleRemoteBind(w http.ResponseWriter, r *http.Request) { s.ptyMgr.closeForTask(prevTaskID) // outgoing task's PTYs only; others keep theirs s.setActiveEngine(eng) - label := remote.ProjectLabel(pc.exec, remotePwd) + label := pc.exec.ProjectLabel(remotePwd) if eng.todoStore != nil { eng.todoStore.Update(nil) @@ -240,12 +292,14 @@ func (s *Server) handleRemoteBind(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]any{ "status": "ok", + "kind": pc.kind, "pwd": remotePwd, "label": label, "name": pathpkg.Base(remotePwd), "host": pc.host, "user": pc.user, "port": pc.port, + "container": pc.container, "remote_path": remotePwd, }) } @@ -310,3 +364,47 @@ func (s *Server) handleRemoteSaveAlias(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) } + +// handleRemoteSaveDockerAlias upserts a saved Docker alias (name/container/path) +// into config so it appears for one-click reconnects and the switch_env tool. +func (s *Server) handleRemoteSaveDockerAlias(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Container string `json:"container"` + Path string `json:"path"` + } + if err := json.NewDecoder(io.LimitReader(r.Body, 1<<16)).Decode(&req); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request body"}) + return + } + if strings.TrimSpace(req.Name) == "" || strings.TrimSpace(req.Container) == "" { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name and container are required"}) + return + } + + s.mu.Lock() + if s.cfg == nil { + s.mu.Unlock() + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "config unavailable"}) + return + } + updated := false + for i := range s.cfg.DockerAliases { + if s.cfg.DockerAliases[i].Name == req.Name { + s.cfg.DockerAliases[i].Container = req.Container + s.cfg.DockerAliases[i].Path = req.Path + updated = true + break + } + } + if !updated { + s.cfg.DockerAliases = append(s.cfg.DockerAliases, config.DockerAlias{Name: req.Name, Container: req.Container, Path: req.Path}) + } + err := config.SaveConfig(s.cfg) + s.mu.Unlock() + if err != nil { + writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) + return + } + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) +} diff --git a/internal/web/server.go b/internal/web/server.go index 4ac1bfe..8543c77 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -79,8 +79,8 @@ type Server struct { newEngine func(taskID, pwd, mode string) (*EngineConfig, error) // newRemoteEngine is newEngine's remote sibling: it builds a task engine bound - // to an SSH executor instead of a local pwd. - newRemoteEngine func(taskID string, executor *tools.SSHExecutor, remotePwd, mode string) (*EngineConfig, error) + // to a remote executor (SSH or Docker) instead of a local pwd. + newRemoteEngine func(taskID string, executor tools.RemoteExecutor, remotePwd, mode string) (*EngineConfig, error) // remoteConns holds SSH connections established by the remote-connect wizard // that have not yet been bound to the live env (keyed by connection id). @@ -127,9 +127,9 @@ type ServerConfig struct { Agent *adk.ChatModelAgent CreateAgent func(providerName, modelName string) (*adk.ChatModelAgent, error) RebuildForMode func(planMode bool) (*adk.ChatModelAgent, error) - NewEngine func(taskID, pwd, mode string) (*EngineConfig, error) // factory for new concurrent task engines (local) - NewRemoteEngine func(taskID string, executor *tools.SSHExecutor, remotePwd, mode string) (*EngineConfig, error) // remote sibling of NewEngine - InitialMode string // unified startup mode string ("approval"/"plan"/"full_access") + NewEngine func(taskID, pwd, mode string) (*EngineConfig, error) // factory for new concurrent task engines (local) + NewRemoteEngine func(taskID string, executor tools.RemoteExecutor, remotePwd, mode string) (*EngineConfig, error) // remote sibling of NewEngine (SSH or Docker) + InitialMode string // unified startup mode string ("approval"/"plan"/"full_access") TodoStore *tools.TodoStore Recorder *session.Recorder Tracer *telemetry.LangfuseTracer @@ -277,6 +277,8 @@ func (s *Server) Start(ctx context.Context) error { mux.HandleFunc("POST /api/remote/bind", s.handleRemoteBind) mux.HandleFunc("POST /api/remote/cancel", s.handleRemoteCancel) mux.HandleFunc("POST /api/remote/save-alias", s.handleRemoteSaveAlias) + mux.HandleFunc("GET /api/docker/containers", s.handleListContainers) + mux.HandleFunc("POST /api/remote/save-docker-alias", s.handleRemoteSaveDockerAlias) mux.HandleFunc("GET /api/skills", s.handleListSkills) mux.HandleFunc("POST /api/skills/{name}/toggle", s.handleToggleSkill) mux.HandleFunc("GET /api/slash-commands", s.handleSlashCommands) @@ -2225,10 +2227,32 @@ func (s *Server) handleBrowse(w http.ResponseWriter, r *http.Request) { func (s *Server) handleCreatePTY(w http.ResponseWriter, r *http.Request) { pwd, owner := "", "" + var dockerExec *tools.DockerExecutor if eng := s.activeEngine(); eng != nil { pwd, owner = eng.pwd, eng.taskID + // A container-bound engine gets a terminal INSIDE the container; SSH and + // local engines keep a local shell (SSH-in-terminal remains a known gap). + if eng.env != nil { + if de, ok := eng.env.Exec.(*tools.DockerExecutor); ok { + dockerExec = de + } + } + } + + var ( + id string + err error + ) + if dockerExec != nil { + cli, derr := tools.DockerClient() + if derr != nil { + writeJSON(w, http.StatusBadGateway, map[string]string{"error": derr.Error()}) + return + } + id, err = s.ptyMgr.createDocker(cli, dockerExec.ContainerID(), pwd, owner) + } else { + id, err = s.ptyMgr.create(pwd, owner) } - id, err := s.ptyMgr.create(pwd, owner) if err != nil { writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()}) return diff --git a/web/src/components/RemoteConnectWizard.vue b/web/src/components/RemoteConnectWizard.vue index f5b074d..9f491ce 100644 --- a/web/src/components/RemoteConnectWizard.vue +++ b/web/src/components/RemoteConnectWizard.vue @@ -25,7 +25,7 @@ import { useI18n } from 'vue-i18n' import { useChatStore } from '@/stores/chat' import { useProjectStore } from '@/stores/project' import { api } from '@/composables/api' -import type { RemoteMeta, SSHAlias, RemoteAuthMethod } from '@/types/api' +import type { RemoteMeta, SSHAlias, RemoteAuthMethod, DockerContainer } from '@/types/api' type Prefill = RemoteMeta & { loadTaskUuid?: string } @@ -44,7 +44,7 @@ const emit = defineEmits<{ const store = useChatStore() const projectStore = useProjectStore() -type Step = 'method' | 'config' | 'connecting' | 'dir' +type Step = 'method' | 'config' | 'docker' | 'connecting' | 'dir' const step = ref('method') const method = ref<'ssh' | 'docker'>('ssh') @@ -61,6 +61,11 @@ const form = reactive({ const aliases = ref([]) const selectedAlias = ref('') +// Docker container picker state. +const containers = ref([]) +const containersLoading = ref(false) +const selectedContainer = ref('') + // Label shown on the alias Listbox button (mirrors the option text). const selectedAliasLabel = computed(() => { const a = aliases.value.find((x) => x.name === selectedAlias.value) @@ -85,7 +90,11 @@ const steps = computed<{ key: Step; label: string }[]>(() => [ { key: 'connecting', label: t('wizard.steps.connecting') }, { key: 'dir', label: t('wizard.steps.selectDirectory') }, ]) -const stepIndex = computed(() => steps.value.findIndex((s) => s.key === step.value)) +const stepIndex = computed(() => { + // The docker container picker occupies the same rail position as SSH config. + const key = step.value === 'docker' ? 'config' : step.value + return steps.value.findIndex((s) => s.key === key) +}) watch(() => props.open, (isOpen) => { if (!isOpen) return @@ -97,7 +106,9 @@ watch(() => props.open, (isOpen) => { // the SSH secret, the link must be re-established — but for key/agent auth // (the common case) we can do it silently instead of making the user re-fill // the form. Fall back to the form only if the key isn't accepted. - if (props.prefill.remotePath) { + if (props.prefill.kind === 'docker') { + void autoReconnectDocker(props.prefill) + } else if (props.prefill.remotePath) { void autoReconnect(props.prefill) } else { applyPrefill(props.prefill) @@ -116,6 +127,8 @@ function resetState() { form.keyPath = '~/.ssh/id_rsa' form.passphrase = '' selectedAlias.value = '' + containers.value = [] + selectedContainer.value = '' error.value = '' connectionId.value = '' bound.value = false @@ -197,10 +210,79 @@ function applyAlias(name: string) { } function chooseMethod(m: 'ssh' | 'docker') { - if (m === 'docker') return // disabled placeholder method.value = m } +function proceedFromMethod() { + if (method.value === 'docker') { + step.value = 'docker' + void loadContainers() + } else { + step.value = 'config' + } +} + +async function loadContainers() { + containersLoading.value = true + error.value = '' + try { + const res = await api.dockerContainers() + // Running containers first, then by name. + containers.value = (res.containers || []).slice().sort((a, b) => { + if (a.running !== b.running) return a.running ? -1 : 1 + return (a.name || a.id).localeCompare(b.name || b.id) + }) + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Failed to list containers' + containers.value = [] + } finally { + containersLoading.value = false + } +} + +async function connectDocker(container: string) { + if (!container) return + await discardConnection() + selectedContainer.value = container + error.value = '' + step.value = 'connecting' + try { + const res = await api.remoteConnect({ type: 'docker', container }) + connectionId.value = res.connection_id + await listDir(res.remote_pwd) + step.value = 'dir' + } catch (e: unknown) { + error.value = e instanceof Error ? e.message : 'Connection failed' + step.value = 'docker' + } +} + +// Seamless docker reconnect: re-attach to the known container and bind straight +// to its previous path. Falls back to the container picker on any failure. +async function autoReconnectDocker(p: Prefill) { + method.value = 'docker' + if (!p.container) { + step.value = 'docker' + void loadContainers() + return + } + step.value = 'connecting' + try { + const res = await api.remoteConnect({ type: 'docker', container: p.container }) + connectionId.value = res.connection_id + currentDir.value = p.remotePath && p.remotePath !== '/' ? p.remotePath : res.remote_pwd + await bindHere() + if (!bound.value && connectionId.value) { + await listDir(currentDir.value) + step.value = 'dir' + } + } catch { + error.value = '' + step.value = 'docker' + void loadContainers() + } +} + // discardConnection releases an established-but-unbound SSH connection so it // doesn't linger in the backend's pending registry (up to its TTL). async function discardConnection() { @@ -277,25 +359,33 @@ async function bindHere() { error.value = '' try { const res = await api.remoteBind(connectionId.value, currentDir.value) - const proj = projectStore.upsertRemoteProject(res.label, { - host: res.host, - user: res.user, - port: res.port, - remotePath: res.remote_path, - }) + const isDocker = res.kind === 'docker' + const proj = projectStore.upsertRemoteProject(res.label, isDocker + ? { kind: 'docker', host: '', user: '', port: 0, remotePath: res.remote_path, container: res.container } + : { kind: 'ssh', host: res.host, user: res.user, port: res.port, remotePath: res.remote_path }) projectStore.setActive(proj.id) bound.value = true connectionId.value = '' // ownership transferred; do not cancel on close - // Always persist the host to config so it returns for one-click reconnects + // Always persist the target to config so it returns for one-click reconnects // next time — no opt-in needed. Use the custom name when given, otherwise - // derive a stable one from the address. Secrets are never stored. - const addr = `${res.user}@${res.host}` - const name = aliasName.value.trim() || addr - try { - await api.remoteSaveAlias(name, addr, res.remote_path) - } catch (e: unknown) { - console.error('Failed to save SSH alias:', e) + // derive a stable one. Secrets are never stored. + if (isDocker) { + const container = res.container || '' + const name = aliasName.value.trim() || container || 'container' + try { + await api.remoteSaveDockerAlias(name, container, res.remote_path) + } catch (e: unknown) { + console.error('Failed to save Docker alias:', e) + } + } else { + const addr = `${res.user}@${res.host}` + const name = aliasName.value.trim() || addr + try { + await api.remoteSaveAlias(name, addr, res.remote_path) + } catch (e: unknown) { + console.error('Failed to save SSH alias:', e) + } } await store.resetToWelcomeAfterSwitch() @@ -371,20 +461,20 @@ function close() { {{ t('wizard.ssh') }} {{ t('wizard.remoteHost') }} -
- +
- -