fix(runas): run agent in its workspace under run_as_user (#1315)

sudo -i simulates a login and runs the command from the target user's HOME,
silently overriding cmd.Dir. Under run_as_user the agent therefore ignored its
(multi-workspace) working directory and started in HOME, so it couldn't see the
bound workspace or its CLAUDE.md.

Re-establish the intended cwd inside the spawn: when SpawnOptions.WorkDir is set,
BuildSpawnCommand wraps the command to 'cd "$CC_RUNAS_CHDIR" && exec "$@"'.
The path travels via the preserved env var (not argv) so non-ASCII/space paths
survive sudo's command re-quoting.

Fixes #1312
This commit is contained in:
Baocang Nie authored and GitHub committed 2026-06-16 10:01:32 +08:00
1 parent 1c92a03fdc
commit 58c7e27cc3
4 files changed
+107 -3

No files matched your search

+13
View File
@@ -192,6 +192,11 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
allArgs = append(allArgs, innerArgs...)
allArgs = append(allArgs, outerArgs...)
}
// Under run_as_user isolation, sudo -i ignores cmd.Dir (it chdirs to the
// target user's HOME), so the workspace must be re-applied inside the
// spawn. WorkDir tells BuildSpawnCommand to wrap the command with a chdir;
// the path itself is passed through RunAsChdirEnv below.
spawnOpts.WorkDir = workDir
cmd := core.BuildSpawnCommand(sessionCtx, spawnOpts, cliBin, allArgs...)
cmd.Dir = workDir
// Put the child into its own process group so Close() can terminate the
@@ -213,6 +218,14 @@ func newClaudeSession(ctx context.Context, workDir, cliBin string, cliExtraArgs
// hook itself without this env var, so the real work happens only
// once.
env = core.MergeEnv(env, []string{"CC_CONNECT_PERMISSION_HOOK_SKIP=1"})
// Carry the intended working directory across the sudo -i boundary so the
// re-chdir wrapper in BuildSpawnCommand can restore it (sudo -i would
// otherwise leave the agent in the target user's HOME). Only meaningful
// under isolation; FilterEnvForSpawn keeps it because mergedAllowlist
// includes RunAsChdirEnv whenever WorkDir is set.
if spawnOpts.IsolationMode() && workDir != "" {
env = core.MergeEnv(env, []string{core.RunAsChdirEnv + "=" + workDir})
}
// When run_as_user is set, strip the supervisor's environment down to
// the allowlist before passing it to sudo. sudo --preserve-env also
// enforces this, but filtering here makes the cc-connect spawn argv
+34 -3
View File
@@ -76,10 +76,21 @@ func currentUsername() string {
return u.Username
}
// RunAsChdirEnv carries the intended working directory across the sudo -i
// boundary. sudo -i simulates an initial login and runs the command from the
// target user's HOME, which silently overrides the supervisor's cmd.Dir. The
// spawned command therefore re-establishes the intended cwd by chdir-ing to
// this value before exec-ing the real binary. It travels via the environment
// (not argv) so directories containing spaces or non-ASCII survive sudo's
// command re-quoting. Callers set it on cmd.Env when SpawnOptions.WorkDir is
// non-empty; BuildSpawnCommand adds it to the --preserve-env allowlist.
const RunAsChdirEnv = "CC_RUNAS_CHDIR"
// DefaultEnvAllowlist is the minimal env preserved across the sudo
// boundary. Deliberately excluded:
// - HOME / USER / LOGNAME / SHELL — sudo -i overrides them
// - PWD — set by cmd.Dir
// - PWD — sudo -i overrides cmd.Dir, so the intended cwd is re-applied
// inside the spawn via RunAsChdirEnv (see BuildSpawnCommand)
// - PATH — sudo -i builds it from the target user's /etc/profile +
// ~/.profile. Preserving the supervisor's PATH would (a) leak
// supervisor work directories into the isolated session and
@@ -101,6 +112,11 @@ var DefaultEnvAllowlist = []string{
type SpawnOptions struct {
RunAsUser string
EnvAllowlist []string // extends DefaultEnvAllowlist, not a replacement
// WorkDir is the directory the spawned command should run in. Under
// sudo -i (IsolationMode) cmd.Dir is ignored because -i chdirs to the
// target user's HOME, so when WorkDir is set BuildSpawnCommand wraps the
// command to chdir into it via RunAsChdirEnv. Empty = leave cwd to -i.
WorkDir string
}
func (o SpawnOptions) IsolationMode() bool {
@@ -108,10 +124,14 @@ func (o SpawnOptions) IsolationMode() bool {
}
func (o SpawnOptions) mergedAllowlist() []string {
seen := make(map[string]struct{}, len(DefaultEnvAllowlist)+len(o.EnvAllowlist))
seen := make(map[string]struct{}, len(DefaultEnvAllowlist)+len(o.EnvAllowlist)+1)
for _, v := range DefaultEnvAllowlist {
seen[v] = struct{}{}
}
if o.WorkDir != "" {
// The re-chdir wrapper needs this env var to survive --preserve-env.
seen[RunAsChdirEnv] = struct{}{}
}
for _, v := range o.EnvAllowlist {
if v == "" {
continue
@@ -141,7 +161,18 @@ func BuildSpawnCommand(ctx context.Context, opts SpawnOptions, name string, args
"-iu", opts.RunAsUser,
"--preserve-env=" + strings.Join(opts.mergedAllowlist(), ","),
"--",
name,
}
if opts.WorkDir != "" {
// sudo -i runs the command from the target user's HOME, overriding
// cmd.Dir, so the agent would otherwise start in the wrong directory
// (e.g. ignoring a multi-workspace binding). Re-establish the intended
// cwd inside the spawn. The path travels in $CC_RUNAS_CHDIR (a
// preserved env var, set by the caller) rather than argv so paths with
// spaces or non-ASCII survive sudo's command re-quoting.
sudoArgs = append(sudoArgs,
"/bin/sh", "-c", "cd \"$"+RunAsChdirEnv+"\" || exit 1; exec \"$@\"", "sh", name)
} else {
sudoArgs = append(sudoArgs, name)
}
sudoArgs = append(sudoArgs, args...)
return exec.CommandContext(ctx, "sudo", sudoArgs...)
+55
View File
@@ -72,6 +72,61 @@ func TestBuildSpawnCommand_RunAsUser(t *testing.T) {
}
}
// TestBuildSpawnCommand_RunAsUserWithWorkDir guards the fix for the bug where
// sudo -i runs the command from the target user's HOME, ignoring cmd.Dir, so a
// run_as_user agent ignored its (multi-workspace) working directory. With
// WorkDir set the command must be wrapped so it chdirs into WorkDir — passed
// via the CC_RUNAS_CHDIR env var (added to the preserve-env allowlist), not
// argv, so non-ASCII paths survive sudo's command re-quoting.
func TestBuildSpawnCommand_RunAsUserWithWorkDir(t *testing.T) {
ctx := context.Background()
opts := SpawnOptions{
RunAsUser: "partseeker-coder",
WorkDir: "/opt/shared/workspaces/设计原型界面",
}
cmd := BuildSpawnCommand(ctx, opts, "claude", "--version", "-p", "hello")
if cmd == nil {
t.Fatal("BuildSpawnCommand returned nil")
}
// The chdir env var must be on the preserve-env allowlist or it would be
// stripped before the wrapper can read it.
preserved := strings.Split(strings.TrimPrefix(cmd.Args[4], "--preserve-env="), ",")
if !slices.Contains(preserved, RunAsChdirEnv) {
t.Errorf("preserve-env missing %q; got %v", RunAsChdirEnv, preserved)
}
if cmd.Args[5] != "--" {
t.Fatalf("args[5] = %q, want --", cmd.Args[5])
}
// Expected wrapper: -- /bin/sh -c "cd \"$CC_RUNAS_CHDIR\" || exit 1; exec \"$@\"" sh claude --version -p hello
wantWrap := []string{
"/bin/sh", "-c",
"cd \"$" + RunAsChdirEnv + "\" || exit 1; exec \"$@\"",
"sh", "claude", "--version", "-p", "hello",
}
if !reflect.DeepEqual(cmd.Args[6:], wantWrap) {
t.Fatalf("wrapped argv = %v, want %v", cmd.Args[6:], wantWrap)
}
// The path itself must NOT appear in argv (it travels via env).
for _, a := range cmd.Args {
if strings.Contains(a, "设计原型界面") {
t.Errorf("workdir leaked into argv %q; must travel via %s env", a, RunAsChdirEnv)
}
}
}
// TestMergedAllowlist_WorkDirAddsChdirEnv asserts the chdir env var is only
// allowlisted when WorkDir is set (no leak in the common case).
func TestMergedAllowlist_WorkDirAddsChdirEnv(t *testing.T) {
withWD := SpawnOptions{RunAsUser: "u", WorkDir: "/tmp/ws"}.mergedAllowlist()
if !slices.Contains(withWD, RunAsChdirEnv) {
t.Errorf("with WorkDir: allowlist missing %q; got %v", RunAsChdirEnv, withWD)
}
noWD := SpawnOptions{RunAsUser: "u"}.mergedAllowlist()
if slices.Contains(noWD, RunAsChdirEnv) {
t.Errorf("without WorkDir: allowlist should not contain %q; got %v", RunAsChdirEnv, noWD)
}
}
func TestFilterEnvForSpawn_Legacy(t *testing.T) {
env := []string{"PATH=/usr/bin", "SECRET=top", "PWD=/tmp"}
got := FilterEnvForSpawn(env, SpawnOptions{})
+5
View File
@@ -8,6 +8,10 @@ import (
"os/exec"
)
// RunAsChdirEnv is unused on Windows (run_as_user is not supported) but is
// defined so cross-platform callers referencing core.RunAsChdirEnv compile.
const RunAsChdirEnv = "CC_RUNAS_CHDIR"
// DefaultEnvAllowlist is a stub on Windows — run_as_user is not supported.
var DefaultEnvAllowlist = []string{}
@@ -15,6 +19,7 @@ var DefaultEnvAllowlist = []string{}
type SpawnOptions struct {
RunAsUser string
EnvAllowlist []string
WorkDir string
}
// IsolationMode always returns false on Windows.