diff --git a/CHANGELOG.md b/CHANGELOG.md index f5206f0..5bc4263 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### Added - Restored conservative 14-day automatic archiving through one consented hourly Luna heartbeat, deterministic candidate selection, native archive controls, interruption-safe ownership, and ownership-only restore. +- Restored verified automatic updates through the same heartbeat, with bounded official release selection, checksum and candidate validation, same-version repair, and one version-change announcement. ## v2.1.7 - 2026-08-03 diff --git a/INSTALL.md b/INSTALL.md index 7154305..cf12c03 100644 --- a/INSTALL.md +++ b/INSTALL.md @@ -72,6 +72,7 @@ Only after every check and the dry run succeeds, compose one terminal final answ > - Deterministic classification first; Luna medium only for ambiguity. > - A small Luna helper checks in hourly, then stays quiet when there is nothing to do. > - Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them. +> - ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat. > > Install ThreadBear with this recommended setup? @@ -108,7 +109,7 @@ The install result must show `installed:true`, the exact `main_task_id`, and `ph Inventory and migration cover local Codex Desktop and CLI tasks that the native explicit-target title setter can address. They do not enumerate or rename older signed-in ChatGPT chat-history rows that may also appear in the Desktop sidebar. Disclose that boundary before migration and never describe zero local inventory rows as proof that every visible sidebar row changed. -Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, and stay quiet when nothing changes.” Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. +Create or update one paused hourly heartbeat automation through the native automation control. Its exact ID is `threadbear-maintenance`, its name is “ThreadBear maintenance,” and its target is `MAIN_TASK_ID`. Its prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” Reuse only an existing automation whose ID, name, kind, and target all match; an ID collision with anything else stops installation without changing that automation. Do not create a cron job or a second maintenance schedule. A reinstall already at `migration_complete` may keep it active. If the native automation call fails, say that ThreadBear's local title helper is installed but its housekeeping helper is not, and stop before claiming completion. Do not ask the user to create or repair the automation manually. @@ -150,7 +151,7 @@ On complete success, use this shape in natural prose: > ## ThreadBear is installed > -> Everything passed: ThreadBear VERSION is installed, its managed guidance, two hooks, and hourly Luna helper are healthy, this task is its persistent home, and the migration controller completed with zero remaining native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows were outside this migration and may remain unchanged in the sidebar. +> Everything passed: ThreadBear VERSION is installed, its managed guidance, two hooks, verified updates, and hourly Luna helper are healthy, this task is its persistent home, and the migration controller completed with zero remaining native-addressable local Codex task titles. Older signed-in ChatGPT chat-history rows were outside this migration and may remain unchanged in the sidebar. > > From here, you can ask “how are you?”, “what tasks do you see?”, or “uninstall ThreadBear.” @@ -165,9 +166,10 @@ For later help, lead with a short capability card instead of a command dump. Ver ```sh ~/.local/bin/threadbear status --json ~/.local/bin/threadbear help +~/.local/bin/threadbear update --json ``` -The installed binary's help is the authoritative public command list. Cross-check the exact `threadbear-maintenance` heartbeat with the native automation control before describing hourly housekeeping as healthy. +The installed binary's help is the authoritative public command list. Run `update --json` only for an explicit check-now request or from the owned maintenance heartbeat after archive work is reconciled. Cross-check the exact `threadbear-maintenance` heartbeat with the native automation control before describing hourly housekeeping as healthy. ## Uninstall diff --git a/README.md b/README.md index 969ad21..493c9fe 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ threadbear install threadbear inventory threadbear migration threadbear maintenance +threadbear update threadbear status threadbear self-test threadbear uninstall @@ -35,10 +36,10 @@ threadbear version Every command accepts `--json`. `inventory` is read-only and includes every native-addressable unarchived local Codex Desktop or CLI task, including projectless tasks, excluding the persisted main and controller tasks. Rollout-only internal records and older signed-in ChatGPT chat-history rows that Codex's native title setter cannot enumerate or rename are excluded. Those chat-history rows may remain unchanged in the Desktop sidebar even after local migration completes. `status` reports `ready:true` only after `migration_complete`; the installed binary's `help` output is authoritative. -From the persistent ThreadBear task, ask to “strip title icons” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. The same task's hourly Luna helper can archive only deterministically eligible, ThreadBear-owned complete user tasks after 14 quiet days, and restore only archives recorded in its private ownership ledger. Guided uninstall always pauses that helper and completes title cleanup before removing ThreadBear's local state, hooks, and owned automation. +From the persistent ThreadBear task, ask to “strip title icons” or “check for updates now” at any time. The control task serially removes all leading ThreadBear status marks through the same native setter and exact Pre/Post verification used by ordinary turns. The same task's hourly Luna helper can archive only deterministically eligible, ThreadBear-owned complete user tasks after 14 quiet days, restore only archives recorded in its private ownership ledger, and run the deterministic verified update check last. Guided uninstall always pauses that helper and completes title cleanup before removing ThreadBear's local state, hooks, and owned automation. ## Boundaries -ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, and reconciles ownership; Luna calls Codex's supported native archive control and never edits private UI storage or interprets prose to add targets. ThreadBear does not add token counts or update itself in the background yet, and it adds no model call or narration to ordinary turns. Installation uses one serial native-writing controller and adaptive waves of read-only Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. +ThreadBear installs no daemon or LaunchAgent. One explicitly consented hourly Codex heartbeat runs maintenance from the persistent Luna-medium task and stays quiet on no-op runs. The CLI alone selects archive candidates, stages one operation, reconciles ownership, and chooses the exact Darwin asset from the official release manifest. Luna calls supported native controls and communicates typed results; it never edits private UI storage, interprets prose to add targets, or chooses/downloads/checksums a release. Updates refuse while archive work is pending, verify repository URLs, SHA-256, embedded version, candidate self-test, candidate install, and installed status, and never downgrade. ThreadBear adds no token counts, model call, or narration to ordinary turns. Installation uses one serial native-writing controller and adaptive waves of read-only Luna-medium workers only when genuinely ambiguous history cannot be classified deterministically; workers classify and never write titles. See [architecture](docs/architecture.md), [compatibility](docs/compatibility.md), and the [status footer convention](docs/status-convention.md). diff --git a/assets/help.txt b/assets/help.txt index 0119e38..c1e0409 100644 --- a/assets/help.txt +++ b/assets/help.txt @@ -10,6 +10,7 @@ Commands: migration Record the single installation migration controller phase status Check the installed helper, hooks, and migration phase self-test Validate a release candidate + update Install the latest verified release uninstall Remove ThreadBear after control-task title cleanup version Show the installed version diff --git a/assets/skill/SKILL.md b/assets/skill/SKILL.md index e2936ac..636b174 100644 --- a/assets/skill/SKILL.md +++ b/assets/skill/SKILL.md @@ -9,7 +9,7 @@ Be warm, brief, and lightly bear-themed. Explain visible outcomes before command ## Help -For a help-shaped request, start with a short capability card: ThreadBear keeps Codex Desktop titles useful through two native title calls per ordinary turn, while its hooks deterministically preserve each task's subject. One hourly Luna helper can quietly tuck away owned, completed tasks after 14 inactive days. ThreadBear adds no extra model call or narration to ordinary turns. +For a help-shaped request, start with a short capability card: ThreadBear keeps Codex Desktop titles useful through two native title calls per ordinary turn, while its hooks deterministically preserve each task's subject. One hourly Luna helper can quietly tuck away owned, completed tasks after 14 inactive days and install verified ThreadBear releases. ThreadBear adds no extra model call or narration to ordinary turns. Run `~/.local/bin/threadbear status --json` before saying ThreadBear is installed or healthy. Use `~/.local/bin/threadbear help` as the authoritative public command reference. @@ -20,6 +20,7 @@ Show a command before running it. Ask for explicit consent before any lifecycle | "How are you?" | `~/.local/bin/threadbear status --json` | | "What tasks do you see?" | `~/.local/bin/threadbear inventory --json` | | "Run maintenance now" | Follow **Maintenance** below. | +| "Check for updates now" | Run the update-last step in **Maintenance**. | | "Bring back archived task TASK_ID" | Follow the restore path in **Maintenance**. | | "Strip title icons" | Follow **Title cleanup** below from the persisted ThreadBear task. | | "Install ThreadBear" | Follow **Install** below. | @@ -29,10 +30,10 @@ Show a command before running it. Ask for explicit consent before any lifecycle 1. Read the current install guide and the candidate's help output. Check macOS, architecture, Codex, HTTPS access, and candidate self-test without changing the machine. Resolve the exact current task ID with supported Codex task tooling. 2. Run the exact dry run with `--control-task-id CURRENT_TASK_ID`. Explain the complete effect: adopting that task as the persistent home, the local binary, one small private state file, one managed AGENTS block, this skill, two hook entries, and one owned hourly Luna heartbeat. Explain that migration covers native-addressable local Codex tasks; older signed-in ChatGPT chat-history rows are outside Codex's current task-title API and stay unchanged. -3. Show the recommended setup and include: “A small Luna helper checks in hourly, then stays quiet when there is nothing to do.” and “Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.” Ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. +3. Show the recommended setup and include: “A small Luna helper checks in hourly, then stays quiet when there is nothing to do.”, “Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.”, and “ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat.” Ask once for consent. A clear yes to the unchanged complete recommendation is installation consent. Ask again only if the recommendation changed, the answer was ambiguous, or this is a reinstall with a different effect. 4. Run the confirmed install with the same ID and verify `version`, `self-test`, and `inventory`. A fresh result is `migration_pending`: no controller has started and nothing is running yet. On reinstall, omit the flag only when `status --json` already reports the persisted main task; never replace it with the launching task. 5. Before any migration, use `codex_app__set_thread_title` to set the initiating task to exactly `🧵🐻 ThreadBear 🐻🧵`, use `codex_app__set_thread_pinned` to pin it, and keep that task selected. -6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, and stay quiet when nothing changes.” Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If the call fails, report the partial install and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. +6. Create or update one paused hourly heartbeat through the native automation control. Its ID is `threadbear-maintenance`, name is “ThreadBear maintenance,” target is the persisted main task, and prompt is: “Follow the installed ThreadBear skill's Maintenance section. Reconcile archive work first, process eligible archives serially through native controls, run the verified update check last, and stay quiet when nothing changes.” Reuse only an exact ID/name/kind/target match. A collision stops installation without changing the other automation; never create a cron job or duplicate. If the call fails, report the partial install and stop instead of asking the user to repair it manually. On a reinstall that already reports `migration_complete`, it may be active immediately. 7. For an ordinary guided installation, never use visual inspection, computer control, screenshots, or Codex `/hooks`, and never ask the user to do so. The candidate self-test, installed `self-test --json`, and read-only ThreadBear inventory are the installation gate. Visual hook verification belongs only to **Debug canaries** when the install result explicitly contains `debug_canaries:true`. 8. Create exactly one background migration controller without opening, selecting, or navigating to it, then immediately record it with `migration --phase migration_running`. On a compatible machine with the candidate already downloaded, dispatch it within 60 seconds of consent; do not insert UI inspection, App Server schema generation, native-catalog comparison, or tool-surface discovery before dispatch. If creation fails, leave `migration_pending` unchanged and report that nothing is running plus the exact retry action. 9. Give the controller the **Migration controller** protocol below. Supervise it from the persistent task with compact task waits, reporting only each 25-title milestone or phase change. Do not end the installation turn while durable status is `migration_pending` or `migration_running`. @@ -52,10 +53,12 @@ The owned `threadbear-maintenance` heartbeat runs this section from the persiste 2. For each candidate in stable order, run `~/.local/bin/threadbear maintenance --archive TASK_ID --json` immediately before mutation and require that exact ID with `action:"archive"` and `pending:true`. Call native `codex_app__set_thread_archived` once with that task ID and `archived:true`, without opening or selecting the task. Rerun the exact maintenance command and require `reconciled:true` before continuing. 3. If the native result is unknown or the reconciliation still says pending, do not repeat the mutation. Read the task with native task controls, rerun maintenance, and stop with the pending transaction intact unless the CLI authoritatively reconciles it. If the native call returned a definite failure and a native read confirms the original archive state, run `maintenance --cancel TASK_ID --json` to clear that known-unapplied operation. Never cancel an unknown or in-flight result. Title, footer, activity, kind, identity, or archive drift makes the CLI fail closed. If the CLI reports that an applied archive drifted, it remains pending and unowned: do not adopt it. Report the task for manual recovery; only after the user restores it and a native read confirms it is unarchived may the exact guarded cancel clear the operation. 4. Process one native archive operation at a time. Finish with a no-target maintenance pass and require no pending operation. Report only archived task subjects/counts or an error; do not narrate healthy no-op runs. +5. Only after the closing archive pass proves there is no pending native operation, run `~/.local/bin/threadbear update --json` last. The command alone fetches the exact official manifest, chooses the Darwin architecture, verifies HTTPS repository URLs and SHA-256, checks the embedded version and candidate self-test, and invokes the candidate's existing install path. Luna never browses for, chooses, downloads, checksums, or approves an asset. +6. Stay silent for `current:true`. For `updated:true`, report the old and new versions once in the persistent ThreadBear task. For `repaired:true`, say that the same verified version repaired managed files. On failure, report its typed `stage` and error once; never bypass verification, run a remote script, downgrade, or retry an unknown partial install blindly. For a user-requested restore, verify the request from the persistent ThreadBear task, then run `maintenance --restore TASK_ID --json`. Continue only for `action:"restore"`, call the native archive control once with `archived:false`, and rerun the same command until `reconciled:true`. The CLI accepts only ThreadBear-owned archives and restarts that task's 14-day quiet clock. A user-archived task is never adopted. A task manually restored through Codex is detected on the next pass, removed from the ownership ledger, and receives the same fresh quiet clock. -To change the quiet window, pass the requested positive `--archive-after-days N` consistently to planning and staging and update the owned automation prompt. To disable archival, update the owned heartbeat prompt to omit this archive protocol; do not pause shared maintenance if it also owns other enabled work. +To change the quiet window, pass the requested positive `--archive-after-days N` consistently to planning and staging and update the owned automation prompt. To disable archival, update the owned heartbeat prompt to omit this archive protocol while preserving the update-last step. To pause updates, omit only the update-last step while preserving enabled archival. A check-now request runs the same deterministic update command after proving no pending archive work; there is no separate scheduler or release channel. ## Status and inventory diff --git a/cmd/threadbear/install.go b/cmd/threadbear/install.go index cb88e10..0608046 100644 --- a/cmd/threadbear/install.go +++ b/cmd/threadbear/install.go @@ -106,6 +106,11 @@ func uninstall(ctx context.Context, confirmed bool) (any, error) { if !confirmed { return nil, errors.New("uninstall requires --noninteractive --confirm") } + operationLock, err := newStore(stateDir()).blockingOperationLock() + if err != nil { + return nil, err + } + defer unlock(operationLock) value, err := currentStateOrEmpty() if err != nil { return nil, err diff --git a/cmd/threadbear/install_test.go b/cmd/threadbear/install_test.go index 664c9c4..1992117 100644 --- a/cmd/threadbear/install_test.go +++ b/cmd/threadbear/install_test.go @@ -9,6 +9,7 @@ import ( "reflect" "strings" "testing" + "time" "github.com/ericlitman/threadbear/assets" ) @@ -88,6 +89,74 @@ func TestInstallDryRunAndConfirmationDoNotMutate(t *testing.T) { } } +func TestUninstallWaitsForOperationLockBeforeDeleting(t *testing.T) { + p := isolatedLifecycle(t) + if _, err := install("installer", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + operationLock, err := newStore(stateDir()).operationLock() + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { + _, err := uninstall(context.Background(), true) + done <- err + }() + select { + case err := <-done: + unlock(operationLock) + t.Fatalf("uninstall returned while operation lock was held: %v", err) + case <-time.After(100 * time.Millisecond): + } + for _, path := range []string{p.binary, p.skill, stateDir()} { + if _, err := os.Stat(path); err != nil { + unlock(operationLock) + t.Fatalf("uninstall deleted %s while operation lock was held: %v", path, err) + } + } + unlock(operationLock) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("uninstall did not resume after operation lock was released") + } + for _, path := range []string{p.binary, p.skill, stateDir()} { + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("uninstall left %s after operation lock release: %v", path, err) + } + } +} + +func TestOperationLockDoesNotRecreateRemovedInstallation(t *testing.T) { + dir := filepath.Join(t.TempDir(), "state") + store := newStore(dir) + operationLock, err := store.blockingOperationLock() + if err != nil { + t.Fatal(err) + } + defer unlock(operationLock) + if err := os.RemoveAll(dir); err != nil { + t.Fatal(err) + } + if lock, err := store.operationLock(); err == nil { + unlock(lock) + t.Fatal("operation lock recreated an installation while uninstall held the removed lock inode") + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("operation lock left a replacement state directory: %v", err) + } +} + func TestInstallDebugCanariesAreExplicitOptIn(t *testing.T) { isolatedLifecycle(t) ordinary, err := install("installer", true, false, false) diff --git a/cmd/threadbear/main.go b/cmd/threadbear/main.go index 5b291a0..570d602 100644 --- a/cmd/threadbear/main.go +++ b/cmd/threadbear/main.go @@ -3,6 +3,7 @@ package main import ( "context" "encoding/json" + "errors" "flag" "fmt" "github.com/ericlitman/threadbear/assets" @@ -67,6 +68,8 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. cancel := flags.String("cancel", "", "cancel one known-unapplied native archive operation") days := flags.Int("archive-after-days", 14, "quiet days before a completed task is eligible") action = func() (any, error) { return maintenance(ctx, *archive, *restore, *cancel, *days) } + case "update": + action = func() (any, error) { return update(ctx) } case "status": action = func() (any, error) { return status(ctx) } case "self-test": @@ -87,7 +90,12 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io. } result, err := action() if err != nil { - _ = json.NewEncoder(stdout).Encode(map[string]any{"ready": false, "error": err.Error()}) + failure := map[string]any{"ready": false, "error": err.Error()} + var updateErr *updateError + if errors.As(err, &updateErr) { + failure["stage"] = updateErr.Stage + } + _ = json.NewEncoder(stdout).Encode(failure) return 1 } if json.NewEncoder(stdout).Encode(result) != nil { diff --git a/cmd/threadbear/maintenance.go b/cmd/threadbear/maintenance.go index 73ac66c..49a37ed 100644 --- a/cmd/threadbear/maintenance.go +++ b/cmd/threadbear/maintenance.go @@ -85,6 +85,11 @@ func archiveEligibility(task archiveTask, value *state, days int) (string, bool) } func maintenance(ctx context.Context, archiveID, restoreID, cancelID string, days int) (any, error) { + operationLock, err := newStore(stateDir()).operationLock() + if err != nil { + return nil, err + } + defer unlock(operationLock) actions := 0 for _, id := range []string{archiveID, restoreID, cancelID} { if id != "" { @@ -95,7 +100,7 @@ func maintenance(ctx context.Context, archiveID, restoreID, cancelID string, day return nil, errors.New("maintenance requires positive archive days and at most one target action") } result := map[string]any{"ready": true, "automation_id": maintenanceAutomationID, "archive_after_days": days} - err := newStore(stateDir()).update(func(value *state) (bool, error) { + err = newStore(stateDir()).update(func(value *state) (bool, error) { if value.MainTaskID == "" || value.Phase != phaseMigrationComplete { return false, errors.New("maintenance requires a completed ThreadBear installation") } diff --git a/cmd/threadbear/site_contract_test.go b/cmd/threadbear/site_contract_test.go index 05f2e55..9a95252 100644 --- a/cmd/threadbear/site_contract_test.go +++ b/cmd/threadbear/site_contract_test.go @@ -35,7 +35,6 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "threadbear disable", "threadbear enable", "threadbear inspect", - "threadbear update", } { if strings.Contains(text, removed) { t.Errorf("published install guide contains removed CLI surface %q", removed) @@ -52,6 +51,8 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "Deterministic classification first; Luna medium only for ambiguity.", "A small Luna helper checks in hourly, then stays quiet when there is nothing to do.", "Finished tasks can curl up in the archive after 14 quiet days—and come back whenever you need them.", + "ThreadBear keeps itself fresh from verified releases and tells you when it has a new coat.", + "run the verified update check last", "threadbear-maintenance", "native automation control", "paused hourly heartbeat", @@ -59,6 +60,7 @@ func TestPublishedInstallGuideMatchesCurrentCLI(t *testing.T) { "--control-task-id", "--noninteractive --confirm --json", "~/.local/bin/threadbear inventory --json", + "~/.local/bin/threadbear update --json", "migration_pending", "migration_running", "migration_complete", @@ -203,19 +205,18 @@ func TestHomepageMatchesNativeMaintenanceCapabilities(t *testing.T) { "native task control", "never archives active work", "installs no LaunchAgent", + "verified official release", + "update check last", } { if !strings.Contains(page, requiredClaim) { t.Errorf("homepage is missing maintenance capability claim %q", requiredClaim) } } for _, removedClaim := range []string{ - "ThreadBear can update itself by default", "zero-token idle", "Unchanged heartbeats use zero model tokens", "produce zero output", "exits silently", - "update-check", - "version-change", "control task", } { if strings.Contains(page, removedClaim) { diff --git a/cmd/threadbear/state.go b/cmd/threadbear/state.go index ddef214..a32edc2 100644 --- a/cmd/threadbear/state.go +++ b/cmd/threadbear/state.go @@ -53,9 +53,11 @@ type store struct{ dir string } func newStore(dir string) store { return store{dir: dir} } func (s store) path() string { return filepath.Join(s.dir, "native.json") } -func (s store) lock() (*os.File, error) { - if err := os.MkdirAll(s.dir, 0o700); err != nil { - return nil, err +func (s store) openLock(name string, mode int, createDir bool) (*os.File, error) { + if createDir { + if err := os.MkdirAll(s.dir, 0o700); err != nil { + return nil, err + } } dir, err := unix.Open(s.dir, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW, 0) if err != nil { @@ -65,20 +67,35 @@ func (s store) lock() (*os.File, error) { if err = unix.Fchmod(dir, 0o700); err != nil { return nil, err } - fd, err := unix.Openat(dir, "native.lock", unix.O_CREAT|unix.O_RDWR|unix.O_NOFOLLOW, 0o600) + fd, err := unix.Openat(dir, name, unix.O_CREAT|unix.O_RDWR|unix.O_NOFOLLOW, 0o600) if err != nil { return nil, err } - f := os.NewFile(uintptr(fd), "native.lock") + f := os.NewFile(uintptr(fd), name) info, statErr := f.Stat() if statErr != nil || !info.Mode().IsRegular() { return nil, errors.Join(errors.New("ThreadBear lock is not a regular file"), statErr, f.Close()) } - if err = errors.Join(unix.Fchmod(fd, 0o600), unix.Flock(fd, unix.LOCK_EX)); err != nil { + if err = errors.Join(unix.Fchmod(fd, 0o600), unix.Flock(fd, mode)); err != nil { return nil, errors.Join(err, f.Close()) } return f, nil } +func (s store) lock() (*os.File, error) { return s.openLock("native.lock", unix.LOCK_EX, true) } +func (s store) operationLock() (*os.File, error) { + lock, err := s.openLock("operation.lock", unix.LOCK_EX|unix.LOCK_NB, false) + if errors.Is(err, unix.EWOULDBLOCK) { + return nil, errors.New("another maintenance or update operation is already running") + } + return lock, err +} +func (s store) blockingOperationLock() (*os.File, error) { + return s.openLock("operation.lock", unix.LOCK_EX, true) +} +func unlock(lock *os.File) { + _ = unix.Flock(int(lock.Fd()), unix.LOCK_UN) + _ = lock.Close() +} func (s store) read() (state, error) { fd, err := unix.Open(s.path(), unix.O_RDONLY|unix.O_NOFOLLOW, 0) if err != nil { diff --git a/cmd/threadbear/update.go b/cmd/threadbear/update.go new file mode 100644 index 0000000..5136377 --- /dev/null +++ b/cmd/threadbear/update.go @@ -0,0 +1,297 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "os/exec" + "runtime" + "strconv" + "strings" + "time" +) + +const updateManifestLimit = int64(1 << 20) + +var ( + updateReleaseBase = "https://github.com/ericlitman/threadbear/releases" + updateManifestURL = updateReleaseBase + "/latest/download/latest.json" + updateClient = &http.Client{Timeout: 30 * time.Second} + updateGOOS = runtime.GOOS + updateGOARCH = runtime.GOARCH + updateBinaryLimit = int64(64 << 20) + updateVersionTimeout = 30 * time.Second + updateCandidateTimeout = 30 * time.Second + updateInstallTimeout = 2 * time.Minute +) + +type updateError struct { + Stage string + Err error +} + +func (e *updateError) Error() string { return e.Stage + ": " + e.Err.Error() } +func (e *updateError) Unwrap() error { return e.Err } +func updateFailure(stage string, err error) error { + return &updateError{Stage: stage, Err: err} +} + +type releaseAsset struct { + URL string `json:"url"` + SHA256URL string `json:"sha256_url"` +} + +type releaseManifest struct { + Version string `json:"version"` + Assets map[string]releaseAsset `json:"assets"` +} + +func update(ctx context.Context) (any, error) { + operationLock, err := newStore(stateDir()).operationLock() + if err != nil { + return nil, updateFailure("busy", err) + } + defer unlock(operationLock) + value, err := newStore(stateDir()).read() + if err != nil { + return nil, updateFailure("state", err) + } + if value.MainTaskID == "" || value.Phase != phaseMigrationComplete { + return nil, updateFailure("state", errors.New("update requires a completed ThreadBear installation")) + } + if value.ArchivePending != nil { + return nil, updateFailure("archive_pending", errors.New("reconcile the pending native archive operation before updating")) + } + assetKey, assetName, err := updatePlatform() + if err != nil { + return nil, updateFailure("platform", err) + } + current, err := exactVersion(version) + if err != nil { + return nil, updateFailure("installed_version", err) + } + _, healthErr := status(ctx) + manifestData, err := fetchUpdate(ctx, updateManifestURL, updateManifestLimit) + if err != nil { + return nil, updateFailure("manifest_download", err) + } + var manifest releaseManifest + if json.Unmarshal(manifestData, &manifest) != nil || manifest.Assets == nil { + return nil, updateFailure("manifest", errors.New("release manifest is invalid")) + } + latest, err := exactVersion(manifest.Version) + if err != nil { + return nil, updateFailure("manifest_version", err) + } + comparison := compareVersions(current, latest) + if comparison >= 0 && healthErr == nil { + return map[string]any{"ready": true, "current": true, "version": version, "latest": manifest.Version}, nil + } + if comparison > 0 { + return nil, updateFailure("health", errors.New("installed version is newer than the latest release but managed surfaces are unhealthy")) + } + asset, ok := manifest.Assets[assetKey] + if !ok { + return nil, updateFailure("manifest_asset", fmt.Errorf("release manifest has no %s asset", assetKey)) + } + if err := validateUpdateURL(asset.URL, manifest.Version, assetName); err != nil { + return nil, updateFailure("asset_url", err) + } + if err := validateUpdateURL(asset.SHA256URL, manifest.Version, assetName+".sha256"); err != nil { + return nil, updateFailure("checksum_url", err) + } + checksumData, err := fetchUpdate(ctx, asset.SHA256URL, 4096) + if err != nil { + return nil, updateFailure("checksum_download", err) + } + expected, err := parseChecksum(checksumData) + if err != nil { + return nil, updateFailure("checksum", err) + } + binary, err := fetchUpdate(ctx, asset.URL, updateBinaryLimit) + if err != nil { + return nil, updateFailure("candidate_download", err) + } + actual := sha256.Sum256(binary) + if !bytes.Equal(actual[:], expected) { + return nil, updateFailure("checksum", errors.New("release checksum mismatch")) + } + dir, err := os.MkdirTemp("", "threadbear-update-*") + if err != nil { + return nil, updateFailure("candidate_write", err) + } + defer os.RemoveAll(dir) + candidate := dir + "/threadbear" + if err := writeAtomic(candidate, binary, 0o700); err != nil { + return nil, updateFailure("candidate_write", err) + } + if err := requireCandidate(ctx, candidate, updateVersionTimeout, "version", manifest.Version, "version", "--json"); err != nil { + return nil, updateFailure("candidate_version", err) + } + if err := requireCandidate(ctx, candidate, updateCandidateTimeout, "self-test", manifest.Version, "self-test", "--candidate", "--json"); err != nil { + return nil, updateFailure("candidate_self_test", err) + } + if err := requireCandidate(ctx, candidate, updateInstallTimeout, "install", "", "install", "--noninteractive", "--confirm", "--json"); err != nil { + return nil, updateFailure("candidate_install", err) + } + if err := requireCandidate(ctx, candidate, updateCandidateTimeout, "status", manifest.Version, "status", "--json"); err != nil { + return nil, updateFailure("installed_status", err) + } + result := map[string]any{"ready": true, "from": version, "version": manifest.Version} + if comparison < 0 { + result["updated"] = true + } else { + result["repaired"] = true + } + return result, nil +} + +func updatePlatform() (string, string, error) { + if updateGOOS != "darwin" { + return "", "", errors.New("only Darwin is supported") + } + switch updateGOARCH { + case "arm64": + return "darwin_arm64", "threadbear_darwin_arm64", nil + case "amd64": + return "darwin_amd64", "threadbear_darwin_amd64", nil + default: + return "", "", errors.New("unsupported Darwin architecture") + } +} + +func exactVersion(value string) ([3]int, error) { + var parsed [3]int + parts := strings.Split(value, ".") + if len(parts) != 3 { + return parsed, fmt.Errorf("version %q must be exact N.N.N", value) + } + for index, part := range parts { + number, err := strconv.Atoi(part) + if err != nil || number < 0 || strconv.Itoa(number) != part { + return parsed, fmt.Errorf("version %q must be exact N.N.N", value) + } + parsed[index] = number + } + return parsed, nil +} + +func compareVersions(left, right [3]int) int { + for index := range left { + if left[index] < right[index] { + return -1 + } + if left[index] > right[index] { + return 1 + } + } + return 0 +} + +func validateUpdateURL(raw, releaseVersion, filename string) error { + base, baseErr := url.Parse(updateReleaseBase) + value, err := url.Parse(raw) + if baseErr != nil || err != nil || !value.IsAbs() || value.Scheme != base.Scheme || value.Host != base.Host || value.User != nil || value.RawQuery != "" || value.Fragment != "" || value.RawPath != "" { + return errors.New("release URL is not an exact official URL") + } + want := strings.TrimSuffix(base.Path, "/") + "/download/v" + releaseVersion + "/" + filename + if value.Path != want { + return errors.New("release URL does not match the selected version and architecture") + } + return nil +} + +func fetchUpdate(ctx context.Context, raw string, limit int64) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, raw, nil) + if err != nil { + return nil, err + } + response, err := updateClient.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < 200 || response.StatusCode >= 300 { + return nil, fmt.Errorf("HTTP status %d", response.StatusCode) + } + if response.ContentLength > limit { + return nil, errors.New("response exceeds the size limit") + } + data, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(data)) > limit { + return nil, errors.New("response exceeds the size limit") + } + return data, nil +} + +func parseChecksum(data []byte) ([]byte, error) { + fields := strings.Fields(string(data)) + if len(fields) == 0 || len(fields[0]) != 64 { + return nil, errors.New("release checksum is missing") + } + value, err := hex.DecodeString(fields[0]) + if err != nil || len(value) != sha256.Size { + return nil, errors.New("release checksum is invalid") + } + return value, nil +} + +func requireCandidate(parent context.Context, candidate string, timeout time.Duration, operation, expectedVersion string, args ...string) error { + ctx, cancel := context.WithTimeout(parent, timeout) + defer cancel() + var output cappedOutput + command := exec.CommandContext(ctx, candidate, args...) + command.Stdout, command.Stderr = &output, &output + err := command.Run() + if ctx.Err() != nil { + return errors.New("candidate timed out") + } + if err != nil { + return fmt.Errorf("candidate failed: %w: %s", err, strings.TrimSpace(output.String())) + } + if output.Overflow { + return errors.New("candidate output exceeded the size limit") + } + var result map[string]any + if json.Unmarshal(output.Bytes(), &result) != nil { + return errors.New("candidate returned an invalid result") + } + if expectedVersion != "" && result["version"] != expectedVersion { + return errors.New("candidate version mismatch") + } + if operation != "version" && result["ready"] != true { + return errors.New("candidate returned an unhealthy result") + } + if operation == "install" && result["installed"] != true { + return errors.New("candidate did not confirm installation") + } + return nil +} + +type cappedOutput struct { + bytes.Buffer + Overflow bool +} + +func (output *cappedOutput) Write(data []byte) (int, error) { + const limit = 1 << 20 + written := len(data) + remaining := limit - output.Len() + if remaining < len(data) { + output.Overflow = true + data = data[:max(0, remaining)] + } + _, _ = output.Buffer.Write(data) + return written, nil +} diff --git a/cmd/threadbear/update_test.go b/cmd/threadbear/update_test.go new file mode 100644 index 0000000..4f94247 --- /dev/null +++ b/cmd/threadbear/update_test.go @@ -0,0 +1,296 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/ericlitman/threadbear/assets" +) + +type updateFixtureOptions struct { + ReleaseVersion, CandidateVersion string + ManifestBody []byte + AssetKey, AssetURL string + AssetData, ChecksumBody []byte + SelfTestMode string + InstallFailure bool + AssetDelay time.Duration +} + +type updateFixture struct { + server *httptest.Server + base string + requests map[string]int + mu sync.Mutex +} + +func TestUpdateNewerOlderAndHealthyNoop(t *testing.T) { + prepareUpdate(t, "1.2.3", true) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "1.2.4"}) + result, err := update(context.Background()) + if err != nil || result.(map[string]any)["updated"] != true || result.(map[string]any)["version"] != "1.2.4" { + t.Fatalf("newer update = %#v, %v", result, err) + } + version = "1.2.4" + before := fixture.count("asset") + result, err = update(context.Background()) + if err != nil || result.(map[string]any)["current"] != true || fixture.count("asset") != before { + t.Fatalf("same-version no-op = %#v, %v, asset requests %d", result, err, fixture.count("asset")) + } + version = "1.2.5" + result, err = update(context.Background()) + if err != nil || result.(map[string]any)["current"] != true || fixture.count("asset") != before { + t.Fatalf("older-release no-op = %#v, %v, asset requests %d", result, err, fixture.count("asset")) + } +} + +func TestUpdateSameVersionRepairsManagedSurfaces(t *testing.T) { + p := prepareUpdate(t, "2.0.0", false) + startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.0"}) + result, err := update(context.Background()) + if err != nil || result.(map[string]any)["repaired"] != true { + t.Fatalf("repair = %#v, %v", result, err) + } + data, _ := os.ReadFile(p.skill) + if string(data) != assets.SkillManagedContent { + t.Fatal("same-version repair did not restore the managed skill") + } +} + +func TestUpdateRefusesPendingArchiveBeforeNetwork(t *testing.T) { + prepareUpdate(t, "2.0.0", true) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.0.1"}) + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.ArchivePending = &archiveOperation{TaskID: "target", Action: "archive"} + return true, nil + }); err != nil { + t.Fatal(err) + } + _, err := update(context.Background()) + requireUpdateStage(t, err, "archive_pending") + if fixture.count("manifest") != 0 { + t.Fatal("pending archive allowed update network access") + } +} + +func TestUpdateRefusesConcurrentMaintenanceBeforeNetwork(t *testing.T) { + prepareUpdate(t, "2.1.2", true) + fixture := startUpdateFixture(t, updateFixtureOptions{ReleaseVersion: "2.1.3"}) + lock, err := newStore(stateDir()).operationLock() + if err != nil { + t.Fatal(err) + } + defer unlock(lock) + _, err = update(context.Background()) + requireUpdateStage(t, err, "busy") + if fixture.count("manifest") != 0 { + t.Fatal("update fetched the manifest while maintenance held the operation lock") + } +} + +func TestUpdateRejectsManifestAndCandidateFailures(t *testing.T) { + tests := []struct { + name, stage string + options updateFixtureOptions + configure func() + }{ + {name: "malformed manifest", stage: "manifest", options: updateFixtureOptions{ReleaseVersion: "2.0.1", ManifestBody: []byte(`{"version":`)}}, + {name: "wrong architecture", stage: "manifest_asset", options: updateFixtureOptions{ReleaseVersion: "2.0.1", AssetKey: "darwin_amd64"}}, + {name: "off origin asset", stage: "asset_url", options: updateFixtureOptions{ReleaseVersion: "2.0.1", AssetURL: "https://example.com/threadbear"}}, + {name: "missing checksum", stage: "checksum", options: updateFixtureOptions{ReleaseVersion: "2.0.1", ChecksumBody: []byte("missing\n")}}, + {name: "checksum mismatch", stage: "checksum", options: updateFixtureOptions{ReleaseVersion: "2.0.1", ChecksumBody: []byte(strings.Repeat("0", 64) + "\n")}}, + {name: "embedded version mismatch", stage: "candidate_version", options: updateFixtureOptions{ReleaseVersion: "2.0.1", CandidateVersion: "2.0.2"}}, + {name: "candidate self test failure", stage: "candidate_self_test", options: updateFixtureOptions{ReleaseVersion: "2.0.1", SelfTestMode: "fail"}}, + {name: "candidate timeout", stage: "candidate_self_test", options: updateFixtureOptions{ReleaseVersion: "2.0.1", SelfTestMode: "sleep"}, configure: func() { updateCandidateTimeout = 200 * time.Millisecond }}, + {name: "candidate install failure", stage: "candidate_install", options: updateFixtureOptions{ReleaseVersion: "2.0.1", InstallFailure: true}}, + {name: "oversized download", stage: "candidate_download", options: updateFixtureOptions{ReleaseVersion: "2.0.1", AssetData: bytes.Repeat([]byte("x"), 1024)}, configure: func() { updateBinaryLimit = 32 }}, + {name: "interrupted download", stage: "candidate_download", options: updateFixtureOptions{ReleaseVersion: "2.0.1", AssetDelay: 100 * time.Millisecond}, configure: func() { updateClient.Timeout = 20 * time.Millisecond }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + p := prepareUpdate(t, "2.0.0", true) + before, _ := os.ReadFile(p.binary) + startUpdateFixture(t, test.options) + if test.configure != nil { + test.configure() + } + _, err := update(context.Background()) + requireUpdateStage(t, err, test.stage) + after, _ := os.ReadFile(p.binary) + if !bytes.Equal(before, after) { + t.Fatal("pre-install failure changed the installed binary") + } + }) + } +} + +func TestUpdateCommandReturnsTypedFailureStage(t *testing.T) { + prepareUpdate(t, "2.0.0", true) + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.ArchivePending = &archiveOperation{TaskID: "target", Action: "archive"} + return true, nil + }); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + if code := run(context.Background(), []string{"update", "--json"}, strings.NewReader(""), &output, &bytes.Buffer{}); code != 1 { + t.Fatalf("update exit = %d", code) + } + var result map[string]any + if json.Unmarshal(output.Bytes(), &result) != nil || result["stage"] != "archive_pending" || result["ready"] != false { + t.Fatalf("typed failure = %s", output.String()) + } +} + +func prepareUpdate(t *testing.T, current string, healthy bool) lifecyclePaths { + t.Helper() + p := isolatedLifecycle(t) + oldVersion := version + version = current + t.Cleanup(func() { version = oldVersion }) + if _, err := install("main", false, true, false); err != nil { + t.Fatal(err) + } + if err := newStore(stateDir()).update(func(value *state) (bool, error) { + value.Phase = phaseMigrationComplete + return true, nil + }); err != nil { + t.Fatal(err) + } + source := filepath.Join(t.TempDir(), "managed-skill") + mustWrite(t, source, assets.SkillManagedContent) + t.Setenv("TB_UPDATE_TEST_SKILL_SOURCE", source) + t.Setenv("TB_UPDATE_TEST_SKILL_TARGET", p.skill) + if !healthy { + mustWrite(t, p.skill, "modified\n") + } + return p +} + +func startUpdateFixture(t *testing.T, options updateFixtureOptions) *updateFixture { + t.Helper() + if options.ReleaseVersion == "" { + options.ReleaseVersion = "2.0.1" + } + if options.CandidateVersion == "" { + options.CandidateVersion = options.ReleaseVersion + } + if options.AssetKey == "" { + options.AssetKey = "darwin_arm64" + } + if options.AssetData == nil { + options.AssetData = candidateScript(options.CandidateVersion, options.SelfTestMode, options.InstallFailure) + } + if options.ChecksumBody == nil { + digest := sha256.Sum256(options.AssetData) + options.ChecksumBody = []byte(hex.EncodeToString(digest[:]) + "\n") + } + fixture := &updateFixture{requests: map[string]int{}} + fixture.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + kind := "other" + switch { + case strings.HasSuffix(request.URL.Path, "latest.json"): + kind = "manifest" + case strings.HasSuffix(request.URL.Path, ".sha256"): + kind = "checksum" + default: + kind = "asset" + } + fixture.mu.Lock() + fixture.requests[kind]++ + fixture.mu.Unlock() + switch kind { + case "manifest": + if options.ManifestBody != nil { + _, _ = writer.Write(options.ManifestBody) + return + } + assetURL := options.AssetURL + if assetURL == "" { + assetURL = fixture.base + "/download/v" + options.ReleaseVersion + "/threadbear_darwin_arm64" + } + manifest := releaseManifest{Version: options.ReleaseVersion, Assets: map[string]releaseAsset{ + options.AssetKey: {URL: assetURL, SHA256URL: fixture.base + "/download/v" + options.ReleaseVersion + "/threadbear_darwin_arm64.sha256"}, + }} + _ = json.NewEncoder(writer).Encode(manifest) + case "checksum": + _, _ = writer.Write(options.ChecksumBody) + case "asset": + if options.AssetDelay > 0 { + time.Sleep(options.AssetDelay) + } + _, _ = writer.Write(options.AssetData) + } + })) + fixture.base = fixture.server.URL + "/ericlitman/threadbear/releases" + t.Cleanup(fixture.server.Close) + oldBase, oldManifest, oldClient := updateReleaseBase, updateManifestURL, updateClient + oldGOOS, oldGOARCH := updateGOOS, updateGOARCH + oldLimit, oldVersionTimeout, oldCandidateTimeout, oldInstallTimeout := updateBinaryLimit, updateVersionTimeout, updateCandidateTimeout, updateInstallTimeout + updateReleaseBase, updateManifestURL, updateClient = fixture.base, fixture.base+"/latest/download/latest.json", fixture.server.Client() + updateGOOS, updateGOARCH = "darwin", "arm64" + updateBinaryLimit, updateVersionTimeout, updateCandidateTimeout, updateInstallTimeout = 64<<20, 2*time.Second, 2*time.Second, 2*time.Second + t.Cleanup(func() { + updateReleaseBase, updateManifestURL, updateClient = oldBase, oldManifest, oldClient + updateGOOS, updateGOARCH = oldGOOS, oldGOARCH + updateBinaryLimit, updateVersionTimeout, updateCandidateTimeout, updateInstallTimeout = oldLimit, oldVersionTimeout, oldCandidateTimeout, oldInstallTimeout + }) + return fixture +} + +func (fixture *updateFixture) count(kind string) int { + fixture.mu.Lock() + defer fixture.mu.Unlock() + return fixture.requests[kind] +} + +func candidateScript(candidateVersion, selfTestMode string, installFailure bool) []byte { + selfTest := fmt.Sprintf(`printf '{"ready":true,"version":"%s"}\n'`, candidateVersion) + if selfTestMode == "fail" { + selfTest = "echo self-test-failed >&2; exit 9" + } else if selfTestMode == "sleep" { + selfTest = "sleep 1" + } + install := `cp "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET" +printf '{"ready":true,"installed":true}\n'` + if installFailure { + install = "echo install-failed >&2; exit 8" + } + versionResult := fmt.Sprintf(`printf '{"version":"%s"}\n'`, candidateVersion) + unhealthy := fmt.Sprintf(`printf '{"ready":false,"version":"%s"}\n'`, candidateVersion) + healthy := fmt.Sprintf(`printf '{"ready":true,"version":"%s"}\n'`, candidateVersion) + return []byte(`#!/bin/sh +case "$1" in +version) ` + versionResult + ` ;; +self-test) ` + selfTest + ` ;; +install) ` + install + ` ;; +status) + if ! cmp -s "$TB_UPDATE_TEST_SKILL_SOURCE" "$TB_UPDATE_TEST_SKILL_TARGET"; then + ` + unhealthy + ` + else + ` + healthy + ` + fi ;; +*) exit 7 ;; +esac +`) +} + +func requireUpdateStage(t *testing.T, err error, stage string) { + t.Helper() + value, ok := err.(*updateError) + if !ok || value.Stage != stage { + t.Fatalf("update error = %#v, want stage %q", err, stage) + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 0ccc99c..fde5963 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -23,6 +23,12 @@ The owned `threadbear-maintenance` heartbeat asks the installed skill to run a d Archive and restore use one prepare-native-reconcile transaction. The CLI revalidates identity, kind, title, footer, status, activity, and current archive state, then persists one pending operation. Luna calls Codex's supported native archive control once without opening the target. A second deterministic pass observes native archive state and commits or removes ThreadBear ownership. Interruption leaves the pending operation durable; no new mutation or update may begin until it reconciles. Restore is allowed only for the owned archive ledger, so ThreadBear never adopts a user-created archive. +## Verified updates + +The same heartbeat runs `threadbear update` only after a closing archive pass proves no native operation is pending. The command refuses before network access unless installation migration is complete and archive state is settled. It fetches one bounded official latest manifest, parses an exact version, selects only the running Darwin architecture, and accepts only exact HTTPS asset and checksum URLs under that version's repository release path. Healthy equal or older releases are quiet no-ops; an unhealthy equal version may use the same verified path to repair managed surfaces, while downgrades are never attempted. + +For a newer or repairing release, the command downloads bounded checksum and binary bodies to a private temporary directory, verifies SHA-256 and embedded version, runs the candidate self-test under a deadline, and invokes that candidate's existing confirmed noninteractive install. The candidate then verifies installed status using its own embedded managed assets. A pre-install failure leaves the current installation untouched; a later failure is returned with a typed stage so the persistent task can report it without guessing or blindly retrying. A successful version change is announced once by the maintenance turn; healthy no-op checks remain silent. + The visible title is limited to 60 UTF-16 units. Rendering first computes the bounded standalone status-and-subject display, then truncates or omits only the appended action without changing canonical state. The persisted main task may request one reserved cleanup marker for an explicit target. The same Pre/Post transaction re-reads the target, removes every consecutive leading ThreadBear status mark, stages the subject-only title, validates the exact native result, and repairs ownership state. The marker is denied for every other caller. Guided uninstall uses this serial operation for all active titles before deleting the hooks or ownership state; the same operation is available on demand from the persistent task. diff --git a/site/index.html b/site/index.html index 83e171f..aeaf6cc 100644 --- a/site/index.html +++ b/site/index.html @@ -51,6 +51,7 @@
⏳ 🚨 🙋 🤖 ➡️ ✅ ❔
@@ -59,7 +60,7 @@ThreadBear installs at ~/.local/bin/threadbear, keeps one small private state file under ~/.local/share/threadbear, and adds one managed instruction block, one skill, and two Codex hook entries.
Release binaries are not Developer ID signed or notarized. The installer verifies the published SHA-256 checksum and candidate self-test before installation.
ThreadBear changes titles only through Codex Desktop's native setter and archives only through Codex's native task control. One background, rerunnable controller handles existing titles while the main ThreadBear task stays selected; adaptive waves of read-only Luna-medium workers may classify genuinely ambiguous legacy history, and workers never write titles. The hourly helper follows a deterministic archive plan, never archives active work, and can restore only tasks ThreadBear archived. ThreadBear installs no LaunchAgent, does not add token counts or update itself yet, and never edits Desktop caches.
+ThreadBear changes titles only through Codex Desktop's native setter and archives only through Codex's native task control. One background, rerunnable controller handles existing titles while the main ThreadBear task stays selected; adaptive waves of read-only Luna-medium workers may classify genuinely ambiguous legacy history, and workers never write titles. The hourly helper follows a deterministic archive plan, never archives active work, and can restore only tasks ThreadBear archived. After archive work is reconciled, it checks the exact official manifest and installs a verified official release only after URL, architecture, SHA-256, embedded-version, and candidate self-test checks pass. ThreadBear installs no LaunchAgent, adds no token counts, and never edits Desktop caches.