mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
0e78ef425e
commit
79449ef801
22 files changed
+667
-1477
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
package main
|
||||
|
||||
type recoveryChoice int
|
||||
|
||||
const (
|
||||
recoveryQuit recoveryChoice = iota
|
||||
recoverySafeMode
|
||||
recoveryRepair
|
||||
)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//go:build !darwin && !windows && !linux
|
||||
|
||||
package main
|
||||
|
||||
func nativeRecoveryChoice() recoveryChoice { return recoverySafeMode }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
@@ -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 进程。
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
Reference in new issue
Block a user