feat(desktop): versioned helper activation, drop Guard, NSIS dual layout

Problem:
The Windows update helper still published a flat six-member release unit with
Guard health recovery. The Guard recovery product remained as a first-class
binary and packaging still installed Guard as the permanent entry point.

Root cause:
Post-stage publish was hard-wired to flat InstallRoot members and pending
receipt recording. Packaging and docs still treated reasonix-guard as the
launcher/recovery surface.

Fix:
- Prefer versioned-v1 activation from staged NSIS payloads: write
  versions/<ver>/{desktop,cli,helper}, swap current.json, place thin launcher
  and portable alias at InstallRoot, remove flat desktop/guard leftovers.
- On versioned success, clear the pending transaction without health probation
  and relaunch via reasonix-launcher.
- Resolve InstallRoot and update-helper source for versioned trees; claim
  pending targets from the prepared transaction rather than hard-coded flat
  paths.
- NSIS dual mode: /REASONIXSTAGE=1 still extracts the six-member flat payload
  for 1.18-1.19.1 helpers; normal install writes versioned layout and does not
  persist Guard.
- Delete cmd/reasonix-guard. Compatibility payloads still build
  reasonix-guard from reasonix-legacy-migrator only. Point recovery docs at
  reasonix doctor.
- Linux .desktop Exec uses reasonix-launcher.

Verification:
- go test ./internal/installlayout ./cmd/reasonix-launcher
  ./cmd/reasonix-legacy-migrator ./internal/repair
- cd desktop && go test ./cmd/update-helper/
- focused desktop packaging/window/updater tests
- GOOS=windows go test -c ./cmd/update-helper and desktop package
This commit is contained in:
SivanCola committed 2026-08-02 17:54:11 +08:00
1 parent 0e78ef425e
commit 79449ef801
22 files changed
+667 -1477

No files matched your search

-381
View File
@@ -1,381 +0,0 @@
package main
import (
"bufio"
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"time"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/provider"
_ "reasonix/internal/provider/anthropic"
_ "reasonix/internal/provider/openai"
_ "reasonix/internal/provider/responses"
"reasonix/internal/repair"
)
func runAssist(args []string) int {
fs := flag.NewFlagSet("reasonix-guard assist", flag.ContinueOnError)
root := fs.String("root", ".", "project root to diagnose")
model := fs.String("model", "", "provider or provider/model used for the one-shot plan")
network := fs.Bool("network", true, "include provider connectivity diagnostics")
apply := fs.Bool("apply", false, "apply the validated plan after preview and confirmation")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow a plan to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print the plan and preview as JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
report, err := repair.Diagnose(ctx, repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := requestRepairPlan(ctx, *root, *model, report)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(struct {
Plan repair.RepairPlan `json:"plan"`
PlanID string `json:"planId"`
Preview []repair.RepairPlanPreview `json:"preview"`
PreviewID string `json:"previewId"`
}{plan, repair.RepairPlanID(plan), preview, repair.RepairPlanPreviewID(plan, preview)}); code != 0 {
return code
}
} else {
printPlanPreview(plan, preview)
}
if !*apply {
return 0
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = repair.RepairPlanPreviewID(plan, preview)
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func runApplyPlan(args []string) int {
fs := flag.NewFlagSet("reasonix-guard apply-plan", flag.ContinueOnError)
root := fs.String("root", ".", "project root")
file := fs.String("file", "", "RepairPlan JSON file")
yes := fs.Bool("yes", false, "confirm plan application non-interactively")
allowProject := fs.Bool("allow-project", false, "allow project reasonix.toml repair")
previewID := fs.String("preview-id", "", "expected preview ID from a prior dry-run")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*file) == "" {
return 2
}
b, err := os.ReadFile(*file)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
plan, err := repair.DecodeRepairPlan(b)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
opts := repair.ApplyPlanOptions{Root: *root, AllowProject: *allowProject}
preview, err := repair.PreviewRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if !*jsonOut {
printPlanPreview(plan, preview)
}
actualPreviewID := repair.RepairPlanPreviewID(plan, preview)
expectedPreviewID := strings.TrimSpace(*previewID)
if *yes && expectedPreviewID == "" {
fmt.Fprintln(os.Stderr, "error: --preview-id is required with --yes; preview the plan and confirm its current previewId first")
return 2
}
if expectedPreviewID != "" && expectedPreviewID != actualPreviewID {
fmt.Fprintf(os.Stderr, "error: repair plan preview changed since confirmation; re-preview and re-confirm (expected %s, got %s)\n", expectedPreviewID, actualPreviewID)
return 1
}
if !*yes && !confirmPlan() {
fmt.Println("repair plan not applied")
return 0
}
opts.ExpectedPreviewID = actualPreviewID
result, err := repair.ApplyRepairPlan(plan, opts)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(result)
}
for _, action := range result.Applied {
fmt.Println("applied:", action)
}
return 0
}
func requestRepairPlan(ctx context.Context, root, modelRef string, report repair.DiagnosticReport) (repair.RepairPlan, error) {
cfg, err := loadAIProviderConfig(root)
if err != nil {
return repair.RepairPlan{}, err
}
if strings.TrimSpace(modelRef) == "" {
modelRef = cfg.DefaultModel
}
entry, ok := cfg.ResolveModel(modelRef)
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI assistance model %q is not configured", modelRef)
}
if entry.RequiresAPIKey() && entry.APIKey() == "" {
return repair.RepairPlan{}, fmt.Errorf("AI assistance provider %q has no configured API key", entry.Name)
}
p, err := provider.New(entry.Kind, repairPlanProviderConfig(entry, cfg.NetworkProxySpec()))
if err != nil {
return repair.RepairPlan{}, err
}
safeReport, snapshotAliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
return repair.RepairPlan{}, err
}
stream, err := p.Stream(ctx, provider.Request{
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: repairPlanSystemPrompt},
{Role: provider.RoleUser, Content: string(payload)},
},
Temperature: provider.TemperaturePtr(0),
MaxTokens: 2000,
})
if err != nil {
return repair.RepairPlan{}, err
}
var out strings.Builder
for chunk := range stream {
switch chunk.Type {
case provider.ChunkText:
out.WriteString(chunk.Text)
case provider.ChunkError:
if chunk.Err != nil {
return repair.RepairPlan{}, chunk.Err
}
}
}
plan, err := repair.DecodeRepairPlan([]byte(out.String()))
if err != nil {
return repair.RepairPlan{}, err
}
return resolveProviderSnapshotAliases(plan, snapshotAliases)
}
func repairPlanProviderConfig(entry *config.ProviderEntry, proxy netclient.ProxySpec) provider.Config {
return provider.Config{
Name: entry.Name, BaseURL: entry.BaseURL, Model: entry.Model, APIKey: entry.APIKey(),
Extra: map[string]any{
"api_key_env": entry.APIKeyEnv, "api_key_source": entry.APIKeySourceLabel(),
"thinking": entry.Thinking, "effort": config.EffectiveEffort(entry), "supported_efforts": entry.SupportedEfforts,
"reasoning_protocol": config.ReasoningProtocolForEntry(entry),
"chat_url": entry.ChatURL, "headers": entry.Headers, "extra_body": entry.ExtraBody,
"auth_header": entry.AuthHeader, "proxy_spec": proxy,
"vision": config.EffectiveVision(entry), "vision_detail": entry.VisionDetail,
},
}
}
func loadAIProviderConfig(root string) (*config.Config, error) {
// Recovery tooling must never rewrite user configuration, so skip the
// on-disk legacy MCP tier migration that LoadForRoot performs.
if cfg, err := config.LoadForRootReadOnly(root); err == nil {
return cfg, nil
}
if snapshots, err := repair.ListConfigSnapshots(); err == nil {
for _, snapshot := range snapshots {
cfg, loadErr := config.LoadForEditReadOnlyStrict(snapshot.Path)
if loadErr == nil {
return cfg, nil
}
}
}
// Built-in recovery defaults resolve the global credential file without
// reading, migrating, or rewriting malformed user/project TOML.
return config.LoadRecoveryDefaultsForRoot(root), nil
}
const repairPlanSystemPrompt = `You are a Reasonix recovery planner. Return JSON only, matching exactly:
{"schemaVersion":1,"summary":"...","actions":[{"type":"...","scope":"","snapshotId":"","target":"","reason":"..."}]}
The user message is a diagnostic report whose findings carry only a severity, a machine code (e.g. config.invalid_toml, derived.invalid_json, update related codes), and a generic scope; pick actions from those codes.
Snapshot IDs are host-issued aliases ordered newest first; pendingUpdate=true means rollback_update is available.
Allowed actions only (return an empty actions array when no safe action applies):
- repair_config with scope global or project
- restore_snapshot with snapshotId
- rebuild_derived_state with target tabs, projects, window, zoom, or all
- rollback_update with no parameters
Never request shell commands, credential changes, session-content edits, arbitrary paths, plugin execution, or source-code changes. Prefer the smallest reversible plan supported by the diagnostics. Do not invent snapshot IDs.`
// providerSafeReport is the strict allowlist DTO sent to the AI provider.
// It deliberately carries no free-form diagnostic strings or state metadata:
// finding messages can quote config lines, URLs, commands, and permission
// rules, while snapshot and update metadata can be edited on disk. Only fixed
// diagnostic vocabulary, booleans, and host-issued snapshot aliases leave the
// machine.
type providerSafeReport struct {
Root string `json:"root"`
Network bool `json:"network"`
Snapshots []providerSafeSnapshot `json:"snapshots"`
PendingUpdate bool `json:"pendingUpdate"`
Findings []providerSafeFinding `json:"findings"`
}
type providerSafeSnapshot struct {
ID string `json:"id"`
}
type providerSafeFinding struct {
Severity string `json:"severity"`
Code string `json:"code"`
Scope string `json:"scope,omitempty"`
}
func providerSafeReportFrom(report repair.DiagnosticReport) (providerSafeReport, map[string]string) {
safe := providerSafeReport{
Root: "<project>",
Network: report.Network,
Snapshots: make([]providerSafeSnapshot, 0, len(report.Snapshots)),
PendingUpdate: report.PendingUpdate != nil,
Findings: make([]providerSafeFinding, 0, len(report.Findings)),
}
snapshotAliases := make(map[string]string, len(report.Snapshots))
for _, snapshot := range report.Snapshots {
if strings.TrimSpace(snapshot.ID) != "" {
alias := fmt.Sprintf("snapshot-%d", len(safe.Snapshots)+1)
safe.Snapshots = append(safe.Snapshots, providerSafeSnapshot{ID: alias})
snapshotAliases[alias] = snapshot.ID
}
}
for _, finding := range report.Findings {
safe.Findings = append(safe.Findings, providerSafeFinding{
Severity: providerSafeSeverity(finding.Severity),
Code: providerSafeDiagnosticCode(finding.Code),
Scope: providerSafeScope(finding.Scope),
})
}
return safe, snapshotAliases
}
func resolveProviderSnapshotAliases(plan repair.RepairPlan, aliases map[string]string) (repair.RepairPlan, error) {
resolved := plan
resolved.Actions = append([]repair.RepairPlanAction(nil), plan.Actions...)
for i := range resolved.Actions {
action := &resolved.Actions[i]
if action.Type != "restore_snapshot" {
continue
}
id, ok := aliases[action.SnapshotID]
if !ok {
return repair.RepairPlan{}, fmt.Errorf("AI repair plan referenced unknown snapshot alias %q", action.SnapshotID)
}
action.SnapshotID = id
}
return resolved, nil
}
func providerSafeSeverity(severity string) string {
switch severity {
case "error", "warning", "info":
return severity
default:
return "unknown"
}
}
func providerSafeDiagnosticCode(code string) string {
switch code {
case "config.invalid_toml", "config.load_failed",
"provider.missing_name", "provider.duplicate_name", "provider.unsupported_kind",
"provider.no_models", "provider.invalid_url", "provider.invalid_models_url",
"provider.invalid_key_name", "provider.missing_key",
"model.no_default", "model.invalid_default",
"plugin.missing_name", "plugin.duplicate_name", "plugin.missing_command",
"plugin.command_missing", "plugin.invalid_url", "plugin.invalid_type",
"permissions.conflict", "file.permissions",
"directory.unavailable", "directory.unreadable", "directory.not_directory", "directory.not_writable",
"derived.invalid_json",
"network.invalid_proxy", "network.client_failed", "network.unreachable",
"network.authentication_failed", "network.ok", "network.unexpected_status":
return code
default:
return "unknown"
}
}
// providerSafeScope generalizes a finding scope to a closed vocabulary.
// derived:* names survive because rebuild_derived_state needs the target and
// the suffixes are a fixed enum; provider:*/plugin:* carry user-chosen names
// and collapse to their category; anything unrecognized becomes "other".
func providerSafeScope(scope string) string {
scope = strings.TrimSpace(scope)
switch {
case scope == "":
return ""
case strings.HasPrefix(scope, "derived:"):
switch scope[len("derived:"):] {
case "tabs", "projects", "window", "zoom":
return scope
}
return "derived:other"
case strings.HasPrefix(scope, "provider:"):
return "provider"
case strings.HasPrefix(scope, "plugin:"):
return "plugin"
}
switch scope {
case "global", "project", "runtime", "network", "model", "provider", "plugin", "permissions",
"global config", "credential file", "Reasonix home", "Reasonix state directory", "project root":
return scope
}
return "other"
}
func printPlanPreview(plan repair.RepairPlan, previews []repair.RepairPlanPreview) {
fmt.Println("AI repair plan:", plan.Summary)
for _, preview := range previews {
fmt.Printf(" %d. %s\n", preview.Index, preview.Description)
if preview.Diff != "" {
fmt.Println(preview.Diff)
}
}
}
func confirmPlan() bool {
fmt.Print("Apply this repair plan? [y/N] ")
line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
line = strings.ToLower(strings.TrimSpace(line))
return line == "y" || line == "yes"
}
-246
View File
@@ -1,246 +0,0 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"reasonix/internal/config"
"reasonix/internal/netclient"
"reasonix/internal/repair"
)
func TestRepairPlanProviderConfigPreservesKimiK3Efforts(t *testing.T) {
entry := &config.ProviderEntry{
Name: "opencode-go",
Kind: "openai",
BaseURL: "https://opencode.ai/zen/go/v1",
Model: "kimi-k3",
ReasoningProtocol: config.ReasoningProtocolOpenAI,
SupportedEfforts: []string{"high", "max"},
DefaultEffort: "max",
}
got := repairPlanProviderConfig(entry, netclient.ProxySpec{})
if effort := got.Extra["effort"]; effort != "max" {
t.Fatalf("effort = %#v, want max", effort)
}
if efforts, ok := got.Extra["supported_efforts"].([]string); !ok || !reflect.DeepEqual(efforts, []string{"high", "max"}) {
t.Fatalf("supported_efforts = %#v, want [high max]", got.Extra["supported_efforts"])
}
if protocol := got.Extra["reasoning_protocol"]; protocol != config.ReasoningProtocolOpenAI {
t.Fatalf("reasoning_protocol = %#v, want openai", protocol)
}
}
// TestProviderSafeReportDropsUserControlledContent pins the outbound-privacy
// contract: the payload sent to the AI provider must carry only allowlisted
// codes, severities, and generic scopes — never diagnostic prose, which can
// quote config lines, MCP command lines, permission rules, URLs with
// credentials, emails, tokens, or absolute paths.
func TestProviderSafeReportDropsUserControlledContent(t *testing.T) {
const metadataSecret = "snapshot-metadata-secret"
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
secrets := []string{
"/Users/someone/dotfiles",
"[email protected]",
"sk-live-Abc123Secret",
"npx --api-key=sk-live-Abc123Secret mcp-server",
"bash(rm -rf *)",
"corp-internal-llm",
"https://user:[email protected]:8080",
metadataSecret,
"secret.finding.code",
"secret-severity",
snapshotID,
}
report := repair.DiagnosticReport{
GeneratedAt: metadataSecret,
Root: "/Users/someone/dotfiles",
Network: true,
Snapshots: []repair.DiagnosticSnapshot{
{ID: snapshotID, RecordedAt: metadataSecret, Version: metadataSecret},
{ID: metadataSecret, RecordedAt: metadataSecret, Version: metadataSecret},
},
PendingUpdate: &repair.DiagnosticUpdate{FromVersion: metadataSecret, ToVersion: metadataSecret},
Findings: []repair.DiagnosticFinding{
{Severity: "error", Code: "config.invalid_toml", Scope: "global",
Message: "Configuration cannot be parsed: line 3: api_key = \"sk-live-Abc123Secret\" contact [email protected]"},
{Severity: "warning", Code: "plugin.command_missing", Scope: "plugin:corp-internal-llm",
Message: `MCP command "npx --api-key=sk-live-Abc123Secret mcp-server" was not found.`},
{Severity: "warning", Code: "permissions.conflict", Scope: "permissions",
Message: `Permission rule "bash(rm -rf *)" appears in allow, deny; deny takes precedence.`},
{Severity: "error", Code: "network.invalid_proxy", Scope: "network",
Message: "Proxy configuration is invalid: parse \"https://user:[email protected]:8080\": bad"},
{Severity: "warning", Code: "provider.missing_key", Scope: "provider:corp-internal-llm",
Message: "The configured API key is missing."},
{Severity: "warning", Code: "derived.invalid_json", Scope: "derived:tabs",
Message: "Derived desktop state desktop-tabs.json is malformed."},
{Severity: "secret-severity", Code: "secret.finding.code", Scope: metadataSecret,
Message: metadataSecret, Remediation: metadataSecret},
},
}
safeReport, aliases := providerSafeReportFrom(report)
payload, err := json.Marshal(safeReport)
if err != nil {
t.Fatal(err)
}
body := string(payload)
for _, secret := range secrets {
if strings.Contains(body, secret) {
t.Fatalf("outbound payload leaked %q:\n%s", secret, body)
}
}
for _, want := range []string{
`"root":"\u003cproject\u003e"`, // json.Marshal HTML-escapes <project>
`"code":"config.invalid_toml"`,
`"scope":"provider"`,
`"scope":"plugin"`,
`"scope":"derived:tabs"`,
`"id":"snapshot-1"`,
`"id":"snapshot-2"`,
`"pendingUpdate":true`,
`"severity":"unknown"`,
`"code":"unknown"`,
`"scope":"other"`,
} {
if !strings.Contains(body, want) {
t.Fatalf("outbound payload missing %s:\n%s", want, body)
}
}
if aliases["snapshot-1"] != snapshotID || aliases["snapshot-2"] != metadataSecret {
t.Fatalf("snapshot alias map = %v", aliases)
}
for _, forbiddenField := range []string{"generatedAt", "recordedAt", "version", "fromVersion", "toVersion"} {
if strings.Contains(body, `"`+forbiddenField+`"`) {
t.Fatalf("outbound payload retained free-form field %q:\n%s", forbiddenField, body)
}
}
}
func TestResolveProviderSnapshotAliases(t *testing.T) {
const snapshotID = "20260715T000000.000000000Z-abcdef123456"
plan, err := repair.DecodeRepairPlan([]byte(`{
"schemaVersion": 1,
"summary": "restore newest snapshot",
"actions": [
{"type":"repair_config","scope":"global","reason":"invalid config"},
{"type":"restore_snapshot","snapshotId":"snapshot-1","reason":"known-good config"}
]
}`))
if err != nil {
t.Fatal(err)
}
resolved, err := resolveProviderSnapshotAliases(plan, map[string]string{"snapshot-1": snapshotID})
if err != nil {
t.Fatal(err)
}
if got := resolved.Actions[1].SnapshotID; got != snapshotID {
t.Fatalf("resolved snapshot ID = %q, want %q", got, snapshotID)
}
if got := plan.Actions[1].SnapshotID; got != "snapshot-1" {
t.Fatalf("alias resolution mutated source plan: %q", got)
}
if _, err := resolveProviderSnapshotAliases(plan, nil); err == nil {
t.Fatal("unknown snapshot alias was accepted")
}
}
func writeDerivedStatePlan(t *testing.T) (string, repair.RepairPlan) {
t.Helper()
file := filepath.Join(t.TempDir(), "plan.json")
raw := []byte(`{"schemaVersion":1,"summary":"rebuild tabs","actions":[{"type":"rebuild_derived_state","target":"tabs","reason":"malformed"}]}`)
if err := os.WriteFile(file, raw, 0o600); err != nil {
t.Fatal(err)
}
plan, err := repair.DecodeRepairPlan(raw)
if err != nil {
t.Fatal(err)
}
return file, plan
}
func TestApplyPlanYesRequiresConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, _ := writeDerivedStatePlan(t)
if got := runApplyPlan([]string{"--file", file, "--yes"}); got != 2 {
t.Fatalf("exit code = %d, want missing preview ID usage refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "bad-tabs" {
t.Fatalf("unbound --yes touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanRejectsStateChangedAfterPreview(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("first-state"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if err := os.WriteFile(tabs, []byte("changed-after-preview"), 0o600); err != nil {
t.Fatal(err)
}
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 1 {
t.Fatalf("exit code = %d, want stale preview refusal", got)
}
if got, err := os.ReadFile(tabs); err != nil || string(got) != "changed-after-preview" {
t.Fatalf("stale preview touched derived state: %q, %v", got, err)
}
}
func TestApplyPlanAcceptsCurrentConfirmedPreviewID(t *testing.T) {
home := t.TempDir()
t.Setenv("REASONIX_HOME", home)
tabs := filepath.Join(home, "desktop-tabs.json")
if err := os.WriteFile(tabs, []byte("bad-tabs"), 0o600); err != nil {
t.Fatal(err)
}
file, plan := writeDerivedStatePlan(t)
preview, err := repair.PreviewRepairPlan(plan, repair.ApplyPlanOptions{})
if err != nil {
t.Fatal(err)
}
previewID := repair.RepairPlanPreviewID(plan, preview)
if got := runApplyPlan([]string{"--file", file, "--preview-id", previewID, "--yes"}); got != 0 {
t.Fatalf("exit code = %d, want confirmed preview application", got)
}
if _, err := os.Stat(tabs); !os.IsNotExist(err) {
t.Fatalf("confirmed repair did not quarantine derived state: %v", err)
}
}
func TestProviderSafeScopeClosedVocabulary(t *testing.T) {
cases := map[string]string{
"": "",
"global": "global",
"project": "project",
"derived:tabs": "derived:tabs",
"derived:zoom": "derived:zoom",
"derived:/etc/passwd": "derived:other",
"provider:corp-llm": "provider",
"plugin:internal-mcp": "plugin",
"credential file": "credential file",
"[email protected]": "other",
"/Users/someone/project": "other",
}
for in, want := range cases {
if got := providerSafeScope(in); got != want {
t.Errorf("providerSafeScope(%q) = %q, want %q", in, got, want)
}
}
}
-415
View File
@@ -1,415 +0,0 @@
// Command reasonix-guard diagnoses and repairs Reasonix without loading the
// desktop shell. It is packaged beside the desktop application so recovery
// remains available when Wails, WebView, or user configuration cannot start.
package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"reasonix/internal/repair"
)
var version = "dev"
func main() { os.Exit(run(os.Args[1:])) }
func run(args []string) int {
if len(args) == 0 {
return runLaunch(nil)
}
switch args[0] {
case "check":
return runCheck(args[1:], false)
case "repair":
return runCheck(args[1:], true)
case "launch":
return runLaunch(args[1:])
case "recover":
return runRecover(args[1:])
case "snapshots":
return runSnapshots(args[1:])
case "restore":
return runRestore(args[1:])
case "undo":
return runUndo(args[1:])
case "diagnose":
return runDiagnose(args[1:])
case "rebuild":
return runRebuild(args[1:])
case "assist":
return runAssist(args[1:])
case "apply-plan":
return runApplyPlan(args[1:])
case "version", "--version", "-v":
fmt.Println("reasonix-guard", version)
return 0
case "help", "--help", "-h":
usage()
return 0
default:
if isMacBundleLauncher() {
return runLaunch(append([]string{"--"}, args...))
}
fmt.Fprintln(os.Stderr, "unknown command:", args[0])
usage()
return 2
}
}
func runDiagnose(args []string) int {
fs := flag.NewFlagSet("reasonix-guard diagnose", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
network := fs.Bool("network", false, "probe provider endpoints and credentials")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.Diagnose(context.Background(), repair.DiagnoseOptions{Root: *root, Network: *network})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
if code := printJSON(report); code != 0 {
return code
}
} else {
fmt.Println("Reasonix Guard diagnostics")
if len(report.Findings) == 0 {
fmt.Println(" ok: no issues found")
}
for _, finding := range report.Findings {
fmt.Printf(" %-7s %-32s %s\n", finding.Severity, finding.Code, finding.Message)
}
}
if report.HasErrors() {
return 1
}
return 0
}
func runRebuild(args []string) int {
fs := flag.NewFlagSet("reasonix-guard rebuild", flag.ContinueOnError)
target := fs.String("target", "", "tabs|projects|window|zoom|all")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*target) == "" {
return 2
}
applied, err := repair.RebuildDerivedState(*target)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(applied)
}
for _, path := range applied {
fmt.Println("quarantined:", path)
}
return 0
}
func runSnapshots(args []string) int {
fs := flag.NewFlagSet("reasonix-guard snapshots", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
snapshots, err := repair.ListConfigSnapshots()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(snapshots)
}
for _, snap := range snapshots {
fmt.Printf("%s %s %s\n", snap.ID, snap.Version, snap.RecordedAt)
}
return 0
}
func runRestore(args []string) int {
fs := flag.NewFlagSet("reasonix-guard restore", flag.ContinueOnError)
snapshot := fs.String("snapshot", "", "snapshot id to restore")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 || strings.TrimSpace(*snapshot) == "" {
return 2
}
tx, err := repair.RestoreConfigSnapshot(*snapshot)
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
fmt.Println("restored config snapshot; undo transaction:", tx.ID)
return 0
}
func runUndo(args []string) int {
fs := flag.NewFlagSet("reasonix-guard undo", flag.ContinueOnError)
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
tx, err := repair.UndoLastRepair()
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
return printJSON(tx)
}
fmt.Println("undid repair:", tx.ID)
return 0
}
func printJSON(v any) int {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(v); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func runCheck(args []string, apply bool) int {
name := "reasonix-guard check"
if apply {
name = "reasonix-guard repair"
}
fs := flag.NewFlagSet(name, flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
jsonOut := fs.Bool("json", false, "print JSON")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
report, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: apply, IncludeProject: *includeProject})
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(report); err != nil {
return 1
}
} else {
fmt.Println("Reasonix Guard")
for _, check := range report.Checks {
status := "ok"
if !check.Exists {
status = "missing"
} else if !check.Valid {
status = "invalid"
}
fmt.Printf(" %-8s %-8s %s\n", check.Scope, status, check.Path)
if check.Error != "" {
fmt.Println(" ", check.Error)
}
}
for _, action := range report.Applied {
fmt.Println(" applied:", action)
}
}
for _, check := range report.Checks {
if check.Exists && !check.Valid {
return 1
}
}
return 0
}
// failedInstallBlocksLaunch decides whether startup must stop after a failed
// RecoverFailedInstall. The failure marker means the installer may have left
// the release unit partially replaced, so the binaries cannot be presumed
// coherent until a rollback completes: any error with the restore unfinished
// fails closed. MixedInstall alone is not the signal — a rollback that failed
// before touching anything (e.g. while staging) reports MixedInstall=false yet
// leaves the installer's half-written unit in place. Only a completed restore
// (RolledBack, with just the marker cleanup failing) is safe to launch.
func failedInstallBlocksLaunch(result repair.UpdateRollbackResult, err error) bool {
return err != nil && !result.RolledBack
}
func runLaunch(args []string) int {
fs := flag.NewFlagSet("reasonix-guard launch", flag.ContinueOnError)
app := fs.String("app", "", "desktop executable path")
safeMode := fs.Bool("safe-mode", false, "force Safe Mode")
detach := fs.Bool("detach", packagedDetachedLauncher(), "start the desktop and exit the launcher")
if err := fs.Parse(args); err != nil {
return 2
}
// An update helper that watched the installer fail records a marker (it
// cannot roll back itself from outside the install directory). Restore the
// previous release unit immediately instead of waiting for a crash loop.
if result, failure, err := repair.RecoverFailedInstall(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback after failed install failed:", err)
if failedInstallBlocksLaunch(result, err) {
fmt.Fprintln(os.Stderr, "error: the failed installer may have left a mix of two releases and the rollback did not complete; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
} else if failure != nil && result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after the %s installer failed.\n", result.ToVersion, result.FromVersion)
}
forceSafeMode := false
if result, err := repair.ReconcilePendingUpdate(version); err != nil {
if !errors.Is(err, repair.ErrPendingUpdateAwaitingHealth) {
fmt.Fprintln(os.Stderr, "previous update recovery failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation may mix two releases; refusing to start. Re-run reasonix-guard to retry recovery, or reinstall Reasonix.")
return 1
}
forceSafeMode = true
}
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s before launch after an unfinished %s update.\n", result.FromVersion, result.ToVersion)
} else if result.Cleared {
fmt.Fprintf(os.Stderr, "Reasonix Guard cleared an abandoned %s update before launch.\n", result.ToVersion)
}
tracker := repair.NewStartupTracker("")
useSafeMode := *safeMode || forceSafeMode
if !useSafeMode && tracker.SafeModeRecommended() {
if result, err := repair.RollbackPendingUpdate(); err != nil {
fmt.Fprintln(os.Stderr, "update rollback failed:", err)
if result.MixedInstall {
fmt.Fprintln(os.Stderr, "error: the installation now mixes two releases; refusing to start. Re-run reasonix-guard to retry the rollback, or reinstall Reasonix.")
return 1
}
// The compensated install is still a coherent (new-version) release
// unit, so a Safe Mode boot is a sound fallback while the pending
// transaction stays on disk for the next rollback attempt.
useSafeMode = true
} else if result.RolledBack {
fmt.Fprintf(os.Stderr, "Reasonix Guard restored %s after %s failed to start.\n", result.ToVersion, result.FromVersion)
_ = tracker.MarkClean()
} else {
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: ".", Apply: true}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
}
useSafeMode = true
case recoverySafeMode:
useSafeMode = true
case recoveryQuit:
return 0
}
}
}
path := strings.TrimSpace(*app)
if path == "" {
path = siblingDesktopExecutable()
}
if path == "" {
fmt.Fprintln(os.Stderr, "error: cannot locate reasonix-desktop; pass --app PATH")
return 1
}
childArgs := append([]string(nil), fs.Args()...)
if useSafeMode {
childArgs = append([]string{"--safe-mode"}, childArgs...)
}
cmd := exec.Command(path, childArgs...)
cmd.Stdin, cmd.Stdout, cmd.Stderr = os.Stdin, os.Stdout, os.Stderr
if *detach {
if err := cmd.Start(); err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
if err := cmd.Run(); err != nil {
if exit, ok := err.(*exec.ExitError); ok {
return exit.ExitCode()
}
fmt.Fprintln(os.Stderr, "error:", err)
return 1
}
return 0
}
func packagedDetachedLauncher() bool {
if runtime.GOOS != "windows" {
return false
}
exe, err := os.Executable()
if err != nil {
return false
}
name := strings.ToLower(filepath.Base(exe))
return name == "reasonix-launcher.exe" || name == "reasonix.exe"
}
func runRecover(args []string) int {
fs := flag.NewFlagSet("reasonix-guard recover", flag.ContinueOnError)
root := fs.String("root", ".", "project root to inspect")
includeProject := fs.Bool("project", false, "allow repair to quarantine project reasonix.toml")
app := fs.String("app", "", "desktop executable path")
if err := fs.Parse(args); err != nil || fs.NArg() != 0 {
return 2
}
switch nativeRecoveryChoice() {
case recoveryRepair:
if _, err := repair.InspectAndRepairConfig(repair.ConfigOptions{Root: *root, Apply: true, IncludeProject: *includeProject}); err != nil {
fmt.Fprintln(os.Stderr, "repair failed:", err)
return 1
}
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
case recoverySafeMode:
launch := []string{"--safe-mode"}
if strings.TrimSpace(*app) != "" {
launch = append(launch, "--app", *app)
}
return runLaunch(launch)
default:
return 0
}
}
func siblingDesktopExecutable() string {
exe, err := os.Executable()
if err != nil {
return ""
}
name := "reasonix-desktop"
if runtime.GOOS == "windows" {
name += ".exe"
}
candidates := []string{filepath.Join(filepath.Dir(exe), name)}
if runtime.GOOS == "darwin" {
candidates = append(candidates, filepath.Clean(filepath.Join(filepath.Dir(exe), "..", "MacOS", name)))
}
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
return path
}
}
return ""
}
func isMacBundleLauncher() bool {
if runtime.GOOS != "darwin" {
return false
}
exe, err := os.Executable()
return err == nil && strings.Contains(filepath.ToSlash(exe), ".app/Contents/MacOS/")
}
func usage() {
fmt.Println("usage: reasonix-guard <check|repair|diagnose|rebuild|assist|apply-plan|launch|recover|snapshots|restore|undo> [options]")
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"errors"
"os"
"path/filepath"
"testing"
"reasonix/internal/repair"
)
func TestCheckReportsInvalidProjectConfig(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "reasonix.toml"), []byte("[broken\n"), 0o600); err != nil {
t.Fatal(err)
}
if code := run([]string{"check", "--root", root, "--json"}); code != 1 {
t.Fatalf("exit code = %d, want 1", code)
}
}
func TestFailedInstallBlocksLaunch(t *testing.T) {
cases := []struct {
name string
result repair.UpdateRollbackResult
err error
want bool
}{
{name: "no error never blocks", result: repair.UpdateRollbackResult{}, err: nil, want: false},
{name: "incomplete rollback fails closed", result: repair.UpdateRollbackResult{}, err: errors.New("stage failed"), want: true},
{name: "uncompensated rollback fails closed", result: repair.UpdateRollbackResult{MixedInstall: true}, err: errors.New("restore failed"), want: true},
{name: "completed restore with marker cleanup error launches", result: repair.UpdateRollbackResult{RolledBack: true}, err: errors.New("remove marker: permission denied"), want: false},
}
for _, tc := range cases {
if got := failedInstallBlocksLaunch(tc.result, tc.err); got != tc.want {
t.Errorf("%s: failedInstallBlocksLaunch = %v, want %v", tc.name, got, tc.want)
}
}
}
-9
View File
@@ -1,9 +0,0 @@
package main
type recoveryChoice int
const (
recoveryQuit recoveryChoice = iota
recoverySafeMode
recoveryRepair
)
-24
View File
@@ -1,24 +0,0 @@
//go:build darwin
package main
import (
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
script := `button returned of (display dialog "Reasonix failed to start repeatedly. You can repair damaged configuration or start with external integrations disabled." with title "Reasonix Recovery" buttons {"Quit", "Safe Mode", "Repair and Start"} default button "Repair and Start" with icon caution)`
out, err := exec.Command("osascript", "-e", script).Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Repair and Start":
return recoveryRepair
case "Safe Mode":
return recoverySafeMode
default:
return recoveryQuit
}
}
-36
View File
@@ -1,36 +0,0 @@
//go:build linux
package main
import (
"errors"
"os/exec"
"strings"
)
func nativeRecoveryChoice() recoveryChoice {
if path, err := exec.LookPath("zenity"); err == nil {
cmd := exec.Command(path, "--question", "--title=Reasonix Recovery",
"--text=Reasonix failed to start repeatedly.",
"--ok-label=Repair and Start", "--cancel-label=Safe Mode", "--extra-button=Quit")
out, err := cmd.Output()
if strings.TrimSpace(string(out)) == "Quit" {
return recoveryQuit
}
if err == nil {
return recoveryRepair
}
return recoverySafeMode
}
if path, err := exec.LookPath("kdialog"); err == nil {
err := exec.Command(path, "--warningyesnocancel", "Reasonix failed to start repeatedly. Repair configuration and start?", "--title", "Reasonix Recovery").Run()
if err == nil {
return recoveryRepair
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == 2 {
return recoveryQuit
}
}
return recoverySafeMode
}
-5
View File
@@ -1,5 +0,0 @@
//go:build !darwin && !windows && !linux
package main
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
-27
View File
@@ -1,27 +0,0 @@
//go:build windows
package main
import (
"os/exec"
"strings"
"syscall"
)
func nativeRecoveryChoice() recoveryChoice {
script := `Add-Type -AssemblyName PresentationFramework; $r=[System.Windows.MessageBox]::Show('Reasonix failed to start repeatedly. Yes: repair configuration and start. No: start in Safe Mode. Cancel: quit.','Reasonix Recovery','YesNoCancel','Warning'); Write-Output $r`
cmd := exec.Command("powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script)
cmd.SysProcAttr = &syscall.SysProcAttr{HideWindow: true}
out, err := cmd.Output()
if err != nil {
return recoverySafeMode
}
switch strings.TrimSpace(string(out)) {
case "Yes":
return recoveryRepair
case "No":
return recoverySafeMode
default:
return recoveryQuit
}
}
+58 -37
View File
@@ -273,60 +273,49 @@ FunctionEnd
Section
!insertmacro wails.setShellContext
; Current auto-update helpers extract the signed installer payload into a
; unique temporary directory, then compare-and-publish the exact claimed
; release unit. Legacy helpers omit /REASONIXSTAGE and retain the historical
; in-place update behavior required to upgrade into the hardened release.
StrCmp $ReasonixStageMode "1" reasonix_copy_payload
; /REASONIXSTAGE=1: flat six-member payload for 1.18–1.19.1 helpers (and
; the new helper's staging extract). Do not write shortcuts/uninstaller.
; Normal install: versioned-v1 layout under versions/v${INFO_PRODUCTVERSION}/
; with a permanent thin launcher at InstallRoot. Guard is only present in
; STAGE payloads (as the one-shot legacy migrator) and is not persisted on
; a normal install.
StrCmp $ReasonixStageMode "1" reasonix_stage_payload
!insertmacro wails.webview2runtime
Call reasonix.waitForExecutableUnlock
Goto reasonix_normal_install
reasonix_copy_payload:
reasonix_stage_payload:
SetOutPath $INSTDIR
; The release workflow embeds a minisign-authenticated manifest after
; SignPath has signed the five executable members. Only the extraction
; handoff needs these files; a normal installation does not persist them.
StrCmp $ReasonixStageMode "1" 0 reasonix_payload_metadata_done
!if /FileExists "${REASONIX_PAYLOAD_MANIFEST}"
File "/oname=${REASONIX_PAYLOAD_MANIFEST}" "${REASONIX_PAYLOAD_MANIFEST}"
!endif
!if /FileExists "${REASONIX_PAYLOAD_SIGNATURE}"
File "/oname=${REASONIX_PAYLOAD_SIGNATURE}" "${REASONIX_PAYLOAD_SIGNATURE}"
!endif
reasonix_payload_metadata_done:
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_GUARD}"
File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"
!endif
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done
; Portable archives expose Reasonix.exe as a second copy of the Guard
; launcher. Preserve that layout only when upgrading an existing portable
; directory, and keep the alias in lockstep with the canonical launcher.
IfFileExists "$INSTDIR\${REASONIX_PORTABLE_ENTRY}" 0 reasonix_no_portable_entry
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
reasonix_no_portable_entry:
; Keep the Guard launcher as the target while taking the visible shortcut
; icon from the Wails application. The launcher embeds the same icon too,
; but an explicit icon source prevents stale/generic taskbar pins after an
; in-place upgrade from a launcher build that had no Windows resources.
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0
reasonix_launcher_payload_done:
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
Goto reasonix_section_done
reasonix_normal_install:
; Versioned layout: InstallRoot/{launcher,cli,current.json,versions/vX/...}
CreateDirectory "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"
SetOutPath "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"
!insertmacro wails.files
!if /FileExists "${REASONIX_UPDATE_HELPER}"
File "/oname=${REASONIX_UPDATE_HELPER}" "${REASONIX_UPDATE_HELPER}"
!else
StrCmp $ReasonixStageMode "1" reasonix_launcher_payload_done_no_launcher
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${PRODUCT_EXECUTABLE}"
reasonix_launcher_payload_done_no_launcher:
!warning "${REASONIX_UPDATE_HELPER} was not found; Windows auto-update will fail safely until the helper is installed."
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
@@ -334,11 +323,41 @@ reasonix_launcher_payload_done_no_launcher:
!warning "${REASONIX_CLI} was not found; remote upload installation will be unavailable."
!endif
StrCmp $ReasonixStageMode "1" reasonix_section_done
SetOutPath $INSTDIR
!if /FileExists "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_LAUNCHER}" "${REASONIX_LAUNCHER}"
File "/oname=${REASONIX_PORTABLE_ENTRY}" "${REASONIX_LAUNCHER}"
!endif
!if /FileExists "${REASONIX_CLI}"
File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"
!endif
; current.json schema 1 — activeVersion uses the numeric product version
; with a leading v, matching installlayout.ValidateVersionName.
FileOpen $0 "$INSTDIR\current.json" w
FileWrite $0 "{$\r$\n"
FileWrite $0 ' "schemaVersion": 1,$\r$\n'
FileWrite $0 ' "activeVersion": "v${INFO_PRODUCTVERSION}",$\r$\n'
FileWrite $0 ' "activeDir": "versions/v${INFO_PRODUCTVERSION}"$\r$\n'
FileWrite $0 "}$\r$\n"
FileClose $0
; Remove flat leftovers from prior 1.18–1.19 installs when overwriting.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
!if /FileExists "${REASONIX_LAUNCHER}"
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0
!else
CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}"
!endif
!insertmacro wails.associateFiles
!insertmacro wails.associateCustomProtocols
!insertmacro reasonix.writeUninstaller
reasonix_section_done:
SectionEnd
@@ -347,13 +366,15 @@ Section "uninstall"
RMDir /r "$AppData\${PRODUCT_EXECUTABLE}" # Remove the WebView2 DataPath
; Precision uninstall: delete main application files
; Precision uninstall: flat leftovers, thin entry points, and version trees.
Delete "$INSTDIR\${PRODUCT_EXECUTABLE}"
Delete "$INSTDIR\${REASONIX_UPDATE_HELPER}"
Delete "$INSTDIR\${REASONIX_GUARD}"
Delete "$INSTDIR\${REASONIX_LAUNCHER}"
Delete "$INSTDIR\${REASONIX_CLI}"
Delete "$INSTDIR\${REASONIX_PORTABLE_ENTRY}"
Delete "$INSTDIR\current.json"
RMDir /r "$INSTDIR\versions"
Delete "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk"
Delete "$DESKTOP\${INFO_PRODUCTNAME}.lnk"
+69 -18
View File
@@ -87,6 +87,24 @@ func run(args []string) int {
return 1
}
}
// Resolve claim targets from the prepared pending transaction so versioned
// layouts (desktop under versions/<ver>/) and flat layouts share one path.
pending, err := repair.ReadPendingUpdate()
if err != nil {
logger.Printf("read pending update: %v", err)
return 1
}
if strings.TrimSpace(pending.ToVersion) != strings.TrimSpace(toVersion) ||
strings.TrimSpace(pending.CreatedAt) != strings.TrimSpace(createdAt) ||
repair.UpdateTransactionID(pending) != strings.TrimSpace(transactionID) {
logger.Print("pending update identity does not match helper arguments")
return 1
}
claimTargets := make([]string, 0, len(pending.Files)+1)
claimTargets = append(claimTargets, pending.TargetPath)
for _, f := range pending.Files {
claimTargets = append(claimTargets, f.TargetPath)
}
// The old desktop may acquire the pending lock during its normal shutdown.
// Wait for that exact process first, then claim the transaction and hold both
// pending and target locks across the installer replacement window.
@@ -94,8 +112,8 @@ func run(args []string) int {
toVersion,
createdAt,
transactionID,
filepath.Join(installDir, "reasonix-desktop.exe"),
windowsReleaseUnitPaths(installDir),
pending.TargetPath,
claimTargets,
parentExitTimeout,
)
if err != nil {
@@ -161,29 +179,41 @@ func run(args []string) int {
}
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after publish failure: %v", relaunchErr)
}
}
return 1
}
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
if receipts == nil {
// Versioned-v1 activation: no flat publish receipts and no health
// probation. Clear the pending transaction and relaunch the launcher.
releaseClaim()
if cancelErr := repair.CancelPendingUpdateExact(claimed); cancelErr != nil {
logger.Printf("clear pending after versioned activate: %v", cancelErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(relaunch, installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
if clearErr := repair.ClearUpdateApplyFailureExact(claimed); clearErr != nil {
logger.Printf("clear apply failure after versioned activate: %v", clearErr)
}
} else {
if _, err := recordInstalledUpdateFn(claimed, receipts...); err != nil {
logger.Printf("record installed release unit: %v", err)
if markErr := repair.MarkUpdateApplyFailedExact(claimed, err.Error()); markErr != nil {
logger.Printf("record install failure: %v", markErr)
}
if relaunch != "" {
if relaunchErr := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); relaunchErr != nil {
logger.Printf("relaunch after failed install verification: %v", relaunchErr)
}
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
return 1
}
if err := repair.ClearUpdateApplyFailureExact(claimed); err != nil {
logger.Printf("clear completed update marker: %v", err)
}
if relaunch != "" {
if err := startRelaunchFn(relaunch, installDir); err != nil {
if err := startRelaunchFn(preferRelaunchPath(relaunch, installDir), installDir); err != nil {
logger.Printf("relaunch: %v", err)
return 1
}
@@ -191,6 +221,18 @@ func run(args []string) int {
return 0
}
// preferRelaunchPath chooses the thin launcher when present so post-update
// restarts use the permanent entry point, not a flat desktop binary.
func preferRelaunchPath(relaunch, installDir string) string {
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe"} {
path := filepath.Join(installDir, name)
if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() {
return path
}
}
return relaunch
}
func validSHA256(value string) bool {
value = strings.TrimSpace(value)
if len(value) != sha256.Size*2 {
@@ -328,13 +370,22 @@ func installStagedWindowsReleaseUnit(
claimed *repair.UpdateTransaction,
stagingDir string,
) (bool, []repair.FileUpdateInstallReceipt, error) {
// Prefer the v1.20+ versioned layout. On success receipts are nil and the
// caller clears the pending transaction without flat-file publish records.
if preferVersionedWindowsActivation(stagingDir) {
if err := activateVersionedWindowsFromStaging(claimed, stagingDir); err != nil {
return false, nil, fmt.Errorf("versioned activate: %w", err)
}
return true, nil, nil
}
// Legacy flat release-unit path for incomplete staged payloads.
members, err := loadWindowsStagedReleaseUnit(claimed, stagingDir)
if err != nil {
return false, nil, err
}
// Persist recovery intent before the first live rename. If the helper exits
// between release-unit members, Guard rolls the exact pending transaction
// back instead of launching a mixed install.
// Persist recovery intent before the first live rename so a mid-publish
// crash can be reconciled from the pending transaction.
if err := repair.MarkUpdateApplyFailedExact(claimed, "Windows update publish did not complete"); err != nil {
return false, nil, fmt.Errorf("record update recovery intent: %w", err)
}
@@ -0,0 +1,140 @@
//go:build windows
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// activateVersionedWindowsFromStaging publishes the versioned-v1 layout from a
// staged NSIS payload:
//
// InstallRoot/
// reasonix-launcher.exe
// Reasonix.exe (launcher alias when present or portable)
// reasonix-cli.exe (CLI entry; full binary for now)
// current.json
// versions/<version>/
// reasonix-desktop.exe
// reasonix-cli.exe
// reasonix-update-helper.exe
//
// Any failure before the current.json pointer swap leaves the previous active
// version unchanged. The helper never counts crashes or selects prior versions.
func activateVersionedWindowsFromStaging(claimed *repair.UpdateTransaction, stagingDir string) error {
if claimed == nil {
return fmt.Errorf("versioned activate: transaction is nil")
}
installRoot := filepath.Clean(strings.TrimSpace(filepath.Dir(claimed.TargetPath)))
// When the claimed primary is already under versions/<ver>/, climb to root.
if root, err := installlayout.ResolveInstallRoot(claimed.TargetPath); err == nil && root != "" {
installRoot = root
}
version := strings.TrimSpace(claimed.ToVersion)
if err := installlayout.ValidateVersionName(version); err != nil {
// Accept bare product versions from NSIS (1.20.0 → v1.20.0).
if !strings.HasPrefix(version, "v") {
version = "v" + version
}
if err := installlayout.ValidateVersionName(version); err != nil {
return fmt.Errorf("versioned activate: %w", err)
}
}
stagingDir = filepath.Clean(strings.TrimSpace(stagingDir))
desktopSrc := filepath.Join(stagingDir, "reasonix-desktop.exe")
cliSrc := filepath.Join(stagingDir, "reasonix-cli.exe")
helperSrc := filepath.Join(stagingDir, "reasonix-update-helper.exe")
launcherSrc := filepath.Join(stagingDir, "reasonix-launcher.exe")
for _, path := range []string{desktopSrc, cliSrc, helperSrc, launcherSrc} {
info, err := os.Lstat(path)
if err != nil {
return fmt.Errorf("versioned activate: staged %s: %w", filepath.Base(path), err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("versioned activate: staged %s is not a regular file", filepath.Base(path))
}
}
requestID := repair.UpdateTransactionID(claimed)
if requestID == "" {
requestID = "helper-" + version
}
if err := installlayout.ActivateVersion(installlayout.ActivationRequest{
InstallRoot: installRoot,
Version: version,
RequestID: requestID,
Members: []installlayout.Member{
{Name: "reasonix-desktop.exe", Path: desktopSrc, Mode: 0o700},
{Name: "reasonix-cli.exe", Path: cliSrc, Mode: 0o700},
{Name: "reasonix-update-helper.exe", Path: helperSrc, Mode: 0o700},
},
}); err != nil {
return err
}
// Root entry points: thin launcher + portable alias + CLI forward copy.
if err := copyRegularFile(launcherSrc, filepath.Join(installRoot, "reasonix-launcher.exe"), 0o700); err != nil {
return fmt.Errorf("versioned activate: install launcher: %w", err)
}
// Always refresh Reasonix.exe as the portable/start-menu alias of the launcher.
if err := copyRegularFile(launcherSrc, filepath.Join(installRoot, "Reasonix.exe"), 0o700); err != nil {
return fmt.Errorf("versioned activate: install portable alias: %w", err)
}
if err := copyRegularFile(cliSrc, filepath.Join(installRoot, "reasonix-cli.exe"), 0o700); err != nil {
return fmt.Errorf("versioned activate: install CLI entry: %w", err)
}
// Remove flat release-unit leftovers so the install root is the thin layout.
// Do not remove the launcher/CLI/alias we just wrote.
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-guard.exe",
"reasonix-update-helper.exe", // helper lives only under versions/
} {
_ = os.Remove(filepath.Join(installRoot, name))
}
// Best-effort retention GC of older version trees.
_ = installlayout.RetainPreviousVersions(installRoot, 0)
_ = installlayout.CleanupStaleStaging(installRoot, 0)
return nil
}
func copyRegularFile(src, dst string, mode os.FileMode) error {
data, err := os.ReadFile(src)
if err != nil {
return err
}
tmp := dst + ".tmp-activate"
if err := os.WriteFile(tmp, data, mode); err != nil {
return err
}
if err := os.Rename(tmp, dst); err != nil {
_ = os.Remove(tmp)
return err
}
return nil
}
// preferVersionedWindowsActivation reports whether the staged payload is
// complete enough for versioned-v1 activation.
func preferVersionedWindowsActivation(stagingDir string) bool {
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
info, err := os.Lstat(filepath.Join(stagingDir, name))
if err != nil || !info.Mode().IsRegular() {
return false
}
}
return true
}
@@ -0,0 +1,129 @@
//go:build windows
package main
import (
"os"
"path/filepath"
"testing"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
func TestPreferVersionedWindowsActivation(t *testing.T) {
staging := t.TempDir()
if preferVersionedWindowsActivation(staging) {
t.Fatal("empty staging must not prefer versioned")
}
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte(name), 0o700); err != nil {
t.Fatal(err)
}
}
if !preferVersionedWindowsActivation(staging) {
t.Fatal("complete staging must prefer versioned")
}
}
func TestActivateVersionedWindowsFromStaging(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("payload:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Seed a flat desktop so cleanup is observable.
if err := os.WriteFile(filepath.Join(installDir, "reasonix-desktop.exe"), []byte("old"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(installDir, "reasonix-guard.exe"), []byte("old-guard"), 0o700); err != nil {
t.Fatal(err)
}
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
if err := activateVersionedWindowsFromStaging(claimed, staging); err != nil {
t.Fatal(err)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("current.json missing")
}
ptr, err := installlayout.ReadCurrent(installDir)
if err != nil || ptr.ActiveVersion != "v1.20.0" {
t.Fatalf("pointer=%+v err=%v", ptr, err)
}
desktop, err := installlayout.ActiveDesktopPath(installDir)
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(desktop)
if err != nil || string(raw) != "payload:reasonix-desktop.exe" {
t.Fatalf("desktop payload = %q err=%v", raw, err)
}
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-cli.exe"} {
if _, err := os.Stat(filepath.Join(installDir, name)); err != nil {
t.Fatalf("root entry %s: %v", name, err)
}
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-desktop.exe")); !os.IsNotExist(err) {
t.Fatal("flat desktop should be removed")
}
if _, err := os.Stat(filepath.Join(installDir, "reasonix-guard.exe")); !os.IsNotExist(err) {
t.Fatal("flat guard should be removed")
}
if prefer := preferRelaunchPath("", installDir); filepath.Base(prefer) != "reasonix-launcher.exe" {
t.Fatalf("prefer relaunch = %s", prefer)
}
}
func TestInstallStagedWindowsReleaseUnitPrefersVersioned(t *testing.T) {
installDir := t.TempDir()
staging := t.TempDir()
for _, name := range []string{
"reasonix-desktop.exe",
"reasonix-cli.exe",
"reasonix-update-helper.exe",
"reasonix-launcher.exe",
"reasonix-guard.exe",
} {
if err := os.WriteFile(filepath.Join(staging, name), []byte("p:"+name), 0o700); err != nil {
t.Fatal(err)
}
}
// Minimal claimed unit (versioned path does not need full flat file list).
claimed := &repair.UpdateTransaction{
SchemaVersion: 1,
ToVersion: "v1.20.0",
TargetKind: "file",
TargetPath: filepath.Join(installDir, "reasonix-desktop.exe"),
CreatedAt: "2026-01-01T00:00:00Z",
}
started, receipts, err := installStagedWindowsReleaseUnit(claimed, staging)
if err != nil {
t.Fatal(err)
}
if !started || receipts != nil {
t.Fatalf("started=%v receipts=%v want started with nil receipts", started, receipts)
}
if !installlayout.HasCurrent(installDir) {
t.Fatal("versioned activation did not write current.json")
}
}
+13 -4
View File
@@ -77,6 +77,8 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`CLINAME="reasonix"`,
`WINDOWS_CLINAME="reasonix-cli"`,
`./cmd/reasonix`,
`./cmd/reasonix-legacy-migrator`,
`./cmd/reasonix-launcher`,
`cp "$guard_out" "$app/Contents/MacOS/$GUARDNAME"`,
`cp "$cli_out" "$app/Contents/MacOS/$CLINAME"`,
`[ "$bundle_executable" = "$BINNAME" ]`,
@@ -86,8 +88,9 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`cmp -s "$darwin_icon" "$app/Contents/Resources/$bundle_icon"`,
`Contents/Resources/$bundle_icon`,
`-H windowsgui`,
`stamp_windows_executable "$guard_out" "Reasonix Guard"`,
`stamp_windows_executable "$guard_out" "Reasonix Legacy Migrator"`,
`stamp_windows_executable "$launcher_out" "Reasonix Launcher"`,
`Exec=reasonix-launcher`,
`stamp_windows_executable "build/windows/installer/$UPDATE_HELPER" "Reasonix Update Helper"`,
`payload_dir="$ROOT/desktop/build/windows/signing-payload"`,
`cp "build/bin/$BINNAME.exe" "$payload_dir/$BINNAME.exe"`,
@@ -207,11 +210,17 @@ func TestDesktopPackagesPreserveNativePlatformLaunchers(t *testing.T) {
`File "/oname=${REASONIX_CLI}" "${REASONIX_CLI}"`,
`!uninstfinalize 'cmd.exe /C copy /Y "%1" "reasonix-uninstall.exe" >NUL'`,
`File "/oname=uninstall.exe" "${ARG_REASONIX_SIGNED_UNINSTALLER}"`,
`CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0`,
`CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "launch --detach" "$INSTDIR\${PRODUCT_EXECUTABLE}" 0`,
// Normal install writes versioned-v1 layout + thin launcher shortcuts.
`CreateDirectory "$INSTDIR\versions\v${INFO_PRODUCTVERSION}"`,
`FileWrite $0 ' "schemaVersion": 1,$\r$\n'`,
`CreateShortcut "$SMPROGRAMS\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`,
`CreateShortCut "$DESKTOP\${INFO_PRODUCTNAME}.lnk" "$INSTDIR\${REASONIX_LAUNCHER}" "" "$INSTDIR\versions\v${INFO_PRODUCTVERSION}\${PRODUCT_EXECUTABLE}" 0`,
// STAGE mode still ships the six-member flat payload for old helpers.
`StrCmp $ReasonixStageMode "1" reasonix_stage_payload`,
`File "/oname=${REASONIX_GUARD}" "${REASONIX_GUARD}"`,
} {
if !strings.Contains(windows, want) {
t.Errorf("Windows installer missing guard shortcut contract %q", want)
t.Errorf("Windows installer missing versioned-layout contract %q", want)
}
}
}
+72 -17
View File
@@ -1203,14 +1203,17 @@ func currentExecutablePath() string {
return exe
}
// currentInstallDir is the directory of the running executable — the location a
// Windows update must overwrite. Empty when it can't be resolved, in which case
// the installer falls back to its own InstallDir logic.
// currentInstallDir is the InstallRoot for updates. For the versioned layout it
// is the directory that owns current.json (not versions/<ver>/). For flat
// installs it is the directory of the running executable.
func currentInstallDir() string {
exe := currentExecutablePath()
if exe == "" {
return ""
}
if root, err := installlayout.ResolveInstallRoot(exe); err == nil && root != "" {
return root
}
return filepath.Dir(exe)
}
@@ -1233,8 +1236,27 @@ func releaseUnitPathsFor(dir, goos string) []string {
if dir == "" {
return nil
}
// Versioned-v1 layout: primary is the active desktop under versions/.
if goos == "windows" && installlayout.HasCurrent(dir) {
paths := make([]string, 0, 6)
if desktop, err := installlayout.ActiveDesktopPath(dir); err == nil {
paths = append(paths, desktop)
} else {
paths = append(paths, filepath.Join(dir, "reasonix-desktop.exe"))
}
if helper, err := installlayout.ActiveUpdateHelperPath(dir); err == nil {
paths = append(paths, helper)
}
if cli, err := installlayout.ActiveCLIPath(dir); err == nil {
paths = append(paths, cli)
}
for _, name := range []string{"reasonix-launcher.exe", "reasonix-cli.exe", "Reasonix.exe"} {
paths = append(paths, filepath.Join(dir, name))
}
return paths
}
names := updateSiblingNames(goos)
paths := make([]string, 0, len(names))
paths := make([]string, 0, len(names)+1)
switch goos {
case "linux":
paths = append(paths, filepath.Join(dir, "reasonix-desktop"))
@@ -1253,6 +1275,8 @@ func releaseUnitPathsFor(dir, goos string) []string {
func updateSiblingNames(goos string) []string {
switch goos {
case "windows":
// Legacy flat release unit. reasonix-guard.exe may still exist on disk
// during migration from 1.18–1.19.1; the new layout omits it.
return []string{"reasonix-guard.exe", "reasonix-launcher.exe", "reasonix-update-helper.exe", "reasonix-cli.exe", "Reasonix.exe"}
case "linux":
return []string{"reasonix-guard", "reasonix"}
@@ -1261,21 +1285,40 @@ func updateSiblingNames(goos string) []string {
}
}
// relaunchThroughGuard starts the packaged launcher so the replacement build is
// covered by the same crash-loop and rollback policy as a normal app launch.
// relaunchThroughGuard starts the permanent thin launcher (or falls back to the
// running executable). The function name is historical; Guard recovery policy
// is gone as of v1.20.
func relaunchThroughGuard() error {
exe, err := os.Executable()
if err != nil {
return err
}
launcher := filepath.Join(filepath.Dir(exe), "reasonix-guard")
root := filepath.Dir(exe)
if resolved, err := installlayout.ResolveInstallRoot(exe); err == nil && resolved != "" {
root = resolved
}
candidates := []string{
filepath.Join(root, "reasonix-launcher"),
filepath.Join(root, "Reasonix.exe"),
filepath.Join(root, "reasonix-guard"), // migration window only
}
if runtime.GOOS == "windows" {
launcher += ".exe"
candidates[0] += ".exe"
candidates[2] += ".exe"
}
if _, statErr := os.Stat(launcher); statErr != nil {
launcher = exe
launcher := exe
for _, path := range candidates {
if _, err := os.Stat(path); err == nil {
launcher = path
break
}
}
cmd := exec.Command(launcher, "launch", "--detach")
args := []string{}
// Only legacy guard understands "launch --detach"; the thin launcher strips it.
if strings.Contains(strings.ToLower(filepath.Base(launcher)), "guard") {
args = []string{"launch", "--detach"}
}
cmd := exec.Command(launcher, args...)
cmd.Stdout, cmd.Stderr, cmd.Stdin = os.Stdout, os.Stderr, os.Stdin
return cmd.Start()
}
@@ -1285,14 +1328,26 @@ func currentLauncherPath() string {
if exe == "" {
return ""
}
name := "reasonix-guard"
if runtime.GOOS == "windows" {
name = "reasonix-launcher.exe"
root := filepath.Dir(exe)
if resolved, err := installlayout.ResolveInstallRoot(exe); err == nil && resolved != "" {
root = resolved
}
launcher := filepath.Join(filepath.Dir(exe), name)
if _, err := os.Stat(launcher); err == nil {
return launcher
for _, name := range []string{"reasonix-launcher.exe", "Reasonix.exe", "reasonix-launcher", "reasonix-guard.exe", "reasonix-guard"} {
if runtime.GOOS != "windows" && strings.HasSuffix(name, ".exe") {
continue
}
if runtime.GOOS == "windows" && !strings.HasSuffix(name, ".exe") && name != "Reasonix.exe" {
// Unix names on Windows are unused.
if !strings.HasSuffix(name, ".exe") {
continue
}
}
path := filepath.Join(root, name)
if _, err := os.Stat(path); err == nil {
return path
}
}
// Fall through to previous flat-dir behavior for incomplete installs.
if runtime.GOOS == "windows" {
guard := filepath.Join(filepath.Dir(exe), "reasonix-guard.exe")
if _, err := os.Stat(guard); err == nil {
+15 -3
View File
@@ -21,9 +21,19 @@ import (
"golang.org/x/sys/windows"
"reasonix/internal/installlayout"
"reasonix/internal/repair"
)
// resolveWindowsUpdateHelperSource finds the on-disk helper for the running
// install: versioned active dir first, then the flat InstallRoot layout.
func resolveWindowsUpdateHelperSource(installDir string) string {
if path, err := installlayout.ActiveUpdateHelperPath(installDir); err == nil {
return path
}
return filepath.Join(installDir, windowsUpdateHelperFileName)
}
const windowsUpdateHelperFileName = "reasonix-update-helper.exe"
var claimWindowsUpdateHelperExecutionFn = claimVerifiedWindowsUpdateHelperExecution
@@ -89,9 +99,10 @@ func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, insta
if prepared == nil || prepared.TargetKind != "file" || repair.UpdateTransactionID(prepared) == "" {
return "", fmt.Errorf("prepare Windows update helper: transaction identity is incomplete")
}
helperPath := filepath.Clean(filepath.Join(installDir, windowsUpdateHelperFileName))
// Match by basename so versioned layouts (versions/<ver>/helper) and flat
// layouts (InstallRoot/helper) share one prepare path.
for _, file := range prepared.Files {
if !strings.EqualFold(filepath.Clean(file.TargetPath), helperPath) {
if !strings.EqualFold(filepath.Base(file.TargetPath), windowsUpdateHelperFileName) {
continue
}
if file.MissingBefore || !validWindowsSHA256(file.SHA256) {
@@ -99,6 +110,7 @@ func preparedWindowsUpdateHelperSHA256(prepared *repair.UpdateTransaction, insta
}
return strings.TrimSpace(file.SHA256), nil
}
_ = installDir
return "", fmt.Errorf("prepare Windows update helper: helper is outside the prepared release unit")
}
@@ -112,7 +124,7 @@ func validWindowsSHA256(value string) bool {
}
func prepareWindowsUpdateHelper(installDir, preparedSHA256 string) (string, [sha256.Size]byte, error) {
src := filepath.Join(installDir, windowsUpdateHelperFileName)
src := resolveWindowsUpdateHelperSource(installDir)
data, err := os.ReadFile(src)
if err != nil {
return "", [sha256.Size]byte{}, err
+40 -123
View File
@@ -1,138 +1,55 @@
# Recovery and Safe Mode
# Recovery and diagnostics (v1.20+)
Reasonix includes a small recovery executable, `reasonix-guard`, that does not
load Wails, WebView, plugins, MCP servers, hooks, bots, or session transcripts.
It remains usable when the desktop shell or a TOML configuration cannot start.
Reasonix no longer ships a product `reasonix-guard` recovery shell. Crash
records, pending-update state, and configuration problems do not change the
next launch into a global Safe Mode.
## Commands
## Prefer these tools
```bash
reasonix-guard check [--root PATH] [--json]
reasonix-guard repair [--root PATH] [--project] [--json]
reasonix-guard diagnose [--root PATH] [--network] [--json]
reasonix-guard rebuild --target tabs|projects|window|zoom|all
reasonix-guard snapshots [--json]
reasonix-guard restore --snapshot ID
reasonix-guard undo [--json]
reasonix-guard launch [--app PATH] [--safe-mode] [--detach]
reasonix-guard recover [--root PATH] [--project]
reasonix-guard assist [--model PROVIDER/MODEL] [--apply] [--allow-project]
reasonix-guard apply-plan --file PLAN.json [--preview-id ID] [--yes] [--allow-project]
reasonix doctor repair [--root PATH] [--apply] [--project] [--json]
```text
reasonix doctor
reasonix doctor repair
reasonix crash report # when available in your build
```
Windows and Linux packaged desktop shortcuts start through Guard. The macOS
application bundle starts the Wails desktop directly so LaunchServices, the Dock,
and the application window have the same native process identity; it runs the
same startup recovery preflight before creating WebView. Guard remains bundled
as an independent recovery command. Running `reasonix-guard` without a subcommand
launches the sibling desktop executable; use the explicit `check` command for a
read-only configuration check. Windows packages use the same Guard code in a
GUI-subsystem launcher for shortcuts and retain `reasonix-guard.exe` as the
terminal-oriented command.
Windows and Linux shortcuts detach after starting the desktop; an explicit
terminal `reasonix-guard launch` waits by default unless `--detach` is supplied.
- **doctor** inspects configuration, derived desktop state, and common install
problems without loading the Wails shell.
- **doctor repair** applies safe, explicit repairs the user opts into.
- Crash reports remain opt-in and never force a degraded product mode.
`check` and `doctor repair` are read-only unless `repair` or `--apply` is used.
An applied repair renames malformed TOML to a timestamped
`.reasonix-quarantine-*` file. A malformed global config is then restored from
the last-known-good snapshot recorded after a successful desktop startup. The
credential `.env`, session JSONL files, and project source files are never
deleted. Project `reasonix.toml` is only quarantined when `--project` is given.
## Install layout (v1.20+)
Guard retains the five newest healthy global-config snapshots. Each snapshot has
a SHA-256 digest and must pass both hash and TOML validation before restore.
Every applied configuration or derived-state repair is persisted in
`last-repair.json` for undo and appended to `repair-log.jsonl` on a best-effort
basis. An audit-log failure never invalidates an otherwise durable repair.
`undo` restores the files moved aside by the latest repair while retaining the
repaired copy as a redo candidate. A multi-action `apply-plan` run is recorded
as one transaction, so a single `undo` reverts the whole plan (or the applied
prefix when a plan failed partway); an interrupted undo resumes from where it
stopped.
Windows and Linux use a versioned install root:
`diagnose` adds offline semantic checks for model references, provider and MCP
URLs, credentials, proxy structure, MCP commands, permission conflicts, file
permissions, and derived desktop JSON. `--network` is explicit: it probes the
provider model endpoint through the configured proxy and classifies connectivity
and authentication status without storing response bodies. `rebuild` never
deletes derived state; it quarantines the selected file and lets Reasonix recreate
it.
```text
InstallRoot/
reasonix-launcher[.exe]
Reasonix.exe # Windows portable / Start Menu alias
reasonix[-cli.exe]
current.json
versions/<version>/
reasonix-desktop[.exe]
reasonix-cli[.exe]
reasonix-update-helper[.exe]
```
## Automatic Safe Mode
The thin launcher only reads `current.json` and starts the active desktop. It
never selects a previous version or enters Safe Mode.
The desktop records `starting`, `ready`, `healthy`, and `clean-exit` under the
Reasonix state directory. `ready` begins a 30-second probation period. On the
third incomplete startup within five minutes, Guard (or the macOS desktop
preflight) opens a native recovery dialog that does not depend on WebView. Safe
Mode uses built-in configuration, does not restore saved tabs, and disables
external integrations for that run. It does not rewrite the user's configuration.
## Upgrading from 1.18–1.19.1
## Update rollback
If an older client is stuck on a pending update or Safe Mode loop:
Before an automatic **portable** update (Windows installer path, Linux `.tar.gz`,
or a macOS app-bundle replace), Reasonix retains the complete installed release
unit — the desktop executable plus the Guard/launcher binaries the installer
also replaces — or the application bundle (macOS). The backups remain until the
replacement build reaches `healthy` or exits cleanly. If the replacement enters
the startup failure threshold, Guard (or the macOS desktop preflight) verifies
every backup hash and restores the complete release unit before relaunching, so
a rollback never produces a mixed-version install. When the Windows installer
itself fails after the desktop has exited, the update helper records the failure
and relaunches Guard, which performs the same full rollback immediately instead
of waiting for a crash loop. Update metadata and hashes are stored under the
Reasonix repair state; arbitrary backup or target paths are rejected, and
unhashed backups are refused.
1. Download the latest signed installer / package from the official download page.
2. Run it once (Windows: double-click; no need to uninstall or delete JSON).
3. Compatibility payloads may still include a one-shot binary named
`reasonix-guard` that only migrates the flat layout into `current.json` and
then deletes itself. That binary is not the old Guard product.
**Debian/Ubuntu `.deb` installs** do not use Guard file-level rollback for
upgrades. In-app updates authorize a root helper via Polkit and install with
`apt-get --only-upgrade`. On failure the running process stays up, the verified
download remains cached for retry, and package state is left to apt/dpkg.
Successful installs are managed by the system package manager and are not
auto-downgraded by Reasonix. Users on an older `.deb` without the helper should
overwrite-install the bootstrap package once:
`sudo apt install ./Reasonix-linux-amd64.deb` (no uninstall required).
Do not manually delete `pending-update.json`, locks, or AppData as the recovery
procedure.
## Optional AI assistance
## macOS
Offline `check`, `repair`, `diagnose`, rollback, and Safe Mode never call a model.
`assist` is a separate, explicit second layer. It sends a redacted diagnostic
summary to the selected configured provider as a one-shot request and may incur
provider token charges. It does not change the normal chat prompt or tool list.
The response must be a versioned `RepairPlan` JSON object. Unknown fields and
actions are rejected. The only accepted actions are configuration quarantine,
verified snapshot restore, derived-state rebuild, and pending-update rollback.
The host displays an operation preview and unified configuration diff, asks for
confirmation, and executes only those built-in operations. A plan cannot run a
shell command, edit credentials or session content, or name an arbitrary path.
Machine-readable assist output includes a `planId` and a `previewId`. Hosts
that confirm a dry-run later must pass the same `previewId` to `apply-plan`;
the guard re-computes the preview before writing and refuses when the plan,
action list, filesystem-derived diff, derived-state input, or pending-update
transaction changed. Non-interactive `apply-plan --yes` requires
`--preview-id`; interactive confirmation binds the preview displayed in that
same invocation. Direct package callers that own their own approval boundary
remain source compatible and can opt into the same check with
`ApplyPlanOptions.ExpectedPreviewID`.
Confirmed configuration, snapshot, and derived-state mutations are serialized
across Reasonix processes. Repair transaction bookkeeping and undo are
serialized before per-target locks, so disjoint repairs cannot exchange or lose
undo records. After taking the target lock, Guard re-checks the confirmed action
and its inputs, then verifies the node moved by the final rename. A process that
recreates the target during repair wins: Guard does not overwrite the new file
and reports that the confirmed state was moved aside. Pending-update rollback
uses the complete confirmed transaction identity, not only its version or
timestamp. File updates keep `pending-update.json` immutable and record the
complete installed release unit in a transaction-unique, create-only sidecar,
so a crash cannot hide the rollback transaction between two state-file
replacements.
New fields and sidecars are additive. Older Reasonix releases ignore unknown
data, and current releases can still read legacy transactions with missing
bindings. A missing binding is a safe zero value, not destructive
authorization: automatic handoff, healthy commit, or app-bundle rollback fails
closed when it cannot prove ownership. A legacy app-bundle backup without a
tree digest can be restored only through an explicitly confirmed repair-plan
preview that binds that exact backup.
macOS keeps LaunchServices launching the Wails app bundle directly. Updates
replace the signed `.app` atomically; there is no Guard process.
+36 -90
View File
@@ -1,105 +1,51 @@
# 恢复与安全模式
# 恢复与诊断(v1.20+)
Reasonix 随桌面端提供一个很小的恢复程序 `reasonix-guard`。它不加载 Wails、
WebView、插件、MCP、Hooks、机器人或会话正文,因此桌面壳层或 TOML 配置无法启动时,
它仍可使用。
Reasonix 不再提供产品化的 `reasonix-guard` 恢复壳。崩溃记录、pending 更新状态
和配置问题**不会**在下次启动时强制进入全局安全模式。
## 命令
## 请使用这些命令
```bash
reasonix-guard check [--root PATH] [--json]
reasonix-guard repair [--root PATH] [--project] [--json]
reasonix-guard diagnose [--root PATH] [--network] [--json]
reasonix-guard rebuild --target tabs|projects|window|zoom|all
reasonix-guard snapshots [--json]
reasonix-guard restore --snapshot ID
reasonix-guard undo [--json]
reasonix-guard launch [--app PATH] [--safe-mode] [--detach]
reasonix-guard recover [--root PATH] [--project]
reasonix-guard assist [--model PROVIDER/MODEL] [--apply] [--allow-project]
reasonix-guard apply-plan --file PLAN.json [--preview-id ID] [--yes] [--allow-project]
reasonix doctor repair [--root PATH] [--apply] [--project] [--json]
```text
reasonix doctor
reasonix doctor repair
reasonix crash report # 视构建是否包含而定
```
Windows/Linux 安装后的桌面快捷方式默认先启动 Guard。macOS 应用 Bundle 则直接
启动 Wails 桌面进程,让 LaunchServices、Dock 图标和应用窗口拥有同一个原生进程身份;
它会在创建 WebView 前执行同等的启动恢复预检。Guard 仍作为独立恢复命令随包提供。
直接运行 `reasonix-guard` 会启动同目录的桌面程序;只读检查请显式使用 `check`。
Windows 安装包的快捷方式使用同一套 Guard 代码编译出的无终端窗口启动器,同时保留
`reasonix-guard.exe` 供命令行诊断使用。
Windows/Linux 快捷方式启动桌面后会退出启动器;终端中显式执行
`reasonix-guard launch` 默认等待桌面退出,只有传入 `--detach` 才分离。
- **doctor**:检查配置、桌面派生状态与常见安装问题,不加载 Wails。
- **doctor repair**:在用户明确选择后做安全修复。
- 崩溃上报仍为用户授权后发送,且不会改变下次启动模式。
除非使用 `repair` 或 `--apply`,否则检查过程只读。执行修复时,无法解析的 TOML
会重命名为带时间戳的 `.reasonix-quarantine-*` 文件;全局配置损坏时,还会尝试恢复
桌面端成功启动后保存的最近健康快照。守卫不会删除凭据 `.env`、会话 JSONL 或项目
源码。只有显式传入 `--project` 时,项目的 `reasonix.toml` 才会被隔离。
## 安装布局(v1.20+)
Guard 保留最近 5 个健康的全局配置快照。每个快照都有 SHA-256,恢复前必须同时通过
哈希和 TOML 校验。每次配置或派生状态修复都会把可撤销状态持久化到
`last-repair.json`,并尽力追加到 `repair-log.jsonl`;审计日志写入失败不会让已经可靠
落盘的修复失效。`undo` 会恢复最近一次修复前被移走的文件,同时把当前修复版本保留成
可再次使用的副本。多动作的 `apply-plan` 记录为一个事务:一次 `undo` 即可回退整份
计划(计划中途失败时回退已执行的前缀);被中断的 `undo` 重跑时会从断点继续。
Windows / Linux 使用版本目录:
`diagnose` 增加离线语义检查:模型引用、Provider/MCP URL、凭据、代理结构、MCP
命令、权限规则冲突、文件权限和桌面派生 JSON。只有显式传入 `--network` 才会按当前
代理探测 Provider 模型接口;结果只记录连通性和认证状态,不保存响应正文。
`rebuild` 不删除派生数据,而是先隔离指定文件,再让 Reasonix 自动重建。
```text
InstallRoot/
reasonix-launcher[.exe]
Reasonix.exe # Windows 便携/开始菜单别名
reasonix[-cli.exe]
current.json
versions/<version>/
reasonix-desktop[.exe]
reasonix-cli[.exe]
reasonix-update-helper[.exe]
```
## 自动安全模式
薄启动器只读取 `current.json` 并启动当前 Desktop,**不会**自动选旧版本或进入
安全模式。
桌面端会在 Reasonix 状态目录记录 `starting`、`ready`、`healthy` 和
`clean-exit`。`ready` 后还有 30 秒稳定观察期。五分钟内连续三次未完成启动时,
Guard(macOS 上为桌面启动预检)会显示不依赖 WebView 的系统原生恢复对话框。
安全模式使用内置配置、不恢复上次标签页,并在本次运行中禁用外部集成;它不会改写
用户配置。
## 从 1.18–1.19.1 升级
## 更新回滚
若旧客户端卡在 pending update 或安全模式循环:
自动 **便携版** 更新前(Windows 安装器路径、Linux `.tar.gz`、或 macOS 应用 Bundle
替换),Reasonix 会完整保留安装的发布单元——桌面可执行文件以及安装器同样会替换的
Guard/启动器二进制,或整个应用 Bundle(macOS)。只有新版本进入 `healthy` 或干净
退出后才清理备份。新版本达到启动失败阈值时,Guard(macOS 上为桌面启动预检)会先
校验全部备份哈希,再恢复完整发布单元并重新启动,回滚不会留下新旧混装。Windows
安装器在桌面退出后执行失败时,更新 helper 会记录失败并重新拉起 Guard,Guard 启动时
立即执行同样的完整回滚,而不是等待崩溃循环。更新元数据和哈希位于 Reasonix 修复状态
目录;任何任意目标路径、目录外备份或缺失哈希的备份都会被拒绝。
1. 从官方下载页获取最新签名安装包。
2. 直接运行一次(Windows:双击即可;无需卸载或手工删 JSON)。
3. 兼容载荷中可能仍带有名为 `reasonix-guard` 的**一次性迁移程序**,它只把旧平铺
布局写成 `current.json` 后自删除,并非旧 Guard 产品。
**Debian/Ubuntu `.deb` 安装** 的升级不走 Guard 文件级回滚。应用内更新通过 Polkit
授权 root helper,并以 `apt-get --only-upgrade` 安装。失败时旧进程保持运行、已验证
的下载缓存可重试,包状态交由 apt/dpkg 处理。安装成功后由系统包管理器管理,Reasonix
不会自动降级。尚未包含 helper 的旧 `.deb` 用户需手动覆盖安装一次 bootstrap 包:
`sudo apt install ./Reasonix-linux-amd64.deb`(无需卸载)。
正式恢复路径**不要求**手工删除 `pending-update.json`、锁文件或 AppData。
## 可选 AI 辅助
## macOS
离线 `check`、`repair`、`diagnose`、版本回滚和安全模式都不会调用模型。
`assist` 是独立且必须显式触发的第二层:它把脱敏诊断摘要作为一次性请求发给用户选择
的已配置 Provider,可能产生 token 费用,但不会改变普通聊天的 system prompt、工具
列表或缓存前缀。
模型只能返回带版本号的 `RepairPlan` JSON。未知字段和非白名单动作会被拒绝。白名单
仅包括:隔离配置、恢复已校验快照、重建派生状态、回滚待确认更新。Host 会先展示操作
预览和配置统一 diff,再要求用户确认。计划不能运行 shell、修改凭据或会话正文,也不能
指定任意文件路径。
机器可读的 `assist` 输出包含 `planId` 和 `previewId`。Host 在 dry-run 后延迟确认时,
必须把同一个 `previewId` 传给 `apply-plan`;Guard 会在写入前重新计算预览,并在计划、
动作列表、文件 diff、派生状态输入或待回滚更新事务发生变化时拒绝执行。非交互式
`apply-plan --yes` 必须同时提供 `--preview-id`;交互式确认则自动绑定同一次调用中展示
的预览。自行管理确认边界的 Go 包调用方保持源码兼容,并可通过
`ApplyPlanOptions.ExpectedPreviewID` 启用相同校验。
已确认的配置、快照和派生状态变更会在 Reasonix 进程间串行化。修复事务记录与撤销会先于
各目标锁串行化,因此不同目标的并发修复不会交换或丢失撤销记录。Guard 获取目标锁后会
重新校验已确认的动作及其输入,并核对最终重命名实际移走的节点。如果其他进程在修复期间
重新创建目标文件,该进程的写入优先:Guard 不会覆盖新文件,并会报告已确认状态被移到的
隔离位置。待确认更新的回滚绑定完整事务身份,而不只比较版本号或时间戳。文件更新保持
`pending-update.json` 不可变,并把完整的已安装 release unit 写入当前事务专属、仅创建一次
的 sidecar;因此进程崩溃不会在两个状态文件替换之间隐藏回滚事务。
新增字段和 sidecar 都是增量数据:旧版 Reasonix 会忽略未知数据,当前版本也仍能读取缺少
新绑定的旧事务。缺失绑定只表示安全零值,不构成破坏性操作授权;无法证明节点归属时,
自动 handoff、健康提交或 app-bundle 回滚都会 fail closed。旧 app-bundle 备份没有目录树
摘要时,只有显式确认且绑定该备份的修复计划预览才能执行恢复。
macOS 仍由 LaunchServices 直接启动 Wails App 包;更新原子替换签名 `.app`,无
Guard 进程。
+71
View File
@@ -306,3 +306,74 @@ func HasCurrent(installRoot string) bool {
_, err := ReadCurrent(installRoot)
return err == nil
}
// ResolveInstallRoot walks upward from path (usually the running executable)
// and returns the InstallRoot that owns current.json. Flat installs return the
// directory containing the executable when no pointer is found.
func ResolveInstallRoot(fromPath string) (string, error) {
fromPath = filepath.Clean(strings.TrimSpace(fromPath))
if fromPath == "" {
return "", fmt.Errorf("installlayout: empty path")
}
info, err := os.Lstat(fromPath)
if err != nil {
return "", err
}
dir := fromPath
if !info.IsDir() {
dir = filepath.Dir(fromPath)
}
cur := dir
for {
if HasCurrent(cur) {
return cur, nil
}
parent := filepath.Dir(cur)
if parent == cur {
// No versioned layout found: treat the original directory as the
// flat install root.
return dir, nil
}
// Stop climbing out of a versions tree once we pass InstallRoot.
base := filepath.Base(cur)
if base == VersionsDirName {
// parent is InstallRoot even without current.json yet (migration).
return parent, nil
}
cur = parent
}
}
// ActiveUpdateHelperPath resolves the active update helper binary.
func ActiveUpdateHelperPath(installRoot string) (string, error) {
ptr, err := ReadCurrent(installRoot)
if err != nil {
return "", err
}
dir := filepath.Join(installRoot, filepath.FromSlash(ptr.ActiveDir))
path := filepath.Join(dir, UpdateHelperBinaryName())
info, err := os.Lstat(path)
if err != nil {
return "", fmt.Errorf("installlayout: active update helper: %w", err)
}
if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("installlayout: active update helper is not a regular file")
}
return path, nil
}
// LauncherBinaryName is the permanent thin launcher at InstallRoot.
func LauncherBinaryName() string {
if runtime.GOOS == "windows" {
return "reasonix-launcher.exe"
}
return "reasonix-launcher"
}
// PortableAliasName is the Windows portable entry (Reasonix.exe).
func PortableAliasName() string {
if runtime.GOOS == "windows" {
return "Reasonix.exe"
}
return ""
}
+21
View File
@@ -96,3 +96,24 @@ func TestValidateVersionName(t *testing.T) {
}
}
}
func TestResolveInstallRootFromVersionedDesktop(t *testing.T) {
root := t.TempDir()
version := "v1.20.0"
dir := filepath.Join(root, "versions", version)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
desktop := filepath.Join(dir, DesktopBinaryName())
if err := os.WriteFile(desktop, []byte("x"), 0o755); err != nil {
t.Fatal(err)
}
if err := WriteCurrent(root, CurrentPointer{SchemaVersion: 1, ActiveVersion: version, ActiveDir: VersionDirRelative(version)}); err != nil {
t.Fatal(err)
}
got, err := ResolveInstallRoot(desktop)
if err != nil || got != root {
t.Fatalf("got %q err=%v want %q", got, err, root)
}
}
+2 -2
View File
@@ -77,7 +77,7 @@ func Diagnose(ctx context.Context, opts DiagnoseOptions) (DiagnosticReport, erro
for _, check := range configReport.Checks {
if check.Exists && !check.Valid {
valid = false
report.add("error", "config.invalid_toml", check.Scope, "Configuration cannot be parsed: "+check.Error, "Run reasonix-guard repair; add --project only for a project config.")
report.add("error", "config.invalid_toml", check.Scope, "Configuration cannot be parsed: "+check.Error, "Run reasonix doctor repair; add --project only for a project config.")
}
}
checkSensitiveFileMode(&report, config.UserConfigPath(), "global config")
@@ -291,7 +291,7 @@ func checkDerivedJSON(report *DiagnosticReport) {
continue
}
if !json.Valid(b) {
report.add("warning", "derived.invalid_json", "derived:"+name, fmt.Sprintf("Derived desktop state %s is malformed.", filepath.Base(path)), "Run reasonix-guard rebuild --target "+name+".")
report.add("warning", "derived.invalid_json", "derived:"+name, fmt.Sprintf("Derived desktop state %s is malformed.", filepath.Base(path)), "Run reasonix doctor repair, or delete the broken derived file after backing it up.")
}
}
}
+1 -1
View File
@@ -267,7 +267,7 @@ windows)
;;
linux)
for desktop_contract in \
'Exec=reasonix-guard launch --detach' \
'Exec=reasonix-launcher' \
'Icon=reasonix-desktop' \
'StartupWMClass=reasonix-desktop'; do
grep -F -x -q "$desktop_contract" build/linux/reasonix.desktop || { echo "Linux desktop entry missing: $desktop_contract" >&2; exit 1; }