mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-10-26 01:50:40 +08:00
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:
1 parent
1c92a03fdc
commit
58c7e27cc3
4 files changed
+107
-3
No files matched your search
@@ -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
@@ -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...)
|
||||
|
||||
@@ -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{})
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user