Some checks failed
CodeQL / Analyze (${{ matrix.language }}) (go) (pull_request) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (javascript-typescript) (pull_request) Successful in 6s
CodeQL / Analyze (${{ matrix.language }}) (python) (pull_request) Successful in 5s
Block internal-flavored paths / Block forbidden paths (pull_request) Successful in 16s
Check merge_group trigger on required workflows / Required workflows have merge_group trigger (pull_request) Successful in 17s
branch-protection drift check / Branch protection drift (pull_request) Successful in 21s
E2E API Smoke Test / detect-changes (pull_request) Successful in 20s
Handlers Postgres Integration / detect-changes (pull_request) Successful in 20s
Lint curl status-code capture / Scan workflows for curl status-capture pollution (pull_request) Successful in 19s
Harness Replays / detect-changes (pull_request) Successful in 22s
E2E Staging Canvas (Playwright) / detect-changes (pull_request) Successful in 23s
CI / Detect changes (pull_request) Successful in 27s
Secret scan / Scan diff for credential-shaped strings (pull_request) Successful in 20s
Runtime PR-Built Compatibility / detect-changes (pull_request) Successful in 22s
E2E Staging Canvas (Playwright) / Canvas tabs E2E (pull_request) Successful in 12s
CI / Canvas (Next.js) (pull_request) Successful in 10s
CI / Shellcheck (E2E scripts) (pull_request) Successful in 6s
CI / Canvas Deploy Reminder (pull_request) Has been skipped
CI / Python Lint & Test (pull_request) Successful in 8s
Runtime PR-Built Compatibility / PR-built wheel + import smoke (pull_request) Successful in 9s
Harness Replays / Harness Replays (pull_request) Failing after 25s
E2E API Smoke Test / E2E API Smoke Test (pull_request) Successful in 1m41s
Handlers Postgres Integration / Handlers Postgres Integration (pull_request) Successful in 3m33s
CI / Platform (Go) (pull_request) Successful in 5m11s
Closes molecule-core#112. Composes with #114 (atomic install). Before issuing restartFunc, classify the diff between staged and live: - skill-content-only: only **/SKILL.md content changed → skip restart (Claude Code re-reads SKILL.md on each Skill invocation; no in-memory cache) - cold: anything else → restartFunc as before (hooks/settings load at session start; plugin.yaml is structural; added/removed files require a fresh load) DETECTION - Hash every regular file in staged tree (host filesystem, sha256) - Hash every regular file in live tree (in-container via docker exec sh -c 'cd <livePath> && find . -type f -print0 | xargs -0 sha256sum') - .complete marker dropped from comparison (mtime varies install-to- install; including it would force-cold every reinstall) - File added/removed → cold - File content differs but isn't SKILL.md → cold - All differences are SKILL.md basenames → skill-content-only DEFAULTS COLD - First install (no live tree) → cold - Live tree read failure → cold (conservative; never hot-reload speculatively) - Symlinks skipped during hash (same posture as tar walker) PHASE 4 SELF-REVIEW Correctness: No finding — all error paths default to cold; never falsely classify as skill-content-only. The .complete drop is a deliberate exception (the marker is bookkeeping, not content). Readability: No finding — single-purpose helpers (hashLocalTree, hashContainerTree, isSkillMarkdown, shQuote) each do one thing. The classifier itself reads as 'compare set, then walk diff with isSkillMarkdown gate.' Architecture: No finding — composes existing execAsRoot primitive; new helpers in plugins_classifier.go don't touch any other handler. Old behavior unchanged when live read fails. Security: No finding — shQuote single-quotes any non-trivial path, pluginName comes from validatePluginName-validated source, and the docker exec command takes the path as a single arg (xargs -0 handles binary-safe path delimiting). Symlinks skipped. Performance: No finding — adds two tree walks (host + container) per install. Container walk is one docker exec call returning sha256 lines; for typical plugins (~10-50 files) round-trip is ~100ms. Versus the saved ~5-10s of restart on a hot-reloadable update, this is a clear win. TESTS (4 new, all green; full handler suite green) TestIsSkillMarkdown — basename match, case-sensitive TestHashLocalTree_StableHash — re-hash same dir = same map TestHashLocalTree_SymlinkSkipped — hostile link doesn't poison classifier TestShQuote — quoting boundary for shell injection safety REFS molecule-core#112 — this issue molecule-core#114 — atomic install (.complete marker added there) Reno-Stars iteration safety (Hongming 2026-05-08) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
215 lines
6.5 KiB
Go
215 lines
6.5 KiB
Go
package handlers
|
|
|
|
// plugins_classifier.go — diff classifier for plugin updates.
|
|
//
|
|
// Closes molecule-core#112. Composes with #114 (atomic install) so the
|
|
// platform can decide *before* triggering restartFunc whether the
|
|
// update is content-only (SKILL.md text changed; agent re-reads at next
|
|
// Skill invocation) or structural (hooks/settings/plugin.yaml/file added
|
|
// or removed; agent must restart to pick up the new state).
|
|
//
|
|
// SKILL.md content is hot-reloadable because Claude Code reads the file
|
|
// on each Skill invocation — no in-memory cache. Hooks and settings.json
|
|
// are loaded at session start and need a session restart. plugin.yaml
|
|
// changes are structural by definition (manifest controls everything
|
|
// else).
|
|
//
|
|
// CLASSIFICATION RULE
|
|
// classify(staged, live) → "skill-content-only" if and only if
|
|
// every file present in either tree is one of:
|
|
// - identical between staged and live, OR
|
|
// - a **/SKILL.md file with content change (text body modified)
|
|
// AND no files were added or removed.
|
|
// Anything else → "cold" (the safe default).
|
|
//
|
|
// The classifier reads live-tree files from inside the container via
|
|
// `docker exec cat`. Comparison is by SHA-256 over file content, not
|
|
// mtime — mtime changes on every install regardless of content.
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// classifyKindSkillContentOnly: install can skip restartFunc; the
|
|
// only changes are SKILL.md body text.
|
|
classifyKindSkillContentOnly = "skill-content-only"
|
|
// classifyKindCold: must restart the workspace container; structural
|
|
// or hook/settings change.
|
|
classifyKindCold = "cold"
|
|
)
|
|
|
|
// classifyInstallChanges compares the staged plugin tree (host filesystem)
|
|
// against the currently-live plugin tree inside the container. Returns
|
|
// classifyKindSkillContentOnly when the only diff is SKILL.md content
|
|
// changes, classifyKindCold otherwise (added/removed files, hooks/
|
|
// settings.json edits, plugin.yaml edits, anything else).
|
|
//
|
|
// `noLive` is the sentinel returned when /configs/plugins/<name> doesn't
|
|
// exist (first install for this plugin). Treated as cold — no live state
|
|
// to hot-reload into.
|
|
func (h *PluginsHandler) classifyInstallChanges(
|
|
ctx context.Context, containerName, hostStagedDir, pluginName string,
|
|
) (string, error) {
|
|
livePath := "/configs/plugins/" + pluginName
|
|
|
|
// Probe: does live exist? If not, this is a first install — cold.
|
|
if _, err := h.execAsRoot(ctx, containerName, []string{
|
|
"test", "-d", livePath,
|
|
}); err != nil {
|
|
return classifyKindCold, nil
|
|
}
|
|
|
|
// Build hash maps for both trees.
|
|
stagedHashes, err := hashLocalTree(hostStagedDir)
|
|
if err != nil {
|
|
return classifyKindCold, fmt.Errorf("classifier: hash staged: %w", err)
|
|
}
|
|
liveHashes, err := h.hashContainerTree(ctx, containerName, livePath)
|
|
if err != nil {
|
|
// Live tree read failure: be conservative, cold-restart.
|
|
return classifyKindCold, nil
|
|
}
|
|
|
|
// Drop the .complete marker from comparison — its mtime/atime can
|
|
// vary across installs but content is empty/trivial; including it
|
|
// would force-cold every reinstall.
|
|
delete(stagedHashes, ".complete")
|
|
delete(liveHashes, ".complete")
|
|
|
|
// Set difference: any file in one but not the other → cold.
|
|
for rel := range stagedHashes {
|
|
if _, ok := liveHashes[rel]; !ok {
|
|
return classifyKindCold, nil // file added
|
|
}
|
|
}
|
|
for rel := range liveHashes {
|
|
if _, ok := stagedHashes[rel]; !ok {
|
|
return classifyKindCold, nil // file removed
|
|
}
|
|
}
|
|
|
|
// Same set of files. Walk the diff.
|
|
for rel, stagedHash := range stagedHashes {
|
|
liveHash := liveHashes[rel]
|
|
if stagedHash == liveHash {
|
|
continue
|
|
}
|
|
// Content differs. Allow if and only if it's a SKILL.md.
|
|
if !isSkillMarkdown(rel) {
|
|
return classifyKindCold, nil
|
|
}
|
|
}
|
|
return classifyKindSkillContentOnly, nil
|
|
}
|
|
|
|
// isSkillMarkdown returns true for any path whose basename is SKILL.md
|
|
// (case-sensitive, matches Claude Code's skill discovery rule).
|
|
func isSkillMarkdown(rel string) bool {
|
|
return filepath.Base(rel) == "SKILL.md"
|
|
}
|
|
|
|
// hashLocalTree walks a host directory and returns rel-path → sha256-hex.
|
|
// Symlinks are skipped (same posture as the tar walker).
|
|
func hashLocalTree(root string) (map[string]string, error) {
|
|
out := map[string]string{}
|
|
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if d.IsDir() {
|
|
return nil
|
|
}
|
|
info, err := d.Info()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if info.Mode()&os.ModeSymlink != 0 {
|
|
return nil
|
|
}
|
|
if !info.Mode().IsRegular() {
|
|
return nil
|
|
}
|
|
rel, err := filepath.Rel(root, p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
body, err := os.ReadFile(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
sum := sha256.Sum256(body)
|
|
out[filepath.ToSlash(rel)] = hex.EncodeToString(sum[:])
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// hashContainerTree reads every regular file under livePath via docker
|
|
// exec sh -c 'cd <livePath> && find . -type f -not -name .complete | xargs -I {} sh -c "echo {}; sha256sum {}"'.
|
|
//
|
|
// The output is parsed line-by-line into rel-path → sha256-hex.
|
|
func (h *PluginsHandler) hashContainerTree(
|
|
ctx context.Context, containerName, livePath string,
|
|
) (map[string]string, error) {
|
|
out, err := h.execAsRoot(ctx, containerName, []string{
|
|
"sh", "-c",
|
|
// Find regular files, hash each, output `<hex> ./<relpath>`.
|
|
// `cd` then `find .` keeps paths relative to livePath.
|
|
fmt.Sprintf("cd %s && find . -type f -print0 | xargs -0 -r sha256sum 2>/dev/null", shQuote(livePath)),
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("hash container tree: %w", err)
|
|
}
|
|
hashes := map[string]string{}
|
|
for _, line := range strings.Split(out, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
// sha256sum output: "<hex> <path>" (two spaces). Path starts with "./".
|
|
parts := strings.SplitN(line, " ", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
hash := parts[0]
|
|
rel := strings.TrimPrefix(parts[1], "./")
|
|
hashes[rel] = hash
|
|
}
|
|
return hashes, nil
|
|
}
|
|
|
|
// shQuote single-quotes a string for safe insertion into a shell command.
|
|
// Returns the input unchanged if it's already shell-safe (alphanumeric +
|
|
// /._-). Otherwise wraps in single quotes and escapes inner '.
|
|
func shQuote(s string) string {
|
|
safe := true
|
|
for _, c := range s {
|
|
switch {
|
|
case c >= 'a' && c <= 'z':
|
|
case c >= 'A' && c <= 'Z':
|
|
case c >= '0' && c <= '9':
|
|
case c == '/' || c == '.' || c == '_' || c == '-':
|
|
default:
|
|
safe = false
|
|
}
|
|
if !safe {
|
|
break
|
|
}
|
|
}
|
|
if safe {
|
|
return s
|
|
}
|
|
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
}
|