diff --git a/apps/backend/internal/agent/runtime/lifecycle/default_scripts.go b/apps/backend/internal/agent/runtime/lifecycle/default_scripts.go index cafe2386b7..5d3beeffd1 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/default_scripts.go +++ b/apps/backend/internal/agent/runtime/lifecycle/default_scripts.go @@ -1,7 +1,17 @@ package lifecycle +import ( + "crypto/sha256" + "encoding/hex" +) + const executorTypeSSH = "ssh" +// This hash identifies the built-in Sprites script that shipped before the +// in-place, full-history materialization flow. Upgrade only that exact value; +// user-authored prepare scripts must remain unchanged. +const legacySpritesPrepareScriptSHA256 = "e656f9e51496e1bb1e5cee205058bbeaa7fe1ab37073f600e6c94310ead7be4d" + // DefaultPrepareScript returns the default prepare script for a given executor type string. func DefaultPrepareScript(executorType string) string { switch executorType { @@ -22,6 +32,11 @@ func DefaultPrepareScript(executorType string) string { } } +func isLegacySpritesPrepareScript(script string) bool { + digest := sha256.Sum256([]byte(script)) + return hex.EncodeToString(digest[:]) == legacySpritesPrepareScriptSHA256 +} + // KandevBranchCheckoutPostlude returns a kandev-managed shell snippet that // guarantees the session's feature branch is checked out inside the // workspace, no matter what the user's stored prepare_script does. @@ -57,17 +72,25 @@ func KandevBranchCheckoutPostlude() string { # quoted literal that cannot inject commands. Do NOT wrap these in double # quotes — double quotes would re-expose $(...) command substitution. Do NOT # assume they are unquoted data either; the value carries its own quoting. +# +# Keep the complete commit graph so diff and rebase operations have a common +# ancestor. +worktree_branch={{worktree.branch}} +repository_branch={{repository.branch}} ( if [ -d {{workspace.path}}/.git ] \ - && [ -n {{worktree.branch}} ] \ - && [ {{worktree.branch}} != {{repository.branch}} ]; then + && [ -n "$worktree_branch" ] \ + && [ "$worktree_branch" != "$repository_branch" ]; then cd {{workspace.path}} || exit 0 - if git rev-parse --verify {{worktree.branch}} >/dev/null 2>&1; then - git checkout {{worktree.branch}} - elif git fetch --depth=1 origin {{worktree.branch}} 2>/dev/null; then - git checkout -b {{worktree.branch}} origin/{{worktree.branch}} + if [ "$(git rev-parse --is-shallow-repository 2>/dev/null || echo false)" = true ]; then + git fetch --unshallow --no-tags origin + fi + if git rev-parse --verify "$worktree_branch" >/dev/null 2>&1; then + git checkout "$worktree_branch" + elif git fetch --no-tags origin "+refs/heads/${worktree_branch}:refs/remotes/origin/${worktree_branch}" 2>/dev/null; then + git checkout -b "$worktree_branch" "origin/$worktree_branch" else - git checkout -b {{worktree.branch}} + git checkout -b "$worktree_branch" fi fi ) || true @@ -204,40 +227,106 @@ const defaultSpritesPrepareScript = `#!/bin/bash set -euo pipefail -# ---- Add SSH host keys (prevent "Host key verification failed") ---- -mkdir -p ~/.ssh -ssh-keyscan -t ed25519 github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts 2>/dev/null +# SECURITY: the providers substitute a fully single-quoted, self-contained +# shell token (scriptengine.shellQuote) for every DATA placeholder. Each one is +# dereferenced BARE exactly once, into a shell variable, and used as "$var" +# afterwards. Never write "{{...}}" — double quotes re-expose $(...) inside a +# hostile branch name or clone URL, which is the injection the quoting exists +# to prevent. +workspace={{workspace.path}} +repository_url={{repository.clone_url}} +repository_branch={{repository.branch}} # ---- Configure git/gh for HTTPS auth (token-based, no SSH keys needed) ---- # Rewrite SSH URLs to HTTPS so git clone git@github.com:... works via token auth git config --global url."https://github.com/".insteadOf "git@github.com:" git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" +# Kandev can write into the workspace before this script runs, so the checkout +# is not guaranteed to be owned by the user running the prepare script. +git config --global --add safe.directory '*' + # Configure GitHub token for gh CLI and git operations # GH_TOKEN is the primary env var for gh CLI authentication {{github.auth_setup}} -# ---- Install pnpm globally ---- -curl -fsSL https://github.com/pnpm/pnpm/releases/download/v10.32.1/pnpm-linux-x64 -o /usr/local/bin/pnpm -chmod +x /usr/local/bin/pnpm +# ---- Install pnpm (best effort, never fatal) ---- +# Use corepack so the repository packageManager value controls the pnpm version. +# Package-manager setup errors must not stop sandbox preparation. +if ! command -v pnpm >/dev/null 2>&1; then + corepack enable pnpm >/dev/null 2>&1 || true +fi +if ! pnpm --version >/dev/null 2>&1; then + npm install -g pnpm >/dev/null 2>&1 || true +fi # ---- Git identity ---- {{git.identity_setup}} -# ---- Clone repository ---- +# ---- Materialize the primary repository ---- +# Initialize in place because Office files can exist before preparation starts. +# Fetch a blobless commit graph so later diff and rebase operations retain history. # The kandev-managed feature-branch checkout is appended as an invariant # postlude (see KandevBranchCheckoutPostlude) — keep it out of the default # so old profiles snapshotting this script and the postlude never disagree. -# SECURITY: the providers substitute fully single-quoted tokens (shellQuote) for -# these placeholders. printf takes them as separate arguments, NOT inside a -# double-quoted string (which would re-expand a hostile URL/branch). Do not -# reintroduce an echo of these placeholders inside a double-quoted string. -printf 'Cloning %s (branch: %s)...\n' {{repository.clone_url}} {{repository.branch}} -git clone --depth=1 --quiet --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}} -cd {{workspace.path}} +# Never print $repository_url because it can contain a credential. +mkdir -p "$workspace" +if [ -n "$repository_url" ]; then + scrub_origin() { + origin_url=$(git -C "$workspace" remote get-url origin 2>/dev/null || true) + if [ -n "$origin_url" ]; then + clean_origin_url=$(printf '%s' "$origin_url" | sed 's|https://[^@]*@github.com/|https://github.com/|') + git -C "$workspace" remote set-url origin "$clean_origin_url" 2>/dev/null || true + fi + } + trap scrub_origin EXIT HUP INT TERM + if git -C "$workspace" rev-parse --git-dir >/dev/null 2>&1; then + configured_url=$(git -C "$workspace" config --get remote.origin.url 2>/dev/null || true) + if [ -z "$configured_url" ]; then + git -C "$workspace" remote add origin "$repository_url" + else + configured_id=$(printf '%s' "$configured_url" | sed 's|://[^/@]*@|://|') + expected_id=$(printf '%s' "$repository_url" | sed 's|://[^/@]*@|://|') + if [ "$configured_id" != "$expected_id" ]; then + echo 'kandev: target origin identity conflict' >&2 + exit 1 + fi + git -C "$workspace" remote set-url origin "$repository_url" + fi + else + git init -q "$workspace" + git -C "$workspace" remote add origin "$repository_url" + fi + git_dir=$(git -C "$workspace" rev-parse --absolute-git-dir) + exclude_file="$git_dir/info/exclude" + mkdir -p "$(dirname "$exclude_file")" + touch "$exclude_file" + grep -Fqx '/.kandev/' "$exclude_file" || printf '%s\n' '/.kandev/' >>"$exclude_file" -# Strip embedded token from remote URL to avoid persisting credentials in .git/config -git remote set-url origin "$(git remote get-url origin | sed 's|https://[^@]*@github.com/|https://github.com/|')" 2>/dev/null || true + if [ -z "$repository_branch" ]; then + echo 'kandev: target repository has no base branch' >&2 + exit 1 + fi + + git -C "$workspace" config remote.origin.promisor true + git -C "$workspace" config remote.origin.partialclonefilter blob:none + echo "Fetching $repository_branch..." + if ! git -C "$workspace" fetch --filter=blob:none --no-tags origin \ + "+refs/heads/${repository_branch}:refs/remotes/origin/${repository_branch}"; then + echo 'kandev: target base branch is unavailable' >&2 + exit 1 + fi + # A reused checkout keeps its current branch, local commits, and untracked + # files. Only an empty repository needs its initial branch created. + if ! git -C "$workspace" rev-parse --verify HEAD >/dev/null 2>&1; then + git -C "$workspace" checkout -q -b "$repository_branch" "refs/remotes/origin/$repository_branch" + fi + + scrub_origin + trap - EXIT HUP INT TERM +fi + +cd "$workspace" # ---- Repository setup (if configured) ---- {{repository.setup_script}} diff --git a/apps/backend/internal/agent/runtime/lifecycle/default_scripts_test.go b/apps/backend/internal/agent/runtime/lifecycle/default_scripts_test.go index e3e6eaa1fc..d88f7f4124 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/default_scripts_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/default_scripts_test.go @@ -1,6 +1,7 @@ package lifecycle import ( + "os" "os/exec" "path/filepath" "regexp" @@ -23,14 +24,17 @@ func TestKandevBranchCheckoutPostlude_HasInvariantSteps(t *testing.T) { // hostile branch name resolves to a quoted literal. The placeholders must // NOT be double-quoted here (double quotes would re-expose $(...)). want := []string{ + `worktree_branch={{worktree.branch}}`, + `repository_branch={{repository.branch}}`, `if [ -d {{workspace.path}}/.git ]`, - `[ -n {{worktree.branch}} ]`, - `[ {{worktree.branch}} != {{repository.branch}} ]`, + `[ -n "$worktree_branch" ]`, + `[ "$worktree_branch" != "$repository_branch" ]`, `cd {{workspace.path}}`, - `git rev-parse --verify {{worktree.branch}}`, - `git fetch --depth=1 origin {{worktree.branch}}`, - `git checkout -b {{worktree.branch}} origin/{{worktree.branch}}`, - `git checkout -b {{worktree.branch}}`, + `git fetch --unshallow --no-tags origin`, + `git rev-parse --verify "$worktree_branch"`, + `git fetch --no-tags origin "+refs/heads/${worktree_branch}:refs/remotes/origin/${worktree_branch}"`, + `git checkout -b "$worktree_branch" "origin/$worktree_branch"`, + `git checkout -b "$worktree_branch"`, `|| true`, } for _, w := range want { @@ -45,6 +49,13 @@ func TestKandevBranchCheckoutPostlude_HasInvariantSteps(t *testing.T) { strings.Contains(postlude, `"{{workspace.path}}`) { t.Errorf("postlude must not double-quote data placeholders:\n%s", postlude) } + // A depth-limited fetch grafts a history-less tip into the workspace, which + // leaves the session branch with no merge-base against the base branch and + // breaks the diff panel and the pull-request flow. Nothing unshallows a + // workspace afterwards, so the graft is permanent — verify it stays gone. + if strings.Contains(shellCodeOnly(postlude), "--depth") { + t.Errorf("postlude must not fetch with --depth (it permanently grafts a shallow tip):\n%s", postlude) + } // The destructive `-B branch origin/branch` form orphaned local commits on // resume — verify it does NOT come back. forbidden := []string{ @@ -240,6 +251,395 @@ func TestDefaultPrepareScript_SSHMaterializesPrimaryWorkspace(t *testing.T) { } } +// TestKandevBranchCheckoutPostlude_PreservesHistoryForRemoteBranch is the +// regression for the shallow graft. When the session branch already exists on +// origin (resume, re-run, or a fresh sandbox provisioned for an existing task) +// the postlude fetches it. A depth-limited fetch there wrote .git/shallow even +// into a full clone, after which the session branch had no common ancestor +// with the base branch: "git merge-base" fails and "git diff ...HEAD" is +// meaningless. Those are exactly what the diff panel and the pull-request flow +// run, and nothing in the backend ever unshallows a workspace afterwards. +func TestKandevBranchCheckoutPostlude_PreservesHistoryForRemoteBranch(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + const ( + baseBranch = "main" + featureBranch = "feature/from-remote" + ) + + workspace, originDir := setupPostludeRepo(t, baseBranch) + seedOriginBranch(t, originDir, featureBranch) + + script := strings.NewReplacer( + "{{workspace.path}}", workspace, + "{{worktree.branch}}", featureBranch, + "{{repository.branch}}", baseBranch, + ).Replace(KandevBranchCheckoutPostlude()) + + if out, err := exec.Command("bash", "-e", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("bash -e postlude failed: %v\n%s", err, out) + } + + shallow := strings.TrimSpace(string(runIn(t, workspace, "git", "rev-parse", "--is-shallow-repository"))) + if shallow != "false" { + t.Errorf("workspace is shallow after the postlude (--is-shallow-repository = %q)", shallow) + } + + mergeBase := exec.Command("git", "merge-base", baseBranch, "HEAD") + mergeBase.Dir = workspace + out, err := mergeBase.CombinedOutput() + if err != nil { + t.Fatalf("git merge-base %s HEAD failed after the postlude: %v\n%s", baseBranch, err, out) + } + if strings.TrimSpace(string(out)) == "" { + t.Fatalf("git merge-base %s HEAD returned no commit", baseBranch) + } +} + +func TestKandevBranchCheckoutPostlude_UnshallowsSingleBranchClone(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + const ( + baseBranch = "main" + featureBranch = "feature/from-shallow" + ) + workspace, originDir := setupShallowPostludeRepo(t, baseBranch) + seedOriginBranch(t, originDir, featureBranch) + + script := strings.NewReplacer( + "{{workspace.path}}", workspace, + "{{worktree.branch}}", featureBranch, + "{{repository.branch}}", baseBranch, + ).Replace(KandevBranchCheckoutPostlude()) + + if out, err := exec.Command("bash", "-e", "-c", script).CombinedOutput(); err != nil { + t.Fatalf("bash -e postlude failed: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(runIn(t, workspace, "git", "branch", "--show-current"))); got != featureBranch { + t.Fatalf("after postlude branch = %q, want %q", got, featureBranch) + } + if got := strings.TrimSpace(string(runIn(t, workspace, "git", "rev-parse", "--is-shallow-repository"))); got != "false" { + t.Fatalf("workspace shallow state = %q, want false", got) + } + if out, err := exec.Command("git", "-C", workspace, "merge-base", baseBranch, "HEAD").CombinedOutput(); err != nil || strings.TrimSpace(string(out)) == "" { + t.Fatalf("merge-base %s HEAD = %q, %v", baseBranch, out, err) + } +} + +// TestDefaultPrepareScript_SpritesMaterializesWithoutTruncatingHistory pins the +// properties the Sprites default regressed on: it must not clone into a +// directory kandev may already have written into, it must not truncate the +// history the pull-request flow needs, and it must never print the clone URL, +// which carries an injected access token, into the streamed prepare output. +func TestDefaultPrepareScript_SpritesMaterializesWithoutTruncatingHistory(t *testing.T) { + script := DefaultPrepareScript("sprites") + if script == "" { + t.Fatal(`DefaultPrepareScript("sprites") returned empty`) + } + + for _, want := range []string{ + "{{workspace.path}}", + "{{repository.clone_url}}", + "{{repository.branch}}", + "{{repository.setup_script}}", + "{{kandev.agentctl.install}}", + "{{kandev.agentctl.start}}", + "git init", + "fetch --filter=blob:none --no-tags", + "remote.origin.url", + } { + if !strings.Contains(script, want) { + t.Errorf("Sprites default prepare script missing %q", want) + } + } + + // Each of these shipped in the default and each broke a real launch path. + forbidden := map[string]string{ + "--depth": "truncated history breaks merge-base and pull-request diffs", + "git clone": "the workspace is not guaranteed to be empty when prepare runs", + "ssh-keyscan": "all transports are HTTPS + token; a DNS blip killed the launch under set -e", + "printf 'Cloning %s": "leaked the token-bearing clone URL into the streamed task output", + "pnpm-linux-x64": "a hardcoded version ignores each repository's packageManager pin", + } + code := shellCodeOnly(script) + for bad, why := range forbidden { + if strings.Contains(code, bad) { + t.Errorf("Sprites default prepare script must not contain %q: %s", bad, why) + } + } +} + +func TestDefaultPrepareScript_SpritesCleansCredentialsWhenFetchFails(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + root := t.TempDir() + workspace := filepath.Join(root, "workspace") + home := filepath.Join(root, "home") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(workspace, 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + realGit, err := exec.LookPath("git") + if err != nil { + t.Fatal(err) + } + gitWrapper := filepath.Join(bin, "git") + wrapper := "#!/bin/sh\n" + + "if [ \"$1\" = \"-C\" ] && [ \"$3\" = \"fetch\" ]; then exit 1; fi\n" + + "exec " + realGit + " \"$@\"\n" + if err := os.WriteFile(gitWrapper, []byte(wrapper), 0o755); err != nil { + t.Fatal(err) + } + pnpm := filepath.Join(bin, "pnpm") + if err := os.WriteFile(pnpm, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + script := strings.NewReplacer( + "{{workspace.path}}", shellQuote(workspace), + "{{repository.clone_url}}", shellQuote("https://x-access-token:secret@github.com/acme/repo.git"), + "{{repository.branch}}", shellQuote("main"), + "{{repository.setup_script}}", ":", + "{{git.identity_setup}}", ":", + "{{github.auth_setup}}", ":", + "{{kandev.agents.install}}", ":", + "{{kandev.agentctl.install}}", ":", + "{{kandev.agentctl.start}}", ":", + ).Replace(DefaultPrepareScript("sprites")) + cmd := exec.Command("bash", "-e", "-c", script) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH")) + if out, err := cmd.CombinedOutput(); err == nil { + t.Fatalf("prepare unexpectedly succeeded after forced fetch failure: %s", out) + } + got := strings.TrimSpace(string(runIn(t, workspace, "git", "config", "--get", "remote.origin.url"))) + if strings.Contains(got, "secret") { + t.Fatalf("origin retained credential after fetch failure: %q", got) + } +} + +func TestDefaultPrepareScript_SpritesMaterializesNonEmptyWorkspace(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + if _, err := exec.LookPath("bash"); err != nil { + t.Skip("bash not available") + } + + root := t.TempDir() + origin := filepath.Join(root, "origin.git") + seed := filepath.Join(root, "seed") + workspace := filepath.Join(root, "workspace") + home := filepath.Join(root, "home") + bin := filepath.Join(root, "bin") + if err := os.MkdirAll(filepath.Join(workspace, ".agents", "skills", "kandev-demo"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(bin, 0o755); err != nil { + t.Fatal(err) + } + pnpm := filepath.Join(bin, "pnpm") + if err := os.WriteFile(pnpm, []byte("#!/bin/sh\nexit 0\n"), 0o755); err != nil { + t.Fatal(err) + } + + runIn(t, root, "git", "init", "--quiet", "--bare", "--initial-branch=main", origin) + runIn(t, root, "git", "init", "--quiet", "--initial-branch=main", seed) + runIn(t, seed, "git", "config", "user.email", "test@example.com") + runIn(t, seed, "git", "config", "user.name", "Test") + if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("repository"), 0o644); err != nil { + t.Fatal(err) + } + runIn(t, seed, "git", "add", "README.md") + runIn(t, seed, "git", "commit", "--quiet", "-m", "init") + runIn(t, seed, "git", "remote", "add", "origin", origin) + runIn(t, seed, "git", "push", "--quiet", "origin", "main") + + script := strings.NewReplacer( + "{{workspace.path}}", shellQuote(workspace), + "{{repository.clone_url}}", shellQuote(origin), + "{{repository.branch}}", shellQuote("main"), + "{{repository.setup_script}}", ":", + "{{git.identity_setup}}", ":", + "{{github.auth_setup}}", ":", + "{{kandev.agents.install}}", ":", + "{{kandev.agentctl.install}}", ":", + "{{kandev.agentctl.start}}", ":", + ).Replace(DefaultPrepareScript("sprites")) + cmd := exec.Command("bash", "-e", "-c", script) + cmd.Env = append(os.Environ(), "HOME="+home, "PATH="+bin+":"+os.Getenv("PATH")) + if out, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("Sprites prepare failed: %v\n%s", err, out) + } + if got := strings.TrimSpace(string(runIn(t, workspace, "git", "branch", "--show-current"))); got != "main" { + t.Fatalf("prepared branch = %q, want main", got) + } + if _, err := os.Stat(filepath.Join(workspace, ".agents", "skills", "kandev-demo")); err != nil { + t.Fatalf("uploaded Office skill directory was not preserved: %v", err) + } +} + +func setupShallowPostludeRepo(t *testing.T, baseBranch string) (workspace, origin string) { + t.Helper() + root := t.TempDir() + origin = filepath.Join(root, "origin.git") + seed := filepath.Join(root, "seed") + workspace = filepath.Join(root, "workspace") + + runIn(t, root, "git", "init", "--quiet", "--bare", "--initial-branch="+baseBranch, origin) + runIn(t, root, "git", "init", "--quiet", "--initial-branch="+baseBranch, seed) + runIn(t, seed, "git", "config", "user.email", "test@example.com") + runIn(t, seed, "git", "config", "user.name", "Test") + runIn(t, seed, "git", "commit", "--allow-empty", "-m", "init") + runIn(t, seed, "git", "remote", "add", "origin", origin) + runIn(t, seed, "git", "push", "--quiet", "origin", baseBranch) + runIn(t, root, "git", "clone", "--quiet", "--no-local", "--depth=1", "--single-branch", "--branch", baseBranch, origin, workspace) + runIn(t, workspace, "git", "config", "user.email", "test@example.com") + runIn(t, workspace, "git", "config", "user.name", "Test") + return workspace, origin +} + +func TestSpritesResolvePrepareScriptUpgradesLegacyDefault(t *testing.T) { + exec := newTestSpritesExecutor(nil) + script, err := exec.resolvePrepareScript(&ExecutorCreateRequest{ + Metadata: map[string]interface{}{ + MetadataKeySetupScript: legacySpritesDefaultPrepareScript, + MetadataKeyBaseBranch: "main", + }, + }) + if err != nil { + t.Fatalf("resolvePrepareScript: %v", err) + } + if strings.Contains(script, "git clone --depth=1") || !strings.Contains(script, "git init") { + t.Fatalf("legacy Sprites default was not upgraded:\n%s", script) + } +} + +func TestSpritesResolvePrepareScriptPreservesCustomScript(t *testing.T) { + exec := newTestSpritesExecutor(nil) + const custom = "printf custom > marker" + script, err := exec.resolvePrepareScript(&ExecutorCreateRequest{ + Metadata: map[string]interface{}{ + MetadataKeySetupScript: custom, + MetadataKeyBaseBranch: "main", + }, + }) + if err != nil { + t.Fatalf("resolvePrepareScript: %v", err) + } + if !strings.Contains(script, custom) { + t.Fatalf("custom prepare script was not preserved:\n%s", script) + } +} + +func TestSpriteProjectSkillDir(t *testing.T) { + tests := []struct { + name string + metadata map[string]interface{} + want string + }{ + {name: "no manifest", metadata: map[string]interface{}{}, want: ""}, + {name: "default directory", metadata: map[string]interface{}{ + MetadataKeySkillManifestJSON: `{"Skills":[]}`, + }, want: ".agents/skills"}, + {name: "custom directory", metadata: map[string]interface{}{ + MetadataKeySkillManifestJSON: `{"ProjectSkillDir":".claude/skills"}`, + }, want: ".claude/skills"}, + {name: "invalid manifest", metadata: map[string]interface{}{ + MetadataKeySkillManifestJSON: `{not json`, + }, want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if got := spriteProjectSkillDir(tc.metadata); got != tc.want { + t.Fatalf("spriteProjectSkillDir() = %q, want %q", got, tc.want) + } + }) + } +} + +const legacySpritesDefaultPrepareScript = `#!/bin/bash +# Prepare Sprites.dev cloud sandbox +# +# Pre-installed tools (no need to install): +# git, curl, wget, gh (GitHub CLI), node, python, go, +# build-essential, openssh-client, ca-certificates + +set -euo pipefail + +# ---- Add SSH host keys (prevent "Host key verification failed") ---- +mkdir -p ~/.ssh +ssh-keyscan -t ed25519 github.com gitlab.com bitbucket.org >> ~/.ssh/known_hosts 2>/dev/null + +# ---- Configure git/gh for HTTPS auth (token-based, no SSH keys needed) ---- +# Rewrite SSH URLs to HTTPS so git clone git@github.com:... works via token auth +git config --global url."https://github.com/".insteadOf "git@github.com:" +git config --global url."https://github.com/".insteadOf "ssh://git@github.com/" + +# Configure GitHub token for gh CLI and git operations +# GH_TOKEN is the primary env var for gh CLI authentication +{{github.auth_setup}} + +# ---- Install pnpm globally ---- +curl -fsSL https://github.com/pnpm/pnpm/releases/download/v10.32.1/pnpm-linux-x64 -o /usr/local/bin/pnpm +chmod +x /usr/local/bin/pnpm + +# ---- Git identity ---- +{{git.identity_setup}} + +# ---- Clone repository ---- +printf 'Cloning %s (branch: %s)...\n' {{repository.clone_url}} {{repository.branch}} +git clone --depth=1 --quiet --branch {{repository.branch}} {{repository.clone_url}} {{workspace.path}} +cd {{workspace.path}} + +# Strip embedded token from remote URL to avoid persisting credentials in .git/config +git remote set-url origin "$(git remote get-url origin | sed 's|https://[^@]*@github.com/|https://github.com/|')" 2>/dev/null || true + +# ---- Repository setup (if configured) ---- +{{repository.setup_script}} +` + +// shellCodeOnly drops whole-line "#" comments so a forbidden-substring check +// tests what the script RUNS rather than what its comments say about it. The +// comments deliberately name the constructs that were removed (--depth, +// git clone, ssh-keyscan), and matching those would make the check assert the +// opposite of its intent. +func shellCodeOnly(script string) string { + lines := strings.Split(script, "\n") + code := make([]string, 0, len(lines)) + for _, line := range lines { + if strings.HasPrefix(strings.TrimSpace(line), "#") { + continue + } + code = append(code, line) + } + return strings.Join(code, "\n") +} + // TestManagedScripts_BraceParameterExpansionsBeforeColon guards every managed // script that can run under a user-selected login shell. zsh parses an // unbraced `$name:r` as a modifier, so `refs/heads/$branch:refs/...` silently diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go index 8a84f91a88..7cbe8fa270 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_docker_test.go @@ -916,7 +916,8 @@ func TestResolvePrepareScript(t *testing.T) { if !strings.Contains(script, "git remote set-url") { t.Error("expected token stripping after clone") } - if !strings.Contains(script, "git checkout -b 'feature/task-abc'") { + if !strings.Contains(script, `worktree_branch='feature/task-abc'`) || + !strings.Contains(script, `git checkout -b "$worktree_branch" "origin/$worktree_branch"`) { t.Fatalf("expected Docker prepare script to create task branch, got:\n%s", script) } }) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go index 48b3cc6bb3..55f2e371a0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go @@ -452,6 +452,9 @@ func (r *SpritesExecutor) stepSetupEnvironment( step.Output = output report(spriteStepRunPrepareScript, step) }) + if projectSkillDir := spriteProjectSkillDir(req.Metadata); projectSkillDir != "" { + r.ensureSpriteGitExclude(ctx, sprite, projectSkillDir) + } if err != nil { completeStepError(&step, err.Error()) report(spriteStepRunPrepareScript, step) diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go index 8bb94dab92..c0220bbf94 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_sprites_operations.go @@ -26,6 +26,23 @@ import ( // validSlugRe matches slugs that are safe for use in shell commands and file paths. var validSlugRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`) +func spriteProjectSkillDir(metadata map[string]interface{}) string { + manifestJSON := getMetadataString(metadata, MetadataKeySkillManifestJSON) + if manifestJSON == "" { + return "" + } + var manifest struct { + ProjectSkillDir string `json:"ProjectSkillDir"` + } + if err := json.Unmarshal([]byte(manifestJSON), &manifest); err != nil { + return "" + } + if strings.TrimSpace(manifest.ProjectSkillDir) == "" { + return ".agents/skills" + } + return manifest.ProjectSkillDir +} + // createSprite creates a new sprite via the API (explicit POST, not lazy). func (r *SpritesExecutor) createSprite(ctx context.Context, client *sprites.Client, name string) (*sprites.Sprite, error) { stepCtx, cancel := context.WithTimeout(ctx, spriteStepTimeout) @@ -313,6 +330,9 @@ func (r *SpritesExecutor) runPrepareScript( // commit straight onto main. func (r *SpritesExecutor) resolvePrepareScript(req *ExecutorCreateRequest) (string, error) { script := getMetadataString(req.Metadata, MetadataKeySetupScript) + if isLegacySpritesPrepareScript(script) { + script = DefaultPrepareScript("sprites") + } if script == "" { script = DefaultPrepareScript("sprites") }