Skip to content

feat(web): Docker container workspaces#97

Merged
cnjack merged 2 commits into
mainfrom
feat/docker-remote-workspace
Jun 23, 2026
Merged

feat(web): Docker container workspaces#97
cnjack merged 2 commits into
mainfrom
feat/docker-remote-workspace

Conversation

@cnjack

@cnjack cnjack commented Jun 22, 2026

Copy link
Copy Markdown
Owner

What

Adds Docker container as a remote workspace target in the web UI, alongside SSH. A task can be bound to a container; the agent's file/command tools and the embedded terminal then operate inside that container. Built with the Docker Go SDK.

This is the implementation of the design we converged on (research → design → decisions A1 / B / C / D / E).

Design decisions implemented

  • Approach: Docker Go SDK (github.com/docker/docker/client, FromEnv → honors DOCKER_HOST, with API-version negotiation).
  • Lifecycle (A1): a stopped container is started on connect (no CMD override). It's polled until it reports Running; a one-shot container that exits immediately is rejected with its logs instead of failing cryptically.
  • Auto-stop (B): ref-counted. A container we started is stopped again once the last task using it tears down. Containers the user already had running are never stopped by us. Re-acquiring a container we started takes an extra ref (no stop-out-from-under).
  • File I/O (C) / symlinks (D): via docker exec (cat / base64 / test / mkdir), identical in spirit to the SSH executor — cat follows symlinks naturally.
  • switch_env (E): the agent can switch into a saved Docker alias (docker_aliases in config); switching away releases the previous target's hold.
  • Terminal: opens a real TTY inside the bound container (docker exec, bash→sh). SSH/local engines keep a local shell (unchanged).

Key changes

Backend

  • internal/tools/docker_exec.goDockerExecutor + shared client + ref-count lifecycle + A1 AcquireDockerContainer.
  • internal/tools/env.go — new RemoteExecutor interface; SetRemote/CloseRemote; IsRemote now means "not local"; ProjectLabel is a method.
  • Generalized *tools.SSHExecutortools.RemoteExecutor across server.go, engine.go, command/web.go, web/remote.go. Engine teardown releases the remote hold.
  • internal/web/pty.goptyBackend interface with local-PTY and docker-exec-TTY backends; handleCreatePTY routes container-bound engines to the docker terminal.
  • internal/remote/docker.goListContainers / ConnectDocker.
  • Endpoints: GET /api/docker/containers, docker branch in POST /api/remote/connect, POST /api/remote/save-docker-alias. config.DockerAlias.

Frontend

  • RemoteConnectWizard.vue — Docker method enabled with a container picker; bind/reconnect handle docker:// workspaces.
  • project store parses docker://<container>/<path> labels; types + api composable updated.

Testing

  • go build ./... (default and -tags jcode_headless), go vet ./..., gofmt, full go test ./... — all clean.
  • Frontend: vue-tsc type-check, vite build, oxlint, eslint — all clean.
  • Daemon-gated integration tests (internal/tools/docker_exec_test.go) skip cleanly without Docker; against a real daemon they create and clean up their own throwaway container and verify A1 start, exec, write/read roundtrip, stat, ProjectLabel, and ref-count auto-stop — plus the one-shot-rejection path. Verified passing locally against the daemon.

Known limitations (consistent with SSH today)

  • Web git operations still run locally (not container-aware) — same gap SSH has; left for a follow-up.
  • The SDK's client.FromEnv does not read docker context; users with a non-default socket (e.g. OrbStack) need DOCKER_HOST set.

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features
    • Added Docker container workspace support in the remote-connect wizard, enabling seamless connection to existing containers with automatic startup if needed
    • Added Docker container listing and selection UI with running/stopped status indicators
    • Saved Docker workspace aliases for quick reconnection to frequently-used containers
    • Embedded terminal now supports Docker containers with full TTY access

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 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds Docker container workspaces to the remote-connect flow. A new DockerExecutor runs file and command operations inside containers via docker exec, with reference-counted start/stop lifecycle. The RemoteExecutor interface generalizes the existing SSH executor surface to cover Docker. PTY sessions, the remote-connect/bind web endpoints, the switch_env tool, and the frontend wizard are all extended to support Docker alongside SSH.

Changes

Docker Container Workspace Support

Layer / File(s) Summary
Type contracts, config, and RemoteExecutor interface
internal/config/config.go, internal/tools/env.go, web/src/types/api.ts, go.mod
Adds DockerAlias struct and DockerAliases []DockerAlias to Config; introduces RemoteExecutor interface extending Executor with Close/ProjectLabel; adds Env.SetRemote, Env.CloseRemote, Env.IsRemote; adds SSHExecutor.ProjectLabel; generalizes frontend API types with RemoteKind, DockerContainer, DockerContainersResponse, and extends RemoteMeta/request/response shapes; adds Docker and OpenTelemetry go.mod dependencies.
DockerExecutor: client, lifecycle, file ops, and command runner
internal/tools/docker_exec.go, internal/tools/docker_exec_test.go
Implements process-wide DockerClient singleton, container reference counting with mutex-protected acquire/release/stop, AcquireDockerContainer with start/poll/rollback and tail-log error reporting, DockerExecutor file operations (ReadFile, WriteFile, MkdirAll, Stat), Exec, platform detection, and internal run/dockerExecCapture/tailDockerLogs helpers; adds integration tests for smoke workflow and one-shot-exit failure.
switch_env Docker alias switching and resource cleanup
internal/tools/switch_env.go
Updates switch_env tool description; adds Docker-alias matching loop that acquires DockerExecutor, sets remote state, and closes the prior executor; fixes SSH alias pointer selection; adds closeIfRemote helper used on local-reset and SSH-switch paths.
Web backend: remote connect/bind generalization and Docker endpoints
internal/remote/docker.go, internal/web/remote.go, internal/web/engine.go, internal/web/server.go, internal/command/web.go
Generalizes pendingConn to tools.RemoteExecutor with kind discriminator; adds connectDocker, handleListContainers, and handleRemoteSaveDockerAlias endpoints; updates handleRemoteBind to use exec.ProjectLabel and return kind/container; generalizes buildRemoteEngine and ServerConfig.NewRemoteEngine to tools.RemoteExecutor; adds Engine.teardown CloseRemote call; registers /api/docker/containers and /api/remote/save-docker-alias routes.
PTY backend abstraction for local and Docker terminal sessions
internal/web/pty.go, internal/web/server.go
Introduces ptyBackend interface; refactors ptySession to store backend; adds localPTYBackend and dockerPTYBackend (Docker exec attach with polling goroutine); updates kill/closeAll/closeForTask and serveWS read/write/resize paths to use backend; wires handleCreatePTY to dispatch createDocker when engine uses *tools.DockerExecutor.
Frontend: API client, project store, and RemoteConnectWizard Docker flow
web/src/composables/api.ts, web/src/stores/project.ts, web/src/components/RemoteConnectWizard.vue, web/src/i18n/locales/en.ts
Adds dockerContainers and remoteSaveDockerAlias API methods; extends isRemotePath/parseRemoteLabel for docker://; adds docker wizard step, container picker state, loadContainers/connectDocker/autoReconnectDocker functions, proceedFromMethod routing, and bindHere kind-branched alias persistence; adds container picker template, Docker i18n strings, and CSS classes.
Changelog
CHANGELOG.md
Adds Unreleased changelog entry for Docker container workspace support.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant RemoteConnectWizard
  participant api
  participant handleRemoteConnect
  participant AcquireDockerContainer
  participant handleRemoteBind
  participant buildRemoteEngine
  participant handleCreatePTY
  participant dockerPTYBackend

  User->>RemoteConnectWizard: select Docker method
  RemoteConnectWizard->>api: dockerContainers()
  api-->>RemoteConnectWizard: []DockerContainer
  User->>RemoteConnectWizard: select container
  RemoteConnectWizard->>handleRemoteConnect: POST /api/remote/connect {type:"docker", container}
  handleRemoteConnect->>AcquireDockerContainer: containerRef
  AcquireDockerContainer-->>handleRemoteConnect: *DockerExecutor (started+ref-counted)
  handleRemoteConnect-->>RemoteConnectWizard: {id, container, pwd}
  User->>RemoteConnectWizard: bindHere
  RemoteConnectWizard->>handleRemoteBind: POST /api/remote/bind {id, pwd}
  handleRemoteBind->>buildRemoteEngine: RemoteExecutor, pwd
  handleRemoteBind-->>RemoteConnectWizard: {label:"docker://...", kind:"docker"}
  RemoteConnectWizard->>api: remoteSaveDockerAlias(name, container, path)
  User->>handleCreatePTY: open terminal
  handleCreatePTY->>dockerPTYBackend: createDocker(containerID)
  dockerPTYBackend-->>User: TTY stream via WebSocket
Loading

Estimated code review effort

🎯 5 (Critical) | ⏱️ ~120 minutes

Possibly related PRs

  • cnjack/jcode#82: Modifies internal/web/remote.go and internal/web/server.go around the SSH pending-connection flow that this PR generalizes to a RemoteExecutor interface covering both SSH and Docker.

Poem

🐰 A rabbit hops into a container, hooray!
docker exec — the shell is there to stay,
Ref-counted starts, a PTY with real TTY,
SSH or Docker, both connect the same way,
Aliases saved, the wizard guides the leap,
Into containers, cozy corners to keep! 🐳

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 69.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(web): Docker container workspaces' clearly and concisely summarizes the main change—adding Docker container support as remote workspaces in the web UI, which is the primary objective of this PR.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/docker-remote-workspace

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

… (CI lint)

golangci-lint (unconvert) flagged string(s.State) — s.State is already a
string. Use it directly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
internal/web/engine.go (1)

346-348: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Close the remote executor when engine creation fails.

If s.newRemoteEngine(...) returns an error after the Docker/SSH executor has been acquired, this path returns without releasing it. For Docker, that can leave the container ref held and skip the expected auto-stop behavior.

🧹 Proposed cleanup on factory failure
 	ec, err := s.newRemoteEngine(taskID, exec, remotePwd, modeStr)
 	if err != nil {
+		if closeErr := exec.Close(); closeErr != nil {
+			config.Logger().Printf("[web] failed to close remote executor after engine creation error: %v", closeErr)
+		}
 		return nil, err
 	}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/engine.go` around lines 346 - 348, When s.newRemoteEngine()
returns an error in the error handling block after the executor has been
acquired, the remote executor (the exec parameter) needs to be properly closed
to release the Docker/SSH resource. Add a cleanup call to close the executor
before returning the error, ensuring the container reference is released and
expected auto-stop behavior is not skipped.
internal/web/pty.go (1)

246-264: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Avoid leaving a stale backend reader after the WebSocket disconnects.

When conn.ReadMessage() fails, the handler breaks and waits on <-done>, but nothing closes or signals sess.backend. An idle shell/container exec can keep the backend read goroutine blocked indefinitely; on reconnect, the stale reader can also consume terminal output.

🔌 Minimal fix if PTY sessions are WebSocket-scoped
 		msgType, msg, err := conn.ReadMessage()
 		if err != nil {
+			_ = sess.backend.Close()
 			break
 		}

If sessions are meant to survive browser refreshes, add explicit attach/detach ownership so the old reader stops before a new WebSocket can attach.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/pty.go` around lines 246 - 264, When conn.ReadMessage() fails
and the loop breaks, the backend reader goroutine is left stale and blocked,
consuming resources and potentially receiving output meant for new connections.
After the break statement in the WebSocket read loop (where err != nil), add
cleanup logic to close or signal sess.backend before waiting on the done
channel. This ensures the backend reader goroutine unblocks and releases
resources, preventing stale readers from interfering with new WebSocket
connections. If PTY sessions are meant to survive reconnects, implement explicit
attach/detach ownership tracking so that old readers are properly detached
before new WebSocket clients can attach.
🧹 Nitpick comments (2)
internal/tools/docker_exec.go (1)

49-52: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use sync.RWMutex for docker ref shared state.

dockerRefs has read-heavy access (e.g., Line 113), but the lock is sync.Mutex. Switching to sync.RWMutex aligns this file with the tool-state concurrency guideline.

As per coding guidelines: internal/tools/**/*.go requires “Use sync.RWMutex for shared state in tools (see TodoStore, BackgroundManager).”

♻️ Suggested adjustment
 var (
-	dockerRefMu sync.Mutex
+	dockerRefMu sync.RWMutex
 	dockerRefs  = map[string]int{}
 )
@@
-	dockerRefMu.Lock()
+	dockerRefMu.RLock()
 	alreadyOurs := dockerRefs[info.ID] > 0
-	dockerRefMu.Unlock()
+	dockerRefMu.RUnlock()

Also applies to: 112-114

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/docker_exec.go` around lines 49 - 52, The dockerRefMu variable
is declared as sync.Mutex but should be sync.RWMutex to support read-heavy
access patterns (such as the reads at line 113). Change the type declaration of
dockerRefMu from sync.Mutex to sync.RWMutex, then update all lock usage
throughout the file to use RLock/RUnlock for read-only operations on dockerRefs
and Lock/Unlock for write operations to maintain proper concurrency control.

Source: Coding guidelines

internal/remote/docker.go (1)

27-34: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Wrap returned errors with context.

Both daemon errors are returned bare, so callers can't tell whether client init or ContainerList failed. Per the Go guideline, wrap with a context prefix.

♻️ Proposed wrapping
 	cli, err := tools.DockerClient()
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("docker client: %w", err)
 	}
 	summaries, err := cli.ContainerList(ctx, container.ListOptions{All: true})
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("list containers: %w", err)
 	}

(add "fmt" to imports)

As per coding guidelines: "Use fmt.Errorf("tool_name: %w", err) for wrapped errors in non-tool code."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/remote/docker.go` around lines 27 - 34, Wrap both error returns with
context using fmt.Errorf to help callers identify which operation failed. When
the tools.DockerClient() call returns an error, wrap it with a descriptive
context prefix using fmt.Errorf with the "%w" verb. Similarly, when the
cli.ContainerList() call returns an error, wrap it with a context prefix using
the same pattern. Make sure to add "fmt" to the imports if not already present,
then replace both bare error returns with wrapped versions that include
meaningful context about which operation failed.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@go.mod`:
- Line 16: The github.com/docker/docker dependency is deprecated and will not
receive security patches. Remove or replace the github.com/docker/docker
v28.5.2+incompatible line in go.mod and add the maintained replacements
github.com/moby/moby/client and github.com/moby/moby/api modules with
appropriate versions. Additionally, update all import statements throughout the
codebase that currently reference github.com/docker/docker packages to use the
corresponding github.com/moby/moby/client and github.com/moby/moby/api packages
instead.

In `@internal/tools/docker_exec.go`:
- Around line 299-314: The code discards errors from two critical operations:
the StdCopy stream copy and the ContainerExecInspect call. When the `copyDone`
channel receives a signal in the first select case, capture any error from the
underlying stdcopy.StdCopy operation and return it if it exists. Additionally,
after the ContainerExecInspect call, check if `ierr != nil` (not just the
condition on line 311) and return the error instead of silently succeeding. This
ensures that I/O failures and inspect operation errors are properly propagated
rather than marked as successful executions.

In `@internal/tools/switch_env.go`:
- Around line 74-98: The PTY backend created by ptyManager.createDocker() does
not hold its own reference to the Docker container, so when switch_env calls
closeIfRemote(prev), the container can be stopped even while a PTY session is
actively reading/writing to it. Fix this by having ptyManager.createDocker()
call AcquireDockerContainer() to acquire and hold a reference to the container
at session creation time, then store this reference in the dockerPTYBackend
structure. Update the PTY backend's Close() method to release this container
reference when the session terminates, ensuring the container remains alive for
the duration of the active terminal connection.

In `@internal/web/pty.go`:
- Around line 104-118: The createDocker function and related Docker operations
use context.Background() which doesn't support request cancellation, causing
goroutines to block indefinitely if the Docker daemon stalls. Add a
context.Context parameter as the first parameter to the createDocker function
signature, then replace all context.Background() calls within createDocker and
the related functions at lines 131-135 and 313-317 with the passed context or
bounded child contexts with appropriate timeouts. Finally, update all callers of
createDocker in internal/web/server.go to pass the request context instead of
creating fresh background contexts.
- Around line 252-256: The code currently treats any websocket TextMessage
starting with `{` as a control message and consumes it via the
handleControlMessage call with a continue statement. This causes invalid JSON or
non-resize JSON to be dropped instead of being sent to the shell. Fix this by
only consuming messages that are actually recognized control messages. Modify
the logic around the check for msg[0] == '{' to validate that the message is a
legitimate control message (such as a resize command with the proper JSON
structure) before calling handleControlMessage and using continue. If the
message is not a recognized control message, allow it to fall through to the
normal shell input handling instead of discarding it. Apply this fix to both
occurrences of this pattern mentioned in the comment.

In `@internal/web/remote.go`:
- Around line 385-404: The config read-modify-write section in this handler
incorrectly uses s.mu instead of the dedicated s.cfgMu mutex. Replace all
s.mu.Lock() and s.mu.Unlock() calls in this handler block (including the early
unlock after the s.cfg == nil check and the final unlock after SaveConfig) with
s.cfgMu.Lock() and s.cfgMu.Unlock() respectively. This ensures proper
serialization of concurrent config modifications across all handlers and
prevents races with handlers that correctly use s.cfgMu.
- Around line 167-192: The remoteConnRegistry only performs lazy cleanup via
sweepLocked() when add() is called in connectDocker, causing abandoned
connections and expired entries to linger indefinitely. More critically, on
server shutdown (around lines 338-345), remoteConns is never cleaned up, leaving
Docker container reference counts unrelemented. Add a closeAll() method to the
remoteConnRegistry type that iterates through and closes all pending connections
and their executors, then invoke this method during server shutdown before
calling srv.Shutdown(). Additionally, consider implementing a background
goroutine using a ticker that periodically invokes the sweep mechanism to
proactively enforce TTL cleanup rather than relying exclusively on lazy
evaluation triggered by add().

In `@web/src/components/RemoteConnectWizard.vue`:
- Around line 577-618: The Docker wizard template in RemoteConnectWizard.vue is
using four new i18n keys (wizard.dockerConnection, wizard.dockerDesc,
wizard.selectContainer, wizard.noContainers) that are missing translations in
the non-English locale files. Add these four keys with appropriate translations
to each of the locale files ja.ts, ko.ts, zh-Hans.ts, and zh-Hant.ts, following
the same structure and naming convention as other wizard-related translation
keys already present in those files.

In `@web/src/i18n/locales/en.ts`:
- Around line 441-449: The Docker wizard keys (container, dockerConnection,
dockerDesc, selectContainer, noContainers, refresh, running, stopped) have been
added to the English locale file but are missing from the non-English locale
files. Add these same keys with appropriate translations to the zh-Hans.ts,
zh-Hant.ts, ja.ts, and ko.ts locale files, maintaining the same object structure
and key names as in the English version. Ensure all keys are present and
properly translated in each non-English locale to avoid runtime translation
mismatches.

---

Outside diff comments:
In `@internal/web/engine.go`:
- Around line 346-348: When s.newRemoteEngine() returns an error in the error
handling block after the executor has been acquired, the remote executor (the
exec parameter) needs to be properly closed to release the Docker/SSH resource.
Add a cleanup call to close the executor before returning the error, ensuring
the container reference is released and expected auto-stop behavior is not
skipped.

In `@internal/web/pty.go`:
- Around line 246-264: When conn.ReadMessage() fails and the loop breaks, the
backend reader goroutine is left stale and blocked, consuming resources and
potentially receiving output meant for new connections. After the break
statement in the WebSocket read loop (where err != nil), add cleanup logic to
close or signal sess.backend before waiting on the done channel. This ensures
the backend reader goroutine unblocks and releases resources, preventing stale
readers from interfering with new WebSocket connections. If PTY sessions are
meant to survive reconnects, implement explicit attach/detach ownership tracking
so that old readers are properly detached before new WebSocket clients can
attach.

---

Nitpick comments:
In `@internal/remote/docker.go`:
- Around line 27-34: Wrap both error returns with context using fmt.Errorf to
help callers identify which operation failed. When the tools.DockerClient() call
returns an error, wrap it with a descriptive context prefix using fmt.Errorf
with the "%w" verb. Similarly, when the cli.ContainerList() call returns an
error, wrap it with a context prefix using the same pattern. Make sure to add
"fmt" to the imports if not already present, then replace both bare error
returns with wrapped versions that include meaningful context about which
operation failed.

In `@internal/tools/docker_exec.go`:
- Around line 49-52: The dockerRefMu variable is declared as sync.Mutex but
should be sync.RWMutex to support read-heavy access patterns (such as the reads
at line 113). Change the type declaration of dockerRefMu from sync.Mutex to
sync.RWMutex, then update all lock usage throughout the file to use
RLock/RUnlock for read-only operations on dockerRefs and Lock/Unlock for write
operations to maintain proper concurrency control.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 97eadeba-7307-4aff-8ede-2bcd26a07367

📥 Commits

Reviewing files that changed from the base of the PR and between 7496c66 and 5547aed.

⛔ Files ignored due to path filters (1)
  • go.sum is excluded by !**/*.sum
📒 Files selected for processing (18)
  • CHANGELOG.md
  • go.mod
  • internal/command/web.go
  • internal/config/config.go
  • internal/remote/docker.go
  • internal/tools/docker_exec.go
  • internal/tools/docker_exec_test.go
  • internal/tools/env.go
  • internal/tools/switch_env.go
  • internal/web/engine.go
  • internal/web/pty.go
  • internal/web/remote.go
  • internal/web/server.go
  • web/src/components/RemoteConnectWizard.vue
  • web/src/composables/api.ts
  • web/src/i18n/locales/en.ts
  • web/src/stores/project.ts
  • web/src/types/api.ts

Comment thread go.mod
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major

🧩 Analysis chain

🌐 Web query:

For github.com/docker/docker, do GO-2026-4883, GO-2026-4887, GHSA-rg2x-37c3-w2rh, GHSA-vp62-88p7-qqf5, and GHSA-x86f-5xw2-fm2r impact the client package (github.com/docker/docker/client) or only daemon/server components? Also list the first patched module version for each advisory.

💡 Result:

The Go module github.com/docker/docker was deprecated as of Docker Engine v29 (November 2025) and will not receive further updates or patches [1][2][3]. Vulnerabilities affecting this module, including those listed, generally impact the daemon/server-side logic rather than the client package [4][5][6][7][8]. Users are advised to migrate to the maintained github.com/moby/moby/client and github.com/moby/moby/api modules [1][9][2]. Below is the impact analysis and status for each advisory: 1. GO-2026-4883 (CVE-2026-33997): Impacts daemon components (plugin privilege validation) [4][10]. No patched version exists for the deprecated github.com/docker/docker module [10]. 2. GO-2026-4887 (CVE-2026-34040): Impacts daemon components (AuthZ plugin bypass) [5]. No patched version exists for the deprecated github.com/docker/docker module [5]. 3. GHSA-rg2x-37c3-w2rh (CVE-2026-42306): Impacts daemon components (bind mount redirection during docker cp) [6][3][11]. No patched version exists for the deprecated github.com/docker/docker module. 4. GHSA-vp62-88p7-qqf5 (CVE-2026-41568): Impacts daemon components (symlink race during docker cp) [7]. No patched version exists for the deprecated github.com/docker/docker module. 5. GHSA-x86f-5xw2-fm2r (CVE-2026-41567): Impacts daemon components (arbitrary code execution via compressed archives) [8]. No patched version exists for the deprecated github.com/docker/docker module. Since github.com/docker/docker is deprecated and frozen, there are no first patched module versions for these advisories within that specific module path [1][9]. Mitigation requires migrating to the supported moby/moby modules [1][12].

Citations:


github.com/docker/docker is deprecated with no available patches; migrate to github.com/moby/moby modules.

The listed advisories (GO-2026-4883, GO-2026-4887, GHSA-rg2x-37c3-w2rh, GHSA-vp62-88p7-qqf5, GHSA-x86f-5xw2-fm2r) impact only daemon/server-side components, not the client package. However, github.com/docker/docker was deprecated as of Docker Engine v29 (November 2025) and is frozen—no patched versions will be released. Replace with the maintained github.com/moby/moby/client and github.com/moby/moby/api modules.

🧰 Tools
🪛 OSV Scanner (2.4.0)

[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Moby has an Off-by-one error in its plugin privilege validation in github.com/docker/docker

(GO-2026-4883)


[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Moby has AuthZ plugin bypass when provided oversized request bodies in github.com/docker/docker

(GO-2026-4887)


[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Moby has an Off-by-one error in its plugin privilege validation

(GHSA-pxq6-2prw-chj9)


[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Docker: Race condition in docker cp allows bind mount redirection to host path

(GHSA-rg2x-37c3-w2rh)


[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Docker: Race condition in docker cp allows creation of arbitrary empty files on the host via symlink swap

(GHSA-vp62-88p7-qqf5)


[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Moby has AuthZ plugin bypass when provided oversized request bodies

(GHSA-x744-4wpc-v9h2)


[HIGH] 16-16: github.com/docker/docker 28.5.2+incompatible: Docker: PUT /containers/{id}/archive executes container binary on the host

(GHSA-x86f-5xw2-fm2r)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@go.mod` at line 16, The github.com/docker/docker dependency is deprecated and
will not receive security patches. Remove or replace the
github.com/docker/docker v28.5.2+incompatible line in go.mod and add the
maintained replacements github.com/moby/moby/client and github.com/moby/moby/api
modules with appropriate versions. Additionally, update all import statements
throughout the codebase that currently reference github.com/docker/docker
packages to use the corresponding github.com/moby/moby/client and
github.com/moby/moby/api packages instead.

Source: Linters/SAST tools

Comment on lines +299 to +314
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cat -n internal/tools/docker_exec.go | head -350

Repository: cnjack/jcode

Length of output: 14519


Capture and return errors from stream copy and inspect operations.

Line 300 discards the error from copyDone, and lines 310–314 silently succeed when ContainerExecInspect fails. This can mark failed or partial execs as successful, corrupting file and command operations.

Specifically:

  • If stdcopy.StdCopy returns an error (I/O failure, stream truncation, etc.), it is discarded at line 300.
  • If ContainerExecInspect fails (network error, server error), line 311's condition ierr == nil && ... evaluates false, skipping the error return and reaching line 314 which returns success.
Proposed fix
	select {
-	case <-copyDone:
+	case copyErr := <-copyDone:
+		if copyErr != nil {
+			return stdout.String(), stderr.String(), fmt.Errorf("docker exec stream copy: %w", copyErr)
+		}
 		// 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())
 	}
 
-	inspect, ierr := d.cli.ContainerExecInspect(context.Background(), resp.ID)
-	if ierr == nil && inspect.ExitCode != 0 {
+	inspect, ierr := d.cli.ContainerExecInspect(context.Background(), resp.ID)
+	if ierr != nil {
+		return stdout.String(), stderr.String(), fmt.Errorf("docker exec inspect: %w", ierr)
+	}
+	if inspect.ExitCode != 0 {
 		return stdout.String(), stderr.String(), fmt.Errorf("command exited with code %d", inspect.ExitCode)
 	}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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
select {
case copyErr := <-copyDone:
if copyErr != nil {
return stdout.String(), stderr.String(), fmt.Errorf("docker exec stream copy: %w", copyErr)
}
// 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 {
return stdout.String(), stderr.String(), fmt.Errorf("docker exec inspect: %w", ierr)
}
if inspect.ExitCode != 0 {
return stdout.String(), stderr.String(), fmt.Errorf("command exited with code %d", inspect.ExitCode)
}
return stdout.String(), stderr.String(), nil
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/docker_exec.go` around lines 299 - 314, The code discards
errors from two critical operations: the StdCopy stream copy and the
ContainerExecInspect call. When the `copyDone` channel receives a signal in the
first select case, capture any error from the underlying stdcopy.StdCopy
operation and return it if it exists. Additionally, after the
ContainerExecInspect call, check if `ierr != nil` (not just the condition on
line 311) and return the error instead of silently succeeding. This ensures that
I/O failures and inspect operation errors are properly propagated rather than
marked as successful executions.

Comment on lines +74 to +98
// 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
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Does any PTY/terminal path acquire its own docker ref, or does it rely on the engine's executor?
rg -nP -C3 '(dockerAcquireRef|AcquireDockerContainer|dockerReleaseRef|startedByUs)'
# Inspect dockerPTYBackend lifecycle and how the container ref is kept alive during a terminal session.
fd -e go pty.go --exec rg -nP -C4 '(dockerPTYBackend|AcquireDockerContainer|Close)'

Repository: cnjack/jcode

Length of output: 342


🏁 Script executed:

cat /tmp/coderabbit-shell-logs/shell-output-5QNPrR

Repository: cnjack/jcode

Length of output: 342


🏁 Script executed:

# Find and examine the PTY-related files
find . -name "*pty*" -type f -name "*.go"

Repository: cnjack/jcode

Length of output: 172


🏁 Script executed:

# Search for dockerReleaseRef, dockerAcquireRef, and startedByUs usage
rg "dockerReleaseRef|dockerAcquireRef|startedByUs" --type go -B2 -A2

Repository: cnjack/jcode

Length of output: 150


🏁 Script executed:

# Examine DockerExecutor.Close() implementation
rg "func.*DockerExecutor.*Close|type DockerExecutor" --type go -A 20

Repository: cnjack/jcode

Length of output: 150


🏁 Script executed:

# Check the log files
tail -100 /tmp/coderabbit-shell-logs/shell-output-UWeX4B

Repository: cnjack/jcode

Length of output: 150


🏁 Script executed:

# List Go files in docker-related areas
find . -name "*.go" -path "*/docker*" -o -name "*.go" -path "*/internal/*" | grep -i docker | head -20

Repository: cnjack/jcode

Length of output: 247


🏁 Script executed:

# Read the PTY file to understand lifecycle
wc -l ./internal/web/pty.go

Repository: cnjack/jcode

Length of output: 176


🏁 Script executed:

# Read switch_env.go to see full context
wc -l ./internal/tools/switch_env.go

Repository: cnjack/jcode

Length of output: 185


🏁 Script executed:

# Read the switch_env.go file
cat -n ./internal/tools/switch_env.go

Repository: cnjack/jcode

Length of output: 4985


🏁 Script executed:

# Read the pty.go file to see PTY backend implementation
cat -n ./internal/web/pty.go

Repository: cnjack/jcode

Length of output: 10946


🏁 Script executed:

# Read docker.go to understand ref counting
cat -n ./internal/remote/docker.go | head -100

Repository: cnjack/jcode

Length of output: 2183


🏁 Script executed:

# Find Docker executor implementation
fd -e go docker.go docker_exec.go --exec grep -l "DockerExecutor" {} \;

Repository: cnjack/jcode

Length of output: 252


🏁 Script executed:

# Read the DockerExecutor type and Close method
rg "type DockerExecutor" -A 30 internal/tools/

Repository: cnjack/jcode

Length of output: 2219


🏁 Script executed:

# Search for AcquireDockerContainer and dockerReleaseRef
rg "func AcquireDockerContainer|func.*dockerReleaseRef|startedByUs" -B 2 -A 10 internal/tools/

Repository: cnjack/jcode

Length of output: 7433


🏁 Script executed:

# Check docker_exec.go for the full Docker executor implementation
cat -n ./internal/tools/docker_exec.go | head -150

Repository: cnjack/jcode

Length of output: 6358


Docker ref-count drop can terminate active PTY sessions: PTY backend should acquire its own container reference.

The acquire → SetRemotecloseIfRemote(prev) ordering is correct for typical switching. However, there's a critical gap: when switch_env closes prev after a successful acquire, DockerExecutor.Close() calls dockerReleaseRef(containerID, true) whenever startedByUs is set. If this is the last reference, the container stops immediately—even if a web PTY/terminal session created via ptyManager.createDocker() is actively reading/writing to the container via docker exec.

The PTY backend (dockerPTYBackend in internal/web/pty.go lines 304–322) does not acquire its own Docker container reference. Its Close() method only calls att.Close() on the hijacked connection and never touches container lifetime. The PTY's cleanup goroutine (lines 128–138) similarly expects the container to remain running and only polls the exec process state.

When an agent switches away from a container that started a PTY session, the last ref release stops the container, terminating the active docker exec session out from under the connected client.

The fix: ptyManager.createDocker() should call AcquireDockerContainer() to hold a reference on containers it creates PTY backends for, and release it when the session closes. This prevents the container from being stopped while any terminal remains attached.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/tools/switch_env.go` around lines 74 - 98, The PTY backend created
by ptyManager.createDocker() does not hold its own reference to the Docker
container, so when switch_env calls closeIfRemote(prev), the container can be
stopped even while a PTY session is actively reading/writing to it. Fix this by
having ptyManager.createDocker() call AcquireDockerContainer() to acquire and
hold a reference to the container at session creation time, then store this
reference in the dockerPTYBackend structure. Update the PTY backend's Close()
method to release this container reference when the session terminates, ensuring
the container remains alive for the duration of the active terminal connection.

Comment thread internal/web/pty.go
Comment on lines +104 to +118
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})

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Thread request cancellation into Docker PTY operations.

createDocker, the exec-inspect poll, and resize calls all use context.Background(), so Docker daemon stalls can outlive the HTTP/WebSocket request and leave goroutines blocked. Pass a caller context into createDocker/backend operations and use bounded child contexts for long-running Docker calls. As per coding guidelines, Use context.Context as the first parameter and thread cancellation properly.

⏱️ Sketch of the direction
-func (m *ptyManager) createDocker(cli *client.Client, containerID, workDir, ownerID string) (string, error) {
-	ctx := context.Background()
+func (m *ptyManager) createDocker(ctx context.Context, cli *client.Client, containerID, workDir, ownerID string) (string, error) {
+	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
+	defer cancel()
 	resp, err := cli.ContainerExecCreate(ctx, containerID, container.ExecOptions{

And update the caller in internal/web/server.go:

-		id, err = s.ptyMgr.createDocker(cli, dockerExec.ContainerID(), pwd, owner)
+		id, err = s.ptyMgr.createDocker(r.Context(), cli, dockerExec.ContainerID(), pwd, owner)

Also applies to: 131-135, 313-317

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/pty.go` around lines 104 - 118, The createDocker function and
related Docker operations use context.Background() which doesn't support request
cancellation, causing goroutines to block indefinitely if the Docker daemon
stalls. Add a context.Context parameter as the first parameter to the
createDocker function signature, then replace all context.Background() calls
within createDocker and the related functions at lines 131-135 and 313-317 with
the passed context or bounded child contexts with appropriate timeouts. Finally,
update all callers of createDocker in internal/web/server.go to pass the request
context instead of creating fresh background contexts.

Source: Coding guidelines

Comment thread internal/web/pty.go
Comment on lines 252 to 256
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don’t swallow terminal input that starts with {.

Any text frame beginning with { is treated as a control message; invalid JSON or non-resize JSON is then dropped instead of being written to the shell. This breaks normal shell input such as grouped commands pasted into the terminal.

⌨️ Proposed fix: only consume recognized control messages
-				m.handleControlMessage(sess, msg)
-				continue
+				if m.handleControlMessage(sess, msg) {
+					continue
+				}
-func (m *ptyManager) handleControlMessage(sess *ptySession, msg []byte) {
+func (m *ptyManager) handleControlMessage(sess *ptySession, msg []byte) bool {
 	// Parse JSON control message: {"type":"resize","cols":80,"rows":24}
 	var ctrl struct {
 		Type string `json:"type"`
 		Cols uint16 `json:"cols"`
 		Rows uint16 `json:"rows"`
 	}
 	if err := json.Unmarshal(msg, &ctrl); err != nil {
-		return
+		return false
 	}
 	if ctrl.Type == "resize" && ctrl.Cols > 0 && ctrl.Rows > 0 {
 		_ = sess.backend.Resize(ctrl.Cols, ctrl.Rows)
+		return true
 	}
+	return false
 }

Also applies to: 267-279

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/pty.go` around lines 252 - 256, The code currently treats any
websocket TextMessage starting with `{` as a control message and consumes it via
the handleControlMessage call with a continue statement. This causes invalid
JSON or non-resize JSON to be dropped instead of being sent to the shell. Fix
this by only consuming messages that are actually recognized control messages.
Modify the logic around the check for msg[0] == '{' to validate that the message
is a legitimate control message (such as a resize command with the proper JSON
structure) before calling handleControlMessage and using continue. If the
message is not a recognized control message, allow it to fall through to the
normal shell input handling instead of discarding it. Apply this fix to both
occurrences of this pattern mentioned in the comment.

Comment thread internal/web/remote.go
Comment on lines +167 to +192
// 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(),
})
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect the pending registry's expiry/cleanup path to confirm it closes executors.
rg -nP -C8 'remoteConnRegistry|func .*remoteConns|createdAt|TTL|expire|sweep|reap' internal/web
ast-grep --pattern '$_.exec.Close()'

Repository: cnjack/jcode

Length of output: 9792


🏁 Script executed:

# Check for background cleanup, goroutines, or periodic sweeps related to remoteConns
rg -n "go\s+.*sweep|ticker|time\.NewTicker|time\.After" internal/web --type go
rg -n "newServer|func.*Server\)" internal/web --type go -A10 | head -60

Repository: cnjack/jcode

Length of output: 4205


🏁 Script executed:

# Look for Server initialization and any background cleanup goroutines
rg -n "func newServer|func NewServer" internal/web --type go -A30
rg -n "func.*Server.*Close|Shutdown" internal/web --type go -B2 -A10

Repository: cnjack/jcode

Length of output: 3455


🏁 Script executed:

# Get the complete NewServer function to check for background cleanup goroutines
sed -n '154,250p' internal/web/server.go

Repository: cnjack/jcode

Length of output: 3638


🏁 Script executed:

# Continue reading the Start function to check for background cleanup
sed -n '250,350p' internal/web/server.go

Repository: cnjack/jcode

Length of output: 5095


🏁 Script executed:

# Check what happens to remoteConns during server shutdown or context cancellation
rg -n "remoteConns" internal/web/server.go -B2 -A2
rg -n "CloseAllEngines\|teardown" internal/web/server.go

Repository: cnjack/jcode

Length of output: 717


🏁 Script executed:

# Check if remoteConns is ever closed or cleaned up during shutdown
rg -n "remoteConns\|Close\|Shutdown" internal/web/server.go | grep -A5 -B5 "remoteConns"
# Also check if there's a method to close all pending connections
rg -n "func.*remoteConnRegistry.*Close\|func.*pendingConn.*Close" internal/web

Repository: cnjack/jcode

Length of output: 150


🏁 Script executed:

# Check the complete remote.go file to see all methods on remoteConnRegistry
cat -n internal/web/remote.go | sed -n '38,100p'
# Also check handleRemoteCancel to see if it properly closes connections
rg -n "handleRemoteCancel" internal/web --type go -A15

Repository: cnjack/jcode

Length of output: 5054


🏁 Script executed:

# Check the shutdown path more carefully - look at the graceful shutdown handler
sed -n '330,360p' internal/web/server.go

Repository: cnjack/jcode

Length of output: 1041


Add server-shutdown cleanup and optional background sweep for pending connections.

The registry's sweepLocked() method does close executors on TTL expiry (lines 85–95), as expected. However, the mechanism is lazy: it only runs when add() is called. If the user abandons a Docker connection and creates no new connections afterward, the expired pending entry lingers indefinitely. More critically, on server shutdown (lines 338–345), remoteConns is never swept, so any unbound pending connections remain open. For Docker containers started via AcquireDockerContainer, this means the ref-count is never decremented and auto-started containers continue running indefinitely.

Either:

  1. Add a closeAll() method to remoteConnRegistry and call it during server shutdown before srv.Shutdown(), or
  2. Start a background sweep goroutine (e.g., with a ticker) to enforce TTL proactively, or
  3. Both for redundancy.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/remote.go` around lines 167 - 192, The remoteConnRegistry only
performs lazy cleanup via sweepLocked() when add() is called in connectDocker,
causing abandoned connections and expired entries to linger indefinitely. More
critically, on server shutdown (around lines 338-345), remoteConns is never
cleaned up, leaving Docker container reference counts unrelemented. Add a
closeAll() method to the remoteConnRegistry type that iterates through and
closes all pending connections and their executors, then invoke this method
during server shutdown before calling srv.Shutdown(). Additionally, consider
implementing a background goroutine using a ticker that periodically invokes the
sweep mechanism to proactively enforce TTL cleanup rather than relying
exclusively on lazy evaluation triggered by add().

Comment thread internal/web/remote.go
Comment on lines +385 to +404
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Find all SaveConfig call sites in web handlers and the lock guarding them.
rg -nP -C6 '\bSaveConfig\s*\(' internal/web
echo '--- cfgMu usages ---'
rg -nP '\bcfgMu\b' internal/web
echo '--- s.mu usages around cfg mutation ---'
rg -nP -B2 -A2 's\.cfg\b' internal/web

Repository: cnjack/jcode

Length of output: 21495


Lock inconsistency — config read-modify-write uses s.mu, but other handlers use s.cfgMu

The handler guards its config read-modify-write and SaveConfig under s.mu, but the dedicated cfgMu mutex is explicitly documented for exactly this purpose ("serializes read-modify-write SaveConfig from concurrent handlers"). The inconsistency is widespread: most config-mutating handlers (handleCreateMCP, handleUpdateMCP, handleDeleteMCP, handleToggleMCP, handleMCPLogin, handleRemoteSaveSSHAlias, handleChannelBLEStatus) use s.mu, but others (handleApprovalModeSwitch at line 2422, handleToggleSkill at line 2656) correctly use s.cfgMu. This allows races: a config writer under s.mu does not serialize against one under s.cfgMu, creating lost-update and data-race hazards during concurrent config persists.

Align all config-mutating handlers to use s.cfgMu (not s.mu) for read-modify-write + SaveConfig, starting with this handler.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@internal/web/remote.go` around lines 385 - 404, The config read-modify-write
section in this handler incorrectly uses s.mu instead of the dedicated s.cfgMu
mutex. Replace all s.mu.Lock() and s.mu.Unlock() calls in this handler block
(including the early unlock after the s.cfg == nil check and the final unlock
after SaveConfig) with s.cfgMu.Lock() and s.cfgMu.Unlock() respectively. This
ensures proper serialization of concurrent config modifications across all
handlers and prevents races with handlers that correctly use s.cfgMu.

Comment on lines +577 to +618
<!-- Step 2b: docker container picker -->
<template v-else-if="step === 'docker' || (step === 'connecting' && method === 'docker')">
<h3 class="rcw-h">{{ t('wizard.dockerConnection') }}</h3>
<p class="rcw-sub">{{ t('wizard.dockerDesc') }}</p>

<div v-if="error" class="rcw-error">{{ error }}</div>

<div v-if="step === 'connecting'" class="rcw-hint">
<ArrowPathIcon class="w-3.5 h-3.5 spin" /> {{ t('wizard.connecting') }}
</div>
<template v-else>
<div class="rcw-dirbar">
<span class="rcw-dir-path">{{ t('wizard.selectContainer') }}</span>
<button class="rcw-back" style="margin-left:auto" :title="t('wizard.refresh')" @click="loadContainers">
<ArrowPathIcon class="w-3.5 h-3.5" :class="{ spin: containersLoading }" />
</button>
</div>

<div class="rcw-dirlist">
<div v-if="containersLoading" class="rcw-hint"><ArrowPathIcon class="w-3.5 h-3.5 spin" /> {{ t('wizard.loading') }}</div>
<template v-else>
<button
v-for="c in containers"
:key="c.id"
class="rcw-dir-row"
@click="connectDocker(c.name || c.id)"
>
<CubeIcon class="w-3.5 h-3.5" />
<span class="rcw-ctr-name">{{ c.name || c.id.slice(0, 12) }}</span>
<span class="rcw-ctr-img">{{ c.image }}</span>
<span class="rcw-ctr-state" :class="{ on: c.running }">{{ c.running ? t('wizard.running') : t('wizard.stopped') }}</span>
</button>
<div v-if="containers.length === 0" class="rcw-hint">{{ t('wizard.noContainers') }}</div>
</template>
</div>

<div class="rcw-foot">
<button class="rcw-ghost" @click="step = 'method'">{{ t('common.back') }}</button>
<span />
</div>
</template>
</template>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Confirm each new wizard key resolves in every locale file.
keys=(docker dockerConnection dockerDesc selectContainer running stopped noContainers container refresh loading connecting next)
fd -e ts . web/src/i18n/locales | while read -r f; do
  echo "== $f =="
  for k in "${keys[@]}"; do
    rg -nP "(^|\s)$k\s*:" "$f" >/dev/null || echo "MISSING wizard.$k"
  done
done

Repository: cnjack/jcode

Length of output: 893


Add missing i18n keys to all non-English locale files.

The new Docker wizard keys are missing translations in Japanese, Korean, Simplified Chinese, and Traditional Chinese. Japanese, Korean, and Simplified/Traditional Chinese users will see untranslated key strings instead of localized text.

Missing keys in ja.ts, ko.ts, zh-Hans.ts, and zh-Hant.ts:

  • wizard.dockerConnection
  • wizard.dockerDesc
  • wizard.selectContainer
  • wizard.noContainers

Add these keys with appropriate translations to all non-English locale files before merge.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/components/RemoteConnectWizard.vue` around lines 577 - 618, The
Docker wizard template in RemoteConnectWizard.vue is using four new i18n keys
(wizard.dockerConnection, wizard.dockerDesc, wizard.selectContainer,
wizard.noContainers) that are missing translations in the non-English locale
files. Add these four keys with appropriate translations to each of the locale
files ja.ts, ko.ts, zh-Hans.ts, and zh-Hant.ts, following the same structure and
naming convention as other wizard-related translation keys already present in
those files.

Comment on lines +441 to +449
container: 'Container',
comingSoon: 'Coming soon',
dockerConnection: 'Docker container',
dockerDesc: 'Pick a container to use as the workspace. A stopped container is started for you, and stopped again when no task is using it.',
selectContainer: 'Select a container',
noContainers: 'No containers found on this Docker host.',
refresh: 'Refresh',
running: 'running',
stopped: 'stopped',

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Compare locale key paths across web/src/i18n/locales/*.ts without modifying files.
# Expect: No missing keys reported for the newly added wizard Docker strings.

fd -e ts . web/src/i18n/locales -x printf '%s\n' {}

python - <<'PY'
from pathlib import Path
import re

locale_dir = Path("web/src/i18n/locales")
files = sorted(locale_dir.glob("*.ts"))
if not files:
    raise SystemExit("No locale files found")

target_keys = {
    "container",
    "dockerConnection",
    "dockerDesc",
    "selectContainer",
    "noContainers",
    "refresh",
    "running",
    "stopped",
}

for path in files:
    text = path.read_text(encoding="utf-8")
    missing = []
    wizard = re.search(r"\bwizard\s*:\s*\{(?P<body>.*?)\n\s*\},\n\n\s*sidebar\s*:", text, re.S)
    body = wizard.group("body") if wizard else ""
    for key in sorted(target_keys):
        if not re.search(rf"\b{re.escape(key)}\s*:", body):
            missing.append(key)
    if missing:
        print(f"{path}: missing {', '.join(missing)}")
PY

Repository: cnjack/jcode

Length of output: 886


Add missing Docker wizard keys to non-English locales.

The new Docker wizard keys (container, dockerConnection, dockerDesc, selectContainer, noContainers, refresh, running, stopped) are missing from all non-English locale files. Add these keys to zh-Hans.ts, zh-Hant.ts, ja.ts, and ko.ts to match the English source.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/src/i18n/locales/en.ts` around lines 441 - 449, The Docker wizard keys
(container, dockerConnection, dockerDesc, selectContainer, noContainers,
refresh, running, stopped) have been added to the English locale file but are
missing from the non-English locale files. Add these same keys with appropriate
translations to the zh-Hans.ts, zh-Hant.ts, ja.ts, and ko.ts locale files,
maintaining the same object structure and key names as in the English version.
Ensure all keys are present and properly translated in each non-English locale
to avoid runtime translation mismatches.

@cnjack cnjack merged commit e697fb2 into main Jun 23, 2026
3 checks passed
@cnjack cnjack deleted the feat/docker-remote-workspace branch June 23, 2026 00:54
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant