Skip to content

Commit bf2e5b8

Browse files
nagyvclaudejulianknutsen
authored
fix: skip symlinked directories in build-image context copy (gastownhall#2232)
## Summary filepath.Walk uses os.Lstat and does not follow symlinks, so symlinks to directories have IsDir()=false and were incorrectly passed to copyFile, which failed with "is a directory" when os.Open followed the link. Now detect symlinks via ModeSymlink: skip dir symlinks, copy file symlink content with the resolved mode. Closes gastownhall#2231 ## Testing - [x] `make check` - I ran it and it fails on some unrelated code - [ ] `make check-docs` if docs, navigation, or links changed: not relevant - [ ] `make test-integration` if runtime, controller, or workflow behavior changed: not relevant ## Checklist - [x] Linked an issue, or explained why one is not needed - [x] Added or updated tests for behavior changes - [ ] Updated docs for user-facing changes: not relevant - [ ] Called out breaking changes or migration notes: not relevant --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: Julian Knutsen <julianknutsen@users.noreply.github.com>
1 parent 3a77218 commit bf2e5b8

4 files changed

Lines changed: 354 additions & 3 deletions

File tree

cmd/gc/cmd_build_image.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ func doBuildImage(args []string, tag, baseImage string, rigPaths []string, push,
109109
BaseImage: baseImage,
110110
Tag: tag,
111111
RigPaths: rigs,
112+
Stderr: stderr,
112113
}
113114
if err := buildimage.AssembleContext(opts); err != nil {
114115
fmt.Fprintf(stderr, "gc build-image: %v\n", err) //nolint:errcheck // best-effort stderr

cmd/gc/cmd_build_image_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,49 @@ name = "test-city"
153153
_ = os.RemoveAll(outputDir)
154154
}
155155

156+
func TestBuildImageRoutesContextWarningsToStderr(t *testing.T) {
157+
cityDir := t.TempDir()
158+
targetDir := t.TempDir()
159+
160+
if err := os.MkdirAll(filepath.Join(cityDir, ".claude", "skills"), 0o755); err != nil {
161+
t.Fatal(err)
162+
}
163+
if err := os.WriteFile(filepath.Join(cityDir, "city.toml"), []byte(`[workspace]
164+
name = "test-city"
165+
`), 0o644); err != nil {
166+
t.Fatal(err)
167+
}
168+
if err := os.WriteFile(filepath.Join(targetDir, "skill.md"), []byte("# skill"), 0o644); err != nil {
169+
t.Fatal(err)
170+
}
171+
if err := os.Symlink(targetDir, filepath.Join(cityDir, ".claude", "skills", "core.gc-agents")); err != nil {
172+
t.Fatal(err)
173+
}
174+
175+
var stdout, stderr bytes.Buffer
176+
code := doBuildImage(
177+
[]string{cityDir},
178+
"",
179+
"gc-agent:latest",
180+
nil,
181+
false,
182+
true,
183+
&stdout, &stderr,
184+
)
185+
if code != 0 {
186+
t.Fatalf("doBuildImage returned %d; stderr: %s", code, stderr.String())
187+
}
188+
if !strings.Contains(stderr.String(), "skipping symlinked directory") {
189+
t.Fatalf("stderr = %q, want skipped directory symlink diagnostic", stderr.String())
190+
}
191+
192+
line := strings.TrimSpace(stdout.String())
193+
parts := strings.SplitN(line, ": ", 2)
194+
if len(parts) == 2 {
195+
_ = os.RemoveAll(parts[1])
196+
}
197+
}
198+
156199
func TestBuildImageCLIRegistered(t *testing.T) {
157200
var stdout, stderr bytes.Buffer
158201
root := newRootCmd(&stdout, &stderr)

internal/buildimage/context.go

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ type Options struct {
2424
Tag string
2525
// RigPaths maps rig name → local repo path for baking rig content.
2626
RigPaths map[string]string
27+
// Stderr receives non-fatal diagnostics. Defaults to os.Stderr.
28+
Stderr io.Writer
2729
}
2830

2931
// Manifest records what was baked into the image for debugging.
@@ -73,21 +75,25 @@ func AssembleContext(opts Options) error {
7375
if opts.BaseImage == "" {
7476
opts.BaseImage = "gc-agent:latest"
7577
}
78+
stderr := opts.Stderr
79+
if stderr == nil {
80+
stderr = os.Stderr
81+
}
7682

7783
wsDir := filepath.Join(opts.OutputDir, "workspace")
7884
if err := os.MkdirAll(wsDir, 0o755); err != nil {
7985
return fmt.Errorf("creating workspace dir: %w", err)
8086
}
8187

8288
// Copy city directory contents into workspace, excluding runtime state.
83-
if err := copyDirFiltered(opts.CityPath, wsDir); err != nil {
89+
if err := copyDirFiltered(opts.CityPath, wsDir, stderr); err != nil {
8490
return fmt.Errorf("copying city to workspace: %w", err)
8591
}
8692

8793
// Copy rig paths into workspace.
8894
for rigName, rigPath := range opts.RigPaths {
8995
rigDst := filepath.Join(wsDir, rigName)
90-
if err := copyDirFiltered(rigPath, rigDst); err != nil {
96+
if err := copyDirFiltered(rigPath, rigDst, stderr); err != nil {
9197
return fmt.Errorf("copying rig %q: %w", rigName, err)
9298
}
9399
}
@@ -118,7 +124,20 @@ func AssembleContext(opts Options) error {
118124
}
119125

120126
// copyDirFiltered copies src directory to dst, skipping excluded paths.
121-
func copyDirFiltered(src, dst string) error {
127+
// File symlinks are dereferenced and copied with the resolved file's mode,
128+
// unless the resolved target is excluded. Directory symlinks are skipped with
129+
// a diagnostic written to stderr. Broken symlinks are skipped; other
130+
// resolution errors are returned.
131+
func copyDirFiltered(src, dst string, stderr io.Writer) error {
132+
if stderr == nil {
133+
stderr = os.Stderr
134+
}
135+
absSrc, err := filepath.Abs(src)
136+
if err != nil {
137+
return fmt.Errorf("resolving source path %q: %w", src, err)
138+
}
139+
src = absSrc
140+
122141
return filepath.Walk(src, func(path string, info os.FileInfo, err error) error {
123142
if err != nil {
124143
return err
@@ -149,10 +168,52 @@ func copyDirFiltered(src, dst string) error {
149168
return os.MkdirAll(target, info.Mode())
150169
}
151170

171+
if info.Mode()&os.ModeSymlink != 0 {
172+
resolvedPath, err := filepath.EvalSymlinks(path)
173+
if err != nil {
174+
if os.IsNotExist(err) {
175+
return nil // broken symlink, skip
176+
}
177+
return fmt.Errorf("resolving symlink %q: %w", path, err)
178+
}
179+
resolved, err := os.Stat(resolvedPath)
180+
if err != nil {
181+
if os.IsNotExist(err) {
182+
return nil // broken symlink, skip
183+
}
184+
return fmt.Errorf("stat resolved symlink %q -> %q: %w", path, resolvedPath, err)
185+
}
186+
if resolved.IsDir() {
187+
fmt.Fprintf(stderr, "skipping symlinked directory %s -> %s in build context\n", path, resolvedPath) //nolint:errcheck // best-effort diagnostic
188+
return nil
189+
}
190+
resolvedRel, err := filepath.Rel(src, resolvedPath)
191+
if err != nil {
192+
return fmt.Errorf("rel resolved symlink %q -> %q: %w", path, resolvedPath, err)
193+
}
194+
if excludedPath(resolvedRel) || excludedResolvedPath(resolvedRel) {
195+
return nil
196+
}
197+
return copyFile(resolvedPath, target, resolved.Mode())
198+
}
199+
152200
return copyFile(path, target, info.Mode())
153201
})
154202
}
155203

204+
func excludedResolvedPath(rel string) bool {
205+
parts := strings.Split(filepath.ToSlash(filepath.Clean(rel)), "/")
206+
for i, part := range parts {
207+
if part != citylayout.RuntimeRoot || i == len(parts)-1 {
208+
continue
209+
}
210+
if excludedPath(filepath.Join(parts[i:]...)) {
211+
return true
212+
}
213+
}
214+
return false
215+
}
216+
156217
// copyFile copies a single file.
157218
func copyFile(src, dst string, mode os.FileMode) error {
158219
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {

0 commit comments

Comments
 (0)