Skip to content

Commit ae656e2

Browse files
committed
chore: fixup
1 parent c5265ba commit ae656e2

File tree

5 files changed

+120
-41
lines changed

5 files changed

+120
-41
lines changed

experimental/aitools/lib/installer/installer.go

Lines changed: 64 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ const (
2727
skillsRepoName = "databricks-agent-skills"
2828
skillsRepoPath = "skills"
2929
defaultSkillsRepoRef = "v0.1.4"
30+
httpTimeout = 30 * time.Second
3031
)
3132

3233
// managedSkillMarker is written at the root of each materialized (non-symlink)
@@ -94,14 +95,17 @@ func skillFileRawURL(ref, skillName, filePath string) string {
9495
}
9596

9697
func fetchSkillFile(ctx context.Context, ref, skillName, filePath string) ([]byte, error) {
98+
if rootPath, ok := strings.CutPrefix(filePath, sharedFilePrefix); ok && rootPath == "" {
99+
return nil, fmt.Errorf("invalid manifest file entry: empty path after %q", sharedFilePrefix)
100+
}
97101
url := skillFileRawURL(ref, skillName, filePath)
98102

99103
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
100104
if err != nil {
101105
return nil, fmt.Errorf("failed to create request: %w", err)
102106
}
103107

104-
client := &http.Client{Timeout: 30 * time.Second}
108+
client := &http.Client{Timeout: httpTimeout}
105109
resp, err := client.Do(req)
106110
if err != nil {
107111
return nil, fmt.Errorf("failed to fetch %s: %w", filePath, err)
@@ -147,6 +151,8 @@ func InstallSkillsForAgents(ctx context.Context, src ManifestSource, targetAgent
147151
if len(targetAgents) == 0 {
148152
return fmt.Errorf("no agents support project-scoped skills. The following detected agents are global-only: %s", strings.Join(incompatible, ", "))
149153
}
154+
} else if len(targetAgents) == 0 {
155+
return errors.New("no target agents")
150156
}
151157

152158
// Load existing state for idempotency checks.
@@ -419,6 +425,7 @@ func installSkillForAgents(ctx context.Context, skillName string, files []string
419425
// Symlinks point at the canonical tree (full manifest layout for every agent).
420426
symlinkEligible := params.scope == ScopeProject || len(detectedAgents) > 1
421427

428+
installedForAgent := 0
422429
for _, agent := range detectedAgents {
423430
agentSkillDir, err := agentSkillsDirForScope(ctx, agent, params.scope, params.cwd)
424431
if err != nil {
@@ -449,15 +456,22 @@ func installSkillForAgents(ctx context.Context, skillName string, files []string
449456
log.Warnf(ctx, "Failed to install for %s: %v", agent.DisplayName, err)
450457
continue
451458
}
459+
log.Debugf(ctx, "Installed %q for %s (materialized copy)", skillName, agent.DisplayName)
460+
} else {
461+
log.Debugf(ctx, "Installed %q for %s (symlinked)", skillName, agent.DisplayName)
452462
}
453-
log.Debugf(ctx, "Installed %q for %s (symlinked)", skillName, agent.DisplayName)
454463
} else {
455464
if err := copySkillMaterialized(canonicalDir, destDir); err != nil {
456465
log.Warnf(ctx, "Failed to install for %s: %v", agent.DisplayName, err)
457466
continue
458467
}
459468
log.Debugf(ctx, "Installed %q for %s", skillName, agent.DisplayName)
460469
}
470+
installedForAgent++
471+
}
472+
473+
if len(detectedAgents) > 0 && installedForAgent == 0 {
474+
return fmt.Errorf("failed to install %q for any agent", skillName)
461475
}
462476

463477
return nil
@@ -486,19 +500,16 @@ func backupThirdPartySkill(ctx context.Context, destDir, canonicalDir, skillName
486500
if fi.Mode()&os.ModeSymlink != 0 {
487501
target, err := os.Readlink(destDir)
488502
if err == nil {
489-
absTarget := target
490-
if !filepath.IsAbs(target) {
491-
absTarget = filepath.Clean(filepath.Join(filepath.Dir(destDir), target))
492-
}
493-
if absTarget == canonicalDir {
503+
if pathWithinOrEqual(resolveSymlinkTarget(destDir, target), canonicalDir) {
494504
return nil
495505
}
496506
}
497507
}
498508

499509
// Prior materialized install from this CLI; copySkillMaterialized will replace it.
500510
if fi.IsDir() {
501-
if _, err := os.Stat(filepath.Join(destDir, managedSkillMarker)); err == nil {
511+
mi, err := os.Lstat(filepath.Join(destDir, managedSkillMarker))
512+
if err == nil && mi.Mode().IsRegular() {
502513
return nil
503514
}
504515
}
@@ -527,21 +538,20 @@ func installSkillToDir(ctx context.Context, ref, skillName, destDir string, file
527538
return fmt.Errorf("failed to create directory: %w", err)
528539
}
529540

530-
for _, file := range files {
531-
content, err := fetchFileFn(ctx, ref, skillName, file)
532-
if err != nil {
533-
return err
534-
}
535-
536-
// Strip the @root: prefix so shared assets land at a local path
537-
// (e.g. "@root:assets/databricks.svg" → "assets/databricks.svg").
538-
if rootPath, ok := strings.CutPrefix(file, sharedFilePrefix); ok {
541+
for _, manifestFile := range files {
542+
relForDisk := manifestFile
543+
if rootPath, ok := strings.CutPrefix(manifestFile, sharedFilePrefix); ok {
539544
if rootPath == "" {
540545
return fmt.Errorf("invalid manifest file entry: empty path after %q", sharedFilePrefix)
541546
}
542-
file = rootPath
547+
relForDisk = rootPath
548+
}
549+
destPath, err := safeSkillDestPath(destDir, relForDisk)
550+
if err != nil {
551+
return err
543552
}
544-
destPath, err := safeSkillDestPath(destDir, file)
553+
554+
content, err := fetchFileFn(ctx, ref, skillName, manifestFile)
545555
if err != nil {
546556
return err
547557
}
@@ -550,9 +560,9 @@ func installSkillToDir(ctx context.Context, ref, skillName, destDir string, file
550560
return fmt.Errorf("failed to create directory: %w", err)
551561
}
552562

553-
log.Debugf(ctx, "Downloading %s/%s", skillName, file)
563+
log.Debugf(ctx, "Downloading %s/%s", skillName, manifestFile)
554564
if err := os.WriteFile(destPath, content, 0o644); err != nil {
555-
return fmt.Errorf("failed to write %s: %w", file, err)
565+
return fmt.Errorf("failed to write %s: %w", relForDisk, err)
556566
}
557567
}
558568

@@ -580,6 +590,29 @@ func safeSkillDestPath(destDir, file string) (string, error) {
580590
return destPath, nil
581591
}
582592

593+
// resolveSymlinkTarget returns the absolute, cleaned path that a symlink at
594+
// linkPath points to, given the raw target from os.Readlink.
595+
func resolveSymlinkTarget(linkPath, target string) string {
596+
if !filepath.IsAbs(target) {
597+
return filepath.Clean(filepath.Join(filepath.Dir(linkPath), target))
598+
}
599+
return filepath.Clean(target)
600+
}
601+
602+
// pathWithinOrEqual reports whether target is exactly base or lexically under base.
603+
func pathWithinOrEqual(target, base string) bool {
604+
t := filepath.Clean(target)
605+
b := filepath.Clean(base)
606+
if t == b {
607+
return true
608+
}
609+
rel, err := filepath.Rel(b, t)
610+
if err != nil {
611+
return false
612+
}
613+
return filepath.IsLocal(rel)
614+
}
615+
583616
// copySkillMaterialized copies the full skill tree from src to dest and writes
584617
// managedSkillMarker so uninstall can remove the directory safely.
585618
func copySkillMaterialized(src, dest string) error {
@@ -599,6 +632,10 @@ func copySkillMaterialized(src, dest string) error {
599632

600633
target := filepath.Join(dest, rel)
601634

635+
if d.Type()&fs.ModeSymlink != 0 {
636+
return fmt.Errorf("unexpected symlink in skill tree: %s", rel)
637+
}
638+
602639
if d.IsDir() {
603640
return os.MkdirAll(target, 0o755)
604641
}
@@ -613,9 +650,14 @@ func copySkillMaterialized(src, dest string) error {
613650
return os.WriteFile(target, data, 0o644)
614651
})
615652
if err != nil {
653+
_ = os.RemoveAll(dest)
616654
return err
617655
}
618-
return os.WriteFile(filepath.Join(dest, managedSkillMarker), nil, 0o644)
656+
if werr := os.WriteFile(filepath.Join(dest, managedSkillMarker), nil, 0o644); werr != nil {
657+
_ = os.RemoveAll(dest)
658+
return fmt.Errorf("failed to write managed skill marker: %w", werr)
659+
}
660+
return nil
619661
}
620662

621663
func createSymlink(source, dest string) error {

experimental/aitools/lib/installer/installer_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ func (m *mockManifestSource) FetchManifest(_ context.Context, _ string) (*Manife
3131
return m.manifest, nil
3232
}
3333

34+
func TestInstallSkillsForAgentsRejectsNoTargetAgents(t *testing.T) {
35+
setupTestHome(t)
36+
ctx := cmdio.MockDiscard(t.Context())
37+
src := &mockManifestSource{manifest: testManifest()}
38+
err := InstallSkillsForAgents(ctx, src, nil, InstallOptions{})
39+
require.Error(t, err)
40+
assert.Contains(t, err.Error(), "no target agents")
41+
}
42+
3443
func testManifest() *Manifest {
3544
return &Manifest{
3645
Version: "1",

experimental/aitools/lib/installer/skillurl_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,3 +46,14 @@ func TestSafeSkillDestPath(t *testing.T) {
4646
_, err = safeSkillDestPath(destDir, "foo/..")
4747
require.Error(t, err)
4848
}
49+
50+
func TestPathWithinOrEqual(t *testing.T) {
51+
tmp := t.TempDir()
52+
base := filepath.Join(tmp, "canonical")
53+
sub := filepath.Join(base, "nested", "file")
54+
55+
assert.True(t, pathWithinOrEqual(base, base))
56+
assert.True(t, pathWithinOrEqual(sub, base))
57+
assert.False(t, pathWithinOrEqual(filepath.Join(tmp, "outside"), base))
58+
assert.False(t, pathWithinOrEqual(filepath.Join(base, "..", "outside"), base))
59+
}

experimental/aitools/lib/installer/uninstall.go

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@ import (
66
"fmt"
77
"os"
88
"path/filepath"
9-
"strings"
109

1110
"github.com/databricks/cli/experimental/aitools/lib/agents"
1211
"github.com/databricks/cli/libs/cmdio"
@@ -143,7 +142,8 @@ func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir, scop
143142
log.Debugf(ctx, "Skipping non-symlink %s for %s", destDir, agent.DisplayName)
144143
continue
145144
}
146-
if _, err := os.Stat(filepath.Join(destDir, managedSkillMarker)); err != nil {
145+
mi, err := os.Lstat(filepath.Join(destDir, managedSkillMarker))
146+
if err != nil || !mi.Mode().IsRegular() {
147147
log.Debugf(ctx, "Skipping directory without managed marker %s for %s", destDir, agent.DisplayName)
148148
continue
149149
}
@@ -161,15 +161,8 @@ func removeSymlinksFromAgents(ctx context.Context, skillName, canonicalDir, scop
161161
continue
162162
}
163163

164-
// Resolve relative symlinks to absolute for comparison.
165-
absTarget := target
166-
if !filepath.IsAbs(target) {
167-
absTarget = filepath.Join(filepath.Dir(destDir), target)
168-
absTarget = filepath.Clean(absTarget)
169-
}
170-
171-
// Only remove if the symlink points into our canonical dir.
172-
if !strings.HasPrefix(absTarget, canonicalDir+string(os.PathSeparator)) && absTarget != canonicalDir {
164+
absTarget := resolveSymlinkTarget(destDir, target)
165+
if !pathWithinOrEqual(absTarget, canonicalDir) {
173166
log.Debugf(ctx, "Skipping symlink %s (points to %s, not %s)", destDir, absTarget, canonicalDir)
174167
continue
175168
}
@@ -217,14 +210,7 @@ func cleanOrphanedSymlinks(ctx context.Context, baseDir, scope, cwd string) {
217210
continue
218211
}
219212

220-
// Resolve relative symlinks to absolute for comparison.
221-
absTarget := target
222-
if !filepath.IsAbs(target) {
223-
absTarget = filepath.Clean(filepath.Join(filepath.Dir(entryPath), target))
224-
}
225-
226-
// Check if the symlink points into our managed skills dir.
227-
if !strings.HasPrefix(absTarget, baseDir+string(os.PathSeparator)) && absTarget != baseDir {
213+
if !pathWithinOrEqual(resolveSymlinkTarget(entryPath, target), baseDir) {
228214
continue
229215
}
230216

experimental/aitools/lib/installer/uninstall_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,37 @@ func TestUninstallLeavesNonCanonicalSymlinks(t *testing.T) {
225225
assert.Contains(t, stderr.String(), "Uninstalled 1 skill.")
226226
}
227227

228+
func TestUninstallRemovesManagedMaterializedCopies(t *testing.T) {
229+
tmp := setupTestHome(t)
230+
globalDir := filepath.Join(tmp, ".databricks", "aitools", "skills")
231+
232+
state := &InstallState{
233+
SchemaVersion: 1,
234+
Release: "v0.1.0",
235+
LastUpdated: time.Now(),
236+
Skills: map[string]string{
237+
"databricks-sql": "0.1.0",
238+
},
239+
}
240+
require.NoError(t, SaveState(globalDir, state))
241+
require.NoError(t, os.MkdirAll(filepath.Join(globalDir, "databricks-sql"), 0o755))
242+
243+
require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".claude"), 0o755))
244+
agentSkillsDir := filepath.Join(tmp, ".claude", "skills")
245+
managedDir := filepath.Join(agentSkillsDir, "databricks-sql")
246+
require.NoError(t, os.MkdirAll(managedDir, 0o755))
247+
require.NoError(t, os.WriteFile(filepath.Join(managedDir, managedSkillMarker), nil, 0o644))
248+
require.NoError(t, os.WriteFile(filepath.Join(managedDir, "SKILL.md"), []byte("managed"), 0o644))
249+
250+
ctx, stderr := cmdio.NewTestContextWithStderr(t.Context())
251+
err := UninstallSkills(ctx)
252+
require.NoError(t, err)
253+
254+
_, err = os.Lstat(managedDir)
255+
assert.True(t, os.IsNotExist(err), "managed materialized skill dir should be removed")
256+
assert.Contains(t, stderr.String(), "Uninstalled 1 skill.")
257+
}
258+
228259
func TestUninstallLeavesNonSymlinkDirectories(t *testing.T) {
229260
tmp := setupTestHome(t)
230261
globalDir := filepath.Join(tmp, ".databricks", "aitools", "skills")

0 commit comments

Comments
 (0)