diff --git a/bin/native/darwin-arm64/loaf b/bin/native/darwin-arm64/loaf index b11fefae..48e0b47b 100755 Binary files a/bin/native/darwin-arm64/loaf and b/bin/native/darwin-arm64/loaf differ diff --git a/docs/changes/20260710-journal-reliability-foundation/change.md b/docs/changes/20260710-journal-reliability-foundation/change.md index d9fe00dd..460e584f 100644 --- a/docs/changes/20260710-journal-reliability-foundation/change.md +++ b/docs/changes/20260710-journal-reliability-foundation/change.md @@ -363,11 +363,12 @@ Every schema migration is backup-first, idempotent, and tested from the current - **U8 verification and review:** `LOAF_VALIDATE_TYPESCRIPT=1 npm run build` passed all targets and synchronized generated Go artifacts. Fresh retained smokes passed for Claude startup, Codex startup, and OpenCode request delivery; the OpenCode receipt additionally proves plugin load, root-session lookup, no supplied auth, and disposable-state cleanup. The strict capability suite, 19 Node smoke-parser tests, `npm run test`, `npm run typecheck`, executable Change check, render-drift, ephemeral-provenance, check-secrets, validate-commit, security-audit, and `git diff --check` passed. The parity regression test was falsified by the former unconditional Amp-hook expectation and now requires the unsupported capture hook to remain absent while preserving every enforcement hook. Initial final reviews found the omitted Amp missing/disabled-plugin bypass and substitution-damaged generated guidance; after correcting both and sweeping the U8-touched orchestration sources, final Sol/high and Claude Opus verification returned `APPROVE` with no blocking findings. Claude separately noted pre-existing plural-substitution defects in unrelated housekeeping, wrap, and foundations guidance; U9's documentation reconciliation retains that boundary. - **U9 content-ownership primitive:** managed fences use version-independent body fingerprints with legacy migration, tamper refusal, atomic user-prose preservation, and strict fence parsing; the shared skill manifest v2 records deterministic per-tree digests with v1 migration, stale/missing convergence, foreign/tamper/race refusal, and rollback, covered by mode, symlink, strict-manifest, and fence-parser tests. Rebuilding changed the candidate native binary hash, so the strict capability suite failed closed on stale receipts; fresh isolated Claude startup, Codex startup, and OpenCode request smokes passed, refreshed retained hashes, and integrated tests passed afterward. - **U9 target-adapter manifest slice:** every native build target now emits one deterministic strict manifest whose adapter identities come from the validated version-3 capability evidence and whose managed-instruction digest is version-independent. Concrete hook/plugin artifacts bind content plus permission mode, while merged user hook projections retain their existing mode; malformed metadata, source or installed content/mode drift, unsafe paths, symlinks, duplicate keys, and trailing JSON fail closed. OpenCode, Cursor, Codex, and Amp installs persist strict ownership state, converge same-version changes, migrate recognized legacy surfaces, retire unchanged stale artifacts, preserve foreign content, reject unowned or modified destinations, revalidate each destination immediately before mutation, and roll back only actual mutations in reverse order with restoration failures surfaced alongside the primary error and current recovery state. Claude Code receives the build manifest but remains intentionally outside CLI install ownership because the CLI has no Claude installer. Focused manifest/install tests, the full internal CLI suite, `go vet ./...`, formatting, and diff checks passed; this slice does not complete V12 or U9. +- **U9 installed-ownership doctor slice:** `loaf doctor` now runs one read-only, non-fixable aggregate ownership check for recorded or detected OpenCode, Cursor, Codex, and Amp installs. It reads each recorded configuration root as authoritative, requires the strict installed manifest, skips the managed-instruction pseudo-artifact, and validates every declared hook, plugin, or merged projection through safe destination resolution plus exact projection/content digest and concrete permission-mode ownership; malformed or symlinked manifests, unsafe or symlinked artifact paths, missing artifacts, foreign Loaf-projection drift, content drift, and mode drift fail closed. Missing records for detected legacy installs, missing manifests, malformed records or manifests, wrong-target manifests, and record/manifest/trusted executable-distribution version disagreement are reported deterministically alongside other target and artifact defects. The check has no fix function, and `doctor --fix` leaves target bytes and permission modes unchanged. If the executable distribution root cannot be proven, doctor retains legacy fence behavior but makes no current-version ownership claim from an unrelated project package. Focused doctor tests, formatting, and diff checks passed; this slice does not implement source-to-build comparison, capability or smoke diagnostics, repair, or V12 completion. - **Search-parity incident evidence:** a real global divergence belonging entirely to another project reproduced Claude Code's nonblocking SessionStart failure because schema validation incorrectly treated the derived FTS index as a whole-database gate. A two-project regression falsified the fix under the old gate, then proved current-project context, recent, and log remain available while global parity stays non-ready and search still returns the typed divergence refusal. The rebuilt Claude plugin's exact SessionStart command exits zero with no stderr against that unchanged database; no global repair or unrelated-project mutation was performed. - **Direct evidence passed during implementation:** independent two-process `journal defer` convergence passed at count 10; schema-upgrade rollback and stale-source refusal tests passed; state, CLI, and command-surface suites passed in the integrated workspace. Atomic staged storage copy SIGKILL and concurrent-publisher coverage passed at count 10 and under `-race` count 3. Final Sol review was clean. - **Project-move race evidence:** a deterministic independent-store claimant race passed at focused count 10 and under `-race` count 3; disabling transactional rejection falsified the guard. The protected path retained one owner/current mapping and both projects' journals remained preserved. - **Final verification gates:** `LOAF_VALIDATE_TYPESCRIPT=1 npm run build` passed all targets with Go artifact synchronization; `npm run test` and typecheck passed. Executable Change check passed with only the expected unmaterialized `release-after` warning. Render-drift, ephemeral-provenance, changed-text secret, validate-commit, and security checks passed. Isolated public smokes proved manual origin capture, atomic defer/search, current schema, backup plus verify recovery/provenance readiness, and contract-v2 synthesis/lineage/blocker/Change projection without touching the global database. -- **Remaining boundary:** U7 normalized envelopes, outcome-aware trusted seams, namespaced origins, lifecycle suppression, safe-hook preflight, and replay evidence are implemented. U8 now has strict installed model-visible proof for Claude startup, Codex startup, and OpenCode request delivery; OpenCode completion is disabled, Cursor remains candidate, and Amp remains an explicitly unsupported automatic-context fallback with a foreground-turn candidate trigger. The U9 content-ownership primitive and target-adapter manifest slice are implemented: deterministic strict build manifests cover Claude Code, OpenCode, Cursor, Codex, and Amp, while strict CLI install ownership covers OpenCode, Cursor, Codex, and Amp because Claude Code intentionally has no CLI installer. U9 still owns doctor diagnostics, broader documentation reconciliation, legacy `~/.amp` retirement, isolated builds/upgrades, and approved real installed cross-harness dogfood; V12 and the root Change remain open. +- **Remaining boundary:** U7 normalized envelopes, outcome-aware trusted seams, namespaced origins, lifecycle suppression, safe-hook preflight, and replay evidence are implemented. U8 now has strict installed model-visible proof for Claude startup, Codex startup, and OpenCode request delivery; OpenCode completion is disabled, Cursor remains candidate, and Amp remains an explicitly unsupported automatic-context fallback with a foreground-turn candidate trigger. The U9 content-ownership primitive, target-adapter manifest slice, and read-only installed-ownership doctor slice are implemented: deterministic strict build manifests cover Claude Code, OpenCode, Cursor, Codex, and Amp, while strict CLI install ownership and recorded-install doctor checks cover OpenCode, Cursor, Codex, and Amp because Claude Code intentionally has no CLI installer. U9 still owns source-to-build comparison, capability mismatch and missing-adapter diagnostics, smoke freshness and absence diagnostics, broader documentation reconciliation, legacy `~/.amp` cleanup, isolated builds/upgrades, and approved real installed cross-harness dogfood; V12, U9, and the root Change remain open. ## Verification Contract diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json index 14c3456a..05789f64 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-claude-code-2.1.207-candidate-smoke.json @@ -1,6 +1,6 @@ { "evidence_version": 2, - "timestamp": "2026-07-14T00:53:40.132Z", + "timestamp": "2026-07-14T01:36:06.452Z", "target": "claude-code", "surface": "cli", "version": "2.1.207", @@ -41,7 +41,7 @@ "stderr_empty": true, "model_visible_marker_observed": true, "assistant_marker_match": true, - "marker": "LOAF_U8_SMOKE_DCBBB6ABD741", + "marker": "LOAF_U8_SMOKE_5CE1278E9700", "hook_observation": { "event_name": "SessionStart:startup", "native_json": true, @@ -52,6 +52,6 @@ "hooks_path": "plugins/loaf/hooks/hooks.json", "hooks_sha256": "5c7343a34af37d6c6e930981f71e405b8023975de4146822058f98c15251b7c2", "native_binary_path": "plugins/loaf/bin/native/darwin-arm64/loaf", - "native_binary_sha256": "93e4ce1f4961b186429fc423627780eb384661b71490dc03d73bab07f86006b8" + "native_binary_sha256": "902e41eea856eacaf172fc9708aa873bb2bc67c360ab00c7ac36070b106855f4" } } diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json index fc58cf49..6a0ee9f3 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-codex-0.144.1-isolated-smoke.json @@ -1,6 +1,6 @@ { "evidence_version": 2, - "timestamp": "2026-07-14T00:53:48.175Z", + "timestamp": "2026-07-14T01:36:15.758Z", "target": "codex", "surface": "cli", "version": "0.144.1", @@ -39,7 +39,7 @@ "stderr": "Reading additional input from stdin...", "model_visible_marker_observed": true, "assistant_marker_match": true, - "marker": "LOAF_CODEX_U8_88ABE98B1675", + "marker": "LOAF_CODEX_U8_FFFA85032CEE", "hook_observation": { "event_name": "SessionStart:startup", "native_json": true, @@ -50,6 +50,6 @@ "hooks_path": "dist/codex/.codex/hooks.json", "hooks_sha256": "0f9e3feb7204c3309a7db6a02224ad881f6801a8cdd126ec6222e6d9a804c33d", "native_binary_path": "bin/native/darwin-arm64/loaf", - "native_binary_sha256": "93e4ce1f4961b186429fc423627780eb384661b71490dc03d73bab07f86006b8" + "native_binary_sha256": "902e41eea856eacaf172fc9708aa873bb2bc67c360ab00c7ac36070b106855f4" } } diff --git a/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json b/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json index 5a2aa24b..b90a0648 100644 --- a/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json +++ b/docs/changes/20260710-journal-reliability-foundation/research/u8-opencode-1.17.18-isolated-smoke.json @@ -1,6 +1,6 @@ { "evidence_version": 2, - "timestamp": "2026-07-14T00:54:04.170Z", + "timestamp": "2026-07-14T01:36:41.695Z", "target": "opencode", "surface": "cli", "version": "1.17.18", @@ -39,11 +39,11 @@ "root_session_lookup_proven": true, "no_auth_supplied": true, "cleanup_succeeded": true, - "marker": "LOAF_OPENCODE_U8_A65933F45C20", + "marker": "LOAF_OPENCODE_U8_5817D3B6970E", "candidate_artifacts": { "hooks_path": "dist/opencode/plugins/hooks.ts", "hooks_sha256": "7715cf7701ce54a9652c0fdaa67025493d0b3407153b89fdfb3be23f26438bef", "native_binary_path": "bin/native/darwin-arm64/loaf", - "native_binary_sha256": "93e4ce1f4961b186429fc423627780eb384661b71490dc03d73bab07f86006b8" + "native_binary_sha256": "902e41eea856eacaf172fc9708aa873bb2bc67c360ab00c7ac36070b106855f4" } } diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 0a674a40..1e9e260a 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -1,6 +1,7 @@ package cli import ( + "encoding/json" "fmt" "io" "os" @@ -65,13 +66,30 @@ func (r Runner) runDoctor(args []string, out io.Writer, runtimeRoot string) erro writeDoctorHelp(out) return nil } - report := runDoctorChecks(out, doctorContext{projectRoot: runtimeRoot}, options, packageVersion(runtimeRoot)) + loafRoot, resolveErr := resolveLoafPackageRoot(r.WorkingDir, runtimeRoot) + fenceVersion := packageVersion(runtimeRoot) + if resolveErr == nil { + fenceVersion = packageVersion(loafRoot) + } + targetAdapterVersion := "" + if executable, err := os.Executable(); err == nil { + targetAdapterVersion = targetAdapterVersionFromExecutable(executable) + } + report := runDoctorChecks(out, doctorContext{projectRoot: runtimeRoot}, options, fenceVersion, targetAdapterVersion) if report.Failures > 0 { return ExitError{Code: 1} } return nil } +func targetAdapterVersionFromExecutable(executable string) string { + root, ok := findLoafPackageRoot(filepath.Dir(executable), map[string]bool{}) + if !ok { + return "" + } + return packageVersion(root) +} + func parseLoafDoctorArgs(args []string) (doctorOptions, error) { var options doctorOptions for _, arg := range args { @@ -98,11 +116,11 @@ func writeDoctorHelp(out io.Writer) { fmt.Fprint(out, " -h, --help Show help\n") } -func runDoctorChecks(out io.Writer, ctx doctorContext, options doctorOptions, cliVersion string) doctorReport { +func runDoctorChecks(out io.Writer, ctx doctorContext, options doctorOptions, fenceVersion string, targetAdapterVersion string) doctorReport { report := doctorReport{} fmt.Fprintf(out, "\n%s\n\n", ansiBold("loaf doctor")) - for _, check := range doctorChecks(cliVersion) { + for _, check := range doctorChecks(fenceVersion, targetAdapterVersion) { result := safeRunDoctorCheck(check, ctx) printDoctorCheckLine(out, check, result, options) @@ -125,15 +143,120 @@ func runDoctorChecks(out io.Writer, ctx doctorContext, options doctorOptions, cl return report } -func doctorChecks(cliVersion string) []doctorCheck { +func doctorChecks(fenceVersion string, targetAdapterVersion string) []doctorCheck { return []doctorCheck{ checkCanonicalAgentsFile(), checkAgentsSymlink(), checkClaudeSymlink(), checkStaleCursorMdc(), - checkFencedVersion(cliVersion), + checkFencedVersion(fenceVersion), checkDuplicateFencedSections(), + checkTargetAdapterOwnership(targetAdapterVersion), + } +} + +func checkTargetAdapterOwnership(cliVersion string) doctorCheck { + return doctorCheck{ + Name: "target-adapter-ownership", + Description: "Recorded target adapter artifacts match their managed ownership manifests", + Run: func(_ doctorContext) doctorResult { + targets := []string{"amp", "codex", "cursor", "opencode"} + checked := 0 + failures := 0 + details := []string{} + fail := func(detail string) { + failures++ + details = append(details, detail) + } + defaults := defaultInstallConfigDirs() + for _, target := range targets { + record, state, recordErr := readDoctorInstallRecord(target) + if state == "absent" { + if isLoafInstalledForTargetInstall(target, defaults[target]) { + fail(fmt.Sprintf("%s: Loaf install detected without an install record", target)) + } + continue + } + if recordErr != nil { + fail(fmt.Sprintf("%s: install record is invalid: %v", target, recordErr)) + continue + } + checked++ + if record.Target != target || record.ConfigDir == "" { + fail(fmt.Sprintf("%s: install record has an invalid target or config directory", target)) + continue + } + manifest, err := readTargetAdapterManifest(filepath.Join(record.ConfigDir, targetInstallManifestFile)) + if err != nil { + fail(fmt.Sprintf("%s: ownership manifest is unavailable: %v", target, err)) + continue + } + if manifest.Target != target { + fail(fmt.Sprintf("%s: ownership manifest declares %s", target, manifest.Target)) + continue + } + if record.Version != manifest.PackageVersion || (cliVersion != "" && record.Version != cliVersion) { + versions := fmt.Sprintf("record=%s manifest=%s", record.Version, manifest.PackageVersion) + if cliVersion != "" { + versions += " current=" + cliVersion + } + fail(fmt.Sprintf("%s: version mismatch %s", target, versions)) + } + options := targetInstallOptions{Target: target, ConfigDir: record.ConfigDir, CodexHome: record.ConfigDir, AmpPluginsDir: filepath.Join(record.ConfigDir, "plugins")} + for _, artifact := range manifest.Artifacts { + if artifact.Kind == "instruction" { + continue + } + path, err := targetAdapterDestination(options, artifact) + if err != nil { + fail(fmt.Sprintf("%s %s: unsafe destination: %v", target, artifact.ID, err)) + continue + } + snapshot, err := readTargetAdapterSnapshot(path) + if err != nil { + fail(fmt.Sprintf("%s %s: cannot inspect: %v", target, artifact.ID, err)) + continue + } + if !snapshot.exists { + fail(fmt.Sprintf("%s %s: missing %s", target, artifact.ID, path)) + continue + } + matches, err := targetAdapterSnapshotMatchesArtifact(target, artifact, snapshot) + if err != nil { + fail(fmt.Sprintf("%s %s: cannot validate: %v", target, artifact.ID, err)) + continue + } + if !matches { + fail(fmt.Sprintf("%s %s: drifted %s", target, artifact.ID, path)) + continue + } + details = append(details, fmt.Sprintf("%s %s: matches", target, artifact.ID)) + } + } + if failures > 0 { + return doctorResult{Status: doctorFail, Message: "Target adapter ownership drift detected", Detail: strings.Join(details, "\n")} + } + if checked == 0 { + return doctorResult{Status: doctorSkip, Message: "No recorded target adapter installs"} + } + return doctorResult{Status: doctorPass, Message: fmt.Sprintf("%d recorded target adapter install(s) match ownership manifests", checked), Detail: strings.Join(details, "\n")} + }, + } +} + +func readDoctorInstallRecord(target string) (installTargetRecord, string, error) { + body, err := os.ReadFile(installRecordPath(installHome(), target)) + if os.IsNotExist(err) { + return installTargetRecord{}, "absent", nil + } + if err != nil { + return installTargetRecord{}, "invalid", err + } + var record installTargetRecord + if err := json.Unmarshal(body, &record); err != nil { + return installTargetRecord{}, "invalid", err } + return record, "present", nil } func checkAgentsSymlink() doctorCheck { diff --git a/internal/cli/doctor_test.go b/internal/cli/doctor_test.go index ea027b3d..aa2ac64a 100644 --- a/internal/cli/doctor_test.go +++ b/internal/cli/doctor_test.go @@ -2,6 +2,7 @@ package cli import ( "bytes" + "encoding/json" "errors" "os" "path/filepath" @@ -12,6 +13,26 @@ import ( var regexpANSI = regexp.MustCompile(`\x1b\[[0-9;]*m`) +func TestTargetAdapterVersionFromExecutable(t *testing.T) { + distributionRoot := filepath.Join(t.TempDir(), "distribution") + writeDoctorFile(t, filepath.Join(distributionRoot, "package.json"), `{"name":"loaf","version":"9.8.7-test.1"}`+"\n") + executable := filepath.Join(distributionRoot, "bin", "native", "loaf") + if got := targetAdapterVersionFromExecutable(executable); got != "9.8.7-test.1" { + t.Fatalf("targetAdapterVersionFromExecutable() = %q, want %q", got, "9.8.7-test.1") + } +} + +func TestTargetAdapterVersionFromExecutableIgnoresWorkingAndRuntimeCheckouts(t *testing.T) { + workingRoot := filepath.Join(t.TempDir(), "working") + runtimeRoot := filepath.Join(t.TempDir(), "runtime") + writeDoctorFile(t, filepath.Join(workingRoot, "package.json"), `{"name":"loaf","version":"1.0.0-working"}`+"\n") + writeDoctorFile(t, filepath.Join(runtimeRoot, "package.json"), `{"name":"loaf","version":"2.0.0-runtime"}`+"\n") + executable := filepath.Join(t.TempDir(), "outside-distribution", "loaf") + if got := targetAdapterVersionFromExecutable(executable); got != "" { + t.Fatalf("targetAdapterVersionFromExecutable() = %q, want empty outside a Loaf distribution", got) + } +} + func TestRunnerDoctorHealthyProjectRunsNatively(t *testing.T) { root := writeDoctorFixture(t, "9.8.7-test.1") writeDoctorAgents(t, root, doctorFence("9.8.7-test.1")) @@ -54,6 +75,317 @@ func TestRunnerDoctorReturnsExitOneForFailures(t *testing.T) { } } +func TestRunnerDoctorReportsDriftedTargetAdapterArtifact(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("CODEX_HOME", filepath.Join(home, "codex")) + t.Setenv("PATH", t.TempDir()) + config := filepath.Join(home, "config", "opencode") + mode := uint32(0o644) + artifact := targetAdapterArtifact{ID: "plugin:plugins/hooks.ts", Kind: "plugin", SourcePath: "plugins/hooks.ts", Destination: "plugins/hooks.ts", SHA256: sha256Bytes([]byte("expected\n")), Mode: &mode} + writeDoctorFile(t, filepath.Join(config, "plugins", "hooks.ts"), "drifted\n") + if err := writeTargetAdapterManifest(filepath.Join(config, targetInstallManifestFile), targetAdapterManifest{Version: 1, Target: "opencode", PackageVersion: "9.8.7-test.1", CapabilityContractVersion: TargetCapabilityEvidenceContractVersion, Adapters: []string{"opencode-context-v1"}, Artifacts: []targetAdapterArtifact{artifact}}); err != nil { + t.Fatal(err) + } + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "opencode", ConfigDir: config}) + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"doctor"}) + var exitErr ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 1 || !strings.Contains(stripANSI(stdout.String()), "target-adapter-ownership") { + t.Fatalf("doctor drift = %v\n%s", err, stdout.String()) + } +} + +func TestRunnerDoctorTargetAdapterOwnershipHealthyMissingAndMalformed(t *testing.T) { + for _, tc := range []struct { + name, installed, manifest string + omitManifest bool + wantError bool + }{ + {name: "healthy", installed: "expected\n"}, + {name: "missing artifact", wantError: true}, + {name: "missing manifest", installed: "expected\n", omitManifest: true, wantError: true}, + {name: "malformed manifest", installed: "expected\n", manifest: "{", wantError: true}, + } { + t.Run(tc.name, func(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(home, "config", "opencode") + mode := uint32(0o644) + artifact := targetAdapterArtifact{ID: "plugin:plugins/hooks.ts", Kind: "plugin", SourcePath: "plugins/hooks.ts", Destination: "plugins/hooks.ts", SHA256: sha256Bytes([]byte("expected\n")), Mode: &mode} + if tc.installed != "" { + writeDoctorFile(t, filepath.Join(config, "plugins", "hooks.ts"), tc.installed) + } + if !tc.omitManifest { + if tc.manifest != "" { + writeDoctorFile(t, filepath.Join(config, targetInstallManifestFile), tc.manifest) + } else if err := writeTargetAdapterManifest(filepath.Join(config, targetInstallManifestFile), targetAdapterManifest{Version: 1, Target: "opencode", PackageVersion: "9.8.7-test.1", CapabilityContractVersion: TargetCapabilityEvidenceContractVersion, Adapters: []string{"opencode-context-v1"}, Artifacts: []targetAdapterArtifact{artifact}}); err != nil { + t.Fatal(err) + } + } + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "opencode", ConfigDir: config}) + before := "" + if tc.installed != "" { + before = string(readFileBytes(t, filepath.Join(config, "plugins", "hooks.ts"))) + } + var stdout bytes.Buffer + err := (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"doctor", "--fix"}) + if tc.wantError && err == nil { + t.Fatalf("doctor output = %s, want failure", stdout.String()) + } + if !tc.wantError && err != nil { + t.Fatal(err) + } + if tc.installed != "" && string(readFileBytes(t, filepath.Join(config, "plugins", "hooks.ts"))) != before { + t.Fatal("doctor --fix mutated adapter artifact") + } + }) + } +} + +func TestTargetAdapterOwnershipRejectsWrongTargetSymlinkModeAndUsesRecordedAmpDir(t *testing.T) { + for _, tc := range []struct { + name string + target string + manifestAs string + symlink bool + mode os.FileMode + want doctorStatus + }{ + {name: "wrong target", target: "opencode", manifestAs: "cursor", mode: 0o644, want: doctorFail}, + {name: "symlink manifest", target: "opencode", manifestAs: "opencode", symlink: true, mode: 0o644, want: doctorFail}, + {name: "mode drift", target: "opencode", manifestAs: "opencode", mode: 0o755, want: doctorFail}, + {name: "recorded amp directory", target: "amp", manifestAs: "amp", mode: 0o644, want: doctorPass}, + } { + t.Run(tc.name, func(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(home, "custom", tc.target) + mode := uint32(0o644) + artifact := targetAdapterArtifact{ID: "plugin:plugins/hooks.ts", Kind: "plugin", SourcePath: "plugins/hooks.ts", Destination: "plugins/hooks.ts", SHA256: sha256Bytes([]byte("expected\n")), Mode: &mode} + path := filepath.Join(config, "plugins", "hooks.ts") + if tc.target == "amp" { + artifact = targetAdapterArtifact{ID: "plugin:.amp/plugins/loaf.ts", Kind: "plugin", SourcePath: ".amp/plugins/loaf.ts", Destination: "plugins/loaf.ts", SHA256: sha256Bytes([]byte("expected\n")), Mode: &mode} + path = filepath.Join(config, "plugins", "loaf.ts") + } + writeDoctorFile(t, path, "expected\n") + if err := os.Chmod(path, tc.mode); err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(config, targetInstallManifestFile) + if err := writeTargetAdapterManifest(manifestPath, targetAdapterManifest{Version: 1, Target: tc.manifestAs, PackageVersion: "9.8.7-test.1", CapabilityContractVersion: TargetCapabilityEvidenceContractVersion, Adapters: []string{"adapter"}, Artifacts: []targetAdapterArtifact{artifact}}); err != nil { + t.Fatal(err) + } + if tc.symlink { + if err := os.Rename(manifestPath, manifestPath+".real"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(manifestPath+".real", manifestPath); err != nil { + t.Fatal(err) + } + } + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: tc.target, ConfigDir: config}) + result := checkTargetAdapterOwnership("9.8.7-test.1").Run(doctorContext{projectRoot: root}) + if result.Status != tc.want { + t.Fatalf("status = %s, detail = %s, want %s", result.Status, result.Detail, tc.want) + } + }) + } +} + +func TestTargetAdapterOwnershipFailsClosedForDetectedAndMalformedRecords(t *testing.T) { + for _, tc := range []struct { + name string + record string + }{ + {name: "detected without record"}, + {name: "malformed record", record: "{"}, + } { + t.Run(tc.name, func(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(home, "config", "opencode") + writeDoctorFile(t, filepath.Join(config, loafInstallMarkerFile), "9.8.7-test.1\n") + if tc.record != "" { + writeDoctorFile(t, installRecordPath(home, "opencode"), tc.record) + } + result := checkTargetAdapterOwnership("9.8.7-test.1").Run(doctorContext{projectRoot: root}) + if result.Status != doctorFail { + t.Fatalf("status = %s, want fail", result.Status) + } + }) + } +} + +func TestTargetAdapterOwnershipUsesRecordedCodexAndAmpDirectories(t *testing.T) { + for _, target := range []string{"codex", "amp"} { + t.Run(target, func(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(root, "recorded", target) + var artifacts []targetAdapterArtifact + if target == "codex" { + body := `{"hooks":{"SessionStart":[{"matcher":"startup|resume|clear|compact","hooks":[{"type":"command","command":"'/opt/loaf' journal context --from-hook --codex-hook"}]}]}}` + writeDoctorFile(t, filepath.Join(config, "hooks.json"), body) + digest, err := targetHookProjectionDigest("codex", []byte(body), true) + if err != nil { + t.Fatal(err) + } + artifacts = []targetAdapterArtifact{{ID: "hook-projection:.codex/hooks.json", Kind: "hook-projection", SourcePath: ".codex/hooks.json", Destination: "hooks.json", SHA256: digest}} + } else { + artifacts = []targetAdapterArtifact{writeDoctorConcreteAdapterArtifact(t, config, "plugin:.amp/plugins/loaf.ts", "plugin", ".amp/plugins/loaf.ts", "plugins/loaf.ts", "amp plugin\n", 0o644)} + } + writeDoctorOwnershipManifest(t, config, target, "9.8.7-test.1", artifacts) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: target, ConfigDir: config}) + result := checkTargetAdapterOwnership("9.8.7-test.1").Run(doctorContext{projectRoot: root}) + if result.Status != doctorPass { + t.Fatalf("status = %s, detail = %s", result.Status, result.Detail) + } + }) + } +} + +func TestTargetAdapterOwnershipDetailsAreDeterministicAcrossTargetsAndArtifacts(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + opencodeConfig := filepath.Join(root, "recorded", "opencode") + ampConfig := filepath.Join(root, "recorded", "amp") + opencodeArtifacts := []targetAdapterArtifact{ + writeDoctorConcreteAdapterArtifact(t, opencodeConfig, "plugin:z", "plugin", "plugins/z.ts", "plugins/z.ts", "z\n", 0o644), + writeDoctorConcreteAdapterArtifact(t, opencodeConfig, "hook-file:a", "hook-file", "hooks/a.sh", "hooks/a.sh", "#!/bin/sh\n", 0o755), + } + ampArtifacts := []targetAdapterArtifact{writeDoctorConcreteAdapterArtifact(t, ampConfig, "plugin:amp", "plugin", ".amp/plugins/loaf.ts", "plugins/loaf.ts", "amp\n", 0o644)} + writeDoctorOwnershipManifest(t, opencodeConfig, "opencode", "9.8.7-test.1", opencodeArtifacts) + writeDoctorOwnershipManifest(t, ampConfig, "amp", "9.8.7-test.1", ampArtifacts) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "opencode", ConfigDir: opencodeConfig}) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "amp", ConfigDir: ampConfig}) + writeDoctorFile(t, filepath.Join(ampConfig, "plugins", "loaf.ts"), "drifted amp\n") + writeDoctorFile(t, filepath.Join(opencodeConfig, "plugins", "z.ts"), "drifted z\n") + + result := checkTargetAdapterOwnership("9.8.7-test.1").Run(doctorContext{projectRoot: root}) + if result.Status != doctorFail { + t.Fatalf("status = %s, detail = %s", result.Status, result.Detail) + } + want := strings.Join([]string{ + "amp plugin:amp: drifted " + filepath.Join(ampConfig, "plugins", "loaf.ts"), + "opencode hook-file:a: matches", + "opencode plugin:z: drifted " + filepath.Join(opencodeConfig, "plugins", "z.ts"), + }, "\n") + if result.Detail != want { + t.Fatalf("detail = %q, want %q", result.Detail, want) + } +} + +func TestTargetAdapterOwnershipRejectsArtifactSymlink(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(root, "recorded", "opencode") + artifact := writeDoctorConcreteAdapterArtifact(t, config, "plugin:hooks", "plugin", "plugins/hooks.ts", "plugins/hooks.ts", "plugin\n", 0o644) + path := filepath.Join(config, "plugins", "hooks.ts") + if err := os.Rename(path, path+".real"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(path+".real", path); err != nil { + t.Fatal(err) + } + writeDoctorOwnershipManifest(t, config, "opencode", "9.8.7-test.1", []targetAdapterArtifact{artifact}) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "opencode", ConfigDir: config}) + result := checkTargetAdapterOwnership("9.8.7-test.1").Run(doctorContext{projectRoot: root}) + if result.Status != doctorFail || !strings.Contains(result.Detail, "must be a regular file") { + t.Fatalf("status = %s, detail = %s", result.Status, result.Detail) + } +} + +func TestTargetAdapterOwnershipMatchesCursorProjectionAndPreservesForeignHooks(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(root, "recorded", "cursor") + installed := `{"version":1,"hooks":{"PostToolUse":[{"command":"foreign one"},{"command":"loaf task refresh","matcher":"Edit|Write","loaf-managed":true}]}}` + writeDoctorFile(t, filepath.Join(config, "hooks.json"), installed) + digest, err := targetHookProjectionDigest("cursor", []byte(installed), true) + if err != nil { + t.Fatal(err) + } + artifact := targetAdapterArtifact{ID: "hook-projection:hooks.json", Kind: "hook-projection", SourcePath: "hooks.json", Destination: "hooks.json", SHA256: digest} + writeDoctorOwnershipManifest(t, config, "cursor", "9.8.7-test.1", []targetAdapterArtifact{artifact}) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "cursor", ConfigDir: config}) + for _, body := range []string{ + installed, + `{"version":1,"hooks":{"PostToolUse":[{"command":"foreign changed without touching Loaf"},{"command":"loaf task refresh","matcher":"Edit|Write","loaf-managed":true}]}}`, + } { + writeDoctorFile(t, filepath.Join(config, "hooks.json"), body) + result := checkTargetAdapterOwnership("9.8.7-test.1").Run(doctorContext{projectRoot: root}) + if result.Status != doctorPass { + t.Fatalf("status = %s, detail = %s", result.Status, result.Detail) + } + } +} + +func TestTargetAdapterOwnershipReportsVersionMismatchWithoutUntrustedCLIComparison(t *testing.T) { + for _, tc := range []struct { + name, recordVersion, manifestVersion, cliVersion string + want doctorStatus + }{ + {name: "record mismatch", recordVersion: "1.0.0", manifestVersion: "2.0.0", cliVersion: "2.0.0", want: doctorFail}, + {name: "manifest mismatch", recordVersion: "2.0.0", manifestVersion: "1.0.0", cliVersion: "2.0.0", want: doctorFail}, + {name: "current CLI mismatch", recordVersion: "2.0.0", manifestVersion: "2.0.0", cliVersion: "3.0.0", want: doctorFail}, + {name: "untrusted CLI fallback omitted", recordVersion: "2.0.0", manifestVersion: "2.0.0", cliVersion: "", want: doctorPass}, + } { + t.Run(tc.name, func(t *testing.T) { + root := writeDoctorFixture(t, "unrelated-project-version") + home := os.Getenv("HOME") + config := filepath.Join(root, "recorded", "opencode") + artifact := writeDoctorConcreteAdapterArtifact(t, config, "plugin:hooks", "plugin", "plugins/hooks.ts", "plugins/hooks.ts", "plugin\n", 0o644) + writeDoctorOwnershipManifest(t, config, "opencode", tc.manifestVersion, []targetAdapterArtifact{artifact}) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: tc.recordVersion, Target: "opencode", ConfigDir: config}) + result := checkTargetAdapterOwnership(tc.cliVersion).Run(doctorContext{projectRoot: root}) + if result.Status != tc.want { + t.Fatalf("status = %s, detail = %s, want %s", result.Status, result.Detail, tc.want) + } + if tc.cliVersion == "" && strings.Contains(result.Detail, "doctor=") { + t.Fatalf("untrusted CLI version appeared in detail: %s", result.Detail) + } + }) + } +} + +func TestRunnerDoctorFixDoesNotMutateTargetAdapterBytesOrMode(t *testing.T) { + root := writeDoctorFixture(t, "9.8.7-test.1") + home := os.Getenv("HOME") + config := filepath.Join(root, "recorded", "opencode") + artifact := writeDoctorConcreteAdapterArtifact(t, config, "plugin:hooks", "plugin", "plugins/hooks.ts", "plugins/hooks.ts", "locally modified\n", 0o755) + artifact.SHA256 = sha256Bytes([]byte("expected\n")) + manifestPath := filepath.Join(config, targetInstallManifestFile) + writeDoctorOwnershipManifest(t, config, "opencode", "9.8.7-test.1", []targetAdapterArtifact{artifact}) + writeDoctorInstallRecord(t, home, installTargetRecord{Version: "9.8.7-test.1", Target: "opencode", ConfigDir: config}) + artifactPath := filepath.Join(config, "plugins", "hooks.ts") + beforeArtifact := append([]byte(nil), readFileBytes(t, artifactPath)...) + beforeManifest := append([]byte(nil), readFileBytes(t, manifestPath)...) + beforeInfo, err := os.Stat(artifactPath) + if err != nil { + t.Fatal(err) + } + var stdout bytes.Buffer + err = (Runner{Stdout: &stdout, WorkingDir: root}).Run([]string{"doctor", "--fix"}) + var exitErr ExitError + if !errors.As(err, &exitErr) || exitErr.Code != 1 { + t.Fatalf("doctor --fix error = %v, output = %s", err, stdout.String()) + } + afterInfo, err := os.Stat(artifactPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(readFileBytes(t, artifactPath), beforeArtifact) || !bytes.Equal(readFileBytes(t, manifestPath), beforeManifest) || afterInfo.Mode().Perm() != beforeInfo.Mode().Perm() { + t.Fatal("doctor --fix mutated target adapter bytes or permission mode") + } + check := checkTargetAdapterOwnership("9.8.7-test.1") + if check.Fix != nil || check.Run(doctorContext{projectRoot: root}).Fixable { + t.Fatal("target adapter ownership check unexpectedly exposes a fix path") + } +} + func TestRunnerDoctorWarningsDoNotFail(t *testing.T) { root := writeDoctorFixture(t, "9.8.7-test.1") writeDoctorAgents(t, root, doctorFence("0.0.1-test")) @@ -173,6 +505,11 @@ func TestRunnerDoctorRejectsUnknownOptionsNatively(t *testing.T) { func writeDoctorFixture(t *testing.T, version string) string { t.Helper() root := realpath(t, t.TempDir()) + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, "config")) + t.Setenv("CODEX_HOME", filepath.Join(home, "codex")) + t.Setenv("PATH", t.TempDir()) writeFile(t, filepath.Join(root, "package.json"), `{"name":"loaf","version":"`+version+`"}`+"\n") return root } @@ -210,6 +547,35 @@ func writeDoctorFile(t *testing.T, path string, body string) { writeFile(t, path, body) } +func writeDoctorInstallRecord(t *testing.T, home string, record installTargetRecord) { + t.Helper() + body, err := json.Marshal(record) + if err != nil { + t.Fatalf("Marshal install record error = %v", err) + } + writeDoctorFile(t, installRecordPath(home, record.Target), string(body)+"\n") +} + +func writeDoctorConcreteAdapterArtifact(t *testing.T, config string, id string, kind string, sourcePath string, destination string, body string, mode os.FileMode) targetAdapterArtifact { + t.Helper() + path := filepath.Join(config, filepath.FromSlash(destination)) + writeDoctorFile(t, path, body) + if err := os.Chmod(path, mode); err != nil { + t.Fatalf("Chmod(%s) error = %v", path, err) + } + manifestMode := uint32(mode.Perm()) + return targetAdapterArtifact{ID: id, Kind: kind, SourcePath: sourcePath, Destination: destination, SHA256: sha256Bytes([]byte(body)), Mode: &manifestMode} +} + +func writeDoctorOwnershipManifest(t *testing.T, config string, target string, version string, artifacts []targetAdapterArtifact) { + t.Helper() + instruction := targetAdapterArtifact{ID: "managed-instructions", Kind: "instruction", Destination: "project-instructions", SHA256: sha256Bytes([]byte("instruction"))} + manifest := targetAdapterManifest{Version: 1, Target: target, PackageVersion: version, CapabilityContractVersion: TargetCapabilityEvidenceContractVersion, Adapters: []string{target + "-doctor-v1"}, Artifacts: append([]targetAdapterArtifact{instruction}, artifacts...)} + if err := writeTargetAdapterManifest(filepath.Join(config, targetInstallManifestFile), manifest); err != nil { + t.Fatalf("writeTargetAdapterManifest error = %v", err) + } +} + func assertSymlinkTarget(t *testing.T, linkPath string, want string) { t.Helper() info, err := os.Lstat(linkPath) diff --git a/plugins/loaf/bin/native/darwin-arm64/loaf b/plugins/loaf/bin/native/darwin-arm64/loaf index b11fefae..48e0b47b 100755 Binary files a/plugins/loaf/bin/native/darwin-arm64/loaf and b/plugins/loaf/bin/native/darwin-arm64/loaf differ