fix(backend): stop Sprites prepare truncating git history and leaking the clone token (#3411)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
@@ -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}}
|
||||
|
||||
@@ -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 <base>...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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user