Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 0 additions & 34 deletions cmd/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,6 @@ func runHarness(cmd *cobra.Command, args []string) error {
return fmt.Errorf("coding harness is not installed and update checks are disabled — " +
"unset KIMCHI_NO_UPDATE_CHECK or install the harness manually")
}
if err := guardAgainstSelfExec(harnessPath); err != nil {
return err
}
return launchHarness(cmd, harnessPath, args)
}

Expand All @@ -49,10 +46,6 @@ func runHarness(cmd *cobra.Command, args []string) error {
return nil
}

if err := guardAgainstSelfExec(harnessPath); err != nil {
return err
}

var props map[string]any
if v, err := update.HarnessCurrentVersion(ctx); err == nil && v != nil {
props = map[string]any{"version": v.String()}
Expand All @@ -62,33 +55,6 @@ func runHarness(cmd *cobra.Command, args []string) error {
return launchHarness(cmd, harnessPath, args)
}

// guardAgainstSelfExec refuses to launch the harness if its resolved path
// points at the running CLI binary. Without this check a misconfigured path
// would cause syscall.Exec to replace the CLI with itself, producing an
// infinite launch loop instead of a clear failure.
func guardAgainstSelfExec(harnessPath string) error {
selfPath, err := update.ResolveExecutablePath()
if err != nil {
klog.V(1).ErrorS(err, "self-exec guard: could not resolve current executable; skipping check")
return nil
}
selfInfo, err := os.Stat(selfPath)
if err != nil {
klog.V(1).ErrorS(err, "self-exec guard: stat self failed; skipping check", "path", selfPath)
return nil
}
harnessInfo, err := os.Stat(harnessPath)
if err != nil {
klog.V(1).ErrorS(err, "self-exec guard: stat harness failed; skipping check", "path", harnessPath)
return nil
}
if os.SameFile(selfInfo, harnessInfo) {
return fmt.Errorf("refusing to launch harness: resolved harness path %q is the kimchi CLI itself "+
"(this would loop forever). Remove the stray file and reinstall: rm %q && kimchi update", harnessPath, harnessPath)
}
return nil
}

// launchHarness resolves the API key (prompting if missing), ensures it is
// saved in the config file, and execs into the harness binary.
func launchHarness(cmd *cobra.Command, harnessPath string, args []string) error {
Expand Down
22 changes: 11 additions & 11 deletions internal/update/extract_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,18 @@ func TestExtractStructuredArchive_SplitsBinAndData(t *testing.T) {

archive := createArchive(t, []archiveFile{
{Name: "bin", IsDir: true},
{Name: "bin/kimchi", Content: binaryContent, Mode: 0755},
{Name: "bin/kimchi-code", Content: binaryContent, Mode: 0755},
{Name: "share/kimchi", IsDir: true},
{Name: "share/kimchi/package.json", Content: packageJSON},
{Name: "share/kimchi/theme", IsDir: true},
{Name: "share/kimchi/theme/dark.json", Content: themeContent},
})

root, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi")
root, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi-code")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(root) }()

gotBinary, err := os.ReadFile(filepath.Join(root, "bin", "kimchi"))
gotBinary, err := os.ReadFile(filepath.Join(root, "bin", "kimchi-code"))
require.NoError(t, err)
assert.Equal(t, binaryContent, gotBinary)

Expand All @@ -46,39 +46,39 @@ func TestExtractStructuredArchive_MissingBinary(t *testing.T) {
{Name: "share/kimchi/package.json", Content: []byte(`{}`)},
})

_, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi")
_, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi-code")
require.Error(t, err)
assert.ErrorContains(t, err, "kimchi")
assert.ErrorContains(t, err, "kimchi-code")
assert.ErrorContains(t, err, "not found")
}

func TestExtractStructuredArchive_PreservesFilePermissions(t *testing.T) {
archive := createArchive(t, []archiveFile{
{Name: "bin/kimchi", Content: []byte("binary"), Mode: 0755},
{Name: "bin/kimchi-code", Content: []byte("binary"), Mode: 0755},
{Name: "share/kimchi/package.json", Content: []byte(`{}`)},
})

root, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi")
root, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi-code")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(root) }()

info, err := os.Stat(filepath.Join(root, "bin", "kimchi"))
info, err := os.Stat(filepath.Join(root, "bin", "kimchi-code"))
require.NoError(t, err)
assert.Equal(t, os.FileMode(0755), info.Mode().Perm())
}

func TestExtractStructuredArchive_SkipsDirectoryTraversal(t *testing.T) {
archive := createArchive(t, []archiveFile{
{Name: "bin/kimchi", Content: []byte("binary"), Mode: 0755},
{Name: "bin/kimchi-code", Content: []byte("binary"), Mode: 0755},
{Name: "share/kimchi/package.json", Content: []byte(`{}`)},
{Name: "../etc/passwd", Content: []byte("malicious")},
})

root, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi")
root, err := extractStructuredArchive(bytes.NewReader(archive), "kimchi-code")
require.NoError(t, err)
defer func() { _ = os.RemoveAll(root) }()

assert.FileExists(t, filepath.Join(root, "bin", "kimchi"))
assert.FileExists(t, filepath.Join(root, "bin", "kimchi-code"))
assert.FileExists(t, filepath.Join(root, "share", "kimchi", "package.json"))
assert.NoFileExists(t, filepath.Join(root, "..", "etc", "passwd"))
}
2 changes: 1 addition & 1 deletion internal/update/github_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type Repo struct {

var (
kimchiRepo = Repo{Owner: "castai", Name: "kimchi", Binary: "kimchi"}
kimchiDevRepo = Repo{Owner: "castai", Name: "kimchi-dev", Binary: "kimchi"}
kimchiDevRepo = Repo{Owner: "castai", Name: "kimchi-dev", Binary: "kimchi-code"}
)

type ReleaseInfo struct {
Expand Down
12 changes: 8 additions & 4 deletions internal/update/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,15 @@ func HarnessPathInDir(dir string) string {
return filepath.Join(dir, kimchiDevRepo.Binary)
}

// ResolveHarnessPath returns the path where the harness binary lives.
// It is placed inside the harness data directory so it does not collide
// with the Go CLI binary (both are now named "kimchi" after the merge).
// ResolveHarnessPath derives the harness binary path from the kimchi executable's
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️🏗️ Design

The ResolveHarnessPath function now places the harness binary in the same directory as the CLI executable (e.g., /usr/local/bin/kimchi-code alongside /usr/local/bin/kimchi), rather than in the user-writable data directory (~/.local/share/kimchi/bin). This change may cause permission issues if the CLI is installed in a system directory, as updating the harness will now require write access to that system directory.

💡 Suggestion: Consider documenting this requirement or ensuring the installer handles permission elevation when the CLI is installed in a system path. Alternatively, fall back to the data directory if the binary directory is not writable.

// resolved directory. For example, if kimchi is at /usr/local/bin/kimchi, this
// returns /usr/local/bin/kimchi-code.
func ResolveHarnessPath() (string, error) {
return HarnessPathInDir(filepath.Join(harnessDataDir(), "bin")), nil
execPath, err := ResolveExecutablePath()
if err != nil {
return "", fmt.Errorf("resolve harness path: %w", err)
}
return HarnessPathInDir(filepath.Dir(execPath)), nil
}

// HarnessInstalled reports whether the harness binary exists at the given path.
Expand Down
10 changes: 5 additions & 5 deletions internal/update/harness_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,19 +11,19 @@ import (

func TestHarnessPathInDir(t *testing.T) {
got := HarnessPathInDir("/usr/local/bin")
assert.Equal(t, "/usr/local/bin/kimchi", got)
assert.Equal(t, "/usr/local/bin/kimchi-code", got)
}

func Test_HarnessInstalled(t *testing.T) {
t.Run("exists", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "kimchi")
path := filepath.Join(dir, "kimchi-code")
require.NoError(t, os.WriteFile(path, []byte("binary"), 0755))
assert.True(t, HarnessInstalled(path))
})

t.Run("missing", func(t *testing.T) {
assert.False(t, HarnessInstalled(filepath.Join(t.TempDir(), "kimchi")))
assert.False(t, HarnessInstalled(filepath.Join(t.TempDir(), "kimchi-code")))
})
}

Expand All @@ -37,7 +37,7 @@ func TestResolveHarnessPackageJSON(t *testing.T) {
require.NoError(t, os.WriteFile(xdgPkg, []byte(`{"version":"2.0.0"}`), 0644))

binDir := t.TempDir()
binaryPath := filepath.Join(binDir, "kimchi")
binaryPath := filepath.Join(binDir, "kimchi-code")
legacyPkg := filepath.Join(binDir, "package.json")
require.NoError(t, os.WriteFile(legacyPkg, []byte(`{"version":"1.0.0"}`), 0644))

Expand All @@ -50,7 +50,7 @@ func TestResolveHarnessPackageJSON(t *testing.T) {
t.Setenv("XDG_DATA_HOME", t.TempDir())

binDir := t.TempDir()
binaryPath := filepath.Join(binDir, "kimchi")
binaryPath := filepath.Join(binDir, "kimchi-code")

got, err := resolveHarnessPackageJSON(binaryPath)
require.NoError(t, err)
Expand Down
4 changes: 2 additions & 2 deletions internal/update/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ func TestWorkflowRun_HarnessUpdate_PlacesSupportingFilesInDataDir(t *testing.T)
t.Setenv("XDG_CACHE_HOME", t.TempDir())

newBinary := []byte("#!/bin/sh\necho v2.0.0")
packageJSON := []byte(`{"name":"kimchi","version":"2.0.0"}`)
packageJSON := []byte(`{"name":"kimchi-code","version":"2.0.0"}`)
themeContent := []byte(`{"background":"#000"}`)

archive := createArchive(t, []archiveFile{
Expand Down Expand Up @@ -485,7 +485,7 @@ func TestWorkflowRun_HarnessFreshInstall_PlacesSupportingFilesInDataDir(t *testi
t.Setenv("XDG_CACHE_HOME", t.TempDir())

newBinary := []byte("#!/bin/sh\necho v1.0.0")
packageJSON := []byte(`{"name":"kimchi","version":"1.0.0"}`)
packageJSON := []byte(`{"name":"kimchi-code","version":"1.0.0"}`)
themeContent := []byte(`{"background":"#000"}`)

archive := createArchive(t, []archiveFile{
Expand Down
Loading