fix(gpg): resolve tmp file collision breaking GPG forwarding for browser IDEs - #869
Conversation
Required to keep the tree building after Task 1's CreateSSHCommand signature change; full Task 2 scope (tests, browser_tunnel.go audit) is out of scope for this task.
…ith non-root remoteUser
…ustification acquireGPGSetupLock's os.Chmod(0o666) ran unconditionally, so a non-owning second acquirer (e.g. a different container user than the one that created the lock file) would get EPERM even when the mode was already correct. Skip the chmod when the file's mode already matches, and only attempt (and propagate errors from) it when a real change is needed.
Resolves revive's argument-limit without a nolint suppression: the function's 5 positional args (3 of them same-typed strings) become one sshCommandArgsParams struct, removing the transposition hazard along with the lint finding.
Reasoning is preserved (multi-user /tmp collision, umask vs chmod ownership semantics, EPERM-on-redundant-chmod) — condensed to the essential point rather than removed.
|
Warning Review limit reached
Next review available in: 13 minutes You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughThe change adds secure shared-file permission handling, applies it to GPG, activity, and result files, propagates resolved SSH users, and adds non-root GPG forwarding coverage. ChangesWorkspace coordination and non-root workspace support
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
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. Comment |
✅ Deploy Preview for devsydev canceled.
|
✅ Deploy Preview for images-devsy-sh canceled.
|
…ode assertion os.Chmod follows symlinks; the GPG setup lock path is a fixed, predictable, world-writable /tmp path, so a symlink planted there could redirect the chmod onto an arbitrary root-owned file. lockFileNeedsChmod now Lstats and refuses to proceed if the path is a symlink. Also compares the activity-file test's mode assertion against the literal 0o666 instead of the production constant it's meant to be checking. Addresses CodeRabbit findings on PR #869.
A lock file left behind by a pre-fix binary (or created by a different user) could sit at 0600. flock's own open() would EACCES on it before acquireGPGSetupLock's existing post-lock chmod logic ever ran, wedging a non-owning acquirer exactly like the original bug this branch fixes. widenStaleLockFile runs before the flock attempt: it chmods a restrictive existing lock file to 0666, escalating to sudo when the current process doesn't own it. A sudo failure is non-fatal — the subsequent flock attempt surfaces a clearer error if widening didn't help. Addresses the Major follow-up noted on PR #869's CodeRabbit review.
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 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 `@cmd/internal/agentworkspace/setup_gpg.go`:
- Around line 162-189: Update widenStaleLockFile to accept a context and invoke
the sudo chmod fallback through exec.CommandContext with a bounded timeout,
including sudo’s non-interactive -n option so unavailable credentials fail
immediately. Update its call site to pass the existing context while preserving
the current non-fatal logging and error behavior.
In `@pkg/tunnel/browser_test.go`:
- Around line 66-155: Add the missing testCtxName declaration in
pkg/tunnel/browser_test.go before the helper functions baseSSHArgs and
baseParams use it, using the existing test constant/declaration style and the
context value expected by these tests.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: c3362ead-0eed-492b-ab6d-489f9e0c386c
📒 Files selected for processing (10)
cmd/internal/agentworkspace/setup_gpg.gocmd/internal/agentworkspace/setup_gpg_test.gocmd/internal/ssh_server_test.goe2e/tests/ide/browser_returns.goe2e/tests/ide/testdata-gpg-nonroot/.devcontainer.jsonpkg/gpg/forward_test.gopkg/ide/opener/browser_tunnel_test.gopkg/ide/opener/opener.gopkg/tunnel/browser.gopkg/tunnel/browser_test.go
| func baseSSHArgs(ctx, user, ws string) []string { | ||
| return []string{ | ||
| "workspace", "ssh", "--user=root", "--agent-forwarding=false", | ||
| "workspace", "ssh", "--user=" + user, "--agent-forwarding=false", | ||
| "--start-services=false", "--context", ctx, ws, | ||
| } | ||
| } | ||
|
|
||
| func baseParams(user string) sshCommandArgsParams { | ||
| return sshCommandArgsParams{ | ||
| clientContext: testCtxName, workspace: testWorkspaceName, user: user, | ||
| } | ||
| } | ||
|
|
||
| func TestBuildSSHCommandArgs(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| context string | ||
| workspace string | ||
| debug bool | ||
| extraArgs []string | ||
| expected []string | ||
| name string | ||
| params sshCommandArgsParams | ||
| expected []string | ||
| }{ | ||
| { | ||
| name: "basic", context: "default", workspace: "my-workspace", | ||
| expected: baseSSHArgs("default", "my-workspace"), | ||
| name: "basic root user", | ||
| params: baseParams(testUserRoot), | ||
| expected: baseSSHArgs(testCtxName, testUserRoot, testWorkspaceName), | ||
| }, | ||
| { | ||
| name: "non-root workspace user", | ||
| params: baseParams(testUserVSCode), | ||
| expected: baseSSHArgs(testCtxName, testUserVSCode, testWorkspaceName), | ||
| }, | ||
| { | ||
| name: "empty user falls back to root", | ||
| params: baseParams(""), | ||
| expected: baseSSHArgs(testCtxName, testUserRoot, testWorkspaceName), | ||
| }, | ||
| { | ||
| name: "with debug", context: "default", workspace: "my-workspace", | ||
| debug: true, | ||
| expected: append(baseSSHArgs("default", "my-workspace"), "--debug"), | ||
| name: "with debug", | ||
| params: sshCommandArgsParams{ | ||
| clientContext: testCtxName, workspace: testWorkspaceName, | ||
| user: testUserVSCode, debug: true, | ||
| }, | ||
| expected: append( | ||
| baseSSHArgs(testCtxName, testUserVSCode, testWorkspaceName), "--debug", | ||
| ), | ||
| }, | ||
| { | ||
| name: "with extra args", context: "prod", workspace: "ws", | ||
| extraArgs: []string{"--stdio", "--log-output=raw"}, | ||
| expected: append(baseSSHArgs("prod", "ws"), "--stdio", "--log-output=raw"), | ||
| name: "with extra args", | ||
| params: sshCommandArgsParams{ | ||
| clientContext: "prod", workspace: "ws", user: testUserVSCode, | ||
| extraArgs: []string{"--stdio", "--log-output=raw"}, | ||
| }, | ||
| expected: append( | ||
| baseSSHArgs("prod", testUserVSCode, "ws"), "--stdio", "--log-output=raw", | ||
| ), | ||
| }, | ||
| { | ||
| name: "with debug and extra args", context: "default", workspace: "my-workspace", | ||
| debug: true, extraArgs: []string{"--stdio"}, | ||
| expected: append(baseSSHArgs("default", "my-workspace"), "--debug", "--stdio"), | ||
| name: "with debug and extra args", | ||
| params: sshCommandArgsParams{ | ||
| clientContext: testCtxName, workspace: testWorkspaceName, user: testUserVSCode, | ||
| debug: true, extraArgs: []string{"--stdio"}, | ||
| }, | ||
| expected: append( | ||
| baseSSHArgs(testCtxName, testUserVSCode, testWorkspaceName), "--debug", "--stdio", | ||
| ), | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| got := buildSSHCommandArgs(tt.context, tt.workspace, tt.debug, tt.extraArgs) | ||
| got := buildSSHCommandArgs(tt.params) | ||
| assert.Equal(t, tt.expected, got) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestBuildBackhaulCmd_UsesResolvedRemoteUser(t *testing.T) { | ||
| writer := &bytes.Buffer{} | ||
| cmd := buildBackhaulCmd(context.Background(), backhaulCmdParams{ | ||
| execPath: "/usr/bin/true", | ||
| remoteUser: testUserVSCode, | ||
| client: fakeWorkspaceClient{}, | ||
| authSockID: "sock123", | ||
| writer: writer, | ||
| }) | ||
|
|
||
| joined := strings.Join(cmd.Args, " ") | ||
| assert.Contains(t, joined, "--user vscode", | ||
| "backhaul connection must use the resolved workspace user, not root, "+ | ||
| "so it doesn't fight the primary tunnel's ssh-server/setup-gpg sessions "+ | ||
| "over the shared /tmp coordination files") | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Description: Confirm testCtxName is declared in pkg/tunnel/browser_test.go.
rg -n 'testCtxName' pkg/tunnel/browser_test.goRepository: devsy-org/devsy
Length of output: 780
🏁 Script executed:
#!/bin/bash
# Inspect the test file declarations and all references to testCtxName.
set -euo pipefail
echo "Declaration / reference occurrences in pkg/tunnel/browser_test.go:"
rg -n '\b(testCtxName|testUserRoot|testUserVSCode|testWorkspaceName)\b' pkg/tunnel/browser_test.go || true
echo
echo "Top of file:"
sed -n '1,110p' pkg/tunnel/browser_test.goRepository: devsy-org/devsy
Length of output: 4270
Define testCtxName before using it.
baseSSHArgs and baseParams reference testCtxName, but pkg/tunnel/browser_test.go does not declare it. Add the missing declaration to avoid a compile-time failure for this test file.
🤖 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 `@pkg/tunnel/browser_test.go` around lines 66 - 155, Add the missing
testCtxName declaration in pkg/tunnel/browser_test.go before the helper
functions baseSSHArgs and baseParams use it, using the existing test
constant/declaration style and the context value expected by these tests.
Without a context or sudo -n, a sudo prompt for credentials could block acquireGPGSetupLock indefinitely, defeating gpgSetupLockTimeout entirely. exec.CommandContext ties the subprocess to the caller's existing lock timeout; -n makes sudo fail immediately instead of prompting. Addresses a CodeRabbit finding on PR #869. The paired finding (missing testCtxName in pkg/tunnel/browser_test.go) is a false positive: the constant is declared in the same package's services_test.go:13 and the package already builds and vets clean.
…s root Task 1 of this fix threaded the resolved remoteUser into startBrowserTunnelSSH's CreateSSHCommand call to align it with the GPG-forwarding tunnel's user, avoiding a collision on shared /tmp coordination files. That connection is also the one runBrowserTunnelServices/RunServices uses to read root-owned files (e.g. DevContainerResultPath, 0600 root:root) and to bootstrap the container-side ssh-server that serves the actual port-forward listener. Su-wrapping that bootstrap into remoteUser broke both: RunServices's forwardDevContainerPorts got permission denied on result.json, and the browser-tunnel's port-forward stopped surviving past the idle-timeout window — reproduced deterministically against real Docker containers, twice. The GPG lock/activity file collision this was meant to prevent is already resolved independently by making /tmp/devsy-gpg-setup.lock and /tmp/devsy.activity safe for a mixed root+remoteUser pair (see acquireGPGSetupLock's world-lockable mode and ensureActivityFile's 0666 mode) — this connection never needed to match remoteUser in the first place. Verified against real Docker containers: the previously-flaky "browser- tunnel port-forward survives past the old 5s idle timeout" e2e spec now passes consistently (5/5 local runs) with this revert; it failed consistently before it, on both the pre- and post-Task-1 code.
…Path ownership Widens /var/run/devsy/result.json to 0644 (readable by any container user; it holds no secrets, just devcontainer.json's merged config/mounts/port attributes) instead of the previous 0600 root:root, and adds a chmod to correct a stale restrictive mode left by a pre-fix binary. This removes the actual coupling behind the prior commit's "keep the primary browser-tunnel connection root" workaround: getContainerResult and credentials_server.go's portOptionsFromResult both read this file over an SSH session that can be authenticated as either root or the workspace's remoteUser (pkg/ssh/server/ssh_container.go's containerServer.handler sets the process credential directly from the authenticated session user — one identity per session, no separate privilege-drop step). With the file world-readable, startBrowserTunnelSSH's connection can use p.User again, matching the GPG-forwarding tunnel's user as originally intended, without breaking RunServices' privileged-file read. Verified against real Docker containers: the full "browser IDE returns instead of blocking" Ordered container (7 specs, including both the idle- timeout regression from the prior commit and the GPG-collision e2e test) now passes cleanly with p.User restored.
…o pkg/sharedfile A devsy container runs commands over SSH sessions authenticated as either root or the workspace's remoteUser, so any file two sessions both touch needs its permissions to survive being created by either one — the standard Unix pattern for this (like /tmp/.X11-unix or /var/run/utmp), not a workaround. Three call sites implemented that pattern's safety requirements inconsistently, having drifted apart over time: - cmd/internal/agentworkspace/setup_gpg.go (acquireGPGSetupLock): correct, stat-first chmod plus a sudo fallback for stale root-owned files — the reference implementation this refactor generalizes. - cmd/internal/agentcontainer/daemon.go (setupTimeout): unconditional os.Chmod on every call, which EPERMs when a non-owning process's activity file is already correctly moded. - cmd/internal/fleet_server.go: os.Create with no explicit mode or chmod at all, so under a typical 022 umask it silently creates the activity file at 0644 and locks out whichever user isn't its own UID. pkg/sharedfile centralizes the safety logic (create-if-missing without truncating a concurrent creator's write, stat-first chmod to avoid EPERM for a non-owning widener, Lstat + symlink rejection so a planted symlink at the fixed coordination-file path can't redirect a chmod onto an arbitrary target, and an optional non-interactive sudo fallback for the one caller that needs it). All three call sites, plus cmd/internal's own ensureActivityFile, now delegate to it instead of re-implementing it. fleet_server.go's activity-touch is extracted into ssh_server.go's new touchActivityFile, parameterized on path (was previously hardcoded to config.ContainerActivityFile, making it untestable). Verified against real Docker containers: the full "browser IDE returns instead of blocking" Ordered container (7 specs, including the idle- timeout and GPG-collision regressions from prior commits on this branch) passes cleanly with this refactor applied.
- CreateSSHCommand, WidenWithSudoFallback, touchActivityFile, and gpgSetupLockMode: trim WHY comments that had grown into WHAT restatements or cross-package implementation references. - writeResultFileTo: use sharedfile.WidenIfNeeded's umask-safe chmod instead of a fourth hand-rolled copy of the same write-then-chmod logic this session's refactor consolidated everywhere else. - containsAdjacent (browser_tunnel_test.go): restore a doc comment stating argument order, and rename needle/value to flag/value to match.
log has no dependency on pkg/sharedfile, so there's no cycle risk in calling pkg/log directly instead of threading a logFn parameter through for the one real caller.
CodeRabbit correctly flagged that WidenIfNeeded's separate Lstat-then- Chmod-by-path had a race window: a symlink planted at path between the check and the chmod would redirect the chmod onto an arbitrary target, since chmod(2) follows symlinks. The prior symlink-rejection fix only caught a symlink already in place at check time, not one swapped in after. WidenIfNeeded now opens path with O_NOFOLLOW and chmods the resulting file descriptor instead of the path — once open, the descriptor is pinned to that inode regardless of what the path later resolves to, so there's no window left to race. WidenWithSudoFallback had the same race one layer up: `sudo chmod <path>` has no way to refuse following a symlink at its target, unlike chmod's lesser-known variants for other attributes. It now re-execs `<self> internal widen-shared-file <path> <mode>` under sudo instead, so the escalated mode change still goes through WidenIfNeeded's O_NOFOLLOW open. Also fixes a related bug CodeRabbit flagged in writeResultFileTo: the unchanged-content early return skipped mode-widening entirely, so a stale restrictively-moded file with matching content never got repaired until its content next changed. Verified against real Docker containers: the full "browser IDE returns instead of blocking" Ordered container passes with these changes, including the GPG-collision spec.
syscall.O_NOFOLLOW is undefined on windows, breaking the devsy CLI's windows-latest cross-compile — every sharedfile caller (setup-gpg, the SSH server's activity file, the devcontainer result file) only ever runs inside the Linux container, never on a Windows host, but the CLI binary itself still needs to build there. Extracts the platform-specific open into openNoFollow, split supported/unsupported like the existing pkg/file convention. The windows stub errors if ever called, since nothing should reach it in practice.
…d is needed WidenIfNeeded's own open required write access to the file, even for the "mode already matches, skip the chmod" fast path — the exact case meant for a non-owning caller. A coordination-file mode like 0644 grants read to everyone but write only to the owner, so a non-owning process calling WidenIfNeeded on an already-correct 0644 file got a false permission error instead of the intended no-op. fchmod cares about ownership, not how the fd was opened, so O_RDONLY is sufficient — it only needs write access in the branch that's about to Chmod anyway, at which point ownership is what governs that regardless. This also meant WidenWithSudoFallback would escalate to sudo needlessly on every call against a 0644 file it doesn't own, even when the mode was already correct, since WidenIfNeeded's false EACCES looked identical to a real EPERM-from-wrong-mode to that caller. Currently masked in production because every real call site but writeResultFileTo's uses 0666 (write for everyone), so this only bit the one 0644 caller — and even there, by luck of that caller always running as the same user (root) on every invocation today. Found by manually re-auditing this package's file-open modes against the actual permission bits each caller uses, not by a failing test.
…nging CodeRabbit correctly flagged that openNoFollow's open could block forever: a FIFO planted at the coordination-file path with no writer hangs an O_RDONLY open indefinitely, letting any container user with write access to the parent directory (/tmp) wedge every future caller of acquireGPGSetupLock/ensureActivityFile/etc. openNoFollow now also passes O_NONBLOCK, which makes opening a FIFO return immediately instead of waiting for a writer. WidenIfNeeded then rejects any non-regular file via IsRegular() before comparing modes or chmodding — O_NONBLOCK only keeps the open itself from hanging, it doesn't stop a FIFO fd from being returned. Verified: a regression test creates a real FIFO at the coordination path and confirms WidenIfNeeded returns an error within 2s instead of hanging; fails against the pre-fix code (confirmed by temporarily reverting O_NONBLOCK), passes with it.
There was a problem hiding this comment.
Actionable comments posted: 2
🧹 Nitpick comments (1)
cmd/internal/ssh_server_test.go (1)
225-238: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick winCover stale permissions in the existing-file test.
Line 229 creates the fixture with mode
0o666, so this test cannot detect iftouchActivityFilestops widening a restrictive existing file. Start with0o600, then assert that the mode is0o666after the call. IfensureActivityFileowns this behavior, add the assertion to its existing-file test instead.Suggested regression test
- require.NoError(t, os.WriteFile(path, nil, 0o666)) + require.NoError(t, os.WriteFile(path, nil, 0o600)) require.NoError(t, os.Chtimes(path, old, old)) touchActivityFile(path) info, err := os.Stat(path) require.NoError(t, err) + assert.Equal(t, os.FileMode(0o666), info.Mode().Perm()) assert.WithinDuration(t, time.Now(), info.ModTime(), 2*time.Second,🤖 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 `@cmd/internal/ssh_server_test.go` around lines 225 - 238, Update TestTouchActivityFile_UpdatesMtimeOfExistingFile to create the existing fixture with restrictive mode 0o600, then stat it after touchActivityFile and assert its permissions are 0o666 while preserving the mtime assertion. If ensureActivityFile owns the permission widening, place this regression assertion in its existing-file test instead.
🤖 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 `@pkg/devcontainer/setup/setup.go`:
- Around line 224-238: Update the result-file flow around the existing
os.ReadFile, os.WriteFile, and sharedfile.WidenIfNeeded calls to use no-follow,
nonblocking file descriptors for reading, content comparison, creation/update,
and permission changes. Ensure symlink substitution and FIFO paths cannot
redirect or block the root caller, while preserving the unchanged-content
widening behavior. Add regression tests covering both symlink substitution and
FIFO inputs.
In `@pkg/sharedfile/sharedfile_unsupported.go`:
- Around line 11-14: Replace the unsupported openNoFollow implementation in
sharedfile_unsupported.go with a Windows-safe regular-file widening path,
preserving symlink safety when used by WidenIfNeeded and writeResultFileTo. Use
the platform-specific implementation or Windows APIs needed to reject symlinks
while allowing regular result files to be reopened successfully.
---
Nitpick comments:
In `@cmd/internal/ssh_server_test.go`:
- Around line 225-238: Update TestTouchActivityFile_UpdatesMtimeOfExistingFile
to create the existing fixture with restrictive mode 0o600, then stat it after
touchActivityFile and assert its permissions are 0o666 while preserving the
mtime assertion. If ensureActivityFile owns the permission widening, place this
regression assertion in its existing-file test instead.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: 907ef724-04db-4a56-a26a-a7fd15874d22
📒 Files selected for processing (20)
cmd/internal/agentcontainer/daemon.gocmd/internal/agentworkspace/setup_gpg.gocmd/internal/agentworkspace/setup_gpg_test.gocmd/internal/fleet_server.gocmd/internal/internal.gocmd/internal/ssh_server.gocmd/internal/ssh_server_test.gocmd/internal/widen_shared_file.gocmd/internal/widen_shared_file_test.gopkg/devcontainer/setup/setup.gopkg/devcontainer/setup/setup_test.gopkg/ide/opener/browser_tunnel_test.gopkg/sharedfile/sharedfile.gopkg/sharedfile/sharedfile_supported.gopkg/sharedfile/sharedfile_supported_test.gopkg/sharedfile/sharedfile_test.gopkg/sharedfile/sharedfile_unsupported.gopkg/sharedfile/sudo.gopkg/sharedfile/sudo_test.gopkg/tunnel/browser.go
🚧 Files skipped from review as they are similar to previous changes (4)
- cmd/internal/agentworkspace/setup_gpg.go
- cmd/internal/agentworkspace/setup_gpg_test.go
- pkg/ide/opener/browser_tunnel_test.go
- pkg/tunnel/browser.go
| // openNoFollow has no symlink-safe open on Windows. | ||
| func openNoFollow(string) (*os.File, error) { | ||
| return nil, fmt.Errorf("sharedfile: not supported on %s", runtime.GOOS) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift
Provide a Windows-safe implementation.
Line 13 makes every Windows call to WidenIfNeeded fail. writeResultFileTo calls this function after each write, so the new result-file tests fail on Windows and callers receive an error after the file is created.
Implement a Windows-safe regular-file path, or use a platform-specific widening implementation that preserves the required symlink safety.
🤖 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 `@pkg/sharedfile/sharedfile_unsupported.go` around lines 11 - 14, Replace the
unsupported openNoFollow implementation in sharedfile_unsupported.go with a
Windows-safe regular-file widening path, preserving symlink safety when used by
WidenIfNeeded and writeResultFileTo. Use the platform-specific implementation or
Windows APIs needed to reject symlinks while allowing regular result files to be
reopened successfully.
…sharedfile Addresses two legitimate CodeRabbit findings from this session's third review pass: 1. TestTouchActivityFile_UpdatesMtimeOfExistingFile seeded its fixture already at 0666, so it could never have caught touchActivityFile regressing the "widen a stale restrictive mode" behavior. Added the missing mode assertion to ensureActivityFile's own existing-file test instead, since that function is what actually owns the widening. 2. writeResultFileTo did its own raw os.ReadFile/os.WriteFile rather than routing through sharedfile's no-follow/non-blocking primitives, unlike every other coordination-file writer in this codebase. Not currently exploitable (DevContainerResultPath's parent dir is 0755, owner-only — a non-root container user can't plant a symlink or FIFO there today), but inconsistent defense-in-depth for a fixed, predictable path. Adds sharedfile.ReadFile/WriteFile as symlink/FIFO-safe counterparts to os.ReadFile/os.WriteFile, sharing the same openNoFollow primitive WidenIfNeeded already used (now parameterized on flag and create mode so all four functions — EnsureMode, WidenIfNeeded, ReadFile, WriteFile — go through one hardened open). writeResultFileTo now calls these instead of the stdlib versions. Verified against real Docker containers: the full "browser IDE returns instead of blocking" Ordered container (7 specs) passes with this rewiring in place, confirming devsy up's result-file read/write path still works end-to-end.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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 `@pkg/sharedfile/sharedfile.go`:
- Around line 81-95: Update WriteFile to return errors from both f.Stat() and
f.Close() instead of ignoring them. Preserve the existing write and chmod error
context, ensure a failed Stat prevents silent success, and restructure cleanup
so a Close failure is propagated after successful writes and permission
handling.
🪄 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: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: dd65c82c-43a3-4fda-b414-3572be7d0ced
📒 Files selected for processing (8)
cmd/internal/ssh_server_test.gopkg/devcontainer/setup/setup.gopkg/devcontainer/setup/setup_unix_test.gopkg/sharedfile/sharedfile.gopkg/sharedfile/sharedfile_supported.gopkg/sharedfile/sharedfile_supported_test.gopkg/sharedfile/sharedfile_test.gopkg/sharedfile/sharedfile_unsupported.go
🚧 Files skipped from review as they are similar to previous changes (4)
- pkg/sharedfile/sharedfile_supported.go
- pkg/sharedfile/sharedfile_unsupported.go
- pkg/devcontainer/setup/setup.go
- cmd/internal/ssh_server_test.go
CodeRabbit correctly flagged that WriteFile's Stat error was swallowed (statErr == nil && ... short-circuited the mode-check branch entirely on any Stat failure, falling through to a silent nil return) and its Close error was discarded via the deferred _ = f.Close(). A caller could not distinguish "wrote and confirmed the mode" from "wrote, but I have no idea what mode it ended up at" — and since os.File is unbuffered, a rare Close-time failure (some filesystems defer write errors to close) would report success on data that was not actually durable. Stat's error now returns immediately instead of being ignored. Close's error is captured into the named return via the same error-if-not-already-set pattern os.WriteFile itself uses, so an earlier write/stat/chmod error still takes priority over a Close failure.
Summary
Browser-based IDE workspaces (openvscode, code-server, vscode-web, jupyter, marimo) opened two independent SSH sessions into the dev container as different OS users: the primary browser-IDE tunnel was hardcoded to
root, while the GPG-agent-forwarding tunnel correctly resolved and used the workspace'sremoteUser. Both sessions wrote to the same owner-exclusive files under/tmp(devsy-gpg-setup.lock,devsy.activity), so whichever session's user created the file first locked the other out withEACCES/EPERM— surfacing aspermission denied,operation not permitted, and ultimately "GPG agent forwarding failed ... continuing without it" for any browser IDE against a devcontainer with a non-rootremoteUser.buildSSHCommandArgs/CreateSSHCommand, plus thestartFleetcall site), so it matches the GPG-forwarding tunnel's user./tmp/devsy-gpg-setup.lockworld-lockable (0o666) as defense-in-depth, with a stat-first check so a non-owning second acquirer doesn't hit a redundant, EPERM-pronechmod.0o666.--ssh-gpg-forwardingagainst aremoteUser-set devcontainer) — independently run against real Docker containers and confirmed passing.Test plan
go build ./...,go vet ./...,go test ./...all pass.golangci-lint run --new-from-rev=<base> ./...reports 0 new issues.ide/sshlabels pass, including the new regression spec and the pre-existing GPG specs.--ide=vscode-web --ide-launch=headless --ssh-gpg-forwardingagainst aremoteUser-set devcontainer):gpg -Kinside the container lists the forwarded key with zero occurrences of the three failure substrings.Summary by CodeRabbit
Bug Fixes
Tests